From d662c05cff537c4530ff660ab151bcbe4c35ce91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cayocodess=E2=80=9D?= Date: Fri, 7 Aug 2026 03:33:32 +0200 Subject: [PATCH 1/2] feat: expand Studio podcast workflow --- README.md | 5 + backend/main.py | 48 ++ backend/services/caption_renderer.py | 9 +- backend/services/captions_burn.py | 9 +- backend/services/clip_generator.py | 15 + backend/services/silence_removal.py | 479 ++++++++++++ remotion/render-full-episode.mjs | 217 ++++++ remotion/render.mjs | 3 + remotion/src/CaptionedClip.tsx | 13 +- remotion/src/Root.tsx | 23 +- remotion/src/chunks.test.ts | 18 +- remotion/src/chunks.ts | 9 + remotion/src/components/BrandedCaptions.tsx | 37 +- remotion/src/components/HormoziCaptions.tsx | 4 +- remotion/src/components/KaraokeCaptions.tsx | 25 +- remotion/src/components/SubtleCaptions.tsx | 18 +- remotion/src/types.ts | 9 + src/models/index.ts | 7 +- src/ui/client/CopyButton.tsx | 33 +- src/ui/client/EpisodeWorkspace.jsx | 793 ++++++++++++++++++-- src/ui/client/Layout.tsx | 10 +- src/ui/client/lib.test.ts | 67 ++ src/ui/client/lib.ts | 131 ++++ src/ui/public/css/styles.css | 242 ++++++ src/ui/web-server.ts | 424 ++++++++++- src/utils/full-episode-export.test.ts | 20 + src/utils/full-episode-export.ts | 41 + src/utils/http-range.test.ts | 22 + src/utils/http-range.ts | 24 + tests/test_silence_removal.py | 69 ++ 30 files changed, 2709 insertions(+), 115 deletions(-) create mode 100644 backend/services/silence_removal.py create mode 100644 remotion/render-full-episode.mjs create mode 100644 src/utils/full-episode-export.test.ts create mode 100644 src/utils/full-episode-export.ts create mode 100644 src/utils/http-range.test.ts create mode 100644 src/utils/http-range.ts create mode 100644 tests/test_silence_removal.py diff --git a/README.md b/README.md index 2e27d7f..48826d2 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ podcli

