Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
))


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
16 changes: 10 additions & 6 deletions backend/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +20 to +27

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.


DEFAULT_PRESET = {
"caption_style": "branded",
"crop_strategy": "face",
"format": "vertical",
"time_adjust": -1.0,
"logo_path": "",
"outro_path": "",
Expand Down
34 changes: 22 additions & 12 deletions backend/services/clip_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions backend/services/formats.py
Original file line number Diff line number Diff line change
@@ -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])
Comment on lines +66 to +67

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.

34 changes: 32 additions & 2 deletions backend/services/video_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading