Skip to content
Merged
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 getattr(args, "thumbnails", None) is not None:
config["generate_thumbnails"] = args.thumbnails
if args.top:
Expand Down Expand Up @@ -798,6 +801,7 @@ def _transcribe_progress(pct, msg):
end_second=clip["end_second"],
caption_style=config.get("caption_style", "branded"),
crop_strategy=config.get("crop_strategy", "face"),
format=config.get("format", "vertical"),
transcript_words=words,
title=clip.get("title", f"clip_{i+1}"),
output_dir=output_dir,
Expand Down Expand Up @@ -1010,6 +1014,7 @@ def _transcribe_progress(pct, msg):
end_second=clip["end_second"],
caption_style=config.get("caption_style", "branded"),
crop_strategy=config.get("crop_strategy", "face"),
format=config.get("format", "vertical"),
transcript_words=words,
title=clip.get("title", f"clip_{i+1}"),
output_dir=output_dir,
Expand Down Expand Up @@ -1333,6 +1338,7 @@ def _rerender_clip(r):
end_second=clip["end_second"],
caption_style=config.get("caption_style", "branded"),
crop_strategy=config.get("crop_strategy", "face"),
format=config.get("format", "vertical"),
transcript_words=words,
title=clip.get("title", "clip"),
output_dir=output_dir,
Expand Down Expand Up @@ -1517,6 +1523,7 @@ def _rerender_clip(r):
end_second=f_clip["end_second"],
caption_style=config.get("caption_style", "branded"),
crop_strategy=config.get("crop_strategy", "face"),
format=config.get("format", "vertical"),
transcript_words=words,
title=f_clip.get("title", "clip"),
output_dir=output_dir,
Expand Down Expand Up @@ -1571,6 +1578,7 @@ def _rerender_clip(r):
end_second=nc["end_second"],
caption_style=config.get("caption_style", "branded"),
crop_strategy=config.get("crop_strategy", "face"),
format=config.get("format", "vertical"),
transcript_words=words,
title=nc.get("title", "clip"),
output_dir=output_dir,
Expand Down Expand Up @@ -3307,6 +3315,7 @@ def main():
proc.add_argument("--no-thumbnails", dest="thumbnails", action="store_false", help="Skip thumbnail generation")
proc.add_argument("--caption-style", choices=["branded", "hormozi", "karaoke", "subtle"])
proc.add_argument("--crop", choices=["center", "face", "speaker", "speaker-hardcut"])
proc.add_argument("--format", choices=["vertical", "horizontal", "square"], help="Output aspect ratio (default: vertical)")
proc.add_argument("--logo", help="Logo image (asset name or path)")
proc.add_argument("--outro", help="Outro video (asset name or path)")
proc.add_argument("--time-adjust", type=float, help="Timestamp offset in seconds")
Expand Down
26 changes: 26 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 Expand Up @@ -480,6 +482,29 @@ def handle_generate_content(task_id: str, params: dict):
emit_result(task_id, "success", data=result)


def handle_generate_custom(task_id: str, params: dict):
"""Run a free-form content request against the AI CLI with KB + transcript context."""
from services.content_generator import generate_custom_content

instruction = str(params.get("instruction", "")).strip()
if not instruction:
emit_result(task_id, "error", error="instruction is required")
return

result = generate_custom_content(
instruction=instruction,
transcript_segments=params.get("transcript_segments", []),
mode=params.get("mode", "shorts"),
progress_callback=lambda pct, msg: emit_progress(task_id, "generating", pct, msg),
)

if result is None:
emit_result(task_id, "error", error="No AI CLI available (install Claude Code or Codex)")
return

emit_result(task_id, "success", data=result)


def handle_manage_integrations(task_id: str, params: dict):
from services.integrations import IntegrationsManager

Expand Down Expand Up @@ -568,6 +593,7 @@ def handle_run_integration_tool(task_id: str, params: dict):
"manage_env": handle_manage_env,
"ai_cli_status": handle_ai_cli_status,
"generate_content": handle_generate_content,
"generate_custom": handle_generate_custom,
"manage_integrations": handle_manage_integrations,
"run_integration_tool": handle_run_integration_tool,
"manage_config": handle_manage_config,
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

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)

