diff --git a/README.md b/README.md
index 2e27d7f..46fff50 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,20 @@
+> [!NOTE]
+> **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.
+
+### 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.
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/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}
)}
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"]