diff --git a/.gitignore b/.gitignore index f6693ce..977be1e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dist/ # Data directory data/ .podcli/ +.podcli-home presets/ # Environment diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3468c15 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to podcli + +Thank you for helping improve podcli. This project is AGPL-3.0 — contributions are welcome under the same license. + +## Development setup + +```bash +./setup.sh +npm install +npm run build +``` + +Python backend lives in `backend/`. TypeScript MCP server and Web UI live in `src/`. + +## Project layout + +| Path | Purpose | +| --------------------------------------- | --------------------------------------------------------------- | +| `.podcli/` | Config home (knowledge, presets, assets, settings) — gitignored | +| `data/` | Runtime data (cache, output, working) — gitignored | +| `backend/config/paths.py` | Canonical path resolution (Python) | +| `src/config/paths.ts` | Path resolution for Node (must stay aligned) | +| `backend/config_bundle.py` | Portable profile export/import | +| `backend/services/transcript_packer.py` | Transcript cache keys + packed markdown | + +## Before you open a PR + +1. Run tests: `python3 -m unittest discover -s tests -v` +2. Run TypeScript build: `npm run build` +3. If you change paths, env vars, or cache layout, update `README.md` and add a note to the config migration logic. +4. Keep diffs focused — one feature or fix per PR when possible. + +## Path and cache conventions + +- **Config home** (`PODCLI_HOME` or `.podcli-home` marker): portable settings only. +- **Data dir** (`PODCLI_DATA` or `data/`): transcripts cache, outputs, temp files. +- **Transcript cache**: `data/cache/transcripts/{16-char-hash}.json` — same hash algorithm in Python (`compute_cache_hash`) and TypeScript (`TranscriptCache`). + +Do not reintroduce a separate CLI-only cache path without updating both sides. + +## Adding an integration + +1. Create `backend/services/integrations//` with `IntegrationBase` subclass. +2. Register in that package’s `__init__.py`. +3. Add MCP tool wiring in `src/server.ts` (or a thin handler) and optional UI toggle at `/integrations.html`. + +## Security + +- Config import uses zip path validation (`_safe_extract_zip`). Do not replace with raw `extractall` on untrusted archives. +- Do not commit `.env`, API keys, or personal media under `.podcli/` or `data/`. + +## Questions + +Open a GitHub issue with reproduction steps for bugs, or a short design note for larger features. diff --git a/README.md b/README.md index b332e49..527cea4 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,12 @@ Run `/publish-checklist` when uploading. A week later, run `/retro-episode` with ## The Two Halves -| | Video Engine (podcli core) | Content Workflow (PodStack) | -|---|---|---| -| **What** | Transcription, clip detection, rendering | Titles, descriptions, thumbnails, publishing | -| **How** | Python + FFmpeg + Whisper + OpenCV + Claude/Codex | Claude Code slash commands | -| **Interface** | Web UI, CLI, MCP tools | `/slash-commands` in Claude Code | -| **Output** | `.mp4` files ready to upload | Content packages ready to paste into YouTube | +| | Video Engine (podcli core) | Content Workflow (PodStack) | +| ------------- | ------------------------------------------------- | -------------------------------------------- | +| **What** | Transcription, clip detection, rendering | Titles, descriptions, thumbnails, publishing | +| **How** | Python + FFmpeg + Whisper + OpenCV + Claude/Codex | Claude Code slash commands | +| **Interface** | Web UI, CLI, MCP tools | `/slash-commands` in Claude Code | +| **Output** | `.mp4` files ready to upload | Content packages ready to paste into YouTube | Both halves share the same **knowledge base** (`.podcli/knowledge/`) — your show's brand, voice, title formulas, episode database, and style guide. Set it up once, everything stays on-brand. @@ -118,6 +118,7 @@ Both halves share the same **knowledge base** (`.podcli/knowledge/`) — your sh ## Features ### Video Processing + - **AI clip suggestion** — Claude/Codex-powered moment detection with knowledge base context, multi-cut segments, 4-dimension scoring - **Face tracking** — YuNet face detection, exponential-smoothing camera, split-screen support, speaker-aware tracking with snap cooldown - **Burned-in captions** — 4 styles: branded, hormozi, karaoke, subtle @@ -128,6 +129,7 @@ Both halves share the same **knowledge base** (`.podcli/knowledge/`) — your sh - **Transcript import** — paste `Speaker (MM:SS)`, JSON, drag-drop `.txt` / `.srt` / `.vtt` ### Content Workflow (PodStack) + - **`/process-transcript`** — extract and score best moments from any transcript - **`/generate-titles`** — 8 titles per clip with 6-point verification checklist - **`/generate-descriptions`** — descriptions + hashtags + SEO keywords @@ -138,6 +140,7 @@ Both halves share the same **knowledge base** (`.podcli/knowledge/`) — your sh - **`/retro-episode`** — performance analysis after publishing ### Infrastructure + - **Knowledge base** — `.md` files that teach the AI your brand, voice, and style - **Asset management** — register logos and videos for quick reuse - **Clip history** — tracks everything to avoid duplicates @@ -150,13 +153,13 @@ Both halves share the same **knowledge base** (`.podcli/knowledge/`) — your sh ## Prerequisites -| Tool | Install | -|------|---------| -| **Node.js** >= 18 | [nodejs.org](https://nodejs.org) | -| **Python** >= 3.10 | [python.org](https://python.org) | -| **FFmpeg** | `brew install ffmpeg` / `sudo apt install ffmpeg` | -| **Claude Code** (optional) | [docs.anthropic.com](https://docs.anthropic.com/en/docs/claude-code) — needed for PodStack slash commands | -| **Codex** (optional) | [openai.com/codex](https://openai.com/index/introducing-codex/) — alternative AI engine for clip suggestion (auto-detected if Claude is unavailable) | +| Tool | Install | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Node.js** >= 18 | [nodejs.org](https://nodejs.org) | +| **Python** >= 3.10 | [python.org](https://python.org) | +| **FFmpeg** | `brew install ffmpeg` / `sudo apt install ffmpeg` | +| **Claude Code** (optional) | [docs.anthropic.com](https://docs.anthropic.com/en/docs/claude-code) — needed for PodStack slash commands | +| **Codex** (optional) | [openai.com/codex](https://openai.com/index/introducing-codex/) — alternative AI engine for clip suggestion (auto-detected if Claude is unavailable) | ## Quick Start @@ -257,25 +260,25 @@ Or just paste a transcript — Claude auto-detects the input and runs the right ## Knowledge Base -The knowledge base is what makes podcli understand *your* show. Drop `.md` files into `.podcli/knowledge/` and both the video engine and content workflow use them. The clip suggestion engine reads 8 of these files (prioritized by relevance), checks the episode database for duplicate avoidance, and applies your voice rules and title formulas when generating suggestions. +The knowledge base is what makes podcli understand _your_ show. Drop `.md` files into `.podcli/knowledge/` and both the video engine and content workflow use them. The clip suggestion engine reads 8 of these files (prioritized by relevance), checks the episode database for duplicate avoidance, and applies your voice rules and title formulas when generating suggestions. PodStack ships with **13 starter templates** that you fill in with your show's details: -| File | What It Teaches The AI | -|------|----------------------| -| `00-master-instructions.md` | Auto-detection rules, decision tree, quality gates | -| `01-brand-identity.md` | Show name, positioning, tagline, hosts, format | -| `02-voice-and-tone.md` | Voice fingerprint, banned words, the Coffee Test | -| `03-episodes-database.md` | Episode tracking, existing shorts (for dedup) | +| File | What It Teaches The AI | +| ----------------------------- | ---------------------------------------------------- | +| `00-master-instructions.md` | Auto-detection rules, decision tree, quality gates | +| `01-brand-identity.md` | Show name, positioning, tagline, hosts, format | +| `02-voice-and-tone.md` | Voice fingerprint, banned words, the Coffee Test | +| `03-episodes-database.md` | Episode tracking, existing shorts (for dedup) | | `04-shorts-creation-guide.md` | Moment types, selection criteria, extraction process | -| `05-title-formulas.md` | Title shapes, rules, templates by content type | -| `06-descriptions-template.md` | Description formulas, hashtag library, SEO keywords | -| `07-thumbnail-guide.md` | Layouts, brand colors, typography, visual specs | -| `08-topics-themes.md` | Core topics, cross-cutting themes, audience map | -| `09-content-workflow.md` | End-to-end workflow phases, handoff specs | -| `10-internal-processing.md` | Auto-execution rules, internal quality gates | -| `11-inspiration-channels.md` | Reference channels, viral hooks, hybrid formulas | -| `12-quick-reference.md` | Copy-paste hooks, hashtags, CTAs, checklists | +| `05-title-formulas.md` | Title shapes, rules, templates by content type | +| `06-descriptions-template.md` | Description formulas, hashtag library, SEO keywords | +| `07-thumbnail-guide.md` | Layouts, brand colors, typography, visual specs | +| `08-topics-themes.md` | Core topics, cross-cutting themes, audience map | +| `09-content-workflow.md` | End-to-end workflow phases, handoff specs | +| `10-internal-processing.md` | Auto-execution rules, internal quality gates | +| `11-inspiration-channels.md` | Reference channels, viral hooks, hybrid formulas | +| `12-quick-reference.md` | Copy-paste hooks, hashtags, CTAs, checklists | Manage via the web UI at `/knowledge.html` (drag & drop, inline editor) or through the `knowledge_base` MCP tool. @@ -311,36 +314,36 @@ Run `./setup.sh --mcp` to get the exact config with your paths filled in. ### MCP Tools -| Tool | Description | -|------|-------------| -| `transcribe_podcast` | Transcribe audio/video with Whisper + speaker detection | -| `suggest_clips` | Submit clip suggestions (includes duplicate check) | -| `create_clip` | Render a single short-form clip as a vertical short | -| `batch_create_clips` | Render multiple clips in one batch | -| `knowledge_base` | Read/manage podcast context files (hosts, style, audience, etc.) | -| `manage_assets` | Register/list reusable assets (logos, videos) | -| `clip_history` | View previously created clips, check for duplicates | -| `get_ui_state` | Read current session state and get workflow next-step guidance | -| `modify_clip` | Adjust a suggested clip's timing, title, or caption style (or delete it) | -| `toggle_clip` | Select or deselect a suggested clip for export | -| `update_settings` | Update rendering settings (caption style, crop strategy, logo, outro) | -| `list_outputs` | List all rendered clip files in the output directory | -| `manage_presets` | Save, load, list, or delete rendering presets | -| `analyze_energy` | Analyze audio energy levels to find high-energy moments | -| `set_video` | Set the working video file without transcribing | -| `import_transcript` | Import an external transcript with word-level timestamps (skips Whisper) | -| `parse_transcript` | Parse raw speaker-labeled plain text into word-level timestamps | +| Tool | Description | +| -------------------- | ------------------------------------------------------------------------ | +| `transcribe_podcast` | Transcribe audio/video with Whisper + speaker detection | +| `suggest_clips` | Submit clip suggestions (includes duplicate check) | +| `create_clip` | Render a single short-form clip as a vertical short | +| `batch_create_clips` | Render multiple clips in one batch | +| `knowledge_base` | Read/manage podcast context files (hosts, style, audience, etc.) | +| `manage_assets` | Register/list reusable assets (logos, videos) | +| `clip_history` | View previously created clips, check for duplicates | +| `get_ui_state` | Read current session state and get workflow next-step guidance | +| `modify_clip` | Adjust a suggested clip's timing, title, or caption style (or delete it) | +| `toggle_clip` | Select or deselect a suggested clip for export | +| `update_settings` | Update rendering settings (caption style, crop strategy, logo, outro) | +| `list_outputs` | List all rendered clip files in the output directory | +| `manage_presets` | Save, load, list, or delete rendering presets | +| `analyze_energy` | Analyze audio energy levels to find high-energy moments | +| `set_video` | Set the working video file without transcribing | +| `import_transcript` | Import an external transcript with word-level timestamps (skips Whisper) | +| `parse_transcript` | Parse raw speaker-labeled plain text into word-level timestamps | --- ## Caption Styles -| Style | Look | -|-------|------| +| Style | Look | +| ----------- | ----------------------------------------------------------------------------------- | | **branded** | Large bold text, dark box highlight on active word, gradient overlay, optional logo | -| **hormozi** | Bold uppercase pop-on text, yellow active word (Alex Hormozi style) | -| **karaoke** | Full sentence visible, words highlight progressively | -| **subtle** | Clean minimal white text at bottom | +| **hormozi** | Bold uppercase pop-on text, yellow active word (Alex Hormozi style) | +| **karaoke** | Full sentence visible, words highlight progressively | +| **subtle** | Clean minimal white text at bottom | --- @@ -393,28 +396,60 @@ podcli/ │ └── config/ │ └── caption_styles.py │ -└── .podcli/ # local data (gitignored) - ├── knowledge/ # .md context files for AI (13 templates) - ├── assets/ # registered logos, videos - ├── cache/transcripts/ # cached transcriptions - ├── history/ # generated clip history +├── .podcli/ # config home (gitignored) — knowledge, presets, assets +│ ├── knowledge/ +│ ├── assets/ +│ ├── presets/ +│ └── history/ +└── data/ # runtime data (gitignored) — cache, output, working + ├── cache/ # CLI transcription cache + remotion bundle + │ └── transcripts/ # MCP/UI transcript cache ├── output/ # rendered clips - ├── presets/ # saved configurations - └── working/ # temp files + └── working/ # temp uploads and task dirs ``` ## Configuration Copy `.env.example` to `.env` (setup.sh does this automatically): -| Variable | Default | Description | -|----------|---------|-------------| -| `WHISPER_MODEL` | `base` | Whisper model size (tiny, base, small, medium, large) | -| `WHISPER_DEVICE` | `auto` | `cpu`, `cuda`, or `auto` | -| `PYTHON_PATH` | (venv) | Path to Python binary | -| `PODCLI_HOME` | `.podcli/` | Data directory (relative to project root) | -| `FFMPEG_PATH` | `ffmpeg` | Custom FFmpeg path | -| `LOG_LEVEL` | `info` | Logging verbosity | +| Variable | Default | Description | +| ---------------- | ---------- | ----------------------------------------------------- | +| `WHISPER_MODEL` | `base` | Whisper model size (tiny, base, small, medium, large) | +| `WHISPER_DEVICE` | `auto` | `cpu`, `cuda`, or `auto` | +| `PYTHON_PATH` | (venv) | Path to Python binary | +| `PODCLI_HOME` | `.podcli/` | Config home (knowledge, presets, assets, settings) | +| `PODCLI_DATA` | `data/` | Runtime data (cache, output, working, logs) | +| `FFMPEG_PATH` | `ffmpeg` | Custom FFmpeg path | +| `LOG_LEVEL` | `info` | Logging verbosity | + +### Config profiles (multi-show / multi-machine) + +Portable bundles zip your config home (not cache or rendered clips): + +```bash +podcli config export ~/backups/myshow.zip +podcli config import ~/backups/myshow.zip --home ~/.podcli-myshow --activate +podcli config status +``` + +Activate a config root without importing: `podcli config use ~/.podcli-myshow` (writes `.podcli-home` in the project). + +### Upgrading from older layouts + +Older releases stored transcription cache under `project/.podcli/cache/` (now `data/cache/`) and presets under `project/presets/` (now `.podcli/presets/`). After upgrading, migration runs automatically when legacy files are still present (CLI, Web UI, MCP). To preview or run manually: + +```bash +podcli config migrate --dry-run # preview only +podcli config migrate # apply (same as auto when legacy cache exists) +``` + +**One source of truth:** settings live in **config home** (`PODCLI_HOME` or `.podcli/`, tracked by `.podcli-home`); heavy/runtime files live under **data** (`PODCLI_DATA` or `data/`). The marker file only points at which config home is active — it does not replace either root. + +MCP: `manage_config(action=migrate)`. + +Web UI: [Config profiles](http://localhost:3847/config.html) (when `npm run ui` is running). + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development conventions. ## Transcript Format diff --git a/backend/cli.py b/backend/cli.py index 995b31f..140d892 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -14,23 +14,15 @@ import json import os import shutil +import sys +import time -# Load .env file (for HF_TOKEN, etc.) +_env_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env") try: from dotenv import load_dotenv - load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env")) + load_dotenv(_env_file) except ImportError: pass -import sys -import time - -# Suppress macOS ObjC duplicate class warnings from OpenCV's bundled dylibs -os.environ["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES" -if sys.platform == "darwin": - os.environ.setdefault("DYLD_LIBRARY_PATH", "") - -# Load .env file into os.environ (HF_TOKEN, PODCLI_QUALITY, etc.) -_env_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env") if os.path.exists(_env_file): with open(_env_file) as _f: for _line in _f: @@ -41,15 +33,99 @@ if _key and _val: os.environ.setdefault(_key, _val) -# Quality defaults (can be overridden via .env or shell env). -# Keep transition autofix enabled by default with a strict hard cap in renderer. +os.environ["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES" +if sys.platform == "darwin": + os.environ.setdefault("DYLD_LIBRARY_PATH", "") os.environ.setdefault("PODCLI_TRANSITION_AUTOFIX_PASSES", "2") -# Add parent dir to path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from config.paths import paths from presets import MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX +def _suggestions_session_path(cache_hash: str) -> str: + return os.path.join(paths["home"], "sessions", f"clips-{cache_hash}.json") + + +def _load_suggestions_session(cache_hash: str, top_n: int) -> list | None: + if not cache_hash: + return None + path = _suggestions_session_path(cache_hash) + if not os.path.exists(path): + return None + try: + with open(path, encoding="utf-8") as f: + payload = json.load(f) + except Exception: + return None + if not isinstance(payload, dict) or payload.get("top_n") != top_n: + return None + clips = payload.get("clips") + if not isinstance(clips, list) or not clips: + return None + return clips + + +def _save_suggestions_session(cache_hash: str, top_n: int, engine: str | None, clips: list) -> None: + if not cache_hash or not clips: + return + path = _suggestions_session_path(cache_hash) + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump( + {"cache_hash": cache_hash, "top_n": top_n, "engine": engine, "clips": clips}, + f, + ) + except Exception: + pass + + +def _clear_suggestions_session(cache_hash: str) -> None: + if not cache_hash: + return + path = _suggestions_session_path(cache_hash) + try: + if os.path.exists(path): + os.unlink(path) + except Exception: + pass + + +def _should_auto_migrate_cli(args) -> bool: + if getattr(args, "show_help", False): + return False + if args.command == "config": + action = getattr(args, "config_action", None) or "status" + if action == "status": + return False + if action == "migrate" and getattr(args, "dry_run", False): + return False + return True + + +def _auto_migrate_cli(args) -> None: + if not _should_auto_migrate_cli(args): + return + from config_bundle import auto_migrate_legacy_if_pending + + summary = auto_migrate_legacy_if_pending(quiet=True) + if not summary: + return + moved_cache = summary.get("moved_json") or summary.get("moved_remotion_bundle") + moved_presets = (summary.get("presets_migration") or {}).get("moved") + if moved_cache or moved_presets: + gray = "\033[38;5;245m" + green = "\033[38;2;74;222;128m" + reset = "\033[0m" + if moved_cache: + print(f" {green}✓{reset} {gray}Migrated legacy cache → {summary.get('target_dir')}{reset}") + if moved_presets: + pm = summary.get("presets_migration") or {} + print(f" {green}✓{reset} {gray}Migrated presets → {pm.get('target_dir')}{reset}") + print() + + def _thumbnail_lead_timestamp(start_second: float, frame_offset: float = 1 / 30) -> float: """Timestamp for the still frame that leads into a rendered clip.""" try: @@ -326,42 +402,25 @@ def cmd_process(args): print(f" Video: {os.path.basename(video_path)}") print() + # Cache hash for resume — keyed by video size+mtime, shared with transcript cache. + cache_hash = "" + try: + from services.transcript_packer import compute_cache_hash as _compute_cache_hash + + cache_hash = _compute_cache_hash(video_path) + except Exception: + cache_hash = "" + # ── Step 1: Get transcript ── transcript = None words = [] segments = [] result = {} - # Transcription cache: keyed by video file path + size + mtime. - # Saves ~2-5 min on re-runs by skipping Whisper + speaker detection. - import hashlib - cache_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".podcli", "cache") - - def _cache_key(path): - stat = os.stat(path) - raw = f"{os.path.abspath(path)}:{stat.st_size}:{stat.st_mtime}" - return hashlib.md5(raw.encode()).hexdigest() - - def _load_cache(path): - try: - key = _cache_key(path) - cache_file = os.path.join(cache_dir, f"{key}.json") - if os.path.exists(cache_file): - with open(cache_file) as f: - return json.load(f) - except Exception: - pass - return None - - def _save_cache(path, data): - try: - os.makedirs(cache_dir, exist_ok=True) - key = _cache_key(path) - cache_file = os.path.join(cache_dir, f"{key}.json") - with open(cache_file, "w") as f: - json.dump(data, f) - except Exception: - pass + from services.transcript_packer import ( + load_cached_transcript_for_video, + save_cached_transcript_for_video, + ) if args.transcript: print(" [1/4] Loading transcript...") @@ -390,7 +449,7 @@ def _save_cache(path, data): print(f" Parsed: {len(segments)} segments, {len(words)} words") else: # Check cache first - cached = _load_cache(video_path) + cached = load_cached_transcript_for_video(video_path) if cached and not config.get("no_cache", False): print(" [1/4] Loaded from cache (instant)") words = cached["words"] @@ -437,7 +496,7 @@ def _transcribe_progress(pct, msg): print(f" Done: {len(segments)} segments, {len(words)} words") # Save to cache for next run - _save_cache(video_path, result) + save_cached_transcript_for_video(video_path, result) # Apply word corrections (Whisper misheard proper nouns, brand names) from services.corrections import apply_corrections @@ -478,12 +537,20 @@ def _transcribe_progress(pct, msg): # ── Step 3: Score and select clips ── top_n = config.get("top_clips", 5) clips = None + resumed_from_session = False + + if not getattr(args, "no_resume", False): + resumed = _load_suggestions_session(cache_hash, top_n) + if resumed: + print(f" [3/4] Resumed {len(resumed)} cached suggestions (use --no-resume to regenerate)") + clips = resumed + resumed_from_session = True # Try an AI CLI first (uses PodStack knowledge base for intelligent selection) from services.claude_suggest import suggest_initial_with_claude, _engine_label, _find_ai_cli ai_path, ai_engine = _find_ai_cli() - if ai_path and config.get("ai_select", True): + if not clips and ai_path and config.get("ai_select", True): ai_label = _engine_label(ai_engine) print(f" [3/4] Selecting moments with {ai_label} (PodStack)...") clips = suggest_initial_with_claude( @@ -494,6 +561,7 @@ def _transcribe_progress(pct, msg): if clips: 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") + _save_suggestions_session(cache_hash, top_n, actual_engine, clips) else: print(" ⚠ AI CLI unavailable, falling back to heuristics") elif not config.get("ai_select", True): @@ -511,6 +579,8 @@ def _transcribe_progress(pct, msg): min_dur=config.get("min_clip_duration", MIN_CLIP_DURATION), max_dur=config.get("max_clip_duration", MAX_CLIP_DURATION), ) + if clips and not resumed_from_session: + _save_suggestions_session(cache_hash, top_n, "heuristic", clips) if not clips: print(" No clips found. Try a longer transcript or lower --min-duration.", file=sys.stderr) @@ -527,7 +597,7 @@ def _transcribe_progress(pct, msg): # Check if thumbnail generation is enabled thumb_dir = os.path.join(output_dir, "thumbnails") _thumb_intro_duration = 0.8 - _tc_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".podcli", "thumbnail-config.json") + _tc_path = paths["thumbnailConfig"] # Opt-in: a brand config must exist before podcli generates thumbnails. # New users get no auto-thumbnails until they run `podcli init-thumbnail` @@ -623,7 +693,7 @@ def _transcribe_progress(pct, msg): output_path=os.path.join(clip_thumb_dir, "_lead_frame.jpg"), start_second=result.get("start_second", clip.get("start_second", 0)), ) - paths = _thumb_gen( + thumb_paths = _thumb_gen( title=clip.get("title", f"Clip {i+1}"), output_dir=clip_thumb_dir, photo_path=lead_frame or _thumb_photo, @@ -632,16 +702,16 @@ def _transcribe_progress(pct, msg): end_second=result.get("end_second", clip.get("end_second")), logo_path=_thumb_logo, ) - if paths: + if thumb_paths: thumb_video = os.path.join(clip_thumb_dir, "thumb_frame.mp4") - _thumb_to_video(paths[0], thumb_video, duration=_thumb_intro_duration) + _thumb_to_video(thumb_paths[0], thumb_video, duration=_thumb_intro_duration) from services.video_processor import concat_outro final_with_thumb = result["output_path"].replace(".mp4", "_with_thumb.mp4") # Hard cut — thumbnail uses the frame just before content, # so playback feels like the clip has started. concat_outro(thumb_video, result["output_path"], final_with_thumb, crossfade_duration=0.0) os.replace(final_with_thumb, result["output_path"]) - print(f" + thumbnail prepended ({_thumb_intro_duration:.2f}s, {len(paths)} variations in {os.path.basename(clip_thumb_dir)}/)") + print(f" + thumbnail prepended ({_thumb_intro_duration:.2f}s, {len(thumb_paths)} variations in {os.path.basename(clip_thumb_dir)}/)") except Exception as e: print(f" ⚠ thumbnail: {e}") @@ -1982,7 +2052,7 @@ def cmd_init_thumbnail(args): repo_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") example_path = os.path.abspath(os.path.join(repo_root, "docs", "thumbnail-config.example.json")) - target_dir = os.path.abspath(os.path.join(repo_root, ".podcli")) + target_dir = paths["home"] target_path = os.path.join(target_dir, "thumbnail-config.json") if not os.path.exists(example_path): @@ -2248,8 +2318,8 @@ def cmd_corrections(args): def cmd_knowledge(args): - """Manage knowledge base files (.podcli/knowledge/).""" - kb_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".podcli", "knowledge") + """Manage knowledge base files.""" + kb_dir = paths["knowledge"] accent = "\033[38;2;212;135;74m" gray = "\033[38;5;245m" @@ -2339,9 +2409,105 @@ def cmd_knowledge(args): print(f" {red}✗{reset} Not found: {name}", file=sys.stderr) +def _print_config_result(action: str, data: dict) -> None: + accent = "\033[38;2;212;135;74m" + gray = "\033[38;5;245m" + green = "\033[38;2;74;222;128m" + bold = "\033[1m" + reset = "\033[0m" + + if action == "status": + yellow = "\033[38;2;250;204;21m" + print(f"\n {bold}Paths (two roots){reset}") + print(f" {gray}config home{reset}: {accent}{data.get('home')}{reset}") + print(f" {gray}→ knowledge, presets, assets, corrections, integrations{reset}") + print(f" {gray}data cache{reset}: {data.get('cache')}") + print(f" {gray}→ transcripts, remotion bundle (override with PODCLI_DATA){reset}") + print(f" {gray}profile marker{reset}: {data.get('profile_marker')}") + if data.get("legacy_cache_pending"): + print(f" {yellow}legacy cache{reset}: project/.podcli/cache still has files — run Migrate below") + else: + print(f" {green}✓{reset} legacy cache: nothing pending under project/.podcli/cache") + if data.get("legacy_presets_pending"): + print(f" {yellow}legacy presets{reset}: project/presets/ still has files — run Migrate below") + else: + print(f" {green}✓{reset} legacy presets: nothing pending under project/presets/") + print() + return + + if action == "migrate": + print(f"\n {bold}Legacy migration{reset}") + print(f" {gray}cache{reset}") + print(f" {gray}from{reset}: {data.get('legacy_dir')}") + print(f" {gray}to{reset}: {data.get('target_dir')}") + print(f" {gray}moved json{reset}: {data.get('moved_json')}") + if data.get("skipped_json"): + print(f" {gray}skipped{reset}: {data['skipped_json']} (already in target)") + if data.get("moved_remotion_bundle"): + print(f" {gray}remotion{reset}: bundle moved") + if data.get("removed_duplicate_remotion_bundle"): + print(f" {gray}remotion{reset}: removed duplicate legacy bundle") + presets = data.get("presets_migration") or {} + if presets: + print(f" {gray}presets{reset}") + print(f" {gray}from{reset}: {presets.get('legacy_dir')}") + print(f" {gray}to{reset}: {presets.get('target_dir')}") + print(f" {gray}moved{reset}: {presets.get('moved')}") + if presets.get("skipped"): + print(f" {gray}skipped{reset}: {presets['skipped']} (already in target)") + if not data.get("dry_run"): + print(f"\n {green}✓{reset} Migration complete") + print() + return + + if action == "export": + print(f"\n {green}✓{reset} Exported config bundle") + print(f" {gray}{data.get('bundle')}{reset}") + print(f" {gray}assets:{reset} {data.get('asset_count')}") + print() + return + + if action == "import": + print(f"\n {green}✓{reset} Imported config bundle") + print(f" {gray}{data.get('home')}{reset}") + if data.get("activated"): + print(f" {gray}activated{reset}: yes") + if data.get("backup"): + print(f" {gray}backup{reset}: {data['backup']}") + print() + return + + if action == "use": + print(f"\n {green}✓{reset} Activated config root") + print(f" {gray}{data.get('home')}{reset}\n") + + +def cmd_config(args): + """Export, import, and activate config profiles.""" + from config_bundle import run_config_action + + yellow = "\033[38;2;250;204;21m" + reset = "\033[0m" + action = getattr(args, "config_action", None) or "status" + + try: + data = run_config_action( + action, + bundle_path=getattr(args, "bundle", None), + home=getattr(args, "home", None), + activate=getattr(args, "activate", False), + dry_run=getattr(args, "dry_run", False), + ) + except ValueError as e: + print(f" {yellow}✗{reset} {e}", file=sys.stderr) + sys.exit(1) + + _print_config_result(action, data) + + def cmd_cache(args): """Manage transcription cache.""" - cache_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".podcli", "cache") + cache_dir = paths["cache"] accent = "\033[38;2;212;135;74m" gray = "\033[38;5;245m" @@ -2352,10 +2518,19 @@ def cmd_cache(args): action = getattr(args, "cache_action", None) or "status" if action == "clear": - if os.path.exists(cache_dir): - import shutil - count = len([f for f in os.listdir(cache_dir) if f.endswith(".json")]) - shutil.rmtree(cache_dir) + count = 0 + if os.path.isdir(cache_dir): + for fname in os.listdir(cache_dir): + if fname.endswith(".json"): + os.unlink(os.path.join(cache_dir, fname)) + count += 1 + transcripts_dir = os.path.join(cache_dir, "transcripts") + if os.path.isdir(transcripts_dir): + for fname in os.listdir(transcripts_dir): + if fname.endswith(".json"): + os.unlink(os.path.join(transcripts_dir, fname)) + count += 1 + if count: print(f"\n {green}✓{reset} Cleared {count} cached transcription(s)") else: print(f"\n {gray}Cache is already empty{reset}") @@ -2371,6 +2546,13 @@ def cmd_cache(args): return files = [f for f in os.listdir(cache_dir) if f.endswith(".json")] + transcripts_dir = os.path.join(cache_dir, "transcripts") + if os.path.isdir(transcripts_dir): + files.extend( + os.path.join("transcripts", f) + for f in os.listdir(transcripts_dir) + if f.endswith(".json") + ) if not files: print(f" {gray}Empty — no cached transcriptions{reset}\n") return @@ -2471,7 +2653,7 @@ def print_banner(): encoder_label = "CPU" # Count knowledge base files - kb_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".podcli", "knowledge") + kb_path = paths["knowledge"] kb_count = len([f for f in os.listdir(kb_path) if f.endswith(".md")]) if os.path.isdir(kb_path) else 0 gray = "\033[38;5;245m" @@ -2503,7 +2685,7 @@ def print_banner(): print(f" {bold}podcli{reset} v{VERSION}") # Cache info - cache_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".podcli", "cache") + cache_dir = paths["cache"] cache_count = 0 if os.path.isdir(cache_dir): cache_count = len([f for f in os.listdir(cache_dir) if f.endswith(".json")]) @@ -2584,6 +2766,7 @@ def print_help(): print(f" {accent}presets{reset} {gray}{reset} Save/load rendering presets") print(f" {accent}thumbnails{reset} {gray}{reset} Generate thumbnail variations") print(f" {accent}knowledge{reset} {gray}<action>{reset} Manage knowledge base (.podcli/knowledge/)") + print(f" {accent}config{reset} {gray}<action>{reset} Export/import/migrate config profiles") print(f" {accent}corrections{reset} {gray}<action>{reset} Fix Whisper misheard words (Boxel→Voxel)") print(f" {accent}cache{reset} {gray}[clear]{reset} Show/clear transcription cache") print(f" {accent}info{reset} Show system info (encoder, codecs)") @@ -2653,6 +2836,7 @@ def main(): proc.add_argument("--no-energy", action="store_true", help="Skip audio energy analysis") proc.add_argument("--no-speakers", action="store_true", help="Skip speaker detection (faster, uses face detection only)") proc.add_argument("--no-cache", action="store_true", help="Force re-transcription (ignore cached transcript)") + proc.add_argument("--no-resume", action="store_true", help="Ignore cached AI suggestions for this video and regenerate") proc.add_argument("--quality", choices=["low", "medium", "high", "max"], help="Output quality (default: high)") proc.add_argument("--allow-ass-fallback", action="store_true", help="Use ASS captions if Remotion rendering fails") proc.add_argument("--review-each", action="store_true", help="Review each rendered clip interactively") @@ -2749,6 +2933,22 @@ def main(): kb_del = kb_sub.add_parser("delete", help="Delete a knowledge file") kb_del.add_argument("filename", help="File name to delete") + # ── config ── + cfg = sub.add_parser("config", help="Export, import, and activate config profiles") + cfg_sub = cfg.add_subparsers(dest="config_action") + cfg_sub.add_parser("status", help="Show the active config root") + cfg_migrate = cfg_sub.add_parser("migrate", help="Move legacy .podcli/cache into data/cache") + cfg_migrate.add_argument("--dry-run", action="store_true", help="Show what would be moved without changing files") + cfg_export = cfg_sub.add_parser("export", help="Export the active config root to a zip bundle") + cfg_export.add_argument("bundle", help="Output .zip bundle path") + cfg_export.add_argument("--home", help="Export from a specific config root instead of the active one") + cfg_import = cfg_sub.add_parser("import", help="Import a config bundle into a config root") + cfg_import.add_argument("bundle", help="Input .zip bundle path") + cfg_import.add_argument("--home", help="Import into a specific config root") + cfg_import.add_argument("--activate", action="store_true", help="Make the imported config root active") + cfg_use = cfg_sub.add_parser("use", help="Activate a config root for future runs") + cfg_use.add_argument("home", help="Path to the config root to activate") + # ── cache ── cache_p = sub.add_parser("cache", help="Manage transcription cache") cache_sub = cache_p.add_subparsers(dest="cache_action") @@ -2770,6 +2970,8 @@ def main(): args = parser.parse_args() + _auto_migrate_cli(args) + if getattr(args, "show_help", False) and args.command is None: print_help() return @@ -2790,6 +2992,8 @@ def main(): cmd_corrections(args) elif args.command == "knowledge": cmd_knowledge(args) + elif args.command == "config": + cmd_config(args) elif args.command == "cache": cmd_cache(args) elif args.command == "info": @@ -2843,6 +3047,7 @@ def interactive_menu(): questionary.Choice("Presets", value="presets"), questionary.Choice("Assets", value="assets"), questionary.Choice("Knowledge base", value="knowledge"), + questionary.Choice("Config profiles", value="config"), questionary.Choice("Corrections", value="corrections"), questionary.Separator(), questionary.Choice("Thumbnails", value="thumbnails"), @@ -2873,6 +3078,8 @@ def interactive_menu(): _interactive_presets() elif choice == "knowledge": _interactive_knowledge() + elif choice == "config": + _interactive_config() elif choice == "corrections": _interactive_corrections() elif choice == "thumbnails": @@ -3291,6 +3498,97 @@ def _interactive_process(): sys.exit(_sp.call(cmd)) +def _interactive_config(): + """Interactive config profiles: status, migrate, export/import, switch home.""" + import argparse as _ap + import questionary + from questionary import Style + from config_bundle import run_config_action + + gray = "\033[38;5;245m" + dim = "\033[2m" + reset = "\033[0m" + + qstyle = Style([ + ("qmark", "fg:#d4874a bold"), + ("question", "bold"), + ("answer", "fg:#4ade80"), + ("pointer", "fg:#d4874a bold"), + ("highlighted", "fg:#d4874a bold"), + ("selected", "fg:#4ade80"), + ("instruction", "fg:#a1a1aa"), + ]) + + while True: + _print_config_result("status", run_config_action("status")) + + choices = [ + questionary.Choice("Migrate legacy cache → data/cache", value="migrate"), + questionary.Choice("Export config bundle (.zip)", value="export"), + questionary.Choice("Import config bundle (.zip)", value="import"), + questionary.Choice("Switch active config home", value="use"), + questionary.Choice("Open Web UI config page", value="webui"), + questionary.Choice("← Back", value="_back"), + ] + + action = questionary.select("Config profiles:", choices=choices, style=qstyle).ask() + if action is None or action == "_back": + return + + if action == "migrate": + if questionary.confirm("Preview migration (dry run)?", default=True, style=qstyle).ask(): + cmd_config(_ap.Namespace(config_action="migrate", dry_run=True)) + if questionary.confirm("Run migration now?", default=True, style=qstyle).ask(): + cmd_config(_ap.Namespace(config_action="migrate", dry_run=False)) + continue + + if action == "export": + bundle = questionary.text("Bundle path (.zip):", style=qstyle).ask() + if not bundle: + continue + bundle = _clean_path(bundle) + cmd_config(_ap.Namespace(config_action="export", bundle=bundle, home=None, activate=False, dry_run=False)) + continue + + if action == "import": + bundle = questionary.text("Bundle path (.zip):", style=qstyle).ask() + if not bundle: + continue + bundle = _clean_path(bundle) + home = questionary.text( + "Import into (leave empty = active home):", + style=qstyle, + ).ask() + home = _clean_path(home) if home else None + activate = questionary.confirm("Activate this home after import?", default=True, style=qstyle).ask() + cmd_config(_ap.Namespace( + config_action="import", + bundle=bundle, + home=home, + activate=bool(activate), + dry_run=False, + )) + continue + + if action == "use": + home = questionary.text("Config home path:", style=qstyle).ask() + if not home: + continue + cmd_config(_ap.Namespace(config_action="use", bundle=None, home=_clean_path(home), activate=False, dry_run=False)) + continue + + if action == "webui": + import webbrowser + + port = os.environ.get("PORT", "3847") + url = f"http://localhost:{port}/config.html" + print(f"\n {gray}Config UI:{reset} {url}") + print(f" {dim}Start the UI first if needed: npm run ui{reset}\n") + webbrowser.open(url) + questionary.press_any_key_to_continue(style=qstyle).ask() + continue + + def _interactive_cache(): """Interactive cache management using questionary.""" import argparse as _ap @@ -3546,7 +3844,7 @@ def _interactive_knowledge(): ("instruction", "fg:#a1a1aa"), ]) - kb_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".podcli", "knowledge") + kb_dir = paths["knowledge"] files = sorted(f for f in os.listdir(kb_dir) if f.endswith(".md")) if os.path.isdir(kb_dir) else [] # Show current files diff --git a/backend/config/paths.py b/backend/config/paths.py new file mode 100644 index 0000000..935adc5 --- /dev/null +++ b/backend/config/paths.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _marker_path() -> Path: + return _project_root() / ".podcli-home" + + +def _read_home_marker() -> str | None: + marker = _marker_path() + if not marker.exists(): + return None + try: + value = marker.read_text(encoding="utf-8").strip() + except OSError: + return None + return value or None + + +def _resolve_home() -> Path: + env_home = os.environ.get("PODCLI_HOME") + if env_home: + return Path(env_home).expanduser().resolve() + + marker_home = _read_home_marker() + if marker_home: + marker_path = Path(marker_home).expanduser() + if not marker_path.is_absolute(): + marker_path = (_project_root() / marker_path).resolve() + else: + marker_path = marker_path.resolve() + return marker_path + + return (_project_root() / ".podcli").resolve() + + +home = _resolve_home() +project_root = _project_root() +data_dir = Path(os.environ.get("PODCLI_DATA", str(project_root / "data"))).expanduser().resolve() + +paths = { + "home": str(home), + "project_root": str(project_root), + "cache": str(data_dir / "cache"), + "transcripts": str(data_dir / "cache" / "transcripts"), + "packed": str(home / "packed"), + "working": str(data_dir / "working"), + "output": str(data_dir / "output"), + "logs": str(data_dir / "logs"), + "assets": str(home / "assets"), + "assetsRegistry": str(home / "assets" / "registry.json"), + "history": str(home / "history"), + "clipsHistory": str(home / "history" / "clips.json"), + "knowledge": str(home / "knowledge"), + "uiState": str(home / "ui-state.json"), + "thumbnailConfig": str(home / "thumbnail-config.json"), + "corrections": str(home / "corrections.json"), + "integrations": str(home / "integrations.json"), + "profileMarker": str(_marker_path()), +} + diff --git a/backend/config_bundle.py b/backend/config_bundle.py new file mode 100644 index 0000000..a8c05b9 --- /dev/null +++ b/backend/config_bundle.py @@ -0,0 +1,571 @@ +from __future__ import annotations + +import json +import os +import re +import shutil +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from config.paths import paths + +MIGRATION_MARKER_NAME = ".paths-migrated-v1" + + +MANAGED_FILES = [ + "thumbnail-config.json", + "corrections.json", + "ui-state.json", + "integrations.json", +] + +MANAGED_DIRS = [ + "knowledge", + "presets", + "history", + "sessions", + "packed", +] + +ASSET_ARCHIVE_DIR = "assets/files" +ASSET_PATH_KEYS = { + "logo_path", + "outro_path", + "logoPath", + "outroPath", +} + + +def _safe_part(value: str) -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip()) + safe = safe.strip("._") + return safe or "asset" + + +def _home_path() -> Path: + return Path(paths["home"]) + + +def _marker_path() -> Path: + return Path(paths["profileMarker"]) + + +def _data_dir() -> Path: + return Path(paths["cache"]).parent + + +def _migration_marker_path() -> Path: + return _data_dir() / MIGRATION_MARKER_NAME + + +def _legacy_cache_dir() -> Path: + return Path(paths["project_root"]) / ".podcli" / "cache" + + +def _legacy_presets_dir() -> Path: + return Path(paths["project_root"]) / "presets" + + +def _legacy_presets_has_content() -> bool: + legacy = _legacy_presets_dir() + return legacy.is_dir() and any(legacy.glob("*.json")) + + +def _legacy_migration_pending() -> bool: + return _legacy_cache_has_content() or _legacy_presets_has_content() + + +def _read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + + +def _write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _iter_files(root: Path) -> list[Path]: + if not root.exists(): + return [] + return [p for p in root.rglob("*") if p.is_file()] + + +def _collect_asset_paths(value: Any) -> list[str]: + paths_found: list[str] = [] + if isinstance(value, dict): + for key, child in value.items(): + if key in ASSET_PATH_KEYS and isinstance(child, str) and child: + paths_found.append(child) + else: + paths_found.extend(_collect_asset_paths(child)) + elif isinstance(value, list): + for child in value: + paths_found.extend(_collect_asset_paths(child)) + return paths_found + + +def _archive_name_for(index: int, label: str, source: Path) -> str: + return f"{index:02d}_{_safe_part(label)}_{_safe_part(source.name)}" + + +def export_config(bundle_path: str, source_home: str | None = None) -> dict[str, Any]: + home = Path(source_home).expanduser().resolve() if source_home else _home_path() + if not home.exists(): + raise FileNotFoundError(f"Config home not found: {home}") + + bundle = Path(bundle_path).expanduser().resolve() + bundle.parent.mkdir(parents=True, exist_ok=True) + + manifest = { + "version": 1, + "created_at": datetime.now(timezone.utc).isoformat(), + "source_home": str(home), + "managed_files": MANAGED_FILES, + "managed_dirs": MANAGED_DIRS, + "asset_archive_dir": ASSET_ARCHIVE_DIR, + } + + path_map: dict[str, str] = {} + + with zipfile.ZipFile(bundle, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for file_name in MANAGED_FILES: + src = home / file_name + if src.exists(): + zf.write(src, arcname=file_name) + + for dir_name in MANAGED_DIRS: + root = home / dir_name + for file_path in _iter_files(root): + zf.write(file_path, arcname=str(file_path.relative_to(home))) + + registry_source = home / "assets" / "registry.json" + raw_registry = _read_json(registry_source) + raw_assets = raw_registry.get("assets", []) if isinstance(raw_registry, dict) else [] + registry_export = [] + for index, item in enumerate(raw_assets): + if not isinstance(item, dict): + continue + source = Path(str(item.get("path", ""))).expanduser() + if not source.exists(): + continue + archive_name = _archive_name_for(index, str(item.get("name", "asset")), source) + archive_path = f"{ASSET_ARCHIVE_DIR}/{archive_name}" + zf.write(source, arcname=archive_path) + registry_export.append({**item, "path": archive_path}) + path_map[str(source.resolve())] = archive_path + + extra_sources: list[Path] = [] + for rel in ["ui-state.json"]: + src = home / rel + if src.exists(): + raw = _read_json(src) + if raw is not None: + for candidate in _collect_asset_paths(raw): + candidate_path = Path(candidate).expanduser() + if candidate_path.exists(): + extra_sources.append(candidate_path) + + presets_dir = home / "presets" + if presets_dir.exists(): + for preset_file in presets_dir.glob("*.json"): + raw = _read_json(preset_file) + if raw is None: + continue + for candidate in _collect_asset_paths(raw): + candidate_path = Path(candidate).expanduser() + if candidate_path.exists(): + extra_sources.append(candidate_path) + + for source in extra_sources: + resolved = str(source.resolve()) + if resolved in path_map: + continue + archive_name = _archive_name_for(len(path_map), source.stem or "asset", source) + archive_path = f"{ASSET_ARCHIVE_DIR}/{archive_name}" + zf.write(source, arcname=archive_path) + path_map[resolved] = archive_path + + zf.writestr("assets/registry.json", json.dumps({"assets": registry_export}, indent=2) + "\n") + manifest["path_map"] = path_map + manifest["asset_count"] = len(registry_export) + len(extra_sources) + zf.writestr("manifest.json", json.dumps(manifest, indent=2) + "\n") + + return {"bundle": str(bundle), "home": str(home), "asset_count": manifest["asset_count"]} + + +_ZIP_SYMLINK_TYPE = 0xA + + +def _safe_extract_zip(zf: zipfile.ZipFile, target: Path) -> None: + root = target.resolve() + for info in zf.infolist(): + name = info.filename + if name.endswith("/"): + continue + if name.startswith("/") or name.startswith("\\"): + raise ValueError(f"Unsafe absolute path in bundle: {name}") + # Reject symlink entries — Resolve would happily follow them outside `root`. + if (info.external_attr >> 28) & 0xF == _ZIP_SYMLINK_TYPE: + raise ValueError(f"Symlink entries not allowed in bundle: {name}") + dest = (root / name).resolve() + if os.path.commonpath([str(root), str(dest)]) != str(root): + raise ValueError(f"Unsafe path in bundle: {name}") + zf.extractall(root) + + +def migrate_legacy_cache(*, dry_run: bool = False) -> dict[str, Any]: + legacy = _legacy_cache_dir() + target = Path(paths["cache"]) + transcripts = target / "transcripts" + result: dict[str, Any] = { + "legacy_dir": str(legacy), + "target_dir": str(target), + "moved_json": 0, + "skipped_json": 0, + "moved_remotion_bundle": False, + "dry_run": dry_run, + } + + if not legacy.is_dir(): + return result + + target.mkdir(parents=True, exist_ok=True) + transcripts.mkdir(parents=True, exist_ok=True) + + for item in legacy.iterdir(): + if item.name == "remotion-bundle": + continue + if item.is_file() and item.suffix == ".json": + dest = target / item.name + if dest.exists(): + result["skipped_json"] += 1 + continue + if not dry_run: + shutil.move(str(item), str(dest)) + result["moved_json"] += 1 + + legacy_bundle = legacy / "remotion-bundle" + target_bundle = target / "remotion-bundle" + if legacy_bundle.is_dir(): + legacy_index = legacy_bundle / "index.html" + target_index = target_bundle / "index.html" + if legacy_index.exists() and not target_index.exists(): + if not dry_run: + if target_bundle.exists(): + shutil.rmtree(target_bundle) + shutil.move(str(legacy_bundle), str(target_bundle)) + result["moved_remotion_bundle"] = True + elif legacy_index.exists() and target_index.exists() and not dry_run: + shutil.rmtree(legacy_bundle) + result["removed_duplicate_remotion_bundle"] = True + + if not dry_run: + try: + if legacy.is_dir() and not any(legacy.iterdir()): + legacy.rmdir() + legacy_parent = legacy.parent + if legacy_parent.is_dir() and legacy_parent.name == ".podcli" and not any(legacy_parent.iterdir()): + legacy_parent.rmdir() + except OSError: + pass + try: + from services.transcript_packer import migrate_transcript_cache_layout + result["transcript_layout"] = migrate_transcript_cache_layout() + except Exception: + result["transcript_layout"] = {"moved_to_transcripts": 0, "skipped": 0} + + return result + + +def _legacy_cache_has_content() -> bool: + legacy = _legacy_cache_dir() + if not legacy.is_dir(): + return False + return any(legacy.iterdir()) + + +def migrate_legacy_presets(*, dry_run: bool = False) -> dict[str, Any]: + legacy = _legacy_presets_dir() + target = Path(paths["home"]) / "presets" + result: dict[str, Any] = { + "legacy_dir": str(legacy), + "target_dir": str(target), + "moved": 0, + "skipped": 0, + "dry_run": dry_run, + } + if not legacy.is_dir(): + return result + target.mkdir(parents=True, exist_ok=True) + for src in legacy.glob("*.json"): + dest = target / src.name + if dest.exists(): + result["skipped"] += 1 + continue + if not dry_run: + shutil.move(str(src), str(dest)) + result["moved"] += 1 + if not dry_run: + try: + if legacy.is_dir() and not any(legacy.iterdir()): + legacy.rmdir() + except OSError: + pass + return result + + +def auto_migrate_legacy_if_pending(*, quiet: bool = True) -> dict[str, Any] | None: + if not _legacy_migration_pending(): + return None + return ensure_legacy_migrated(quiet=quiet) + + +def ensure_legacy_migrated(*, quiet: bool = True) -> dict[str, Any]: + marker = _migration_marker_path() + had_marker = marker.exists() + summary = migrate_legacy_cache(dry_run=False) + presets_summary = migrate_legacy_presets(dry_run=False) + summary["presets_migration"] = presets_summary + try: + from services.transcript_packer import migrate_transcript_cache_layout + + layout = migrate_transcript_cache_layout() + summary["transcript_layout"] = layout + changed = bool( + summary.get("moved_json") + or summary.get("moved_remotion_bundle") + or summary.get("removed_duplicate_remotion_bundle") + or layout.get("moved_to_transcripts") + or presets_summary.get("moved") + ) + except Exception: + changed = bool( + summary.get("moved_json") + or summary.get("moved_remotion_bundle") + or summary.get("removed_duplicate_remotion_bundle") + or presets_summary.get("moved") + ) + if not had_marker or changed: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text( + json.dumps( + { + "migrated_at": datetime.now(timezone.utc).isoformat(), + "summary": summary, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + summary["already_migrated"] = had_marker and not changed and not _legacy_migration_pending() + summary["marker"] = str(marker) if marker.exists() else None + return summary + + +def _has_managed_content(home: Path) -> bool: + for file_name in MANAGED_FILES: + if (home / file_name).exists(): + return True + for dir_name in MANAGED_DIRS + ["assets"]: + if (home / dir_name).exists(): + return True + return False + + +def _backup_managed_paths(home: Path) -> Path | None: + if not _has_managed_content(home): + return None + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup = home.parent / f"{home.name}.backup-{stamp}" + backup.mkdir(parents=True, exist_ok=True) + for file_name in MANAGED_FILES: + src = home / file_name + if src.exists(): + shutil.copy2(src, backup / file_name) + for dir_name in MANAGED_DIRS + ["assets"]: + src = home / dir_name + if src.exists(): + shutil.copytree(src, backup / dir_name, dirs_exist_ok=True) + return backup + + +def _restore_from_backup(home: Path, backup: Path) -> None: + _cleanup_managed_paths(home) + for file_name in MANAGED_FILES: + src = backup / file_name + if src.exists(): + shutil.copy2(src, home / file_name) + for dir_name in MANAGED_DIRS + ["assets"]: + src = backup / dir_name + if src.exists(): + shutil.copytree(src, home / dir_name, dirs_exist_ok=True) + + +def _cleanup_managed_paths(home: Path) -> None: + for file_name in MANAGED_FILES: + path = home / file_name + if path.exists(): + path.unlink() + + for dir_name in MANAGED_DIRS + ["assets"]: + path = home / dir_name + if path.exists(): + shutil.rmtree(path) + + +def _rewrite_asset_paths(home: Path, path_map: dict[str, str]) -> None: + if not path_map: + return + + archive_to_target = { + archive_path: str((home / archive_path).resolve()) + for archive_path in path_map.values() + } + source_to_target = { + source_path: archive_to_target[archive_path] + for source_path, archive_path in path_map.items() + if archive_path in archive_to_target + } + + for json_path in [p for p in home.rglob("*.json") if p.is_file() and p.name != "manifest.json"]: + if json_path.parts[-2:] == ("assets", "registry.json"): + continue + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + except Exception: + continue + + def rewrite(value: Any) -> Any: + if isinstance(value, dict): + return {k: rewrite(v) for k, v in value.items()} + if isinstance(value, list): + return [rewrite(v) for v in value] + if isinstance(value, str) and value in source_to_target: + return source_to_target[value] + return value + + updated = rewrite(data) + if updated != data: + _write_json(json_path, updated) + + +def import_config(bundle_path: str, target_home: str | None = None, activate: bool = False) -> dict[str, Any]: + bundle = Path(bundle_path).expanduser().resolve() + if not bundle.exists(): + raise FileNotFoundError(f"Bundle not found: {bundle}") + + target = Path(target_home).expanduser().resolve() if target_home else _home_path() + target.mkdir(parents=True, exist_ok=True) + + backup_dir: Path | None = None + manifest: dict[str, Any] = {} + + try: + with zipfile.ZipFile(bundle, "r") as zf: + try: + manifest = json.loads(zf.read("manifest.json").decode("utf-8")) + except Exception: + pass + + backup_dir = _backup_managed_paths(target) + _cleanup_managed_paths(target) + _safe_extract_zip(zf, target) + + registry_path = target / "assets" / "registry.json" + registry = _read_json(registry_path) + if isinstance(registry, dict): + assets = registry.get("assets", []) + rewritten: list[dict[str, Any]] = [] + for item in assets: + if not isinstance(item, dict): + continue + archive_path = str(item.get("path", "")) + if not archive_path: + continue + rewritten.append({**item, "path": str((target / archive_path).resolve())}) + _write_json(registry_path, {"assets": rewritten}) + + _rewrite_asset_paths(target, manifest.get("path_map", {}) if isinstance(manifest, dict) else {}) + + if activate: + _marker_path().write_text(str(target) + "\n", encoding="utf-8") + except Exception: + if backup_dir and backup_dir.exists(): + _restore_from_backup(target, backup_dir) + raise + + return { + "bundle": str(bundle), + "home": str(target), + "activated": activate, + "manifest": manifest, + "backup": str(backup_dir) if backup_dir else None, + } + + +def set_active_home(home_path: str) -> str: + target = Path(home_path).expanduser().resolve() + target.mkdir(parents=True, exist_ok=True) + _marker_path().write_text(str(target) + "\n", encoding="utf-8") + return str(target) + + +def get_active_home() -> str: + return str(_home_path()) + + +def get_config_status() -> dict[str, Any]: + marker = _migration_marker_path() + migration_info: dict[str, Any] | None = None + if marker.exists(): + raw = _read_json(marker) + if isinstance(raw, dict): + migration_info = raw + return { + "home": get_active_home(), + "cache": paths["cache"], + "profile_marker": paths["profileMarker"], + "legacy_cache_pending": _legacy_cache_has_content(), + "legacy_presets_pending": _legacy_presets_has_content(), + "migration_marker": str(marker) if marker.exists() else None, + "migration": migration_info, + } + + +def run_config_action( + action: str, + *, + bundle_path: str | None = None, + home: str | None = None, + activate: bool = False, + dry_run: bool = False, +) -> dict[str, Any]: + act = (action or "status").strip().lower() + if act == "status": + return get_config_status() + if act == "migrate": + if dry_run: + cache = migrate_legacy_cache(dry_run=True) + presets = migrate_legacy_presets(dry_run=True) + cache["presets_migration"] = presets + return cache + return ensure_legacy_migrated(quiet=True) + if act == "export": + if not bundle_path: + raise ValueError("bundle_path is required") + return export_config(bundle_path, source_home=home) + if act == "import": + if not bundle_path: + raise ValueError("bundle_path is required") + return import_config(bundle_path, target_home=home, activate=activate) + if act == "use": + if not home: + raise ValueError("home is required") + return {"home": set_active_home(home)} + raise ValueError(f"Unknown config action: {action}") diff --git a/backend/main.py b/backend/main.py index 5399601..d05b861 100644 --- a/backend/main.py +++ b/backend/main.py @@ -114,6 +114,7 @@ def handle_create_clip(task_id: str, params: dict): keep_segments=params.get("keep_segments"), allow_ass_fallback=params.get("allow_ass_fallback", False), use_ass_captions=params.get("use_ass_captions", False), + keep_caption_overlay=params.get("keep_caption_overlay", False), progress_callback=lambda pct, msg: emit_progress(task_id, "processing", pct, msg), ) emit_result(task_id, "success", data=result) @@ -151,6 +152,9 @@ def handle_batch_clips(task_id: str, params: dict): keep_segments=clip.get("keep_segments"), allow_ass_fallback=clip.get("allow_ass_fallback", params.get("allow_ass_fallback", False)), use_ass_captions=clip.get("use_ass_captions", params.get("use_ass_captions", False)), + keep_caption_overlay=clip.get( + "keep_caption_overlay", params.get("keep_caption_overlay", False) + ), progress_callback=lambda pct, msg, _i=i: emit_progress( task_id, "batch", int((_i / total) * 100 + pct / total), msg ), @@ -379,6 +383,77 @@ def handle_generate_content(task_id: str, params: dict): emit_result(task_id, "success", data=result) +def handle_manage_integrations(task_id: str, params: dict): + from services.integrations import IntegrationsManager + + manager = IntegrationsManager() + action = params.get("action", "list") + + if action == "list": + emit_result(task_id, "success", data={"integrations": manager.list_all()}) + return + + name = params.get("name", "") + if action in ("enable", "disable"): + try: + manager.set_enabled(name, action == "enable") + emit_result(task_id, "success", data={"name": name, "enabled": action == "enable"}) + except ValueError as e: + emit_result(task_id, "error", error=str(e)) + return + + emit_result(task_id, "error", error=f"Unknown action: {action}") + + +def handle_manage_config(task_id: str, params: dict): + from config_bundle import run_config_action + + try: + data = run_config_action( + params.get("action", "status"), + bundle_path=params.get("bundle_path") or params.get("bundle"), + home=params.get("home"), + activate=bool(params.get("activate", False)), + dry_run=bool(params.get("dry_run", False)), + ) + emit_result(task_id, "success", data=data) + except 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 + + integration_name = params.get("integration", "") + tool_name = params.get("tool", "") + tool_params = params.get("params", {}) + + manager = IntegrationsManager() + if not manager.is_enabled(integration_name): + emit_result( + task_id, + "error", + error=f"Integration '{integration_name}' is disabled. Enable via manage_integrations.", + ) + return + + integration = IntegrationRegistry.get(integration_name) + if integration is None: + emit_result(task_id, "error", error=f"Unknown integration: {integration_name}") + return + + tool = next((t for t in integration.tools() if t.name == tool_name), None) + if tool is None: + emit_result(task_id, "error", error=f"Unknown tool '{tool_name}' on '{integration_name}'") + return + + try: + result = tool.handler(tool_params) + emit_result(task_id, "success", data=result) + except Exception as e: + emit_result(task_id, "error", error=f"{type(e).__name__}: {e}\n{traceback.format_exc()}") + + TASK_HANDLERS = { "ping": handle_ping, "transcribe": handle_transcribe, @@ -392,9 +467,22 @@ def handle_generate_content(task_id: str, params: dict): "corrections": handle_corrections, "suggest_clips": handle_suggest_clips, "generate_content": handle_generate_content, + "manage_integrations": handle_manage_integrations, + "run_integration_tool": handle_run_integration_tool, + "manage_config": handle_manage_config, } +def _maybe_auto_migrate_backend(task_type: str, params: dict) -> None: + if task_type == "manage_config": + action = params.get("action", "status") + if action == "status" or (action == "migrate" and params.get("dry_run")): + return + from config_bundle import auto_migrate_legacy_if_pending + + auto_migrate_legacy_if_pending(quiet=True) + + def main(): try: raw = sys.stdin.readline().strip() @@ -407,6 +495,8 @@ def main(): task_type = request.get("task_type", "") params = request.get("params", {}) + _maybe_auto_migrate_backend(task_type, params) + handler = TASK_HANDLERS.get(task_type) if not handler: emit_result(task_id, "error", error=f"Unknown task type: {task_type}") diff --git a/backend/presets.py b/backend/presets.py index f7cbb87..f186084 100644 --- a/backend/presets.py +++ b/backend/presets.py @@ -12,12 +12,9 @@ import json from typing import Optional -# Default to .podcli/ in the project root (two levels up from backend/) -_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -PRESETS_DIR = os.path.join( - os.environ.get("PODCLI_HOME", os.path.join(_PROJECT_ROOT, ".podcli")), - "presets", -) +from config.paths import paths + +PRESETS_DIR = os.path.join(paths["home"], "presets") # ── Clip duration constants (single source of truth) ── # These are for clip CONTENT only — outro is appended separately. diff --git a/backend/services/asset_store.py b/backend/services/asset_store.py index 2056d0e..9e2ced1 100644 --- a/backend/services/asset_store.py +++ b/backend/services/asset_store.py @@ -15,10 +15,12 @@ import shutil from typing import Optional +from config.paths import paths + def _registry_path() -> str: """Path to the asset registry JSON file.""" - base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".podcli", "assets") + base = paths["assets"] os.makedirs(base, exist_ok=True) return os.path.join(base, "registry.json") diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index b781083..fb452d1 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -15,6 +15,8 @@ import tempfile from typing import Optional, Callable +from config.paths import paths + 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 @@ -169,9 +171,7 @@ def _build_prompt( # Load knowledge base files inline — prioritized by relevance to clip selection kb_context = "" - kb_dir = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "..", "..", ".podcli", "knowledge" - ) + kb_dir = paths["knowledge"] # (filename, max_chars) — higher priority files get more budget _kb_files = [ ("04-shorts-creation-guide.md", 4000), # moment selection criteria, content types diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py index 626b4ba..a6e4c0c 100644 --- a/backend/services/clip_generator.py +++ b/backend/services/clip_generator.py @@ -14,6 +14,7 @@ from typing import Optional, Callable from utils.proc import run as proc_run, ProcError +from config.paths import paths sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -389,6 +390,11 @@ def _build_tight_segments( _remotion_available = None # True/False/None — environment availability, not per-clip success +def _kept_caption_overlay_path(output_path: str) -> str: + base, _ = os.path.splitext(os.path.abspath(output_path)) + return f"{base}_captions.mov" + + def _render_with_remotion( video_path: str, words: list[dict], @@ -396,9 +402,10 @@ def _render_with_remotion( output_path: str, time_offset: float = 0.0, logo_path: Optional[str] = None, -) -> bool: + keep_caption_overlay: bool = False, +) -> tuple[bool, Optional[str]]: """ - Render captions using Remotion. Returns True on success, False to fall back to ASS. + Render captions using Remotion. Returns (success, optional_prores_overlay_path). """ global _remotion_available import subprocess @@ -407,37 +414,41 @@ def _render_with_remotion( # Quick bail only if the environment is known unavailable for this session. # Transient per-clip render failures should not poison the rest of the batch. if _remotion_available is False: - return False + return False, None # Find the render script - project_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") + project_root = paths["project_root"] render_script = os.path.join(project_root, "remotion", "render.mjs") if not os.path.exists(render_script): _remotion_available = False - return False + return False, None # Check node is available node_path = shutil.which("node") if not node_path: _remotion_available = False - return False + return False, None + + remotion_env = {**os.environ, "PODCLI_CACHE_DIR": paths["cache"]} - # Pre-check: ensure bundle exists (prebundle if not) - cache_dir = os.path.join(project_root, ".podcli", "cache", "remotion-bundle") + cache_dir = os.path.join(paths["cache"], "remotion-bundle") bundle_index = os.path.join(cache_dir, "index.html") if not os.path.exists(bundle_index): - # Try a quick prebundle try: - r = proc_run( + r = subprocess.run( [node_path, render_script, "--prebundle"], - timeout=30, check=False, cwd=project_root, + timeout=30, + cwd=project_root, + env=remotion_env, + capture_output=True, + text=True, ) if r.returncode != 0 or not os.path.exists(bundle_index): _remotion_available = False - return False + return False, None except Exception: _remotion_available = False - return False + return False, None # Prepare words JSON (adjust timestamps by offset) adjusted_words = [] @@ -505,6 +516,8 @@ def _render_with_remotion( ] if logo_path and os.path.exists(logo_path): cmd.extend(["--logo", os.path.abspath(logo_path)]) + if keep_caption_overlay: + cmd.append("--keep-overlay") # Redirect stderr to devnull to suppress Chrome/FFmpeg noise # (avoids buffer deadlock and terminal spam) @@ -516,13 +529,18 @@ def _render_with_remotion( text=True, timeout=600, cwd=project_root, + env=remotion_env, ) if result.returncode == 0 and os.path.exists(output_path): _remotion_available = True - return True + overlay_path = None + if keep_caption_overlay: + overlay_path = _kept_caption_overlay_path(output_path) + if not os.path.exists(overlay_path): + overlay_path = None + return True, overlay_path - # Log errors stdout = result.stdout or "" if stdout: lines = [l.strip() for l in stdout.strip().split("\n") if l.strip()] @@ -530,14 +548,14 @@ def _render_with_remotion( print(f" Remotion: {lines[-1][:120]}", flush=True) print(" Remotion: falling back to ASS for this clip", flush=True) - return False + return False, None except subprocess.TimeoutExpired: print(" Remotion: timed out, using ASS for this clip", flush=True) - return False - except Exception as e: + return False, None + except Exception: print(" Remotion: render error, using ASS for this clip", flush=True) - return False + return False, None finally: try: os.unlink(words_file) @@ -562,6 +580,7 @@ def generate_clip( trim_opening: Optional[bool] = None, allow_ass_fallback: bool = False, use_ass_captions: bool = False, + keep_caption_overlay: bool = False, progress_callback: Optional[Callable[[int, str], None]] = None, ) -> dict: """ @@ -587,6 +606,15 @@ def generate_clip( "title": str, } """ + # Auto-enable caption overlay export when an editor integration that needs it is on. + if not keep_caption_overlay: + try: + from services.integrations.manager import IntegrationsManager + if IntegrationsManager().is_enabled("davinci_resolve"): + keep_caption_overlay = True + except Exception: + pass + if not os.path.exists(video_path): raise FileNotFoundError(f"Video not found: {video_path}") @@ -681,6 +709,7 @@ def generate_clip( # Create temp working directory work_dir = tempfile.mkdtemp(prefix="podcast_clip_") + caption_overlay_path = None try: total_steps = 4 + (1 if outro_path and os.path.exists(str(outro_path)) else 0) @@ -769,14 +798,16 @@ def generate_clip( captioned_path = os.path.join(work_dir, "captioned.mp4") remotion_ok = False + caption_overlay_path = None if not use_ass_captions: - remotion_ok = _render_with_remotion( + remotion_ok, caption_overlay_path = _render_with_remotion( video_path=cropped_path, words=clip_words, caption_style=caption_style, output_path=captioned_path, 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, ) if not remotion_ok and not allow_ass_fallback: @@ -866,7 +897,7 @@ def generate_clip( if progress_callback: progress_callback(100, "Clip complete!") - return { + out = { "output_path": final_path, "duration": round(duration, 2), "file_size_mb": file_size_mb, @@ -876,6 +907,10 @@ def generate_clip( "caption_style": caption_style, "crop_strategy": crop_strategy, } + if keep_caption_overlay and caption_overlay_path and os.path.exists(caption_overlay_path): + out["caption_overlay_path"] = caption_overlay_path + out["cropped_source_path"] = cropped_path + return out finally: # Clean up temp files (but not if output is in work_dir) diff --git a/backend/services/content_generator.py b/backend/services/content_generator.py index fea47e7..de4d46c 100644 --- a/backend/services/content_generator.py +++ b/backend/services/content_generator.py @@ -10,14 +10,13 @@ import tempfile from typing import Optional, Callable +from config.paths import paths from services.claude_suggest import _engine_label, _find_ai_cli_candidates, _run_ai_command def _load_kb_context() -> str: """Load PodStack knowledge base files for content generation.""" - kb_dir = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "..", "..", ".podcli", "knowledge" - ) + kb_dir = paths["knowledge"] kb_context = "" for fname, max_chars in [ ("05-title-formulas.md", 3000), diff --git a/backend/services/corrections.py b/backend/services/corrections.py index 6a0f3ff..41c47a5 100644 --- a/backend/services/corrections.py +++ b/backend/services/corrections.py @@ -18,10 +18,9 @@ import re from typing import Optional +from config.paths import paths -_CORRECTIONS_PATH = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "..", "..", ".podcli", "corrections.json" -) +_CORRECTIONS_PATH = paths["corrections"] def _load_corrections() -> dict[str, str]: diff --git a/backend/services/encoder.py b/backend/services/encoder.py index 915b2de..7f7f295 100644 --- a/backend/services/encoder.py +++ b/backend/services/encoder.py @@ -18,6 +18,8 @@ import functools import sys +from config.paths import paths + @functools.lru_cache(maxsize=1) def detect_encoders() -> dict: @@ -151,10 +153,7 @@ def get_video_encode_flags() -> list[str]: def _encoder_cache_path() -> str: - return os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "..", "..", ".podcli", "cache", "encoder.json", - ) + return os.path.join(paths["cache"], "encoder.json") def _ffmpeg_fingerprint() -> str: @@ -171,7 +170,7 @@ def _ffmpeg_fingerprint() -> str: def get_encoder_info() -> dict: """Get full encoder detection info (for UI/logging). - Cached at .podcli/cache/encoder.json keyed by ffmpeg binary fingerprint. + Cached at data/cache/encoder.json keyed by ffmpeg binary fingerprint. Encoder probing runs ffmpeg twice (~1.6s on macOS) — huge startup win. """ import json diff --git a/backend/services/integrations/__init__.py b/backend/services/integrations/__init__.py new file mode 100644 index 0000000..867e9b1 --- /dev/null +++ b/backend/services/integrations/__init__.py @@ -0,0 +1,6 @@ +"""podcli integrations — editor exporters, platform uploads, productivity tools, AI helpers.""" +from .base import IntegrationBase, IntegrationRegistry, ToolSpec +from .manager import IntegrationsManager +from . import davinci_resolve as _davinci_resolve # noqa: F401 + +__all__ = ["IntegrationBase", "IntegrationRegistry", "ToolSpec", "IntegrationsManager"] diff --git a/backend/services/integrations/_shared/__init__.py b/backend/services/integrations/_shared/__init__.py new file mode 100644 index 0000000..39d81d6 --- /dev/null +++ b/backend/services/integrations/_shared/__init__.py @@ -0,0 +1 @@ +"""Shared utilities reused across integrations.""" diff --git a/backend/services/integrations/_shared/fcpxml.py b/backend/services/integrations/_shared/fcpxml.py new file mode 100644 index 0000000..2d66547 --- /dev/null +++ b/backend/services/integrations/_shared/fcpxml.py @@ -0,0 +1,175 @@ +"""FCPXML 1.10 primitives shared by FCPXML-consuming editors (Resolve, FCP, Premiere).""" +from __future__ import annotations + +import urllib.parse +import xml.etree.ElementTree as ET +from fractions import Fraction +from pathlib import Path +from typing import Optional + + +def frames_to_seconds(frames: int, fps: float) -> Fraction: + return Fraction(frames) / Fraction(fps).limit_denominator(1000) + + +def seconds_to_time(seconds: Fraction) -> str: + if seconds == 0: + return "0s" + if seconds.denominator == 1: + return f"{seconds.numerator}s" + return f"{seconds.numerator}/{seconds.denominator}s" + + +def rational_time(frames: int, fps: float) -> str: + return seconds_to_time(frames_to_seconds(frames, fps)) + + +def file_uri(p: Path) -> str: + return "file://" + urllib.parse.quote(str(p.resolve())) + + +def make_format(format_id: str, fps: float, width: int, height: int) -> ET.Element: + fps_frac = Fraction(fps).limit_denominator(1000) + return ET.Element("format", { + "id": format_id, + "name": f"FFVideoFormat{width}x{height}p{int(round(fps))}", + "frameDuration": f"{fps_frac.denominator}/{fps_frac.numerator}s", + "width": str(width), + "height": str(height), + "colorSpace": "1-1-1 (Rec. 709)", + }) + + +def make_asset( + *, + asset_id: str, + name: str, + media_path: Path, + frames: int, + fps: float, + format_id: str, + has_video: bool = True, + has_audio: bool = False, + audio_channels: int = 0, +) -> ET.Element: + attrs = { + "id": asset_id, + "name": name, + "start": "0s", + "duration": rational_time(frames, fps), + "hasVideo": "1" if has_video else "0", + "format": format_id, + } + if has_audio: + attrs["hasAudio"] = "1" + attrs["audioSources"] = "1" + attrs["audioChannels"] = str(audio_channels or 2) + attrs["audioRate"] = "48000" + asset = ET.Element("asset", attrs) + ET.SubElement(asset, "media-rep", { + "kind": "original-media", + "src": file_uri(media_path), + }) + return asset + + +def make_compound_media( + *, + media_id: str, + name: str, + format_id: str, + source_duration: Fraction, + v1_asset_id: str, + v1_has_audio: bool, + v2: Optional[tuple[str, Fraction]] = None, + v3: Optional[tuple[str, Fraction]] = None, +) -> ET.Element: + media = ET.Element("media", {"id": media_id, "name": name}) + seq = ET.SubElement(media, "sequence", { + "format": format_id, + "tcStart": "0s", + "tcFormat": "NDF", + "duration": seconds_to_time(source_duration), + }) + spine = ET.SubElement(seq, "spine") + main_attrs = { + "ref": v1_asset_id, + "offset": "0s", + "start": "0s", + "duration": seconds_to_time(source_duration), + "name": name, + "format": format_id, + } + if v1_has_audio: + main_attrs["audioRole"] = "dialogue" + main_clip = ET.SubElement(spine, "asset-clip", main_attrs) + + for lane, overlay, label in ( + (1, v2, "captions"), + (2, v3, "logo"), + ): + if overlay is None: + continue + overlay_id, overlay_duration = overlay + clamped = min(overlay_duration, source_duration) + ET.SubElement(main_clip, "video", { + "ref": overlay_id, + "lane": str(lane), + "offset": "0s", + "start": "0s", + "duration": seconds_to_time(clamped), + "name": f"{name} {label}", + }) + return media + + +def make_project_library( + *, + project_name: str, + event_name: str, + format_id: str, + compounds: list[tuple[str, str, Fraction]], +) -> ET.Element: + total = sum((d for _, _, d in compounds), Fraction(0)) + library = ET.Element("library") + event = ET.SubElement(library, "event", {"name": event_name}) + project = ET.SubElement(event, "project", {"name": project_name}) + seq = ET.SubElement(project, "sequence", { + "format": format_id, + "tcStart": "0s", + "tcFormat": "NDF", + "duration": seconds_to_time(total), + }) + spine = ET.SubElement(seq, "spine") + offset = Fraction(0) + for media_id, name, duration in compounds: + ET.SubElement(spine, "ref-clip", { + "ref": media_id, + "offset": seconds_to_time(offset), + "start": "0s", + "duration": seconds_to_time(duration), + "name": name, + }) + offset += duration + return library + + +def write_fcpxml( + out_path: Path, + resources: list[ET.Element], + library: ET.Element, + version: str = "1.10", +) -> None: + root = ET.Element("fcpxml", {"version": version}) + res_el = ET.SubElement(root, "resources") + for r in resources: + res_el.append(r) + root.append(library) + + ET.indent(ET.ElementTree(root), space=" ") + xml_str = ET.tostring(root, encoding="unicode") + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text( + f'<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE fcpxml>\n{xml_str}\n', + encoding="utf-8", + ) diff --git a/backend/services/integrations/_shared/media_probe.py b/backend/services/integrations/_shared/media_probe.py new file mode 100644 index 0000000..c69dd42 --- /dev/null +++ b/backend/services/integrations/_shared/media_probe.py @@ -0,0 +1,62 @@ +"""Standalone ffprobe wrapper used by integration emitters.""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + + +def probe_media(path: str | Path) -> dict[str, Any]: + p = str(Path(path).resolve()) + + v_cmd = [ + "ffprobe", "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height,r_frame_rate,nb_frames,duration", + "-show_entries", "format=duration", + "-of", "json", + p, + ] + v_data = json.loads(subprocess.check_output(v_cmd, text=True)) + v_stream = (v_data.get("streams") or [{}])[0] + fmt_duration = float((v_data.get("format") or {}).get("duration") or 0.0) + + width = int(v_stream.get("width", 0)) + height = int(v_stream.get("height", 0)) + + rfr = v_stream.get("r_frame_rate", "30/1") + num_s, den_s = rfr.split("/") + fps_num, fps_den = float(num_s), float(den_s) + fps = fps_num / fps_den if fps_den > 0 else 30.0 + + nb_frames_raw = v_stream.get("nb_frames") + if nb_frames_raw and str(nb_frames_raw).isdigit() and int(nb_frames_raw) > 0: + duration_frames = int(nb_frames_raw) + elif v_stream.get("duration"): + duration_frames = round(float(v_stream["duration"]) * fps) + elif fmt_duration > 0: + duration_frames = round(fmt_duration * fps) + else: + raise RuntimeError(f"Could not determine duration for {p}") + + a_cmd = [ + "ffprobe", "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=channels", + "-of", "json", + p, + ] + a_data = json.loads(subprocess.check_output(a_cmd, text=True)) + a_streams = a_data.get("streams") or [] + has_audio = len(a_streams) > 0 + audio_channels = int(a_streams[0].get("channels", 0)) if has_audio else 0 + + return { + "width": width, + "height": height, + "fps": fps, + "duration_frames": duration_frames, + "has_audio": has_audio, + "audio_channels": audio_channels, + } diff --git a/backend/services/integrations/_shared/timeline_ir.py b/backend/services/integrations/_shared/timeline_ir.py new file mode 100644 index 0000000..ceded83 --- /dev/null +++ b/backend/services/integrations/_shared/timeline_ir.py @@ -0,0 +1,50 @@ +"""Timeline IR consumed by editor exporters.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class MediaClip: + path: Path + fps: float + duration_frames: int + width: int + height: int + has_audio: bool = False + audio_channels: int = 0 + + +@dataclass +class CaptionLayer: + path: Path + fps: float + duration_frames: int + + +@dataclass +class Marker: + time_seconds: float + name: str + note: str = "" + color: str = "blue" + + +@dataclass +class Short: + title: str + source: MediaClip + captions: Optional[CaptionLayer] = None + logo: Optional[CaptionLayer] = None + markers: list[Marker] = field(default_factory=list) + + +@dataclass +class Project: + name: str + fps: float = 30.0 + width: int = 1080 + height: int = 1920 + shorts: list[Short] = field(default_factory=list) diff --git a/backend/services/integrations/base.py b/backend/services/integrations/base.py new file mode 100644 index 0000000..1a2d4af --- /dev/null +++ b/backend/services/integrations/base.py @@ -0,0 +1,66 @@ +"""Integration base class + central registry.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Callable, Literal + +Category = Literal["editor_export", "platform_upload", "productivity", "ai_helper"] + + +@dataclass +class ToolSpec: + name: str + description: str + handler: Callable[[dict[str, Any]], dict[str, Any]] + input_schema: dict[str, Any] + tags: list[str] = field(default_factory=list) + + +class IntegrationBase(ABC): + name: str + category: Category + description: str = "" + default_enabled: bool = False + + @abstractmethod + def tools(self) -> list[ToolSpec]: ... + + def describe(self) -> dict[str, Any]: + return { + "name": self.name, + "category": self.category, + "description": self.description, + "default_enabled": self.default_enabled, + "tools": [ + {"name": t.name, "description": t.description, "tags": t.tags} + for t in self.tools() + ], + } + + +class IntegrationRegistry: + _instances: dict[str, IntegrationBase] = {} + + @classmethod + def register(cls, integration: IntegrationBase) -> None: + cls._instances[integration.name] = integration + + @classmethod + def get(cls, name: str) -> IntegrationBase | None: + return cls._instances.get(name) + + @classmethod + def all(cls) -> dict[str, IntegrationBase]: + return dict(cls._instances) + + @classmethod + def by_category(cls, cat: Category) -> list[IntegrationBase]: + return [i for i in cls._instances.values() if i.category == cat] + + @classmethod + def all_tools(cls) -> list[ToolSpec]: + out: list[ToolSpec] = [] + for inst in cls._instances.values(): + out.extend(inst.tools()) + return out diff --git a/backend/services/integrations/davinci_resolve/README.md b/backend/services/integrations/davinci_resolve/README.md new file mode 100644 index 0000000..ddaa432 --- /dev/null +++ b/backend/services/integrations/davinci_resolve/README.md @@ -0,0 +1,137 @@ +# DaVinci Resolve integration + +Exports podcli shorts as a DaVinci Resolve FCPXML 1.10 project. Each short +lands on the master timeline as a compound clip; double-clicking the compound +reveals V1 source + V2 ProRes 4444 alpha caption overlay as separate editable +layers. + +Targets free + Studio Resolve 20.x on macOS, Windows, Linux. No Studio gates +on the import path (FCPXML import, compound clips, alpha auto-detect, SRT +subtitle import, markers — all confirmed free-tier). + +## What it does (and doesn't) + +- ✅ Source clip on V1, alpha caption overlay on V2 — Resolve auto-detects the + alpha and composites identically to podcli's baked render. +- ✅ Per-short compound clip on the master timeline. Double-click to dive into + the nested timeline and edit individual layers. +- ✅ Editable cuts, trims, audio, color grading on top, sidecar SRT. +- ⚠️ Reframe (face-track Pan/Tilt) is **baked into the V1 source** — Resolve's + FCPXML importer mis-translates transform keyframe values, so we don't emit + them. To re-frame, swap V1 with the untouched source clip and add your own + Pan/Tilt, or re-render in podcli with different params. +- ⚠️ Caption animation curves (Hormozi spring physics, karaoke highlight) are + inside the V2 alpha overlay. Swap the overlay by re-rendering in podcli with + a different `--style` — no per-word edits inside Resolve. + +## Free Resolve caveat + +Resolve 20.2 silently watermarks the timeline if **any** Studio-only effect +appears anywhere on it, even unused. The emitter is constrained to free-tier +structure only — no `<adjust-blend>`, no Studio-gated nodes, no FCPXML +transform keyframes (which Resolve mis-translates anyway). + +## Spike verification + +The spike answers one open question: _does an FCPXML emitted from this +module, referencing a ProRes 4444 alpha overlay inside a compound clip, +round-trip into free Resolve and render identical to podcli's bake?_ + +Run from `backend/` so the `services.*` import path resolves, or set +`PYTHONPATH=backend`. + +### A. Structural check (5 minutes, no re-render) + +Confirms Resolve accepts the FCPXML, the compound clip appears, and the +alpha overlay auto-detects on V2 above V1. Uses any baked `*_short.mp4` +as V1 plus a synthetic ProRes 4444 alpha test pattern as V2. + +```bash +# Generate a 1080x1920 ProRes 4444 alpha test pattern matching the source's duration. +DUR=50 # seconds — match (or undershoot) the source duration +ffmpeg -y -f lavfi -i "color=c=red@0.0:s=1080x1920:r=24:d=$DUR" \ + -vf "drawbox=x=140:y=1500:w=800:h=180:color=yellow@0.85:t=fill, \ + drawtext=text='ALPHA OVERLAY':fontcolor=black:fontsize=110:x=190:y=1545" \ + -c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le \ + /tmp/test_alpha.mov + +cd backend +python3 -m services.integrations.davinci_resolve.cli \ + --title "spike_structural" \ + --source ../data/output/your_short.mp4 \ + --captions /tmp/test_alpha.mov +``` + +Import the resulting `.fcpxml` into Resolve. **Pass criteria:** compound +clip appears on V1 of the master timeline; double-clicking reveals V1 +(source) + V2 (alpha overlay) stacked; the yellow `ALPHA OVERLAY` box +appears over the source where the alpha is opaque, with the source fully +visible elsewhere. + +### B. Pixel-diff check (full re-render) + +Confirms Resolve's composite of (clean source + alpha captions) renders +visually identical to podcli's baked output. + +Requires a clean cropped-but-uncaptioned intermediate from podcli. Today +the pipeline discards this intermediate; running render.mjs directly with +`--keep-overlay` is the supported path: + +```bash +node remotion/render.mjs \ + --video path/to/cropped_clip.mp4 \ + --words path/to/words.json \ + --style branded \ + --output /tmp/spike_baked.mp4 \ + --keep-overlay +``` + +That produces: + +- `/tmp/spike_baked.mp4` — baked composite (the ground-truth render) +- `/tmp/spike_baked_captions.mov` — ProRes 4444 alpha overlay + +Then: + +```bash +cd backend +python3 -m services.integrations.davinci_resolve.cli \ + --title "spike_pixel" \ + --source path/to/cropped_clip.mp4 \ + --captions /tmp/spike_baked_captions.mov +``` + +Import into Resolve, render the master timeline (Deliver → H.264, source +resolution and fps, CRF 18) to `/tmp/resolve_render.mp4`, then diff: + +```bash +ffmpeg -i /tmp/spike_baked.mp4 -i /tmp/resolve_render.mp4 \ + -filter_complex "[0:v][1:v]blend=all_mode=difference" \ + -t 5 /tmp/diff.mp4 +``` + +Expect a near-black diff. Encoder differences (libx264 vs Resolve's H.264) +produce minor noise; <5% mean luma is pass. + +## Architecture notes (for future integrations) + +This module sits in `backend/services/integrations/davinci_resolve/`. +Adding a sibling editor (Premiere, FCP, CapCut) means: + +1. Create `backend/services/integrations/<editor>/` +2. Subclass `IntegrationBase` from `..base` +3. Consume the same `Project` IR from `.._shared.timeline_ir` +4. Emit the editor's project file in `emitter.py` +5. Register the integration in `__init__.py` + +The `_shared.fcpxml` module is reusable by any FCPXML-consuming editor +(Final Cut Pro X, Premiere via its FCPXML importer). + +## Production wiring + +- MCP: `manage_integrations` (enable `davinci_resolve`) and `export_to_davinci_resolve` +- Web UI: `/integrations.html` toggle +- Python: `run_integration_tool` in `backend/main.py` +- Caption overlay for V2: `node remotion/render.mjs ... --keep-overlay` (writes `*_captions.mov` beside the output) +- Still manual: per-short `source_path` + optional `captions_path` must be supplied to the export tool +- Optional future work: `--keep-intermediate` on `clip_generator.py` to preserve cropped source in one command diff --git a/backend/services/integrations/davinci_resolve/__init__.py b/backend/services/integrations/davinci_resolve/__init__.py new file mode 100644 index 0000000..e0870a8 --- /dev/null +++ b/backend/services/integrations/davinci_resolve/__init__.py @@ -0,0 +1,8 @@ +"""DaVinci Resolve integration — emits FCPXML 1.10 for free + Studio Resolve 20.x.""" +from ..base import IntegrationRegistry +from .integration import integration +from . import emitter + +IntegrationRegistry.register(integration) + +__all__ = ["integration", "emitter"] diff --git a/backend/services/integrations/davinci_resolve/cli.py b/backend/services/integrations/davinci_resolve/cli.py new file mode 100644 index 0000000..a0d3c24 --- /dev/null +++ b/backend/services/integrations/davinci_resolve/cli.py @@ -0,0 +1,101 @@ +"""Spike CLI — emit a Resolve FCPXML referencing one or more shorts on disk. + +Usage: + python -m services.integrations.davinci_resolve.cli \ + --title "podcli spike" \ + --source path/to/short.mp4 \ + [--captions path/to/short_captions.mov] \ + [--out path/to/project.fcpxml] +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[4] +if str(REPO_ROOT / "backend") not in sys.path: + sys.path.insert(0, str(REPO_ROOT / "backend")) + +from services.integrations.davinci_resolve import emitter +from services.integrations._shared.media_probe import probe_media +from services.integrations._shared.timeline_ir import ( + CaptionLayer, + MediaClip, + Project, + Short, +) + + +def _build_short(title: str, source: Path, captions: Path | None) -> Short: + src_info = probe_media(source) + src_clip = MediaClip( + path=source.resolve(), + fps=src_info["fps"], + duration_frames=src_info["duration_frames"], + width=src_info["width"], + height=src_info["height"], + has_audio=src_info["has_audio"], + audio_channels=src_info["audio_channels"], + ) + cap_layer: CaptionLayer | None = None + if captions: + cap_info = probe_media(captions) + cap_layer = CaptionLayer( + path=captions.resolve(), + fps=cap_info["fps"], + duration_frames=cap_info["duration_frames"], + ) + return Short(title=title, source=src_clip, captions=cap_layer) + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--title", default="podcli spike") + p.add_argument("--source", required=True, type=Path) + p.add_argument("--captions", type=Path, default=None) + p.add_argument("--out", type=Path, default=None) + p.add_argument("--fps", type=float, default=None) + p.add_argument("--width", type=int, default=None) + p.add_argument("--height", type=int, default=None) + args = p.parse_args() + + if not args.source.exists(): + print(f"error: source not found: {args.source}", file=sys.stderr) + return 1 + if args.captions and not args.captions.exists(): + print(f"error: captions not found: {args.captions}", file=sys.stderr) + return 1 + + out = args.out or ( + REPO_ROOT / "data" / "export" / "davinci_resolve" / f"{args.title.replace(' ', '_')}.fcpxml" + ) + + short = _build_short(args.title, args.source, args.captions) + project = Project( + name=args.title, + fps=args.fps if args.fps is not None else short.source.fps, + width=args.width if args.width is not None else short.source.width, + height=args.height if args.height is not None else short.source.height, + shorts=[short], + ) + + emitter.emit(project, out) + + print(f"wrote: {out}") + print(f" source: {short.source.path} ({short.source.duration_frames}f @ {short.source.fps:.3f}fps)") + if short.captions: + print(f" captions: {short.captions.path} ({short.captions.duration_frames}f)") + else: + print(" captions: (none — re-run with --captions for the layered spike)") + print() + print("To verify in DaVinci Resolve (free or Studio, 20.x):") + print(" 1. Open Resolve → New Project") + print(" 2. File → Import → Timeline → select the .fcpxml above") + print(" 3. Confirm one compound clip per short appears on the timeline") + print(" 4. Double-click a compound clip — V1 source and V2 captions should be on separate tracks") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/services/integrations/davinci_resolve/emitter.py b/backend/services/integrations/davinci_resolve/emitter.py new file mode 100644 index 0000000..4acd874 --- /dev/null +++ b/backend/services/integrations/davinci_resolve/emitter.py @@ -0,0 +1,103 @@ +"""Project IR -> FCPXML 1.10 for DaVinci Resolve 20.x. + +Resolve quirks the emitter routes around: + - FCPXML transform keyframes mis-translate values (center-origin math). Reframe + is pre-baked into the V1 source layer instead of emitted as keyframes. + - Composite-mode / blend-mode attributes are silently dropped. Alpha is carried + by the asset itself (ProRes 4444); Resolve auto-detects and composites normal. + - PNG image sequences import as one-frame-per-clip. Use single video files. + - Avoid any Studio-only effect node — Resolve 20.2 watermarks the timeline + if it sees one, even if unused. + - Pin to FCPXML 1.10; 1.11+ have intermittent import failures. +""" +from __future__ import annotations + +import xml.etree.ElementTree as ET +from fractions import Fraction +from pathlib import Path + +from .._shared import fcpxml as fx +from .._shared.timeline_ir import Project + + +def emit(project: Project, out_path: Path) -> Path: + fmt_id = "r1" + resources: list[ET.Element] = [ + fx.make_format(fmt_id, project.fps, project.width, project.height), + ] + compounds: list[tuple[str, str, Fraction]] = [] + + next_asset = 2 + next_media = 1000 + + for short in project.shorts: + src_id = f"r{next_asset}" + next_asset += 1 + resources.append(fx.make_asset( + asset_id=src_id, + name=f"{short.title} — source", + media_path=short.source.path, + frames=short.source.duration_frames, + fps=short.source.fps, + format_id=fmt_id, + has_video=True, + has_audio=short.source.has_audio, + audio_channels=short.source.audio_channels, + )) + + v2: tuple[str, Fraction] | None = None + if short.captions: + cid = f"r{next_asset}" + next_asset += 1 + resources.append(fx.make_asset( + asset_id=cid, + name=f"{short.title} — captions", + media_path=short.captions.path, + frames=short.captions.duration_frames, + fps=short.captions.fps, + format_id=fmt_id, + has_video=True, + has_audio=False, + )) + v2 = (cid, fx.frames_to_seconds(short.captions.duration_frames, short.captions.fps)) + + v3: tuple[str, Fraction] | None = None + if short.logo: + lid = f"r{next_asset}" + next_asset += 1 + resources.append(fx.make_asset( + asset_id=lid, + name=f"{short.title} — logo", + media_path=short.logo.path, + frames=short.logo.duration_frames, + fps=short.logo.fps, + format_id=fmt_id, + has_video=True, + has_audio=False, + )) + v3 = (lid, fx.frames_to_seconds(short.logo.duration_frames, short.logo.fps)) + + cmpd_id = f"r{next_media}" + next_media += 1 + source_duration = fx.frames_to_seconds(short.source.duration_frames, short.source.fps) + compounds.append((cmpd_id, short.title, source_duration)) + resources.append(fx.make_compound_media( + media_id=cmpd_id, + name=short.title, + format_id=fmt_id, + source_duration=source_duration, + v1_asset_id=src_id, + v1_has_audio=short.source.has_audio, + v2=v2, + v3=v3, + )) + + library = fx.make_project_library( + project_name=project.name, + event_name="podcli", + format_id=fmt_id, + compounds=compounds, + ) + + fx.write_fcpxml(out_path, resources, library, version="1.10") + return out_path diff --git a/backend/services/integrations/davinci_resolve/integration.py b/backend/services/integrations/davinci_resolve/integration.py new file mode 100644 index 0000000..dbe22cf --- /dev/null +++ b/backend/services/integrations/davinci_resolve/integration.py @@ -0,0 +1,111 @@ +"""DaVinci Resolve integration — exposes the export_to_davinci_resolve MCP tool.""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..base import IntegrationBase, ToolSpec +from .._shared.media_probe import probe_media +from .._shared.timeline_ir import CaptionLayer, MediaClip, Project, Short +from . import emitter + + +class DaVinciResolveIntegration(IntegrationBase): + name = "davinci_resolve" + category = "editor_export" + description = ( + "Export podcli shorts as a DaVinci Resolve FCPXML — each short becomes an " + "editable compound clip (source on V1, ProRes 4444 alpha captions on V2). " + "Works in free + Studio Resolve 20.x." + ) + default_enabled = False + + def tools(self) -> list[ToolSpec]: + return [ + ToolSpec( + name="export_to_davinci_resolve", + description=( + "Export podcli shorts as a DaVinci Resolve FCPXML project. " + "Each short becomes a compound clip on the master timeline; " + "source video and ProRes 4444 alpha caption overlay land on " + "separate layers inside the compound so they remain editable. " + "Works in free and Studio Resolve 20.x." + ), + handler=self._handle_export, + input_schema={ + "type": "object", + "properties": { + "project_name": {"type": "string"}, + "output_path": {"type": "string"}, + "fps": {"type": "number", "default": 30}, + "width": {"type": "integer", "default": 1080}, + "height": {"type": "integer", "default": 1920}, + "shorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "source_path": {"type": "string"}, + "captions_path": {"type": "string"}, + "logo_path": {"type": "string"}, + }, + "required": ["title", "source_path"], + }, + }, + }, + "required": ["project_name", "output_path", "shorts"], + }, + tags=["editor", "export", "davinci", "fcpxml"], + ), + ] + + def _handle_export(self, params: dict[str, Any]) -> dict[str, Any]: + shorts: list[Short] = [] + for s in params["shorts"]: + src_info = probe_media(s["source_path"]) + source = MediaClip( + path=Path(s["source_path"]).resolve(), + fps=src_info["fps"], + duration_frames=src_info["duration_frames"], + width=src_info["width"], + height=src_info["height"], + has_audio=src_info["has_audio"], + audio_channels=src_info["audio_channels"], + ) + shorts.append(Short( + title=s["title"], + source=source, + captions=_maybe_layer(s.get("captions_path")), + logo=_maybe_layer(s.get("logo_path")), + )) + + first = shorts[0].source if shorts else None + project = Project( + name=params["project_name"], + fps=float(params["fps"]) if params.get("fps") is not None else (first.fps if first else 30.0), + width=int(params["width"]) if params.get("width") is not None else (first.width if first else 1080), + height=int(params["height"]) if params.get("height") is not None else (first.height if first else 1920), + shorts=shorts, + ) + out_path = Path(params["output_path"]).resolve() + emitter.emit(project, out_path) + return { + "fcpxml_path": str(out_path), + "shorts_count": len(shorts), + "format": f"{project.width}x{project.height}@{project.fps}fps", + } + + +def _maybe_layer(p: str | None) -> CaptionLayer | None: + if not p: + return None + info = probe_media(p) + return CaptionLayer( + path=Path(p).resolve(), + fps=info["fps"], + duration_frames=info["duration_frames"], + ) + + +integration = DaVinciResolveIntegration() diff --git a/backend/services/integrations/manager.py b/backend/services/integrations/manager.py new file mode 100644 index 0000000..2845f50 --- /dev/null +++ b/backend/services/integrations/manager.py @@ -0,0 +1,50 @@ +"""Tracks which integrations are enabled (integrations.json under config home).""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from config.paths import paths +from .base import IntegrationRegistry + + +def _default_state_path() -> Path: + return Path(paths["integrations"]) + + +class IntegrationsManager: + def __init__(self, state_path: Path | None = None): + self.state_path = state_path or _default_state_path() + + def _load(self) -> dict[str, dict[str, Any]]: + if not self.state_path.exists(): + return {} + try: + return json.loads(self.state_path.read_text()) + except json.JSONDecodeError: + return {} + + def _save(self, state: dict[str, dict[str, Any]]) -> None: + self.state_path.parent.mkdir(parents=True, exist_ok=True) + self.state_path.write_text(json.dumps(state, indent=2) + "\n") + + def is_enabled(self, name: str) -> bool: + integration = IntegrationRegistry.get(name) + if integration is None: + return False + state = self._load() + return state.get(name, {}).get("enabled", integration.default_enabled) + + def set_enabled(self, name: str, enabled: bool) -> None: + if IntegrationRegistry.get(name) is None: + raise ValueError(f"unknown integration: {name}") + state = self._load() + state.setdefault(name, {})["enabled"] = enabled + self._save(state) + + def list_all(self) -> list[dict[str, Any]]: + return [ + {**inst.describe(), "enabled": self.is_enabled(name)} + for name, inst in IntegrationRegistry.all().items() + ] diff --git a/backend/services/thumbnail_ai.py b/backend/services/thumbnail_ai.py index b49f68b..5ca37bc 100644 --- a/backend/services/thumbnail_ai.py +++ b/backend/services/thumbnail_ai.py @@ -18,6 +18,8 @@ import base64 from typing import Optional +from config.paths import paths + def _load_brand_config() -> dict: """Load brand constraints from thumbnail-config.json.""" @@ -30,9 +32,7 @@ def _load_brand_config() -> dict: "enabled": True, "variations": 3, } - config_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "..", "..", ".podcli", "thumbnail-config.json" - ) + config_path = paths["thumbnailConfig"] if os.path.exists(config_path): try: with open(config_path) as f: diff --git a/backend/services/thumbnail_generator.py b/backend/services/thumbnail_generator.py index b2799fc..3a794e3 100644 --- a/backend/services/thumbnail_generator.py +++ b/backend/services/thumbnail_generator.py @@ -16,6 +16,8 @@ import sys from typing import Optional +from config.paths import paths + try: from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance except ImportError: @@ -93,9 +95,7 @@ def _load_config() -> dict: """Load thumbnail config from .podcli/thumbnail-config.json, merged with defaults.""" config = {**DEFAULT_CONFIG} - config_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "..", "..", ".podcli", "thumbnail-config.json" - ) + config_path = paths["thumbnailConfig"] if os.path.exists(config_path): try: with open(config_path) as f: diff --git a/backend/services/thumbnail_html.py b/backend/services/thumbnail_html.py index 5adaa3f..3929f97 100644 --- a/backend/services/thumbnail_html.py +++ b/backend/services/thumbnail_html.py @@ -13,6 +13,7 @@ import re import shutil import subprocess +from config.paths import paths from utils.proc import run as proc_run, ProcError import sys import tempfile @@ -112,9 +113,7 @@ def _load_config() -> dict: "playwright_timeout_ms": 150000, } - config_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "..", "..", ".podcli", "thumbnail-config.json" - ) + config_path = paths["thumbnailConfig"] if os.path.exists(config_path): try: with open(config_path) as f: diff --git a/backend/services/transcript_packer.py b/backend/services/transcript_packer.py index 280f0d3..ee4f513 100644 --- a/backend/services/transcript_packer.py +++ b/backend/services/transcript_packer.py @@ -5,8 +5,8 @@ cut points without watching video. Words, speakers, silences, and optional energy peaks fit into ~10-20KB of text. -Input: cached transcription JSON (.podcli/cache/<hash>.json) -Output: .podcli/packed/<hash>.md +Input: cached transcription JSON (data/cache/transcripts/<hash>.json) +Output: packed/<hash>.md under the active config home """ from __future__ import annotations @@ -14,14 +14,23 @@ import hashlib import json import os +import re import sys -from typing import Optional +from typing import Any, Optional -PROJECT_ROOT = os.path.abspath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") -) -CACHE_DIR = os.path.join(PROJECT_ROOT, ".podcli", "cache") -PACKED_DIR = os.path.join(PROJECT_ROOT, ".podcli", "packed") +from config.paths import paths + +def _transcripts_cache_dir() -> str: + return paths["transcripts"] + + +def _legacy_cache_dir() -> str: + return paths["cache"] + + +def _packed_dir() -> str: + return paths["packed"] +_HASH16_RE = re.compile(r"^[a-f0-9]{16}$", re.I) # Phrase construction tuning SILENCE_SPLIT_SEC = 0.5 # split a phrase on word gap >= this @@ -47,6 +56,62 @@ def compute_cache_hash(video_path: str) -> str: return h.hexdigest()[:16] +def legacy_md5_cache_path(video_path: str) -> str: + stat = os.stat(video_path) + raw = f"{os.path.abspath(video_path)}:{stat.st_size}:{stat.st_mtime}" + return os.path.join(_legacy_cache_dir(), hashlib.md5(raw.encode()).hexdigest() + ".json") + + +def transcript_json_path(cache_hash: str) -> str: + return os.path.join(_transcripts_cache_dir(), f"{cache_hash}.json") + + +def load_cached_transcript_for_video(video_path: str) -> dict[str, Any] | None: + cache_hash = compute_cache_hash(video_path) + canonical = transcript_json_path(cache_hash) + if os.path.exists(canonical): + with open(canonical, encoding="utf-8") as f: + return json.load(f) + legacy = legacy_md5_cache_path(video_path) + if os.path.exists(legacy): + with open(legacy, encoding="utf-8") as f: + data = json.load(f) + save_cached_transcript_for_video(video_path, data) + return data + return None + + +def save_cached_transcript_for_video(video_path: str, data: dict[str, Any]) -> str: + cache_hash = compute_cache_hash(video_path) + path = transcript_json_path(cache_hash) + os.makedirs(_transcripts_cache_dir(), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) + return path + + +def migrate_transcript_cache_layout() -> dict[str, Any]: + result = {"moved_to_transcripts": 0, "skipped": 0} + os.makedirs(_transcripts_cache_dir(), exist_ok=True) + legacy_dir = _legacy_cache_dir() + if not os.path.isdir(legacy_dir): + return result + for fname in os.listdir(legacy_dir): + if not fname.endswith(".json") or fname == "encoder.json": + continue + stem = fname[:-5] + if not _HASH16_RE.match(stem): + continue + src = os.path.join(legacy_dir, fname) + dest = os.path.join(_transcripts_cache_dir(), fname) + if os.path.exists(dest): + result["skipped"] += 1 + continue + os.rename(src, dest) + result["moved_to_transcripts"] += 1 + return result + + def _fmt_duration(seconds: float) -> str: s = int(round(seconds)) h, rem = divmod(s, 3600) @@ -273,13 +338,19 @@ def pack_transcript( def load_cache(cache_hash: str) -> dict: - path = os.path.join(CACHE_DIR, f"{cache_hash}.json") - with open(path, "r") as f: + path = transcript_json_path(cache_hash) + if not os.path.exists(path): + legacy = os.path.join(_legacy_cache_dir(), f"{cache_hash}.json") + if os.path.exists(legacy): + path = legacy + else: + raise FileNotFoundError(f"Transcript cache not found: {cache_hash}") + with open(path, encoding="utf-8") as f: return json.load(f) def packed_path_for(cache_hash: str) -> str: - return os.path.join(PACKED_DIR, f"{cache_hash}.md") + return os.path.join(_packed_dir(), f"{cache_hash}.md") def write_packed( @@ -291,7 +362,7 @@ def write_packed( """Pack a transcript dict and write to .podcli/packed/<hash>.md.""" label = source_label or cache_hash md = pack_transcript(transcript, label, energy_data=energy_data) - os.makedirs(PACKED_DIR, exist_ok=True) + os.makedirs(_packed_dir(), exist_ok=True) out_path = packed_path_for(cache_hash) with open(out_path, "w") as f: f.write(md) diff --git a/backend/utils/prompt_files.py b/backend/utils/prompt_files.py index fbaca3a..19b23d0 100644 --- a/backend/utils/prompt_files.py +++ b/backend/utils/prompt_files.py @@ -18,15 +18,11 @@ import os import tempfile +from config.paths import paths + def _tmp_dir() -> str: - base = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "..", - "..", - ".podcli", - "tmp", - ) + base = os.path.join(paths["home"], "tmp") os.makedirs(base, exist_ok=True) return base diff --git a/remotion/render.mjs b/remotion/render.mjs index 45368cf..536b047 100644 --- a/remotion/render.mjs +++ b/remotion/render.mjs @@ -2,8 +2,8 @@ /** * Remotion render script — called by Python backend. * - * Caches the bundle to .podcli/cache/remotion-bundle/ so subsequent renders - * skip the ~15-20s bundling step. + * Caches the bundle under PODCLI_CACHE_DIR/remotion-bundle/ (default data/cache/) + * so subsequent renders skip the ~15-20s bundling step. * * Usage: * node remotion/render.mjs \ @@ -27,20 +27,26 @@ import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const CACHE_DIR = path.join(PROJECT_ROOT, ".podcli", "cache", "remotion-bundle"); +const CACHE_ROOT = process.env.PODCLI_CACHE_DIR + ? path.resolve(process.env.PODCLI_CACHE_DIR) + : path.join(PROJECT_ROOT, "data", "cache"); +const CACHE_DIR = path.join(CACHE_ROOT, "remotion-bundle"); const BUNDLE_HASH_FILE = path.join(CACHE_DIR, ".hash"); const ENTRY_POINT = path.join(__dirname, "src", "index.ts"); +const BOOLEAN_FLAGS = new Set(["prebundle", "keep-overlay"]); + function parseArgs() { const args = process.argv.slice(2); const opts = {}; for (let i = 0; i < args.length; i++) { - if (args[i] === "--prebundle") { - opts.prebundle = true; + const key = args[i].replace(/^--/, ""); + if (BOOLEAN_FLAGS.has(key)) { + opts[key] = true; continue; } if (args[i].startsWith("--") && i + 1 < args.length) { - opts[args[i].replace(/^--/, "")] = args[i + 1]; + opts[key] = args[i + 1]; i++; } } @@ -210,13 +216,17 @@ async function main() { inputProps, }); - // Render captions as transparent WebM overlay (no video = 10x faster) const cpus = os.cpus().length; const concurrency = Math.max(2, Math.min(cpus, 8)); - // Store overlay in /tmp to survive temp dir cleanup - const overlaySeed = `${path.resolve(opts.output)}:${process.pid}:${Date.now()}`; - const overlayId = crypto.createHash("md5").update(overlaySeed).digest("hex").slice(0, 12); - const captionOverlay = path.join(os.tmpdir(), `remotion_overlay_${overlayId}.mov`); + let captionOverlay; + if (opts["keep-overlay"]) { + const outBase = opts.output.replace(/\.[^.]+$/, ""); + captionOverlay = `${outBase}_captions.mov`; + } else { + const overlaySeed = `${path.resolve(opts.output)}:${process.pid}:${Date.now()}`; + const overlayId = crypto.createHash("md5").update(overlaySeed).digest("hex").slice(0, 12); + captionOverlay = path.join(os.tmpdir(), `remotion_overlay_${overlayId}.mov`); + } let lastPct = -1; await renderMedia({ @@ -254,8 +264,11 @@ async function main() { { stdio: ["pipe", "pipe", "pipe"], timeout: 300000 } ); - // Clean up overlay - try { fs.unlinkSync(captionOverlay); } catch {} + if (opts["keep-overlay"]) { + console.log(`PODCLI_OVERLAY_PATH=${captionOverlay}`); + } else { + try { fs.unlinkSync(captionOverlay); } catch {} + } console.log(`Done: ${opts.output}`); } finally { closeAssetServer(); diff --git a/remotion/test-render.mjs b/remotion/test-render.mjs index 2f6ac4f..2167746 100644 --- a/remotion/test-render.mjs +++ b/remotion/test-render.mjs @@ -19,7 +19,10 @@ import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const CACHE_DIR = path.join(PROJECT_ROOT, ".podcli", "cache", "remotion-bundle"); +const CACHE_ROOT = process.env.PODCLI_CACHE_DIR + ? path.resolve(process.env.PODCLI_CACHE_DIR) + : path.join(PROJECT_ROOT, "data", "cache"); +const CACHE_DIR = path.join(CACHE_ROOT, "remotion-bundle"); const BUNDLE_HASH_FILE = path.join(CACHE_DIR, ".hash"); const ENTRY_POINT = path.join(__dirname, "src", "index.ts"); @@ -41,7 +44,10 @@ async function getCachedBundle() { const currentHash = hashSrcDir(); if (fs.existsSync(BUNDLE_HASH_FILE)) { const cachedHash = fs.readFileSync(BUNDLE_HASH_FILE, "utf-8").trim(); - if (cachedHash === currentHash && fs.existsSync(path.join(CACHE_DIR, "index.html"))) { + if ( + cachedHash === currentHash && + fs.existsSync(path.join(CACHE_DIR, "index.html")) + ) { return CACHE_DIR; } } @@ -71,14 +77,26 @@ const words = [ const fps = 30; const durationInFrames = Math.ceil(5.5 * fps); -const outputPath = path.join(process.env.HOME, "Downloads", `remotion-test-${styleName}.mp4`); +const outputPath = path.join( + process.env.HOME, + "Downloads", + `remotion-test-${styleName}.mp4`, +); // Try to load logo from podcli asset registry let logoPath = ""; try { - const registry = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, ".podcli", "assets", "registry.json"), "utf-8")); - const logo = registry.assets?.find((a) => a.type === "logo"); - if (logo?.path && fs.existsSync(logo.path)) logoPath = logo.path; + const home = process.env.PODCLI_HOME + ? path.resolve(process.env.PODCLI_HOME) + : path.join(PROJECT_ROOT, ".podcli"); + const registryPath = path.join(home, "assets", "registry.json"); + if (!fs.existsSync(registryPath)) { + console.warn("No assets registry at", registryPath); + } else { + const registry = JSON.parse(fs.readFileSync(registryPath, "utf-8")); + const logo = registry.assets?.find((a) => a.type === "logo"); + if (logo?.path && fs.existsSync(logo.path)) logoPath = logo.path; + } } catch {} let logoSrc; @@ -111,7 +129,13 @@ async function main() { console.log(`Rendering ${styleName} → ${outputPath}`); await renderMedia({ - composition: { ...composition, durationInFrames, fps, width: 2160, height: 3840 }, + composition: { + ...composition, + durationInFrames, + fps, + width: 2160, + height: 3840, + }, serveUrl: bundleLocation, codec: "h264", outputLocation: outputPath, diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts new file mode 100644 index 0000000..26e65a6 --- /dev/null +++ b/src/config/paths.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join, resolve } from "path"; + +const tmp = mkdtempSync(join(tmpdir(), "podcli-paths-")); +const savedHome = process.env.PODCLI_HOME; +const savedData = process.env.PODCLI_DATA; + +let paths: typeof import("./paths.js").paths; + +beforeAll(async () => { + process.env.PODCLI_HOME = join(tmp, "config-home"); + process.env.PODCLI_DATA = join(tmp, "data-root"); + ({ paths } = await import("./paths.js")); +}); + +afterAll(() => { + if (savedHome === undefined) delete process.env.PODCLI_HOME; + else process.env.PODCLI_HOME = savedHome; + if (savedData === undefined) delete process.env.PODCLI_DATA; + else process.env.PODCLI_DATA = savedData; + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("paths", () => { + it("resolves PODCLI_HOME and PODCLI_DATA independently", () => { + expect(paths.home).toBe(resolve(tmp, "config-home")); + expect(paths.dataDir).toBe(resolve(tmp, "data-root")); + expect(paths.cache).toBe(resolve(tmp, "data-root", "cache")); + expect(paths.transcripts).toBe(resolve(tmp, "data-root", "cache", "transcripts")); + expect(paths.knowledge).toBe(resolve(tmp, "config-home", "knowledge")); + expect(paths.integrations).toBe(resolve(tmp, "config-home", "integrations.json")); + }); + + it("keeps profile marker at project root", () => { + expect(paths.homeMarker).toBe(join(paths.projectRoot, ".podcli-home")); + }); +}); diff --git a/src/config/paths.ts b/src/config/paths.ts index d41cfbb..8da213f 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,19 +1,28 @@ -import { join, dirname } from "path"; +import { join, dirname, resolve, isAbsolute } from "path"; import { fileURLToPath } from "url"; -import { existsSync } from "fs"; +import { existsSync, readFileSync } from "fs"; const __dirname = dirname(fileURLToPath(import.meta.url)); -// Project root (next to package.json) -const projectRoot = join(__dirname, "..", ".."); +const projectRoot = resolve(__dirname, "..", ".."); +const homeMarker = join(projectRoot, ".podcli-home"); +const dataDir = resolve(process.env.PODCLI_DATA || join(projectRoot, "data")); -// Visible data/ directory for outputs and user-facing data -const dataDir = process.env.PODCLI_DATA || join(projectRoot, "data"); +function resolveHome(): string { + if (process.env.PODCLI_HOME) { + return resolve(process.env.PODCLI_HOME); + } + if (existsSync(homeMarker)) { + const marker = readFileSync(homeMarker, "utf-8").trim(); + if (marker) { + return isAbsolute(marker) ? resolve(marker) : resolve(projectRoot, marker); + } + } + return resolve(projectRoot, ".podcli"); +} -// Internal .podcli directory for caches, state, and config -const home = process.env.PODCLI_HOME || join(projectRoot, ".podcli"); +const home = resolveHome(); -// Auto-detect venv python (same logic as the bash wrapper) function detectPython(): string { if (process.env.PYTHON_PATH) return process.env.PYTHON_PATH; const venvPython = join(projectRoot, "venv", "bin", "python3"); @@ -24,6 +33,8 @@ function detectPython(): string { export const paths = { home, projectRoot, + homeMarker, + dataDir, cache: join(dataDir, "cache"), transcripts: join(dataDir, "cache", "transcripts"), packed: join(home, "packed"), @@ -36,7 +47,10 @@ export const paths = { clipsHistory: join(home, "history", "clips.json"), knowledge: join(home, "knowledge"), uiState: join(home, "ui-state.json"), - pythonBackend: join(__dirname, "..", "..", "backend", "main.py"), + corrections: join(home, "corrections.json"), + thumbnailConfig: join(home, "thumbnail-config.json"), + integrations: join(home, "integrations.json"), + pythonBackend: join(projectRoot, "backend", "main.py"), pythonPath: detectPython(), ffmpegPath: process.env.FFMPEG_PATH || "ffmpeg", ffprobePath: process.env.FFPROBE_PATH || "ffprobe", diff --git a/src/handlers/batch-clips.handler.ts b/src/handlers/batch-clips.handler.ts index 690fe7c..7e4d29d 100644 --- a/src/handlers/batch-clips.handler.ts +++ b/src/handlers/batch-clips.handler.ts @@ -89,6 +89,12 @@ export const batchClipsToolDef = { "Allow fallback to legacy ASS captions if Remotion caption rendering fails. Default: false.", default: false, }, + keep_caption_overlay: { + type: "boolean", + description: + "Keep ProRes 4444 alpha caption overlays for DaVinci Resolve export. Default: false.", + default: false, + }, transcript_words: { type: "array", description: @@ -144,8 +150,8 @@ export async function handleBatchClips(input: BatchClipsInput): Promise<string> caption_style: s.suggested_caption_style || settings.captionStyle || "hormozi", crop_strategy: settings.cropStrategy || "speaker", allow_ass_fallback: input.allow_ass_fallback === true, + keep_caption_overlay: input.keep_caption_overlay === true, logo_path: settings.logoPath || null, - // Preserve multi-cut segments from suggestion ...(s.segments && s.segments.length > 0 && { keep_segments: s.segments }), }); @@ -188,6 +194,10 @@ export async function handleBatchClips(input: BatchClipsInput): Promise<string> }); } + if (input.keep_caption_overlay === true) { + clips = clips.map((c) => ({ ...c, keep_caption_overlay: c.keep_caption_overlay ?? true })); + } + // Async path — route through Web UI so caller can poll job_status for // live progress during a multi-minute render. Falls back to sync if the // UI isn't running. @@ -203,6 +213,7 @@ export async function handleBatchClips(input: BatchClipsInput): Promise<string> clean_fillers: input.clean_fillers !== false, logo_path: settings.logoPath || null, outro_path: settings.outroPath || null, + keep_caption_overlay: input.keep_caption_overlay === true, }), }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); @@ -230,6 +241,7 @@ export async function handleBatchClips(input: BatchClipsInput): Promise<string> transcript_words: transcriptWords, clean_fillers: input.clean_fillers !== false, allow_ass_fallback: input.allow_ass_fallback === true, + keep_caption_overlay: input.keep_caption_overlay === true, output_dir: paths.output, logo_path: settings.logoPath || null, }); diff --git a/src/handlers/create-clip.handler.ts b/src/handlers/create-clip.handler.ts index a946420..1ea3352 100644 --- a/src/handlers/create-clip.handler.ts +++ b/src/handlers/create-clip.handler.ts @@ -27,7 +27,8 @@ export const createClipToolDef = { "Output: H.264 MP4 with burned-in captions, normalized audio (-14 LUFS).\n\n" + "For batch export, use batch_create_clips instead.\n" + "Caption styles: branded (professional), hormozi (bold/yellow), karaoke (progressive highlight), subtle (minimal).\n" + - "Crop modes: speaker (speaker-aware), face (face tracking), center (fixed center crop).", + "Crop modes: speaker (speaker-aware), face (face tracking), center (fixed center crop).\n" + + "Set keep_caption_overlay=true to retain a ProRes alpha overlay for DaVinci Resolve (export_to_davinci_resolve).", inputSchema: { type: "object" as const, properties: { @@ -104,6 +105,12 @@ export const createClipToolDef = { type: "string", description: "Path to an outro video to append at the end of the clip", }, + keep_caption_overlay: { + type: "boolean", + description: + "Keep ProRes 4444 alpha caption overlay for DaVinci Resolve. Returns caption_overlay_path and cropped_source_path.", + default: false, + }, }, required: [], }, @@ -168,6 +175,7 @@ export async function handleCreateClip(input: CreateClipInput): Promise<string> output_dir: paths.output, clean_fillers: input.clean_fillers !== false, allow_ass_fallback: input.allow_ass_fallback === true, + keep_caption_overlay: input.keep_caption_overlay === true, logo_path: logoPath, outro_path: outroPath, ...(keepSegments && { keep_segments: keepSegments }), diff --git a/src/handlers/integrations.handler.ts b/src/handlers/integrations.handler.ts new file mode 100644 index 0000000..be4f35d --- /dev/null +++ b/src/handlers/integrations.handler.ts @@ -0,0 +1,192 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { PythonExecutor } from "../services/python-executor.js"; + +const executor = new PythonExecutor(); + +interface IntegrationTool { + name: string; + description: string; + tags?: string[]; +} + +interface IntegrationDescriptor { + name: string; + category: "editor_export" | "platform_upload" | "productivity" | "ai_helper"; + description: string; + default_enabled: boolean; + enabled: boolean; + tools: IntegrationTool[]; +} + +interface ListResult { integrations: IntegrationDescriptor[] } +interface SetResult { name: string; enabled: boolean } + +export const manageIntegrationsToolDef = { + name: "manage_integrations", + description: + "List, enable, or disable podcli integrations (editor exporters, platform uploads, productivity tools, AI helpers).\n\n" + + "Actions:\n" + + " • list — return all installed integrations with their enabled state (default)\n" + + " • enable — turn an integration on (its tools become callable)\n" + + " • disable — turn an integration off (calls return a disabled error)\n\n" + + "State persists at the active config home (integrations.json, gitignored).", +}; + +export async function handleManageIntegrations(input: { + action?: "list" | "enable" | "disable"; + name?: string; +}): Promise<string> { + const action = input.action ?? "list"; + const result = await executor.execute<ListResult | SetResult>("manage_integrations", { + action, + name: input.name ?? "", + }); + if (!result.data) throw new Error(`manage_integrations returned no data (action=${action})`); + return JSON.stringify(result.data, null, 2); +} + +interface DvShortInput { + title: string; + source_path: string; + captions_path?: string; + logo_path?: string; +} + +interface DvExportInput { + project_name: string; + output_path: string; + fps?: number; + width?: number; + height?: number; + shorts: DvShortInput[]; +} + +export const exportToDaVinciResolveToolDef = { + name: "export_to_davinci_resolve", + description: + "Export podcli shorts as a DaVinci Resolve FCPXML project.\n\n" + + "Each short becomes a compound clip with V1 source + V2 ProRes 4444 alpha caption overlay — " + + "fully editable in free or Studio Resolve 20.x.\n\n" + + "Requires the davinci_resolve integration to be enabled (manage_integrations action=enable name=davinci_resolve).", +}; + +export async function handleExportToDaVinciResolve(input: DvExportInput): Promise<string> { + const result = await executor.execute<Record<string, unknown>>("run_integration_tool", { + integration: "davinci_resolve", + tool: "export_to_davinci_resolve", + params: input, + }); + if (!result.data) throw new Error("export_to_davinci_resolve returned no data"); + return JSON.stringify(result.data, null, 2); +} + +export const manageConfigToolDef = { + name: "manage_config", + description: + "Manage portable config profiles and legacy path migration.\n\n" + + "Actions:\n" + + " • status — active config home, cache dir, migration state (default)\n" + + " • migrate — move legacy project/.podcli/cache into data/cache (idempotent)\n" + + " • export — zip the active config home (knowledge, presets, assets, settings)\n" + + " • import — restore a bundle; backs up existing config before overwrite\n" + + " • use — activate a config home path (writes .podcli-home marker)", +}; + +export async function handleManageConfig(input: { + action?: "status" | "migrate" | "export" | "import" | "use"; + bundle_path?: string; + home?: string; + activate?: boolean; + dry_run?: boolean; +}): Promise<string> { + const action = input.action ?? "status"; + const result = await executor.execute<Record<string, unknown>>("manage_config", { + action, + bundle_path: input.bundle_path, + home: input.home, + activate: input.activate, + dry_run: input.dry_run, + }); + if (!result.data) throw new Error(`manage_config returned no data (action=${action})`); + return JSON.stringify(result.data, null, 2); +} + +function mcpText(text: string, isError = false) { + return { + content: [{ type: "text" as const, text }], + ...(isError ? { isError: true as const } : {}), + }; +} + +export function registerIntegrationMcpTools(server: McpServer): void { + server.tool( + manageIntegrationsToolDef.name, + manageIntegrationsToolDef.description, + { + action: z.enum(["list", "enable", "disable"]).optional().default("list").describe("list | enable | disable"), + name: z.string().optional().describe("Integration name (required for enable/disable)"), + }, + async ({ action, name }) => { + try { + const text = await handleManageIntegrations({ action, name }); + return mcpText(text); + } catch (err) { + return mcpText(err instanceof Error ? err.message : String(err), true); + } + } + ); + + server.tool( + exportToDaVinciResolveToolDef.name, + exportToDaVinciResolveToolDef.description, + { + project_name: z.string().describe("Name of the Resolve project"), + output_path: z.string().describe("Destination path for the .fcpxml file"), + fps: z.number().optional().describe("Project fps (defaults to source clip's fps)"), + width: z.number().int().optional().describe("Project width (defaults to source's width)"), + height: z.number().int().optional().describe("Project height (defaults to source's height)"), + shorts: z + .array( + z.object({ + title: z.string(), + source_path: z.string().describe("Cropped 9:16 video for V1"), + captions_path: z.string().optional().describe("ProRes 4444 alpha overlay for V2"), + logo_path: z.string().optional().describe("ProRes 4444 alpha overlay for V3"), + }) + ) + .describe("Shorts to lay on the master timeline"), + }, + async (params) => { + try { + const text = await handleExportToDaVinciResolve(params); + return mcpText(text); + } catch (err) { + return mcpText(err instanceof Error ? err.message : String(err), true); + } + } + ); + + server.tool( + manageConfigToolDef.name, + manageConfigToolDef.description, + { + action: z + .enum(["status", "migrate", "export", "import", "use"]) + .optional() + .default("status"), + bundle_path: z.string().optional().describe("Zip path for export/import"), + home: z.string().optional().describe("Config home override or target for import/use"), + activate: z.boolean().optional().describe("After import, set imported home as active"), + dry_run: z.boolean().optional().describe("For migrate: preview moves without changing files"), + }, + async (params) => { + try { + const text = await handleManageConfig(params); + return mcpText(text); + } catch (err) { + return mcpText(err instanceof Error ? err.message : String(err), true); + } + } + ); +} diff --git a/src/handlers/integrations.routes.ts b/src/handlers/integrations.routes.ts new file mode 100644 index 0000000..8aff1dd --- /dev/null +++ b/src/handlers/integrations.routes.ts @@ -0,0 +1,132 @@ +import type { Express } from "express"; +import multer from "multer"; +import { existsSync } from "fs"; +import { mkdir } from "fs/promises"; +import { join, extname } from "path"; +import { v4 as uuidv4 } from "uuid"; +import type { PythonExecutor } from "../services/python-executor.js"; +import { paths } from "../config/paths.js"; + +function routeError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export interface ConfigIntegrationRouteDeps { + executor: PythonExecutor; + uploadDir: string; +} + +export function registerConfigIntegrationRoutes( + app: Express, + deps: ConfigIntegrationRouteDeps +): void { + const { executor, uploadDir } = deps; + const projectRoot = paths.projectRoot; + + app.get("/api/config/status", async (_req, res) => { + try { + const result = await executor.execute<Record<string, unknown>>("manage_config", { action: "status" }); + res.json(result.data ?? {}); + } catch (err) { + res.status(500).json({ error: routeError(err) }); + } + }); + + app.post("/api/config/migrate", async (_req, res) => { + try { + const result = await executor.execute<Record<string, unknown>>("manage_config", { action: "migrate" }); + res.json(result.data ?? {}); + } catch (err) { + res.status(500).json({ error: routeError(err) }); + } + }); + + app.get("/api/config/export", async (_req, res) => { + try { + const bundlePath = join(paths.working, `profile-export-${uuidv4()}.zip`); + await mkdir(paths.working, { recursive: true }); + const result = await executor.execute<{ bundle: string }>("manage_config", { + action: "export", + bundle_path: bundlePath, + }); + const file = result.data?.bundle ?? bundlePath; + if (!existsSync(file)) { + res.status(500).json({ error: "Export failed: bundle not created" }); + return; + } + res.download(file, "podcli-profile.zip"); + } catch (err) { + res.status(500).json({ error: routeError(err) }); + } + }); + + const configUpload = multer({ + storage: multer.diskStorage({ + destination: async (_req, _file, cb) => { + await mkdir(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (_req, _file, cb) => { + cb(null, `profile-import-${uuidv4()}.zip`); + }, + }), + limits: { fileSize: 512 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ext = extname(file.originalname).toLowerCase(); + if (ext === ".zip") cb(null, true); + else cb(new Error("Profile import must be a .zip file")); + }, + }); + + app.post("/api/config/import", configUpload.single("bundle"), async (req, res) => { + try { + if (!req.file?.path) { + res.status(400).json({ error: "Missing bundle file" }); + return; + } + const activate = req.body?.activate === "1" || req.body?.activate === true; + const result = await executor.execute<Record<string, unknown>>("manage_config", { + action: "import", + bundle_path: req.file.path, + activate, + }); + res.json(result.data ?? {}); + } catch (err) { + res.status(500).json({ error: routeError(err) }); + } + }); + + app.get("/api/integrations", async (_req, res) => { + try { + const result = await executor.execute<{ integrations: unknown[] }>("manage_integrations", { + action: "list", + }); + res.json(result.data ?? { integrations: [] }); + } catch (err) { + res.status(500).json({ error: routeError(err) }); + } + }); + + app.post("/api/integrations/:name", async (req, res) => { + const name = req.params.name; + const enabled = !!req.body?.enabled; + try { + const result = await executor.execute<{ name: string; enabled: boolean }>("manage_integrations", { + action: enabled ? "enable" : "disable", + name, + }); + res.json(result.data ?? { name, enabled }); + } catch (err) { + res.status(500).json({ error: routeError(err) }); + } + }); + + app.get("/api/integration-info", (_req, res) => { + const distPath = join(projectRoot, "dist", "index.js"); + res.json({ + dist_path: distPath, + project_root: projectRoot, + server_ok: existsSync(distPath), + }); + }); +} diff --git a/src/models/index.ts b/src/models/index.ts index 6bf2129..bb578f2 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" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "generate_content" | "corrections"; + task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "generate_content" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config"; params: Record<string, unknown>; } @@ -84,6 +84,8 @@ export interface ClipResult { output_path: string; duration: number; file_size_mb: number; + caption_overlay_path?: string; + cropped_source_path?: string; } export interface SuggestedClip { @@ -131,6 +133,7 @@ export interface CreateClipInput { transcript_words?: WordTimestamp[]; clean_fillers?: boolean; allow_ass_fallback?: boolean; + keep_caption_overlay?: boolean; } export interface BatchClipSpec { @@ -141,6 +144,7 @@ export interface BatchClipSpec { crop_strategy?: string; logo_path?: string | null; allow_ass_fallback?: boolean; + keep_caption_overlay?: boolean; keep_segments?: Array<{ start: number; end: number }>; } @@ -152,6 +156,7 @@ export interface BatchClipsInput { export_selected?: boolean; clean_fillers?: boolean; allow_ass_fallback?: boolean; + keep_caption_overlay?: boolean; /** * When true, POST to the Web UI's /api/batch-clips and return a job_id * immediately so the caller can poll job_status and emit live progress. diff --git a/src/server.ts b/src/server.ts index 1677582..3b0a74e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,9 +9,19 @@ import { jobStatusToolDef, handleJobStatus, } from "./handlers/transcribe.handler.js"; -import { suggestClipsToolDef, handleSuggestClips } from "./handlers/suggest-clips.handler.js"; -import { createClipToolDef, handleCreateClip } from "./handlers/create-clip.handler.js"; -import { batchClipsToolDef, handleBatchClips } from "./handlers/batch-clips.handler.js"; +import { + suggestClipsToolDef, + handleSuggestClips, +} from "./handlers/suggest-clips.handler.js"; +import { + createClipToolDef, + handleCreateClip, +} from "./handlers/create-clip.handler.js"; +import { + batchClipsToolDef, + handleBatchClips, +} from "./handlers/batch-clips.handler.js"; +import { registerIntegrationMcpTools } from "./handlers/integrations.handler.js"; import { FileManager } from "./services/file-manager.js"; import { KnowledgeBase } from "./services/knowledge-base.js"; import { AssetManager } from "./services/asset-manager.js"; @@ -166,43 +176,43 @@ async function getWorkflowGuidance(): Promise<string> { if (!hasVideo) { lines.push( "NEXT: No video loaded yet. To begin:\n" + - " → Use transcribe_podcast(file_path: \"/path/to/episode.mp4\") to transcribe and set the video in one step\n" + - " → Or use set_video(file_path: ...) if you'll import a transcript separately" + ' → Use transcribe_podcast(file_path: "/path/to/episode.mp4") to transcribe and set the video in one step\n' + + " → Or use set_video(file_path: ...) if you'll import a transcript separately", ); } else if (!hasTranscript && !hasRawTranscript) { lines.push( `NEXT: Video is loaded but no transcript yet.\n` + - ` → Use transcribe_podcast(file_path: \"${state.videoPath}\") to auto-transcribe with Whisper\n` + - ` → Or the user can paste a transcript in the Web UI and you can read it with get_ui_state(include_transcript: true)` + ` → Use transcribe_podcast(file_path: \"${state.videoPath}\") to auto-transcribe with Whisper\n` + + ` → Or the user can paste a transcript in the Web UI and you can read it with get_ui_state(include_transcript: true)`, ); } else if (hasRawTranscript && !hasTranscript) { lines.push( "NEXT: Raw transcript text is available but not yet parsed.\n" + - " → Use get_ui_state(include_transcript: true) to read the raw text\n" + - " → Then use parse_transcript to get word-level timestamps\n" + - " → Or analyze the text directly and call suggest_clips with your findings" + " → Use get_ui_state(include_transcript: true) to read the raw text\n" + + " → Then use parse_transcript to get word-level timestamps\n" + + " → Or analyze the text directly and call suggest_clips with your findings", ); } else if (hasTranscript && suggestions.length === 0) { lines.push( `NEXT: Transcript is ready (${wordCount} words). Time to find viral moments!\n` + - " → Use get_ui_state(include_transcript: true) to read the full transcript\n" + - " → Analyze it for the most engaging, viral-worthy moments\n" + - " → Then call suggest_clips with your suggestions (title, start_second, end_second, reasoning)" + " → Use get_ui_state(include_transcript: true) to read the full transcript\n" + + " → Analyze it for the most engaging, viral-worthy moments\n" + + " → Then call suggest_clips with your suggestions (title, start_second, end_second, reasoning)", ); } else if (phase === "review" && selectedCount > 0) { lines.push( `NEXT: ${selectedCount} clips are ready for export!\n` + - " → Use batch_create_clips(export_selected: true) to export all selected clips at once\n" + - " → Or use create_clip(clip_number: N) to export a specific one\n" + - " → Use modify_clip to adjust timing or titles before export\n" + - " → Use toggle_clip to select/deselect clips" + " → Use batch_create_clips(export_selected: true) to export all selected clips at once\n" + + " → Or use create_clip(clip_number: N) to export a specific one\n" + + " → Use modify_clip to adjust timing or titles before export\n" + + " → Use toggle_clip to select/deselect clips", ); - } else if (phase === "done" || phase === "idle" && suggestions.length > 0) { + } else if (phase === "done" || (phase === "idle" && suggestions.length > 0)) { lines.push( "DONE: Clips have been exported!\n" + - " → Use list_outputs to see all rendered clips\n" + - " → Use get_ui_state(include_transcript: true) to find more moments\n" + - " → Try different caption styles (hormozi, karaoke, subtle, branded) for variety" + " → Use list_outputs to see all rendered clips\n" + + " → Use get_ui_state(include_transcript: true) to find more moments\n" + + " → Try different caption styles (hormozi, karaoke, subtle, branded) for variety", ); } @@ -237,11 +247,25 @@ export function createServer(): McpServer { num_speakers: z .number() .optional() - .describe("Exact number of speakers if known (e.g. 2). Auto-detects if omitted."), + .describe( + "Exact number of speakers if known (e.g. 2). Auto-detects if omitted.", + ), }, - async ({ file_path, model_size, language, enable_diarization, num_speakers }) => { + async ({ + file_path, + model_size, + language, + enable_diarization, + num_speakers, + }) => { try { - const result = await handleTranscribe({ file_path, model_size, language, enable_diarization, num_speakers }); + const result = await handleTranscribe({ + file_path, + model_size, + language, + enable_diarization, + num_speakers, + }); // Push FULL transcript (words[] + segments[]) to Web UI state from the // on-disk cache — NOT the trimmed MCP response. Without words in UI @@ -265,7 +289,7 @@ export function createServer(): McpServer { const text = withNextStep( await withKnowledge(result), "Transcript is ready! Now read it with get_ui_state(include_transcript: true), " + - "analyze it for viral moments, then call suggest_clips with your findings." + "analyze it for viral moments, then call suggest_clips with your findings.", ); return { content: [{ type: "text" as const, text }] }; } catch (err: unknown) { @@ -275,7 +299,7 @@ export function createServer(): McpServer { isError: true, }; } - } + }, ); // ============================================= @@ -286,7 +310,10 @@ export function createServer(): McpServer { transcribeStartToolDef.description, { file_path: z.string(), - model_size: z.enum(["tiny", "base", "small", "medium", "large"]).optional().default("base"), + model_size: z + .enum(["tiny", "base", "small", "medium", "large"]) + .optional() + .default("base"), language: z.string().optional(), enable_diarization: z.boolean().optional().default(true), num_speakers: z.number().optional(), @@ -302,7 +329,7 @@ export function createServer(): McpServer { isError: true, }; } - } + }, ); // ============================================= @@ -326,7 +353,7 @@ export function createServer(): McpServer { isError: true, }; } - } + }, ); // ============================================= @@ -345,7 +372,9 @@ export function createServer(): McpServer { segments: z .array(z.object({ start: z.number(), end: z.number() })) .optional() - .describe("Multi-cut keep-ranges. Omit for a single continuous clip."), + .describe( + "Multi-cut keep-ranges. Omit for a single continuous clip.", + ), reasoning: z.string(), preview_text: z.string().optional(), content_type: z.string().optional(), @@ -353,7 +382,7 @@ export function createServer(): McpServer { suggested_caption_style: z .enum(["hormozi", "karaoke", "subtle", "branded"]) .optional(), - }) + }), ) .describe("Array of suggested clip moments"), }, @@ -375,9 +404,12 @@ export function createServer(): McpServer { const existing = await history.list(20); let text = await withKnowledge(result); if (existing.length > 0) { - const summary = existing.map( - (e) => ` - "${e.title}" ${e.start_second}s–${e.end_second}s (${e.caption_style})` - ).join("\n"); + const summary = existing + .map( + (e) => + ` - "${e.title}" ${e.start_second}s–${e.end_second}s (${e.caption_style})`, + ) + .join("\n"); text += `\n\n[Previously Created Clips — avoid duplicates]\n${summary}`; } @@ -386,10 +418,10 @@ export function createServer(): McpServer { text = withNextStep( text, `${clipCount} clips suggested and sent to the UI! The user can review them in the Web UI.\n` + - " → To export all at once: batch_create_clips(export_selected: true)\n" + - " → To export specific ones: create_clip(clip_number: 1) or batch_create_clips(clip_numbers: [1, 3, 5])\n" + - " → To adjust: modify_clip(clip_number: N, updates: {start_second: ..., end_second: ...})\n" + - " → To remove one: toggle_clip(clip_number: N, selected: false)" + " → To export all at once: batch_create_clips(export_selected: true)\n" + + " → To export specific ones: create_clip(clip_number: 1) or batch_create_clips(clip_numbers: [1, 3, 5])\n" + + " → To adjust: modify_clip(clip_number: N, updates: {start_second: ..., end_second: ...})\n" + + " → To remove one: toggle_clip(clip_number: N, selected: false)", ); return { content: [{ type: "text" as const, text }] }; } catch (err: unknown) { @@ -399,7 +431,7 @@ export function createServer(): McpServer { isError: true, }; } - } + }, ); // ============================================= @@ -409,10 +441,30 @@ export function createServer(): McpServer { createClipToolDef.name, createClipToolDef.description, { - clip_number: z.number().optional().describe("Export a suggested clip by its number (from suggest_clips). Auto-fills video_path, start/end times, title, and transcript_words from session state."), - video_path: z.string().optional().describe("Path to the original podcast video. Auto-loaded from session state if clip_number is provided."), - start_second: z.number().optional().describe("Clip start time in seconds. Auto-loaded from clip_number if omitted."), - end_second: z.number().optional().describe("Clip end time in seconds. Auto-loaded from clip_number if omitted."), + clip_number: z + .number() + .optional() + .describe( + "Export a suggested clip by its number (from suggest_clips). Auto-fills video_path, start/end times, title, and transcript_words from session state.", + ), + video_path: z + .string() + .optional() + .describe( + "Path to the original podcast video. Auto-loaded from session state if clip_number is provided.", + ), + start_second: z + .number() + .optional() + .describe( + "Clip start time in seconds. Auto-loaded from clip_number if omitted.", + ), + end_second: z + .number() + .optional() + .describe( + "Clip end time in seconds. Auto-loaded from clip_number if omitted.", + ), caption_style: z .enum(["hormozi", "karaoke", "subtle", "branded"]) .optional() @@ -427,7 +479,9 @@ export function createServer(): McpServer { .boolean() .optional() .default(false) - .describe("Allow ASS caption fallback if Remotion rendering fails (default: false)"), + .describe( + "Allow ASS caption fallback if Remotion rendering fails (default: false)", + ), transcript_words: z .array( z.object({ @@ -435,19 +489,30 @@ export function createServer(): McpServer { start: z.number(), end: z.number(), confidence: z.number().optional().default(0), - }) + }), ) .optional() - .describe("Word-level timestamps. Auto-loaded from session state if omitted."), + .describe( + "Word-level timestamps. Auto-loaded from session state if omitted.", + ), title: z.string().optional().default("clip").describe("Clip title"), logo_path: z .string() .optional() - .describe("Path or registered asset name for PNG logo. Shown in top-left (branded style)."), + .describe( + "Path or registered asset name for PNG logo. Shown in top-left (branded style).", + ), outro_path: z .string() .optional() .describe("Path to an outro video to append at the end of the clip"), + keep_caption_overlay: z + .boolean() + .optional() + .default(false) + .describe( + "Keep ProRes 4444 alpha caption overlay beside the render (for DaVinci Resolve export). Returns caption_overlay_path and cropped_source_path.", + ), }, async (params) => { try { @@ -459,33 +524,49 @@ export function createServer(): McpServer { // Resolve clip_number from UI state BEFORE routing let keepSegments: Array<{ start: number; end: number }> | null = null; - if (params.clip_number != null && (params.start_second == null || params.end_second == null)) { + if ( + params.clip_number != null && + (params.start_second == null || params.end_second == null) + ) { const uiState = await readUIState(); const suggestions = uiState?.suggestions ?? []; const settings = uiState?.settings ?? {}; const idx = (params.clip_number as number) - 1; if (idx < 0 || idx >= suggestions.length) { return { - content: [{ - type: "text" as const, - text: `Clip #${params.clip_number} not found. Available: 1-${suggestions.length}`, - }], + content: [ + { + type: "text" as const, + text: `Clip #${params.clip_number} not found. Available: 1-${suggestions.length}`, + }, + ], }; } const suggestion = suggestions[idx]; - if (params.start_second == null) params.start_second = suggestion.start_second as number; - if (params.end_second == null) params.end_second = suggestion.end_second as number; - if (!params.video_path) params.video_path = (uiState?.videoPath || uiState?.filePath || "") as string; - if (!params.title || params.title === "clip") params.title = (suggestion.title as string) || "clip"; + if (params.start_second == null) + params.start_second = suggestion.start_second as number; + if (params.end_second == null) + params.end_second = suggestion.end_second as number; + if (!params.video_path) + params.video_path = (uiState?.videoPath || + uiState?.filePath || + "") as string; + if (!params.title || params.title === "clip") + params.title = (suggestion.title as string) || "clip"; if (!params.caption_style || params.caption_style === "hormozi") { - params.caption_style = ((suggestion.suggested_caption_style as string) || settings.captionStyle || "hormozi") as typeof params.caption_style; + params.caption_style = + ((suggestion.suggested_caption_style as string) || + settings.captionStyle || + "hormozi") as typeof params.caption_style; } if (!params.transcript_words) { const transcript = uiState?.transcript; if (transcript?.words) params.transcript_words = transcript.words; } // Pull multi-cut segments from suggestion - const segs = suggestion.segments as Array<{ start: number; end: number }> | undefined; + const segs = suggestion.segments as + | Array<{ start: number; end: number }> + | undefined; if (segs && segs.length > 0) { keepSegments = segs; } @@ -497,14 +578,16 @@ export function createServer(): McpServer { params.start_second as number, params.end_second as number, (params.caption_style || "hormozi") as string, - (params.crop_strategy || "speaker") as string + (params.crop_strategy || "speaker") as string, ); if (dup) { return { - content: [{ - type: "text" as const, - text: `Duplicate found! This clip was already created on ${dup.created_at}.\nOutput: ${dup.output_path}\nUse a different time range or style to create a new clip.`, - }], + content: [ + { + type: "text" as const, + text: `Duplicate found! This clip was already created on ${dup.created_at}.\nOutput: ${dup.output_path}\nUse a different time range or style to create a new clip.`, + }, + ], }; } @@ -517,15 +600,17 @@ export function createServer(): McpServer { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_path: params.video_path, - clips: [{ - start_second: params.start_second, - end_second: params.end_second, - title: (params.title || "clip") as string, - caption_style: params.caption_style || "hormozi", - crop_strategy: params.crop_strategy || "speaker", - allow_ass_fallback: params.allow_ass_fallback === true, - ...(keepSegments && { segments: keepSegments }), - }], + clips: [ + { + start_second: params.start_second, + end_second: params.end_second, + title: (params.title || "clip") as string, + caption_style: params.caption_style || "hormozi", + crop_strategy: params.crop_strategy || "speaker", + allow_ass_fallback: params.allow_ass_fallback === true, + ...(keepSegments && { segments: keepSegments }), + }, + ], transcript_words: params.transcript_words, logo_path: params.logo_path || null, outro_path: params.outro_path || null, @@ -539,7 +624,9 @@ export function createServer(): McpServer { const deadline = Date.now() + 3600_000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 2000)); - const pollRes = await fetch(`http://localhost:3847/api/job/${jobId}`); + const pollRes = await fetch( + `http://localhost:3847/api/job/${jobId}`, + ); if (!pollRes.ok) break; const job = (await pollRes.json()) as WebJob; if (job.status === "done") { @@ -556,8 +643,12 @@ export function createServer(): McpServer { } } } catch (webErr: unknown) { - const webMsg = webErr instanceof Error ? webErr.message : String(webErr); - if (!webMsg.includes("ECONNREFUSED") && !webMsg.includes("fetch failed")) { + const webMsg = + webErr instanceof Error ? webErr.message : String(webErr); + if ( + !webMsg.includes("ECONNREFUSED") && + !webMsg.includes("fetch failed") + ) { // Unexpected error — still try direct fallback } } @@ -590,9 +681,9 @@ export function createServer(): McpServer { const clipText = withNextStep( finalResult, "Clip exported to data/output/! You can:\n" + - " → Export more: create_clip(clip_number: N) or batch_create_clips(export_selected: true)\n" + - " → List all outputs: list_outputs\n" + - " → Try a different style: create_clip(clip_number: N, caption_style: \"karaoke\")" + " → Export more: create_clip(clip_number: N) or batch_create_clips(export_selected: true)\n" + + " → List all outputs: list_outputs\n" + + ' → Try a different style: create_clip(clip_number: N, caption_style: "karaoke")', ); return { content: [{ type: "text" as const, text: clipText }] }; } catch (err: unknown) { @@ -602,7 +693,7 @@ export function createServer(): McpServer { isError: true, }; } - } + }, ); // ============================================= @@ -612,20 +703,36 @@ export function createServer(): McpServer { batchClipsToolDef.name, batchClipsToolDef.description, { - video_path: z.string().optional().describe("Path to the original podcast video. Auto-loaded from session state if omitted."), + video_path: z + .string() + .optional() + .describe( + "Path to the original podcast video. Auto-loaded from session state if omitted.", + ), clips: z .array( z.object({ start_second: z.number(), end_second: z.number(), title: z.string().optional(), - caption_style: z.enum(["hormozi", "karaoke", "subtle", "branded"]).optional(), + caption_style: z + .enum(["hormozi", "karaoke", "subtle", "branded"]) + .optional(), crop_strategy: z.enum(["center", "face", "speaker"]).optional(), allow_ass_fallback: z.boolean().optional(), - }) + keep_caption_overlay: z.boolean().optional(), + }), ) .optional() - .describe("Array of clips to create. Auto-loaded from suggestions if omitted."), + .describe( + "Array of clips to create. Auto-loaded from suggestions if omitted.", + ), + keep_caption_overlay: z + .boolean() + .optional() + .describe( + "Keep ProRes 4444 alpha caption overlays for DaVinci Resolve export (batch-level default; per-clip overrides).", + ), transcript_words: z .array( z.object({ @@ -633,19 +740,29 @@ export function createServer(): McpServer { start: z.number(), end: z.number(), confidence: z.number().optional().default(0), - }) + }), ) .optional() - .describe("Word-level timestamps. Auto-loaded from session state if omitted."), - export_selected: z.boolean().optional().describe("If true, export all selected suggestions from the UI."), - clip_numbers: z.array(z.number()).optional().describe("Export specific clip numbers from suggestions (e.g. [1, 3, 5])."), + .describe( + "Word-level timestamps. Auto-loaded from session state if omitted.", + ), + export_selected: z + .boolean() + .optional() + .describe("If true, export all selected suggestions from the UI."), + clip_numbers: z + .array(z.number()) + .optional() + .describe( + "Export specific clip numbers from suggestions (e.g. [1, 3, 5]).", + ), async_mode: z .boolean() .optional() .default(false) .describe( "Return a job_id immediately and render in background. Use for multi-clip batches " + - "so Claude can poll job_status and emit live progress. Requires Web UI running." + "so Claude can poll job_status and emit live progress. Requires Web UI running.", ), }, async (params) => { @@ -659,7 +776,10 @@ export function createServer(): McpServer { const suggestions = uiState?.suggestions ?? []; const settings = uiState?.settings ?? {}; const deselected = (uiState?.deselectedIndices as number[]) || []; - if (!resolvedVideoPath) resolvedVideoPath = (uiState?.videoPath || uiState?.filePath || "") as string; + if (!resolvedVideoPath) + resolvedVideoPath = (uiState?.videoPath || + uiState?.filePath || + "") as string; if (!resolvedTranscriptWords) { const transcript = uiState?.transcript; if (transcript?.words) resolvedTranscriptWords = transcript.words; @@ -672,10 +792,14 @@ export function createServer(): McpServer { start_second: s.start_second, end_second: s.end_second, title: s.title || `clip_${i + 1}`, - caption_style: s.suggested_caption_style || settings.captionStyle || "hormozi", + caption_style: + s.suggested_caption_style || + settings.captionStyle || + "hormozi", crop_strategy: settings.cropStrategy || "speaker", allow_ass_fallback: false, - ...(s.segments && s.segments.length > 0 && { keep_segments: s.segments }), + ...(s.segments && + s.segments.length > 0 && { keep_segments: s.segments }), })) as any; } else if (params.clip_numbers) { resolvedClips = (params.clip_numbers as number[]) @@ -686,10 +810,14 @@ export function createServer(): McpServer { start_second: s.start_second, end_second: s.end_second, title: s.title || `clip_${n}`, - caption_style: s.suggested_caption_style || settings.captionStyle || "hormozi", + caption_style: + s.suggested_caption_style || + settings.captionStyle || + "hormozi", crop_strategy: settings.cropStrategy || "speaker", allow_ass_fallback: false, - ...(s.segments && s.segments.length > 0 && { keep_segments: s.segments }), + ...(s.segments && + s.segments.length > 0 && { keep_segments: s.segments }), }; }) as any; } @@ -735,7 +863,9 @@ export function createServer(): McpServer { const deadline = Date.now() + 3600_000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 2000)); - const pollRes = await fetch(`http://localhost:3847/api/job/${jobId}`); + const pollRes = await fetch( + `http://localhost:3847/api/job/${jobId}`, + ); if (!pollRes.ok) break; const job = (await pollRes.json()) as WebJob; if (job.status === "done") { @@ -752,8 +882,12 @@ export function createServer(): McpServer { } } } catch (webErr: unknown) { - const webMsg = webErr instanceof Error ? webErr.message : String(webErr); - if (!webMsg.includes("ECONNREFUSED") && !webMsg.includes("fetch failed")) { + const webMsg = + webErr instanceof Error ? webErr.message : String(webErr); + if ( + !webMsg.includes("ECONNREFUSED") && + !webMsg.includes("fetch failed") + ) { // Unexpected error — still try fallback } } @@ -790,22 +924,24 @@ export function createServer(): McpServer { const batchText = withNextStep( finalResult, "Batch export complete! Clips are in data/output/. You can:\n" + - " → Use list_outputs to see all rendered clips with file sizes\n" + - " → Find more moments: get_ui_state(include_transcript: true) and suggest_clips again\n" + - " → Re-export with different styles: update_settings then batch_create_clips(export_selected: true)" + " → Use list_outputs to see all rendered clips with file sizes\n" + + " → Find more moments: get_ui_state(include_transcript: true) and suggest_clips again\n" + + " → Re-export with different styles: update_settings then batch_create_clips(export_selected: true)", ); return { content: [{ type: "text" as const, text: batchText }] }; } catch (err: unknown) { // Notify UI of error await uiPing({ phase: "review" }); - log.error("batch_create_clips failed", { err: err instanceof Error ? err.stack : String(err) }); + log.error("batch_create_clips failed", { + err: err instanceof Error ? err.stack : String(err), + }); const msg = err instanceof Error ? err.message : String(err); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true, }; } - } + }, ); // ============================================= @@ -815,16 +951,31 @@ export function createServer(): McpServer { "knowledge_base", "Read or manage the podcli knowledge base. These are .md files that provide context about the podcast (hosts, style, audience, etc). Always read the knowledge base before suggesting or creating clips.", { - action: z.enum(["read_all", "list", "read", "write", "delete"]).describe("Action to perform"), - filename: z.string().optional().describe("Filename for read/write/delete (e.g. 'style.md')"), - content: z.string().optional().describe("Markdown content for write action"), + action: z + .enum(["read_all", "list", "read", "write", "delete"]) + .describe("Action to perform"), + filename: z + .string() + .optional() + .describe("Filename for read/write/delete (e.g. 'style.md')"), + content: z + .string() + .optional() + .describe("Markdown content for write action"), }, async ({ action, filename, content }) => { try { if (action === "read_all" || action === "list") { const files = await kb.listFiles(); if (files.length === 0) { - return { content: [{ type: "text" as const, text: "Knowledge base is empty. Add .md files to .podcli/knowledge/ in the project directory." }] }; + return { + content: [ + { + type: "text" as const, + text: "Knowledge base is empty. Add .md files to .podcli/knowledge/ in the project directory.", + }, + ], + }; } const summaries: string[] = []; for (const f of files) { @@ -832,7 +983,9 @@ export function createServer(): McpServer { try { const content = await kb.readFile(f.filename); // First non-empty, non-heading line as preview - const lines = content.split("\n").filter((l: string) => l.trim() && !l.startsWith("#")); + const lines = content + .split("\n") + .filter((l: string) => l.trim() && !l.startsWith("#")); preview = lines[0]?.trim().slice(0, 100) || ""; } catch (err) { log.debug("Failed to read KB file for preview", { @@ -840,9 +993,18 @@ export function createServer(): McpServer { err: err instanceof Error ? err.message : String(err), }); } - summaries.push(`- ${f.filename} (updated ${f.updatedAt})${preview ? `\n ${preview}` : ""}`); + summaries.push( + `- ${f.filename} (updated ${f.updatedAt})${preview ? `\n ${preview}` : ""}`, + ); } - return { content: [{ type: "text" as const, text: `Knowledge base (${files.length} files) — use read action with filename to get full content:\n${summaries.join("\n")}` }] }; + return { + content: [ + { + type: "text" as const, + text: `Knowledge base (${files.length} files) — use read action with filename to get full content:\n${summaries.join("\n")}`, + }, + ], + }; } if (action === "read" && filename) { const text = await kb.readFile(filename); @@ -850,18 +1012,32 @@ export function createServer(): McpServer { } if (action === "write" && filename && content) { await kb.writeFile(filename, content); - return { content: [{ type: "text" as const, text: `Saved ${filename}` }] }; + return { + content: [{ type: "text" as const, text: `Saved ${filename}` }], + }; } if (action === "delete" && filename) { await kb.deleteFile(filename); - return { content: [{ type: "text" as const, text: `Deleted ${filename}` }] }; + return { + content: [{ type: "text" as const, text: `Deleted ${filename}` }], + }; } - return { content: [{ type: "text" as const, text: "Invalid action or missing parameters." }] }; + return { + content: [ + { + type: "text" as const, + text: "Invalid action or missing parameters.", + }, + ], + }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -871,41 +1047,88 @@ export function createServer(): McpServer { "manage_assets", "Register and manage reusable assets (logos, videos). Registered assets can be referenced by name in create_clip instead of full paths.", { - action: z.enum(["list", "register", "unregister", "resolve", "import"]).describe("Action to perform"), + action: z + .enum(["list", "register", "unregister", "resolve", "import"]) + .describe("Action to perform"), name: z.string().optional().describe("Asset name (e.g. 'podcast-logo')"), path: z.string().optional().describe("Absolute file path (for register)"), - type: z.enum(["logo", "video", "image", "other"]).optional().describe("Asset type (for register/list filter)"), + type: z + .enum(["logo", "video", "image", "other"]) + .optional() + .describe("Asset type (for register/list filter)"), }, async ({ action, name, path, type }) => { try { if (action === "list") { const items = await assets.list(type || undefined); - if (items.length === 0) return { content: [{ type: "text" as const, text: "No assets registered." }] }; - const text = items.map((a) => `- ${a.name} (${a.type}): ${a.path}`).join("\n"); + if (items.length === 0) + return { + content: [ + { type: "text" as const, text: "No assets registered." }, + ], + }; + const text = items + .map((a) => `- ${a.name} (${a.type}): ${a.path}`) + .join("\n"); return { content: [{ type: "text" as const, text }] }; } if (action === "register" && name && path) { const asset = await assets.register(name, path, type || "other"); - return { content: [{ type: "text" as const, text: `Registered "${asset.name}" → ${asset.path}` }] }; + return { + content: [ + { + type: "text" as const, + text: `Registered "${asset.name}" → ${asset.path}`, + }, + ], + }; } if (action === "unregister" && name) { await assets.unregister(name); - return { content: [{ type: "text" as const, text: `Unregistered "${name}"` }] }; + return { + content: [ + { type: "text" as const, text: `Unregistered "${name}"` }, + ], + }; } if (action === "resolve" && name) { const resolved = await assets.resolve(name); - return { content: [{ type: "text" as const, text: resolved || `Asset "${name}" not found.` }] }; + return { + content: [ + { + type: "text" as const, + text: resolved || `Asset "${name}" not found.`, + }, + ], + }; } if (action === "import" && path && name) { const asset = await assets.importFile(path, name, type || "other"); - return { content: [{ type: "text" as const, text: `Imported "${asset.name}" → ${asset.path}` }] }; + return { + content: [ + { + type: "text" as const, + text: `Imported "${asset.name}" → ${asset.path}`, + }, + ], + }; } - return { content: [{ type: "text" as const, text: "Invalid action or missing parameters." }] }; + return { + content: [ + { + type: "text" as const, + text: "Invalid action or missing parameters.", + }, + ], + }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -915,42 +1138,101 @@ export function createServer(): McpServer { "clip_history", "View previously created clips to avoid duplicates. Check before creating new clips.", { - action: z.enum(["list", "check"]).describe("list = recent clips, check = find duplicate"), - source_video: z.string().optional().describe("Source video path (for check or filter)"), + action: z + .enum(["list", "check"]) + .describe("list = recent clips, check = find duplicate"), + source_video: z + .string() + .optional() + .describe("Source video path (for check or filter)"), start_second: z.number().optional().describe("Start time (for check)"), end_second: z.number().optional().describe("End time (for check)"), - caption_style: z.string().optional().describe("Caption style (for check)"), - crop_strategy: z.string().optional().describe("Crop strategy (for check)"), + caption_style: z + .string() + .optional() + .describe("Caption style (for check)"), + crop_strategy: z + .string() + .optional() + .describe("Crop strategy (for check)"), limit: z.number().optional().default(20).describe("Max results for list"), }, - async ({ action, source_video, start_second, end_second, caption_style, crop_strategy, limit }) => { + async ({ + action, + source_video, + start_second, + end_second, + caption_style, + crop_strategy, + limit, + }) => { try { if (action === "list") { const entries = source_video ? await history.getBySource(source_video) : await history.list(limit || 20); - if (entries.length === 0) return { content: [{ type: "text" as const, text: "No clips in history." }] }; + if (entries.length === 0) + return { + content: [ + { type: "text" as const, text: "No clips in history." }, + ], + }; const text = entries - .map((e) => `- "${e.title}" ${e.start_second}s–${e.end_second}s | ${e.caption_style} | ${e.created_at} | ${e.output_path}`) + .map( + (e) => + `- "${e.title}" ${e.start_second}s–${e.end_second}s | ${e.caption_style} | ${e.created_at} | ${e.output_path}`, + ) .join("\n"); return { content: [{ type: "text" as const, text }] }; } - if (action === "check" && source_video && start_second !== undefined && end_second !== undefined) { + if ( + action === "check" && + source_video && + start_second !== undefined && + end_second !== undefined + ) { const dup = await history.findDuplicate( - source_video, start_second, end_second, - caption_style || "hormozi", crop_strategy || "speaker" + source_video, + start_second, + end_second, + caption_style || "hormozi", + crop_strategy || "speaker", ); if (dup) { - return { content: [{ type: "text" as const, text: `Duplicate found: "${dup.title}" created ${dup.created_at}\nOutput: ${dup.output_path}` }] }; + return { + content: [ + { + type: "text" as const, + text: `Duplicate found: "${dup.title}" created ${dup.created_at}\nOutput: ${dup.output_path}`, + }, + ], + }; } - return { content: [{ type: "text" as const, text: "No duplicate found. Safe to create." }] }; + return { + content: [ + { + type: "text" as const, + text: "No duplicate found. Safe to create.", + }, + ], + }; } - return { content: [{ type: "text" as const, text: "Invalid action or missing parameters." }] }; + return { + content: [ + { + type: "text" as const, + text: "Invalid action or missing parameters.", + }, + ], + }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -959,17 +1241,21 @@ export function createServer(): McpServer { server.tool( "get_ui_state", "Read the current podcli session state and get guidance on what to do next. " + - "Returns: video path, transcript status, clip suggestions, settings, and workflow next steps.\n\n" + - "IMPORTANT: Call this FIRST when starting a new conversation to understand the current state.\n" + - "Clips are numbered #1, #2, etc. Use these numbers with create_clip(clip_number), " + - "batch_create_clips(clip_numbers), modify_clip, and toggle_clip.\n\n" + - "Set include_transcript=true to analyze transcript content. Returns a compact phrase-grouped " + - "markdown view (~10x smaller than raw segments) with speaker attribution, silence gaps, and " + - "optional energy peaks — the primary surface for reasoning about clip boundaries.", + "Returns: video path, transcript status, clip suggestions, settings, and workflow next steps.\n\n" + + "IMPORTANT: Call this FIRST when starting a new conversation to understand the current state.\n" + + "Clips are numbered #1, #2, etc. Use these numbers with create_clip(clip_number), " + + "batch_create_clips(clip_numbers), modify_clip, and toggle_clip.\n\n" + + "Set include_transcript=true to analyze transcript content. Returns a compact phrase-grouped " + + "markdown view (~10x smaller than raw segments) with speaker attribution, silence gaps, and " + + "optional energy peaks — the primary surface for reasoning about clip boundaries.", { - include_transcript: z.boolean().optional().default(false).describe( - "Include full transcript segments in the response. Set true when analyzing content for clip suggestions." - ), + include_transcript: z + .boolean() + .optional() + .default(false) + .describe( + "Include full transcript segments in the response. Set true when analyzing content for clip suggestions.", + ), }, async ({ include_transcript }) => { try { @@ -980,29 +1266,38 @@ export function createServer(): McpServer { const lines: string[] = []; lines.push(`Phase: ${state.phase}`); lines.push(`Video: ${state.videoPath || state.filePath || "(none)"}`); - lines.push(`Settings: caption=${state.settings?.captionStyle}, crop=${state.settings?.cropStrategy}, logo=${state.settings?.logoPath || "none"}`); + lines.push( + `Settings: caption=${state.settings?.captionStyle}, crop=${state.settings?.cropStrategy}, logo=${state.settings?.logoPath || "none"}`, + ); lines.push(`Transcript: ${state.transcriptWordCount ?? 0} words`); const allSuggestions = state.suggestions ?? []; const deselected = state.deselectedIndices ?? []; const selectedCount = allSuggestions.length - deselected.length; - lines.push(`Clips: ${selectedCount} selected, ${allSuggestions.length} total`); + lines.push( + `Clips: ${selectedCount} selected, ${allSuggestions.length} total`, + ); if (allSuggestions.length) { lines.push(""); - lines.push("Clips (use these numbers with create_clip/batch_create_clips):"); + lines.push( + "Clips (use these numbers with create_clip/batch_create_clips):", + ); for (let i = 0; i < allSuggestions.length; i++) { const clip = allSuggestions[i]; const num = i + 1; const title = clip.title || "untitled"; const start = clip.start_second ?? "?"; const end = clip.end_second ?? "?"; - const duration = typeof start === "number" && typeof end === "number" - ? `${Math.round(end - start)}s` - : "?"; + const duration = + typeof start === "number" && typeof end === "number" + ? `${Math.round(end - start)}s` + : "?"; const style = clip.suggested_caption_style || "hormozi"; const tag = deselected.includes(i) ? " [DESELECTED]" : ""; - lines.push(` #${num}: "${title}" (${start}s–${end}s, ${duration}) [${style}]${tag}`); + lines.push( + ` #${num}: "${title}" (${start}s–${end}s, ${duration}) [${style}]${tag}`, + ); } } @@ -1014,7 +1309,9 @@ export function createServer(): McpServer { packed = await transcriptCache.getPackedMarkdown(state.videoPath); } if (!packed && state.rawTranscriptText) { - packed = await transcriptCache.getPackedMarkdownFromText(state.rawTranscriptText); + packed = await transcriptCache.getPackedMarkdownFromText( + state.rawTranscriptText, + ); } if (packed) { lines.push(""); @@ -1035,7 +1332,9 @@ export function createServer(): McpServer { } } else if (state.rawTranscriptText) { lines.push(""); - lines.push("=== RAW TRANSCRIPT (not yet parsed — use this to analyze and suggest clips) ==="); + lines.push( + "=== RAW TRANSCRIPT (not yet parsed — use this to analyze and suggest clips) ===", + ); lines.push(state.rawTranscriptText); } } @@ -1052,15 +1351,22 @@ export function createServer(): McpServer { if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) { const guidance = await getWorkflowGuidance(); return { - content: [{ type: "text" as const, text: `Web UI is not running. Start with: npm run ui\n\n${guidance}` }], + content: [ + { + type: "text" as const, + text: `Web UI is not running. Start with: npm run ui\n\n${guidance}`, + }, + ], }; } return { - content: [{ type: "text" as const, text: `Error reading UI state: ${msg}` }], + content: [ + { type: "text" as const, text: `Error reading UI state: ${msg}` }, + ], isError: true, }; } - } + }, ); // ============================================= @@ -1069,20 +1375,40 @@ export function createServer(): McpServer { server.tool( "modify_clip", "Adjust a suggested clip before exporting. Change timing, title, or caption style. " + - "Use action='delete' to remove a clip entirely. Reference clips by clip_number (from get_ui_state).", + "Use action='delete' to remove a clip entirely. Reference clips by clip_number (from get_ui_state).", { - clip_number: z.number().optional().describe("Clip number (1-based, from get_ui_state)"), - clip_id: z.string().optional().describe("UUID of the clip (alternative to clip_number)"), - index: z.number().optional().describe("0-based index (deprecated, use clip_number)"), - action: z.enum(["update", "delete"]).optional().default("update").describe("Action: 'update' (default) or 'delete'"), - updates: z.object({ - title: z.string().optional(), - start_second: z.number().optional(), - end_second: z.number().optional(), - reasoning: z.string().optional(), - preview_text: z.string().optional(), - suggested_caption_style: z.enum(["hormozi", "karaoke", "subtle", "branded"]).optional(), - }).optional().describe("Partial fields to update on the clip (ignored when action='delete')"), + clip_number: z + .number() + .optional() + .describe("Clip number (1-based, from get_ui_state)"), + clip_id: z + .string() + .optional() + .describe("UUID of the clip (alternative to clip_number)"), + index: z + .number() + .optional() + .describe("0-based index (deprecated, use clip_number)"), + action: z + .enum(["update", "delete"]) + .optional() + .default("update") + .describe("Action: 'update' (default) or 'delete'"), + updates: z + .object({ + title: z.string().optional(), + start_second: z.number().optional(), + end_second: z.number().optional(), + reasoning: z.string().optional(), + preview_text: z.string().optional(), + suggested_caption_style: z + .enum(["hormozi", "karaoke", "subtle", "branded"]) + .optional(), + }) + .optional() + .describe( + "Partial fields to update on the clip (ignored when action='delete')", + ), }, async ({ clip_number, clip_id, index, action, updates }) => { try { @@ -1093,7 +1419,11 @@ export function createServer(): McpServer { const suggestions = state.suggestions ?? []; if (!suggestions.length) { - return { content: [{ type: "text" as const, text: "No suggestions in UI state." }] }; + return { + content: [ + { type: "text" as const, text: "No suggestions in UI state." }, + ], + }; } // 2. Find target clip (clip_number is 1-based) @@ -1107,7 +1437,14 @@ export function createServer(): McpServer { } if (targetIdx < 0 || targetIdx >= suggestions.length) { - return { content: [{ type: "text" as const, text: `Clip not found. Use get_ui_state to see available clips.` }] }; + return { + content: [ + { + type: "text" as const, + text: `Clip not found. Use get_ui_state to see available clips.`, + }, + ], + }; } // --- DELETE --- @@ -1127,29 +1464,45 @@ export function createServer(): McpServer { }); return { - content: [{ - type: "text" as const, - text: `Deleted clip #${targetIdx + 1}: "${removed.title}". ${suggestions.length} clips remaining.`, - }], + content: [ + { + type: "text" as const, + text: `Deleted clip #${targetIdx + 1}: "${removed.title}". ${suggestions.length} clips remaining.`, + }, + ], }; } // --- UPDATE --- const upd = updates || {}; if (Object.keys(upd).length === 0) { - return { content: [{ type: "text" as const, text: "No updates provided. Specify at least one field: title, start_second, end_second, reasoning, preview_text, or suggested_caption_style." }] }; + return { + content: [ + { + type: "text" as const, + text: "No updates provided. Specify at least one field: title, start_second, end_second, reasoning, preview_text, or suggested_caption_style.", + }, + ], + }; } const clip = suggestions[targetIdx]; if (upd.title !== undefined) clip.title = upd.title; - if (upd.start_second !== undefined) clip.start_second = upd.start_second; + if (upd.start_second !== undefined) + clip.start_second = upd.start_second; if (upd.end_second !== undefined) clip.end_second = upd.end_second; if (upd.reasoning !== undefined) clip.reasoning = upd.reasoning; - if (upd.preview_text !== undefined) clip.preview_text = upd.preview_text; - if (upd.suggested_caption_style !== undefined) clip.suggested_caption_style = upd.suggested_caption_style; + if (upd.preview_text !== undefined) + clip.preview_text = upd.preview_text; + if (upd.suggested_caption_style !== undefined) + clip.suggested_caption_style = upd.suggested_caption_style; // Recalculate derived fields - clip.duration = Math.round((clip.end_second - clip.start_second) * 10) / 10; - const fmtTime = (s: number) => `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, "0")}`; + clip.duration = + Math.round((clip.end_second - clip.start_second) * 10) / 10; + const fmtTime = (s: number) => + `${Math.floor(s / 60)}:${Math.floor(s % 60) + .toString() + .padStart(2, "0")}`; clip.timestamp_display = `${fmtTime(clip.start_second)} → ${fmtTime(clip.end_second)}`; suggestions[targetIdx] = clip; @@ -1160,19 +1513,31 @@ export function createServer(): McpServer { }); return { - content: [{ - type: "text" as const, - text: `Updated clip #${targetIdx + 1}: "${clip.title}" (${clip.start_second}s–${clip.end_second}s, ${clip.duration}s)`, - }], + content: [ + { + type: "text" as const, + text: `Updated clip #${targetIdx + 1}: "${clip.title}" (${clip.start_second}s–${clip.end_second}s, ${clip.duration}s)`, + }, + ], }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) { - return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] }; + return { + content: [ + { + type: "text" as const, + text: "Web UI is not running. Start with: npm run ui", + }, + ], + }; } - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -1182,9 +1547,18 @@ export function createServer(): McpServer { "toggle_clip", "Select or deselect a suggested clip by clip_number. Selected clips are exported with export_selected.", { - clip_number: z.number().optional().describe("Clip number (1-based, from get_ui_state)"), - clip_id: z.string().optional().describe("UUID of the clip (alternative to clip_number)"), - index: z.number().optional().describe("0-based index (deprecated, use clip_number)"), + clip_number: z + .number() + .optional() + .describe("Clip number (1-based, from get_ui_state)"), + clip_id: z + .string() + .optional() + .describe("UUID of the clip (alternative to clip_number)"), + index: z + .number() + .optional() + .describe("0-based index (deprecated, use clip_number)"), selected: z.boolean().describe("true = select, false = deselect"), }, async ({ clip_number, clip_id, index, selected }) => { @@ -1206,14 +1580,23 @@ export function createServer(): McpServer { } if (targetIdx < 0 || targetIdx >= suggestions.length) { - return { content: [{ type: "text" as const, text: "Clip not found. Use get_ui_state to see available clips." }] }; + return { + content: [ + { + type: "text" as const, + text: "Clip not found. Use get_ui_state to see available clips.", + }, + ], + }; } let updated: number[]; if (selected) { updated = deselected.filter((i: number) => i !== targetIdx); } else { - updated = deselected.includes(targetIdx) ? deselected : [...deselected, targetIdx]; + updated = deselected.includes(targetIdx) + ? deselected + : [...deselected, targetIdx]; } await fetch("http://localhost:3847/api/ui-state", { @@ -1224,19 +1607,31 @@ export function createServer(): McpServer { const clip = suggestions[targetIdx]; return { - content: [{ - type: "text" as const, - text: `Clip #${targetIdx + 1} "${clip.title}" is now ${selected ? "selected" : "deselected"}. (${suggestions.length - updated.length}/${suggestions.length} selected)`, - }], + content: [ + { + type: "text" as const, + text: `Clip #${targetIdx + 1} "${clip.title}" is now ${selected ? "selected" : "deselected"}. (${suggestions.length - updated.length}/${suggestions.length} selected)`, + }, + ], }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) { - return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] }; + return { + content: [ + { + type: "text" as const, + text: "Web UI is not running. Start with: npm run ui", + }, + ], + }; } - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -1246,10 +1641,22 @@ export function createServer(): McpServer { "update_settings", "Update rendering settings (caption style, crop strategy, logo, outro) in the Web UI.", { - caption_style: z.enum(["hormozi", "karaoke", "subtle", "branded"]).optional().describe("Caption style"), - crop_strategy: z.enum(["center", "face", "speaker"]).optional().describe("Cropping strategy"), - logo_path: z.string().optional().describe("Path or registered asset name for PNG logo"), - outro_path: z.string().optional().describe("Path or registered asset name for outro video"), + caption_style: z + .enum(["hormozi", "karaoke", "subtle", "branded"]) + .optional() + .describe("Caption style"), + crop_strategy: z + .enum(["center", "face", "speaker"]) + .optional() + .describe("Cropping strategy"), + logo_path: z + .string() + .optional() + .describe("Path or registered asset name for PNG logo"), + outro_path: z + .string() + .optional() + .describe("Path or registered asset name for outro video"), }, async ({ caption_style, crop_strategy, logo_path, outro_path }) => { try { @@ -1266,7 +1673,14 @@ export function createServer(): McpServer { } if (Object.keys(settings).length === 0) { - return { content: [{ type: "text" as const, text: "No settings provided. Specify at least one of: caption_style, crop_strategy, logo_path, outro_path." }] }; + return { + content: [ + { + type: "text" as const, + text: "No settings provided. Specify at least one of: caption_style, crop_strategy, logo_path, outro_path.", + }, + ], + }; } await fetch("http://localhost:3847/api/ui-state", { @@ -1276,15 +1690,32 @@ export function createServer(): McpServer { }); const parts = Object.entries(settings).map(([k, v]) => `${k}=${v}`); - return { content: [{ type: "text" as const, text: `Settings updated: ${parts.join(", ")}` }] }; + return { + content: [ + { + type: "text" as const, + text: `Settings updated: ${parts.join(", ")}`, + }, + ], + }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) { - return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] }; + return { + content: [ + { + type: "text" as const, + text: "Web UI is not running. Start with: npm run ui", + }, + ], + }; } - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -1301,7 +1732,11 @@ export function createServer(): McpServer { const clips = (await res.json()) as OutputClip[]; if (!clips.length) { - return { content: [{ type: "text" as const, text: "No rendered clips found." }] }; + return { + content: [ + { type: "text" as const, text: "No rendered clips found." }, + ], + }; } const lines = clips.map((c) => { @@ -1310,16 +1745,31 @@ export function createServer(): McpServer { }); return { - content: [{ type: "text" as const, text: `${clips.length} rendered clip${clips.length === 1 ? "" : "s"}:\n${lines.join("\n")}` }], + content: [ + { + type: "text" as const, + text: `${clips.length} rendered clip${clips.length === 1 ? "" : "s"}:\n${lines.join("\n")}`, + }, + ], }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) { - return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] }; + return { + content: [ + { + type: "text" as const, + text: "Web UI is not running. Start with: npm run ui", + }, + ], + }; } - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -1329,19 +1779,37 @@ export function createServer(): McpServer { "manage_presets", "Save, load, list, or delete rendering presets. Presets store caption_style, crop_strategy, logo_path, and outro_path for quick reuse.", { - action: z.enum(["list", "save", "load", "delete"]).describe("Preset action"), - name: z.string().optional().describe("Preset name (required for save/load/delete)"), - config: z.object({ - caption_style: z.enum(["hormozi", "karaoke", "subtle", "branded"]).optional(), - crop_strategy: z.enum(["center", "face", "speaker"]).optional(), - logo_path: z.string().optional(), - outro_path: z.string().optional(), - }).optional().describe("Preset config (for save action)"), + action: z + .enum(["list", "save", "load", "delete"]) + .describe("Preset action"), + name: z + .string() + .optional() + .describe("Preset name (required for save/load/delete)"), + config: z + .object({ + caption_style: z + .enum(["hormozi", "karaoke", "subtle", "branded"]) + .optional(), + crop_strategy: z.enum(["center", "face", "speaker"]).optional(), + logo_path: z.string().optional(), + outro_path: z.string().optional(), + }) + .optional() + .describe("Preset config (for save action)"), }, async ({ action, name, config }) => { try { if (["save", "load", "delete"].includes(action) && !name) { - return { content: [{ type: "text" as const, text: `Error: 'name' is required for action '${action}'.` }], isError: true }; + return { + content: [ + { + type: "text" as const, + text: `Error: 'name' is required for action '${action}'.`, + }, + ], + isError: true, + }; } const res = await fetch("http://localhost:3847/api/presets", { @@ -1353,22 +1821,38 @@ export function createServer(): McpServer { const data = (await res.json()) as PresetResult; if (data.error) { - return { content: [{ type: "text" as const, text: `Error: ${data.error}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${data.error}` }], + isError: true, + }; } if (action === "list") { const presets = data.presets ?? []; - if (!presets.length) return { content: [{ type: "text" as const, text: "No presets saved." }] }; - return { content: [{ type: "text" as const, text: `Presets:\n${presets.map((p) => ` - ${typeof p === "string" ? p : p.name || JSON.stringify(p)}`).join("\n")}` }] }; + if (!presets.length) + return { + content: [{ type: "text" as const, text: "No presets saved." }], + }; + return { + content: [ + { + type: "text" as const, + text: `Presets:\n${presets.map((p) => ` - ${typeof p === "string" ? p : p.name || JSON.stringify(p)}`).join("\n")}`, + }, + ], + }; } if (action === "load" && data.config) { // Push loaded config to UI settings const settings: Record<string, string> = {}; - if (data.config.caption_style) settings.captionStyle = data.config.caption_style; - if (data.config.crop_strategy) settings.cropStrategy = data.config.crop_strategy; + if (data.config.caption_style) + settings.captionStyle = data.config.caption_style; + if (data.config.crop_strategy) + settings.cropStrategy = data.config.crop_strategy; if (data.config.logo_path) settings.logoPath = data.config.logo_path; - if (data.config.outro_path) settings.outroPath = data.config.outro_path; + if (data.config.outro_path) + settings.outroPath = data.config.outro_path; if (Object.keys(settings).length) { await fetch("http://localhost:3847/api/ui-state", { method: "POST", @@ -1376,26 +1860,53 @@ export function createServer(): McpServer { body: JSON.stringify({ settings }), }); } - return { content: [{ type: "text" as const, text: `Loaded preset "${name}" and applied to UI settings.` }] }; + return { + content: [ + { + type: "text" as const, + text: `Loaded preset "${name}" and applied to UI settings.`, + }, + ], + }; } if (action === "save") { - return { content: [{ type: "text" as const, text: `Saved preset "${name}".` }] }; + return { + content: [ + { type: "text" as const, text: `Saved preset "${name}".` }, + ], + }; } if (action === "delete") { - return { content: [{ type: "text" as const, text: `Deleted preset "${name}".` }] }; + return { + content: [ + { type: "text" as const, text: `Deleted preset "${name}".` }, + ], + }; } - return { content: [{ type: "text" as const, text: JSON.stringify(data) }] }; + return { + content: [{ type: "text" as const, text: JSON.stringify(data) }], + }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) { - return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] }; + return { + content: [ + { + type: "text" as const, + text: "Web UI is not running. Start with: npm run ui", + }, + ], + }; } - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -1405,11 +1916,21 @@ export function createServer(): McpServer { "analyze_energy", "Analyze audio energy levels for a video or specific segments. Useful for finding high-energy moments. Defaults to the current UI video and suggestions if not specified.", { - video_path: z.string().optional().describe("Path to video file (defaults to current UI video)"), - segments: z.array(z.object({ - start: z.number(), - end: z.number(), - })).optional().describe("Specific segments to analyze (defaults to current suggestions)"), + video_path: z + .string() + .optional() + .describe("Path to video file (defaults to current UI video)"), + segments: z + .array( + z.object({ + start: z.number(), + end: z.number(), + }), + ) + .optional() + .describe( + "Specific segments to analyze (defaults to current suggestions)", + ), }, async ({ video_path, segments }) => { try { @@ -1424,17 +1945,34 @@ export function createServer(): McpServer { if (!vPath) vPath = state.videoPath || state.filePath; if (!segs) { const suggestions = state.suggestions ?? []; - segs = suggestions.map((s) => ({ start: s.start_second, end: s.end_second })); + segs = suggestions.map((s) => ({ + start: s.start_second, + end: s.end_second, + })); } } } if (!vPath) { - return { content: [{ type: "text" as const, text: "No video path. Set a video first or provide video_path." }] }; + return { + content: [ + { + type: "text" as const, + text: "No video path. Set a video first or provide video_path.", + }, + ], + }; } if (!segs || segs.length === 0) { - return { content: [{ type: "text" as const, text: "No segments to analyze. Provide segments explicitly or suggest clips first." }] }; + return { + content: [ + { + type: "text" as const, + text: "No segments to analyze. Provide segments explicitly or suggest clips first.", + }, + ], + }; } const res = await fetch("http://localhost:3847/api/analyze-energy", { @@ -1446,18 +1984,35 @@ export function createServer(): McpServer { const data = (await res.json()) as ApiError & Record<string, unknown>; if (data.error) { - return { content: [{ type: "text" as const, text: `Error: ${data.error}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${data.error}` }], + isError: true, + }; } - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return { + content: [ + { type: "text" as const, text: JSON.stringify(data, null, 2) }, + ], + }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) { - return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] }; + return { + content: [ + { + type: "text" as const, + text: "Web UI is not running. Start with: npm run ui", + }, + ], + }; } - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -1466,7 +2021,7 @@ export function createServer(): McpServer { server.tool( "set_video", "Set the working video file without transcribing. Use this when you'll import a transcript separately. " + - "After this, either transcribe_podcast or import a transcript via import_transcript / parse_transcript.", + "After this, either transcribe_podcast or import a transcript via import_transcript / parse_transcript.", { file_path: z.string().describe("Absolute path to the video file"), }, @@ -1480,7 +2035,15 @@ export function createServer(): McpServer { }); if (!selectRes.ok) { const err = (await selectRes.json()) as ApiError; - return { content: [{ type: "text" as const, text: `Error: ${err.error || "File not found"}` }], isError: true }; + return { + content: [ + { + type: "text" as const, + text: `Error: ${err.error || "File not found"}`, + }, + ], + isError: true, + }; } const fileInfo = (await selectRes.json()) as FileInfo; @@ -1494,17 +2057,27 @@ export function createServer(): McpServer { const setText = withNextStep( `Video set: ${fileInfo.filename} (${fileInfo.size_mb} MB)`, `Now transcribe it: transcribe_podcast(file_path: "${file_path}")\n` + - " Or if the user pastes a transcript in the UI, read it with get_ui_state(include_transcript: true)" + " Or if the user pastes a transcript in the UI, read it with get_ui_state(include_transcript: true)", ); return { content: [{ type: "text" as const, text: setText }] }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) { - return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] }; + return { + content: [ + { + type: "text" as const, + text: "Web UI is not running. Start with: npm run ui", + }, + ], + }; } - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -1514,24 +2087,37 @@ export function createServer(): McpServer { "import_transcript", "Import an external transcript (e.g. from a transcription service) into the UI. Skips Whisper entirely. The transcript must include word-level timestamps.", { - file_path: z.string().describe("Path to the video file the transcript belongs to"), - transcript: z.object({ - words: z.array(z.object({ - word: z.string(), - start: z.number(), - end: z.number(), - speaker: z.string().optional(), - })).describe("Word-level timestamps"), - segments: z.array(z.object({ - text: z.string(), - start: z.number(), - end: z.number(), - speaker: z.string().optional(), - })).optional().describe("Segment-level transcript"), - duration: z.number().optional().describe("Total duration in seconds"), - language: z.string().optional().describe("ISO language code"), - text: z.string().optional().describe("Full transcript text"), - }).describe("Transcript data with word-level timestamps"), + file_path: z + .string() + .describe("Path to the video file the transcript belongs to"), + transcript: z + .object({ + words: z + .array( + z.object({ + word: z.string(), + start: z.number(), + end: z.number(), + speaker: z.string().optional(), + }), + ) + .describe("Word-level timestamps"), + segments: z + .array( + z.object({ + text: z.string(), + start: z.number(), + end: z.number(), + speaker: z.string().optional(), + }), + ) + .optional() + .describe("Segment-level transcript"), + duration: z.number().optional().describe("Total duration in seconds"), + language: z.string().optional().describe("ISO language code"), + text: z.string().optional().describe("Full transcript text"), + }) + .describe("Transcript data with word-level timestamps"), }, async ({ file_path, transcript }) => { try { @@ -1542,7 +2128,15 @@ export function createServer(): McpServer { }); if (!res.ok) { const err = (await res.json()) as ApiError; - return { content: [{ type: "text" as const, text: `Error: ${err.error || "Import failed"}` }], isError: true }; + return { + content: [ + { + type: "text" as const, + text: `Error: ${err.error || "Import failed"}`, + }, + ], + isError: true, + }; } const result = (await res.json()) as ImportTranscriptResult; @@ -1564,17 +2158,27 @@ export function createServer(): McpServer { const importText = withNextStep( `Transcript imported: ${wordCount} words, ${Math.round(duration)}s duration.`, "Now read the transcript with get_ui_state(include_transcript: true), " + - "analyze it for viral moments, then call suggest_clips with your picks." + "analyze it for viral moments, then call suggest_clips with your picks.", ); return { content: [{ type: "text" as const, text: importText }] }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) { - return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] }; + return { + content: [ + { + type: "text" as const, + text: "Web UI is not running. Start with: npm run ui", + }, + ], + }; } - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); // ============================================= @@ -1584,21 +2188,43 @@ export function createServer(): McpServer { "parse_transcript", "Parse a raw speaker-labeled plain text transcript into word-level timestamps. Input format: 'Speaker (MM:SS)\\ntext...\\n\\nSpeaker2 (MM:SS)\\ntext...'. Uses the Python backend to generate accurate word timings.", { - file_path: z.string().describe("Path to the video file the transcript belongs to"), + file_path: z + .string() + .describe("Path to the video file the transcript belongs to"), raw_text: z.string().describe("Raw speaker-labeled transcript text"), - total_duration: z.number().optional().describe("Total video duration in seconds (helps accuracy)"), - time_adjust: z.number().optional().default(0).describe("Offset in seconds to add to all timestamps"), + total_duration: z + .number() + .optional() + .describe("Total video duration in seconds (helps accuracy)"), + time_adjust: z + .number() + .optional() + .default(0) + .describe("Offset in seconds to add to all timestamps"), }, async ({ file_path, raw_text, total_duration, time_adjust }) => { try { const res = await fetch("http://localhost:3847/api/parse-transcript", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ file_path, raw_text, total_duration, time_adjust }), + body: JSON.stringify({ + file_path, + raw_text, + total_duration, + time_adjust, + }), }); if (!res.ok) { const err = (await res.json()) as ApiError; - return { content: [{ type: "text" as const, text: `Error: ${err.error || "Parse failed"}` }], isError: true }; + return { + content: [ + { + type: "text" as const, + text: `Error: ${err.error || "Parse failed"}`, + }, + ], + isError: true, + }; } const result = (await res.json()) as ImportTranscriptResult; @@ -1620,19 +2246,31 @@ export function createServer(): McpServer { const parseText = withNextStep( `Transcript parsed: ${wordCount} words, ${segCount} segments.`, "Now read the transcript with get_ui_state(include_transcript: true), " + - "analyze it for viral moments, then call suggest_clips with your picks." + "analyze it for viral moments, then call suggest_clips with your picks.", ); return { content: [{ type: "text" as const, text: parseText }] }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) { - return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] }; + return { + content: [ + { + type: "text" as const, + text: "Web UI is not running. Start with: npm run ui", + }, + ], + }; } - return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + return { + content: [{ type: "text" as const, text: `Error: ${msg}` }], + isError: true, + }; } - } + }, ); + registerIntegrationMcpTools(server); + // ============================================= // MCP Prompt: workflow guide // ============================================= @@ -1640,57 +2278,63 @@ export function createServer(): McpServer { "workflow", "Complete podcli workflow guide — from podcast file to finished clips", async () => ({ - messages: [{ - role: "user" as const, - content: { - type: "text" as const, - text: [ - "You are a podcast clip extraction assistant using podcli MCP tools.", - "Follow this workflow to create viral short-form clips from podcasts:", - "", - "## Step 1: Check current state", - "Call get_ui_state() to see what's already loaded (video, transcript, clips).", - "", - "## Step 2: Load the podcast", - "If no video is set, use transcribe_podcast(file_path: \"/path/to/file.mp4\") to transcribe.", - "This both sets the video AND generates a transcript with word-level timestamps.", - "If the user already pasted a transcript in the UI, you can skip to Step 3.", - "", - "## Step 3: Read the transcript", - "Call get_ui_state(include_transcript: true) to read the full transcript.", - "Also check if there's a knowledge base with podcast context (host names, show style, etc).", - "", - "## Step 4: Analyze and suggest clips", - "Read through the transcript carefully. Look for:", - "- Controversial or surprising statements", - "- Strong emotional moments (laughter, passion, anger)", - "- Clear actionable advice or insights", - "- Story hooks and cliffhangers", - "- Quotable one-liners", - "- Questions that hook the viewer", - "", - "For each moment, note the start/end timestamps and craft a catchy title.", - "Aim for 15-45 second clips (target 20-35s). Then call suggest_clips with your picks.", - "", - "## Step 5: Export", - "Call batch_create_clips(export_selected: true) to render all clips.", - "Or use create_clip(clip_number: N) for individual clips.", - "", - "## Available caption styles:", - "- branded: Professional look with dark highlight box, gradient, optional logo", - "- hormozi: Bold uppercase, yellow highlight, high energy pop-on reveal", - "- karaoke: Full sentence visible, words progressively highlight", - "- subtle: Clean minimal white text, no effects", - "", - "## Tips:", - "- Use modify_clip to adjust timing before export", - "- Use toggle_clip to select/deselect clips", - "- Use update_settings to change the default caption style or crop strategy", - "- The user can review and adjust clips in the Web UI at http://localhost:3847", - ].join("\n"), + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: [ + "You are a podcast clip extraction assistant using podcli MCP tools.", + "Follow this workflow to create viral short-form clips from podcasts:", + "", + "## Step 1: Check current state", + "Call get_ui_state() to see what's already loaded (video, transcript, clips).", + "", + "## Step 2: Load the podcast", + 'If no video is set, use transcribe_podcast(file_path: "/path/to/file.mp4") to transcribe.', + "This both sets the video AND generates a transcript with word-level timestamps.", + "If the user already pasted a transcript in the UI, you can skip to Step 3.", + "", + "## Step 3: Read the transcript", + "Call get_ui_state(include_transcript: true) to read the full transcript.", + "Also check if there's a knowledge base with podcast context (host names, show style, etc).", + "", + "## Step 4: Analyze and suggest clips", + "Read through the transcript carefully. Look for:", + "- Controversial or surprising statements", + "- Strong emotional moments (laughter, passion, anger)", + "- Clear actionable advice or insights", + "- Story hooks and cliffhangers", + "- Quotable one-liners", + "- Questions that hook the viewer", + "", + "For each moment, note the start/end timestamps and craft a catchy title.", + "Aim for 15-45 second clips (target 20-35s). Then call suggest_clips with your picks.", + "", + "## Step 5: Export", + "Call batch_create_clips(export_selected: true) to render all clips.", + "Or use create_clip(clip_number: N) for individual clips.", + "", + "## Optional: DaVinci Resolve", + "manage_integrations(action=enable, name=davinci_resolve) then export_to_davinci_resolve with source + caption overlay paths.", + "Use manage_config(action=migrate) once after upgrading if transcription cache seems empty.", + "", + "## Available caption styles:", + "- branded: Professional look with dark highlight box, gradient, optional logo", + "- hormozi: Bold uppercase, yellow highlight, high energy pop-on reveal", + "- karaoke: Full sentence visible, words progressively highlight", + "- subtle: Clean minimal white text, no effects", + "", + "## Tips:", + "- Use modify_clip to adjust timing before export", + "- Use toggle_clip to select/deselect clips", + "- Use update_settings to change the default caption style or crop strategy", + "- The user can review and adjust clips in the Web UI at http://localhost:3847", + ].join("\n"), + }, }, - }], - }) + ], + }), ); return server; diff --git a/src/services/python-executor.ts b/src/services/python-executor.ts index ad9d32b..c85c30d 100644 --- a/src/services/python-executor.ts +++ b/src/services/python-executor.ts @@ -36,6 +36,8 @@ export class PythonExecutor { env: { ...process.env, PYTHONUNBUFFERED: "1", + PODCLI_HOME: paths.home, + PODCLI_DATA: paths.dataDir, }, }); diff --git a/src/ui/public/config.html b/src/ui/public/config.html new file mode 100644 index 0000000..12dc336 --- /dev/null +++ b/src/ui/public/config.html @@ -0,0 +1,181 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Podcli — Config profiles + + + + + + + + + +
+ + +
+

