Skip to content

Add horizontal (16:9) and square clip formats#36

Closed
nmbrthirteen wants to merge 1 commit into
mainfrom
feat/horizontal-clips-phase1
Closed

Add horizontal (16:9) and square clip formats#36
nmbrthirteen wants to merge 1 commit into
mainfrom
feat/horizontal-clips-phase1

Conversation

@nmbrthirteen

@nmbrthirteen nmbrthirteen commented Jul 3, 2026

Copy link
Copy Markdown
Owner

What

Makes podcli render a detected moment to more than one aspect ratio. Adds horizontal (16:9) and square (1:1) output alongside the existing vertical (9:16), so the same moment can become a Shorts clip and a YouTube in-feed / X / LinkedIn clip.

Format is a render-time property: a moment stays format-neutral; you pick the format at render. Everything defaults to vertical, so existing behavior is byte-identical until a caller opts into another format.

How

  • Single source of truth — new backend/services/formats.py (FormatSpec + FORMATS) replaces scattered 1080x1920 hardcodes. Duration constants become aliases of the vertical spec (kept as module-level names so existing importers don't break).
  • Render forkgenerate_clip resolves spec = get_format(format) once and forks on spec.reframe: reframe formats (vertical, square) keep the face-tracked crop; horizontal uses a new fit_to_frame scale+pad letterbox that skips face analysis entirely.
  • Caps — Python duration cap uses spec.dur_max; the web HTTP caps are format-keyed (horizontal→300s, others keep 180s).
  • Threadingformat flows through the MCP tools (server.ts), the web server (incl. the styledClips export whitelist), the CLI (--format), and clip history. Dedup treats a missing format as vertical, so a horizontal re-clip of an already-vertical range is not a false duplicate.

Verification

  • End to end: a 1280x720 source renders horizontal1920x1080 (via fit-letterbox, no face analysis) and vertical1080x1920 (unchanged).
  • 341 Python + 47 TS tests pass; tsc --noEmit clean.

Deferred (follow-up)

Captions still use vertical geometry on horizontal clips (the caption components use absolute pixels tuned for 1080-tall, not relative sizing). Per-format caption profiles (lower_third) across Remotion + the ASS fallback are the next phase. The video is dimension-correct; only caption placement is not yet format-aware.

Full plan: plans/horizontal-clips.md.

Summary by CodeRabbit

  • New Features

    • Added clip format options: vertical, horizontal, and square.
    • Clip creation and batch exports now remember and apply the selected format across the workflow.
  • Bug Fixes

    • Resuming or reusing saved clip suggestions now respects the chosen format.
    • Duration checks now adapt to the selected format, allowing longer horizontal clips.
    • Clip history and duplicate detection now distinguish between formats to avoid mismatches.

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.
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces multi-format clip output support (vertical, horizontal, square) across the stack. A new FormatSpec/FORMATS registry centralizes per-format geometry, duration limits, and crop behavior. This format value is threaded through clip generation, CLI, presets, MCP handlers, server routing, history duplicate detection/recording, and the web UI, with corresponding validation and defaults, plus a phased rollout plan document.

Changes

Multi-format clip rendering

Layer / File(s) Summary
Format registry and video processing
backend/services/formats.py, backend/services/video_processor.py
Adds FormatSpec dataclass, FORMATS registry (vertical/horizontal/square), get_format() helper, and updates crop_to_vertical/adds fit_to_frame to use configurable target_dims.
generate_clip format parameter and presets
backend/services/clip_generator.py, backend/presets.py
generate_clip accepts format, resolves a spec, validates duration via spec.dur_max, chooses crop vs fit based on spec.reframe, and returns format in output; presets derive duration constants from FORMATS["vertical"] and add format to DEFAULT_PRESET.
CLI and Python executor format plumbing
backend/cli.py, backend/main.py
CLI process command adds --format, includes it in the selection signature and config, and passes it to all generate_clip call sites; main.py forwards format for create_clip/batch_clips.
Type contracts and MCP handler schemas
src/models/index.ts, src/handlers/create-clip.handler.ts, src/handlers/batch-clips.handler.ts
Adds Format type and format? fields to model interfaces; extends tool input schemas with format and resolves it into payloads passed to executors.
Server routing and history format tracking
src/server.ts, src/services/clips-history.ts
Tool schemas and clip creation/batch flows propagate format into duplicate detection, export payloads, and history records; findDuplicate and recordBatchResults incorporate format matching/defaults.
Web UI format settings and validation
src/ui/web-server.ts
Persists settings.format, validates format on /api/create-clip and /api/batch-clips with format-dependent duration caps, and threads format through /api/ui-state and /api/mcp/export.
Horizontal clips rollout plan
plans/horizontal-clips.md
Documents phased plan for extending vertical-only clip generation to horizontal/square formats.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Server as server.ts / web-server.ts
  participant History as ClipsHistory
  participant Executor as Python create_clip
  participant Formats as get_format/FORMATS

  Client->>Server: create_clip(format)
  Server->>History: findDuplicate(..., format)
  History-->>Server: duplicate check result
  Server->>Executor: execute create_clip(format)
  Executor->>Formats: get_format(format)
  Formats-->>Executor: FormatSpec(dims, dur_max, reframe)
  Executor-->>Server: clip result(format)
  Server->>History: record(entry with format)
Loading

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

  • nmbrthirteen/podcli#1: Both PRs modify generate_clip(...) in backend/services/clip_generator.py, adding new parameters at the same call-site level.
  • nmbrthirteen/podcli#5: Both PRs change crop_to_vertical() and generate_clip() behavior around cropping and target dimensions.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding horizontal and square clip format support.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/horizontal-clips-phase1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/cli.py (1)

77-86: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't include format in the selection signature.

format is a render-time-only setting — it doesn't change which clips get selected, per the PR's "moment remains format-neutral" design. Including it here busts the cached-suggestions session whenever a user re-runs with a different --format, forcing an unnecessary (and possibly nondeterministic) re-suggestion pass, which directly undermines the "reuse the same detected moments across formats" goal this PR is built around.

🐛 Proposed fix
     return "|".join(str(x) for x in (
         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"),
     ))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cli.py` around lines 77 - 86, The selection cache key in
_selection_signature should not include the render-only format value, since it
does not affect which clips are selected. Update the signature to depend only on
selection-affecting inputs in _selection_signature (ai_select,
min_clip_duration, max_clip_duration), so rerunning with a different --format
reuses the same detected moments instead of forcing a new suggestion pass.
🧹 Nitpick comments (3)
plans/horizontal-clips.md (1)

7-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a language tag to the fenced block.

The bare fence triggers markdownlint MD040. text is enough here and keeps the doc lint-clean.

Suggested tweak
-```
+```text
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plans/horizontal-clips.md` around lines 7 - 13, The fenced markdown block is
missing a language tag, which triggers markdownlint MD040. Update the fenced
snippet in the horizontal clips plan to use a text fence, keeping the existing
content unchanged so the documentation stays lint-clean.

Source: Linters/SAST tools

backend/cli.py (1)

3282-3282: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive --format choices from the FORMATS registry instead of hardcoding.

The PR's stated intent is a single source of truth for format definitions (backend/services/formats.py). Hardcoding the choices list here means it can silently drift if a format is added/removed from the registry later.

♻️ Proposed refactor
+    from services.formats import FORMATS
+    ...
-    proc.add_argument("--format", choices=["vertical", "horizontal", "square"], help="Output aspect ratio (default: vertical)")
+    proc.add_argument("--format", choices=list(FORMATS.keys()), help="Output aspect ratio (default: vertical)")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cli.py` at line 3282, Update the argument definition in cli argument
setup so `--format` no longer hardcodes the allowed values; instead, derive the
`choices` from the `FORMATS` registry in backend/services/formats.py. Use the
same source of truth referenced by the format-related codepath so the CLI stays
in sync if formats are added or removed, and locate the change near
`proc.add_argument` for `--format`.
src/ui/web-server.ts (1)

673-676: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated hardcoded duration caps (180/300) across two endpoints.

The format === "horizontal" ? 300 : 180 ternary is repeated verbatim for single-clip and batch validation. Extract into a shared constant to avoid drift, and please confirm these values match the format-aware duration limits defined in backend/services/formats.py (single source of truth per PR objective).

Suggested refactor
+const FORMAT_MAX_DURATION: Record<string, number> = {
+  horizontal: 300,
+  vertical: 180,
+  square: 180,
+};
+
 // ...single-clip check:
-  const maxDur = format === "horizontal" ? 300 : 180;
+  const maxDur = FORMAT_MAX_DURATION[format] ?? 180;

 // ...batch check:
-    const maxDur = c.format === "horizontal" ? 300 : 180;
+    const maxDur = FORMAT_MAX_DURATION[c.format] ?? 180;

Also applies to: 825-828

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ui/web-server.ts` around lines 673 - 676, The duration cap logic is
duplicated in the single-clip and batch validation paths, so extract the `format
=== "horizontal" ? 300 : 180` rule into a shared constant or helper used by both
checks in `web-server.ts`. Update the validation in the relevant handler(s) to
reference that shared source, and verify the values stay aligned with the
format-aware limits defined in `backend/services/formats.py` so there is one
authoritative source for these caps.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/presets.py`:
- Around line 20-27: The clip suggestion flow is still using vertical-only
duration aliases, so horizontal and square formats can be clamped to the wrong
bounds. Update `handle_suggest_clips()` and `backend/services/claude_suggest.py`
to accept and use the selected format from `FORMATS` instead of importing
`MIN_CLIP_DURATION`, `MAX_CLIP_DURATION`, `TARGET_CLIP_DURATION_MIN`, and
`TARGET_CLIP_DURATION_MAX` directly. Thread the chosen format through both the
prompt limit construction and the runtime filtering logic so duration bounds
always come from the active format.

In `@backend/services/formats.py`:
- Around line 66-67: The get_format helper is silently falling back to
DEFAULT_FORMAT for unknown format strings, which masks typos like unsupported
values. Update get_format in formats.py to explicitly validate the requested
name against FORMATS and raise an error for any non-null, unknown format instead
of using FORMATS.get(..., FORMATS[DEFAULT_FORMAT]). Keep the default only when
name is None, and make sure callers through get_format receive a clear failure
for invalid input.

In `@src/handlers/batch-clips.handler.ts`:
- Line 156: The batch clip payload is not forwarding a top-level fallback
format, so explicit clip batches can default to the backend’s vertical value
instead of the session/user format. Update the batch-clips flow around the
input.format || settings.format || "vertical" handling so both the
/api/batch-clips request and the sync executor include a top-level format
fallback when input.clips is present, ensuring clips without their own format
inherit the intended value.

In `@src/models/index.ts`:
- Line 74: The new Format union is defined in src/models/index.ts but the
related format fields still use string, so update every format?: property in
ClipResult, UIState.settings, CreateClipInput, BatchClipSpec, BatchClipsInput,
BatchClipsResult.results[], and ClipHistoryEntry to use Format instead. Keep the
change consistent across the model types in this file so all callers that thread
format through the CLI, server, and history layers get the same type safety.

In `@src/ui/web-server.ts`:
- Around line 2623-2630: The /api/ui-state handler in uiState.settings
assignment is persisting settings.format directly without validation, unlike
/api/create-clip. Update the settings update path to validate
body.settings.format against the same allowed values before writing it into
uiState.settings.format, and reject or ignore invalid values so only supported
formats can later be used by /api/mcp/export and the batch-clips flow.
- Around line 825-828: The batch clip path in web-server.ts is missing the same
format validation that /api/create-clip already performs, so invalid c.format
values can slip through and be forwarded to the render pipeline. Add an explicit
enum/allowed-value check in the /api/batch-clips per-clip validation flow before
using c.format in the maxDur logic, and return a clear 400 error for bad values;
reuse the existing create-clip format validation behavior/pattern so both
endpoints enforce the same rules.

---

Outside diff comments:
In `@backend/cli.py`:
- Around line 77-86: The selection cache key in _selection_signature should not
include the render-only format value, since it does not affect which clips are
selected. Update the signature to depend only on selection-affecting inputs in
_selection_signature (ai_select, min_clip_duration, max_clip_duration), so
rerunning with a different --format reuses the same detected moments instead of
forcing a new suggestion pass.

---

Nitpick comments:
In `@backend/cli.py`:
- Line 3282: Update the argument definition in cli argument setup so `--format`
no longer hardcodes the allowed values; instead, derive the `choices` from the
`FORMATS` registry in backend/services/formats.py. Use the same source of truth
referenced by the format-related codepath so the CLI stays in sync if formats
are added or removed, and locate the change near `proc.add_argument` for
`--format`.

In `@plans/horizontal-clips.md`:
- Around line 7-13: The fenced markdown block is missing a language tag, which
triggers markdownlint MD040. Update the fenced snippet in the horizontal clips
plan to use a text fence, keeping the existing content unchanged so the
documentation stays lint-clean.

In `@src/ui/web-server.ts`:
- Around line 673-676: The duration cap logic is duplicated in the single-clip
and batch validation paths, so extract the `format === "horizontal" ? 300 : 180`
rule into a shared constant or helper used by both checks in `web-server.ts`.
Update the validation in the relevant handler(s) to reference that shared
source, and verify the values stay aligned with the format-aware limits defined
in `backend/services/formats.py` so there is one authoritative source for these
caps.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b1a135ed-d53c-4895-9c50-3149e0ecd587

📥 Commits

Reviewing files that changed from the base of the PR and between f19fcc7 and 0eaad64.

📒 Files selected for processing (13)
  • backend/cli.py
  • backend/main.py
  • backend/presets.py
  • backend/services/clip_generator.py
  • backend/services/formats.py
  • backend/services/video_processor.py
  • plans/horizontal-clips.md
  • src/handlers/batch-clips.handler.ts
  • src/handlers/create-clip.handler.ts
  • src/models/index.ts
  • src/server.ts
  • src/services/clips-history.ts
  • src/ui/web-server.ts

Comment thread backend/presets.py
Comment on lines +20 to +27
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether claude_suggest.py uses format-aware bounds or the fixed vertical aliases
fd claude_suggest.py backend
rg -n 'MIN_CLIP_DURATION|MAX_CLIP_DURATION|TARGET_CLIP_DURATION|get_format|FORMATS' backend/services/claude_suggest.py 2>/dev/null backend/claude_suggest.py 2>/dev/null

Repository: nmbrthirteen/podcli

Length of output: 1598


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' backend/presets.py
printf '\n--- claude_suggest imports and duration logic ---\n'
sed -n '1,120p' backend/services/claude_suggest.py
printf '\n--- format-aware searches ---\n'
rg -n 'get_format\(|format\b|MIN_CLIP_DURATION|MAX_CLIP_DURATION|TARGET_CLIP_DURATION' backend/services/claude_suggest.py backend | sed -n '1,220p'

Repository: nmbrthirteen/podcli

Length of output: 19096


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- claude_suggest function signatures ---'
rg -n '^def |format\s*[:=]' backend/services/claude_suggest.py | sed -n '1,120p'

printf '\n%s\n' '--- surrounding caller/context in cli.py ---'
sed -n '760,860p' backend/cli.py
printf '\n%s\n' '--- more callers if any ---'
rg -n 'claude_suggest|suggest.*clip|ai_select|more_suggestions_multiplier' backend/cli.py backend/main.py backend/services | sed -n '1,220p'

Repository: nmbrthirteen/podcli

Length of output: 10124


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '390,760p' backend/services/claude_suggest.py
printf '\n--- main.py suggest_clips handler ---\n'
sed -n '340,430p' backend/main.py

Repository: nmbrthirteen/podcli

Length of output: 17619


Thread clip-suggestion bounds through the selected format. backend/services/claude_suggest.py still imports these vertical-only aliases and uses them for both prompt limits and runtime filtering, while handle_suggest_clips() passes no format. Horizontal/square runs can still be clamped to vertical duration caps.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/presets.py` around lines 20 - 27, The clip suggestion flow is still
using vertical-only duration aliases, so horizontal and square formats can be
clamped to the wrong bounds. Update `handle_suggest_clips()` and
`backend/services/claude_suggest.py` to accept and use the selected format from
`FORMATS` instead of importing `MIN_CLIP_DURATION`, `MAX_CLIP_DURATION`,
`TARGET_CLIP_DURATION_MIN`, and `TARGET_CLIP_DURATION_MAX` directly. Thread the
chosen format through both the prompt limit construction and the runtime
filtering logic so duration bounds always come from the active format.

Comment on lines +66 to +67
def get_format(name: str | None) -> FormatSpec:
return FORMATS.get(name or DEFAULT_FORMAT, FORMATS[DEFAULT_FORMAT])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Invalid format strings silently fall back to vertical instead of erroring.

get_format uses FORMATS.get(name or DEFAULT_FORMAT, FORMATS[DEFAULT_FORMAT]), so a typo or unsupported format value (e.g., "horzontal") silently resolves to vertical rather than surfacing an error. Since this is the single source of truth consumed by the CLI, MCP handlers, and web server (per PR objectives), a mistyped/unsupported format would render silently wrong output with no diagnostic.

🛡️ Proposed fix to raise on unknown format
 def get_format(name: str | None) -> FormatSpec:
-    return FORMATS.get(name or DEFAULT_FORMAT, FORMATS[DEFAULT_FORMAT])
+    key = name or DEFAULT_FORMAT
+    try:
+        return FORMATS[key]
+    except KeyError:
+        raise ValueError(f"Unknown format '{name}'. Valid formats: {', '.join(FORMATS)}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def get_format(name: str | None) -> FormatSpec:
return FORMATS.get(name or DEFAULT_FORMAT, FORMATS[DEFAULT_FORMAT])
def get_format(name: str | None) -> FormatSpec:
key = name or DEFAULT_FORMAT
try:
return FORMATS[key]
except KeyError:
raise ValueError(f"Unknown format '{name}'. Valid formats: {', '.join(FORMATS)}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/formats.py` around lines 66 - 67, The get_format helper is
silently falling back to DEFAULT_FORMAT for unknown format strings, which masks
typos like unsupported values. Update get_format in formats.py to explicitly
validate the requested name against FORMATS and raise an error for any non-null,
unknown format instead of using FORMATS.get(..., FORMATS[DEFAULT_FORMAT]). Keep
the default only when name is None, and make sure callers through get_format
receive a clear failure for invalid input.

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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 "format" src/ui/web-server.ts | rg -n -A3 -B3 "batch-clips"

Repository: nmbrthirteen/podcli

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the handler and backend files first.
git ls-files src/handlers/batch-clips.handler.ts backend/main.py src/ui/web-server.ts

# Show the relevant sections with line numbers.
sed -n '130,280p' src/handlers/batch-clips.handler.ts
printf '\n--- backend/main.py ---\n'
sed -n '1,260p' backend/main.py
printf '\n--- src/ui/web-server.ts ---\n'
sed -n '1,260p' src/ui/web-server.ts

Repository: nmbrthirteen/podcli

Length of output: 23522


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the batch-clips route and inspect the request body it forwards.
rg -n "batch-clips|batch_clips|format" src/ui/web-server.ts
printf '\n--- relevant section ---\n'
sed -n '780,940p' src/ui/web-server.ts

Repository: nmbrthirteen/podcli

Length of output: 5724


Forward format for explicit clip batches
input.clips currently bypasses the input.format || settings.format || "vertical" default, and neither /api/batch-clips nor the sync executor call forwards a top-level format. That makes clips without their own format fall back to "vertical" in the backend, even when the session/user format is different.

🐛 Proposed fix
   } else if (input.clips) {
-    clips = input.clips;
+    clips = input.clips.map((c) => ({
+      ...c,
+      format: c.format || input.format || settings.format || "vertical",
+    }));
   } else {

Also pass a top-level fallback in both backend calls:

       const result = await executor.execute<BatchClipsResult>("batch_clips", {
         video_path: videoPath,
         clips,
         transcript_words: transcriptWords,
+        format: input.format || settings.format || "vertical",
         clean_fillers: input.clean_fillers !== false,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/handlers/batch-clips.handler.ts` at line 156, The batch clip payload is
not forwarding a top-level fallback format, so explicit clip batches can default
to the backend’s vertical value instead of the session/user format. Update the
batch-clips flow around the input.format || settings.format || "vertical"
handling so both the /api/batch-clips request and the sync executor include a
top-level format fallback when input.clips is present, ensuring clips without
their own format inherit the intended value.

Comment thread src/models/index.ts

export type CaptionStyle = "branded" | "hormozi" | "karaoke" | "subtle";
export type CropStrategy = "center" | "face" | "speaker";
export type Format = "vertical" | "horizontal" | "square";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the new Format type instead of string for all format fields.

The Format union is defined but never applied — every format?: field in this file (ClipResult, UIState.settings, CreateClipInput, BatchClipSpec, BatchClipsInput, BatchClipsResult.results[], ClipHistoryEntry) still uses string. This loses the type-safety benefit across the whole stack (CLI handlers, server, clip history) that this PR threads format through.

♻️ Proposed refactor
-  format?: string;
+  format?: Format;

Apply consistently to each of the fields listed above.

Also applies to: 90-90, 121-121, 137-137, 152-152, 165-165, 188-188, 237-237

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/models/index.ts` at line 74, The new Format union is defined in
src/models/index.ts but the related format fields still use string, so update
every format?: property in ClipResult, UIState.settings, CreateClipInput,
BatchClipSpec, BatchClipsInput, BatchClipsResult.results[], and ClipHistoryEntry
to use Format instead. Keep the change consistent across the model types in this
file so all callers that thread format through the CLI, server, and history
layers get the same type safety.

Comment thread src/ui/web-server.ts
Comment on lines +825 to +828
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.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

/api/batch-clips doesn't validate c.format against the allowed enum.

/api/create-clip explicitly rejects invalid format values (lines 702-708), but the batch endpoint's per-clip loop only uses c.format for a duration-cap comparison — an invalid/typo'd value silently falls into the default 180s bucket and is forwarded to the render pipeline instead of failing fast with a clear error.

Suggested fix
+    const validFormats = ["vertical", "horizontal", "square"];
+    if (c.format && !validFormats.includes(c.format)) {
+      res.status(400).json({
+        error: `Clip ${i + 1}: invalid format. Use: ${validFormats.join(", ")}`,
+      });
+      return;
+    }
     const maxDur = c.format === "horizontal" ? 300 : 180;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.`,
const validFormats = ["vertical", "horizontal", "square"];
if (c.format && !validFormats.includes(c.format)) {
res.status(400).json({
error: `Clip ${i + 1}: invalid format. Use: ${validFormats.join(", ")}`,
});
return;
}
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 ${maxDur}s.`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ui/web-server.ts` around lines 825 - 828, The batch clip path in
web-server.ts is missing the same format validation that /api/create-clip
already performs, so invalid c.format values can slip through and be forwarded
to the render pipeline. Add an explicit enum/allowed-value check in the
/api/batch-clips per-clip validation flow before using c.format in the maxDur
logic, and return a clear 400 error for bad values; reuse the existing
create-clip format validation behavior/pattern so both endpoints enforce the
same rules.

Comment thread src/ui/web-server.ts
Comment on lines 2623 to 2630
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

/api/ui-state persists settings.format without validating it.

Unlike /api/create-clip, an arbitrary string can be written into persisted settings.format here, which later becomes the fallback default in /api/mcp/export and (per the sibling comment) the unvalidated /api/batch-clips.

Suggested fix
-    if (body.settings.format !== undefined)
+    if (
+      body.settings.format !== undefined &&
+      ["vertical", "horizontal", "square"].includes(body.settings.format)
+    )
       uiState.settings.format = body.settings.format;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
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)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ui/web-server.ts` around lines 2623 - 2630, The /api/ui-state handler in
uiState.settings assignment is persisting settings.format directly without
validation, unlike /api/create-clip. Update the settings update path to validate
body.settings.format against the same allowed values before writing it into
uiState.settings.format, and reject or ignore invalid values so only supported
formats can later be used by /api/mcp/export and the batch-clips flow.

@nmbrthirteen

Copy link
Copy Markdown
Owner Author

Superseded by #37, which contains this branch's Phase 1-2 commits plus Phase 3 (per-format captions), the studio format selector, and the UI refresh. Recommend closing this in favor of #37 for the v2.2.0 release.

@nmbrthirteen

Copy link
Copy Markdown
Owner Author

Superseded by #37. Verified this branch's head (0eaad64) is a direct ancestor of #37, so the full Phase 1 formats foundation ships there verbatim. Closing in favor of #37 to avoid a duplicate merge.

@nmbrthirteen nmbrthirteen deleted the feat/horizontal-clips-phase1 branch July 5, 2026 11:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant