From 0eaad64f0091e1f69a41dabb27a5b87c2b3d90aa Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 4 Jul 2026 00:17:27 +0400 Subject: [PATCH 01/11] Add horizontal (16:9) and square clip formats Introduce a FormatSpec single source of truth (backend/services/formats.py) and thread an output format through the render pipeline so a detected moment can render vertical (9:16), horizontal (16:9), or square (1:1). Everything defaults to vertical, so existing behavior is unchanged. - Parameterize crop_to_vertical with target_dims; duration constants become aliases of the vertical FormatSpec (kept as module names for importers). - Fork the render on spec.reframe: reframe formats keep face-tracked cropping; horizontal uses a new fit_to_frame scale+pad letterbox that skips face analysis. Duration cap uses spec.dur_max; web HTTP caps are format-keyed. - Thread format across the MCP tools, web server, CLI (--format), and clip history (dedup treats a missing format as vertical, so a horizontal re-clip of a vertical range is not a false duplicate). Captions still use vertical geometry on horizontal clips; per-format caption profiles are a follow-up. --- backend/cli.py | 9 +++ backend/main.py | 2 + backend/presets.py | 16 +++-- backend/services/clip_generator.py | 34 ++++++---- backend/services/formats.py | 67 ++++++++++++++++++++ backend/services/video_processor.py | 34 +++++++++- plans/horizontal-clips.md | 96 +++++++++++++++++++++++++++++ src/handlers/batch-clips.handler.ts | 5 ++ src/handlers/create-clip.handler.ts | 8 +++ src/models/index.ts | 8 +++ src/server.ts | 11 ++++ src/services/clips-history.ts | 6 +- src/ui/web-server.ts | 34 ++++++++-- 13 files changed, 304 insertions(+), 26 deletions(-) create mode 100644 backend/services/formats.py create mode 100644 plans/horizontal-clips.md diff --git a/backend/cli.py b/backend/cli.py index 2ec78b1..8cc793c 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 args.top: config["top_clips"] = args.top if getattr(args, "review_each", False): @@ -796,6 +799,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, @@ -1008,6 +1012,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, @@ -1331,6 +1336,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, @@ -1515,6 +1521,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, @@ -1569,6 +1576,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, @@ -3271,6 +3279,7 @@ def main(): proc.add_argument("--fast", action="store_true", help="Draft mode: tiny Whisper, heuristic selection, center crop, low quality") 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 d07e803..5a94001 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"), 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/formats.py b/backend/services/formats.py new file mode 100644 index 0000000..89aba58 --- /dev/null +++ b/backend/services/formats.py @@ -0,0 +1,67 @@ +"""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. +""" + +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: + 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/plans/horizontal-clips.md b/plans/horizontal-clips.md new file mode 100644 index 0000000..bc43e5e --- /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 + +``` +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/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 79831c1..91dc943 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -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 02a8bfe..e737fc0 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/web-server.ts b/src/ui/web-server.ts index 0c97c70..f60c92b 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -110,6 +110,7 @@ interface UIState { settings: { captionStyle: string; cropStrategy: string; + format: string; logoPath: string; outroPath: string; }; @@ -140,6 +141,7 @@ function loadPersistedState(): UIState { settings: { captionStyle: saved.settings?.captionStyle || "branded", cropStrategy: saved.settings?.cropStrategy || "speaker", + format: saved.settings?.format || "vertical", logoPath: saved.settings?.logoPath || "", outroPath: saved.settings?.outroPath || "", }, @@ -166,6 +168,7 @@ function loadPersistedState(): UIState { settings: { captionStyle: "branded", cropStrategy: "speaker", + format: "vertical", logoPath: "", outroPath: "", }, @@ -263,6 +266,7 @@ function createBatchHistoryRecorder({ transcriptWords, defaultCaptionStyle, defaultCropStrategy, + defaultFormat, label, }: { jobId: string; @@ -270,6 +274,7 @@ function createBatchHistoryRecorder({ transcriptWords: WordTimestamp[]; defaultCaptionStyle?: string; defaultCropStrategy?: string; + defaultFormat?: string; label: string; }) { const recordedClipIndexes = new Set(); @@ -281,6 +286,7 @@ function createBatchHistoryRecorder({ transcriptWords, defaultCaptionStyle, defaultCropStrategy, + defaultFormat, contentTypeFor: (s, e) => findContentType(uiState.suggestions, s, e), }); for (const row of rows) { @@ -635,6 +641,7 @@ app.post("/api/create-clip", async (req, res) => { end_second, caption_style = "hormozi", crop_strategy = "speaker", + format = "vertical", transcript_words = [], title = "clip", logo_path = null, @@ -663,9 +670,10 @@ app.post("/api/create-clip", async (req, res) => { return; } const duration = end_second - start_second; - if (duration > 180) { + const maxDur = format === "horizontal" ? 300 : 180; + if (duration > maxDur) { res.status(400).json({ - error: `Clip too long (${Math.round(duration)}s). Max 180 seconds.`, + error: `Clip too long (${Math.round(duration)}s). Max ${maxDur} seconds.`, }); return; } @@ -691,6 +699,13 @@ app.post("/api/create-clip", async (req, res) => { .json({ error: `Invalid crop strategy. Use: ${validCrops.join(", ")}` }); return; } + const validFormats = ["vertical", "horizontal", "square"]; + if (!validFormats.includes(format)) { + res + .status(400) + .json({ error: `Invalid format. Use: ${validFormats.join(", ")}` }); + return; + } await fileManager.ensureDirectories(); @@ -716,6 +731,7 @@ app.post("/api/create-clip", async (req, res) => { end_second, caption_style, crop_strategy, + format, transcript_words, title, output_dir: paths.output, @@ -743,6 +759,7 @@ app.post("/api/create-clip", async (req, res) => { end_second, caption_style, crop_strategy, + format, logo_path: logo_path || undefined, outro_path: outro_path || undefined, title, @@ -755,7 +772,7 @@ app.post("/api/create-clip", async (req, res) => { const clipWords = sliceWords(transcript_words, start_second, end_second); await clipsHistory.saveWords(rec.id, clipWords); await clipsHistory.saveRecipe(rec.id, { - caption_style, crop_strategy, logo_path: logo_path || null, outro_path: outro_path || null, + caption_style, crop_strategy, format, logo_path: logo_path || null, outro_path: outro_path || null, clean_fillers, transcript_words: clipWords, }); broadcastHistoryUpdated(jobId, [rec]); @@ -805,9 +822,10 @@ app.post("/api/batch-clips", async (req, res) => { res.status(400).json({ error: `Clip ${i + 1}: end must be after start` }); return; } - if (dur > 180) { + const maxDur = c.format === "horizontal" ? 300 : 180; + if (dur > maxDur) { res.status(400).json({ - error: `Clip ${i + 1}: too long (${Math.round(dur)}s). Max 180s.`, + error: `Clip ${i + 1}: too long (${Math.round(dur)}s). Max ${maxDur}s.`, }); return; } @@ -2605,6 +2623,8 @@ app.post("/api/ui-state", (req, res) => { uiState.settings.captionStyle = body.settings.captionStyle; if (body.settings.cropStrategy !== undefined) uiState.settings.cropStrategy = body.settings.cropStrategy; + if (body.settings.format !== undefined) + uiState.settings.format = body.settings.format; if (body.settings.logoPath !== undefined) uiState.settings.logoPath = body.settings.logoPath; if (body.settings.outroPath !== undefined) @@ -2654,6 +2674,8 @@ app.post("/api/mcp/export", async (req, res) => { req.body.caption_style || uiState.settings.captionStyle || "branded"; const cropStrategy = req.body.crop_strategy || uiState.settings.cropStrategy || "speaker"; + const format = + req.body.format || uiState.settings.format || "vertical"; const allowAssFallback = req.body.allow_ass_fallback === true; if (!videoPath || !existsSync(videoPath)) { @@ -2674,6 +2696,7 @@ app.post("/api/mcp/export", async (req, res) => { title: (c.title || "clip").slice(0, 40), caption_style: c.caption_style || captionStyle, crop_strategy: c.crop_strategy || cropStrategy, + format: c.format || format, allow_ass_fallback: c.allow_ass_fallback === true || allowAssFallback, // Preserve multi-cut segments from suggestions ...(Array.isArray(c.segments) && @@ -2697,6 +2720,7 @@ app.post("/api/mcp/export", async (req, res) => { transcriptWords, defaultCaptionStyle: captionStyle, defaultCropStrategy: cropStrategy, + defaultFormat: format, label: "MCP export", }); From 16a7bab2b9b09c340388145d2f11a411629474a3 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 4 Jul 2026 00:34:03 +0400 Subject: [PATCH 02/11] Scale captions per format and add studio format selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caption geometry (font sizes, margins, logo box, insets) was authored for a 1920-tall vertical canvas, so on a 1080-tall horizontal/square frame captions rendered oversized and floated mid-frame. Multiply all geometry by captionScale = height / 1920 in the four caption components; vertical (height 1920 → factor 1.0) is unchanged, horizontal/square get a proportional lower-third. Add a Format selector (Vertical 9:16 / Horizontal 16:9 / Square 1:1) to the studio workspace next to caption style and crop. Format persists via settings sync and rides on each clip in the export payloads, so the backend renders the chosen aspect ratio. --- remotion/src/components/BrandedCaptions.tsx | 40 ++++++++++++--------- remotion/src/components/HormoziCaptions.tsx | 12 ++++--- remotion/src/components/KaraokeCaptions.tsx | 17 +++++---- remotion/src/components/SubtleCaptions.tsx | 16 +++++---- remotion/src/types.ts | 7 ++++ src/ui/client/EpisodeWorkspace.jsx | 20 ++++++++--- 6 files changed, 71 insertions(+), 41 deletions(-) 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/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx index 8e26116..8874fb3 100644 --- a/src/ui/client/EpisodeWorkspace.jsx +++ b/src/ui/client/EpisodeWorkspace.jsx @@ -498,6 +498,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( const [whisperModel, setWhisperModel] = useState('base'); const [captionStyle, setCaptionStyle] = useState('branded'); const [cropStrategy, setCropStrategy] = useState('face'); + const [format, setFormat] = useState('vertical'); const [showTikTokFrame, setShowTikTokFrame] = useState(false); const [logoPath, setLogoPath] = useState(''); const logoRef = useRef(); @@ -585,6 +586,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( const d = response.config || response; if (d.caption_style) setCaptionStyle(d.caption_style); if (d.crop_strategy) setCropStrategy(d.crop_strategy); + if (d.format) setFormat(d.format); if (d.logo_path !== undefined) setLogoPath(d.logo_path || ''); if (d.outro_path !== undefined) setOutroPath(d.outro_path || ''); if (d.video_path !== undefined) { @@ -762,14 +764,14 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( filePath: file?.file_path || '', suggestions, deselectedIndices: Array.from(deselected), - settings: { captionStyle, cropStrategy, logoPath, outroPath }, + settings: { captionStyle, cropStrategy, format, logoPath, outroPath }, phase, }; const key = JSON.stringify(state); if (key === prevSyncRef.current) return; prevSyncRef.current = key; fetch('/api/ui-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: key }).catch(() => { }); - }, [videoPath, file, suggestions, deselected, captionStyle, cropStrategy, logoPath, outroPath, phase]); + }, [videoPath, file, suggestions, deselected, captionStyle, cropStrategy, format, logoPath, outroPath, phase]); // Sync transcript separately (large payload) const prevTranscriptRef = useRef(null); @@ -810,6 +812,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( if (d.settings) { if (d.settings.captionStyle) setCaptionStyle(d.settings.captionStyle); if (d.settings.cropStrategy) setCropStrategy(d.settings.cropStrategy); + if (d.settings.format) setFormat(d.settings.format); if (d.settings.logoPath !== undefined) setLogoPath(d.settings.logoPath); if (d.settings.outroPath !== undefined) setOutroPath(d.settings.outroPath); } @@ -871,6 +874,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( const onCaptionChange = (v) => { setCaptionStyle(v); flashSetting('caption'); }; const onCropChange = (v) => { setCropStrategy(v); flashSetting('crop'); }; + const onFormatChange = (v) => { setFormat(v); flashSetting('format'); }; // Click clip row → seek source video const onClipClick = (idx) => { @@ -922,7 +926,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( const data = await api('/batch-clips', { method: 'POST', body: JSON.stringify({ video_path: vp, - clips: sc.map(c => ({ start_second: c.start_second, end_second: c.end_second, title: c.title.slice(0, 40), caption_style: captionStyle, crop_strategy: cropStrategy })), + clips: sc.map(c => ({ start_second: c.start_second, end_second: c.end_second, title: c.title.slice(0, 40), caption_style: captionStyle, crop_strategy: cropStrategy, format })), transcript_words: transcript?.words || [], logo_path: logoPath || undefined, outro_path: outroPath || undefined, clean_fillers: cleanFillers || undefined, }) }); @@ -941,7 +945,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( const data = await api('/create-clip', { method: 'POST', body: JSON.stringify({ video_path: vp, start_second: c.start_second, end_second: c.end_second, - title: c.title.slice(0, 40), caption_style: captionStyle, crop_strategy: cropStrategy, + title: c.title.slice(0, 40), caption_style: captionStyle, crop_strategy: cropStrategy, format, transcript_words: transcript?.words || [], logo_path: logoPath || undefined, outro_path: outroPath || undefined, clean_fillers: cleanFillers || undefined, }) }); @@ -1031,7 +1035,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( _source: 'ui', videoPath: videoPath.trim(), rawTranscriptText: transcriptText.trim() || undefined, - settings: { captionStyle, cropStrategy, logoPath, outroPath }, + settings: { captionStyle, cropStrategy, format, logoPath, outroPath }, }), }).catch(() => { }); } @@ -1340,6 +1344,12 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
+
+ + +
{captionStyle === 'branded' && ( From b204a442d93106147cec5644dbc49e2f81b208fa Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 4 Jul 2026 01:01:05 +0400 Subject: [PATCH 03/11] Refine studio palette, unify button system, add "New frame" thumbnail action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Palette: replace the muddy warm-brown surfaces and desaturated tan accent with cleaner, higher-contrast values and a crisp orange accent, keeping the brand warmth. - Buttons: make the clips toolbar consistent — the Energy button joins the raised ghost family instead of a bespoke outlined style, the overflow button matches the row height, and the raised-edge depth is aligned to 4px across primary/ghost/danger. - Thumbnails: add a "New frame" button in the clip thumbnail editor that pulls a different ranked frame from the clip and re-renders, one click like Regenerate but swapping the background frame. --- src/ui/client/ClipDetail.tsx | 32 +++++++++++++++++ src/ui/client/EpisodeWorkspace.jsx | 4 +-- src/ui/public/css/styles.css | 58 +++++++++++++++--------------- 3 files changed, 63 insertions(+), 31 deletions(-) diff --git a/src/ui/client/ClipDetail.tsx b/src/ui/client/ClipDetail.tsx index 470f442..c5b38d5 100644 --- a/src/ui/client/ClipDetail.tsx +++ b/src/ui/client/ClipDetail.tsx @@ -53,6 +53,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); @@ -136,6 +137,34 @@ 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 next = (frameIdx + 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 { @@ -260,6 +289,9 @@ export default function ClipDetail() { + diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx index 8874fb3..0233bb3 100644 --- a/src/ui/client/EpisodeWorkspace.jsx +++ b/src/ui/client/EpisodeWorkspace.jsx @@ -1557,7 +1557,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
{phase === 'review' && videoPath && ( - )} @@ -1568,7 +1568,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( )} {transcript && (phase === 'review' || phase === 'done') && (
-
diff --git a/src/ui/public/css/styles.css b/src/ui/public/css/styles.css index 045c988..01374e2 100644 --- a/src/ui/public/css/styles.css +++ b/src/ui/public/css/styles.css @@ -1,37 +1,37 @@ /* ─── podcli — Shared Styles ─── */ :root { - --bg: #141210; - --bg2: #191613; - --surface: #1e1b17; - --surface2: #26221d; - --surface3: #2f2a24; - --border: #2b2620; - --border-hover: #3d362d; - --border-h: #3d362d; - --text: #e8e6df; - --text2: #9a9388; - --text3: #8b8579; - --accent: #db8b48; - --accent-hover: #ec9d5b; - --accent-2: #f1b369; - --accent-edge: #9c5a28; - --accent-subtle: rgba(219, 139, 72, 0.12); - --accent-dim: rgba(219, 139, 72, 0.12); - --accent-glow: rgba(219, 139, 72, 0.32); + --bg: #0f0e0d; + --bg2: #16150f; + --surface: #1b1a17; + --surface2: #24221d; + --surface3: #302d27; + --border: #2c2822; + --border-hover: #403a31; + --border-h: #403a31; + --text: #f4f2ee; + --text2: #ada79c; + --text3: #7d766a; + --accent: #f2894a; + --accent-hover: #ff9f61; + --accent-2: #ffb37c; + --accent-edge: #b3612a; + --accent-subtle: rgba(242, 137, 74, 0.14); + --accent-dim: rgba(242, 137, 74, 0.10); + --accent-glow: rgba(242, 137, 74, 0.30); --green: #4ade80; - --green-subtle: rgba(74, 222, 128, 0.07); - --green-border: rgba(74, 222, 128, 0.18); + --green-subtle: rgba(74, 222, 128, 0.09); + --green-border: rgba(74, 222, 128, 0.22); --red: #f87171; - --red-subtle: rgba(248, 113, 113, 0.07); - --red-border: rgba(248, 113, 113, 0.18); + --red-subtle: rgba(248, 113, 113, 0.09); + --red-border: rgba(248, 113, 113, 0.22); --blue: #60a5fa; - --blue-subtle: rgba(96, 165, 250, 0.08); - --blue-border: rgba(96, 165, 250, 0.18); + --blue-subtle: rgba(96, 165, 250, 0.10); + --blue-border: rgba(96, 165, 250, 0.22); --yellow: #facc15; - --glass-bg: rgba(26, 23, 20, 0.55); - --glass-border: rgba(255, 255, 255, 0.055); - --glass-hi: rgba(255, 255, 255, 0.05); + --glass-bg: rgba(22, 21, 15, 0.6); + --glass-border: rgba(255, 255, 255, 0.07); + --glass-hi: rgba(255, 255, 255, 0.06); --radius: 12px; --radius-sm: 8px; --radius-lg: 16px; @@ -450,7 +450,7 @@ select { .btn-primary:disabled { opacity: 0.25; cursor: not-allowed; } .btn-ghost { background: var(--surface3); color: var(--text); - box-shadow: 0 5px #1a1a1f, 0 11px 18px -5px #000000d9, + box-shadow: 0 4px #1a1a1f, 0 11px 18px -5px #000000d9, 0 0 0 1px #ffffff14, inset 0 1px #ffffff24; } .btn-ghost:hover:not(:disabled) { background: var(--surface3); } @@ -458,7 +458,7 @@ select { .btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; } .btn-danger { background: var(--surface3); color: var(--text2); - box-shadow: 0 5px #1a1a1f, 0 11px 18px -5px #000000d9, + box-shadow: 0 4px #1a1a1f, 0 11px 18px -5px #000000d9, 0 0 0 1px #ffffff14, inset 0 1px #ffffff24; } .btn-danger:hover:not(:disabled) { color: var(--red); box-shadow: 0 5px #1a1a1f, 0 11px 18px -5px #000000d9, 0 0 0 1px var(--red-border), inset 0 1px #ffffff24; } From 1172122da73bb9a7a087dfb3f9c23a88b9deacec Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 4 Jul 2026 01:03:11 +0400 Subject: [PATCH 04/11] Add "New frame" action to the standalone Thumbnail studio page --- src/ui/client/ThumbnailStudio.tsx | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/ui/client/ThumbnailStudio.tsx b/src/ui/client/ThumbnailStudio.tsx index 720c8f3..4f320ba 100644 --- a/src/ui/client/ThumbnailStudio.tsx +++ b/src/ui/client/ThumbnailStudio.tsx @@ -15,6 +15,7 @@ export default function ThumbnailStudio() { 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 [preview, setPreview] = useState(null); const [busy, setBusy] = useState(null); @@ -95,6 +96,43 @@ export default function ThumbnailStudio() { } catch (e: any) { setMsg(`Generate failed: ${e.message}`); } finally { setBusy(null); } }; + // Pull a different frame from the source (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) { + if (!title.trim()) { setMsg("Enter a title first, headlines are written from it"); return; } + const r = await api("/thumbnail-studio/options", { + method: "POST", + body: JSON.stringify({ + title, + video_path: video?.path, + start: startS !== "" ? Number(startS) : undefined, + end: endS !== "" ? Number(endS) : undefined, + }), + }); + frames = r.frames || []; + setFrameOpts(frames); + if ((r.texts || []).length) setTextOpts(r.texts); + } + if (!frames.length) { setMsg("No frames found. Pick a video source first."); return; } + const next = (frameIdx + 1) % frames.length; + setFrameIdx(next); + const frame = frames[next]; + setSelFrame({ path: frame.path, info: frame }); + const r = await api("/thumbnail-studio/render", { + method: "POST", + body: JSON.stringify({ title, line1: line1 || undefined, line2: line2 || undefined, frame_path: frame.path, frame_info: frame }), + }); + if (!r.path) throw new Error("no thumbnail produced"); + setPreview(r.path); + setBust(Date.now()); + setMsg(`New frame from source (${next + 1}/${frames.length})`); + } catch (e: any) { setMsg(`New frame failed: ${e.message}`); } finally { setBusy(null); } + }; + return (
@@ -161,6 +199,9 @@ export default function ThumbnailStudio() { + {preview && ( Download From 21c90589a552e9cdb98e29e6993c5eb278882073 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 4 Jul 2026 01:11:01 +0400 Subject: [PATCH 05/11] Standardize form inputs across the studio and unify label styling Every text/number/password/select/textarea now inherits the shared base input style instead of carrying its own inline font-size and padding, so inputs are identical across all pages (they previously drifted across seven padding values). Broaden the base selector to cover bare `input` elements (word corrections) and strip their one-off inline styling. Also align the labelStyle helper with the .section-label class so section headers match regardless of which is used, and update the select-dropdown arrow SVG to the new palette's text color. --- src/ui/client/AnalyticsPage.tsx | 4 ++-- src/ui/client/ClipDetail.tsx | 6 +++--- src/ui/client/ConfigPage.tsx | 2 +- src/ui/client/ContentStudio.tsx | 4 ++-- src/ui/client/EpisodeWorkspace.jsx | 14 +++++++------- src/ui/client/ThumbnailStudio.tsx | 10 +++++----- src/ui/client/ThumbnailTemplate.tsx | 4 ++-- src/ui/client/lib.ts | 6 ++++-- src/ui/public/css/styles.css | 6 +++--- 9 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/ui/client/AnalyticsPage.tsx b/src/ui/client/AnalyticsPage.tsx index c63dc55..bcf6f58 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%" }} />
diff --git a/src/ui/client/ClipDetail.tsx b/src/ui/client/ClipDetail.tsx index c5b38d5..2f7474b 100644 --- a/src/ui/client/ClipDetail.tsx +++ b/src/ui/client/ClipDetail.tsx @@ -245,7 +245,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 }} />
- setTitle(e.target.value)} placeholder="Title to write headlines from" style={{ flex: "1 1 280px", fontSize: 14, padding: "10px 13px" }} /> - setStartS(e.target.value)} placeholder="Start (s)" style={{ width: 90, fontSize: 13, padding: "9px 10px" }} /> - setEndS(e.target.value)} placeholder="End (s)" style={{ width: 90, fontSize: 13, padding: "9px 10px" }} /> + setTitle(e.target.value)} placeholder="Title to write headlines from" style={{ flex: "1 1 280px" }} /> + setStartS(e.target.value)} placeholder="Start (s)" style={{ width: 90 }} /> + setEndS(e.target.value)} placeholder="End (s)" style={{ width: 90 }} /> @@ -193,8 +193,8 @@ export default function ThumbnailStudio() {
- 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 }} />
@@ -276,7 +276,7 @@ export default function ClipDetail() { ) : selFrame ? ( selected frame ) : ( -
+
Get options, pick a frame, then generate
)} @@ -297,13 +297,13 @@ export default function ClipDetail() { 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) => (
- {busy && stage &&
{stage}
} + {busy && stage &&
{stage}
} {msg &&
{msg}
}
@@ -145,7 +145,7 @@ export default function ContentStudio() {
{result.titles?.length ? (
-
Title options · click to copy
+
Title options · click to copy
{result.titles.map((t, i) => { const clean = t.replace(/^\d+\.\s*/, ""); @@ -163,7 +163,7 @@ export default function ContentStudio() { {result.description ? (
- Description + Description
{result.description}
@@ -173,7 +173,7 @@ export default function ContentStudio() { {result.tags ? (
- Tags + Tags
{result.tags}
@@ -183,7 +183,7 @@ export default function ContentStudio() { {result.hashtags ? (
- Hashtags + Hashtags
{result.hashtags}
diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx index 8cc18d8..c048b04 100644 --- a/src/ui/client/EpisodeWorkspace.jsx +++ b/src/ui/client/EpisodeWorkspace.jsx @@ -461,7 +461,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
{collapsed ? `${hints.length} prompts` : 'Click to copy'}
- {'\u25BC'} + {'\u25BC'}
{!collapsed && (
@@ -1155,7 +1155,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
1.
Accept model terms {' → '} - 2. Get free token (Read permission) + 2. Get free token (Read permission) {' → '} 3. Add HF_TOKEN=hf_... to your .env
@@ -1233,7 +1233,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( Time offset setTimeAdjust(parseFloat(e.target.value) || 0)} style={{ width: 72 }} disabled={isProcessing} /> - sec + sec
)} @@ -1440,12 +1440,12 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( Word Corrections {Object.keys(corrections).length > 0 && ( - ({Object.keys(corrections).length}) + ({Object.keys(corrections).length}) )}
{correctionsOpen && (
-
+
Fix Whisper misheard words. Applied automatically to all transcripts.
@@ -1716,7 +1716,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
setHistoryOpen(!historyOpen)}> History ({clipHistory.length}) - {'\u25BC'} + {'\u25BC'}
{historyOpen && (
@@ -1731,7 +1731,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
{c.title || fname}
-
+
{c.duration}s {'\u00B7'} {c.file_size_mb?.toFixed(1)}MB {'\u00B7'} {c.caption_style} {'\u00B7'} {timeStr}
@@ -1851,16 +1851,16 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
setEditForm(f => ({ ...f, start: parseFloat(e.target.value) || 0 }))} style={{ textAlign: 'center' }} /> -
{fmt(editForm.start)}
+
{fmt(editForm.start)}
{'\u2192'}
setEditForm(f => ({ ...f, end: parseFloat(e.target.value) || 0 }))} style={{ textAlign: 'center' }} /> -
{fmt(editForm.end)}
+
{fmt(editForm.end)}
-
+
Duration: {Math.round(editForm.end - editForm.start)}s
diff --git a/src/ui/client/ReframeEditor.tsx b/src/ui/client/ReframeEditor.tsx index b6d24e4..f86e2ab 100644 --- a/src/ui/client/ReframeEditor.tsx +++ b/src/ui/client/ReframeEditor.tsx @@ -156,7 +156,7 @@ export default function ReframeEditor({
-
+
{detectingCuts ? "Detecting camera switches…" : cuts.length ? `${cuts.length} camera switch${cuts.length > 1 ? "es" : ""} detected. A keyframe near one snaps to it.` : "No camera switches detected"}
diff --git a/src/ui/client/ThumbnailStudio.tsx b/src/ui/client/ThumbnailStudio.tsx index c0af35d..ec05026 100644 --- a/src/ui/client/ThumbnailStudio.tsx +++ b/src/ui/client/ThumbnailStudio.tsx @@ -186,7 +186,7 @@ export default function ThumbnailStudio() { ) : selFrame ? ( selected frame ) : ( -
+
Pick a source and get options, or upload an image
)} @@ -208,13 +208,13 @@ export default function ThumbnailStudio() { )}
-
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) => (
-
{clip.tags}
+
{clip.tags}
) : null} {clip.hashtags ? ( diff --git a/src/ui/client/ConfigPage.tsx b/src/ui/client/ConfigPage.tsx index 726dd02..8daf4e1 100644 --- a/src/ui/client/ConfigPage.tsx +++ b/src/ui/client/ConfigPage.tsx @@ -162,7 +162,7 @@ export default function ConfigPage() { {importing ? "Importing…" : "Import"}
-
-
{result.tags}
+
{result.tags}
) : null} diff --git a/src/ui/client/EpisodeWorkspace.jsx b/src/ui/client/EpisodeWorkspace.jsx index c048b04..8db4563 100644 --- a/src/ui/client/EpisodeWorkspace.jsx +++ b/src/ui/client/EpisodeWorkspace.jsx @@ -1150,7 +1150,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
Set up speaker detection
-
+
Identify who's talking in your podcast. Free, takes 2 minutes.
1. Accept model terms @@ -1230,7 +1230,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart( disabled={isProcessing} style={{ minHeight: transcriptText ? 80 : 120 }} onDragOver={e => { preventDef(e); setTranscriptDragOver(true); }} onDrop={handleTranscriptDrop} />
- Time offset + Time offset setTimeAdjust(parseFloat(e.target.value) || 0)} style={{ width: 72 }} disabled={isProcessing} /> sec @@ -1687,7 +1687,7 @@ const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(
P
PodStack: next steps
-

+

Clips are rendered. Now generate titles, descriptions, and thumbnails in Claude Code:

diff --git a/src/ui/client/ThumbnailStudio.tsx b/src/ui/client/ThumbnailStudio.tsx index ec05026..3edb434 100644 --- a/src/ui/client/ThumbnailStudio.tsx +++ b/src/ui/client/ThumbnailStudio.tsx @@ -158,7 +158,7 @@ export default function ThumbnailStudio() { style={{ display: "none" }} onChange={(e) => e.target.files?.[0] && uploadFile(e.target.files[0])} /> - {video && {video.name}} + {video && {video.name}}
setTitle(e.target.value)} placeholder="Title to write headlines from" style={{ flex: "1 1 280px" }} /> diff --git a/src/ui/client/ThumbnailTemplate.tsx b/src/ui/client/ThumbnailTemplate.tsx index 9663abe..340de41 100644 --- a/src/ui/client/ThumbnailTemplate.tsx +++ b/src/ui/client/ThumbnailTemplate.tsx @@ -111,7 +111,7 @@ export default function ThumbnailTemplate({ onBack }: { onBack?: () => void }) { const field = (f: Field) => { const v = cfg[f.k]; if (f.t === "bool") return ( -
)} -