+> [!NOTE] +> **This is a maintained fork of [nmbrthirteen/podcli](https://github.com/nmbrthirteen/podcli).** It keeps Podcli's local processing and CLI while adding a simpler Studio workflow: full-episode YouTube preview and export, local silence removal, adjustable captions and logo placement, formatted transcript viewing and copying, and the `podclip` launcher. Upstream updates are merged regularly. + +Launch the local Studio with `podclip`. +

Open-source AI podcast clipper.
Turn a long episode into short clips with face tracking and burned-in captions. Drive it from the CLI, a web studio, or your coding agent. diff --git a/backend/main.py b/backend/main.py index bb52b4e..11b8c1a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -169,6 +169,9 @@ def handle_create_clip(task_id: str, params: dict): start_second=params["start_second"], end_second=params["end_second"], caption_style=params.get("caption_style", "hormozi"), + caption_position=params.get("caption_position", "auto"), + caption_font_scale=params.get("caption_font_scale", 100), + logo_position=params.get("logo_position", "top-left"), crop_strategy=params.get("crop_strategy", "face"), format=params.get("format", "vertical"), crop_keyframes=params.get("crop_keyframes"), @@ -223,6 +226,9 @@ def render_one(i: int, clip: dict) -> dict: start_second=clip["start_second"], end_second=clip["end_second"], caption_style=clip.get("caption_style", "hormozi"), + caption_position=clip.get("caption_position", params.get("caption_position", "auto")), + caption_font_scale=clip.get("caption_font_scale", params.get("caption_font_scale", 100)), + logo_position=clip.get("logo_position", params.get("logo_position", "top-left")), crop_strategy=clip.get("crop_strategy", "face"), format=clip.get("format", params.get("format", "vertical")), transcript_words=params.get("transcript_words", []), @@ -876,6 +882,46 @@ def handle_manage_config(task_id: str, params: dict): emit_result(task_id, "error", error=str(e)) +def handle_analyze_silence(task_id: str, params: dict): + """Analyze a full episode locally and return a conservative cut plan.""" + from services.silence_removal import analyze_silence + + try: + result = analyze_silence( + video_path=params.get("video_path", ""), + transcript_words=params.get("transcript_words") or [], + threshold=float(params.get("threshold", 0.5)), + min_silence_seconds=float(params.get("min_silence_seconds", 0.65)), + padding_seconds=float(params.get("padding_seconds", 0.12)), + progress_callback=lambda pct, msg: emit_progress( + task_id, "silence_analysis", pct, msg + ), + ) + emit_result(task_id, "success", data=result) + except (FileNotFoundError, RuntimeError, ValueError) as e: + emit_result(task_id, "error", error=str(e)) + + +def handle_render_silence_removed(task_id: str, params: dict): + """Render the approved local cut plan and remap transcript timestamps.""" + from config.paths import paths + from services.silence_removal import render_silence_removed + + try: + result = render_silence_removed( + video_path=params.get("video_path", ""), + keep_segments=params.get("keep_segments") or [], + transcript=params.get("transcript") or {}, + output_dir=params.get("output_dir") or paths["output"], + progress_callback=lambda pct, msg: emit_progress( + task_id, "silence_render", pct, msg + ), + ) + emit_result(task_id, "success", data=result) + except (FileNotFoundError, RuntimeError, ValueError) as e: + emit_result(task_id, "error", error=str(e)) + + def handle_run_integration_tool(task_id: str, params: dict): from services.integrations import IntegrationRegistry, IntegrationsManager @@ -932,6 +978,8 @@ def handle_run_integration_tool(task_id: str, params: dict): "manage_integrations": handle_manage_integrations, "run_integration_tool": handle_run_integration_tool, "manage_config": handle_manage_config, + "analyze_silence": handle_analyze_silence, + "render_silence_removed": handle_render_silence_removed, } diff --git a/backend/services/caption_renderer.py b/backend/services/caption_renderer.py index 8731fe8..06291c5 100644 --- a/backend/services/caption_renderer.py +++ b/backend/services/caption_renderer.py @@ -93,6 +93,8 @@ def render_captions( caption_style: str, output_path: str, time_offset: float = 0.0, + caption_position: str = "auto", + caption_font_scale: int = 100, ) -> str: """ Generate an ASS subtitle file from word-level timestamps. @@ -113,7 +115,12 @@ def render_captions( f.write(generate_ass_header(get_style(caption_style))) return output_path - style = get_style(caption_style) + style = dict(get_style(caption_style)) + scale = max(60, min(160, int(caption_font_scale))) / 100 + style["font_size"] = round(style["font_size"] * scale) + position_margins = {"upper": 760, "center": 480, "lower": 220} + if caption_position in position_margins: + style["margin_v"] = position_margins[caption_position] if caption_style == "hormozi": content = _render_hormozi(words, style, time_offset) diff --git a/backend/services/captions_burn.py b/backend/services/captions_burn.py index 424f844..79cfcbb 100644 --- a/backend/services/captions_burn.py +++ b/backend/services/captions_burn.py @@ -64,6 +64,7 @@ def burn_captions( logo_height: int = 80, logo_margin_x: int = 30, logo_margin_y: int = 40, + logo_position: str = "top-left", ) -> str: """Burn ASS subtitles into the video. @@ -99,9 +100,13 @@ def burn_captions( logo_idx = input_idx input_idx += 1 filter_parts.append(f"[{logo_idx}:v]scale=-1:{logo_height}[logo]") - filter_parts.append( - f"[{current_label}][logo]overlay={logo_margin_x}:{logo_margin_y}[withlogo]" + logo_x = ( + str(logo_margin_x) if logo_position.endswith("-left") + else f"main_w-overlay_w-{logo_margin_x}" if logo_position.endswith("-right") + else "(main_w-overlay_w)/2" ) + logo_y = str(logo_margin_y) if logo_position.startswith("top-") else f"main_h-overlay_h-{logo_margin_y}" + filter_parts.append(f"[{current_label}][logo]overlay={logo_x}:{logo_y}[withlogo]") current_label = "withlogo" # Burn ASS subtitles diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py index 1f7c613..56d143c 100644 --- a/backend/services/clip_generator.py +++ b/backend/services/clip_generator.py @@ -465,6 +465,9 @@ def _render_with_remotion( time_offset: float = 0.0, logo_path: Optional[str] = None, keep_caption_overlay: bool = False, + caption_position: str = "auto", + caption_font_scale: int = 100, + logo_position: str = "top-left", ) -> tuple[bool, Optional[str]]: """ Render captions using Remotion. Returns (success, optional_prores_overlay_path). @@ -586,6 +589,9 @@ def _render_with_remotion( "--words", os.path.abspath(words_file), "--style", caption_style, "--output", os.path.abspath(output_path), + "--caption-position", caption_position, + "--caption-font-scale", str(caption_font_scale), + "--logo-position", logo_position, ] if logo_path and os.path.exists(logo_path): cmd.extend(["--logo", os.path.abspath(logo_path)]) @@ -641,6 +647,9 @@ def generate_clip( start_second: float, end_second: float, caption_style: str = "hormozi", + caption_position: str = "auto", + caption_font_scale: int = 100, + logo_position: str = "top-left", crop_strategy: str = "face", format: str = "vertical", crop_keyframes: list[dict] = None, @@ -908,6 +917,9 @@ def generate_clip( time_offset=caption_time_offset, logo_path=logo_path if (style_config.get("logo_support", False) and logo_path) else None, keep_caption_overlay=keep_caption_overlay, + caption_position=caption_position, + caption_font_scale=caption_font_scale, + logo_position=logo_position, ) if not remotion_ok and not allow_ass_fallback: @@ -924,6 +936,8 @@ def generate_clip( caption_style=caption_style, output_path=ass_path, time_offset=caption_time_offset, + caption_position=caption_position, + caption_font_scale=caption_font_scale, ) use_gradient = style_config.get("gradient_overlay", False) @@ -940,6 +954,7 @@ def generate_clip( logo_height=style_config.get("logo_height", 80), logo_margin_x=style_config.get("logo_margin_x", 30), logo_margin_y=style_config.get("logo_margin_y", 40), + logo_position=logo_position, ) else: captioned_path = cropped_path diff --git a/backend/services/silence_removal.py b/backend/services/silence_removal.py new file mode 100644 index 0000000..a90af44 --- /dev/null +++ b/backend/services/silence_removal.py @@ -0,0 +1,479 @@ +"""Local full-episode silence analysis and rendering. + +Silero VAD (MIT, https://github.com/snakers4/silero-vad) finds speech without +uploading media. Transcript word ranges are +unioned with VAD output before cuts are planned, so known words are never cut. +The derived video and remapped transcript keep every downstream Podcli feature +on one compact timeline while the original source remains untouched. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import tempfile +import urllib.request +import uuid +import wave +from pathlib import Path +from typing import Callable, Iterable, Optional + +from config.paths import paths +from services.audio_extract import extract_wav_16k_mono +from services.media_probe import get_media_duration_seconds, has_audio_stream +from utils.proc import run as proc_run + +try: + import numpy as np + import onnxruntime as ort + _VAD_RUNTIME_AVAILABLE = True +except ImportError: + _VAD_RUNTIME_AVAILABLE = False + + +ProgressCallback = Optional[Callable[[int, str], None]] + +SILERO_MODEL_URL = ( + "https://raw.githubusercontent.com/snakers4/silero-vad/" + "76e3dc408eb2a5c655c34e230d2d5459b4439daa/" + "src/silero_vad/data/silero_vad_16k_op15.onnx" +) +SILERO_MODEL_SHA256 = "7ed98ddbad84ccac4cd0aeb3099049280713df825c610a8ed34543318f1b2c49" +SILERO_MODEL_FILENAME = "silero_vad_16k_op15.onnx" +SAMPLE_RATE = 16_000 +WINDOW_SAMPLES = 512 +CONTEXT_SAMPLES = 64 + + +def _emit(callback: ProgressCallback, percent: int, message: str) -> None: + if callback: + callback(max(0, min(100, int(percent))), message) + + +def _model_path() -> Path: + return Path(paths["cache"]) / "models" / SILERO_MODEL_FILENAME + + +def _sha256(file_path: Path) -> str: + digest = hashlib.sha256() + with file_path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def ensure_silero_model(progress_callback: ProgressCallback = None) -> Path: + """Return verified model path, downloading the 1.3 MB model once if needed.""" + model_path = _model_path() + if model_path.exists() and _sha256(model_path) == SILERO_MODEL_SHA256: + return model_path + + model_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = model_path.with_name(f".{model_path.name}.{uuid.uuid4().hex}.download") + _emit(progress_callback, 2, "Downloading local speech detector (first use only)") + try: + request = urllib.request.Request( + SILERO_MODEL_URL, + headers={"User-Agent": "podcli-silence-removal"}, + ) + with urllib.request.urlopen(request, timeout=60) as response, temp_path.open("wb") as target: + shutil.copyfileobj(response, target) + if _sha256(temp_path) != SILERO_MODEL_SHA256: + raise RuntimeError("Silero VAD model checksum mismatch") + os.replace(temp_path, model_path) + finally: + try: + temp_path.unlink() + except OSError: + pass + return model_path + + +def _merge_segments( + segments: Iterable[dict], + duration: float, + merge_gap: float = 0.0, +) -> list[dict]: + cleaned: list[dict] = [] + for raw in segments: + try: + start = max(0.0, min(duration, float(raw["start"]))) + end = max(0.0, min(duration, float(raw["end"]))) + except (KeyError, TypeError, ValueError): + continue + if end - start < 0.02: + continue + cleaned.append({"start": start, "end": end}) + cleaned.sort(key=lambda item: (item["start"], item["end"])) + + merged: list[dict] = [] + for segment in cleaned: + if merged and segment["start"] <= merged[-1]["end"] + merge_gap: + merged[-1]["end"] = max(merged[-1]["end"], segment["end"]) + else: + merged.append(dict(segment)) + return merged + + +def probabilities_to_speech_segments( + probabilities: list[float], + audio_samples: int, + *, + threshold: float = 0.5, + min_speech_ms: int = 250, + min_silence_ms: int = 180, +) -> list[dict]: + """Convert Silero probabilities into unpadded speech ranges.""" + negative_threshold = max(0.01, threshold - 0.15) + min_speech_samples = SAMPLE_RATE * min_speech_ms / 1000 + min_silence_samples = SAMPLE_RATE * min_silence_ms / 1000 + triggered = False + temporary_end = 0 + current_start = 0 + speech: list[dict] = [] + + for index, probability in enumerate(probabilities): + current_sample = index * WINDOW_SAMPLES + if probability >= threshold: + if not triggered: + triggered = True + current_start = current_sample + temporary_end = 0 + continue + + if triggered and probability < negative_threshold: + if not temporary_end: + temporary_end = current_sample + if current_sample - temporary_end >= min_silence_samples: + if temporary_end - current_start >= min_speech_samples: + speech.append({ + "start": current_start / SAMPLE_RATE, + "end": temporary_end / SAMPLE_RATE, + }) + triggered = False + temporary_end = 0 + + if triggered and audio_samples - current_start >= min_speech_samples: + speech.append({ + "start": current_start / SAMPLE_RATE, + "end": audio_samples / SAMPLE_RATE, + }) + return speech + + +def detect_speech( + video_path: str, + *, + threshold: float = 0.5, + progress_callback: ProgressCallback = None, +) -> list[dict]: + if not _VAD_RUNTIME_AVAILABLE: + raise RuntimeError("Local silence detection requires numpy and onnxruntime") + + model_path = ensure_silero_model(progress_callback) + _emit(progress_callback, 5, "Extracting episode audio") + wav_path = extract_wav_16k_mono(video_path) + try: + session = ort.InferenceSession(str(model_path), providers=["CPUExecutionProvider"]) + state = np.zeros((2, 1, 128), dtype=np.float32) + context = np.zeros((1, CONTEXT_SAMPLES), dtype=np.float32) + probabilities: list[float] = [] + + with wave.open(wav_path, "rb") as audio: + if audio.getframerate() != SAMPLE_RATE or audio.getnchannels() != 1 or audio.getsampwidth() != 2: + raise RuntimeError("Extracted audio is not 16 kHz mono PCM") + total_samples = audio.getnframes() + processed = 0 + last_percent = -1 + while True: + frames = audio.readframes(WINDOW_SAMPLES) + if not frames: + break + chunk = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0 + actual_samples = chunk.size + if actual_samples < WINDOW_SAMPLES: + chunk = np.pad(chunk, (0, WINDOW_SAMPLES - actual_samples)) + model_input = np.concatenate((context, chunk.reshape(1, -1)), axis=1) + output, state = session.run(None, { + "input": model_input, + "state": state, + "sr": np.array(SAMPLE_RATE, dtype=np.int64), + }) + probabilities.append(float(output[0][0])) + context = model_input[:, -CONTEXT_SAMPLES:] + processed += actual_samples + percent = 10 + int((processed / max(1, total_samples)) * 55) + if percent >= last_percent + 3: + last_percent = percent + _emit(progress_callback, percent, "Finding spoken sections") + + return probabilities_to_speech_segments(probabilities, total_samples, threshold=threshold) + finally: + try: + os.unlink(wav_path) + except OSError: + pass + + +def plan_silence_removal( + duration: float, + vad_segments: list[dict], + transcript_words: list[dict], + *, + min_silence_seconds: float = 0.65, + padding_seconds: float = 0.12, +) -> dict: + """Build conservative keep ranges from VAD plus transcript word timings.""" + duration = max(0.0, float(duration)) + min_silence_seconds = max(0.2, min(5.0, float(min_silence_seconds))) + padding_seconds = max(0.0, min(0.5, float(padding_seconds))) + if duration <= 0: + raise ValueError("Video duration must be positive") + + protected: list[dict] = [] + for segment in vad_segments: + protected.append({ + "start": float(segment.get("start", 0)) - padding_seconds, + "end": float(segment.get("end", 0)) + padding_seconds, + }) + for word in transcript_words: + try: + protected.append({ + "start": float(word["start"]) - padding_seconds, + "end": float(word["end"]) + padding_seconds, + }) + except (KeyError, TypeError, ValueError): + continue + + keep_segments = _merge_segments(protected, duration, merge_gap=min_silence_seconds) + if not keep_segments: + keep_segments = [{"start": 0.0, "end": duration}] + + removed_ranges: list[dict] = [] + cursor = 0.0 + for segment in keep_segments: + if segment["start"] - cursor >= min_silence_seconds: + removed_ranges.append({"start": cursor, "end": segment["start"]}) + elif cursor < segment["start"]: + segment["start"] = cursor + cursor = segment["end"] + if duration - cursor >= min_silence_seconds: + removed_ranges.append({"start": cursor, "end": duration}) + elif cursor < duration: + keep_segments[-1]["end"] = duration + + # Rebuild exact keep ranges as the complement of accepted removals. This + # prevents short leading/interstitial gaps from disappearing accidentally. + keep_segments = [] + cursor = 0.0 + for removed in removed_ranges: + if removed["start"] > cursor: + keep_segments.append({"start": cursor, "end": removed["start"]}) + cursor = removed["end"] + if cursor < duration: + keep_segments.append({"start": cursor, "end": duration}) + if not keep_segments: + keep_segments = [{"start": 0.0, "end": duration}] + + keep_segments = [ + {"start": round(item["start"], 3), "end": round(item["end"], 3)} + for item in keep_segments if item["end"] - item["start"] >= 0.04 + ] + removed_ranges = [ + {"start": round(item["start"], 3), "end": round(item["end"], 3)} + for item in removed_ranges + ] + output_duration = sum(item["end"] - item["start"] for item in keep_segments) + removed_duration = max(0.0, duration - output_duration) + return { + "keep_segments": keep_segments, + "removed_ranges": removed_ranges, + "source_duration": round(duration, 3), + "output_duration": round(output_duration, 3), + "removed_duration": round(removed_duration, 3), + "removed_percent": round((removed_duration / duration) * 100, 1), + "cut_count": len(removed_ranges), + "min_silence_seconds": min_silence_seconds, + "padding_seconds": padding_seconds, + "method": "silero-vad+word-boundaries", + } + + +def analyze_silence( + video_path: str, + transcript_words: list[dict], + *, + threshold: float = 0.5, + min_silence_seconds: float = 0.65, + padding_seconds: float = 0.12, + progress_callback: ProgressCallback = None, +) -> dict: + duration = get_media_duration_seconds(video_path) + if duration <= 0: + raise RuntimeError("Could not determine episode duration") + speech = detect_speech(video_path, threshold=threshold, progress_callback=progress_callback) + _emit(progress_callback, 75, "Protecting word boundaries") + plan = plan_silence_removal( + duration, + speech, + transcript_words, + min_silence_seconds=min_silence_seconds, + padding_seconds=padding_seconds, + ) + plan["vad_threshold"] = threshold + _emit(progress_callback, 100, "Silence analysis ready") + return plan + + +def _map_range(start: float, end: float, keep_segments: list[dict]) -> Optional[tuple[float, float]]: + output_cursor = 0.0 + mapped_parts: list[tuple[float, float]] = [] + for segment in keep_segments: + overlap_start = max(start, segment["start"]) + overlap_end = min(end, segment["end"]) + if overlap_end > overlap_start: + mapped_parts.append(( + output_cursor + overlap_start - segment["start"], + output_cursor + overlap_end - segment["start"], + )) + output_cursor += segment["end"] - segment["start"] + if not mapped_parts: + return None + return mapped_parts[0][0], mapped_parts[-1][1] + + +def remap_timed_items(items: list[dict], keep_segments: list[dict]) -> list[dict]: + remapped: list[dict] = [] + for item in items: + try: + start = float(item["start"]) + end = float(item["end"]) + except (KeyError, TypeError, ValueError): + continue + mapped = _map_range(start, end, keep_segments) + if not mapped or mapped[1] - mapped[0] < 0.01: + continue + remapped.append({**item, "start": round(mapped[0], 3), "end": round(mapped[1], 3)}) + return remapped + + +def remap_transcript(transcript: dict, keep_segments: list[dict]) -> dict: + remapped = dict(transcript or {}) + remapped["words"] = remap_timed_items(list(remapped.get("words") or []), keep_segments) + remapped["segments"] = remap_timed_items(list(remapped.get("segments") or []), keep_segments) + remapped["duration"] = round(sum(s["end"] - s["start"] for s in keep_segments), 3) + remapped["silence_removed"] = True + return remapped + + +def _reserve_output_path(video_path: str, output_dir: str) -> Path: + stem = Path(video_path).stem + for suffix in range(1, 10_000): + name = f"{stem}_silence_removed_podcli.mp4" if suffix == 1 else f"{stem}_silence_removed_podcli-{suffix}.mp4" + candidate = Path(output_dir) / name + if not candidate.exists(): + return candidate + raise RuntimeError("Could not reserve silence-removed output filename") + + +def _render_batch( + video_path: str, + output_path: str, + segments: list[dict], + audio: bool, +) -> None: + filters: list[str] = [] + concat_inputs: list[str] = [] + for index, segment in enumerate(segments): + start = segment["start"] + end = segment["end"] + filters.append(f"[0:v:0]trim=start={start:.3f}:end={end:.3f},setpts=PTS-STARTPTS[v{index}]") + concat_inputs.append(f"[v{index}]") + if audio: + filters.append(f"[0:a:0]atrim=start={start:.3f}:end={end:.3f},asetpts=PTS-STARTPTS[a{index}]") + concat_inputs.append(f"[a{index}]") + filters.append( + "".join(concat_inputs) + + f"concat=n={len(segments)}:v=1:a={1 if audio else 0}[vout]" + + ("[aout]" if audio else "") + ) + command = [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", video_path, + "-filter_complex", ";".join(filters), + "-map", "[vout]", + ] + if audio: + command += ["-map", "[aout]"] + command += [ + "-c:v", "libx264", "-crf", "18", "-preset", "fast", "-profile:v", "high", + "-pix_fmt", "yuv420p", + ] + if audio: + command += ["-c:a", "aac", "-b:a", "192k"] + command += ["-movflags", "+faststart", output_path] + proc_run(command, timeout=3600, check=True) + + +def render_silence_removed( + video_path: str, + keep_segments: list[dict], + transcript: dict, + output_dir: str, + *, + progress_callback: ProgressCallback = None, +) -> dict: + if not os.path.exists(video_path): + raise FileNotFoundError(f"Video not found: {video_path}") + duration = get_media_duration_seconds(video_path) + normalized = _merge_segments(keep_segments, duration) + if not normalized: + raise ValueError("No valid speech segments to render") + + Path(output_dir).mkdir(parents=True, exist_ok=True) + output_path = _reserve_output_path(video_path, output_dir) + work_dir = Path(tempfile.mkdtemp(prefix="podcli_silence_", dir=paths["working"] if os.path.isdir(paths["working"]) else None)) + batch_size = 80 + chunks: list[Path] = [] + audio = has_audio_stream(video_path) + try: + batches = [normalized[index:index + batch_size] for index in range(0, len(normalized), batch_size)] + for index, batch in enumerate(batches): + _emit(progress_callback, 5 + int((index / len(batches)) * 85), f"Building compact episode {index + 1}/{len(batches)}") + chunk_path = work_dir / f"chunk-{index:04d}.mp4" + _render_batch(video_path, str(chunk_path), batch, audio) + chunks.append(chunk_path) + + partial = work_dir / "finished.mp4" + if len(chunks) == 1: + shutil.copy2(chunks[0], partial) + else: + concat_path = work_dir / "chunks.txt" + concat_path.write_text("".join(f"file '{chunk.as_posix()}'\n" for chunk in chunks), encoding="utf-8") + proc_run([ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", + "-f", "concat", "-safe", "0", "-i", str(concat_path), + "-c", "copy", "-movflags", "+faststart", str(partial), + ], timeout=1800, check=True) + os.replace(partial, output_path) + _emit(progress_callback, 96, "Remapping captions and clips") + remapped = remap_transcript(transcript, normalized) + manifest = output_path.with_suffix(".silence.json") + manifest.write_text(json.dumps({ + "source_video": os.path.abspath(video_path), + "output_video": str(output_path), + "keep_segments": normalized, + }, ensure_ascii=False, indent=2), encoding="utf-8") + _emit(progress_callback, 100, "Compact episode ready") + stat = output_path.stat() + return { + "output_path": str(output_path), + "filename": output_path.name, + "file_size_mb": round(stat.st_size / (1024 * 1024), 2), + "duration": remapped["duration"], + "transcript": remapped, + "manifest_path": str(manifest), + } + finally: + shutil.rmtree(work_dir, ignore_errors=True) diff --git a/remotion/render-full-episode.mjs b/remotion/render-full-episode.mjs new file mode 100644 index 0000000..1a7df81 --- /dev/null +++ b/remotion/render-full-episode.mjs @@ -0,0 +1,217 @@ +#!/usr/bin/env node + +/** + * Burn Remotion captions into an arbitrarily long source video. + * + * A full-length transparent ProRes overlay can consume tens of gigabytes. This + * renderer instead creates one short overlay at a time, composites it, deletes + * it, then losslessly concatenates the compressed video chunks and remuxes the + * source audio. + */ + +import { renderMedia, selectComposition } from "@remotion/renderer"; +import { getCachedBundle } from "./bundle-cache.mjs"; +import { spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +const parseArgs = () => { + const out = {}; + for (let i = 2; i < process.argv.length; i += 2) { + const key = process.argv[i]?.replace(/^--/, ""); + const value = process.argv[i + 1]; + if (key && value) out[key] = value; + } + return out; +}; + +const run = (command, args) => { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "unknown error").trim().slice(-3000); + throw new Error(`${path.basename(command)} failed (${result.status}): ${detail}`); + } + return result.stdout.trim(); +}; + +const progress = (percent, message) => { + process.stdout.write(`PODCLI_PROGRESS=${JSON.stringify({ percent, message })}\n`); +}; + +const quoteConcatPath = (filePath) => { + const normalized = filePath.replaceAll("\\", "/"); + return `'${normalized.replaceAll("'", "'\\''")}'`; +}; + +const args = parseArgs(); +for (const required of ["video", "words", "output", "ffmpeg", "ffprobe"]) { + if (!args[required]) throw new Error(`Missing --${required}`); +} + +const video = path.resolve(args.video); +const wordsPath = path.resolve(args.words); +const output = path.resolve(args.output); +const partialOutput = `${output}.partial.mp4`; +const logo = args.logo ? path.resolve(args.logo) : null; +const styleName = args.style || "branded"; +const captionPosition = args["caption-position"] || "auto"; +const captionFontScale = Number(args["caption-font-scale"] || 100); +const logoPosition = args["logo-position"] || "top-left"; +const fps = Number(args.fps || 30); +const chunkSeconds = Number(args["chunk-seconds"] || 15); + +if (fs.existsSync(output)) throw new Error(`Refusing to overwrite existing output: ${output}`); +if (!fs.existsSync(video)) throw new Error(`Video not found: ${video}`); +if (!fs.existsSync(wordsPath)) throw new Error(`Words JSON not found: ${wordsPath}`); +if (logo && !fs.existsSync(logo)) throw new Error(`Logo not found: ${logo}`); +if (!(fps > 0) || !(chunkSeconds > 0)) throw new Error("fps and chunk-seconds must be positive"); + +const wordsData = JSON.parse(fs.readFileSync(wordsPath, "utf8")); +const words = Array.isArray(wordsData) ? wordsData : wordsData.words || []; +const faceY = Array.isArray(wordsData) ? null : wordsData.faceY ?? null; +const dimensions = run(args.ffprobe, [ + "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", + "-of", "csv=s=x:p=0", video, +]); +const [width, height] = dimensions.split("x").map(Number); +const duration = Number(run(args.ffprobe, [ + "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", video, +])); +if (!(width > 0 && height > 0 && duration > 0)) throw new Error("Could not probe video"); + +const durationInFrames = Math.ceil(duration * fps); +const framesPerChunk = Math.max(1, Math.round(chunkSeconds * fps)); +const outputDir = path.dirname(output); +fs.mkdirSync(outputDir, { recursive: true }); +const workDir = fs.mkdtempSync(path.join(outputDir, ".podcli-full-caption-work-")); +let server; + +const cleanup = () => { + try { server?.close(); } catch {} + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {} + try { fs.rmSync(partialOutput, { force: true }); } catch {} +}; +process.on("SIGINT", () => { cleanup(); process.exit(130); }); +process.on("SIGTERM", () => { cleanup(); process.exit(143); }); + +try { + server = http.createServer((request, response) => { + if (request.url !== "/logo.png" || !logo) { + response.writeHead(404); + response.end(); + return; + } + const stat = fs.statSync(logo); + response.writeHead(200, { + "Content-Type": "image/png", + "Content-Length": stat.size, + "Access-Control-Allow-Origin": "*", + }); + fs.createReadStream(logo).pipe(response); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const logoSrc = logo ? `http://127.0.0.1:${server.address().port}/logo.png` : undefined; + + progress(2, "Preparing caption renderer"); + const bundle = await getCachedBundle({ onBundle: () => progress(3, "Preparing caption renderer") }); + const inputProps = { + videoSrc: "", + words, + styleName, + logoSrc, + faceY, + durationInFrames, + fps, + captionPosition, + captionFontScale, + logoPosition, + singleLine: true, + }; + const composition = await selectComposition({ + serveUrl: bundle, + id: "CaptionedClip", + inputProps, + timeoutInMilliseconds: 120000, + }); + const renderComposition = { ...composition, durationInFrames, fps, width, height }; + const chunks = []; + const chunkCount = Math.ceil(durationInFrames / framesPerChunk); + const requestedConcurrency = Number.parseInt(process.env.PODCLI_REMOTION_CONCURRENCY || "", 10); + const concurrency = Number.isFinite(requestedConcurrency) + ? Math.max(1, Math.min(os.cpus().length, requestedConcurrency)) + : Math.max(2, Math.min(os.cpus().length, 8)); + + for (let index = 0; index < chunkCount; index++) { + const startFrame = index * framesPerChunk; + const endFrame = Math.min(durationInFrames - 1, startFrame + framesPerChunk - 1); + const startSeconds = startFrame / fps; + const sectionDuration = (endFrame - startFrame + 1) / fps; + const id = String(index + 1).padStart(4, "0"); + const overlay = path.join(workDir, `overlay-${id}.mov`); + const chunk = path.join(workDir, `video-${id}.mp4`); + let lastPercent = -1; + + await renderMedia({ + composition: renderComposition, + serveUrl: bundle, + codec: "prores", + proResProfile: "4444", + pixelFormat: "yuva444p10le", + imageFormat: "png", + outputLocation: overlay, + inputProps, + frameRange: [startFrame, endFrame], + concurrency, + timeoutInMilliseconds: 120000, + onProgress: ({ progress: chunkProgress }) => { + const percent = Math.floor(chunkProgress * 100); + if (percent >= lastPercent + 10) { + lastPercent = percent; + const overall = 5 + ((index + chunkProgress) / chunkCount) * 80; + progress(overall, `Rendering captions ${index + 1}/${chunkCount}`); + } + }, + }); + + progress(5 + ((index + 1) / chunkCount) * 80, `Compositing section ${index + 1}/${chunkCount}`); + run(args.ffmpeg, [ + "-y", "-hide_banner", "-loglevel", "error", + "-ss", startSeconds.toFixed(6), "-t", sectionDuration.toFixed(6), "-i", video, + "-i", overlay, + "-filter_complex", "[0:v][1:v]overlay=0:0:shortest=1,format=yuv420p[v]", + "-map", "[v]", "-an", + "-c:v", "libx264", "-crf", "18", "-preset", "fast", + "-r", String(fps), "-g", String(fps * 2), + "-movflags", "+faststart", chunk, + ]); + fs.rmSync(overlay, { force: true }); + chunks.push(chunk); + } + + progress(90, "Joining captioned sections"); + const listPath = path.join(workDir, "chunks.txt"); + fs.writeFileSync(listPath, chunks.map((chunk) => `file ${quoteConcatPath(chunk)}`).join("\n") + "\n"); + const videoOnly = path.join(workDir, `video-only-${crypto.randomUUID()}.mp4`); + run(args.ffmpeg, [ + "-y", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", + "-i", listPath, "-c", "copy", "-movflags", "+faststart", videoOnly, + ]); + + progress(96, "Adding original audio"); + run(args.ffmpeg, [ + "-y", "-hide_banner", "-loglevel", "error", "-i", videoOnly, "-i", video, + "-map", "0:v:0", "-map", "1:a:0?", "-c", "copy", "-shortest", + "-movflags", "+faststart", partialOutput, + ]); + fs.renameSync(partialOutput, output); + progress(100, "Full episode ready"); +} finally { + cleanup(); +} diff --git a/remotion/render.mjs b/remotion/render.mjs index ecbc9c8..ad5c268 100644 --- a/remotion/render.mjs +++ b/remotion/render.mjs @@ -151,6 +151,9 @@ async function main() { faceY, durationInFrames, fps, + captionPosition: opts["caption-position"] || "auto", + captionFontScale: Number(opts["caption-font-scale"] || 100), + logoPosition: opts["logo-position"] || "top-left", }; console.log( diff --git a/remotion/src/CaptionedClip.tsx b/remotion/src/CaptionedClip.tsx index 72050b4..d29e9e1 100644 --- a/remotion/src/CaptionedClip.tsx +++ b/remotion/src/CaptionedClip.tsx @@ -4,7 +4,7 @@ import { HormoziCaptions } from "./components/HormoziCaptions"; import { KaraokeCaptions } from "./components/KaraokeCaptions"; import { SubtleCaptions } from "./components/SubtleCaptions"; import { BrandedCaptions } from "./components/BrandedCaptions"; -import type { Word, CaptionStyle } from "./types"; +import type { Word, CaptionStyle, CaptionPosition, LogoPosition } from "./types"; export interface CaptionedClipProps { videoSrc: string; @@ -12,6 +12,9 @@ export interface CaptionedClipProps { style: CaptionStyle; logoSrc?: string; faceY?: number | null; + captionPosition?: CaptionPosition; + logoPosition?: LogoPosition; + singleLine?: boolean; } export const CaptionedClip: React.FC = ({ @@ -19,6 +22,9 @@ export const CaptionedClip: React.FC = ({ style, logoSrc, faceY, + captionPosition = "auto", + logoPosition = "top-left", + singleLine = false, }) => { const CaptionComponent = { hormozi: HormoziCaptions, @@ -30,9 +36,10 @@ export const CaptionedClip: React.FC = ({ return ( {style.name === "branded" ? ( - + ) : ( - + )} ); diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx index 0cc2512..ca1545e 100644 --- a/remotion/src/Root.tsx +++ b/remotion/src/Root.tsx @@ -4,6 +4,7 @@ import { CaptionedClip } from "./CaptionedClip"; import { Bookend } from "./Bookend"; import { STYLES } from "./types"; import type { Word } from "./types"; +import type { CaptionPosition, LogoPosition } from "./types"; import dmSans400 from "@fontsource/dm-sans/files/dm-sans-latin-400-normal.woff2"; import dmSans700 from "@fontsource/dm-sans/files/dm-sans-latin-700-normal.woff2"; @@ -33,6 +34,10 @@ const inputProps = getInputProps() as { styleName?: string; logoSrc?: string; faceY?: number | null; + captionPosition?: CaptionPosition; + captionFontScale?: number; + logoPosition?: LogoPosition; + singleLine?: boolean; durationInFrames?: number; fps?: number; bookendMode?: "intro" | "outro"; @@ -46,6 +51,19 @@ const inputProps = getInputProps() as { export const RemotionRoot: React.FC = () => { const fps = inputProps.fps || 30; const durationInFrames = inputProps.durationInFrames || 900; + const baseStyle = STYLES[inputProps.styleName || "branded"]; + const positionMargins: Partial> = { + upper: 760, + center: 480, + lower: 220, + }; + const captionPosition = inputProps.captionPosition || "auto"; + const fontScale = Math.max(0.6, Math.min(1.6, (inputProps.captionFontScale || 100) / 100)); + const style = { + ...baseStyle, + fontSize: baseStyle.fontSize * fontScale, + marginBottom: positionMargins[captionPosition] ?? baseStyle.marginBottom, + }; return ( <> @@ -59,9 +77,12 @@ export const RemotionRoot: React.FC = () => { defaultProps={{ videoSrc: inputProps.videoSrc || "", words: inputProps.words || [], - style: STYLES[inputProps.styleName || "branded"], + style, logoSrc: inputProps.logoSrc, faceY: inputProps.faceY ?? null, + captionPosition, + logoPosition: inputProps.logoPosition || "top-left", + singleLine: inputProps.singleLine === true, }} /> { ]); }); }); + +describe("splitCaptionLines", () => { + it("uses one line for YouTube full-episode captions", () => { + expect(splitCaptionLines(["one", "two", "three", "four"], 2, true)).toEqual([ + ["one", "two", "three", "four"], + [], + ]); + }); + + it("preserves normal clip line splitting", () => { + expect(splitCaptionLines(["one", "two", "three", "four"], 2)).toEqual([ + ["one", "two"], + ["three", "four"], + ]); + }); +}); diff --git a/remotion/src/chunks.ts b/remotion/src/chunks.ts index 949a815..e1b1280 100644 --- a/remotion/src/chunks.ts +++ b/remotion/src/chunks.ts @@ -98,3 +98,12 @@ export function buildChunks(words: Word[], opts: ChunkOptions): Chunk[] { export function activeChunkAt(chunks: Chunk[], time: number): Chunk | undefined { return chunks.find((c) => time >= c.start && time < c.displayEnd); } + +export function splitCaptionLines( + items: T[], + splitIndex: number, + singleLine = false, +): [T[], T[]] { + if (singleLine || items.length <= splitIndex) return [items, []]; + return [items.slice(0, splitIndex), items.slice(splitIndex)]; +} diff --git a/remotion/src/components/BrandedCaptions.tsx b/remotion/src/components/BrandedCaptions.tsx index 789e310..bb160ce 100644 --- a/remotion/src/components/BrandedCaptions.tsx +++ b/remotion/src/components/BrandedCaptions.tsx @@ -6,26 +6,22 @@ import { Img, staticFile, } from "remotion"; -import type { Word, CaptionStyle } from "../types"; +import type { Word, CaptionStyle, CaptionPosition, LogoPosition } from "../types"; import { captionScale } from "../types"; -import { buildChunks, activeChunkAt } from "../chunks"; +import { buildChunks, activeChunkAt, splitCaptionLines } from "../chunks"; interface Props { words: Word[]; style: CaptionStyle; logoSrc?: string; faceY?: number | null; // normalized 0-1 (0=top, 1=bottom) + captionPosition?: CaptionPosition; + logoPosition?: LogoPosition; + singleLine?: boolean; } const MAX_CHARS_PER_CHUNK = 18; -function splitIntoLines(words: Word[]): [Word[], Word[]] { - if (words.length <= 2) { - return [words, []]; - } - return [words.slice(0, 2), words.slice(2)]; -} - /** * Active pill rendered as an absolutely positioned background behind the word. * The word itself is always rendered as plain inline text so layout doesn't shift. @@ -77,7 +73,8 @@ const CaptionLine: React.FC<{ frame: number; fps: number; style: CaptionStyle; -}> = ({ words, currentTime, frame, fps, style }) => { + singleLine?: boolean; +}> = ({ words, currentTime, frame, fps, style, singleLine = false }) => { return (

{words.map((word, i) => { @@ -117,6 +115,9 @@ export const BrandedCaptions: React.FC = ({ style, logoSrc, faceY, + captionPosition = "auto", + logoPosition = "top-left", + singleLine = false, }) => { const frame = useCurrentFrame(); const { fps, height, durationInFrames } = useVideoConfig(); @@ -137,10 +138,10 @@ export const BrandedCaptions: React.FC = ({ // Default margin is style.marginBottom. If face center is below 0.55, reduce margin. const baseMargin = style.marginBottom * s; let dynamicMargin = baseMargin; - if (faceY != null && faceY > 0.55) { + if (captionPosition === "auto" && faceY != null && faceY > 0.55) { // Face is low — push captions to the very bottom dynamicMargin = Math.max(80 * s, baseMargin - Math.round((faceY - 0.55) * height * 0.6)); - } else if (faceY != null && faceY < 0.35) { + } else if (captionPosition === "auto" && faceY != null && faceY < 0.35) { // Face is high — can bring captions up a bit dynamicMargin = baseMargin + 60 * s; } @@ -152,8 +153,12 @@ export const BrandedCaptions: React.FC = ({ src={logoSrc.startsWith("http") ? logoSrc : staticFile(logoSrc)} style={{ position: "absolute", - top: 180 * s, - left: 108 * s, + ...(logoPosition.startsWith("top-") ? { top: 180 * s } : { bottom: 180 * s }), + ...(logoPosition.endsWith("-left") + ? { left: 108 * s } + : logoPosition.endsWith("-right") + ? { right: 108 * s } + : { left: "50%", transform: "translateX(-50%)" }), width: 255 * s, height: 126 * s, objectFit: "contain", @@ -162,7 +167,7 @@ export const BrandedCaptions: React.FC = ({ )} {activeChunk && (() => { - const [line1, line2] = splitIntoLines(activeChunk.words); + const [line1, line2] = splitCaptionLines(activeChunk.words, 2, singleLine); return (
= ({ frame={frame} fps={fps} style={scaledStyle} + singleLine={singleLine} /> {line2.length > 0 && ( = ({ frame={frame} fps={fps} style={scaledStyle} + singleLine={singleLine} /> )}
diff --git a/remotion/src/components/HormoziCaptions.tsx b/remotion/src/components/HormoziCaptions.tsx index a709056..54dc294 100644 --- a/remotion/src/components/HormoziCaptions.tsx +++ b/remotion/src/components/HormoziCaptions.tsx @@ -12,9 +12,10 @@ import { buildChunks, activeChunkAt } from "../chunks"; interface Props { words: Word[]; style: CaptionStyle; + singleLine?: boolean; } -export const HormoziCaptions: React.FC = ({ words, style }) => { +export const HormoziCaptions: React.FC = ({ words, style, singleLine = false }) => { const frame = useCurrentFrame(); const { fps, height, durationInFrames } = useVideoConfig(); const s = captionScale(height); @@ -65,6 +66,7 @@ export const HormoziCaptions: React.FC = ({ words, style }) => { maxWidth: `calc(100% - ${120 * s}px)`, boxSizing: "border-box", overflowWrap: "anywhere", + whiteSpace: singleLine ? "nowrap" : undefined, textAlign: "center", fontFamily: style.fontFamily, fontSize: style.fontSize * s, diff --git a/remotion/src/components/KaraokeCaptions.tsx b/remotion/src/components/KaraokeCaptions.tsx index 20bee27..2fb86d3 100644 --- a/remotion/src/components/KaraokeCaptions.tsx +++ b/remotion/src/components/KaraokeCaptions.tsx @@ -2,24 +2,20 @@ import React from "react"; import { useCurrentFrame, useVideoConfig } from "remotion"; import type { Word, CaptionStyle } from "../types"; import { captionScale } from "../types"; -import { buildChunks, activeChunkAt } from "../chunks"; +import { buildChunks, activeChunkAt, splitCaptionLines } from "../chunks"; interface Props { words: Word[]; style: CaptionStyle; -} - -function splitIntoLines(words: Word[]): [Word[], Word[]] { - if (words.length <= 3) return [words, []]; - const mid = Math.ceil(words.length / 2); - return [words.slice(0, mid), words.slice(mid)]; + singleLine?: boolean; } const KaraokeLine: React.FC<{ words: Word[]; currentTime: number; style: CaptionStyle; -}> = ({ words, currentTime, style }) => { + singleLine?: boolean; +}> = ({ words, currentTime, style, singleLine = false }) => { return (
{words.map((word, i) => { @@ -64,7 +61,7 @@ const KaraokeLine: React.FC<{ ); }; -export const KaraokeCaptions: React.FC = ({ words, style }) => { +export const KaraokeCaptions: React.FC = ({ words, style, singleLine = false }) => { const frame = useCurrentFrame(); const { fps, height, durationInFrames } = useVideoConfig(); const s = captionScale(height); @@ -79,7 +76,11 @@ export const KaraokeCaptions: React.FC = ({ words, style }) => { if (!activeChunk) return null; - const [line1, line2] = splitIntoLines(activeChunk.words); + const [line1, line2] = splitCaptionLines( + activeChunk.words, + Math.ceil(activeChunk.words.length / 2), + singleLine, + ); const scaledStyle = { ...style, fontSize: style.fontSize * s }; return ( @@ -95,9 +96,9 @@ export const KaraokeCaptions: React.FC = ({ words, style }) => { gap: 4 * s, }} > - + {line2.length > 0 && ( - + )}
); diff --git a/remotion/src/components/SubtleCaptions.tsx b/remotion/src/components/SubtleCaptions.tsx index 3f985dd..e7cc1fa 100644 --- a/remotion/src/components/SubtleCaptions.tsx +++ b/remotion/src/components/SubtleCaptions.tsx @@ -2,20 +2,15 @@ import React from "react"; import { useCurrentFrame, useVideoConfig, interpolate } from "remotion"; import type { Word, CaptionStyle } from "../types"; import { captionScale } from "../types"; -import { buildChunks, activeChunkAt } from "../chunks"; +import { buildChunks, activeChunkAt, splitCaptionLines } from "../chunks"; interface Props { words: Word[]; style: CaptionStyle; + singleLine?: boolean; } -function splitIntoLines(words: Word[]): [Word[], Word[]] { - if (words.length <= 4) return [words, []]; - const mid = Math.ceil(words.length / 2); - return [words.slice(0, mid), words.slice(mid)]; -} - -export const SubtleCaptions: React.FC = ({ words, style }) => { +export const SubtleCaptions: React.FC = ({ words, style, singleLine = false }) => { const frame = useCurrentFrame(); const { fps, height, durationInFrames } = useVideoConfig(); const s = captionScale(height); @@ -46,7 +41,11 @@ export const SubtleCaptions: React.FC = ({ words, style }) => { { extrapolateRight: "clamp" } ); - const [line1, line2] = splitIntoLines(activeChunk.words); + const [line1, line2] = splitCaptionLines( + activeChunk.words, + Math.ceil(activeChunk.words.length / 2), + singleLine, + ); const text1 = line1.map((w) => w.word).join(" "); const text2 = line2.map((w) => w.word).join(" "); @@ -75,6 +74,7 @@ export const SubtleCaptions: React.FC = ({ words, style }) => { "0 1px 3px rgba(0,0,0,0.95), 0 0 20px rgba(0,0,0,0.6), 0 0 50px rgba(0,0,0,0.3)", textAlign: "center", lineHeight: 1.35, + whiteSpace: singleLine ? "nowrap" : undefined, }} > {text1} diff --git a/remotion/src/types.ts b/remotion/src/types.ts index b7a2cbb..629e380 100644 --- a/remotion/src/types.ts +++ b/remotion/src/types.ts @@ -17,6 +17,15 @@ export interface CaptionStyle { marginBottom: number; } +export type CaptionPosition = "auto" | "upper" | "center" | "lower"; +export type LogoPosition = + | "top-left" + | "top-center" + | "top-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + export interface CaptionProps { words: Word[]; style: CaptionStyle; diff --git a/src/models/index.ts b/src/models/index.ts index 64054e3..df10618 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,7 +2,7 @@ export interface TaskRequest { task_id: string; - task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status"; + task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status" | "analyze_silence" | "render_silence_removed"; params: Record; } @@ -114,6 +114,8 @@ export interface UIState { activeExportJobId?: string | null; transcript?: TranscriptResult | null; rawTranscriptText?: string; + silenceOriginal?: { videoPath: string; transcript: TranscriptResult } | null; + silencePlan?: Record | null; suggestions?: SuggestedClip[]; deselectedIndices?: number[]; settings?: { @@ -124,6 +126,9 @@ export interface UIState { outroPath?: string; introPath?: string; cleanFillers?: boolean; + silenceThreshold?: number; + silenceMinPause?: number; + silencePadding?: number; }; phase?: string; lastUpdated?: number; diff --git a/src/ui/client/CopyButton.tsx b/src/ui/client/CopyButton.tsx index 4bb7e12..a6ce5a9 100644 --- a/src/ui/client/CopyButton.tsx +++ b/src/ui/client/CopyButton.tsx @@ -1,6 +1,37 @@ import React, { useEffect, useRef, useState } from "react"; import { Copy, Check } from "lucide-react"; +async function copyText(value: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + } catch { + // WebKit and embedded browsers can deny Clipboard API despite localhost. + } + + const field = document.createElement("textarea"); + field.value = value; + field.setAttribute("readonly", ""); + field.style.position = "fixed"; + field.style.opacity = "0"; + field.style.pointerEvents = "none"; + const activeElement = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + document.body.appendChild(field); + let copied = false; + try { + field.select(); + copied = document.execCommand("copy"); + } finally { + field.remove(); + activeElement?.focus({ preventScroll: true }); + } + if (!copied) throw new Error("Clipboard unavailable"); +} + type CopyButtonProps = { text?: string; getText?: () => string; @@ -45,7 +76,7 @@ export default function CopyButton({ if (!value) return; try { - await navigator.clipboard.writeText(value); + await copyText(value); setCopied(true); onCopied?.(); diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx index c4494de..07b112c 100644 --- a/src/ui/client/EpisodeWorkspace.jsx +++ b/src/ui/client/EpisodeWorkspace.jsx @@ -23,17 +23,27 @@ import { ChevronRight, ChevronDown, ArrowRight, + Volume2, + Settings as SettingsGlyph, + Maximize, + Captions, + ThumbsUp, + Share2, + Bell, + Scissors, } from 'lucide-react'; import CopyButton from './CopyButton'; import AssetPicker from './AssetPicker'; +import { assetSrc, useAssets } from './useAssets'; import RecentSources from './RecentSources'; import MomentTrim from './MomentTrim'; import { useDialog } from './useDialog'; import { PageHeader } from './Page'; import { buildPreviewChunks, activePreviewChunk, selectPreviewWords } from './captionChunks'; -import { findClipResult, resultBoundsKey, clipKey, buildEnergyMap, dropEnergy, clampClipIndex } from './lib'; +import { findClipResult, resultBoundsKey, clipKey, buildEnergyMap, dropEnergy, clampClipIndex, resolveAssetName, formatTranscriptText } from './lib'; const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; +const fmtSaved = (s) => s < 10 ? `${Number(s || 0).toFixed(1)}s` : fmt(s); const isHttpUrl = (value) => /^https?:\/\//i.test(value.trim()); const onKeyActivate = (fn) => (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); fn(e); } @@ -98,6 +108,45 @@ const onKeyActivate = (fn) => (e) => { const PREVIEW_SCALE = 0.27; const px = (n) => Math.round(n * PREVIEW_SCALE); const PROD_TO_PCT = (margin) => ((margin / 1920) * 100).toFixed(1) + '%'; + const CAPTION_BOTTOMS = { upper: PROD_TO_PCT(760), center: PROD_TO_PCT(480), lower: PROD_TO_PCT(220) }; + const captionBottom = (cfg, position) => CAPTION_BOTTOMS[position] || cfg.bottom; + const logoPlacement = (position) => { + const vertical = position.startsWith('bottom-') ? { bottom: '7%', top: 'auto' } : { top: '7%', bottom: 'auto' }; + const horizontal = position.endsWith('-right') + ? { right: '3%', left: 'auto', transform: 'none' } + : position.endsWith('-center') + ? { left: '50%', right: 'auto', transform: 'translateX(-50%)' } + : { left: '3%', right: 'auto', transform: 'none' }; + return { ...vertical, ...horizontal }; + }; + + const LOGO_POSITIONS = [ + ['top-left', 'Top left'], + ['top-center', 'Top center'], + ['top-right', 'Top right'], + ['bottom-left', 'Bottom left'], + ['bottom-center', 'Bottom center'], + ['bottom-right', 'Bottom right'], + ]; + + function LogoPositionPicker({ value, onChange, disabled }) { + const activeLabel = LOGO_POSITIONS.find(([position]) => position === value)?.[1] || 'Top left'; + return ( +
+
+ {LOGO_POSITIONS.map(([position, label]) => ( + + ))} +
+ {activeLabel} +
+ ); + } const STYLE_CONFIGS = { branded: { @@ -207,7 +256,34 @@ const onKeyActivate = (fn) => (e) => { ); } - function PhoneCaptionBody({ chunk, activeWordInChunk, cfg }) { + function YouTubeWireframe({ title, playing, progress, currentTime, duration, onTogglePlay }) { + return ( +
+
{title || 'Full episode preview'}
+ {!playing && ( + + )} +
+
+
+ + + {fmt(currentTime)} / {duration ? fmt(duration) : '0:00'} + + + + +
+
+
+ ); + } + + function PhoneCaptionBody({ chunk, activeWordInChunk, cfg, singleLine = false }) { if (!chunk || !chunk.length) return null; const fmt = (w) => (cfg.uppercase ? w.toUpperCase() : w); @@ -240,7 +316,7 @@ const onKeyActivate = (fn) => (e) => { }; // Branded: split chunk into [first 2 words, rest], render as 2 lines. - if (cfg.splitLines) { + if (cfg.splitLines && !singleLine) { const [line1, line2] = splitBrandedLines(chunk); const startIdx2 = line1.length; return ( @@ -287,14 +363,11 @@ const onKeyActivate = (fn) => (e) => { }}>{inner}
); } - return
{inner}
; + return
{inner}
; } - function LivePhonePreview({ videoUrl, videoRef, captionStyle, activeClip, transcriptWords, logoPath, showTikTokFrame, onToggleFrame, clipEnded, onReplay }) { + function useLiveCaptionPreview({ videoUrl, videoRef, captionStyle, activeClip, transcriptWords }) { const cfg = STYLE_CONFIGS[captionStyle] || STYLE_CONFIGS.branded; - const [logoBroken, setLogoBroken] = useState(false); - useEffect(() => { setLogoBroken(false); }, [logoPath]); - const sourcePool = useMemo(() => { const words = selectPreviewWords(transcriptWords, activeClip); return words.length >= 2 ? words : null; @@ -356,6 +429,16 @@ const onKeyActivate = (fn) => (e) => { } } + return { cfg, usingSample, activeChunk, activeWordInChunk }; + } + + function LivePhonePreview({ videoUrl, videoRef, captionStyle, captionPosition, captionFontScale, logoPosition, activeClip, transcriptWords, logoPreviewUrl, showTikTokFrame, onToggleFrame, clipEnded, onReplay }) { + const { cfg, usingSample, activeChunk, activeWordInChunk } = useLiveCaptionPreview({ + videoUrl, videoRef, captionStyle, activeClip, transcriptWords, + }); + const [logoBroken, setLogoBroken] = useState(false); + useEffect(() => { setLogoBroken(false); }, [logoPreviewUrl]); + return ( <>
@@ -382,9 +465,9 @@ const onKeyActivate = (fn) => (e) => { )} - {videoUrl && captionStyle === 'branded' && logoPath && !logoBroken && ( -
- + Logo setLogoBroken(true)} style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> @@ -392,8 +475,8 @@ const onKeyActivate = (fn) => (e) => { )} {videoUrl && (
@@ -427,20 +510,110 @@ const onKeyActivate = (fn) => (e) => { ); } + function LiveYouTubePreview({ videoUrl, videoRef, captionStyle, captionPosition, captionFontScale, logoPosition, transcriptWords, logoPreviewUrl, rendered, title, showYouTubeFrame, onToggleFrame, onBack }) { + const { cfg, usingSample, activeChunk, activeWordInChunk } = useLiveCaptionPreview({ + videoUrl, videoRef, captionStyle, activeClip: null, transcriptWords, + }); + const [logoBroken, setLogoBroken] = useState(false); + const [playing, setPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + useEffect(() => { setLogoBroken(false); }, [logoPreviewUrl]); + useEffect(() => { setPlaying(false); setCurrentTime(0); setDuration(0); }, [videoUrl]); + + const togglePlay = () => { + const video = videoRef?.current; + if (!video) return; + if (video.paused) video.play().catch(() => {}); + else video.pause(); + }; + const progress = duration > 0 ? Math.max(0, Math.min(100, (currentTime / duration) * 100)) : 0; + + return ( +
+
+ {videoUrl ? ( +