From d662c05cff537c4530ff660ab151bcbe4c35ce91 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=80=9Cayocodess=E2=80=9D?=
Date: Fri, 7 Aug 2026 03:33:32 +0200
Subject: [PATCH 01/12] feat: expand Studio podcast workflow
---
README.md | 5 +
backend/main.py | 48 ++
backend/services/caption_renderer.py | 9 +-
backend/services/captions_burn.py | 9 +-
backend/services/clip_generator.py | 15 +
backend/services/silence_removal.py | 479 ++++++++++++
remotion/render-full-episode.mjs | 217 ++++++
remotion/render.mjs | 3 +
remotion/src/CaptionedClip.tsx | 13 +-
remotion/src/Root.tsx | 23 +-
remotion/src/chunks.test.ts | 18 +-
remotion/src/chunks.ts | 9 +
remotion/src/components/BrandedCaptions.tsx | 37 +-
remotion/src/components/HormoziCaptions.tsx | 4 +-
remotion/src/components/KaraokeCaptions.tsx | 25 +-
remotion/src/components/SubtleCaptions.tsx | 18 +-
remotion/src/types.ts | 9 +
src/models/index.ts | 7 +-
src/ui/client/CopyButton.tsx | 33 +-
src/ui/client/EpisodeWorkspace.jsx | 793 ++++++++++++++++++--
src/ui/client/Layout.tsx | 10 +-
src/ui/client/lib.test.ts | 67 ++
src/ui/client/lib.ts | 131 ++++
src/ui/public/css/styles.css | 242 ++++++
src/ui/web-server.ts | 424 ++++++++++-
src/utils/full-episode-export.test.ts | 20 +
src/utils/full-episode-export.ts | 41 +
src/utils/http-range.test.ts | 22 +
src/utils/http-range.ts | 24 +
tests/test_silence_removal.py | 69 ++
30 files changed, 2709 insertions(+), 115 deletions(-)
create mode 100644 backend/services/silence_removal.py
create mode 100644 remotion/render-full-episode.mjs
create mode 100644 src/utils/full-episode-export.test.ts
create mode 100644 src/utils/full-episode-export.ts
create mode 100644 src/utils/http-range.test.ts
create mode 100644 src/utils/http-range.ts
create mode 100644 tests/test_silence_removal.py
diff --git a/README.md b/README.md
index 2e27d7f..48826d2 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,11 @@
+> [!NOTE]
+> **This is a maintained fork of [nmbrthirteen/podcli](https://github.com/nmbrthirteen/podcli).** It keeps Podcli's local processing and CLI while adding a simpler Studio workflow: full-episode YouTube preview and export, local silence removal, adjustable captions and logo placement, formatted transcript viewing and copying, and the `podclip` launcher. Upstream updates are merged regularly.
+
+Launch the local Studio with `podclip`.
+
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.
diff --git a/backend/main.py b/backend/main.py
index bb52b4e..11b8c1a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -169,6 +169,9 @@ def handle_create_clip(task_id: str, params: dict):
start_second=params["start_second"],
end_second=params["end_second"],
caption_style=params.get("caption_style", "hormozi"),
+ caption_position=params.get("caption_position", "auto"),
+ caption_font_scale=params.get("caption_font_scale", 100),
+ logo_position=params.get("logo_position", "top-left"),
crop_strategy=params.get("crop_strategy", "face"),
format=params.get("format", "vertical"),
crop_keyframes=params.get("crop_keyframes"),
@@ -223,6 +226,9 @@ def render_one(i: int, clip: dict) -> dict:
start_second=clip["start_second"],
end_second=clip["end_second"],
caption_style=clip.get("caption_style", "hormozi"),
+ caption_position=clip.get("caption_position", params.get("caption_position", "auto")),
+ caption_font_scale=clip.get("caption_font_scale", params.get("caption_font_scale", 100)),
+ logo_position=clip.get("logo_position", params.get("logo_position", "top-left")),
crop_strategy=clip.get("crop_strategy", "face"),
format=clip.get("format", params.get("format", "vertical")),
transcript_words=params.get("transcript_words", []),
@@ -876,6 +882,46 @@ def handle_manage_config(task_id: str, params: dict):
emit_result(task_id, "error", error=str(e))
+def handle_analyze_silence(task_id: str, params: dict):
+ """Analyze a full episode locally and return a conservative cut plan."""
+ from services.silence_removal import analyze_silence
+
+ try:
+ result = analyze_silence(
+ video_path=params.get("video_path", ""),
+ transcript_words=params.get("transcript_words") or [],
+ threshold=float(params.get("threshold", 0.5)),
+ min_silence_seconds=float(params.get("min_silence_seconds", 0.65)),
+ padding_seconds=float(params.get("padding_seconds", 0.12)),
+ progress_callback=lambda pct, msg: emit_progress(
+ task_id, "silence_analysis", pct, msg
+ ),
+ )
+ emit_result(task_id, "success", data=result)
+ except (FileNotFoundError, RuntimeError, ValueError) as e:
+ emit_result(task_id, "error", error=str(e))
+
+
+def handle_render_silence_removed(task_id: str, params: dict):
+ """Render the approved local cut plan and remap transcript timestamps."""
+ from config.paths import paths
+ from services.silence_removal import render_silence_removed
+
+ try:
+ result = render_silence_removed(
+ video_path=params.get("video_path", ""),
+ keep_segments=params.get("keep_segments") or [],
+ transcript=params.get("transcript") or {},
+ output_dir=params.get("output_dir") or paths["output"],
+ progress_callback=lambda pct, msg: emit_progress(
+ task_id, "silence_render", pct, msg
+ ),
+ )
+ emit_result(task_id, "success", data=result)
+ except (FileNotFoundError, RuntimeError, ValueError) as e:
+ emit_result(task_id, "error", error=str(e))
+
+
def handle_run_integration_tool(task_id: str, params: dict):
from services.integrations import IntegrationRegistry, IntegrationsManager
@@ -932,6 +978,8 @@ def handle_run_integration_tool(task_id: str, params: dict):
"manage_integrations": handle_manage_integrations,
"run_integration_tool": handle_run_integration_tool,
"manage_config": handle_manage_config,
+ "analyze_silence": handle_analyze_silence,
+ "render_silence_removed": handle_render_silence_removed,
}
diff --git a/backend/services/caption_renderer.py b/backend/services/caption_renderer.py
index 8731fe8..06291c5 100644
--- a/backend/services/caption_renderer.py
+++ b/backend/services/caption_renderer.py
@@ -93,6 +93,8 @@ def render_captions(
caption_style: str,
output_path: str,
time_offset: float = 0.0,
+ caption_position: str = "auto",
+ caption_font_scale: int = 100,
) -> str:
"""
Generate an ASS subtitle file from word-level timestamps.
@@ -113,7 +115,12 @@ def render_captions(
f.write(generate_ass_header(get_style(caption_style)))
return output_path
- style = get_style(caption_style)
+ style = dict(get_style(caption_style))
+ scale = max(60, min(160, int(caption_font_scale))) / 100
+ style["font_size"] = round(style["font_size"] * scale)
+ position_margins = {"upper": 760, "center": 480, "lower": 220}
+ if caption_position in position_margins:
+ style["margin_v"] = position_margins[caption_position]
if caption_style == "hormozi":
content = _render_hormozi(words, style, time_offset)
diff --git a/backend/services/captions_burn.py b/backend/services/captions_burn.py
index 424f844..79cfcbb 100644
--- a/backend/services/captions_burn.py
+++ b/backend/services/captions_burn.py
@@ -64,6 +64,7 @@ def burn_captions(
logo_height: int = 80,
logo_margin_x: int = 30,
logo_margin_y: int = 40,
+ logo_position: str = "top-left",
) -> str:
"""Burn ASS subtitles into the video.
@@ -99,9 +100,13 @@ def burn_captions(
logo_idx = input_idx
input_idx += 1
filter_parts.append(f"[{logo_idx}:v]scale=-1:{logo_height}[logo]")
- filter_parts.append(
- f"[{current_label}][logo]overlay={logo_margin_x}:{logo_margin_y}[withlogo]"
+ logo_x = (
+ str(logo_margin_x) if logo_position.endswith("-left")
+ else f"main_w-overlay_w-{logo_margin_x}" if logo_position.endswith("-right")
+ else "(main_w-overlay_w)/2"
)
+ logo_y = str(logo_margin_y) if logo_position.startswith("top-") else f"main_h-overlay_h-{logo_margin_y}"
+ filter_parts.append(f"[{current_label}][logo]overlay={logo_x}:{logo_y}[withlogo]")
current_label = "withlogo"
# Burn ASS subtitles
diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py
index 1f7c613..56d143c 100644
--- a/backend/services/clip_generator.py
+++ b/backend/services/clip_generator.py
@@ -465,6 +465,9 @@ def _render_with_remotion(
time_offset: float = 0.0,
logo_path: Optional[str] = None,
keep_caption_overlay: bool = False,
+ caption_position: str = "auto",
+ caption_font_scale: int = 100,
+ logo_position: str = "top-left",
) -> tuple[bool, Optional[str]]:
"""
Render captions using Remotion. Returns (success, optional_prores_overlay_path).
@@ -586,6 +589,9 @@ def _render_with_remotion(
"--words", os.path.abspath(words_file),
"--style", caption_style,
"--output", os.path.abspath(output_path),
+ "--caption-position", caption_position,
+ "--caption-font-scale", str(caption_font_scale),
+ "--logo-position", logo_position,
]
if logo_path and os.path.exists(logo_path):
cmd.extend(["--logo", os.path.abspath(logo_path)])
@@ -641,6 +647,9 @@ def generate_clip(
start_second: float,
end_second: float,
caption_style: str = "hormozi",
+ caption_position: str = "auto",
+ caption_font_scale: int = 100,
+ logo_position: str = "top-left",
crop_strategy: str = "face",
format: str = "vertical",
crop_keyframes: list[dict] = None,
@@ -908,6 +917,9 @@ def generate_clip(
time_offset=caption_time_offset,
logo_path=logo_path if (style_config.get("logo_support", False) and logo_path) else None,
keep_caption_overlay=keep_caption_overlay,
+ caption_position=caption_position,
+ caption_font_scale=caption_font_scale,
+ logo_position=logo_position,
)
if not remotion_ok and not allow_ass_fallback:
@@ -924,6 +936,8 @@ def generate_clip(
caption_style=caption_style,
output_path=ass_path,
time_offset=caption_time_offset,
+ caption_position=caption_position,
+ caption_font_scale=caption_font_scale,
)
use_gradient = style_config.get("gradient_overlay", False)
@@ -940,6 +954,7 @@ def generate_clip(
logo_height=style_config.get("logo_height", 80),
logo_margin_x=style_config.get("logo_margin_x", 30),
logo_margin_y=style_config.get("logo_margin_y", 40),
+ logo_position=logo_position,
)
else:
captioned_path = cropped_path
diff --git a/backend/services/silence_removal.py b/backend/services/silence_removal.py
new file mode 100644
index 0000000..a90af44
--- /dev/null
+++ b/backend/services/silence_removal.py
@@ -0,0 +1,479 @@
+"""Local full-episode silence analysis and rendering.
+
+Silero VAD (MIT, https://github.com/snakers4/silero-vad) finds speech without
+uploading media. Transcript word ranges are
+unioned with VAD output before cuts are planned, so known words are never cut.
+The derived video and remapped transcript keep every downstream Podcli feature
+on one compact timeline while the original source remains untouched.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+import tempfile
+import urllib.request
+import uuid
+import wave
+from pathlib import Path
+from typing import Callable, Iterable, Optional
+
+from config.paths import paths
+from services.audio_extract import extract_wav_16k_mono
+from services.media_probe import get_media_duration_seconds, has_audio_stream
+from utils.proc import run as proc_run
+
+try:
+ import numpy as np
+ import onnxruntime as ort
+ _VAD_RUNTIME_AVAILABLE = True
+except ImportError:
+ _VAD_RUNTIME_AVAILABLE = False
+
+
+ProgressCallback = Optional[Callable[[int, str], None]]
+
+SILERO_MODEL_URL = (
+ "https://raw.githubusercontent.com/snakers4/silero-vad/"
+ "76e3dc408eb2a5c655c34e230d2d5459b4439daa/"
+ "src/silero_vad/data/silero_vad_16k_op15.onnx"
+)
+SILERO_MODEL_SHA256 = "7ed98ddbad84ccac4cd0aeb3099049280713df825c610a8ed34543318f1b2c49"
+SILERO_MODEL_FILENAME = "silero_vad_16k_op15.onnx"
+SAMPLE_RATE = 16_000
+WINDOW_SAMPLES = 512
+CONTEXT_SAMPLES = 64
+
+
+def _emit(callback: ProgressCallback, percent: int, message: str) -> None:
+ if callback:
+ callback(max(0, min(100, int(percent))), message)
+
+
+def _model_path() -> Path:
+ return Path(paths["cache"]) / "models" / SILERO_MODEL_FILENAME
+
+
+def _sha256(file_path: Path) -> str:
+ digest = hashlib.sha256()
+ with file_path.open("rb") as source:
+ for block in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(block)
+ return digest.hexdigest()
+
+
+def ensure_silero_model(progress_callback: ProgressCallback = None) -> Path:
+ """Return verified model path, downloading the 1.3 MB model once if needed."""
+ model_path = _model_path()
+ if model_path.exists() and _sha256(model_path) == SILERO_MODEL_SHA256:
+ return model_path
+
+ model_path.parent.mkdir(parents=True, exist_ok=True)
+ temp_path = model_path.with_name(f".{model_path.name}.{uuid.uuid4().hex}.download")
+ _emit(progress_callback, 2, "Downloading local speech detector (first use only)")
+ try:
+ request = urllib.request.Request(
+ SILERO_MODEL_URL,
+ headers={"User-Agent": "podcli-silence-removal"},
+ )
+ with urllib.request.urlopen(request, timeout=60) as response, temp_path.open("wb") as target:
+ shutil.copyfileobj(response, target)
+ if _sha256(temp_path) != SILERO_MODEL_SHA256:
+ raise RuntimeError("Silero VAD model checksum mismatch")
+ os.replace(temp_path, model_path)
+ finally:
+ try:
+ temp_path.unlink()
+ except OSError:
+ pass
+ return model_path
+
+
+def _merge_segments(
+ segments: Iterable[dict],
+ duration: float,
+ merge_gap: float = 0.0,
+) -> list[dict]:
+ cleaned: list[dict] = []
+ for raw in segments:
+ try:
+ start = max(0.0, min(duration, float(raw["start"])))
+ end = max(0.0, min(duration, float(raw["end"])))
+ except (KeyError, TypeError, ValueError):
+ continue
+ if end - start < 0.02:
+ continue
+ cleaned.append({"start": start, "end": end})
+ cleaned.sort(key=lambda item: (item["start"], item["end"]))
+
+ merged: list[dict] = []
+ for segment in cleaned:
+ if merged and segment["start"] <= merged[-1]["end"] + merge_gap:
+ merged[-1]["end"] = max(merged[-1]["end"], segment["end"])
+ else:
+ merged.append(dict(segment))
+ return merged
+
+
+def probabilities_to_speech_segments(
+ probabilities: list[float],
+ audio_samples: int,
+ *,
+ threshold: float = 0.5,
+ min_speech_ms: int = 250,
+ min_silence_ms: int = 180,
+) -> list[dict]:
+ """Convert Silero probabilities into unpadded speech ranges."""
+ negative_threshold = max(0.01, threshold - 0.15)
+ min_speech_samples = SAMPLE_RATE * min_speech_ms / 1000
+ min_silence_samples = SAMPLE_RATE * min_silence_ms / 1000
+ triggered = False
+ temporary_end = 0
+ current_start = 0
+ speech: list[dict] = []
+
+ for index, probability in enumerate(probabilities):
+ current_sample = index * WINDOW_SAMPLES
+ if probability >= threshold:
+ if not triggered:
+ triggered = True
+ current_start = current_sample
+ temporary_end = 0
+ continue
+
+ if triggered and probability < negative_threshold:
+ if not temporary_end:
+ temporary_end = current_sample
+ if current_sample - temporary_end >= min_silence_samples:
+ if temporary_end - current_start >= min_speech_samples:
+ speech.append({
+ "start": current_start / SAMPLE_RATE,
+ "end": temporary_end / SAMPLE_RATE,
+ })
+ triggered = False
+ temporary_end = 0
+
+ if triggered and audio_samples - current_start >= min_speech_samples:
+ speech.append({
+ "start": current_start / SAMPLE_RATE,
+ "end": audio_samples / SAMPLE_RATE,
+ })
+ return speech
+
+
+def detect_speech(
+ video_path: str,
+ *,
+ threshold: float = 0.5,
+ progress_callback: ProgressCallback = None,
+) -> list[dict]:
+ if not _VAD_RUNTIME_AVAILABLE:
+ raise RuntimeError("Local silence detection requires numpy and onnxruntime")
+
+ model_path = ensure_silero_model(progress_callback)
+ _emit(progress_callback, 5, "Extracting episode audio")
+ wav_path = extract_wav_16k_mono(video_path)
+ try:
+ session = ort.InferenceSession(str(model_path), providers=["CPUExecutionProvider"])
+ state = np.zeros((2, 1, 128), dtype=np.float32)
+ context = np.zeros((1, CONTEXT_SAMPLES), dtype=np.float32)
+ probabilities: list[float] = []
+
+ with wave.open(wav_path, "rb") as audio:
+ if audio.getframerate() != SAMPLE_RATE or audio.getnchannels() != 1 or audio.getsampwidth() != 2:
+ raise RuntimeError("Extracted audio is not 16 kHz mono PCM")
+ total_samples = audio.getnframes()
+ processed = 0
+ last_percent = -1
+ while True:
+ frames = audio.readframes(WINDOW_SAMPLES)
+ if not frames:
+ break
+ chunk = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
+ actual_samples = chunk.size
+ if actual_samples < WINDOW_SAMPLES:
+ chunk = np.pad(chunk, (0, WINDOW_SAMPLES - actual_samples))
+ model_input = np.concatenate((context, chunk.reshape(1, -1)), axis=1)
+ output, state = session.run(None, {
+ "input": model_input,
+ "state": state,
+ "sr": np.array(SAMPLE_RATE, dtype=np.int64),
+ })
+ probabilities.append(float(output[0][0]))
+ context = model_input[:, -CONTEXT_SAMPLES:]
+ processed += actual_samples
+ percent = 10 + int((processed / max(1, total_samples)) * 55)
+ if percent >= last_percent + 3:
+ last_percent = percent
+ _emit(progress_callback, percent, "Finding spoken sections")
+
+ return probabilities_to_speech_segments(probabilities, total_samples, threshold=threshold)
+ finally:
+ try:
+ os.unlink(wav_path)
+ except OSError:
+ pass
+
+
+def plan_silence_removal(
+ duration: float,
+ vad_segments: list[dict],
+ transcript_words: list[dict],
+ *,
+ min_silence_seconds: float = 0.65,
+ padding_seconds: float = 0.12,
+) -> dict:
+ """Build conservative keep ranges from VAD plus transcript word timings."""
+ duration = max(0.0, float(duration))
+ min_silence_seconds = max(0.2, min(5.0, float(min_silence_seconds)))
+ padding_seconds = max(0.0, min(0.5, float(padding_seconds)))
+ if duration <= 0:
+ raise ValueError("Video duration must be positive")
+
+ protected: list[dict] = []
+ for segment in vad_segments:
+ protected.append({
+ "start": float(segment.get("start", 0)) - padding_seconds,
+ "end": float(segment.get("end", 0)) + padding_seconds,
+ })
+ for word in transcript_words:
+ try:
+ protected.append({
+ "start": float(word["start"]) - padding_seconds,
+ "end": float(word["end"]) + padding_seconds,
+ })
+ except (KeyError, TypeError, ValueError):
+ continue
+
+ keep_segments = _merge_segments(protected, duration, merge_gap=min_silence_seconds)
+ if not keep_segments:
+ keep_segments = [{"start": 0.0, "end": duration}]
+
+ removed_ranges: list[dict] = []
+ cursor = 0.0
+ for segment in keep_segments:
+ if segment["start"] - cursor >= min_silence_seconds:
+ removed_ranges.append({"start": cursor, "end": segment["start"]})
+ elif cursor < segment["start"]:
+ segment["start"] = cursor
+ cursor = segment["end"]
+ if duration - cursor >= min_silence_seconds:
+ removed_ranges.append({"start": cursor, "end": duration})
+ elif cursor < duration:
+ keep_segments[-1]["end"] = duration
+
+ # Rebuild exact keep ranges as the complement of accepted removals. This
+ # prevents short leading/interstitial gaps from disappearing accidentally.
+ keep_segments = []
+ cursor = 0.0
+ for removed in removed_ranges:
+ if removed["start"] > cursor:
+ keep_segments.append({"start": cursor, "end": removed["start"]})
+ cursor = removed["end"]
+ if cursor < duration:
+ keep_segments.append({"start": cursor, "end": duration})
+ if not keep_segments:
+ keep_segments = [{"start": 0.0, "end": duration}]
+
+ keep_segments = [
+ {"start": round(item["start"], 3), "end": round(item["end"], 3)}
+ for item in keep_segments if item["end"] - item["start"] >= 0.04
+ ]
+ removed_ranges = [
+ {"start": round(item["start"], 3), "end": round(item["end"], 3)}
+ for item in removed_ranges
+ ]
+ output_duration = sum(item["end"] - item["start"] for item in keep_segments)
+ removed_duration = max(0.0, duration - output_duration)
+ return {
+ "keep_segments": keep_segments,
+ "removed_ranges": removed_ranges,
+ "source_duration": round(duration, 3),
+ "output_duration": round(output_duration, 3),
+ "removed_duration": round(removed_duration, 3),
+ "removed_percent": round((removed_duration / duration) * 100, 1),
+ "cut_count": len(removed_ranges),
+ "min_silence_seconds": min_silence_seconds,
+ "padding_seconds": padding_seconds,
+ "method": "silero-vad+word-boundaries",
+ }
+
+
+def analyze_silence(
+ video_path: str,
+ transcript_words: list[dict],
+ *,
+ threshold: float = 0.5,
+ min_silence_seconds: float = 0.65,
+ padding_seconds: float = 0.12,
+ progress_callback: ProgressCallback = None,
+) -> dict:
+ duration = get_media_duration_seconds(video_path)
+ if duration <= 0:
+ raise RuntimeError("Could not determine episode duration")
+ speech = detect_speech(video_path, threshold=threshold, progress_callback=progress_callback)
+ _emit(progress_callback, 75, "Protecting word boundaries")
+ plan = plan_silence_removal(
+ duration,
+ speech,
+ transcript_words,
+ min_silence_seconds=min_silence_seconds,
+ padding_seconds=padding_seconds,
+ )
+ plan["vad_threshold"] = threshold
+ _emit(progress_callback, 100, "Silence analysis ready")
+ return plan
+
+
+def _map_range(start: float, end: float, keep_segments: list[dict]) -> Optional[tuple[float, float]]:
+ output_cursor = 0.0
+ mapped_parts: list[tuple[float, float]] = []
+ for segment in keep_segments:
+ overlap_start = max(start, segment["start"])
+ overlap_end = min(end, segment["end"])
+ if overlap_end > overlap_start:
+ mapped_parts.append((
+ output_cursor + overlap_start - segment["start"],
+ output_cursor + overlap_end - segment["start"],
+ ))
+ output_cursor += segment["end"] - segment["start"]
+ if not mapped_parts:
+ return None
+ return mapped_parts[0][0], mapped_parts[-1][1]
+
+
+def remap_timed_items(items: list[dict], keep_segments: list[dict]) -> list[dict]:
+ remapped: list[dict] = []
+ for item in items:
+ try:
+ start = float(item["start"])
+ end = float(item["end"])
+ except (KeyError, TypeError, ValueError):
+ continue
+ mapped = _map_range(start, end, keep_segments)
+ if not mapped or mapped[1] - mapped[0] < 0.01:
+ continue
+ remapped.append({**item, "start": round(mapped[0], 3), "end": round(mapped[1], 3)})
+ return remapped
+
+
+def remap_transcript(transcript: dict, keep_segments: list[dict]) -> dict:
+ remapped = dict(transcript or {})
+ remapped["words"] = remap_timed_items(list(remapped.get("words") or []), keep_segments)
+ remapped["segments"] = remap_timed_items(list(remapped.get("segments") or []), keep_segments)
+ remapped["duration"] = round(sum(s["end"] - s["start"] for s in keep_segments), 3)
+ remapped["silence_removed"] = True
+ return remapped
+
+
+def _reserve_output_path(video_path: str, output_dir: str) -> Path:
+ stem = Path(video_path).stem
+ for suffix in range(1, 10_000):
+ name = f"{stem}_silence_removed_podcli.mp4" if suffix == 1 else f"{stem}_silence_removed_podcli-{suffix}.mp4"
+ candidate = Path(output_dir) / name
+ if not candidate.exists():
+ return candidate
+ raise RuntimeError("Could not reserve silence-removed output filename")
+
+
+def _render_batch(
+ video_path: str,
+ output_path: str,
+ segments: list[dict],
+ audio: bool,
+) -> None:
+ filters: list[str] = []
+ concat_inputs: list[str] = []
+ for index, segment in enumerate(segments):
+ start = segment["start"]
+ end = segment["end"]
+ filters.append(f"[0:v:0]trim=start={start:.3f}:end={end:.3f},setpts=PTS-STARTPTS[v{index}]")
+ concat_inputs.append(f"[v{index}]")
+ if audio:
+ filters.append(f"[0:a:0]atrim=start={start:.3f}:end={end:.3f},asetpts=PTS-STARTPTS[a{index}]")
+ concat_inputs.append(f"[a{index}]")
+ filters.append(
+ "".join(concat_inputs)
+ + f"concat=n={len(segments)}:v=1:a={1 if audio else 0}[vout]"
+ + ("[aout]" if audio else "")
+ )
+ command = [
+ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", video_path,
+ "-filter_complex", ";".join(filters),
+ "-map", "[vout]",
+ ]
+ if audio:
+ command += ["-map", "[aout]"]
+ command += [
+ "-c:v", "libx264", "-crf", "18", "-preset", "fast", "-profile:v", "high",
+ "-pix_fmt", "yuv420p",
+ ]
+ if audio:
+ command += ["-c:a", "aac", "-b:a", "192k"]
+ command += ["-movflags", "+faststart", output_path]
+ proc_run(command, timeout=3600, check=True)
+
+
+def render_silence_removed(
+ video_path: str,
+ keep_segments: list[dict],
+ transcript: dict,
+ output_dir: str,
+ *,
+ progress_callback: ProgressCallback = None,
+) -> dict:
+ if not os.path.exists(video_path):
+ raise FileNotFoundError(f"Video not found: {video_path}")
+ duration = get_media_duration_seconds(video_path)
+ normalized = _merge_segments(keep_segments, duration)
+ if not normalized:
+ raise ValueError("No valid speech segments to render")
+
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
+ output_path = _reserve_output_path(video_path, output_dir)
+ work_dir = Path(tempfile.mkdtemp(prefix="podcli_silence_", dir=paths["working"] if os.path.isdir(paths["working"]) else None))
+ batch_size = 80
+ chunks: list[Path] = []
+ audio = has_audio_stream(video_path)
+ try:
+ batches = [normalized[index:index + batch_size] for index in range(0, len(normalized), batch_size)]
+ for index, batch in enumerate(batches):
+ _emit(progress_callback, 5 + int((index / len(batches)) * 85), f"Building compact episode {index + 1}/{len(batches)}")
+ chunk_path = work_dir / f"chunk-{index:04d}.mp4"
+ _render_batch(video_path, str(chunk_path), batch, audio)
+ chunks.append(chunk_path)
+
+ partial = work_dir / "finished.mp4"
+ if len(chunks) == 1:
+ shutil.copy2(chunks[0], partial)
+ else:
+ concat_path = work_dir / "chunks.txt"
+ concat_path.write_text("".join(f"file '{chunk.as_posix()}'\n" for chunk in chunks), encoding="utf-8")
+ proc_run([
+ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
+ "-f", "concat", "-safe", "0", "-i", str(concat_path),
+ "-c", "copy", "-movflags", "+faststart", str(partial),
+ ], timeout=1800, check=True)
+ os.replace(partial, output_path)
+ _emit(progress_callback, 96, "Remapping captions and clips")
+ remapped = remap_transcript(transcript, normalized)
+ manifest = output_path.with_suffix(".silence.json")
+ manifest.write_text(json.dumps({
+ "source_video": os.path.abspath(video_path),
+ "output_video": str(output_path),
+ "keep_segments": normalized,
+ }, ensure_ascii=False, indent=2), encoding="utf-8")
+ _emit(progress_callback, 100, "Compact episode ready")
+ stat = output_path.stat()
+ return {
+ "output_path": str(output_path),
+ "filename": output_path.name,
+ "file_size_mb": round(stat.st_size / (1024 * 1024), 2),
+ "duration": remapped["duration"],
+ "transcript": remapped,
+ "manifest_path": str(manifest),
+ }
+ finally:
+ shutil.rmtree(work_dir, ignore_errors=True)
diff --git a/remotion/render-full-episode.mjs b/remotion/render-full-episode.mjs
new file mode 100644
index 0000000..1a7df81
--- /dev/null
+++ b/remotion/render-full-episode.mjs
@@ -0,0 +1,217 @@
+#!/usr/bin/env node
+
+/**
+ * Burn Remotion captions into an arbitrarily long source video.
+ *
+ * A full-length transparent ProRes overlay can consume tens of gigabytes. This
+ * renderer instead creates one short overlay at a time, composites it, deletes
+ * it, then losslessly concatenates the compressed video chunks and remuxes the
+ * source audio.
+ */
+
+import { renderMedia, selectComposition } from "@remotion/renderer";
+import { getCachedBundle } from "./bundle-cache.mjs";
+import { spawnSync } from "node:child_process";
+import crypto from "node:crypto";
+import fs from "node:fs";
+import http from "node:http";
+import os from "node:os";
+import path from "node:path";
+
+const parseArgs = () => {
+ const out = {};
+ for (let i = 2; i < process.argv.length; i += 2) {
+ const key = process.argv[i]?.replace(/^--/, "");
+ const value = process.argv[i + 1];
+ if (key && value) out[key] = value;
+ }
+ return out;
+};
+
+const run = (command, args) => {
+ const result = spawnSync(command, args, {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ if (result.status !== 0) {
+ const detail = (result.stderr || result.stdout || "unknown error").trim().slice(-3000);
+ throw new Error(`${path.basename(command)} failed (${result.status}): ${detail}`);
+ }
+ return result.stdout.trim();
+};
+
+const progress = (percent, message) => {
+ process.stdout.write(`PODCLI_PROGRESS=${JSON.stringify({ percent, message })}\n`);
+};
+
+const quoteConcatPath = (filePath) => {
+ const normalized = filePath.replaceAll("\\", "/");
+ return `'${normalized.replaceAll("'", "'\\''")}'`;
+};
+
+const args = parseArgs();
+for (const required of ["video", "words", "output", "ffmpeg", "ffprobe"]) {
+ if (!args[required]) throw new Error(`Missing --${required}`);
+}
+
+const video = path.resolve(args.video);
+const wordsPath = path.resolve(args.words);
+const output = path.resolve(args.output);
+const partialOutput = `${output}.partial.mp4`;
+const logo = args.logo ? path.resolve(args.logo) : null;
+const styleName = args.style || "branded";
+const captionPosition = args["caption-position"] || "auto";
+const captionFontScale = Number(args["caption-font-scale"] || 100);
+const logoPosition = args["logo-position"] || "top-left";
+const fps = Number(args.fps || 30);
+const chunkSeconds = Number(args["chunk-seconds"] || 15);
+
+if (fs.existsSync(output)) throw new Error(`Refusing to overwrite existing output: ${output}`);
+if (!fs.existsSync(video)) throw new Error(`Video not found: ${video}`);
+if (!fs.existsSync(wordsPath)) throw new Error(`Words JSON not found: ${wordsPath}`);
+if (logo && !fs.existsSync(logo)) throw new Error(`Logo not found: ${logo}`);
+if (!(fps > 0) || !(chunkSeconds > 0)) throw new Error("fps and chunk-seconds must be positive");
+
+const wordsData = JSON.parse(fs.readFileSync(wordsPath, "utf8"));
+const words = Array.isArray(wordsData) ? wordsData : wordsData.words || [];
+const faceY = Array.isArray(wordsData) ? null : wordsData.faceY ?? null;
+const dimensions = run(args.ffprobe, [
+ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height",
+ "-of", "csv=s=x:p=0", video,
+]);
+const [width, height] = dimensions.split("x").map(Number);
+const duration = Number(run(args.ffprobe, [
+ "-v", "error", "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1", video,
+]));
+if (!(width > 0 && height > 0 && duration > 0)) throw new Error("Could not probe video");
+
+const durationInFrames = Math.ceil(duration * fps);
+const framesPerChunk = Math.max(1, Math.round(chunkSeconds * fps));
+const outputDir = path.dirname(output);
+fs.mkdirSync(outputDir, { recursive: true });
+const workDir = fs.mkdtempSync(path.join(outputDir, ".podcli-full-caption-work-"));
+let server;
+
+const cleanup = () => {
+ try { server?.close(); } catch {}
+ try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
+ try { fs.rmSync(partialOutput, { force: true }); } catch {}
+};
+process.on("SIGINT", () => { cleanup(); process.exit(130); });
+process.on("SIGTERM", () => { cleanup(); process.exit(143); });
+
+try {
+ server = http.createServer((request, response) => {
+ if (request.url !== "/logo.png" || !logo) {
+ response.writeHead(404);
+ response.end();
+ return;
+ }
+ const stat = fs.statSync(logo);
+ response.writeHead(200, {
+ "Content-Type": "image/png",
+ "Content-Length": stat.size,
+ "Access-Control-Allow-Origin": "*",
+ });
+ fs.createReadStream(logo).pipe(response);
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const logoSrc = logo ? `http://127.0.0.1:${server.address().port}/logo.png` : undefined;
+
+ progress(2, "Preparing caption renderer");
+ const bundle = await getCachedBundle({ onBundle: () => progress(3, "Preparing caption renderer") });
+ const inputProps = {
+ videoSrc: "",
+ words,
+ styleName,
+ logoSrc,
+ faceY,
+ durationInFrames,
+ fps,
+ captionPosition,
+ captionFontScale,
+ logoPosition,
+ singleLine: true,
+ };
+ const composition = await selectComposition({
+ serveUrl: bundle,
+ id: "CaptionedClip",
+ inputProps,
+ timeoutInMilliseconds: 120000,
+ });
+ const renderComposition = { ...composition, durationInFrames, fps, width, height };
+ const chunks = [];
+ const chunkCount = Math.ceil(durationInFrames / framesPerChunk);
+ const requestedConcurrency = Number.parseInt(process.env.PODCLI_REMOTION_CONCURRENCY || "", 10);
+ const concurrency = Number.isFinite(requestedConcurrency)
+ ? Math.max(1, Math.min(os.cpus().length, requestedConcurrency))
+ : Math.max(2, Math.min(os.cpus().length, 8));
+
+ for (let index = 0; index < chunkCount; index++) {
+ const startFrame = index * framesPerChunk;
+ const endFrame = Math.min(durationInFrames - 1, startFrame + framesPerChunk - 1);
+ const startSeconds = startFrame / fps;
+ const sectionDuration = (endFrame - startFrame + 1) / fps;
+ const id = String(index + 1).padStart(4, "0");
+ const overlay = path.join(workDir, `overlay-${id}.mov`);
+ const chunk = path.join(workDir, `video-${id}.mp4`);
+ let lastPercent = -1;
+
+ await renderMedia({
+ composition: renderComposition,
+ serveUrl: bundle,
+ codec: "prores",
+ proResProfile: "4444",
+ pixelFormat: "yuva444p10le",
+ imageFormat: "png",
+ outputLocation: overlay,
+ inputProps,
+ frameRange: [startFrame, endFrame],
+ concurrency,
+ timeoutInMilliseconds: 120000,
+ onProgress: ({ progress: chunkProgress }) => {
+ const percent = Math.floor(chunkProgress * 100);
+ if (percent >= lastPercent + 10) {
+ lastPercent = percent;
+ const overall = 5 + ((index + chunkProgress) / chunkCount) * 80;
+ progress(overall, `Rendering captions ${index + 1}/${chunkCount}`);
+ }
+ },
+ });
+
+ progress(5 + ((index + 1) / chunkCount) * 80, `Compositing section ${index + 1}/${chunkCount}`);
+ run(args.ffmpeg, [
+ "-y", "-hide_banner", "-loglevel", "error",
+ "-ss", startSeconds.toFixed(6), "-t", sectionDuration.toFixed(6), "-i", video,
+ "-i", overlay,
+ "-filter_complex", "[0:v][1:v]overlay=0:0:shortest=1,format=yuv420p[v]",
+ "-map", "[v]", "-an",
+ "-c:v", "libx264", "-crf", "18", "-preset", "fast",
+ "-r", String(fps), "-g", String(fps * 2),
+ "-movflags", "+faststart", chunk,
+ ]);
+ fs.rmSync(overlay, { force: true });
+ chunks.push(chunk);
+ }
+
+ progress(90, "Joining captioned sections");
+ const listPath = path.join(workDir, "chunks.txt");
+ fs.writeFileSync(listPath, chunks.map((chunk) => `file ${quoteConcatPath(chunk)}`).join("\n") + "\n");
+ const videoOnly = path.join(workDir, `video-only-${crypto.randomUUID()}.mp4`);
+ run(args.ffmpeg, [
+ "-y", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
+ "-i", listPath, "-c", "copy", "-movflags", "+faststart", videoOnly,
+ ]);
+
+ progress(96, "Adding original audio");
+ run(args.ffmpeg, [
+ "-y", "-hide_banner", "-loglevel", "error", "-i", videoOnly, "-i", video,
+ "-map", "0:v:0", "-map", "1:a:0?", "-c", "copy", "-shortest",
+ "-movflags", "+faststart", partialOutput,
+ ]);
+ fs.renameSync(partialOutput, output);
+ progress(100, "Full episode ready");
+} finally {
+ cleanup();
+}
diff --git a/remotion/render.mjs b/remotion/render.mjs
index ecbc9c8..ad5c268 100644
--- a/remotion/render.mjs
+++ b/remotion/render.mjs
@@ -151,6 +151,9 @@ async function main() {
faceY,
durationInFrames,
fps,
+ captionPosition: opts["caption-position"] || "auto",
+ captionFontScale: Number(opts["caption-font-scale"] || 100),
+ logoPosition: opts["logo-position"] || "top-left",
};
console.log(
diff --git a/remotion/src/CaptionedClip.tsx b/remotion/src/CaptionedClip.tsx
index 72050b4..d29e9e1 100644
--- a/remotion/src/CaptionedClip.tsx
+++ b/remotion/src/CaptionedClip.tsx
@@ -4,7 +4,7 @@ import { HormoziCaptions } from "./components/HormoziCaptions";
import { KaraokeCaptions } from "./components/KaraokeCaptions";
import { SubtleCaptions } from "./components/SubtleCaptions";
import { BrandedCaptions } from "./components/BrandedCaptions";
-import type { Word, CaptionStyle } from "./types";
+import type { Word, CaptionStyle, CaptionPosition, LogoPosition } from "./types";
export interface CaptionedClipProps {
videoSrc: string;
@@ -12,6 +12,9 @@ export interface CaptionedClipProps {
style: CaptionStyle;
logoSrc?: string;
faceY?: number | null;
+ captionPosition?: CaptionPosition;
+ logoPosition?: LogoPosition;
+ singleLine?: boolean;
}
export const CaptionedClip: React.FC = ({
@@ -19,6 +22,9 @@ export const CaptionedClip: React.FC = ({
style,
logoSrc,
faceY,
+ captionPosition = "auto",
+ logoPosition = "top-left",
+ singleLine = false,
}) => {
const CaptionComponent = {
hormozi: HormoziCaptions,
@@ -30,9 +36,10 @@ export const CaptionedClip: React.FC = ({
return (
{style.name === "branded" ? (
-
+
) : (
-
+
)}
);
diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx
index 0cc2512..ca1545e 100644
--- a/remotion/src/Root.tsx
+++ b/remotion/src/Root.tsx
@@ -4,6 +4,7 @@ import { CaptionedClip } from "./CaptionedClip";
import { Bookend } from "./Bookend";
import { STYLES } from "./types";
import type { Word } from "./types";
+import type { CaptionPosition, LogoPosition } from "./types";
import dmSans400 from "@fontsource/dm-sans/files/dm-sans-latin-400-normal.woff2";
import dmSans700 from "@fontsource/dm-sans/files/dm-sans-latin-700-normal.woff2";
@@ -33,6 +34,10 @@ const inputProps = getInputProps() as {
styleName?: string;
logoSrc?: string;
faceY?: number | null;
+ captionPosition?: CaptionPosition;
+ captionFontScale?: number;
+ logoPosition?: LogoPosition;
+ singleLine?: boolean;
durationInFrames?: number;
fps?: number;
bookendMode?: "intro" | "outro";
@@ -46,6 +51,19 @@ const inputProps = getInputProps() as {
export const RemotionRoot: React.FC = () => {
const fps = inputProps.fps || 30;
const durationInFrames = inputProps.durationInFrames || 900;
+ const baseStyle = STYLES[inputProps.styleName || "branded"];
+ const positionMargins: Partial> = {
+ upper: 760,
+ center: 480,
+ lower: 220,
+ };
+ const captionPosition = inputProps.captionPosition || "auto";
+ const fontScale = Math.max(0.6, Math.min(1.6, (inputProps.captionFontScale || 100) / 100));
+ const style = {
+ ...baseStyle,
+ fontSize: baseStyle.fontSize * fontScale,
+ marginBottom: positionMargins[captionPosition] ?? baseStyle.marginBottom,
+ };
return (
<>
@@ -59,9 +77,12 @@ export const RemotionRoot: React.FC = () => {
defaultProps={{
videoSrc: inputProps.videoSrc || "",
words: inputProps.words || [],
- style: STYLES[inputProps.styleName || "branded"],
+ style,
logoSrc: inputProps.logoSrc,
faceY: inputProps.faceY ?? null,
+ captionPosition,
+ logoPosition: inputProps.logoPosition || "top-left",
+ singleLine: inputProps.singleLine === true,
}}
/>
{
]);
});
});
+
+describe("splitCaptionLines", () => {
+ it("uses one line for YouTube full-episode captions", () => {
+ expect(splitCaptionLines(["one", "two", "three", "four"], 2, true)).toEqual([
+ ["one", "two", "three", "four"],
+ [],
+ ]);
+ });
+
+ it("preserves normal clip line splitting", () => {
+ expect(splitCaptionLines(["one", "two", "three", "four"], 2)).toEqual([
+ ["one", "two"],
+ ["three", "four"],
+ ]);
+ });
+});
diff --git a/remotion/src/chunks.ts b/remotion/src/chunks.ts
index 949a815..e1b1280 100644
--- a/remotion/src/chunks.ts
+++ b/remotion/src/chunks.ts
@@ -98,3 +98,12 @@ export function buildChunks(words: Word[], opts: ChunkOptions): Chunk[] {
export function activeChunkAt(chunks: Chunk[], time: number): Chunk | undefined {
return chunks.find((c) => time >= c.start && time < c.displayEnd);
}
+
+export function splitCaptionLines(
+ items: T[],
+ splitIndex: number,
+ singleLine = false,
+): [T[], T[]] {
+ if (singleLine || items.length <= splitIndex) return [items, []];
+ return [items.slice(0, splitIndex), items.slice(splitIndex)];
+}
diff --git a/remotion/src/components/BrandedCaptions.tsx b/remotion/src/components/BrandedCaptions.tsx
index 789e310..bb160ce 100644
--- a/remotion/src/components/BrandedCaptions.tsx
+++ b/remotion/src/components/BrandedCaptions.tsx
@@ -6,26 +6,22 @@ import {
Img,
staticFile,
} from "remotion";
-import type { Word, CaptionStyle } from "../types";
+import type { Word, CaptionStyle, CaptionPosition, LogoPosition } from "../types";
import { captionScale } from "../types";
-import { buildChunks, activeChunkAt } from "../chunks";
+import { buildChunks, activeChunkAt, splitCaptionLines } from "../chunks";
interface Props {
words: Word[];
style: CaptionStyle;
logoSrc?: string;
faceY?: number | null; // normalized 0-1 (0=top, 1=bottom)
+ captionPosition?: CaptionPosition;
+ logoPosition?: LogoPosition;
+ singleLine?: boolean;
}
const MAX_CHARS_PER_CHUNK = 18;
-function splitIntoLines(words: Word[]): [Word[], Word[]] {
- if (words.length <= 2) {
- return [words, []];
- }
- return [words.slice(0, 2), words.slice(2)];
-}
-
/**
* Active pill rendered as an absolutely positioned background behind the word.
* The word itself is always rendered as plain inline text so layout doesn't shift.
@@ -77,7 +73,8 @@ const CaptionLine: React.FC<{
frame: number;
fps: number;
style: CaptionStyle;
-}> = ({ words, currentTime, frame, fps, style }) => {
+ singleLine?: boolean;
+}> = ({ words, currentTime, frame, fps, style, singleLine = false }) => {
return (
{words.map((word, i) => {
@@ -117,6 +115,9 @@ export const BrandedCaptions: React.FC
= ({
style,
logoSrc,
faceY,
+ captionPosition = "auto",
+ logoPosition = "top-left",
+ singleLine = false,
}) => {
const frame = useCurrentFrame();
const { fps, height, durationInFrames } = useVideoConfig();
@@ -137,10 +138,10 @@ export const BrandedCaptions: React.FC = ({
// Default margin is style.marginBottom. If face center is below 0.55, reduce margin.
const baseMargin = style.marginBottom * s;
let dynamicMargin = baseMargin;
- if (faceY != null && faceY > 0.55) {
+ if (captionPosition === "auto" && faceY != null && faceY > 0.55) {
// Face is low — push captions to the very bottom
dynamicMargin = Math.max(80 * s, baseMargin - Math.round((faceY - 0.55) * height * 0.6));
- } else if (faceY != null && faceY < 0.35) {
+ } else if (captionPosition === "auto" && faceY != null && faceY < 0.35) {
// Face is high — can bring captions up a bit
dynamicMargin = baseMargin + 60 * s;
}
@@ -152,8 +153,12 @@ export const BrandedCaptions: React.FC = ({
src={logoSrc.startsWith("http") ? logoSrc : staticFile(logoSrc)}
style={{
position: "absolute",
- top: 180 * s,
- left: 108 * s,
+ ...(logoPosition.startsWith("top-") ? { top: 180 * s } : { bottom: 180 * s }),
+ ...(logoPosition.endsWith("-left")
+ ? { left: 108 * s }
+ : logoPosition.endsWith("-right")
+ ? { right: 108 * s }
+ : { left: "50%", transform: "translateX(-50%)" }),
width: 255 * s,
height: 126 * s,
objectFit: "contain",
@@ -162,7 +167,7 @@ export const BrandedCaptions: React.FC = ({
)}
{activeChunk && (() => {
- const [line1, line2] = splitIntoLines(activeChunk.words);
+ const [line1, line2] = splitCaptionLines(activeChunk.words, 2, singleLine);
return (
= ({
frame={frame}
fps={fps}
style={scaledStyle}
+ singleLine={singleLine}
/>
{line2.length > 0 && (
= ({
frame={frame}
fps={fps}
style={scaledStyle}
+ singleLine={singleLine}
/>
)}
diff --git a/remotion/src/components/HormoziCaptions.tsx b/remotion/src/components/HormoziCaptions.tsx
index a709056..54dc294 100644
--- a/remotion/src/components/HormoziCaptions.tsx
+++ b/remotion/src/components/HormoziCaptions.tsx
@@ -12,9 +12,10 @@ import { buildChunks, activeChunkAt } from "../chunks";
interface Props {
words: Word[];
style: CaptionStyle;
+ singleLine?: boolean;
}
-export const HormoziCaptions: React.FC = ({ words, style }) => {
+export const HormoziCaptions: React.FC = ({ words, style, singleLine = false }) => {
const frame = useCurrentFrame();
const { fps, height, durationInFrames } = useVideoConfig();
const s = captionScale(height);
@@ -65,6 +66,7 @@ export const HormoziCaptions: React.FC = ({ words, style }) => {
maxWidth: `calc(100% - ${120 * s}px)`,
boxSizing: "border-box",
overflowWrap: "anywhere",
+ whiteSpace: singleLine ? "nowrap" : undefined,
textAlign: "center",
fontFamily: style.fontFamily,
fontSize: style.fontSize * s,
diff --git a/remotion/src/components/KaraokeCaptions.tsx b/remotion/src/components/KaraokeCaptions.tsx
index 20bee27..2fb86d3 100644
--- a/remotion/src/components/KaraokeCaptions.tsx
+++ b/remotion/src/components/KaraokeCaptions.tsx
@@ -2,24 +2,20 @@ import React from "react";
import { useCurrentFrame, useVideoConfig } from "remotion";
import type { Word, CaptionStyle } from "../types";
import { captionScale } from "../types";
-import { buildChunks, activeChunkAt } from "../chunks";
+import { buildChunks, activeChunkAt, splitCaptionLines } from "../chunks";
interface Props {
words: Word[];
style: CaptionStyle;
-}
-
-function splitIntoLines(words: Word[]): [Word[], Word[]] {
- if (words.length <= 3) return [words, []];
- const mid = Math.ceil(words.length / 2);
- return [words.slice(0, mid), words.slice(mid)];
+ singleLine?: boolean;
}
const KaraokeLine: React.FC<{
words: Word[];
currentTime: number;
style: CaptionStyle;
-}> = ({ words, currentTime, style }) => {
+ singleLine?: boolean;
+}> = ({ words, currentTime, style, singleLine = false }) => {
return (
{words.map((word, i) => {
@@ -64,7 +61,7 @@ const KaraokeLine: React.FC<{
);
};
-export const KaraokeCaptions: React.FC
= ({ words, style }) => {
+export const KaraokeCaptions: React.FC = ({ words, style, singleLine = false }) => {
const frame = useCurrentFrame();
const { fps, height, durationInFrames } = useVideoConfig();
const s = captionScale(height);
@@ -79,7 +76,11 @@ export const KaraokeCaptions: React.FC = ({ words, style }) => {
if (!activeChunk) return null;
- const [line1, line2] = splitIntoLines(activeChunk.words);
+ const [line1, line2] = splitCaptionLines(
+ activeChunk.words,
+ Math.ceil(activeChunk.words.length / 2),
+ singleLine,
+ );
const scaledStyle = { ...style, fontSize: style.fontSize * s };
return (
@@ -95,9 +96,9 @@ export const KaraokeCaptions: React.FC = ({ words, style }) => {
gap: 4 * s,
}}
>
-
+
{line2.length > 0 && (
-
+
)}
);
diff --git a/remotion/src/components/SubtleCaptions.tsx b/remotion/src/components/SubtleCaptions.tsx
index 3f985dd..e7cc1fa 100644
--- a/remotion/src/components/SubtleCaptions.tsx
+++ b/remotion/src/components/SubtleCaptions.tsx
@@ -2,20 +2,15 @@ import React from "react";
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
import type { Word, CaptionStyle } from "../types";
import { captionScale } from "../types";
-import { buildChunks, activeChunkAt } from "../chunks";
+import { buildChunks, activeChunkAt, splitCaptionLines } from "../chunks";
interface Props {
words: Word[];
style: CaptionStyle;
+ singleLine?: boolean;
}
-function splitIntoLines(words: Word[]): [Word[], Word[]] {
- if (words.length <= 4) return [words, []];
- const mid = Math.ceil(words.length / 2);
- return [words.slice(0, mid), words.slice(mid)];
-}
-
-export const SubtleCaptions: React.FC = ({ words, style }) => {
+export const SubtleCaptions: React.FC = ({ words, style, singleLine = false }) => {
const frame = useCurrentFrame();
const { fps, height, durationInFrames } = useVideoConfig();
const s = captionScale(height);
@@ -46,7 +41,11 @@ export const SubtleCaptions: React.FC = ({ words, style }) => {
{ extrapolateRight: "clamp" }
);
- const [line1, line2] = splitIntoLines(activeChunk.words);
+ const [line1, line2] = splitCaptionLines(
+ activeChunk.words,
+ Math.ceil(activeChunk.words.length / 2),
+ singleLine,
+ );
const text1 = line1.map((w) => w.word).join(" ");
const text2 = line2.map((w) => w.word).join(" ");
@@ -75,6 +74,7 @@ export const SubtleCaptions: React.FC = ({ words, style }) => {
"0 1px 3px rgba(0,0,0,0.95), 0 0 20px rgba(0,0,0,0.6), 0 0 50px rgba(0,0,0,0.3)",
textAlign: "center",
lineHeight: 1.35,
+ whiteSpace: singleLine ? "nowrap" : undefined,
}}
>
{text1}
diff --git a/remotion/src/types.ts b/remotion/src/types.ts
index b7a2cbb..629e380 100644
--- a/remotion/src/types.ts
+++ b/remotion/src/types.ts
@@ -17,6 +17,15 @@ export interface CaptionStyle {
marginBottom: number;
}
+export type CaptionPosition = "auto" | "upper" | "center" | "lower";
+export type LogoPosition =
+ | "top-left"
+ | "top-center"
+ | "top-right"
+ | "bottom-left"
+ | "bottom-center"
+ | "bottom-right";
+
export interface CaptionProps {
words: Word[];
style: CaptionStyle;
diff --git a/src/models/index.ts b/src/models/index.ts
index 64054e3..df10618 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -2,7 +2,7 @@
export interface TaskRequest {
task_id: string;
- task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status";
+ task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status" | "analyze_silence" | "render_silence_removed";
params: Record;
}
@@ -114,6 +114,8 @@ export interface UIState {
activeExportJobId?: string | null;
transcript?: TranscriptResult | null;
rawTranscriptText?: string;
+ silenceOriginal?: { videoPath: string; transcript: TranscriptResult } | null;
+ silencePlan?: Record | null;
suggestions?: SuggestedClip[];
deselectedIndices?: number[];
settings?: {
@@ -124,6 +126,9 @@ export interface UIState {
outroPath?: string;
introPath?: string;
cleanFillers?: boolean;
+ silenceThreshold?: number;
+ silenceMinPause?: number;
+ silencePadding?: number;
};
phase?: string;
lastUpdated?: number;
diff --git a/src/ui/client/CopyButton.tsx b/src/ui/client/CopyButton.tsx
index 4bb7e12..a6ce5a9 100644
--- a/src/ui/client/CopyButton.tsx
+++ b/src/ui/client/CopyButton.tsx
@@ -1,6 +1,37 @@
import React, { useEffect, useRef, useState } from "react";
import { Copy, Check } from "lucide-react";
+async function copyText(value: string): Promise {
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(value);
+ return;
+ }
+ } catch {
+ // WebKit and embedded browsers can deny Clipboard API despite localhost.
+ }
+
+ const field = document.createElement("textarea");
+ field.value = value;
+ field.setAttribute("readonly", "");
+ field.style.position = "fixed";
+ field.style.opacity = "0";
+ field.style.pointerEvents = "none";
+ const activeElement = document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ document.body.appendChild(field);
+ let copied = false;
+ try {
+ field.select();
+ copied = document.execCommand("copy");
+ } finally {
+ field.remove();
+ activeElement?.focus({ preventScroll: true });
+ }
+ if (!copied) throw new Error("Clipboard unavailable");
+}
+
type CopyButtonProps = {
text?: string;
getText?: () => string;
@@ -45,7 +76,7 @@ export default function CopyButton({
if (!value) return;
try {
- await navigator.clipboard.writeText(value);
+ await copyText(value);
setCopied(true);
onCopied?.();
diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx
index c4494de..07b112c 100644
--- a/src/ui/client/EpisodeWorkspace.jsx
+++ b/src/ui/client/EpisodeWorkspace.jsx
@@ -23,17 +23,27 @@ import {
ChevronRight,
ChevronDown,
ArrowRight,
+ Volume2,
+ Settings as SettingsGlyph,
+ Maximize,
+ Captions,
+ ThumbsUp,
+ Share2,
+ Bell,
+ Scissors,
} from 'lucide-react';
import CopyButton from './CopyButton';
import AssetPicker from './AssetPicker';
+import { assetSrc, useAssets } from './useAssets';
import RecentSources from './RecentSources';
import MomentTrim from './MomentTrim';
import { useDialog } from './useDialog';
import { PageHeader } from './Page';
import { buildPreviewChunks, activePreviewChunk, selectPreviewWords } from './captionChunks';
-import { findClipResult, resultBoundsKey, clipKey, buildEnergyMap, dropEnergy, clampClipIndex } from './lib';
+import { findClipResult, resultBoundsKey, clipKey, buildEnergyMap, dropEnergy, clampClipIndex, resolveAssetName, formatTranscriptText } from './lib';
const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
+const fmtSaved = (s) => s < 10 ? `${Number(s || 0).toFixed(1)}s` : fmt(s);
const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim());
const onKeyActivate = (fn) => (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); fn(e); }
@@ -98,6 +108,45 @@ const onKeyActivate = (fn) => (e) => {
const PREVIEW_SCALE = 0.27;
const px = (n) => Math.round(n * PREVIEW_SCALE);
const PROD_TO_PCT = (margin) => ((margin / 1920) * 100).toFixed(1) + '%';
+ const CAPTION_BOTTOMS = { upper: PROD_TO_PCT(760), center: PROD_TO_PCT(480), lower: PROD_TO_PCT(220) };
+ const captionBottom = (cfg, position) => CAPTION_BOTTOMS[position] || cfg.bottom;
+ const logoPlacement = (position) => {
+ const vertical = position.startsWith('bottom-') ? { bottom: '7%', top: 'auto' } : { top: '7%', bottom: 'auto' };
+ const horizontal = position.endsWith('-right')
+ ? { right: '3%', left: 'auto', transform: 'none' }
+ : position.endsWith('-center')
+ ? { left: '50%', right: 'auto', transform: 'translateX(-50%)' }
+ : { left: '3%', right: 'auto', transform: 'none' };
+ return { ...vertical, ...horizontal };
+ };
+
+ const LOGO_POSITIONS = [
+ ['top-left', 'Top left'],
+ ['top-center', 'Top center'],
+ ['top-right', 'Top right'],
+ ['bottom-left', 'Bottom left'],
+ ['bottom-center', 'Bottom center'],
+ ['bottom-right', 'Bottom right'],
+ ];
+
+ function LogoPositionPicker({ value, onChange, disabled }) {
+ const activeLabel = LOGO_POSITIONS.find(([position]) => position === value)?.[1] || 'Top left';
+ return (
+
+
+ {LOGO_POSITIONS.map(([position, label]) => (
+ onChange(position)}>
+
+
+ ))}
+
+
{activeLabel}
+
+ );
+ }
const STYLE_CONFIGS = {
branded: {
@@ -207,7 +256,34 @@ const onKeyActivate = (fn) => (e) => {
);
}
- function PhoneCaptionBody({ chunk, activeWordInChunk, cfg }) {
+ function YouTubeWireframe({ title, playing, progress, currentTime, duration, onTogglePlay }) {
+ return (
+
+
{title || 'Full episode preview'}
+ {!playing && (
+
+
+
+ )}
+
+
+
+
+ {playing ? Ⅱ : }
+
+
+
{fmt(currentTime)} / {duration ? fmt(duration) : '0:00'}
+
+
+
+
+
+
+
+ );
+ }
+
+ function PhoneCaptionBody({ chunk, activeWordInChunk, cfg, singleLine = false }) {
if (!chunk || !chunk.length) return null;
const fmt = (w) => (cfg.uppercase ? w.toUpperCase() : w);
@@ -240,7 +316,7 @@ const onKeyActivate = (fn) => (e) => {
};
// Branded: split chunk into [first 2 words, rest], render as 2 lines.
- if (cfg.splitLines) {
+ if (cfg.splitLines && !singleLine) {
const [line1, line2] = splitBrandedLines(chunk);
const startIdx2 = line1.length;
return (
@@ -287,14 +363,11 @@ const onKeyActivate = (fn) => (e) => {
}}>{inner}
);
}
- return {inner}
;
+ return {inner}
;
}
- function LivePhonePreview({ videoUrl, videoRef, captionStyle, activeClip, transcriptWords, logoPath, showTikTokFrame, onToggleFrame, clipEnded, onReplay }) {
+ function useLiveCaptionPreview({ videoUrl, videoRef, captionStyle, activeClip, transcriptWords }) {
const cfg = STYLE_CONFIGS[captionStyle] || STYLE_CONFIGS.branded;
- const [logoBroken, setLogoBroken] = useState(false);
- useEffect(() => { setLogoBroken(false); }, [logoPath]);
-
const sourcePool = useMemo(() => {
const words = selectPreviewWords(transcriptWords, activeClip);
return words.length >= 2 ? words : null;
@@ -356,6 +429,16 @@ const onKeyActivate = (fn) => (e) => {
}
}
+ return { cfg, usingSample, activeChunk, activeWordInChunk };
+ }
+
+ function LivePhonePreview({ videoUrl, videoRef, captionStyle, captionPosition, captionFontScale, logoPosition, activeClip, transcriptWords, logoPreviewUrl, showTikTokFrame, onToggleFrame, clipEnded, onReplay }) {
+ const { cfg, usingSample, activeChunk, activeWordInChunk } = useLiveCaptionPreview({
+ videoUrl, videoRef, captionStyle, activeClip, transcriptWords,
+ });
+ const [logoBroken, setLogoBroken] = useState(false);
+ useEffect(() => { setLogoBroken(false); }, [logoPreviewUrl]);
+
return (
<>
@@ -382,9 +465,9 @@ const onKeyActivate = (fn) => (e) => {
)}
- {videoUrl && captionStyle === 'branded' && logoPath && !logoBroken && (
-
-
+
setLogoBroken(true)}
style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
@@ -392,8 +475,8 @@ const onKeyActivate = (fn) => (e) => {
)}
{videoUrl && (
@@ -427,20 +510,110 @@ const onKeyActivate = (fn) => (e) => {
);
}
+ function LiveYouTubePreview({ videoUrl, videoRef, captionStyle, captionPosition, captionFontScale, logoPosition, transcriptWords, logoPreviewUrl, rendered, title, showYouTubeFrame, onToggleFrame, onBack }) {
+ const { cfg, usingSample, activeChunk, activeWordInChunk } = useLiveCaptionPreview({
+ videoUrl, videoRef, captionStyle, activeClip: null, transcriptWords,
+ });
+ const [logoBroken, setLogoBroken] = useState(false);
+ const [playing, setPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ useEffect(() => { setLogoBroken(false); }, [logoPreviewUrl]);
+ useEffect(() => { setPlaying(false); setCurrentTime(0); setDuration(0); }, [videoUrl]);
+
+ const togglePlay = () => {
+ const video = videoRef?.current;
+ if (!video) return;
+ if (video.paused) video.play().catch(() => {});
+ else video.pause();
+ };
+ const progress = duration > 0 ? Math.max(0, Math.min(100, (currentTime / duration) * 100)) : 0;
+
+ return (
+
+
+ {videoUrl ? (
+
setPlaying(true)} onPause={() => setPlaying(false)}
+ onTimeUpdate={e => setCurrentTime(e.currentTarget.currentTime || 0)}
+ onLoadedMetadata={e => setDuration(e.currentTarget.duration || 0)} />
+ ) : (
+ Select a video to preview
+ )}
+ {videoUrl && !rendered && cfg.gradient &&
}
+ {videoUrl && !rendered && captionStyle === 'branded' && logoPreviewUrl && !logoBroken && (
+
+
setLogoBroken(true)} />
+
+ )}
+ {videoUrl && !rendered && (
+
+
w.text) : null}
+ activeWordInChunk={activeWordInChunk}
+ cfg={cfg}
+ singleLine
+ />
+ {usingSample && Transcribe to preview captions
}
+
+ )}
+ {videoUrl && showYouTubeFrame && (
+
+ )}
+
+ {showYouTubeFrame && (
+
+
{title || 'Full episode preview'}
+
+
P
+
Your channel Full episode
+
Subscribe
+
+
Like
+
Share
+
+
+ )}
+
+
+ YouTube full episode
+ {rendered ? 'Rendered captions' : 'Live caption preview · 16:9'}
+
+
Back to clip preview
+
+
+
+
+ YouTube wireframe
+
+
+
+ );
+ }
+
/* ── Spec Recap ── re-renders form state as a reviewable card. */
- function SpecRecap({ captionStyle, cropStrategy, logoPath, outroPath, activePreset, quality, cleanFillers }) {
+ function SpecRecap({ captionStyle, captionPosition, captionFontScale, logoPosition, cropStrategy, logoPath, outroPath, activePreset, quality, cleanFillers }) {
const cfg = STYLE_CONFIGS[captionStyle] || STYLE_CONFIGS.branded;
// Sample "color" comes from the active-word style of the caption preset.
const swatch = (cfg.activeStyle && (cfg.activeStyle.color || cfg.activeStyle.background)) || '#ffffff';
const rows = [
['Caption style', captionStyle],
['Crop', cropStrategy],
- ['Font size', `${cfg.fontSize}px`],
+ ['Caption size', `${captionFontScale}%`],
+ ['Caption position', captionPosition],
['Highlight',
{swatch}],
['Quality', quality || 'standard'],
['Clean fillers', cleanFillers ? 'on' : 'off'],
];
- if (logoPath) rows.push(['Logo', logoPath.split('/').pop()]);
+ if (logoPath) rows.push(['Logo', `${logoPath.split('/').pop()} · ${logoPosition}`]);
if (outroPath) rows.push(['Outro', outroPath.split('/').pop()]);
if (activePreset) rows.push(['Preset', activePreset]);
return (
@@ -524,6 +697,7 @@ const onKeyActivate = (fn) => (e) => {
}
export default function App() {
+ const { assets } = useAssets();
const [videoPath, setVideoPath] = useState('');
const [transcriptMode, setTranscriptMode] = useState('whisper');
const [transcriptText, setTranscriptText] = useState('');
@@ -532,13 +706,21 @@ const onKeyActivate = (fn) => (e) => {
const [assemblyAiKey, setAssemblyAiKey] = useState('');
const [whisperModel, setWhisperModel] = useState('base');
const [captionStyle, setCaptionStyle] = useState('branded');
+ const [captionPosition, setCaptionPosition] = useState('auto');
+ const [captionFontScale, setCaptionFontScale] = useState(100);
+ const [logoPosition, setLogoPosition] = useState('top-left');
const [cropStrategy, setCropStrategy] = useState('face');
const [format, setFormat] = useState('vertical');
const [showTikTokFrame, setShowTikTokFrame] = useState(false);
const [logoPath, setLogoPath] = useState('');
const [outroPath, setOutroPath] = useState('');
const [introPath, setIntroPath] = useState('');
- const initializedRef = useRef(false);
+ const logoPreviewUrl = useMemo(() => {
+ const name = resolveAssetName(assets, logoPath, 'logo');
+ return name ? assetSrc(name) : '';
+ }, [assets, logoPath]);
+ const [stateHydrated, setStateHydrated] = useState(false);
+ const hydrationTargetRef = useRef(null);
const videoFileRef = useRef();
const [transcriptDragOver, setTranscriptDragOver] = useState(false);
const [transcriptFileName, setTranscriptFileName] = useState('');
@@ -546,11 +728,27 @@ const onKeyActivate = (fn) => (e) => {
const [phase, setPhase] = useState('idle');
const [file, setFile] = useState(null);
const [transcript, setTranscript] = useState(null);
+ const [transcriptOpen, setTranscriptOpen] = useState(true);
+ const [transcriptFormat, setTranscriptFormat] = useState('readable');
+ const formattedTranscript = useMemo(
+ () => formatTranscriptText(transcript, transcriptFormat),
+ [transcript, transcriptFormat],
+ );
const [suggestions, setSuggestions] = useState([]);
const [deselected, setDeselected] = useState(new Set());
const [batchJobId, setBatchJobId] = useState(null);
const batchStream = useJob(batchJobId);
const [results, setResults] = useState([]);
+ const [fullEpisodeJobId, setFullEpisodeJobId] = useState(null);
+ const fullEpisodeStream = useJob(fullEpisodeJobId);
+ const [fullEpisodeResult, setFullEpisodeResult] = useState(null);
+ const [silenceOriginal, setSilenceOriginal] = useState(null);
+ const [silencePlan, setSilencePlan] = useState(null);
+ const [silenceAnalyzeJobId, setSilenceAnalyzeJobId] = useState(null);
+ const silenceAnalyzeStream = useJob(silenceAnalyzeJobId);
+ const [silenceRenderJobId, setSilenceRenderJobId] = useState(null);
+ const silenceRenderStream = useJob(silenceRenderJobId);
+ const pendingSilenceOriginalRef = useRef(null);
const [error, setError] = useState(null);
const [previewFile, setPreviewFile] = useState(null);
const [momentText, setMomentText] = useState('');
@@ -590,6 +788,10 @@ const onKeyActivate = (fn) => (e) => {
const [minDuration, setMinDuration] = useState(20);
const [maxDuration, setMaxDuration] = useState(45);
const [energyBoost, setEnergyBoost] = useState(true);
+ const [showYouTubeFrame, setShowYouTubeFrame] = useState(true);
+ const [silenceThreshold, setSilenceThreshold] = useState(0.5);
+ const [silenceMinPause, setSilenceMinPause] = useState(0.65);
+ const [silencePadding, setSilencePadding] = useState(0.12);
// Clip editing
const [editingClip, setEditingClip] = useState(null); // index
@@ -640,6 +842,9 @@ const onKeyActivate = (fn) => (e) => {
const response = await api('/presets', { method: 'POST', body: JSON.stringify({ action: 'get', name }) });
const d = response.config || response;
if (d.caption_style) setCaptionStyle(d.caption_style);
+ if (d.caption_position) setCaptionPosition(d.caption_position);
+ if (d.caption_font_scale) setCaptionFontScale(Number(d.caption_font_scale));
+ if (d.logo_position) setLogoPosition(d.logo_position);
if (d.crop_strategy) setCropStrategy(d.crop_strategy);
if (d.format) setFormat(d.format);
if (d.logo_path !== undefined) setLogoPath(d.logo_path || '');
@@ -651,6 +856,8 @@ const onKeyActivate = (fn) => (e) => {
setVideoPath(nextVideoPath);
setFile(null);
if (changedVideo) {
+ setSilenceOriginal(null);
+ setSilencePlan(null);
setTranscript(null);
setCachedTranscript(false);
setTranscriptText('');
@@ -684,7 +891,7 @@ const onKeyActivate = (fn) => (e) => {
try {
await api('/presets', { method: 'POST', body: JSON.stringify({
action: 'save', name: presetName.trim(),
- config: { caption_style: captionStyle, crop_strategy: cropStrategy, format, logo_path: logoPath, outro_path: outroPath, intro_path: introPath, video_path: videoPath.trim(), whisper_model: whisperModel, transcription_engine: transcriptionEngine, time_adjust: timeAdjust, clean_fillers: cleanFillers, quality, top_clips: topClips, min_clip_duration: minDuration, max_clip_duration: maxDuration, energy_boost: energyBoost }
+ config: { caption_style: captionStyle, caption_position: captionPosition, caption_font_scale: captionFontScale, logo_position: logoPosition, crop_strategy: cropStrategy, format, logo_path: logoPath, outro_path: outroPath, intro_path: introPath, video_path: videoPath.trim(), whisper_model: whisperModel, transcription_engine: transcriptionEngine, time_adjust: timeAdjust, clean_fillers: cleanFillers, quality, top_clips: topClips, min_clip_duration: minDuration, max_clip_duration: maxDuration, energy_boost: energyBoost }
})});
setActivePreset(presetName.trim());
setPresetName(''); setShowPresetSave(false);
@@ -831,43 +1038,49 @@ const onKeyActivate = (fn) => (e) => {
// Guard: don't sync until initial SSE state has been received to avoid overwriting persisted state with defaults
const prevSyncRef = useRef('');
useEffect(() => {
- if (!initializedRef.current) return;
- const state = {
- _source: 'ui',
+ if (!stateHydrated) return;
+ const syncable = {
videoPath,
- filePath: file?.file_path || '',
+ silenceOriginal,
+ silencePlan,
suggestions,
deselectedIndices: Array.from(deselected),
- settings: { captionStyle, cropStrategy, format, logoPath, outroPath, introPath, cleanFillers },
+ settings: { captionStyle, captionPosition, captionFontScale, logoPosition, cropStrategy, format, logoPath, outroPath, introPath, cleanFillers, silenceThreshold, silenceMinPause, silencePadding },
phase,
results,
energyData,
};
+ const signature = JSON.stringify(syncable);
+ // React may expose the readiness flag before every restored field has
+ // committed. Never let that intermediate render erase server state.
+ if (hydrationTargetRef.current && signature !== hydrationTargetRef.current) return;
+ hydrationTargetRef.current = null;
+ const state = { _source: 'ui', filePath: file?.file_path || '', ...syncable };
const key = JSON.stringify(state);
if (key === prevSyncRef.current) return;
prevSyncRef.current = key;
fetch('/api/ui-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: key }).catch(() => { });
- }, [videoPath, file, suggestions, deselected, captionStyle, cropStrategy, format, logoPath, outroPath, introPath, cleanFillers, phase, results, energyData]);
+ }, [stateHydrated, videoPath, file, silenceOriginal, silencePlan, suggestions, deselected, captionStyle, captionPosition, captionFontScale, logoPosition, cropStrategy, format, logoPath, outroPath, introPath, cleanFillers, silenceThreshold, silenceMinPause, silencePadding, phase, results, energyData]);
// Sync transcript separately (large payload)
const prevTranscriptRef = useRef(null);
useEffect(() => {
- if (!initializedRef.current) return;
+ if (!stateHydrated) return;
if (!transcript || transcript === prevTranscriptRef.current) return;
prevTranscriptRef.current = transcript;
fetch('/api/ui-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ _source: 'ui', transcript }) }).catch(() => { });
- }, [transcript]);
+ }, [stateHydrated, transcript]);
// Sync raw transcript text so MCP can read it before pipeline runs
const prevRawRef = useRef('');
useEffect(() => {
- if (!initializedRef.current) return;
+ if (!stateHydrated) return;
if (transcriptText === prevRawRef.current) return;
prevRawRef.current = transcriptText;
if (transcriptText.trim()) {
fetch('/api/ui-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ _source: 'ui', rawTranscriptText: transcriptText }) }).catch(() => { });
}
- }, [transcriptText]);
+ }, [stateHydrated, transcriptText]);
const resultFor = useCallback(
(clip, resultIdx) => findClipResult(results, clip, resultIdx),
@@ -893,6 +1106,33 @@ const onKeyActivate = (fn) => (e) => {
if (sseEvent.type === 'state-sync' || sseEvent.type === 'state') {
const d = sseEvent.data;
+ if (sseEvent.type === 'state') {
+ hydrationTargetRef.current = JSON.stringify({
+ videoPath: d.videoPath || '',
+ silenceOriginal: d.silenceOriginal || null,
+ silencePlan: d.silencePlan || null,
+ suggestions: d.suggestions || [],
+ deselectedIndices: d.deselectedIndices || [],
+ settings: {
+ captionStyle: d.settings?.captionStyle || 'branded',
+ captionPosition: d.settings?.captionPosition || 'auto',
+ captionFontScale: d.settings?.captionFontScale || 100,
+ logoPosition: d.settings?.logoPosition || 'top-left',
+ cropStrategy: d.settings?.cropStrategy || 'speaker',
+ format: d.settings?.format || 'vertical',
+ logoPath: d.settings?.logoPath || '',
+ outroPath: d.settings?.outroPath || '',
+ introPath: d.settings?.introPath || '',
+ cleanFillers: d.settings?.cleanFillers !== false,
+ silenceThreshold: d.settings?.silenceThreshold || 0.5,
+ silenceMinPause: d.settings?.silenceMinPause || 0.65,
+ silencePadding: d.settings?.silencePadding || 0.12,
+ },
+ phase: d.phase || 'idle',
+ results: Array.isArray(d.results) ? d.results : [],
+ energyData: d.energyData || {},
+ });
+ }
if (d.suggestions) setSuggestions(d.suggestions);
if (d.energyData !== undefined) setEnergyData(d.energyData || {});
if (d.deselectedIndices !== undefined) setDeselected(new Set(d.deselectedIndices));
@@ -901,6 +1141,8 @@ const onKeyActivate = (fn) => (e) => {
if (sseEvent.type === 'state' && Array.isArray(d.results)) setResults(d.results);
if (d.activeExportJobId !== undefined) setBatchJobId(d.activeExportJobId);
if (d.videoPath !== undefined) setVideoPath(d.videoPath);
+ if (d.silenceOriginal !== undefined) setSilenceOriginal(d.silenceOriginal);
+ if (d.silencePlan !== undefined) setSilencePlan(d.silencePlan);
if (d.transcript !== undefined) {
setTranscript(d.transcript);
if (d.videoPath) autoTranscribeRef.current = d.videoPath;
@@ -909,16 +1151,23 @@ const onKeyActivate = (fn) => (e) => {
if (d.rawTranscriptText !== undefined && (d.transcript === null || !transcript)) setTranscriptText(d.rawTranscriptText);
if (d.settings) {
if (d.settings.captionStyle) setCaptionStyle(d.settings.captionStyle);
+ if (d.settings.captionPosition) setCaptionPosition(d.settings.captionPosition);
+ if (d.settings.captionFontScale) setCaptionFontScale(Number(d.settings.captionFontScale));
+ if (d.settings.logoPosition) setLogoPosition(d.settings.logoPosition);
if (d.settings.cropStrategy) setCropStrategy(d.settings.cropStrategy);
if (d.settings.format) setFormat(d.settings.format);
if (d.settings.logoPath !== undefined) setLogoPath(d.settings.logoPath);
if (d.settings.outroPath !== undefined) setOutroPath(d.settings.outroPath);
if (d.settings.introPath !== undefined) setIntroPath(d.settings.introPath);
if (d.settings.cleanFillers !== undefined) setCleanFillers(d.settings.cleanFillers !== false);
+ if (d.settings.silenceThreshold !== undefined) setSilenceThreshold(Number(d.settings.silenceThreshold));
+ if (d.settings.silenceMinPause !== undefined) setSilenceMinPause(Number(d.settings.silenceMinPause));
+ if (d.settings.silencePadding !== undefined) setSilencePadding(Number(d.settings.silencePadding));
}
- // Mark initialized after first state restoration so sync useEffects don't overwrite with defaults
+ // Flip readiness in the same React batch as the restored fields. The
+ // first sync therefore contains restored values, never mount defaults.
if (sseEvent.type === 'state') {
- initializedRef.current = true;
+ setStateHydrated(true);
}
} else if (sseEvent.type === 'export-started') {
setBatchJobId(sseEvent.data.jobId);
@@ -946,8 +1195,10 @@ const onKeyActivate = (fn) => (e) => {
const videoRef = useRef();
const [activeClipIdx, setActiveClipIdx] = useState(null);
const [previewSrc, setPreviewSrc] = useState(null); // null=source, string=rendered clip filename
+ const [previewMode, setPreviewMode] = useState('clips'); // clips | youtube
const [settingsFlash, setSettingsFlash] = useState(null);
const [clipEnded, setClipEnded] = useState(false);
+ const previewSessionRef = useRef(Date.now().toString(36));
const activeClip = activeClipIdx !== null ? suggestions[activeClipIdx] : null;
@@ -961,8 +1212,12 @@ const onKeyActivate = (fn) => (e) => {
const videoUrl = previewSrc
? `/api/preview/${previewSrc}`
: videoPath && !isHttpUrl(videoPath)
- ? `/api/stream-source?path=${encodeURIComponent(videoPath)}`
+ ? `/api/stream-source?path=${encodeURIComponent(videoPath)}&preview=${previewSessionRef.current}`
: null;
+ const youtubePreviewTitle = (videoPath.split(/[\\/]/).pop() || 'Full episode')
+ .replace(/\.[^.]+$/, '')
+ .replace(/[_-]+/g, ' ')
+ .trim();
// Seek to clip when active clip changes (and showing source), pause at
// its end boundary so the preview doesn't run into the rest of the episode
@@ -1010,23 +1265,56 @@ const onKeyActivate = (fn) => (e) => {
// Click clip row → seek source video
const onClipClick = (idx) => {
+ setPreviewMode('clips');
setActiveClipIdx(idx);
if (previewSrc) setPreviewSrc(null);
};
// Play rendered clip in preview panel
const onPlayRendered = (filename) => {
+ setPreviewMode('clips');
+ setPreviewSrc(filename);
+ setActiveClipIdx(null);
+ };
+
+ const onPreviewFullEpisode = (filename = null) => {
+ setPreviewMode('youtube');
setPreviewSrc(filename);
setActiveClipIdx(null);
};
+ const clearEpisode = () => {
+ fetch('/api/ui-state', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ _source: 'ui', _allowClear: true, videoPath: '', filePath: '',
+ transcript: null, rawTranscriptText: '', suggestions: [],
+ silenceOriginal: null, silencePlan: null,
+ deselectedIndices: [], phase: 'idle', results: [], energyData: {},
+ }),
+ }).catch(() => {});
+ setVideoPath(''); setFile(null); setTranscript(null); setTranscriptText('');
+ setSuggestions([]); setDeselected(new Set()); setPhase('idle'); setResults([]);
+ setSilenceOriginal(null); setSilencePlan(null);
+ setEnergyData({}); setPreviewSrc(null); setPreviewMode('clips');
+ autoTranscribeRef.current = '';
+ };
+
const setUploadedVideo = useCallback(async (file) => {
if (!file) return;
setBrowsing(true); setError(null);
try {
const d = await uploadFile(file, () => { });
if (d.error) setError(d.error);
- if (d.file_path) setVideoPath(d.file_path);
+ if (d.file_path) {
+ setVideoPath(d.file_path);
+ setFile({ file_path: d.file_path });
+ setTranscript(null); setCachedTranscript(false); setTranscriptText('');
+ setSilenceOriginal(null); setSilencePlan(null);
+ resetClipWorkForSource();
+ autoTranscribeRef.current = '';
+ }
} catch (e) { setError('Upload failed: ' + e.message); }
finally { setBrowsing(false); }
}, []);
@@ -1066,6 +1354,8 @@ const onKeyActivate = (fn) => (e) => {
}
setFile(d);
setVideoPath(d.file_path);
+ setSilenceOriginal(null);
+ setSilencePlan(null);
setTranscript(null);
setCachedTranscript(false);
setTranscriptText('');
@@ -1112,6 +1402,9 @@ const onKeyActivate = (fn) => (e) => {
end_second: c.end_second,
title: c.title,
caption_style: captionStyle,
+ caption_position: captionPosition,
+ caption_font_scale: captionFontScale,
+ logo_position: logoPosition,
crop_strategy: cropStrategy,
format,
...(Array.isArray(c.segments) && c.segments.length > 0 && { keep_segments: c.segments }),
@@ -1120,17 +1413,145 @@ const onKeyActivate = (fn) => (e) => {
const startExport = async () => {
setPhase('exporting'); setResults([]);
const sc = suggestions.filter((_, i) => !deselected.has(i));
- const vp = file?.file_path || videoPath.trim();
+ const vp = videoPath.trim() || file?.file_path;
const data = await api('/batch-clips', {
method: 'POST', body: JSON.stringify({
video_path: vp,
clips: sc.map(clipExportPayload),
transcript_words: transcript?.words || [], logo_path: logoPath || undefined, outro_path: outroPath || undefined, intro_path: introPath || undefined, clean_fillers: cleanFillers || undefined,
+ caption_position: captionPosition, caption_font_scale: captionFontScale, logo_position: logoPosition,
})
});
setBatchJobId(data.job_id);
};
+ const startFullEpisodeExport = async () => {
+ setError(null);
+ setFullEpisodeResult(null);
+ const vp = videoPath.trim() || file?.file_path;
+ const data = await api('/export-full-episode', {
+ method: 'POST', body: JSON.stringify({
+ video_path: vp,
+ transcript_words: transcript?.words || [],
+ caption_style: captionStyle,
+ caption_position: captionPosition,
+ caption_font_scale: captionFontScale,
+ logo_position: logoPosition,
+ logo_path: captionStyle === 'branded' ? logoPath || undefined : undefined,
+ })
+ });
+ if (data.error) {
+ setError(data.error);
+ return;
+ }
+ setFullEpisodeJobId(data.job_id);
+ };
+
+ useEffect(() => {
+ if (fullEpisodeStream?.status === 'done') {
+ setFullEpisodeResult(fullEpisodeStream.result || null);
+ setFullEpisodeJobId(null);
+ }
+ if (fullEpisodeStream?.status === 'error') {
+ setError('Full episode export failed: ' + (fullEpisodeStream.error || 'Unknown error'));
+ setFullEpisodeJobId(null);
+ }
+ }, [fullEpisodeStream?.status]);
+
+ const analyzeSilence = async () => {
+ const vp = videoPath.trim() || file?.file_path;
+ if (!vp || !transcript?.words?.length || silenceOriginal) return;
+ setError(null);
+ setSilencePlan(null);
+ const data = await api('/analyze-silence', {
+ method: 'POST',
+ body: JSON.stringify({
+ video_path: vp,
+ transcript_words: transcript.words,
+ threshold: silenceThreshold,
+ min_silence_seconds: silenceMinPause,
+ padding_seconds: silencePadding,
+ }),
+ });
+ if (data.error) { setError(data.error); return; }
+ setSilenceAnalyzeJobId(data.job_id);
+ };
+
+ const applySilenceRemoval = async () => {
+ const vp = videoPath.trim() || file?.file_path;
+ if (!vp || !transcript || !silencePlan?.keep_segments?.length || silenceOriginal) return;
+ setError(null);
+ pendingSilenceOriginalRef.current = { videoPath: vp, transcript };
+ const data = await api('/render-silence-removed', {
+ method: 'POST',
+ body: JSON.stringify({
+ video_path: vp,
+ keep_segments: silencePlan.keep_segments,
+ transcript,
+ }),
+ });
+ if (data.error) {
+ pendingSilenceOriginalRef.current = null;
+ setError(data.error);
+ return;
+ }
+ setSilenceRenderJobId(data.job_id);
+ };
+
+ const resetClipWorkForSource = () => {
+ setSuggestions([]);
+ setDeselected(new Set());
+ setResults([]);
+ setEnergyData({});
+ setPreviewSrc(null);
+ setActiveClipIdx(null);
+ setFullEpisodeResult(null);
+ setPhase('idle');
+ };
+
+ const restoreSilenceOriginal = () => {
+ if (!silenceOriginal) return;
+ setVideoPath(silenceOriginal.videoPath);
+ setFile({ file_path: silenceOriginal.videoPath });
+ setTranscript(silenceOriginal.transcript);
+ setSilenceOriginal(null);
+ setSilencePlan(null);
+ resetClipWorkForSource();
+ autoTranscribeRef.current = silenceOriginal.videoPath;
+ };
+
+ useEffect(() => {
+ if (silenceAnalyzeStream?.status === 'done') {
+ setSilencePlan(silenceAnalyzeStream.result || null);
+ setSilenceAnalyzeJobId(null);
+ } else if (silenceAnalyzeStream?.status === 'error') {
+ setError('Silence analysis failed: ' + (silenceAnalyzeStream.error || 'Unknown error'));
+ setSilenceAnalyzeJobId(null);
+ }
+ }, [silenceAnalyzeStream?.status]);
+
+ useEffect(() => {
+ if (silenceRenderStream?.status === 'done') {
+ const rendered = silenceRenderStream.result;
+ const original = pendingSilenceOriginalRef.current;
+ if (rendered?.output_path && rendered?.transcript && original) {
+ setSilenceOriginal(original);
+ setVideoPath(rendered.output_path);
+ setFile({ file_path: rendered.output_path });
+ setTranscript(rendered.transcript);
+ setSilencePlan(prev => ({ ...(prev || {}), applied: true, output_path: rendered.output_path }));
+ resetClipWorkForSource();
+ autoTranscribeRef.current = rendered.output_path;
+ }
+ pendingSilenceOriginalRef.current = null;
+ setSilenceRenderJobId(null);
+ } else if (silenceRenderStream?.status === 'error') {
+ setError('Silence removal failed: ' + (silenceRenderStream.error || 'Unknown error'));
+ pendingSilenceOriginalRef.current = null;
+ setSilenceRenderJobId(null);
+ }
+ }, [silenceRenderStream?.status]);
+
useEffect(() => {
if (!batchStream) return;
const rows = Array.isArray(batchStream.clip_results)
@@ -1152,7 +1573,7 @@ const onKeyActivate = (fn) => (e) => {
const data = await api('/create-clip', {
method: 'POST', body: JSON.stringify({
video_path: vp, start_second: c.start_second, end_second: c.end_second,
- title: c.title, caption_style: captionStyle, crop_strategy: cropStrategy, format,
+ title: c.title, caption_style: captionStyle, caption_position: captionPosition, caption_font_scale: captionFontScale, logo_position: logoPosition, crop_strategy: cropStrategy, format,
transcript_words: transcript?.words || [], logo_path: logoPath || undefined, outro_path: outroPath || undefined, intro_path: introPath || undefined, clean_fillers: cleanFillers || undefined,
...(Array.isArray(c.segments) && c.segments.length > 0 && { keep_segments: c.segments }),
})
@@ -1289,7 +1710,7 @@ const onKeyActivate = (fn) => (e) => {
_source: 'ui',
videoPath: videoPath.trim(),
rawTranscriptText: transcriptText.trim() || undefined,
- settings: { captionStyle, cropStrategy, format, logoPath, outroPath, introPath },
+ settings: { captionStyle, captionPosition, captionFontScale, logoPosition, cropStrategy, format, logoPath, outroPath, introPath },
}),
}).catch(() => { });
}
@@ -1355,7 +1776,9 @@ const onKeyActivate = (fn) => (e) => {
}
};
- const isProcessing = phase === 'parsing' || phase === 'suggesting' || phase === 'exporting' || transcribing || downloadingVideo;
+ const fullEpisodeBusy = fullEpisodeStream?.status === 'running' || !!fullEpisodeJobId;
+ const silenceBusy = !!silenceAnalyzeJobId || !!silenceRenderJobId;
+ const isProcessing = phase === 'parsing' || phase === 'suggesting' || phase === 'exporting' || transcribing || downloadingVideo || fullEpisodeBusy || silenceBusy;
const sourceIsUrl = isHttpUrl(videoPath);
const exportStats = phase === 'done' ? {
total: results.length || selectedClips.length,
@@ -1457,7 +1880,7 @@ const onKeyActivate = (fn) => (e) => {
{videoPath.split(/[\\/]/).pop()}
-
setVideoPath('')} style={{ padding: '4px 10px', fontSize: 11 }}>Clear
+
Clear
)}
@@ -1573,18 +1996,53 @@ const onKeyActivate = (fn) => (e) => {
{/* Transcript ready indicator */}
{transcript && transcriptMode === 'whisper' && (
-
-
-
Transcript ready
-
- {transcript.words?.length || 0} words
- {transcript.duration &&
{'\u00B7'} {fmt(transcript.duration)} }
+ <>
+
+
+
+
Transcript ready
+
+ {transcript.words?.length || 0} words
+ {transcript.duration && {'\u00B7'} {fmt(transcript.duration)} }
+
+ {cachedTranscript && (
+
cached
+ )}
+
+
+ setTranscriptOpen(open => !open)} style={{ padding: '4px 10px', fontSize: 11 }}>
+ {transcriptOpen ? 'Hide transcript' : 'View transcript'}
+
+ { setTranscript(null); setCachedTranscript(false); autoTranscribeRef.current = ''; }} style={{ padding: '4px 10px', fontSize: 11 }}>Re-transcribe
+
- {cachedTranscript && (
-
cached
+ {transcriptOpen && formattedTranscript && (
+
+
+
+ Full transcript
+ Clean paragraphs, ready to read or copy
+
+
+
+ {[['readable', 'Readable'], ['timestamped', 'Timestamps']].map(([value, label]) => (
+ setTranscriptFormat(value)}>{label}
+ ))}
+
+
+
+
+
+ {formattedTranscript}
+
+
)}
-
{ setTranscript(null); setCachedTranscript(false); autoTranscribeRef.current = ''; }} style={{ padding: '4px 10px', fontSize: 11 }}>Re-transcribe
-
+ >
)}
{!transcript && !transcribing && videoPath.trim() && (
@@ -1596,6 +2054,128 @@ const onKeyActivate = (fn) => (e) => {
)}
+ {/* Silence removal */}
+
+
+
+
+
+
Remove silence
+
Tighten the full episode before making clips
+
+
+
Local
+
+
+ {silenceOriginal ? (
+
+
+
+
+ Compact episode is active
+
+ {silencePlan?.removed_duration ? `${fmtSaved(silencePlan.removed_duration)} removed · ` : ''}
+ previews, clips, captions, and full-episode export now use it.
+
+
+
+
+ {silencePlan?.output_path && (
+
+ Download MP4
+
+ )}
+
+ Restore original
+
+
+
+ ) : (
+ <>
+
+
+ Cut style
+ { setSilenceThreshold(Number(e.target.value)); setSilencePlan(null); }} disabled={isProcessing}>
+ Gentle
+ Balanced
+ Punchy
+
+
+
+ Remove pauses longer than
+ { setSilenceMinPause(Number(e.target.value)); setSilencePlan(null); }} disabled={isProcessing}>
+ 1 second
+ 0.65 seconds
+ 0.4 seconds
+
+
+
+ Breathing room
+ { setSilencePadding(Number(e.target.value)); setSilencePlan(null); }} disabled={isProcessing}>
+ Relaxed
+ Natural
+ Tight
+
+
+
+
+ {silenceAnalyzeJobId && (
+
+
+
{silenceAnalyzeStream?.message || 'Analyzing speech locally…'}
+
{silenceAnalyzeStream?.progress || 0}%
+
+
+
+ )}
+
+ {silenceRenderJobId && (
+
+
+
{silenceRenderStream?.message || 'Creating compact episode…'}
+
{silenceRenderStream?.progress || 0}%
+
+
+
+ )}
+
+ {silencePlan && !silenceBusy && (
+
+
+
{fmt(silencePlan.source_duration || 0)} Original
+
+
{fmt(silencePlan.output_duration || 0)} After
+
{fmtSaved(silencePlan.removed_duration || 0)} Saved
+
+
+ {(silencePlan.removed_ranges || []).map((range, index) => (
+
+ ))}
+
+
+ {silencePlan.cut_count || 0} pauses · {silencePlan.removed_percent || 0}% shorter
+
+ Create compact episode
+
+
+
+ )}
+
+ {!silencePlan && !silenceBusy && (
+
+
Uses local speech detection and protects every transcript word. Your original stays untouched.
+
+ Analyze episode
+
+
+ )}
+ >
+ )}
+
+
{/* Settings */}
Settings
@@ -1659,6 +2239,37 @@ const onKeyActivate = (fn) => (e) => {
+
+
+ Video layout
+ Updates both previews and exports
+
+
+
+ Caption position
+ setCaptionPosition(e.target.value)} disabled={isProcessing}>
+ Automatic
+ Upper third
+ Center
+ Lower third
+
+
+
+
Caption size
+
+ setCaptionFontScale(Number(e.target.value))} disabled={isProcessing} />
+ {captionFontScale}%
+
+
+
+ Logo position
+
+
+
+
+
{/* Advanced Settings */}
+ {/* Full episode export stays separate from short-clip selection. */}
+ {transcript && videoPath.trim() && (
+
+
+
+
Full episode
+
+ Export this entire imported video with {captionStyle} captions. Original framing and audio stay intact.
+
+
+
Original frame
+
+
+ {fullEpisodeBusy && (
+
+
+
+
+ {fullEpisodeStream?.message || 'Preparing full episode…'}
+
+
{fullEpisodeStream?.progress || 0}%
+
+
+
+ )}
+
+ {!fullEpisodeBusy && (
+
+
+ {fullEpisodeResult ? 'Export another copy' : 'Export full episode'}
+
+
onPreviewFullEpisode()}>
+ Preview for YouTube
+
+ {fullEpisodeResult?.filename && (
+ <>
+
onPreviewFullEpisode(fullEpisodeResult.filename)}>
+ Preview rendered
+
+
+ Download
+
+
{fullEpisodeResult.file_size_mb}MB
+ >
+ )}
+
+ )}
+
+ )}
+
{/* Word Corrections */}
(e) => {
{(phase === 'done' || phase === 'review' || phase === 'exporting') && (
{
- setPhase('idle'); setResults([]); setSuggestions([]); setBatchJobId(null); setFile(null); setTranscript(null); setActiveClipIdx(null); setPreviewSrc(null); setEnergyData({}); setCachedTranscript(false); autoTranscribeRef.current = '';
+ setPhase('idle'); setResults([]); setSuggestions([]); setBatchJobId(null); setFile(null); setTranscript(null); setActiveClipIdx(null); setPreviewSrc(null); setPreviewMode('clips'); setEnergyData({}); setCachedTranscript(false); autoTranscribeRef.current = '';
fetch('/api/ui-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ _source: 'ui', phase: 'idle', suggestions: [], deselectedIndices: [] }) }).catch(() => { });
}}>Start over
{phase === 'done' &&
{ setPhase('review'); setResults([]); setBatchJobId(null); }}>Re-export }
@@ -2053,8 +2716,8 @@ const onKeyActivate = (fn) => (e) => {
return (
{ const f = c.output_path?.split('/').pop(); if (f) setPreviewSrc(f); }}
- onKeyDown={onKeyActivate(() => { const f = c.output_path?.split('/').pop(); if (f) setPreviewSrc(f); })}>
+ onClick={() => { const f = c.output_path?.split('/').pop(); if (f) onPlayRendered(f); }}
+ onKeyDown={onKeyActivate(() => { const f = c.output_path?.split('/').pop(); if (f) onPlayRendered(f); })}>
{c.title || fname}
@@ -2075,9 +2738,27 @@ const onKeyActivate = (fn) => (e) => {
+ {previewMode === 'youtube' && (
+
setShowYouTubeFrame(v => !v)}
+ onBack={() => { setPreviewMode('clips'); setPreviewSrc(null); }}
+ />
+ )}
+
{/* Old video player - used ONLY for playing a rendered clip.
Source-video preview now lives inside . */}
- {videoUrl && previewSrc && (
+ {previewMode === 'clips' && videoUrl && previewSrc && (
(e) => {
{/* Style preview mockup — hidden when rendered clip is playing, whose
captions are already burned in and whose clock is clip-relative */}
- {!previewSrc && (
+ {previewMode === 'clips' && !previewSrc && (
setShowTikTokFrame(v => !v)}
clipEnded={clipEnded}
@@ -2120,6 +2804,9 @@ const onKeyActivate = (fn) => (e) => {
)}
{
- fetch("/api/session-cache/clear", { method: "POST" }).then((res) => {
- if (!res.ok) console.warn("Failed to clear session cache", res.status);
- }).catch((err: unknown) => {
- console.warn("Failed to clear session cache", err);
- });
- }, []);
-
return (
diff --git a/src/ui/client/lib.test.ts b/src/ui/client/lib.test.ts
index ea28650..cf3615d 100644
--- a/src/ui/client/lib.test.ts
+++ b/src/ui/client/lib.test.ts
@@ -7,6 +7,8 @@ import {
clipKey,
dropEnergy,
clampClipIndex,
+ resolveAssetName,
+ formatTranscriptText,
} from "./lib";
describe("fmt", () => {
@@ -145,3 +147,68 @@ describe("clampClipIndex", () => {
expect(clampClipIndex(null, 5)).toBeNull();
});
});
+
+describe("resolveAssetName", () => {
+ const assets = [
+ { name: "brand", path: "/assets/brand.png", type: "logo" },
+ { name: "intro", path: "/assets/intro.mp4", type: "intro" },
+ ];
+
+ it("resolves both persisted paths and names to the registered asset name", () => {
+ expect(resolveAssetName(assets, "/assets/brand.png", "logo")).toBe("brand");
+ expect(resolveAssetName(assets, "brand", "logo")).toBe("brand");
+ });
+
+ it("does not expose unregistered paths or assets of the wrong type", () => {
+ expect(resolveAssetName(assets, "/tmp/unregistered.png", "logo")).toBeNull();
+ expect(resolveAssetName(assets, "intro", "logo")).toBeNull();
+ });
+});
+
+describe("formatTranscriptText", () => {
+ it("joins Whisper fragments into readable paragraphs", () => {
+ const transcript = {
+ segments: [
+ { start: 0, end: 2, text: "Welcome to the show." },
+ { start: 2, end: 4, text: "Today we discuss relationships." },
+ ],
+ };
+ expect(formatTranscriptText(transcript)).toBe(
+ "Welcome to the show. Today we discuss relationships.",
+ );
+ });
+
+ it("labels speakers and starts a new paragraph when the speaker changes", () => {
+ const transcript = {
+ segments: [
+ { start: 5, end: 8, text: "Why did you start?", speaker: "SPEAKER_00" },
+ { start: 8, end: 12, text: "Relationships matter.", speaker: "SPEAKER_01" },
+ ],
+ };
+ expect(formatTranscriptText(transcript)).toBe(
+ "Speaker 1\nWhy did you start?\n\nSpeaker 2\nRelationships matter.",
+ );
+ });
+
+ it("preserves an existing human-readable speaker label", () => {
+ const transcript = {
+ segments: [{ start: 0, end: 2, text: "Welcome back.", speaker: "Speaker 1" }],
+ };
+ expect(formatTranscriptText(transcript)).toBe("Speaker 1\nWelcome back.");
+ });
+
+ it("adds copy-ready timestamps without changing transcript text", () => {
+ const transcript = {
+ segments: [{ start: 65.2, end: 70, text: "A useful answer." }],
+ };
+ expect(formatTranscriptText(transcript, "timestamped")).toBe(
+ "[1:05]\nA useful answer.",
+ );
+ });
+
+ it("formats raw transcript text when segments are unavailable", () => {
+ expect(formatTranscriptText({ transcript: "First sentence. Second sentence." })).toBe(
+ "First sentence. Second sentence.",
+ );
+ });
+});
diff --git a/src/ui/client/lib.ts b/src/ui/client/lib.ts
index 32a5375..eca88ee 100644
--- a/src/ui/client/lib.ts
+++ b/src/ui/client/lib.ts
@@ -84,6 +84,137 @@ export function timeAgo(iso: string): string {
export const basename = (p: string) => (p || "").split(/[/\\]/).pop() || "";
+interface AssetReference {
+ name: string;
+ path: string;
+ type?: string;
+}
+
+/**
+ * Resolves a persisted asset name or absolute path back to its registered name.
+ * Preview URLs must use the asset route; the video source route intentionally
+ * rejects arbitrary image paths.
+ */
+export function resolveAssetName(
+ assets: AssetReference[],
+ reference: string,
+ type?: string,
+): string | null {
+ if (!reference) return null;
+ return assets.find(
+ (asset) =>
+ (!type || asset.type === type) &&
+ (asset.name === reference || asset.path === reference),
+ )?.name ?? null;
+}
+
+interface TranscriptSegmentLike {
+ start?: number;
+ end?: number;
+ text?: string;
+ speaker?: string | null;
+}
+
+interface TranscriptLike {
+ transcript?: string;
+ text?: string;
+ segments?: TranscriptSegmentLike[];
+}
+
+export type TranscriptFormat = "readable" | "timestamped";
+
+interface TranscriptParagraph {
+ start: number;
+ speaker: string | null;
+ text: string;
+}
+
+const cleanTranscriptText = (value: unknown): string =>
+ typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
+
+const endsSentence = (value: string): boolean => /[.!?…][\]})"']?$/.test(value);
+
+function friendlySpeaker(value: string | null | undefined): string | null {
+ const speaker = cleanTranscriptText(value);
+ if (!speaker) return null;
+ const machineLabel = speaker.match(/^speaker[_-]+0*(\d+)$/i);
+ if (machineLabel) return `Speaker ${Number(machineLabel[1]) + 1}`;
+ return speaker.replace(/_/g, " ");
+}
+
+function paragraphsFromPlainText(value: string): TranscriptParagraph[] {
+ const text = cleanTranscriptText(value);
+ if (!text) return [];
+ const sentences = text.split(/(?<=[.!?…])\s+/).filter(Boolean);
+ const paragraphs: TranscriptParagraph[] = [];
+ let buffer = "";
+ for (const sentence of sentences) {
+ buffer = buffer ? `${buffer} ${sentence}` : sentence;
+ if (buffer.length >= 420 || sentences.length === 1) {
+ paragraphs.push({ start: 0, speaker: null, text: buffer });
+ buffer = "";
+ }
+ }
+ if (buffer) paragraphs.push({ start: 0, speaker: null, text: buffer });
+ return paragraphs;
+}
+
+function transcriptParagraphs(transcript: TranscriptLike | null | undefined): TranscriptParagraph[] {
+ const segments = Array.isArray(transcript?.segments)
+ ? transcript.segments.filter((segment) => cleanTranscriptText(segment?.text))
+ : [];
+ if (!segments.length) {
+ return paragraphsFromPlainText(transcript?.transcript || transcript?.text || "");
+ }
+
+ const paragraphs: TranscriptParagraph[] = [];
+ let current: TranscriptParagraph | null = null;
+ let previousEnd: number | null = null;
+ const flush = () => {
+ if (current?.text) paragraphs.push(current);
+ current = null;
+ };
+
+ for (const segment of segments) {
+ const text = cleanTranscriptText(segment.text);
+ const segmentStart: number = typeof segment.start === "number" && Number.isFinite(segment.start)
+ ? segment.start
+ : previousEnd ?? 0;
+ const segmentEnd: number = typeof segment.end === "number" && Number.isFinite(segment.end)
+ ? segment.end
+ : segmentStart;
+ const speaker = friendlySpeaker(segment.speaker);
+ const speakerChanged = current !== null && current.speaker !== speaker;
+ const longPause = current !== null && previousEnd !== null && segmentStart - previousEnd >= 2.5;
+ if (speakerChanged || longPause) flush();
+
+ if (!current) current = { start: segmentStart, speaker, text };
+ else current.text = `${current.text} ${text}`;
+ previousEnd = segmentEnd;
+
+ if ((current.text.length >= 420 && endsSentence(current.text)) || current.text.length >= 900) {
+ flush();
+ }
+ }
+ flush();
+ return paragraphs;
+}
+
+/** Produces copy-ready paragraphs from Whisper or imported transcript data. */
+export function formatTranscriptText(
+ transcript: TranscriptLike | null | undefined,
+ format: TranscriptFormat = "readable",
+): string {
+ return transcriptParagraphs(transcript)
+ .map((paragraph) => {
+ const heading = format === "timestamped"
+ ? `[${fmt(paragraph.start)}]${paragraph.speaker ? ` ${paragraph.speaker}` : ""}`
+ : paragraph.speaker;
+ return heading ? `${heading}\n${paragraph.text}` : paragraph.text;
+ })
+ .join("\n\n");
+}
+
// A render result's clip_index counts the clips submitted to the renderer, which
// for an agent-driven export is not the studio's clip order. The server stamps
// every row with the bounds of the clip it rendered; match a result to a clip on
diff --git a/src/ui/public/css/styles.css b/src/ui/public/css/styles.css
index 75ee434..4f5f7c6 100644
--- a/src/ui/public/css/styles.css
+++ b/src/ui/public/css/styles.css
@@ -91,6 +91,15 @@ h1 { font-size: 28px; font-weight: 700; letter-spacing: 0; line-height: 1.15; }
.main-col { min-width: 0; }
.preview-col { position: relative; align-self: stretch; }
+/* YouTube preview needs enough room to judge a landscape episode. Expand the
+ workspace into otherwise-unused desktop width and give the player a larger,
+ fluid column while preserving the editor's usable width. */
+.shell-main .app:has(.youtube-preview) { max-width: 1280px; }
+.layout:has(.youtube-preview) {
+ grid-template-columns: minmax(0, 1fr) clamp(360px, 38vw, 480px);
+ gap: 32px;
+}
+
/* ─── Content preview (YouTube-style card) ─── */
.yt-card { display: flex; flex-direction: column; gap: 12px; margin-top: 18px; max-width: 400px; }
.yt-thumb {
@@ -124,6 +133,99 @@ h1 { font-size: 28px; font-weight: 700; letter-spacing: 0; line-height: 1.15; }
.preview-player video.vertical {
max-height: 480px; width: auto; margin: 0 auto;
}
+.youtube-preview {
+ background: #080808; border: 1px solid var(--border);
+ border-radius: var(--radius); overflow: hidden; margin-bottom: 12px;
+ box-shadow: 0 10px 34px rgba(0, 0, 0, 0.28);
+}
+.youtube-preview-canvas {
+ position: relative; width: 100%; aspect-ratio: 16 / 9;
+ background: #000; overflow: hidden;
+}
+.youtube-preview-canvas video {
+ position: absolute; inset: 0; width: 100%; height: 100%;
+ object-fit: contain; background: #000;
+}
+.yt-wireframe { position: absolute; inset: 0; z-index: 5; color: #fff; pointer-events: none; }
+.ytwf-top-title {
+ position: absolute; top: 0; left: 0; right: 0; padding: 12px 14px 28px;
+ font-size: 12px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ background: linear-gradient(to bottom, rgba(0,0,0,0.72), transparent);
+ text-shadow: 0 1px 3px rgba(0,0,0,0.8);
+}
+.ytwf-center-play {
+ position: absolute; inset: 0; margin: auto; width: 48px; height: 34px;
+ border: 0; border-radius: 9px; background: #ff0033; color: #fff;
+ display: flex; align-items: center; justify-content: center; cursor: pointer;
+ pointer-events: auto; box-shadow: 0 4px 16px rgba(0,0,0,0.38);
+}
+.ytwf-bottom {
+ position: absolute; left: 0; right: 0; bottom: 0; padding: 28px 12px 9px;
+ background: linear-gradient(to top, rgba(0,0,0,0.82), transparent);
+}
+.ytwf-progress { height: 3px; border-radius: 2px; background: rgba(255,255,255,0.34); overflow: hidden; }
+.ytwf-progress span { display: block; height: 100%; min-width: 2px; background: #ff0033; }
+.ytwf-controls { display: flex; align-items: center; gap: 9px; margin-top: 7px; }
+.ytwf-controls button {
+ width: 18px; height: 18px; padding: 0; border: 0; background: none; color: #fff;
+ display: inline-flex; align-items: center; justify-content: center; pointer-events: auto; cursor: pointer;
+}
+.ytwf-pause { font-size: 12px; font-weight: 900; letter-spacing: -2px; transform: translateX(-1px); }
+.ytwf-time { font-size: 9px; font-variant-numeric: tabular-nums; }
+.ytwf-spacer { flex: 1; }
+.youtube-preview-gradient {
+ position: absolute; inset: auto 0 0 0; height: 55%;
+ background: linear-gradient(to top, rgba(0,0,0,0.72), rgba(0,0,0,0.28) 45%, transparent);
+ pointer-events: none; z-index: 1;
+}
+.youtube-preview-logo {
+ position: absolute; top: 7%; left: 3%; z-index: 3;
+ width: 18%; height: 13%;
+}
+.youtube-preview-logo img { width: 100%; height: 100%; object-fit: contain; object-position: left center; }
+.youtube-preview-caption {
+ position: absolute; left: 5%; right: 5%; z-index: 2;
+ color: #fff; text-align: center; pointer-events: none;
+ text-shadow: 0 2px 8px rgba(0,0,0,0.78);
+}
+.youtube-preview-placeholder {
+ margin-top: 5px; color: rgba(255,255,255,0.55);
+ font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.45px;
+}
+.youtube-preview-empty {
+ position: absolute; inset: 0; display: flex; align-items: center;
+ justify-content: center; gap: 8px; color: var(--text3); font-size: 12px;
+}
+.youtube-page-meta { padding: 11px 12px 9px; background: #0f0f0f; color: #f1f1f1; }
+.youtube-page-title {
+ font-size: 13px; font-weight: 700; line-height: 1.3;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 9px;
+}
+.youtube-page-row { display: flex; align-items: center; gap: 7px; }
+.youtube-channel-avatar {
+ width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
+ display: flex; align-items: center; justify-content: center;
+ background: linear-gradient(135deg, #e7772f, #9f421d); color: #fff; font-size: 11px; font-weight: 800;
+}
+.youtube-channel-copy { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
+.youtube-channel-copy strong { font-size: 10px; white-space: nowrap; }
+.youtube-channel-copy span { font-size: 8px; color: #aaa; white-space: nowrap; }
+.youtube-subscribe, .youtube-action {
+ height: 25px; padding: 0 9px; border-radius: 13px; display: flex; align-items: center; gap: 4px;
+ font-size: 9px; font-weight: 650; white-space: nowrap;
+}
+.youtube-subscribe { background: #f1f1f1; color: #0f0f0f; }
+.youtube-action { background: #272727; color: #f1f1f1; }
+.youtube-page-spacer { flex: 1; }
+.youtube-preview-bar {
+ padding: 10px 12px; display: flex; align-items: center;
+ justify-content: space-between; gap: 12px; background: var(--surface);
+ border-top: 1px solid var(--border);
+}
+.youtube-preview-bar > div { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
+.youtube-preview-bar strong { font-size: 12px; color: var(--text); }
+.youtube-preview-bar span { font-size: 10px; color: var(--text3); }
+.youtube-toggle-row { border-top: 1px solid var(--border); padding-bottom: 10px; background: var(--surface); }
.preview-empty {
aspect-ratio: 16/9; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 8px;
@@ -553,6 +655,49 @@ select {
.file-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
.file-badge .name { font-size: 13px; color: var(--green); font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-badge .meta { font-size: 11px; color: var(--text2); flex-shrink: 0; font-variant-numeric: tabular-nums; }
+.transcript-ready-summary { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
+.transcript-ready-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
+.full-transcript-panel {
+ margin-top: 10px; border: 1px solid var(--border); border-radius: var(--radius);
+ background: var(--surface); overflow: hidden;
+}
+.full-transcript-head {
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
+ padding: 11px 12px; border-bottom: 1px solid var(--border);
+}
+.full-transcript-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.full-transcript-title strong { font-size: 12px; color: var(--text); }
+.full-transcript-title span { font-size: 10px; color: var(--text3); }
+.full-transcript-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
+.transcript-format-tabs {
+ display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--bg);
+ border: 1px solid var(--border);
+}
+.transcript-format-tabs button {
+ padding: 5px 8px; border: 0; border-radius: 6px; background: transparent;
+ color: var(--text3); font: inherit; font-size: 10px; font-weight: 650; cursor: pointer;
+}
+.transcript-format-tabs button:hover { color: var(--text); }
+.transcript-format-tabs button.active { background: var(--surface2); color: var(--accent); }
+.transcript-format-tabs button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+.transcript-copy-button { white-space: nowrap; }
+.transcript-document {
+ max-height: 360px; overflow: auto; padding: 16px 18px 20px;
+ color: var(--text); font-size: 12px; line-height: 1.75; white-space: pre-wrap;
+ user-select: text; cursor: text; content-visibility: auto;
+ scrollbar-gutter: stable;
+}
+.transcript-document:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
+@media (max-width: 700px) {
+ .transcript-ready-badge { align-items: stretch; flex-direction: column; }
+ .transcript-ready-summary { width: 100%; }
+ .transcript-ready-summary .name { min-width: 0; }
+ .transcript-ready-actions { width: 100%; }
+ .transcript-ready-actions .btn { flex: 1; }
+ .full-transcript-head { align-items: flex-start; flex-direction: column; }
+ .full-transcript-actions { width: 100%; justify-content: space-between; }
+ .transcript-document { max-height: 300px; padding: 14px; }
+}
/* ─── Tabs ─── */
.tabs { display: flex; gap: 2px; background: var(--surface); border-radius: var(--radius); padding: 3px; margin-bottom: 14px; }
@@ -569,6 +714,52 @@ select {
/* ─── Settings ─── */
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
.field-label { font-size: 11px; color: var(--text2); font-weight: 600; display: block; margin-bottom: 6px; }
+.video-layout-controls {
+ margin-top: 14px; padding: 14px; border: 1px solid var(--border);
+ border-radius: var(--radius); background: color-mix(in srgb, var(--surface2) 72%, transparent);
+}
+.video-layout-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.video-layout-heading span { font-size: 12px; font-weight: 700; color: var(--text); }
+.video-layout-heading small { font-size: 10px; color: var(--text3); }
+.video-layout-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; align-items: end; }
+.layout-range-row { height: 38px; display: flex; align-items: center; gap: 8px; }
+.layout-range-row input { min-width: 0; flex: 1; }
+.layout-range-row output {
+ width: 44px; padding: 5px 6px; border-radius: 7px; background: var(--surface);
+ color: var(--text2); font-size: 11px; font-weight: 650; text-align: center; font-variant-numeric: tabular-nums;
+}
+.logo-position-control { display: flex; align-items: center; gap: 8px; min-height: 38px; }
+.logo-position-picker {
+ display: grid; grid-template-columns: repeat(3, 34px); grid-template-rows: repeat(2, 25px); gap: 4px;
+}
+.logo-position-option {
+ position: relative; width: 34px; height: 25px; padding: 0;
+ border: 1px solid var(--border); border-radius: 6px; background: var(--surface);
+ cursor: pointer; transition: border-color 0.15s var(--ease), background 0.15s var(--ease), box-shadow 0.15s var(--ease);
+}
+.logo-position-option:hover:not(:disabled) { border-color: var(--border-hover); background: var(--surface2); }
+.logo-position-option.selected {
+ border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--surface));
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
+}
+.logo-position-option:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.logo-position-option:disabled { cursor: not-allowed; opacity: 0.42; }
+.logo-position-mark {
+ position: absolute; width: 10px; height: 5px; border-radius: 2px;
+ background: currentColor; color: var(--text3);
+}
+.logo-position-option.selected .logo-position-mark { color: var(--accent); }
+.logo-position-mark.top-left { top: 5px; left: 5px; }
+.logo-position-mark.top-center { top: 5px; left: 50%; transform: translateX(-50%); }
+.logo-position-mark.top-right { top: 5px; right: 5px; }
+.logo-position-mark.bottom-left { bottom: 5px; left: 5px; }
+.logo-position-mark.bottom-center { bottom: 5px; left: 50%; transform: translateX(-50%); }
+.logo-position-mark.bottom-right { right: 5px; bottom: 5px; }
+.logo-position-control output { min-width: 55px; color: var(--text3); font-size: 10px; line-height: 1.2; }
+@media (max-width: 700px) {
+ .video-layout-grid { grid-template-columns: 1fr; }
+ .video-layout-heading { align-items: flex-start; flex-direction: column; gap: 2px; }
+}
.row { display: flex; gap: 10px; align-items: flex-end; }
.row > * { flex: 1; }
@@ -1643,3 +1834,54 @@ p, li, figcaption, blockquote, .subtitle, .file-preview, .card-desc, .int-row .d
.cap-karaoke::first-letter { color: var(--yellow); }
.cap-subtle { font-family: var(--font-sans); font-weight: 500; font-size: 12px; color: #e5e7eb; }
.cap-branded { font-family: var(--font-sans); font-weight: 800; font-size: 14px; text-transform: uppercase; color: #fff; background: rgba(0,0,0,0.8); padding: 3px 8px; border-radius: 8px; }
+
+/* ── Local silence removal ── */
+.silence-card { overflow: hidden; }
+.silence-head, .silence-title-wrap, .silence-start-row, .silence-plan-foot, .silence-actions {
+ display: flex; align-items: center;
+}
+.silence-head { justify-content: space-between; gap: 16px; margin-bottom: 16px; }
+.silence-title-wrap { gap: 10px; }
+.silence-icon {
+ width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center;
+ color: var(--accent); background: var(--accent-subtle); border: 1px solid var(--accent-edge);
+}
+.silence-subtitle { color: var(--text3); font-size: 11px; }
+.silence-local-badge {
+ color: var(--green); background: var(--green-subtle); border: 1px solid var(--green-border);
+ padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 700; letter-spacing: .03em;
+}
+.silence-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
+.silence-controls select { width: 100%; }
+.silence-start-row { justify-content: space-between; gap: 16px; margin-top: 14px; color: var(--text3); font-size: 11px; line-height: 1.45; }
+.silence-start-row > span { max-width: 390px; }
+.silence-progress, .silence-plan, .silence-active {
+ margin-top: 14px; padding: 13px; background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-sm);
+}
+.silence-progress .status-line span { display: flex; align-items: center; gap: 8px; font-size: 12px; }
+.silence-progress .status-line b { font-size: 11px; font-variant-numeric: tabular-nums; }
+.silence-progress .progress-track { margin-top: 9px; }
+.silence-stats { display: flex; align-items: center; gap: 14px; }
+.silence-stats > div { display: flex; flex-direction: column; gap: 2px; }
+.silence-stats strong { font-size: 15px; font-variant-numeric: tabular-nums; color: var(--text); }
+.silence-stats span { font-size: 10px; color: var(--text3); }
+.silence-stats .silence-saved { margin-left: auto; text-align: right; }
+.silence-stats .silence-saved strong { color: var(--green); }
+.silence-timeline {
+ position: relative; height: 8px; margin: 13px 0 10px; overflow: hidden;
+ border-radius: 999px; background: linear-gradient(90deg, var(--accent), #7c6cff);
+}
+.silence-cut { position: absolute; top: 0; bottom: 0; background: var(--surface3); border-left: 1px solid var(--bg); border-right: 1px solid var(--bg); }
+.silence-plan-foot { justify-content: space-between; gap: 12px; }
+.silence-plan-foot > span { font-size: 11px; color: var(--text3); }
+.silence-active { display: flex; align-items: center; justify-content: space-between; gap: 14px; border-color: var(--green-border); background: var(--green-subtle); }
+.silence-active-copy { display: flex; align-items: flex-start; gap: 9px; color: var(--green); }
+.silence-active-copy > div { display: flex; flex-direction: column; gap: 3px; }
+.silence-active-copy strong { color: var(--text); font-size: 12px; }
+.silence-active-copy span { color: var(--text2); font-size: 11px; line-height: 1.4; }
+.silence-actions { justify-content: flex-end; gap: 7px; flex-wrap: wrap; }
+@media (max-width: 760px) {
+ .silence-controls { grid-template-columns: 1fr; }
+ .silence-start-row, .silence-active { align-items: stretch; flex-direction: column; }
+ .silence-start-row .btn, .silence-actions .btn, .silence-actions a { justify-content: center; }
+}
diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts
index ece7d3c..19a405b 100644
--- a/src/ui/web-server.ts
+++ b/src/ui/web-server.ts
@@ -46,6 +46,12 @@ import { registerConfigIntegrationRoutes } from "../handlers/integrations.routes
import { childLogger } from "../utils/logger.js";
import { sliceTranscript, sliceWords, findContentType, findSuggestionSegments } from "../utils/transcript.js";
import { errMsg } from "../utils/errors.js";
+import { resolveByteRange } from "../utils/http-range.js";
+import {
+ FULL_EPISODE_CAPTION_STYLES,
+ fullEpisodeOutputStem,
+ parseFullEpisodeProgress,
+} from "../utils/full-episode-export.js";
import type {
AssetType,
BatchClipsResult,
@@ -91,7 +97,7 @@ function safePath(base: string, filename: string): string | null {
// Track active jobs so the UI can poll progress
interface JobState {
id: string;
- type: "transcribe" | "create_clip" | "batch_clips" | "download_video";
+ type: "transcribe" | "create_clip" | "batch_clips" | "download_video" | "full_episode" | "silence_analysis" | "silence_render";
status: "pending" | "running" | "done" | "error";
progress: number;
message: string;
@@ -122,6 +128,17 @@ setInterval(() => {
/** Transcript data stored per file, plus optional face-tracking hints. */
type ServerTranscript = TranscriptResult & { face_map?: unknown };
+type SilenceOriginal = { videoPath: string; transcript: ServerTranscript };
+type SilencePlan = {
+ keep_segments: Array<{ start: number; end: number }>;
+ removed_ranges: Array<{ start: number; end: number }>;
+ source_duration: number;
+ output_duration: number;
+ removed_duration: number;
+ removed_percent: number;
+ cut_count: number;
+ [key: string]: unknown;
+};
// Store the latest transcript per uploaded file for the session
const sessionTranscripts = new Map();
@@ -133,6 +150,8 @@ interface UIState {
activeExportJobId: string | null;
transcript: ServerTranscript | null;
rawTranscriptText: string;
+ silenceOriginal: SilenceOriginal | null;
+ silencePlan: SilencePlan | null;
suggestions: SuggestedClip[];
deselectedIndices: number[];
settings: {
@@ -143,7 +162,13 @@ interface UIState {
outroPath: string;
introPath: string;
cleanFillers: boolean;
+ captionPosition: string;
+ captionFontScale: number;
+ logoPosition: string;
onboardingDismissed: boolean;
+ silenceThreshold: number;
+ silenceMinPause: number;
+ silencePadding: number;
};
phase: string;
results: unknown[];
@@ -163,12 +188,17 @@ function loadPersistedState(): UIState {
saved.filePath = "";
saved.phase = "idle";
}
+ if (saved.silenceOriginal?.videoPath && !existsSync(saved.silenceOriginal.videoPath)) {
+ saved.silenceOriginal = null;
+ }
return {
videoPath: saved.videoPath || "",
filePath: saved.filePath || "",
activeExportJobId: null,
transcript: saved.transcript || null,
rawTranscriptText: saved.rawTranscriptText || "",
+ silenceOriginal: saved.silenceOriginal || null,
+ silencePlan: saved.silencePlan || null,
suggestions: saved.suggestions || [],
deselectedIndices: saved.deselectedIndices || [],
settings: {
@@ -179,7 +209,13 @@ function loadPersistedState(): UIState {
outroPath: saved.settings?.outroPath || "",
introPath: saved.settings?.introPath || "",
cleanFillers: saved.settings?.cleanFillers !== false,
+ captionPosition: ["auto", "upper", "center", "lower"].includes(saved.settings?.captionPosition) ? saved.settings.captionPosition : "auto",
+ captionFontScale: Math.max(60, Math.min(160, Number(saved.settings?.captionFontScale) || 100)),
+ logoPosition: ["top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"].includes(saved.settings?.logoPosition) ? saved.settings.logoPosition : "top-left",
onboardingDismissed: !!saved.settings?.onboardingDismissed,
+ silenceThreshold: Math.max(0.25, Math.min(0.8, Number(saved.settings?.silenceThreshold) || 0.5)),
+ silenceMinPause: Math.max(0.3, Math.min(5, Number(saved.settings?.silenceMinPause) || 0.65)),
+ silencePadding: Math.max(0.02, Math.min(0.5, Number(saved.settings?.silencePadding) || 0.12)),
},
// Never restore mid-export phases
phase: ["exporting", "parsing", "suggesting"].includes(saved.phase)
@@ -203,6 +239,8 @@ function loadPersistedState(): UIState {
activeExportJobId: null,
transcript: null,
rawTranscriptText: "",
+ silenceOriginal: null,
+ silencePlan: null,
suggestions: [],
deselectedIndices: [],
settings: {
@@ -213,7 +251,13 @@ function loadPersistedState(): UIState {
outroPath: "",
introPath: "",
cleanFillers: true,
+ captionPosition: "auto",
+ captionFontScale: 100,
+ logoPosition: "top-left",
onboardingDismissed: false,
+ silenceThreshold: 0.5,
+ silenceMinPause: 0.65,
+ silencePadding: 0.12,
},
phase: "idle",
results: [],
@@ -260,6 +304,7 @@ function registerSourcePath(p: string | undefined | null): void {
} catch {}
}
registerSourcePath(uiState.videoPath);
+registerSourcePath(uiState.silenceOriginal?.videoPath);
// Debounced save to disk
let saveTimer: ReturnType | null = null;
@@ -284,13 +329,12 @@ function streamVideo(req: Request, res: Response, filePath: string, contentType
const onErr = (stream: ReturnType) =>
stream.on("error", () => res.destroy());
if (range) {
- const [s, e] = range.replace(/bytes=/, "").split("-");
- const start = parseInt(s, 10);
- const end = e ? parseInt(e, 10) : fileSize - 1;
- if (Number.isNaN(start) || Number.isNaN(end) || start > end || start < 0 || end >= fileSize) {
+ const resolved = resolveByteRange(range, fileSize);
+ if (!resolved) {
res.writeHead(416, { "Content-Range": `bytes */${fileSize}` }).end();
return;
}
+ const { start, end } = resolved;
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Accept-Ranges": "bytes",
@@ -491,6 +535,8 @@ function clearEpisodeSessionState(): void {
uiState.activeExportJobId = null;
uiState.transcript = null;
uiState.rawTranscriptText = "";
+ uiState.silenceOriginal = null;
+ uiState.silencePlan = null;
uiState.suggestions = [];
uiState.deselectedIndices = [];
uiState.phase = "idle";
@@ -503,7 +549,7 @@ function activeBlockingJobs(): JobState[] {
return [...jobs.values()].filter(
(job) =>
job.status === "running" &&
- ["transcribe", "create_clip", "batch_clips"].includes(job.type),
+ ["transcribe", "create_clip", "batch_clips", "silence_analysis", "silence_render"].includes(job.type),
);
}
@@ -1051,6 +1097,9 @@ app.post("/api/create-clip", async (req, res) => {
allow_ass_fallback = false,
content_type = null,
keep_segments,
+ caption_position = "auto",
+ caption_font_scale = 100,
+ logo_position = "top-left",
} = req.body;
if (!video_path || !existsSync(video_path)) {
@@ -1116,6 +1165,13 @@ app.post("/api/create-clip", async (req, res) => {
.json({ error: `Invalid format. Use: ${validFormats.join(", ")}` });
return;
}
+ const validCaptionPositions = ["auto", "upper", "center", "lower"];
+ const validLogoPositions = ["top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"];
+ if (!validCaptionPositions.includes(caption_position) || !validLogoPositions.includes(logo_position)) {
+ res.status(400).json({ error: "Invalid caption or logo position" });
+ return;
+ }
+ const normalizedFontScale = Math.max(60, Math.min(160, Number(caption_font_scale) || 100));
await fileManager.ensureDirectories();
@@ -1156,6 +1212,9 @@ app.post("/api/create-clip", async (req, res) => {
intro_path,
clean_fillers,
allow_ass_fallback,
+ caption_position,
+ caption_font_scale: normalizedFontScale,
+ logo_position,
...(enriched.keep_segments?.length && { keep_segments: enriched.keep_segments }),
},
(event) => {
@@ -1224,6 +1283,9 @@ app.post("/api/batch-clips", async (req, res) => {
clean_fillers = false,
keep_caption_overlay = false,
format = "vertical",
+ caption_position = "auto",
+ caption_font_scale = 100,
+ logo_position = "top-left",
} = req.body;
if (!video_path || !existsSync(video_path)) {
@@ -1234,6 +1296,12 @@ app.post("/api/batch-clips", async (req, res) => {
res.status(400).json({ error: "No clips provided" });
return;
}
+ if (!["auto", "upper", "center", "lower"].includes(caption_position) ||
+ !["top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"].includes(logo_position)) {
+ res.status(400).json({ error: "Invalid caption or logo position" });
+ return;
+ }
+ const normalizedFontScale = Math.max(60, Math.min(160, Number(caption_font_scale) || 100));
let logo_path: string | null = null;
let outro_path: string | null = null;
@@ -1319,6 +1387,9 @@ app.post("/api/batch-clips", async (req, res) => {
clean_fillers,
keep_caption_overlay: keep_caption_overlay === true,
face_map: uiState.transcript?.face_map,
+ caption_position,
+ caption_font_scale: normalizedFontScale,
+ logo_position,
},
(event) => {
const progress = advanceProgress(job, event.percent);
@@ -1365,6 +1436,298 @@ app.post("/api/batch-clips", async (req, res) => {
});
});
+function findFullEpisodeRenderer(): string | null {
+ const candidates = [
+ join(paths.projectRoot, "remotion", "render-full-episode.mjs"),
+ join(paths.projectRoot, "runtime", "remotion", "render-full-episode.mjs"),
+ ];
+ return candidates.find((candidate) => existsSync(candidate)) || null;
+}
+
+function reserveFullEpisodeOutput(videoPath: string): string {
+ const stem = fullEpisodeOutputStem(videoPath);
+ let candidate = join(paths.output, `${stem}.mp4`);
+ for (let suffix = 2; existsSync(candidate); suffix++) {
+ candidate = join(paths.output, `${stem}-${suffix}.mp4`);
+ }
+ return candidate;
+}
+
+/** Analyze spoken sections locally. The first run downloads a verified 1.3 MB VAD model. */
+app.post("/api/analyze-silence", async (req, res) => {
+ const {
+ video_path,
+ transcript_words = [],
+ threshold = 0.5,
+ min_silence_seconds = 0.65,
+ padding_seconds = 0.12,
+ } = req.body || {};
+ if (typeof video_path !== "string" || !existsSync(video_path)) {
+ res.status(400).json({ error: "Select a local episode first" });
+ return;
+ }
+ if (!Array.isArray(transcript_words) || transcript_words.length === 0) {
+ res.status(400).json({ error: "Transcribe the episode before removing silence" });
+ return;
+ }
+ const normalizedThreshold = Number(threshold);
+ const normalizedPause = Number(min_silence_seconds);
+ const normalizedPadding = Number(padding_seconds);
+ if (
+ !Number.isFinite(normalizedThreshold) || normalizedThreshold < 0.25 || normalizedThreshold > 0.8 ||
+ !Number.isFinite(normalizedPause) || normalizedPause < 0.3 || normalizedPause > 5 ||
+ !Number.isFinite(normalizedPadding) || normalizedPadding < 0.02 || normalizedPadding > 0.5
+ ) {
+ res.status(400).json({ error: "Invalid silence-removal settings" });
+ return;
+ }
+
+ const jobId = uuidv4();
+ const job: JobState = {
+ id: jobId,
+ type: "silence_analysis",
+ status: "running",
+ progress: 0,
+ message: "Preparing local silence analysis...",
+ createdAt: Date.now(),
+ };
+ jobs.set(jobId, job);
+ res.json({ job_id: jobId, status: "running" });
+
+ executor.execute("analyze_silence", {
+ video_path,
+ transcript_words,
+ threshold: normalizedThreshold,
+ min_silence_seconds: normalizedPause,
+ padding_seconds: normalizedPadding,
+ }, (event) => {
+ job.progress = event.percent;
+ job.message = event.message;
+ }).then((result) => {
+ job.status = "done";
+ job.progress = 100;
+ job.message = "Silence analysis ready";
+ job.result = result.data;
+ }).catch((err) => {
+ job.status = "error";
+ job.error = err.message;
+ job.message = `Error: ${err.message}`;
+ });
+});
+
+/** Render an approved keep plan locally, preserving the source and remapping captions. */
+app.post("/api/render-silence-removed", async (req, res) => {
+ const { video_path, keep_segments, transcript } = req.body || {};
+ if (typeof video_path !== "string" || !existsSync(video_path)) {
+ res.status(400).json({ error: "Source episode not found" });
+ return;
+ }
+ if (!Array.isArray(transcript?.words) || transcript.words.length === 0) {
+ res.status(400).json({ error: "Transcript is required to keep caption timing aligned" });
+ return;
+ }
+ if (!Array.isArray(keep_segments) || keep_segments.length === 0 || keep_segments.length > 5000) {
+ res.status(400).json({ error: "Invalid silence-removal plan" });
+ return;
+ }
+ const normalizedSegments: Array<{ start: number; end: number }> = [];
+ let previousEnd = 0;
+ for (const item of keep_segments) {
+ const start = Number(item?.start);
+ const end = Number(item?.end);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < previousEnd || end <= start) {
+ res.status(400).json({ error: "Silence-removal ranges must be ordered and non-overlapping" });
+ return;
+ }
+ normalizedSegments.push({ start, end });
+ previousEnd = end;
+ }
+ await fileManager.ensureDirectories();
+
+ const jobId = uuidv4();
+ const job: JobState = {
+ id: jobId,
+ type: "silence_render",
+ status: "running",
+ progress: 0,
+ message: "Creating compact episode...",
+ createdAt: Date.now(),
+ };
+ jobs.set(jobId, job);
+ res.json({ job_id: jobId, status: "running" });
+
+ executor.execute<{
+ output_path: string;
+ filename: string;
+ duration: number;
+ transcript: ServerTranscript;
+ }>("render_silence_removed", {
+ video_path,
+ keep_segments: normalizedSegments,
+ transcript,
+ output_dir: paths.output,
+ }, (event) => {
+ job.progress = event.percent;
+ job.message = event.message;
+ }).then((result) => {
+ job.status = "done";
+ job.progress = 100;
+ job.message = "Compact episode ready";
+ job.result = result.data;
+ if (result.data?.output_path) registerSourcePath(result.data.output_path);
+ }).catch((err) => {
+ job.status = "error";
+ job.error = err.message;
+ job.message = `Error: ${err.message}`;
+ });
+});
+
+/**
+ * POST /api/export-full-episode — Burn captions into the complete current source.
+ *
+ * Full episodes bypass clip duration limits and retain the source dimensions.
+ * The renderer chunks its temporary alpha overlay to keep disk use bounded.
+ */
+app.post("/api/export-full-episode", async (req, res) => {
+ const {
+ video_path,
+ transcript_words = [],
+ caption_style = "branded",
+ caption_position = "auto",
+ caption_font_scale = 100,
+ logo_position = "top-left",
+ } = req.body || {};
+
+ if (!video_path || typeof video_path !== "string" || !existsSync(video_path)) {
+ res.status(400).json({ error: "Video file not found" });
+ return;
+ }
+ if (!Array.isArray(transcript_words) || transcript_words.length === 0) {
+ res.status(400).json({ error: "Transcribe the episode before exporting it with captions" });
+ return;
+ }
+ if (!FULL_EPISODE_CAPTION_STYLES.includes(caption_style)) {
+ res.status(400).json({ error: `Invalid caption style. Use: ${FULL_EPISODE_CAPTION_STYLES.join(", ")}` });
+ return;
+ }
+ if (!["auto", "upper", "center", "lower"].includes(caption_position) ||
+ !["top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"].includes(logo_position)) {
+ res.status(400).json({ error: "Invalid caption or logo position" });
+ return;
+ }
+ const normalizedFontScale = Math.max(60, Math.min(160, Number(caption_font_scale) || 100));
+
+ let logoPath: string | null = null;
+ if (req.body.logo_path) {
+ logoPath = await assetManager.resolve(req.body.logo_path);
+ if (!logoPath) {
+ res.status(400).json({ error: `logo not found: ${req.body.logo_path}` });
+ return;
+ }
+ }
+
+ const renderer = findFullEpisodeRenderer();
+ if (!renderer) {
+ res.status(500).json({ error: "Full-episode renderer is not installed" });
+ return;
+ }
+
+ await fileManager.ensureDirectories();
+ const outputPath = reserveFullEpisodeOutput(video_path);
+ const wordsPath = join(paths.working, `full-episode-${uuidv4()}.words.json`);
+ writeFileSync(wordsPath, JSON.stringify({ words: transcript_words }), "utf-8");
+
+ const jobId = uuidv4();
+ const job: JobState = {
+ id: jobId,
+ type: "full_episode",
+ status: "running",
+ progress: 0,
+ message: "Preparing full episode...",
+ createdAt: Date.now(),
+ };
+ jobs.set(jobId, job);
+ res.json({ job_id: jobId, status: "running" });
+
+ const args = [
+ renderer,
+ "--video", path.resolve(video_path),
+ "--words", wordsPath,
+ "--style", caption_style,
+ "--output", outputPath,
+ "--ffmpeg", paths.ffmpegPath,
+ "--ffprobe", paths.ffprobePath,
+ "--caption-position", caption_position,
+ "--caption-font-scale", String(normalizedFontScale),
+ "--logo-position", logo_position,
+ ];
+ if (caption_style === "branded" && logoPath) args.push("--logo", logoPath);
+
+ const child = spawn(process.execPath, args, {
+ cwd: dirname(renderer),
+ env: {
+ ...process.env,
+ PODCLI_CACHE_DIR: join(dirname(renderer), ".bundle-cache"),
+ },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ let stdoutCarry = "";
+ let stderrTail = "";
+ const consumeStdout = (text: string, flush = false) => {
+ stdoutCarry += text;
+ const lines = stdoutCarry.split(/\r?\n/);
+ stdoutCarry = flush ? "" : lines.pop() || "";
+ for (const line of lines) {
+ const update = parseFullEpisodeProgress(line);
+ if (!update) continue;
+ job.progress = Math.max(job.progress, update.percent);
+ job.message = update.message;
+ }
+ if (flush && stdoutCarry) {
+ const update = parseFullEpisodeProgress(stdoutCarry);
+ if (update) {
+ job.progress = Math.max(job.progress, update.percent);
+ job.message = update.message;
+ }
+ }
+ };
+
+ child.stdout.on("data", (chunk) => consumeStdout(chunk.toString()));
+ child.stderr.on("data", (chunk) => {
+ stderrTail = (stderrTail + chunk.toString()).slice(-4000);
+ });
+ child.on("error", async (err) => {
+ job.status = "error";
+ job.error = err.message;
+ job.message = `Error: ${err.message}`;
+ try { await unlink(wordsPath); } catch { /* best effort */ }
+ });
+ child.on("close", async (code) => {
+ consumeStdout("", true);
+ try { await unlink(wordsPath); } catch { /* best effort */ }
+ if (job.status === "error") return;
+ if (code !== 0 || !existsSync(outputPath)) {
+ const detail = stderrTail.trim().split(/\r?\n/).slice(-4).join("\n");
+ job.status = "error";
+ job.error = detail || `Renderer exited with code ${code}`;
+ job.message = `Error: ${job.error}`;
+ return;
+ }
+
+ const stat = statSync(outputPath);
+ job.status = "done";
+ job.progress = 100;
+ job.message = "Full episode ready";
+ job.result = {
+ output_path: outputPath,
+ filename: basename(outputPath),
+ file_size_mb: Math.round((stat.size / (1024 * 1024)) * 100) / 100,
+ caption_style,
+ };
+ });
+});
+
/**
* GET /api/job/:id — Poll job status + progress
*/
@@ -3382,6 +3745,8 @@ app.get("/api/ui-state", (_req, res) => {
: 0,
transcript: uiState.transcript,
rawTranscriptText: uiState.rawTranscriptText,
+ silenceOriginal: uiState.silenceOriginal,
+ silencePlan: uiState.silencePlan,
lastUpdated: uiState.lastUpdated,
});
});
@@ -3394,11 +3759,44 @@ app.post("/api/ui-state", (req, res) => {
// Track which fields changed for targeted SSE broadcasts
const source = body._source || "mcp"; // UI sends _source:'ui'
+ // A newly mounted React client briefly holds form defaults before its SSE
+ // snapshot commits. Ignore that exact destructive shape so refresh/navigation
+ // cannot erase an existing episode. User-initiated Clear opts in explicitly.
+ const looksLikeMountDefaults =
+ source === "ui" &&
+ body._allowClear !== true &&
+ !!uiState.videoPath &&
+ body.videoPath === "";
+ if (looksLikeMountDefaults) {
+ res.json({ ok: true, ignored: "stale hydration defaults" });
+ return;
+ }
+
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.silenceOriginal !== undefined) {
+ const original = body.silenceOriginal;
+ if (original === null) {
+ uiState.silenceOriginal = null;
+ } else if (
+ typeof original?.videoPath === "string" &&
+ existsSync(original.videoPath) &&
+ Array.isArray(original?.transcript?.words)
+ ) {
+ uiState.silenceOriginal = original;
+ registerSourcePath(original.videoPath);
+ }
+ }
+ if (body.silencePlan !== undefined) {
+ const plan = body.silencePlan;
+ uiState.silencePlan = plan === null || (
+ Array.isArray(plan?.keep_segments) &&
+ Array.isArray(plan?.removed_ranges)
+ ) ? plan : uiState.silencePlan;
+ }
if (body.suggestions !== undefined) {
if (body._source === "ui" && Array.isArray(body.suggestions)) {
uiState.suggestions = body.suggestions.map((incoming: SuggestedClip) => {
@@ -3440,8 +3838,20 @@ app.post("/api/ui-state", (req, res) => {
uiState.settings.introPath = body.settings.introPath;
if (body.settings.cleanFillers !== undefined)
uiState.settings.cleanFillers = body.settings.cleanFillers !== false;
+ if (["auto", "upper", "center", "lower"].includes(body.settings.captionPosition))
+ uiState.settings.captionPosition = body.settings.captionPosition;
+ if (body.settings.captionFontScale !== undefined)
+ uiState.settings.captionFontScale = Math.max(60, Math.min(160, Number(body.settings.captionFontScale) || 100));
+ if (["top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"].includes(body.settings.logoPosition))
+ uiState.settings.logoPosition = body.settings.logoPosition;
if (body.settings.onboardingDismissed !== undefined)
uiState.settings.onboardingDismissed = !!body.settings.onboardingDismissed;
+ if (body.settings.silenceThreshold !== undefined)
+ uiState.settings.silenceThreshold = Math.max(0.25, Math.min(0.8, Number(body.settings.silenceThreshold) || 0.5));
+ if (body.settings.silenceMinPause !== undefined)
+ uiState.settings.silenceMinPause = Math.max(0.3, Math.min(5, Number(body.settings.silenceMinPause) || 0.65));
+ if (body.settings.silencePadding !== undefined)
+ uiState.settings.silencePadding = Math.max(0.02, Math.min(0.5, Number(body.settings.silencePadding) || 0.12));
}
uiState.lastUpdated = Date.now();
persistState();
@@ -3460,6 +3870,8 @@ app.post("/api/ui-state", (req, res) => {
}),
...(body.phase !== undefined && { phase: uiState.phase }),
...(body.transcript !== undefined && { transcript: uiState.transcript }),
+ ...(body.silenceOriginal !== undefined && { silenceOriginal: uiState.silenceOriginal }),
+ ...(body.silencePlan !== undefined && { silencePlan: uiState.silencePlan }),
...(body.settings && { settings: uiState.settings }),
energyData: uiState.energyData,
});
diff --git a/src/utils/full-episode-export.test.ts b/src/utils/full-episode-export.test.ts
new file mode 100644
index 0000000..86532fb
--- /dev/null
+++ b/src/utils/full-episode-export.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { fullEpisodeOutputStem, parseFullEpisodeProgress } from "./full-episode-export.js";
+
+describe("full episode export helpers", () => {
+ it("builds a safe, recognizable output name", () => {
+ expect(fullEpisodeOutputStem("/shows/My CEO Episode (final).mov"))
+ .toBe("My-CEO-Episode--final_full_captioned");
+ expect(fullEpisodeOutputStem("/shows/💬.mp4"))
+ .toBe("episode_full_captioned");
+ });
+
+ it("parses renderer progress and clamps bad percentages", () => {
+ expect(parseFullEpisodeProgress('PODCLI_PROGRESS={"percent":41.7,"message":"Rendering captions 3/8"}'))
+ .toEqual({ percent: 42, message: "Rendering captions 3/8" });
+ expect(parseFullEpisodeProgress('prefix PODCLI_PROGRESS={"percent":120,"message":"Finishing"}'))
+ .toEqual({ percent: 100, message: "Finishing" });
+ expect(parseFullEpisodeProgress("ordinary renderer output")).toBeNull();
+ expect(parseFullEpisodeProgress("PODCLI_PROGRESS=not-json")).toBeNull();
+ });
+});
diff --git a/src/utils/full-episode-export.ts b/src/utils/full-episode-export.ts
new file mode 100644
index 0000000..09c2248
--- /dev/null
+++ b/src/utils/full-episode-export.ts
@@ -0,0 +1,41 @@
+import { basename, extname } from "path";
+
+export const FULL_EPISODE_CAPTION_STYLES = [
+ "branded",
+ "hormozi",
+ "karaoke",
+ "subtle",
+] as const;
+
+export type FullEpisodeCaptionStyle = (typeof FULL_EPISODE_CAPTION_STYLES)[number];
+
+export interface FullEpisodeProgress {
+ percent: number;
+ message: string;
+}
+
+export function fullEpisodeOutputStem(videoPath: string): string {
+ const filename = basename(videoPath, extname(videoPath));
+ const safe = filename
+ .trim()
+ .replace(/[^a-zA-Z0-9._-]/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return `${safe || "episode"}_full_captioned`;
+}
+
+export function parseFullEpisodeProgress(line: string): FullEpisodeProgress | null {
+ const prefix = "PODCLI_PROGRESS=";
+ const marker = line.indexOf(prefix);
+ if (marker < 0) return null;
+ try {
+ const parsed = JSON.parse(line.slice(marker + prefix.length));
+ const percent = Number(parsed.percent);
+ if (!Number.isFinite(percent) || typeof parsed.message !== "string") return null;
+ return {
+ percent: Math.max(0, Math.min(100, Math.round(percent))),
+ message: parsed.message,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/utils/http-range.test.ts b/src/utils/http-range.test.ts
new file mode 100644
index 0000000..bb6df4f
--- /dev/null
+++ b/src/utils/http-range.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { resolveByteRange } from "./http-range.js";
+
+describe("resolveByteRange", () => {
+ it("parses bounded and open-ended ranges", () => {
+ expect(resolveByteRange("bytes=10-19", 100)).toEqual({ start: 10, end: 19 });
+ expect(resolveByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
+ expect(resolveByteRange("bytes=90-200", 100)).toEqual({ start: 90, end: 99 });
+ });
+
+ it("parses browser suffix ranges", () => {
+ expect(resolveByteRange("bytes=-20", 100)).toEqual({ start: 80, end: 99 });
+ expect(resolveByteRange("bytes=-200", 100)).toEqual({ start: 0, end: 99 });
+ });
+
+ it("rejects malformed or unsatisfiable ranges", () => {
+ expect(resolveByteRange("bytes=-0", 100)).toBeNull();
+ expect(resolveByteRange("bytes=100-", 100)).toBeNull();
+ expect(resolveByteRange("bytes=20-10", 100)).toBeNull();
+ expect(resolveByteRange("bytes=0-1,5-6", 100)).toBeNull();
+ });
+});
diff --git a/src/utils/http-range.ts b/src/utils/http-range.ts
new file mode 100644
index 0000000..5ac3d0e
--- /dev/null
+++ b/src/utils/http-range.ts
@@ -0,0 +1,24 @@
+export interface ByteRange {
+ start: number;
+ end: number;
+}
+
+/** Parse one RFC 9110 byte range, including suffix ranges used by browsers. */
+export function resolveByteRange(header: string, fileSize: number): ByteRange | null {
+ if (!Number.isSafeInteger(fileSize) || fileSize <= 0) return null;
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
+ if (!match || (!match[1] && !match[2])) return null;
+
+ if (!match[1]) {
+ const suffixLength = Number.parseInt(match[2], 10);
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return null;
+ const length = Math.min(suffixLength, fileSize);
+ return { start: fileSize - length, end: fileSize - 1 };
+ }
+
+ const start = Number.parseInt(match[1], 10);
+ const requestedEnd = match[2] ? Number.parseInt(match[2], 10) : fileSize - 1;
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd)) return null;
+ if (start < 0 || start >= fileSize || requestedEnd < start) return null;
+ return { start, end: Math.min(requestedEnd, fileSize - 1) };
+}
diff --git a/tests/test_silence_removal.py b/tests/test_silence_removal.py
new file mode 100644
index 0000000..050f8b2
--- /dev/null
+++ b/tests/test_silence_removal.py
@@ -0,0 +1,69 @@
+from services.silence_removal import (
+ plan_silence_removal,
+ probabilities_to_speech_segments,
+ remap_transcript,
+)
+
+
+def test_plan_preserves_short_pauses_and_removes_long_ones():
+ plan = plan_silence_removal(
+ 12.0,
+ [{"start": 1.0, "end": 3.0}, {"start": 3.4, "end": 5.0}, {"start": 7.0, "end": 10.0}],
+ [],
+ min_silence_seconds=0.65,
+ padding_seconds=0.1,
+ )
+
+ assert plan["cut_count"] == 3
+ assert plan["removed_ranges"] == [
+ {"start": 0.0, "end": 0.9},
+ {"start": 5.1, "end": 6.9},
+ {"start": 10.1, "end": 12.0},
+ ]
+ # 400 ms pause between the first two speech ranges stays intact.
+ assert plan["keep_segments"][0] == {"start": 0.9, "end": 5.1}
+
+
+def test_transcript_words_protect_speech_missed_by_vad():
+ plan = plan_silence_removal(
+ 6.0,
+ [],
+ [{"word": "quiet", "start": 2.0, "end": 2.5}],
+ min_silence_seconds=0.5,
+ padding_seconds=0.1,
+ )
+
+ assert plan["keep_segments"] == [{"start": 1.9, "end": 2.6}]
+ assert plan["cut_count"] == 2
+
+
+def test_remap_transcript_closes_removed_gaps():
+ transcript = {
+ "words": [
+ {"word": "one", "start": 1.0, "end": 1.4},
+ {"word": "two", "start": 5.0, "end": 5.4},
+ ],
+ "segments": [{"text": "one two", "start": 1.0, "end": 5.4}],
+ }
+ remapped = remap_transcript(
+ transcript,
+ [{"start": 0.5, "end": 2.0}, {"start": 4.5, "end": 6.0}],
+ )
+
+ assert remapped["words"][0]["start"] == 0.5
+ assert remapped["words"][1]["start"] == 2.0
+ assert remapped["segments"][0] == {"text": "one two", "start": 0.5, "end": 2.4}
+ assert remapped["duration"] == 3.0
+
+
+def test_probability_hysteresis_ignores_short_noise():
+ probabilities = [0.0] * 5 + [0.8] * 12 + [0.0] * 8 + [0.9] * 2 + [0.0] * 8
+ speech = probabilities_to_speech_segments(
+ probabilities,
+ len(probabilities) * 512,
+ min_speech_ms=250,
+ min_silence_ms=100,
+ )
+
+ assert len(speech) == 1
+ assert speech[0]["end"] > speech[0]["start"]
From c96176d387257b102ca33d66b5bd52f06df584ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=80=9Cayocodess=E2=80=9D?=
Date: Fri, 7 Aug 2026 09:35:52 +0200
Subject: [PATCH 02/12] feat: add clip transcript copying
---
README.md | 13 +++++++++++--
src/ui/client/ClipDetail.tsx | 11 +++++++++--
2 files changed, 20 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 48826d2..46fff50 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,18 @@
> [!NOTE]
-> **This is a maintained fork of [nmbrthirteen/podcli](https://github.com/nmbrthirteen/podcli).** It keeps Podcli's local processing and CLI while adding a simpler Studio workflow: full-episode YouTube preview and export, local silence removal, adjustable captions and logo placement, formatted transcript viewing and copying, and the `podclip` launcher. Upstream updates are merged regularly.
+> **This is a maintained product fork of [nmbrthirteen/podcli](https://github.com/nmbrthirteen/podcli).** It preserves Podcli's local-first engine and CLI while extending Studio for a simpler podcast-production workflow. Upstream changes are reviewed and merged regularly to limit drift.
-Launch the local Studio with `podclip`.
+### What this fork adds
+
+- One-command local Studio launch with `podclip`
+- Local silence detection, review, and removal before editing
+- Full-episode YouTube workflow with a large 16:9 preview and export
+- Live caption and logo previews with placement and font-size controls
+- Format-aware captions, including single-line captions for YouTube
+- Readable full-episode and per-clip transcripts with one-click copying
+
+Launch the Studio from any directory with `podclip`.
Open-source AI podcast clipper.
diff --git a/src/ui/client/ClipDetail.tsx b/src/ui/client/ClipDetail.tsx
index ee47053..5b2cfaa 100644
--- a/src/ui/client/ClipDetail.tsx
+++ b/src/ui/client/ClipDetail.tsx
@@ -391,8 +391,15 @@ export default function ClipDetail() {
{clip.transcript_slice && (
-
Transcript
-
{clip.transcript_slice}
+
+ Full clip transcript
+
+
+
{clip.transcript_slice}
)}
From 7c72488589bcb995df27324d6711d0ada6ee0b4c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:46:20 +0400
Subject: [PATCH 03/12] Bump actions/checkout in the actions-minor-patch group
(#131)
Bumps the actions-minor-patch group with 1 update: [actions/checkout](https://github.com/actions/checkout).
Updates `actions/checkout` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 7.0.1
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions-minor-patch
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/ci.yml | 10 +++++-----
.github/workflows/nightly.yml | 2 +-
.github/workflows/release.yml | 8 ++++----
3 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c288017..c38b1b3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,7 +16,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
@@ -45,7 +45,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -61,7 +61,7 @@ jobs:
name: Remotion release bundle
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
@@ -77,7 +77,7 @@ jobs:
name: Docs drift check
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -95,7 +95,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index d26db4c..fdd30dd 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -22,7 +22,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f81e498..7b26f96 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -31,7 +31,7 @@ jobs:
- { runner: ubuntu-latest, goos: linux, goarch: arm64 }
- { runner: ubuntu-latest, goos: windows, goarch: amd64 }
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
@@ -78,7 +78,7 @@ jobs:
WHISPER_REF: v1.7.4 # pin a known-good whisper.cpp tag
steps:
- name: Clone whisper.cpp
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ggml-org/whisper.cpp
ref: ${{ env.WHISPER_REF }}
@@ -180,7 +180,7 @@ jobs:
studio:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -211,7 +211,7 @@ jobs:
- { runner: windows-latest, goos: windows, goarch: amd64 }
runs-on: ${{ matrix.runner }}
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
From afc47a42ed675abff26d79a05576405f1856022b Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:46:25 +0400
Subject: [PATCH 04/12] Bump actions/setup-python from 6.3.0 to 7.0.0 (#132)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/ece7cb06caefa5fff74198d8649806c4678c61a1...5fda3b95a4ea91299a34e894583c3862153e4b97)
---
updated-dependencies:
- dependency-name: actions/setup-python
dependency-version: 7.0.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/ci.yml | 2 +-
.github/workflows/nightly.yml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c38b1b3..aa82145 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -98,7 +98,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: "pip"
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index fdd30dd..91cbdd0 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
- - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
From c2defb800c8ac2cc743d04ca229dbfaf85e79c4c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:46:31 +0400
Subject: [PATCH 05/12] Update onnxruntime requirement from >=1.27.0 to
>=1.28.0 in /backend (#133)
Updates the requirements on [onnxruntime](https://github.com/microsoft/onnxruntime) to permit the latest version.
- [Release notes](https://github.com/microsoft/onnxruntime/releases)
- [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseManagement.md)
- [Commits](https://github.com/microsoft/onnxruntime/compare/v1.27.0...v1.28.0)
---
updated-dependencies:
- dependency-name: onnxruntime
dependency-version: 1.28.0
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
backend/requirements-runtime.txt | 2 +-
backend/requirements.txt | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/requirements-runtime.txt b/backend/requirements-runtime.txt
index fed40ed..e8a9074 100644
--- a/backend/requirements-runtime.txt
+++ b/backend/requirements-runtime.txt
@@ -6,7 +6,7 @@ opencv-python-headless>=4.8.1.78
numpy>=2.5.1
# Audio-event detection (YAMNet laughter/reaction channel) runs on ONNX Runtime —
# no torch/TF, so it stays on the hermetic native path.
-onnxruntime>=1.27.0
+onnxruntime>=1.28.0
Pillow>=10.0.0
questionary>=2.1.1
python-dotenv>=1.2.2
diff --git a/backend/requirements.txt b/backend/requirements.txt
index ba25daf..160e7de 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -13,7 +13,7 @@ opencv-python-headless>=4.8.1.78
numpy>=2.5.1
# Audio-event detection (laughter/reaction channel via YAMNet ONNX — no torch/TF)
-onnxruntime>=1.27.0
+onnxruntime>=1.28.0
# Thumbnails
Pillow>=10.0.0
From ec24690ff7e2057977e180a6dd994b49c2ef3273 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:46:48 +0400
Subject: [PATCH 06/12] Bump the npm-minor-patch group across 1 directory with
9 updates (#138)
Bumps the npm-minor-patch group with 7 updates in the / directory:
| Package | From | To |
| --- | --- | --- |
| [@fontsource/dm-sans](https://github.com/fontsource/font-files/tree/HEAD/fonts/google/dm-sans) | `5.2.8` | `5.3.0` |
| [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) | `1.29.0` | `1.30.0` |
| [@remotion/bundler](https://github.com/remotion-dev/remotion) | `4.0.490` | `4.0.500` |
| [@remotion/cli](https://github.com/remotion-dev/remotion) | `4.0.490` | `4.0.500` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.25.0` | `1.27.0` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.7` | `19.2.8` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.7` | `19.2.8` |
Updates `@fontsource/dm-sans` from 5.2.8 to 5.3.0
- [Changelog](https://github.com/fontsource/font-files/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/google/dm-sans)
Updates `@modelcontextprotocol/sdk` from 1.29.0 to 1.30.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.29.0...1.30.0)
Updates `@remotion/bundler` from 4.0.490 to 4.0.500
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.490...v4.0.500)
Updates `@remotion/cli` from 4.0.490 to 4.0.500
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.490...v4.0.500)
Updates `@remotion/renderer` from 4.0.490 to 4.0.500
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.490...v4.0.500)
Updates `lucide-react` from 1.25.0 to 1.27.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.27.0/packages/lucide-react)
Updates `react` from 19.2.7 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react)
Updates `react-dom` from 19.2.7 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom)
Updates `remotion` from 4.0.490 to 4.0.500
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.490...v4.0.500)
---
updated-dependencies:
- dependency-name: "@fontsource/dm-sans"
dependency-version: 5.3.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: npm-minor-patch
- dependency-name: "@modelcontextprotocol/sdk"
dependency-version: 1.30.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: npm-minor-patch
- dependency-name: "@remotion/bundler"
dependency-version: 4.0.500
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: npm-minor-patch
- dependency-name: "@remotion/cli"
dependency-version: 4.0.500
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: npm-minor-patch
- dependency-name: "@remotion/renderer"
dependency-version: 4.0.500
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: npm-minor-patch
- dependency-name: lucide-react
dependency-version: 1.27.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: npm-minor-patch
- dependency-name: react
dependency-version: 19.2.8
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: npm-minor-patch
- dependency-name: react-dom
dependency-version: 19.2.8
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: npm-minor-patch
- dependency-name: remotion
dependency-version: 4.0.500
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: npm-minor-patch
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 792 +++++++++++++++++++++++++++++++++++-----------
package.json | 14 +-
2 files changed, 611 insertions(+), 195 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 0d6e8c0..e5e4f63 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,17 +9,17 @@
"version": "2.5.0",
"license": "AGPL-3.0-only",
"dependencies": {
- "@fontsource/dm-sans": "^5.2.8",
- "@modelcontextprotocol/sdk": "^1.29.0",
- "@remotion/bundler": "^4.0.490",
- "@remotion/cli": "^4.0.490",
+ "@fontsource/dm-sans": "^5.3.0",
+ "@modelcontextprotocol/sdk": "^1.30.0",
+ "@remotion/bundler": "^4.0.500",
+ "@remotion/cli": "^4.0.500",
"@remotion/renderer": "^4.0.490",
"dotenv": "^17.4.2",
"express": "^5.2.1",
- "lucide-react": "^1.25.0",
+ "lucide-react": "^1.27.0",
"multer": "^2.2.0",
- "react": "^19.2.7",
- "react-dom": "^19.2.7",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
"react-router-dom": "^6.30.4",
"remotion": "^4.0.490",
"uuid": "^14.0.1",
@@ -47,7 +47,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
@@ -62,7 +61,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -72,7 +70,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
@@ -103,7 +100,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@@ -119,7 +115,6 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -129,7 +124,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
@@ -146,7 +140,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@@ -162,7 +155,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.29.7",
@@ -179,7 +171,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
- "dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
@@ -189,7 +180,6 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -199,14 +189,12 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
- "dev": true,
"license": "ISC"
},
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -216,7 +204,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.7",
@@ -230,7 +217,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
@@ -276,7 +262,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -286,7 +271,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.29.7",
@@ -344,7 +328,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
@@ -359,7 +342,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@@ -375,7 +357,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
@@ -394,7 +375,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@@ -410,7 +390,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
@@ -888,9 +867,9 @@
}
},
"node_modules/@fontsource/dm-sans": {
- "version": "5.2.8",
- "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
- "integrity": "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA==",
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.3.0.tgz",
+ "integrity": "sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
@@ -922,7 +901,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -1004,12 +982,12 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
- "version": "1.29.0",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
- "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
+ "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"license": "MIT",
"dependencies": {
- "@hono/node-server": "^1.19.9",
+ "@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -1118,21 +1096,21 @@
}
},
"node_modules/@remotion/bundler": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.490.tgz",
- "integrity": "sha512-AZe7ncD+b0OUap5ZT8T0boONa7+I7Ap4+kAh5BZOa5sVaBjz4YI7eqILXtGqi+kNuBsG6pAZ4v4a2AW2v9afaw==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.500.tgz",
+ "integrity": "sha512-8GY3spQ+V4AngxJkCHM7E1nDyHzIbPAK8QsYfAN57ywL/oDkFInmd65rKYRLH2di5iiT+pO1z0RAXiC5h8FHwQ==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
- "@remotion/media-parser": "4.0.490",
- "@remotion/studio": "4.0.490",
- "@remotion/studio-shared": "4.0.490",
- "@remotion/timeline-utils": "4.0.490",
+ "@remotion/media-parser": "4.0.500",
+ "@remotion/studio": "4.0.500",
+ "@remotion/studio-shared": "4.0.500",
+ "@remotion/timeline-utils": "4.0.500",
"@rspack/core": "1.7.11",
"@rspack/plugin-react-refresh": "1.6.1",
"css-loader": "7.1.4",
"esbuild": "0.28.1",
"react-refresh": "0.18.0",
- "remotion": "4.0.490",
+ "remotion": "4.0.500",
"style-loader": "4.0.0",
"webpack": "5.105.0"
},
@@ -1142,13 +1120,13 @@
}
},
"node_modules/@remotion/canvas-capture": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/canvas-capture/-/canvas-capture-4.0.490.tgz",
- "integrity": "sha512-P9gCQ//7F0elQamT09eyoFobWsaHO0MMSqlA1ZAwy3IyL5/NQ1Rak8G7PJybucIuO0ISZxNF1Hkkb5tkWhfh4w==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/canvas-capture/-/canvas-capture-4.0.500.tgz",
+ "integrity": "sha512-aIhCwlXm+yMUcz1uG3mKW+daYUQpkpVUqroMeXgkXxEUO4PNGsTb0z/DiCviaWkihN+UYrBM4vMHyIebMAKVOA==",
"license": "Remotion License",
"dependencies": {
"mediabunny": "1.50.8",
- "remotion": "4.0.490"
+ "remotion": "4.0.500"
},
"peerDependencies": {
"react": ">=16.8.0",
@@ -1156,22 +1134,22 @@
}
},
"node_modules/@remotion/cli": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.490.tgz",
- "integrity": "sha512-S9H/EqWVJNx9nQc7pFMsg2TT6JrPfy1M2RhcrvyIKvQOxP3cV3dy/FqOMtM1JSUo+jXQ9/tyX757QMBoUMwVnA==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.500.tgz",
+ "integrity": "sha512-WH21tlWc2QSjOs/ID9FiIa/t3dptW+oVFLUqdxXRVyqo3Bg2z74n+oD55ntCJSfSokKkFB2hwjZUDz64X4X0EQ==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
- "@remotion/bundler": "4.0.490",
- "@remotion/media-utils": "4.0.490",
- "@remotion/player": "4.0.490",
- "@remotion/renderer": "4.0.490",
- "@remotion/studio": "4.0.490",
- "@remotion/studio-server": "4.0.490",
- "@remotion/studio-shared": "4.0.490",
+ "@remotion/bundler": "4.0.500",
+ "@remotion/media-utils": "4.0.500",
+ "@remotion/player": "4.0.500",
+ "@remotion/renderer": "4.0.500",
+ "@remotion/studio": "4.0.500",
+ "@remotion/studio-server": "4.0.500",
+ "@remotion/studio-shared": "4.0.500",
"dotenv": "17.3.1",
"minimist": "1.2.6",
"prompts": "2.4.2",
- "remotion": "4.0.490"
+ "remotion": "4.0.500"
},
"bin": {
"remotion": "remotion-cli.js",
@@ -1202,9 +1180,9 @@
"license": "MIT"
},
"node_modules/@remotion/compositor-darwin-arm64": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.490.tgz",
- "integrity": "sha512-llil+sp64oMTJvYBTpB7TuZkdpnhihmUzt44g5tQKTlS2KPN4LPMxXnQ+BiEz/4mEt8TnQhhZ3u13jJIdJyHcg==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.500.tgz",
+ "integrity": "sha512-wt+3mtivTcfQzq6t+piMMGf8jeCZxR4FzbIXxD7Hi3TB6D7Ivf6uJAXfuqym+m6Bei+Rgi/1WZbhM/jTdPz+lw==",
"cpu": [
"arm64"
],
@@ -1214,9 +1192,9 @@
]
},
"node_modules/@remotion/compositor-darwin-x64": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.490.tgz",
- "integrity": "sha512-jpjZ8H6uCj2dW8JXViQGQXQoZ3iKy9041tjZKghPlLbgTh9G6eWdi8RmJ9W9+svI7Q3GTh6cjUQrQn/9pgpyMA==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.500.tgz",
+ "integrity": "sha512-zK8biM14FISxjCJYOkyhvTk/5Aa6d5pmmCCh4zzjQeAOdJOWo9DytQ2SN/Uyi4UO7Usa6S+XoJjc9DhHFh62kA==",
"cpu": [
"x64"
],
@@ -1226,57 +1204,69 @@
]
},
"node_modules/@remotion/compositor-linux-arm64-gnu": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.490.tgz",
- "integrity": "sha512-i0nffOg4gq3z/pbMvl++l92BgN2NHXIsZPm+E6s3ksC4ZuU8G5DGgqt4gVKIDijRJUCxTUbQ8DdR390dPhrkfg==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.500.tgz",
+ "integrity": "sha512-atzS9pdEDEUu1IyjFhVKYnuUHhASnlVamwmCKTYX65kvZrFd+hKcrwNsEHnGJglqoMKBwKhUFps2FLmrfCRTgA==",
"cpu": [
"arm64"
],
+ "libc": [
+ "glibc"
+ ],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@remotion/compositor-linux-arm64-musl": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.490.tgz",
- "integrity": "sha512-xciEuhXb0NShigei+n8G0xrt/8g2jxmnSa6xQsIvwSee1kh1/fF4itpDKoi3pIMSUYkYMdN6GUdqqKXs16hkRw==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.500.tgz",
+ "integrity": "sha512-WRH7WAH/1T9Zi/dnd1B/BpvWhlLRivMvazf+tL4MvdKL6we7n/5TsBWceNFgqy+4tHO/Cg66sNvb3uvpv7zPgQ==",
"cpu": [
"arm64"
],
+ "libc": [
+ "musl"
+ ],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@remotion/compositor-linux-x64-gnu": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.490.tgz",
- "integrity": "sha512-w5P9iP+xSp6xGIJJl5aw6d9vVrzyh5P9GOAKVzmrYHIoubgwBlQZVltq805n0kITqDpTjVcNHDrt5C8avZorJg==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.500.tgz",
+ "integrity": "sha512-FyVcFtQfX0MRrCYaO+KyGu9Tn553rhbR+xSj0k/rc2P0rVFl+0MUHW9zcGIv7AjKoAw6EWbcOITApl/4v8M+fw==",
"cpu": [
"x64"
],
+ "libc": [
+ "glibc"
+ ],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@remotion/compositor-linux-x64-musl": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.490.tgz",
- "integrity": "sha512-wU8d3zhhWbHiQCssQPSj8fxp16adC0h/8PPiU+lqXvtokFYSt0Is+ya1NgUhRZc7THKg7RT5TVmRaV+qT/XaUA==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.500.tgz",
+ "integrity": "sha512-qN8hqtI6mKTXyH3cUGAdCdt6g3uyplvTo1uxnbzQvYZpJvI/SHEHRHqgdV95eWHDEH2u/fAx2IYlTXQmk9q27A==",
"cpu": [
"x64"
],
+ "libc": [
+ "musl"
+ ],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@remotion/compositor-win32-x64-msvc": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.490.tgz",
- "integrity": "sha512-k8bAJEiuVKJc1ZkJSmwm5g/pZOpQVXbVTVnCsnEjmsF+zbRoF6q71Ii8LuULxnOHBNLUSkfUg/Qlqh9YJ2FVOw==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.500.tgz",
+ "integrity": "sha512-nyFeFPbJMWCgqHJBUePZu51QNM0daO2ODLyBqc05BY4eZh3/O3u/CKE8W2aUInj15t3/pniDgH9gWgUiYrFxtQ==",
"cpu": [
"x64"
],
@@ -1285,26 +1275,32 @@
"win32"
]
},
+ "node_modules/@remotion/drag-and-drop": {
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/drag-and-drop/-/drag-and-drop-4.0.500.tgz",
+ "integrity": "sha512-AswAIQ7cbHwm8kJU1ZtMW3uNTPGP8K3h0XL/NMd44QUjunkdD64lsajqzHmQBioHo+S18xZdc2pcw1pOqartVQ==",
+ "license": "Remotion License"
+ },
"node_modules/@remotion/licensing": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.490.tgz",
- "integrity": "sha512-p+h1JDnWYPIdIvMbMogAkV2Rt8w/5td5c9cyAwK1RUQYMq3PM/Wq6sncgqcWc3DQqWB3NJdmGhPNwSc5tt0KWg==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.500.tgz",
+ "integrity": "sha512-36yWRWe/1Ns2LIB4fHc+GX2+BDhVRK51p7d9wL0jIyAdFRGFibAtlld8sEwGu3YPnVDKkZAGF/Ue0l6no8T2fQ==",
"license": "MIT"
},
"node_modules/@remotion/media-parser": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.490.tgz",
- "integrity": "sha512-Dj64dV0YAh5DiTYyGTRexJNPqa+plCB7YOc/ij5bEBvJfvDAx2zWZtoUNLVjosmhyniHdK3GSB9fUJoLBFUJLw==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.500.tgz",
+ "integrity": "sha512-QQnGq8wubusICRFB1tMWinJNKehGYi1nhGYyvOm1QgBttCG5qF3aISav1CJRwIWIkN152wfAFDUQuVy1htjSqg==",
"license": "Remotion License https://remotion.dev/license"
},
"node_modules/@remotion/media-utils": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.490.tgz",
- "integrity": "sha512-2bO0sSMfeSMlCKFaYggSXyLi8EjsCGdsOaE9c7SeWkCPpMiXSmnM+7eAWBrubEmjP4HMn9RFNJPlRX9MuXJlbA==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.500.tgz",
+ "integrity": "sha512-07Ht4xw6Ycsqk8AJeDoHqM4xHSI/T4BJyqixEDK8Yj8JKZewt6SIH5iPJjw6zPfw8u0+mvl9U1UV5HH+L6yOqg==",
"license": "MIT",
"dependencies": {
"mediabunny": "1.50.8",
- "remotion": "4.0.490"
+ "remotion": "4.0.500"
},
"peerDependencies": {
"react": ">=16.8.0",
@@ -1312,12 +1308,12 @@
}
},
"node_modules/@remotion/player": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.490.tgz",
- "integrity": "sha512-HlxhVgYfxOBoIO1ovp/AO6lZhDx9SZO3Pfk+lZ1FdnWaKINe7MLspx9NvuYq12gY/KuBvAnBPoCF0AVkRvPwOQ==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.500.tgz",
+ "integrity": "sha512-AwMdWV9gCBQcsLnl227iccI05A1Tpq3n+oweNk6h4P8n8580zpxUOF50RGfR9wLBDeU7XIivYvWtafuwVoIxIA==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
- "remotion": "4.0.490"
+ "remotion": "4.0.500"
},
"peerDependencies": {
"react": ">=16.8.0",
@@ -1325,26 +1321,26 @@
}
},
"node_modules/@remotion/renderer": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.490.tgz",
- "integrity": "sha512-Kc4hZ+Sj/taDcSfQG2vTJ26Qu9B4hUCGK0//959GtCWObtb5mZDkFA9fnnD8+yb2Sm48tiNTtO1M2YZ376r2BA==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.500.tgz",
+ "integrity": "sha512-cOE4siN1/Dp42fzX9svHpc83LqmrUZckpzIteFmS5iTCaC7lCWt34nuR4E6nmj5iRytAe/XbdXa2zjFL9bFUKQ==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
- "@remotion/licensing": "4.0.490",
- "@remotion/streaming": "4.0.490",
+ "@remotion/licensing": "4.0.500",
+ "@remotion/streaming": "4.0.500",
"execa": "5.1.1",
- "remotion": "4.0.490",
+ "remotion": "4.0.500",
"source-map": "0.8.0-beta.0",
"ws": "8.21.0"
},
"optionalDependencies": {
- "@remotion/compositor-darwin-arm64": "4.0.490",
- "@remotion/compositor-darwin-x64": "4.0.490",
- "@remotion/compositor-linux-arm64-gnu": "4.0.490",
- "@remotion/compositor-linux-arm64-musl": "4.0.490",
- "@remotion/compositor-linux-x64-gnu": "4.0.490",
- "@remotion/compositor-linux-x64-musl": "4.0.490",
- "@remotion/compositor-win32-x64-msvc": "4.0.490"
+ "@remotion/compositor-darwin-arm64": "4.0.500",
+ "@remotion/compositor-darwin-x64": "4.0.500",
+ "@remotion/compositor-linux-arm64-gnu": "4.0.500",
+ "@remotion/compositor-linux-arm64-musl": "4.0.500",
+ "@remotion/compositor-linux-x64-gnu": "4.0.500",
+ "@remotion/compositor-linux-x64-musl": "4.0.500",
+ "@remotion/compositor-win32-x64-msvc": "4.0.500"
},
"peerDependencies": {
"react": ">=16.8.0",
@@ -1365,32 +1361,33 @@
}
},
"node_modules/@remotion/streaming": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.490.tgz",
- "integrity": "sha512-q4LuNxkGlLg6iEdOUTKVMCsvIdl6ZcMzVMWBuasrhZ8DnceCPr7Xzy2qaeNxAGhk14I8xIDHbFRylwJ8vOHCoA==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.500.tgz",
+ "integrity": "sha512-r0DBivkKmWHhIf5UuWAbwjnexTRVEyrmVGaKMeafBSBwhMj52YGxXxKDmsh1LXX+LMm6rDlK0do0RMGkWYPHHg==",
"license": "MIT"
},
"node_modules/@remotion/studio": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.490.tgz",
- "integrity": "sha512-JY6Oln9hD/JcSTax4AIuiEQ5ZPsE4Rp8UHJHdcClETDvmIXPQPyYmVYYGMuLL/3kgwCzhzCdKFiwvLgre1p8AA==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.500.tgz",
+ "integrity": "sha512-hCc6ZONhg9DHHBl/e4yPZYQLcE7Dd5c3iItwM+vA8SKWOKjQ7MmFi8oLUiQg6o/Nrm26lTAIIUCtpVnOHWsGIw==",
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
- "@remotion/canvas-capture": "4.0.490",
- "@remotion/media-utils": "4.0.490",
- "@remotion/player": "4.0.490",
- "@remotion/renderer": "4.0.490",
- "@remotion/studio-shared": "4.0.490",
- "@remotion/timeline-utils": "4.0.490",
- "@remotion/web-renderer": "4.0.490",
- "@remotion/zod-types": "4.0.490",
+ "@remotion/canvas-capture": "4.0.500",
+ "@remotion/drag-and-drop": "4.0.500",
+ "@remotion/media-utils": "4.0.500",
+ "@remotion/player": "4.0.500",
+ "@remotion/renderer": "4.0.500",
+ "@remotion/studio-shared": "4.0.500",
+ "@remotion/timeline-utils": "4.0.500",
+ "@remotion/web-renderer": "4.0.500",
+ "@remotion/zod-types": "4.0.500",
"mediabunny": "1.50.8",
"memfs": "3.4.3",
"open": "8.4.2",
- "remotion": "4.0.490",
+ "remotion": "4.0.500",
"semver": "7.5.3",
- "zod": "4.3.6"
+ "zod": "4.4.3"
},
"peerDependencies": {
"react": ">=16.8.0",
@@ -1398,21 +1395,25 @@
}
},
"node_modules/@remotion/studio-server": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.490.tgz",
- "integrity": "sha512-QxYQ43Tp5iL4P7y/NBRGj5LCWY0FO4Lpn2eyNflNw1uZySxBayeoyiLhv1YG66GRMWnMzIgjj/2tWVXXlidOig==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.500.tgz",
+ "integrity": "sha512-9nk2aOy+QvQac8PNPkjS/m9fAwgkz1DNR8hCeG0bXrgUguUgovsTs1ey6MLShkJiyOWUXCEsZxNRb+U0dJjPsw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "7.24.1",
"@babel/types": "7.24.0",
- "@remotion/bundler": "4.0.490",
- "@remotion/renderer": "4.0.490",
- "@remotion/studio-shared": "4.0.490",
+ "@remotion/bundler": "4.0.500",
+ "@remotion/drag-and-drop": "4.0.500",
+ "@remotion/renderer": "4.0.500",
+ "@remotion/studio-shared": "4.0.500",
+ "@svgr/core": "8.1.0",
+ "@svgr/plugin-jsx": "8.1.0",
+ "kiwi-schema": "0.5.0",
"memfs": "3.4.3",
"open": "8.4.2",
"prettier": "3.8.1",
"recast": "0.23.11",
- "remotion": "4.0.490",
+ "remotion": "4.0.500",
"semver": "7.5.3"
}
},
@@ -1431,44 +1432,45 @@
}
},
"node_modules/@remotion/studio-shared": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.490.tgz",
- "integrity": "sha512-z84S704IEB8VIGnr6pZGH8XJFsrQD5fr8D5KqCXYqK6/KXW0qgv7965v3hlXhhCcD5rLfdoP8UdQHRpZolTlqw==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.500.tgz",
+ "integrity": "sha512-v2XN/i+SqgFF7teYcNOBxqPQgHwMfnBSjjdEuBFqWqsDRRWa9aD2ZXwVr6X2uqEEl9+bxX7LkvhzVLzv9hxRbA==",
"license": "MIT",
"dependencies": {
- "remotion": "4.0.490"
+ "@remotion/drag-and-drop": "4.0.500",
+ "remotion": "4.0.500"
}
},
"node_modules/@remotion/studio/node_modules/zod": {
- "version": "4.3.6",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
- "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@remotion/timeline-utils": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.490.tgz",
- "integrity": "sha512-thYL+XEe6sE0xs61tpeKfCJY4jFvXhtjLa2n9pDnihSGVdvSQ5wUwbDmunQMPZvAvbiEBA8SaMrQzupF9L2BrA==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.500.tgz",
+ "integrity": "sha512-QTvsTFlCgqW4TNmvuHUi85570XBxX4l3hve22lubUVkPbDJOsXklG0qec94XaL1sF5Gn9W91yo52P0udKpKT6A==",
"license": "MIT",
"dependencies": {
"mediabunny": "1.50.8"
}
},
"node_modules/@remotion/web-renderer": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.490.tgz",
- "integrity": "sha512-LXETwo5svXVba/MLafS8bgNxz0yrL4e85zXDFIicIsqudfOpdb9osmmqFTAima2Uw8U+k+U7k/lGeHj82qXOjg==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.500.tgz",
+ "integrity": "sha512-dyxYPiUyBJ9xBgkgdVMZjs00mhDvy79/HNavrYGOiORrtiuZMIGFwpbvoQD2BgT47rwxAKROILg/OhybhjEJEQ==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@mediabunny/aac-encoder": "1.50.8",
"@mediabunny/flac-encoder": "1.50.8",
"@mediabunny/mp3-encoder": "1.50.8",
- "@remotion/licensing": "4.0.490",
+ "@remotion/licensing": "4.0.500",
"mediabunny": "1.50.8",
- "remotion": "4.0.490"
+ "remotion": "4.0.500"
},
"peerDependencies": {
"react": ">=18.0.0",
@@ -1476,12 +1478,12 @@
}
},
"node_modules/@remotion/zod-types": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.490.tgz",
- "integrity": "sha512-4xJaX+nBW14Nt9rhvTan/XTryChEfrbN1mmxVjtulrk8vxvtPmCvKRJ8kdyION2vgCNG4sza8rfFoButa9b/DQ==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.500.tgz",
+ "integrity": "sha512-F0ctSK4O75TvvaC0HoAZ+9cG8B7cE023z3myDHmHp7Fsq98bqwGHWwKkQ2mMSHrgk53de47hHHJx0eZsD62eCg==",
"license": "MIT",
"dependencies": {
- "remotion": "4.0.490"
+ "remotion": "4.0.500"
}
},
"node_modules/@rolldown/pluginutils": {
@@ -2046,6 +2048,219 @@
"text-hex": "1.0.x"
}
},
+ "node_modules/@svgr/babel-plugin-add-jsx-attribute": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
+ "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-remove-jsx-attribute": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz",
+ "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz",
+ "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz",
+ "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-svg-dynamic-title": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz",
+ "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-svg-em-dimensions": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz",
+ "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-transform-react-native-svg": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz",
+ "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-plugin-transform-svg-component": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz",
+ "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/babel-preset": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz",
+ "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==",
+ "license": "MIT",
+ "dependencies": {
+ "@svgr/babel-plugin-add-jsx-attribute": "8.0.0",
+ "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0",
+ "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0",
+ "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0",
+ "@svgr/babel-plugin-svg-dynamic-title": "8.0.0",
+ "@svgr/babel-plugin-svg-em-dimensions": "8.0.0",
+ "@svgr/babel-plugin-transform-react-native-svg": "8.1.0",
+ "@svgr/babel-plugin-transform-svg-component": "8.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@svgr/core": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
+ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.21.3",
+ "@svgr/babel-preset": "8.1.0",
+ "camelcase": "^6.2.0",
+ "cosmiconfig": "^8.1.3",
+ "snake-case": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ }
+ },
+ "node_modules/@svgr/hast-util-to-babel-ast": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
+ "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.21.3",
+ "entities": "^4.4.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ }
+ },
+ "node_modules/@svgr/plugin-jsx": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz",
+ "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.21.3",
+ "@svgr/babel-preset": "8.1.0",
+ "@svgr/hast-util-to-babel-ast": "8.0.0",
+ "svg-parser": "^2.0.4"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/gregberge"
+ },
+ "peerDependencies": {
+ "@svgr/core": "*"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -2295,7 +2510,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2312,7 +2526,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2329,7 +2542,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2346,7 +2558,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2363,7 +2574,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2380,7 +2590,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2397,7 +2606,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2414,7 +2622,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2431,7 +2638,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2448,7 +2654,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2465,7 +2670,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2482,7 +2686,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2499,7 +2702,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2516,7 +2718,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2533,7 +2734,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2550,7 +2750,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2567,7 +2766,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2584,7 +2782,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2601,7 +2798,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2618,7 +2814,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3043,6 +3238,12 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -3270,6 +3471,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
@@ -3419,7 +3641,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
"license": "MIT"
},
"node_modules/cookie": {
@@ -3457,6 +3678,32 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/cosmiconfig": {
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
+ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+ "license": "MIT",
+ "dependencies": {
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0",
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3582,6 +3829,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/dot-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
+ "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
+ "license": "MIT",
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -3648,6 +3905,27 @@
"node": ">=10.13.0"
}
},
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
@@ -4120,7 +4398,6 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -4314,6 +4591,22 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -4338,6 +4631,12 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "license": "MIT"
+ },
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
@@ -4416,14 +4715,34 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/js-yaml": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
- "dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -4454,7 +4773,6 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
- "dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -4463,6 +4781,15 @@
"node": ">=6"
}
},
+ "node_modules/kiwi-schema": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/kiwi-schema/-/kiwi-schema-0.5.0.tgz",
+ "integrity": "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA==",
+ "license": "MIT",
+ "bin": {
+ "kiwic": "cli.js"
+ }
+ },
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -4478,6 +4805,12 @@
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
"license": "MIT"
},
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "license": "MIT"
+ },
"node_modules/loader-runner": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz",
@@ -4521,6 +4854,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lower-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
+ "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -4534,9 +4876,9 @@
}
},
"node_modules/lucide-react": {
- "version": "1.25.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
- "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
+ "version": "1.27.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
+ "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -4706,6 +5048,16 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
+ "node_modules/no-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
+ "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
+ "license": "MIT",
+ "dependencies": {
+ "lower-case": "^2.0.2",
+ "tslib": "^2.0.3"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.47",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
@@ -4810,6 +5162,36 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -4828,6 +5210,15 @@
"node": ">=8"
}
},
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@@ -5057,24 +5448,24 @@
}
},
"node_modules/react": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
- "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
- "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
- "react": "^19.2.7"
+ "react": "^19.2.8"
}
},
"node_modules/react-refresh": {
@@ -5149,9 +5540,9 @@
}
},
"node_modules/remotion": {
- "version": "4.0.490",
- "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.490.tgz",
- "integrity": "sha512-acyJNHljJvH7+I9kXJAK5bVVN7FAyM3tILGhnwZKJoB27Xp2WQ3cMyGIh8XFwlR66/ZESWkw74s49GItHejOWQ==",
+ "version": "4.0.500",
+ "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.500.tgz",
+ "integrity": "sha512-pMSW1YIkEB8UuM471JGyWklMoH1GBxQKAK36XpDy3z1yixritBLGt/KQsuv4n6k5eXxf37joEvXMbXqz20HZgg==",
"license": "SEE LICENSE IN LICENSE.md",
"peerDependencies": {
"react": ">=16.8.0",
@@ -5167,6 +5558,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -5518,6 +5918,16 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
+ "node_modules/snake-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
+ "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
+ "license": "MIT",
+ "dependencies": {
+ "dot-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -5641,6 +6051,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/svg-parser": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
+ "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
+ "license": "MIT"
+ },
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
@@ -5872,7 +6288,7 @@
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
diff --git a/package.json b/package.json
index 64049a5..22bdec2 100644
--- a/package.json
+++ b/package.json
@@ -35,17 +35,17 @@
"test:watch": "vitest"
},
"dependencies": {
- "@fontsource/dm-sans": "^5.2.8",
- "@modelcontextprotocol/sdk": "^1.29.0",
- "@remotion/bundler": "^4.0.490",
- "@remotion/cli": "^4.0.490",
+ "@fontsource/dm-sans": "^5.3.0",
+ "@modelcontextprotocol/sdk": "^1.30.0",
+ "@remotion/bundler": "^4.0.500",
+ "@remotion/cli": "^4.0.500",
"@remotion/renderer": "^4.0.490",
"dotenv": "^17.4.2",
"express": "^5.2.1",
- "lucide-react": "^1.25.0",
+ "lucide-react": "^1.27.0",
"multer": "^2.2.0",
- "react": "^19.2.7",
- "react-dom": "^19.2.7",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
"react-router-dom": "^6.30.4",
"remotion": "^4.0.490",
"uuid": "^14.0.1",
From 4a4afb06c340feef48c69c4e2687bf5f37cbc345 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:52:14 +0400
Subject: [PATCH 07/12] Update pillow requirement from >=10.0.0 to >=12.3.0 in
/backend (#120)
Updates the requirements on [pillow](https://github.com/python-pillow/Pillow) to permit the latest version.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/10.0.0...12.3.0)
---
updated-dependencies:
- dependency-name: pillow
dependency-version: 12.3.0
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
backend/requirements-runtime.txt | 2 +-
backend/requirements.txt | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/requirements-runtime.txt b/backend/requirements-runtime.txt
index e8a9074..e40e047 100644
--- a/backend/requirements-runtime.txt
+++ b/backend/requirements-runtime.txt
@@ -7,7 +7,7 @@ numpy>=2.5.1
# Audio-event detection (YAMNet laughter/reaction channel) runs on ONNX Runtime —
# no torch/TF, so it stays on the hermetic native path.
onnxruntime>=1.28.0
-Pillow>=10.0.0
+Pillow>=12.3.0
questionary>=2.1.1
python-dotenv>=1.2.2
yt-dlp>=2026.7.4
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 160e7de..aa2a2d5 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -16,7 +16,7 @@ numpy>=2.5.1
onnxruntime>=1.28.0
# Thumbnails
-Pillow>=10.0.0
+Pillow>=12.3.0
# CLI interactive prompts
questionary>=2.1.1
From d52946c1407b3399ca4c50a77ffc4481a8dc4772 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:52:18 +0400
Subject: [PATCH 08/12] Bump react-router-dom from 6.30.4 to 7.18.2 (#129)
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 6.30.4 to 7.18.2.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@7.18.2/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.18.2/packages/react-router-dom)
---
updated-dependencies:
- dependency-name: react-router-dom
dependency-version: 7.18.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 64 +++++++++++++++++++++++++++++------------------
package.json | 2 +-
2 files changed, 41 insertions(+), 25 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index e5e4f63..53de437 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,7 +20,7 @@
"multer": "^2.2.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
- "react-router-dom": "^6.30.4",
+ "react-router-dom": "^7.18.2",
"remotion": "^4.0.490",
"uuid": "^14.0.1",
"winston": "^3.17.0",
@@ -1086,15 +1086,6 @@
"@tybys/wasm-util": "^0.10.1"
}
},
- "node_modules/@remix-run/router": {
- "version": "1.23.3",
- "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
- "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/@remotion/bundler": {
"version": "4.0.500",
"resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.500.tgz",
@@ -5478,35 +5469,54 @@
}
},
"node_modules/react-router": {
- "version": "6.30.4",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
- "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
+ "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"license": "MIT",
"dependencies": {
- "@remix-run/router": "1.23.3"
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=20.0.0"
},
"peerDependencies": {
- "react": ">=16.8"
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
}
},
"node_modules/react-router-dom": {
- "version": "6.30.4",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
- "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
+ "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"license": "MIT",
"dependencies": {
- "@remix-run/router": "1.23.3",
- "react-router": "6.30.4"
+ "react-router": "7.18.2"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=20.0.0"
},
"peerDependencies": {
- "react": ">=16.8",
- "react-dom": ">=16.8"
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/react-router/node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/readable-stream": {
@@ -5800,6 +5810,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
diff --git a/package.json b/package.json
index 22bdec2..785f318 100644
--- a/package.json
+++ b/package.json
@@ -46,7 +46,7 @@
"multer": "^2.2.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
- "react-router-dom": "^6.30.4",
+ "react-router-dom": "^7.18.2",
"remotion": "^4.0.490",
"uuid": "^14.0.1",
"winston": "^3.17.0",
From 7742a1f8b8b3e9b0a33a2f7751a0fa507664905d Mon Sep 17 00:00:00 2001
From: Nika Siradze
Date: Fri, 7 Aug 2026 11:55:47 +0400
Subject: [PATCH 09/12] perf: parallelize face detection during reframing
(#137)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* perf: parallelize face detection during reframing
Face sampling in _track_and_crop was the dominant cost of the reframing
stage, and it was inference-bound rather than decode-bound: on a 60s 1080p
clip, YuNet accounted for ~83% of the time against ~17% for decode.
Detections are independent per frame and OpenCV releases the GIL inside
YuNet, so frames are now decoded serially (cheap, and it keeps frame
indices exact) and detected on a small thread pool. Each worker owns its
own cv2.FaceDetectorYN and walks its own stride of the batch: the detector
carries per-instance input-size state and is not thread-safe, and striding
keeps the assignment deterministic. Batches are bounded by bytes rather
than frame count so a 4K source does not hold a whole clip of decoded
frames in memory.
Measured on a 60s 1080p clip: the stage goes 3281ms -> 1179ms (2.8x), with
detection itself 2718ms -> 594ms (4.6x). Output is bit-identical, verified
across 620 real decoded frames at 4, 8 and 12 workers.
The pool is sized by workload as well as by cores. Each extra detector
costs ~21ms to construct against ~4.5ms per detection, so on a short clip
an unconditional pool is a net loss — a 2s clip measured 216ms -> 313ms
before the cap was added. Below 32 frames per worker it now stays serial.
Also downscale to 320px and drop audio before the scene-detect filter in
count_scene_cuts, which decoded every split-screen clip at full resolution
with audio. The scene score is a whole-frame statistic, so this yields the
same cuts for a fraction of the decode: 240ms -> 110ms on a 12s 1080p clip,
with scene scores within 0.005 of the full-resolution values.
Adds two diagnostics behind existing conventions: a timed() context manager
that emits stage timings at debug level (silent unless PODCLI_LOG_VERBOSE),
and PODCLI_CROP_DUMP, which writes each clip's computed camera path as JSON
so framing decisions can be diffed as text across a change. Crop decisions
were previously the least testable part of the pipeline — unit tests cover
the helper math, and the e2e render uses a synthetic video with no faces.
PODCLI_FACE_WORKERS=1 forces serial detection; it changes speed only, never
the result, so it is the first thing to reach for when bisecting a framing
regression.
* Filter reserved log keys in timed() so collisions cannot mask exceptions
* Assert the original exception survives a reserved-key collision
---
backend/services/clip_generator.py | 35 ++--
backend/services/local_reframe.py | 7 +-
backend/services/video_processor.py | 215 +++++++++++++++++++++--
backend/utils/log.py | 32 ++++
docs/configuration.md | 2 +
tests/test_crop_path_golden.py | 262 ++++++++++++++++++++++++++++
tests/test_local_reframe.py | 95 ++++++++++
tests/test_log_timed.py | 33 ++++
8 files changed, 650 insertions(+), 31 deletions(-)
create mode 100644 tests/test_crop_path_golden.py
create mode 100644 tests/test_local_reframe.py
create mode 100644 tests/test_log_timed.py
diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py
index 1f7c613..40d7f13 100644
--- a/backend/services/clip_generator.py
+++ b/backend/services/clip_generator.py
@@ -16,6 +16,7 @@
from utils.proc import run as proc_run, ProcError
from utils.text import safe_filename
+from utils.log import timed
from config.paths import paths
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -816,10 +817,11 @@ def generate_clip(
progress_callback(10, msg)
segment_path = os.path.join(work_dir, "segment.mp4")
- if keep_segments and len(keep_segments) > 1:
- cut_multi_segment(video_path, segment_path, keep_segments)
- else:
- cut_segment(video_path, segment_path, start_second, end_second)
+ with timed("render", "cut", segments=len(keep_segments) if keep_segments else 1):
+ if keep_segments and len(keep_segments) > 1:
+ cut_multi_segment(video_path, segment_path, keep_segments)
+ else:
+ cut_segment(video_path, segment_path, start_second, end_second)
# Remap transcript words for multi-segment clips.
# Needed before crop (speaker detection) and captions.
@@ -861,18 +863,19 @@ def generate_clip(
progress_callback(30, f"Resizing for {spec.name} format (2/{total_steps})")
cropped_path = os.path.join(work_dir, "cropped.mp4")
- if spec.reframe:
- crop_to_vertical(
- segment_path, cropped_path,
- strategy=crop_strategy,
- transcript_words=crop_words,
- clip_start=crop_clip_start,
- face_map=face_map,
- crop_keyframes=crop_keyframes,
- target_dims=spec.dims,
- )
- else:
- fit_to_frame(segment_path, cropped_path, target_dims=spec.dims)
+ with timed("render", "crop", strategy=crop_strategy if spec.reframe else "fit"):
+ if spec.reframe:
+ crop_to_vertical(
+ segment_path, cropped_path,
+ strategy=crop_strategy,
+ transcript_words=crop_words,
+ clip_start=crop_clip_start,
+ face_map=face_map,
+ crop_keyframes=crop_keyframes,
+ target_dims=spec.dims,
+ )
+ else:
+ fit_to_frame(segment_path, cropped_path, target_dims=spec.dims)
# Step 3: Render captions (Remotion-first; ASS fallback optional)
if transcript_words:
diff --git a/backend/services/local_reframe.py b/backend/services/local_reframe.py
index 9a66728..ee04caa 100644
--- a/backend/services/local_reframe.py
+++ b/backend/services/local_reframe.py
@@ -29,7 +29,12 @@ def count_scene_cuts(video_path: str, threshold: float = 0.35) -> int:
[
"ffmpeg",
"-i", str(video_path),
- "-filter:v", f"select='gt(scene,{threshold})',showinfo",
+ # Audio and subtitles are irrelevant to scene detection, and the
+ # scene score is a whole-frame statistic — computing it on a
+ # 320px-wide copy gives the same cuts for a fraction of the
+ # decode. Downscale before select so the filter sees small frames.
+ "-an", "-sn",
+ "-filter:v", f"scale=320:-2,select='gt(scene,{threshold})',showinfo",
"-f", "null", "-",
],
timeout=180,
diff --git a/backend/services/video_processor.py b/backend/services/video_processor.py
index 7256662..65be64b 100644
--- a/backend/services/video_processor.py
+++ b/backend/services/video_processor.py
@@ -9,11 +9,13 @@
import subprocess
import json
import math
+import time
+from concurrent.futures import ThreadPoolExecutor
from typing import Optional
from services.encoder import get_video_encode_flags
from utils.proc import run as proc_run, ProcError
-from utils.log import log_event
+from utils.log import log_event, timed
from services import media_probe
from services.media_probe import (
CPU_FLAGS,
@@ -980,6 +982,118 @@ def _face_sample_indices(total_frames: int, fps: float) -> list[int]:
return indices
+# Memory ceiling for one batch of decoded frames awaiting detection. Sized so
+# a 1080p clip batches ~40 frames and a 4K clip ~10 — enough to keep the pool
+# busy without holding a whole clip of decoded frames in RAM.
+_FACE_BATCH_BYTES = 256 * 1024 * 1024
+
+
+# Each extra worker needs its own cv2.FaceDetectorYN, which costs ~21ms to
+# construct against ~4.5ms per detection. Below this many frames per worker the
+# construction never pays for itself and the pool makes short clips *slower*,
+# so the pool is sized by workload rather than by core count alone.
+_FACE_FRAMES_PER_WORKER = 32
+
+
+def _face_detect_workers(sample_count: int = 0) -> int:
+ """Thread count for YuNet inference during face sampling.
+
+ PODCLI_FACE_WORKERS overrides and is taken literally; 1 disables the pool
+ entirely, which is the first thing to try when bisecting a framing
+ regression. Otherwise the count is capped both by cores and by how many
+ frames there are to share out — a 2s clip stays serial.
+ """
+ raw = os.environ.get("PODCLI_FACE_WORKERS", "").strip()
+ if raw:
+ try:
+ return max(1, int(raw))
+ except ValueError:
+ pass
+ by_cores = min(12, os.cpu_count() or 4)
+ by_work = sample_count // _FACE_FRAMES_PER_WORKER
+ return max(1, min(by_cores, by_work))
+
+
+def _detect_batch(batch: list, detectors: list, width: int, height: int, executor) -> list:
+ """Detect faces across a batch of (time, frame) pairs, input order preserved.
+
+ Each worker owns one detector and walks its own stride of the batch, so no
+ cv2.FaceDetectorYN instance is ever touched by two threads — they carry
+ per-instance input-size state and are not thread-safe. Striding (rather
+ than a shared work queue) also makes the assignment deterministic.
+ """
+ # Imported here, like every other cv2 dependency in this module, so the
+ # module stays importable without OpenCV installed.
+ from services.face_detector import detect_faces
+
+ if executor is None or len(detectors) < 2 or len(batch) < 2:
+ det = detectors[0]
+ return [(t, detect_faces(det, frame, width, height)) for t, frame in batch]
+
+ n = len(detectors)
+
+ def _stride(k: int) -> list:
+ det = detectors[k]
+ return [
+ (i, (batch[i][0], detect_faces(det, batch[i][1], width, height)))
+ for i in range(k, len(batch), n)
+ ]
+
+ merged: dict = {}
+ for part in executor.map(_stride, range(n)):
+ merged.update(part)
+ return [merged[i] for i in range(len(batch))]
+
+
+def _dump_crop_path(
+ *,
+ input_path: str,
+ keyframes_x: list,
+ crop_w: int,
+ crop_h: int,
+ crop_y: int,
+ width: int,
+ height: int,
+ detections: list,
+ segment_tracks: list,
+ has_any_split: bool,
+) -> None:
+ """Write the computed camera path to PODCLI_CROP_DUMP/.json.
+
+ No-op unless the env var is set, and never raises: this is diagnostics and
+ must not be able to fail a render. Frame-level detections are summarised
+ rather than dumped whole — the camera path is what a regression would move.
+ """
+ dump_dir = os.environ.get("PODCLI_CROP_DUMP")
+ if not dump_dir:
+ return
+ try:
+ os.makedirs(dump_dir, exist_ok=True)
+ stem = os.path.splitext(os.path.basename(input_path))[0]
+ payload = {
+ "source": os.path.basename(input_path),
+ "source_dims": [width, height],
+ "crop": {"w": crop_w, "h": crop_h, "y": crop_y},
+ "has_any_split": bool(has_any_split),
+ "detection_frames": len(detections),
+ "frames_with_faces": sum(1 for _, faces in detections if faces),
+ "segment_tracks": [
+ # (start, end, speaker, track_id, ...) — keep the framing-
+ # relevant fields, drop anything unhashable/verbose.
+ {"start": round(s[0], 3), "end": round(s[1], 3),
+ "speaker": s[2], "track_id": s[3]}
+ for s in segment_tracks
+ ],
+ "keyframes_x": [[t, x] for t, x in keyframes_x],
+ }
+ out = os.path.join(dump_dir, f"{stem}.crop.json")
+ with open(out, "w", encoding="utf-8") as f:
+ json.dump(payload, f, indent=2, sort_keys=True)
+ log_event("crop", "dumped-path", file=out, keyframes=len(keyframes_x))
+ except Exception as exc: # diagnostics must never break a render
+ log_event("crop", "dump-failed", level="warn", error=str(exc))
+
+
def _track_and_crop(
input_path: str,
output_path: str,
@@ -1032,21 +1146,78 @@ def _track_and_crop(
sample_indices = _face_sample_indices(total_frames, fps)
detections = [] # [(time, faces), ...]
+ # This loop is inference-bound, not decode-bound: on a 60s 1080p clip YuNet
+ # is ~83% of the time against ~17% for decode. So frames are decoded
+ # serially (cheap, and it keeps frame indices exact) and detected on a small
+ # pool. Detections are independent per frame and OpenCV releases the GIL
+ # inside YuNet, so results are bit-identical; measured 3281ms -> 1179ms for
+ # the stage, with detection itself going 2718ms -> 594ms.
+ #
+ # Batches are bounded by bytes rather than frame count so a 4K source does
+ # not hold an unbounded number of decoded frames in memory at once.
+ frame_bytes = max(1, width * height * 3)
+ batch_limit = max(1, int(_FACE_BATCH_BYTES / frame_bytes))
+
+ decode_ns = 0
+ detect_ns = 0
next_pos = 0
frame_idx = -1
- while next_pos < len(sample_indices):
- if not cap.grab():
- break
- frame_idx += 1
- if frame_idx < sample_indices[next_pos]:
- continue
- next_pos += 1
- ret, frame = cap.retrieve()
- if not ret:
- continue
- t = frame_idx / fps
- faces = detect_faces(detector, frame, width, height)
- detections.append((t, faces))
+ batch: list = []
+
+ # Detector construction is inside the timed block on purpose: at ~21ms per
+ # instance it is a real cost, and timing only the loop would hide it.
+ with timed("crop", "face_sampling", frames=total_frames) as t_fields:
+ workers = _face_detect_workers(len(sample_indices))
+ detectors = [detector]
+ if workers > 1:
+ detectors += [
+ d for d in (create_detector(width, height) for _ in range(workers - 1))
+ if d is not None
+ ]
+
+ executor = None
+ try:
+ if len(detectors) > 1:
+ executor = ThreadPoolExecutor(max_workers=len(detectors))
+
+ def _flush() -> None:
+ nonlocal detect_ns, batch
+ if not batch:
+ return
+ _t = time.perf_counter_ns()
+ detections.extend(
+ _detect_batch(batch, detectors, width, height, executor)
+ )
+ detect_ns += time.perf_counter_ns() - _t
+ batch = []
+
+ while next_pos < len(sample_indices):
+ _t0 = time.perf_counter_ns()
+ grabbed = cap.grab()
+ decode_ns += time.perf_counter_ns() - _t0
+ if not grabbed:
+ break
+ frame_idx += 1
+ if frame_idx < sample_indices[next_pos]:
+ continue
+ next_pos += 1
+ _t0 = time.perf_counter_ns()
+ ret, frame = cap.retrieve()
+ decode_ns += time.perf_counter_ns() - _t0
+ if not ret:
+ continue
+ batch.append((frame_idx / fps, frame))
+ if len(batch) >= batch_limit:
+ _flush()
+ _flush()
+ finally:
+ if executor is not None:
+ executor.shutdown(wait=True)
+
+ t_fields["samples"] = len(detections)
+ t_fields["workers"] = len(detectors)
+ t_fields["decode_ms"] = decode_ns // 1_000_000
+ t_fields["detect_ms"] = detect_ns // 1_000_000
cap.release()
@@ -1515,6 +1686,22 @@ def _nearest_face_cx(t_target: float, window: float = 1.5) -> float | None:
validated.append((kf_t, kf_x))
keyframes_x = validated
+ # ── Optional crop-path dump (regression harness) ─────────────
+ # Crop decisions are the least testable part of the pipeline: unit tests
+ # cover the helper math, and the e2e render uses a synthetic video with no
+ # faces in it. With PODCLI_CROP_DUMP set to a directory, every crop writes
+ # its computed camera path as JSON, so a change that was meant to be purely
+ # a speed optimization can be proven not to have moved the camera.
+ _dump_crop_path(
+ input_path=input_path,
+ keyframes_x=keyframes_x,
+ crop_w=crop_w, crop_h=crop_h, crop_y=crop_y,
+ width=width, height=height,
+ detections=detections,
+ segment_tracks=segment_tracks,
+ has_any_split=has_any_split,
+ )
+
# ── Build FFmpeg filter ──────────────────────────────────────
if not keyframes_x:
crop_x = max(0, (width - crop_w) // 2)
diff --git a/backend/utils/log.py b/backend/utils/log.py
index 7635f25..d43758b 100644
--- a/backend/utils/log.py
+++ b/backend/utils/log.py
@@ -9,9 +9,13 @@
import os
import sys
+import time
+from contextlib import contextmanager
_VERBOSE = os.environ.get("PODCLI_LOG_VERBOSE", "").lower() in ("1", "true", "yes")
+_RESERVED_FIELDS = frozenset({"category", "message", "level", "stage", "ms"})
+
def log_event(category: str, message: str, *, level: str = "info", **fields) -> None:
"""Emit one structured line: `[category] message k=v k=v`.
@@ -40,3 +44,31 @@ def warn(category: str, message: str, **fields) -> None:
def debug(category: str, message: str, **fields) -> None:
log_event(category, message, level="debug", **fields)
+
+
+@contextmanager
+def timed(category: str, stage: str, **fields):
+ """Time a block and emit `[category] timing stage=... ms=...` on exit.
+
+ Debug level, so stage timings stay silent unless PODCLI_LOG_VERBOSE is set.
+ Emits on failure too — a stage that blows up after 40s is exactly the one
+ worth seeing. Yields a dict the caller can add fields to before the line is
+ written, for counts that are only known once the block has run.
+ """
+ extra: dict = {}
+ start = time.perf_counter()
+ try:
+ yield extra
+ finally:
+ elapsed_ms = int((time.perf_counter() - start) * 1000)
+ # A caller field named like a log_event parameter would raise TypeError
+ # here and mask whatever exception is already unwinding.
+ payload = {
+ key: value
+ for key, value in {**fields, **extra}.items()
+ if key not in _RESERVED_FIELDS
+ }
+ log_event(
+ category, "timing", level="debug",
+ stage=stage, ms=elapsed_ms, **payload,
+ )
diff --git a/docs/configuration.md b/docs/configuration.md
index ba10292..f72a629 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -18,6 +18,8 @@ Copy `.env.example` to `.env`, or export these in your shell. `setup.sh` copies
| `PODCLI_BACKEND` | resolved | Override the Python backend directory |
| `PODCLI_PYTHON` | resolved | Override the Python interpreter. The launcher exports the resolved path as `PYTHON_PATH` for internal use |
| `PODCLI_TRANSITION_AUTOFIX_PASSES` | auto | Transition QA/autofix passes. Runs only on reframes that can produce hard cuts. Set a number to force it, `0` disables; the renderer caps it at 2 |
+| `PODCLI_FACE_WORKERS` | auto | Threads used for face detection during reframing. Auto sizes by core count (max 12) and by clip length, so short clips stay serial rather than paying to build detectors they cannot amortize. Set `1` to force serial — the first thing to try when bisecting a framing regression, since it changes speed only, never the result |
+| `PODCLI_CROP_DUMP` | unset | Directory to write each clip's computed camera path to as `.crop.json`. Diagnostics only; use it to diff framing decisions before and after a change |
| `FFMPEG_PATH` / `FFPROBE_PATH` | `ffmpeg` / `ffprobe` | Override the FFmpeg binaries |
Installed builds provision their own Python, Node, FFmpeg, and whisper.cpp, so the
diff --git a/tests/test_crop_path_golden.py b/tests/test_crop_path_golden.py
new file mode 100644
index 0000000..7472f2b
--- /dev/null
+++ b/tests/test_crop_path_golden.py
@@ -0,0 +1,262 @@
+"""Golden tests for the crop camera path and the crop-path dump.
+
+The helper tests next door check each piece of the tracking math in isolation.
+What they cannot catch is a change that leaves every helper correct but moves
+the camera anyway — a different sampling rate, a reordered pipeline, a changed
+default. Those are exactly the changes a "pure speed optimization" makes.
+
+So these lock the composed output: fixed detections in, exact keyframes out.
+No cv2, ffmpeg, or video files — the fixtures stand in for decoded frames.
+"""
+
+import json
+import os
+import sys
+import tempfile
+import unittest
+from concurrent.futures import ThreadPoolExecutor
+from unittest import mock
+
+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 services import video_processor as vp
+
+WIDTH, HEIGHT = 1920, 1080
+CROP_W = int(HEIGHT * (1080 / 1920)) # 607 — full-height 9:16 window
+
+
+def _face(cx, fw=180):
+ return {"cx": float(cx), "cy": 400.0, "fw": fw, "fh": fw, "confidence": 0.9}
+
+
+def _two_speaker_detections(n=40, step=0.1):
+ """Split-screen: a stable face at x=520 and another at x=1400."""
+ return [(round(i * step, 3), [_face(520), _face(1400)]) for i in range(n)]
+
+
+class FaceSampleIndicesGoldenTests(unittest.TestCase):
+ """The sampling schedule is the input to everything downstream. If a speed
+ change alters it, every keyframe below shifts — so pin the exact schedule."""
+
+ def test_30fps_schedule_is_exact(self):
+ idx = vp._face_sample_indices(90, 30.0)
+ # Every frame to 0.5s (0-14), every other to 1.0s (15-29), then ~10fps.
+ self.assertEqual(idx[:15], list(range(15)))
+ self.assertEqual(idx[15:19], [15, 16, 17, 18])
+ steady = [b - a for a, b in zip(idx, idx[1:]) if a >= 30]
+ self.assertEqual(set(steady), {3})
+ self.assertEqual(idx[-1], 87) # last sample before the 90-frame end
+
+ def test_sampling_rate_is_ten_fps_in_steady_state(self):
+ for fps in (24.0, 25.0, 30.0, 50.0, 60.0):
+ idx = vp._face_sample_indices(int(fps * 10), fps)
+ steady = [b - a for a, b in zip(idx, idx[1:]) if a >= fps]
+ self.assertTrue(steady, f"no steady-state samples at {fps}fps")
+ self.assertEqual(
+ set(steady), {max(1, int(fps / 10))},
+ f"steady-state step drifted off 10fps at {fps}fps",
+ )
+
+
+class AssignFaceTracksGoldenTests(unittest.TestCase):
+ def test_split_screen_yields_two_stable_tracks(self):
+ tracked = vp._assign_face_tracks(_two_speaker_detections(), WIDTH)
+ self.assertEqual(len(tracked), 40)
+ ids_per_frame = [{f["track_id"] for f in faces} for _, faces in tracked]
+ # Two identities, and they stay the same for the whole clip.
+ self.assertTrue(all(len(s) == 2 for s in ids_per_frame))
+ self.assertEqual(len(set().union(*ids_per_frame)), 2)
+
+ def test_track_ids_follow_position_not_list_order(self):
+ # Same two faces, but the detector returns them in flipped order
+ # halfway through. Identity must stay pinned to position.
+ dets = []
+ for i in range(20):
+ faces = [_face(520), _face(1400)]
+ dets.append((round(i * 0.1, 3), faces if i < 10 else faces[::-1]))
+ tracked = vp._assign_face_tracks(dets, WIDTH)
+ left_ids = {
+ min(faces, key=lambda f: f["cx"])["track_id"] for _, faces in tracked
+ }
+ self.assertEqual(len(left_ids), 1, "left face changed identity mid-clip")
+
+
+class TripodCameraGoldenTests(unittest.TestCase):
+ """The camera is what the viewer actually sees. Pin its behaviour."""
+
+ def test_force_snap_centres_exactly(self):
+ cam = vp._update_tripod_camera(
+ current_center_x=100.0, target_center_x=960.0,
+ crop_w=CROP_W, video_width=WIDTH, dt=0.0, force_snap=True,
+ )
+ self.assertEqual(cam, 960.0)
+
+ def test_camera_holds_still_for_small_drift(self):
+ start = 960.0
+ cam = start
+ # A face jittering by a few px must not move the camera at all.
+ for target in (964.0, 957.0, 962.0, 959.0):
+ cam = vp._update_tripod_camera(
+ current_center_x=cam, target_center_x=target,
+ crop_w=CROP_W, video_width=WIDTH, dt=0.1,
+ )
+ self.assertEqual(cam, start, "tripod drifted on sub-threshold jitter")
+
+ def test_camera_never_leaves_frame(self):
+ for target in (-500.0, 0.0, 99999.0):
+ cam = vp._update_tripod_camera(
+ current_center_x=960.0, target_center_x=target,
+ crop_w=CROP_W, video_width=WIDTH, dt=1.0, force_snap=True,
+ )
+ self.assertGreaterEqual(cam, CROP_W / 2)
+ self.assertLessEqual(cam, WIDTH - CROP_W / 2)
+
+
+class FaceDetectWorkersTests(unittest.TestCase):
+ """Pool size is a correctness-adjacent concern: each worker needs its own
+ ~21ms detector, so over-sizing the pool on a short clip is slower than
+ staying serial. Measured before this cap: a 2s clip went 216ms -> 313ms."""
+
+ def test_env_override_wins_and_is_taken_literally(self):
+ with mock.patch.dict(os.environ, {"PODCLI_FACE_WORKERS": "3"}):
+ self.assertEqual(vp._face_detect_workers(10_000), 3)
+ # Even when the workload would not justify it.
+ self.assertEqual(vp._face_detect_workers(1), 3)
+
+ def test_one_worker_is_allowed_to_disable_the_pool(self):
+ with mock.patch.dict(os.environ, {"PODCLI_FACE_WORKERS": "1"}):
+ self.assertEqual(vp._face_detect_workers(10_000), 1)
+
+ def test_garbage_and_zero_fall_back_to_a_sane_count(self):
+ for bad in ("garbage", "0", "-4"):
+ with mock.patch.dict(os.environ, {"PODCLI_FACE_WORKERS": bad}):
+ self.assertGreaterEqual(vp._face_detect_workers(10_000), 1)
+
+ def test_short_clips_stay_serial(self):
+ # A ~2s clip samples ~40 frames — not enough to repay a second detector.
+ with mock.patch.dict(os.environ, {"PODCLI_FACE_WORKERS": ""}):
+ self.assertEqual(vp._face_detect_workers(0), 1)
+ self.assertEqual(vp._face_detect_workers(40), 1)
+
+ def test_pool_grows_with_the_workload(self):
+ with mock.patch.object(os, "cpu_count", return_value=128):
+ with mock.patch.dict(os.environ, {"PODCLI_FACE_WORKERS": ""}):
+ self.assertEqual(vp._face_detect_workers(70), 2)
+ self.assertEqual(vp._face_detect_workers(170), 5)
+ # Capped at 12 no matter how long the clip or how many cores.
+ self.assertEqual(vp._face_detect_workers(100_000), 12)
+
+ def test_never_exceeds_core_count(self):
+ with mock.patch.object(os, "cpu_count", return_value=2):
+ with mock.patch.dict(os.environ, {"PODCLI_FACE_WORKERS": ""}):
+ self.assertEqual(vp._face_detect_workers(100_000), 2)
+
+
+class DetectBatchTests(unittest.TestCase):
+ """Parallel inference must be indistinguishable from serial. Verified on a
+ real 620-frame 1080p clip (5.5x faster, identical output); these pin the
+ ordering and detector-isolation properties that make that true."""
+
+ @staticmethod
+ def _fake_detect(det, frame, w, h):
+ # Encodes which detector handled the frame, so sharing is detectable.
+ return [{"cx": float(frame), "detector": det}]
+
+ def _run(self, n_frames, n_detectors, use_executor=True):
+ batch = [(i * 0.1, i) for i in range(n_frames)]
+ detectors = [f"det{k}" for k in range(n_detectors)]
+ executor = (
+ ThreadPoolExecutor(max_workers=n_detectors)
+ if use_executor and n_detectors > 1
+ else None
+ )
+ try:
+ with mock.patch(
+ "services.face_detector.detect_faces", side_effect=self._fake_detect
+ ):
+ return vp._detect_batch(batch, detectors, 1920, 1080, executor)
+ finally:
+ if executor:
+ executor.shutdown(wait=True)
+
+ def test_parallel_matches_serial_exactly(self):
+ serial = self._run(50, 1, use_executor=False)
+ parallel = self._run(50, 4)
+ self.assertEqual(
+ [(t, f[0]["cx"]) for t, f in parallel],
+ [(t, f[0]["cx"]) for t, f in serial],
+ )
+
+ def test_input_order_is_preserved(self):
+ out = self._run(37, 5)
+ self.assertEqual([t for t, _ in out], [i * 0.1 for i in range(37)])
+ self.assertEqual([f[0]["cx"] for _, f in out], [float(i) for i in range(37)])
+
+ def test_each_detector_is_used_by_exactly_one_stride(self):
+ out = self._run(20, 4)
+ # Frame i must be handled by detector i % 4 — deterministic striding,
+ # so no detector is ever touched by two threads.
+ for i, (_, faces) in enumerate(out):
+ self.assertEqual(faces[0]["detector"], f"det{i % 4}")
+
+ def test_falls_back_to_serial_without_executor(self):
+ out = self._run(10, 4, use_executor=False)
+ self.assertTrue(all(f[0]["detector"] == "det0" for _, f in out))
+
+ def test_handles_empty_and_single_frame_batches(self):
+ self.assertEqual(self._run(0, 4), [])
+ self.assertEqual(len(self._run(1, 4)), 1)
+
+
+class DumpCropPathTests(unittest.TestCase):
+ """The dump is the harness used to prove a speed change moved nothing.
+ If it is silently a no-op or throws, that proof is worthless."""
+
+ KWARGS = dict(
+ input_path="/tmp/clip_001.mp4",
+ keyframes_x=[(0.0, 300), (1.5, 900)],
+ crop_w=CROP_W, crop_h=HEIGHT, crop_y=0,
+ width=WIDTH, height=HEIGHT,
+ detections=_two_speaker_detections(n=3),
+ segment_tracks=[(0.0, 1.5, "SPEAKER_00", 1, None)],
+ has_any_split=True,
+ )
+
+ def test_writes_expected_payload(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ with mock.patch.dict(os.environ, {"PODCLI_CROP_DUMP": tmp}):
+ vp._dump_crop_path(**self.KWARGS)
+ out = os.path.join(tmp, "clip_001.crop.json")
+ self.assertTrue(os.path.exists(out), "no dump written")
+ with open(out, encoding="utf-8") as f:
+ payload = json.load(f)
+
+ self.assertEqual(payload["keyframes_x"], [[0.0, 300], [1.5, 900]])
+ self.assertEqual(payload["crop"], {"w": CROP_W, "h": HEIGHT, "y": 0})
+ self.assertEqual(payload["source_dims"], [WIDTH, HEIGHT])
+ self.assertEqual(payload["detection_frames"], 3)
+ self.assertEqual(payload["frames_with_faces"], 3)
+ self.assertEqual(
+ payload["segment_tracks"],
+ [{"start": 0.0, "end": 1.5, "speaker": "SPEAKER_00", "track_id": 1}],
+ )
+
+ def test_noop_without_env_var(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ env = {k: v for k, v in os.environ.items() if k != "PODCLI_CROP_DUMP"}
+ with mock.patch.dict(os.environ, env, clear=True):
+ vp._dump_crop_path(**self.KWARGS)
+ self.assertEqual(os.listdir(tmp), [])
+
+ def test_never_raises_on_bad_destination(self):
+ # An unwritable dump dir must not take a render down with it.
+ bad = "/dev/null/nope"
+ with mock.patch.dict(os.environ, {"PODCLI_CROP_DUMP": bad}):
+ vp._dump_crop_path(**self.KWARGS) # must not raise
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_local_reframe.py b/tests/test_local_reframe.py
new file mode 100644
index 0000000..fab73d8
--- /dev/null
+++ b/tests/test_local_reframe.py
@@ -0,0 +1,95 @@
+"""Tests for local_reframe scene-cut detection.
+
+count_scene_cuts runs a full decode of every split-screen clip, so it is worth
+keeping cheap. It downscales to 320px and drops audio before the scene filter:
+the scene score is a whole-frame statistic, so a small copy yields the same
+cuts. Measured on a 1080p source, that is ~2x faster with scene scores within
+0.005 of the full-resolution values.
+
+These tests pin the flags that make it cheap, so the optimization cannot be
+quietly dropped, and cover the parsing/failure contract callers rely on.
+"""
+
+import os
+import sys
+import unittest
+from unittest import mock
+
+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 services import local_reframe
+from utils.proc import ProcError
+
+
+def _result(returncode=0, stderr=""):
+ return mock.Mock(returncode=returncode, stdout="", stderr=stderr)
+
+
+# Two cuts, in the showinfo format the parser scrapes.
+_TWO_CUTS = (
+ "[Parsed_showinfo_1 @ 0x1] n:0 pts:61440 pts_time:4 pos:1 fmt:yuv420p\n"
+ "[Parsed_showinfo_1 @ 0x1] n:1 pts:122880 pts_time:8 pos:2 fmt:yuv420p\n"
+)
+
+
+class CountSceneCutsCommandTests(unittest.TestCase):
+ def _captured_cmd(self, **kwargs):
+ with mock.patch.object(
+ local_reframe, "proc_run", return_value=_result(stderr=_TWO_CUTS)
+ ) as run:
+ local_reframe.count_scene_cuts("/tmp/clip.mp4", **kwargs)
+ return run.call_args[0][0]
+
+ def test_downscales_before_scene_filter(self):
+ cmd = self._captured_cmd()
+ vf = cmd[cmd.index("-filter:v") + 1]
+ # Order matters: scale must come first so select sees small frames.
+ self.assertTrue(
+ vf.startswith("scale=320:-2,"),
+ f"scene filter is not running on a downscaled copy: {vf}",
+ )
+ self.assertIn("select=", vf)
+ self.assertIn("showinfo", vf)
+
+ def test_skips_audio_and_subtitle_decode(self):
+ cmd = self._captured_cmd()
+ self.assertIn("-an", cmd)
+ self.assertIn("-sn", cmd)
+
+ def test_threshold_is_passed_through(self):
+ vf = self._captured_cmd(threshold=0.5)[
+ self._captured_cmd(threshold=0.5).index("-filter:v") + 1
+ ]
+ self.assertIn("gt(scene,0.5)", vf)
+
+
+class CountSceneCutsResultTests(unittest.TestCase):
+ def test_counts_showinfo_lines(self):
+ with mock.patch.object(
+ local_reframe, "proc_run", return_value=_result(stderr=_TWO_CUTS)
+ ):
+ self.assertEqual(local_reframe.count_scene_cuts("/tmp/clip.mp4"), 2)
+
+ def test_no_cuts_returns_zero(self):
+ with mock.patch.object(local_reframe, "proc_run", return_value=_result()):
+ self.assertEqual(local_reframe.count_scene_cuts("/tmp/clip.mp4"), 0)
+
+ def test_ffmpeg_failure_returns_zero_not_raise(self):
+ # Callers treat 0 as "no info" and proceed with their default plan;
+ # a scene-detect failure must never take a render down.
+ with mock.patch.object(
+ local_reframe, "proc_run", return_value=_result(returncode=1)
+ ):
+ self.assertEqual(local_reframe.count_scene_cuts("/tmp/clip.mp4"), 0)
+
+ def test_proc_error_returns_zero_not_raise(self):
+ err = ProcError(["ffmpeg"], returncode=-9, stderr="timed out", duration=180.0)
+ with mock.patch.object(local_reframe, "proc_run", side_effect=err):
+ self.assertEqual(local_reframe.count_scene_cuts("/tmp/clip.mp4"), 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_log_timed.py b/tests/test_log_timed.py
new file mode 100644
index 0000000..3d11500
--- /dev/null
+++ b/tests/test_log_timed.py
@@ -0,0 +1,33 @@
+import os
+import sys
+import unittest
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if ROOT not in sys.path:
+ sys.path.insert(0, ROOT)
+
+from backend.utils.log import timed
+
+
+class TimedTests(unittest.TestCase):
+ def test_reserved_field_does_not_mask_exception(self):
+ with self.assertRaises(ValueError) as context:
+ with timed("crop", "detect", ms=1, message="collide"):
+ raise ValueError("original")
+ self.assertEqual(str(context.exception), "original")
+
+ def test_reserved_extra_field_does_not_mask_exception(self):
+ with self.assertRaises(ValueError) as context:
+ with timed("crop", "detect") as extra:
+ extra["level"] = "warn"
+ raise ValueError("original")
+ self.assertEqual(str(context.exception), "original")
+
+ def test_yields_dict_for_late_fields(self):
+ with timed("crop", "detect") as extra:
+ extra["frames"] = 12
+ self.assertEqual(extra["frames"], 12)
+
+
+if __name__ == "__main__":
+ unittest.main()
From 848afc71a7d541c8b5032ff415b3d1f70fd66708 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:55:52 +0400
Subject: [PATCH 10/12] Bump vitest from 2.1.9 to 4.1.10 (#130)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 2.1.9 to 4.1.10.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)
---
updated-dependencies:
- dependency-name: vitest
dependency-version: 4.1.10
dependency-type: direct:development
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 1101 ++++++++++++++++++++++++++++++++++++---------
package.json | 2 +-
2 files changed, 890 insertions(+), 213 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 53de437..8fa1159 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -37,7 +37,7 @@
"tsx": "^4.23.1",
"typescript": "^7.0.2",
"vite": "^5.4.21",
- "vitest": "^2.1.9"
+ "vitest": "^4.1.10"
},
"engines": {
"node": ">=18.0.0"
@@ -1086,6 +1086,16 @@
"@tybys/wasm-util": "^0.10.1"
}
},
+ "node_modules/@oxc-project/types": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
+ "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
"node_modules/@remotion/bundler": {
"version": "4.0.500",
"resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.500.tgz",
@@ -1477,6 +1487,262 @@
"remotion": "4.0.500"
}
},
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
+ "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
+ "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
+ "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
+ "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
+ "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
+ "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
+ "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
+ "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
+ "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
+ "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
+ "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
+ "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
+ "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
+ "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2039,6 +2305,13 @@
"text-hex": "1.0.x"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
@@ -2318,6 +2591,17 @@
"@types/node": "*"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -2328,6 +2612,13 @@
"@types/node": "*"
}
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",
@@ -2846,113 +3137,86 @@
}
},
"node_modules/@vitest/expect": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
- "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "2.1.9",
- "@vitest/utils": "2.1.9",
- "chai": "^5.1.2",
- "tinyrainbow": "^1.2.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/mocker": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
- "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "2.1.9",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.12"
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^5.0.0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
}
},
"node_modules/@vitest/pretty-format": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
- "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^1.2.0"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
- "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "2.1.9",
- "pathe": "^1.1.2"
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
- "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "2.1.9",
- "magic-string": "^0.30.12",
- "pathe": "^1.1.2"
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
- "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "tinyspy": "^3.0.2"
- },
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
- "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "2.1.9",
- "loupe": "^3.1.2",
- "tinyrainbow": "^1.2.0"
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -3423,16 +3687,6 @@
"node": ">= 0.8"
}
},
- "node_modules/cac": {
- "version": "6.7.14",
- "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
- "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -3504,32 +3758,15 @@
"license": "CC-BY-4.0"
},
"node_modules/chai": {
- "version": "5.3.3",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
- "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "assertion-error": "^2.0.1",
- "check-error": "^2.1.1",
- "deep-eql": "^5.0.1",
- "loupe": "^3.1.0",
- "pathval": "^2.0.0"
- },
"engines": {
"node": ">=18"
}
},
- "node_modules/check-error": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
- "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 16"
- }
- },
"node_modules/chrome-trace-event": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
@@ -3792,16 +4029,6 @@
}
}
},
- "node_modules/deep-eql": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
- "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/define-lazy-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
@@ -3820,6 +4047,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/dot-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
@@ -4304,6 +4541,24 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fecha": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
@@ -4796,6 +5051,279 @@
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
"license": "MIT"
},
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -4838,13 +5366,6 @@
"node": ">= 12.0.0"
}
},
- "node_modules/loupe": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
- "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@@ -5007,9 +5528,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.17",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+ "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"funding": [
{
"type": "github",
@@ -5091,6 +5612,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -5211,28 +5746,31 @@
}
},
"node_modules/pathe": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
- "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
- "node_modules/pathval": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
- "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14.16"
- }
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
@@ -5243,9 +5781,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.1",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
- "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
@@ -5262,7 +5800,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.8",
+ "nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -5577,6 +6115,46 @@
"node": ">=4"
}
},
+ "node_modules/rolldown": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
+ "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.143.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.3",
+ "@rolldown/binding-darwin-arm64": "1.2.3",
+ "@rolldown/binding-darwin-x64": "1.2.3",
+ "@rolldown/binding-freebsd-x64": "1.2.3",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.3",
+ "@rolldown/binding-linux-arm64-musl": "1.2.3",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.3",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.3",
+ "@rolldown/binding-linux-x64-gnu": "1.2.3",
+ "@rolldown/binding-linux-x64-musl": "1.2.3",
+ "@rolldown/binding-openharmony-arm64": "1.2.3",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.3",
+ "@rolldown/binding-win32-x64-msvc": "1.2.3"
+ }
+ },
+ "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -6004,9 +6582,9 @@
}
},
"node_modules/std-env": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
- "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
@@ -6184,36 +6762,36 @@
"license": "MIT"
},
"node_modules/tinyexec": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
- "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinypool": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
- "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": ">=18"
}
},
- "node_modules/tinyrainbow": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
- "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
"engines": {
- "node": ">=14.0.0"
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
}
},
- "node_modules/tinyspy": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
- "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6468,36 +7046,6 @@
}
}
},
- "node_modules/vite-node": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
- "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cac": "^6.7.14",
- "debug": "^4.3.7",
- "es-module-lexer": "^1.5.4",
- "pathe": "^1.1.2",
- "vite": "^5.0.0"
- },
- "bin": {
- "vite-node": "vite-node.mjs"
- },
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/vite-node/node_modules/es-module-lexer": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
- "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -6929,58 +7477,79 @@
}
},
"node_modules/vitest": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
- "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/expect": "2.1.9",
- "@vitest/mocker": "2.1.9",
- "@vitest/pretty-format": "^2.1.9",
- "@vitest/runner": "2.1.9",
- "@vitest/snapshot": "2.1.9",
- "@vitest/spy": "2.1.9",
- "@vitest/utils": "2.1.9",
- "chai": "^5.1.2",
- "debug": "^4.3.7",
- "expect-type": "^1.1.0",
- "magic-string": "^0.30.12",
- "pathe": "^1.1.2",
- "std-env": "^3.8.0",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
- "tinyexec": "^0.3.1",
- "tinypool": "^1.0.1",
- "tinyrainbow": "^1.2.0",
- "vite": "^5.0.0",
- "vite-node": "2.1.9",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
- "@types/node": "^18.0.0 || >=20.0.0",
- "@vitest/browser": "2.1.9",
- "@vitest/ui": "2.1.9",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
"happy-dom": "*",
- "jsdom": "*"
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
+ "@opentelemetry/api": {
+ "optional": true
+ },
"@types/node": {
"optional": true
},
- "@vitest/browser": {
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
@@ -6991,6 +7560,114 @@
},
"jsdom": {
"optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/vite": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.25",
+ "rolldown": "~1.2.1",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
}
}
},
diff --git a/package.json b/package.json
index 785f318..b2c3f2d 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,6 @@
"tsx": "^4.23.1",
"typescript": "^7.0.2",
"vite": "^5.4.21",
- "vitest": "^2.1.9"
+ "vitest": "^4.1.10"
}
}
From 866c02df60eb4df133e7abf7c2fb262e56c2f2e8 Mon Sep 17 00:00:00 2001
From: Nika Siradze
Date: Sat, 8 Aug 2026 23:49:58 +0400
Subject: [PATCH 11/12] Consolidate AI provider selection; add optional remote
sync (#146)
* Consolidate AI provider selection and cache CLI discovery
* Add optional remote sync for clips, assets and knowledge
* Match the whisper.cpp DTW preset to the loaded model
-dtw was hardcoded to the base alignment-head preset while the model came
from settings, so --fast (tiny.en) aborted whisper-cli with exit 3. Derive
the preset from the model file, and omit -dtw for models with no preset.
Also repoint two tests at the seams the AI provider consolidation moved:
they patched claude_suggest._find_ai_cli_candidates, which no longer gates
either path, so they only passed on machines with a real CLI installed.
* Address the review on the provider and sync work
Twenty threads, and the ones that mattered were about claiming success that
had not happened:
- `podcli sync` marked a clip synchronised before its video upload, so it
could exit happy while every share link played nothing.
- Nested knowledge files were pulled once and never pushed again: the local
scan was not recursive, so later edits to `brand/voice.md` never went back.
- Two workspace assets whose names differed only by folder collided on one
local file, and the workspace's own kind was thrown away in favour of
guessing from the extension.
- The backfill kept hashing and uploading after a 401 or 402 that was going
to refuse every remaining clip.
- Three fetches had no timeout, so one stalled connection hung a whole sync.
- The auth file was created with the default umask and chmodded afterwards,
leaving the session token briefly readable by anyone on the machine.
- `q4_k_m` models lost `-dtw` because the quantisation suffix pattern missed
underscore-qualified names. Covered by a regression case now.
- The CLI discovery cache ignored the .env file it also reads, so saving a
path in the studio did nothing until the process restarted.
The rest are smaller: JSON extraction now tries whichever opener comes first,
a model answering with an object where a list belongs is a failed attempt
rather than an AttributeError, a cloud 401 no longer tells someone to log
into a CLI they do not use, and the settings panels stop disagreeing about
whether a CLI was found.
* Keep the review fixes off the local path
Three of them reached further than the review asked:
The recursive knowledge scan used `readdir({ recursive })` and
`dirent.parentPath`, which need Node 20.1 and 20.12. podcli supports 18 and
CI runs 20, so that crash would have found Node 18 users rather than the
build. Walked by hand instead.
Dropping `classify_cli_error` entirely took the "run `claude` once in a
terminal" advice away from the local CLI users it is written for. It is now
skipped only for cloud and API attempts, which is what it was rewriting
wrongly. The CLI path tags an attempt with its engine name rather than "cli",
so the condition asks the question the other way round.
A clip whose rendered file is gone can never be uploaded, so it is settled
rather than reported as failed on every run, which is the unfixable number
this file already refuses to print.
* Drop a dead import and say why 'other' is not honoured
The asset kind list omits a type that is valid at both ends, which reads as an
oversight. It is not: 'other' is what anything unrecognised uploads as, so
taking it back would turn a local video into 'other' on the round trip.
* Finish the three the review only half got
Two of the earlier fixes stopped at the first call site, and one of them
created a new hole:
- The "no AI available" message was corrected in one of the two places that
print it. `handle_generate_custom` still sent cloud users to install a
binary they will never use.
- `AiSetup` learned to reject a bad payload; `WorkspaceInsights` reads the
same one and still dereferenced arrays the server can omit.
- Requiring `clips` to be a non-empty list still admits a null or a string
inside it, and the alternate responses were not checked at all, so `.get`
on one of those raised out of a path with nothing above it to catch. Clip
records, their scores and their segments are now each checked before use.
---
.gitignore | 3 +
backend/cli.py | 189 +++-
backend/main.py | 38 +-
backend/services/ai_cli.py | 497 +++++++++++
backend/services/ai_provider.py | 355 ++++++++
backend/services/claude_suggest.py | 826 ++++--------------
backend/services/content_generator.py | 161 ++--
backend/services/env_settings.py | 4 +-
.../integrations/youtube/learnings.py | 24 +-
backend/services/podcli_cloud.py | 356 ++++++++
backend/services/thumbnail_ai.py | 43 +-
backend/services/transcription_whispercpp.py | 30 +-
cli/internal/engine/engine.go | 28 +
cli/main.go | 9 +
scripts/build-studio.sh | 4 +-
src/models/index.ts | 8 +-
src/services/asset-sync.test.ts | 72 ++
src/services/asset-sync.ts | 173 ++++
src/services/clips-history-cloud.test.ts | 100 +++
src/services/clips-history.ts | 136 ++-
src/services/knowledge-sync.test.ts | 104 +++
src/services/knowledge-sync.ts | 172 ++++
src/services/podcli-cloud.ts | 292 +++++++
src/sync.ts | 86 ++
src/ui/client/AccountChip.tsx | 45 +
src/ui/client/AiSetup.tsx | 142 +++
src/ui/client/AnalyticsPage.tsx | 3 +
src/ui/client/ConfigPage.tsx | 6 +
src/ui/client/Layout.tsx | 3 +
src/ui/client/WorkspaceInsights.tsx | 118 +++
src/ui/public/css/styles.css | 9 +
src/ui/web-server.ts | 50 ++
tests/test_ai_fallback.py | 70 +-
tests/test_entitlement_chain.py | 80 ++
tests/test_find_moments.py | 16 +-
tests/test_suggest_handler.py | 4 +-
tests/test_whispercpp_adapter.py | 23 +-
37 files changed, 3430 insertions(+), 849 deletions(-)
create mode 100644 backend/services/ai_cli.py
create mode 100644 backend/services/ai_provider.py
create mode 100644 backend/services/podcli_cloud.py
create mode 100644 src/services/asset-sync.test.ts
create mode 100644 src/services/asset-sync.ts
create mode 100644 src/services/clips-history-cloud.test.ts
create mode 100644 src/services/knowledge-sync.test.ts
create mode 100644 src/services/knowledge-sync.ts
create mode 100644 src/services/podcli-cloud.ts
create mode 100644 src/sync.ts
create mode 100644 src/ui/client/AccountChip.tsx
create mode 100644 src/ui/client/AiSetup.tsx
create mode 100644 src/ui/client/WorkspaceInsights.tsx
create mode 100644 tests/test_entitlement_chain.py
diff --git a/.gitignore b/.gitignore
index 7f3afdd..c87914f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,6 +47,9 @@ tmp*.txt
# Episodes (generated content packages)
episodes/
+# Planning notes
+plans/
+
# Media & temp files
*.mp4
*.mp3
diff --git a/backend/cli.py b/backend/cli.py
index dc9f67d..27dd014 100644
--- a/backend/cli.py
+++ b/backend/cli.py
@@ -920,15 +920,15 @@ def _transcribe_progress(pct, msg):
print(" ⚠ No highlights found, falling back to transcript selection")
# Try an AI CLI first (uses PodStack knowledge base for intelligent selection)
- from services.claude_suggest import (
- suggest_initial_with_claude, blend_signal_scores, _engine_label, _find_ai_cli,
- )
+ from services import ai_provider
+ from services.ai_cli import _engine_label
+ from services.claude_suggest import blend_signal_scores, suggest_initial_with_claude
- ai_path, ai_engine = _find_ai_cli()
+ providers = ai_provider.status()["providers"]
if clips:
pass # already selected (resumed cache or saliency profile)
- elif ai_path and config.get("ai_select", True):
- ai_label = _engine_label(ai_engine)
+ elif providers and config.get("ai_select", True):
+ ai_label = providers[0]["label"]
print(f" [3/4] Selecting moments with {ai_label} (PodStack)...")
clips = suggest_initial_with_claude(
segments=segments,
@@ -938,8 +938,9 @@ def _transcribe_progress(pct, msg):
)
if clips:
blend_signal_scores(clips, energy_data=energy_data, events_data=events_data)
- actual_engine = next((c.get("_ai_engine") for c in clips if c.get("_ai_engine")), ai_engine)
- print(f" ✓ {_engine_label(actual_engine)} selected {len(clips)} clips")
+ engine_id = next((c.get("_ai_engine") for c in clips if c.get("_ai_engine")), "")
+ actual_engine = _engine_label(engine_id) if engine_id in ("claude", "codex") else ai_label
+ print(f" ✓ {actual_engine} selected {len(clips)} clips")
_save_suggestions_session(cache_hash, top_n, actual_engine, clips, selection_sig)
else:
print(" ⚠ AI CLI unavailable, falling back to heuristics")
@@ -1006,9 +1007,9 @@ def _transcribe_progress(pct, msg):
pass
_thumb_intro_duration = max(0.5, min(_thumb_intro_duration, 1.0))
- # Check if AI CLI is available for per-clip content generation
- from services.claude_suggest import _find_ai_cli
- _ai_cli_path, _ = _find_ai_cli()
+ # Per-clip content generation needs any provider, not specifically a binary.
+ from services import ai_provider
+ _ai_cli_path = "cloud" if ai_provider.available() else None
# Pre-load thumbnail tools if enabled
_thumb_gen = None
@@ -3491,9 +3492,15 @@ def print_banner():
_diarization_ok = False
speakers_ok = bool(hf_token) and _diarization_ok
- # Check AI CLI (Claude Code or Codex)
- from services.claude_suggest import _find_ai_cli
- ai_path, ai_engine = _find_ai_cli()
+ # `info` should report what AI podcli will actually use, which for a
+ # signed-in user is the workspace rather than any local binary.
+ from services import ai_provider
+ # Every other lookup in this banner is guarded. This one reads and parses
+ # the local auth file, so a truncated one would take `podcli info` with it.
+ try:
+ _providers = ai_provider.status()["providers"]
+ except Exception:
+ _providers = []
print(f" {bold}podcli{reset} v{VERSION}")
@@ -3504,8 +3511,8 @@ def print_banner():
cache_count = len([f for f in os.listdir(cache_dir) if f.endswith(".json")])
# Status — one line
- ai_label = ("Claude" if ai_engine == "claude" else "Codex") if ai_path else "AI CLI"
- ai_tag = f"{green}✓ {ai_label}{reset}" if ai_path else f"{yellow}✗{reset}"
+ ai_label = _providers[0]["label"] if _providers else "AI"
+ ai_tag = f"{green}✓ {ai_label}{reset}" if _providers else f"{yellow}✗{reset}"
speaker_tag = f"{green}✓{reset}" if speakers_ok else f"{yellow}✗{reset}"
cache_tag = f"{green}{cache_count}{reset}" if cache_count else f"{gray}0{reset}"
kb_tag = f"{green}{kb_count}{reset}" if kb_count else f"{yellow}0{reset}"
@@ -3633,6 +3640,132 @@ def print_help():
print()
+def cmd_login(args):
+ import getpass
+ from services import podcli_cloud
+
+ email = (args.email or input("Email: ")).strip()
+ # Prefer the prompt: a password in argv is visible in ps output and lands in
+ # the user's shell history.
+ password = args.password or getpass.getpass("Password: ")
+ if not email or not password:
+ print("Email and password are required.")
+ sys.exit(1)
+
+ try:
+ podcli_cloud.login(email, password)
+ account = podcli_cloud.me()
+ podcli_cloud.remember_plan(account.get("plan", ""))
+ except podcli_cloud.CloudError as exc:
+ print(f"Sign-in failed: {exc}")
+ sys.exit(1)
+
+ workspace = account.get("workspace") or {}
+ print(f"Signed in to {workspace.get('name', 'your workspace')} "
+ f"({account.get('plan', 'free')} plan, {account.get('role', 'member')}).")
+ if account.get("plan") == "free":
+ print("This workspace has no active subscription — podcli will keep using "
+ "your local AI CLI until one starts.")
+
+ # Everything already rendered on this machine belongs in the workspace too,
+ # so the performance model starts with a back catalogue instead of nothing.
+ try:
+ synced, failed = podcli_cloud.backfill_clips()
+ except Exception:
+ synced, failed = 0, 0
+ if synced:
+ print(f"Synced {synced} existing clip{'s' if synced != 1 else ''} to your workspace.")
+ if failed:
+ print(f"{failed} could not be synced — `podcli whoami` will retry later.")
+
+
+def cmd_logout(args):
+ from services import podcli_cloud
+
+ if not podcli_cloud.signed_in():
+ print("Not signed in.")
+ return
+ podcli_cloud.clear_token()
+ print("Signed out. podcli will use your local AI CLI from now on.")
+
+
+def cmd_whoami(args):
+ from services import ai_provider, podcli_cloud
+
+ if not podcli_cloud.signed_in():
+ print("Not signed in to podcli Pro. Run `podcli login`.")
+ else:
+ try:
+ account = podcli_cloud.me()
+ podcli_cloud.remember_plan(account.get("plan", ""))
+ workspace = account.get("workspace") or {}
+ print(f"Signed in to {workspace.get('name', '?')} "
+ f"({account.get('plan')} plan, {account.get('role')})")
+ used = workspace.get("episodes_used")
+ if used is not None:
+ print(f"Episodes used this month: {used}")
+ except podcli_cloud.CloudError as exc:
+ print(f"Signed in, but the account could not be checked: {exc}")
+
+ providers = ai_provider.status()["providers"]
+ if providers:
+ print("AI will use: " + " → ".join(p["label"] for p in providers))
+ else:
+ print("No AI available. Install Claude Code, set ANTHROPIC_API_KEY, or sign in.")
+
+
+def cmd_workspace(args):
+ from services import podcli_cloud
+
+ if not podcli_cloud.signed_in():
+ print("Not signed in to podcli Pro. Run `podcli login`.")
+ sys.exit(1)
+
+ action = getattr(args, "workspace_action", None) or "list"
+ try:
+ if action == "new":
+ created = podcli_cloud.create_workspace(args.name)
+ print(f"Created {created['name']} and switched to it (free plan).")
+ print("Each show carries its own subscription, so this one needs its own.")
+ _warn_local_data()
+ return
+
+ workspaces = podcli_cloud.list_workspaces()
+
+ if action == "use":
+ target = next(
+ (w for w in workspaces
+ if args.name.lower() in (w["name"].lower(), w["id"].lower())),
+ None,
+ )
+ if not target:
+ print(f"No workspace matching {args.name!r}.")
+ sys.exit(1)
+ switched = podcli_cloud.switch_workspace(target["id"])
+ print(f"Switched to {switched['name']} ({switched['plan']} plan).")
+ _warn_local_data()
+ return
+
+ for w in workspaces:
+ marker = "*" if w.get("current") else " "
+ print(f" {marker} {w['name']} ({w['plan']}, {w['role']})")
+ except podcli_cloud.CloudError as exc:
+ print(f"Could not reach podcli Pro: {exc}")
+ sys.exit(1)
+
+
+def _warn_local_data():
+ """Switching workspace does not move the local knowledge base or assets.
+
+ Those live in .podcli/ on this machine, and a second show's brand voice
+ overwriting the first is data loss rather than a sync. Keeping each show in
+ its own directory (or PODCLI_HOME) is the honest answer until profiles do it
+ automatically.
+ """
+ print()
+ print(" Local .podcli/ data is per-directory, not per-workspace.")
+ print(" Work on each show from its own folder so their knowledge bases "
+ "and assets stay separate.")
def _onboarding_marker() -> str:
return os.path.join(paths["home"], ".onboarded")
@@ -3824,6 +3957,20 @@ def main():
parser.add_argument("--no-banner", action="store_true", help=argparse.SUPPRESS)
sub = parser.add_subparsers(dest="command")
+ # ── podcli Pro account ──
+ login_p = sub.add_parser("login", help="Sign in to podcli Pro")
+ login_p.add_argument("--email", help="Account email (prompted if omitted)")
+ login_p.add_argument("--password", help="Password (prompted if omitted; prefer the prompt)")
+ sub.add_parser("logout", help="Sign out of podcli Pro on this machine")
+ sub.add_parser("whoami", help="Show the signed-in podcli Pro account")
+ ws_p = sub.add_parser("workspace", help="Switch between shows in podcli Pro")
+ ws_sub = ws_p.add_subparsers(dest="workspace_action")
+ ws_sub.add_parser("list", help="List your workspaces")
+ ws_new = ws_sub.add_parser("new", help="Create a workspace for another show")
+ ws_new.add_argument("name", help="Workspace name")
+ ws_use = ws_sub.add_parser("use", help="Switch to a workspace")
+ ws_use.add_argument("name", help="Workspace name or id")
+
# ── process ──
proc = sub.add_parser("process", help="Process a video into clips")
proc.add_argument("video", nargs="?", default=None, help="Path to podcast video file (optional if preset has video_path)")
@@ -4160,7 +4307,15 @@ def main():
print(" Setup cancelled. Your command did not run.", file=sys.stderr)
sys.exit(130)
- if args.command == "process":
+ if args.command == "login":
+ cmd_login(args)
+ elif args.command == "logout":
+ cmd_logout(args)
+ elif args.command == "whoami":
+ cmd_whoami(args)
+ elif args.command == "workspace":
+ cmd_workspace(args)
+ elif args.command == "process":
if not getattr(args, "no_banner", False):
print()
cmd_process(args)
diff --git a/backend/main.py b/backend/main.py
index bb52b4e..6368b43 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -676,8 +676,8 @@ def _signal_profiles_for_suggest(
def handle_suggest_clips(task_id: str, params: dict):
"""AI-powered clip suggestion using Claude/Codex and PodStack knowledge base."""
+ from services import ai_provider
from services.claude_suggest import (
- _find_ai_cli_candidates,
select_clips_with_signal_scores,
suggest_initial_with_claude,
)
@@ -690,13 +690,15 @@ def handle_suggest_clips(task_id: str, params: dict):
emit_result(task_id, "error", error="segments is required")
return
- if not _find_ai_cli_candidates():
+ # Gate on the provider chain, not on a local binary: a signed-in Pro user
+ # has AI available without installing anything.
+ if not ai_provider.available():
emit_result(
task_id,
"error",
error=(
- "No AI CLI available (install Claude Code or Codex). "
- "If already installed, set the path in Config → AI CLI or PODCLI_CLAUDE_PATH."
+ "No AI available. Sign in with `podcli login`, install Claude Code "
+ "or Codex, or set ANTHROPIC_API_KEY."
),
)
return
@@ -740,11 +742,19 @@ def handle_manage_env(task_id: str, params: dict):
def handle_ai_cli_status(task_id: str, params: dict):
- from services.claude_suggest import get_ai_cli_status
+ from services.ai_cli import get_ai_cli_status
emit_result(task_id, "success", data=get_ai_cli_status())
+def handle_ai_provider_status(task_id: str, params: dict):
+ """Everything podcli can use for AI, not just local binaries — so the studio
+ can tell "nothing installed" apart from "signed in, nothing needed"."""
+ from services import ai_provider
+
+ emit_result(task_id, "success", data=ai_provider.status())
+
+
def handle_find_moment(task_id: str, params: dict):
"""Locate user-pasted/described moments in the transcript via the AI CLI."""
from services.claude_suggest import find_moments_from_text
@@ -774,7 +784,7 @@ def handle_find_moment(task_id: str, params: dict):
def handle_generate_content(task_id: str, params: dict):
"""Generate titles, descriptions, tags for a clip using PodStack knowledge base."""
from services.content_generator import generate_clip_content
- from services.claude_suggest import _find_ai_cli_candidates
+ from services import ai_provider
clip = params.get("clip", {})
transcript_segments = params.get("transcript_segments", [])
@@ -783,13 +793,15 @@ def handle_generate_content(task_id: str, params: dict):
emit_result(task_id, "error", error="clip is required")
return
- if not _find_ai_cli_candidates():
+ # Gate on the provider chain, not on a local binary: a signed-in Pro user
+ # has AI available without installing anything.
+ if not ai_provider.available():
emit_result(
task_id,
"error",
error=(
- "No AI CLI available (install Claude Code or Codex). "
- "If already installed, set the path in Config → AI CLI or PODCLI_CLAUDE_PATH."
+ "No AI available. Sign in with `podcli login`, install Claude Code "
+ "or Codex, or set ANTHROPIC_API_KEY."
),
)
return
@@ -808,7 +820,7 @@ def handle_generate_content(task_id: str, params: dict):
emit_result(
task_id,
"error",
- error="AI CLI found but content generation failed — check claude/codex login and try again",
+ error="Content generation failed — check that your AI provider is reachable and try again",
)
return
@@ -832,7 +844,10 @@ def handle_generate_custom(task_id: str, params: dict):
)
if result is None:
- emit_result(task_id, "error", error="No AI CLI available (install Claude Code or Codex)")
+ # The gate above admits a workspace session or an API key as well as a
+ # local binary, so naming only the binary sends cloud users to install
+ # something they will never use.
+ emit_result(task_id, "error", error="No AI provider available — sign in to podcli Pro, install Claude Code or Codex, or set ANTHROPIC_API_KEY")
return
emit_result(task_id, "success", data=result)
@@ -927,6 +942,7 @@ def handle_run_integration_tool(task_id: str, params: dict):
"find_moment": handle_find_moment,
"manage_env": handle_manage_env,
"ai_cli_status": handle_ai_cli_status,
+ "ai_provider_status": handle_ai_provider_status,
"generate_content": handle_generate_content,
"generate_custom": handle_generate_custom,
"manage_integrations": handle_manage_integrations,
diff --git a/backend/services/ai_cli.py b/backend/services/ai_cli.py
new file mode 100644
index 0000000..6d8daf0
--- /dev/null
+++ b/backend/services/ai_cli.py
@@ -0,0 +1,497 @@
+"""Discovery and invocation of the user's local AI CLI (Claude Code or Codex).
+
+Finding the binary is genuinely hard: npm prefixes, version managers, shell
+aliases and platform extensions all move it. That search lives here so the
+provider layer above can treat "run this prompt" as one call.
+"""
+
+import os
+import subprocess
+import sys
+from functools import lru_cache
+from typing import Optional
+
+def _cli_name_exts() -> list[str]:
+ if sys.platform == "win32":
+ return ["", ".cmd", ".exe", ".bat"]
+ return [""]
+
+
+def _resolve_cli_path(path: str) -> Optional[str]:
+ for ext in _cli_name_exts():
+ candidate = path + ext
+ if os.path.isfile(candidate):
+ return candidate
+ return None
+
+
+def _dedupe_dirs(dirs: list[str]) -> list[str]:
+ seen: set[str] = set()
+ ordered: list[str] = []
+ for directory in dirs:
+ if not directory:
+ continue
+ directory = os.path.expanduser(directory)
+ if directory in seen:
+ continue
+ seen.add(directory)
+ if os.path.isdir(directory):
+ ordered.append(directory)
+ return ordered
+
+
+def _npmrc_prefix_dirs() -> list[str]:
+ dirs: list[str] = []
+ npmrc_paths = [os.path.join(os.path.expanduser("~"), ".npmrc")]
+ try:
+ from services.env_settings import _env_path
+ npmrc_paths.append(os.path.join(os.path.dirname(_env_path()), ".npmrc"))
+ except Exception:
+ pass
+ for npmrc in npmrc_paths:
+ if not os.path.isfile(npmrc):
+ continue
+ try:
+ with open(npmrc, encoding="utf-8") as f:
+ for line in f:
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#") or stripped.startswith(";"):
+ continue
+ if stripped.startswith("prefix="):
+ prefix = stripped.split("=", 1)[1].strip()
+ if prefix:
+ dirs.append(prefix if sys.platform == "win32" else os.path.join(prefix, "bin"))
+ except Exception:
+ pass
+ return dirs
+
+
+def _package_manager_bin_dirs() -> list[str]:
+ dirs: list[str] = []
+ npm_cmds = [
+ (["npm", "config", "get", "prefix"], "prefix"),
+ (["npm", "root", "-g"], "root"),
+ ]
+ for args, kind in npm_cmds:
+ try:
+ result = subprocess.run(args, capture_output=True, text=True, timeout=2)
+ except Exception:
+ continue
+ if result.returncode != 0:
+ continue
+ raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
+ if not raw:
+ continue
+ if kind == "prefix":
+ dirs.append(raw if sys.platform == "win32" else os.path.join(raw, "bin"))
+ elif kind == "root":
+ dirs.append(os.path.join(raw, ".bin"))
+ else:
+ dirs.append(raw)
+
+ for args, kind in (
+ (["pnpm", "config", "get", "global-bin-dir"], "bin"),
+ (["pnpm", "bin", "-g"], "bin"),
+ (["yarn", "global", "bin"], "bin"),
+ ):
+ try:
+ result = subprocess.run(args, capture_output=True, text=True, timeout=2)
+ except Exception:
+ continue
+ if result.returncode != 0:
+ continue
+ raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
+ if raw:
+ dirs.append(raw)
+
+ return dirs
+
+
+def _version_manager_bin_dirs() -> list[str]:
+ home = os.path.expanduser("~")
+ dirs = [
+ os.path.join(home, "bin"),
+ os.path.join(home, ".asdf", "shims"),
+ os.path.join(home, ".local", "share", "mise", "shims"),
+ os.path.join(home, ".local", "share", "rtx", "shims"),
+ os.path.join(home, ".bun", "bin"),
+ os.path.join(home, ".cargo", "bin"),
+ os.path.join(home, "go", "bin"),
+ os.path.join(home, ".local", "share", "pnpm"),
+ os.path.join(home, ".claude", "bin"),
+ ]
+
+ nvm_dir = os.environ.get("NVM_DIR") or os.path.join(home, ".nvm")
+ try:
+ import glob
+ dirs.extend(sorted(glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")), reverse=True))
+ dirs.extend(glob.glob(os.path.join(home, ".fnm", "node-versions", "*", "installation", "bin")))
+ dirs.extend(glob.glob(os.path.join(home, ".local", "share", "fnm", "node-versions", "*", "installation", "bin")))
+ except Exception:
+ pass
+
+ fnm_bin = os.path.join(home, ".local", "share", "fnm", "current", "bin")
+ dirs.append(fnm_bin)
+ dirs.append(os.path.join(home, ".volta", "bin"))
+
+ if sys.platform == "win32":
+ for env_key in ("APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"):
+ base = os.environ.get(env_key)
+ if not base:
+ continue
+ dirs.extend([
+ os.path.join(base, "npm"),
+ os.path.join(base, "Programs", "nodejs"),
+ os.path.join(base, "Microsoft", "WinGet", "Links"),
+ ])
+ dirs.append(os.path.join(home, "scoop", "shims"))
+ dirs.append(os.path.join(os.environ.get("ProgramData", ""), "npm"))
+ else:
+ dirs.extend([
+ "/usr/bin",
+ "/bin",
+ "/usr/local/bin",
+ "/opt/homebrew/bin",
+ "/opt/homebrew/sbin",
+ "/snap/bin",
+ "/var/lib/snapd/snap/bin",
+ ])
+
+ npm_prefix = (
+ os.environ.get("NPM_CONFIG_PREFIX")
+ or os.environ.get("npm_config_prefix")
+ or ""
+ ).strip()
+ if npm_prefix:
+ dirs.append(os.path.join(os.path.expanduser(npm_prefix), "bin"))
+
+ return dirs
+
+
+def _static_lookup_dirs() -> list[str]:
+ home = os.path.expanduser("~")
+ dirs = [
+ os.path.join(home, ".local", "bin"),
+ os.path.join(home, ".claude", "local", "bin"),
+ os.path.join(home, ".claude", "local", "node_modules", ".bin"),
+ os.path.join(home, ".npm-global", "bin"),
+ ]
+ if sys.platform == "win32":
+ appdata = os.environ.get("APPDATA")
+ if appdata:
+ dirs.append(os.path.join(appdata, "npm"))
+ dirs.append(os.path.join(home, ".local", "bin"))
+ return dirs
+
+
+@lru_cache(maxsize=8)
+def _lookup_dirs(_key: tuple) -> list[str]:
+ return _dedupe_dirs(
+ _static_lookup_dirs()
+ + _version_manager_bin_dirs()
+ + _npmrc_prefix_dirs()
+ + _package_manager_bin_dirs()
+ )
+
+
+def _all_lookup_dirs() -> list[str]:
+ return list(_lookup_dirs(_discovery_key()))
+
+
+def _path_lookup_dirs() -> list[str]:
+ return _all_lookup_dirs()
+
+
+def _npm_global_bin_dirs() -> list[str]:
+ return _package_manager_bin_dirs()
+
+
+def _parse_shell_lookup_line(line: str) -> Optional[str]:
+ candidate = line.strip().strip('"')
+ if not candidate:
+ return None
+ if " is " in candidate:
+ candidate = candidate.split(" is ", 1)[1].strip()
+ if candidate.startswith("(") and candidate.endswith(")"):
+ candidate = candidate[1:-1].strip()
+ return _resolve_cli_path(candidate) or (candidate if os.path.isfile(candidate) else None)
+
+
+def _shell_lookup(name: str) -> Optional[str]:
+ if sys.platform == "win32":
+ commands = [
+ ["where", name],
+ [
+ "powershell",
+ "-NoProfile",
+ "-Command",
+ f"(Get-Command {name} -All -ErrorAction SilentlyContinue | "
+ f"Select-Object -ExpandProperty Source)",
+ ],
+ ]
+ else:
+ commands = [
+ ["sh", "-lc", f"command -v {name}"],
+ ["bash", "-lc", f"type -a {name} 2>/dev/null"],
+ ["zsh", "-lc", f"whence -p {name} 2>/dev/null; command -v {name} 2>/dev/null"],
+ ["fish", "-lc", f"type -a {name} 2>/dev/null"],
+ ]
+
+ for cmd in commands:
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
+ except Exception:
+ continue
+ if result.returncode != 0 or not result.stdout.strip():
+ continue
+ for line in result.stdout.strip().splitlines():
+ resolved = _parse_shell_lookup_line(line)
+ if resolved:
+ return resolved
+ return None
+
+
+def _glob_cli_paths(name: str) -> list[str]:
+ import glob
+ home = os.path.expanduser("~")
+ patterns = [
+ os.path.join(home, ".claude", "bin", name),
+ os.path.join(home, ".claude", "*", "bin", name),
+ os.path.join(home, ".local", "share", "claude", "bin", name),
+ os.path.join(home, ".local", "share", "npm", "*", "bin", name),
+ ]
+ if sys.platform == "win32":
+ patterns.extend([
+ os.path.join(home, ".claude", "bin", f"{name}.exe"),
+ os.path.join(home, ".claude", "bin", f"{name}.cmd"),
+ ])
+ found: list[str] = []
+ for pattern in patterns:
+ try:
+ found.extend(glob.glob(pattern))
+ except Exception:
+ pass
+ return found
+
+
+def _configured_cli_path(engine: str) -> Optional[str]:
+ env_key = "PODCLI_CLAUDE_PATH" if engine == "claude" else "PODCLI_CODEX_PATH"
+ raw = (os.environ.get(env_key) or "").strip()
+ if not raw:
+ try:
+ from services.env_settings import _read_pairs
+ raw = (_read_pairs().get(env_key) or "").strip()
+ except Exception:
+ pass
+ if not raw:
+ return None
+ return _resolve_cli_path(raw) or (raw if os.path.isfile(raw) else None)
+
+
+def _find_cli(name: str, extra_paths: list[str] = None) -> Optional[str]:
+ import shutil
+
+ for path in (extra_paths or []) + _glob_cli_paths(name):
+ resolved = _resolve_cli_path(path)
+ if resolved:
+ return resolved
+
+ lookup_dirs = _all_lookup_dirs()
+ lookup_path = os.pathsep.join(lookup_dirs + [os.environ.get("PATH", "")])
+ found = shutil.which(name, path=lookup_path)
+ if found:
+ return found
+
+ for directory in lookup_dirs:
+ resolved = _resolve_cli_path(os.path.join(directory, name))
+ if resolved:
+ return resolved
+
+ for directory in (os.environ.get("PATH", "") or "").split(os.pathsep):
+ if not directory:
+ continue
+ resolved = _resolve_cli_path(os.path.join(directory, name))
+ if resolved:
+ return resolved
+
+ return _shell_lookup(name)
+
+
+def _ai_cli_search_paths(name: str) -> list[str]:
+ paths_out = [os.path.join(directory, name) for directory in _all_lookup_dirs()]
+ paths_out.extend(_glob_cli_paths(name))
+ return paths_out
+
+
+def _env_cli_path(engine: str) -> Optional[str]:
+ return _configured_cli_path(engine)
+
+
+def get_ai_cli_status() -> dict:
+ configured = {
+ "claude": _configured_cli_path("claude"),
+ "codex": _configured_cli_path("codex"),
+ }
+ candidates = [
+ {"engine": engine, "path": path}
+ for path, engine in _find_ai_cli_candidates()
+ ]
+ return {
+ "configured": configured,
+ "candidates": candidates,
+ "available": bool(candidates),
+ "searched_dirs": _all_lookup_dirs(),
+ }
+
+
+def _env_file_stamp() -> tuple:
+ """
+ Identity of the .env discovery also reads.
+
+ A configured CLI path can come from the file as well as the environment,
+ and the backend task runner is long-lived: it serves the request that saves
+ the path and every request after it. Without the file in the key, saving a
+ path in the studio has no effect until the process restarts, which is a
+ regression against the old probe-every-time behaviour.
+ """
+ try:
+ from services.env_settings import _env_path
+ path = _env_path()
+ stat = os.stat(path)
+ return (path, stat.st_mtime_ns, stat.st_size)
+ except Exception:
+ # No file, or no reading it: nothing to invalidate against.
+ return ()
+
+
+def _discovery_key() -> tuple:
+ """Everything discovery reads. Changing any of it must re-probe."""
+ return tuple(
+ os.environ.get(name, "")
+ for name in (
+ "PATH", "HOME", "NVM_DIR", "APPDATA", "ProgramData",
+ "NPM_CONFIG_PREFIX", "npm_config_prefix",
+ "PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH",
+ )
+ ) + _env_file_stamp()
+
+
+@lru_cache(maxsize=8)
+def _discover(_key: tuple) -> list[tuple[str, str]]:
+ candidates = []
+
+ claude = _env_cli_path("claude") or _find_cli("claude", _ai_cli_search_paths("claude"))
+ if claude:
+ candidates.append((claude, "claude"))
+
+ codex = _env_cli_path("codex") or _find_cli("codex", _ai_cli_search_paths("codex"))
+ if codex:
+ candidates.append((codex, "codex"))
+
+ return candidates
+
+
+def _find_ai_cli_candidates() -> list[tuple[str, str]]:
+ # Each probe shells out to npm, pnpm and yarn, which costs ~3s. Callers ask
+ # several times per render and the filesystem does not move underneath them,
+ # so the result is cached against the environment it was derived from.
+ return list(_discover(_discovery_key()))
+
+
+def _find_ai_cli() -> tuple[Optional[str], str]:
+ """
+ Find the best available AI CLI.
+
+ Returns (path, engine) where engine is "claude" or "codex".
+ Returns (None, "") if neither is available.
+ """
+ candidates = _find_ai_cli_candidates()
+ return candidates[0] if candidates else (None, "")
+
+
+def _engine_label(engine: str) -> str:
+ """Human-readable name for an AI engine id."""
+ if engine == "claude":
+ return "Claude"
+ if engine == "codex":
+ return "Codex"
+ return "AI"
+
+
+def _format_timeout_label(timeout: int) -> str:
+ """Render a human-readable timeout label for progress messages."""
+ if timeout % 60 == 0 and timeout >= 60:
+ minutes = timeout // 60
+ unit = "minute" if minutes == 1 else "minutes"
+ return f"{minutes} {unit}"
+ return f"{timeout}s"
+
+
+def _run_ai_command(
+ cli_path: str,
+ engine: str,
+ prompt: str,
+ prompt_file: str,
+ project_dir: str,
+ timeout: int,
+) -> subprocess.CompletedProcess:
+ """Execute one AI CLI prompt and return the completed process."""
+ if engine == "codex":
+ output_file = prompt_file + ".out"
+ result = subprocess.run(
+ [
+ cli_path, "exec",
+ "--full-auto",
+ "-o", output_file,
+ prompt,
+ ],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ cwd=project_dir,
+ timeout=timeout,
+ )
+ if os.path.exists(output_file):
+ with open(output_file, encoding="utf-8") as f:
+ result = subprocess.CompletedProcess(
+ args=result.args,
+ returncode=result.returncode,
+ stdout=f.read(),
+ stderr=result.stderr,
+ )
+ try:
+ os.unlink(output_file)
+ except Exception:
+ pass
+ return result
+
+ shell = sys.platform == "win32" and cli_path.lower().endswith((".cmd", ".bat"))
+ cmd = f'"{cli_path}" --print -p -' if shell else [cli_path, "--print", "-p", "-"]
+ with open(prompt_file, encoding="utf-8") as prompt_fh:
+ return subprocess.run(
+ cmd,
+ stdin=prompt_fh,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ cwd=project_dir,
+ timeout=timeout,
+ shell=shell,
+ )
+
+
+def classify_cli_error(detail: str) -> str:
+ """Turn a raw AI CLI failure into an actionable hint. The generic
+ 'check login' message hides whether it's auth, a plan limit, or a crash."""
+ low = (detail or "").lower()
+ if any(s in low for s in ("not logged in", "please run", "/login", "authenticate", "unauthorized", "invalid api key", "no credentials")):
+ return "not logged in. Run `claude` (or `codex`) once in a terminal to authenticate, then retry."
+ if any(s in low for s in ("usage limit", "rate limit", "quota", "too many requests", "429")):
+ return "usage or rate limit reached on your plan. Wait for the limit to reset, then retry."
+ if "timed out" in low or "timeout" in low:
+ return detail
+ if not detail:
+ return "the AI CLI returned no output. Run `claude` once in a terminal to confirm it responds."
+ return detail
diff --git a/backend/services/ai_provider.py b/backend/services/ai_provider.py
new file mode 100644
index 0000000..856f95b
--- /dev/null
+++ b/backend/services/ai_provider.py
@@ -0,0 +1,355 @@
+"""Single entry point for every AI generation in podcli.
+
+Three backends, tried in order until one answers:
+
+ cloud podcli Pro, if signed in (fastest, no install, prompt caching)
+ cli the user's local Claude Code / Codex binary (free, needs an install)
+ api ANTHROPIC_API_KEY, called directly over HTTPS (no install, per token)
+
+Callers pass a prompt and get text back. They do not care which backend ran,
+which is the point: the local CLI is found, launched and parsed differently on
+every platform, and that mess stops here.
+
+Selection is controlled by PODCLI_AI_PROVIDER (auto|cloud|cli|api). On `auto`
+the order above applies: a Pro subscriber gets what they paid for first, and the
+local CLI remains the fallback if the network or the subscription is unavailable
+— podcli never stops working because a server did.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import sys
+import urllib.error
+import urllib.request
+from dataclasses import dataclass, field
+from typing import Any, Callable, Optional
+
+from services import ai_cli, podcli_cloud
+
+ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
+ANTHROPIC_VERSION = "2023-06-01"
+DEFAULT_API_MODEL = "claude-sonnet-5"
+DEFAULT_MAX_TOKENS = 16000
+
+
+@dataclass
+class AIResult:
+ ok: bool
+ text: str = ""
+ error: str = ""
+ provider: str = ""
+ label: str = ""
+ attempts: list[str] = field(default_factory=list)
+ # Extra independent answers to the same prompt, when the backend ran several.
+ # Callers that know how to merge them get a wider search for almost nothing;
+ # callers that ignore them behave exactly as before.
+ alternates: list[str] = field(default_factory=list)
+
+
+def _mode() -> str:
+ mode = (os.environ.get("PODCLI_AI_PROVIDER") or "auto").strip().lower()
+ return mode if mode in ("auto", "cloud", "cli", "api") else "auto"
+
+
+def _api_key() -> Optional[str]:
+ key = (os.environ.get("ANTHROPIC_API_KEY") or "").strip()
+ return key or None
+
+
+def _api_model() -> str:
+ return (os.environ.get("PODCLI_AI_MODEL") or "").strip() or DEFAULT_API_MODEL
+
+
+def _chain() -> list[tuple[str, str, str]]:
+ """Backends to try, in order: (kind, path_or_key, engine)."""
+ mode = _mode()
+ chain: list[tuple[str, str, str]] = []
+ # A signed-in free workspace would otherwise upload the whole transcript on
+ # every pass only to be told 402. Forcing the mode still tries, so someone
+ # debugging entitlement can reach the server.
+ if mode in ("auto", "cloud") and podcli_cloud.signed_in():
+ if mode == "cloud" or podcli_cloud.entitled():
+ chain.append(("cloud", "", "cloud"))
+ if mode in ("auto", "cli"):
+ chain.extend(("cli", path, engine) for path, engine in ai_cli._find_ai_cli_candidates())
+ if mode in ("auto", "api"):
+ key = _api_key()
+ if key:
+ chain.append(("api", key, "api"))
+ return chain
+
+
+def label_for(kind: str, engine: str) -> str:
+ if kind == "cloud":
+ return "podcli Pro"
+ if kind == "api":
+ return "Claude API"
+ return ai_cli._engine_label(engine)
+
+
+def available() -> bool:
+ return bool(_chain())
+
+
+def claude_cli_path() -> Optional[str]:
+ """The local Claude binary, for the one caller that streams its output."""
+ for kind, path, engine in _chain():
+ if kind == "cli" and engine == "claude":
+ return path
+ return None
+
+
+def status() -> dict:
+ cli_status = ai_cli.get_ai_cli_status()
+ chain = _chain()
+ return {
+ **cli_status,
+ "mode": _mode(),
+ "api_key_set": bool(_api_key()),
+ "api_model": _api_model(),
+ "available": bool(chain),
+ "providers": [
+ {"kind": kind, "engine": engine, "label": label_for(kind, engine)}
+ for kind, _, engine in chain
+ ],
+ }
+
+
+def extract_json(text: str) -> Optional[Any]:
+ """Pull the first JSON value out of a model response.
+
+ Models fence their JSON, prefix it with prose, or both, regardless of how
+ firmly the prompt asks them not to.
+ """
+ if not text:
+ return None
+ body = text.strip()
+ if "```" in body:
+ fenced = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", body, re.DOTALL)
+ if fenced:
+ body = fenced.group(1).strip()
+ # By position, not by preference: a top-level array whose first element is
+ # an object would otherwise match "{" at index 1 and return one element of
+ # the list instead of the list.
+ openers = sorted(
+ (body.find(opener), opener) for opener in ("{", "[") if body.find(opener) >= 0
+ )
+ for start, _opener in openers:
+ try:
+ value, _ = json.JSONDecoder().raw_decode(body, start)
+ return value
+ except ValueError:
+ continue
+ return None
+
+
+def _run_api(key: str, prompt: str, timeout: int) -> AIResult:
+ payload = json.dumps({
+ "model": _api_model(),
+ "max_tokens": DEFAULT_MAX_TOKENS,
+ "messages": [{"role": "user", "content": prompt}],
+ }).encode("utf-8")
+ request = urllib.request.Request(
+ ANTHROPIC_URL,
+ data=payload,
+ headers={
+ "content-type": "application/json",
+ "x-api-key": key,
+ "anthropic-version": ANTHROPIC_VERSION,
+ },
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ body = json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ detail = ""
+ try:
+ detail = json.loads(exc.read().decode("utf-8")).get("error", {}).get("message", "")
+ except Exception:
+ pass
+ return AIResult(ok=False, provider="api", label="Claude API",
+ error=detail or f"Claude API returned HTTP {exc.code}")
+ except Exception as exc:
+ return AIResult(ok=False, provider="api", label="Claude API", error=str(exc))
+
+ if body.get("stop_reason") == "refusal":
+ return AIResult(ok=False, provider="api", label="Claude API",
+ error="Claude declined this request.")
+
+ text = "".join(
+ block.get("text", "")
+ for block in body.get("content", [])
+ if block.get("type") == "text"
+ ).strip()
+ if not text:
+ return AIResult(ok=False, provider="api", label="Claude API",
+ error="Claude API returned no text.")
+ return AIResult(ok=True, text=text, provider="api", label="Claude API")
+
+
+def _run_cloud(purpose: str, instruction: str, system: Optional[str],
+ cached_context: Optional[str], episode_source_hash: Optional[str],
+ timeout: int) -> AIResult:
+ try:
+ payload = podcli_cloud.generate(
+ purpose=purpose,
+ instruction=instruction,
+ system=system,
+ cached_context=cached_context,
+ episode_source_hash=episode_source_hash,
+ timeout=timeout,
+ )
+ except podcli_cloud.CloudError as exc:
+ return AIResult(ok=False, provider="cloud", label="podcli Pro", error=str(exc))
+
+ text = (payload.get("text") or "").strip()
+ if not text:
+ return AIResult(ok=False, provider="cloud", label="podcli Pro",
+ error="podcli Pro returned no text.")
+ return AIResult(
+ ok=True, text=text, provider="cloud", label="podcli Pro",
+ alternates=[a for a in (payload.get("alternates") or []) if a],
+ )
+
+
+def _run_cli(cli_path: str, engine: str, prompt: str, prompt_file: str,
+ project_dir: str, timeout: int) -> AIResult:
+ label = ai_cli._engine_label(engine)
+ try:
+ completed = ai_cli._run_ai_command(
+ cli_path=cli_path,
+ engine=engine,
+ prompt=prompt,
+ prompt_file=prompt_file,
+ project_dir=project_dir,
+ timeout=timeout,
+ )
+ except Exception as exc:
+ timed_out = "timed out" in str(exc).lower() or exc.__class__.__name__ == "TimeoutExpired"
+ detail = (
+ f"{label} timed out ({ai_cli._format_timeout_label(timeout)} limit)"
+ if timed_out else f"{label} failed to start: {exc}"
+ )
+ return AIResult(ok=False, provider=engine, label=label, error=detail)
+
+ text = (completed.stdout or "").strip()
+ if completed.returncode != 0 or not text:
+ detail = (completed.stderr or "").strip() or text
+ return AIResult(ok=False, provider=engine, label=label,
+ error=ai_cli.classify_cli_error(detail))
+ return AIResult(ok=True, text=text, provider=engine, label=label)
+
+
+def generate(
+ prompt: str,
+ *,
+ timeout: int = 900,
+ project_dir: Optional[str] = None,
+ on_attempt: Optional[Callable[[str], None]] = None,
+ accept: Optional[Callable[[str], bool]] = None,
+ adapt: Optional[Callable[[str, str], str]] = None,
+ purpose: str = "generate",
+ stable_prefix: Optional[str] = None,
+ local_prompt: Optional[str] = None,
+ episode_source_hash: Optional[str] = None,
+) -> AIResult:
+ """Run one prompt through the first backend that answers.
+
+ on_attempt is called with a human label ("Claude", "podcli Pro") before each
+ attempt so callers can drive progress UI without knowing the chain.
+
+ accept rejects a response the backend considers successful — an engine that
+ answers with prose where JSON was asked for has failed, and the next one
+ deserves a turn. Return True to accept, or False / a reason string to reject.
+
+ adapt(engine, prompt) rewrites the prompt per backend, for engines that need
+ a shorter one than the others.
+
+ stable_prefix is the large, unchanging half of the prompt — the transcript.
+ The cloud backend sends it as a separate cacheable block so it is read once
+ per episode rather than once per pass, which is the difference between ~$0.41
+ and ~$0.90 per episode.
+
+ Caching wants the stable text first and the varying ask last; several local
+ prompts are built the other way round, and reordering them would change what
+ the free path produces. local_prompt is the escape hatch: pass the exact
+ legacy string and local backends send it untouched while the cloud gets the
+ split form. Omit it and the prefix is simply prepended.
+ """
+ chain = _chain()
+ if not chain:
+ return AIResult(
+ ok=False,
+ error="No AI available. Install Claude Code or set ANTHROPIC_API_KEY "
+ "(podcli config set ANTHROPIC_API_KEY ...).",
+ )
+
+ if project_dir is None:
+ project_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+ # The Claude CLI reads its prompt from a file, so an adapted prompt needs its
+ # own file rather than the shared one.
+ prompt_files: dict[str, str] = {}
+
+ def prompt_file_for(text: str) -> str:
+ if text not in prompt_files:
+ from utils.prompt_files import write_prompt_file
+ prompt_files[text] = write_prompt_file(text)
+ return prompt_files[text]
+
+ attempts: list[str] = []
+ last = AIResult(ok=False, error="No AI backend produced a response.")
+ try:
+ for kind, target, engine in chain:
+ label = label_for(kind, engine)
+ if on_attempt:
+ on_attempt(label)
+ if kind == "cloud":
+ result = _run_cloud(purpose, prompt, None, stable_prefix,
+ episode_source_hash, timeout)
+ else:
+ whole = local_prompt or (
+ f"{stable_prefix}\n\n{prompt}" if stable_prefix else prompt
+ )
+ text = adapt(engine, whole) if adapt else whole
+ if kind == "api":
+ result = _run_api(target, text, timeout)
+ else:
+ result = _run_cli(target, engine, text, prompt_file_for(text),
+ project_dir, timeout)
+ if result.ok and accept:
+ verdict = accept(result.text)
+ if verdict is not True:
+ reason = verdict if isinstance(verdict, str) and verdict else \
+ f"{label} returned an unusable response."
+ result = AIResult(ok=False, provider=result.provider,
+ label=label, error=reason)
+ attempts.append(f"{label}: {'ok' if result.ok else result.error}")
+ if result.ok:
+ result.attempts = attempts
+ return result
+ last = result
+ last.attempts = attempts
+ return last
+ finally:
+ for path in prompt_files.values():
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+
+def generate_json(prompt: str, **kwargs) -> tuple[Optional[Any], AIResult]:
+ """generate() plus the fence-stripping every caller was doing by hand.
+
+ A backend whose answer will not parse is treated as failed, so the next one
+ in the chain gets a turn.
+ """
+ kwargs.setdefault("accept", lambda text: extract_json(text) is not None)
+ result = generate(prompt, **kwargs)
+ if not result.ok:
+ return None, result
+ return extract_json(result.text), result
diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py
index e12f8d2..57f4641 100644
--- a/backend/services/claude_suggest.py
+++ b/backend/services/claude_suggest.py
@@ -12,7 +12,6 @@
import os
import subprocess
import sys
-import tempfile
from typing import Optional, Callable
from config.paths import paths
@@ -22,432 +21,16 @@
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from presets import MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX
from utils.text import clean_title
-
-
-def _cli_name_exts() -> list[str]:
- if sys.platform == "win32":
- return ["", ".cmd", ".exe", ".bat"]
- return [""]
-
-
-def _resolve_cli_path(path: str) -> Optional[str]:
- for ext in _cli_name_exts():
- candidate = path + ext
- if os.path.isfile(candidate):
- return candidate
- return None
-
-
-def _dedupe_dirs(dirs: list[str]) -> list[str]:
- seen: set[str] = set()
- ordered: list[str] = []
- for directory in dirs:
- if not directory:
- continue
- directory = os.path.expanduser(directory)
- if directory in seen:
- continue
- seen.add(directory)
- if os.path.isdir(directory):
- ordered.append(directory)
- return ordered
-
-
-def _npmrc_prefix_dirs() -> list[str]:
- dirs: list[str] = []
- npmrc_paths = [os.path.join(os.path.expanduser("~"), ".npmrc")]
- try:
- from services.env_settings import _env_path
- npmrc_paths.append(os.path.join(os.path.dirname(_env_path()), ".npmrc"))
- except Exception:
- pass
- for npmrc in npmrc_paths:
- if not os.path.isfile(npmrc):
- continue
- try:
- with open(npmrc, encoding="utf-8") as f:
- for line in f:
- stripped = line.strip()
- if not stripped or stripped.startswith("#") or stripped.startswith(";"):
- continue
- if stripped.startswith("prefix="):
- prefix = stripped.split("=", 1)[1].strip()
- if prefix:
- dirs.append(prefix if sys.platform == "win32" else os.path.join(prefix, "bin"))
- except Exception:
- pass
- return dirs
-
-
-def _package_manager_bin_dirs() -> list[str]:
- dirs: list[str] = []
- npm_cmds = [
- (["npm", "config", "get", "prefix"], "prefix"),
- (["npm", "root", "-g"], "root"),
- ]
- for args, kind in npm_cmds:
- try:
- result = subprocess.run(args, capture_output=True, text=True, timeout=2)
- except Exception:
- continue
- if result.returncode != 0:
- continue
- raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
- if not raw:
- continue
- if kind == "prefix":
- dirs.append(raw if sys.platform == "win32" else os.path.join(raw, "bin"))
- elif kind == "root":
- dirs.append(os.path.join(raw, ".bin"))
- else:
- dirs.append(raw)
-
- for args, kind in (
- (["pnpm", "config", "get", "global-bin-dir"], "bin"),
- (["pnpm", "bin", "-g"], "bin"),
- (["yarn", "global", "bin"], "bin"),
- ):
- try:
- result = subprocess.run(args, capture_output=True, text=True, timeout=2)
- except Exception:
- continue
- if result.returncode != 0:
- continue
- raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
- if raw:
- dirs.append(raw)
-
- return dirs
-
-
-def _version_manager_bin_dirs() -> list[str]:
- home = os.path.expanduser("~")
- dirs = [
- os.path.join(home, "bin"),
- os.path.join(home, ".asdf", "shims"),
- os.path.join(home, ".local", "share", "mise", "shims"),
- os.path.join(home, ".local", "share", "rtx", "shims"),
- os.path.join(home, ".bun", "bin"),
- os.path.join(home, ".cargo", "bin"),
- os.path.join(home, "go", "bin"),
- os.path.join(home, ".local", "share", "pnpm"),
- os.path.join(home, ".claude", "bin"),
- ]
-
- nvm_dir = os.environ.get("NVM_DIR") or os.path.join(home, ".nvm")
- try:
- import glob
- dirs.extend(sorted(glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")), reverse=True))
- dirs.extend(glob.glob(os.path.join(home, ".fnm", "node-versions", "*", "installation", "bin")))
- dirs.extend(glob.glob(os.path.join(home, ".local", "share", "fnm", "node-versions", "*", "installation", "bin")))
- except Exception:
- pass
-
- fnm_bin = os.path.join(home, ".local", "share", "fnm", "current", "bin")
- dirs.append(fnm_bin)
- dirs.append(os.path.join(home, ".volta", "bin"))
-
- if sys.platform == "win32":
- for env_key in ("APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"):
- base = os.environ.get(env_key)
- if not base:
- continue
- dirs.extend([
- os.path.join(base, "npm"),
- os.path.join(base, "Programs", "nodejs"),
- os.path.join(base, "Microsoft", "WinGet", "Links"),
- ])
- dirs.append(os.path.join(home, "scoop", "shims"))
- dirs.append(os.path.join(os.environ.get("ProgramData", ""), "npm"))
- else:
- dirs.extend([
- "/usr/bin",
- "/bin",
- "/usr/local/bin",
- "/opt/homebrew/bin",
- "/opt/homebrew/sbin",
- "/snap/bin",
- "/var/lib/snapd/snap/bin",
- ])
-
- npm_prefix = (
- os.environ.get("NPM_CONFIG_PREFIX")
- or os.environ.get("npm_config_prefix")
- or ""
- ).strip()
- if npm_prefix:
- dirs.append(os.path.join(os.path.expanduser(npm_prefix), "bin"))
-
- return dirs
-
-
-def _static_lookup_dirs() -> list[str]:
- home = os.path.expanduser("~")
- dirs = [
- os.path.join(home, ".local", "bin"),
- os.path.join(home, ".claude", "local", "bin"),
- os.path.join(home, ".claude", "local", "node_modules", ".bin"),
- os.path.join(home, ".npm-global", "bin"),
- ]
- if sys.platform == "win32":
- appdata = os.environ.get("APPDATA")
- if appdata:
- dirs.append(os.path.join(appdata, "npm"))
- dirs.append(os.path.join(home, ".local", "bin"))
- return dirs
-
-
-def _all_lookup_dirs() -> list[str]:
- return _dedupe_dirs(
- _static_lookup_dirs()
- + _version_manager_bin_dirs()
- + _npmrc_prefix_dirs()
- + _package_manager_bin_dirs()
- )
-
-
-def _path_lookup_dirs() -> list[str]:
- return _all_lookup_dirs()
-
-
-def _npm_global_bin_dirs() -> list[str]:
- return _package_manager_bin_dirs()
-
-
-def _parse_shell_lookup_line(line: str) -> Optional[str]:
- candidate = line.strip().strip('"')
- if not candidate:
- return None
- if " is " in candidate:
- candidate = candidate.split(" is ", 1)[1].strip()
- if candidate.startswith("(") and candidate.endswith(")"):
- candidate = candidate[1:-1].strip()
- return _resolve_cli_path(candidate) or (candidate if os.path.isfile(candidate) else None)
-
-
-def _shell_lookup(name: str) -> Optional[str]:
- if sys.platform == "win32":
- commands = [
- ["where", name],
- [
- "powershell",
- "-NoProfile",
- "-Command",
- f"(Get-Command {name} -All -ErrorAction SilentlyContinue | "
- f"Select-Object -ExpandProperty Source)",
- ],
- ]
- else:
- commands = [
- ["sh", "-lc", f"command -v {name}"],
- ["bash", "-lc", f"type -a {name} 2>/dev/null"],
- ["zsh", "-lc", f"whence -p {name} 2>/dev/null; command -v {name} 2>/dev/null"],
- ["fish", "-lc", f"type -a {name} 2>/dev/null"],
- ]
-
- for cmd in commands:
- try:
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
- except Exception:
- continue
- if result.returncode != 0 or not result.stdout.strip():
- continue
- for line in result.stdout.strip().splitlines():
- resolved = _parse_shell_lookup_line(line)
- if resolved:
- return resolved
- return None
-
-
-def _glob_cli_paths(name: str) -> list[str]:
- import glob
- home = os.path.expanduser("~")
- patterns = [
- os.path.join(home, ".claude", "bin", name),
- os.path.join(home, ".claude", "*", "bin", name),
- os.path.join(home, ".local", "share", "claude", "bin", name),
- os.path.join(home, ".local", "share", "npm", "*", "bin", name),
- ]
- if sys.platform == "win32":
- patterns.extend([
- os.path.join(home, ".claude", "bin", f"{name}.exe"),
- os.path.join(home, ".claude", "bin", f"{name}.cmd"),
- ])
- found: list[str] = []
- for pattern in patterns:
- try:
- found.extend(glob.glob(pattern))
- except Exception:
- pass
- return found
-
-
-def _configured_cli_path(engine: str) -> Optional[str]:
- env_key = "PODCLI_CLAUDE_PATH" if engine == "claude" else "PODCLI_CODEX_PATH"
- raw = (os.environ.get(env_key) or "").strip()
- if not raw:
- try:
- from services.env_settings import _read_pairs
- raw = (_read_pairs().get(env_key) or "").strip()
- except Exception:
- pass
- if not raw:
- return None
- return _resolve_cli_path(raw) or (raw if os.path.isfile(raw) else None)
-
-
-def _find_cli(name: str, extra_paths: list[str] = None) -> Optional[str]:
- import shutil
-
- for path in (extra_paths or []) + _glob_cli_paths(name):
- resolved = _resolve_cli_path(path)
- if resolved:
- return resolved
-
- lookup_dirs = _all_lookup_dirs()
- lookup_path = os.pathsep.join(lookup_dirs + [os.environ.get("PATH", "")])
- found = shutil.which(name, path=lookup_path)
- if found:
- return found
-
- for directory in lookup_dirs:
- resolved = _resolve_cli_path(os.path.join(directory, name))
- if resolved:
- return resolved
-
- for directory in (os.environ.get("PATH", "") or "").split(os.pathsep):
- if not directory:
- continue
- resolved = _resolve_cli_path(os.path.join(directory, name))
- if resolved:
- return resolved
-
- return _shell_lookup(name)
-
-
-def _ai_cli_search_paths(name: str) -> list[str]:
- paths_out = [os.path.join(directory, name) for directory in _all_lookup_dirs()]
- paths_out.extend(_glob_cli_paths(name))
- return paths_out
-
-
-def _env_cli_path(engine: str) -> Optional[str]:
- return _configured_cli_path(engine)
-
-
-def get_ai_cli_status() -> dict:
- configured = {
- "claude": _configured_cli_path("claude"),
- "codex": _configured_cli_path("codex"),
- }
- candidates = [
- {"engine": engine, "path": path}
- for path, engine in _find_ai_cli_candidates()
- ]
- return {
- "configured": configured,
- "candidates": candidates,
- "available": bool(candidates),
- "searched_dirs": _all_lookup_dirs(),
- }
-
-
-def _find_ai_cli_candidates() -> list[tuple[str, str]]:
- candidates = []
-
- claude = _env_cli_path("claude") or _find_cli("claude", _ai_cli_search_paths("claude"))
- if claude:
- candidates.append((claude, "claude"))
-
- codex = _env_cli_path("codex") or _find_cli("codex", _ai_cli_search_paths("codex"))
- if codex:
- candidates.append((codex, "codex"))
-
- return candidates
-
-
-def _find_ai_cli() -> tuple[Optional[str], str]:
- """
- Find the best available AI CLI.
-
- Returns (path, engine) where engine is "claude" or "codex".
- Returns (None, "") if neither is available.
- """
- candidates = _find_ai_cli_candidates()
- return candidates[0] if candidates else (None, "")
-
-
-def _engine_label(engine: str) -> str:
- """Human-readable name for an AI engine id."""
- if engine == "claude":
- return "Claude"
- if engine == "codex":
- return "Codex"
- return "AI"
-
-
-def _format_timeout_label(timeout: int) -> str:
- """Render a human-readable timeout label for progress messages."""
- if timeout % 60 == 0 and timeout >= 60:
- minutes = timeout // 60
- unit = "minute" if minutes == 1 else "minutes"
- return f"{minutes} {unit}"
- return f"{timeout}s"
-
-
-def _run_ai_command(
- cli_path: str,
- engine: str,
- prompt: str,
- prompt_file: str,
- project_dir: str,
- timeout: int,
-) -> subprocess.CompletedProcess:
- """Execute one AI CLI prompt and return the completed process."""
- if engine == "codex":
- output_file = prompt_file + ".out"
- result = subprocess.run(
- [
- cli_path, "exec",
- "--full-auto",
- "-o", output_file,
- prompt,
- ],
- capture_output=True,
- text=True,
- encoding="utf-8",
- errors="replace",
- cwd=project_dir,
- timeout=timeout,
- )
- if os.path.exists(output_file):
- with open(output_file, encoding="utf-8") as f:
- result = subprocess.CompletedProcess(
- args=result.args,
- returncode=result.returncode,
- stdout=f.read(),
- stderr=result.stderr,
- )
- try:
- os.unlink(output_file)
- except Exception:
- pass
- return result
-
- shell = sys.platform == "win32" and cli_path.lower().endswith((".cmd", ".bat"))
- cmd = f'"{cli_path}" --print -p -' if shell else [cli_path, "--print", "-p", "-"]
- with open(prompt_file, encoding="utf-8") as prompt_fh:
- return subprocess.run(
- cmd,
- stdin=prompt_fh,
- capture_output=True,
- text=True,
- encoding="utf-8",
- errors="replace",
- cwd=project_dir,
- timeout=timeout,
- shell=shell,
- )
+from services import ai_provider, podcli_cloud
+from services.ai_cli import (
+ _engine_label,
+ _find_ai_cli,
+ _find_ai_cli_candidates,
+ _format_timeout_label,
+ _run_ai_command,
+ classify_cli_error,
+ get_ai_cli_status,
+)
def _load_existing_shorts(episodes_path: str) -> list[str]:
@@ -634,6 +217,19 @@ def _build_prompt(
{transcript_text}"""
+def _split_prompt_for_cache(prompt: str, transcript_text: str) -> tuple[str, str]:
+ """Separate the transcript from the ask, for backends that cache prefixes.
+
+ Returns (stable_prefix, instruction). The transcript is the only large
+ thing here and it is identical across every pass on an episode, so caching
+ it turns four full reads into one read and three cache hits.
+ """
+ marker = f"\n\n{transcript_text}"
+ if not transcript_text or not prompt.endswith(marker):
+ return "", prompt
+ return transcript_text, prompt[: -len(marker)]
+
+
def _build_transcript_text(segments: list[dict]) -> str:
"""Serialize transcript segments into the prompt-friendly text format."""
lines = []
@@ -739,14 +335,13 @@ def find_moments_from_text(
progress_callback: Optional[Callable[[int, str], None]] = None,
max_results: int = 3,
) -> list[dict]:
- """Locate the moment(s) the user described/pasted in the transcript via an AI
- CLI. Returns clip dicts (same shape as suggest_with_claude). Status goes to
+ """Locate the moment(s) the user described/pasted in the transcript.
+ Returns clip dicts (same shape as suggest_with_claude). Status goes to
progress_callback; warnings to stderr — never stdout, which is the task
runner's JSON-RPC channel."""
existing_clips = existing_clips or []
- candidates = _find_ai_cli_candidates()
- if not candidates:
- print("No AI CLI available for moment search", file=sys.stderr, flush=True)
+ if not ai_provider.available():
+ print("No AI available for moment search", file=sys.stderr, flush=True)
return []
if progress_callback:
@@ -794,49 +389,17 @@ def find_moments_from_text(
Transcript:
{transcript_text}"""
- # Prompt goes to .podcli/tmp/ (gitignored), not the repo root, so a crash
- # mid-run never litters the working tree with transcript dumps.
project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
- from utils.prompt_files import write_prompt_file
- prompt_file = write_prompt_file(prompt)
-
- try:
- for idx, (cli_path, engine) in enumerate(candidates):
- if progress_callback:
- label = "Claude" if engine == "claude" else "Codex"
- progress_callback(40, f"Searching transcript with {label}...")
- try:
- result = _run_ai_command(
- cli_path=cli_path,
- engine=engine,
- prompt=prompt,
- prompt_file=prompt_file,
- project_dir=project_dir,
- timeout=900,
- )
- except Exception:
- continue
-
- if result.returncode != 0 or not result.stdout.strip():
- continue
- response = result.stdout.strip()
- if "```" in response:
- import re
-
- fence_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", response, re.DOTALL)
- if fence_match:
- response = fence_match.group(1).strip()
-
- try:
- json_start = response.find("{")
- if json_start >= 0:
- data, _ = json.JSONDecoder().raw_decode(response, json_start)
- else:
- data = json.loads(response)
- except Exception:
- continue
+ def announce(label: str) -> None:
+ if progress_callback:
+ progress_callback(40, f"Searching transcript with {label}...")
+ try:
+ data, _result = ai_provider.generate_json(
+ prompt, timeout=900, project_dir=project_dir, on_attempt=announce,
+ )
+ if data:
found = []
for c in data.get("clips", []):
scores = c.get("scores", {})
@@ -882,26 +445,6 @@ def find_moments_from_text(
except Exception as e:
print(f"Moment search error: {e}", file=sys.stderr, flush=True)
return []
- finally:
- try:
- os.unlink(prompt_file)
- except Exception:
- pass
-
-
-def classify_cli_error(detail: str) -> str:
- """Turn a raw AI CLI failure into an actionable hint. The generic
- 'check login' message hides whether it's auth, a plan limit, or a crash."""
- low = (detail or "").lower()
- if any(s in low for s in ("not logged in", "please run", "/login", "authenticate", "unauthorized", "invalid api key", "no credentials")):
- return "not logged in. Run `claude` (or `codex`) once in a terminal to authenticate, then retry."
- if any(s in low for s in ("usage limit", "rate limit", "quota", "too many requests", "429")):
- return "usage or rate limit reached on your plan. Wait for the limit to reset, then retry."
- if "timed out" in low or "timeout" in low:
- return detail
- if not detail:
- return "the AI CLI returned no output. Run `claude` once in a terminal to confirm it responds."
- return detail
def suggest_with_claude(
@@ -919,13 +462,12 @@ def suggest_with_claude(
Tries available AI CLIs in preference order and retries on runtime failure.
Returns None if neither succeeds.
"""
- candidates = _find_ai_cli_candidates()
- if not candidates:
+ providers = ai_provider.status()["providers"]
+ if not providers:
return None
if progress_callback:
- label = _engine_label(candidates[0][1])
- progress_callback(0, f"Preparing transcript for {label}...")
+ progress_callback(0, f"Preparing transcript for {providers[0]['label']}...")
transcript_text = _build_transcript_text(segments)
@@ -943,161 +485,177 @@ def suggest_with_claude(
reaction_times=reaction_times,
)
- # Write prompt to temp file to avoid shell escaping issues.
- # Goes to .podcli/tmp/ (gitignored) so crashes don't litter the repo root.
project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
- from utils.prompt_files import write_prompt_file
- prompt_file = write_prompt_file(prompt)
-
- if progress_callback:
- first_label = _engine_label(candidates[0][1])
- progress_callback(20, f"Asking {first_label} to analyze transcript...")
-
- try:
- def _parse_seconds(val) -> float:
- """Parse a timestamp value — handles both 123.4 and '2:03' formats."""
- if isinstance(val, (int, float)):
- return float(val)
- s = str(val).strip()
- if ":" in s:
- parts = s.split(":")
- try:
- return float(parts[0]) * 60 + float(parts[1])
- except (ValueError, IndexError):
- return 0.0
+ def _parse_seconds(val) -> float:
+ """Parse a timestamp value — handles both 123.4 and '2:03' formats."""
+ if isinstance(val, (int, float)):
+ return float(val)
+ s = str(val).strip()
+ if ":" in s:
+ parts = s.split(":")
try:
- return float(s)
- except ValueError:
+ return float(parts[0]) * 60 + float(parts[1])
+ except (ValueError, IndexError):
return 0.0
+ try:
+ return float(s)
+ except ValueError:
+ return 0.0
- last_detail: Optional[str] = None
- for idx, (cli_path, engine) in enumerate(candidates):
- label = _engine_label(engine)
- if idx > 0 and progress_callback:
- progress_callback(0, f"Retrying with {label}...")
- progress_callback(20, f"Asking {label} to analyze transcript...")
+ attempted: list[str] = []
+ current = {"label": ""}
- try:
- result = _run_ai_command(
- cli_path=cli_path,
- engine=engine,
- prompt=prompt,
- prompt_file=prompt_file,
- project_dir=project_dir,
- timeout=timeout,
- )
- except subprocess.TimeoutExpired:
- last_detail = f"{label} timed out ({_format_timeout_label(timeout)} limit)"
- if progress_callback:
- progress_callback(0, last_detail)
- continue
- except Exception as e:
- last_detail = f"{label} error: {e}"
- if progress_callback:
- progress_callback(0, last_detail)
- continue
+ def announce(label: str) -> None:
+ current["label"] = label
+ if attempted and progress_callback:
+ progress_callback(0, f"Retrying with {label}...")
+ if progress_callback:
+ progress_callback(20, f"Asking {label} to analyze transcript...")
+ attempted.append(label)
- if result.returncode != 0 or not result.stdout.strip():
- detail = (result.stderr or "no response").strip()[:200]
- last_detail = f"{label}: {detail}"
- if progress_callback:
- progress_callback(0, f"{label} returned error: {detail}")
- continue
+ def usable(text: str):
+ """Reject a response that parses but has nothing in it, so the next
+ engine gets a turn rather than the user getting an empty result."""
+ label = current["label"]
+ if progress_callback:
+ progress_callback(80, f"Parsing {label}'s suggestions...")
+ parsed = ai_provider.extract_json(text)
+ if not isinstance(parsed, dict):
+ return f"{label} returned output that wasn't valid JSON"
+ if not isinstance(parsed.get("clips"), list) or not parsed["clips"]:
+ return f"{label} ran but found no clips in the transcript"
+ return True
+
+ cached_prefix, instruction = _split_prompt_for_cache(prompt, transcript_text)
+
+ # What this channel's own published clips say about what works, plus the
+ # house style learned from edits the team made to earlier output. Empty for
+ # everyone else, so the free path is unchanged.
+ learned = podcli_cloud.prompt_block()
+ if learned:
+ instruction = f"{learned}\n\n{instruction}"
+
+ attempt = ai_provider.generate(
+ instruction,
+ timeout=timeout,
+ project_dir=project_dir,
+ on_attempt=announce,
+ accept=usable,
+ purpose="select_moments",
+ stable_prefix=cached_prefix or None,
+ # Local backends keep the prompt exactly as it has always been built;
+ # only the cloud sees the split form.
+ local_prompt=prompt,
+ )
- if progress_callback:
- progress_callback(80, f"Parsing {label}'s suggestions...")
+ if not attempt.ok:
+ if progress_callback:
+ progress_callback(0, attempt.error)
+ if error_sink is not None:
+ # Classified only for a CLI failure. The advice it adds is "run
+ # `claude` once in a terminal", which is right for a local CLI and
+ # wrong for a workspace session or an API key, and `classify_cli_error`
+ # matches on "unauthorized" so it rewrote those too.
+ # The CLI path tags the attempt with its engine name, not "cli",
+ # so this asks the question the other way round.
+ error_sink.append(
+ attempt.error
+ if attempt.provider in ("cloud", "api")
+ else classify_cli_error(attempt.error)
+ )
+ return None
- try:
- response = result.stdout.strip()
- if "```" in response:
- import re
- fence_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", response, re.DOTALL)
- if fence_match:
- response = fence_match.group(1).strip()
-
- json_start = response.find("{")
- if json_start >= 0:
- decoder = json.JSONDecoder()
- data, _ = decoder.raw_decode(response, json_start)
- else:
- data = json.loads(response)
- except json.JSONDecodeError as e:
- last_detail = f"{label} returned output that wasn't valid JSON ({e})"
- if progress_callback:
- progress_callback(0, f"Could not parse {label}'s response as JSON: {e}")
+ label = attempt.label
+
+ # Several independent searches over the same transcript find overlapping but
+ # not identical moments. Keeping the union and re-ranking beats picking one
+ # set, and the dedupe/scoring below already exists for exactly this shape.
+ def records(payload: object) -> list[dict]:
+ """
+ The clip objects in a response, and only those.
+
+ `usable` checked the primary response is a non-empty list, which still
+ admits a null or a string inside it, and the alternates are not checked
+ at all: `.get` on any of those is an AttributeError out of a code path
+ with nothing above it to catch.
+ """
+ if not isinstance(payload, dict):
+ return []
+ found = payload.get("clips")
+ if not isinstance(found, list):
+ return []
+ return [c for c in found if isinstance(c, dict)]
+
+ clips = records(ai_provider.extract_json(attempt.text))
+ for alternate in attempt.alternates:
+ clips.extend(records(ai_provider.extract_json(alternate)))
+
+ normalized = []
+ for c in clips:
+ scores = c.get("scores")
+ scores = scores if isinstance(scores, dict) else {}
+ total = sum(scores.values()) if scores else c.get("total_score", 0)
+
+ raw_segments = c.get("segments")
+ raw_segments = raw_segments if isinstance(raw_segments, list) else []
+ keep_segments = []
+ for seg in raw_segments:
+ if not isinstance(seg, dict):
continue
+ s = round(_parse_seconds(seg.get("start", 0)), 1)
+ e = round(_parse_seconds(seg.get("end", 0)), 1)
+ if e > s:
+ keep_segments.append({"start": s, "end": e})
- clips = data.get("clips", [])
- if not clips:
- last_detail = f"{label} ran but found no clips in the transcript"
- if progress_callback:
- progress_callback(0, f"{label} returned no clips")
- continue
+ start_sec = round(_parse_seconds(c.get("start_second", 0)), 1)
+ end_sec = round(_parse_seconds(c.get("end_second", 0)), 1)
- normalized = []
- for c in clips:
- scores = c.get("scores", {})
- total = sum(scores.values()) if scores else c.get("total_score", 0)
+ if not keep_segments and end_sec > start_sec:
+ keep_segments = [{"start": start_sec, "end": end_sec}]
- raw_segments = c.get("segments", [])
- keep_segments = []
- for seg in raw_segments:
- s = round(_parse_seconds(seg.get("start", 0)), 1)
- e = round(_parse_seconds(seg.get("end", 0)), 1)
- if e > s:
- keep_segments.append({"start": s, "end": e})
-
- start_sec = round(_parse_seconds(c.get("start_second", 0)), 1)
- end_sec = round(_parse_seconds(c.get("end_second", 0)), 1)
-
- if not keep_segments and end_sec > start_sec:
- keep_segments = [{"start": start_sec, "end": end_sec}]
-
- kept_duration = sum(seg["end"] - seg["start"] for seg in keep_segments)
- if kept_duration < MIN_CLIP_DURATION or kept_duration > MAX_CLIP_DURATION:
- continue
-
- normalized.append({
- "title": clean_title(c.get("title", "Untitled")),
- "start_second": keep_segments[0]["start"] if keep_segments else start_sec,
- "end_second": keep_segments[-1]["end"] if keep_segments else end_sec,
- "segments": keep_segments,
- "duration": round(kept_duration),
- "score": total,
- "content_type": c.get("content_type", "unknown"),
- "reasoning": c.get("why", ""),
- "preview_text": c.get("quote", "")[:120],
- "suggested_caption_style": "hormozi",
- "quote": c.get("quote", ""),
- "why": c.get("why", ""),
- "reasons": [c.get("content_type", "")],
- "preview": c.get("quote", "")[:120],
- "_ai_engine": engine,
- })
+ kept_duration = sum(seg["end"] - seg["start"] for seg in keep_segments)
+ if kept_duration < MIN_CLIP_DURATION or kept_duration > MAX_CLIP_DURATION:
+ continue
- selected = _select_top_by_score(
- _drop_clips_overlapping(normalized, exclude_clips or []), top_n
- )
+ normalized.append({
+ "title": clean_title(c.get("title", "Untitled")),
+ "start_second": keep_segments[0]["start"] if keep_segments else start_sec,
+ "end_second": keep_segments[-1]["end"] if keep_segments else end_sec,
+ "segments": keep_segments,
+ "duration": round(kept_duration),
+ "score": total,
+ "content_type": c.get("content_type", "unknown"),
+ "reasoning": c.get("why", ""),
+ "preview_text": c.get("quote", "")[:120],
+ "suggested_caption_style": "hormozi",
+ "quote": c.get("quote", ""),
+ "why": c.get("why", ""),
+ "reasons": [c.get("content_type", "")],
+ "preview": c.get("quote", "")[:120],
+ "_ai_engine": attempt.provider,
+ })
- if selected:
- if progress_callback:
- progress_callback(100, f"{label} suggested {len(selected)} clips")
- return selected
+ # Dedupe within the pool as well as against already-selected clips: several
+ # attempts routinely surface the same strong moment, and without this the
+ # top N would be the same clip repeated.
+ selected = _select_top_by_score(
+ _drop_clips_overlapping(_dedupe_clips_by_range(normalized), exclude_clips or []),
+ top_n,
+ )
- last_detail = f"{label} returned clips but none were usable (wrong length or format)"
- if progress_callback:
- progress_callback(0, f"{label} returned no usable clips")
+ if selected:
+ if progress_callback:
+ progress_callback(100, f"{label} suggested {len(selected)} clips")
+ return selected
- if error_sink is not None:
- error_sink.append(classify_cli_error(last_detail or ""))
- return None
- finally:
- # Clean up temp file
- try:
- os.unlink(prompt_file)
- except Exception:
- pass
+ if progress_callback:
+ progress_callback(0, f"{label} returned no usable clips")
+ if error_sink is not None:
+ error_sink.append(
+ f"{label} returned clips but none were usable (wrong length or format)"
+ )
+ return None
def suggest_initial_with_claude(
diff --git a/backend/services/content_generator.py b/backend/services/content_generator.py
index 99b20c1..4abe9ed 100644
--- a/backend/services/content_generator.py
+++ b/backend/services/content_generator.py
@@ -1,5 +1,5 @@
"""
-Per-clip content generation (titles, descriptions, tags) via AI CLI.
+Per-clip content generation (titles, descriptions, tags).
Single source of truth used by CLI, Web UI, and MCP.
"""
@@ -12,9 +12,18 @@
import threading
from typing import Optional, Callable
-from services.claude_suggest import _engine_label, _find_ai_cli_candidates, _run_ai_command
+from config.paths import paths
+from services import ai_provider
from services.knowledge_base import load_kb_context as kb_load_context, warn_missing_context
+# Codex silently truncates long prompts, so it gets a shortened one. The prompts
+# here lead with the request precisely so this cut only costs transcript tail.
+CODEX_PROMPT_LIMIT = 4000
+
+
+def _shorten_for_codex(engine: str, prompt: str) -> str:
+ return prompt[:CODEX_PROMPT_LIMIT] if engine == "codex" else prompt
+
CONTENT_KB_FILES = [
("05-title-formulas.md", 3000),
@@ -186,8 +195,7 @@ def generate_custom_content(
Returns {"text", "engine"} with the raw model output, or None if no AI CLI.
"""
- candidates = _find_ai_cli_candidates()
- if not candidates:
+ if not ai_provider.available():
return None
kb_context = load_kb_context()
@@ -210,37 +218,25 @@ def generate_custom_content(
TRANSCRIPT EXCERPT:
{excerpt}"""
- project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
- from utils.prompt_files import write_prompt_file
- prompt_file = write_prompt_file(prompt)
- try:
- for idx, (cli_path, engine) in enumerate(candidates):
- label = _engine_label(engine)
- if progress_callback:
- progress_callback(30, f"Asking {label}..." if idx == 0 else f"Retrying with {label}...")
- try:
- cr = _run_ai_command(
- cli_path=cli_path,
- engine=engine,
- prompt=prompt[:4000] if engine == "codex" else prompt,
- prompt_file=prompt_file,
- project_dir=project_dir,
- timeout=120,
- )
- except Exception as exc:
- print(f"Warning: {label} content generation failed: {exc}", file=sys.stderr)
- continue
- if cr.returncode != 0 or not cr.stdout.strip():
- continue
- if progress_callback:
- progress_callback(100, "Done")
- return {"text": cr.stdout.strip(), "engine": engine}
+ attempted: list[str] = []
+
+ def announce(label: str) -> None:
+ if progress_callback:
+ progress_callback(30, f"Asking {label}..." if not attempted else f"Retrying with {label}...")
+ attempted.append(label)
+
+ result = ai_provider.generate(
+ prompt,
+ timeout=120,
+ on_attempt=announce,
+ adapt=_shorten_for_codex,
+ )
+ if not result.ok:
+ print(f"Warning: content generation failed: {result.error}", file=sys.stderr)
return None
- finally:
- try:
- os.unlink(prompt_file)
- except Exception as exc:
- print(f"Warning: could not remove prompt file {prompt_file}: {exc}", file=sys.stderr)
+ if progress_callback:
+ progress_callback(100, "Done")
+ return {"text": result.text, "engine": result.provider}
def generate_clip_content(
@@ -262,13 +258,12 @@ def generate_clip_content(
Returns:
dict with raw_text, titles, description, tags, hashtags, or None if AI unavailable
"""
- candidates = _find_ai_cli_candidates()
- if not candidates:
+ providers = ai_provider.status()["providers"]
+ if not providers:
return None
- label = _engine_label(candidates[0][1])
if progress_callback:
- progress_callback(0, f"Generating content via {label}...")
+ progress_callback(0, f"Generating content via {providers[0]['label']}...")
kb_context = load_kb_context(task="title and description generation")
@@ -355,19 +350,23 @@ def generate_clip_content(
project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
- from utils.prompt_files import write_prompt_file
- prompt_file = write_prompt_file(prompt)
+ def usable(text: str) -> bool:
+ parsed = _parse_content(text)
+ return bool(parsed["titles"] or parsed["description"])
- try:
- for idx, (cli_path, engine) in enumerate(candidates):
- label = _engine_label(engine)
- if progress_callback:
- if idx > 0:
- progress_callback(0, f"Retrying content generation with {label}...")
- progress_callback(30, f"Asking {label} for titles & descriptions...")
+ raw_text = None
+ engine_used = ""
- raw_text = None
- if engine == "claude" and partial_callback is not None:
+ # The Studio renders titles as they arrive. Only the Claude CLI can stream,
+ # so it gets first refusal; everything else falls through to the chain.
+ if partial_callback is not None:
+ cli_path = ai_provider.claude_cli_path()
+ if cli_path:
+ if progress_callback:
+ progress_callback(30, "Asking Claude for titles & descriptions...")
+ from utils.prompt_files import write_prompt_file
+ prompt_file = write_prompt_file(prompt)
+ try:
raw_text = _stream_claude_content(
cli_path=cli_path,
prompt_file=prompt_file,
@@ -375,42 +374,42 @@ def generate_clip_content(
timeout=120,
on_partial=partial_callback,
)
-
- if raw_text is None:
+ engine_used = "claude"
+ finally:
try:
- cr = _run_ai_command(
- cli_path=cli_path,
- engine=engine,
- prompt=prompt[:4000] if engine == "codex" else prompt,
- prompt_file=prompt_file,
- project_dir=project_dir,
- timeout=120,
- )
- except subprocess.TimeoutExpired:
- continue
- except Exception:
- continue
-
- if cr.returncode != 0 or not cr.stdout.strip():
- continue
- raw_text = cr.stdout.strip()
+ os.unlink(prompt_file)
+ except OSError:
+ pass
+ if raw_text is None or not usable(raw_text):
+ attempted: list[str] = []
+
+ def announce(label: str) -> None:
if progress_callback:
- progress_callback(90, "Parsing content...")
+ if attempted:
+ progress_callback(0, f"Retrying content generation with {label}...")
+ progress_callback(30, f"Asking {label} for titles & descriptions...")
+ attempted.append(label)
+
+ attempt = ai_provider.generate(
+ prompt,
+ timeout=120,
+ project_dir=project_dir,
+ on_attempt=announce,
+ adapt=_shorten_for_codex,
+ accept=usable,
+ )
+ if not attempt.ok:
+ return None
+ raw_text, engine_used = attempt.text, attempt.provider
- result = _parse_content(raw_text)
- result["engine"] = engine
- if not result["titles"] and not result["description"]:
- continue
+ if progress_callback:
+ progress_callback(90, "Parsing content...")
- if progress_callback:
- progress_callback(100, f"Content ready ({len(result['titles'])} titles)")
+ result = _parse_content(raw_text)
+ result["engine"] = engine_used
- return result
+ if progress_callback:
+ progress_callback(100, f"Content ready ({len(result['titles'])} titles)")
- return None
- finally:
- try:
- os.unlink(prompt_file)
- except Exception:
- pass
+ return result
diff --git a/backend/services/env_settings.py b/backend/services/env_settings.py
index fe4d6ca..cc3550b 100644
--- a/backend/services/env_settings.py
+++ b/backend/services/env_settings.py
@@ -143,7 +143,7 @@ def set_setting(key: str, value: str) -> None:
if not value:
raise ValueError("value is empty")
if key in ("PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH"):
- from services.claude_suggest import _resolve_cli_path
+ from services.ai_cli import _resolve_cli_path
resolved = _resolve_cli_path(value) or (value if os.path.isfile(value) else None)
if not resolved:
raise ValueError(f"path does not exist: {value}")
@@ -160,7 +160,7 @@ def unset_setting(key: str) -> None:
def run_env_action(action: str, key: Optional[str] = None, value: Optional[str] = None) -> dict[str, Any]:
act = (action or "list").strip().lower()
if act == "list":
- from services.claude_suggest import get_ai_cli_status
+ from services.ai_cli import get_ai_cli_status
return {
"settings": list_settings(),
"path": os.path.abspath(_env_path()),
diff --git a/backend/services/integrations/youtube/learnings.py b/backend/services/integrations/youtube/learnings.py
index 829c603..049746e 100644
--- a/backend/services/integrations/youtube/learnings.py
+++ b/backend/services/integrations/youtube/learnings.py
@@ -129,13 +129,11 @@ def write_semantic_learnings(top_n: int = 4, min_total: int = 6) -> Optional[str
top_performers, underperformers = ranked[:top_n], ranked[-top_n:]
try:
- from services.claude_suggest import _find_ai_cli_candidates, _run_ai_command
+ from services import ai_provider
except Exception:
return None
- candidates = _find_ai_cli_candidates()
- if not candidates:
+ if not ai_provider.available():
return None
- cli_path, engine = candidates[0]
prompt = (
"You analyze short-form video performance to guide future clip selection.\n"
@@ -146,22 +144,10 @@ def write_semantic_learnings(top_n: int = 4, min_total: int = 6) -> Optional[str
"the top performers from the underperformers (hooks, topic, emotional beat, structure) and give "
"actionable guidance for picking future shorts. No preamble, just the bullets."
)
- os.makedirs(paths["working"], exist_ok=True)
- prompt_file = os.path.join(paths["working"], "_perf_analysis_prompt.txt")
- with open(prompt_file, "w", encoding="utf-8") as f:
- f.write(prompt)
- try:
- res = _run_ai_command(cli_path, engine, prompt, prompt_file, paths["project_root"], timeout=180)
- except Exception:
- return None
- finally:
- try:
- os.unlink(prompt_file)
- except Exception:
- pass
- text = (res.stdout or "").strip()
- if not text:
+ result = ai_provider.generate(prompt, timeout=180, project_dir=paths["project_root"])
+ if not result.ok:
return None
+ text = result.text
now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
block = f"{AI_START}\n## What separates top performers (AI analysis · {now})\n\n{text}\n{AI_END}"
return write_learnings(ai_block=block)
diff --git a/backend/services/podcli_cloud.py b/backend/services/podcli_cloud.py
new file mode 100644
index 0000000..2e33d5a
--- /dev/null
+++ b/backend/services/podcli_cloud.py
@@ -0,0 +1,356 @@
+"""Client for podcli Pro's hosted API.
+
+This module is the whole of Pro that lives in the open source app: where the
+token is kept, how it is sent, and what shape the request takes. There is no
+secret here and nothing to crack — the server decides entitlement, so a patched
+client gets a UI that says Pro and an HTTP 401.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from typing import Any, Optional
+
+from config.paths import paths
+
+DEFAULT_API_URL = "https://api.podcli.com"
+AUTH_FILENAME = "auth.json"
+
+
+def api_url() -> str:
+ """
+ The API base, restricted to http and https.
+
+ urlopen honours whatever scheme it is given, so an unchecked value here
+ lets `file:` turn a local path into what the code treats as an API
+ response.
+ """
+ raw = (os.environ.get("PODCLI_API_URL") or DEFAULT_API_URL).rstrip("/")
+ if urllib.parse.urlparse(raw).scheme not in ("http", "https"):
+ return DEFAULT_API_URL
+ return raw
+
+
+def _auth_path() -> str:
+ return os.path.join(paths["home"], AUTH_FILENAME)
+
+
+def read_token() -> Optional[str]:
+ """The session token, from the environment or the file `podcli login` wrote."""
+ env = (os.environ.get("PODCLI_TOKEN") or "").strip()
+ if env:
+ return env
+ try:
+ with open(_auth_path(), encoding="utf-8") as fh:
+ token = (json.load(fh).get("token") or "").strip()
+ return token or None
+ except (OSError, ValueError):
+ return None
+
+
+def _auth_data() -> dict:
+ try:
+ with open(_auth_path(), encoding="utf-8") as fh:
+ return json.load(fh) or {}
+ except (OSError, ValueError):
+ return {}
+
+
+def _write_auth(data: dict) -> None:
+ os.makedirs(paths["home"], exist_ok=True)
+ path = _auth_path()
+ # Opened with the mode already set rather than chmod'd afterwards: the
+ # session token would otherwise be world-readable for the width of the
+ # write, and a file that already existed would keep its old mode until the
+ # chmod landed.
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
+ json.dump(data, fh)
+ try:
+ os.chmod(path, 0o600)
+ except OSError:
+ pass
+
+
+def write_token(token: str, workspace_id: str = "") -> None:
+ _write_auth({"token": token, "workspace_id": workspace_id})
+
+
+PAID_PLANS = ("pro", "team", "studio", "agency")
+PLAN_TTL_SECONDS = 6 * 3600
+
+
+def remember_plan(plan: str) -> None:
+ """Cache what the server last said this workspace is entitled to."""
+ data = _auth_data()
+ if not data.get("token"):
+ return
+ data["plan"] = (plan or "").strip().lower()
+ data["plan_checked_at"] = time.time()
+ _write_auth(data)
+
+
+def entitled() -> bool:
+ """
+ False only when the workspace is known to have no subscription.
+
+ Unknown and stale both mean "try": a subscription bought a minute ago has to
+ work without signing out first, and the server is the only real authority.
+ """
+ data = _auth_data()
+ plan = (data.get("plan") or "").strip().lower()
+ if not plan:
+ return True
+ if time.time() - float(data.get("plan_checked_at") or 0) > PLAN_TTL_SECONDS:
+ return True
+ return plan in PAID_PLANS
+
+
+def clear_token() -> None:
+ try:
+ os.unlink(_auth_path())
+ except OSError:
+ pass
+
+
+def signed_in() -> bool:
+ return read_token() is not None
+
+
+class CloudError(Exception):
+ def __init__(self, message: str, status: int = 0, retryable: bool = False):
+ super().__init__(message)
+ self.status = status
+ self.retryable = retryable
+
+
+def request(method: str, path: str, body: Optional[dict] = None,
+ timeout: int = 300) -> Any:
+ token = read_token()
+ if not token:
+ raise CloudError("not signed in — run `podcli login`", status=401)
+
+ data = json.dumps(body).encode("utf-8") if body is not None else None
+ req = urllib.request.Request(
+ f"{api_url()}{path}",
+ data=data,
+ method=method,
+ headers={
+ "authorization": f"Bearer {token}",
+ **({"content-type": "application/json"} if data else {}),
+ },
+ )
+
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as response:
+ raw = response.read().decode("utf-8")
+ return json.loads(raw) if raw else None
+ except urllib.error.HTTPError as exc:
+ detail, retryable = _describe(exc)
+ raise CloudError(detail, status=exc.code, retryable=retryable) from None
+ except urllib.error.URLError as exc:
+ raise CloudError(f"could not reach {api_url()}: {exc.reason}",
+ retryable=True) from None
+
+
+def _describe(exc: urllib.error.HTTPError) -> tuple[str, bool]:
+ """Turn an HTTP failure into something a user can act on."""
+ payload: dict = {}
+ try:
+ parsed = json.loads(exc.read().decode("utf-8"))
+ # A server can answer with a list or a bare string. Assuming an object
+ # turns the error path itself into an AttributeError.
+ if isinstance(parsed, dict):
+ payload = parsed
+ except Exception:
+ pass
+ detail = payload.get("error")
+ if isinstance(detail, list):
+ detail = "; ".join(str(item.get("message", item)) for item in detail)
+
+ if exc.code == 401:
+ return ("podcli Pro session expired — run `podcli login` again", False)
+ if exc.code == 402:
+ return ("this workspace has no active podcli Pro subscription", False)
+ if exc.code == 403:
+ return (detail or "your role does not allow this", False)
+ if exc.code == 429:
+ used, cap = payload.get("used"), payload.get("cap")
+ if used is not None and cap is not None:
+ return (f"monthly limit reached ({used}/{cap} episodes)", False)
+ return (detail or "rate limited, try again shortly", True)
+ if exc.code >= 500 or exc.code == 503:
+ return (detail or "podcli Pro is temporarily unavailable", True)
+ return (detail or f"podcli Pro returned HTTP {exc.code}", False)
+
+
+def generate(purpose: str, instruction: str, *, system: Optional[str] = None,
+ cached_context: Optional[str] = None,
+ episode_source_hash: Optional[str] = None,
+ max_tokens: int = 16000, timeout: int = 300) -> dict:
+ body: dict[str, Any] = {
+ "purpose": purpose,
+ "instruction": instruction,
+ "maxTokens": max_tokens,
+ }
+ if system:
+ body["system"] = system
+ if cached_context:
+ body["cachedContext"] = cached_context
+ if episode_source_hash:
+ body["episodeSourceHash"] = episode_source_hash
+ return request("POST", "/v1/ai/generate", body, timeout=timeout)
+
+
+def source_hash(video_path: str) -> Optional[str]:
+ """Identify an episode across machines.
+
+ Must stay byte-identical to the TypeScript implementation in
+ src/services/podcli-cloud.ts — the two clients hash the same file and the
+ server dedupes episodes on the result, so any divergence silently splits one
+ episode into two. First 8 MB only: distinctive enough, and digesting a 2 GB
+ master on every render is not.
+ """
+ import hashlib
+
+ digest = hashlib.sha256()
+ remaining = 8 * 1024 * 1024
+ try:
+ with open(video_path, "rb") as fh:
+ while remaining > 0:
+ chunk = fh.read(min(1024 * 1024, remaining))
+ if not chunk:
+ break
+ digest.update(chunk)
+ remaining -= len(chunk)
+ except OSError:
+ return None
+ return digest.hexdigest()[:32]
+
+
+def register_clip(clip: dict) -> Optional[dict]:
+ return request("POST", "/v1/clips", clip, timeout=60)
+
+
+def backfill_clips(limit: int = 200) -> tuple[int, int]:
+ """Push locally-recorded clips that never reached the workspace.
+
+ Runs at sign-in so a new subscriber's back catalogue is behind the
+ performance model from their first session, rather than the model starting
+ empty and staying useless for months.
+ """
+ from services.clips_history import load_clips_history, update_clip
+
+ synced = failed = 0
+ for entry in load_clips_history():
+ if synced + failed >= limit:
+ break
+ if entry.get("cloud_id"):
+ continue
+ source = entry.get("source_video")
+ if not source or not os.path.exists(source):
+ continue
+
+ digest = source_hash(source)
+ if not digest:
+ failed += 1
+ continue
+
+ try:
+ result = register_clip({
+ "sourceHash": digest,
+ "episodeTitle": os.path.basename(source),
+ "title": entry.get("title"),
+ "startSecond": entry.get("start_second"),
+ "endSecond": entry.get("end_second"),
+ "durationSec": entry.get("duration"),
+ "contentType": entry.get("content_type"),
+ "captionStyle": entry.get("caption_style"),
+ "aspectRatio": entry.get("format"),
+ "transcriptSlice": entry.get("transcript_slice"),
+ })
+ except CloudError as exc:
+ failed += 1
+ # An expired session or a workspace with no subscription answers the
+ # same way for every remaining clip. Continuing would hash and
+ # upload another few hundred megabytes to be refused each time.
+ if exc.status in (401, 402, 403):
+ break
+ continue
+
+ if result and result.get("id"):
+ update_clip(entry["id"], cloud_id=result["id"], cloud_synced=True)
+ synced += 1
+ else:
+ failed += 1
+
+ return synced, failed
+
+
+def prompt_block() -> str:
+ """What this workspace has learned, phrased for the selection prompt.
+
+ Rendered server-side rather than assembled here, so improving how a
+ workspace's history is presented to the model is a deploy rather than
+ something that waits for every user to upgrade their CLI.
+
+ Short timeout and silent on failure: better clips are the point, but not at
+ the cost of blocking a suggestion run behind a slow network.
+ """
+ if not signed_in():
+ return ""
+ try:
+ payload = request("GET", "/v1/insights/prompt-block", timeout=10)
+ except CloudError:
+ return ""
+ return (payload or {}).get("block") or ""
+
+
+def list_workspaces() -> list[dict]:
+ return (request("GET", "/v1/workspaces", timeout=30) or {}).get("workspaces", [])
+
+
+def create_workspace(name: str) -> dict:
+ payload = request("POST", "/v1/workspaces", {"name": name}, timeout=30)
+ write_token(payload["token"], payload["id"])
+ return payload
+
+
+def switch_workspace(workspace_id: str) -> dict:
+ """Switching means a new session, not a mutable field on the old one.
+
+ Tenancy is decided once, at authentication, from the session's workspace —
+ so a token can never be pointed at a workspace it was not issued for.
+ """
+ payload = request("POST", f"/v1/workspaces/{workspace_id}/session", {}, timeout=30)
+ write_token(payload["token"], payload["workspaceId"])
+ return payload
+
+
+def me() -> dict:
+ return request("GET", "/v1/auth/me", timeout=30)
+
+
+def login(email: str, password: str) -> dict:
+ """Exchange credentials for a session token. Does not require an existing one."""
+ data = json.dumps({"email": email, "password": password}).encode("utf-8")
+ req = urllib.request.Request(
+ f"{api_url()}/v1/auth/login", data=data, method="POST",
+ headers={"content-type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=30) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ detail, _ = _describe(exc)
+ raise CloudError(detail, status=exc.code) from None
+ except urllib.error.URLError as exc:
+ raise CloudError(f"could not reach {api_url()}: {exc.reason}") from None
+
+ write_token(payload["token"], payload.get("workspaceId", ""))
+ return payload
diff --git a/backend/services/thumbnail_ai.py b/backend/services/thumbnail_ai.py
index d9fb2fd..3bf2114 100644
--- a/backend/services/thumbnail_ai.py
+++ b/backend/services/thumbnail_ai.py
@@ -480,40 +480,15 @@ def _extract_json(text: str):
def _ask_ai_for_json(prompt: str, timeout: int = 30):
- """Run the first available AI CLI on `prompt`, returning the first JSON value
- it emits, or None if no CLI is available or none returns parseable JSON."""
- from services.claude_suggest import _find_ai_cli_candidates, _run_ai_command
-
- candidates = _find_ai_cli_candidates()
- if not candidates:
- return None
-
- prompt_file = None
- try:
- from utils.prompt_files import write_prompt_file
- prompt_file = write_prompt_file(prompt)
- project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
- for cli_path, engine in candidates:
- try:
- result = _run_ai_command(
- cli_path=cli_path, engine=engine, prompt=prompt,
- prompt_file=prompt_file, project_dir=project_dir, timeout=timeout,
- )
- except Exception as e:
- log_event("thumbnail-ai", "ai cli failed", level="warn", engine=engine, err=e)
- continue
- if result.returncode != 0 or not result.stdout.strip():
- continue
- parsed = _extract_json(result.stdout)
- if parsed is not None:
- return parsed
- finally:
- if prompt_file:
- try:
- os.unlink(prompt_file)
- except Exception:
- pass
- return None
+ """Run `prompt` through the AI provider chain, returning the first JSON value
+ it emits, or None if nothing is available or nothing returns parseable JSON."""
+ from services import ai_provider
+
+ parsed, result = ai_provider.generate_json(prompt, timeout=timeout)
+ if parsed is None:
+ log_event("thumbnail-ai", "ai request failed", level="warn",
+ err=result.error, tried=", ".join(result.attempts))
+ return parsed
def _thumbnail_kb_context() -> str:
diff --git a/backend/services/transcription_whispercpp.py b/backend/services/transcription_whispercpp.py
index de32502..6d3548c 100644
--- a/backend/services/transcription_whispercpp.py
+++ b/backend/services/transcription_whispercpp.py
@@ -16,6 +16,29 @@
_SPECIAL = re.compile(r"^\[.*\]$") # [_BEG_], [_TT_...], etc.
+# whisper.cpp's WHISPER_AHEADS_* presets. The alignment heads are per-architecture:
+# passing a preset whose layer/head indices exceed the loaded model's dimensions
+# aborts whisper-cli with exit 3, so the preset must track the model, not a default.
+_DTW_PRESETS = {
+ "tiny", "tiny.en", "base", "base.en", "small", "small.en",
+ "medium", "medium.en", "large.v1", "large.v2", "large.v3", "large.v3-turbo",
+}
+_QUANT_SUFFIX = re.compile(r"-(?:q\d+(?:_\d+)?(?:_?[a-z]+)*|f16|f32)$", re.IGNORECASE)
+
+
+def _dtw_preset_for_model(model_path: str) -> Optional[str]:
+ name = os.path.basename(model_path)
+ for ext in (".bin", ".gguf"):
+ if name.lower().endswith(ext):
+ name = name[: -len(ext)]
+ break
+ if name.lower().startswith("ggml-"):
+ name = name[5:]
+ name = _QUANT_SUFFIX.sub("", name).lower()
+ if name.startswith("large-v"):
+ name = "large." + name[len("large-"):]
+ return name if name in _DTW_PRESETS else None
+
def _extract_wav(media_path: str, wav_path: str, ffmpeg: str = "ffmpeg") -> None:
subprocess.run(
@@ -142,7 +165,7 @@ def transcribe_file(
whisper_cli: str = "whisper-cli",
ffmpeg: str = "ffmpeg",
language: Optional[str] = "en",
- dtw_model: str = "base",
+ dtw_model: Optional[str] = None,
threads: int = 4,
vad: bool = False,
vad_model: Optional[str] = None,
@@ -165,8 +188,9 @@ def transcribe_file(
cmd = [whisper_cli, "-m", model_path, "-f", wav, "-ojf",
"-of", out_base, "-t", str(threads)]
- if dtw_model:
- cmd += ["-dtw", dtw_model]
+ dtw = dtw_model if dtw_model is not None else _dtw_preset_for_model(model_path)
+ if dtw:
+ cmd += ["-dtw", dtw]
if vad and vad_model and os.path.exists(vad_model):
# VAD removes the trailing-words-into-silence failure mode but adds a
# systematic early bias (silence-removal remapping). Off by default;
diff --git a/cli/internal/engine/engine.go b/cli/internal/engine/engine.go
index 722be67..d6200a3 100644
--- a/cli/internal/engine/engine.go
+++ b/cli/internal/engine/engine.go
@@ -151,6 +151,34 @@ func MCPServer() string {
return ""
}
+func SyncScript() string {
+ p := filepath.Join(paths.RuntimeDir(), "studio", "sync.mjs")
+ if exists(p) {
+ return p
+ }
+ return ""
+}
+
+// RunSync reconciles this machine with the podcli Pro workspace. Ships with the
+// studio bundle because the sync logic lives on the TypeScript side, alongside
+// the clip history and asset registry it reconciles.
+func RunSync() (int, error) {
+ node, script := Node(), SyncScript()
+ if node == "" || script == "" {
+ return 1, fmt.Errorf("sync not provisioned — run `podcli setup`")
+ }
+ cmd := exec.Command(node, script)
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
+ cmd.Env = nodeEnv()
+ if err := cmd.Run(); err != nil {
+ if ee, ok := err.(*exec.ExitError); ok {
+ return ee.ExitCode(), nil
+ }
+ return 1, err
+ }
+ return 0, nil
+}
+
// nodeEnv builds the env a bundled Node server (studio/MCP) needs: the TS
// paths.ts reads these names (note PYTHON_PATH/FFMPEG_PATH differ from the
// PODCLI_* names the Python side uses). Project data stays cwd-local.
diff --git a/cli/main.go b/cli/main.go
index a6ba4ef..304311e 100644
--- a/cli/main.go
+++ b/cli/main.go
@@ -54,6 +54,12 @@ func main() {
fmt.Fprintln(os.Stderr, "podcli:", err)
}
os.Exit(code)
+ case "sync":
+ code, err := engine.RunSync()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "podcli:", err)
+ }
+ os.Exit(code)
case "config":
if len(args) >= 2 && (args[1] == "get" || args[1] == "set") {
os.Exit(configCmd(args[1:]))
@@ -772,6 +778,9 @@ PodStack commands (run inside Claude Code / Codex):
retro-episode Add --codex / --claude to pick the agent
Launcher commands:
+ login | logout | whoami
+ podcli Pro account on this machine
+ sync Reconcile clips, assets, and knowledge with your workspace
doctor Show resolved paths, interpreter, backend, ffmpeg, models
version Print version
update Check for and apply a newer release
diff --git a/scripts/build-studio.sh b/scripts/build-studio.sh
index 19f4866..1f6507a 100644
--- a/scripts/build-studio.sh
+++ b/scripts/build-studio.sh
@@ -22,4 +22,6 @@ node -e "require('esbuild').buildSync({entryPoints:['dist/ui/web-server.js'],bun
cp -r dist/ui/public "$out/public"
# MCP stdio server (the mcp__podcli__* tools Claude/Codex drive).
node -e "require('esbuild').buildSync({entryPoints:['dist/index.js'],bundle:true,platform:'node',format:'esm',outfile:'$out/mcp-server.mjs',banner:{js:\"$banner\"},logLevel:'error'})"
-echo "studio + mcp bundle -> $out"
+# `podcli sync` — reconciles clips, assets, and knowledge with a Pro workspace.
+node -e "require('esbuild').buildSync({entryPoints:['dist/sync.js'],bundle:true,platform:'node',format:'esm',outfile:'$out/sync.mjs',banner:{js:\"$banner\"},logLevel:'error'})"
+echo "studio + mcp + sync bundle -> $out"
diff --git a/src/models/index.ts b/src/models/index.ts
index 64054e3..908dfaf 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -2,7 +2,7 @@
export interface TaskRequest {
task_id: string;
- task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status";
+ task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status" | "ai_provider_status";
params: Record;
}
@@ -277,6 +277,12 @@ export interface ClipHistoryEntry {
description?: string;
tags?: string;
hashtags?: string;
+ // Set for signed-in users once the clip is mirrored to the workspace. A false
+ // cloud_synced marks a clip a later sweep should backfill; the local file
+ // stays the source of truth either way.
+ cloud_id?: string;
+ cloud_synced?: boolean;
+ cloud_video_uploaded?: boolean;
}
// === Knowledge Base Models ===
diff --git a/src/services/asset-sync.test.ts b/src/services/asset-sync.test.ts
new file mode 100644
index 0000000..3332256
--- /dev/null
+++ b/src/services/asset-sync.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { createHash } from "crypto";
+import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs";
+import { tmpdir } from "os";
+import { join } from "path";
+
+const tmp = mkdtempSync(join(tmpdir(), "podcli-assetsync-test-"));
+process.env.PODCLI_HOME = tmp;
+process.env.PODCLI_DATA = tmp;
+
+const digest = (body: string) =>
+ createHash("sha256").update(Buffer.from(body)).digest("hex").slice(0, 32);
+
+vi.mock("./podcli-cloud.js", () => ({
+ signedIn: vi.fn(async () => true),
+ listAssets: vi.fn(async () => []),
+ uploadAsset: vi.fn(async () => ({ unchanged: false })),
+ checksum: (body: Buffer) =>
+ createHash("sha256").update(body).digest("hex").slice(0, 32),
+}));
+
+const cloud = await import("./podcli-cloud.js");
+const { push } = await import("./asset-sync.js");
+const { AssetManager } = await import("./asset-manager.js");
+
+const intro = join(tmp, "intro.mp4");
+let assetName = "";
+
+describe("asset push", () => {
+ beforeEach(async () => {
+ rmSync(join(tmp, "assets"), { recursive: true, force: true });
+ mkdirSync(join(tmp, "assets"), { recursive: true });
+ writeFileSync(intro, "intro bytes");
+ vi.clearAllMocks();
+ vi.mocked(cloud.signedIn).mockResolvedValue(true);
+ assetName = (await new AssetManager().register("Show intro", intro, "intro")).name;
+ });
+
+ it("does not re-upload an asset the workspace already holds", async () => {
+ vi.mocked(cloud.listAssets).mockResolvedValue([
+ { id: "1", name: assetName, kind: "intro", is_default: false,
+ size_bytes: "11", checksum: digest("intro bytes") },
+ ]);
+
+ const report = await push();
+
+ expect(cloud.uploadAsset).not.toHaveBeenCalled();
+ expect(report.skipped).toContain(assetName);
+ expect(report.uploaded).toEqual([]);
+ });
+
+ it("uploads when the local file has changed", async () => {
+ vi.mocked(cloud.listAssets).mockResolvedValue([
+ { id: "1", name: assetName, kind: "intro", is_default: false,
+ size_bytes: "11", checksum: digest("something else") },
+ ]);
+
+ const report = await push();
+
+ expect(cloud.uploadAsset).toHaveBeenCalledTimes(1);
+ expect(report.uploaded).toContain(assetName);
+ });
+
+ it("still uploads when the listing cannot be read", async () => {
+ vi.mocked(cloud.listAssets).mockRejectedValue(new Error("offline"));
+
+ const report = await push();
+
+ expect(cloud.uploadAsset).toHaveBeenCalledTimes(1);
+ expect(report.failed).toEqual([]);
+ });
+});
diff --git a/src/services/asset-sync.ts b/src/services/asset-sync.ts
new file mode 100644
index 0000000..6265da1
--- /dev/null
+++ b/src/services/asset-sync.ts
@@ -0,0 +1,173 @@
+import { existsSync } from "fs";
+import { mkdir, readFile, writeFile } from "fs/promises";
+import { join } from "path";
+import { paths } from "../config/paths.js";
+import { AssetManager, inferType } from "./asset-manager.js";
+import * as cloud from "./podcli-cloud.js";
+import type { Asset, AssetType } from "../models/index.js";
+
+/**
+ * Two-way sync between .podcli/assets/ and the workspace asset library.
+ *
+ * Local assets keep working untouched for everyone; this only runs for
+ * signed-in users. The point is that a second machine, or a teammate, gets the
+ * show's logo and outro without anyone emailing files around.
+ */
+
+const SYNCABLE_KINDS: Record = {
+ logo: "logo",
+ intro: "intro",
+ outro: "outro",
+ music: "music",
+} as Record;
+
+function cloudKind(type: AssetType): string {
+ return SYNCABLE_KINDS[type] ?? "other";
+}
+
+export type SyncReport = {
+ uploaded: string[];
+ downloaded: string[];
+ skipped: string[];
+ failed: Array<{ name: string; reason: string }>;
+};
+
+const empty = (): SyncReport => ({ uploaded: [], downloaded: [], skipped: [], failed: [] });
+
+/**
+ * Push local assets the workspace doesn't have.
+ *
+ * The server discards an upload whose checksum it already holds, but only after
+ * receiving it. Comparing first keeps a 200 MB intro off the wire on every
+ * sync; if the listing cannot be fetched, everything is uploaded as before.
+ */
+export async function push(): Promise {
+ const report = empty();
+ if (!(await cloud.signedIn())) return report;
+
+ const manager = new AssetManager();
+ const registry = await manager.load();
+
+ let held = new Map();
+ try {
+ held = new Map((await cloud.listAssets()).map((a) => [a.name, a.checksum]));
+ } catch {
+ // Fall through: an unreadable listing must not stop the push.
+ }
+
+ for (const asset of registry.assets) {
+ if (!existsSync(asset.path)) {
+ report.skipped.push(asset.name);
+ continue;
+ }
+ try {
+ const body = await readFile(asset.path);
+ if (held.get(asset.name) === cloud.checksum(body)) {
+ report.skipped.push(asset.name);
+ continue;
+ }
+ const result = await cloud.uploadAsset(
+ asset.name,
+ cloudKind(asset.type),
+ body,
+ Boolean(asset.default),
+ );
+ if (result?.unchanged) report.skipped.push(asset.name);
+ else report.uploaded.push(asset.name);
+ } catch (err) {
+ report.failed.push({
+ name: asset.name,
+ reason: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ return report;
+}
+
+/**
+ * Pull workspace assets this machine is missing.
+ *
+ * Files land in .podcli/assets/ and are registered locally, so every existing
+ * code path — rendering, presets, the studio — finds them exactly where it
+ * already looks. Nothing downstream needs to know they came from a server.
+ */
+export async function pull(): Promise {
+ const report = empty();
+ if (!(await cloud.signedIn())) return report;
+
+ const manager = new AssetManager();
+ const registry = await manager.load();
+ const known = new Map(registry.assets.map((a) => [a.name, a]));
+
+ let remote: Awaited>;
+ try {
+ remote = await cloud.listAssets();
+ } catch (err) {
+ report.failed.push({
+ name: "(list)",
+ reason: err instanceof Error ? err.message : String(err),
+ });
+ return report;
+ }
+
+ const dir = join(paths.assets, "shared");
+ for (const entry of remote) {
+ const local = known.get(entry.name);
+ // A local file that already exists wins: the user's own copy is never
+ // silently overwritten by the workspace version.
+ if (local && existsSync(local.path)) {
+ report.skipped.push(entry.name);
+ continue;
+ }
+ try {
+ const body = await cloud.downloadAsset(entry.id);
+ await mkdir(dir, { recursive: true });
+ // The whole name, flattened: two workspace assets called `intro/logo.png`
+ // and `outro/logo.png` both end in `logo.png`, and the second download
+ // would land on the first and leave two registry entries pointing at one
+ // file.
+ const target = join(dir, entry.name.replace(/[\\/]+/g, "-"));
+ await writeFile(target, body);
+ // The workspace already knows what this is. Re-deriving the type from the
+ // extension turns a `music` asset stored as .mp4 into a video.
+ await manager.register(entry.name, target, assetType(entry.kind) ?? inferType(target));
+ report.downloaded.push(entry.name);
+ } catch (err) {
+ report.failed.push({
+ name: entry.name,
+ reason: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ return report;
+}
+
+/**
+ * The kinds worth taking back from the workspace.
+ *
+ * "other" is deliberately absent even though it is a valid type both ends: it
+ * is what `cloudKind` uploads anything unrecognised as, so honouring it on the
+ * way back would turn a local video into "other" on the round trip. Guessing
+ * from the file is the better answer for exactly that case.
+ */
+const ASSET_TYPES: readonly AssetType[] = [
+ "logo", "outro", "intro", "music", "video", "image", "audio",
+];
+
+/** The workspace's own kind, when it is one this app models. */
+function assetType(kind: string | undefined): AssetType | null {
+ return kind && (ASSET_TYPES as readonly string[]).includes(kind)
+ ? (kind as AssetType)
+ : null;
+}
+
+export async function sync(): Promise {
+ const up = await push();
+ const down = await pull();
+ return {
+ uploaded: up.uploaded,
+ downloaded: down.downloaded,
+ skipped: [...up.skipped, ...down.skipped],
+ failed: [...up.failed, ...down.failed],
+ };
+}
diff --git a/src/services/clips-history-cloud.test.ts b/src/services/clips-history-cloud.test.ts
new file mode 100644
index 0000000..8bd84b0
--- /dev/null
+++ b/src/services/clips-history-cloud.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs";
+import { tmpdir } from "os";
+import { join } from "path";
+
+const tmp = mkdtempSync(join(tmpdir(), "podcli-clipcloud-test-"));
+process.env.PODCLI_HOME = tmp;
+process.env.PODCLI_DATA = tmp;
+
+vi.mock("./podcli-cloud.js", () => ({
+ signedIn: vi.fn(async () => false),
+ sourceHash: vi.fn(async () => "abc123"),
+ registerClip: vi.fn(async () => ({ id: "cloud-clip-1" })),
+ uploadClipVideo: vi.fn(async () => true),
+ logClipEvent: vi.fn(async () => undefined),
+}));
+
+const cloud = await import("./podcli-cloud.js");
+const { ClipsHistory } = await import("./clips-history.js");
+
+const source = join(tmp, "episode.mp4");
+const output = join(tmp, "clip.mp4");
+
+/** record() fires its cloud sync in the background; let it settle before asserting. */
+const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
+
+async function seed(history: InstanceType) {
+ return history.record({
+ title: "A clip",
+ source_video: source,
+ output_path: output,
+ duration: 42,
+ start_second: 10,
+ end_second: 52,
+ format: "9:16",
+ } as never);
+}
+
+describe("clip cloud sync", () => {
+ let history: InstanceType;
+
+ beforeEach(() => {
+ rmSync(join(tmp, "history"), { recursive: true, force: true });
+ mkdirSync(join(tmp, "history"), { recursive: true });
+ writeFileSync(source, "source bytes");
+ writeFileSync(output, "rendered bytes");
+ vi.clearAllMocks();
+ vi.mocked(cloud.signedIn).mockResolvedValue(false);
+ history = new ClipsHistory();
+ });
+
+ it("makes no network call when signed out", async () => {
+ await seed(history);
+ const result = await history.backfillCloud();
+
+ expect(result).toEqual({ synced: 0, failed: 0 });
+ expect(cloud.registerClip).not.toHaveBeenCalled();
+ expect(cloud.uploadClipVideo).not.toHaveBeenCalled();
+ });
+
+ it("uploads the rendered clip after registering it", async () => {
+ const entry = await seed(history);
+ await settle();
+ vi.mocked(cloud.signedIn).mockResolvedValue(true);
+
+ await history.backfillCloud();
+
+ expect(cloud.registerClip).toHaveBeenCalledTimes(1);
+ expect(cloud.uploadClipVideo).toHaveBeenCalledWith("cloud-clip-1", output);
+ const after = await history.findById(entry.id);
+ expect(after?.cloud_id).toBe("cloud-clip-1");
+ expect(after?.cloud_video_uploaded).toBe(true);
+ });
+
+ it("registers a clip once when two syncs overlap", async () => {
+ await seed(history);
+ await settle();
+ vi.mocked(cloud.signedIn).mockResolvedValue(true);
+
+ await Promise.all([history.backfillCloud(), history.backfillCloud()]);
+
+ expect(cloud.registerClip).toHaveBeenCalledTimes(1);
+ expect(cloud.uploadClipVideo).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not re-upload a clip whose video the workspace already has", async () => {
+ const entry = await seed(history);
+ await settle();
+ vi.mocked(cloud.signedIn).mockResolvedValue(true);
+ await history.backfillCloud();
+ vi.clearAllMocks();
+ vi.mocked(cloud.signedIn).mockResolvedValue(true);
+
+ await history.backfillCloud();
+
+ expect(cloud.uploadClipVideo).not.toHaveBeenCalled();
+ expect(cloud.registerClip).not.toHaveBeenCalled();
+ expect((await history.findById(entry.id))?.cloud_video_uploaded).toBe(true);
+ });
+});
diff --git a/src/services/clips-history.ts b/src/services/clips-history.ts
index 316a43f..4ba255a 100644
--- a/src/services/clips-history.ts
+++ b/src/services/clips-history.ts
@@ -40,6 +40,7 @@ export class ClipsHistory {
// requests can't lose each other's edits. Cross-process safety (vs the Python
// CLI) rests on the atomic temp-file rename in save().
private writeChain: Promise = Promise.resolve();
+ private syncing = new Set();
private async ensureDir() {
if (!existsSync(paths.history)) {
@@ -86,9 +87,76 @@ export class ClipsHistory {
await this.mutate((entries) => {
entries.push(full);
});
+ void this.syncToCloud(full);
return full;
}
+ /**
+ * Mirror a rendered clip to the workspace, for signed-in users.
+ *
+ * Deliberately not awaited and unable to throw: a clip that rendered
+ * successfully must be recorded locally whether or not a server was reachable.
+ * The local history file remains the source of truth; this is a copy.
+ *
+ * Clips that fail to sync are left marked so a later sweep can backfill them —
+ * the performance model wants the whole history, not the part that happened to
+ * have a working network.
+ */
+ private async syncToCloud(entry: ClipHistoryEntry): Promise {
+ // record() starts this in the background, so `podcli sync` can reach the
+ // same entry while it is still in flight and register the clip twice.
+ if (this.syncing.has(entry.id)) return;
+ this.syncing.add(entry.id);
+ try {
+ const cloud = await import("./podcli-cloud.js");
+ if (!(await cloud.signedIn())) return;
+
+ const source = entry.source_video;
+ if (!source) return;
+
+ const clipId = entry.cloud_id ?? (await cloud.registerClip({
+ sourceHash: await cloud.sourceHash(source),
+ episodeTitle: basename(source),
+ title: entry.title,
+ startSecond: entry.start_second,
+ endSecond: entry.end_second,
+ durationSec: entry.duration,
+ contentType: entry.content_type,
+ captionStyle: entry.caption_style,
+ aspectRatio: entry.format,
+ transcriptSlice: entry.transcript_slice,
+ }))?.id;
+ if (!clipId) return;
+
+ await this.update(entry.id, { cloud_id: clipId });
+
+ // Metadata alone leaves a share link with nothing to play, so the
+ // rendered file follows it. Uploaded once: the server keeps the first
+ // copy and answers `unchanged` after that.
+ let hasVideo = entry.cloud_video_uploaded === true;
+ // A rendered file that no longer exists locally can never be uploaded.
+ // There is nothing left to do for it, and reporting it as failed on every
+ // run is the unfixable number this file already refuses to print.
+ const uploadable = !hasVideo && existsSync(entry.output_path);
+
+ if (uploadable) {
+ hasVideo = await cloud.uploadClipVideo(clipId, entry.output_path);
+ if (hasVideo) await this.update(entry.id, { cloud_video_uploaded: true });
+ }
+
+ // Synchronised means the clip is watchable, not merely described: an
+ // upload that failed while still reporting success is how `podcli sync`
+ // exits happy with every share link playing nothing.
+ await this.update(entry.id, {
+ cloud_synced: hasVideo || !existsSync(entry.output_path),
+ });
+ } catch {
+ await this.update(entry.id, { cloud_synced: false }).catch(() => {});
+ } finally {
+ this.syncing.delete(entry.id);
+ }
+ }
+
// Persist every successful row of a batch render. Single source of truth for
// turning backend batch results into history entries — callers used to inline
// this loop, drifting on defaults and on which fields got recorded.
@@ -230,12 +298,76 @@ export class ClipsHistory {
async update(id: string, patch: Partial): Promise {
if (!id) return null;
- return this.mutate((entries) => {
+ const changed = await this.mutate((entries) => {
const e = entries.find((x) => x.id === id);
if (!e) return null;
+ const before = e.title;
Object.assign(e, patch);
- return e;
+ return { entry: e, previousTitle: before };
});
+ if (!changed) return null;
+
+ // A human rewriting a generated title is the clearest taste signal podcli
+ // gets — it says what the model produced and what a person preferred
+ // instead. Reported only when the title actually changed, so the sync
+ // bookkeeping in syncToCloud can't trigger it.
+ if (patch.title !== undefined && patch.title !== changed.previousTitle) {
+ void this.reportEvent(changed.entry, "title_edited", changed.previousTitle, patch.title);
+ }
+ return changed.entry;
+ }
+
+ /** Best-effort; never blocks or fails the edit that produced it. */
+ private async reportEvent(
+ entry: ClipHistoryEntry,
+ kind: "title_edited" | "discarded" | "thumbnail_regenerated",
+ before?: string,
+ after?: string,
+ ): Promise {
+ if (!entry.cloud_id) return;
+ try {
+ const cloud = await import("./podcli-cloud.js");
+ if (!(await cloud.signedIn())) return;
+ await cloud.logClipEvent(entry.cloud_id, kind, before, after);
+ } catch {
+ // The signal is nice to have, not worth surfacing an error over.
+ }
+ }
+
+ /**
+ * Push clips that never reached the workspace.
+ *
+ * Covers two cases that both matter: a render that happened while the network
+ * was down, and — more importantly — everything rendered *before* the user
+ * subscribed. A new Pro user should start with their back catalogue behind the
+ * performance model, not an empty history.
+ */
+ async backfillCloud(limit = 200): Promise<{ synced: number; failed: number }> {
+ const cloud = await import("./podcli-cloud.js");
+ if (!(await cloud.signedIn())) return { synced: 0, failed: 0 };
+
+ // A clip whose source video has been moved or deleted can never be hashed,
+ // so it can never sync. Skipping it keeps `podcli sync` quiet; counting it
+ // as a failure would report the same unfixable number on every run until
+ // people stopped reading the output.
+ const pending = (await this.load())
+ .filter((e) => e.source_video && existsSync(e.source_video))
+ .filter((e) => !e.cloud_id || !e.cloud_video_uploaded)
+ .slice(0, limit);
+
+ let synced = 0;
+ let failed = 0;
+ for (const entry of pending) {
+ try {
+ await this.syncToCloud(entry);
+ const after = await this.findById(entry.id);
+ if (after?.cloud_synced) synced++;
+ else failed++;
+ } catch {
+ failed++;
+ }
+ }
+ return { synced, failed };
}
// Remove a clip and the artifacts podcli rendered for it (output video,
diff --git a/src/services/knowledge-sync.test.ts b/src/services/knowledge-sync.test.ts
new file mode 100644
index 0000000..4a6ccee
--- /dev/null
+++ b/src/services/knowledge-sync.test.ts
@@ -0,0 +1,104 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mkdtempSync, rmSync, mkdirSync, existsSync, readdirSync, writeFileSync, readFileSync } from "fs";
+import { tmpdir } from "os";
+import { join } from "path";
+
+const tmp = mkdtempSync(join(tmpdir(), "podcli-ksync-test-"));
+process.env.PODCLI_HOME = tmp;
+process.env.PODCLI_DATA = tmp;
+
+vi.mock("./podcli-cloud.js", () => ({
+ signedIn: vi.fn(async () => true),
+ listKnowledge: vi.fn(async () => []),
+ getKnowledge: vi.fn(async () => ({ content: "owned", version: 1 })),
+ putKnowledge: vi.fn(async () => ({ conflict: false, version: 1, unchanged: true })),
+}));
+
+const cloud = await import("./podcli-cloud.js");
+const { sync } = await import("./knowledge-sync.js");
+
+describe("knowledge sync", () => {
+ beforeEach(() => {
+ rmSync(join(tmp, "knowledge"), { recursive: true, force: true });
+ // The sync state lives beside the folder, not in it. Leaving it behind made
+ // these tests order-dependent: the pull phase skips any path already in the
+ // map, so a later test passed only because an earlier one had not recorded
+ // a version for the same filename.
+ rmSync(join(tmp, "knowledge-sync.json"), { force: true });
+ mkdirSync(join(tmp, "knowledge"), { recursive: true });
+ vi.clearAllMocks();
+ });
+
+ it("refuses a workspace path that escapes the knowledge folder", async () => {
+ vi.mocked(cloud.listKnowledge).mockResolvedValue([
+ { path: "../../pwned.md", version: 1, updated_at: "" },
+ ]);
+
+ const report = await sync();
+
+ expect(existsSync(join(tmp, "..", "pwned.md"))).toBe(false);
+ expect(report.pulled).toEqual([]);
+ expect(report.failed[0]?.path).toBe("../../pwned.md");
+ // Rejected before the content is ever requested.
+ expect(cloud.getKnowledge).not.toHaveBeenCalled();
+ });
+
+ it("never pushes shipped defaults over the workspace copy on a first sync", async () => {
+ writeFileSync(join(tmp, "knowledge", "02-voice-and-tone.md"), "# shipped default");
+ vi.mocked(cloud.listKnowledge).mockResolvedValue([
+ { path: "02-voice-and-tone.md", version: 7, updated_at: "" },
+ ]);
+ vi.mocked(cloud.getKnowledge).mockResolvedValue({
+ content: "# the team's real voice guide", version: 7,
+ });
+
+ const report = await sync();
+
+ expect(cloud.putKnowledge).not.toHaveBeenCalled();
+ expect(report.conflicts).toEqual([{ path: "02-voice-and-tone.md", theirVersion: 7 }]);
+ // Neither copy is lost.
+ expect(readFileSync(join(tmp, "knowledge", "02-voice-and-tone.md"), "utf-8"))
+ .toBe("# shipped default");
+ expect(readFileSync(join(tmp, "knowledge", "02-voice-and-tone.md.workspace-7"), "utf-8"))
+ .toBe("# the team's real voice guide");
+ });
+
+ it("adopts the workspace version when both copies already match", async () => {
+ writeFileSync(join(tmp, "knowledge", "05-title-formulas.md"), "# same bytes");
+ vi.mocked(cloud.listKnowledge).mockResolvedValue([
+ { path: "05-title-formulas.md", version: 4, updated_at: "" },
+ ]);
+ vi.mocked(cloud.getKnowledge).mockResolvedValue({ content: "# same bytes", version: 4 });
+
+ const report = await sync();
+
+ expect(cloud.putKnowledge).not.toHaveBeenCalled();
+ expect(report.unchanged).toEqual(["05-title-formulas.md"]);
+ expect(report.conflicts).toEqual([]);
+ expect(existsSync(join(tmp, "knowledge", "05-title-formulas.md.workspace-4"))).toBe(false);
+ });
+
+ it("pushes a local file the workspace does not have", async () => {
+ writeFileSync(join(tmp, "knowledge", "99-mine.md"), "# only here");
+ vi.mocked(cloud.listKnowledge).mockResolvedValue([]);
+ vi.mocked(cloud.putKnowledge).mockResolvedValue({
+ conflict: false, version: 1, unchanged: false,
+ });
+
+ const report = await sync();
+
+ expect(cloud.putKnowledge).toHaveBeenCalledWith("99-mine.md", "# only here", undefined);
+ expect(report.pushed).toEqual(["99-mine.md"]);
+ });
+
+ it("pulls a file the workspace has and this machine does not", async () => {
+ vi.mocked(cloud.listKnowledge).mockResolvedValue([
+ { path: "02-voice-and-tone.md", version: 3, updated_at: "" },
+ ]);
+
+ const report = await sync();
+
+ expect(report.pulled).toEqual(["02-voice-and-tone.md"]);
+ expect(readdirSync(join(tmp, "knowledge"))).toContain("02-voice-and-tone.md");
+ });
+});
diff --git a/src/services/knowledge-sync.ts b/src/services/knowledge-sync.ts
new file mode 100644
index 0000000..cbd529c
--- /dev/null
+++ b/src/services/knowledge-sync.ts
@@ -0,0 +1,172 @@
+import { existsSync } from "fs";
+import { mkdir, readFile, readdir, writeFile } from "fs/promises";
+import { dirname, join, relative, resolve, sep } from "path";
+import { paths } from "../config/paths.js";
+import * as cloud from "./podcli-cloud.js";
+
+/**
+ * Sync .podcli/knowledge/ with the workspace.
+ *
+ * This is the shared brand brain: voice, banned words, title formulas,
+ * thumbnail rules. A new editor joining a team should inherit all of it by
+ * signing in, rather than being sent a folder over Slack.
+ *
+ * Free podcli keeps these files local and fully effective, as it always will.
+ */
+
+const STATE_FILE = "knowledge-sync.json";
+
+/**
+ * Version of each file as of the last successful sync.
+ *
+ * Without this there is no way to tell "I edited this" from "they edited this"
+ * — both just look like a difference — and every sync would either clobber
+ * someone or refuse to do anything.
+ */
+type SyncState = Record;
+
+async function loadState(): Promise {
+ try {
+ return JSON.parse(await readFile(join(paths.home, STATE_FILE), "utf-8"));
+ } catch {
+ return {};
+ }
+}
+
+async function saveState(state: SyncState): Promise {
+ await mkdir(paths.home, { recursive: true });
+ await writeFile(join(paths.home, STATE_FILE), JSON.stringify(state, null, 2), "utf-8");
+}
+
+/**
+ * The workspace decides these filenames, so a server that returned
+ * `../../.zshrc` would otherwise have this write anywhere the user can.
+ */
+function insideKnowledge(path: string): string | null {
+ const root = resolve(paths.knowledge);
+ const target = resolve(root, path);
+ return target.startsWith(root + sep) ? target : null;
+}
+
+/**
+ * Every .md under the knowledge folder, as workspace-style relative paths.
+ *
+ * Walked by hand rather than with `readdir({ recursive })`: that option needs
+ * Node 20.1 and `dirent.parentPath` needs 20.12, while podcli supports 18. CI
+ * runs 20, so the crash would have reached Node 18 users rather than the build.
+ *
+ * Recursive at all because the workspace accepts nested paths: a flat listing
+ * pulled `brand/voice.md` once and then never pushed a local edit to it again.
+ */
+async function localFiles(): Promise {
+ if (!existsSync(paths.knowledge)) return [];
+
+ const found: string[] = [];
+ const walk = async (dir: string): Promise => {
+ const entries = await readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) await walk(full);
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
+ found.push(relative(paths.knowledge, full).split(sep).join("/"));
+ }
+ }
+ };
+
+ await walk(paths.knowledge);
+ return found.sort();
+}
+
+export type KnowledgeSyncReport = {
+ pushed: string[];
+ pulled: string[];
+ unchanged: string[];
+ conflicts: Array<{ path: string; theirVersion: number }>;
+ failed: Array<{ path: string; reason: string }>;
+};
+
+export async function sync(): Promise {
+ const report: KnowledgeSyncReport = {
+ pushed: [], pulled: [], unchanged: [], conflicts: [], failed: [],
+ };
+ if (!(await cloud.signedIn())) return report;
+
+ const state = await loadState();
+ const remote = new Map((await cloud.listKnowledge()).map((f) => [f.path, f]));
+ const local = await localFiles();
+ const localSet = new Set(local);
+
+ // Reconciled before anything is pushed. podcli ships default knowledge files,
+ // so a machine that has never synced has a full set of boilerplate that would
+ // otherwise be pushed straight over the workspace's real one — the server
+ // skips its conflict check when no expectedVersion is sent.
+ const unresolved = new Set();
+ for (const [path, meta] of remote) {
+ const target = insideKnowledge(path);
+ if (!target) {
+ report.failed.push({ path, reason: "path escapes the knowledge folder" });
+ unresolved.add(path);
+ continue;
+ }
+ if (state[path] !== undefined) continue;
+
+ try {
+ const file = await cloud.getKnowledge(path);
+ const version = file.version ?? meta.version;
+
+ if (!localSet.has(path)) {
+ await mkdir(dirname(target), { recursive: true });
+ await writeFile(target, file.content, "utf-8");
+ state[path] = version;
+ report.pulled.push(path);
+ continue;
+ }
+
+ // Both sides have this file and nothing records which came first. Equal
+ // content is simply adopted; otherwise the workspace copy lands beside
+ // the local one and a human decides.
+ const mine = await readFile(target, "utf-8");
+ if (mine === file.content) {
+ state[path] = version;
+ report.unchanged.push(path);
+ } else {
+ await writeFile(join(paths.knowledge, `${path}.workspace-${version}`),
+ file.content, "utf-8");
+ report.conflicts.push({ path, theirVersion: version });
+ }
+ unresolved.add(path);
+ } catch (err) {
+ report.failed.push({ path, reason: err instanceof Error ? err.message : String(err) });
+ unresolved.add(path);
+ }
+ }
+
+ for (const path of local) {
+ if (unresolved.has(path)) continue;
+ const content = await readFile(join(paths.knowledge, path), "utf-8");
+ const known = state[path];
+ try {
+ const result = await cloud.putKnowledge(path, content, known);
+ if (result.conflict) {
+ // Neither copy is discarded. The workspace version is written beside
+ // the local one so a human can compare and merge; nobody's work is
+ // thrown away by a sync running in the background.
+ const version = Number(result.version);
+ const suffix = Number.isFinite(version) ? version : "remote";
+ const theirs = join(paths.knowledge, `${path}.workspace-${suffix}`);
+ await writeFile(theirs, result.content, "utf-8");
+ report.conflicts.push({ path, theirVersion: version });
+ continue;
+ }
+ state[path] = result.version;
+ if (result.unchanged) report.unchanged.push(path);
+ else report.pushed.push(path);
+ } catch (err) {
+ report.failed.push({ path, reason: err instanceof Error ? err.message : String(err) });
+ }
+ }
+
+
+ await saveState(state);
+ return report;
+}
diff --git a/src/services/podcli-cloud.ts b/src/services/podcli-cloud.ts
new file mode 100644
index 0000000..c669777
--- /dev/null
+++ b/src/services/podcli-cloud.ts
@@ -0,0 +1,292 @@
+import { createHash } from "node:crypto";
+import { createReadStream } from "node:fs";
+import { readFile, stat } from "node:fs/promises";
+import { join } from "node:path";
+import { paths } from "../config/paths.js";
+
+/**
+ * Client for podcli Pro's hosted API.
+ *
+ * The Python backend has its own copy of this because the two runtimes cannot
+ * share one — deliberate duplication of about eighty lines, not an accident.
+ *
+ * Nothing here is secret. The server decides entitlement, so a patched client
+ * gets an HTTP 401 rather than free Pro.
+ */
+
+const DEFAULT_API_URL = "https://api.podcli.com";
+
+export function apiUrl(): string {
+ return (process.env.PODCLI_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
+}
+
+/**
+ * Read on every call, deliberately not cached.
+ *
+ * The studio server is long-running, so a cached token survives `podcli logout`
+ * in another terminal and the UI keeps claiming the user is signed in. Reading a
+ * small file costs microseconds against the HTTP request that follows it, so
+ * caching bought nothing and cost correctness.
+ */
+export async function readToken(): Promise {
+ const fromEnv = (process.env.PODCLI_TOKEN || "").trim();
+ if (fromEnv) return fromEnv;
+ try {
+ const raw = await readFile(join(paths.home, "auth.json"), "utf-8");
+ return ((JSON.parse(raw).token as string | undefined) || "").trim() || null;
+ } catch {
+ return null;
+ }
+}
+
+export async function signedIn(): Promise {
+ return (await readToken()) !== null;
+}
+
+async function request(method: string, path: string, body?: unknown, timeoutMs = 30_000) {
+ const token = await readToken();
+ if (!token) throw new Error("not signed in");
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetch(`${apiUrl()}${path}`, {
+ method,
+ headers: {
+ authorization: `Bearer ${token}`,
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: controller.signal,
+ });
+ if (!response.ok) {
+ const detail = await response.text().catch(() => "");
+ throw new Error(`HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`);
+ }
+ const text = await response.text();
+ return text ? JSON.parse(text) : null;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/**
+ * Identifies an episode across machines.
+ *
+ * Hashing the first 8 MB rather than the whole file: a 2 GB master would take
+ * seconds to digest and the head of a video is more than distinctive enough to
+ * key on. Two editors working from the same file land on the same episode.
+ */
+export async function sourceHash(videoPath: string): Promise {
+ const hash = createHash("sha256");
+ const stream = createReadStream(videoPath, { start: 0, end: 8 * 1024 * 1024 - 1 });
+ for await (const chunk of stream) hash.update(chunk as Buffer);
+ return hash.digest("hex").slice(0, 32);
+}
+
+export type ClipRegistration = {
+ sourceHash: string;
+ episodeTitle?: string;
+ episodeDuration?: number;
+ title?: string;
+ startSecond?: number;
+ endSecond?: number;
+ durationSec?: number;
+ contentType?: string;
+ captionStyle?: string;
+ aspectRatio?: string;
+ aiEngine?: string;
+ score?: number;
+ quote?: string;
+ reasoning?: string;
+ transcriptSlice?: string;
+ extra?: Record;
+};
+
+export async function registerClip(clip: ClipRegistration): Promise<{ id: string } | null> {
+ return request("POST", "/v1/clips", clip);
+}
+
+/** Matches the server's body cap; a larger file is refused before the upload. */
+const MAX_CLIP_BYTES = 200 * 1024 * 1024;
+
+/**
+ * Send the rendered clip itself, so share links have something to play.
+ *
+ * Only the rendered clip travels — never the source video. It is the whole
+ * reason a share link can exist without the storage cost of the master.
+ */
+export async function uploadClipVideo(clipId: string, filePath: string): Promise {
+ const token = await readToken();
+ if (!token) return false;
+
+ const { size } = await stat(filePath);
+ if (size === 0 || size > MAX_CLIP_BYTES) return false;
+
+ const response = await fetch(`${apiUrl()}/v1/clips/${clipId}/video`, {
+ method: "PUT",
+ headers: { authorization: `Bearer ${token}`, "content-type": "video/mp4" },
+ body: await readFile(filePath),
+ signal: AbortSignal.timeout(300_000),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
+ }
+ return true;
+}
+
+export type Breakdown = {
+ key: string;
+ clips: number;
+ retention: number | null;
+ ctr: number | null;
+ views: number | null;
+};
+
+export type Insights = {
+ sampleSize: number;
+ byContentType: Breakdown[];
+ byCaptionStyle: Breakdown[];
+ byLength: Breakdown[];
+ topClips: Array<{ title: string; retention: number; views: number; content_type: string }>;
+ guidance: string[];
+};
+
+export type Preferences = {
+ titleEdits: Array<{ before: string; after: string }>;
+ discardRate: number | null;
+ observations: string[];
+};
+
+// Nullable because `request` returns null for an empty body, and a caller that
+// trusts the declared shape would dereference it.
+export async function getInsights(): Promise {
+ return request("GET", "/v1/insights");
+}
+
+export async function getPreferences(): Promise {
+ return request("GET", "/v1/insights/preferences");
+}
+
+export async function whoami(): Promise<{
+ workspaceId: string;
+ role: string;
+ plan: string;
+ workspace: { name: string; episodes_used: number };
+}> {
+ return request("GET", "/v1/auth/me", undefined, 10_000);
+}
+
+export type RemoteKnowledgeFile = { path: string; version: number; updated_at: string };
+
+export async function listKnowledge(): Promise {
+ const payload = await request("GET", "/v1/knowledge");
+ return payload?.files ?? [];
+}
+
+export async function getKnowledge(path: string): Promise<{ content: string; version: number }> {
+ return request("GET", `/v1/knowledge/file?path=${encodeURIComponent(path)}`);
+}
+
+export type PutKnowledgeResult =
+ | { conflict: false; version: number; unchanged: boolean }
+ | { conflict: true; version: number; content: string };
+
+export async function putKnowledge(
+ path: string,
+ content: string,
+ expectedVersion?: number,
+): Promise {
+ const token = await readToken();
+ if (!token) throw new Error("not signed in");
+
+ const response = await fetch(`${apiUrl()}/v1/knowledge/file`, {
+ method: "PUT",
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
+ body: JSON.stringify({ path, content, expectedVersion }),
+ signal: AbortSignal.timeout(30_000),
+ });
+
+ // A 409 is an expected outcome here, not an error: someone else edited the
+ // file. The body carries their version so the caller can show both.
+ if (response.status === 409) {
+ const body = await response.json();
+ return { conflict: true, version: body.version, content: body.content };
+ }
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
+ }
+ const body = await response.json();
+ return { conflict: false, version: body.version, unchanged: Boolean(body.unchanged) };
+}
+
+export type RemoteAsset = {
+ id: string;
+ name: string;
+ kind: string;
+ is_default: boolean;
+ size_bytes: string;
+ checksum: string;
+};
+
+/** Mirrors how the workspace digests an asset, so an upload can be skipped. */
+export function checksum(body: Buffer): string {
+ return createHash("sha256").update(body).digest("hex").slice(0, 32);
+}
+
+export async function listAssets(): Promise {
+ const payload = await request("GET", "/v1/assets");
+ return payload?.assets ?? [];
+}
+
+export async function uploadAsset(
+ name: string,
+ kind: string,
+ body: Buffer,
+ isDefault = false,
+): Promise<{ id: string; unchanged?: boolean }> {
+ const token = await readToken();
+ if (!token) throw new Error("not signed in");
+
+ const params = new URLSearchParams({ name, kind, isDefault: String(isDefault) });
+ const response = await fetch(`${apiUrl()}/v1/assets?${params}`, {
+ method: "PUT",
+ headers: {
+ authorization: `Bearer ${token}`,
+ "content-type": "application/octet-stream",
+ },
+ // Node's fetch wants a view, not the Buffer's whole underlying pool.
+ body: new Uint8Array(body),
+ signal: AbortSignal.timeout(300_000),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
+ }
+ return response.json();
+}
+
+export async function downloadAsset(id: string): Promise {
+ const token = await readToken();
+ if (!token) throw new Error("not signed in");
+
+ const response = await fetch(`${apiUrl()}/v1/assets/${id}/download`, {
+ headers: { authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(300_000),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return Buffer.from(await response.arrayBuffer());
+}
+
+export type ClipEventKind =
+ | "suggested" | "rendered" | "discarded"
+ | "title_edited" | "thumbnail_regenerated"
+ | "approved" | "changes_requested" | "published";
+
+export async function logClipEvent(
+ cloudClipId: string,
+ kind: ClipEventKind,
+ before?: string,
+ after?: string,
+): Promise {
+ await request("POST", `/v1/clips/${cloudClipId}/events`, { kind, before, after });
+}
diff --git a/src/sync.ts b/src/sync.ts
new file mode 100644
index 0000000..059afb9
--- /dev/null
+++ b/src/sync.ts
@@ -0,0 +1,86 @@
+import { ClipsHistory } from "./services/clips-history.js";
+import * as assetSync from "./services/asset-sync.js";
+import * as knowledgeSync from "./services/knowledge-sync.js";
+import * as cloud from "./services/podcli-cloud.js";
+
+/**
+ * `podcli sync` — reconcile this machine with the workspace.
+ *
+ * Clips, assets, and knowledge each sync automatically at the moments that
+ * matter (render, login), so this is the manual catch-up: after working
+ * offline, after a teammate changes the brand guide, or on a new machine.
+ *
+ * Every step is independent and none can fail another — a knowledge conflict
+ * must not stop assets from arriving.
+ */
+async function main(): Promise {
+ if (!(await cloud.signedIn())) {
+ console.log("Not signed in to podcli Pro. Run `podcli login` first.");
+ return 1;
+ }
+
+ let problems = 0;
+
+ console.log("Syncing clips...");
+ try {
+ const { synced, failed } = await new ClipsHistory().backfillCloud();
+ console.log(
+ synced || failed
+ ? ` ${synced} synced${failed ? `, ${failed} failed` : ""}`
+ : " already up to date",
+ );
+ problems += failed;
+ } catch (err) {
+ console.log(` failed: ${err instanceof Error ? err.message : String(err)}`);
+ problems++;
+ }
+
+ console.log("Syncing assets...");
+ try {
+ const report = await assetSync.sync();
+ const parts = [
+ report.uploaded.length && `${report.uploaded.length} uploaded`,
+ report.downloaded.length && `${report.downloaded.length} downloaded`,
+ ].filter(Boolean);
+ console.log(parts.length ? ` ${parts.join(", ")}` : " already up to date");
+ for (const f of report.failed) console.log(` ${f.name}: ${f.reason}`);
+ problems += report.failed.length;
+ } catch (err) {
+ console.log(` failed: ${err instanceof Error ? err.message : String(err)}`);
+ problems++;
+ }
+
+ console.log("Syncing knowledge base...");
+ try {
+ const report = await knowledgeSync.sync();
+ const parts = [
+ report.pushed.length && `${report.pushed.length} pushed`,
+ report.pulled.length && `${report.pulled.length} pulled`,
+ ].filter(Boolean);
+ console.log(parts.length ? ` ${parts.join(", ")}` : " already up to date");
+
+ for (const conflict of report.conflicts) {
+ console.log(
+ ` conflict: ${conflict.path} — the workspace copy was saved as ` +
+ `${conflict.path}.workspace-${conflict.theirVersion}. Merge it, then sync again.`,
+ );
+ }
+ for (const f of report.failed) console.log(` ${f.path}: ${f.reason}`);
+ problems += report.failed.length;
+ } catch (err) {
+ console.log(` failed: ${err instanceof Error ? err.message : String(err)}`);
+ problems++;
+ }
+
+ // Conflicts are not counted as problems: they are a normal outcome that
+ // needs a human, not a failure that needs a retry.
+ return problems > 0 ? 1 : 0;
+}
+
+main().then(
+ (code) => process.exit(code),
+ (err) => {
+ console.error("sync failed:", err instanceof Error ? err.message : String(err));
+ process.exit(1);
+ },
+);
diff --git a/src/ui/client/AccountChip.tsx b/src/ui/client/AccountChip.tsx
new file mode 100644
index 0000000..d281d8d
--- /dev/null
+++ b/src/ui/client/AccountChip.tsx
@@ -0,0 +1,45 @@
+import React, { useEffect, useState } from "react";
+
+type Account = {
+ signedIn: boolean;
+ workspace?: string;
+ plan?: string;
+ episodesUsed?: number;
+ cap?: number;
+};
+
+/**
+ * Signed-in state at the bottom of the sidebar.
+ *
+ * Shows nothing when signed out. Sync that runs invisibly feels like sync that
+ * isn't running, so a subscriber should be able to see their workspace without
+ * going looking for it.
+ */
+export default function AccountChip() {
+ const [account, setAccount] = useState(null);
+
+ useEffect(() => {
+ fetch("/api/pro/account")
+ .then((r) => r.json())
+ .then(setAccount)
+ .catch(() => setAccount({ signedIn: false }));
+ }, []);
+
+ if (!account?.signedIn) return null;
+
+ const used = account.episodesUsed ?? 0;
+ const cap = account.cap ?? 0;
+ // Only surface the quota once it's close enough to matter. A counter at 2/10
+ // is noise; at 8/10 it's the difference between planning and being surprised.
+ const showQuota = cap > 0 && used / cap >= 0.7;
+
+ return (
+
+
{account.workspace}
+
+ {account.plan === "team" ? "Team" : "Pro"}
+ {showQuota && ` · ${used}/${cap} episodes`}
+
+
+ );
+}
diff --git a/src/ui/client/AiSetup.tsx b/src/ui/client/AiSetup.tsx
new file mode 100644
index 0000000..ba00706
--- /dev/null
+++ b/src/ui/client/AiSetup.tsx
@@ -0,0 +1,142 @@
+import React, { useEffect, useState } from "react";
+import { Cloud, Terminal, Key } from "lucide-react";
+import { labelStyle } from "./lib";
+
+/**
+ * What podcli will use for AI, and what to do when the answer is "nothing".
+ *
+ * Two real options are offered side by side and neither is dressed up as the
+ * only one: install a CLI you already pay for, or let us run it. A user who
+ * picks the free path has solved their problem, which is the point.
+ */
+
+type Provider = { kind: string; engine: string; label: string };
+
+type Status = {
+ available: boolean;
+ providers: Provider[];
+ mode: string;
+ api_key_set: boolean;
+ candidates: Array<{ engine: string; path: string }>;
+};
+
+const INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";
+
+function Option({
+ icon, title, body, action,
+}: {
+ icon: React.ReactNode; title: string; body: string; action: React.ReactNode;
+}) {
+ return (
+
+
+ {icon}
+ {title}
+
+
{body}
+ {action}
+
+ );
+}
+
+export default function AiSetup() {
+ const [status, setStatus] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ fetch("/api/ai-provider-status")
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
+ // The types say these are always present; the server can answer with an
+ // error body or a partial payload, and rendering a missing array throws
+ // out of this component and takes the settings page with it.
+ .then((payload) => setStatus({
+ ...payload,
+ providers: Array.isArray(payload?.providers) ? payload.providers : [],
+ candidates: Array.isArray(payload?.candidates) ? payload.candidates : [],
+ }))
+ .catch(() => setStatus(null));
+ }, []);
+
+ if (!status) return null;
+
+ if (status.available) {
+ return (
+
+
AI
+
+ Using
+
+ {status.providers.map((p) => p.label).join(" → ")}
+
+
+ {status.providers.length > 1 && (
+
+ podcli tries these in order, so a failure falls through to the next one
+ rather than stopping.
+
+ )}
+
+ );
+ }
+
+ return (
+
+
AI is not set up
+
+ podcli transcribes, cuts, and renders without any of this. Picking moments,
+ titles, and descriptions needs a model. Two ways to get one:
+
+
+
+
}
+ title="Use Claude Code"
+ body="Free with a Claude subscription you may already have. Runs on this machine; nothing leaves it."
+ action={
+
{
+ navigator.clipboard.writeText(INSTALL_COMMAND);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ }}
+ >
+ {copied ? "Copied" : INSTALL_COMMAND}
+
+ }
+ />
+
+
}
+ title="Use podcli Pro"
+ body="Nothing to install. Faster, and picks moments using what has actually performed on your channel."
+ action={
+
+ See podcli Pro
+
+ }
+ />
+
+
}
+ title="Use your own API key"
+ body="Set ANTHROPIC_API_KEY and podcli calls the API directly. You pay per token."
+ action={
+
Open config
+ }
+ />
+
+
+ {status.candidates.length > 0 && (
+ // Found but unusable is a different problem from missing, and saying
+ // "not detected" here would send someone to reinstall what they have.
+
+ A CLI was found at {status.candidates[0].path} but did not respond.
+ Run {status.candidates[0].engine} once in a terminal to sign in, then reload.
+
+ )}
+
+ );
+}
diff --git a/src/ui/client/AnalyticsPage.tsx b/src/ui/client/AnalyticsPage.tsx
index dbf4fd3..0c6cd67 100644
--- a/src/ui/client/AnalyticsPage.tsx
+++ b/src/ui/client/AnalyticsPage.tsx
@@ -3,6 +3,7 @@ import { PageHeader } from "./Page";
import { Link } from "react-router-dom";
import { TrendingUp, Eye, Percent, MousePointerClick } from "lucide-react";
import { api, upload, fmt } from "./lib";
+import WorkspaceInsights from "./WorkspaceInsights";
interface Row { key: string; count: number; avgViews: number; avgRetention: number; avgCtr: number }
interface Data {
@@ -164,6 +165,8 @@ export default function AnalyticsPage() {
{msg && {msg}
}
+
+
{showConnect && (
Connect YouTube (read-only)
diff --git a/src/ui/client/ConfigPage.tsx b/src/ui/client/ConfigPage.tsx
index 58c3e6d..aa2b4e5 100644
--- a/src/ui/client/ConfigPage.tsx
+++ b/src/ui/client/ConfigPage.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { PageHeader } from "./Page";
import { api, upload } from "./lib";
+import AiSetup from "./AiSetup";
type SettingRow = {
key: string;
@@ -54,7 +55,10 @@ export default function ConfigPage() {
} catch { /* settings are optional */ }
}
+ const [aiRefresh, setAiRefresh] = useState(0);
+
async function refreshAiCli() {
+ setAiRefresh((n) => n + 1);
try {
setAiCli(await api
("/ai-cli-status"));
} catch {
@@ -169,6 +173,8 @@ export default function ConfigPage() {
)}
+
+
AI CLI
{aiCli ? (
diff --git a/src/ui/client/Layout.tsx b/src/ui/client/Layout.tsx
index 0a635ea..7043788 100644
--- a/src/ui/client/Layout.tsx
+++ b/src/ui/client/Layout.tsx
@@ -15,6 +15,7 @@ import {
Search,
} from "lucide-react";
import CommandPalette from "./CommandPalette";
+import AccountChip from "./AccountChip";
const icons: Record
= {
library: LayoutGrid,
@@ -73,6 +74,8 @@ export default function Layout() {
Insights
Analytics
+
+
diff --git a/src/ui/client/WorkspaceInsights.tsx b/src/ui/client/WorkspaceInsights.tsx
new file mode 100644
index 0000000..b21583e
--- /dev/null
+++ b/src/ui/client/WorkspaceInsights.tsx
@@ -0,0 +1,118 @@
+import React, { useEffect, useState } from "react";
+import { PenLine, TrendingUp, Users } from "lucide-react";
+import { labelStyle } from "./lib";
+
+/**
+ * Workspace-wide performance and learned house style.
+ *
+ * Renders nothing at all when signed out. A free user sees the Analytics page
+ * they have always seen — no locked panel, no upsell banner, no greyed-out
+ * button. This section exists because the workspace data exists.
+ */
+
+type Breakdown = { key: string; clips: number; retention: number | null };
+
+type Payload = {
+ signedIn: boolean;
+ insights?: {
+ sampleSize: number;
+ byContentType: Breakdown[];
+ byLength: Breakdown[];
+ guidance: string[];
+ topClips: Array<{ title: string; retention: number; content_type: string }>;
+ };
+ preferences?: {
+ observations: string[];
+ discardRate: number | null;
+ titleEdits: Array<{ before: string; after: string }>;
+ };
+};
+
+export default function WorkspaceInsights() {
+ const [data, setData] = useState(null);
+
+ useEffect(() => {
+ fetch("/api/pro/insights")
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
+ .then(setData)
+ .catch(() => setData({ signedIn: false }));
+ }, []);
+
+ if (!data?.signedIn || !data.insights) return null;
+
+ const { insights, preferences } = data;
+ // The types say these arrays are always there. The server can answer with a
+ // partial payload, and reading a missing one throws out of this panel and
+ // takes the page it sits on with it.
+ const hasModel = (insights.guidance?.length ?? 0) > 0;
+ const hasStyle = (preferences?.observations?.length ?? 0) > 0;
+
+ // Signed in but nothing learned yet. Say why, and say what changes it —
+ // an empty panel with no explanation reads as broken.
+ if (!hasModel && !hasStyle) {
+ return (
+
+
Workspace
+
+ {insights.sampleSize === 0
+ ? "Connect YouTube and publish a few clips. Once performance data arrives, podcli starts picking moments based on what works for this channel."
+ : `Tracking ${insights.sampleSize} published clip${insights.sampleSize === 1 ? "" : "s"}. A few more and patterns become reliable enough to act on.`}
+
+
+ );
+ }
+
+ return (
+ <>
+ {hasModel && (
+
+
+ What works on this channel
+
+
+ From {insights.sampleSize} published clips across your workspace. podcli uses this
+ when picking moments.
+
+ {insights.guidance.map((line) => (
+
+
+ {line}
+
+ ))}
+
+ )}
+
+ {hasStyle && (
+
+
+
+ Learned from edits your team made to generated output. Nobody configured these.
+
+ {preferences!.observations.map((line) => (
+
{line}
+ ))}
+
+ {preferences!.titleEdits.length > 0 && (
+
+
+ Recent title rewrites ({preferences!.titleEdits.length})
+
+
+ {preferences!.titleEdits.slice(0, 6).map((edit, i) => (
+
+
+ {edit.before}
+
+
{edit.after}
+
+ ))}
+
+
+ )}
+
+ )}
+ >
+ );
+}
diff --git a/src/ui/public/css/styles.css b/src/ui/public/css/styles.css
index 75ee434..8514d95 100644
--- a/src/ui/public/css/styles.css
+++ b/src/ui/public/css/styles.css
@@ -1203,6 +1203,15 @@ input[type="range"]::-webkit-slider-thumb {
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active { background: var(--accent-subtle); color: var(--accent); }
+
+/* Signed-in workspace, pinned to the foot of the sidebar so it reads as status
+ rather than as another navigation item. */
+.sidebar-account { margin-top: auto; padding: 12px; border-top: 1px solid var(--border); }
+.sidebar-account-name {
+ font-size: 12px; font-weight: 700; color: var(--text);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.sidebar-account-sub { font-size: 11px; color: var(--text3); margin-top: 2px; }
.sidebar-link .ico { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.85; }
.sidebar-link.disabled { opacity: 0.4; pointer-events: none; }
.sidebar-link .soon { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: 0.5px; color: var(--text3); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts
index ece7d3c..899a3ab 100644
--- a/src/ui/web-server.ts
+++ b/src/ui/web-server.ts
@@ -1425,6 +1425,47 @@ app.get("/api/job/:id/stream", (req, res) => {
/**
* GET /api/outputs — List finished clips
*/
+/**
+ * Workspace-wide performance, for signed-in users.
+ *
+ * Returns `{ signedIn: false }` rather than an error when there's no account:
+ * the studio renders the same Analytics page either way, just without the
+ * workspace section. Nothing is greyed out and nothing says "upgrade".
+ */
+app.get("/api/pro/insights", async (_req, res) => {
+ try {
+ const cloud = await import("../services/podcli-cloud.js");
+ if (!(await cloud.signedIn())) return res.json({ signedIn: false });
+
+ const [insights, preferences] = await Promise.all([
+ cloud.getInsights(),
+ cloud.getPreferences(),
+ ]);
+ res.json({ signedIn: true, insights, preferences });
+ } catch (err) {
+ // A workspace that can't be reached must not break the local analytics the
+ // page is primarily there to show.
+ res.json({ signedIn: false, error: err instanceof Error ? err.message : String(err) });
+ }
+});
+
+app.get("/api/pro/account", async (_req, res) => {
+ try {
+ const cloud = await import("../services/podcli-cloud.js");
+ if (!(await cloud.signedIn())) return res.json({ signedIn: false });
+ const me = await cloud.whoami();
+ res.json({
+ signedIn: true,
+ workspace: me.workspace?.name,
+ plan: me.plan,
+ episodesUsed: me.workspace?.episodes_used,
+ cap: me.plan === "team" ? 40 : 10,
+ });
+ } catch {
+ res.json({ signedIn: false });
+ }
+});
+
app.get("/api/outputs", async (_req, res) => {
try {
await mkdir(paths.output, { recursive: true });
@@ -2335,6 +2376,15 @@ app.get("/api/ai-cli-status", async (_req, res) => {
}
});
+app.get("/api/ai-provider-status", async (_req, res) => {
+ try {
+ const result = await executor.execute>("ai_provider_status", {});
+ res.json(result.data ?? { available: false, providers: [], candidates: [] });
+ } catch (err: unknown) {
+ res.status(500).json({ error: errMsg(err) });
+ }
+});
+
app.get("/api/youtube/config", (_req, res) => {
try {
const all = JSON.parse(readFileSync(paths.integrations, "utf-8"));
diff --git a/tests/test_ai_fallback.py b/tests/test_ai_fallback.py
index f54a70f..23fe0a4 100644
--- a/tests/test_ai_fallback.py
+++ b/tests/test_ai_fallback.py
@@ -12,6 +12,8 @@
if BACKEND_ROOT not in sys.path:
sys.path.insert(0, BACKEND_ROOT)
+from services import ai_cli as ai
+from services import ai_provider as ap
from services import claude_suggest as cs
from services import content_generator as cg
from services import thumbnail_ai as tai
@@ -58,11 +60,11 @@ def test_suggest_with_claude_retries_with_codex_after_runtime_failure(self):
})
with mock.patch.object(
- cs,
- "_find_ai_cli_candidates",
- return_value=[("/tmp/claude", "claude"), ("/tmp/codex", "codex")],
+ ap,
+ "_chain",
+ return_value=[("cli", "/tmp/claude", "claude"), ("cli", "/tmp/codex", "codex")],
), mock.patch.object(
- cs,
+ ai,
"_run_ai_command",
side_effect=[
subprocess.CompletedProcess(args=["claude"], returncode=1, stdout="", stderr="claude down"),
@@ -218,11 +220,11 @@ def test_suggest_with_claude_reports_actual_timeout_limit(self):
progress = []
with mock.patch.object(
- cs,
- "_find_ai_cli_candidates",
- return_value=[("/tmp/claude", "claude")],
+ ap,
+ "_chain",
+ return_value=[("cli", "/tmp/claude", "claude")],
), mock.patch.object(
- cs,
+ ai,
"_run_ai_command",
side_effect=subprocess.TimeoutExpired(cmd=["claude"], timeout=90),
):
@@ -264,11 +266,11 @@ def test_generate_clip_content_retries_with_codex(self):
#power #energy #datacenters #ai #infrastructure"""
with mock.patch.object(
- cg,
- "_find_ai_cli_candidates",
- return_value=[("/tmp/claude", "claude"), ("/tmp/codex", "codex")],
+ ap,
+ "_chain",
+ return_value=[("cli", "/tmp/claude", "claude"), ("cli", "/tmp/codex", "codex")],
), mock.patch.object(
- cg,
+ ai,
"_run_ai_command",
side_effect=[
subprocess.CompletedProcess(args=["claude"], returncode=1, stdout="", stderr="claude down"),
@@ -300,11 +302,11 @@ def test_thumbnail_layout_retries_with_codex(self):
"""
with mock.patch.object(
- cs,
- "_find_ai_cli_candidates",
- return_value=[("/tmp/claude", "claude"), ("/tmp/codex", "codex")],
+ ap,
+ "_chain",
+ return_value=[("cli", "/tmp/claude", "claude"), ("cli", "/tmp/codex", "codex")],
), mock.patch.object(
- cs,
+ ai,
"_run_ai_command",
side_effect=[
subprocess.CompletedProcess(args=["claude"], returncode=1, stdout="", stderr="claude down"),
@@ -329,8 +331,8 @@ def test_find_cli_resolves_windows_cmd_shim(self):
shim = os.path.join(tmp, "claude.cmd")
with open(shim, "w", encoding="utf-8") as fh:
fh.write("@echo off\n")
- with mock.patch.object(cs.sys, "platform", "win32"):
- found = cs._find_cli("claude", [os.path.join(tmp, "claude")])
+ with mock.patch.object(ai.sys, "platform", "win32"):
+ found = ai._find_cli("claude", [os.path.join(tmp, "claude")])
self.assertEqual(found, shim)
@unittest.skipIf(os.name == "nt", "POSIX executable discovery; Windows uses .cmd/.exe shims")
@@ -343,7 +345,7 @@ def test_find_cli_uses_home_bin(self):
fh.write("#!/bin/sh\n")
with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False):
with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)):
- found = cs._find_cli("claude", [])
+ found = ai._find_cli("claude", [])
self.assertEqual(found, cli)
@unittest.skipIf(os.name == "nt", "POSIX executable discovery; Windows uses .cmd/.exe shims")
@@ -359,16 +361,16 @@ def test_npmrc_prefix_is_searched(self):
fh.write(f"prefix={prefix}\n")
with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False):
with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)):
- with mock.patch.object(cs, "_package_manager_bin_dirs", return_value=[]):
- with mock.patch.object(cs, "_shell_lookup", return_value=None):
- found = cs._find_cli("claude", [])
+ with mock.patch.object(ai, "_package_manager_bin_dirs", return_value=[]):
+ with mock.patch.object(ai, "_shell_lookup", return_value=None):
+ found = ai._find_cli("claude", [])
self.assertEqual(found, cli)
def test_parse_shell_lookup_line_handles_type_a(self):
with tempfile.NamedTemporaryFile(delete=False) as tmp:
path = tmp.name
try:
- self.assertEqual(cs._parse_shell_lookup_line(f"claude is {path}"), path)
+ self.assertEqual(ai._parse_shell_lookup_line(f"claude is {path}"), path)
finally:
os.remove(path)
@@ -381,7 +383,7 @@ def test_find_cli_uses_legacy_claude_local_path(self):
fh.write("#!/bin/sh\n")
with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False):
with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)):
- found = cs._find_cli("claude", cs._ai_cli_search_paths("claude"))
+ found = ai._find_cli("claude", ai._ai_cli_search_paths("claude"))
self.assertEqual(found, cli)
def test_env_override_prefers_podcli_claude_path(self):
@@ -390,8 +392,8 @@ def test_env_override_prefers_podcli_claude_path(self):
with open(cli, "w", encoding="utf-8") as fh:
fh.write("#!/bin/sh\n")
with mock.patch.dict(os.environ, {"PODCLI_CLAUDE_PATH": cli, "PATH": ""}, clear=False):
- with mock.patch.object(cs, "_find_cli", return_value=None) as find_mock:
- candidates = cs._find_ai_cli_candidates()
+ with mock.patch.object(ai, "_find_cli", return_value=None) as find_mock:
+ candidates = ai._find_ai_cli_candidates()
find_mock.assert_called_once()
self.assertEqual(find_mock.call_args.args[0], "codex")
self.assertEqual(candidates[0], (cli, "claude"))
@@ -406,7 +408,7 @@ def test_configured_path_reads_from_env_file(self):
fh.write(f"PODCLI_CLAUDE_PATH={cli}\n")
with mock.patch.dict(os.environ, {"PODCLI_ENV_FILE": env_file, "PATH": ""}, clear=False):
os.environ.pop("PODCLI_CLAUDE_PATH", None)
- found = cs._configured_cli_path("claude")
+ found = ai._configured_cli_path("claude")
self.assertEqual(found, cli)
def test_find_cli_falls_back_to_shell_lookup(self):
@@ -414,18 +416,18 @@ def test_find_cli_falls_back_to_shell_lookup(self):
cli = os.path.join(tmp, "claude")
with open(cli, "w", encoding="utf-8") as fh:
fh.write("#!/bin/sh\n")
- with mock.patch.object(cs, "_shell_lookup", return_value=cli):
+ with mock.patch.object(ai, "_shell_lookup", return_value=cli):
with mock.patch("shutil.which", return_value=None):
- found = cs._find_cli("claude", [])
+ found = ai._find_cli("claude", [])
self.assertEqual(found, cli)
def test_get_ai_cli_status_reports_candidates(self):
with mock.patch.object(
- cs,
+ ai,
"_find_ai_cli_candidates",
return_value=[("/tmp/claude", "claude")],
- ), mock.patch.object(cs, "_configured_cli_path", return_value=None):
- status = cs.get_ai_cli_status()
+ ), mock.patch.object(ai, "_configured_cli_path", return_value=None):
+ status = ai.get_ai_cli_status()
self.assertTrue(status["available"])
self.assertEqual(status["candidates"][0]["engine"], "claude")
with tempfile.TemporaryDirectory() as tmp:
@@ -436,14 +438,14 @@ def test_get_ai_cli_status_reports_candidates(self):
with open(cli, "w", encoding="utf-8") as fh:
fh.write("#!/bin/sh\n")
- with mock.patch("services.claude_suggest.subprocess.run") as run_mock:
+ with mock.patch("services.ai_cli.subprocess.run") as run_mock:
run_mock.return_value = subprocess.CompletedProcess(
args=[cli, "--print", "-p", "-"],
returncode=0,
stdout="{}",
stderr="",
)
- cs._run_ai_command(
+ ai._run_ai_command(
cli_path=cli,
engine="claude",
prompt="find clips",
diff --git a/tests/test_entitlement_chain.py b/tests/test_entitlement_chain.py
new file mode 100644
index 0000000..de76acb
--- /dev/null
+++ b/tests/test_entitlement_chain.py
@@ -0,0 +1,80 @@
+import json
+import os
+import sys
+import tempfile
+import time
+import unittest
+from unittest import mock
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
+
+from services import ai_provider, podcli_cloud # noqa: E402
+
+
+class EntitlementTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp()
+ patcher = mock.patch.dict(podcli_cloud.paths, {"home": self.tmp})
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ # PODCLI_TOKEN would shadow the file these tests are about.
+ env = mock.patch.dict(os.environ, {"PODCLI_TOKEN": "", "PODCLI_AI_PROVIDER": ""})
+ env.start()
+ self.addCleanup(env.stop)
+
+ def write_auth(self, **fields):
+ with open(os.path.join(self.tmp, "auth.json"), "w", encoding="utf-8") as fh:
+ json.dump({"token": "t", "workspace_id": "w", **fields}, fh)
+
+ def test_unknown_plan_still_tries_the_cloud(self):
+ self.write_auth()
+ self.assertTrue(podcli_cloud.entitled())
+
+ def test_free_plan_is_not_entitled(self):
+ self.write_auth(plan="free", plan_checked_at=time.time())
+ self.assertFalse(podcli_cloud.entitled())
+
+ def test_paid_plan_is_entitled(self):
+ for plan in ("pro", "team", "studio"):
+ with self.subTest(plan=plan):
+ self.write_auth(plan=plan, plan_checked_at=time.time())
+ self.assertTrue(podcli_cloud.entitled())
+
+ def test_a_stale_free_verdict_is_retried(self):
+ # A subscription bought after the last check must work without re-login.
+ self.write_auth(plan="free",
+ plan_checked_at=time.time() - podcli_cloud.PLAN_TTL_SECONDS - 1)
+ self.assertTrue(podcli_cloud.entitled())
+
+ def test_remember_plan_keeps_the_token(self):
+ self.write_auth()
+ podcli_cloud.remember_plan("pro")
+ with open(os.path.join(self.tmp, "auth.json"), encoding="utf-8") as fh:
+ data = json.load(fh)
+ self.assertEqual(data["token"], "t")
+ self.assertEqual(data["workspace_id"], "w")
+ self.assertEqual(data["plan"], "pro")
+
+ def test_free_workspace_skips_the_cloud_leg(self):
+ self.write_auth(plan="free", plan_checked_at=time.time())
+ with mock.patch.object(ai_provider.ai_cli, "_find_ai_cli_candidates",
+ return_value=[("/bin/claude", "claude")]):
+ chain = ai_provider._chain()
+ self.assertEqual([kind for kind, _, _ in chain], ["cli"])
+
+ def test_paid_workspace_puts_the_cloud_first(self):
+ self.write_auth(plan="pro", plan_checked_at=time.time())
+ with mock.patch.object(ai_provider.ai_cli, "_find_ai_cli_candidates",
+ return_value=[("/bin/claude", "claude")]):
+ chain = ai_provider._chain()
+ self.assertEqual([kind for kind, _, _ in chain], ["cloud", "cli"])
+
+ def test_forced_cloud_mode_ignores_a_free_verdict(self):
+ self.write_auth(plan="free", plan_checked_at=time.time())
+ with mock.patch.dict(os.environ, {"PODCLI_AI_PROVIDER": "cloud"}):
+ chain = ai_provider._chain()
+ self.assertEqual([kind for kind, _, _ in chain], ["cloud"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_find_moments.py b/tests/test_find_moments.py
index b1d1fb2..a4f0ef3 100644
--- a/tests/test_find_moments.py
+++ b/tests/test_find_moments.py
@@ -11,6 +11,8 @@
if BACKEND_ROOT not in sys.path:
sys.path.insert(0, BACKEND_ROOT)
+from services import ai_cli
+from services import ai_provider
from services import claude_suggest as cs
SEGMENTS = [
@@ -39,14 +41,14 @@ def _fake_run(**kwargs):
class FindMomentsTests(unittest.TestCase):
def setUp(self):
- self._orig_candidates = cs._find_ai_cli_candidates
- self._orig_run = cs._run_ai_command
- cs._find_ai_cli_candidates = lambda: [("/usr/bin/claude", "claude")]
- cs._run_ai_command = lambda **kw: _fake_run(**kw)
+ self._orig_chain = ai_provider._chain
+ self._orig_run = ai_cli._run_ai_command
+ ai_provider._chain = lambda: [("cli", "/usr/bin/claude", "claude")]
+ ai_cli._run_ai_command = lambda **kw: _fake_run(**kw)
def tearDown(self):
- cs._find_ai_cli_candidates = self._orig_candidates
- cs._run_ai_command = self._orig_run
+ ai_provider._chain = self._orig_chain
+ ai_cli._run_ai_command = self._orig_run
def test_finds_and_shapes_moment(self):
clips = cs.find_moments_from_text("the turning point", SEGMENTS, [])
@@ -60,7 +62,7 @@ def test_finds_and_shapes_moment(self):
self.assertGreater(c["score"], 0)
def test_no_ai_cli_returns_empty(self):
- cs._find_ai_cli_candidates = lambda: []
+ ai_provider._chain = lambda: []
self.assertEqual(cs.find_moments_from_text("x", SEGMENTS, []), [])
def test_progress_callback_invoked(self):
diff --git a/tests/test_suggest_handler.py b/tests/test_suggest_handler.py
index 4c577d2..a28d21c 100644
--- a/tests/test_suggest_handler.py
+++ b/tests/test_suggest_handler.py
@@ -11,7 +11,7 @@
sys.path.insert(0, BACKEND_ROOT)
import main as backend_main
-from services import claude_suggest
+from services import ai_provider, claude_suggest
SEGMENTS = [{"start": 0.0, "end": 10.0, "text": "hello"}]
ENERGY_DATA = [{"time": float(t), "rms_db": -30.0} for t in range(31)] + [
@@ -44,7 +44,7 @@ def fake_emit_result(task_id, status, data=None, error=None):
emitted.update({"task_id": task_id, "status": status, "data": data, "error": error})
with mock.patch.object(claude_suggest, "suggest_initial_with_claude", fake_suggest), \
- mock.patch.object(claude_suggest, "_find_ai_cli_candidates", return_value=["claude"]), \
+ mock.patch.object(ai_provider, "available", return_value=True), \
mock.patch.object(backend_main, "emit_result", fake_emit_result), \
mock.patch.object(backend_main, "emit_progress"):
backend_main.handle_suggest_clips("task-1", params)
diff --git a/tests/test_whispercpp_adapter.py b/tests/test_whispercpp_adapter.py
index c1bb9ac..78dea57 100644
--- a/tests/test_whispercpp_adapter.py
+++ b/tests/test_whispercpp_adapter.py
@@ -7,7 +7,7 @@
if BACKEND_ROOT not in sys.path:
sys.path.insert(0, BACKEND_ROOT)
-from services.transcription_whispercpp import _tokens_to_words
+from services.transcription_whispercpp import _dtw_preset_for_model, _tokens_to_words
class WhisperCppAdapterTests(unittest.TestCase):
@@ -19,5 +19,26 @@ def test_sentencepiece_marker_is_removed(self):
self.assertEqual([w["word"] for w in words], ["hello", "world"])
+class DtwPresetTests(unittest.TestCase):
+ def test_preset_tracks_the_model_file(self):
+ cases = {
+ "ggml-tiny.en.bin": "tiny.en",
+ "ggml-base.bin": "base",
+ "ggml-small.bin": "small",
+ "ggml-large-v3-turbo.bin": "large.v3-turbo",
+ "ggml-base.en-q5_1.bin": "base.en",
+ # K-quantisation names carry underscores between the qualifiers, and
+ # failing to strip them dropped -dtw for a model that supports it.
+ "ggml-large-v3-q4_k_m.gguf": "large.v3",
+ "ggml-small.en-q8_0.bin": "small.en",
+ }
+ for name, preset in cases.items():
+ with self.subTest(name=name):
+ self.assertEqual(_dtw_preset_for_model(os.path.join("/models", name)), preset)
+
+ def test_unknown_model_gets_no_preset(self):
+ self.assertIsNone(_dtw_preset_for_model("/models/ggml-distil-large-v2.bin"))
+
+
if __name__ == "__main__":
unittest.main()
From a87c5962a8a084d7f9d9f9058ae9b034d628c3e5 Mon Sep 17 00:00:00 2001
From: Nika Siradze
Date: Sat, 8 Aug 2026 23:53:50 +0400
Subject: [PATCH 12/12] Release 2.6.0 (#147)
Cloud AI selection and optional remote sync, plus the whisper.cpp DTW preset
fix. cli/VERSION is generated from package.json by go generate.
---
cli/VERSION | 2 +-
package.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/cli/VERSION b/cli/VERSION
index 437459c..e70b452 100644
--- a/cli/VERSION
+++ b/cli/VERSION
@@ -1 +1 @@
-2.5.0
+2.6.0
diff --git a/package.json b/package.json
index b2c3f2d..3da4252 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "podcli",
- "version": "2.5.0",
+ "version": "2.6.0",
"private": true,
"description": "AI-powered podcast clip generator for TikTok/YouTube Shorts. Transcribe, find viral moments, export vertical clips with burned captions.",
"type": "module",