diff --git a/backend/cli.py b/backend/cli.py index 760e886..16db8e3 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -82,6 +82,7 @@ def _selection_signature(config: dict) -> str: bool(config.get("ai_select", True)), config.get("min_clip_duration", MIN_CLIP_DURATION), config.get("max_clip_duration", MAX_CLIP_DURATION), + config.get("format", "vertical"), )) @@ -440,6 +441,8 @@ def cmd_process(args): config["caption_style"] = args.caption_style if args.crop: config["crop_strategy"] = args.crop + if getattr(args, "format", None): + config["format"] = args.format if getattr(args, "thumbnails", None) is not None: config["generate_thumbnails"] = args.thumbnails if args.top: @@ -798,6 +801,7 @@ def _transcribe_progress(pct, msg): end_second=clip["end_second"], caption_style=config.get("caption_style", "branded"), crop_strategy=config.get("crop_strategy", "face"), + format=config.get("format", "vertical"), transcript_words=words, title=clip.get("title", f"clip_{i+1}"), output_dir=output_dir, @@ -1010,6 +1014,7 @@ def _transcribe_progress(pct, msg): end_second=clip["end_second"], caption_style=config.get("caption_style", "branded"), crop_strategy=config.get("crop_strategy", "face"), + format=config.get("format", "vertical"), transcript_words=words, title=clip.get("title", f"clip_{i+1}"), output_dir=output_dir, @@ -1333,6 +1338,7 @@ def _rerender_clip(r): end_second=clip["end_second"], caption_style=config.get("caption_style", "branded"), crop_strategy=config.get("crop_strategy", "face"), + format=config.get("format", "vertical"), transcript_words=words, title=clip.get("title", "clip"), output_dir=output_dir, @@ -1517,6 +1523,7 @@ def _rerender_clip(r): end_second=f_clip["end_second"], caption_style=config.get("caption_style", "branded"), crop_strategy=config.get("crop_strategy", "face"), + format=config.get("format", "vertical"), transcript_words=words, title=f_clip.get("title", "clip"), output_dir=output_dir, @@ -1571,6 +1578,7 @@ def _rerender_clip(r): end_second=nc["end_second"], caption_style=config.get("caption_style", "branded"), crop_strategy=config.get("crop_strategy", "face"), + format=config.get("format", "vertical"), transcript_words=words, title=nc.get("title", "clip"), output_dir=output_dir, @@ -3307,6 +3315,7 @@ def main(): proc.add_argument("--no-thumbnails", dest="thumbnails", action="store_false", help="Skip thumbnail generation") proc.add_argument("--caption-style", choices=["branded", "hormozi", "karaoke", "subtle"]) proc.add_argument("--crop", choices=["center", "face", "speaker", "speaker-hardcut"]) + proc.add_argument("--format", choices=["vertical", "horizontal", "square"], help="Output aspect ratio (default: vertical)") proc.add_argument("--logo", help="Logo image (asset name or path)") proc.add_argument("--outro", help="Outro video (asset name or path)") proc.add_argument("--time-adjust", type=float, help="Timestamp offset in seconds") diff --git a/backend/main.py b/backend/main.py index 727fcc7..468669a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -113,6 +113,7 @@ def handle_create_clip(task_id: str, params: dict): end_second=params["end_second"], caption_style=params.get("caption_style", "hormozi"), crop_strategy=params.get("crop_strategy", "face"), + format=params.get("format", "vertical"), crop_keyframes=params.get("crop_keyframes"), transcript_words=params.get("transcript_words", []), title=params.get("title", "clip"), @@ -152,6 +153,7 @@ def handle_batch_clips(task_id: str, params: dict): end_second=clip["end_second"], caption_style=clip.get("caption_style", "hormozi"), crop_strategy=clip.get("crop_strategy", "face"), + format=clip.get("format", params.get("format", "vertical")), transcript_words=params.get("transcript_words", []), title=clip.get("title", f"clip_{i + 1}"), output_dir=params.get("output_dir"), @@ -480,6 +482,29 @@ def handle_generate_content(task_id: str, params: dict): emit_result(task_id, "success", data=result) +def handle_generate_custom(task_id: str, params: dict): + """Run a free-form content request against the AI CLI with KB + transcript context.""" + from services.content_generator import generate_custom_content + + instruction = str(params.get("instruction", "")).strip() + if not instruction: + emit_result(task_id, "error", error="instruction is required") + return + + result = generate_custom_content( + instruction=instruction, + transcript_segments=params.get("transcript_segments", []), + mode=params.get("mode", "shorts"), + progress_callback=lambda pct, msg: emit_progress(task_id, "generating", pct, msg), + ) + + if result is None: + emit_result(task_id, "error", error="No AI CLI available (install Claude Code or Codex)") + return + + emit_result(task_id, "success", data=result) + + def handle_manage_integrations(task_id: str, params: dict): from services.integrations import IntegrationsManager @@ -568,6 +593,7 @@ def handle_run_integration_tool(task_id: str, params: dict): "manage_env": handle_manage_env, "ai_cli_status": handle_ai_cli_status, "generate_content": handle_generate_content, + "generate_custom": handle_generate_custom, "manage_integrations": handle_manage_integrations, "run_integration_tool": handle_run_integration_tool, "manage_config": handle_manage_config, diff --git a/backend/presets.py b/backend/presets.py index 70f30f6..5aab62c 100644 --- a/backend/presets.py +++ b/backend/presets.py @@ -13,19 +13,23 @@ from typing import Optional from config.paths import paths +from services.formats import FORMATS PRESETS_DIR = os.path.join(paths["home"], "presets") -# ── Clip duration constants (single source of truth) ── -# These are for clip CONTENT only — outro is appended separately. -MIN_CLIP_DURATION = 20 -MAX_CLIP_DURATION = 45 # hard limit with buffer (outro not included) -TARGET_CLIP_DURATION_MIN = 20 -TARGET_CLIP_DURATION_MAX = 35 # Claude targets this range +# Back-compat aliases for the vertical format's durations. Source of truth is +# FORMATS["vertical"]; kept as module-level names because cli.py, +# clip_generator.py and claude_suggest.py import them directly. +_VERTICAL = FORMATS["vertical"] +MIN_CLIP_DURATION = _VERTICAL.dur_min +MAX_CLIP_DURATION = _VERTICAL.dur_max +TARGET_CLIP_DURATION_MIN = _VERTICAL.target_min +TARGET_CLIP_DURATION_MAX = _VERTICAL.target_max DEFAULT_PRESET = { "caption_style": "branded", "crop_strategy": "face", + "format": "vertical", "time_adjust": -1.0, "logo_path": "", "outro_path": "", diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py index 90e2adc..f73076d 100644 --- a/backend/services/clip_generator.py +++ b/backend/services/clip_generator.py @@ -23,12 +23,13 @@ cut_segment, cut_multi_segment, crop_to_vertical, + fit_to_frame, burn_captions, normalize_audio, concat_outro, ) from config.caption_styles import get_style -from presets import MAX_CLIP_DURATION +from services.formats import get_format _FILLER_WORDS = frozenset([ "um", "uh", "uhh", "uhm", "umm", "hmm", "hm", "mhm", @@ -574,6 +575,7 @@ def generate_clip( end_second: float, caption_style: str = "hormozi", crop_strategy: str = "face", + format: str = "vertical", crop_keyframes: list[dict] = None, transcript_words: list[dict] = None, title: str = "clip", @@ -598,6 +600,7 @@ def generate_clip( end_second: Clip end time caption_style: "hormozi", "karaoke", "subtle", or "branded" crop_strategy: "center", "face", "speaker", or "speaker-hardcut" + format: "vertical", "horizontal", or "square" transcript_words: Word-level timestamps from transcription title: Clip title (used in filename) output_dir: Where to save the final clip (defaults to temp) @@ -627,6 +630,8 @@ def generate_clip( if end_second <= start_second: raise ValueError("end_second must be greater than start_second") + spec = get_format(format) + if trim_opening is None: trim_opening = not (keep_segments and len(keep_segments) > 0) @@ -708,8 +713,8 @@ def generate_clip( keep_segments = None duration = end_second - start_second - if duration > MAX_CLIP_DURATION: - raise ValueError(f"Clip too long ({duration:.0f}s). Max {MAX_CLIP_DURATION} seconds for shorts.") + if duration > spec.dur_max: + raise ValueError(f"Clip too long ({duration:.0f}s). Max {spec.dur_max} seconds for {spec.name}.") # Load style config for branded-specific settings style_config = get_style(caption_style) @@ -770,17 +775,21 @@ def generate_clip( # Step 2: Crop to vertical 9:16 if progress_callback: - progress_callback(30, f"Resizing for vertical format (2/{total_steps})") + progress_callback(30, f"Resizing for {spec.name} format (2/{total_steps})") cropped_path = os.path.join(work_dir, "cropped.mp4") - crop_to_vertical( - segment_path, cropped_path, - strategy=crop_strategy, - transcript_words=crop_words, - clip_start=crop_clip_start, - face_map=face_map, - crop_keyframes=crop_keyframes, - ) + if spec.reframe: + crop_to_vertical( + segment_path, cropped_path, + strategy=crop_strategy, + transcript_words=crop_words, + clip_start=crop_clip_start, + face_map=face_map, + crop_keyframes=crop_keyframes, + target_dims=spec.dims, + ) + else: + fit_to_frame(segment_path, cropped_path, target_dims=spec.dims) # Step 3: Render captions (Remotion-first; ASS fallback optional) if transcript_words: @@ -914,6 +923,7 @@ def generate_clip( "end_second": end_second, "caption_style": caption_style, "crop_strategy": crop_strategy, + "format": spec.name, } if keep_caption_overlay and caption_overlay_path and os.path.exists(caption_overlay_path): out["caption_overlay_path"] = caption_overlay_path diff --git a/backend/services/content_generator.py b/backend/services/content_generator.py index 6359998..881b959 100644 --- a/backend/services/content_generator.py +++ b/backend/services/content_generator.py @@ -183,6 +183,72 @@ def _stream_claude_content( return out +def generate_custom_content( + instruction: str, + transcript_segments: list[dict], + mode: str = "shorts", + progress_callback: Optional[Callable[[int, str], None]] = None, +) -> Optional[dict]: + """Run a free-form content request against the AI CLI with KB + transcript context. + + Returns {"text", "engine"} with the raw model output, or None if no AI CLI. + """ + candidates = _find_ai_cli_candidates() + if not candidates: + return None + + kb_context = load_kb_context() + lines = [] + for seg in transcript_segments: + sp = seg.get("speaker", "") + sp_label = f"[{sp}] " if sp else "" + lines.append(f"{sp_label}{seg.get('text', '').strip()}") + excerpt = chr(10).join(_sample_lines(lines, max_lines=40)) + + # REQUEST first so codex prompt truncation never drops the ask. + prompt = f"""You are helping create YouTube content for a podcast. Follow the knowledge base voice and rules. Return ONLY the requested output, no preamble. + +REQUEST ({'full episode' if mode == 'episode' else 'short clip'}): +{instruction.strip()} + +KNOWLEDGE BASE: +{kb_context} + +TRANSCRIPT EXCERPT: +{excerpt}""" + + project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") + from utils.prompt_files import write_prompt_file + prompt_file = write_prompt_file(prompt) + try: + for idx, (cli_path, engine) in enumerate(candidates): + label = _engine_label(engine) + if progress_callback: + progress_callback(30, f"Asking {label}..." if idx == 0 else f"Retrying with {label}...") + try: + cr = _run_ai_command( + cli_path=cli_path, + engine=engine, + prompt=prompt[:4000] if engine == "codex" else prompt, + prompt_file=prompt_file, + project_dir=project_dir, + timeout=120, + ) + except Exception: + continue + if cr.returncode != 0 or not cr.stdout.strip(): + continue + if progress_callback: + progress_callback(100, "Done") + return {"text": cr.stdout.strip(), "engine": engine} + return None + finally: + try: + os.unlink(prompt_file) + except Exception: + pass + + def generate_clip_content( clip: dict, transcript_segments: list[dict], diff --git a/backend/services/formats.py b/backend/services/formats.py new file mode 100644 index 0000000..b749e3c --- /dev/null +++ b/backend/services/formats.py @@ -0,0 +1,72 @@ +"""Output format specifications — the single source of truth for clip dimensions. + +Every aspect-ratio decision (crop target, caption geometry, duration bounds, +which scoring profile applies) derives from a FormatSpec so the render pipeline +is parameterized on format instead of hardcoding 1080x1920 per call site. +""" + +import sys +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FormatSpec: + name: str + width: int + height: int + reframe: bool + caption_profile: str + dur_min: int + dur_max: int + target_min: int + target_max: int + score_key: str + + @property + def dims(self) -> tuple[int, int]: + return (self.width, self.height) + + @property + def ratio(self) -> float: + return self.width / self.height + + +FORMATS = { + "vertical": FormatSpec( + name="vertical", + width=1080, height=1920, + reframe=True, + caption_profile="vertical", + dur_min=20, dur_max=45, + target_min=20, target_max=35, + score_key="vertical_score", + ), + "horizontal": FormatSpec( + name="horizontal", + width=1920, height=1080, + reframe=False, + caption_profile="lower_third", + dur_min=60, dur_max=300, + target_min=90, target_max=240, + score_key="horizontal_score", + ), + "square": FormatSpec( + name="square", + width=1080, height=1080, + reframe=True, + caption_profile="center", + dur_min=20, dur_max=45, + target_min=20, target_max=35, + score_key="vertical_score", + ), +} + +DEFAULT_FORMAT = "vertical" + + +def get_format(name: str | None) -> FormatSpec: + if name is not None and name not in FORMATS: + # Raw MCP/API callers bypass the CLI's choices= guard; warn so a typo'd + # format doesn't silently render as vertical. + print(f"[formats] unknown format {name!r}; using {DEFAULT_FORMAT}", file=sys.stderr) + return FORMATS.get(name or DEFAULT_FORMAT, FORMATS[DEFAULT_FORMAT]) diff --git a/backend/services/video_processor.py b/backend/services/video_processor.py index cbf00d7..8787898 100644 --- a/backend/services/video_processor.py +++ b/backend/services/video_processor.py @@ -83,6 +83,7 @@ def crop_to_vertical( clip_start: float = 0, face_map: dict = None, crop_keyframes: list = None, + target_dims: tuple = (1080, 1920), ) -> str: """ Crop/scale video to 1080x1920 (9:16 vertical). @@ -100,8 +101,8 @@ def crop_to_vertical( clip_start: The start time of this clip in the original video (for timestamp alignment). """ width, height = get_dimensions(input_path) - target_w, target_h = 1080, 1920 - target_ratio = target_w / target_h # 0.5625 + target_w, target_h = target_dims + target_ratio = target_w / target_h # 0.5625 for the vertical default source_ratio = width / height @@ -314,6 +315,35 @@ def crop_to_vertical( ) +def fit_to_frame( + input_path: str, + output_path: str, + target_dims: tuple = (1920, 1080), +) -> str: + """Scale to fit target_dims and letterbox with black bars, preserving the + whole frame. Used for non-reframe formats (e.g. 16:9) where cropping to a + subject is undesirable; collapses to a plain scale when the source already + matches the target aspect ratio. + """ + target_w, target_h = target_dims + log_event("crop", "chose=fit-letterbox", target=f"{target_w}x{target_h}") + vf = ( + f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease," + f"pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2:black,setsar=1" + ) + return _run_ffmpeg_with_fallback( + cmd_parts_before_enc=["ffmpeg", "-y", "-i", input_path, "-vf", vf], + cmd_parts_after_enc=[ + "-c:a", "aac", + "-b:a", "192k", + "-ar", "44100", + "-movflags", "+faststart", + ], + output_path=output_path, + label="fit_frame", + ) + + def _detect_split_screen(video_path: str, width: int, height: int) -> bool: """Quick check: is this a split-screen layout (two side-by-side cameras)?""" try: diff --git a/out/product-shots/podcli-fixed-flat-4card.png b/out/product-shots/podcli-fixed-flat-4card.png new file mode 100644 index 0000000..dbe9380 Binary files /dev/null and b/out/product-shots/podcli-fixed-flat-4card.png differ diff --git a/out/product-shots/podcli-fixed-flat-rounded.png b/out/product-shots/podcli-fixed-flat-rounded.png new file mode 100644 index 0000000..aa412cd Binary files /dev/null and b/out/product-shots/podcli-fixed-flat-rounded.png differ diff --git a/out/product-shots/podcli-hd-1920x1080.png b/out/product-shots/podcli-hd-1920x1080.png new file mode 100644 index 0000000..9b606d9 Binary files /dev/null and b/out/product-shots/podcli-hd-1920x1080.png differ diff --git a/out/product-shots/podcli-hd-2x.png b/out/product-shots/podcli-hd-2x.png new file mode 100644 index 0000000..cfbd465 Binary files /dev/null and b/out/product-shots/podcli-hd-2x.png differ diff --git a/out/product-shots/podcli-library-crop-shot.png b/out/product-shots/podcli-library-crop-shot.png new file mode 100644 index 0000000..04c9bb1 Binary files /dev/null and b/out/product-shots/podcli-library-crop-shot.png differ diff --git a/out/product-shots/podcli-library-fullpage.png b/out/product-shots/podcli-library-fullpage.png new file mode 100644 index 0000000..811266c Binary files /dev/null and b/out/product-shots/podcli-library-fullpage.png differ diff --git a/out/product-shots/podcli-ui-concepts.png b/out/product-shots/podcli-ui-concepts.png new file mode 100644 index 0000000..8b7f70c Binary files /dev/null and b/out/product-shots/podcli-ui-concepts.png differ diff --git a/out/product-shots/podcli-ui-full.png b/out/product-shots/podcli-ui-full.png new file mode 100644 index 0000000..f557b90 Binary files /dev/null and b/out/product-shots/podcli-ui-full.png differ diff --git a/out/product-shots/ui-avatar-crop.png b/out/product-shots/ui-avatar-crop.png new file mode 100644 index 0000000..6777924 Binary files /dev/null and b/out/product-shots/ui-avatar-crop.png differ diff --git a/out/product-shots/ui-avatar.png b/out/product-shots/ui-avatar.png new file mode 100644 index 0000000..a844a2c Binary files /dev/null and b/out/product-shots/ui-avatar.png differ diff --git a/out/product-shots/ui-minimal-crop.png b/out/product-shots/ui-minimal-crop.png new file mode 100644 index 0000000..1b90814 Binary files /dev/null and b/out/product-shots/ui-minimal-crop.png differ diff --git a/out/product-shots/ui-minimal.png b/out/product-shots/ui-minimal.png new file mode 100644 index 0000000..080c031 Binary files /dev/null and b/out/product-shots/ui-minimal.png differ diff --git a/out/product-shots/ui-silhouette-crop.png b/out/product-shots/ui-silhouette-crop.png new file mode 100644 index 0000000..e8dbf91 Binary files /dev/null and b/out/product-shots/ui-silhouette-crop.png differ diff --git a/out/product-shots/ui-silhouette.png b/out/product-shots/ui-silhouette.png new file mode 100644 index 0000000..8b1ee85 Binary files /dev/null and b/out/product-shots/ui-silhouette.png differ diff --git a/out/product-shots/ui-waveform-crop.png b/out/product-shots/ui-waveform-crop.png new file mode 100644 index 0000000..f5b1b9d Binary files /dev/null and b/out/product-shots/ui-waveform-crop.png differ diff --git a/out/product-shots/ui-waveform.png b/out/product-shots/ui-waveform.png new file mode 100644 index 0000000..239d451 Binary files /dev/null and b/out/product-shots/ui-waveform.png differ diff --git a/plans/horizontal-clips.md b/plans/horizontal-clips.md new file mode 100644 index 0000000..9cdacac --- /dev/null +++ b/plans/horizontal-clips.md @@ -0,0 +1,96 @@ +# podcli → Horizontal (16:9) clips + multi-format repurposing + +> Goal: render every detected moment to **more than one format** (vertical 9:16 today, horizontal 16:9 next, square later) so podcli becomes the one place to repurpose a podcast, not just a vertical-clip button. The AI half is the real product: horizontal needs a **different viral-moment profile** than vertical, not just a wider crop. + +## North star + +```text +one detected moment + ├─ 9:16 short → Shorts / Reels / TikTok (hook-first, 20-45s) ← today + └─ 16:9 clip → YouTube in-feed / X / LinkedIn (arc-first, 60-300s) ← this plan + ↓ both performance streams feed the YouTube learning loop + → the scorer learns which format wins per moment type +``` + +## The one decision everything hangs on + +**Format is a render-time property. A moment is format-neutral.** + +A moment is a scored time-range (`start`, `end`, `segments`, `speakers`, `content_type`) with no aspect ratio. It carries **per-format scores** (`vertical_score`, `horizontal_score`) because virality scoring differs by format, then renders to one or more formats. Keeping format at render time (not on the moment) is what makes "detect once, fan out to N formats" coherent and preserves the learning loop. Every other repurposing output (audiogram, quote card, show notes, X thread) is later "just another renderer off the same moment + transcript + brand KB." + +## Vertical vs horizontal are different products + +| | Vertical 9:16 (have) | Horizontal 16:9 (this plan) | +|---|---|---| +| Winning moment | one punchy line, hook in 3s | narrative arc, debate/tension, payoff | +| Length | 20-45s | 60-300s | +| Framing | active-speaker crop / split-screen | wide two-shot, reaction faces are an asset | +| Reframe cost | high (face tracking) | low (near-passthrough, `reframe=false`) | +| Platform | Shorts, Reels, TikTok | YouTube in-feed, X, LinkedIn desktop | + +So the work splits in two: **selection** (a second scoring profile + a dialogue-tension signal) and **framing** (which gets *simpler*, since 16:9-from-16:9 is scale/pad, not crop). + +--- + +## Verified coupling inventory + +An adversarial audit (7 parallel readers + 8 refutation passes + synthesis) found the real surface is **~31 distinct aspect-ratio coupling sites (~56 raw literals, ~66 edit points including format threading + duration caps)**, not the 8 originally mapped. The audit **refuted 4 design assumptions** — those corrections are baked into the phases below: + +1. **Captions are NOT relative.** `HormoziCaptions`/`SubtleCaptions`/`KaraokeCaptions` read only `fps` from `useVideoConfig()`; `BrandedCaptions` also reads `height` (only for a face-avoid nudge), never `width`. All font sizes/margins/logo box/side insets are absolute pixels in `remotion/src/types.ts` STYLES tuned for 1080x1920. Changing composition dims alone leaves captions ~1.8x oversized floating mid-frame. **Caption geometry is the biggest blind spot** and is net-new plumbing across 4 layers (Python cmd → `render.mjs` CLI flag → `inputProps` → `CaptionedClip` prop), plus the ASS fallback (`captions_burn.py`, `caption_renderer.py` PlayRes, `caption_styles.py` margins). +2. **The MCP "two schemas" was wrong.** The handler `inputSchema` objects (`create-clip.handler.ts:33`, `batch-clips.handler.ts`) are **dead code** (`server.tool()` uses only `.name`/`.description`). The real gates are (a) the inline Zod shape in `src/server.ts` (MCP, strips unknown keys), and (b) `src/ui/web-server.ts` — the **primary** path — which manually destructures `req.body` and builds the Python payload with an explicit allowlist (`styledClips` whitelist ~`2671`, `/api/create-clip`, `/api/batch-clips`). A `format` param must be added at ~10 hops or it silently reverts to vertical with no error. +3. **`fit_to_frame` is NOT redundant** with `crop_to_vertical`'s center branch. That branch blur-pillarboxes wider-than-target sources and crops top/bottom off narrower ones; only pixel-exact 16:9 passes through. Real-world near-16:9 inputs (1920x1088, 2.39:1, 4:3 inserts) are mishandled. Keep `fit_to_frame` as a distinct scale+pad/letterbox path. +4. **Render canvas dims are already dynamic.** `render.mjs:170-238` ffprobes the cropped clip and overrides Remotion composition width/height at `renderMedia` time, so a 1920x1080 source auto-produces a 1920x1080 overlay (DaVinci ProRes overlay too). `Root.tsx:38-39` literals govern only Remotion Studio preview. So horizontal render is *dimension*-correct for free; it is *caption-layout*-broken until profile work lands. + +### Two hard caps that block horizontal from functioning +- `backend/services/clip_generator.py:711` — `if duration > MAX_CLIP_DURATION: raise ValueError` (45s). A 60-300s clip crashes here. Must become `spec.dur_max`-aware. +- `src/ui/web-server.ts` — 180s HTTP duration cap on `/api/create-clip` and `/api/batch-clips`. Rejects horizontal at the boundary before Python. + +### Deferred / do-not-touch +- **Thumbnails** are a separate ~4-file / ~12-literal 9:16 track (`thumbnail_ai.py`, `thumbnail_generator.py`, `thumbnail_html.py`, `ThumbnailTemplate.tsx`). Not part of clip FormatSpec. Horizontal clips get portrait thumbnails until Phase 5. +- **Latent-safe at 9:16, only matters for a non-vertical *reframe* format (square):** `face_analysis.py:171` and the `crop_to_vertical:170` call into `_detect_local_speaker_reframe_plan` (which omits `target_ratio`, so `local_reframe.py:188` stays hardcoded 9/16). Fix only when square/reframe lands. +- **Native/dist:** `formats.py` auto-propagates into `cli/internal/backend/files/` (go:embed) via the sync step; contributors must run `go generate` before a native build. Any Phase-5 CSS variant must land in `src` + `dist/ui` + `dist/studio` copies. + +--- + +## Phases + +### Phase 1 — Parameterize rendering (SAFE, no behavior change) ✅ DONE + +Verified **byte-identical for the vertical default** (341/341 backend tests pass; smoke-checked that duration aliases hold 20/45/20/35, affected importers load, and `crop_to_vertical`'s `target_dims` default is `(1080,1920)`). Two guardrails were mandatory and applied: + +- **Guardrail A — keep duration constants as aliases.** `cli.py:70,398`, `clip_generator.py:31`, `claude_suggest.py:21`, and `DEFAULT_PRESET` itself import `MIN/MAX/TARGET_CLIP_DURATION` by name; deleting them ImportError-crashes all four. They now derive from `FORMATS["vertical"]` but keep the exact module-level names. +- **Guardrail B — change only the crop-path literal.** Only `video_processor.py:103` was touched. Thumbnails, caption PlayRes, `clip_studio.py`, `render.mjs` fallback, and `Root.tsx` were left alone — they resolve to vertical anyway and rewriting them adds risk with no behavior benefit. + +Edits shipped: +1. **NEW** `backend/services/formats.py` — `FormatSpec` (frozen dataclass) + `FORMATS` {vertical, horizontal, square} + `get_format()`. Imports nothing from `presets` (avoids cycle; `presets` imports from it). +2. `backend/presets.py` — duration constants now alias `FORMATS["vertical"].{dur_min,dur_max,target_min,target_max}`; added `"format": "vertical"` to `DEFAULT_PRESET` (inert; the `{**DEFAULT_PRESET, **saved}` merge back-fills old presets). +3. `backend/services/video_processor.py` — `crop_to_vertical(...)` gained `target_dims: tuple = (1080, 1920)`; line 103 is now `target_w, target_h = target_dims`. + +### Phase 2 — Render horizontal (still defaults vertical) ✅ DONE + +Verified end to end: a 1280x720 source rendered `format="horizontal"` → **1920x1080** via `chose=fit-letterbox` (face analysis skipped), and `format="vertical"` → **1080x1920** unchanged. 341 Python + 47 TS tests pass, `tsc` clean. + +- **Render fork** in `clip_generator.py` step 2: `spec.reframe ? crop_to_vertical(..., target_dims=spec.dims) : fit_to_frame(..., spec.dims)`. New `fit_to_frame` in `video_processor.py` = `scale=…:force_original_aspect_ratio=decrease` + centered `pad` + `setsar=1` (letterbox, collapses to plain scale for exact-ratio), skips all face analysis. `spec = get_format(format)` resolved once and reused for the dur cap, progress label, and fork. (Kept the `crop_to_vertical` name for now; rename deferred.) +- **Caps relaxed:** `clip_generator.py` uses `spec.dur_max` (vertical still 45); the two web-server 180s HTTP caps use a format-keyed ceiling (horizontal→300, else 180 — *not* `spec.dur_max`, which would tighten vertical). +- **`format` threaded end to end**, defaulting vertical at every hop: `generate_clip` signature + result dict; `main.py` create/batch; **6** `cli.py` `generate_clip` call sites + `--format` flag + `_selection_signature` cache key; `server.ts` create/batch Zod + primary clip literal + export-selected/clip-numbers maps + `findDuplicate`/`record`; `web-server.ts` create-clip destructure/validation/payload/history/recipe + MCP-export resolver + `styledClips` whitelist + batch per-clip cap + `createBatchHistoryRecorder`; `models/index.ts` types; `clips-history.ts` `findDuplicate` (backward-compatible, missing→vertical, so a horizontal re-clip of a vertical range is no longer a false duplicate). +- `Root.tsx`/`render.mjs` untouched (ffprobe already sizes the canvas). Output is dimension-correct; **captions still use vertical geometry** until Phase 3 (conscious deferral, not a silent bug). + +### Phase 3 — Correct captions per format (the real blind spot) +- Add a `caption_profile` carrying per-format geometry (fontSize, margins, logo box, side insets). Wire a `--format`/`--caption-profile` flag through `render.mjs` → `inputProps` → `CaptionedClipProps`, and add a `lower_third` profile to `types.ts` STYLES + the 4 Remotion caption components (make absolute pixels profile-driven). +- Mirror in the **ASS fallback** (default path, `allow_ass_fallback=True`): thread real dims into `captions_burn.py:27`, `caption_renderer.py` PlayRes (`17`,`501`), `caption_styles.py:73` margins; wire up the already-parameterized-but-dead `_calibrate_libass_y`. + +### Phase 4 — Iterate the AI for horizontal viral moments (the product) +- **Second scoring profile.** Keep `standalone/hook/relevance/quotability` as `vertical_score`; add `arc/tension/depth/payoff` as `horizontal_score` in `claude_suggest.py:_build_prompt`. New KB file `.podcli/knowledge/04b-longform-creation-guide.md` loaded alongside `04-shorts-creation-guide.md`. Moment schema (`suggest-clips.handler.ts`) gains both scores (keep `score` alias). Detect once, rank twice. +- **Dialogue-tension signal** (highest-leverage new AI): a function in `audio_analyzer.py` next to `compute_energy_scores` combining speaker-turn frequency (from diarization labels) + sustained energy → catches debate/back-and-forth that vertical spike-scoring misses. Feeds `horizontal_score`. +- Make the LLM duration window format-aware (`claude_suggest.py:241-311,433` currently bake 20-45s into the prompt). + +### Phase 5 — UI + learning loop + more renderers +- ContentStudio format selector + "render both"; 16:9 preview variants in `styles.css` (+ `dist` copies) and `EpisodeWorkspace.jsx:77` (`PROD_TO_PCT` hardcodes 1920). +- Feed both format performance streams into the YouTube learning loop ([studio-phase-1] roadmap) → learn format × moment-type winners → back into scoring. +- Horizontal thumbnails (16:9 / 1280x720) as a deliverable. Then the cheap renderers off the same spine: audiograms, quote cards (reuse `thumbnail_ai.py`), show notes / X threads (LLM over the transcript). + +--- + +## Sequencing guarantee + +Each phase is independently mergeable and leaves `main` green. Phase 1 already is (behavior-identical). Phases 2-3 ship behind the default-vertical flag (no existing user sees a change until they pick horizontal). Phase 4 is where the product value lands. diff --git a/remotion/src/components/BrandedCaptions.tsx b/remotion/src/components/BrandedCaptions.tsx index beeb939..75786df 100644 --- a/remotion/src/components/BrandedCaptions.tsx +++ b/remotion/src/components/BrandedCaptions.tsx @@ -7,6 +7,7 @@ import { staticFile, } from "remotion"; import type { Word, CaptionStyle } from "../types"; +import { captionScale } from "../types"; interface Props { words: Word[]; @@ -84,6 +85,8 @@ const WordWithPill: React.FC<{ frame: number; fps: number; }> = ({ word, isActive, frame, fps }) => { + const { height } = useVideoConfig(); + const s = captionScale(height); const wordEntryFrame = Math.round(word.start * fps); const pillOpacity = isActive @@ -100,12 +103,12 @@ const WordWithPill: React.FC<{ = ({ }) => { const frame = useCurrentFrame(); const { fps, height } = useVideoConfig(); + const s = captionScale(height); const currentTime = frame / fps; + const scaledStyle = { ...style, fontSize: style.fontSize * s }; const chunks = buildChunks(words, style.wordsPerChunk); const activeChunk = chunks.find( @@ -175,13 +180,14 @@ export const BrandedCaptions: React.FC = ({ // Dynamic margin: if face is in the lower portion, push captions further down // faceY is normalized 0-1 (0=top, 1=bottom) // Default margin is style.marginBottom. If face center is below 0.55, reduce margin. - let dynamicMargin = style.marginBottom; + const baseMargin = style.marginBottom * s; + let dynamicMargin = baseMargin; if (faceY != null && faceY > 0.55) { // Face is low — push captions to the very bottom - dynamicMargin = Math.max(80, style.marginBottom - Math.round((faceY - 0.55) * height * 0.6)); + dynamicMargin = Math.max(80 * s, baseMargin - Math.round((faceY - 0.55) * height * 0.6)); } else if (faceY != null && faceY < 0.35) { // Face is high — can bring captions up a bit - dynamicMargin = style.marginBottom + 60; + dynamicMargin = baseMargin + 60 * s; } return ( @@ -191,10 +197,10 @@ export const BrandedCaptions: React.FC = ({ src={logoSrc.startsWith("http") ? logoSrc : staticFile(logoSrc)} style={{ position: "absolute", - top: 180, - left: 108, - width: 255, - height: 126, + top: 180 * s, + left: 108 * s, + width: 255 * s, + height: 126 * s, objectFit: "contain", }} /> @@ -208,12 +214,12 @@ export const BrandedCaptions: React.FC = ({ style={{ position: "absolute", bottom: dynamicMargin, - left: 60, - right: 60, + left: 60 * s, + right: 60 * s, display: "flex", flexDirection: "column", alignItems: "center", - gap: 8, + gap: 8 * s, }} > = ({ currentTime={currentTime} frame={frame} fps={fps} - style={style} + style={scaledStyle} /> {line2.length > 0 && ( = ({ currentTime={currentTime} frame={frame} fps={fps} - style={style} + style={scaledStyle} /> )} diff --git a/remotion/src/components/HormoziCaptions.tsx b/remotion/src/components/HormoziCaptions.tsx index 725491b..7cb531c 100644 --- a/remotion/src/components/HormoziCaptions.tsx +++ b/remotion/src/components/HormoziCaptions.tsx @@ -6,6 +6,7 @@ import { spring, } from "remotion"; import type { Word, CaptionStyle } from "../types"; +import { captionScale } from "../types"; interface Props { words: Word[]; @@ -37,7 +38,8 @@ function buildChunks(words: Word[], perChunk: number): Chunk[] { export const HormoziCaptions: React.FC = ({ words, style }) => { const frame = useCurrentFrame(); - const { fps } = useVideoConfig(); + const { fps, height } = useVideoConfig(); + const s = captionScale(height); const currentTime = frame / fps; const chunks = buildChunks(words, style.wordsPerChunk); @@ -66,7 +68,7 @@ export const HormoziCaptions: React.FC = ({ words, style }) => {
= ({ words, style }) => {
= ({ words, style }) => { const frame = useCurrentFrame(); - const { fps } = useVideoConfig(); + const { fps, height } = useVideoConfig(); + const s = captionScale(height); const currentTime = frame / fps; const chunks = buildChunks(words, style.wordsPerChunk); @@ -98,23 +100,24 @@ export const KaraokeCaptions: React.FC = ({ words, style }) => { if (!activeChunk) return null; const [line1, line2] = splitIntoLines(activeChunk.words); + const scaledStyle = { ...style, fontSize: style.fontSize * s }; return (
- + {line2.length > 0 && ( - + )}
); diff --git a/remotion/src/components/SubtleCaptions.tsx b/remotion/src/components/SubtleCaptions.tsx index ddabaa9..ee35521 100644 --- a/remotion/src/components/SubtleCaptions.tsx +++ b/remotion/src/components/SubtleCaptions.tsx @@ -1,6 +1,7 @@ import React from "react"; import { useCurrentFrame, useVideoConfig, interpolate } from "remotion"; import type { Word, CaptionStyle } from "../types"; +import { captionScale } from "../types"; interface Props { words: Word[]; @@ -38,7 +39,8 @@ function splitIntoLines(words: Word[]): [Word[], Word[]] { export const SubtleCaptions: React.FC = ({ words, style }) => { const frame = useCurrentFrame(); - const { fps } = useVideoConfig(); + const { fps, height } = useVideoConfig(); + const s = captionScale(height); const currentTime = frame / fps; const chunks = buildChunks(words, style.wordsPerChunk); @@ -60,7 +62,7 @@ export const SubtleCaptions: React.FC = ({ words, style }) => { const translateY = interpolate( frame - entryFrame, [0, 6], - [8, 0], + [8 * s, 0], { extrapolateRight: "clamp" } ); @@ -72,13 +74,13 @@ export const SubtleCaptions: React.FC = ({ words, style }) => {
= ({ words, style }) => { height / REFERENCE_HEIGHT; + export const STYLES: Record = { hormozi: { name: "hormozi", diff --git a/src/handlers/batch-clips.handler.ts b/src/handlers/batch-clips.handler.ts index 7e4d29d..24ad80b 100644 --- a/src/handlers/batch-clips.handler.ts +++ b/src/handlers/batch-clips.handler.ts @@ -70,6 +70,10 @@ export const batchClipsToolDef = { type: "string", enum: ["center", "face", "speaker"], }, + format: { + type: "string", + enum: ["vertical", "horizontal", "square"], + }, allow_ass_fallback: { type: "boolean", }, @@ -149,6 +153,7 @@ export async function handleBatchClips(input: BatchClipsInput): Promise title: s.title || `clip_${num}`, caption_style: s.suggested_caption_style || settings.captionStyle || "hormozi", crop_strategy: settings.cropStrategy || "speaker", + format: input.format || settings.format || "vertical", allow_ass_fallback: input.allow_ass_fallback === true, keep_caption_overlay: input.keep_caption_overlay === true, logo_path: settings.logoPath || null, diff --git a/src/handlers/create-clip.handler.ts b/src/handlers/create-clip.handler.ts index 8920234..56bbd5f 100644 --- a/src/handlers/create-clip.handler.ts +++ b/src/handlers/create-clip.handler.ts @@ -66,6 +66,12 @@ export const createClipToolDef = { description: "How to crop to vertical. Auto-loaded from session settings if omitted.", }, + format: { + type: "string", + enum: ["vertical", "horizontal", "square"], + description: + "Output aspect ratio. vertical=9:16 shorts, horizontal=16:9, square=1:1. Auto-loaded from session settings if omitted. Default: vertical.", + }, transcript_words: { type: "array", description: @@ -148,6 +154,7 @@ export async function handleCreateClip(input: CreateClipInput): Promise settings.captionStyle || "hormozi"; const cropStrategy = input.crop_strategy || settings.cropStrategy || "speaker"; + const format = input.format || settings.format || "vertical"; const logoPath = input.logo_path || settings.logoPath || null; const outroPath = input.outro_path || settings.outroPath || null; const transcriptWords = input.transcript_words ?? transcript?.words ?? []; @@ -171,6 +178,7 @@ export async function handleCreateClip(input: CreateClipInput): Promise end_second: endSecond, caption_style: captionStyle, crop_strategy: cropStrategy, + format, transcript_words: transcriptWords, title, output_dir: paths.output, diff --git a/src/models/index.ts b/src/models/index.ts index 5b24e87..d275502 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,7 +2,7 @@ export interface TaskRequest { task_id: string; - task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status"; + task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "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"; params: Record; } @@ -71,6 +71,7 @@ export interface TranscriptResult { export type CaptionStyle = "branded" | "hormozi" | "karaoke" | "subtle"; export type CropStrategy = "center" | "face" | "speaker"; +export type Format = "vertical" | "horizontal" | "square"; export interface ClipRequest { video_path: string; @@ -86,6 +87,7 @@ export interface ClipResult { output_path: string; duration: number; file_size_mb: number; + format?: string; caption_overlay_path?: string; cropped_source_path?: string; } @@ -116,6 +118,7 @@ export interface UIState { settings?: { captionStyle?: string; cropStrategy?: string; + format?: string; logoPath?: string; outroPath?: string; }; @@ -131,6 +134,7 @@ export interface CreateClipInput { title?: string; caption_style?: string; crop_strategy?: string; + format?: string; logo_path?: string; outro_path?: string; transcript_words?: WordTimestamp[]; @@ -145,6 +149,7 @@ export interface BatchClipSpec { title?: string; caption_style?: string; crop_strategy?: string; + format?: string; logo_path?: string | null; allow_ass_fallback?: boolean; keep_caption_overlay?: boolean; @@ -157,6 +162,7 @@ export interface BatchClipsInput { clip_numbers?: number[]; clips?: BatchClipSpec[]; export_selected?: boolean; + format?: string; clean_fillers?: boolean; allow_ass_fallback?: boolean; keep_caption_overlay?: boolean; @@ -179,6 +185,7 @@ export interface BatchClipsResult { end_second?: number; caption_style?: string; crop_strategy?: string; + format?: string; title?: string; file_size_mb?: number; duration?: number; @@ -227,6 +234,7 @@ export interface ClipHistoryEntry { end_second: number; caption_style: string; crop_strategy: string; + format?: string; logo_path?: string; outro_path?: string; title: string; diff --git a/src/server.ts b/src/server.ts index 469ef04..2f8319f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -461,6 +461,11 @@ export function createServer(): McpServer { .optional() .default("speaker") .describe("Cropping strategy"), + format: z + .enum(["vertical", "horizontal", "square"]) + .optional() + .default("vertical") + .describe("Output aspect ratio (vertical=9:16, horizontal=16:9, square=1:1)"), allow_ass_fallback: z .boolean() .optional() @@ -565,6 +570,7 @@ export function createServer(): McpServer { params.end_second as number, (params.caption_style || "hormozi") as string, (params.crop_strategy || "speaker") as string, + (params.format || "vertical") as string, ); if (dup) { return { @@ -593,6 +599,7 @@ export function createServer(): McpServer { title: (params.title || "clip") as string, caption_style: params.caption_style || "hormozi", crop_strategy: params.crop_strategy || "speaker", + format: params.format || "vertical", allow_ass_fallback: params.allow_ass_fallback === true, keep_caption_overlay: params.keep_caption_overlay === true, ...(keepSegments && { segments: keepSegments }), @@ -655,6 +662,7 @@ export function createServer(): McpServer { end_second: params.end_second as number, caption_style: (params.caption_style || "hormozi") as string, crop_strategy: (params.crop_strategy || "speaker") as string, + format: (params.format || "vertical") as string, logo_path: params.logo_path as string | undefined, title: (params.title || "clip") as string, output_path: parsed.output_path, @@ -705,6 +713,7 @@ export function createServer(): McpServer { .enum(["hormozi", "karaoke", "subtle", "branded"]) .optional(), crop_strategy: z.enum(["center", "face", "speaker"]).optional(), + format: z.enum(["vertical", "horizontal", "square"]).optional(), allow_ass_fallback: z.boolean().optional(), keep_caption_overlay: z.boolean().optional(), }), @@ -783,6 +792,7 @@ export function createServer(): McpServer { settings.captionStyle || "hormozi", crop_strategy: settings.cropStrategy || "speaker", + format: settings.format || "vertical", allow_ass_fallback: false, ...(s.segments && s.segments.length > 0 && { keep_segments: s.segments }), @@ -801,6 +811,7 @@ export function createServer(): McpServer { settings.captionStyle || "hormozi", crop_strategy: settings.cropStrategy || "speaker", + format: settings.format || "vertical", allow_ass_fallback: false, ...(s.segments && s.segments.length > 0 && { keep_segments: s.segments }), diff --git a/src/services/clips-history.ts b/src/services/clips-history.ts index 33c58e5..5624b56 100644 --- a/src/services/clips-history.ts +++ b/src/services/clips-history.ts @@ -13,6 +13,7 @@ interface BatchRecordContext { transcriptWords?: WordTimestamp[] | null; defaultCaptionStyle?: string; defaultCropStrategy?: string; + defaultFormat?: string; contentTypeFor?: (start: number, end: number) => string | undefined; } @@ -98,6 +99,7 @@ export class ClipsHistory { end_second: end, caption_style: r.caption_style || ctx.defaultCaptionStyle || "hormozi", crop_strategy: r.crop_strategy || ctx.defaultCropStrategy || "speaker", + format: r.format || ctx.defaultFormat || "vertical", title: r.title || "clip", output_path: r.output_path, file_size_mb: r.file_size_mb || 0, @@ -119,7 +121,8 @@ export class ClipsHistory { startSecond: number, endSecond: number, captionStyle: string, - cropStrategy: string + cropStrategy: string, + format: string = "vertical" ): Promise { const entries = await this.load(); const srcName = basename(sourceVideo); @@ -129,6 +132,7 @@ export class ClipsHistory { if (basename(e.source_video) !== srcName) return false; if (e.caption_style !== captionStyle) return false; if (e.crop_strategy !== cropStrategy) return false; + if ((e.format || "vertical") !== format) return false; if (Math.abs(e.start_second - startSecond) > 2) return false; if (Math.abs(e.end_second - endSecond) > 2) return false; // Check output still exists diff --git a/src/ui/client/AnalyticsPage.tsx b/src/ui/client/AnalyticsPage.tsx index c63dc55..1ca8efb 100644 --- a/src/ui/client/AnalyticsPage.tsx +++ b/src/ui/client/AnalyticsPage.tsx @@ -142,11 +142,11 @@ export default function AnalyticsPage() {
- setClientId(e.target.value)} style={{ width: "100%", fontSize: 13, padding: "8px 11px" }} /> + setClientId(e.target.value)} style={{ width: "100%" }} />
- setClientSecret(e.target.value)} placeholder={hasSecret ? "••••••••" : ""} style={{ width: "100%", fontSize: 13, padding: "8px 11px" }} /> + setClientSecret(e.target.value)} placeholder={hasSecret ? "••••••••" : ""} style={{ width: "100%" }} />
@@ -201,7 +201,7 @@ export default function AnalyticsPage() {
{c.title}
-
{fmt(c.duration)} · {c.caption_style}{c.content_type ? ` · ${c.content_type}` : ""}
+
{fmt(c.duration)} · {c.caption_style}{c.content_type ? ` · ${c.content_type}` : ""}
{fmtViews(c.metrics?.views || 0)} views diff --git a/src/ui/client/ClipDetail.tsx b/src/ui/client/ClipDetail.tsx index 470f442..6dd5474 100644 --- a/src/ui/client/ClipDetail.tsx +++ b/src/ui/client/ClipDetail.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Link, useParams, useNavigate } from "react-router-dom"; import { api, upload, fmt, basename, labelStyle } from "./lib"; import ClipPlayer from "./ClipPlayer"; +import { BackIcon } from "./icons"; import ReframeEditor from "./ReframeEditor"; import CopyButton from "./CopyButton"; @@ -53,6 +54,7 @@ export default function ClipDetail() { const [line2, setLine2] = useState(""); const [textOpts, setTextOpts] = useState<[string, string][]>([]); const [frameOpts, setFrameOpts] = useState([]); + const [frameIdx, setFrameIdx] = useState(0); const [selFrame, setSelFrame] = useState<{ path: string; info?: any } | null>(null); const [busy, setBusy] = useState(null); const [msg, setMsg] = useState(null); @@ -83,7 +85,7 @@ export default function ClipDetail() { }, []); if (loading) return
Loading…
; - if (!clip) return

Clip not found

← Library
; + if (!clip) return

Clip not found

Library
; const tc = clip.thumbnail_config || {}; const dirty = title !== clip.title || captionStyle !== clip.caption_style; @@ -136,6 +138,35 @@ export default function ClipDetail() { } catch (e: any) { setMsg(`Generate failed: ${e.message}`); } finally { setBusy(null); } }; + // Pull a different frame from the clip (cycles the ranked candidates) and + // re-render — same one-click flow as Regenerate, but swaps the background frame. + const newFrame = async () => { + setBusy("newframe"); setMsg(null); + try { + let frames = frameOpts; + if (!frames.length) { + const r = await api(`/clips/${clip.id}/thumbnail/options?texts=6&frames=8`); + if (r.error) throw new Error(r.error); + frames = r.frames || []; + setFrameOpts(frames); + if ((r.texts || []).length) setTextOpts(r.texts); + } + if (!frames.length) { setMsg("No frames found in this clip"); return; } + const curIdx = frames.findIndex((f: any) => f.path === selFrame?.path); + const next = ((curIdx < 0 ? frameIdx : curIdx) + 1) % frames.length; + setFrameIdx(next); + const frame = frames[next]; + setSelFrame({ path: frame.path, info: frame }); + const r = await api(`/clips/${clip.id}/thumbnail/render`, { + method: "POST", + body: JSON.stringify({ line1: line1 || undefined, line2: line2 || undefined, frame_path: frame.path, frame_info: frame }), + }); + if (r.error) throw new Error(r.error); + setBust(Date.now()); load(); + setMsg(`New frame from clip (${next + 1}/${frames.length})`); + } catch (e: any) { setMsg(`New frame failed: ${e.message}`); } finally { setBusy(null); } + }; + const reopen = async () => { setBusy("reopen"); setMsg(null); try { @@ -186,7 +217,7 @@ export default function ClipDetail() { return (
- ← Library + Library

{clip.title}

@@ -208,7 +239,7 @@ export default function ClipDetail() { {clip.content_type && {clip.content_type}} {clip.file_size_mb != null && {clip.file_size_mb.toFixed(1)}MB}
-
{basename(clip.source_video)}
+
{basename(clip.source_video)}
@@ -216,7 +247,7 @@ export default function ClipDetail() {
- setTitle(e.target.value)} style={{ width: "100%", fontSize: 14, padding: "10px 13px" }} /> + setTitle(e.target.value)} style={{ width: "100%" }} />
setLine1(e.target.value)} placeholder="Line 1" style={{ width: "100%", fontSize: 14, padding: "9px 12px" }} /> - setLine2(e.target.value)} placeholder="Line 2 (highlighted)" style={{ width: "100%", fontSize: 14, padding: "9px 12px", marginTop: 8 }} /> + setLine1(e.target.value)} placeholder="Line 1" style={{ width: "100%" }} /> + setLine2(e.target.value)} placeholder="Line 2 (highlighted)" style={{ width: "100%", marginTop: 8 }} />
+ e.target.files?.[0] && uploadFrame(e.target.files[0])} />
-
Leave line 1 and line 2 empty to auto-write the text.
+
Leave line 1 and line 2 empty to auto-write the text.
{textOpts.length > 0 && (
-
Text options · click to use
+
Text options · click to use
{textOpts.map(([l1, l2], i) => (
-