diff --git a/backend/cli.py b/backend/cli.py index 27dd014..057cfc8 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -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), ] @@ -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) diff --git a/backend/clip_studio.py b/backend/clip_studio.py index 7a2c9b9..66de96b 100644 --- a/backend/clip_studio.py +++ b/backend/clip_studio.py @@ -119,13 +119,13 @@ 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), @@ -133,13 +133,18 @@ def _render_fragment(video, start, end, words, style, crop, title, out_dir): 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] @@ -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 = [] @@ -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( @@ -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) @@ -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()] @@ -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) @@ -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) @@ -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}") diff --git a/tests/test_studio_format.py b/tests/test_studio_format.py new file mode 100644 index 0000000..3d2a876 --- /dev/null +++ b/tests/test_studio_format.py @@ -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()