From 84b10f7fe0ae77f4230de3d7b6786369e4b28cf9 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Thu, 29 Oct 2026 07:44:14 +0000 Subject: [PATCH] Keep --help from downloading, and give process a JSON stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--help` and `--version` no longer trigger first-run setup or model downloads. `process --json --yes` writes one result object to stdout and logs to stderr; NO_COLOR and a non-TTY turn off colour and spinners. `podcli mcp` still does not provision, because stdout is JSON-RPC — run `podcli setup` first. doctor --json now includes a provisioned map. Two extra MCP tools, the original 30 stay: rank_clip_candidates returns a shortlist from the cached transcript; process_episode runs the pipeline as a job that job_status can poll without the studio. Default quality is high everywhere, encode flags are read at call time, ffmpeg timeouts scale with duration, and a hard-cut concat no longer uses a 0-duration afade. Align A/V after that concat. Docs: Intel Macs aren't supported (dropped "in progress"), llms.txt is generated from registered tools, SKILL.md covers the agent workflow, server.json and .well-known/mcp.json are the MCP discovery files. --- .well-known/mcp.json | 9 + CLAUDE.md | 6 +- README.md | 19 +- SKILL.md | 56 ++++++ backend/cli.py | 243 ++++++++++++++++++++++++-- backend/main.py | 137 +++++++++++++++ backend/presets.py | 2 +- backend/services/captions_burn.py | 14 +- backend/services/clip_generator.py | 19 +- backend/services/media_probe.py | 110 ++++++++++-- backend/services/video_processor.py | 99 ++++++++--- cli/doctor_test.go | 19 ++ cli/internal/engine/engine.go | 5 +- cli/main.go | 125 ++++++++++--- cli/main_test.go | 53 ++++++ install.sh | 2 +- llms.txt | 80 +++++++++ scripts/check-docs-drift.mjs | 20 +++ scripts/gen-docs-manifest.mjs | 54 ++++++ server.json | 11 ++ src/handlers/suggest-clips.handler.ts | 26 +++ src/handlers/transcribe.handler.ts | 119 ++++++++++++- src/models/index.ts | 2 +- src/podstack-commands.test.ts | 5 + src/server.ts | 69 +++++++- tests/test_cli.py | 3 + tests/test_cli_helpers.py | 48 +++++ tests/test_media_probe.py | 37 ++++ 28 files changed, 1276 insertions(+), 116 deletions(-) create mode 100644 .well-known/mcp.json create mode 100644 SKILL.md create mode 100644 llms.txt create mode 100644 server.json diff --git a/.well-known/mcp.json b/.well-known/mcp.json new file mode 100644 index 00000000..6567fcfa --- /dev/null +++ b/.well-known/mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "podcli": { + "command": "podcli", + "args": ["mcp"], + "description": "Transcribe, shortlist clips, and render shorts. Run podcli setup once before this." + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 644a417d..35a6a156 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Both share the same knowledge base at `.podcli/knowledge/`. ## MCP tools (podcli engine) -All 30 tools registered by the MCP server. +All 32 tools registered by the MCP server. **Transcription and input** @@ -39,7 +39,7 @@ All 30 tools registered by the MCP server. |------|-------------| | `transcribe_podcast` | Transcribe audio/video with Whisper word timestamps + speaker detection | | `transcribe_start` | Start transcription as a background job, returns a job_id immediately | -| `job_status` | Poll any background job (transcription, render, batch export) with long-polling | +| `job_status` | Poll any background job (transcription, render, batch export, process_episode) with long-polling | | `set_video` | Set the working video without transcribing | | `import_transcript` | Import an external transcript with word-level timestamps, skips Whisper | | `parse_transcript` | Parse a speaker-labeled plain text transcript into word-level timestamps | @@ -51,6 +51,8 @@ All 30 tools registered by the MCP server. | Tool | What it does | |------|-------------| | `get_ui_state` | Read session state (video, transcript, suggestions, settings) and next steps | +| `rank_clip_candidates` | Return ~20 pre-ranked, sentence-aligned clip candidates from the cached transcript (no full transcript in the result) | +| `process_episode` | One-shot job: transcribe, rank, render; poll with `job_status` and read clip paths from the result | | `suggest_clips` | Submit clip suggestions, assigns clip numbers, pushes them to the web UI | | `modify_clip` | Adjust a suggested clip: timing, title, caption style, or delete it | | `toggle_clip` | Select or deselect a suggested clip for export | diff --git a/README.md b/README.md index 1776c7ae..8a607ddf 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ podcli process episode.mp4 ``` -That one command transcribes the episode, picks the moments worth clipping, crops to whoever is speaking, and burns the captions in. Transcription and rendering run on your machine. The only network calls are the optional Claude or Codex requests when you use AI clip scoring. +That one command transcribes the episode, picks the moments worth clipping, crops to whoever is speaking, and burns the captions in. Transcription and rendering run on your machine. Clip scoring uses Claude Code or Codex login, or an Anthropic API key; without those it falls back to heuristics. ## Install @@ -48,7 +48,7 @@ curl -fsSL https://podcli.com/install.sh | sh irm https://podcli.com/install.ps1 | iex ``` -Runs on macOS (Apple Silicon), Linux (x64 and arm64), and Windows (x64). Intel Mac support is in progress. +Runs on macOS (Apple Silicon), Linux (x64 and arm64), and Windows (x64). Intel Macs aren't supported. ## Quick start @@ -97,7 +97,7 @@ Clips land in `podcli-clips/` in the directory you ran it from, so each show kee **Shipping it** -- 30 MCP tools, so an agent can transcribe, score, render, and publish through conversation +- 32 MCP tools, so an agent can transcribe, score, render, and publish through conversation - YouTube publishing plus performance analytics to see which clips landed - DaVinci Resolve export as FCPXML when you want to finish by hand - Presets, clip history with duplicate detection, and a transcript cache @@ -108,7 +108,7 @@ If you are weighing podcli against the cloud clippers, this is the difference: - Runs locally. Transcription and rendering happen on your machine by default, so episodes stay there. Only the optional cloud engine (AssemblyAI) and publishing to YouTube send anything out. - Free and open source under AGPL-3.0. Exports are unlimited, full quality, and watermark-free. -- Agent-native. 30 MCP tools let Claude Code or Codex drive the whole flow, transcription through publishing. +- Agent-native. 32 MCP tools let Claude Code or Codex drive the whole flow, transcription through publishing. - A knowledge base keeps titles, captions, and descriptions in your show's voice, and stops the engine from resuggesting moments you already published. - DaVinci Resolve handoff. Export any clip as FCPXML when you want to finish the edit yourself. @@ -120,6 +120,17 @@ podcli is an [MCP](https://modelcontextprotocol.io) server, so an agent can tran podcli mcp install # registers it with Claude Code and Codex, whichever CLI is on PATH ``` +`--help` and `--version` never download anything. First run of `process`, `studio`, `transcribe`, or `multicam` provisions the engine. `podcli mcp` does not — stdout is JSON-RPC — so run `podcli setup` once before you point an agent at it. + +```bash +podcli process episode.mp4 --json --yes +podcli doctor --json +``` + +`--json` writes one object to stdout and logs to stderr. `-y/--yes` skips prompts. `NO_COLOR=1` (or a non-TTY) turns off colour and spinners. + +`rank_clip_candidates` returns a shortlist so the agent does not have to read the whole transcript. `process_episode` runs the pipeline as one job; poll it with `job_status`. The workflow lives in [SKILL.md](SKILL.md). Tool names are generated into [llms.txt](llms.txt). + Claude Desktop and Codex setup is in the [MCP docs](https://podcli.com/docs/mcp-server). ## Content workflow diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 00000000..acc97c11 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,56 @@ +--- +name: podcli +description: Transcribe a podcast episode, shortlist clips, render shorts, and hand off titles and descriptions for publishing. Use when the user wants to process an episode, make clips, or drive podcli from an agent. +--- + +# podcli + +Turn a long episode into short clips with face tracking and burned-in captions. Drive it from the CLI, the studio, or MCP. + +## Setup + +```bash +curl -fsSL https://podcli.com/install.sh | sh +podcli setup # once; mcp does not auto-provision +podcli mcp install # Claude Code / Codex, whichever is on PATH +podcli doctor --json # confirm backend, mcp, ffmpeg, models +``` + +`--help` and `--version` never download. `podcli mcp` needs setup first because stdout is JSON-RPC. + +Clip scoring uses Claude Code or Codex login, or an Anthropic API key. Without those it falls back to heuristics. Speaker diarization needs `HF_TOKEN`. + +## Process an episode (CLI) + +```bash +podcli process episode.mp4 --json --yes +``` + +JSON on stdout: clip paths, durations, scores, titles, descriptions, tags, caption files. Logs on stderr. `-y/--yes` skips prompts. `NO_COLOR=1` turns off colour and spinners. + +Exit codes: 0 success, 1 runtime error, 2 usage, 130 interrupted. + +## Process an episode (MCP) + +Prefer the short path so you do not load the full transcript: + +1. `transcribe_podcast` (or `transcribe_start` + `job_status` on a long file) +2. `rank_clip_candidates(video_path)` — about 20 sentence-aligned candidates +3. Pick a subset. `suggest_clips` to store them, or `create_clip` / `batch_create_clips` to render +4. `list_outputs` if you only need paths on disk + +One-shot when the user wants finished files, not a review pass: + +1. `process_episode(video_path)` +2. `job_status(job_id, wait_seconds: 30)` until `done` +3. Read clip paths, durations, scores, titles, captions from `result` + +The original tools stay as they were. These two are extras. + +## After clips exist + +Titles, descriptions, thumbnails, brand review: PodStack commands (`/produce-shorts`, `/generate-titles`, `/generate-descriptions`, `/plan-thumbnails`, `/review-content`). Knowledge base is `.podcli/knowledge/`. + +Publishing is outside podcli unless YouTube is linked (`youtube` CLI / integrations). Hand the JSON (paths + titles + descriptions + tags) to the user's publisher. + +Do not call `get_ui_state(include_transcript: true)` unless you need to rewrite a range; use `rank_clip_candidates`. Do not run `podcli mcp` as the first command on a fresh install; run `podcli setup`. diff --git a/backend/cli.py b/backend/cli.py index c7ea09c1..da80029a 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -13,6 +13,7 @@ import argparse import json import os +import re import shutil import subprocess import sys @@ -76,6 +77,61 @@ def _reveal_in_os(path: str) -> None: from presets import DEFAULT_PRESET, MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX from services.knowledge_base import is_empty as kb_is_empty, kb_files +_ANSI_RE = re.compile(r"\033\[[0-9;]*[A-Za-z]") + + +def _use_color(stream=None) -> bool: + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + stream = sys.stdout if stream is None else stream + return hasattr(stream, "isatty") and stream.isatty() + + +def _want_spinner() -> bool: + if os.environ.get("NO_COLOR") or os.environ.get("PODCLI_QUIET"): + return False + return hasattr(sys.stderr, "isatty") and sys.stderr.isatty() + + +def _non_interactive(args=None) -> bool: + if os.environ.get("PODCLI_YES"): + return True + if args is not None and (getattr(args, "yes", False) or getattr(args, "json", False)): + return True + return not (sys.stdin.isatty() and sys.stdout.isatty()) + + +class _PlainStream: + def __init__(self, inner): + self._inner = inner + + def write(self, s): + if isinstance(s, str): + s = _ANSI_RE.sub("", s) + return self._inner.write(s) + + def writelines(self, lines): + for line in lines: + self.write(line) + + def flush(self): + return self._inner.flush() + + def isatty(self): + return False + + def __getattr__(self, name): + return getattr(self._inner, name) + + +def _install_plain_streams(): + if not _use_color(sys.stdout) and not isinstance(sys.stdout, _PlainStream): + sys.stdout = _PlainStream(sys.stdout) + if not _use_color(sys.stderr) and not isinstance(sys.stderr, _PlainStream): + sys.stderr = _PlainStream(sys.stderr) + def _parse_json_transcript(raw_text): data = json.loads(raw_text) @@ -468,7 +524,7 @@ def _should_enter_post_render_loop(config: dict, interrupted: bool, results: lis """Open the post-render rerender flow on explicit config or partial interrupt recovery.""" # The post-render loop is interactive (prompt_toolkit); never enter it # without a real terminal or it crashes with OSError [Errno 22]. - if not sys.stdin.isatty(): + if config.get("non_interactive") or not sys.stdin.isatty(): return False return bool(config.get("post_render_review", False) or (interrupted and _has_successful_results(results))) @@ -1027,6 +1083,94 @@ def _pull_multicam(args, mc, target: str): return session +def _parse_content_text(raw: str) -> dict: + titles, tags, hashtags = [], [], [] + description_lines = [] + section = None + for line in (raw or "").splitlines(): + stripped = line.strip() + upper = stripped.upper() + if upper.startswith("TITLES"): + section = "titles" + continue + if upper.startswith("DESCRIPTION"): + section = "description" + continue + if upper.startswith("HASHTAGS"): + section = "hashtags" + continue + if upper.startswith("TAGS"): + section = "tags" + continue + if upper.startswith("TOP PICK"): + section = None + continue + if not stripped: + continue + if section == "titles": + titles.append(re.sub(r"^\d+\.\s*", "", stripped)) + elif section == "description": + description_lines.append(stripped) + elif section == "tags": + tags.extend(part.strip() for part in stripped.split(",") if part.strip()) + elif section == "hashtags": + hashtags.extend(part for part in stripped.split() if part.startswith("#")) + return { + "titles": titles, + "description": " ".join(description_lines).strip(), + "tags": tags, + "hashtags": hashtags, + } + + +def _emit_process_json(*, json_out, video_path, output_dir, quality, clips, results, interrupted): + payload_clips = [] + for i, result in enumerate(results): + clip = clips[i] if i < len(clips) else {} + path = result.get("output_path") + captions = {} + if path: + base, _ = os.path.splitext(path) + if os.path.exists(base + ".srt"): + captions["srt"] = base + ".srt" + if os.path.exists(base + ".vtt"): + captions["vtt"] = base + ".vtt" + content = {} + raw = result.get("content_text") + if not raw and result.get("content_path") and os.path.exists(result["content_path"]): + with open(result["content_path"], encoding="utf-8") as fh: + raw = fh.read() + if raw: + content = _parse_content_text(raw) + content["path"] = result.get("content_path") + entry = { + "index": i + 1, + "title": clip.get("title") or result.get("title"), + "path": path, + "duration": result.get("duration") or clip.get("duration"), + "output_duration": result.get("output_duration"), + "start_second": result.get("start_second", clip.get("start_second")), + "end_second": result.get("end_second", clip.get("end_second")), + "score": clip.get("score"), + "file_size_mb": result.get("file_size_mb"), + "status": result.get("status", "ok" if path else "error"), + "error": result.get("error"), + "captions": captions or None, + "thumbnails": result.get("thumbnail_paths") or None, + "content": content or None, + } + payload_clips.append({k: v for k, v in entry.items() if v is not None}) + payload = { + "ok": not interrupted and any(c.get("path") for c in payload_clips), + "interrupted": bool(interrupted), + "video": video_path, + "output_dir": output_dir, + "quality": quality, + "clips": payload_clips, + } + print(json.dumps(payload, ensure_ascii=False), file=json_out, flush=True) + + def cmd_process(args): """Full auto pipeline: transcribe → suggest → export.""" from services.clip_generator import generate_clip @@ -1036,6 +1180,11 @@ def cmd_process(args): from services.encoder import get_encoder_info from presets import get_preset, DEFAULT_PRESET, MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX + json_out = sys.stdout + args._json_out = json_out + if getattr(args, "json", False): + sys.stdout = sys.stderr + # Load preset or defaults if args.preset: try: @@ -1124,9 +1273,11 @@ def cmd_process(args): if args.top: config["top_clips"] = args.top if getattr(args, "review_each", False): - config["review_each_clip"] = True + config["review_each_clip"] = True if getattr(args, "post_review", False): config["post_render_review"] = True + if getattr(args, "yes", False) or getattr(args, "json", False): + config["non_interactive"] = True if args.logo: from services.asset_store import resolve as resolve_asset resolved = resolve_asset(args.logo) @@ -1233,8 +1384,18 @@ def cmd_process(args): config["outro_path"] = "" os.environ["PODCLI_TRANSITION_AUTOFIX_PASSES"] = "0" - # Set quality env var before importing video_processor - quality = config.get("quality", os.environ.get("PODCLI_QUALITY", "max")) + if getattr(args, "quiet", False): + os.environ["PODCLI_QUIET"] = "1" + if getattr(args, "yes", False) or getattr(args, "json", False): + os.environ["PODCLI_YES"] = "1" + config["non_interactive"] = True + config["review_each_clip"] = False + config["post_render_review"] = False + + # Set quality env var before encoding. media_probe.cpu_flags() reads this + # at call time so the value here is what actually encodes, not a leftover + # from import. + quality = config.get("quality") or os.environ.get("PODCLI_QUALITY") or "high" os.environ["PODCLI_QUALITY"] = quality # Output directory: explicit --output wins. Otherwise preset runs get @@ -1376,9 +1537,11 @@ def cmd_process(args): from services.transcription import transcribe_file import threading - # Spinner runs in background while Whisper blocks + # Spinner runs in background while Whisper blocks. Off when stderr + # isn't a TTY, NO_COLOR is set, or --json/--quiet was passed. _spin_stop = threading.Event() _spin_msg = ["Loading model..."] + _spinning = _want_spinner() def _spinner(): frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] @@ -1389,8 +1552,10 @@ def _spinner(): _spin_stop.wait(0.12) print(f"\r {'':60}", end="\r") # clear line - spin_thread = threading.Thread(target=_spinner, daemon=True) - spin_thread.start() + spin_thread = None + if _spinning: + spin_thread = threading.Thread(target=_spinner, daemon=True) + spin_thread.start() def _transcribe_progress(pct, msg): _spin_msg[0] = f"{msg} ({pct}%)" if pct < 100 else msg @@ -1410,11 +1575,13 @@ def _transcribe_progress(pct, msg): ) except (RuntimeError, FileNotFoundError, subprocess.CalledProcessError) as e: _spin_stop.set() - spin_thread.join(timeout=1) + if spin_thread: + spin_thread.join(timeout=1) print(f"\r{' ' * 70}\r ✗ {e}\n", flush=True) sys.exit(1) _spin_stop.set() - spin_thread.join(timeout=1) + if spin_thread: + spin_thread.join(timeout=1) words = result["words"] segments = result["segments"] print(f" Done: {len(segments)} segments, {len(words)} words") @@ -1712,7 +1879,11 @@ def _transcribe_progress(pct, msg): print(f"\n [4/4] Exporting {len(clips)} clips{_ai_label} to {output_dir}/") results = [] t0 = time.time() - _skip_review = not config.get("review_each_clip", False) or not sys.stdin.isatty() + _skip_review = ( + not config.get("review_each_clip", False) + or config.get("non_interactive") + or not sys.stdin.isatty() + ) interrupted = False try: @@ -1788,6 +1959,8 @@ def _transcribe_progress(pct, msg): _pair = rendered_thumbs["pair"] if _pair and _pair["layout"] != "pair": print(f" ℹ {_pair['reason']}") + if thumb_paths: + result["thumbnail_paths"] = thumb_paths if thumb_paths and _thumb_placement == "off": print(f" + {len(thumb_paths)} thumbnail(s) in " f"{os.path.basename(clip_thumb_dir)}/") @@ -1795,10 +1968,12 @@ def _transcribe_progress(pct, msg): thumb_video = os.path.join(clip_thumb_dir, "thumb_frame.mp4") _thumb_to_video(thumb_paths[0], thumb_video, duration=_thumb_intro_duration) from services.video_processor import concat_outro + from services.media_probe import align_av_duration as _align_av_duration 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) + _align_av_duration(final_with_thumb) os.replace(final_with_thumb, result["output_path"]) print(f" + thumbnail prepended ({_thumb_intro_duration:.2f}s, {len(thumb_paths)} variations in {os.path.basename(clip_thumb_dir)}/)") except Exception as e: @@ -1817,6 +1992,8 @@ def _transcribe_progress(pct, msg): _content_path = result["output_path"].replace(".mp4", "_content.md") with open(_content_path, "w", encoding="utf-8") as _cf: _cf.write(f"# {clip.get('title', 'Clip')}\n\n{content_result['raw_text']}") + result["content_path"] = _content_path + result["content_text"] = content_result.get("raw_text", "") # Pretty-print in terminal _accent = "\033[38;2;212;135;74m" @@ -2019,6 +2196,17 @@ def _transcribe_progress(pct, msg): if not _ai_cli_path: print(f"\n {gray}For titles, descriptions & tags: install Claude Code or Codex CLI{reset}") + if getattr(args, "json", False): + _emit_process_json( + json_out=getattr(args, "_json_out", sys.__stdout__), + video_path=video_path, + output_dir=output_dir, + quality=quality, + clips=clips, + results=results, + interrupted=interrupted, + ) + # ── Post-render iteration ── if _should_enter_post_render_loop(config, interrupted, results): if interrupted and not config.get("post_render_review", False): @@ -2049,6 +2237,9 @@ def __init__(self, message: str, indent: str = " "): def __enter__(self): import threading self._stop.clear() + if not _want_spinner(): + print(f"{self._indent}{self._msg}", flush=True) + return self def _run(): frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] @@ -2111,7 +2302,7 @@ def _review_clips(clips: list, segments: list, energy_scores: list | None, confi # Non-interactive (piped/scripted/no TTY): skip the picker and render all # suggested clips. The picker uses prompt_toolkit, which raises # OSError [Errno 22] when stdin isn't a real terminal. - if not sys.stdin.isatty(): + if config.get("non_interactive") or not sys.stdin.isatty(): print(f"\n Non-interactive run — rendering all {len(clips)} suggested clips.") return clips @@ -4500,7 +4691,10 @@ def print_help(): print(f" {green}--fast{reset} Draft mode: tiny Whisper, heuristic clips, low quality") print(f" {green}--logo{reset} {gray}{reset} Overlay logo image") print(f" {green}--outro{reset} {gray}{reset} Append outro video") - print(f" {green}--quality{reset} {gray}{reset} low | medium | high | max") + print(f" {green}--quality{reset} {gray}{reset} low | medium | high | max {dim}(default: high){reset}") + print(f" {green}--json{reset} One JSON object on stdout; logs on stderr") + print(f" {green}-y{reset}, {green}--yes{reset} Do not prompt") + print(f" {green}-q{reset}, {green}--quiet{reset} No spinners") print(f" {green}--no-energy{reset} Skip audio energy analysis") print(f" {green}--no-speakers{reset} Skip speaker detection (faster)") print(f" {green}--no-cache{reset} Force re-transcription") @@ -4508,6 +4702,7 @@ def print_help(): print() print(f" {bold}Examples:{reset}") print(f" {dim}${reset} podcli process episode.mp4") + print(f" {dim}${reset} podcli process episode.mp4 --json --yes") print(f" {dim}${reset} podcli process ep42.mp4 -t transcript.json --top 8 --caption-style hormozi") print(f" {dim}${reset} podcli process ep42.mp4 --preset myshow --quality max") print(f" {dim}${reset} podcli assets add mylogo ~/branding/logo.png") @@ -4820,7 +5015,7 @@ def _onboarding_marker() -> str: def _needs_onboarding() -> bool: - if os.environ.get("PODCLI_NO_ONBOARDING"): + if os.environ.get("PODCLI_NO_ONBOARDING") or os.environ.get("PODCLI_YES"): return False if os.path.exists(_onboarding_marker()): return False @@ -5109,6 +5304,12 @@ def main(): 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") proc.add_argument("--post-review", action="store_true", help="Open the post-render review loop after export") + proc.add_argument("--json", action="store_true", + help="Emit one JSON object to stdout (clip paths, durations, scores, titles, captions); logs go to stderr") + proc.add_argument("-y", "--yes", action="store_true", + help="Do not prompt; render every suggested clip") + proc.add_argument("-q", "--quiet", action="store_true", + help="No spinners; keep stderr to warnings and errors") # ── reel (highlights, detect once then iterate fast) ── reel_p = sub.add_parser("reel", help="Create and iterate on a highlights reel") @@ -5555,6 +5756,12 @@ def main(): args = parser.parse_args() + if getattr(args, "yes", False) or getattr(args, "json", False): + os.environ["PODCLI_YES"] = "1" + if getattr(args, "quiet", False): + os.environ["PODCLI_QUIET"] = "1" + _install_plain_streams() + if getattr(args, "json", False): # --json promises stdout holds only the JSON result. import contextlib @@ -5567,7 +5774,9 @@ def main(): print_help() return - if _needs_onboarding() and not _first_run_setup(): + if getattr(args, "json", False) or getattr(args, "yes", False): + pass + elif _needs_onboarding() and not _first_run_setup(): print(" Setup cancelled. Your command did not run.", file=sys.stderr) sys.exit(130) @@ -5580,7 +5789,7 @@ def main(): elif args.command == "workspace": cmd_workspace(args) elif args.command == "process": - if not getattr(args, "no_banner", False): + if not getattr(args, "no_banner", False) and not getattr(args, "json", False): print() cmd_process(args) elif args.command == "studio": @@ -6107,7 +6316,7 @@ def _interactive_process(): quality = questionary.select( "Quality:", choices=["low", "medium", "high", "max"], - default="max", + default="high", style=qstyle, ).ask() if quality is None: @@ -6509,7 +6718,7 @@ def _interactive_presets(): quality = questionary.select( "Quality:", choices=["low", "medium", "high", "max"], - default=config.get("quality", "max"), + default=config.get("quality", "high"), style=qstyle, ).ask() if quality: diff --git a/backend/main.py b/backend/main.py index 3b762165..9e269960 100644 --- a/backend/main.py +++ b/backend/main.py @@ -806,6 +806,141 @@ def handle_suggest_clips(task_id: str, params: dict): emit_result(task_id, "success", data={"clips": clips}) +def handle_rank_candidates(task_id: str, params: dict): + """Sentence-aligned heuristic ranking. Returns a shortlist, not the transcript.""" + from services.transcript_packer import load_cached_transcript_for_video + import cli as cli_mod + + segments = params.get("segments") or [] + video_path = params.get("video_path") + if not segments: + if not video_path: + emit_result(task_id, "error", error="video_path or segments is required") + return + cached = load_cached_transcript_for_video(video_path) + if not cached: + emit_result( + task_id, + "error", + error="No transcript cached for this video. Call transcribe_podcast first.", + ) + return + segments = cached.get("segments") or [] + if not segments: + emit_result(task_id, "error", error="Transcript has no segments to rank") + return + + top_n = int(params.get("top_n") or 20) + top_n = max(1, min(top_n, 40)) + emit_progress(task_id, "ranking", 10, f"Ranking up to {top_n} sentence-aligned candidates...") + clips = cli_mod._suggest_clips(segments=segments, top_n=top_n) + candidates = [] + for i, clip in enumerate(clips): + preview = clip.get("preview") or clip.get("preview_text") or clip.get("quote") or "" + candidates.append({ + "rank": i + 1, + "start_second": clip.get("start_second"), + "end_second": clip.get("end_second"), + "duration": clip.get("duration"), + "score": clip.get("score"), + "title": clip.get("title"), + "preview": str(preview)[:180], + "reasons": clip.get("reasons") or [], + }) + emit_result(task_id, "success", data={"count": len(candidates), "candidates": candidates}) + + +def handle_process_episode(task_id: str, params: dict): + """Run `process` non-interactively and return the --json payload.""" + import argparse + import io + import cli as cli_mod + + video_path = params.get("video_path") or params.get("file_path") + if not video_path: + emit_result(task_id, "error", error="video_path is required") + return + emit_progress(task_id, "process", 1, "Processing episode...") + args = argparse.Namespace( + video=video_path, + transcript=params.get("transcript"), + top=params.get("top") or params.get("top_n"), + output=params.get("output"), + preset=params.get("preset"), + engine=params.get("engine"), + assemblyai_api_key=params.get("assemblyai_api_key"), + fast=bool(params.get("fast")), + thumbnails=params.get("thumbnails"), + caption_style=params.get("caption_style"), + crop=params.get("crop"), + format=params.get("format"), + profile=params.get("profile"), + logo=params.get("logo"), + outro=params.get("outro"), + no_outro=bool(params.get("no_outro", False)), + intro=params.get("intro"), + time_adjust=params.get("time_adjust"), + no_energy=bool(params.get("no_energy", False)), + no_speakers=bool(params.get("no_speakers", False)), + no_cache=bool(params.get("no_cache", False)), + no_resume=bool(params.get("no_resume", False)), + quality=params.get("quality"), + allow_ass_fallback=bool(params.get("allow_ass_fallback", False)), + review_each=False, + post_review=False, + json=True, + yes=True, + quiet=True, + no_banner=True, + cloud=False, + language=params.get("language"), + template=None, + caption_position=None, + caption_scale=None, + face_map=None, + crop_keyframes=None, + thumbnail_placement=None, + logo_position=None, + logo_scale=None, + topic=None, + progress=False, + progress_color=None, + brand=None, + style=None, + font_family=None, + name_card=None, + motion=None, + bookend_fade=None, + ) + buf = io.StringIO() + old_out = sys.stdout + try: + sys.stdout = buf + cli_mod.cmd_process(args) + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else 1 + if code not in (0, None): + emit_result(task_id, "error", error=f"process exited {code}") + return + finally: + sys.stdout = old_out + + raw = buf.getvalue().strip() + payload = None + for line in reversed(raw.splitlines() or [""]): + line = line.strip() + if line.startswith("{"): + try: + payload = json.loads(line) + break + except json.JSONDecodeError: + continue + if payload is None: + emit_result(task_id, "error", error="process finished without a JSON result") + return + emit_result(task_id, "success", data=payload) + + def handle_manage_env(task_id: str, params: dict): """List/set/unset user secrets in the global .env (e.g. HF_TOKEN).""" from services.env_settings import run_env_action @@ -1194,6 +1329,8 @@ def handle_run_integration_tool(task_id: str, params: dict): "presets": handle_presets, "corrections": handle_corrections, "suggest_clips": handle_suggest_clips, + "rank_candidates": handle_rank_candidates, + "process_episode": handle_process_episode, "find_moment": handle_find_moment, "manage_env": handle_manage_env, "ai_cli_status": handle_ai_cli_status, diff --git a/backend/presets.py b/backend/presets.py index 7431e231..cf275578 100644 --- a/backend/presets.py +++ b/backend/presets.py @@ -43,7 +43,7 @@ "min_clip_duration": MIN_CLIP_DURATION, "target_lufs": -14.0, "energy_boost": True, - "quality": "max", + "quality": "high", "no_speakers": False, "allow_ass_fallback": True, "use_ass_captions": False, diff --git a/backend/services/captions_burn.py b/backend/services/captions_burn.py index 79cfcbb1..73ecbce6 100644 --- a/backend/services/captions_burn.py +++ b/backend/services/captions_burn.py @@ -14,9 +14,11 @@ from services.encoder import get_video_encode_flags from services.media_probe import ( - CPU_FLAGS, FFMPEG_TIMEOUT, + cpu_flags, + ffmpeg_timeout, get_dimensions, + get_media_duration_seconds, has_audio_stream, ) from utils.proc import run as proc_run @@ -129,10 +131,12 @@ def burn_captions( output_path, ] - result = proc_run(cmd, timeout=FFMPEG_TIMEOUT, check=False) + burn_timeout = ffmpeg_timeout(get_media_duration_seconds(input_path, default=30.0)) + result = proc_run(cmd, timeout=burn_timeout, check=False) # Fallback to CPU if HW encoder failed - if result.returncode != 0 and enc_flags != CPU_FLAGS: + fallback_flags = cpu_flags() + if result.returncode != 0 and enc_flags != fallback_flags: print( "Warning: HW encoder failed for caption burn, falling back to libx264", file=sys.stderr, @@ -143,11 +147,11 @@ def burn_captions( "-filter_complex", filter_complex, "-map", "[out]", *audio_map, - *CPU_FLAGS, + *fallback_flags, "-movflags", "+faststart", output_path, ] - result = proc_run(cmd_fallback, timeout=FFMPEG_TIMEOUT, check=False) + result = proc_run(cmd_fallback, timeout=burn_timeout, check=False) if gradient_overlay and gradient_path and os.path.exists(gradient_path): os.remove(gradient_path) diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py index a1cfc831..3f8b3c46 100644 --- a/backend/services/clip_generator.py +++ b/backend/services/clip_generator.py @@ -43,6 +43,7 @@ ) from config.caption_styles import get_style from services.formats import get_format +from services.media_probe import align_av_duration, ffmpeg_timeout _FILLER_WORDS = frozenset([ "um", "uh", "uhh", "uhm", "umm", "hmm", "hm", "mhm", @@ -270,9 +271,21 @@ def _apply_local_transition_smoothing( output_path, ] try: - result = proc_run(cmd, timeout=300, check=False) - return result.returncode == 0 and os.path.exists(output_path) + duration = _get_media_duration(input_path) + result = proc_run(cmd, timeout=ffmpeg_timeout(duration), check=False) + ok = result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0 + if not ok and os.path.exists(output_path): + try: + os.remove(output_path) + except OSError: + pass + return ok except Exception: + if os.path.exists(output_path): + try: + os.remove(output_path) + except OSError: + pass return False @@ -1453,6 +1466,8 @@ def generate_clip( shutil.copy2(final_video_path, final_path) + align_av_duration(final_path) + # Optional bounded QA/autofix pass for transition jumps. # Hard-capped to avoid any infinite rerender loop. max_autofix_passes = _render_transition_autofix_passes( diff --git a/backend/services/media_probe.py b/backend/services/media_probe.py index 76198f22..89f0c6be 100644 --- a/backend/services/media_probe.py +++ b/backend/services/media_probe.py @@ -20,8 +20,8 @@ from services.encoder import get_video_encode_flags from utils.proc import run as proc_run -# Max time for any single FFmpeg/ffprobe call (seconds). -# Prevents the rendering pipeline from hanging on a stuck process. +# Max time for a short probe. Encode timeouts scale with duration via +# ffmpeg_timeout(); a 40s 1080x1920 libx264 slower pass on CPU can exceed 300s. FFMPEG_TIMEOUT = 300 # Quality presets: name → (crf, preset) for libx264. @@ -33,18 +33,30 @@ "max": {"crf": "14", "preset": "slower"}, # ~15-30 MB/min, near-lossless } -_quality = os.environ.get("PODCLI_QUALITY", "high") -_qp = QUALITY_PRESETS.get(_quality, QUALITY_PRESETS["high"]) -CPU_FLAGS = [ - "-c:v", "libx264", - "-crf", _qp["crf"], - "-preset", _qp["preset"], - "-profile:v", "high", - # High profile is 4:2:0 only, so leaving the format to negotiation makes - # this command fail on any filtergraph that hands it 4:4:4. See - # services/encoder.py for the whole story. - "-pix_fmt", "yuv420p", -] + +def cpu_flags() -> list: + """libx264 flags for the current PODCLI_QUALITY. Read at call time so a + `process --quality` set after import is what actually encodes.""" + quality = os.environ.get("PODCLI_QUALITY", "high") + qp = QUALITY_PRESETS.get(quality, QUALITY_PRESETS["high"]) + return [ + "-c:v", "libx264", + "-crf", qp["crf"], + "-preset", qp["preset"], + "-profile:v", "high", + "-pix_fmt", "yuv420p", + ] + + +CPU_FLAGS = cpu_flags() + + +def ffmpeg_timeout(duration_s: float | None = None) -> float: + """Seconds allowed for one encode. Probes keep using FFMPEG_TIMEOUT.""" + base = float(FFMPEG_TIMEOUT) + if duration_s and duration_s > 0: + return min(1800.0, max(base, duration_s * 20.0 + 120.0)) + return base def run_ffmpeg_with_fallback( @@ -52,6 +64,7 @@ def run_ffmpeg_with_fallback( cmd_parts_after_enc: list, output_path: str, label: str = "encode", + duration_s: float | None = None, ) -> str: """Run an FFmpeg command with the best encoder; retry libx264 on failure. @@ -62,19 +75,21 @@ def run_ffmpeg_with_fallback( retries with libx264 before raising. """ enc_flags = get_video_encode_flags() + fallback_flags = cpu_flags() cmd = cmd_parts_before_enc + enc_flags + cmd_parts_after_enc + [output_path] + timeout = ffmpeg_timeout(duration_s) - result = proc_run(cmd, timeout=FFMPEG_TIMEOUT, check=False) + result = proc_run(cmd, timeout=timeout, check=False) if result.returncode == 0: return output_path - if enc_flags != CPU_FLAGS: + if enc_flags != fallback_flags: print( f"Warning: HW encoder failed for {label}, falling back to libx264", file=sys.stderr, ) - cmd_fallback = cmd_parts_before_enc + CPU_FLAGS + cmd_parts_after_enc + [output_path] - result2 = proc_run(cmd_fallback, timeout=FFMPEG_TIMEOUT, check=False) + cmd_fallback = cmd_parts_before_enc + fallback_flags + cmd_parts_after_enc + [output_path] + result2 = proc_run(cmd_fallback, timeout=timeout, check=False) if result2.returncode == 0: return output_path raise RuntimeError( @@ -153,3 +168,62 @@ def get_dimensions(video_path: str) -> tuple[int, int]: if stream.get("codec_type") == "video": return int(stream["width"]), int(stream["height"]) raise ValueError(f"No video stream found in {video_path}") + + +def stream_durations(video_path: str) -> tuple[float | None, float | None]: + """Return (video_stream_duration, audio_stream_duration) in seconds.""" + try: + info = get_video_info(video_path) + except Exception: + return None, None + video_d = None + audio_d = None + for stream in info.get("streams", []): + dur = parse_duration_seconds(stream.get("duration")) + if dur is None: + continue + kind = stream.get("codec_type") + if kind == "video" and video_d is None: + video_d = dur + elif kind == "audio" and audio_d is None: + audio_d = dur + return video_d, audio_d + + +def align_av_duration(path: str, slack: float = 0.2) -> str: + """Trim a frozen video tail (or pad a short picture) so A/V durations match. + + Thumbnail prepend and a timed-out re-encode can leave video longer than + audio. Trim to the shorter stream; keep the original if they already agree + or if the rewrite fails. + """ + video_d, audio_d = stream_durations(path) + if video_d is None or audio_d is None: + return path + gap = abs(video_d - audio_d) + if gap <= slack: + return path + target = min(video_d, audio_d) + tmp = path + ".avalign.mp4" + cmd = [ + "ffmpeg", "-y", + "-i", path, + "-t", f"{target:.3f}", + "-c:v", "copy", + "-c:a", "copy", + "-movflags", "+faststart", + tmp, + ] + try: + result = proc_run(cmd, timeout=FFMPEG_TIMEOUT, check=False) + if result.returncode == 0 and os.path.exists(tmp) and os.path.getsize(tmp) > 0: + os.replace(tmp, path) + return path + except Exception: + pass + if os.path.exists(tmp): + try: + os.remove(tmp) + except OSError: + pass + return path diff --git a/backend/services/video_processor.py b/backend/services/video_processor.py index 06d0f9a4..378db371 100644 --- a/backend/services/video_processor.py +++ b/backend/services/video_processor.py @@ -22,6 +22,8 @@ CPU_FLAGS, FFMPEG_TIMEOUT as _FFMPEG_TIMEOUT, QUALITY_PRESETS, + align_av_duration, + ffmpeg_timeout, get_dimensions, get_video_info, get_media_duration_seconds as _get_media_duration_seconds, @@ -3197,6 +3199,44 @@ def concat_outro( ) outro_duration = _get_media_duration_seconds(outro_scaled, default=0.0) + if crossfade_duration <= 0: + # Hard cut (thumbnail prepend, bookend_fade=0). No afade: a 0-duration + # fade can drop packets and leave video longer than audio. + AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo" + if _has_audio_stream(input_path) and _has_audio_stream(outro_scaled): + try: + joined = _run_ffmpeg_with_fallback( + cmd_parts_before_enc=[ + "ffmpeg", "-y", + "-i", input_path, + "-i", outro_scaled, + "-filter_complex", + ( + "[0:v][1:v]concat=n=2:v=1:a=0[v];" + f"[0:a]{AFMT}[a0];[1:a]{AFMT}[a1];" + "[a0][a1]concat=n=2:v=0:a=1[a]" + ), + "-map", "[v]", + "-map", "[a]", + ], + cmd_parts_after_enc=[ + "-c:a", "aac", "-b:a", "192k", "-ar", "44100", + "-movflags", "+faststart", + ], + output_path=output_path, + label="hardcut_concat", + duration_s=(main_duration or 0) + (outro_duration or 0), + ) + except Exception: + joined = None + if joined: + try: + os.remove(outro_scaled) + except OSError: + pass + align_av_duration(output_path) + return output_path + safe_crossfade = 0.0 safe_crossfade = min(safe_crossfade, max(0.05, main_duration - 0.05)) if outro_duration > 0: safe_crossfade = min(safe_crossfade, max(0.05, outro_duration - 0.05)) @@ -3239,7 +3279,11 @@ def concat_outro( "-movflags", "+faststart", output_path, ] - result = proc_run(xfade_cmd, timeout=_FFMPEG_TIMEOUT, check=False) + result = proc_run( + xfade_cmd, + timeout=ffmpeg_timeout((main_duration or 0) + (outro_duration or 0)), + check=False, + ) if result.returncode == 0: if os.path.exists(outro_scaled): os.remove(outro_scaled) @@ -3259,31 +3303,34 @@ def concat_outro( audio_fade = min(safe_crossfade, max(0.05, max(0.1, main_duration) - 0.05)) audio_fade_start = max(0.0, main_duration - audio_fade) AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo" - try: - joined = _run_ffmpeg_with_fallback( - cmd_parts_before_enc=[ - "ffmpeg", "-y", - "-i", input_path, - "-i", outro_scaled, - "-filter_complex", - ( - "[0:v][1:v]concat=n=2:v=1:a=0[v];" - f"[0:a]{AFMT},afade=t=out:st={audio_fade_start}:d={audio_fade}[a0];" - f"[1:a]{AFMT},afade=t=in:st=0:d={audio_fade}[a1];" - "[a0][a1]concat=n=2:v=0:a=1[a]" - ), - "-map", "[v]", - "-map", "[a]", - ], - cmd_parts_after_enc=[ - "-c:a", "aac", "-b:a", "192k", "-ar", "44100", - "-movflags", "+faststart", - ], - output_path=output_path, - label="outro_hardcut_soft_audio", - ) - except Exception: - joined = None + joined = None + if audio_fade >= 0.05: + try: + joined = _run_ffmpeg_with_fallback( + cmd_parts_before_enc=[ + "ffmpeg", "-y", + "-i", input_path, + "-i", outro_scaled, + "-filter_complex", + ( + "[0:v][1:v]concat=n=2:v=1:a=0[v];" + f"[0:a]{AFMT},afade=t=out:st={audio_fade_start}:d={audio_fade}[a0];" + f"[1:a]{AFMT},afade=t=in:st=0:d={audio_fade}[a1];" + "[a0][a1]concat=n=2:v=0:a=1[a]" + ), + "-map", "[v]", + "-map", "[a]", + ], + cmd_parts_after_enc=[ + "-c:a", "aac", "-b:a", "192k", "-ar", "44100", + "-movflags", "+faststart", + ], + output_path=output_path, + label="outro_hardcut_soft_audio", + duration_s=(main_duration or 0) + (outro_duration or 0), + ) + except Exception: + joined = None if joined: # The scaled outro sits in the user's output folder. A failed # delete must not send a finished join into the fallback below. diff --git a/cli/doctor_test.go b/cli/doctor_test.go index 70d29b6f..149dcd79 100644 --- a/cli/doctor_test.go +++ b/cli/doctor_test.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "os" "path/filepath" + "strings" "testing" "podcli/internal/provision" @@ -159,3 +161,20 @@ func TestChecksAllOKCountsFailLevelChecks(t *testing.T) { t.Fatal("a fail-level check must flip doctor's overall result to failing") } } + +func TestDoctorReportJSONIncludesProvisioned(t *testing.T) { + report := doctorReport{ + Version: "test", + Paths: map[string]string{"home": "/tmp"}, + Provisioned: map[string]bool{"mcp": false, "backend": true}, + OK: true, + } + data, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + s := string(data) + if !strings.Contains(s, `"provisioned"`) || !strings.Contains(s, `"mcp":false`) { + t.Fatalf("provisioned map missing from doctor JSON: %s", s) + } +} diff --git a/cli/internal/engine/engine.go b/cli/internal/engine/engine.go index d6200a3a..4e8a5a53 100644 --- a/cli/internal/engine/engine.go +++ b/cli/internal/engine/engine.go @@ -19,9 +19,12 @@ import ( type stderrFilter struct{ buf []byte } func isStderrNoise(line string) bool { + trim := strings.TrimSpace(line) return strings.HasPrefix(line, "objc[") || strings.Contains(line, "FP16 is not supported") || - strings.HasPrefix(strings.TrimSpace(line), "warnings.warn(") + strings.HasPrefix(trim, "warnings.warn(") || + strings.HasPrefix(trim, "[ WARN:") || + strings.HasPrefix(trim, "[WARN:") } func (w *stderrFilter) Write(p []byte) (int, error) { diff --git a/cli/main.go b/cli/main.go index d1928b7f..739cdb7b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -78,9 +78,29 @@ func main() { } } +// isMetaFlag reports --help/--version (and short forms) anywhere before `--`. +// Those must never start a download: `podcli process --help` is a help request, +// not a first run of process. +func isMetaFlag(args []string) bool { + for _, a := range args { + if a == "--" { + break + } + switch a { + case "--help", "-h", "--version", "-v": + return true + } + } + return false +} + // wantsRuntime gates first-run auto-provisioning: only commands that need the -// backend trigger the download, not lightweight ones like config. +// backend trigger the download, not lightweight ones like config, and never +// --help/--version even when they sit on a command that would otherwise provision. func wantsRuntime(args []string) bool { + if isMetaFlag(args) { + return false + } if len(args) == 0 { return true } @@ -194,45 +214,74 @@ func ensureBackendDeps() error { func runEngine(args []string) int { update.NotifyIfOutdated(Version) + helpOrVersion := isMetaFlag(args) if wantsRuntime(args) { if err := ensureRuntime(); err != nil { fmt.Fprintln(os.Stderr, "podcli:", err) return 1 } - } else if err := refreshBackend(); err != nil { - fmt.Fprintln(os.Stderr, "podcli:", err) - return 1 + } else if !helpOrVersion { + if err := refreshBackend(); err != nil { + fmt.Fprintln(os.Stderr, "podcli:", err) + return 1 + } } if wantsStudio(args) { refreshStudioBundles() } - switch transcribeEngine(args) { - case "whispercpp": - model, err := provision.EnsureModel(transcribeModel(args)) - if err != nil { - fmt.Fprintln(os.Stderr, "podcli: provisioning model:", err) - return 1 - } - os.Setenv("PODCLI_ENGINE", "whispercpp") - os.Setenv("PODCLI_WHISPERCPP_MODEL", model) - case "omnilingual": - model, tokens, err := provision.EnsureOmnilingualModel() - if err != nil { - fmt.Fprintln(os.Stderr, "podcli: provisioning omnilingual model:", err) - return 1 + if !helpOrVersion { + switch transcribeEngine(args) { + case "whispercpp": + model, err := provision.EnsureModel(transcribeModel(args)) + if err != nil { + fmt.Fprintln(os.Stderr, "podcli: provisioning model:", err) + return 1 + } + os.Setenv("PODCLI_ENGINE", "whispercpp") + os.Setenv("PODCLI_WHISPERCPP_MODEL", model) + case "omnilingual": + model, tokens, err := provision.EnsureOmnilingualModel() + if err != nil { + fmt.Fprintln(os.Stderr, "podcli: provisioning omnilingual model:", err) + return 1 + } + os.Setenv("PODCLI_ENGINE", "omnilingual") + os.Setenv("PODCLI_OMNILINGUAL_MODEL", model) + os.Setenv("PODCLI_OMNILINGUAL_TOKENS", tokens) } - os.Setenv("PODCLI_ENGINE", "omnilingual") - os.Setenv("PODCLI_OMNILINGUAL_MODEL", model) - os.Setenv("PODCLI_OMNILINGUAL_TOKENS", tokens) } code, err := engine.Run(args) if err != nil { + if helpOrVersion { + if hasFlag(args, "--version", "-v") { + fmt.Printf("podcli %s\n", Version) + return 0 + } + printHelp() + return 0 + } fmt.Fprintln(os.Stderr, "podcli:", err) return 1 } return code } +func hasFlag(args []string, names ...string) bool { + want := map[string]struct{}{} + for _, n := range names { + want[n] = struct{}{} + } + for _, a := range args { + if a == "--" { + break + } + if _, ok := want[a]; ok { + return true + } + } + return false +} + func transcribeModel(args []string) string { fast := false for _, arg := range args { @@ -827,10 +876,11 @@ type doctorCheck struct { } type doctorReport struct { - Version string `json:"version"` - Paths map[string]string `json:"paths"` - Checks []doctorCheck `json:"checks"` - OK bool `json:"ok"` + Version string `json:"version"` + Paths map[string]string `json:"paths"` + Provisioned map[string]bool `json:"provisioned"` + Checks []doctorCheck `json:"checks"` + OK bool `json:"ok"` } func firstLine(s string) string { @@ -1030,9 +1080,20 @@ func doctor(args []string) int { checks := runDoctorChecks() allOK := checksAllOK(checks) + _, backendOK := engine.BackendRoot() + provisioned := map[string]bool{ + "backend": backendOK, + "python": engine.IsHermeticPython() || engine.Python() != "python3", + "studio": engine.StudioServer() != "", + "mcp": engine.MCPServer() != "", + "remotion": fileExists(provision.RemotionScript()), + "ffmpeg": engine.FFmpeg() != "", + "whisper": engine.WhisperCLI() != "", + "node": engine.Node() != "", + } if asJSON { - report := doctorReport{Version: Version, Paths: pathInfo, Checks: checks, OK: allOK} + report := doctorReport{Version: Version, Paths: pathInfo, Provisioned: provisioned, Checks: checks, OK: allOK} data, err := json.MarshalIndent(report, "", " ") if err != nil { fmt.Fprintln(os.Stderr, "podcli: doctor:", err) @@ -1134,7 +1195,7 @@ Launcher commands: login | logout | whoami podcli Pro account on this machine sync Reconcile clips, assets, and knowledge with your workspace - doctor Show resolved paths, interpreter, backend, ffmpeg, models + doctor [--json] Show resolved paths, interpreter, backend, ffmpeg, models version Print version update Check for and apply a newer release uninstall Remove podcli app files (keeps user data unless --purge) @@ -1142,10 +1203,18 @@ Launcher commands: Provision runtimes + models (--speakers adds pyannote+torch, ~2GB) setup --refresh Re-provision runtimes for this launcher version, skipping models mcp Run the MCP server (stdio) for Claude/Codex - mcp install Register the MCP server with Claude Code + mcp install Register the MCP server with Claude Code and Codex config set update.auto off Disable auto-update (also: PODCLI_NO_UPDATE=1) config get update.auto +process --json --yes writes one JSON object to stdout and skips prompts. +--help and --version never download. First run of process, studio, transcribe, +or multicam provisions the engine. podcli mcp does not — stdout is JSON-RPC — +so run podcli setup first. + +Exit codes: 0 success, 1 runtime or processing error, 2 usage, 130 interrupted. +NO_COLOR=1 (or a non-TTY stdout) turns off colour and spinners. + Run a command with --help for its options. `, Version) } diff --git a/cli/main_test.go b/cli/main_test.go index d4aaf3df..3ba89c8c 100644 --- a/cli/main_test.go +++ b/cli/main_test.go @@ -6,6 +6,59 @@ import ( "testing" ) +func TestIsMetaFlag(t *testing.T) { + for _, tc := range []struct { + args []string + want bool + }{ + {[]string{"--help"}, true}, + {[]string{"-h"}, true}, + {[]string{"--version"}, true}, + {[]string{"-v"}, true}, + {[]string{"process", "--help"}, true}, + {[]string{"process", "episode.mp4", "-h"}, true}, + {[]string{"process", "--version"}, true}, + {[]string{"process", "episode.mp4"}, false}, + {[]string{"process", "--json", "episode.mp4"}, false}, + {[]string{"process", "--", "--help"}, false}, + {[]string{}, false}, + } { + if got := isMetaFlag(tc.args); got != tc.want { + t.Errorf("isMetaFlag(%q) = %v, want %v", tc.args, got, tc.want) + } + } +} + +func TestWantsRuntimeSkipsHelp(t *testing.T) { + if !wantsRuntime([]string{"process", "episode.mp4"}) { + t.Fatal("process should provision") + } + if wantsRuntime([]string{"process", "--help"}) { + t.Fatal("process --help must not provision") + } + if wantsRuntime([]string{"process", "episode.mp4", "-h"}) { + t.Fatal("process ... -h must not provision") + } + if wantsRuntime([]string{"--help"}) { + t.Fatal("top-level --help must not provision") + } + if wantsRuntime([]string{"config"}) { + t.Fatal("config should not provision") + } + if !wantsRuntime(nil) { + t.Fatal("bare podcli should provision") + } +} + +func TestHasFlag(t *testing.T) { + if !hasFlag([]string{"process", "--version"}, "--version", "-v") { + t.Fatal("expected --version") + } + if hasFlag([]string{"process", "--", "--version"}, "--version", "-v") { + t.Fatal("-- after -- is a positional") + } +} + func TestTranscribeModel(t *testing.T) { if got := transcribeModel([]string{"process", "episode.mp4"}); got != "base" { t.Fatalf("default model = %q, want base", got) diff --git a/install.sh b/install.sh index 707ba049..0099d4c8 100755 --- a/install.sh +++ b/install.sh @@ -64,7 +64,7 @@ case "$arch" in esac target="${goos}-${goarch}" if [ "$target" = "darwin-amd64" ]; then - err "Intel Macs aren't supported yet. Apple Silicon, Linux, and Windows are available." + err "Intel Macs aren't supported. Apple Silicon, Linux (x64 and arm64), and Windows (x64) are." fi mkdir -p "$bin_dir" diff --git a/llms.txt b/llms.txt new file mode 100644 index 00000000..5d1d7756 --- /dev/null +++ b/llms.txt @@ -0,0 +1,80 @@ +# podcli + +Open-source AI podcast clipper. Turn a long episode into short clips with face tracking and burned-in captions. Drive it from the CLI, a web studio, or your coding agent. + +Install: `curl -fsSL https://podcli.com/install.sh | sh` +Windows: `irm https://podcli.com/install.ps1 | iex` + +Version: 2.8.1 +License: AGPL-3.0. Commercial licensing: siradze@nikusha.me +Repo: https://github.com/nmbrthirteen/podcli +Site: https://podcli.com + +## Platforms + +macOS (Apple Silicon), Linux (x64 and arm64), Windows (x64). Intel Macs aren't supported. + +## Auth for AI scoring + +Clip scoring uses Claude Code or Codex login, or an Anthropic API key; without those it falls back to heuristics. Speaker diarization needs HF_TOKEN. The local engine is free; podcli Pro is an optional cloud editor and account (`podcli login`). + +## First run + +`podcli process episode.mp4` (or studio / transcribe / multicam) provisions the engine on first run. +`podcli mcp` does not — stdout is JSON-RPC. Run `podcli setup` once before pointing an agent at it. +`--help` and `--version` never download anything. + +## Agent CLI + +``` +podcli process episode.mp4 --json --yes +podcli doctor --json +``` + +JSON on stdout; progress and logs on stderr. `-y/--yes` skips prompts. `NO_COLOR=1` or a non-TTY turns off colour and spinners. + +Exit codes: 0 success, 1 runtime error, 2 usage, 130 interrupted. + +## MCP + +32 tools. `podcli mcp` on stdio; `podcli mcp install` registers with Claude Code and Codex. + +- ai_cli_status +- analyze_energy +- batch_create_clips +- clip_history +- compare_transcription_engines +- create_clip +- export_to_davinci_resolve +- get_ui_state +- import_transcript +- job_status +- knowledge_base +- list_outputs +- manage_assets +- manage_config +- manage_env +- manage_integrations +- manage_multicam +- manage_presets +- manage_reel +- manage_thumbnail_config +- mine_channel +- modify_clip +- parse_transcript +- process_episode +- rank_clip_candidates +- record_decisions +- set_video +- suggest_clips +- toggle_clip +- transcribe_podcast +- transcribe_start +- update_settings + +`rank_clip_candidates` returns a shortlist so you do not have to read the whole transcript. +`process_episode` runs the pipeline as one job; poll it with `job_status`. + +## Also + +Clips (9:16 / 16:9 / 1:1) and full-episode multicam edit. Knowledge base at .podcli/knowledge/. PodStack slash commands in .claude/commands/. diff --git a/scripts/check-docs-drift.mjs b/scripts/check-docs-drift.mjs index 96b5090e..db7f921d 100644 --- a/scripts/check-docs-drift.mjs +++ b/scripts/check-docs-drift.mjs @@ -17,6 +17,26 @@ const readme = read("README.md"); const errors = []; const warnings = []; +try { + const llms = read("llms.txt"); + const llmsCount = [...llms.matchAll(/(\d+) tools/g)].map((m) => Number(m[1])); + for (const claim of llmsCount) { + if (claim !== manifest.mcpToolCount) { + errors.push(`llms.txt mentions "${claim} tools" but src registers ${manifest.mcpToolCount}. Re-run: node scripts/gen-docs-manifest.mjs`); + } + } + const listed = [...llms.matchAll(/^- ([a-z][a-z0-9_]+)$/gm)].map((m) => m[1]).sort(); + if (Array.isArray(manifest.mcpToolNames) && listed.length) { + const registered = [...manifest.mcpToolNames].sort(); + const missing = registered.filter((t) => !listed.includes(t)); + const stale = listed.filter((t) => !registered.includes(t)); + if (missing.length) errors.push(`llms.txt is missing tools: ${missing.join(", ")}`); + if (stale.length) errors.push(`llms.txt lists unregistered tools: ${stale.join(", ")}`); + } +} catch { + errors.push("llms.txt not found. Run: node scripts/gen-docs-manifest.mjs"); +} + if (/single-page/i.test(readme) && manifest.studioRouteCount > 1) { errors.push(`README calls the studio "single-page" but it has ${manifest.studioRouteCount} routes.`); } diff --git a/scripts/gen-docs-manifest.mjs b/scripts/gen-docs-manifest.mjs index 9ff419cb..09603bf7 100644 --- a/scripts/gen-docs-manifest.mjs +++ b/scripts/gen-docs-manifest.mjs @@ -66,3 +66,57 @@ const manifest = { writeFileSync(join(root, "docs-manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); console.log("wrote docs-manifest.json:", JSON.stringify(manifest)); + +const toolList = mcpToolNames.map((n) => `- ${n}`).join("\n"); +const llms = `# podcli + +Open-source AI podcast clipper. Turn a long episode into short clips with face tracking and burned-in captions. Drive it from the CLI, a web studio, or your coding agent. + +Install: \`curl -fsSL https://podcli.com/install.sh | sh\` +Windows: \`irm https://podcli.com/install.ps1 | iex\` + +Version: ${manifest.packageVersion} +License: AGPL-3.0. Commercial licensing: siradze@nikusha.me +Repo: https://github.com/nmbrthirteen/podcli +Site: https://podcli.com + +## Platforms + +macOS (Apple Silicon), Linux (x64 and arm64), Windows (x64). Intel Macs aren't supported. + +## Auth for AI scoring + +Clip scoring uses Claude Code or Codex login, or an Anthropic API key; without those it falls back to heuristics. Speaker diarization needs HF_TOKEN. The local engine is free; podcli Pro is an optional cloud editor and account (\`podcli login\`). + +## First run + +\`podcli process episode.mp4\` (or studio / transcribe / multicam) provisions the engine on first run. +\`podcli mcp\` does not — stdout is JSON-RPC. Run \`podcli setup\` once before pointing an agent at it. +\`--help\` and \`--version\` never download anything. + +## Agent CLI + +\`\`\` +podcli process episode.mp4 --json --yes +podcli doctor --json +\`\`\` + +JSON on stdout; progress and logs on stderr. \`-y/--yes\` skips prompts. \`NO_COLOR=1\` or a non-TTY turns off colour and spinners. + +Exit codes: 0 success, 1 runtime error, 2 usage, 130 interrupted. + +## MCP + +${mcpToolNames.length} tools. \`podcli mcp\` on stdio; \`podcli mcp install\` registers with Claude Code and Codex. + +${toolList} + +\`rank_clip_candidates\` returns a shortlist so you do not have to read the whole transcript. +\`process_episode\` runs the pipeline as one job; poll it with \`job_status\`. + +## Also + +Clips (9:16 / 16:9 / 1:1) and full-episode multicam edit. Knowledge base at .podcli/knowledge/. PodStack slash commands in .claude/commands/. +`; +writeFileSync(join(root, "llms.txt"), llms); +console.log("wrote llms.txt"); diff --git a/server.json b/server.json new file mode 100644 index 00000000..dac14612 --- /dev/null +++ b/server.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", + "name": "io.github.nmbrthirteen/podcli", + "description": "Transcribe a podcast, shortlist clips, and render shorts with captions.", + "repository": { + "url": "https://github.com/nmbrthirteen/podcli", + "source": "github" + }, + "version": "2.8.1", + "websiteUrl": "https://podcli.com" +} diff --git a/src/handlers/suggest-clips.handler.ts b/src/handlers/suggest-clips.handler.ts index 8bb08e09..ba919ee6 100644 --- a/src/handlers/suggest-clips.handler.ts +++ b/src/handlers/suggest-clips.handler.ts @@ -7,12 +7,15 @@ import { randomUUID } from "crypto"; import { z } from "zod"; +import { PythonExecutor } from "../services/python-executor.js"; import { validateSuggestionContext, validateSuggestionRange, } from "../utils/clip-validation.js"; import { playbackDuration, validateHook } from "../utils/clip-hook.js"; +const rankExecutor = new PythonExecutor(); + /** Shared by suggest_clips, modify_clip, create_clip and batch_create_clips. */ export const hookSchema = z .object({ @@ -184,3 +187,26 @@ function formatTime(seconds: number): string { const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, "0")}`; } + +export const rankClipCandidatesToolDef = { + name: "rank_clip_candidates", + description: + "After transcribe_podcast, call this instead of reading the whole transcript. " + + "Returns ~20 pre-ranked, sentence-aligned candidates (start/end, duration, score, title, preview). " + + "Pick from the list, then submit winners with suggest_clips or render with create_clip.\n\n" + + "Uses the cached transcript for video_path. Does not call an AI CLI.", +}; + +export async function handleRankClipCandidates(input: { + video_path?: string; + top_n?: number; +}): Promise { + const result = await rankExecutor.execute<{ + count: number; + candidates: Array>; + }>("rank_candidates", { + video_path: input.video_path, + top_n: input.top_n ?? 20, + }); + return JSON.stringify(result.data ?? result); +} diff --git a/src/handlers/transcribe.handler.ts b/src/handlers/transcribe.handler.ts index f2ce804f..b9444b4f 100644 --- a/src/handlers/transcribe.handler.ts +++ b/src/handlers/transcribe.handler.ts @@ -1,10 +1,22 @@ import { basename } from "path"; +import { randomUUID } from "crypto"; import { PythonExecutor } from "../services/python-executor.js"; import { TranscriptCache, needsDiarizationRetry } from "../services/transcript-cache.js"; import { resolveTranscribeEngine, engineCanDiarize } from "../services/engine-resolve.js"; import { webServerUrl } from "../config/server.js"; import type { TranscriptResult } from "../models/index.js"; +type LocalJob = { + status: "pending" | "running" | "done" | "error"; + progress: number; + message: string; + result?: unknown; + error?: string; +}; + +const localJobs = new Map(); +const processExecutor = new PythonExecutor(); + const executor = new PythonExecutor(); const cache = new TranscriptCache(); @@ -32,8 +44,9 @@ export const transcribeToolDef = { "phrase-grouped markdown view (~10x smaller than raw segments).\n" + "Caching: Results are cached by file hash — same file won't be re-transcribed.\n" + "Supported formats: MP4, MOV, WebM, MKV, MP3, WAV.\n\n" + - "After transcription: call get_ui_state(include_transcript: true) to read the transcript, " + - "then analyze it for viral moments and call suggest_clips.", + "After transcription: call rank_clip_candidates(video_path) for a ~20-clip shortlist " + + "(do not read the whole transcript unless you need to rewrite a range). " + + "Or call process_episode(video_path) to transcribe, rank, and render in one job.", inputSchema: { type: "object" as const, properties: { @@ -294,12 +307,12 @@ export async function handleTranscribeStart(input: TranscribeInput): Promise= deadline) { + return JSON.stringify({ + status: job.status, + progress: job.progress, + message: job.message, + done: terminal, + result: job.result, + error: job.error, + next_step: + job.status === "done" + ? "Job complete. The result field lists clip paths, durations, scores, titles, and captions." + : job.status === "error" + ? "Job failed — check the error. Retry process_episode if appropriate." + : `Still running — call job_status("${input.job_id}", wait_seconds: 30) again.`, + }); + } + lastProgress = job.progress; + await new Promise((r) => setTimeout(r, 2000)); + } + } + try { while (true) { const res = await fetch(`${webServerUrl}/api/job/${input.job_id}`); @@ -370,3 +411,73 @@ export async function handleJobStatus(input: { throw err; } } + +export const processEpisodeToolDef = { + name: "process_episode", + description: + "One-shot episode job: transcribe, rank moments, render clips, write titles/descriptions/tags. " + + "Returns { job_id } immediately. Poll with job_status until done; the result is the same JSON " + + "`podcli process --json` prints (clip paths, durations, scores, titles, captions).\n\n" + + "Does not require the web studio. For a shortlist without rendering, use rank_clip_candidates instead.", +}; + +export async function handleProcessEpisode(input: { + video_path: string; + transcript?: string; + top?: number; + output?: string; + quality?: string; + caption_style?: string; + crop?: string; + format?: string; + language?: string; + engine?: string; + fast?: boolean; +}): Promise { + const jobId = randomUUID(); + localJobs.set(jobId, { + status: "running", + progress: 0, + message: "Starting episode process", + }); + void processExecutor + .execute("process_episode", { + video_path: input.video_path, + transcript: input.transcript, + top: input.top, + output: input.output, + quality: input.quality, + caption_style: input.caption_style, + crop: input.crop, + format: input.format, + language: input.language, + engine: input.engine, + fast: input.fast, + }, (event) => { + const job = localJobs.get(jobId); + if (!job || job.status !== "running") return; + job.progress = event.percent; + job.message = event.message; + }) + .then((result) => { + localJobs.set(jobId, { + status: "done", + progress: 100, + message: "Done", + result: result.data, + }); + }) + .catch((err: unknown) => { + localJobs.set(jobId, { + status: "error", + progress: 0, + message: "Failed", + error: err instanceof Error ? err.message : String(err), + }); + }); + return JSON.stringify({ + job_id: jobId, + status: "running", + next_step: `Poll job_status("${jobId}", wait_seconds: 30) until done. The result field lists clip paths.`, + }); +} diff --git a/src/models/index.ts b/src/models/index.ts index 8e82688e..933fdc2d 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -8,7 +8,7 @@ export type { ThumbnailLayout, ThumbnailPerson } from "../utils/thumbnail-layout export interface TaskRequest { task_id: string; - task_type: "transcribe" | "resolve_transcribe_engine" | "compare_engines" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status" | "ai_provider_status" | "analyze_silence" | "render_silence_removed" | "manage_multicam"; + task_type: "transcribe" | "resolve_transcribe_engine" | "compare_engines" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "rank_candidates" | "process_episode" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status" | "ai_provider_status" | "analyze_silence" | "render_silence_removed" | "manage_multicam"; params: Record; } diff --git a/src/podstack-commands.test.ts b/src/podstack-commands.test.ts index b93da5f9..bd61aa57 100644 --- a/src/podstack-commands.test.ts +++ b/src/podstack-commands.test.ts @@ -39,6 +39,11 @@ describe("PodStack command frontmatter matches registered MCP tools", () => { expect(toolNames.size).toBeGreaterThan(0); }); + it("registers rank_clip_candidates and process_episode", () => { + expect(toolNames.has("rank_clip_candidates")).toBe(true); + expect(toolNames.has("process_episode")).toBe(true); + }); + for (const file of commandFiles) { it(`${file}: every mcp__podcli__* in allowed-tools is a registered tool`, () => { const source = readFileSync(join(commandsDir, file), "utf-8"); diff --git a/src/server.ts b/src/server.ts index 713c6baf..f14347a3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,8 @@ import { handleTranscribeStart, jobStatusToolDef, handleJobStatus, + processEpisodeToolDef, + handleProcessEpisode, } from "./handlers/transcribe.handler.js"; import { compareEnginesToolDef, @@ -18,6 +20,8 @@ import { suggestClipsToolDef, suggestClipsInputShape, handleSuggestClips, + rankClipCandidatesToolDef, + handleRankClipCandidates, hookSchema, } from "./handlers/suggest-clips.handler.js"; import { @@ -491,7 +495,7 @@ export function createServer(): McpServer { ); // ============================================= - // Tool: job_status (generic poll — transcribe, render, batch) + // Tool: job_status (generic poll — transcribe, render, batch, process_episode) // ============================================= server.tool( jobStatusToolDef.name, @@ -510,6 +514,55 @@ export function createServer(): McpServer { }, ); + // ============================================= + // Tool: process_episode + // ============================================= + server.tool( + processEpisodeToolDef.name, + processEpisodeToolDef.description, + { + video_path: z.string().describe("Absolute path to the episode video or audio"), + transcript: z.string().optional().describe("Existing transcript path (.srt, .vtt, .txt, .json)"), + top: z.number().optional().describe("How many clips to export. Default 5."), + output: z.string().optional().describe("Output directory"), + quality: z.enum(["low", "medium", "high", "max"]).optional(), + caption_style: z.enum(["branded", "hormozi", "karaoke", "subtle"]).optional(), + crop: z.enum(["center", "face", "speaker", "speaker-hardcut"]).optional(), + format: z.enum(["vertical", "horizontal", "square"]).optional(), + language: z.string().optional(), + engine: z.enum(["whisper-py", "whispercpp", "assemblyai", "omnilingual"]).optional(), + fast: z.boolean().optional(), + }, + async (input) => { + try { + const text = await handleProcessEpisode(input); + return { content: [{ type: "text" as const, text }] }; + } catch (err: unknown) { + return mcpError(err); + } + }, + ); + + // ============================================= + // Tool: rank_clip_candidates + // ============================================= + server.tool( + rankClipCandidatesToolDef.name, + rankClipCandidatesToolDef.description, + { + video_path: z.string().describe("Episode video whose transcript is already cached"), + top_n: z.number().min(1).max(40).optional().describe("How many candidates to return. Default 20."), + }, + async (input) => { + try { + const text = await handleRankClipCandidates(input); + return { content: [{ type: "text" as const, text }] }; + } catch (err: unknown) { + return mcpError(err); + } + }, + ); + // ============================================= // Tool: suggest_clips // ============================================= @@ -2975,12 +3028,16 @@ export function createServer(): McpServer { "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 3: Shortlist clips without reading the whole transcript", + "Call rank_clip_candidates(video_path) for ~20 sentence-aligned, pre-ranked candidates.", + "Only call get_ui_state(include_transcript: true) if you need to rewrite a range.", + "", + "## Step 3b (one-shot): process_episode", + "process_episode(video_path) transcribes, ranks, and renders in one job. Poll job_status.", + "Use this when the user wants finished clips, not a review pass.", "", - "## Step 4: Analyze and suggest clips", - "Read through the transcript carefully. Look for:", + "## Step 4: Pick from the shortlist", + "From the candidates, look for:", "- Controversial or surprising statements", "- Strong emotional moments (laughter, passion, anger)", "- Clear actionable advice or insights", diff --git a/tests/test_cli.py b/tests/test_cli.py index 16e1a7e0..ddd25b1b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -59,6 +59,9 @@ def _process_args(video_path, transcript_path, output_dir): allow_ass_fallback=False, review_each=False, post_review=False, + json=False, + yes=False, + quiet=False, ) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 33503f8c..d7afd743 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -1,5 +1,7 @@ """Tests for backend.cli helpers: the shared WAV cache and Studio port resolution.""" +import argparse +import json import os import sys import tempfile @@ -139,3 +141,49 @@ def test_malformed_json_exits(self): def test_a_json_array_is_not_an_object(self): with self.assertRaises(SystemExit): cli_mod._json_object_arg("[1, 2]", "--style") + + +class AgentOutputHelpersTests(unittest.TestCase): + def test_no_color_disables_color(self): + with mock.patch.dict(os.environ, {"NO_COLOR": "1"}, clear=False): + self.assertFalse(cli_mod._use_color()) + + def test_parse_content_text(self): + raw = ( + "TITLES\n1. First title\n2. Second title\n" + "DESCRIPTION\nA short blurb about the clip.\n" + "TAGS\nalpha, beta\n" + "HASHTAGS\n#one #two\n" + ) + parsed = cli_mod._parse_content_text(raw) + self.assertEqual(parsed["titles"], ["First title", "Second title"]) + self.assertIn("blurb", parsed["description"]) + self.assertEqual(parsed["tags"], ["alpha", "beta"]) + self.assertEqual(parsed["hashtags"], ["#one", "#two"]) + + def test_want_spinner_off_when_quiet(self): + with mock.patch.dict(os.environ, {"PODCLI_QUIET": "1"}, clear=False): + self.assertFalse(cli_mod._want_spinner()) + + def test_non_interactive_yes_flag(self): + ns = argparse.Namespace(yes=True, json=False) + self.assertTrue(cli_mod._non_interactive(ns)) + + def test_emit_process_json_writes_one_object(self): + import io + + buf = io.StringIO() + cli_mod._emit_process_json( + json_out=buf, + video_path="/ep.mp4", + output_dir="/out", + quality="high", + clips=[{"title": "A", "start_second": 1, "end_second": 12, "score": 9}], + results=[{"output_path": "/out/a.mp4", "duration": 11, "status": "ok"}], + interrupted=False, + ) + payload = json.loads(buf.getvalue()) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["quality"], "high") + self.assertEqual(payload["clips"][0]["title"], "A") + self.assertEqual(payload["clips"][0]["path"], "/out/a.mp4") diff --git a/tests/test_media_probe.py b/tests/test_media_probe.py index 05492918..1adba987 100644 --- a/tests/test_media_probe.py +++ b/tests/test_media_probe.py @@ -145,5 +145,42 @@ def test_video_processor_still_exposes_cpu_flags_and_timeout(self): self.assertEqual(vp._FFMPEG_TIMEOUT, media_probe.FFMPEG_TIMEOUT) +class CpuFlagsAndTimeoutTests(unittest.TestCase): + def test_cpu_flags_follow_podcli_quality(self): + old = os.environ.get("PODCLI_QUALITY") + try: + os.environ["PODCLI_QUALITY"] = "max" + flags = media_probe.cpu_flags() + self.assertEqual(flags[flags.index("-crf") + 1], "14") + self.assertEqual(flags[flags.index("-preset") + 1], "slower") + os.environ["PODCLI_QUALITY"] = "high" + flags = media_probe.cpu_flags() + self.assertEqual(flags[flags.index("-crf") + 1], "18") + finally: + if old is None: + os.environ.pop("PODCLI_QUALITY", None) + else: + os.environ["PODCLI_QUALITY"] = old + + def test_ffmpeg_timeout_scales_with_duration(self): + self.assertEqual(media_probe.ffmpeg_timeout(), 300) + self.assertGreater(media_probe.ffmpeg_timeout(40), 300) + self.assertLessEqual(media_probe.ffmpeg_timeout(10_000), 1800) + + def test_stream_durations(self): + info = { + "streams": [ + {"codec_type": "video", "duration": "39.4"}, + {"codec_type": "audio", "duration": "32.8"}, + ] + } + with mock.patch.object(media_probe, "get_video_info", return_value=info): + self.assertEqual(media_probe.stream_durations("/fake.mp4"), (39.4, 32.8)) + + def test_align_av_skips_when_streams_agree(self): + with mock.patch.object(media_probe, "stream_durations", return_value=(33.0, 32.95)): + self.assertEqual(media_probe.align_av_duration("/fake.mp4"), "/fake.mp4") + + if __name__ == "__main__": unittest.main()