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", });