Comment thread
nmbrthirteen marked this conversation as resolved.
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
66 changes: 66 additions & 0 deletions backend/services/content_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,72 @@ def _stream_claude_content(
return out


def generate_custom_content(
instruction: str,
transcript_segments: list[dict],
mode: str = "shorts",
progress_callback: Optional[Callable[[int, str], None]] = None,
) -> Optional[dict]:
"""Run a free-form content request against the AI CLI with KB + transcript context.

Returns {"text", "engine"} with the raw model output, or None if no AI CLI.
"""
candidates = _find_ai_cli_candidates()
if not candidates:
return None

kb_context = load_kb_context()
lines = []
for seg in transcript_segments:
sp = seg.get("speaker", "")
sp_label = f"[{sp}] " if sp else ""
lines.append(f"{sp_label}{seg.get('text', '').strip()}")
excerpt = chr(10).join(_sample_lines(lines, max_lines=40))

# REQUEST first so codex prompt truncation never drops the ask.
prompt = f"""You are helping create YouTube content for a podcast. Follow the knowledge base voice and rules. Return ONLY the requested output, no preamble.

REQUEST ({'full episode' if mode == 'episode' else 'short clip'}):
{instruction.strip()}

KNOWLEDGE BASE:
{kb_context}

TRANSCRIPT EXCERPT:
{excerpt}"""

project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
from utils.prompt_files import write_prompt_file
prompt_file = write_prompt_file(prompt)
try:
for idx, (cli_path, engine) in enumerate(candidates):
label = _engine_label(engine)
if progress_callback:
progress_callback(30, f"Asking {label}..." if idx == 0 else f"Retrying with {label}...")
try:
cr = _run_ai_command(
cli_path=cli_path,
engine=engine,
prompt=prompt[:4000] if engine == "codex" else prompt,
prompt_file=prompt_file,
project_dir=project_dir,
timeout=120,
)
except Exception:
continue
if cr.returncode != 0 or not cr.stdout.strip():
continue
if progress_callback:
progress_callback(100, "Done")
return {"text": cr.stdout.strip(), "engine": engine}
return None
finally:
try:
os.unlink(prompt_file)
except Exception:
pass


def generate_clip_content(
clip: dict,
transcript_segments: list[dict],
Expand Down
72 changes: 72 additions & 0 deletions backend/services/formats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Output format specifications — the single source of truth for clip dimensions.

Every aspect-ratio decision (crop target, caption geometry, duration bounds,
which scoring profile applies) derives from a FormatSpec so the render pipeline
is parameterized on format instead of hardcoding 1080x1920 per call site.
"""

import sys
from dataclasses import dataclass


@dataclass(frozen=True)
class FormatSpec:
name: str
width: int
height: int
reframe: bool
caption_profile: str
dur_min: int
dur_max: int
target_min: int
target_max: int
score_key: str

@property
def dims(self) -> tuple[int, int]:
return (self.width, self.height)

@property
def ratio(self) -> float:
return self.width / self.height


FORMATS = {
"vertical": FormatSpec(
name="vertical",
width=1080, height=1920,
reframe=True,
caption_profile="vertical",
dur_min=20, dur_max=45,
target_min=20, target_max=35,
score_key="vertical_score",
),
"horizontal": FormatSpec(
name="horizontal",
width=1920, height=1080,
reframe=False,
caption_profile="lower_third",
dur_min=60, dur_max=300,
target_min=90, target_max=240,
score_key="horizontal_score",
),
"square": FormatSpec(
name="square",
width=1080, height=1080,
reframe=True,
caption_profile="center",
dur_min=20, dur_max=45,
target_min=20, target_max=35,
score_key="vertical_score",
),
}

DEFAULT_FORMAT = "vertical"


def get_format(name: str | None) -> FormatSpec:
if name is not None and name not in FORMATS:
# Raw MCP/API callers bypass the CLI's choices= guard; warn so a typo'd
# format doesn't silently render as vertical.
print(f"[formats] unknown format {name!r}; using {DEFAULT_FORMAT}", file=sys.stderr)
return FORMATS.get(name or DEFAULT_FORMAT, FORMATS[DEFAULT_FORMAT])
Comment thread
nmbrthirteen marked this conversation as resolved.
Loading
Loading