diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c288017..aa82145 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -45,7 +45,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -61,7 +61,7 @@ jobs: name: Remotion release bundle runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 @@ -77,7 +77,7 @@ jobs: name: Docs drift check runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -95,10 +95,10 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" cache: "pip" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d26db4c..91cbdd0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -22,13 +22,13 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f81e498..7b26f96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - { runner: ubuntu-latest, goos: linux, goarch: arm64 } - { runner: ubuntu-latest, goos: windows, goarch: amd64 } steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -78,7 +78,7 @@ jobs: WHISPER_REF: v1.7.4 # pin a known-good whisper.cpp tag steps: - name: Clone whisper.cpp - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ggml-org/whisper.cpp ref: ${{ env.WHISPER_REF }} @@ -180,7 +180,7 @@ jobs: studio: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -211,7 +211,7 @@ jobs: - { runner: windows-latest, goos: windows, goarch: amd64 } runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.gitignore b/.gitignore index 7f3afdd..c87914f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ tmp*.txt # Episodes (generated content packages) episodes/ +# Planning notes +plans/ + # Media & temp files *.mp4 *.mp3 diff --git a/backend/cli.py b/backend/cli.py index dc9f67d..27dd014 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -920,15 +920,15 @@ def _transcribe_progress(pct, msg): print(" ⚠ No highlights found, falling back to transcript selection") # Try an AI CLI first (uses PodStack knowledge base for intelligent selection) - from services.claude_suggest import ( - suggest_initial_with_claude, blend_signal_scores, _engine_label, _find_ai_cli, - ) + from services import ai_provider + from services.ai_cli import _engine_label + from services.claude_suggest import blend_signal_scores, suggest_initial_with_claude - ai_path, ai_engine = _find_ai_cli() + providers = ai_provider.status()["providers"] if clips: pass # already selected (resumed cache or saliency profile) - elif ai_path and config.get("ai_select", True): - ai_label = _engine_label(ai_engine) + elif providers and config.get("ai_select", True): + ai_label = providers[0]["label"] print(f" [3/4] Selecting moments with {ai_label} (PodStack)...") clips = suggest_initial_with_claude( segments=segments, @@ -938,8 +938,9 @@ def _transcribe_progress(pct, msg): ) if clips: blend_signal_scores(clips, energy_data=energy_data, events_data=events_data) - actual_engine = next((c.get("_ai_engine") for c in clips if c.get("_ai_engine")), ai_engine) - print(f" ✓ {_engine_label(actual_engine)} selected {len(clips)} clips") + engine_id = next((c.get("_ai_engine") for c in clips if c.get("_ai_engine")), "") + actual_engine = _engine_label(engine_id) if engine_id in ("claude", "codex") else ai_label + print(f" ✓ {actual_engine} selected {len(clips)} clips") _save_suggestions_session(cache_hash, top_n, actual_engine, clips, selection_sig) else: print(" ⚠ AI CLI unavailable, falling back to heuristics") @@ -1006,9 +1007,9 @@ def _transcribe_progress(pct, msg): pass _thumb_intro_duration = max(0.5, min(_thumb_intro_duration, 1.0)) - # Check if AI CLI is available for per-clip content generation - from services.claude_suggest import _find_ai_cli - _ai_cli_path, _ = _find_ai_cli() + # Per-clip content generation needs any provider, not specifically a binary. + from services import ai_provider + _ai_cli_path = "cloud" if ai_provider.available() else None # Pre-load thumbnail tools if enabled _thumb_gen = None @@ -3491,9 +3492,15 @@ def print_banner(): _diarization_ok = False speakers_ok = bool(hf_token) and _diarization_ok - # Check AI CLI (Claude Code or Codex) - from services.claude_suggest import _find_ai_cli - ai_path, ai_engine = _find_ai_cli() + # `info` should report what AI podcli will actually use, which for a + # signed-in user is the workspace rather than any local binary. + from services import ai_provider + # Every other lookup in this banner is guarded. This one reads and parses + # the local auth file, so a truncated one would take `podcli info` with it. + try: + _providers = ai_provider.status()["providers"] + except Exception: + _providers = [] print(f" {bold}podcli{reset} v{VERSION}") @@ -3504,8 +3511,8 @@ def print_banner(): cache_count = len([f for f in os.listdir(cache_dir) if f.endswith(".json")]) # Status — one line - ai_label = ("Claude" if ai_engine == "claude" else "Codex") if ai_path else "AI CLI" - ai_tag = f"{green}✓ {ai_label}{reset}" if ai_path else f"{yellow}✗{reset}" + ai_label = _providers[0]["label"] if _providers else "AI" + ai_tag = f"{green}✓ {ai_label}{reset}" if _providers else f"{yellow}✗{reset}" speaker_tag = f"{green}✓{reset}" if speakers_ok else f"{yellow}✗{reset}" cache_tag = f"{green}{cache_count}{reset}" if cache_count else f"{gray}0{reset}" kb_tag = f"{green}{kb_count}{reset}" if kb_count else f"{yellow}0{reset}" @@ -3633,6 +3640,132 @@ def print_help(): print() +def cmd_login(args): + import getpass + from services import podcli_cloud + + email = (args.email or input("Email: ")).strip() + # Prefer the prompt: a password in argv is visible in ps output and lands in + # the user's shell history. + password = args.password or getpass.getpass("Password: ") + if not email or not password: + print("Email and password are required.") + sys.exit(1) + + try: + podcli_cloud.login(email, password) + account = podcli_cloud.me() + podcli_cloud.remember_plan(account.get("plan", "")) + except podcli_cloud.CloudError as exc: + print(f"Sign-in failed: {exc}") + sys.exit(1) + + workspace = account.get("workspace") or {} + print(f"Signed in to {workspace.get('name', 'your workspace')} " + f"({account.get('plan', 'free')} plan, {account.get('role', 'member')}).") + if account.get("plan") == "free": + print("This workspace has no active subscription — podcli will keep using " + "your local AI CLI until one starts.") + + # Everything already rendered on this machine belongs in the workspace too, + # so the performance model starts with a back catalogue instead of nothing. + try: + synced, failed = podcli_cloud.backfill_clips() + except Exception: + synced, failed = 0, 0 + if synced: + print(f"Synced {synced} existing clip{'s' if synced != 1 else ''} to your workspace.") + if failed: + print(f"{failed} could not be synced — `podcli whoami` will retry later.") + + +def cmd_logout(args): + from services import podcli_cloud + + if not podcli_cloud.signed_in(): + print("Not signed in.") + return + podcli_cloud.clear_token() + print("Signed out. podcli will use your local AI CLI from now on.") + + +def cmd_whoami(args): + from services import ai_provider, podcli_cloud + + if not podcli_cloud.signed_in(): + print("Not signed in to podcli Pro. Run `podcli login`.") + else: + try: + account = podcli_cloud.me() + podcli_cloud.remember_plan(account.get("plan", "")) + workspace = account.get("workspace") or {} + print(f"Signed in to {workspace.get('name', '?')} " + f"({account.get('plan')} plan, {account.get('role')})") + used = workspace.get("episodes_used") + if used is not None: + print(f"Episodes used this month: {used}") + except podcli_cloud.CloudError as exc: + print(f"Signed in, but the account could not be checked: {exc}") + + providers = ai_provider.status()["providers"] + if providers: + print("AI will use: " + " → ".join(p["label"] for p in providers)) + else: + print("No AI available. Install Claude Code, set ANTHROPIC_API_KEY, or sign in.") + + +def cmd_workspace(args): + from services import podcli_cloud + + if not podcli_cloud.signed_in(): + print("Not signed in to podcli Pro. Run `podcli login`.") + sys.exit(1) + + action = getattr(args, "workspace_action", None) or "list" + try: + if action == "new": + created = podcli_cloud.create_workspace(args.name) + print(f"Created {created['name']} and switched to it (free plan).") + print("Each show carries its own subscription, so this one needs its own.") + _warn_local_data() + return + + workspaces = podcli_cloud.list_workspaces() + + if action == "use": + target = next( + (w for w in workspaces + if args.name.lower() in (w["name"].lower(), w["id"].lower())), + None, + ) + if not target: + print(f"No workspace matching {args.name!r}.") + sys.exit(1) + switched = podcli_cloud.switch_workspace(target["id"]) + print(f"Switched to {switched['name']} ({switched['plan']} plan).") + _warn_local_data() + return + + for w in workspaces: + marker = "*" if w.get("current") else " " + print(f" {marker} {w['name']} ({w['plan']}, {w['role']})") + except podcli_cloud.CloudError as exc: + print(f"Could not reach podcli Pro: {exc}") + sys.exit(1) + + +def _warn_local_data(): + """Switching workspace does not move the local knowledge base or assets. + + Those live in .podcli/ on this machine, and a second show's brand voice + overwriting the first is data loss rather than a sync. Keeping each show in + its own directory (or PODCLI_HOME) is the honest answer until profiles do it + automatically. + """ + print() + print(" Local .podcli/ data is per-directory, not per-workspace.") + print(" Work on each show from its own folder so their knowledge bases " + "and assets stay separate.") def _onboarding_marker() -> str: return os.path.join(paths["home"], ".onboarded") @@ -3824,6 +3957,20 @@ def main(): parser.add_argument("--no-banner", action="store_true", help=argparse.SUPPRESS) sub = parser.add_subparsers(dest="command") + # ── podcli Pro account ── + login_p = sub.add_parser("login", help="Sign in to podcli Pro") + login_p.add_argument("--email", help="Account email (prompted if omitted)") + login_p.add_argument("--password", help="Password (prompted if omitted; prefer the prompt)") + sub.add_parser("logout", help="Sign out of podcli Pro on this machine") + sub.add_parser("whoami", help="Show the signed-in podcli Pro account") + ws_p = sub.add_parser("workspace", help="Switch between shows in podcli Pro") + ws_sub = ws_p.add_subparsers(dest="workspace_action") + ws_sub.add_parser("list", help="List your workspaces") + ws_new = ws_sub.add_parser("new", help="Create a workspace for another show") + ws_new.add_argument("name", help="Workspace name") + ws_use = ws_sub.add_parser("use", help="Switch to a workspace") + ws_use.add_argument("name", help="Workspace name or id") + # ── process ── proc = sub.add_parser("process", help="Process a video into clips") proc.add_argument("video", nargs="?", default=None, help="Path to podcast video file (optional if preset has video_path)") @@ -4160,7 +4307,15 @@ def main(): print(" Setup cancelled. Your command did not run.", file=sys.stderr) sys.exit(130) - if args.command == "process": + if args.command == "login": + cmd_login(args) + elif args.command == "logout": + cmd_logout(args) + elif args.command == "whoami": + cmd_whoami(args) + elif args.command == "workspace": + cmd_workspace(args) + elif args.command == "process": if not getattr(args, "no_banner", False): print() cmd_process(args) diff --git a/backend/main.py b/backend/main.py index bb52b4e..067a49a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -169,6 +169,9 @@ def handle_create_clip(task_id: str, params: dict): start_second=params["start_second"], end_second=params["end_second"], caption_style=params.get("caption_style", "hormozi"), + caption_position=params.get("caption_position", "auto"), + caption_font_scale=params.get("caption_font_scale", 100), + logo_position=params.get("logo_position", "top-left"), crop_strategy=params.get("crop_strategy", "face"), format=params.get("format", "vertical"), crop_keyframes=params.get("crop_keyframes"), @@ -223,6 +226,9 @@ def render_one(i: int, clip: dict) -> dict: start_second=clip["start_second"], end_second=clip["end_second"], caption_style=clip.get("caption_style", "hormozi"), + caption_position=clip.get("caption_position", params.get("caption_position", "auto")), + caption_font_scale=clip.get("caption_font_scale", params.get("caption_font_scale", 100)), + logo_position=clip.get("logo_position", params.get("logo_position", "top-left")), crop_strategy=clip.get("crop_strategy", "face"), format=clip.get("format", params.get("format", "vertical")), transcript_words=params.get("transcript_words", []), @@ -676,8 +682,8 @@ def _signal_profiles_for_suggest( def handle_suggest_clips(task_id: str, params: dict): """AI-powered clip suggestion using Claude/Codex and PodStack knowledge base.""" + from services import ai_provider from services.claude_suggest import ( - _find_ai_cli_candidates, select_clips_with_signal_scores, suggest_initial_with_claude, ) @@ -690,13 +696,15 @@ def handle_suggest_clips(task_id: str, params: dict): emit_result(task_id, "error", error="segments is required") return - if not _find_ai_cli_candidates(): + # Gate on the provider chain, not on a local binary: a signed-in Pro user + # has AI available without installing anything. + if not ai_provider.available(): emit_result( task_id, "error", error=( - "No AI CLI available (install Claude Code or Codex). " - "If already installed, set the path in Config → AI CLI or PODCLI_CLAUDE_PATH." + "No AI available. Sign in with `podcli login`, install Claude Code " + "or Codex, or set ANTHROPIC_API_KEY." ), ) return @@ -740,11 +748,19 @@ def handle_manage_env(task_id: str, params: dict): def handle_ai_cli_status(task_id: str, params: dict): - from services.claude_suggest import get_ai_cli_status + from services.ai_cli import get_ai_cli_status emit_result(task_id, "success", data=get_ai_cli_status()) +def handle_ai_provider_status(task_id: str, params: dict): + """Everything podcli can use for AI, not just local binaries — so the studio + can tell "nothing installed" apart from "signed in, nothing needed".""" + from services import ai_provider + + emit_result(task_id, "success", data=ai_provider.status()) + + def handle_find_moment(task_id: str, params: dict): """Locate user-pasted/described moments in the transcript via the AI CLI.""" from services.claude_suggest import find_moments_from_text @@ -774,7 +790,7 @@ def handle_find_moment(task_id: str, params: dict): def handle_generate_content(task_id: str, params: dict): """Generate titles, descriptions, tags for a clip using PodStack knowledge base.""" from services.content_generator import generate_clip_content - from services.claude_suggest import _find_ai_cli_candidates + from services import ai_provider clip = params.get("clip", {}) transcript_segments = params.get("transcript_segments", []) @@ -783,13 +799,15 @@ def handle_generate_content(task_id: str, params: dict): emit_result(task_id, "error", error="clip is required") return - if not _find_ai_cli_candidates(): + # Gate on the provider chain, not on a local binary: a signed-in Pro user + # has AI available without installing anything. + if not ai_provider.available(): emit_result( task_id, "error", error=( - "No AI CLI available (install Claude Code or Codex). " - "If already installed, set the path in Config → AI CLI or PODCLI_CLAUDE_PATH." + "No AI available. Sign in with `podcli login`, install Claude Code " + "or Codex, or set ANTHROPIC_API_KEY." ), ) return @@ -808,7 +826,7 @@ def handle_generate_content(task_id: str, params: dict): emit_result( task_id, "error", - error="AI CLI found but content generation failed — check claude/codex login and try again", + error="Content generation failed — check that your AI provider is reachable and try again", ) return @@ -832,7 +850,10 @@ def handle_generate_custom(task_id: str, params: dict): ) if result is None: - emit_result(task_id, "error", error="No AI CLI available (install Claude Code or Codex)") + # The gate above admits a workspace session or an API key as well as a + # local binary, so naming only the binary sends cloud users to install + # something they will never use. + emit_result(task_id, "error", error="No AI provider available — sign in to podcli Pro, install Claude Code or Codex, or set ANTHROPIC_API_KEY") return emit_result(task_id, "success", data=result) @@ -876,6 +897,46 @@ def handle_manage_config(task_id: str, params: dict): emit_result(task_id, "error", error=str(e)) +def handle_analyze_silence(task_id: str, params: dict): + """Analyze a full episode locally and return a conservative cut plan.""" + from services.silence_removal import analyze_silence + + try: + result = analyze_silence( + video_path=params.get("video_path", ""), + transcript_words=params.get("transcript_words") or [], + threshold=float(params.get("threshold", 0.5)), + min_silence_seconds=float(params.get("min_silence_seconds", 0.65)), + padding_seconds=float(params.get("padding_seconds", 0.12)), + progress_callback=lambda pct, msg: emit_progress( + task_id, "silence_analysis", pct, msg + ), + ) + emit_result(task_id, "success", data=result) + except (FileNotFoundError, RuntimeError, ValueError) as e: + emit_result(task_id, "error", error=str(e)) + + +def handle_render_silence_removed(task_id: str, params: dict): + """Render the approved local cut plan and remap transcript timestamps.""" + from config.paths import paths + from services.silence_removal import render_silence_removed + + try: + result = render_silence_removed( + video_path=params.get("video_path", ""), + keep_segments=params.get("keep_segments") or [], + transcript=params.get("transcript") or {}, + output_dir=params.get("output_dir") or paths["output"], + progress_callback=lambda pct, msg: emit_progress( + task_id, "silence_render", pct, msg + ), + ) + emit_result(task_id, "success", data=result) + except (FileNotFoundError, RuntimeError, ValueError) as e: + emit_result(task_id, "error", error=str(e)) + + def handle_run_integration_tool(task_id: str, params: dict): from services.integrations import IntegrationRegistry, IntegrationsManager @@ -927,11 +988,14 @@ def handle_run_integration_tool(task_id: str, params: dict): "find_moment": handle_find_moment, "manage_env": handle_manage_env, "ai_cli_status": handle_ai_cli_status, + "ai_provider_status": handle_ai_provider_status, "generate_content": handle_generate_content, "generate_custom": handle_generate_custom, "manage_integrations": handle_manage_integrations, "run_integration_tool": handle_run_integration_tool, "manage_config": handle_manage_config, + "analyze_silence": handle_analyze_silence, + "render_silence_removed": handle_render_silence_removed, } diff --git a/backend/requirements-runtime.txt b/backend/requirements-runtime.txt index fed40ed..e40e047 100644 --- a/backend/requirements-runtime.txt +++ b/backend/requirements-runtime.txt @@ -6,8 +6,8 @@ opencv-python-headless>=4.8.1.78 numpy>=2.5.1 # Audio-event detection (YAMNet laughter/reaction channel) runs on ONNX Runtime — # no torch/TF, so it stays on the hermetic native path. -onnxruntime>=1.27.0 -Pillow>=10.0.0 +onnxruntime>=1.28.0 +Pillow>=12.3.0 questionary>=2.1.1 python-dotenv>=1.2.2 yt-dlp>=2026.7.4 diff --git a/backend/requirements.txt b/backend/requirements.txt index ba25daf..aa2a2d5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,10 +13,10 @@ opencv-python-headless>=4.8.1.78 numpy>=2.5.1 # Audio-event detection (laughter/reaction channel via YAMNet ONNX — no torch/TF) -onnxruntime>=1.27.0 +onnxruntime>=1.28.0 # Thumbnails -Pillow>=10.0.0 +Pillow>=12.3.0 # CLI interactive prompts questionary>=2.1.1 diff --git a/backend/services/ai_cli.py b/backend/services/ai_cli.py new file mode 100644 index 0000000..6d8daf0 --- /dev/null +++ b/backend/services/ai_cli.py @@ -0,0 +1,497 @@ +"""Discovery and invocation of the user's local AI CLI (Claude Code or Codex). + +Finding the binary is genuinely hard: npm prefixes, version managers, shell +aliases and platform extensions all move it. That search lives here so the +provider layer above can treat "run this prompt" as one call. +""" + +import os +import subprocess +import sys +from functools import lru_cache +from typing import Optional + +def _cli_name_exts() -> list[str]: + if sys.platform == "win32": + return ["", ".cmd", ".exe", ".bat"] + return [""] + + +def _resolve_cli_path(path: str) -> Optional[str]: + for ext in _cli_name_exts(): + candidate = path + ext + if os.path.isfile(candidate): + return candidate + return None + + +def _dedupe_dirs(dirs: list[str]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + for directory in dirs: + if not directory: + continue + directory = os.path.expanduser(directory) + if directory in seen: + continue + seen.add(directory) + if os.path.isdir(directory): + ordered.append(directory) + return ordered + + +def _npmrc_prefix_dirs() -> list[str]: + dirs: list[str] = [] + npmrc_paths = [os.path.join(os.path.expanduser("~"), ".npmrc")] + try: + from services.env_settings import _env_path + npmrc_paths.append(os.path.join(os.path.dirname(_env_path()), ".npmrc")) + except Exception: + pass + for npmrc in npmrc_paths: + if not os.path.isfile(npmrc): + continue + try: + with open(npmrc, encoding="utf-8") as f: + for line in f: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith(";"): + continue + if stripped.startswith("prefix="): + prefix = stripped.split("=", 1)[1].strip() + if prefix: + dirs.append(prefix if sys.platform == "win32" else os.path.join(prefix, "bin")) + except Exception: + pass + return dirs + + +def _package_manager_bin_dirs() -> list[str]: + dirs: list[str] = [] + npm_cmds = [ + (["npm", "config", "get", "prefix"], "prefix"), + (["npm", "root", "-g"], "root"), + ] + for args, kind in npm_cmds: + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=2) + except Exception: + continue + if result.returncode != 0: + continue + raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" + if not raw: + continue + if kind == "prefix": + dirs.append(raw if sys.platform == "win32" else os.path.join(raw, "bin")) + elif kind == "root": + dirs.append(os.path.join(raw, ".bin")) + else: + dirs.append(raw) + + for args, kind in ( + (["pnpm", "config", "get", "global-bin-dir"], "bin"), + (["pnpm", "bin", "-g"], "bin"), + (["yarn", "global", "bin"], "bin"), + ): + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=2) + except Exception: + continue + if result.returncode != 0: + continue + raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" + if raw: + dirs.append(raw) + + return dirs + + +def _version_manager_bin_dirs() -> list[str]: + home = os.path.expanduser("~") + dirs = [ + os.path.join(home, "bin"), + os.path.join(home, ".asdf", "shims"), + os.path.join(home, ".local", "share", "mise", "shims"), + os.path.join(home, ".local", "share", "rtx", "shims"), + os.path.join(home, ".bun", "bin"), + os.path.join(home, ".cargo", "bin"), + os.path.join(home, "go", "bin"), + os.path.join(home, ".local", "share", "pnpm"), + os.path.join(home, ".claude", "bin"), + ] + + nvm_dir = os.environ.get("NVM_DIR") or os.path.join(home, ".nvm") + try: + import glob + dirs.extend(sorted(glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")), reverse=True)) + dirs.extend(glob.glob(os.path.join(home, ".fnm", "node-versions", "*", "installation", "bin"))) + dirs.extend(glob.glob(os.path.join(home, ".local", "share", "fnm", "node-versions", "*", "installation", "bin"))) + except Exception: + pass + + fnm_bin = os.path.join(home, ".local", "share", "fnm", "current", "bin") + dirs.append(fnm_bin) + dirs.append(os.path.join(home, ".volta", "bin")) + + if sys.platform == "win32": + for env_key in ("APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"): + base = os.environ.get(env_key) + if not base: + continue + dirs.extend([ + os.path.join(base, "npm"), + os.path.join(base, "Programs", "nodejs"), + os.path.join(base, "Microsoft", "WinGet", "Links"), + ]) + dirs.append(os.path.join(home, "scoop", "shims")) + dirs.append(os.path.join(os.environ.get("ProgramData", ""), "npm")) + else: + dirs.extend([ + "/usr/bin", + "/bin", + "/usr/local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/snap/bin", + "/var/lib/snapd/snap/bin", + ]) + + npm_prefix = ( + os.environ.get("NPM_CONFIG_PREFIX") + or os.environ.get("npm_config_prefix") + or "" + ).strip() + if npm_prefix: + dirs.append(os.path.join(os.path.expanduser(npm_prefix), "bin")) + + return dirs + + +def _static_lookup_dirs() -> list[str]: + home = os.path.expanduser("~") + dirs = [ + os.path.join(home, ".local", "bin"), + os.path.join(home, ".claude", "local", "bin"), + os.path.join(home, ".claude", "local", "node_modules", ".bin"), + os.path.join(home, ".npm-global", "bin"), + ] + if sys.platform == "win32": + appdata = os.environ.get("APPDATA") + if appdata: + dirs.append(os.path.join(appdata, "npm")) + dirs.append(os.path.join(home, ".local", "bin")) + return dirs + + +@lru_cache(maxsize=8) +def _lookup_dirs(_key: tuple) -> list[str]: + return _dedupe_dirs( + _static_lookup_dirs() + + _version_manager_bin_dirs() + + _npmrc_prefix_dirs() + + _package_manager_bin_dirs() + ) + + +def _all_lookup_dirs() -> list[str]: + return list(_lookup_dirs(_discovery_key())) + + +def _path_lookup_dirs() -> list[str]: + return _all_lookup_dirs() + + +def _npm_global_bin_dirs() -> list[str]: + return _package_manager_bin_dirs() + + +def _parse_shell_lookup_line(line: str) -> Optional[str]: + candidate = line.strip().strip('"') + if not candidate: + return None + if " is " in candidate: + candidate = candidate.split(" is ", 1)[1].strip() + if candidate.startswith("(") and candidate.endswith(")"): + candidate = candidate[1:-1].strip() + return _resolve_cli_path(candidate) or (candidate if os.path.isfile(candidate) else None) + + +def _shell_lookup(name: str) -> Optional[str]: + if sys.platform == "win32": + commands = [ + ["where", name], + [ + "powershell", + "-NoProfile", + "-Command", + f"(Get-Command {name} -All -ErrorAction SilentlyContinue | " + f"Select-Object -ExpandProperty Source)", + ], + ] + else: + commands = [ + ["sh", "-lc", f"command -v {name}"], + ["bash", "-lc", f"type -a {name} 2>/dev/null"], + ["zsh", "-lc", f"whence -p {name} 2>/dev/null; command -v {name} 2>/dev/null"], + ["fish", "-lc", f"type -a {name} 2>/dev/null"], + ] + + for cmd in commands: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3) + except Exception: + continue + if result.returncode != 0 or not result.stdout.strip(): + continue + for line in result.stdout.strip().splitlines(): + resolved = _parse_shell_lookup_line(line) + if resolved: + return resolved + return None + + +def _glob_cli_paths(name: str) -> list[str]: + import glob + home = os.path.expanduser("~") + patterns = [ + os.path.join(home, ".claude", "bin", name), + os.path.join(home, ".claude", "*", "bin", name), + os.path.join(home, ".local", "share", "claude", "bin", name), + os.path.join(home, ".local", "share", "npm", "*", "bin", name), + ] + if sys.platform == "win32": + patterns.extend([ + os.path.join(home, ".claude", "bin", f"{name}.exe"), + os.path.join(home, ".claude", "bin", f"{name}.cmd"), + ]) + found: list[str] = [] + for pattern in patterns: + try: + found.extend(glob.glob(pattern)) + except Exception: + pass + return found + + +def _configured_cli_path(engine: str) -> Optional[str]: + env_key = "PODCLI_CLAUDE_PATH" if engine == "claude" else "PODCLI_CODEX_PATH" + raw = (os.environ.get(env_key) or "").strip() + if not raw: + try: + from services.env_settings import _read_pairs + raw = (_read_pairs().get(env_key) or "").strip() + except Exception: + pass + if not raw: + return None + return _resolve_cli_path(raw) or (raw if os.path.isfile(raw) else None) + + +def _find_cli(name: str, extra_paths: list[str] = None) -> Optional[str]: + import shutil + + for path in (extra_paths or []) + _glob_cli_paths(name): + resolved = _resolve_cli_path(path) + if resolved: + return resolved + + lookup_dirs = _all_lookup_dirs() + lookup_path = os.pathsep.join(lookup_dirs + [os.environ.get("PATH", "")]) + found = shutil.which(name, path=lookup_path) + if found: + return found + + for directory in lookup_dirs: + resolved = _resolve_cli_path(os.path.join(directory, name)) + if resolved: + return resolved + + for directory in (os.environ.get("PATH", "") or "").split(os.pathsep): + if not directory: + continue + resolved = _resolve_cli_path(os.path.join(directory, name)) + if resolved: + return resolved + + return _shell_lookup(name) + + +def _ai_cli_search_paths(name: str) -> list[str]: + paths_out = [os.path.join(directory, name) for directory in _all_lookup_dirs()] + paths_out.extend(_glob_cli_paths(name)) + return paths_out + + +def _env_cli_path(engine: str) -> Optional[str]: + return _configured_cli_path(engine) + + +def get_ai_cli_status() -> dict: + configured = { + "claude": _configured_cli_path("claude"), + "codex": _configured_cli_path("codex"), + } + candidates = [ + {"engine": engine, "path": path} + for path, engine in _find_ai_cli_candidates() + ] + return { + "configured": configured, + "candidates": candidates, + "available": bool(candidates), + "searched_dirs": _all_lookup_dirs(), + } + + +def _env_file_stamp() -> tuple: + """ + Identity of the .env discovery also reads. + + A configured CLI path can come from the file as well as the environment, + and the backend task runner is long-lived: it serves the request that saves + the path and every request after it. Without the file in the key, saving a + path in the studio has no effect until the process restarts, which is a + regression against the old probe-every-time behaviour. + """ + try: + from services.env_settings import _env_path + path = _env_path() + stat = os.stat(path) + return (path, stat.st_mtime_ns, stat.st_size) + except Exception: + # No file, or no reading it: nothing to invalidate against. + return () + + +def _discovery_key() -> tuple: + """Everything discovery reads. Changing any of it must re-probe.""" + return tuple( + os.environ.get(name, "") + for name in ( + "PATH", "HOME", "NVM_DIR", "APPDATA", "ProgramData", + "NPM_CONFIG_PREFIX", "npm_config_prefix", + "PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH", + ) + ) + _env_file_stamp() + + +@lru_cache(maxsize=8) +def _discover(_key: tuple) -> list[tuple[str, str]]: + candidates = [] + + claude = _env_cli_path("claude") or _find_cli("claude", _ai_cli_search_paths("claude")) + if claude: + candidates.append((claude, "claude")) + + codex = _env_cli_path("codex") or _find_cli("codex", _ai_cli_search_paths("codex")) + if codex: + candidates.append((codex, "codex")) + + return candidates + + +def _find_ai_cli_candidates() -> list[tuple[str, str]]: + # Each probe shells out to npm, pnpm and yarn, which costs ~3s. Callers ask + # several times per render and the filesystem does not move underneath them, + # so the result is cached against the environment it was derived from. + return list(_discover(_discovery_key())) + + +def _find_ai_cli() -> tuple[Optional[str], str]: + """ + Find the best available AI CLI. + + Returns (path, engine) where engine is "claude" or "codex". + Returns (None, "") if neither is available. + """ + candidates = _find_ai_cli_candidates() + return candidates[0] if candidates else (None, "") + + +def _engine_label(engine: str) -> str: + """Human-readable name for an AI engine id.""" + if engine == "claude": + return "Claude" + if engine == "codex": + return "Codex" + return "AI" + + +def _format_timeout_label(timeout: int) -> str: + """Render a human-readable timeout label for progress messages.""" + if timeout % 60 == 0 and timeout >= 60: + minutes = timeout // 60 + unit = "minute" if minutes == 1 else "minutes" + return f"{minutes} {unit}" + return f"{timeout}s" + + +def _run_ai_command( + cli_path: str, + engine: str, + prompt: str, + prompt_file: str, + project_dir: str, + timeout: int, +) -> subprocess.CompletedProcess: + """Execute one AI CLI prompt and return the completed process.""" + if engine == "codex": + output_file = prompt_file + ".out" + result = subprocess.run( + [ + cli_path, "exec", + "--full-auto", + "-o", output_file, + prompt, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=project_dir, + timeout=timeout, + ) + if os.path.exists(output_file): + with open(output_file, encoding="utf-8") as f: + result = subprocess.CompletedProcess( + args=result.args, + returncode=result.returncode, + stdout=f.read(), + stderr=result.stderr, + ) + try: + os.unlink(output_file) + except Exception: + pass + return result + + shell = sys.platform == "win32" and cli_path.lower().endswith((".cmd", ".bat")) + cmd = f'"{cli_path}" --print -p -' if shell else [cli_path, "--print", "-p", "-"] + with open(prompt_file, encoding="utf-8") as prompt_fh: + return subprocess.run( + cmd, + stdin=prompt_fh, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=project_dir, + timeout=timeout, + shell=shell, + ) + + +def classify_cli_error(detail: str) -> str: + """Turn a raw AI CLI failure into an actionable hint. The generic + 'check login' message hides whether it's auth, a plan limit, or a crash.""" + low = (detail or "").lower() + if any(s in low for s in ("not logged in", "please run", "/login", "authenticate", "unauthorized", "invalid api key", "no credentials")): + return "not logged in. Run `claude` (or `codex`) once in a terminal to authenticate, then retry." + if any(s in low for s in ("usage limit", "rate limit", "quota", "too many requests", "429")): + return "usage or rate limit reached on your plan. Wait for the limit to reset, then retry." + if "timed out" in low or "timeout" in low: + return detail + if not detail: + return "the AI CLI returned no output. Run `claude` once in a terminal to confirm it responds." + return detail diff --git a/backend/services/ai_provider.py b/backend/services/ai_provider.py new file mode 100644 index 0000000..856f95b --- /dev/null +++ b/backend/services/ai_provider.py @@ -0,0 +1,355 @@ +"""Single entry point for every AI generation in podcli. + +Three backends, tried in order until one answers: + + cloud podcli Pro, if signed in (fastest, no install, prompt caching) + cli the user's local Claude Code / Codex binary (free, needs an install) + api ANTHROPIC_API_KEY, called directly over HTTPS (no install, per token) + +Callers pass a prompt and get text back. They do not care which backend ran, +which is the point: the local CLI is found, launched and parsed differently on +every platform, and that mess stops here. + +Selection is controlled by PODCLI_AI_PROVIDER (auto|cloud|cli|api). On `auto` +the order above applies: a Pro subscriber gets what they paid for first, and the +local CLI remains the fallback if the network or the subscription is unavailable +— podcli never stops working because a server did. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +from services import ai_cli, podcli_cloud + +ANTHROPIC_URL = "https://api.anthropic.com/v1/messages" +ANTHROPIC_VERSION = "2023-06-01" +DEFAULT_API_MODEL = "claude-sonnet-5" +DEFAULT_MAX_TOKENS = 16000 + + +@dataclass +class AIResult: + ok: bool + text: str = "" + error: str = "" + provider: str = "" + label: str = "" + attempts: list[str] = field(default_factory=list) + # Extra independent answers to the same prompt, when the backend ran several. + # Callers that know how to merge them get a wider search for almost nothing; + # callers that ignore them behave exactly as before. + alternates: list[str] = field(default_factory=list) + + +def _mode() -> str: + mode = (os.environ.get("PODCLI_AI_PROVIDER") or "auto").strip().lower() + return mode if mode in ("auto", "cloud", "cli", "api") else "auto" + + +def _api_key() -> Optional[str]: + key = (os.environ.get("ANTHROPIC_API_KEY") or "").strip() + return key or None + + +def _api_model() -> str: + return (os.environ.get("PODCLI_AI_MODEL") or "").strip() or DEFAULT_API_MODEL + + +def _chain() -> list[tuple[str, str, str]]: + """Backends to try, in order: (kind, path_or_key, engine).""" + mode = _mode() + chain: list[tuple[str, str, str]] = [] + # A signed-in free workspace would otherwise upload the whole transcript on + # every pass only to be told 402. Forcing the mode still tries, so someone + # debugging entitlement can reach the server. + if mode in ("auto", "cloud") and podcli_cloud.signed_in(): + if mode == "cloud" or podcli_cloud.entitled(): + chain.append(("cloud", "", "cloud")) + if mode in ("auto", "cli"): + chain.extend(("cli", path, engine) for path, engine in ai_cli._find_ai_cli_candidates()) + if mode in ("auto", "api"): + key = _api_key() + if key: + chain.append(("api", key, "api")) + return chain + + +def label_for(kind: str, engine: str) -> str: + if kind == "cloud": + return "podcli Pro" + if kind == "api": + return "Claude API" + return ai_cli._engine_label(engine) + + +def available() -> bool: + return bool(_chain()) + + +def claude_cli_path() -> Optional[str]: + """The local Claude binary, for the one caller that streams its output.""" + for kind, path, engine in _chain(): + if kind == "cli" and engine == "claude": + return path + return None + + +def status() -> dict: + cli_status = ai_cli.get_ai_cli_status() + chain = _chain() + return { + **cli_status, + "mode": _mode(), + "api_key_set": bool(_api_key()), + "api_model": _api_model(), + "available": bool(chain), + "providers": [ + {"kind": kind, "engine": engine, "label": label_for(kind, engine)} + for kind, _, engine in chain + ], + } + + +def extract_json(text: str) -> Optional[Any]: + """Pull the first JSON value out of a model response. + + Models fence their JSON, prefix it with prose, or both, regardless of how + firmly the prompt asks them not to. + """ + if not text: + return None + body = text.strip() + if "```" in body: + fenced = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", body, re.DOTALL) + if fenced: + body = fenced.group(1).strip() + # By position, not by preference: a top-level array whose first element is + # an object would otherwise match "{" at index 1 and return one element of + # the list instead of the list. + openers = sorted( + (body.find(opener), opener) for opener in ("{", "[") if body.find(opener) >= 0 + ) + for start, _opener in openers: + try: + value, _ = json.JSONDecoder().raw_decode(body, start) + return value + except ValueError: + continue + return None + + +def _run_api(key: str, prompt: str, timeout: int) -> AIResult: + payload = json.dumps({ + "model": _api_model(), + "max_tokens": DEFAULT_MAX_TOKENS, + "messages": [{"role": "user", "content": prompt}], + }).encode("utf-8") + request = urllib.request.Request( + ANTHROPIC_URL, + data=payload, + headers={ + "content-type": "application/json", + "x-api-key": key, + "anthropic-version": ANTHROPIC_VERSION, + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = json.loads(exc.read().decode("utf-8")).get("error", {}).get("message", "") + except Exception: + pass + return AIResult(ok=False, provider="api", label="Claude API", + error=detail or f"Claude API returned HTTP {exc.code}") + except Exception as exc: + return AIResult(ok=False, provider="api", label="Claude API", error=str(exc)) + + if body.get("stop_reason") == "refusal": + return AIResult(ok=False, provider="api", label="Claude API", + error="Claude declined this request.") + + text = "".join( + block.get("text", "") + for block in body.get("content", []) + if block.get("type") == "text" + ).strip() + if not text: + return AIResult(ok=False, provider="api", label="Claude API", + error="Claude API returned no text.") + return AIResult(ok=True, text=text, provider="api", label="Claude API") + + +def _run_cloud(purpose: str, instruction: str, system: Optional[str], + cached_context: Optional[str], episode_source_hash: Optional[str], + timeout: int) -> AIResult: + try: + payload = podcli_cloud.generate( + purpose=purpose, + instruction=instruction, + system=system, + cached_context=cached_context, + episode_source_hash=episode_source_hash, + timeout=timeout, + ) + except podcli_cloud.CloudError as exc: + return AIResult(ok=False, provider="cloud", label="podcli Pro", error=str(exc)) + + text = (payload.get("text") or "").strip() + if not text: + return AIResult(ok=False, provider="cloud", label="podcli Pro", + error="podcli Pro returned no text.") + return AIResult( + ok=True, text=text, provider="cloud", label="podcli Pro", + alternates=[a for a in (payload.get("alternates") or []) if a], + ) + + +def _run_cli(cli_path: str, engine: str, prompt: str, prompt_file: str, + project_dir: str, timeout: int) -> AIResult: + label = ai_cli._engine_label(engine) + try: + completed = ai_cli._run_ai_command( + cli_path=cli_path, + engine=engine, + prompt=prompt, + prompt_file=prompt_file, + project_dir=project_dir, + timeout=timeout, + ) + except Exception as exc: + timed_out = "timed out" in str(exc).lower() or exc.__class__.__name__ == "TimeoutExpired" + detail = ( + f"{label} timed out ({ai_cli._format_timeout_label(timeout)} limit)" + if timed_out else f"{label} failed to start: {exc}" + ) + return AIResult(ok=False, provider=engine, label=label, error=detail) + + text = (completed.stdout or "").strip() + if completed.returncode != 0 or not text: + detail = (completed.stderr or "").strip() or text + return AIResult(ok=False, provider=engine, label=label, + error=ai_cli.classify_cli_error(detail)) + return AIResult(ok=True, text=text, provider=engine, label=label) + + +def generate( + prompt: str, + *, + timeout: int = 900, + project_dir: Optional[str] = None, + on_attempt: Optional[Callable[[str], None]] = None, + accept: Optional[Callable[[str], bool]] = None, + adapt: Optional[Callable[[str, str], str]] = None, + purpose: str = "generate", + stable_prefix: Optional[str] = None, + local_prompt: Optional[str] = None, + episode_source_hash: Optional[str] = None, +) -> AIResult: + """Run one prompt through the first backend that answers. + + on_attempt is called with a human label ("Claude", "podcli Pro") before each + attempt so callers can drive progress UI without knowing the chain. + + accept rejects a response the backend considers successful — an engine that + answers with prose where JSON was asked for has failed, and the next one + deserves a turn. Return True to accept, or False / a reason string to reject. + + adapt(engine, prompt) rewrites the prompt per backend, for engines that need + a shorter one than the others. + + stable_prefix is the large, unchanging half of the prompt — the transcript. + The cloud backend sends it as a separate cacheable block so it is read once + per episode rather than once per pass, which is the difference between ~$0.41 + and ~$0.90 per episode. + + Caching wants the stable text first and the varying ask last; several local + prompts are built the other way round, and reordering them would change what + the free path produces. local_prompt is the escape hatch: pass the exact + legacy string and local backends send it untouched while the cloud gets the + split form. Omit it and the prefix is simply prepended. + """ + chain = _chain() + if not chain: + return AIResult( + ok=False, + error="No AI available. Install Claude Code or set ANTHROPIC_API_KEY " + "(podcli config set ANTHROPIC_API_KEY ...).", + ) + + if project_dir is None: + project_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + # The Claude CLI reads its prompt from a file, so an adapted prompt needs its + # own file rather than the shared one. + prompt_files: dict[str, str] = {} + + def prompt_file_for(text: str) -> str: + if text not in prompt_files: + from utils.prompt_files import write_prompt_file + prompt_files[text] = write_prompt_file(text) + return prompt_files[text] + + attempts: list[str] = [] + last = AIResult(ok=False, error="No AI backend produced a response.") + try: + for kind, target, engine in chain: + label = label_for(kind, engine) + if on_attempt: + on_attempt(label) + if kind == "cloud": + result = _run_cloud(purpose, prompt, None, stable_prefix, + episode_source_hash, timeout) + else: + whole = local_prompt or ( + f"{stable_prefix}\n\n{prompt}" if stable_prefix else prompt + ) + text = adapt(engine, whole) if adapt else whole + if kind == "api": + result = _run_api(target, text, timeout) + else: + result = _run_cli(target, engine, text, prompt_file_for(text), + project_dir, timeout) + if result.ok and accept: + verdict = accept(result.text) + if verdict is not True: + reason = verdict if isinstance(verdict, str) and verdict else \ + f"{label} returned an unusable response." + result = AIResult(ok=False, provider=result.provider, + label=label, error=reason) + attempts.append(f"{label}: {'ok' if result.ok else result.error}") + if result.ok: + result.attempts = attempts + return result + last = result + last.attempts = attempts + return last + finally: + for path in prompt_files.values(): + try: + os.unlink(path) + except OSError: + pass + + +def generate_json(prompt: str, **kwargs) -> tuple[Optional[Any], AIResult]: + """generate() plus the fence-stripping every caller was doing by hand. + + A backend whose answer will not parse is treated as failed, so the next one + in the chain gets a turn. + """ + kwargs.setdefault("accept", lambda text: extract_json(text) is not None) + result = generate(prompt, **kwargs) + if not result.ok: + return None, result + return extract_json(result.text), result diff --git a/backend/services/caption_renderer.py b/backend/services/caption_renderer.py index 8731fe8..06291c5 100644 --- a/backend/services/caption_renderer.py +++ b/backend/services/caption_renderer.py @@ -93,6 +93,8 @@ def render_captions( caption_style: str, output_path: str, time_offset: float = 0.0, + caption_position: str = "auto", + caption_font_scale: int = 100, ) -> str: """ Generate an ASS subtitle file from word-level timestamps. @@ -113,7 +115,12 @@ def render_captions( f.write(generate_ass_header(get_style(caption_style))) return output_path - style = get_style(caption_style) + style = dict(get_style(caption_style)) + scale = max(60, min(160, int(caption_font_scale))) / 100 + style["font_size"] = round(style["font_size"] * scale) + position_margins = {"upper": 760, "center": 480, "lower": 220} + if caption_position in position_margins: + style["margin_v"] = position_margins[caption_position] if caption_style == "hormozi": content = _render_hormozi(words, style, time_offset) diff --git a/backend/services/captions_burn.py b/backend/services/captions_burn.py index 424f844..79cfcbb 100644 --- a/backend/services/captions_burn.py +++ b/backend/services/captions_burn.py @@ -64,6 +64,7 @@ def burn_captions( logo_height: int = 80, logo_margin_x: int = 30, logo_margin_y: int = 40, + logo_position: str = "top-left", ) -> str: """Burn ASS subtitles into the video. @@ -99,9 +100,13 @@ def burn_captions( logo_idx = input_idx input_idx += 1 filter_parts.append(f"[{logo_idx}:v]scale=-1:{logo_height}[logo]") - filter_parts.append( - f"[{current_label}][logo]overlay={logo_margin_x}:{logo_margin_y}[withlogo]" + logo_x = ( + str(logo_margin_x) if logo_position.endswith("-left") + else f"main_w-overlay_w-{logo_margin_x}" if logo_position.endswith("-right") + else "(main_w-overlay_w)/2" ) + logo_y = str(logo_margin_y) if logo_position.startswith("top-") else f"main_h-overlay_h-{logo_margin_y}" + filter_parts.append(f"[{current_label}][logo]overlay={logo_x}:{logo_y}[withlogo]") current_label = "withlogo" # Burn ASS subtitles diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index e12f8d2..57f4641 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -12,7 +12,6 @@ import os import subprocess import sys -import tempfile from typing import Optional, Callable from config.paths import paths @@ -22,432 +21,16 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from presets import MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX from utils.text import clean_title - - -def _cli_name_exts() -> list[str]: - if sys.platform == "win32": - return ["", ".cmd", ".exe", ".bat"] - return [""] - - -def _resolve_cli_path(path: str) -> Optional[str]: - for ext in _cli_name_exts(): - candidate = path + ext - if os.path.isfile(candidate): - return candidate - return None - - -def _dedupe_dirs(dirs: list[str]) -> list[str]: - seen: set[str] = set() - ordered: list[str] = [] - for directory in dirs: - if not directory: - continue - directory = os.path.expanduser(directory) - if directory in seen: - continue - seen.add(directory) - if os.path.isdir(directory): - ordered.append(directory) - return ordered - - -def _npmrc_prefix_dirs() -> list[str]: - dirs: list[str] = [] - npmrc_paths = [os.path.join(os.path.expanduser("~"), ".npmrc")] - try: - from services.env_settings import _env_path - npmrc_paths.append(os.path.join(os.path.dirname(_env_path()), ".npmrc")) - except Exception: - pass - for npmrc in npmrc_paths: - if not os.path.isfile(npmrc): - continue - try: - with open(npmrc, encoding="utf-8") as f: - for line in f: - stripped = line.strip() - if not stripped or stripped.startswith("#") or stripped.startswith(";"): - continue - if stripped.startswith("prefix="): - prefix = stripped.split("=", 1)[1].strip() - if prefix: - dirs.append(prefix if sys.platform == "win32" else os.path.join(prefix, "bin")) - except Exception: - pass - return dirs - - -def _package_manager_bin_dirs() -> list[str]: - dirs: list[str] = [] - npm_cmds = [ - (["npm", "config", "get", "prefix"], "prefix"), - (["npm", "root", "-g"], "root"), - ] - for args, kind in npm_cmds: - try: - result = subprocess.run(args, capture_output=True, text=True, timeout=2) - except Exception: - continue - if result.returncode != 0: - continue - raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" - if not raw: - continue - if kind == "prefix": - dirs.append(raw if sys.platform == "win32" else os.path.join(raw, "bin")) - elif kind == "root": - dirs.append(os.path.join(raw, ".bin")) - else: - dirs.append(raw) - - for args, kind in ( - (["pnpm", "config", "get", "global-bin-dir"], "bin"), - (["pnpm", "bin", "-g"], "bin"), - (["yarn", "global", "bin"], "bin"), - ): - try: - result = subprocess.run(args, capture_output=True, text=True, timeout=2) - except Exception: - continue - if result.returncode != 0: - continue - raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" - if raw: - dirs.append(raw) - - return dirs - - -def _version_manager_bin_dirs() -> list[str]: - home = os.path.expanduser("~") - dirs = [ - os.path.join(home, "bin"), - os.path.join(home, ".asdf", "shims"), - os.path.join(home, ".local", "share", "mise", "shims"), - os.path.join(home, ".local", "share", "rtx", "shims"), - os.path.join(home, ".bun", "bin"), - os.path.join(home, ".cargo", "bin"), - os.path.join(home, "go", "bin"), - os.path.join(home, ".local", "share", "pnpm"), - os.path.join(home, ".claude", "bin"), - ] - - nvm_dir = os.environ.get("NVM_DIR") or os.path.join(home, ".nvm") - try: - import glob - dirs.extend(sorted(glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")), reverse=True)) - dirs.extend(glob.glob(os.path.join(home, ".fnm", "node-versions", "*", "installation", "bin"))) - dirs.extend(glob.glob(os.path.join(home, ".local", "share", "fnm", "node-versions", "*", "installation", "bin"))) - except Exception: - pass - - fnm_bin = os.path.join(home, ".local", "share", "fnm", "current", "bin") - dirs.append(fnm_bin) - dirs.append(os.path.join(home, ".volta", "bin")) - - if sys.platform == "win32": - for env_key in ("APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"): - base = os.environ.get(env_key) - if not base: - continue - dirs.extend([ - os.path.join(base, "npm"), - os.path.join(base, "Programs", "nodejs"), - os.path.join(base, "Microsoft", "WinGet", "Links"), - ]) - dirs.append(os.path.join(home, "scoop", "shims")) - dirs.append(os.path.join(os.environ.get("ProgramData", ""), "npm")) - else: - dirs.extend([ - "/usr/bin", - "/bin", - "/usr/local/bin", - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/snap/bin", - "/var/lib/snapd/snap/bin", - ]) - - npm_prefix = ( - os.environ.get("NPM_CONFIG_PREFIX") - or os.environ.get("npm_config_prefix") - or "" - ).strip() - if npm_prefix: - dirs.append(os.path.join(os.path.expanduser(npm_prefix), "bin")) - - return dirs - - -def _static_lookup_dirs() -> list[str]: - home = os.path.expanduser("~") - dirs = [ - os.path.join(home, ".local", "bin"), - os.path.join(home, ".claude", "local", "bin"), - os.path.join(home, ".claude", "local", "node_modules", ".bin"), - os.path.join(home, ".npm-global", "bin"), - ] - if sys.platform == "win32": - appdata = os.environ.get("APPDATA") - if appdata: - dirs.append(os.path.join(appdata, "npm")) - dirs.append(os.path.join(home, ".local", "bin")) - return dirs - - -def _all_lookup_dirs() -> list[str]: - return _dedupe_dirs( - _static_lookup_dirs() - + _version_manager_bin_dirs() - + _npmrc_prefix_dirs() - + _package_manager_bin_dirs() - ) - - -def _path_lookup_dirs() -> list[str]: - return _all_lookup_dirs() - - -def _npm_global_bin_dirs() -> list[str]: - return _package_manager_bin_dirs() - - -def _parse_shell_lookup_line(line: str) -> Optional[str]: - candidate = line.strip().strip('"') - if not candidate: - return None - if " is " in candidate: - candidate = candidate.split(" is ", 1)[1].strip() - if candidate.startswith("(") and candidate.endswith(")"): - candidate = candidate[1:-1].strip() - return _resolve_cli_path(candidate) or (candidate if os.path.isfile(candidate) else None) - - -def _shell_lookup(name: str) -> Optional[str]: - if sys.platform == "win32": - commands = [ - ["where", name], - [ - "powershell", - "-NoProfile", - "-Command", - f"(Get-Command {name} -All -ErrorAction SilentlyContinue | " - f"Select-Object -ExpandProperty Source)", - ], - ] - else: - commands = [ - ["sh", "-lc", f"command -v {name}"], - ["bash", "-lc", f"type -a {name} 2>/dev/null"], - ["zsh", "-lc", f"whence -p {name} 2>/dev/null; command -v {name} 2>/dev/null"], - ["fish", "-lc", f"type -a {name} 2>/dev/null"], - ] - - for cmd in commands: - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=3) - except Exception: - continue - if result.returncode != 0 or not result.stdout.strip(): - continue - for line in result.stdout.strip().splitlines(): - resolved = _parse_shell_lookup_line(line) - if resolved: - return resolved - return None - - -def _glob_cli_paths(name: str) -> list[str]: - import glob - home = os.path.expanduser("~") - patterns = [ - os.path.join(home, ".claude", "bin", name), - os.path.join(home, ".claude", "*", "bin", name), - os.path.join(home, ".local", "share", "claude", "bin", name), - os.path.join(home, ".local", "share", "npm", "*", "bin", name), - ] - if sys.platform == "win32": - patterns.extend([ - os.path.join(home, ".claude", "bin", f"{name}.exe"), - os.path.join(home, ".claude", "bin", f"{name}.cmd"), - ]) - found: list[str] = [] - for pattern in patterns: - try: - found.extend(glob.glob(pattern)) - except Exception: - pass - return found - - -def _configured_cli_path(engine: str) -> Optional[str]: - env_key = "PODCLI_CLAUDE_PATH" if engine == "claude" else "PODCLI_CODEX_PATH" - raw = (os.environ.get(env_key) or "").strip() - if not raw: - try: - from services.env_settings import _read_pairs - raw = (_read_pairs().get(env_key) or "").strip() - except Exception: - pass - if not raw: - return None - return _resolve_cli_path(raw) or (raw if os.path.isfile(raw) else None) - - -def _find_cli(name: str, extra_paths: list[str] = None) -> Optional[str]: - import shutil - - for path in (extra_paths or []) + _glob_cli_paths(name): - resolved = _resolve_cli_path(path) - if resolved: - return resolved - - lookup_dirs = _all_lookup_dirs() - lookup_path = os.pathsep.join(lookup_dirs + [os.environ.get("PATH", "")]) - found = shutil.which(name, path=lookup_path) - if found: - return found - - for directory in lookup_dirs: - resolved = _resolve_cli_path(os.path.join(directory, name)) - if resolved: - return resolved - - for directory in (os.environ.get("PATH", "") or "").split(os.pathsep): - if not directory: - continue - resolved = _resolve_cli_path(os.path.join(directory, name)) - if resolved: - return resolved - - return _shell_lookup(name) - - -def _ai_cli_search_paths(name: str) -> list[str]: - paths_out = [os.path.join(directory, name) for directory in _all_lookup_dirs()] - paths_out.extend(_glob_cli_paths(name)) - return paths_out - - -def _env_cli_path(engine: str) -> Optional[str]: - return _configured_cli_path(engine) - - -def get_ai_cli_status() -> dict: - configured = { - "claude": _configured_cli_path("claude"), - "codex": _configured_cli_path("codex"), - } - candidates = [ - {"engine": engine, "path": path} - for path, engine in _find_ai_cli_candidates() - ] - return { - "configured": configured, - "candidates": candidates, - "available": bool(candidates), - "searched_dirs": _all_lookup_dirs(), - } - - -def _find_ai_cli_candidates() -> list[tuple[str, str]]: - candidates = [] - - claude = _env_cli_path("claude") or _find_cli("claude", _ai_cli_search_paths("claude")) - if claude: - candidates.append((claude, "claude")) - - codex = _env_cli_path("codex") or _find_cli("codex", _ai_cli_search_paths("codex")) - if codex: - candidates.append((codex, "codex")) - - return candidates - - -def _find_ai_cli() -> tuple[Optional[str], str]: - """ - Find the best available AI CLI. - - Returns (path, engine) where engine is "claude" or "codex". - Returns (None, "") if neither is available. - """ - candidates = _find_ai_cli_candidates() - return candidates[0] if candidates else (None, "") - - -def _engine_label(engine: str) -> str: - """Human-readable name for an AI engine id.""" - if engine == "claude": - return "Claude" - if engine == "codex": - return "Codex" - return "AI" - - -def _format_timeout_label(timeout: int) -> str: - """Render a human-readable timeout label for progress messages.""" - if timeout % 60 == 0 and timeout >= 60: - minutes = timeout // 60 - unit = "minute" if minutes == 1 else "minutes" - return f"{minutes} {unit}" - return f"{timeout}s" - - -def _run_ai_command( - cli_path: str, - engine: str, - prompt: str, - prompt_file: str, - project_dir: str, - timeout: int, -) -> subprocess.CompletedProcess: - """Execute one AI CLI prompt and return the completed process.""" - if engine == "codex": - output_file = prompt_file + ".out" - result = subprocess.run( - [ - cli_path, "exec", - "--full-auto", - "-o", output_file, - prompt, - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - cwd=project_dir, - timeout=timeout, - ) - if os.path.exists(output_file): - with open(output_file, encoding="utf-8") as f: - result = subprocess.CompletedProcess( - args=result.args, - returncode=result.returncode, - stdout=f.read(), - stderr=result.stderr, - ) - try: - os.unlink(output_file) - except Exception: - pass - return result - - shell = sys.platform == "win32" and cli_path.lower().endswith((".cmd", ".bat")) - cmd = f'"{cli_path}" --print -p -' if shell else [cli_path, "--print", "-p", "-"] - with open(prompt_file, encoding="utf-8") as prompt_fh: - return subprocess.run( - cmd, - stdin=prompt_fh, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - cwd=project_dir, - timeout=timeout, - shell=shell, - ) +from services import ai_provider, podcli_cloud +from services.ai_cli import ( + _engine_label, + _find_ai_cli, + _find_ai_cli_candidates, + _format_timeout_label, + _run_ai_command, + classify_cli_error, + get_ai_cli_status, +) def _load_existing_shorts(episodes_path: str) -> list[str]: @@ -634,6 +217,19 @@ def _build_prompt( {transcript_text}""" +def _split_prompt_for_cache(prompt: str, transcript_text: str) -> tuple[str, str]: + """Separate the transcript from the ask, for backends that cache prefixes. + + Returns (stable_prefix, instruction). The transcript is the only large + thing here and it is identical across every pass on an episode, so caching + it turns four full reads into one read and three cache hits. + """ + marker = f"\n\n{transcript_text}" + if not transcript_text or not prompt.endswith(marker): + return "", prompt + return transcript_text, prompt[: -len(marker)] + + def _build_transcript_text(segments: list[dict]) -> str: """Serialize transcript segments into the prompt-friendly text format.""" lines = [] @@ -739,14 +335,13 @@ def find_moments_from_text( progress_callback: Optional[Callable[[int, str], None]] = None, max_results: int = 3, ) -> list[dict]: - """Locate the moment(s) the user described/pasted in the transcript via an AI - CLI. Returns clip dicts (same shape as suggest_with_claude). Status goes to + """Locate the moment(s) the user described/pasted in the transcript. + Returns clip dicts (same shape as suggest_with_claude). Status goes to progress_callback; warnings to stderr — never stdout, which is the task runner's JSON-RPC channel.""" existing_clips = existing_clips or [] - candidates = _find_ai_cli_candidates() - if not candidates: - print("No AI CLI available for moment search", file=sys.stderr, flush=True) + if not ai_provider.available(): + print("No AI available for moment search", file=sys.stderr, flush=True) return [] if progress_callback: @@ -794,49 +389,17 @@ def find_moments_from_text( Transcript: {transcript_text}""" - # Prompt goes to .podcli/tmp/ (gitignored), not the repo root, so a crash - # mid-run never litters the working tree with transcript dumps. project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - - try: - for idx, (cli_path, engine) in enumerate(candidates): - if progress_callback: - label = "Claude" if engine == "claude" else "Codex" - progress_callback(40, f"Searching transcript with {label}...") - try: - result = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=900, - ) - except Exception: - continue - - if result.returncode != 0 or not result.stdout.strip(): - continue - response = result.stdout.strip() - if "```" in response: - import re - - fence_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", response, re.DOTALL) - if fence_match: - response = fence_match.group(1).strip() - - try: - json_start = response.find("{") - if json_start >= 0: - data, _ = json.JSONDecoder().raw_decode(response, json_start) - else: - data = json.loads(response) - except Exception: - continue + def announce(label: str) -> None: + if progress_callback: + progress_callback(40, f"Searching transcript with {label}...") + try: + data, _result = ai_provider.generate_json( + prompt, timeout=900, project_dir=project_dir, on_attempt=announce, + ) + if data: found = [] for c in data.get("clips", []): scores = c.get("scores", {}) @@ -882,26 +445,6 @@ def find_moments_from_text( except Exception as e: print(f"Moment search error: {e}", file=sys.stderr, flush=True) return [] - finally: - try: - os.unlink(prompt_file) - except Exception: - pass - - -def classify_cli_error(detail: str) -> str: - """Turn a raw AI CLI failure into an actionable hint. The generic - 'check login' message hides whether it's auth, a plan limit, or a crash.""" - low = (detail or "").lower() - if any(s in low for s in ("not logged in", "please run", "/login", "authenticate", "unauthorized", "invalid api key", "no credentials")): - return "not logged in. Run `claude` (or `codex`) once in a terminal to authenticate, then retry." - if any(s in low for s in ("usage limit", "rate limit", "quota", "too many requests", "429")): - return "usage or rate limit reached on your plan. Wait for the limit to reset, then retry." - if "timed out" in low or "timeout" in low: - return detail - if not detail: - return "the AI CLI returned no output. Run `claude` once in a terminal to confirm it responds." - return detail def suggest_with_claude( @@ -919,13 +462,12 @@ def suggest_with_claude( Tries available AI CLIs in preference order and retries on runtime failure. Returns None if neither succeeds. """ - candidates = _find_ai_cli_candidates() - if not candidates: + providers = ai_provider.status()["providers"] + if not providers: return None if progress_callback: - label = _engine_label(candidates[0][1]) - progress_callback(0, f"Preparing transcript for {label}...") + progress_callback(0, f"Preparing transcript for {providers[0]['label']}...") transcript_text = _build_transcript_text(segments) @@ -943,161 +485,177 @@ def suggest_with_claude( reaction_times=reaction_times, ) - # Write prompt to temp file to avoid shell escaping issues. - # Goes to .podcli/tmp/ (gitignored) so crashes don't litter the repo root. project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - - if progress_callback: - first_label = _engine_label(candidates[0][1]) - progress_callback(20, f"Asking {first_label} to analyze transcript...") - - try: - def _parse_seconds(val) -> float: - """Parse a timestamp value — handles both 123.4 and '2:03' formats.""" - if isinstance(val, (int, float)): - return float(val) - s = str(val).strip() - if ":" in s: - parts = s.split(":") - try: - return float(parts[0]) * 60 + float(parts[1]) - except (ValueError, IndexError): - return 0.0 + def _parse_seconds(val) -> float: + """Parse a timestamp value — handles both 123.4 and '2:03' formats.""" + if isinstance(val, (int, float)): + return float(val) + s = str(val).strip() + if ":" in s: + parts = s.split(":") try: - return float(s) - except ValueError: + return float(parts[0]) * 60 + float(parts[1]) + except (ValueError, IndexError): return 0.0 + try: + return float(s) + except ValueError: + return 0.0 - last_detail: Optional[str] = None - for idx, (cli_path, engine) in enumerate(candidates): - label = _engine_label(engine) - if idx > 0 and progress_callback: - progress_callback(0, f"Retrying with {label}...") - progress_callback(20, f"Asking {label} to analyze transcript...") + attempted: list[str] = [] + current = {"label": ""} - try: - result = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=timeout, - ) - except subprocess.TimeoutExpired: - last_detail = f"{label} timed out ({_format_timeout_label(timeout)} limit)" - if progress_callback: - progress_callback(0, last_detail) - continue - except Exception as e: - last_detail = f"{label} error: {e}" - if progress_callback: - progress_callback(0, last_detail) - continue + def announce(label: str) -> None: + current["label"] = label + if attempted and progress_callback: + progress_callback(0, f"Retrying with {label}...") + if progress_callback: + progress_callback(20, f"Asking {label} to analyze transcript...") + attempted.append(label) - if result.returncode != 0 or not result.stdout.strip(): - detail = (result.stderr or "no response").strip()[:200] - last_detail = f"{label}: {detail}" - if progress_callback: - progress_callback(0, f"{label} returned error: {detail}") - continue + def usable(text: str): + """Reject a response that parses but has nothing in it, so the next + engine gets a turn rather than the user getting an empty result.""" + label = current["label"] + if progress_callback: + progress_callback(80, f"Parsing {label}'s suggestions...") + parsed = ai_provider.extract_json(text) + if not isinstance(parsed, dict): + return f"{label} returned output that wasn't valid JSON" + if not isinstance(parsed.get("clips"), list) or not parsed["clips"]: + return f"{label} ran but found no clips in the transcript" + return True + + cached_prefix, instruction = _split_prompt_for_cache(prompt, transcript_text) + + # What this channel's own published clips say about what works, plus the + # house style learned from edits the team made to earlier output. Empty for + # everyone else, so the free path is unchanged. + learned = podcli_cloud.prompt_block() + if learned: + instruction = f"{learned}\n\n{instruction}" + + attempt = ai_provider.generate( + instruction, + timeout=timeout, + project_dir=project_dir, + on_attempt=announce, + accept=usable, + purpose="select_moments", + stable_prefix=cached_prefix or None, + # Local backends keep the prompt exactly as it has always been built; + # only the cloud sees the split form. + local_prompt=prompt, + ) - if progress_callback: - progress_callback(80, f"Parsing {label}'s suggestions...") + if not attempt.ok: + if progress_callback: + progress_callback(0, attempt.error) + if error_sink is not None: + # Classified only for a CLI failure. The advice it adds is "run + # `claude` once in a terminal", which is right for a local CLI and + # wrong for a workspace session or an API key, and `classify_cli_error` + # matches on "unauthorized" so it rewrote those too. + # The CLI path tags the attempt with its engine name, not "cli", + # so this asks the question the other way round. + error_sink.append( + attempt.error + if attempt.provider in ("cloud", "api") + else classify_cli_error(attempt.error) + ) + return None - try: - response = result.stdout.strip() - if "```" in response: - import re - fence_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", response, re.DOTALL) - if fence_match: - response = fence_match.group(1).strip() - - json_start = response.find("{") - if json_start >= 0: - decoder = json.JSONDecoder() - data, _ = decoder.raw_decode(response, json_start) - else: - data = json.loads(response) - except json.JSONDecodeError as e: - last_detail = f"{label} returned output that wasn't valid JSON ({e})" - if progress_callback: - progress_callback(0, f"Could not parse {label}'s response as JSON: {e}") + label = attempt.label + + # Several independent searches over the same transcript find overlapping but + # not identical moments. Keeping the union and re-ranking beats picking one + # set, and the dedupe/scoring below already exists for exactly this shape. + def records(payload: object) -> list[dict]: + """ + The clip objects in a response, and only those. + + `usable` checked the primary response is a non-empty list, which still + admits a null or a string inside it, and the alternates are not checked + at all: `.get` on any of those is an AttributeError out of a code path + with nothing above it to catch. + """ + if not isinstance(payload, dict): + return [] + found = payload.get("clips") + if not isinstance(found, list): + return [] + return [c for c in found if isinstance(c, dict)] + + clips = records(ai_provider.extract_json(attempt.text)) + for alternate in attempt.alternates: + clips.extend(records(ai_provider.extract_json(alternate))) + + normalized = [] + for c in clips: + scores = c.get("scores") + scores = scores if isinstance(scores, dict) else {} + total = sum(scores.values()) if scores else c.get("total_score", 0) + + raw_segments = c.get("segments") + raw_segments = raw_segments if isinstance(raw_segments, list) else [] + keep_segments = [] + for seg in raw_segments: + if not isinstance(seg, dict): continue + s = round(_parse_seconds(seg.get("start", 0)), 1) + e = round(_parse_seconds(seg.get("end", 0)), 1) + if e > s: + keep_segments.append({"start": s, "end": e}) - clips = data.get("clips", []) - if not clips: - last_detail = f"{label} ran but found no clips in the transcript" - if progress_callback: - progress_callback(0, f"{label} returned no clips") - continue + start_sec = round(_parse_seconds(c.get("start_second", 0)), 1) + end_sec = round(_parse_seconds(c.get("end_second", 0)), 1) - normalized = [] - for c in clips: - scores = c.get("scores", {}) - total = sum(scores.values()) if scores else c.get("total_score", 0) + if not keep_segments and end_sec > start_sec: + keep_segments = [{"start": start_sec, "end": end_sec}] - raw_segments = c.get("segments", []) - keep_segments = [] - for seg in raw_segments: - s = round(_parse_seconds(seg.get("start", 0)), 1) - e = round(_parse_seconds(seg.get("end", 0)), 1) - if e > s: - keep_segments.append({"start": s, "end": e}) - - start_sec = round(_parse_seconds(c.get("start_second", 0)), 1) - end_sec = round(_parse_seconds(c.get("end_second", 0)), 1) - - if not keep_segments and end_sec > start_sec: - keep_segments = [{"start": start_sec, "end": end_sec}] - - kept_duration = sum(seg["end"] - seg["start"] for seg in keep_segments) - if kept_duration < MIN_CLIP_DURATION or kept_duration > MAX_CLIP_DURATION: - continue - - normalized.append({ - "title": clean_title(c.get("title", "Untitled")), - "start_second": keep_segments[0]["start"] if keep_segments else start_sec, - "end_second": keep_segments[-1]["end"] if keep_segments else end_sec, - "segments": keep_segments, - "duration": round(kept_duration), - "score": total, - "content_type": c.get("content_type", "unknown"), - "reasoning": c.get("why", ""), - "preview_text": c.get("quote", "")[:120], - "suggested_caption_style": "hormozi", - "quote": c.get("quote", ""), - "why": c.get("why", ""), - "reasons": [c.get("content_type", "")], - "preview": c.get("quote", "")[:120], - "_ai_engine": engine, - }) + kept_duration = sum(seg["end"] - seg["start"] for seg in keep_segments) + if kept_duration < MIN_CLIP_DURATION or kept_duration > MAX_CLIP_DURATION: + continue - selected = _select_top_by_score( - _drop_clips_overlapping(normalized, exclude_clips or []), top_n - ) + normalized.append({ + "title": clean_title(c.get("title", "Untitled")), + "start_second": keep_segments[0]["start"] if keep_segments else start_sec, + "end_second": keep_segments[-1]["end"] if keep_segments else end_sec, + "segments": keep_segments, + "duration": round(kept_duration), + "score": total, + "content_type": c.get("content_type", "unknown"), + "reasoning": c.get("why", ""), + "preview_text": c.get("quote", "")[:120], + "suggested_caption_style": "hormozi", + "quote": c.get("quote", ""), + "why": c.get("why", ""), + "reasons": [c.get("content_type", "")], + "preview": c.get("quote", "")[:120], + "_ai_engine": attempt.provider, + }) - if selected: - if progress_callback: - progress_callback(100, f"{label} suggested {len(selected)} clips") - return selected + # Dedupe within the pool as well as against already-selected clips: several + # attempts routinely surface the same strong moment, and without this the + # top N would be the same clip repeated. + selected = _select_top_by_score( + _drop_clips_overlapping(_dedupe_clips_by_range(normalized), exclude_clips or []), + top_n, + ) - last_detail = f"{label} returned clips but none were usable (wrong length or format)" - if progress_callback: - progress_callback(0, f"{label} returned no usable clips") + if selected: + if progress_callback: + progress_callback(100, f"{label} suggested {len(selected)} clips") + return selected - if error_sink is not None: - error_sink.append(classify_cli_error(last_detail or "")) - return None - finally: - # Clean up temp file - try: - os.unlink(prompt_file) - except Exception: - pass + if progress_callback: + progress_callback(0, f"{label} returned no usable clips") + if error_sink is not None: + error_sink.append( + f"{label} returned clips but none were usable (wrong length or format)" + ) + return None def suggest_initial_with_claude( diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py index 1f7c613..b9118be 100644 --- a/backend/services/clip_generator.py +++ b/backend/services/clip_generator.py @@ -16,6 +16,7 @@ from utils.proc import run as proc_run, ProcError from utils.text import safe_filename +from utils.log import timed from config.paths import paths sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -465,6 +466,9 @@ def _render_with_remotion( time_offset: float = 0.0, logo_path: Optional[str] = None, keep_caption_overlay: bool = False, + caption_position: str = "auto", + caption_font_scale: int = 100, + logo_position: str = "top-left", ) -> tuple[bool, Optional[str]]: """ Render captions using Remotion. Returns (success, optional_prores_overlay_path). @@ -586,6 +590,9 @@ def _render_with_remotion( "--words", os.path.abspath(words_file), "--style", caption_style, "--output", os.path.abspath(output_path), + "--caption-position", caption_position, + "--caption-font-scale", str(caption_font_scale), + "--logo-position", logo_position, ] if logo_path and os.path.exists(logo_path): cmd.extend(["--logo", os.path.abspath(logo_path)]) @@ -641,6 +648,9 @@ def generate_clip( start_second: float, end_second: float, caption_style: str = "hormozi", + caption_position: str = "auto", + caption_font_scale: int = 100, + logo_position: str = "top-left", crop_strategy: str = "face", format: str = "vertical", crop_keyframes: list[dict] = None, @@ -816,10 +826,11 @@ def generate_clip( progress_callback(10, msg) segment_path = os.path.join(work_dir, "segment.mp4") - if keep_segments and len(keep_segments) > 1: - cut_multi_segment(video_path, segment_path, keep_segments) - else: - cut_segment(video_path, segment_path, start_second, end_second) + with timed("render", "cut", segments=len(keep_segments) if keep_segments else 1): + if keep_segments and len(keep_segments) > 1: + cut_multi_segment(video_path, segment_path, keep_segments) + else: + cut_segment(video_path, segment_path, start_second, end_second) # Remap transcript words for multi-segment clips. # Needed before crop (speaker detection) and captions. @@ -861,18 +872,19 @@ def generate_clip( progress_callback(30, f"Resizing for {spec.name} format (2/{total_steps})") cropped_path = os.path.join(work_dir, "cropped.mp4") - if spec.reframe: - crop_to_vertical( - segment_path, cropped_path, - strategy=crop_strategy, - transcript_words=crop_words, - clip_start=crop_clip_start, - face_map=face_map, - crop_keyframes=crop_keyframes, - target_dims=spec.dims, - ) - else: - fit_to_frame(segment_path, cropped_path, target_dims=spec.dims) + with timed("render", "crop", strategy=crop_strategy if spec.reframe else "fit"): + if spec.reframe: + crop_to_vertical( + segment_path, cropped_path, + strategy=crop_strategy, + transcript_words=crop_words, + clip_start=crop_clip_start, + face_map=face_map, + crop_keyframes=crop_keyframes, + target_dims=spec.dims, + ) + else: + fit_to_frame(segment_path, cropped_path, target_dims=spec.dims) # Step 3: Render captions (Remotion-first; ASS fallback optional) if transcript_words: @@ -908,6 +920,9 @@ def generate_clip( time_offset=caption_time_offset, logo_path=logo_path if (style_config.get("logo_support", False) and logo_path) else None, keep_caption_overlay=keep_caption_overlay, + caption_position=caption_position, + caption_font_scale=caption_font_scale, + logo_position=logo_position, ) if not remotion_ok and not allow_ass_fallback: @@ -924,6 +939,8 @@ def generate_clip( caption_style=caption_style, output_path=ass_path, time_offset=caption_time_offset, + caption_position=caption_position, + caption_font_scale=caption_font_scale, ) use_gradient = style_config.get("gradient_overlay", False) @@ -940,6 +957,7 @@ def generate_clip( logo_height=style_config.get("logo_height", 80), logo_margin_x=style_config.get("logo_margin_x", 30), logo_margin_y=style_config.get("logo_margin_y", 40), + logo_position=logo_position, ) else: captioned_path = cropped_path diff --git a/backend/services/content_generator.py b/backend/services/content_generator.py index 99b20c1..4abe9ed 100644 --- a/backend/services/content_generator.py +++ b/backend/services/content_generator.py @@ -1,5 +1,5 @@ """ -Per-clip content generation (titles, descriptions, tags) via AI CLI. +Per-clip content generation (titles, descriptions, tags). Single source of truth used by CLI, Web UI, and MCP. """ @@ -12,9 +12,18 @@ import threading from typing import Optional, Callable -from services.claude_suggest import _engine_label, _find_ai_cli_candidates, _run_ai_command +from config.paths import paths +from services import ai_provider from services.knowledge_base import load_kb_context as kb_load_context, warn_missing_context +# Codex silently truncates long prompts, so it gets a shortened one. The prompts +# here lead with the request precisely so this cut only costs transcript tail. +CODEX_PROMPT_LIMIT = 4000 + + +def _shorten_for_codex(engine: str, prompt: str) -> str: + return prompt[:CODEX_PROMPT_LIMIT] if engine == "codex" else prompt + CONTENT_KB_FILES = [ ("05-title-formulas.md", 3000), @@ -186,8 +195,7 @@ def generate_custom_content( Returns {"text", "engine"} with the raw model output, or None if no AI CLI. """ - candidates = _find_ai_cli_candidates() - if not candidates: + if not ai_provider.available(): return None kb_context = load_kb_context() @@ -210,37 +218,25 @@ def generate_custom_content( TRANSCRIPT EXCERPT: {excerpt}""" - project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - try: - for idx, (cli_path, engine) in enumerate(candidates): - label = _engine_label(engine) - if progress_callback: - progress_callback(30, f"Asking {label}..." if idx == 0 else f"Retrying with {label}...") - try: - cr = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt[:4000] if engine == "codex" else prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=120, - ) - except Exception as exc: - print(f"Warning: {label} content generation failed: {exc}", file=sys.stderr) - continue - if cr.returncode != 0 or not cr.stdout.strip(): - continue - if progress_callback: - progress_callback(100, "Done") - return {"text": cr.stdout.strip(), "engine": engine} + attempted: list[str] = [] + + def announce(label: str) -> None: + if progress_callback: + progress_callback(30, f"Asking {label}..." if not attempted else f"Retrying with {label}...") + attempted.append(label) + + result = ai_provider.generate( + prompt, + timeout=120, + on_attempt=announce, + adapt=_shorten_for_codex, + ) + if not result.ok: + print(f"Warning: content generation failed: {result.error}", file=sys.stderr) return None - finally: - try: - os.unlink(prompt_file) - except Exception as exc: - print(f"Warning: could not remove prompt file {prompt_file}: {exc}", file=sys.stderr) + if progress_callback: + progress_callback(100, "Done") + return {"text": result.text, "engine": result.provider} def generate_clip_content( @@ -262,13 +258,12 @@ def generate_clip_content( Returns: dict with raw_text, titles, description, tags, hashtags, or None if AI unavailable """ - candidates = _find_ai_cli_candidates() - if not candidates: + providers = ai_provider.status()["providers"] + if not providers: return None - label = _engine_label(candidates[0][1]) if progress_callback: - progress_callback(0, f"Generating content via {label}...") + progress_callback(0, f"Generating content via {providers[0]['label']}...") kb_context = load_kb_context(task="title and description generation") @@ -355,19 +350,23 @@ def generate_clip_content( project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) + def usable(text: str) -> bool: + parsed = _parse_content(text) + return bool(parsed["titles"] or parsed["description"]) - try: - for idx, (cli_path, engine) in enumerate(candidates): - label = _engine_label(engine) - if progress_callback: - if idx > 0: - progress_callback(0, f"Retrying content generation with {label}...") - progress_callback(30, f"Asking {label} for titles & descriptions...") + raw_text = None + engine_used = "" - raw_text = None - if engine == "claude" and partial_callback is not None: + # The Studio renders titles as they arrive. Only the Claude CLI can stream, + # so it gets first refusal; everything else falls through to the chain. + if partial_callback is not None: + cli_path = ai_provider.claude_cli_path() + if cli_path: + if progress_callback: + progress_callback(30, "Asking Claude for titles & descriptions...") + from utils.prompt_files import write_prompt_file + prompt_file = write_prompt_file(prompt) + try: raw_text = _stream_claude_content( cli_path=cli_path, prompt_file=prompt_file, @@ -375,42 +374,42 @@ def generate_clip_content( timeout=120, on_partial=partial_callback, ) - - if raw_text is None: + engine_used = "claude" + finally: try: - cr = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt[:4000] if engine == "codex" else prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=120, - ) - except subprocess.TimeoutExpired: - continue - except Exception: - continue - - if cr.returncode != 0 or not cr.stdout.strip(): - continue - raw_text = cr.stdout.strip() + os.unlink(prompt_file) + except OSError: + pass + if raw_text is None or not usable(raw_text): + attempted: list[str] = [] + + def announce(label: str) -> None: if progress_callback: - progress_callback(90, "Parsing content...") + if attempted: + progress_callback(0, f"Retrying content generation with {label}...") + progress_callback(30, f"Asking {label} for titles & descriptions...") + attempted.append(label) + + attempt = ai_provider.generate( + prompt, + timeout=120, + project_dir=project_dir, + on_attempt=announce, + adapt=_shorten_for_codex, + accept=usable, + ) + if not attempt.ok: + return None + raw_text, engine_used = attempt.text, attempt.provider - result = _parse_content(raw_text) - result["engine"] = engine - if not result["titles"] and not result["description"]: - continue + if progress_callback: + progress_callback(90, "Parsing content...") - if progress_callback: - progress_callback(100, f"Content ready ({len(result['titles'])} titles)") + result = _parse_content(raw_text) + result["engine"] = engine_used - return result + if progress_callback: + progress_callback(100, f"Content ready ({len(result['titles'])} titles)") - return None - finally: - try: - os.unlink(prompt_file) - except Exception: - pass + return result diff --git a/backend/services/env_settings.py b/backend/services/env_settings.py index fe4d6ca..cc3550b 100644 --- a/backend/services/env_settings.py +++ b/backend/services/env_settings.py @@ -143,7 +143,7 @@ def set_setting(key: str, value: str) -> None: if not value: raise ValueError("value is empty") if key in ("PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH"): - from services.claude_suggest import _resolve_cli_path + from services.ai_cli import _resolve_cli_path resolved = _resolve_cli_path(value) or (value if os.path.isfile(value) else None) if not resolved: raise ValueError(f"path does not exist: {value}") @@ -160,7 +160,7 @@ def unset_setting(key: str) -> None: def run_env_action(action: str, key: Optional[str] = None, value: Optional[str] = None) -> dict[str, Any]: act = (action or "list").strip().lower() if act == "list": - from services.claude_suggest import get_ai_cli_status + from services.ai_cli import get_ai_cli_status return { "settings": list_settings(), "path": os.path.abspath(_env_path()), diff --git a/backend/services/integrations/youtube/learnings.py b/backend/services/integrations/youtube/learnings.py index 829c603..049746e 100644 --- a/backend/services/integrations/youtube/learnings.py +++ b/backend/services/integrations/youtube/learnings.py @@ -129,13 +129,11 @@ def write_semantic_learnings(top_n: int = 4, min_total: int = 6) -> Optional[str top_performers, underperformers = ranked[:top_n], ranked[-top_n:] try: - from services.claude_suggest import _find_ai_cli_candidates, _run_ai_command + from services import ai_provider except Exception: return None - candidates = _find_ai_cli_candidates() - if not candidates: + if not ai_provider.available(): return None - cli_path, engine = candidates[0] prompt = ( "You analyze short-form video performance to guide future clip selection.\n" @@ -146,22 +144,10 @@ def write_semantic_learnings(top_n: int = 4, min_total: int = 6) -> Optional[str "the top performers from the underperformers (hooks, topic, emotional beat, structure) and give " "actionable guidance for picking future shorts. No preamble, just the bullets." ) - os.makedirs(paths["working"], exist_ok=True) - prompt_file = os.path.join(paths["working"], "_perf_analysis_prompt.txt") - with open(prompt_file, "w", encoding="utf-8") as f: - f.write(prompt) - try: - res = _run_ai_command(cli_path, engine, prompt, prompt_file, paths["project_root"], timeout=180) - except Exception: - return None - finally: - try: - os.unlink(prompt_file) - except Exception: - pass - text = (res.stdout or "").strip() - if not text: + result = ai_provider.generate(prompt, timeout=180, project_dir=paths["project_root"]) + if not result.ok: return None + text = result.text now = datetime.now(timezone.utc).strftime("%Y-%m-%d") block = f"{AI_START}\n## What separates top performers (AI analysis · {now})\n\n{text}\n{AI_END}" return write_learnings(ai_block=block) diff --git a/backend/services/local_reframe.py b/backend/services/local_reframe.py index 9a66728..ee04caa 100644 --- a/backend/services/local_reframe.py +++ b/backend/services/local_reframe.py @@ -29,7 +29,12 @@ def count_scene_cuts(video_path: str, threshold: float = 0.35) -> int: [ "ffmpeg", "-i", str(video_path), - "-filter:v", f"select='gt(scene,{threshold})',showinfo", + # Audio and subtitles are irrelevant to scene detection, and the + # scene score is a whole-frame statistic — computing it on a + # 320px-wide copy gives the same cuts for a fraction of the + # decode. Downscale before select so the filter sees small frames. + "-an", "-sn", + "-filter:v", f"scale=320:-2,select='gt(scene,{threshold})',showinfo", "-f", "null", "-", ], timeout=180, diff --git a/backend/services/podcli_cloud.py b/backend/services/podcli_cloud.py new file mode 100644 index 0000000..2e33d5a --- /dev/null +++ b/backend/services/podcli_cloud.py @@ -0,0 +1,356 @@ +"""Client for podcli Pro's hosted API. + +This module is the whole of Pro that lives in the open source app: where the +token is kept, how it is sent, and what shape the request takes. There is no +secret here and nothing to crack — the server decides entitlement, so a patched +client gets a UI that says Pro and an HTTP 401. +""" + +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Optional + +from config.paths import paths + +DEFAULT_API_URL = "https://api.podcli.com" +AUTH_FILENAME = "auth.json" + + +def api_url() -> str: + """ + The API base, restricted to http and https. + + urlopen honours whatever scheme it is given, so an unchecked value here + lets `file:` turn a local path into what the code treats as an API + response. + """ + raw = (os.environ.get("PODCLI_API_URL") or DEFAULT_API_URL).rstrip("/") + if urllib.parse.urlparse(raw).scheme not in ("http", "https"): + return DEFAULT_API_URL + return raw + + +def _auth_path() -> str: + return os.path.join(paths["home"], AUTH_FILENAME) + + +def read_token() -> Optional[str]: + """The session token, from the environment or the file `podcli login` wrote.""" + env = (os.environ.get("PODCLI_TOKEN") or "").strip() + if env: + return env + try: + with open(_auth_path(), encoding="utf-8") as fh: + token = (json.load(fh).get("token") or "").strip() + return token or None + except (OSError, ValueError): + return None + + +def _auth_data() -> dict: + try: + with open(_auth_path(), encoding="utf-8") as fh: + return json.load(fh) or {} + except (OSError, ValueError): + return {} + + +def _write_auth(data: dict) -> None: + os.makedirs(paths["home"], exist_ok=True) + path = _auth_path() + # Opened with the mode already set rather than chmod'd afterwards: the + # session token would otherwise be world-readable for the width of the + # write, and a file that already existed would keep its old mode until the + # chmod landed. + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(data, fh) + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def write_token(token: str, workspace_id: str = "") -> None: + _write_auth({"token": token, "workspace_id": workspace_id}) + + +PAID_PLANS = ("pro", "team", "studio", "agency") +PLAN_TTL_SECONDS = 6 * 3600 + + +def remember_plan(plan: str) -> None: + """Cache what the server last said this workspace is entitled to.""" + data = _auth_data() + if not data.get("token"): + return + data["plan"] = (plan or "").strip().lower() + data["plan_checked_at"] = time.time() + _write_auth(data) + + +def entitled() -> bool: + """ + False only when the workspace is known to have no subscription. + + Unknown and stale both mean "try": a subscription bought a minute ago has to + work without signing out first, and the server is the only real authority. + """ + data = _auth_data() + plan = (data.get("plan") or "").strip().lower() + if not plan: + return True + if time.time() - float(data.get("plan_checked_at") or 0) > PLAN_TTL_SECONDS: + return True + return plan in PAID_PLANS + + +def clear_token() -> None: + try: + os.unlink(_auth_path()) + except OSError: + pass + + +def signed_in() -> bool: + return read_token() is not None + + +class CloudError(Exception): + def __init__(self, message: str, status: int = 0, retryable: bool = False): + super().__init__(message) + self.status = status + self.retryable = retryable + + +def request(method: str, path: str, body: Optional[dict] = None, + timeout: int = 300) -> Any: + token = read_token() + if not token: + raise CloudError("not signed in — run `podcli login`", status=401) + + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request( + f"{api_url()}{path}", + data=data, + method=method, + headers={ + "authorization": f"Bearer {token}", + **({"content-type": "application/json"} if data else {}), + }, + ) + + try: + with urllib.request.urlopen(req, timeout=timeout) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else None + except urllib.error.HTTPError as exc: + detail, retryable = _describe(exc) + raise CloudError(detail, status=exc.code, retryable=retryable) from None + except urllib.error.URLError as exc: + raise CloudError(f"could not reach {api_url()}: {exc.reason}", + retryable=True) from None + + +def _describe(exc: urllib.error.HTTPError) -> tuple[str, bool]: + """Turn an HTTP failure into something a user can act on.""" + payload: dict = {} + try: + parsed = json.loads(exc.read().decode("utf-8")) + # A server can answer with a list or a bare string. Assuming an object + # turns the error path itself into an AttributeError. + if isinstance(parsed, dict): + payload = parsed + except Exception: + pass + detail = payload.get("error") + if isinstance(detail, list): + detail = "; ".join(str(item.get("message", item)) for item in detail) + + if exc.code == 401: + return ("podcli Pro session expired — run `podcli login` again", False) + if exc.code == 402: + return ("this workspace has no active podcli Pro subscription", False) + if exc.code == 403: + return (detail or "your role does not allow this", False) + if exc.code == 429: + used, cap = payload.get("used"), payload.get("cap") + if used is not None and cap is not None: + return (f"monthly limit reached ({used}/{cap} episodes)", False) + return (detail or "rate limited, try again shortly", True) + if exc.code >= 500 or exc.code == 503: + return (detail or "podcli Pro is temporarily unavailable", True) + return (detail or f"podcli Pro returned HTTP {exc.code}", False) + + +def generate(purpose: str, instruction: str, *, system: Optional[str] = None, + cached_context: Optional[str] = None, + episode_source_hash: Optional[str] = None, + max_tokens: int = 16000, timeout: int = 300) -> dict: + body: dict[str, Any] = { + "purpose": purpose, + "instruction": instruction, + "maxTokens": max_tokens, + } + if system: + body["system"] = system + if cached_context: + body["cachedContext"] = cached_context + if episode_source_hash: + body["episodeSourceHash"] = episode_source_hash + return request("POST", "/v1/ai/generate", body, timeout=timeout) + + +def source_hash(video_path: str) -> Optional[str]: + """Identify an episode across machines. + + Must stay byte-identical to the TypeScript implementation in + src/services/podcli-cloud.ts — the two clients hash the same file and the + server dedupes episodes on the result, so any divergence silently splits one + episode into two. First 8 MB only: distinctive enough, and digesting a 2 GB + master on every render is not. + """ + import hashlib + + digest = hashlib.sha256() + remaining = 8 * 1024 * 1024 + try: + with open(video_path, "rb") as fh: + while remaining > 0: + chunk = fh.read(min(1024 * 1024, remaining)) + if not chunk: + break + digest.update(chunk) + remaining -= len(chunk) + except OSError: + return None + return digest.hexdigest()[:32] + + +def register_clip(clip: dict) -> Optional[dict]: + return request("POST", "/v1/clips", clip, timeout=60) + + +def backfill_clips(limit: int = 200) -> tuple[int, int]: + """Push locally-recorded clips that never reached the workspace. + + Runs at sign-in so a new subscriber's back catalogue is behind the + performance model from their first session, rather than the model starting + empty and staying useless for months. + """ + from services.clips_history import load_clips_history, update_clip + + synced = failed = 0 + for entry in load_clips_history(): + if synced + failed >= limit: + break + if entry.get("cloud_id"): + continue + source = entry.get("source_video") + if not source or not os.path.exists(source): + continue + + digest = source_hash(source) + if not digest: + failed += 1 + continue + + try: + result = register_clip({ + "sourceHash": digest, + "episodeTitle": os.path.basename(source), + "title": entry.get("title"), + "startSecond": entry.get("start_second"), + "endSecond": entry.get("end_second"), + "durationSec": entry.get("duration"), + "contentType": entry.get("content_type"), + "captionStyle": entry.get("caption_style"), + "aspectRatio": entry.get("format"), + "transcriptSlice": entry.get("transcript_slice"), + }) + except CloudError as exc: + failed += 1 + # An expired session or a workspace with no subscription answers the + # same way for every remaining clip. Continuing would hash and + # upload another few hundred megabytes to be refused each time. + if exc.status in (401, 402, 403): + break + continue + + if result and result.get("id"): + update_clip(entry["id"], cloud_id=result["id"], cloud_synced=True) + synced += 1 + else: + failed += 1 + + return synced, failed + + +def prompt_block() -> str: + """What this workspace has learned, phrased for the selection prompt. + + Rendered server-side rather than assembled here, so improving how a + workspace's history is presented to the model is a deploy rather than + something that waits for every user to upgrade their CLI. + + Short timeout and silent on failure: better clips are the point, but not at + the cost of blocking a suggestion run behind a slow network. + """ + if not signed_in(): + return "" + try: + payload = request("GET", "/v1/insights/prompt-block", timeout=10) + except CloudError: + return "" + return (payload or {}).get("block") or "" + + +def list_workspaces() -> list[dict]: + return (request("GET", "/v1/workspaces", timeout=30) or {}).get("workspaces", []) + + +def create_workspace(name: str) -> dict: + payload = request("POST", "/v1/workspaces", {"name": name}, timeout=30) + write_token(payload["token"], payload["id"]) + return payload + + +def switch_workspace(workspace_id: str) -> dict: + """Switching means a new session, not a mutable field on the old one. + + Tenancy is decided once, at authentication, from the session's workspace — + so a token can never be pointed at a workspace it was not issued for. + """ + payload = request("POST", f"/v1/workspaces/{workspace_id}/session", {}, timeout=30) + write_token(payload["token"], payload["workspaceId"]) + return payload + + +def me() -> dict: + return request("GET", "/v1/auth/me", timeout=30) + + +def login(email: str, password: str) -> dict: + """Exchange credentials for a session token. Does not require an existing one.""" + data = json.dumps({"email": email, "password": password}).encode("utf-8") + req = urllib.request.Request( + f"{api_url()}/v1/auth/login", data=data, method="POST", + headers={"content-type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail, _ = _describe(exc) + raise CloudError(detail, status=exc.code) from None + except urllib.error.URLError as exc: + raise CloudError(f"could not reach {api_url()}: {exc.reason}") from None + + write_token(payload["token"], payload.get("workspaceId", "")) + return payload diff --git a/backend/services/silence_removal.py b/backend/services/silence_removal.py new file mode 100644 index 0000000..9fcc111 --- /dev/null +++ b/backend/services/silence_removal.py @@ -0,0 +1,481 @@ +"""Local full-episode silence analysis and rendering. + +Silero VAD (MIT, https://github.com/snakers4/silero-vad) finds speech without +uploading media. Transcript word ranges are +unioned with VAD output before cuts are planned, so known words are never cut. +The derived video and remapped transcript keep every downstream Podcli feature +on one compact timeline while the original source remains untouched. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import tempfile +import urllib.request +import uuid +import wave +from pathlib import Path +from typing import Callable, Iterable, Optional + +from config.paths import paths +from services.audio_extract import extract_wav_16k_mono +from services.media_probe import get_media_duration_seconds, has_audio_stream +from utils.proc import run as proc_run + +try: + import numpy as np + import onnxruntime as ort + _VAD_RUNTIME_AVAILABLE = True +except ImportError: + _VAD_RUNTIME_AVAILABLE = False + + +ProgressCallback = Optional[Callable[[int, str], None]] + +SILERO_MODEL_URL = ( + "https://raw.githubusercontent.com/snakers4/silero-vad/" + "76e3dc408eb2a5c655c34e230d2d5459b4439daa/" + "src/silero_vad/data/silero_vad_16k_op15.onnx" +) +SILERO_MODEL_SHA256 = "7ed98ddbad84ccac4cd0aeb3099049280713df825c610a8ed34543318f1b2c49" +SILERO_MODEL_FILENAME = "silero_vad_16k_op15.onnx" +SAMPLE_RATE = 16_000 +WINDOW_SAMPLES = 512 +CONTEXT_SAMPLES = 64 + + +def _emit(callback: ProgressCallback, percent: int, message: str) -> None: + if callback: + callback(max(0, min(100, int(percent))), message) + + +def _model_path() -> Path: + return Path(paths["cache"]) / "models" / SILERO_MODEL_FILENAME + + +def _sha256(file_path: Path) -> str: + digest = hashlib.sha256() + with file_path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def ensure_silero_model(progress_callback: ProgressCallback = None) -> Path: + """Return verified model path, downloading the 1.3 MB model once if needed.""" + model_path = _model_path() + if model_path.exists() and _sha256(model_path) == SILERO_MODEL_SHA256: + return model_path + + model_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = model_path.with_name(f".{model_path.name}.{uuid.uuid4().hex}.download") + _emit(progress_callback, 2, "Downloading local speech detector (first use only)") + try: + request = urllib.request.Request( + SILERO_MODEL_URL, + headers={"User-Agent": "podcli-silence-removal"}, + ) + with urllib.request.urlopen(request, timeout=60) as response, temp_path.open("wb") as target: + shutil.copyfileobj(response, target) + if _sha256(temp_path) != SILERO_MODEL_SHA256: + raise RuntimeError("Silero VAD model checksum mismatch") + os.replace(temp_path, model_path) + finally: + try: + temp_path.unlink() + except OSError: + pass + return model_path + + +def _merge_segments( + segments: Iterable[dict], + duration: float, + merge_gap: float = 0.0, +) -> list[dict]: + cleaned: list[dict] = [] + for raw in segments: + try: + start = max(0.0, min(duration, float(raw["start"]))) + end = max(0.0, min(duration, float(raw["end"]))) + except (KeyError, TypeError, ValueError): + continue + if end - start < 0.02: + continue + cleaned.append({"start": start, "end": end}) + cleaned.sort(key=lambda item: (item["start"], item["end"])) + + merged: list[dict] = [] + for segment in cleaned: + if merged and segment["start"] <= merged[-1]["end"] + merge_gap: + merged[-1]["end"] = max(merged[-1]["end"], segment["end"]) + else: + merged.append(dict(segment)) + return merged + + +def probabilities_to_speech_segments( + probabilities: list[float], + audio_samples: int, + *, + threshold: float = 0.5, + min_speech_ms: int = 250, + min_silence_ms: int = 180, +) -> list[dict]: + """Convert Silero probabilities into unpadded speech ranges.""" + negative_threshold = max(0.01, threshold - 0.15) + min_speech_samples = SAMPLE_RATE * min_speech_ms / 1000 + min_silence_samples = SAMPLE_RATE * min_silence_ms / 1000 + triggered = False + temporary_end = 0 + current_start = 0 + speech: list[dict] = [] + + for index, probability in enumerate(probabilities): + current_sample = index * WINDOW_SAMPLES + if probability >= threshold: + if not triggered: + triggered = True + current_start = current_sample + temporary_end = 0 + continue + + if triggered and probability < negative_threshold: + if not temporary_end: + temporary_end = current_sample + if current_sample - temporary_end >= min_silence_samples: + if temporary_end - current_start >= min_speech_samples: + speech.append({ + "start": current_start / SAMPLE_RATE, + "end": temporary_end / SAMPLE_RATE, + }) + triggered = False + temporary_end = 0 + + if triggered and audio_samples - current_start >= min_speech_samples: + speech.append({ + "start": current_start / SAMPLE_RATE, + "end": audio_samples / SAMPLE_RATE, + }) + return speech + + +def detect_speech( + video_path: str, + *, + threshold: float = 0.5, + progress_callback: ProgressCallback = None, +) -> list[dict]: + if not _VAD_RUNTIME_AVAILABLE: + raise RuntimeError("Local silence detection requires numpy and onnxruntime") + + model_path = ensure_silero_model(progress_callback) + _emit(progress_callback, 5, "Extracting episode audio") + wav_path = extract_wav_16k_mono(video_path) + try: + session = ort.InferenceSession(str(model_path), providers=["CPUExecutionProvider"]) + state = np.zeros((2, 1, 128), dtype=np.float32) + context = np.zeros((1, CONTEXT_SAMPLES), dtype=np.float32) + probabilities: list[float] = [] + + with wave.open(wav_path, "rb") as audio: + if audio.getframerate() != SAMPLE_RATE or audio.getnchannels() != 1 or audio.getsampwidth() != 2: + raise RuntimeError("Extracted audio is not 16 kHz mono PCM") + total_samples = audio.getnframes() + processed = 0 + last_percent = -1 + while True: + frames = audio.readframes(WINDOW_SAMPLES) + if not frames: + break + chunk = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0 + actual_samples = chunk.size + if actual_samples < WINDOW_SAMPLES: + chunk = np.pad(chunk, (0, WINDOW_SAMPLES - actual_samples)) + model_input = np.concatenate((context, chunk.reshape(1, -1)), axis=1) + output, state = session.run(None, { + "input": model_input, + "state": state, + "sr": np.array(SAMPLE_RATE, dtype=np.int64), + }) + probabilities.append(float(output[0][0])) + context = model_input[:, -CONTEXT_SAMPLES:] + processed += actual_samples + percent = 10 + int((processed / max(1, total_samples)) * 55) + if percent >= last_percent + 3: + last_percent = percent + _emit(progress_callback, percent, "Finding spoken sections") + + return probabilities_to_speech_segments(probabilities, total_samples, threshold=threshold) + finally: + try: + os.unlink(wav_path) + except OSError: + pass + + +def plan_silence_removal( + duration: float, + vad_segments: list[dict], + transcript_words: list[dict], + *, + min_silence_seconds: float = 0.65, + padding_seconds: float = 0.12, +) -> dict: + """Build conservative keep ranges from VAD plus transcript word timings.""" + duration = max(0.0, float(duration)) + min_silence_seconds = max(0.2, min(5.0, float(min_silence_seconds))) + padding_seconds = max(0.0, min(0.5, float(padding_seconds))) + if duration <= 0: + raise ValueError("Video duration must be positive") + + protected: list[dict] = [] + for segment in vad_segments: + protected.append({ + "start": float(segment.get("start", 0)) - padding_seconds, + "end": float(segment.get("end", 0)) + padding_seconds, + }) + for word in transcript_words: + try: + protected.append({ + "start": float(word["start"]) - padding_seconds, + "end": float(word["end"]) + padding_seconds, + }) + except (KeyError, TypeError, ValueError): + continue + + keep_segments = _merge_segments(protected, duration, merge_gap=min_silence_seconds) + if not keep_segments: + keep_segments = [{"start": 0.0, "end": duration}] + + removed_ranges: list[dict] = [] + cursor = 0.0 + for segment in keep_segments: + if segment["start"] - cursor >= min_silence_seconds: + removed_ranges.append({"start": cursor, "end": segment["start"]}) + elif cursor < segment["start"]: + segment["start"] = cursor + cursor = segment["end"] + if duration - cursor >= min_silence_seconds: + removed_ranges.append({"start": cursor, "end": duration}) + elif cursor < duration: + keep_segments[-1]["end"] = duration + + # Rebuild exact keep ranges as the complement of accepted removals. This + # prevents short leading/interstitial gaps from disappearing accidentally. + keep_segments = [] + cursor = 0.0 + for removed in removed_ranges: + if removed["start"] > cursor: + keep_segments.append({"start": cursor, "end": removed["start"]}) + cursor = removed["end"] + if cursor < duration: + keep_segments.append({"start": cursor, "end": duration}) + if not keep_segments: + keep_segments = [{"start": 0.0, "end": duration}] + + keep_segments = [ + {"start": round(item["start"], 3), "end": round(item["end"], 3)} + for item in keep_segments if item["end"] - item["start"] >= 0.04 + ] + removed_ranges = [ + {"start": round(item["start"], 3), "end": round(item["end"], 3)} + for item in removed_ranges + ] + output_duration = sum(item["end"] - item["start"] for item in keep_segments) + removed_duration = max(0.0, duration - output_duration) + return { + "keep_segments": keep_segments, + "removed_ranges": removed_ranges, + "source_duration": round(duration, 3), + "output_duration": round(output_duration, 3), + "removed_duration": round(removed_duration, 3), + "removed_percent": round((removed_duration / duration) * 100, 1), + "cut_count": len(removed_ranges), + "min_silence_seconds": min_silence_seconds, + "padding_seconds": padding_seconds, + "method": "silero-vad+word-boundaries", + } + + +def analyze_silence( + video_path: str, + transcript_words: list[dict], + *, + threshold: float = 0.5, + min_silence_seconds: float = 0.65, + padding_seconds: float = 0.12, + progress_callback: ProgressCallback = None, +) -> dict: + duration = get_media_duration_seconds(video_path) + if duration <= 0: + raise RuntimeError("Could not determine episode duration") + speech = detect_speech(video_path, threshold=threshold, progress_callback=progress_callback) + _emit(progress_callback, 75, "Protecting word boundaries") + plan = plan_silence_removal( + duration, + speech, + transcript_words, + min_silence_seconds=min_silence_seconds, + padding_seconds=padding_seconds, + ) + plan["vad_threshold"] = threshold + _emit(progress_callback, 100, "Silence analysis ready") + return plan + + +def _map_range(start: float, end: float, keep_segments: list[dict]) -> Optional[tuple[float, float]]: + output_cursor = 0.0 + mapped_parts: list[tuple[float, float]] = [] + for segment in keep_segments: + overlap_start = max(start, segment["start"]) + overlap_end = min(end, segment["end"]) + if overlap_end > overlap_start: + mapped_parts.append(( + output_cursor + overlap_start - segment["start"], + output_cursor + overlap_end - segment["start"], + )) + output_cursor += segment["end"] - segment["start"] + if not mapped_parts: + return None + return mapped_parts[0][0], mapped_parts[-1][1] + + +def remap_timed_items(items: list[dict], keep_segments: list[dict]) -> list[dict]: + remapped: list[dict] = [] + for item in items: + try: + start = float(item["start"]) + end = float(item["end"]) + except (KeyError, TypeError, ValueError): + continue + mapped = _map_range(start, end, keep_segments) + if not mapped or mapped[1] - mapped[0] < 0.01: + continue + remapped.append({**item, "start": round(mapped[0], 3), "end": round(mapped[1], 3)}) + return remapped + + +def remap_transcript(transcript: dict, keep_segments: list[dict]) -> dict: + remapped = dict(transcript or {}) + remapped["words"] = remap_timed_items(list(remapped.get("words") or []), keep_segments) + remapped["segments"] = remap_timed_items(list(remapped.get("segments") or []), keep_segments) + remapped["duration"] = round(sum(s["end"] - s["start"] for s in keep_segments), 3) + remapped["silence_removed"] = True + return remapped + + +def _reserve_output_path(video_path: str, output_dir: str) -> Path: + stem = Path(video_path).stem + for suffix in range(1, 10_000): + name = f"{stem}_silence_removed_podcli.mp4" if suffix == 1 else f"{stem}_silence_removed_podcli-{suffix}.mp4" + candidate = Path(output_dir) / name + if not candidate.exists(): + return candidate + raise RuntimeError("Could not reserve silence-removed output filename") + + +def _render_batch( + video_path: str, + output_path: str, + segments: list[dict], + audio: bool, +) -> None: + filters: list[str] = [] + concat_inputs: list[str] = [] + for index, segment in enumerate(segments): + start = segment["start"] + end = segment["end"] + filters.append(f"[0:v:0]trim=start={start:.3f}:end={end:.3f},setpts=PTS-STARTPTS[v{index}]") + concat_inputs.append(f"[v{index}]") + if audio: + filters.append(f"[0:a:0]atrim=start={start:.3f}:end={end:.3f},asetpts=PTS-STARTPTS[a{index}]") + concat_inputs.append(f"[a{index}]") + filters.append( + "".join(concat_inputs) + + f"concat=n={len(segments)}:v=1:a={1 if audio else 0}[vout]" + + ("[aout]" if audio else "") + ) + command = [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", video_path, + "-filter_complex", ";".join(filters), + "-map", "[vout]", + ] + if audio: + command += ["-map", "[aout]"] + command += [ + "-c:v", "libx264", "-crf", "18", "-preset", "fast", "-profile:v", "high", + "-pix_fmt", "yuv420p", + ] + if audio: + command += ["-c:a", "aac", "-b:a", "192k"] + command += ["-movflags", "+faststart", output_path] + proc_run(command, timeout=3600, check=True) + + +def render_silence_removed( + video_path: str, + keep_segments: list[dict], + transcript: dict, + output_dir: str, + *, + progress_callback: ProgressCallback = None, +) -> dict: + if not os.path.exists(video_path): + raise FileNotFoundError(f"Video not found: {video_path}") + duration = get_media_duration_seconds(video_path) + normalized = _merge_segments(keep_segments, duration) + if not normalized: + raise ValueError("No valid speech segments to render") + + Path(output_dir).mkdir(parents=True, exist_ok=True) + output_path = _reserve_output_path(video_path, output_dir) + work_dir = Path(tempfile.mkdtemp(prefix="podcli_silence_", dir=paths["working"] if os.path.isdir(paths["working"]) else None)) + batch_size = 80 + chunks: list[Path] = [] + audio = has_audio_stream(video_path) + try: + batches = [normalized[index:index + batch_size] for index in range(0, len(normalized), batch_size)] + for index, batch in enumerate(batches): + _emit(progress_callback, 5 + int((index / len(batches)) * 85), f"Building compact episode {index + 1}/{len(batches)}") + chunk_path = work_dir / f"chunk-{index:04d}.mp4" + _render_batch(video_path, str(chunk_path), batch, audio) + chunks.append(chunk_path) + + partial = work_dir / "finished.mp4" + if len(chunks) == 1: + shutil.copy2(chunks[0], partial) + else: + concat_path = work_dir / "chunks.txt" + concat_path.write_text("".join(f"file '{chunk.as_posix()}'\n" for chunk in chunks), encoding="utf-8") + proc_run([ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-f", "concat", "-safe", "0", "-i", str(concat_path), + "-c", "copy", "-movflags", "+faststart", str(partial), + ], timeout=1800, check=True) + # Falls back to copy+delete: the work dir and a user-configured output + # dir can sit on different volumes, where os.replace raises EXDEV. + shutil.move(str(partial), str(output_path)) + _emit(progress_callback, 96, "Remapping captions and clips") + remapped = remap_transcript(transcript, normalized) + manifest = output_path.with_suffix(".silence.json") + manifest.write_text(json.dumps({ + "source_video": os.path.abspath(video_path), + "output_video": str(output_path), + "keep_segments": normalized, + }, ensure_ascii=False, indent=2), encoding="utf-8") + _emit(progress_callback, 100, "Compact episode ready") + stat = output_path.stat() + return { + "output_path": str(output_path), + "filename": output_path.name, + "file_size_mb": round(stat.st_size / (1024 * 1024), 2), + "duration": remapped["duration"], + "transcript": remapped, + "manifest_path": str(manifest), + } + finally: + shutil.rmtree(work_dir, ignore_errors=True) diff --git a/backend/services/thumbnail_ai.py b/backend/services/thumbnail_ai.py index d9fb2fd..3bf2114 100644 --- a/backend/services/thumbnail_ai.py +++ b/backend/services/thumbnail_ai.py @@ -480,40 +480,15 @@ def _extract_json(text: str): def _ask_ai_for_json(prompt: str, timeout: int = 30): - """Run the first available AI CLI on `prompt`, returning the first JSON value - it emits, or None if no CLI is available or none returns parseable JSON.""" - from services.claude_suggest import _find_ai_cli_candidates, _run_ai_command - - candidates = _find_ai_cli_candidates() - if not candidates: - return None - - prompt_file = None - try: - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - for cli_path, engine in candidates: - try: - result = _run_ai_command( - cli_path=cli_path, engine=engine, prompt=prompt, - prompt_file=prompt_file, project_dir=project_dir, timeout=timeout, - ) - except Exception as e: - log_event("thumbnail-ai", "ai cli failed", level="warn", engine=engine, err=e) - continue - if result.returncode != 0 or not result.stdout.strip(): - continue - parsed = _extract_json(result.stdout) - if parsed is not None: - return parsed - finally: - if prompt_file: - try: - os.unlink(prompt_file) - except Exception: - pass - return None + """Run `prompt` through the AI provider chain, returning the first JSON value + it emits, or None if nothing is available or nothing returns parseable JSON.""" + from services import ai_provider + + parsed, result = ai_provider.generate_json(prompt, timeout=timeout) + if parsed is None: + log_event("thumbnail-ai", "ai request failed", level="warn", + err=result.error, tried=", ".join(result.attempts)) + return parsed def _thumbnail_kb_context() -> str: diff --git a/backend/services/transcription_whispercpp.py b/backend/services/transcription_whispercpp.py index de32502..6d3548c 100644 --- a/backend/services/transcription_whispercpp.py +++ b/backend/services/transcription_whispercpp.py @@ -16,6 +16,29 @@ _SPECIAL = re.compile(r"^\[.*\]$") # [_BEG_], [_TT_...], etc. +# whisper.cpp's WHISPER_AHEADS_* presets. The alignment heads are per-architecture: +# passing a preset whose layer/head indices exceed the loaded model's dimensions +# aborts whisper-cli with exit 3, so the preset must track the model, not a default. +_DTW_PRESETS = { + "tiny", "tiny.en", "base", "base.en", "small", "small.en", + "medium", "medium.en", "large.v1", "large.v2", "large.v3", "large.v3-turbo", +} +_QUANT_SUFFIX = re.compile(r"-(?:q\d+(?:_\d+)?(?:_?[a-z]+)*|f16|f32)$", re.IGNORECASE) + + +def _dtw_preset_for_model(model_path: str) -> Optional[str]: + name = os.path.basename(model_path) + for ext in (".bin", ".gguf"): + if name.lower().endswith(ext): + name = name[: -len(ext)] + break + if name.lower().startswith("ggml-"): + name = name[5:] + name = _QUANT_SUFFIX.sub("", name).lower() + if name.startswith("large-v"): + name = "large." + name[len("large-"):] + return name if name in _DTW_PRESETS else None + def _extract_wav(media_path: str, wav_path: str, ffmpeg: str = "ffmpeg") -> None: subprocess.run( @@ -142,7 +165,7 @@ def transcribe_file( whisper_cli: str = "whisper-cli", ffmpeg: str = "ffmpeg", language: Optional[str] = "en", - dtw_model: str = "base", + dtw_model: Optional[str] = None, threads: int = 4, vad: bool = False, vad_model: Optional[str] = None, @@ -165,8 +188,9 @@ def transcribe_file( cmd = [whisper_cli, "-m", model_path, "-f", wav, "-ojf", "-of", out_base, "-t", str(threads)] - if dtw_model: - cmd += ["-dtw", dtw_model] + dtw = dtw_model if dtw_model is not None else _dtw_preset_for_model(model_path) + if dtw: + cmd += ["-dtw", dtw] if vad and vad_model and os.path.exists(vad_model): # VAD removes the trailing-words-into-silence failure mode but adds a # systematic early bias (silence-removal remapping). Off by default; diff --git a/backend/services/video_processor.py b/backend/services/video_processor.py index 7256662..65be64b 100644 --- a/backend/services/video_processor.py +++ b/backend/services/video_processor.py @@ -9,11 +9,13 @@ import subprocess import json import math +import time +from concurrent.futures import ThreadPoolExecutor from typing import Optional from services.encoder import get_video_encode_flags from utils.proc import run as proc_run, ProcError -from utils.log import log_event +from utils.log import log_event, timed from services import media_probe from services.media_probe import ( CPU_FLAGS, @@ -980,6 +982,118 @@ def _face_sample_indices(total_frames: int, fps: float) -> list[int]: return indices +# Memory ceiling for one batch of decoded frames awaiting detection. Sized so +# a 1080p clip batches ~40 frames and a 4K clip ~10 — enough to keep the pool +# busy without holding a whole clip of decoded frames in RAM. +_FACE_BATCH_BYTES = 256 * 1024 * 1024 + + +# Each extra worker needs its own cv2.FaceDetectorYN, which costs ~21ms to +# construct against ~4.5ms per detection. Below this many frames per worker the +# construction never pays for itself and the pool makes short clips *slower*, +# so the pool is sized by workload rather than by core count alone. +_FACE_FRAMES_PER_WORKER = 32 + + +def _face_detect_workers(sample_count: int = 0) -> int: + """Thread count for YuNet inference during face sampling. + + PODCLI_FACE_WORKERS overrides and is taken literally; 1 disables the pool + entirely, which is the first thing to try when bisecting a framing + regression. Otherwise the count is capped both by cores and by how many + frames there are to share out — a 2s clip stays serial. + """ + raw = os.environ.get("PODCLI_FACE_WORKERS", "").strip() + if raw: + try: + return max(1, int(raw)) + except ValueError: + pass + by_cores = min(12, os.cpu_count() or 4) + by_work = sample_count // _FACE_FRAMES_PER_WORKER + return max(1, min(by_cores, by_work)) + + +def _detect_batch(batch: list, detectors: list, width: int, height: int, executor) -> list: + """Detect faces across a batch of (time, frame) pairs, input order preserved. + + Each worker owns one detector and walks its own stride of the batch, so no + cv2.FaceDetectorYN instance is ever touched by two threads — they carry + per-instance input-size state and are not thread-safe. Striding (rather + than a shared work queue) also makes the assignment deterministic. + """ + # Imported here, like every other cv2 dependency in this module, so the + # module stays importable without OpenCV installed. + from services.face_detector import detect_faces + + if executor is None or len(detectors) < 2 or len(batch) < 2: + det = detectors[0] + return [(t, detect_faces(det, frame, width, height)) for t, frame in batch] + + n = len(detectors) + + def _stride(k: int) -> list: + det = detectors[k] + return [ + (i, (batch[i][0], detect_faces(det, batch[i][1], width, height))) + for i in range(k, len(batch), n) + ] + + merged: dict = {} + for part in executor.map(_stride, range(n)): + merged.update(part) + return [merged[i] for i in range(len(batch))] + + +def _dump_crop_path( + *, + input_path: str, + keyframes_x: list, + crop_w: int, + crop_h: int, + crop_y: int, + width: int, + height: int, + detections: list, + segment_tracks: list, + has_any_split: bool, +) -> None: + """Write the computed camera path to PODCLI_CROP_DUMP/.json. + + No-op unless the env var is set, and never raises: this is diagnostics and + must not be able to fail a render. Frame-level detections are summarised + rather than dumped whole — the camera path is what a regression would move. + """ + dump_dir = os.environ.get("PODCLI_CROP_DUMP") + if not dump_dir: + return + try: + os.makedirs(dump_dir, exist_ok=True) + stem = os.path.splitext(os.path.basename(input_path))[0] + payload = { + "source": os.path.basename(input_path), + "source_dims": [width, height], + "crop": {"w": crop_w, "h": crop_h, "y": crop_y}, + "has_any_split": bool(has_any_split), + "detection_frames": len(detections), + "frames_with_faces": sum(1 for _, faces in detections if faces), + "segment_tracks": [ + # (start, end, speaker, track_id, ...) — keep the framing- + # relevant fields, drop anything unhashable/verbose. + {"start": round(s[0], 3), "end": round(s[1], 3), + "speaker": s[2], "track_id": s[3]} + for s in segment_tracks + ], + "keyframes_x": [[t, x] for t, x in keyframes_x], + } + out = os.path.join(dump_dir, f"{stem}.crop.json") + with open(out, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, sort_keys=True) + log_event("crop", "dumped-path", file=out, keyframes=len(keyframes_x)) + except Exception as exc: # diagnostics must never break a render + log_event("crop", "dump-failed", level="warn", error=str(exc)) + + def _track_and_crop( input_path: str, output_path: str, @@ -1032,21 +1146,78 @@ def _track_and_crop( sample_indices = _face_sample_indices(total_frames, fps) detections = [] # [(time, faces), ...] + # This loop is inference-bound, not decode-bound: on a 60s 1080p clip YuNet + # is ~83% of the time against ~17% for decode. So frames are decoded + # serially (cheap, and it keeps frame indices exact) and detected on a small + # pool. Detections are independent per frame and OpenCV releases the GIL + # inside YuNet, so results are bit-identical; measured 3281ms -> 1179ms for + # the stage, with detection itself going 2718ms -> 594ms. + # + # Batches are bounded by bytes rather than frame count so a 4K source does + # not hold an unbounded number of decoded frames in memory at once. + frame_bytes = max(1, width * height * 3) + batch_limit = max(1, int(_FACE_BATCH_BYTES / frame_bytes)) + + decode_ns = 0 + detect_ns = 0 next_pos = 0 frame_idx = -1 - while next_pos < len(sample_indices): - if not cap.grab(): - break - frame_idx += 1 - if frame_idx < sample_indices[next_pos]: - continue - next_pos += 1 - ret, frame = cap.retrieve() - if not ret: - continue - t = frame_idx / fps - faces = detect_faces(detector, frame, width, height) - detections.append((t, faces)) + batch: list = [] + + # Detector construction is inside the timed block on purpose: at ~21ms per + # instance it is a real cost, and timing only the loop would hide it. + with timed("crop", "face_sampling", frames=total_frames) as t_fields: + workers = _face_detect_workers(len(sample_indices)) + detectors = [detector] + if workers > 1: + detectors += [ + d for d in (create_detector(width, height) for _ in range(workers - 1)) + if d is not None + ] + + executor = None + try: + if len(detectors) > 1: + executor = ThreadPoolExecutor(max_workers=len(detectors)) + + def _flush() -> None: + nonlocal detect_ns, batch + if not batch: + return + _t = time.perf_counter_ns() + detections.extend( + _detect_batch(batch, detectors, width, height, executor) + ) + detect_ns += time.perf_counter_ns() - _t + batch = [] + + while next_pos < len(sample_indices): + _t0 = time.perf_counter_ns() + grabbed = cap.grab() + decode_ns += time.perf_counter_ns() - _t0 + if not grabbed: + break + frame_idx += 1 + if frame_idx < sample_indices[next_pos]: + continue + next_pos += 1 + _t0 = time.perf_counter_ns() + ret, frame = cap.retrieve() + decode_ns += time.perf_counter_ns() - _t0 + if not ret: + continue + batch.append((frame_idx / fps, frame)) + if len(batch) >= batch_limit: + _flush() + _flush() + finally: + if executor is not None: + executor.shutdown(wait=True) + + t_fields["samples"] = len(detections) + t_fields["workers"] = len(detectors) + t_fields["decode_ms"] = decode_ns // 1_000_000 + t_fields["detect_ms"] = detect_ns // 1_000_000 cap.release() @@ -1515,6 +1686,22 @@ def _nearest_face_cx(t_target: float, window: float = 1.5) -> float | None: validated.append((kf_t, kf_x)) keyframes_x = validated + # ── Optional crop-path dump (regression harness) ───────────── + # Crop decisions are the least testable part of the pipeline: unit tests + # cover the helper math, and the e2e render uses a synthetic video with no + # faces in it. With PODCLI_CROP_DUMP set to a directory, every crop writes + # its computed camera path as JSON, so a change that was meant to be purely + # a speed optimization can be proven not to have moved the camera. + _dump_crop_path( + input_path=input_path, + keyframes_x=keyframes_x, + crop_w=crop_w, crop_h=crop_h, crop_y=crop_y, + width=width, height=height, + detections=detections, + segment_tracks=segment_tracks, + has_any_split=has_any_split, + ) + # ── Build FFmpeg filter ────────────────────────────────────── if not keyframes_x: crop_x = max(0, (width - crop_w) // 2) diff --git a/backend/utils/log.py b/backend/utils/log.py index 7635f25..d43758b 100644 --- a/backend/utils/log.py +++ b/backend/utils/log.py @@ -9,9 +9,13 @@ import os import sys +import time +from contextlib import contextmanager _VERBOSE = os.environ.get("PODCLI_LOG_VERBOSE", "").lower() in ("1", "true", "yes") +_RESERVED_FIELDS = frozenset({"category", "message", "level", "stage", "ms"}) + def log_event(category: str, message: str, *, level: str = "info", **fields) -> None: """Emit one structured line: `[category] message k=v k=v`. @@ -40,3 +44,31 @@ def warn(category: str, message: str, **fields) -> None: def debug(category: str, message: str, **fields) -> None: log_event(category, message, level="debug", **fields) + + +@contextmanager +def timed(category: str, stage: str, **fields): + """Time a block and emit `[category] timing stage=... ms=...` on exit. + + Debug level, so stage timings stay silent unless PODCLI_LOG_VERBOSE is set. + Emits on failure too — a stage that blows up after 40s is exactly the one + worth seeing. Yields a dict the caller can add fields to before the line is + written, for counts that are only known once the block has run. + """ + extra: dict = {} + start = time.perf_counter() + try: + yield extra + finally: + elapsed_ms = int((time.perf_counter() - start) * 1000) + # A caller field named like a log_event parameter would raise TypeError + # here and mask whatever exception is already unwinding. + payload = { + key: value + for key, value in {**fields, **extra}.items() + if key not in _RESERVED_FIELDS + } + log_event( + category, "timing", level="debug", + stage=stage, ms=elapsed_ms, **payload, + ) diff --git a/cli/VERSION b/cli/VERSION index 437459c..e70b452 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -2.5.0 +2.6.0 diff --git a/cli/internal/engine/engine.go b/cli/internal/engine/engine.go index 722be67..d6200a3 100644 --- a/cli/internal/engine/engine.go +++ b/cli/internal/engine/engine.go @@ -151,6 +151,34 @@ func MCPServer() string { return "" } +func SyncScript() string { + p := filepath.Join(paths.RuntimeDir(), "studio", "sync.mjs") + if exists(p) { + return p + } + return "" +} + +// RunSync reconciles this machine with the podcli Pro workspace. Ships with the +// studio bundle because the sync logic lives on the TypeScript side, alongside +// the clip history and asset registry it reconciles. +func RunSync() (int, error) { + node, script := Node(), SyncScript() + if node == "" || script == "" { + return 1, fmt.Errorf("sync not provisioned — run `podcli setup`") + } + cmd := exec.Command(node, script) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.Env = nodeEnv() + if err := cmd.Run(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode(), nil + } + return 1, err + } + return 0, nil +} + // nodeEnv builds the env a bundled Node server (studio/MCP) needs: the TS // paths.ts reads these names (note PYTHON_PATH/FFMPEG_PATH differ from the // PODCLI_* names the Python side uses). Project data stays cwd-local. diff --git a/cli/main.go b/cli/main.go index a6ba4ef..304311e 100644 --- a/cli/main.go +++ b/cli/main.go @@ -54,6 +54,12 @@ func main() { fmt.Fprintln(os.Stderr, "podcli:", err) } os.Exit(code) + case "sync": + code, err := engine.RunSync() + if err != nil { + fmt.Fprintln(os.Stderr, "podcli:", err) + } + os.Exit(code) case "config": if len(args) >= 2 && (args[1] == "get" || args[1] == "set") { os.Exit(configCmd(args[1:])) @@ -772,6 +778,9 @@ PodStack commands (run inside Claude Code / Codex): retro-episode Add --codex / --claude to pick the agent Launcher commands: + login | logout | whoami + podcli Pro account on this machine + sync Reconcile clips, assets, and knowledge with your workspace doctor Show resolved paths, interpreter, backend, ffmpeg, models version Print version update Check for and apply a newer release diff --git a/docs/configuration.md b/docs/configuration.md index ba10292..f72a629 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,6 +18,8 @@ Copy `.env.example` to `.env`, or export these in your shell. `setup.sh` copies | `PODCLI_BACKEND` | resolved | Override the Python backend directory | | `PODCLI_PYTHON` | resolved | Override the Python interpreter. The launcher exports the resolved path as `PYTHON_PATH` for internal use | | `PODCLI_TRANSITION_AUTOFIX_PASSES` | auto | Transition QA/autofix passes. Runs only on reframes that can produce hard cuts. Set a number to force it, `0` disables; the renderer caps it at 2 | +| `PODCLI_FACE_WORKERS` | auto | Threads used for face detection during reframing. Auto sizes by core count (max 12) and by clip length, so short clips stay serial rather than paying to build detectors they cannot amortize. Set `1` to force serial — the first thing to try when bisecting a framing regression, since it changes speed only, never the result | +| `PODCLI_CROP_DUMP` | unset | Directory to write each clip's computed camera path to as `.crop.json`. Diagnostics only; use it to diff framing decisions before and after a change | | `FFMPEG_PATH` / `FFPROBE_PATH` | `ffmpeg` / `ffprobe` | Override the FFmpeg binaries | Installed builds provision their own Python, Node, FFmpeg, and whisper.cpp, so the diff --git a/package-lock.json b/package-lock.json index 0d6e8c0..8fa1159 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,18 +9,18 @@ "version": "2.5.0", "license": "AGPL-3.0-only", "dependencies": { - "@fontsource/dm-sans": "^5.2.8", - "@modelcontextprotocol/sdk": "^1.29.0", - "@remotion/bundler": "^4.0.490", - "@remotion/cli": "^4.0.490", + "@fontsource/dm-sans": "^5.3.0", + "@modelcontextprotocol/sdk": "^1.30.0", + "@remotion/bundler": "^4.0.500", + "@remotion/cli": "^4.0.500", "@remotion/renderer": "^4.0.490", "dotenv": "^17.4.2", "express": "^5.2.1", - "lucide-react": "^1.25.0", + "lucide-react": "^1.27.0", "multer": "^2.2.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-router-dom": "^6.30.4", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", "remotion": "^4.0.490", "uuid": "^14.0.1", "winston": "^3.17.0", @@ -37,7 +37,7 @@ "tsx": "^4.23.1", "typescript": "^7.0.2", "vite": "^5.4.21", - "vitest": "^2.1.9" + "vitest": "^4.1.10" }, "engines": { "node": ">=18.0.0" @@ -47,7 +47,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -62,7 +61,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -72,7 +70,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -103,7 +100,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -119,7 +115,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -129,7 +124,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -146,7 +140,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -162,7 +155,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.29.7", @@ -179,7 +171,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -189,7 +180,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -199,14 +189,12 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, "license": "ISC" }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -216,7 +204,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -230,7 +217,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -276,7 +262,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -286,7 +271,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", @@ -344,7 +328,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -359,7 +342,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -375,7 +357,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -394,7 +375,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -410,7 +390,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -888,9 +867,9 @@ } }, "node_modules/@fontsource/dm-sans": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", - "integrity": "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.3.0.tgz", + "integrity": "sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -922,7 +901,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1004,12 +982,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -1108,31 +1086,32 @@ "@tybys/wasm-util": "^0.10.1" } }, - "node_modules/@remix-run/router": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", - "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=14.0.0" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@remotion/bundler": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.490.tgz", - "integrity": "sha512-AZe7ncD+b0OUap5ZT8T0boONa7+I7Ap4+kAh5BZOa5sVaBjz4YI7eqILXtGqi+kNuBsG6pAZ4v4a2AW2v9afaw==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.500.tgz", + "integrity": "sha512-8GY3spQ+V4AngxJkCHM7E1nDyHzIbPAK8QsYfAN57ywL/oDkFInmd65rKYRLH2di5iiT+pO1z0RAXiC5h8FHwQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@remotion/media-parser": "4.0.490", - "@remotion/studio": "4.0.490", - "@remotion/studio-shared": "4.0.490", - "@remotion/timeline-utils": "4.0.490", + "@remotion/media-parser": "4.0.500", + "@remotion/studio": "4.0.500", + "@remotion/studio-shared": "4.0.500", + "@remotion/timeline-utils": "4.0.500", "@rspack/core": "1.7.11", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "7.1.4", "esbuild": "0.28.1", "react-refresh": "0.18.0", - "remotion": "4.0.490", + "remotion": "4.0.500", "style-loader": "4.0.0", "webpack": "5.105.0" }, @@ -1142,13 +1121,13 @@ } }, "node_modules/@remotion/canvas-capture": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/canvas-capture/-/canvas-capture-4.0.490.tgz", - "integrity": "sha512-P9gCQ//7F0elQamT09eyoFobWsaHO0MMSqlA1ZAwy3IyL5/NQ1Rak8G7PJybucIuO0ISZxNF1Hkkb5tkWhfh4w==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/canvas-capture/-/canvas-capture-4.0.500.tgz", + "integrity": "sha512-aIhCwlXm+yMUcz1uG3mKW+daYUQpkpVUqroMeXgkXxEUO4PNGsTb0z/DiCviaWkihN+UYrBM4vMHyIebMAKVOA==", "license": "Remotion License", "dependencies": { "mediabunny": "1.50.8", - "remotion": "4.0.490" + "remotion": "4.0.500" }, "peerDependencies": { "react": ">=16.8.0", @@ -1156,22 +1135,22 @@ } }, "node_modules/@remotion/cli": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.490.tgz", - "integrity": "sha512-S9H/EqWVJNx9nQc7pFMsg2TT6JrPfy1M2RhcrvyIKvQOxP3cV3dy/FqOMtM1JSUo+jXQ9/tyX757QMBoUMwVnA==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.500.tgz", + "integrity": "sha512-WH21tlWc2QSjOs/ID9FiIa/t3dptW+oVFLUqdxXRVyqo3Bg2z74n+oD55ntCJSfSokKkFB2hwjZUDz64X4X0EQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@remotion/bundler": "4.0.490", - "@remotion/media-utils": "4.0.490", - "@remotion/player": "4.0.490", - "@remotion/renderer": "4.0.490", - "@remotion/studio": "4.0.490", - "@remotion/studio-server": "4.0.490", - "@remotion/studio-shared": "4.0.490", + "@remotion/bundler": "4.0.500", + "@remotion/media-utils": "4.0.500", + "@remotion/player": "4.0.500", + "@remotion/renderer": "4.0.500", + "@remotion/studio": "4.0.500", + "@remotion/studio-server": "4.0.500", + "@remotion/studio-shared": "4.0.500", "dotenv": "17.3.1", "minimist": "1.2.6", "prompts": "2.4.2", - "remotion": "4.0.490" + "remotion": "4.0.500" }, "bin": { "remotion": "remotion-cli.js", @@ -1202,9 +1181,9 @@ "license": "MIT" }, "node_modules/@remotion/compositor-darwin-arm64": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.490.tgz", - "integrity": "sha512-llil+sp64oMTJvYBTpB7TuZkdpnhihmUzt44g5tQKTlS2KPN4LPMxXnQ+BiEz/4mEt8TnQhhZ3u13jJIdJyHcg==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.500.tgz", + "integrity": "sha512-wt+3mtivTcfQzq6t+piMMGf8jeCZxR4FzbIXxD7Hi3TB6D7Ivf6uJAXfuqym+m6Bei+Rgi/1WZbhM/jTdPz+lw==", "cpu": [ "arm64" ], @@ -1214,9 +1193,9 @@ ] }, "node_modules/@remotion/compositor-darwin-x64": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.490.tgz", - "integrity": "sha512-jpjZ8H6uCj2dW8JXViQGQXQoZ3iKy9041tjZKghPlLbgTh9G6eWdi8RmJ9W9+svI7Q3GTh6cjUQrQn/9pgpyMA==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.500.tgz", + "integrity": "sha512-zK8biM14FISxjCJYOkyhvTk/5Aa6d5pmmCCh4zzjQeAOdJOWo9DytQ2SN/Uyi4UO7Usa6S+XoJjc9DhHFh62kA==", "cpu": [ "x64" ], @@ -1226,57 +1205,69 @@ ] }, "node_modules/@remotion/compositor-linux-arm64-gnu": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.490.tgz", - "integrity": "sha512-i0nffOg4gq3z/pbMvl++l92BgN2NHXIsZPm+E6s3ksC4ZuU8G5DGgqt4gVKIDijRJUCxTUbQ8DdR390dPhrkfg==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.500.tgz", + "integrity": "sha512-atzS9pdEDEUu1IyjFhVKYnuUHhASnlVamwmCKTYX65kvZrFd+hKcrwNsEHnGJglqoMKBwKhUFps2FLmrfCRTgA==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" ] }, "node_modules/@remotion/compositor-linux-arm64-musl": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.490.tgz", - "integrity": "sha512-xciEuhXb0NShigei+n8G0xrt/8g2jxmnSa6xQsIvwSee1kh1/fF4itpDKoi3pIMSUYkYMdN6GUdqqKXs16hkRw==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.500.tgz", + "integrity": "sha512-WRH7WAH/1T9Zi/dnd1B/BpvWhlLRivMvazf+tL4MvdKL6we7n/5TsBWceNFgqy+4tHO/Cg66sNvb3uvpv7zPgQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" ] }, "node_modules/@remotion/compositor-linux-x64-gnu": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.490.tgz", - "integrity": "sha512-w5P9iP+xSp6xGIJJl5aw6d9vVrzyh5P9GOAKVzmrYHIoubgwBlQZVltq805n0kITqDpTjVcNHDrt5C8avZorJg==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.500.tgz", + "integrity": "sha512-FyVcFtQfX0MRrCYaO+KyGu9Tn553rhbR+xSj0k/rc2P0rVFl+0MUHW9zcGIv7AjKoAw6EWbcOITApl/4v8M+fw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" ] }, "node_modules/@remotion/compositor-linux-x64-musl": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.490.tgz", - "integrity": "sha512-wU8d3zhhWbHiQCssQPSj8fxp16adC0h/8PPiU+lqXvtokFYSt0Is+ya1NgUhRZc7THKg7RT5TVmRaV+qT/XaUA==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.500.tgz", + "integrity": "sha512-qN8hqtI6mKTXyH3cUGAdCdt6g3uyplvTo1uxnbzQvYZpJvI/SHEHRHqgdV95eWHDEH2u/fAx2IYlTXQmk9q27A==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" ] }, "node_modules/@remotion/compositor-win32-x64-msvc": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.490.tgz", - "integrity": "sha512-k8bAJEiuVKJc1ZkJSmwm5g/pZOpQVXbVTVnCsnEjmsF+zbRoF6q71Ii8LuULxnOHBNLUSkfUg/Qlqh9YJ2FVOw==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.500.tgz", + "integrity": "sha512-nyFeFPbJMWCgqHJBUePZu51QNM0daO2ODLyBqc05BY4eZh3/O3u/CKE8W2aUInj15t3/pniDgH9gWgUiYrFxtQ==", "cpu": [ "x64" ], @@ -1285,26 +1276,32 @@ "win32" ] }, + "node_modules/@remotion/drag-and-drop": { + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/drag-and-drop/-/drag-and-drop-4.0.500.tgz", + "integrity": "sha512-AswAIQ7cbHwm8kJU1ZtMW3uNTPGP8K3h0XL/NMd44QUjunkdD64lsajqzHmQBioHo+S18xZdc2pcw1pOqartVQ==", + "license": "Remotion License" + }, "node_modules/@remotion/licensing": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.490.tgz", - "integrity": "sha512-p+h1JDnWYPIdIvMbMogAkV2Rt8w/5td5c9cyAwK1RUQYMq3PM/Wq6sncgqcWc3DQqWB3NJdmGhPNwSc5tt0KWg==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.500.tgz", + "integrity": "sha512-36yWRWe/1Ns2LIB4fHc+GX2+BDhVRK51p7d9wL0jIyAdFRGFibAtlld8sEwGu3YPnVDKkZAGF/Ue0l6no8T2fQ==", "license": "MIT" }, "node_modules/@remotion/media-parser": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.490.tgz", - "integrity": "sha512-Dj64dV0YAh5DiTYyGTRexJNPqa+plCB7YOc/ij5bEBvJfvDAx2zWZtoUNLVjosmhyniHdK3GSB9fUJoLBFUJLw==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.500.tgz", + "integrity": "sha512-QQnGq8wubusICRFB1tMWinJNKehGYi1nhGYyvOm1QgBttCG5qF3aISav1CJRwIWIkN152wfAFDUQuVy1htjSqg==", "license": "Remotion License https://remotion.dev/license" }, "node_modules/@remotion/media-utils": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.490.tgz", - "integrity": "sha512-2bO0sSMfeSMlCKFaYggSXyLi8EjsCGdsOaE9c7SeWkCPpMiXSmnM+7eAWBrubEmjP4HMn9RFNJPlRX9MuXJlbA==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.500.tgz", + "integrity": "sha512-07Ht4xw6Ycsqk8AJeDoHqM4xHSI/T4BJyqixEDK8Yj8JKZewt6SIH5iPJjw6zPfw8u0+mvl9U1UV5HH+L6yOqg==", "license": "MIT", "dependencies": { "mediabunny": "1.50.8", - "remotion": "4.0.490" + "remotion": "4.0.500" }, "peerDependencies": { "react": ">=16.8.0", @@ -1312,12 +1309,12 @@ } }, "node_modules/@remotion/player": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.490.tgz", - "integrity": "sha512-HlxhVgYfxOBoIO1ovp/AO6lZhDx9SZO3Pfk+lZ1FdnWaKINe7MLspx9NvuYq12gY/KuBvAnBPoCF0AVkRvPwOQ==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.500.tgz", + "integrity": "sha512-AwMdWV9gCBQcsLnl227iccI05A1Tpq3n+oweNk6h4P8n8580zpxUOF50RGfR9wLBDeU7XIivYvWtafuwVoIxIA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "remotion": "4.0.490" + "remotion": "4.0.500" }, "peerDependencies": { "react": ">=16.8.0", @@ -1325,26 +1322,26 @@ } }, "node_modules/@remotion/renderer": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.490.tgz", - "integrity": "sha512-Kc4hZ+Sj/taDcSfQG2vTJ26Qu9B4hUCGK0//959GtCWObtb5mZDkFA9fnnD8+yb2Sm48tiNTtO1M2YZ376r2BA==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.500.tgz", + "integrity": "sha512-cOE4siN1/Dp42fzX9svHpc83LqmrUZckpzIteFmS5iTCaC7lCWt34nuR4E6nmj5iRytAe/XbdXa2zjFL9bFUKQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@remotion/licensing": "4.0.490", - "@remotion/streaming": "4.0.490", + "@remotion/licensing": "4.0.500", + "@remotion/streaming": "4.0.500", "execa": "5.1.1", - "remotion": "4.0.490", + "remotion": "4.0.500", "source-map": "0.8.0-beta.0", "ws": "8.21.0" }, "optionalDependencies": { - "@remotion/compositor-darwin-arm64": "4.0.490", - "@remotion/compositor-darwin-x64": "4.0.490", - "@remotion/compositor-linux-arm64-gnu": "4.0.490", - "@remotion/compositor-linux-arm64-musl": "4.0.490", - "@remotion/compositor-linux-x64-gnu": "4.0.490", - "@remotion/compositor-linux-x64-musl": "4.0.490", - "@remotion/compositor-win32-x64-msvc": "4.0.490" + "@remotion/compositor-darwin-arm64": "4.0.500", + "@remotion/compositor-darwin-x64": "4.0.500", + "@remotion/compositor-linux-arm64-gnu": "4.0.500", + "@remotion/compositor-linux-arm64-musl": "4.0.500", + "@remotion/compositor-linux-x64-gnu": "4.0.500", + "@remotion/compositor-linux-x64-musl": "4.0.500", + "@remotion/compositor-win32-x64-msvc": "4.0.500" }, "peerDependencies": { "react": ">=16.8.0", @@ -1365,32 +1362,33 @@ } }, "node_modules/@remotion/streaming": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.490.tgz", - "integrity": "sha512-q4LuNxkGlLg6iEdOUTKVMCsvIdl6ZcMzVMWBuasrhZ8DnceCPr7Xzy2qaeNxAGhk14I8xIDHbFRylwJ8vOHCoA==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.500.tgz", + "integrity": "sha512-r0DBivkKmWHhIf5UuWAbwjnexTRVEyrmVGaKMeafBSBwhMj52YGxXxKDmsh1LXX+LMm6rDlK0do0RMGkWYPHHg==", "license": "MIT" }, "node_modules/@remotion/studio": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.490.tgz", - "integrity": "sha512-JY6Oln9hD/JcSTax4AIuiEQ5ZPsE4Rp8UHJHdcClETDvmIXPQPyYmVYYGMuLL/3kgwCzhzCdKFiwvLgre1p8AA==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.500.tgz", + "integrity": "sha512-hCc6ZONhg9DHHBl/e4yPZYQLcE7Dd5c3iItwM+vA8SKWOKjQ7MmFi8oLUiQg6o/Nrm26lTAIIUCtpVnOHWsGIw==", "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@remotion/canvas-capture": "4.0.490", - "@remotion/media-utils": "4.0.490", - "@remotion/player": "4.0.490", - "@remotion/renderer": "4.0.490", - "@remotion/studio-shared": "4.0.490", - "@remotion/timeline-utils": "4.0.490", - "@remotion/web-renderer": "4.0.490", - "@remotion/zod-types": "4.0.490", + "@remotion/canvas-capture": "4.0.500", + "@remotion/drag-and-drop": "4.0.500", + "@remotion/media-utils": "4.0.500", + "@remotion/player": "4.0.500", + "@remotion/renderer": "4.0.500", + "@remotion/studio-shared": "4.0.500", + "@remotion/timeline-utils": "4.0.500", + "@remotion/web-renderer": "4.0.500", + "@remotion/zod-types": "4.0.500", "mediabunny": "1.50.8", "memfs": "3.4.3", "open": "8.4.2", - "remotion": "4.0.490", + "remotion": "4.0.500", "semver": "7.5.3", - "zod": "4.3.6" + "zod": "4.4.3" }, "peerDependencies": { "react": ">=16.8.0", @@ -1398,21 +1396,25 @@ } }, "node_modules/@remotion/studio-server": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.490.tgz", - "integrity": "sha512-QxYQ43Tp5iL4P7y/NBRGj5LCWY0FO4Lpn2eyNflNw1uZySxBayeoyiLhv1YG66GRMWnMzIgjj/2tWVXXlidOig==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.500.tgz", + "integrity": "sha512-9nk2aOy+QvQac8PNPkjS/m9fAwgkz1DNR8hCeG0bXrgUguUgovsTs1ey6MLShkJiyOWUXCEsZxNRb+U0dJjPsw==", "license": "MIT", "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", - "@remotion/bundler": "4.0.490", - "@remotion/renderer": "4.0.490", - "@remotion/studio-shared": "4.0.490", + "@remotion/bundler": "4.0.500", + "@remotion/drag-and-drop": "4.0.500", + "@remotion/renderer": "4.0.500", + "@remotion/studio-shared": "4.0.500", + "@svgr/core": "8.1.0", + "@svgr/plugin-jsx": "8.1.0", + "kiwi-schema": "0.5.0", "memfs": "3.4.3", "open": "8.4.2", "prettier": "3.8.1", "recast": "0.23.11", - "remotion": "4.0.490", + "remotion": "4.0.500", "semver": "7.5.3" } }, @@ -1431,44 +1433,45 @@ } }, "node_modules/@remotion/studio-shared": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.490.tgz", - "integrity": "sha512-z84S704IEB8VIGnr6pZGH8XJFsrQD5fr8D5KqCXYqK6/KXW0qgv7965v3hlXhhCcD5rLfdoP8UdQHRpZolTlqw==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.500.tgz", + "integrity": "sha512-v2XN/i+SqgFF7teYcNOBxqPQgHwMfnBSjjdEuBFqWqsDRRWa9aD2ZXwVr6X2uqEEl9+bxX7LkvhzVLzv9hxRbA==", "license": "MIT", "dependencies": { - "remotion": "4.0.490" + "@remotion/drag-and-drop": "4.0.500", + "remotion": "4.0.500" } }, "node_modules/@remotion/studio/node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/@remotion/timeline-utils": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.490.tgz", - "integrity": "sha512-thYL+XEe6sE0xs61tpeKfCJY4jFvXhtjLa2n9pDnihSGVdvSQ5wUwbDmunQMPZvAvbiEBA8SaMrQzupF9L2BrA==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.500.tgz", + "integrity": "sha512-QTvsTFlCgqW4TNmvuHUi85570XBxX4l3hve22lubUVkPbDJOsXklG0qec94XaL1sF5Gn9W91yo52P0udKpKT6A==", "license": "MIT", "dependencies": { "mediabunny": "1.50.8" } }, "node_modules/@remotion/web-renderer": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.490.tgz", - "integrity": "sha512-LXETwo5svXVba/MLafS8bgNxz0yrL4e85zXDFIicIsqudfOpdb9osmmqFTAima2Uw8U+k+U7k/lGeHj82qXOjg==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.500.tgz", + "integrity": "sha512-dyxYPiUyBJ9xBgkgdVMZjs00mhDvy79/HNavrYGOiORrtiuZMIGFwpbvoQD2BgT47rwxAKROILg/OhybhjEJEQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@mediabunny/aac-encoder": "1.50.8", "@mediabunny/flac-encoder": "1.50.8", "@mediabunny/mp3-encoder": "1.50.8", - "@remotion/licensing": "4.0.490", + "@remotion/licensing": "4.0.500", "mediabunny": "1.50.8", - "remotion": "4.0.490" + "remotion": "4.0.500" }, "peerDependencies": { "react": ">=18.0.0", @@ -1476,12 +1479,268 @@ } }, "node_modules/@remotion/zod-types": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.490.tgz", - "integrity": "sha512-4xJaX+nBW14Nt9rhvTan/XTryChEfrbN1mmxVjtulrk8vxvtPmCvKRJ8kdyION2vgCNG4sza8rfFoButa9b/DQ==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.500.tgz", + "integrity": "sha512-F0ctSK4O75TvvaC0HoAZ+9cG8B7cE023z3myDHmHp7Fsq98bqwGHWwKkQ2mMSHrgk53de47hHHJx0eZsD62eCg==", "license": "MIT", "dependencies": { - "remotion": "4.0.490" + "remotion": "4.0.500" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/pluginutils": { @@ -2046,34 +2305,254 @@ "text-hex": "1.0.x" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, + "license": "MIT" + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", "dependencies": { @@ -2112,6 +2591,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2122,6 +2612,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dom-mediacapture-transform": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz", @@ -2295,7 +2792,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2312,7 +2808,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2329,7 +2824,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2346,7 +2840,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2363,7 +2856,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2380,7 +2872,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2397,7 +2888,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2414,7 +2904,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2431,7 +2920,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2448,7 +2936,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2465,7 +2952,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2482,7 +2968,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2499,7 +2984,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2516,7 +3000,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2533,7 +3016,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2550,7 +3032,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2567,7 +3048,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2584,7 +3064,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2601,7 +3080,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2618,7 +3096,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2660,113 +3137,86 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3043,6 +3493,12 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3231,16 +3687,6 @@ "node": ">= 0.8" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3270,6 +3716,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -3291,32 +3758,15 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -3419,7 +3869,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -3457,6 +3906,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3554,16 +4029,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -3582,6 +4047,26 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -3648,6 +4133,27 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -4035,6 +4541,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -4120,7 +4644,6 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4314,6 +4837,22 @@ "postcss": "^8.1.0" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4338,6 +4877,12 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -4416,14 +4961,34 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -4454,7 +5019,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -4463,19 +5027,307 @@ "node": ">=6" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "license": "MIT", + "node_modules/kiwi-schema": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/kiwi-schema/-/kiwi-schema-0.5.0.tgz", + "integrity": "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA==", + "license": "MIT", + "bin": { + "kiwic": "cli.js" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, "node_modules/loader-runner": { @@ -4514,12 +5366,14 @@ "node": ">= 12.0.0" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } }, "node_modules/lru-cache": { "version": "6.0.0", @@ -4534,9 +5388,9 @@ } }, "node_modules/lucide-react": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", - "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", + "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4674,9 +5528,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "funding": [ { "type": "github", @@ -4706,6 +5560,16 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -4748,6 +5612,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4810,6 +5688,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4828,29 +5736,41 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "license": "MIT", "engines": { - "node": ">= 14.16" + "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -4861,9 +5781,9 @@ } }, "node_modules/postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -4880,7 +5800,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5057,24 +5977,24 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-refresh": { @@ -5087,35 +6007,54 @@ } }, "node_modules/react-router": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", - "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", - "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3", - "react-router": "6.30.4" + "react-router": "7.18.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/readable-stream": { @@ -5149,9 +6088,9 @@ } }, "node_modules/remotion": { - "version": "4.0.490", - "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.490.tgz", - "integrity": "sha512-acyJNHljJvH7+I9kXJAK5bVVN7FAyM3tILGhnwZKJoB27Xp2WQ3cMyGIh8XFwlR66/ZESWkw74s49GItHejOWQ==", + "version": "4.0.500", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.500.tgz", + "integrity": "sha512-pMSW1YIkEB8UuM471JGyWklMoH1GBxQKAK36XpDy3z1yixritBLGt/KQsuv4n6k5eXxf37joEvXMbXqz20HZgg==", "license": "SEE LICENSE IN LICENSE.md", "peerDependencies": { "react": ">=16.8.0", @@ -5167,6 +6106,55 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -5400,6 +6388,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5518,6 +6512,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -5578,9 +6582,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -5641,6 +6645,12 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "license": "MIT" + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -5752,36 +6762,36 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=18" } }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=14.0.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -5872,7 +6882,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc" @@ -6036,36 +7046,6 @@ } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -6497,58 +7477,79 @@ } }, "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -6559,6 +7560,114 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, diff --git a/package.json b/package.json index 64049a5..3da4252 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "podcli", - "version": "2.5.0", + "version": "2.6.0", "private": true, "description": "AI-powered podcast clip generator for TikTok/YouTube Shorts. Transcribe, find viral moments, export vertical clips with burned captions.", "type": "module", @@ -35,18 +35,18 @@ "test:watch": "vitest" }, "dependencies": { - "@fontsource/dm-sans": "^5.2.8", - "@modelcontextprotocol/sdk": "^1.29.0", - "@remotion/bundler": "^4.0.490", - "@remotion/cli": "^4.0.490", + "@fontsource/dm-sans": "^5.3.0", + "@modelcontextprotocol/sdk": "^1.30.0", + "@remotion/bundler": "^4.0.500", + "@remotion/cli": "^4.0.500", "@remotion/renderer": "^4.0.490", "dotenv": "^17.4.2", "express": "^5.2.1", - "lucide-react": "^1.25.0", + "lucide-react": "^1.27.0", "multer": "^2.2.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-router-dom": "^6.30.4", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", "remotion": "^4.0.490", "uuid": "^14.0.1", "winston": "^3.17.0", @@ -63,6 +63,6 @@ "tsx": "^4.23.1", "typescript": "^7.0.2", "vite": "^5.4.21", - "vitest": "^2.1.9" + "vitest": "^4.1.10" } } diff --git a/remotion/render-full-episode.mjs b/remotion/render-full-episode.mjs new file mode 100644 index 0000000..fb3fa7d --- /dev/null +++ b/remotion/render-full-episode.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node + +/** + * Burn Remotion captions into an arbitrarily long source video. + * + * A full-length transparent ProRes overlay can consume tens of gigabytes. This + * renderer instead creates one short overlay at a time, composites it, deletes + * it, then losslessly concatenates the compressed video chunks and remuxes the + * source audio. + */ + +import { renderMedia, selectComposition } from "@remotion/renderer"; +import { getCachedBundle } from "./bundle-cache.mjs"; +import { spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +const parseArgs = () => { + const out = {}; + for (let i = 2; i < process.argv.length; i += 2) { + const key = process.argv[i]?.replace(/^--/, ""); + const value = process.argv[i + 1]; + if (key && value) out[key] = value; + } + return out; +}; + +// spawnSync blocks the event loop, so the signal handlers below cannot run +// while a child is alive. Without a bound, one stalled ffmpeg leaves the job +// "running" forever with no way for the user to cancel it. +const STEP_TIMEOUT_MS = 30 * 60 * 1000; + +const run = (command, args) => { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: STEP_TIMEOUT_MS, + }); + const name = path.basename(command); + if (result.error) { + const reason = result.error.code === "ETIMEDOUT" + ? `timed out after ${STEP_TIMEOUT_MS / 60000} minutes` + : result.error.message; + throw new Error(`${name} failed: ${reason}`); + } + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "unknown error").trim().slice(-3000); + throw new Error(`${name} failed (${result.status}): ${detail}`); + } + return (result.stdout || "").trim(); +}; + +const progress = (percent, message) => { + process.stdout.write(`PODCLI_PROGRESS=${JSON.stringify({ percent, message })}\n`); +}; + +const quoteConcatPath = (filePath) => { + const normalized = filePath.replaceAll("\\", "/"); + return `'${normalized.replaceAll("'", "'\\''")}'`; +}; + +const args = parseArgs(); +for (const required of ["video", "words", "output", "ffmpeg", "ffprobe"]) { + if (!args[required]) throw new Error(`Missing --${required}`); +} + +const video = path.resolve(args.video); +const wordsPath = path.resolve(args.words); +const output = path.resolve(args.output); +const partialOutput = `${output}.partial.mp4`; +const logo = args.logo ? path.resolve(args.logo) : null; +const styleName = args.style || "branded"; +const captionPosition = args["caption-position"] || "auto"; +// Mirrors the 60-160 clamp in backend/services/caption_renderer.py so the ASS +// and Remotion caption paths render the same size for a given request. +const rawFontScale = Number(args["caption-font-scale"]); +const captionFontScale = Number.isFinite(rawFontScale) + ? Math.min(160, Math.max(60, rawFontScale)) + : 100; +const logoPosition = args["logo-position"] || "top-left"; +const fps = Number(args.fps || 30); +const chunkSeconds = Number(args["chunk-seconds"] || 15); + +if (fs.existsSync(output)) throw new Error(`Refusing to overwrite existing output: ${output}`); +if (!fs.existsSync(video)) throw new Error(`Video not found: ${video}`); +if (!fs.existsSync(wordsPath)) throw new Error(`Words JSON not found: ${wordsPath}`); +if (logo && !fs.existsSync(logo)) throw new Error(`Logo not found: ${logo}`); +if (!(fps > 0) || !(chunkSeconds > 0)) throw new Error("fps and chunk-seconds must be positive"); + +const wordsData = JSON.parse(fs.readFileSync(wordsPath, "utf8")); +const words = Array.isArray(wordsData) ? wordsData : wordsData.words || []; +const faceY = Array.isArray(wordsData) ? null : wordsData.faceY ?? null; +const dimensions = run(args.ffprobe, [ + "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", + "-of", "csv=s=x:p=0", video, +]); +const [width, height] = dimensions.split("x").map(Number); +const duration = Number(run(args.ffprobe, [ + "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", video, +])); +if (!(width > 0 && height > 0 && duration > 0)) throw new Error("Could not probe video"); + +const durationInFrames = Math.ceil(duration * fps); +const framesPerChunk = Math.max(1, Math.round(chunkSeconds * fps)); +const outputDir = path.dirname(output); +fs.mkdirSync(outputDir, { recursive: true }); +const workDir = fs.mkdtempSync(path.join(outputDir, ".podcli-full-caption-work-")); +let server; + +const cleanup = () => { + try { server?.close(); } catch {} + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {} + try { fs.rmSync(partialOutput, { force: true }); } catch {} +}; +process.on("SIGINT", () => { cleanup(); process.exit(130); }); +process.on("SIGTERM", () => { cleanup(); process.exit(143); }); + +try { + server = http.createServer((request, response) => { + if (request.url !== "/logo.png" || !logo) { + response.writeHead(404); + response.end(); + return; + } + const stat = fs.statSync(logo); + response.writeHead(200, { + "Content-Type": "image/png", + "Content-Length": stat.size, + "Access-Control-Allow-Origin": "*", + }); + fs.createReadStream(logo).pipe(response); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const logoSrc = logo ? `http://127.0.0.1:${server.address().port}/logo.png` : undefined; + + progress(2, "Preparing caption renderer"); + const bundle = await getCachedBundle({ onBundle: () => progress(3, "Preparing caption renderer") }); + const inputProps = { + videoSrc: "", + words, + styleName, + logoSrc, + faceY, + durationInFrames, + fps, + captionPosition, + captionFontScale, + logoPosition, + singleLine: true, + }; + const composition = await selectComposition({ + serveUrl: bundle, + id: "CaptionedClip", + inputProps, + timeoutInMilliseconds: 120000, + }); + const renderComposition = { ...composition, durationInFrames, fps, width, height }; + const chunks = []; + const chunkCount = Math.ceil(durationInFrames / framesPerChunk); + const requestedConcurrency = Number.parseInt(process.env.PODCLI_REMOTION_CONCURRENCY || "", 10); + const concurrency = Number.isFinite(requestedConcurrency) + ? Math.max(1, Math.min(os.cpus().length, requestedConcurrency)) + : Math.max(2, Math.min(os.cpus().length, 8)); + + for (let index = 0; index < chunkCount; index++) { + const startFrame = index * framesPerChunk; + const endFrame = Math.min(durationInFrames - 1, startFrame + framesPerChunk - 1); + const startSeconds = startFrame / fps; + const sectionDuration = (endFrame - startFrame + 1) / fps; + const id = String(index + 1).padStart(4, "0"); + const overlay = path.join(workDir, `overlay-${id}.mov`); + const chunk = path.join(workDir, `video-${id}.mp4`); + let lastPercent = -1; + + await renderMedia({ + composition: renderComposition, + serveUrl: bundle, + codec: "prores", + proResProfile: "4444", + pixelFormat: "yuva444p10le", + imageFormat: "png", + outputLocation: overlay, + inputProps, + frameRange: [startFrame, endFrame], + concurrency, + timeoutInMilliseconds: 120000, + onProgress: ({ progress: chunkProgress }) => { + const percent = Math.floor(chunkProgress * 100); + if (percent >= lastPercent + 10) { + lastPercent = percent; + const overall = 5 + ((index + chunkProgress) / chunkCount) * 80; + progress(overall, `Rendering captions ${index + 1}/${chunkCount}`); + } + }, + }); + + progress(5 + ((index + 1) / chunkCount) * 80, `Compositing section ${index + 1}/${chunkCount}`); + run(args.ffmpeg, [ + "-y", "-hide_banner", "-loglevel", "error", + "-ss", startSeconds.toFixed(6), "-t", sectionDuration.toFixed(6), "-i", video, + "-i", overlay, + "-filter_complex", "[0:v][1:v]overlay=0:0:shortest=1,format=yuv420p[v]", + "-map", "[v]", "-an", + "-c:v", "libx264", "-crf", "18", "-preset", "fast", + "-r", String(fps), "-g", String(fps * 2), + "-movflags", "+faststart", chunk, + ]); + fs.rmSync(overlay, { force: true }); + chunks.push(chunk); + } + + progress(90, "Joining captioned sections"); + const listPath = path.join(workDir, "chunks.txt"); + fs.writeFileSync(listPath, chunks.map((chunk) => `file ${quoteConcatPath(chunk)}`).join("\n") + "\n"); + const videoOnly = path.join(workDir, `video-only-${crypto.randomUUID()}.mp4`); + run(args.ffmpeg, [ + "-y", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", + "-i", listPath, "-c", "copy", "-movflags", "+faststart", videoOnly, + ]); + + progress(96, "Adding original audio"); + run(args.ffmpeg, [ + "-y", "-hide_banner", "-loglevel", "error", "-i", videoOnly, "-i", video, + "-map", "0:v:0", "-map", "1:a:0?", "-c", "copy", "-shortest", + "-movflags", "+faststart", partialOutput, + ]); + fs.renameSync(partialOutput, output); + progress(100, "Full episode ready"); +} finally { + cleanup(); +} diff --git a/remotion/render.mjs b/remotion/render.mjs index ecbc9c8..2ac57e9 100644 --- a/remotion/render.mjs +++ b/remotion/render.mjs @@ -44,6 +44,13 @@ function parseArgs() { return opts; } +// Mirrors the 60-160 clamp in backend/services/caption_renderer.py so the ASS +// and Remotion caption paths render the same size for a given request. +function clampCaptionFontScale(raw) { + const value = Number(raw); + return Number.isFinite(value) ? Math.min(160, Math.max(60, value)) : 100; +} + async function main() { const opts = parseArgs(); @@ -151,6 +158,9 @@ async function main() { faceY, durationInFrames, fps, + captionPosition: opts["caption-position"] || "auto", + captionFontScale: clampCaptionFontScale(opts["caption-font-scale"]), + logoPosition: opts["logo-position"] || "top-left", }; console.log( diff --git a/remotion/src/CaptionedClip.tsx b/remotion/src/CaptionedClip.tsx index 72050b4..d29e9e1 100644 --- a/remotion/src/CaptionedClip.tsx +++ b/remotion/src/CaptionedClip.tsx @@ -4,7 +4,7 @@ import { HormoziCaptions } from "./components/HormoziCaptions"; import { KaraokeCaptions } from "./components/KaraokeCaptions"; import { SubtleCaptions } from "./components/SubtleCaptions"; import { BrandedCaptions } from "./components/BrandedCaptions"; -import type { Word, CaptionStyle } from "./types"; +import type { Word, CaptionStyle, CaptionPosition, LogoPosition } from "./types"; export interface CaptionedClipProps { videoSrc: string; @@ -12,6 +12,9 @@ export interface CaptionedClipProps { style: CaptionStyle; logoSrc?: string; faceY?: number | null; + captionPosition?: CaptionPosition; + logoPosition?: LogoPosition; + singleLine?: boolean; } export const CaptionedClip: React.FC = ({ @@ -19,6 +22,9 @@ export const CaptionedClip: React.FC = ({ style, logoSrc, faceY, + captionPosition = "auto", + logoPosition = "top-left", + singleLine = false, }) => { const CaptionComponent = { hormozi: HormoziCaptions, @@ -30,9 +36,10 @@ export const CaptionedClip: React.FC = ({ return ( {style.name === "branded" ? ( - + ) : ( - + )} ); diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx index 0cc2512..ca1545e 100644 --- a/remotion/src/Root.tsx +++ b/remotion/src/Root.tsx @@ -4,6 +4,7 @@ import { CaptionedClip } from "./CaptionedClip"; import { Bookend } from "./Bookend"; import { STYLES } from "./types"; import type { Word } from "./types"; +import type { CaptionPosition, LogoPosition } from "./types"; import dmSans400 from "@fontsource/dm-sans/files/dm-sans-latin-400-normal.woff2"; import dmSans700 from "@fontsource/dm-sans/files/dm-sans-latin-700-normal.woff2"; @@ -33,6 +34,10 @@ const inputProps = getInputProps() as { styleName?: string; logoSrc?: string; faceY?: number | null; + captionPosition?: CaptionPosition; + captionFontScale?: number; + logoPosition?: LogoPosition; + singleLine?: boolean; durationInFrames?: number; fps?: number; bookendMode?: "intro" | "outro"; @@ -46,6 +51,19 @@ const inputProps = getInputProps() as { export const RemotionRoot: React.FC = () => { const fps = inputProps.fps || 30; const durationInFrames = inputProps.durationInFrames || 900; + const baseStyle = STYLES[inputProps.styleName || "branded"]; + const positionMargins: Partial> = { + upper: 760, + center: 480, + lower: 220, + }; + const captionPosition = inputProps.captionPosition || "auto"; + const fontScale = Math.max(0.6, Math.min(1.6, (inputProps.captionFontScale || 100) / 100)); + const style = { + ...baseStyle, + fontSize: baseStyle.fontSize * fontScale, + marginBottom: positionMargins[captionPosition] ?? baseStyle.marginBottom, + }; return ( <> @@ -59,9 +77,12 @@ export const RemotionRoot: React.FC = () => { defaultProps={{ videoSrc: inputProps.videoSrc || "", words: inputProps.words || [], - style: STYLES[inputProps.styleName || "branded"], + style, logoSrc: inputProps.logoSrc, faceY: inputProps.faceY ?? null, + captionPosition, + logoPosition: inputProps.logoPosition || "top-left", + singleLine: inputProps.singleLine === true, }} /> { ]); }); }); + +describe("splitCaptionLines", () => { + it("uses one line for YouTube full-episode captions", () => { + expect(splitCaptionLines(["one", "two", "three", "four"], 2, true)).toEqual([ + ["one", "two", "three", "four"], + [], + ]); + }); + + it("preserves normal clip line splitting", () => { + expect(splitCaptionLines(["one", "two", "three", "four"], 2)).toEqual([ + ["one", "two"], + ["three", "four"], + ]); + }); +}); diff --git a/remotion/src/chunks.ts b/remotion/src/chunks.ts index 949a815..e1b1280 100644 --- a/remotion/src/chunks.ts +++ b/remotion/src/chunks.ts @@ -98,3 +98,12 @@ export function buildChunks(words: Word[], opts: ChunkOptions): Chunk[] { export function activeChunkAt(chunks: Chunk[], time: number): Chunk | undefined { return chunks.find((c) => time >= c.start && time < c.displayEnd); } + +export function splitCaptionLines( + items: T[], + splitIndex: number, + singleLine = false, +): [T[], T[]] { + if (singleLine || items.length <= splitIndex) return [items, []]; + return [items.slice(0, splitIndex), items.slice(splitIndex)]; +} diff --git a/remotion/src/components/BrandedCaptions.tsx b/remotion/src/components/BrandedCaptions.tsx index 789e310..b69162c 100644 --- a/remotion/src/components/BrandedCaptions.tsx +++ b/remotion/src/components/BrandedCaptions.tsx @@ -6,25 +6,27 @@ import { Img, staticFile, } from "remotion"; -import type { Word, CaptionStyle } from "../types"; +import type { Word, CaptionStyle, CaptionPosition, LogoPosition } from "../types"; import { captionScale } from "../types"; -import { buildChunks, activeChunkAt } from "../chunks"; +import { buildChunks, activeChunkAt, splitCaptionLines } from "../chunks"; interface Props { words: Word[]; style: CaptionStyle; logoSrc?: string; faceY?: number | null; // normalized 0-1 (0=top, 1=bottom) + captionPosition?: CaptionPosition; + logoPosition?: LogoPosition; + singleLine?: boolean; } const MAX_CHARS_PER_CHUNK = 18; -function splitIntoLines(words: Word[]): [Word[], Word[]] { - if (words.length <= 2) { - return [words, []]; - } - return [words.slice(0, 2), words.slice(2)]; -} +// Unscaled logo box, shared by the logo style and the caption-margin guard so +// the two cannot drift apart. +const LOGO_INSET = 180; +const LOGO_HEIGHT = 126; +const LOGO_CAPTION_GAP = 24; /** * Active pill rendered as an absolutely positioned background behind the word. @@ -90,6 +92,10 @@ const CaptionLine: React.FC<{ lineHeight: 1.25, maxWidth: "100%", overflowWrap: "anywhere", + // No nowrap: MAX_CHARS_PER_CHUNK bounds character count, not rendered + // width, so at captionFontScale 1.6 a chunk can exceed the safe inset. + // Wrapping degrades better than bleeding off-frame; a chunk that fits + // still renders on one line. }} > {words.map((word, i) => { @@ -117,6 +123,9 @@ export const BrandedCaptions: React.FC = ({ style, logoSrc, faceY, + captionPosition = "auto", + logoPosition = "top-left", + singleLine = false, }) => { const frame = useCurrentFrame(); const { fps, height, durationInFrames } = useVideoConfig(); @@ -137,13 +146,18 @@ export const BrandedCaptions: React.FC = ({ // Default margin is style.marginBottom. If face center is below 0.55, reduce margin. const baseMargin = style.marginBottom * s; let dynamicMargin = baseMargin; - if (faceY != null && faceY > 0.55) { + if (captionPosition === "auto" && faceY != null && faceY > 0.55) { // Face is low — push captions to the very bottom dynamicMargin = Math.max(80 * s, baseMargin - Math.round((faceY - 0.55) * height * 0.6)); - } else if (faceY != null && faceY < 0.35) { + } else if (captionPosition === "auto" && faceY != null && faceY < 0.35) { // Face is high — can bring captions up a bit dynamicMargin = baseMargin + 60 * s; } + // A bottom-anchored logo spans 180-306 scaled units. Captions sitting inside + // that band (captionPosition "lower" starts at 220) would render over it. + if (logoSrc && logoPosition.startsWith("bottom-")) { + dynamicMargin = Math.max(dynamicMargin, (LOGO_INSET + LOGO_HEIGHT + LOGO_CAPTION_GAP) * s); + } return ( <> @@ -152,17 +166,21 @@ export const BrandedCaptions: React.FC = ({ src={logoSrc.startsWith("http") ? logoSrc : staticFile(logoSrc)} style={{ position: "absolute", - top: 180 * s, - left: 108 * s, + ...(logoPosition.startsWith("top-") ? { top: LOGO_INSET * s } : { bottom: LOGO_INSET * s }), + ...(logoPosition.endsWith("-left") + ? { left: 108 * s } + : logoPosition.endsWith("-right") + ? { right: 108 * s } + : { left: "50%", transform: "translateX(-50%)" }), width: 255 * s, - height: 126 * s, + height: LOGO_HEIGHT * s, objectFit: "contain", }} /> )} {activeChunk && (() => { - const [line1, line2] = splitIntoLines(activeChunk.words); + const [line1, line2] = splitCaptionLines(activeChunk.words, 2, singleLine); return (
= ({ words, style }) => { +export const HormoziCaptions: React.FC = ({ words, style, singleLine = false }) => { const frame = useCurrentFrame(); const { fps, height, durationInFrames } = useVideoConfig(); const s = captionScale(height); @@ -65,6 +66,7 @@ export const HormoziCaptions: React.FC = ({ words, style }) => { maxWidth: `calc(100% - ${120 * s}px)`, boxSizing: "border-box", overflowWrap: "anywhere", + whiteSpace: singleLine ? "nowrap" : undefined, textAlign: "center", fontFamily: style.fontFamily, fontSize: style.fontSize * s, diff --git a/remotion/src/components/KaraokeCaptions.tsx b/remotion/src/components/KaraokeCaptions.tsx index 20bee27..2fb86d3 100644 --- a/remotion/src/components/KaraokeCaptions.tsx +++ b/remotion/src/components/KaraokeCaptions.tsx @@ -2,24 +2,20 @@ import React from "react"; import { useCurrentFrame, useVideoConfig } from "remotion"; import type { Word, CaptionStyle } from "../types"; import { captionScale } from "../types"; -import { buildChunks, activeChunkAt } from "../chunks"; +import { buildChunks, activeChunkAt, splitCaptionLines } from "../chunks"; interface Props { words: Word[]; style: CaptionStyle; -} - -function splitIntoLines(words: Word[]): [Word[], Word[]] { - if (words.length <= 3) return [words, []]; - const mid = Math.ceil(words.length / 2); - return [words.slice(0, mid), words.slice(mid)]; + singleLine?: boolean; } const KaraokeLine: React.FC<{ words: Word[]; currentTime: number; style: CaptionStyle; -}> = ({ words, currentTime, style }) => { + singleLine?: boolean; +}> = ({ words, currentTime, style, singleLine = false }) => { return (
{words.map((word, i) => { @@ -64,7 +61,7 @@ const KaraokeLine: React.FC<{ ); }; -export const KaraokeCaptions: React.FC = ({ words, style }) => { +export const KaraokeCaptions: React.FC = ({ words, style, singleLine = false }) => { const frame = useCurrentFrame(); const { fps, height, durationInFrames } = useVideoConfig(); const s = captionScale(height); @@ -79,7 +76,11 @@ export const KaraokeCaptions: React.FC = ({ words, style }) => { if (!activeChunk) return null; - const [line1, line2] = splitIntoLines(activeChunk.words); + const [line1, line2] = splitCaptionLines( + activeChunk.words, + Math.ceil(activeChunk.words.length / 2), + singleLine, + ); const scaledStyle = { ...style, fontSize: style.fontSize * s }; return ( @@ -95,9 +96,9 @@ export const KaraokeCaptions: React.FC = ({ words, style }) => { gap: 4 * s, }} > - + {line2.length > 0 && ( - + )}
); diff --git a/remotion/src/components/SubtleCaptions.tsx b/remotion/src/components/SubtleCaptions.tsx index 3f985dd..e7cc1fa 100644 --- a/remotion/src/components/SubtleCaptions.tsx +++ b/remotion/src/components/SubtleCaptions.tsx @@ -2,20 +2,15 @@ import React from "react"; import { useCurrentFrame, useVideoConfig, interpolate } from "remotion"; import type { Word, CaptionStyle } from "../types"; import { captionScale } from "../types"; -import { buildChunks, activeChunkAt } from "../chunks"; +import { buildChunks, activeChunkAt, splitCaptionLines } from "../chunks"; interface Props { words: Word[]; style: CaptionStyle; + singleLine?: boolean; } -function splitIntoLines(words: Word[]): [Word[], Word[]] { - if (words.length <= 4) return [words, []]; - const mid = Math.ceil(words.length / 2); - return [words.slice(0, mid), words.slice(mid)]; -} - -export const SubtleCaptions: React.FC = ({ words, style }) => { +export const SubtleCaptions: React.FC = ({ words, style, singleLine = false }) => { const frame = useCurrentFrame(); const { fps, height, durationInFrames } = useVideoConfig(); const s = captionScale(height); @@ -46,7 +41,11 @@ export const SubtleCaptions: React.FC = ({ words, style }) => { { extrapolateRight: "clamp" } ); - const [line1, line2] = splitIntoLines(activeChunk.words); + const [line1, line2] = splitCaptionLines( + activeChunk.words, + Math.ceil(activeChunk.words.length / 2), + singleLine, + ); const text1 = line1.map((w) => w.word).join(" "); const text2 = line2.map((w) => w.word).join(" "); @@ -75,6 +74,7 @@ export const SubtleCaptions: React.FC = ({ words, style }) => { "0 1px 3px rgba(0,0,0,0.95), 0 0 20px rgba(0,0,0,0.6), 0 0 50px rgba(0,0,0,0.3)", textAlign: "center", lineHeight: 1.35, + whiteSpace: singleLine ? "nowrap" : undefined, }} > {text1} diff --git a/remotion/src/types.ts b/remotion/src/types.ts index b7a2cbb..629e380 100644 --- a/remotion/src/types.ts +++ b/remotion/src/types.ts @@ -17,6 +17,15 @@ export interface CaptionStyle { marginBottom: number; } +export type CaptionPosition = "auto" | "upper" | "center" | "lower"; +export type LogoPosition = + | "top-left" + | "top-center" + | "top-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + export interface CaptionProps { words: Word[]; style: CaptionStyle; diff --git a/scripts/build-studio.sh b/scripts/build-studio.sh index 19f4866..1f6507a 100644 --- a/scripts/build-studio.sh +++ b/scripts/build-studio.sh @@ -22,4 +22,6 @@ node -e "require('esbuild').buildSync({entryPoints:['dist/ui/web-server.js'],bun cp -r dist/ui/public "$out/public" # MCP stdio server (the mcp__podcli__* tools Claude/Codex drive). node -e "require('esbuild').buildSync({entryPoints:['dist/index.js'],bundle:true,platform:'node',format:'esm',outfile:'$out/mcp-server.mjs',banner:{js:\"$banner\"},logLevel:'error'})" -echo "studio + mcp bundle -> $out" +# `podcli sync` — reconciles clips, assets, and knowledge with a Pro workspace. +node -e "require('esbuild').buildSync({entryPoints:['dist/sync.js'],bundle:true,platform:'node',format:'esm',outfile:'$out/sync.mjs',banner:{js:\"$banner\"},logLevel:'error'})" +echo "studio + mcp + sync bundle -> $out" diff --git a/src/models/index.ts b/src/models/index.ts index 64054e3..ade365d 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,7 +2,7 @@ export interface TaskRequest { task_id: string; - task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status"; + task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status" | "ai_provider_status" | "analyze_silence" | "render_silence_removed"; params: Record; } @@ -114,6 +114,8 @@ export interface UIState { activeExportJobId?: string | null; transcript?: TranscriptResult | null; rawTranscriptText?: string; + silenceOriginal?: { videoPath: string; transcript: TranscriptResult } | null; + silencePlan?: Record | null; suggestions?: SuggestedClip[]; deselectedIndices?: number[]; settings?: { @@ -124,6 +126,13 @@ export interface UIState { outroPath?: string; introPath?: string; cleanFillers?: boolean; + captionPosition?: string; + captionFontScale?: number; + logoPosition?: string; + onboardingDismissed?: boolean; + silenceThreshold?: number; + silenceMinPause?: number; + silencePadding?: number; }; phase?: string; lastUpdated?: number; @@ -277,6 +286,12 @@ export interface ClipHistoryEntry { description?: string; tags?: string; hashtags?: string; + // Set for signed-in users once the clip is mirrored to the workspace. A false + // cloud_synced marks a clip a later sweep should backfill; the local file + // stays the source of truth either way. + cloud_id?: string; + cloud_synced?: boolean; + cloud_video_uploaded?: boolean; } // === Knowledge Base Models === diff --git a/src/services/asset-sync.test.ts b/src/services/asset-sync.test.ts new file mode 100644 index 0000000..3332256 --- /dev/null +++ b/src/services/asset-sync.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createHash } from "crypto"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const tmp = mkdtempSync(join(tmpdir(), "podcli-assetsync-test-")); +process.env.PODCLI_HOME = tmp; +process.env.PODCLI_DATA = tmp; + +const digest = (body: string) => + createHash("sha256").update(Buffer.from(body)).digest("hex").slice(0, 32); + +vi.mock("./podcli-cloud.js", () => ({ + signedIn: vi.fn(async () => true), + listAssets: vi.fn(async () => []), + uploadAsset: vi.fn(async () => ({ unchanged: false })), + checksum: (body: Buffer) => + createHash("sha256").update(body).digest("hex").slice(0, 32), +})); + +const cloud = await import("./podcli-cloud.js"); +const { push } = await import("./asset-sync.js"); +const { AssetManager } = await import("./asset-manager.js"); + +const intro = join(tmp, "intro.mp4"); +let assetName = ""; + +describe("asset push", () => { + beforeEach(async () => { + rmSync(join(tmp, "assets"), { recursive: true, force: true }); + mkdirSync(join(tmp, "assets"), { recursive: true }); + writeFileSync(intro, "intro bytes"); + vi.clearAllMocks(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + assetName = (await new AssetManager().register("Show intro", intro, "intro")).name; + }); + + it("does not re-upload an asset the workspace already holds", async () => { + vi.mocked(cloud.listAssets).mockResolvedValue([ + { id: "1", name: assetName, kind: "intro", is_default: false, + size_bytes: "11", checksum: digest("intro bytes") }, + ]); + + const report = await push(); + + expect(cloud.uploadAsset).not.toHaveBeenCalled(); + expect(report.skipped).toContain(assetName); + expect(report.uploaded).toEqual([]); + }); + + it("uploads when the local file has changed", async () => { + vi.mocked(cloud.listAssets).mockResolvedValue([ + { id: "1", name: assetName, kind: "intro", is_default: false, + size_bytes: "11", checksum: digest("something else") }, + ]); + + const report = await push(); + + expect(cloud.uploadAsset).toHaveBeenCalledTimes(1); + expect(report.uploaded).toContain(assetName); + }); + + it("still uploads when the listing cannot be read", async () => { + vi.mocked(cloud.listAssets).mockRejectedValue(new Error("offline")); + + const report = await push(); + + expect(cloud.uploadAsset).toHaveBeenCalledTimes(1); + expect(report.failed).toEqual([]); + }); +}); diff --git a/src/services/asset-sync.ts b/src/services/asset-sync.ts new file mode 100644 index 0000000..6265da1 --- /dev/null +++ b/src/services/asset-sync.ts @@ -0,0 +1,173 @@ +import { existsSync } from "fs"; +import { mkdir, readFile, writeFile } from "fs/promises"; +import { join } from "path"; +import { paths } from "../config/paths.js"; +import { AssetManager, inferType } from "./asset-manager.js"; +import * as cloud from "./podcli-cloud.js"; +import type { Asset, AssetType } from "../models/index.js"; + +/** + * Two-way sync between .podcli/assets/ and the workspace asset library. + * + * Local assets keep working untouched for everyone; this only runs for + * signed-in users. The point is that a second machine, or a teammate, gets the + * show's logo and outro without anyone emailing files around. + */ + +const SYNCABLE_KINDS: Record = { + logo: "logo", + intro: "intro", + outro: "outro", + music: "music", +} as Record; + +function cloudKind(type: AssetType): string { + return SYNCABLE_KINDS[type] ?? "other"; +} + +export type SyncReport = { + uploaded: string[]; + downloaded: string[]; + skipped: string[]; + failed: Array<{ name: string; reason: string }>; +}; + +const empty = (): SyncReport => ({ uploaded: [], downloaded: [], skipped: [], failed: [] }); + +/** + * Push local assets the workspace doesn't have. + * + * The server discards an upload whose checksum it already holds, but only after + * receiving it. Comparing first keeps a 200 MB intro off the wire on every + * sync; if the listing cannot be fetched, everything is uploaded as before. + */ +export async function push(): Promise { + const report = empty(); + if (!(await cloud.signedIn())) return report; + + const manager = new AssetManager(); + const registry = await manager.load(); + + let held = new Map(); + try { + held = new Map((await cloud.listAssets()).map((a) => [a.name, a.checksum])); + } catch { + // Fall through: an unreadable listing must not stop the push. + } + + for (const asset of registry.assets) { + if (!existsSync(asset.path)) { + report.skipped.push(asset.name); + continue; + } + try { + const body = await readFile(asset.path); + if (held.get(asset.name) === cloud.checksum(body)) { + report.skipped.push(asset.name); + continue; + } + const result = await cloud.uploadAsset( + asset.name, + cloudKind(asset.type), + body, + Boolean(asset.default), + ); + if (result?.unchanged) report.skipped.push(asset.name); + else report.uploaded.push(asset.name); + } catch (err) { + report.failed.push({ + name: asset.name, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + return report; +} + +/** + * Pull workspace assets this machine is missing. + * + * Files land in .podcli/assets/ and are registered locally, so every existing + * code path — rendering, presets, the studio — finds them exactly where it + * already looks. Nothing downstream needs to know they came from a server. + */ +export async function pull(): Promise { + const report = empty(); + if (!(await cloud.signedIn())) return report; + + const manager = new AssetManager(); + const registry = await manager.load(); + const known = new Map(registry.assets.map((a) => [a.name, a])); + + let remote: Awaited>; + try { + remote = await cloud.listAssets(); + } catch (err) { + report.failed.push({ + name: "(list)", + reason: err instanceof Error ? err.message : String(err), + }); + return report; + } + + const dir = join(paths.assets, "shared"); + for (const entry of remote) { + const local = known.get(entry.name); + // A local file that already exists wins: the user's own copy is never + // silently overwritten by the workspace version. + if (local && existsSync(local.path)) { + report.skipped.push(entry.name); + continue; + } + try { + const body = await cloud.downloadAsset(entry.id); + await mkdir(dir, { recursive: true }); + // The whole name, flattened: two workspace assets called `intro/logo.png` + // and `outro/logo.png` both end in `logo.png`, and the second download + // would land on the first and leave two registry entries pointing at one + // file. + const target = join(dir, entry.name.replace(/[\\/]+/g, "-")); + await writeFile(target, body); + // The workspace already knows what this is. Re-deriving the type from the + // extension turns a `music` asset stored as .mp4 into a video. + await manager.register(entry.name, target, assetType(entry.kind) ?? inferType(target)); + report.downloaded.push(entry.name); + } catch (err) { + report.failed.push({ + name: entry.name, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + return report; +} + +/** + * The kinds worth taking back from the workspace. + * + * "other" is deliberately absent even though it is a valid type both ends: it + * is what `cloudKind` uploads anything unrecognised as, so honouring it on the + * way back would turn a local video into "other" on the round trip. Guessing + * from the file is the better answer for exactly that case. + */ +const ASSET_TYPES: readonly AssetType[] = [ + "logo", "outro", "intro", "music", "video", "image", "audio", +]; + +/** The workspace's own kind, when it is one this app models. */ +function assetType(kind: string | undefined): AssetType | null { + return kind && (ASSET_TYPES as readonly string[]).includes(kind) + ? (kind as AssetType) + : null; +} + +export async function sync(): Promise { + const up = await push(); + const down = await pull(); + return { + uploaded: up.uploaded, + downloaded: down.downloaded, + skipped: [...up.skipped, ...down.skipped], + failed: [...up.failed, ...down.failed], + }; +} diff --git a/src/services/clips-history-cloud.test.ts b/src/services/clips-history-cloud.test.ts new file mode 100644 index 0000000..8bd84b0 --- /dev/null +++ b/src/services/clips-history-cloud.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const tmp = mkdtempSync(join(tmpdir(), "podcli-clipcloud-test-")); +process.env.PODCLI_HOME = tmp; +process.env.PODCLI_DATA = tmp; + +vi.mock("./podcli-cloud.js", () => ({ + signedIn: vi.fn(async () => false), + sourceHash: vi.fn(async () => "abc123"), + registerClip: vi.fn(async () => ({ id: "cloud-clip-1" })), + uploadClipVideo: vi.fn(async () => true), + logClipEvent: vi.fn(async () => undefined), +})); + +const cloud = await import("./podcli-cloud.js"); +const { ClipsHistory } = await import("./clips-history.js"); + +const source = join(tmp, "episode.mp4"); +const output = join(tmp, "clip.mp4"); + +/** record() fires its cloud sync in the background; let it settle before asserting. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 10)); + +async function seed(history: InstanceType) { + return history.record({ + title: "A clip", + source_video: source, + output_path: output, + duration: 42, + start_second: 10, + end_second: 52, + format: "9:16", + } as never); +} + +describe("clip cloud sync", () => { + let history: InstanceType; + + beforeEach(() => { + rmSync(join(tmp, "history"), { recursive: true, force: true }); + mkdirSync(join(tmp, "history"), { recursive: true }); + writeFileSync(source, "source bytes"); + writeFileSync(output, "rendered bytes"); + vi.clearAllMocks(); + vi.mocked(cloud.signedIn).mockResolvedValue(false); + history = new ClipsHistory(); + }); + + it("makes no network call when signed out", async () => { + await seed(history); + const result = await history.backfillCloud(); + + expect(result).toEqual({ synced: 0, failed: 0 }); + expect(cloud.registerClip).not.toHaveBeenCalled(); + expect(cloud.uploadClipVideo).not.toHaveBeenCalled(); + }); + + it("uploads the rendered clip after registering it", async () => { + const entry = await seed(history); + await settle(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + + await history.backfillCloud(); + + expect(cloud.registerClip).toHaveBeenCalledTimes(1); + expect(cloud.uploadClipVideo).toHaveBeenCalledWith("cloud-clip-1", output); + const after = await history.findById(entry.id); + expect(after?.cloud_id).toBe("cloud-clip-1"); + expect(after?.cloud_video_uploaded).toBe(true); + }); + + it("registers a clip once when two syncs overlap", async () => { + await seed(history); + await settle(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + + await Promise.all([history.backfillCloud(), history.backfillCloud()]); + + expect(cloud.registerClip).toHaveBeenCalledTimes(1); + expect(cloud.uploadClipVideo).toHaveBeenCalledTimes(1); + }); + + it("does not re-upload a clip whose video the workspace already has", async () => { + const entry = await seed(history); + await settle(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + await history.backfillCloud(); + vi.clearAllMocks(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + + await history.backfillCloud(); + + expect(cloud.uploadClipVideo).not.toHaveBeenCalled(); + expect(cloud.registerClip).not.toHaveBeenCalled(); + expect((await history.findById(entry.id))?.cloud_video_uploaded).toBe(true); + }); +}); diff --git a/src/services/clips-history.ts b/src/services/clips-history.ts index 316a43f..4ba255a 100644 --- a/src/services/clips-history.ts +++ b/src/services/clips-history.ts @@ -40,6 +40,7 @@ export class ClipsHistory { // requests can't lose each other's edits. Cross-process safety (vs the Python // CLI) rests on the atomic temp-file rename in save(). private writeChain: Promise = Promise.resolve(); + private syncing = new Set(); private async ensureDir() { if (!existsSync(paths.history)) { @@ -86,9 +87,76 @@ export class ClipsHistory { await this.mutate((entries) => { entries.push(full); }); + void this.syncToCloud(full); return full; } + /** + * Mirror a rendered clip to the workspace, for signed-in users. + * + * Deliberately not awaited and unable to throw: a clip that rendered + * successfully must be recorded locally whether or not a server was reachable. + * The local history file remains the source of truth; this is a copy. + * + * Clips that fail to sync are left marked so a later sweep can backfill them — + * the performance model wants the whole history, not the part that happened to + * have a working network. + */ + private async syncToCloud(entry: ClipHistoryEntry): Promise { + // record() starts this in the background, so `podcli sync` can reach the + // same entry while it is still in flight and register the clip twice. + if (this.syncing.has(entry.id)) return; + this.syncing.add(entry.id); + try { + const cloud = await import("./podcli-cloud.js"); + if (!(await cloud.signedIn())) return; + + const source = entry.source_video; + if (!source) return; + + const clipId = entry.cloud_id ?? (await cloud.registerClip({ + sourceHash: await cloud.sourceHash(source), + episodeTitle: basename(source), + title: entry.title, + startSecond: entry.start_second, + endSecond: entry.end_second, + durationSec: entry.duration, + contentType: entry.content_type, + captionStyle: entry.caption_style, + aspectRatio: entry.format, + transcriptSlice: entry.transcript_slice, + }))?.id; + if (!clipId) return; + + await this.update(entry.id, { cloud_id: clipId }); + + // Metadata alone leaves a share link with nothing to play, so the + // rendered file follows it. Uploaded once: the server keeps the first + // copy and answers `unchanged` after that. + let hasVideo = entry.cloud_video_uploaded === true; + // A rendered file that no longer exists locally can never be uploaded. + // There is nothing left to do for it, and reporting it as failed on every + // run is the unfixable number this file already refuses to print. + const uploadable = !hasVideo && existsSync(entry.output_path); + + if (uploadable) { + hasVideo = await cloud.uploadClipVideo(clipId, entry.output_path); + if (hasVideo) await this.update(entry.id, { cloud_video_uploaded: true }); + } + + // Synchronised means the clip is watchable, not merely described: an + // upload that failed while still reporting success is how `podcli sync` + // exits happy with every share link playing nothing. + await this.update(entry.id, { + cloud_synced: hasVideo || !existsSync(entry.output_path), + }); + } catch { + await this.update(entry.id, { cloud_synced: false }).catch(() => {}); + } finally { + this.syncing.delete(entry.id); + } + } + // Persist every successful row of a batch render. Single source of truth for // turning backend batch results into history entries — callers used to inline // this loop, drifting on defaults and on which fields got recorded. @@ -230,12 +298,76 @@ export class ClipsHistory { async update(id: string, patch: Partial): Promise { if (!id) return null; - return this.mutate((entries) => { + const changed = await this.mutate((entries) => { const e = entries.find((x) => x.id === id); if (!e) return null; + const before = e.title; Object.assign(e, patch); - return e; + return { entry: e, previousTitle: before }; }); + if (!changed) return null; + + // A human rewriting a generated title is the clearest taste signal podcli + // gets — it says what the model produced and what a person preferred + // instead. Reported only when the title actually changed, so the sync + // bookkeeping in syncToCloud can't trigger it. + if (patch.title !== undefined && patch.title !== changed.previousTitle) { + void this.reportEvent(changed.entry, "title_edited", changed.previousTitle, patch.title); + } + return changed.entry; + } + + /** Best-effort; never blocks or fails the edit that produced it. */ + private async reportEvent( + entry: ClipHistoryEntry, + kind: "title_edited" | "discarded" | "thumbnail_regenerated", + before?: string, + after?: string, + ): Promise { + if (!entry.cloud_id) return; + try { + const cloud = await import("./podcli-cloud.js"); + if (!(await cloud.signedIn())) return; + await cloud.logClipEvent(entry.cloud_id, kind, before, after); + } catch { + // The signal is nice to have, not worth surfacing an error over. + } + } + + /** + * Push clips that never reached the workspace. + * + * Covers two cases that both matter: a render that happened while the network + * was down, and — more importantly — everything rendered *before* the user + * subscribed. A new Pro user should start with their back catalogue behind the + * performance model, not an empty history. + */ + async backfillCloud(limit = 200): Promise<{ synced: number; failed: number }> { + const cloud = await import("./podcli-cloud.js"); + if (!(await cloud.signedIn())) return { synced: 0, failed: 0 }; + + // A clip whose source video has been moved or deleted can never be hashed, + // so it can never sync. Skipping it keeps `podcli sync` quiet; counting it + // as a failure would report the same unfixable number on every run until + // people stopped reading the output. + const pending = (await this.load()) + .filter((e) => e.source_video && existsSync(e.source_video)) + .filter((e) => !e.cloud_id || !e.cloud_video_uploaded) + .slice(0, limit); + + let synced = 0; + let failed = 0; + for (const entry of pending) { + try { + await this.syncToCloud(entry); + const after = await this.findById(entry.id); + if (after?.cloud_synced) synced++; + else failed++; + } catch { + failed++; + } + } + return { synced, failed }; } // Remove a clip and the artifacts podcli rendered for it (output video, diff --git a/src/services/knowledge-sync.test.ts b/src/services/knowledge-sync.test.ts new file mode 100644 index 0000000..4a6ccee --- /dev/null +++ b/src/services/knowledge-sync.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, existsSync, readdirSync, writeFileSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const tmp = mkdtempSync(join(tmpdir(), "podcli-ksync-test-")); +process.env.PODCLI_HOME = tmp; +process.env.PODCLI_DATA = tmp; + +vi.mock("./podcli-cloud.js", () => ({ + signedIn: vi.fn(async () => true), + listKnowledge: vi.fn(async () => []), + getKnowledge: vi.fn(async () => ({ content: "owned", version: 1 })), + putKnowledge: vi.fn(async () => ({ conflict: false, version: 1, unchanged: true })), +})); + +const cloud = await import("./podcli-cloud.js"); +const { sync } = await import("./knowledge-sync.js"); + +describe("knowledge sync", () => { + beforeEach(() => { + rmSync(join(tmp, "knowledge"), { recursive: true, force: true }); + // The sync state lives beside the folder, not in it. Leaving it behind made + // these tests order-dependent: the pull phase skips any path already in the + // map, so a later test passed only because an earlier one had not recorded + // a version for the same filename. + rmSync(join(tmp, "knowledge-sync.json"), { force: true }); + mkdirSync(join(tmp, "knowledge"), { recursive: true }); + vi.clearAllMocks(); + }); + + it("refuses a workspace path that escapes the knowledge folder", async () => { + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "../../pwned.md", version: 1, updated_at: "" }, + ]); + + const report = await sync(); + + expect(existsSync(join(tmp, "..", "pwned.md"))).toBe(false); + expect(report.pulled).toEqual([]); + expect(report.failed[0]?.path).toBe("../../pwned.md"); + // Rejected before the content is ever requested. + expect(cloud.getKnowledge).not.toHaveBeenCalled(); + }); + + it("never pushes shipped defaults over the workspace copy on a first sync", async () => { + writeFileSync(join(tmp, "knowledge", "02-voice-and-tone.md"), "# shipped default"); + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "02-voice-and-tone.md", version: 7, updated_at: "" }, + ]); + vi.mocked(cloud.getKnowledge).mockResolvedValue({ + content: "# the team's real voice guide", version: 7, + }); + + const report = await sync(); + + expect(cloud.putKnowledge).not.toHaveBeenCalled(); + expect(report.conflicts).toEqual([{ path: "02-voice-and-tone.md", theirVersion: 7 }]); + // Neither copy is lost. + expect(readFileSync(join(tmp, "knowledge", "02-voice-and-tone.md"), "utf-8")) + .toBe("# shipped default"); + expect(readFileSync(join(tmp, "knowledge", "02-voice-and-tone.md.workspace-7"), "utf-8")) + .toBe("# the team's real voice guide"); + }); + + it("adopts the workspace version when both copies already match", async () => { + writeFileSync(join(tmp, "knowledge", "05-title-formulas.md"), "# same bytes"); + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "05-title-formulas.md", version: 4, updated_at: "" }, + ]); + vi.mocked(cloud.getKnowledge).mockResolvedValue({ content: "# same bytes", version: 4 }); + + const report = await sync(); + + expect(cloud.putKnowledge).not.toHaveBeenCalled(); + expect(report.unchanged).toEqual(["05-title-formulas.md"]); + expect(report.conflicts).toEqual([]); + expect(existsSync(join(tmp, "knowledge", "05-title-formulas.md.workspace-4"))).toBe(false); + }); + + it("pushes a local file the workspace does not have", async () => { + writeFileSync(join(tmp, "knowledge", "99-mine.md"), "# only here"); + vi.mocked(cloud.listKnowledge).mockResolvedValue([]); + vi.mocked(cloud.putKnowledge).mockResolvedValue({ + conflict: false, version: 1, unchanged: false, + }); + + const report = await sync(); + + expect(cloud.putKnowledge).toHaveBeenCalledWith("99-mine.md", "# only here", undefined); + expect(report.pushed).toEqual(["99-mine.md"]); + }); + + it("pulls a file the workspace has and this machine does not", async () => { + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "02-voice-and-tone.md", version: 3, updated_at: "" }, + ]); + + const report = await sync(); + + expect(report.pulled).toEqual(["02-voice-and-tone.md"]); + expect(readdirSync(join(tmp, "knowledge"))).toContain("02-voice-and-tone.md"); + }); +}); diff --git a/src/services/knowledge-sync.ts b/src/services/knowledge-sync.ts new file mode 100644 index 0000000..cbd529c --- /dev/null +++ b/src/services/knowledge-sync.ts @@ -0,0 +1,172 @@ +import { existsSync } from "fs"; +import { mkdir, readFile, readdir, writeFile } from "fs/promises"; +import { dirname, join, relative, resolve, sep } from "path"; +import { paths } from "../config/paths.js"; +import * as cloud from "./podcli-cloud.js"; + +/** + * Sync .podcli/knowledge/ with the workspace. + * + * This is the shared brand brain: voice, banned words, title formulas, + * thumbnail rules. A new editor joining a team should inherit all of it by + * signing in, rather than being sent a folder over Slack. + * + * Free podcli keeps these files local and fully effective, as it always will. + */ + +const STATE_FILE = "knowledge-sync.json"; + +/** + * Version of each file as of the last successful sync. + * + * Without this there is no way to tell "I edited this" from "they edited this" + * — both just look like a difference — and every sync would either clobber + * someone or refuse to do anything. + */ +type SyncState = Record; + +async function loadState(): Promise { + try { + return JSON.parse(await readFile(join(paths.home, STATE_FILE), "utf-8")); + } catch { + return {}; + } +} + +async function saveState(state: SyncState): Promise { + await mkdir(paths.home, { recursive: true }); + await writeFile(join(paths.home, STATE_FILE), JSON.stringify(state, null, 2), "utf-8"); +} + +/** + * The workspace decides these filenames, so a server that returned + * `../../.zshrc` would otherwise have this write anywhere the user can. + */ +function insideKnowledge(path: string): string | null { + const root = resolve(paths.knowledge); + const target = resolve(root, path); + return target.startsWith(root + sep) ? target : null; +} + +/** + * Every .md under the knowledge folder, as workspace-style relative paths. + * + * Walked by hand rather than with `readdir({ recursive })`: that option needs + * Node 20.1 and `dirent.parentPath` needs 20.12, while podcli supports 18. CI + * runs 20, so the crash would have reached Node 18 users rather than the build. + * + * Recursive at all because the workspace accepts nested paths: a flat listing + * pulled `brand/voice.md` once and then never pushed a local edit to it again. + */ +async function localFiles(): Promise { + if (!existsSync(paths.knowledge)) return []; + + const found: string[] = []; + const walk = async (dir: string): Promise => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) await walk(full); + else if (entry.isFile() && entry.name.endsWith(".md")) { + found.push(relative(paths.knowledge, full).split(sep).join("/")); + } + } + }; + + await walk(paths.knowledge); + return found.sort(); +} + +export type KnowledgeSyncReport = { + pushed: string[]; + pulled: string[]; + unchanged: string[]; + conflicts: Array<{ path: string; theirVersion: number }>; + failed: Array<{ path: string; reason: string }>; +}; + +export async function sync(): Promise { + const report: KnowledgeSyncReport = { + pushed: [], pulled: [], unchanged: [], conflicts: [], failed: [], + }; + if (!(await cloud.signedIn())) return report; + + const state = await loadState(); + const remote = new Map((await cloud.listKnowledge()).map((f) => [f.path, f])); + const local = await localFiles(); + const localSet = new Set(local); + + // Reconciled before anything is pushed. podcli ships default knowledge files, + // so a machine that has never synced has a full set of boilerplate that would + // otherwise be pushed straight over the workspace's real one — the server + // skips its conflict check when no expectedVersion is sent. + const unresolved = new Set(); + for (const [path, meta] of remote) { + const target = insideKnowledge(path); + if (!target) { + report.failed.push({ path, reason: "path escapes the knowledge folder" }); + unresolved.add(path); + continue; + } + if (state[path] !== undefined) continue; + + try { + const file = await cloud.getKnowledge(path); + const version = file.version ?? meta.version; + + if (!localSet.has(path)) { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, file.content, "utf-8"); + state[path] = version; + report.pulled.push(path); + continue; + } + + // Both sides have this file and nothing records which came first. Equal + // content is simply adopted; otherwise the workspace copy lands beside + // the local one and a human decides. + const mine = await readFile(target, "utf-8"); + if (mine === file.content) { + state[path] = version; + report.unchanged.push(path); + } else { + await writeFile(join(paths.knowledge, `${path}.workspace-${version}`), + file.content, "utf-8"); + report.conflicts.push({ path, theirVersion: version }); + } + unresolved.add(path); + } catch (err) { + report.failed.push({ path, reason: err instanceof Error ? err.message : String(err) }); + unresolved.add(path); + } + } + + for (const path of local) { + if (unresolved.has(path)) continue; + const content = await readFile(join(paths.knowledge, path), "utf-8"); + const known = state[path]; + try { + const result = await cloud.putKnowledge(path, content, known); + if (result.conflict) { + // Neither copy is discarded. The workspace version is written beside + // the local one so a human can compare and merge; nobody's work is + // thrown away by a sync running in the background. + const version = Number(result.version); + const suffix = Number.isFinite(version) ? version : "remote"; + const theirs = join(paths.knowledge, `${path}.workspace-${suffix}`); + await writeFile(theirs, result.content, "utf-8"); + report.conflicts.push({ path, theirVersion: version }); + continue; + } + state[path] = result.version; + if (result.unchanged) report.unchanged.push(path); + else report.pushed.push(path); + } catch (err) { + report.failed.push({ path, reason: err instanceof Error ? err.message : String(err) }); + } + } + + + await saveState(state); + return report; +} diff --git a/src/services/podcli-cloud.ts b/src/services/podcli-cloud.ts new file mode 100644 index 0000000..c669777 --- /dev/null +++ b/src/services/podcli-cloud.ts @@ -0,0 +1,292 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { paths } from "../config/paths.js"; + +/** + * Client for podcli Pro's hosted API. + * + * The Python backend has its own copy of this because the two runtimes cannot + * share one — deliberate duplication of about eighty lines, not an accident. + * + * Nothing here is secret. The server decides entitlement, so a patched client + * gets an HTTP 401 rather than free Pro. + */ + +const DEFAULT_API_URL = "https://api.podcli.com"; + +export function apiUrl(): string { + return (process.env.PODCLI_API_URL || DEFAULT_API_URL).replace(/\/+$/, ""); +} + +/** + * Read on every call, deliberately not cached. + * + * The studio server is long-running, so a cached token survives `podcli logout` + * in another terminal and the UI keeps claiming the user is signed in. Reading a + * small file costs microseconds against the HTTP request that follows it, so + * caching bought nothing and cost correctness. + */ +export async function readToken(): Promise { + const fromEnv = (process.env.PODCLI_TOKEN || "").trim(); + if (fromEnv) return fromEnv; + try { + const raw = await readFile(join(paths.home, "auth.json"), "utf-8"); + return ((JSON.parse(raw).token as string | undefined) || "").trim() || null; + } catch { + return null; + } +} + +export async function signedIn(): Promise { + return (await readToken()) !== null; +} + +async function request(method: string, path: string, body?: unknown, timeoutMs = 30_000) { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${apiUrl()}${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } finally { + clearTimeout(timer); + } +} + +/** + * Identifies an episode across machines. + * + * Hashing the first 8 MB rather than the whole file: a 2 GB master would take + * seconds to digest and the head of a video is more than distinctive enough to + * key on. Two editors working from the same file land on the same episode. + */ +export async function sourceHash(videoPath: string): Promise { + const hash = createHash("sha256"); + const stream = createReadStream(videoPath, { start: 0, end: 8 * 1024 * 1024 - 1 }); + for await (const chunk of stream) hash.update(chunk as Buffer); + return hash.digest("hex").slice(0, 32); +} + +export type ClipRegistration = { + sourceHash: string; + episodeTitle?: string; + episodeDuration?: number; + title?: string; + startSecond?: number; + endSecond?: number; + durationSec?: number; + contentType?: string; + captionStyle?: string; + aspectRatio?: string; + aiEngine?: string; + score?: number; + quote?: string; + reasoning?: string; + transcriptSlice?: string; + extra?: Record; +}; + +export async function registerClip(clip: ClipRegistration): Promise<{ id: string } | null> { + return request("POST", "/v1/clips", clip); +} + +/** Matches the server's body cap; a larger file is refused before the upload. */ +const MAX_CLIP_BYTES = 200 * 1024 * 1024; + +/** + * Send the rendered clip itself, so share links have something to play. + * + * Only the rendered clip travels — never the source video. It is the whole + * reason a share link can exist without the storage cost of the master. + */ +export async function uploadClipVideo(clipId: string, filePath: string): Promise { + const token = await readToken(); + if (!token) return false; + + const { size } = await stat(filePath); + if (size === 0 || size > MAX_CLIP_BYTES) return false; + + const response = await fetch(`${apiUrl()}/v1/clips/${clipId}/video`, { + method: "PUT", + headers: { authorization: `Bearer ${token}`, "content-type": "video/mp4" }, + body: await readFile(filePath), + signal: AbortSignal.timeout(300_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + return true; +} + +export type Breakdown = { + key: string; + clips: number; + retention: number | null; + ctr: number | null; + views: number | null; +}; + +export type Insights = { + sampleSize: number; + byContentType: Breakdown[]; + byCaptionStyle: Breakdown[]; + byLength: Breakdown[]; + topClips: Array<{ title: string; retention: number; views: number; content_type: string }>; + guidance: string[]; +}; + +export type Preferences = { + titleEdits: Array<{ before: string; after: string }>; + discardRate: number | null; + observations: string[]; +}; + +// Nullable because `request` returns null for an empty body, and a caller that +// trusts the declared shape would dereference it. +export async function getInsights(): Promise { + return request("GET", "/v1/insights"); +} + +export async function getPreferences(): Promise { + return request("GET", "/v1/insights/preferences"); +} + +export async function whoami(): Promise<{ + workspaceId: string; + role: string; + plan: string; + workspace: { name: string; episodes_used: number }; +}> { + return request("GET", "/v1/auth/me", undefined, 10_000); +} + +export type RemoteKnowledgeFile = { path: string; version: number; updated_at: string }; + +export async function listKnowledge(): Promise { + const payload = await request("GET", "/v1/knowledge"); + return payload?.files ?? []; +} + +export async function getKnowledge(path: string): Promise<{ content: string; version: number }> { + return request("GET", `/v1/knowledge/file?path=${encodeURIComponent(path)}`); +} + +export type PutKnowledgeResult = + | { conflict: false; version: number; unchanged: boolean } + | { conflict: true; version: number; content: string }; + +export async function putKnowledge( + path: string, + content: string, + expectedVersion?: number, +): Promise { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const response = await fetch(`${apiUrl()}/v1/knowledge/file`, { + method: "PUT", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ path, content, expectedVersion }), + signal: AbortSignal.timeout(30_000), + }); + + // A 409 is an expected outcome here, not an error: someone else edited the + // file. The body carries their version so the caller can show both. + if (response.status === 409) { + const body = await response.json(); + return { conflict: true, version: body.version, content: body.content }; + } + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + const body = await response.json(); + return { conflict: false, version: body.version, unchanged: Boolean(body.unchanged) }; +} + +export type RemoteAsset = { + id: string; + name: string; + kind: string; + is_default: boolean; + size_bytes: string; + checksum: string; +}; + +/** Mirrors how the workspace digests an asset, so an upload can be skipped. */ +export function checksum(body: Buffer): string { + return createHash("sha256").update(body).digest("hex").slice(0, 32); +} + +export async function listAssets(): Promise { + const payload = await request("GET", "/v1/assets"); + return payload?.assets ?? []; +} + +export async function uploadAsset( + name: string, + kind: string, + body: Buffer, + isDefault = false, +): Promise<{ id: string; unchanged?: boolean }> { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const params = new URLSearchParams({ name, kind, isDefault: String(isDefault) }); + const response = await fetch(`${apiUrl()}/v1/assets?${params}`, { + method: "PUT", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/octet-stream", + }, + // Node's fetch wants a view, not the Buffer's whole underlying pool. + body: new Uint8Array(body), + signal: AbortSignal.timeout(300_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + return response.json(); +} + +export async function downloadAsset(id: string): Promise { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const response = await fetch(`${apiUrl()}/v1/assets/${id}/download`, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(300_000), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); +} + +export type ClipEventKind = + | "suggested" | "rendered" | "discarded" + | "title_edited" | "thumbnail_regenerated" + | "approved" | "changes_requested" | "published"; + +export async function logClipEvent( + cloudClipId: string, + kind: ClipEventKind, + before?: string, + after?: string, +): Promise { + await request("POST", `/v1/clips/${cloudClipId}/events`, { kind, before, after }); +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..059afb9 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,86 @@ +import { ClipsHistory } from "./services/clips-history.js"; +import * as assetSync from "./services/asset-sync.js"; +import * as knowledgeSync from "./services/knowledge-sync.js"; +import * as cloud from "./services/podcli-cloud.js"; + +/** + * `podcli sync` — reconcile this machine with the workspace. + * + * Clips, assets, and knowledge each sync automatically at the moments that + * matter (render, login), so this is the manual catch-up: after working + * offline, after a teammate changes the brand guide, or on a new machine. + * + * Every step is independent and none can fail another — a knowledge conflict + * must not stop assets from arriving. + */ +async function main(): Promise { + if (!(await cloud.signedIn())) { + console.log("Not signed in to podcli Pro. Run `podcli login` first."); + return 1; + } + + let problems = 0; + + console.log("Syncing clips..."); + try { + const { synced, failed } = await new ClipsHistory().backfillCloud(); + console.log( + synced || failed + ? ` ${synced} synced${failed ? `, ${failed} failed` : ""}` + : " already up to date", + ); + problems += failed; + } catch (err) { + console.log(` failed: ${err instanceof Error ? err.message : String(err)}`); + problems++; + } + + console.log("Syncing assets..."); + try { + const report = await assetSync.sync(); + const parts = [ + report.uploaded.length && `${report.uploaded.length} uploaded`, + report.downloaded.length && `${report.downloaded.length} downloaded`, + ].filter(Boolean); + console.log(parts.length ? ` ${parts.join(", ")}` : " already up to date"); + for (const f of report.failed) console.log(` ${f.name}: ${f.reason}`); + problems += report.failed.length; + } catch (err) { + console.log(` failed: ${err instanceof Error ? err.message : String(err)}`); + problems++; + } + + console.log("Syncing knowledge base..."); + try { + const report = await knowledgeSync.sync(); + const parts = [ + report.pushed.length && `${report.pushed.length} pushed`, + report.pulled.length && `${report.pulled.length} pulled`, + ].filter(Boolean); + console.log(parts.length ? ` ${parts.join(", ")}` : " already up to date"); + + for (const conflict of report.conflicts) { + console.log( + ` conflict: ${conflict.path} — the workspace copy was saved as ` + + `${conflict.path}.workspace-${conflict.theirVersion}. Merge it, then sync again.`, + ); + } + for (const f of report.failed) console.log(` ${f.path}: ${f.reason}`); + problems += report.failed.length; + } catch (err) { + console.log(` failed: ${err instanceof Error ? err.message : String(err)}`); + problems++; + } + + // Conflicts are not counted as problems: they are a normal outcome that + // needs a human, not a failure that needs a retry. + return problems > 0 ? 1 : 0; +} + +main().then( + (code) => process.exit(code), + (err) => { + console.error("sync failed:", err instanceof Error ? err.message : String(err)); + process.exit(1); + }, +); diff --git a/src/ui/client/AccountChip.tsx b/src/ui/client/AccountChip.tsx new file mode 100644 index 0000000..d281d8d --- /dev/null +++ b/src/ui/client/AccountChip.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from "react"; + +type Account = { + signedIn: boolean; + workspace?: string; + plan?: string; + episodesUsed?: number; + cap?: number; +}; + +/** + * Signed-in state at the bottom of the sidebar. + * + * Shows nothing when signed out. Sync that runs invisibly feels like sync that + * isn't running, so a subscriber should be able to see their workspace without + * going looking for it. + */ +export default function AccountChip() { + const [account, setAccount] = useState(null); + + useEffect(() => { + fetch("/api/pro/account") + .then((r) => r.json()) + .then(setAccount) + .catch(() => setAccount({ signedIn: false })); + }, []); + + if (!account?.signedIn) return null; + + const used = account.episodesUsed ?? 0; + const cap = account.cap ?? 0; + // Only surface the quota once it's close enough to matter. A counter at 2/10 + // is noise; at 8/10 it's the difference between planning and being surprised. + const showQuota = cap > 0 && used / cap >= 0.7; + + return ( +
+
{account.workspace}
+
+ {account.plan === "team" ? "Team" : "Pro"} + {showQuota && ` · ${used}/${cap} episodes`} +
+
+ ); +} diff --git a/src/ui/client/AiSetup.tsx b/src/ui/client/AiSetup.tsx new file mode 100644 index 0000000..ba00706 --- /dev/null +++ b/src/ui/client/AiSetup.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useState } from "react"; +import { Cloud, Terminal, Key } from "lucide-react"; +import { labelStyle } from "./lib"; + +/** + * What podcli will use for AI, and what to do when the answer is "nothing". + * + * Two real options are offered side by side and neither is dressed up as the + * only one: install a CLI you already pay for, or let us run it. A user who + * picks the free path has solved their problem, which is the point. + */ + +type Provider = { kind: string; engine: string; label: string }; + +type Status = { + available: boolean; + providers: Provider[]; + mode: string; + api_key_set: boolean; + candidates: Array<{ engine: string; path: string }>; +}; + +const INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code"; + +function Option({ + icon, title, body, action, +}: { + icon: React.ReactNode; title: string; body: string; action: React.ReactNode; +}) { + return ( +
+
+ {icon} + {title} +
+
{body}
+ {action} +
+ ); +} + +export default function AiSetup() { + const [status, setStatus] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + fetch("/api/ai-provider-status") + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + // The types say these are always present; the server can answer with an + // error body or a partial payload, and rendering a missing array throws + // out of this component and takes the settings page with it. + .then((payload) => setStatus({ + ...payload, + providers: Array.isArray(payload?.providers) ? payload.providers : [], + candidates: Array.isArray(payload?.candidates) ? payload.candidates : [], + })) + .catch(() => setStatus(null)); + }, []); + + if (!status) return null; + + if (status.available) { + return ( +
+
AI
+
+ Using + + {status.providers.map((p) => p.label).join(" → ")} + +
+ {status.providers.length > 1 && ( +
+ podcli tries these in order, so a failure falls through to the next one + rather than stopping. +
+ )} +
+ ); + } + + return ( +
+
AI is not set up
+
+ podcli transcribes, cuts, and renders without any of this. Picking moments, + titles, and descriptions needs a model. Two ways to get one: +
+ +
+
+ + {status.candidates.length > 0 && ( + // Found but unusable is a different problem from missing, and saying + // "not detected" here would send someone to reinstall what they have. +
+ A CLI was found at {status.candidates[0].path} but did not respond. + Run {status.candidates[0].engine} once in a terminal to sign in, then reload. +
+ )} +
+ ); +} diff --git a/src/ui/client/AnalyticsPage.tsx b/src/ui/client/AnalyticsPage.tsx index dbf4fd3..0c6cd67 100644 --- a/src/ui/client/AnalyticsPage.tsx +++ b/src/ui/client/AnalyticsPage.tsx @@ -3,6 +3,7 @@ import { PageHeader } from "./Page"; import { Link } from "react-router-dom"; import { TrendingUp, Eye, Percent, MousePointerClick } from "lucide-react"; import { api, upload, fmt } from "./lib"; +import WorkspaceInsights from "./WorkspaceInsights"; interface Row { key: string; count: number; avgViews: number; avgRetention: number; avgCtr: number } interface Data { @@ -164,6 +165,8 @@ export default function AnalyticsPage() {
{msg &&
{msg}
} + + {showConnect && (
Connect YouTube (read-only)
diff --git a/src/ui/client/ClipDetail.tsx b/src/ui/client/ClipDetail.tsx index ee47053..5b2cfaa 100644 --- a/src/ui/client/ClipDetail.tsx +++ b/src/ui/client/ClipDetail.tsx @@ -391,8 +391,15 @@ export default function ClipDetail() { {clip.transcript_slice && (
- -
{clip.transcript_slice}
+
+ + +
+
{clip.transcript_slice}
)}
diff --git a/src/ui/client/ConfigPage.tsx b/src/ui/client/ConfigPage.tsx index 58c3e6d..aa2b4e5 100644 --- a/src/ui/client/ConfigPage.tsx +++ b/src/ui/client/ConfigPage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; import { PageHeader } from "./Page"; import { api, upload } from "./lib"; +import AiSetup from "./AiSetup"; type SettingRow = { key: string; @@ -54,7 +55,10 @@ export default function ConfigPage() { } catch { /* settings are optional */ } } + const [aiRefresh, setAiRefresh] = useState(0); + async function refreshAiCli() { + setAiRefresh((n) => n + 1); try { setAiCli(await api("/ai-cli-status")); } catch { @@ -169,6 +173,8 @@ export default function ConfigPage() { )} + +
AI CLI
{aiCli ? ( diff --git a/src/ui/client/CopyButton.tsx b/src/ui/client/CopyButton.tsx index 4bb7e12..897f685 100644 --- a/src/ui/client/CopyButton.tsx +++ b/src/ui/client/CopyButton.tsx @@ -1,6 +1,37 @@ import React, { useEffect, useRef, useState } from "react"; import { Copy, Check } from "lucide-react"; +async function copyText(value: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + } catch { + // WebKit and embedded browsers can deny Clipboard API despite localhost. + } + + const field = document.createElement("textarea"); + field.value = value; + field.setAttribute("readonly", ""); + field.style.position = "fixed"; + field.style.opacity = "0"; + field.style.pointerEvents = "none"; + const activeElement = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + document.body.appendChild(field); + let copied = false; + try { + field.select(); + copied = document.execCommand("copy"); + } finally { + field.remove(); + activeElement?.focus({ preventScroll: true }); + } + if (!copied) throw new Error("Clipboard unavailable"); +} + type CopyButtonProps = { text?: string; getText?: () => string; @@ -14,6 +45,7 @@ type CopyButtonProps = { resetMs?: number; style?: React.CSSProperties; onCopied?: () => void; + failedLabel?: string; }; export default function CopyButton({ @@ -21,6 +53,7 @@ export default function CopyButton({ getText, label = "Copy", copiedLabel = "Copied", + failedLabel = "Copy failed", className = "copy-btn", title, disabled = false, @@ -31,6 +64,7 @@ export default function CopyButton({ onCopied, }: CopyButtonProps) { const [copied, setCopied] = useState(false); + const [failed, setFailed] = useState(false); const timerRef = useRef(null); useEffect(() => { @@ -44,32 +78,34 @@ export default function CopyButton({ const value = getText ? getText() : text; if (!value) return; + if (timerRef.current) window.clearTimeout(timerRef.current); try { - await navigator.clipboard.writeText(value); + await copyText(value); + setFailed(false); setCopied(true); onCopied?.(); - - if (timerRef.current) window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(() => setCopied(false), resetMs); } catch { setCopied(false); + setFailed(true); + timerRef.current = window.setTimeout(() => setFailed(false), resetMs); } }; return ( + ))} +
+ {activeLabel} + + ); + } const STYLE_CONFIGS = { branded: { @@ -207,7 +256,34 @@ const onKeyActivate = (fn) => (e) => { ); } - function PhoneCaptionBody({ chunk, activeWordInChunk, cfg }) { + function YouTubeWireframe({ title, playing, progress, currentTime, duration, onTogglePlay }) { + return ( +
+
{title || 'Full episode preview'}
+ {!playing && ( + + )} +
+
+
+ + + {fmt(currentTime)} / {duration ? fmt(duration) : '0:00'} + + + + +
+
+
+ ); + } + + function PhoneCaptionBody({ chunk, activeWordInChunk, cfg, singleLine = false }) { if (!chunk || !chunk.length) return null; const fmt = (w) => (cfg.uppercase ? w.toUpperCase() : w); @@ -240,7 +316,7 @@ const onKeyActivate = (fn) => (e) => { }; // Branded: split chunk into [first 2 words, rest], render as 2 lines. - if (cfg.splitLines) { + if (cfg.splitLines && !singleLine) { const [line1, line2] = splitBrandedLines(chunk); const startIdx2 = line1.length; return ( @@ -287,14 +363,11 @@ const onKeyActivate = (fn) => (e) => { }}>{inner} ); } - return
{inner}
; + return
{inner}
; } - function LivePhonePreview({ videoUrl, videoRef, captionStyle, activeClip, transcriptWords, logoPath, showTikTokFrame, onToggleFrame, clipEnded, onReplay }) { + function useLiveCaptionPreview({ videoUrl, videoRef, captionStyle, activeClip, transcriptWords }) { const cfg = STYLE_CONFIGS[captionStyle] || STYLE_CONFIGS.branded; - const [logoBroken, setLogoBroken] = useState(false); - useEffect(() => { setLogoBroken(false); }, [logoPath]); - const sourcePool = useMemo(() => { const words = selectPreviewWords(transcriptWords, activeClip); return words.length >= 2 ? words : null; @@ -356,6 +429,16 @@ const onKeyActivate = (fn) => (e) => { } } + return { cfg, usingSample, activeChunk, activeWordInChunk }; + } + + function LivePhonePreview({ videoUrl, videoRef, captionStyle, captionPosition, captionFontScale, logoPosition, activeClip, transcriptWords, logoPreviewUrl, showTikTokFrame, onToggleFrame, clipEnded, onReplay }) { + const { cfg, usingSample, activeChunk, activeWordInChunk } = useLiveCaptionPreview({ + videoUrl, videoRef, captionStyle, activeClip, transcriptWords, + }); + const [logoBroken, setLogoBroken] = useState(false); + useEffect(() => { setLogoBroken(false); }, [logoPreviewUrl]); + return ( <>
@@ -382,9 +465,9 @@ const onKeyActivate = (fn) => (e) => { )} - {videoUrl && captionStyle === 'branded' && logoPath && !logoBroken && ( -
- + Logo setLogoBroken(true)} style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> @@ -392,8 +475,8 @@ const onKeyActivate = (fn) => (e) => { )} {videoUrl && (
@@ -427,20 +510,110 @@ const onKeyActivate = (fn) => (e) => { ); } + function LiveYouTubePreview({ videoUrl, videoRef, captionStyle, captionPosition, captionFontScale, logoPosition, transcriptWords, logoPreviewUrl, rendered, title, showYouTubeFrame, onToggleFrame, onBack }) { + const { cfg, usingSample, activeChunk, activeWordInChunk } = useLiveCaptionPreview({ + videoUrl, videoRef, captionStyle, activeClip: null, transcriptWords, + }); + const [logoBroken, setLogoBroken] = useState(false); + const [playing, setPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + useEffect(() => { setLogoBroken(false); }, [logoPreviewUrl]); + useEffect(() => { setPlaying(false); setCurrentTime(0); setDuration(0); }, [videoUrl]); + + const togglePlay = () => { + const video = videoRef?.current; + if (!video) return; + if (video.paused) video.play().catch(() => {}); + else video.pause(); + }; + const progress = duration > 0 ? Math.max(0, Math.min(100, (currentTime / duration) * 100)) : 0; + + return ( +
+
+ {videoUrl ? ( +