#!/usr/bin/env python3 """OBS Studio CLI -- A stateful command-line interface for OBS scene collection editing. This CLI provides full OBS Studio scene management capabilities using a JSON scene collection format. No OBS installation required for editing. Usage: # One-shot commands python3 -m cli.obs_cli project new --name "my_stream" python3 -m cli.obs_cli source add video_capture --name "Camera" python3 -m cli.obs_cli scene add --name "BRB" # Interactive REPL python3 -m cli.obs_cli repl """ import sys import os import json import shlex import click from typing import Optional # Add parent to path for imports sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from cli_anything.obs_studio.core.session import Session from cli_anything.obs_studio.core import project as proj_mod from cli_anything.obs_studio.core import scenes as scene_mod from cli_anything.obs_studio.core import sources as src_mod from cli_anything.obs_studio.core import filters as filt_mod from cli_anything.obs_studio.core import audio as audio_mod from cli_anything.obs_studio.core import transitions as trans_mod from cli_anything.obs_studio.core import output as out_mod # Global session state _session: Optional[Session] = None _json_output = False _repl_mode = False def get_session() -> Session: global _session if _session is None: _session = Session() return _session def output(data, message: str = ""): if _json_output: click.echo(json.dumps(data, indent=2, default=str)) else: if message: click.echo(message) if isinstance(data, dict): _print_dict(data) elif isinstance(data, list): _print_list(data) else: click.echo(str(data)) def _print_dict(d: dict, indent: int = 0): prefix = " " * indent for k, v in d.items(): if isinstance(v, dict): click.echo(f"{prefix}{k}:") _print_dict(v, indent + 1) elif isinstance(v, list): click.echo(f"{prefix}{k}:") _print_list(v, indent + 1) else: click.echo(f"{prefix}{k}: {v}") def _print_list(items: list, indent: int = 0): prefix = " " * indent for i, item in enumerate(items): if isinstance(item, dict): click.echo(f"{prefix}[{i}]") _print_dict(item, indent + 1) else: click.echo(f"{prefix}- {item}") def handle_error(func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except FileNotFoundError as e: if _json_output: click.echo(json.dumps({"error": str(e), "type": "file_not_found"})) else: click.echo(f"Error: {e}", err=True) if not _repl_mode: sys.exit(1) except (ValueError, IndexError, RuntimeError) as e: if _json_output: click.echo(json.dumps({"error": str(e), "type": type(e).__name__})) else: click.echo(f"Error: {e}", err=True) if not _repl_mode: sys.exit(1) except FileExistsError as e: if _json_output: click.echo(json.dumps({"error": str(e), "type": "file_exists"})) else: click.echo(f"Error: {e}", err=True) if not _repl_mode: sys.exit(1) wrapper.__name__ = func.__name__ wrapper.__doc__ = func.__doc__ return wrapper # -- Main CLI Group ---------------------------------------------------------- @click.group(invoke_without_command=True) @click.option("--json", "use_json", is_flag=True, help="Output as JSON") @click.option("--project", "project_path", type=str, default=None, help="Path to OBS scene collection JSON file") @click.option("--dry-run", "dry_run", is_flag=True, default=False, help="Run command without saving changes to disk") @click.pass_context def cli(ctx, use_json, project_path, dry_run): """OBS Studio CLI -- Stateful scene collection editing from the command line. Run without a subcommand to enter interactive REPL mode. """ global _json_output _json_output = use_json if project_path: sess = get_session() if not sess.has_project(): proj = proj_mod.open_project(project_path) sess.set_project(proj, project_path) if ctx.invoked_subcommand is None: ctx.invoke(repl, project_path=None) @cli.result_callback() def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs): """Auto-save project after one-shot commands if state was modified.""" if _repl_mode: return if dry_run: return sess = get_session() if sess.has_project() and sess._modified and sess.project_path: try: sess.save_session() except Exception as e: click.echo(f"Warning: Auto-save failed: {e}", err=True) # -- Project Commands -------------------------------------------------------- @cli.group() def project(): """Project management commands.""" pass @project.command("new") @click.option("--name", "-n", default="untitled", help="Project name") @click.option("--width", "-w", type=int, default=1920, help="Output width") @click.option("--height", "-h", type=int, default=1080, help="Output height") @click.option("--fps", type=int, default=30, help="Frames per second") @click.option("--encoder", type=str, default="x264", help="Video encoder") @click.option("--output", "-o", type=str, default=None, help="Save path") @handle_error def project_new(name, width, height, fps, encoder, output): """Create a new OBS scene collection.""" proj = proj_mod.create_project( name=name, output_width=width, output_height=height, fps=fps, encoder=encoder, ) sess = get_session() sess.set_project(proj, output) if output: proj_mod.save_project(proj, output) info = proj_mod.get_project_info(proj) globals()["output"](info, f"Created project: {name}") @project.command("open") @click.argument("path") @handle_error def project_open(path): """Open an existing project.""" proj = proj_mod.open_project(path) sess = get_session() sess.set_project(proj, path) info = proj_mod.get_project_info(proj) globals()["output"](info, f"Opened: {path}") @project.command("save") @click.argument("path", required=False) @handle_error def project_save(path): """Save the current project.""" sess = get_session() saved = sess.save_session(path) globals()["output"]({"saved": saved}, f"Saved to: {saved}") @project.command("info") @handle_error def project_info(): """Show project information.""" sess = get_session() info = proj_mod.get_project_info(sess.get_project()) globals()["output"](info) @project.command("json") @handle_error def project_json(): """Print raw project JSON.""" sess = get_session() click.echo(json.dumps(sess.get_project(), indent=2, default=str)) # -- Scene Commands ---------------------------------------------------------- @cli.group("scene") def scene_group(): """Scene management commands.""" pass @scene_group.command("add") @click.option("--name", "-n", default="Scene", help="Scene name") @handle_error def scene_add(name): """Add a new scene.""" sess = get_session() sess.snapshot(f"Add scene: {name}") scene = scene_mod.add_scene(sess.get_project(), name=name) output(scene, f"Added scene: {scene['name']}") @scene_group.command("remove") @click.argument("index", type=int) @handle_error def scene_remove(index): """Remove a scene by index.""" sess = get_session() sess.snapshot(f"Remove scene {index}") removed = scene_mod.remove_scene(sess.get_project(), index) output(removed, f"Removed scene: {removed['name']}") @scene_group.command("duplicate") @click.argument("index", type=int) @handle_error def scene_duplicate(index): """Duplicate a scene.""" sess = get_session() sess.snapshot(f"Duplicate scene {index}") dup = scene_mod.duplicate_scene(sess.get_project(), index) output(dup, f"Duplicated scene: {dup['name']}") @scene_group.command("set-active") @click.argument("index", type=int) @handle_error def scene_set_active(index): """Set the active scene.""" sess = get_session() sess.snapshot(f"Set active scene {index}") result = scene_mod.set_active_scene(sess.get_project(), index) output(result, f"Active scene: {result['active_scene']}") @scene_group.command("list") @handle_error def scene_list(): """List all scenes.""" sess = get_session() scenes = scene_mod.list_scenes(sess.get_project()) output(scenes, "Scenes:") # -- Source Commands --------------------------------------------------------- @cli.group("source") def source_group(): """Source management commands.""" pass @source_group.command("add") @click.argument("source_type", type=click.Choice(sorted(src_mod.SOURCE_TYPES.keys()))) @click.option("--name", "-n", default=None, help="Source name") @click.option("--scene", "-s", "scene_index", type=int, default=0, help="Scene index") @click.option("--position", "-p", default=None, help="Position x,y") @click.option("--size", default=None, help="Size widthxheight") @click.option("--setting", "-S", multiple=True, help="Setting: key=value") @handle_error def source_add(source_type, name, scene_index, position, size, setting): """Add a source to a scene.""" pos = None if position: parts = position.split(",") pos = {"x": float(parts[0]), "y": float(parts[1])} sz = None if size: parts = size.split("x") sz = {"width": int(parts[0]), "height": int(parts[1])} settings = {} for s in setting: if "=" not in s: raise ValueError(f"Invalid setting format: '{s}'. Use key=value.") k, v = s.split("=", 1) try: v = float(v) if "." in v else int(v) except ValueError: pass settings[k] = v sess = get_session() sess.snapshot(f"Add source: {source_type}") src = src_mod.add_source( sess.get_project(), source_type, scene_index=scene_index, name=name, position=pos, size=sz, settings=settings if settings else None, ) output(src, f"Added {source_type}: {src['name']}") @source_group.command("remove") @click.argument("index", type=int) @click.option("--scene", "-s", "scene_index", type=int, default=0) @handle_error def source_remove(index, scene_index): """Remove a source by index.""" sess = get_session() sess.snapshot(f"Remove source {index}") removed = src_mod.remove_source(sess.get_project(), index, scene_index) output(removed, f"Removed source: {removed['name']}") @source_group.command("duplicate") @click.argument("index", type=int) @click.option("--scene", "-s", "scene_index", type=int, default=0) @handle_error def source_duplicate(index, scene_index): """Duplicate a source.""" sess = get_session() sess.snapshot(f"Duplicate source {index}") dup = src_mod.duplicate_source(sess.get_project(), index, scene_index) output(dup, f"Duplicated source: {dup['name']}") @source_group.command("set") @click.argument("index", type=int) @click.argument("prop") @click.argument("value") @click.option("--scene", "-s", "scene_index", type=int, default=0) @handle_error def source_set(index, prop, value, scene_index): """Set a source property (name, visible, locked, opacity, rotation).""" sess = get_session() sess.snapshot(f"Set source {index} {prop}={value}") src = src_mod.set_source_property(sess.get_project(), index, prop, value, scene_index) output({"source": index, "property": prop, "value": value}, f"Set source {index} {prop} = {value}") @source_group.command("transform") @click.argument("index", type=int) @click.option("--position", "-p", default=None, help="Position x,y") @click.option("--size", default=None, help="Size widthxheight") @click.option("--crop", default=None, help="Crop top,bottom,left,right") @click.option("--rotation", "-r", type=float, default=None) @click.option("--scene", "-s", "scene_index", type=int, default=0) @handle_error def source_transform(index, position, size, crop, rotation, scene_index): """Transform a source (position, size, crop, rotation).""" pos = None if position: parts = position.split(",") pos = {"x": float(parts[0]), "y": float(parts[1])} sz = None if size: parts = size.split("x") sz = {"width": int(parts[0]), "height": int(parts[1])} cr = None if crop: parts = crop.split(",") cr = {"top": int(parts[0]), "bottom": int(parts[1]), "left": int(parts[2]), "right": int(parts[3])} sess = get_session() sess.snapshot(f"Transform source {index}") src = src_mod.transform_source( sess.get_project(), index, scene_index, position=pos, size=sz, crop=cr, rotation=rotation, ) output(src, f"Transformed source {index}: {src['name']}") @source_group.command("list") @click.option("--scene", "-s", "scene_index", type=int, default=0) @handle_error def source_list(scene_index): """List all sources in a scene.""" sess = get_session() sources = src_mod.list_sources(sess.get_project(), scene_index) output(sources, "Sources:") # -- Filter Commands --------------------------------------------------------- @cli.group("filter") def filter_group(): """Filter management commands.""" pass @filter_group.command("add") @click.argument("filter_type", type=click.Choice(sorted(filt_mod.FILTER_TYPES.keys()))) @click.option("--source", "-S", "source_index", type=int, default=0, help="Source index") @click.option("--scene", "-s", "scene_index", type=int, default=0, help="Scene index") @click.option("--name", "-n", default=None, help="Filter name") @click.option("--param", "-p", multiple=True, help="Parameter: key=value") @handle_error def filter_add(filter_type, source_index, scene_index, name, param): """Add a filter to a source.""" params = {} for p in param: if "=" not in p: raise ValueError(f"Invalid param format: '{p}'. Use key=value.") k, v = p.split("=", 1) try: v = float(v) if "." in v else int(v) except ValueError: pass params[k] = v sess = get_session() sess.snapshot(f"Add filter: {filter_type}") filt = filt_mod.add_filter( sess.get_project(), filter_type, source_index, scene_index, name=name, params=params if params else None, ) output(filt, f"Added filter: {filt['name']}") @filter_group.command("remove") @click.argument("filter_index", type=int) @click.option("--source", "-S", "source_index", type=int, default=0) @click.option("--scene", "-s", "scene_index", type=int, default=0) @handle_error def filter_remove(filter_index, source_index, scene_index): """Remove a filter from a source.""" sess = get_session() sess.snapshot(f"Remove filter {filter_index}") removed = filt_mod.remove_filter(sess.get_project(), filter_index, source_index, scene_index) output(removed, f"Removed filter: {removed['name']}") @filter_group.command("set") @click.argument("filter_index", type=int) @click.argument("param") @click.argument("value") @click.option("--source", "-S", "source_index", type=int, default=0) @click.option("--scene", "-s", "scene_index", type=int, default=0) @handle_error def filter_set(filter_index, param, value, source_index, scene_index): """Set a filter parameter.""" try: value = float(value) if "." in str(value) else int(value) except ValueError: pass sess = get_session() sess.snapshot(f"Set filter {filter_index} {param}={value}") filt_mod.set_filter_param( sess.get_project(), filter_index, param, value, source_index, scene_index, ) output({"filter": filter_index, "param": param, "value": value}, f"Set filter {filter_index} {param} = {value}") @filter_group.command("list") @click.option("--source", "-S", "source_index", type=int, default=0) @click.option("--scene", "-s", "scene_index", type=int, default=0) @handle_error def filter_list(source_index, scene_index): """List all filters on a source.""" sess = get_session() filters = filt_mod.list_filters(sess.get_project(), source_index, scene_index) output(filters, "Filters:") @filter_group.command("list-available") @click.option("--category", "-c", type=str, default=None, help="Filter by category: video, audio") @handle_error def filter_list_available(category): """List all available filter types.""" filters = filt_mod.list_available_filters(category) output(filters, "Available filters:") # -- Audio Commands ---------------------------------------------------------- @cli.group("audio") def audio_group(): """Audio management commands.""" pass @audio_group.command("add") @click.option("--name", "-n", default="Audio", help="Audio source name") @click.option("--type", "audio_type", type=click.Choice(["input", "output"]), default="input") @click.option("--device", "-d", default="", help="Device identifier") @click.option("--volume", "-v", type=float, default=1.0, help="Volume (0.0-3.0)") @handle_error def audio_add(name, audio_type, device, volume): """Add a global audio source.""" sess = get_session() sess.snapshot(f"Add audio: {name}") src = audio_mod.add_audio_source( sess.get_project(), name=name, audio_type=audio_type, device=device, volume=volume, ) output(src, f"Added audio source: {src['name']}") @audio_group.command("remove") @click.argument("index", type=int) @handle_error def audio_remove(index): """Remove a global audio source.""" sess = get_session() sess.snapshot(f"Remove audio {index}") removed = audio_mod.remove_audio_source(sess.get_project(), index) output(removed, f"Removed audio: {removed['name']}") @audio_group.command("volume") @click.argument("index", type=int) @click.argument("level", type=float) @handle_error def audio_volume(index, level): """Set volume for an audio source (0.0-3.0).""" sess = get_session() sess.snapshot(f"Set audio {index} volume={level}") src = audio_mod.set_volume(sess.get_project(), index, level) output({"audio": index, "volume": level}, f"Volume set to {level}") @audio_group.command("mute") @click.argument("index", type=int) @handle_error def audio_mute(index): """Mute an audio source.""" sess = get_session() sess.snapshot(f"Mute audio {index}") src = audio_mod.mute(sess.get_project(), index) output({"audio": index, "muted": True}, f"Muted audio {index}") @audio_group.command("unmute") @click.argument("index", type=int) @handle_error def audio_unmute(index): """Unmute an audio source.""" sess = get_session() sess.snapshot(f"Unmute audio {index}") src = audio_mod.unmute(sess.get_project(), index) output({"audio": index, "muted": False}, f"Unmuted audio {index}") @audio_group.command("monitor") @click.argument("index", type=int) @click.argument("monitor_type", type=click.Choice(["none", "monitor_only", "monitor_and_output"])) @handle_error def audio_monitor(index, monitor_type): """Set audio monitoring type.""" sess = get_session() sess.snapshot(f"Set audio {index} monitor={monitor_type}") src = audio_mod.set_monitor(sess.get_project(), index, monitor_type) output({"audio": index, "monitor": monitor_type}, f"Monitor set to {monitor_type}") @audio_group.command("list") @handle_error def audio_list(): """List all audio sources.""" sess = get_session() sources = audio_mod.list_audio(sess.get_project()) output(sources, "Audio sources:") # -- Transition Commands ----------------------------------------------------- @cli.group("transition") def transition_group(): """Transition management commands.""" pass @transition_group.command("add") @click.argument("transition_type", type=click.Choice(sorted(trans_mod.TRANSITION_TYPES.keys()))) @click.option("--name", "-n", default=None, help="Transition name") @click.option("--duration", "-d", type=int, default=None, help="Duration in ms") @handle_error def transition_add(transition_type, name, duration): """Add a transition.""" sess = get_session() sess.snapshot(f"Add transition: {transition_type}") trans = trans_mod.add_transition( sess.get_project(), transition_type, name=name, duration=duration, ) output(trans, f"Added transition: {trans['name']}") @transition_group.command("remove") @click.argument("index", type=int) @handle_error def transition_remove(index): """Remove a transition.""" sess = get_session() sess.snapshot(f"Remove transition {index}") removed = trans_mod.remove_transition(sess.get_project(), index) output(removed, f"Removed transition: {removed['name']}") @transition_group.command("set-active") @click.argument("index", type=int) @handle_error def transition_set_active(index): """Set the active transition.""" sess = get_session() sess.snapshot(f"Set active transition {index}") result = trans_mod.set_active_transition(sess.get_project(), index) output(result, f"Active transition: {result['active_transition']}") @transition_group.command("duration") @click.argument("index", type=int) @click.argument("duration", type=int) @handle_error def transition_duration(index, duration): """Set transition duration in milliseconds.""" sess = get_session() sess.snapshot(f"Set transition {index} duration={duration}") trans = trans_mod.set_duration(sess.get_project(), index, duration) output(trans, f"Duration set to {duration}ms") @transition_group.command("list") @handle_error def transition_list(): """List all transitions.""" sess = get_session() transitions = trans_mod.list_transitions(sess.get_project()) output(transitions, "Transitions:") # -- Output Commands --------------------------------------------------------- @cli.group("output") def output_group(): """Output/streaming/recording configuration.""" pass @output_group.command("streaming") @click.option("--service", type=click.Choice(["twitch", "youtube", "facebook", "custom"]), default=None) @click.option("--server", type=str, default=None) @click.option("--key", type=str, default=None) @handle_error def output_streaming(service, server, key): """Configure streaming settings.""" sess = get_session() sess.snapshot("Update streaming settings") result = out_mod.set_streaming(sess.get_project(), service=service, server=server, key=key) globals()["output"](result, "Streaming settings updated") @output_group.command("recording") @click.option("--path", type=str, default=None) @click.option("--format", "fmt", type=click.Choice(["mkv", "mp4", "mov", "flv", "ts"]), default=None) @click.option("--quality", type=click.Choice(["low", "medium", "high", "lossless"]), default=None) @handle_error def output_recording(path, fmt, quality): """Configure recording settings.""" sess = get_session() sess.snapshot("Update recording settings") result = out_mod.set_recording(sess.get_project(), path=path, fmt=fmt, quality=quality) globals()["output"](result, "Recording settings updated") @output_group.command("settings") @click.option("--width", type=int, default=None) @click.option("--height", type=int, default=None) @click.option("--fps", type=int, default=None) @click.option("--video-bitrate", type=int, default=None) @click.option("--audio-bitrate", type=int, default=None) @click.option("--encoder", type=str, default=None) @click.option("--preset", type=str, default=None, help="Apply encoding preset") @handle_error def output_settings(width, height, fps, video_bitrate, audio_bitrate, encoder, preset): """Configure output settings.""" sess = get_session() sess.snapshot("Update output settings") result = out_mod.set_output_settings( sess.get_project(), output_width=width, output_height=height, fps=fps, video_bitrate=video_bitrate, audio_bitrate=audio_bitrate, encoder=encoder, preset=preset, ) globals()["output"](result, "Output settings updated") @output_group.command("info") @handle_error def output_info(): """Show current output configuration.""" sess = get_session() info = out_mod.get_output_info(sess.get_project()) globals()["output"](info) @output_group.command("presets") @handle_error def output_presets(): """List available encoding presets.""" presets = out_mod.list_encoding_presets() globals()["output"](presets, "Encoding presets:") # -- Session Commands -------------------------------------------------------- @cli.group() def session(): """Session management commands.""" pass @session.command("status") @handle_error def session_status(): """Show session status.""" sess = get_session() output(sess.status()) @session.command("undo") @handle_error def session_undo(): """Undo the last operation.""" sess = get_session() desc = sess.undo() output({"undone": desc}, f"Undone: {desc}") @session.command("redo") @handle_error def session_redo(): """Redo the last undone operation.""" sess = get_session() desc = sess.redo() output({"redone": desc}, f"Redone: {desc}") @session.command("history") @handle_error def session_history(): """Show undo history.""" sess = get_session() history = sess.list_history() output(history, "Undo history:") # -- REPL -------------------------------------------------------------------- @cli.command() @click.option("--project", "project_path", type=str, default=None) @handle_error def repl(project_path): """Start interactive REPL session.""" from cli_anything.obs_studio.utils.repl_skin import ReplSkin global _repl_mode _repl_mode = True skin = ReplSkin("obs_studio", version="1.0.0") if project_path: sess = get_session() proj = proj_mod.open_project(project_path) sess.set_project(proj, project_path) skin.print_banner() pt_session = skin.create_prompt_session() def _get_project_name(): """Get current project name for prompt display.""" try: sess = get_session() if sess.has_project(): info = proj_mod.get_project_info(sess.get_project()) return info.get("name", "") except Exception: pass return "" while True: try: line = skin.get_input( pt_session, project_name=_get_project_name(), modified=False, ).strip() if not line: continue if line.lower() in ("quit", "exit", "q"): skin.print_goodbye() break if line.lower() == "help": _repl_help(skin) continue try: args = shlex.split(line) except ValueError: args = line.split() try: cli.main(args, standalone_mode=False) except SystemExit: pass except click.exceptions.UsageError as e: skin.error(f"Usage error: {e}") except Exception as e: skin.error(str(e)) except (EOFError, KeyboardInterrupt): skin.print_goodbye() break _repl_mode = False def _repl_help(skin=None): commands = { "project new|open|save|info|json": "Project management", "scene add|remove|duplicate|set-active|list": "Scene management", "source add|remove|duplicate|set|transform|list": "Source management", "filter add|remove|set|list|list-available": "Filter management", "audio add|remove|volume|mute|unmute|monitor|list": "Audio management", "transition add|remove|set-active|duration|list": "Transition management", "output streaming|recording|settings|info|presets": "Output configuration", "session status|undo|redo|history": "Session management", "help": "Show this help", "quit": "Exit REPL", } if skin is not None: skin.help(commands) else: click.echo("\nCommands:") for cmd, desc in commands.items(): click.echo(f" {cmd:55s} {desc}") click.echo() # -- Entry Point ------------------------------------------------------------- def main(): cli() if __name__ == "__main__": main()