Skip to content
Open
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
3 changes: 3 additions & 0 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ def cmd_studio(args):
cmd += [
"--caption-style", args.caption_style,
"--crop", args.crop,
"--format", getattr(args, "format", None) or "vertical",
"--intro-seconds", str(args.intro_seconds),
"--outro-seconds", str(args.outro_seconds),
]
Expand Down Expand Up @@ -4039,6 +4040,8 @@ def main():
studio.add_argument("--assemblyai-api-key", help="AssemblyAI API key for --engine assemblyai. Prefer ASSEMBLYAI_API_KEY; command-line secrets can appear in process listings.")
studio.add_argument("--caption-style", choices=["hormozi", "karaoke", "subtle", "branded"], default="hormozi")
studio.add_argument("--crop", choices=["center", "face", "speaker", "speaker-hardcut"], default="face")
studio.add_argument("--format", choices=["vertical", "horizontal", "square"], default="vertical",
help="Output aspect ratio (default: vertical)")
studio.add_argument("-o", "--output", help="Final output path")
studio.add_argument("--intro-title", help="Intro headline (default: derived from first words)")
studio.add_argument("--outro-title", default=None)
Expand Down
45 changes: 33 additions & 12 deletions backend/clip_studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,27 +119,32 @@ def _find_paragraph(words: list, phrase: str) -> tuple[float, float]:
return start, end


def _render_fragment(video, start, end, words, style, crop, title, out_dir):
def _render_fragment(video, start, end, words, style, crop, title, out_dir, fmt="vertical"):
"""Render the fragment with face-crop + captions via the existing engine."""
from services.clip_generator import generate_clip
print(f" [fragment] rendering {start:.1f}s–{end:.1f}s ({style}, crop={crop})", flush=True)
print(f" [fragment] rendering {start:.1f}s–{end:.1f}s ({style}, crop={crop}, {fmt})", flush=True)
res = generate_clip(
video_path=video, start_second=start, end_second=end,
caption_style=style, crop_strategy=crop,
caption_style=style, crop_strategy=crop, format=fmt,
transcript_words=words, title=title, output_dir=out_dir,
clean_fillers=True, allow_ass_fallback=True,
progress_callback=lambda p, m: print(f" {p}% {m}", flush=True),
)
return res["output_path"]