Config profiles

+

Export or import your show settings (knowledge, presets, assets, corrections). Cache and rendered clips stay on this machine.

+
+ +
+
Loading…
+
+ +
+

Actions

+
+ + +
+
+ + + + +
+
+
+ + +
+ + + + diff --git a/src/ui/public/index.html b/src/ui/public/index.html index 1c37a24..dababbd 100644 --- a/src/ui/public/index.html +++ b/src/ui/public/index.html @@ -1149,6 +1149,8 @@ )} Knowledge + Config + Integrations MCP Setup diff --git a/src/ui/public/integration.html b/src/ui/public/integration.html index 8908798..c671ba5 100644 --- a/src/ui/public/integration.html +++ b/src/ui/public/integration.html @@ -51,6 +51,8 @@ diff --git a/src/ui/public/integrations.html b/src/ui/public/integrations.html new file mode 100644 index 0000000..5965519 --- /dev/null +++ b/src/ui/public/integrations.html @@ -0,0 +1,223 @@ + + + + + + Podcli — Integrations + + + + + + + + + +
+ + +
+

Output integrations

+

Enable the destinations you want podcli to push to. Disabled integrations error out with a hint when called from MCP.

+
+ +
+
Loading…
+
+ + +
+ + + + diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index e67090c..5b17daa 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -11,7 +11,13 @@ import express from "express"; import multer from "multer"; -import { createReadStream, existsSync, statSync, readFileSync, writeFileSync } from "fs"; +import { + createReadStream, + existsSync, + statSync, + readFileSync, + writeFileSync, +} from "fs"; import { mkdir, readdir, unlink } from "fs/promises"; import path from "path"; import { join, dirname, basename, extname, resolve } from "path"; @@ -26,6 +32,7 @@ import { AssetManager } from "../services/asset-manager.js"; import { ClipsHistory } from "../services/clips-history.js"; import { KnowledgeBase } from "../services/knowledge-base.js"; import { paths } from "../config/paths.js"; +import { registerConfigIntegrationRoutes } from "../handlers/integrations.routes.js"; import { childLogger } from "../utils/logger.js"; import type { BatchClipsResult, @@ -124,12 +131,16 @@ function loadPersistedState(): UIState { outroPath: saved.settings?.outroPath || "", }, // Never restore mid-export phases - phase: ["exporting", "parsing", "suggesting"].includes(saved.phase) ? "idle" : (saved.phase || "idle"), + phase: ["exporting", "parsing", "suggesting"].includes(saved.phase) + ? "idle" + : saved.phase || "idle", lastUpdated: saved.lastUpdated || 0, }; } } catch (err) { - log.warn("Failed to load persisted UI state; using defaults", { err: errMsg(err) }); + log.warn("Failed to load persisted UI state; using defaults", { + err: errMsg(err), + }); } return { videoPath: "", @@ -138,7 +149,12 @@ function loadPersistedState(): UIState { rawTranscriptText: "", suggestions: [], deselectedIndices: [], - settings: { captionStyle: "branded", cropStrategy: "speaker", logoPath: "", outroPath: "" }, + settings: { + captionStyle: "branded", + cropStrategy: "speaker", + logoPath: "", + outroPath: "", + }, phase: "idle", lastUpdated: 0, }; @@ -195,12 +211,28 @@ const upload = multer({ }), limits: { fileSize: 10 * 1024 * 1024 * 1024 }, // 10 GB fileFilter: (_req, file, cb) => { - const allowed = [".mp4", ".mov", ".mkv", ".webm", ".mp3", ".wav", ".m4a", ".png", ".jpg", ".jpeg", ".svg"]; + const allowed = [ + ".mp4", + ".mov", + ".mkv", + ".webm", + ".mp3", + ".wav", + ".m4a", + ".png", + ".jpg", + ".jpeg", + ".svg", + ]; const ext = extname(file.originalname).toLowerCase(); if (allowed.includes(ext)) { cb(null, true); } else { - cb(new Error(`Unsupported format: ${ext}. Use MP4, MOV, MKV, WebM, MP3, WAV, M4A.`)); + cb( + new Error( + `Unsupported format: ${ext}. Use MP4, MOV, MKV, WebM, MP3, WAV, M4A.`, + ), + ); } }, }); @@ -247,12 +279,15 @@ app.get("/api/browse-file", (_req, res) => { let filePath: string; if (process.platform === "darwin") { const script = `osascript -e 'POSIX path of (choose file of type {"mp4","mov","mkv","webm","mp3","wav","m4a"})'`; - filePath = execSync(script, { encoding: "utf-8", timeout: 120_000 }).trim(); + filePath = execSync(script, { + encoding: "utf-8", + timeout: 120_000, + }).trim(); } else { // Linux fallback filePath = execSync( `zenity --file-selection --file-filter="Media files|*.mp4 *.mov *.mkv *.webm *.mp3 *.wav *.m4a"`, - { encoding: "utf-8", timeout: 120_000 } + { encoding: "utf-8", timeout: 120_000 }, ).trim(); } @@ -290,19 +325,23 @@ app.post("/api/import-transcript", (req, res) => { } if (!transcript || !transcript.words || !Array.isArray(transcript.words)) { res.status(400).json({ - error: "transcript must include a 'words' array with { word, start, end } objects", + error: + "transcript must include a 'words' array with { word, start, end } objects", }); return; } // Build a full transcript result from the imported data const result: Record = { - transcript: transcript.text || transcript.words.map((w: any) => w.word).join(" "), + transcript: + transcript.text || transcript.words.map((w: any) => w.word).join(" "), words: transcript.words, segments: transcript.segments || [], - duration: transcript.duration || (transcript.words.length > 0 - ? transcript.words[transcript.words.length - 1].end - : 0), + duration: + transcript.duration || + (transcript.words.length > 0 + ? transcript.words[transcript.words.length - 1].end + : 0), language: transcript.language || "en", speakers: transcript.speakers || null, speaker_segments: transcript.speaker_segments || null, @@ -344,7 +383,10 @@ app.post("/api/parse-transcript", async (req, res) => { }); if (result.data) { - sessionTranscripts.set(file_path, result.data as unknown as ServerTranscript); + sessionTranscripts.set( + file_path, + result.data as unknown as ServerTranscript, + ); } res.json({ @@ -353,7 +395,9 @@ app.post("/api/parse-transcript", async (req, res) => { data: result.data, }); } catch (err: any) { - res.status(500).json({ error: err.message || "Failed to parse transcript" }); + res + .status(500) + .json({ error: err.message || "Failed to parse transcript" }); } }); @@ -361,7 +405,13 @@ app.post("/api/parse-transcript", async (req, res) => { * POST /api/transcribe — Start transcription job */ app.post("/api/transcribe", async (req, res) => { - const { file_path, model_size = "base", language, enable_diarization = false, num_speakers } = req.body; + const { + file_path, + model_size = "base", + language, + enable_diarization = false, + num_speakers, + } = req.body; if (!file_path || !existsSync(file_path)) { res.status(400).json({ error: "File not found" }); @@ -403,14 +453,17 @@ app.post("/api/transcribe", async (req, res) => { (event) => { job.progress = event.percent; job.message = event.message; - } + }, ) .then(async (result) => { job.status = "done"; job.progress = 100; job.message = "Transcription complete"; job.result = result.data; - sessionTranscripts.set(file_path, result.data as unknown as ServerTranscript); + sessionTranscripts.set( + file_path, + result.data as unknown as ServerTranscript, + ); // Populate uiState.transcript with the FULL result so downstream // batch_create_clips can resolve transcript_words for caption burn-in. uiState.transcript = result.data as unknown as typeof uiState.transcript; @@ -457,16 +510,22 @@ app.post("/api/create-clip", async (req, res) => { // Validate clip params before spawning Python if (typeof start_second !== "number" || typeof end_second !== "number") { - res.status(400).json({ error: "start_second and end_second must be numbers" }); + res + .status(400) + .json({ error: "start_second and end_second must be numbers" }); return; } if (end_second <= start_second) { - res.status(400).json({ error: "end_second must be greater than start_second" }); + res + .status(400) + .json({ error: "end_second must be greater than start_second" }); return; } const duration = end_second - start_second; if (duration > 180) { - res.status(400).json({ error: `Clip too long (${Math.round(duration)}s). Max 180 seconds.` }); + res.status(400).json({ + error: `Clip too long (${Math.round(duration)}s). Max 180 seconds.`, + }); return; } if (logo_path && !existsSync(logo_path)) { @@ -479,12 +538,16 @@ app.post("/api/create-clip", async (req, res) => { } const validStyles = ["hormozi", "karaoke", "subtle", "branded"]; if (!validStyles.includes(caption_style)) { - res.status(400).json({ error: `Invalid caption style. Use: ${validStyles.join(", ")}` }); + res + .status(400) + .json({ error: `Invalid caption style. Use: ${validStyles.join(", ")}` }); return; } const validCrops = ["center", "face", "speaker"]; if (!validCrops.includes(crop_strategy)) { - res.status(400).json({ error: `Invalid crop strategy. Use: ${validCrops.join(", ")}` }); + res + .status(400) + .json({ error: `Invalid crop strategy. Use: ${validCrops.join(", ")}` }); return; } @@ -523,7 +586,7 @@ app.post("/api/create-clip", async (req, res) => { (event) => { job.progress = event.percent; job.message = event.message; - } + }, ) .then(async (result) => { job.status = "done"; @@ -536,15 +599,21 @@ app.post("/api/create-clip", async (req, res) => { const d = result.data; await clipsHistory.record({ source_video: video_path, - start_second, end_second, - caption_style, crop_strategy, + start_second, + end_second, + caption_style, + crop_strategy, logo_path: logo_path || undefined, - title, output_path: d?.output_path || "", + title, + output_path: d?.output_path || "", file_size_mb: d?.file_size_mb || 0, duration: d?.duration || 0, }); } catch (err) { - log.warn("Failed to record clip to history", { title, err: errMsg(err) }); + log.warn("Failed to record clip to history", { + title, + err: errMsg(err), + }); } }) .catch((err) => { @@ -559,7 +628,15 @@ app.post("/api/create-clip", async (req, res) => { * POST /api/batch-clips — Create multiple clips */ app.post("/api/batch-clips", async (req, res) => { - const { video_path, clips, transcript_words = [], logo_path = null, outro_path = null, clean_fillers = false } = req.body; + const { + video_path, + clips, + transcript_words = [], + logo_path = null, + outro_path = null, + clean_fillers = false, + keep_caption_overlay = false, + } = req.body; if (!video_path || !existsSync(video_path)) { res.status(400).json({ error: "Video file not found" }); @@ -578,7 +655,9 @@ app.post("/api/batch-clips", async (req, res) => { return; } if (dur > 180) { - res.status(400).json({ error: `Clip ${i + 1}: too long (${Math.round(dur)}s). Max 180s.` }); + res.status(400).json({ + error: `Clip ${i + 1}: too long (${Math.round(dur)}s). Max 180s.`, + }); return; } } @@ -614,12 +693,26 @@ app.post("/api/batch-clips", async (req, res) => { executor .execute( "batch_clips", - { video_path, clips, transcript_words, output_dir: paths.output, logo_path, outro_path, clean_fillers, face_map: uiState.transcript?.face_map }, + { + video_path, + clips, + transcript_words, + output_dir: paths.output, + logo_path, + outro_path, + clean_fillers, + keep_caption_overlay: keep_caption_overlay === true, + face_map: uiState.transcript?.face_map, + }, (event) => { job.progress = event.percent; job.message = event.message; - broadcastSSE("job-update", { jobId, progress: event.percent, message: event.message }); - } + broadcastSSE("job-update", { + jobId, + progress: event.percent, + message: event.message, + }); + }, ) .then(async (result) => { job.status = "done"; @@ -648,7 +741,9 @@ app.post("/api/batch-clips", async (req, res) => { } } } catch (err) { - log.warn("Failed to record batch clips to history", { err: errMsg(err) }); + log.warn("Failed to record batch clips to history", { + err: errMsg(err), + }); } }) .catch((err) => { @@ -703,7 +798,7 @@ app.get("/api/job/:id/stream", (req, res) => { message: current.message, result: current.result, error: current.error, - })}\n\n` + })}\n\n`, ); if (current.status === "done" || current.status === "error") { @@ -734,7 +829,9 @@ app.get("/api/outputs", async (_req, res) => { created: stat.mtime.toISOString(), }; }) - .sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); + .sort( + (a, b) => new Date(b.created).getTime() - new Date(a.created).getTime(), + ); res.json(clips); } catch { res.json([]); @@ -810,10 +907,15 @@ app.get("/api/stream-source", (req, res) => { } // Validate the path is the current session video or within the uploads directory const resolvedPath = path.resolve(filePath); - const isSessionVideo = uiState.videoPath && resolvedPath === path.resolve(uiState.videoPath); - const isUploadedFile = resolvedPath.startsWith(path.resolve(join(paths.working, "uploads"))); + const isSessionVideo = + uiState.videoPath && resolvedPath === path.resolve(uiState.videoPath); + const isUploadedFile = resolvedPath.startsWith( + path.resolve(join(paths.working, "uploads")), + ); if (!isSessionVideo && !isUploadedFile) { - res.status(403).json({ error: "Access denied: path not in allowed directories" }); + res + .status(403) + .json({ error: "Access denied: path not in allowed directories" }); return; } @@ -853,19 +955,7 @@ app.get("/api/stream-source", (req, res) => { } }); -// --- Integration info --- -app.get("/api/integration-info", (_req, res) => { - const projectRoot = join(__dirname, "..", ".."); - const distPath = join(projectRoot, "dist", "index.js"); - const serverOk = existsSync(distPath); - - res.json({ - dist_path: distPath, - project_root: projectRoot, - server_ok: serverOk, - tools_count: 4, - }); -}); +registerConfigIntegrationRoutes(app, { executor, uploadDir }); // --- Transcript export (SRT/VTT) --- app.get("/api/export-transcript", (_req, res) => { @@ -910,7 +1000,10 @@ app.get("/api/export-transcript", (_req, res) => { res.setHeader("Content-Disposition", "attachment; filename=transcript.vtt"); res.send(vtt); } else if (format === "json") { - res.setHeader("Content-Disposition", "attachment; filename=transcript.json"); + res.setHeader( + "Content-Disposition", + "attachment; filename=transcript.json", + ); res.json(transcript); } else { // SRT @@ -927,9 +1020,13 @@ app.get("/api/export-transcript", (_req, res) => { // --- Analyze audio energy --- app.post("/api/analyze-energy", async (req, res) => { const { video_path, segments } = req.body; - if (!video_path) return res.status(400).json({ error: "video_path required" }); + if (!video_path) + return res.status(400).json({ error: "video_path required" }); try { - const result = await executor.execute("analyze_energy", { video_path, segments: segments || [] }); + const result = await executor.execute("analyze_energy", { + video_path, + segments: segments || [], + }); res.json(result.data || {}); } catch (err: any) { res.status(500).json({ error: err.message }); @@ -1027,7 +1124,13 @@ app.get("/api/history", async (req, res) => { app.get("/api/history/check", async (req, res) => { try { - const { source, start, end, style = "hormozi", crop = "speaker" } = req.query; + const { + source, + start, + end, + style = "hormozi", + crop = "speaker", + } = req.query; if (!source || !start || !end) { res.json({ duplicate: null }); return; @@ -1037,7 +1140,7 @@ app.get("/api/history/check", async (req, res) => { parseFloat(start as string), parseFloat(end as string), style as string, - crop as string + crop as string, ); res.json({ duplicate: dup }); } catch (err: any) { @@ -1047,7 +1150,7 @@ app.get("/api/history/check", async (req, res) => { // --- Transcript Corrections --- -const correctionsPath = join(process.cwd(), ".podcli", "corrections.json"); +const correctionsPath = paths.corrections; app.get("/api/corrections", (_req, res) => { try { @@ -1128,7 +1231,10 @@ const knowledgeUpload = multer({ filename: (_req, file, cb) => cb(null, file.originalname), }), fileFilter: (_req, file, cb) => { - if (file.originalname.endsWith(".md") || file.originalname.endsWith(".txt")) { + if ( + file.originalname.endsWith(".md") || + file.originalname.endsWith(".txt") + ) { cb(null, true); } else { cb(new Error("Only .md and .txt files are allowed")); @@ -1136,14 +1242,18 @@ const knowledgeUpload = multer({ }, }); -app.post("/api/knowledge/upload", knowledgeUpload.array("files", 50), (req, res) => { - const files = req.files as Express.Multer.File[]; - if (!files || files.length === 0) { - res.status(400).json({ error: "No files uploaded" }); - return; - } - res.json({ uploaded: files.map((f) => f.originalname) }); -}); +app.post( + "/api/knowledge/upload", + knowledgeUpload.array("files", 50), + (req, res) => { + const files = req.files as Express.Multer.File[]; + if (!files || files.length === 0) { + res.status(400).json({ error: "No files uploaded" }); + return; + } + res.json({ uploaded: files.map((f) => f.originalname) }); + }, +); app.get("/api/knowledge/:filename", async (req, res) => { if (!safePath(paths.knowledge, req.params.filename)) { @@ -1191,11 +1301,21 @@ function getStateContext() { const hasTranscript = (uiState.transcript?.words?.length ?? 0) > 0; const hasRawTranscript = !!uiState.rawTranscriptText?.trim(); const hasSuggestions = uiState.suggestions.length > 0; - const selectedCount = uiState.suggestions.length - uiState.deselectedIndices.length; - return { hasVideo, hasTranscript, hasRawTranscript, hasSuggestions, selectedCount, phase: uiState.phase }; + const selectedCount = + uiState.suggestions.length - uiState.deselectedIndices.length; + return { + hasVideo, + hasTranscript, + hasRawTranscript, + hasSuggestions, + selectedCount, + phase: uiState.phase, + }; } -function buildPromptForAction(action: "suggest" | "export" | "restyle"): string { +function buildPromptForAction( + action: "suggest" | "export" | "restyle", +): string { const ctx = getStateContext(); const parts: string[] = []; @@ -1205,26 +1325,40 @@ function buildPromptForAction(action: "suggest" | "export" | "restyle"): string `Crop: ${uiState.settings.cropStrategy}`, uiState.settings.logoPath ? `Logo: set` : null, uiState.settings.outroPath ? `Outro: set` : null, - ].filter(Boolean).join(", "); + ] + .filter(Boolean) + .join(", "); if (action === "suggest") { if (!ctx.hasTranscript && !ctx.hasRawTranscript) { // No transcript yet — prompt includes transcribe step if (uiState.videoPath) { - parts.push(`Transcribe the podcast at ${uiState.videoPath} using transcribe_podcast.`); + parts.push( + `Transcribe the podcast at ${uiState.videoPath} using transcribe_podcast.`, + ); } else { - parts.push("Set a video path first, then transcribe it using transcribe_podcast."); + parts.push( + "Set a video path first, then transcribe it using transcribe_podcast.", + ); } - parts.push("Then find the 5-8 best viral-worthy moments and call suggest_clips."); + parts.push( + "Then find the 5-8 best viral-worthy moments and call suggest_clips.", + ); } else { - parts.push("Use get_ui_state with include_transcript=true to read the full transcript."); - parts.push("Find the 5-8 best viral-worthy moments — hot takes, strong opinions, funny moments, actionable advice, and emotional stories."); + parts.push( + "Use get_ui_state with include_transcript=true to read the full transcript.", + ); + parts.push( + "Find the 5-8 best viral-worthy moments — hot takes, strong opinions, funny moments, actionable advice, and emotional stories.", + ); parts.push("Then call suggest_clips with your suggestions."); } parts.push(`Current settings: ${settingsSummary}`); } else if (action === "export") { parts.push("Use get_ui_state to read the selected clips."); - parts.push(`Export all ${ctx.selectedCount} selected clip${ctx.selectedCount !== 1 ? "s" : ""} using batch_create_clips.`); + parts.push( + `Export all ${ctx.selectedCount} selected clip${ctx.selectedCount !== 1 ? "s" : ""} using batch_create_clips.`, + ); parts.push(`Current settings: ${settingsSummary}`); } else if (action === "restyle") { parts.push("Use get_ui_state to read the current clips."); @@ -1259,29 +1393,88 @@ app.get("/api/mcp-hints", (_req, res) => { if ((ctx.hasRawTranscript || ctx.hasTranscript) && !ctx.hasSuggestions) { // Transcript is ready — suggest clip-finding prompts hints.push( - { prompt: "Find the 5 best viral moments from this podcast", description: "Clips optimized for TikTok/Shorts", category: "analyze" }, - { prompt: "Find moments with hot takes and strong opinions", description: "Controversial, high-engagement clips", category: "analyze" }, - { prompt: "Find funny moments and quotable one-liners", description: "Entertainment-focused clips", category: "analyze" }, - { prompt: "Find actionable advice and key insights", description: "Value-driven educational clips", category: "analyze" }, + { + prompt: "Find the 5 best viral moments from this podcast", + description: "Clips optimized for TikTok/Shorts", + category: "analyze", + }, + { + prompt: "Find moments with hot takes and strong opinions", + description: "Controversial, high-engagement clips", + category: "analyze", + }, + { + prompt: "Find funny moments and quotable one-liners", + description: "Entertainment-focused clips", + category: "analyze", + }, + { + prompt: "Find actionable advice and key insights", + description: "Value-driven educational clips", + category: "analyze", + }, ); - } else if (ctx.phase === "review" && ctx.hasSuggestions && ctx.selectedCount > 0) { + } else if ( + ctx.phase === "review" && + ctx.hasSuggestions && + ctx.selectedCount > 0 + ) { // Clips suggested, ready for action hints.push( - { prompt: "Export all selected clips", description: `Render ${ctx.selectedCount} clip${ctx.selectedCount !== 1 ? "s" : ""} as vertical shorts`, category: "export" }, - { prompt: "Export clip #1", description: "Render just the first clip", category: "export" }, - { prompt: "Change all clips to hormozi style", description: "Bold uppercase, yellow highlight", category: "refine" }, - { prompt: "Extend clip #1 by 10 seconds", description: "Adjust timing before export", category: "refine" }, - { prompt: "Find 5 more moments", description: "Additional clips from the transcript", category: "analyze" }, + { + prompt: "Export all selected clips", + description: `Render ${ctx.selectedCount} clip${ctx.selectedCount !== 1 ? "s" : ""} as vertical shorts`, + category: "export", + }, + { + prompt: "Export clip #1", + description: "Render just the first clip", + category: "export", + }, + { + prompt: "Change all clips to hormozi style", + description: "Bold uppercase, yellow highlight", + category: "refine", + }, + { + prompt: "Extend clip #1 by 10 seconds", + description: "Adjust timing before export", + category: "refine", + }, + { + prompt: "Find 5 more moments", + description: "Additional clips from the transcript", + category: "analyze", + }, ); } else if (ctx.phase === "done") { hints.push( - { prompt: "Find more viral moments from the transcript", description: "Get another batch of clips", category: "analyze" }, - { prompt: "Re-export all clips with karaoke style", description: "Try a different caption look", category: "refine" }, - { prompt: "Save these settings as a preset called 'myshow'", description: "Reuse this config next time", category: "refine" }, + { + prompt: "Find more viral moments from the transcript", + description: "Get another batch of clips", + category: "analyze", + }, + { + prompt: "Re-export all clips with karaoke style", + description: "Try a different caption look", + category: "refine", + }, + { + prompt: "Save these settings as a preset called 'myshow'", + description: "Reuse this config next time", + category: "refine", + }, ); } - res.json({ hints, phase: ctx.phase, hasVideo: ctx.hasVideo, hasTranscript: ctx.hasTranscript, hasSuggestions: ctx.hasSuggestions, selectedCount: ctx.selectedCount }); + res.json({ + hints, + phase: ctx.phase, + hasVideo: ctx.hasVideo, + hasTranscript: ctx.hasTranscript, + hasSuggestions: ctx.hasSuggestions, + selectedCount: ctx.selectedCount, + }); }); /** @@ -1292,7 +1485,9 @@ app.get("/api/mcp-hints", (_req, res) => { app.post("/api/generate-prompt", (req, res) => { const action = req.body.action || "suggest"; if (!["suggest", "export", "restyle"].includes(action)) { - res.status(400).json({ error: "action must be 'suggest', 'export', or 'restyle'" }); + res + .status(400) + .json({ error: "action must be 'suggest', 'export', or 'restyle'" }); return; } const prompt = buildPromptForAction(action); @@ -1307,7 +1502,9 @@ app.post("/api/claude-suggest", async (req, res) => { // Need transcript in state if (!uiState.transcript && !uiState.rawTranscriptText) { - res.status(400).json({ error: "No transcript loaded. Transcribe or import one first." }); + res + .status(400) + .json({ error: "No transcript loaded. Transcribe or import one first." }); return; } @@ -1324,7 +1521,11 @@ app.post("/api/claude-suggest", async (req, res) => { const result = await executor.execute<{ clips?: SuggestedClip[] }>( "suggest_clips", params, - (event) => broadcastSSE("job-update", { progress: event.percent, message: event.message }), + (event) => + broadcastSSE("job-update", { + progress: event.percent, + message: event.message, + }), ); const clips = result.data?.clips ?? []; @@ -1351,7 +1552,9 @@ app.post("/api/claude-suggest", async (req, res) => { res.json({ clips, source: "python" }); } catch (err: unknown) { const msg = errMsg(err); - res.status(500).json({ error: `Suggestion failed: ${msg.substring(0, 200)}` }); + res + .status(500) + .json({ error: `Suggestion failed: ${msg.substring(0, 200)}` }); } }); @@ -1372,12 +1575,18 @@ app.post("/api/generate-content", async (req, res) => { const result = await executor.execute( "generate_content", { clip, transcript_segments: segs }, - (event) => broadcastSSE("job-update", { progress: event.percent, message: event.message }), + (event) => + broadcastSSE("job-update", { + progress: event.percent, + message: event.message, + }), ); res.json(result.data); } catch (err: any) { - res.status(500).json({ error: `Content generation failed: ${err.message?.substring(0, 200)}` }); + res.status(500).json({ + error: `Content generation failed: ${err.message?.substring(0, 200)}`, + }); } }); @@ -1419,7 +1628,7 @@ app.get("/api/events", (_req, res) => { */ app.get("/api/ui-state", (_req, res) => { const selected = uiState.suggestions.filter( - (_: unknown, i: number) => !uiState.deselectedIndices.includes(i) + (_: unknown, i: number) => !uiState.deselectedIndices.includes(i), ); res.json({ videoPath: uiState.videoPath, @@ -1432,7 +1641,7 @@ app.get("/api/ui-state", (_req, res) => { totalSuggestions: uiState.suggestions.length, deselectedCount: uiState.deselectedIndices.length, transcriptWordCount: Array.isArray(uiState.transcript?.words) - ? uiState.transcript?.words ?? [].length + ? (uiState.transcript?.words ?? [].length) : 0, transcript: uiState.transcript, rawTranscriptText: uiState.rawTranscriptText, @@ -1451,15 +1660,21 @@ app.post("/api/ui-state", (req, res) => { if (body.videoPath !== undefined) uiState.videoPath = body.videoPath; if (body.filePath !== undefined) uiState.filePath = body.filePath; if (body.transcript !== undefined) uiState.transcript = body.transcript; - if (body.rawTranscriptText !== undefined) uiState.rawTranscriptText = body.rawTranscriptText; + if (body.rawTranscriptText !== undefined) + uiState.rawTranscriptText = body.rawTranscriptText; if (body.suggestions !== undefined) uiState.suggestions = body.suggestions; - if (body.deselectedIndices !== undefined) uiState.deselectedIndices = body.deselectedIndices; + if (body.deselectedIndices !== undefined) + uiState.deselectedIndices = body.deselectedIndices; if (body.phase !== undefined) uiState.phase = body.phase; if (body.settings) { - if (body.settings.captionStyle !== undefined) uiState.settings.captionStyle = body.settings.captionStyle; - if (body.settings.cropStrategy !== undefined) uiState.settings.cropStrategy = body.settings.cropStrategy; - if (body.settings.logoPath !== undefined) uiState.settings.logoPath = body.settings.logoPath; - if (body.settings.outroPath !== undefined) uiState.settings.outroPath = body.settings.outroPath; + if (body.settings.captionStyle !== undefined) + uiState.settings.captionStyle = body.settings.captionStyle; + if (body.settings.cropStrategy !== undefined) + uiState.settings.cropStrategy = body.settings.cropStrategy; + if (body.settings.logoPath !== undefined) + uiState.settings.logoPath = body.settings.logoPath; + if (body.settings.outroPath !== undefined) + uiState.settings.outroPath = body.settings.outroPath; } uiState.lastUpdated = Date.now(); persistState(); @@ -1470,7 +1685,9 @@ app.post("/api/ui-state", (req, res) => { ...(body.videoPath !== undefined && { videoPath: body.videoPath }), ...(body.filePath !== undefined && { filePath: body.filePath }), ...(body.suggestions !== undefined && { suggestions: body.suggestions }), - ...(body.deselectedIndices !== undefined && { deselectedIndices: body.deselectedIndices }), + ...(body.deselectedIndices !== undefined && { + deselectedIndices: body.deselectedIndices, + }), ...(body.phase !== undefined && { phase: body.phase }), ...(body.transcript !== undefined && { transcript: body.transcript }), ...(body.settings && { settings: body.settings }), @@ -1485,16 +1702,24 @@ app.post("/api/ui-state", (req, res) => { */ app.post("/api/mcp/export", async (req, res) => { // Use UI state if no explicit params provided - const videoPath = req.body.video_path || uiState.filePath || uiState.videoPath; - const clips = req.body.clips || uiState.suggestions.filter( - (_: unknown, i: number) => !uiState.deselectedIndices.includes(i) - ); - const transcriptWords = req.body.transcript_words || - (Array.isArray(uiState.transcript?.words) ? uiState.transcript?.words ?? [] : []); + const videoPath = + req.body.video_path || uiState.filePath || uiState.videoPath; + const clips = + req.body.clips || + uiState.suggestions.filter( + (_: unknown, i: number) => !uiState.deselectedIndices.includes(i), + ); + const transcriptWords = + req.body.transcript_words || + (Array.isArray(uiState.transcript?.words) + ? (uiState.transcript?.words ?? []) + : []); const logoPath = req.body.logo_path || uiState.settings.logoPath || null; const outroPath = req.body.outro_path || uiState.settings.outroPath || null; - const captionStyle = req.body.caption_style || uiState.settings.captionStyle || "branded"; - const cropStrategy = req.body.crop_strategy || uiState.settings.cropStrategy || "speaker"; + const captionStyle = + req.body.caption_style || uiState.settings.captionStyle || "branded"; + const cropStrategy = + req.body.crop_strategy || uiState.settings.cropStrategy || "speaker"; const allowAssFallback = req.body.allow_ass_fallback === true; if (!videoPath || !existsSync(videoPath)) { @@ -1517,7 +1742,8 @@ app.post("/api/mcp/export", async (req, res) => { crop_strategy: c.crop_strategy || cropStrategy, allow_ass_fallback: c.allow_ass_fallback === true || allowAssFallback, // Preserve multi-cut segments from suggestions - ...(Array.isArray(c.segments) && c.segments.length > 0 && { keep_segments: c.segments }), + ...(Array.isArray(c.segments) && + c.segments.length > 0 && { keep_segments: c.segments }), })); const jobId = uuidv4(); @@ -1542,12 +1768,24 @@ app.post("/api/mcp/export", async (req, res) => { executor .execute( "batch_clips", - { video_path: videoPath, clips: styledClips, transcript_words: transcriptWords, output_dir: paths.output, logo_path: logoPath, outro_path: outroPath, face_map: uiState.transcript?.face_map }, + { + video_path: videoPath, + clips: styledClips, + transcript_words: transcriptWords, + output_dir: paths.output, + logo_path: logoPath, + outro_path: outroPath, + face_map: uiState.transcript?.face_map, + }, (event) => { job.progress = event.percent; job.message = event.message; - broadcastSSE("job-update", { jobId, progress: event.percent, message: event.message }); - } + broadcastSSE("job-update", { + jobId, + progress: event.percent, + message: event.message, + }); + }, ) .then(async (result) => { job.status = "done"; @@ -1575,7 +1813,9 @@ app.post("/api/mcp/export", async (req, res) => { } } } catch (err) { - log.warn("Failed to record batch export clips to history", { err: errMsg(err) }); + log.warn("Failed to record batch export clips to history", { + err: errMsg(err), + }); } broadcastSSE("job-complete", { jobId, result: result.data }); }) @@ -1601,12 +1841,29 @@ async function main() { log.warn("Startup temp-file cleanup failed", { err: errMsg(err) }); } + try { + const status = await executor.execute<{ + legacy_cache_pending?: boolean; + legacy_presets_pending?: boolean; + }>("manage_config", { + action: "status", + }); + if (status.data?.legacy_cache_pending || status.data?.legacy_presets_pending) { + await executor.execute("manage_config", { action: "migrate" }); + log.info("Migrated legacy transcription cache to data/cache"); + } + } catch (err) { + log.warn("Legacy cache migration skipped", { err: errMsg(err) }); + } + app.listen(PORT, () => { log.info(`podcli running at http://localhost:${PORT}`); }); } main().catch((err) => { - log.error("Fatal error during startup", { err: err instanceof Error ? err.stack : String(err) }); + log.error("Fatal error during startup", { + err: err instanceof Error ? err.stack : String(err), + }); process.exit(1); }); diff --git a/src/utils/logger.test.ts b/src/utils/logger.test.ts index 36d1321..ed08f22 100644 --- a/src/utils/logger.test.ts +++ b/src/utils/logger.test.ts @@ -17,6 +17,14 @@ describe("logger", () => { it("respects PODCLI_LOG_LEVEL default (debug in dev)", () => { // level is set at import time; just verify it's a known winston level - expect(["silly", "debug", "verbose", "http", "info", "warn", "error"]).toContain(logger.level); + expect([ + "silly", + "debug", + "verbose", + "http", + "info", + "warn", + "error", + ]).toContain(logger.level); }); }); diff --git a/tests/test_clip_generator.py b/tests/test_clip_generator.py index 4ede01f..814d414 100644 --- a/tests/test_clip_generator.py +++ b/tests/test_clip_generator.py @@ -31,6 +31,13 @@ def _exists(path): return real_exists(path) return _exists + def test_kept_caption_overlay_path_matches_remotion_contract(self): + with tempfile.TemporaryDirectory() as td: + output_path = os.path.join(td, "captioned.mp4") + expected = cg._kept_caption_overlay_path(output_path) + self.assertTrue(expected.endswith("_captions.mov")) + self.assertIn("captioned", expected) + def test_remotion_runtime_failure_does_not_disable_future_clips(self): real_exists = os.path.exists fail_result = subprocess.CompletedProcess( @@ -62,8 +69,8 @@ def test_remotion_runtime_failure_does_not_disable_future_clips(self): output_path=output_path, ) - self.assertFalse(first) - self.assertFalse(second) + self.assertEqual(first, (False, None)) + self.assertEqual(second, (False, None)) self.assertIsNone(cg._remotion_available) self.assertGreaterEqual(mock_run.call_count, 4) @@ -93,8 +100,8 @@ def test_remotion_timeout_does_not_disable_future_clips(self): output_path=output_path, ) - self.assertFalse(first) - self.assertFalse(second) + self.assertEqual(first, (False, None)) + self.assertEqual(second, (False, None)) self.assertIsNone(cg._remotion_available) self.assertGreaterEqual(mock_run.call_count, 4) diff --git a/tests/test_config_bundle.py b/tests/test_config_bundle.py new file mode 100644 index 0000000..791b9e3 --- /dev/null +++ b/tests/test_config_bundle.py @@ -0,0 +1,246 @@ +"""Tests for portable config bundle export/import.""" + +import json +import os +import sys +import tempfile +import unittest +import zipfile + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +BACKEND_ROOT = os.path.join(ROOT, "backend") +if BACKEND_ROOT not in sys.path: + sys.path.insert(0, BACKEND_ROOT) + +from pathlib import Path + +from config_bundle import ( + auto_migrate_legacy_if_pending, + export_config, + import_config, + migrate_legacy_cache, + run_config_action, +) + + +class ConfigBundleTests(unittest.TestCase): + def setUp(self): + self.src_home = tempfile.mkdtemp(prefix="podcli-src-home-") + self.dst_home = tempfile.mkdtemp(prefix="podcli-dst-home-") + self.bundle = os.path.join(tempfile.mkdtemp(prefix="podcli-bundle-"), "profile.zip") + + os.makedirs(os.path.join(self.src_home, "knowledge"), exist_ok=True) + os.makedirs(os.path.join(self.src_home, "presets"), exist_ok=True) + os.makedirs(os.path.join(self.src_home, "history"), exist_ok=True) + os.makedirs(os.path.join(self.src_home, "assets"), exist_ok=True) + + with open(os.path.join(self.src_home, "thumbnail-config.json"), "w", encoding="utf-8") as f: + json.dump({"line1_font_size": "64px"}, f) + with open(os.path.join(self.src_home, "corrections.json"), "w", encoding="utf-8") as f: + json.dump({"Boxel": "Voxel"}, f) + with open(os.path.join(self.src_home, "ui-state.json"), "w", encoding="utf-8") as f: + json.dump({"settings": {"captionStyle": "karaoke"}}, f) + with open(os.path.join(self.src_home, "integrations.json"), "w", encoding="utf-8") as f: + json.dump({"resolve": {"enabled": True}}, f) + with open(os.path.join(self.src_home, "knowledge", "style.md"), "w", encoding="utf-8") as f: + f.write("# Style\ncustom voice\n") + with open(os.path.join(self.src_home, "presets", "myshow.json"), "w", encoding="utf-8") as f: + json.dump({"caption_style": "branded"}, f) + with open(os.path.join(self.src_home, "history", "clips.json"), "w", encoding="utf-8") as f: + json.dump([{"id": "clip-1"}], f) + + self.asset_file = os.path.join(self.src_home, "assets", "logo.png") + with open(self.asset_file, "wb") as f: + f.write(b"logo") + with open(os.path.join(self.src_home, "assets", "registry.json"), "w", encoding="utf-8") as f: + json.dump({"assets": [{"name": "main-logo", "type": "logo", "path": self.asset_file}]}, f) + + def tearDown(self): + import shutil + + shutil.rmtree(self.src_home, ignore_errors=True) + shutil.rmtree(self.dst_home, ignore_errors=True) + shutil.rmtree(os.path.dirname(self.bundle), ignore_errors=True) + + def test_export_and_import_round_trip(self): + result = export_config(self.bundle, source_home=self.src_home) + self.assertTrue(os.path.exists(result["bundle"])) + + with zipfile.ZipFile(self.bundle, "r") as zf: + self.assertIn("manifest.json", zf.namelist()) + self.assertIn("assets/registry.json", zf.namelist()) + + imported = import_config(self.bundle, target_home=self.dst_home) + self.assertEqual(imported["home"], os.path.realpath(self.dst_home)) + + with open(os.path.join(self.dst_home, "thumbnail-config.json"), encoding="utf-8") as f: + self.assertEqual(json.load(f)["line1_font_size"], "64px") + with open(os.path.join(self.dst_home, "knowledge", "style.md"), encoding="utf-8") as f: + self.assertIn("custom voice", f.read()) + with open(os.path.join(self.dst_home, "assets", "registry.json"), encoding="utf-8") as f: + registry = json.load(f) + self.assertEqual(len(registry["assets"]), 1) + self.assertTrue(registry["assets"][0]["path"].startswith(os.path.realpath(self.dst_home))) + self.assertTrue(os.path.exists(registry["assets"][0]["path"])) + + + def test_import_restores_backup_on_failure(self): + export_config(self.bundle, source_home=self.src_home) + keep_dir = os.path.join(self.dst_home, "knowledge") + os.makedirs(keep_dir, exist_ok=True) + keep_file = os.path.join(keep_dir, "keep.md") + with open(keep_file, "w", encoding="utf-8") as f: + f.write("must survive") + + real_extract = zipfile.ZipFile.extractall + + def boom(self, path): + raise OSError("simulated extract failure") + + zipfile.ZipFile.extractall = boom + try: + with self.assertRaises(OSError): + import_config(self.bundle, target_home=self.dst_home) + finally: + zipfile.ZipFile.extractall = real_extract + + self.assertTrue(os.path.exists(keep_file)) + + def test_auto_migrate_skips_when_no_legacy_cache(self): + self.assertIsNone(auto_migrate_legacy_if_pending(quiet=True)) + + def test_migrate_legacy_presets(self): + import shutil + + import config.paths as paths_mod + from config_bundle import migrate_legacy_presets + + legacy_root = os.path.join(os.path.dirname(self.bundle), "legacy-presets-root") + legacy_presets = os.path.join(legacy_root, "presets") + os.makedirs(legacy_presets, exist_ok=True) + with open(os.path.join(legacy_presets, "myshow.json"), "w", encoding="utf-8") as f: + json.dump({"caption_style": "branded"}, f) + + old_root = paths_mod.paths["project_root"] + old_home = paths_mod.paths["home"] + try: + paths_mod.paths["project_root"] = legacy_root + paths_mod.paths["home"] = self.dst_home + target = os.path.join(self.dst_home, "presets") + os.makedirs(target, exist_ok=True) + + summary = migrate_legacy_presets(dry_run=False) + self.assertEqual(summary["moved"], 1) + self.assertTrue(os.path.exists(os.path.join(target, "myshow.json"))) + self.assertFalse(os.path.exists(os.path.join(legacy_presets, "myshow.json"))) + finally: + paths_mod.paths["project_root"] = old_root + paths_mod.paths["home"] = old_home + shutil.rmtree(legacy_root, ignore_errors=True) + + def test_status_does_not_migrate_legacy_cache(self): + import importlib + import config.paths as paths_mod + import config_bundle + + legacy_root = os.path.join(os.path.dirname(self.bundle), "legacy-status") + legacy = os.path.join(legacy_root, ".podcli", "cache") + os.makedirs(legacy, exist_ok=True) + legacy_file = os.path.join(legacy, "stay.json") + with open(legacy_file, "w", encoding="utf-8") as f: + json.dump({"words": []}, f) + + old_root = paths_mod.paths["project_root"] + try: + paths_mod.paths["project_root"] = legacy_root + config_bundle.paths["project_root"] = legacy_root + status = run_config_action("status") + self.assertTrue(status.get("legacy_cache_pending")) + self.assertTrue(os.path.exists(legacy_file)) + finally: + paths_mod.paths["project_root"] = old_root + config_bundle.paths["project_root"] = old_root + import shutil + shutil.rmtree(legacy_root, ignore_errors=True) + + def test_migrate_legacy_cache(self): + import shutil + + import config.paths as paths_mod + import config_bundle + + legacy_root = os.path.join(os.path.dirname(self.bundle), "legacy-project") + legacy = os.path.join(legacy_root, ".podcli", "cache") + os.makedirs(legacy, exist_ok=True) + legacy_file = os.path.join(legacy, "abc123.json") + with open(legacy_file, "w", encoding="utf-8") as f: + json.dump({"words": []}, f) + + old_root = paths_mod.paths["project_root"] + old_cache = paths_mod.paths["cache"] + try: + paths_mod.paths["project_root"] = legacy_root + paths_mod.paths["cache"] = os.path.join(self.dst_home, "cache") + config_bundle.paths["project_root"] = legacy_root + config_bundle.paths["cache"] = paths_mod.paths["cache"] + target = paths_mod.paths["cache"] + os.makedirs(target, exist_ok=True) + + summary = migrate_legacy_cache(dry_run=False) + self.assertEqual(summary["moved_json"], 1) + self.assertTrue(os.path.exists(os.path.join(target, "abc123.json"))) + self.assertFalse(os.path.exists(legacy_file)) + finally: + paths_mod.paths["project_root"] = old_root + paths_mod.paths["cache"] = old_cache + config_bundle.paths["project_root"] = old_root + config_bundle.paths["cache"] = old_cache + shutil.rmtree(legacy_root, ignore_errors=True) + + def test_safe_extract_rejects_zip_slip(self): + import zipfile + + evil = os.path.join(os.path.dirname(self.bundle), "evil.zip") + target = os.path.join(self.dst_home, "import-target") + os.makedirs(target, exist_ok=True) + with zipfile.ZipFile(evil, "w") as zf: + zf.writestr("../outside.txt", "bad") + with zipfile.ZipFile(evil, "r") as zf: + from config_bundle import _safe_extract_zip + with self.assertRaises(ValueError): + _safe_extract_zip(zf, Path(target)) + os.remove(evil) + + def test_unified_transcript_cache_round_trip(self): + import config.paths as paths_mod + import config_bundle + from services import transcript_packer as tp + from services.transcript_packer import ( + compute_cache_hash, + load_cached_transcript_for_video, + save_cached_transcript_for_video, + ) + + video = os.path.join(self.src_home, "episode.mp4") + with open(video, "wb") as f: + f.write(b"fake video bytes for hashing") + payload = {"words": [{"word": "hi", "start": 0, "end": 1}], "segments": []} + old_transcripts = paths_mod.paths["transcripts"] + try: + paths_mod.paths["transcripts"] = os.path.join(self.dst_home, "cache", "transcripts") + config_bundle.paths["transcripts"] = paths_mod.paths["transcripts"] + tp.paths["transcripts"] = paths_mod.paths["transcripts"] + os.makedirs(paths_mod.paths["transcripts"], exist_ok=True) + save_cached_transcript_for_video(video, payload) + loaded = load_cached_transcript_for_video(video) + self.assertEqual(loaded["words"][0]["word"], "hi") + h = compute_cache_hash(video) + self.assertTrue(os.path.exists(os.path.join(paths_mod.paths["transcripts"], f"{h}.json"))) + finally: + paths_mod.paths["transcripts"] = old_transcripts + config_bundle.paths["transcripts"] = old_transcripts + tp.paths["transcripts"] = old_transcripts + + +if __name__ == "__main__": + unittest.main()