def _render_bookend(mode, title, handle, platforms, seconds, out_path, accent, bg):
def _render_bookend(mode, title, handle, platforms, seconds, out_path, accent, bg,
width=1080, height=1920):
cmd = [
NODE, os.path.join(ROOT, "remotion", "render-bookend.mjs"),
"--mode", mode, "--title", title,
"--platforms", ",".join(platforms),
"--seconds", str(seconds), "--output", out_path,
"--accent", accent, "--bg", bg,
# Sized to match the fragment. Left at the renderer's own defaults a
# square or wide clip would get vertical cards and be letterboxed by
# the concat below.
"--width", str(width), "--height", str(height),
]
if handle:
cmd += ["--handle", handle]
Expand All @@ -150,12 +155,18 @@ def _render_bookend(mode, title, handle, platforms, seconds, out_path, accent, b
return out_path


def _concat(parts: list[str], out_path: str, fps: int = 30):
def _concat(parts: list[str], out_path: str, fps: int = 30,
width: int = 1080, height: int = 1920):
"""Concatenate parts (intro + clip + outro) in a single ffmpeg pass.

Every part is normalized to 1080x1920 @ fps with stereo 44.1k audio inside
the filtergraph, then joined with the concat filter. This gives an exact
summed duration (no crossfade-offset drift) and a single clean re-encode.
Every part is normalized to width x height @ fps with stereo 44.1k audio
inside the filtergraph, then joined with the concat filter. This gives an
exact summed duration (no crossfade-offset drift) and a single clean
re-encode.

The size comes from the format spec rather than being fixed at 1080x1920,
which is what services/formats.py exists to prevent: normalizing a square
fragment to a vertical canvas would pillarbox the clip it just rendered.
"""
n = len(parts)
inputs = []
Expand All @@ -165,10 +176,10 @@ def _concat(parts: list[str], out_path: str, fps: int = 30):
fc = []
labels = []
for i in range(n):
# Normalize video: scale to fit 1080x1920, pad, set fps + SAR + format.
# Normalize video: scale to fit the canvas, pad, set fps + SAR + format.
fc.append(
f"[{i}:v]scale=1080:1920:force_original_aspect_ratio=decrease,"
f"pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black,setsar=1,fps={fps},format=yuv420p[v{i}];"
f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1,fps={fps},format=yuv420p[v{i}];"
)
# Normalize audio to a common format so concat doesn't choke.
fc.append(
Expand Down Expand Up @@ -204,6 +215,8 @@ def main():
ap.add_argument("--engine", choices=["whisper-py", "whispercpp", "assemblyai"], default=None, help="Transcription engine")
ap.add_argument("--caption-style", default="hormozi", choices=["hormozi", "karaoke", "subtle", "branded"])
ap.add_argument("--crop", default="face", choices=["center", "face", "speaker", "speaker-hardcut"])
ap.add_argument("--format", default="vertical", choices=["vertical", "horizontal", "square"],
help="Output aspect ratio (default: vertical)")
ap.add_argument("--output", default=None, help="Final output path")
# bookends (defaults are None so we can tell what the user explicitly set;
# unset values fall back to the saved brand config, then BRAND_DEFAULTS)
Expand Down Expand Up @@ -256,9 +269,15 @@ def main():
else:
raise SystemExit("Provide either --start/--end or --paragraph")

# The canvas every part is rendered and stitched on. One lookup, so the
# fragment, the bookends and the concat cannot disagree about the shape.
from services.formats import get_format
spec = get_format(args.format)

# 1. Fragment
fragment = _render_fragment(
video, start, end, words, args.caption_style, args.crop, "fragment", out_dir,
fmt=args.format,
)

platforms = [p.strip() for p in platforms_str.split(",") if p.strip()]
Expand All @@ -274,6 +293,7 @@ def main():
intro = _render_bookend(
"intro", intro_title, handle, platforms,
args.intro_seconds, os.path.join(out_dir, "_intro.mp4"), accent, bg,
width=spec.width, height=spec.height,
)
parts.append(intro)

Expand All @@ -284,6 +304,7 @@ def main():
outro = _render_bookend(
"outro", outro_title, handle, platforms,
args.outro_seconds, os.path.join(out_dir, "_outro.mp4"), accent, bg,
width=spec.width, height=spec.height,
)
parts.append(outro)

Expand All @@ -297,7 +318,7 @@ def main():
import shutil
shutil.copy(parts[0], final)
else:
_concat(parts, final)
_concat(parts, final, width=spec.width, height=spec.height)

dur = _probe_duration(final)
print(f"\n ✓ DONE {final}")
Expand Down
149 changes: 149 additions & 0 deletions tests/test_studio_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Tests for `podcli studio --format`.

The studio pipeline renders three things and stitches them: the fragment, the
two bookend cards, and the concat that joins them. All three had the vertical
canvas baked in, so the shape has to reach every one of them or the result is a
correctly-shaped fragment pillarboxed onto a 1080x1920 canvas.

These assert the wiring rather than the pixels: that the shape survives the
hand-off to the render script, that it defaults to what the command did before,
and that each stage is handed the canvas the format spec names.
"""

import argparse
import os
import sys
import unittest
from unittest import mock

ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
BACKEND_ROOT = os.path.join(ROOT, "backend")
if BACKEND_ROOT not in sys.path:
sys.path.insert(0, BACKEND_ROOT)

import cli as cli_mod
from services.formats import get_format


def _studio_args(**overrides):
"""The namespace `podcli studio` builds, with only the shape worth varying."""
args = argparse.Namespace(
video="video.mp4", save_brand=False, start=0.0, end=10.0, paragraph=None,
language=None, engine=None, assemblyai_api_key=None,
caption_style="hormozi", crop="face", format="vertical",
intro_seconds=2.0, outro_seconds=3.0, outro_title=None, platforms=None,
accent=None, bg=None, intro_title=None, handle=None, output=None,
no_intro=True, no_outro=True,
)
for key, value in overrides.items():
setattr(args, key, value)
return args


def _run_studio(args):
"""Run cmd_studio with the render script stubbed, and return its argv."""
with mock.patch("subprocess.run") as run:
run.return_value = mock.Mock(returncode=0)
with mock.patch.object(cli_mod.os.path, "exists", return_value=True):
with self_exit():
cli_mod.cmd_studio(args)
return run.call_args[0][0]


class self_exit:
"""cmd_studio ends by handing the script's exit code back up."""

def __enter__(self):
return self

def __exit__(self, exc_type, exc, tb):
return exc_type is SystemExit


class StudioFormatTests(unittest.TestCase):
def test_the_shape_reaches_the_render_script(self):
cmd = _run_studio(_studio_args(format="square"))
self.assertIn("--format", cmd)
self.assertEqual(cmd[cmd.index("--format") + 1], "square")

def test_defaults_to_the_shape_it_always_produced(self):
cmd = _run_studio(_studio_args())
self.assertEqual(cmd[cmd.index("--format") + 1], "vertical")

def test_a_caller_that_names_no_shape_still_gets_one(self):
"""Older callers build this namespace without a format at all."""
args = _studio_args()
del args.format
cmd = _run_studio(args)
self.assertEqual(cmd[cmd.index("--format") + 1], "vertical")


class StudioCanvasTests(unittest.TestCase):
"""Each stage must be handed the canvas, and it must be the spec's."""

def test_bookend_is_rendered_at_the_canvas_it_is_given(self):
import clip_studio

with mock.patch.object(clip_studio.subprocess, "run") as run:
run.return_value = mock.Mock(returncode=0, stderr="")
with mock.patch.object(clip_studio.os.path, "exists", return_value=True):
clip_studio._render_bookend(
"intro", "Title", "@handle", ["tiktok"], 2.0, "/tmp/x.mp4",
"#FFE000", "#0B0B0F", width=1080, height=1080,
)

cmd = run.call_args[0][0]
self.assertEqual(cmd[cmd.index("--width") + 1], "1080")
self.assertEqual(cmd[cmd.index("--height") + 1], "1080")

def test_bookend_still_defaults_to_the_vertical_canvas(self):
import clip_studio

with mock.patch.object(clip_studio.subprocess, "run") as run:
run.return_value = mock.Mock(returncode=0, stderr="")
with mock.patch.object(clip_studio.os.path, "exists", return_value=True):
clip_studio._render_bookend(
"outro", "Title", None, ["tiktok"], 3.0, "/tmp/x.mp4",
"#FFE000", "#0B0B0F",
)

cmd = run.call_args[0][0]
self.assertEqual(cmd[cmd.index("--width") + 1], "1080")
self.assertEqual(cmd[cmd.index("--height") + 1], "1920")

def test_concat_normalizes_onto_the_canvas_it_is_given(self):
import clip_studio

with mock.patch.object(clip_studio.subprocess, "run") as run:
run.return_value = mock.Mock(returncode=0, stderr="")
with mock.patch.object(clip_studio.os.path, "exists", return_value=True):
clip_studio._concat(["/a.mp4", "/b.mp4"], "/out.mp4",
width=1920, height=1080)

cmd = run.call_args[0][0]
graph = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("scale=1920:1080", graph)
self.assertIn("pad=1920:1080", graph)
self.assertNotIn("1080:1920", graph)

def test_concat_still_defaults_to_the_vertical_canvas(self):
import clip_studio

with mock.patch.object(clip_studio.subprocess, "run") as run:
run.return_value = mock.Mock(returncode=0, stderr="")
with mock.patch.object(clip_studio.os.path, "exists", return_value=True):
clip_studio._concat(["/a.mp4", "/b.mp4"], "/out.mp4")

cmd = run.call_args[0][0]
graph = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("scale=1080:1920", graph)

def test_every_shape_names_a_canvas(self):
for shape in ("vertical", "horizontal", "square"):
spec = get_format(shape)
self.assertGreater(spec.width, 0)
self.assertGreater(spec.height, 0)


if __name__ == "__main__":
unittest.main()