From 5d614eda57a089bb489323760dbecb5a5b66c4e7 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Mon, 10 Aug 2026 09:56:41 +0400 Subject: [PATCH] The logo goes on every clip, the outro plays whole, and a guest has a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things measured against a published short that was cut with this very renderer. None of them needed a new feature; all four were the renderer not doing what it was told. `--logo` was accepted, resolved from the asset store, and then dropped unless the caption style happened to be "branded". Two gates did it: a `logo_support` flag in the style config, and the fact that the logo was drawn inside the branded caption component. A logo belongs to the show, not to a caption style. Both gates are gone, the mark moved one level up into CaptionedClip at exactly its old position, and a branded render is byte-identical, frame hash for frame hash. Subtle captions faded in and then cut, so every chunk boundary flickered: the outgoing line vanished on the frame the incoming one started at zero. They now ramp down inside their own window, which crossfades without moving a single caption timing. Skipped on chunks too short to hold full opacity. An intro or an outro was joined with 0.8 seconds of crossfade through black, which ate three quarters of a second of both. Six seconds of clip plus two of outro came out at 7.23. A designed bookend should be cut to; the fade is now a number, and it defaults to none. And a clip lifted out of an hour of conversation opens on a stranger, so there is a lower third: name, role, an accent underline, gone after three seconds. `--name-card`, `--name-card-sub`. Two regression tests, because this class of bug is silent: one asserts no caption style may gate the logo, the other that the name card reaches the renderer at all. The suite already caught one live mistake here — a parameter used in a body whose signature never got it, swallowed by the surrounding except. --- backend/cli.py | 21 +++++ backend/config/caption_styles.py | 4 - backend/services/clip_generator.py | 22 ++++- remotion/render.mjs | 12 +++ remotion/src/CaptionedClip.tsx | 13 ++- remotion/src/Root.tsx | 9 +++ remotion/src/components/BrandedCaptions.tsx | 18 ----- remotion/src/components/NameCard.tsx | 90 +++++++++++++++++++++ remotion/src/components/SubtleCaptions.tsx | 10 +-- remotion/src/components/Watermark.tsx | 31 +++++++ remotion/src/motion.ts | 42 ++++++++++ tests/test_clip_generator.py | 64 +++++++++++++++ 12 files changed, 302 insertions(+), 34 deletions(-) create mode 100644 remotion/src/components/NameCard.tsx create mode 100644 remotion/src/components/Watermark.tsx create mode 100644 remotion/src/motion.ts diff --git a/backend/cli.py b/backend/cli.py index 27dd014..df3692a 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -575,6 +575,15 @@ def cmd_process(args): config["format"] = args.format if getattr(args, "profile", None): config["profile"] = args.profile + if getattr(args, "name_card", None): + config["name_card"] = { + "title": args.name_card, + "subtitle": getattr(args, "name_card_sub", None), + "seconds": getattr(args, "name_card_seconds", None), + "accent": getattr(args, "name_card_accent", None), + } + if getattr(args, "bookend_fade", None) is not None: + config["bookend_fade"] = args.bookend_fade if getattr(args, "thumbnails", None) is not None: config["generate_thumbnails"] = args.thumbnails if args.top: @@ -1055,6 +1064,8 @@ def _transcribe_progress(pct, msg): logo_path=config.get("logo_path") or None, outro_path=config.get("outro_path") or None, intro_path=config.get("intro_path") or None, + name_card=config.get("name_card"), + bookend_fade=config.get("bookend_fade", 0.0), keep_segments=clip.get("segments"), face_map=face_map, allow_ass_fallback=config.get("allow_ass_fallback", False), @@ -3989,6 +4000,16 @@ def main(): proc.add_argument("--profile", choices=["podcast", "party", "action"], help="Detection profile: podcast (transcript-first, default), party/action (laughter/energy highlights)") 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("--name-card", dest="name_card", + help="Lower third naming the speaker, shown for the first seconds") + proc.add_argument("--name-card-sub", dest="name_card_sub", + help="Second line of the lower third") + proc.add_argument("--name-card-seconds", dest="name_card_seconds", type=float, + help="How long the lower third holds (default 3)") + proc.add_argument("--name-card-accent", dest="name_card_accent", + help="Underline colour on the lower third") + proc.add_argument("--bookend-fade", dest="bookend_fade", type=float, default=0.0, + help="Seconds of crossfade into an intro or outro (default 0, a cut)") proc.add_argument("--no-outro", action="store_true", help="Do not append an outro (default for highlight profiles)") proc.add_argument("--intro", help="Intro video (asset name or path)") proc.add_argument("--time-adjust", type=float, help="Timestamp offset in seconds") diff --git a/backend/config/caption_styles.py b/backend/config/caption_styles.py index e0ae80b..edf4276 100644 --- a/backend/config/caption_styles.py +++ b/backend/config/caption_styles.py @@ -74,7 +74,6 @@ def _detect_font() -> str: "words_per_chunk": 3, # Show 3 words at a time "uppercase": True, "gradient_overlay": False, - "logo_support": False, }, "karaoke": { "description": "Full sentence visible, words highlight progressively", @@ -92,7 +91,6 @@ def _detect_font() -> str: "words_per_chunk": 5, "uppercase": False, "gradient_overlay": False, - "logo_support": False, }, "subtle": { "description": "Clean white text at bottom with shadow, professional look", @@ -110,7 +108,6 @@ def _detect_font() -> str: "words_per_chunk": 5, "uppercase": False, "gradient_overlay": False, - "logo_support": False, }, "branded": { "description": "Large bold text, 5-7 words wrapping across 2 lines, dark rounded pill on active word. Clean, no gradient.", @@ -134,7 +131,6 @@ def _detect_font() -> str: "uppercase": False, # Mixed case, natural capitalization "gradient_overlay": False, # No gradient — clean direct-on-video "gradient_opacity": 0.0, - "logo_support": True, # Logo top-left "logo_margin_x": 40, # Logo X offset from left "logo_margin_y": 60, # Logo Y offset from top "logo_height": 100, # Logo height in px diff --git a/backend/services/clip_generator.py b/backend/services/clip_generator.py index 40d7f13..44f0e1c 100644 --- a/backend/services/clip_generator.py +++ b/backend/services/clip_generator.py @@ -465,6 +465,7 @@ def _render_with_remotion( output_path: str, time_offset: float = 0.0, logo_path: Optional[str] = None, + name_card: Optional[dict] = None, keep_caption_overlay: bool = False, ) -> tuple[bool, Optional[str]]: """ @@ -590,6 +591,14 @@ def _render_with_remotion( ] if logo_path and os.path.exists(logo_path): cmd.extend(["--logo", os.path.abspath(logo_path)]) + if name_card and name_card.get("title"): + cmd.extend(["--name-card", str(name_card["title"])]) + if name_card.get("subtitle"): + cmd.extend(["--name-card-sub", str(name_card["subtitle"])]) + if name_card.get("seconds"): + cmd.extend(["--name-card-seconds", str(name_card["seconds"])]) + if name_card.get("accent"): + cmd.extend(["--name-card-accent", str(name_card["accent"])]) if keep_caption_overlay: cmd.append("--keep-overlay") @@ -652,6 +661,8 @@ def generate_clip( logo_path: Optional[str] = None, outro_path: Optional[str] = None, intro_path: Optional[str] = None, + name_card: Optional[dict] = None, + bookend_fade: float = 0.0, clean_fillers: bool = True, keep_segments: list[dict] = None, trim_opening: Optional[bool] = None, @@ -909,7 +920,8 @@ def generate_clip( caption_style=caption_style, output_path=captioned_path, time_offset=caption_time_offset, - logo_path=logo_path if (style_config.get("logo_support", False) and logo_path) else None, + logo_path=logo_path or None, + name_card=name_card, keep_caption_overlay=keep_caption_overlay, ) @@ -931,7 +943,7 @@ def generate_clip( use_gradient = style_config.get("gradient_overlay", False) gradient_opacity = style_config.get("gradient_opacity", 0.6) - use_logo = style_config.get("logo_support", False) and logo_path + use_logo = bool(logo_path) burn_captions( input_path=cropped_path, @@ -970,7 +982,8 @@ def generate_clip( intro_scaled = os.path.join(work_dir, "intro_scaled.mp4") scale_to_frame(intro_path, intro_scaled, cw, ch) with_intro_path = os.path.join(work_dir, "with_intro.mp4") - concat_outro(intro_scaled, final_video_path, with_intro_path) + concat_outro(intro_scaled, final_video_path, with_intro_path, + crossfade_duration=bookend_fade) final_video_path = with_intro_path if outro_path and os.path.exists(outro_path): @@ -978,7 +991,8 @@ def generate_clip( progress_callback(85, f"Adding outro ({total_steps}/{total_steps})") with_outro_path = os.path.join(work_dir, "with_outro.mp4") - concat_outro(final_video_path, outro_path, with_outro_path) + concat_outro(final_video_path, outro_path, with_outro_path, + crossfade_duration=bookend_fade) final_video_path = with_outro_path # Step 6: Move to output diff --git a/remotion/render.mjs b/remotion/render.mjs index ecbc9c8..7e0fecf 100644 --- a/remotion/render.mjs +++ b/remotion/render.mjs @@ -143,12 +143,24 @@ async function main() { const durationSec = videoDuration || (lastWord ? lastWord.end + 0.5 : 30); const durationInFrames = Math.ceil(durationSec * fps); + // Who is speaking, for the lower third. Sent whole so the composition takes + // one prop rather than six loose ones. + const nameCard = opts["name-card"] + ? { + title: opts["name-card"], + subtitle: opts["name-card-sub"] || undefined, + seconds: opts["name-card-seconds"] ? parseFloat(opts["name-card-seconds"]) : undefined, + accent: opts["name-card-accent"] || undefined, + } + : null; + const inputProps = { videoSrc, words, styleName, logoSrc, faceY, + nameCard, durationInFrames, fps, }; diff --git a/remotion/src/CaptionedClip.tsx b/remotion/src/CaptionedClip.tsx index 72050b4..f99577d 100644 --- a/remotion/src/CaptionedClip.tsx +++ b/remotion/src/CaptionedClip.tsx @@ -1,9 +1,12 @@ import React from "react"; -import { AbsoluteFill } from "remotion"; +import { AbsoluteFill, useVideoConfig } from "remotion"; import { HormoziCaptions } from "./components/HormoziCaptions"; import { KaraokeCaptions } from "./components/KaraokeCaptions"; import { SubtleCaptions } from "./components/SubtleCaptions"; import { BrandedCaptions } from "./components/BrandedCaptions"; +import { NameCard } from "./components/NameCard"; +import type { NameCardProps } from "./components/NameCard"; +import { Watermark } from "./components/Watermark"; import type { Word, CaptionStyle } from "./types"; export interface CaptionedClipProps { @@ -12,6 +15,8 @@ export interface CaptionedClipProps { style: CaptionStyle; logoSrc?: string; faceY?: number | null; + /** Who is speaking, shown for the first few seconds. */ + nameCard?: NameCardProps | null; } export const CaptionedClip: React.FC = ({ @@ -19,7 +24,9 @@ export const CaptionedClip: React.FC = ({ style, logoSrc, faceY, + nameCard, }) => { + const { height } = useVideoConfig(); const CaptionComponent = { hormozi: HormoziCaptions, karaoke: KaraokeCaptions, @@ -29,11 +36,13 @@ export const CaptionedClip: React.FC = ({ return ( + {style.name === "branded" ? ( - + ) : ( )} + {nameCard?.title && } ); }; diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx index 0cc2512..ded44ee 100644 --- a/remotion/src/Root.tsx +++ b/remotion/src/Root.tsx @@ -41,6 +41,14 @@ const inputProps = getInputProps() as { bookendPlatforms?: string[]; bookendBg?: string; bookendAccent?: string; + nameCard?: { + title: string; + subtitle?: string; + seconds?: number; + background?: string; + color?: string; + accent?: string; + } | null; }; export const RemotionRoot: React.FC = () => { @@ -62,6 +70,7 @@ export const RemotionRoot: React.FC = () => { style: STYLES[inputProps.styleName || "branded"], logoSrc: inputProps.logoSrc, faceY: inputProps.faceY ?? null, + nameCard: inputProps.nameCard ?? null, }} /> = ({ words, style, - logoSrc, faceY, }) => { const frame = useCurrentFrame(); @@ -147,20 +143,6 @@ export const BrandedCaptions: React.FC = ({ return ( <> - {logoSrc && ( - - )} - {activeChunk && (() => { const [line1, line2] = splitIntoLines(activeChunk.words); diff --git a/remotion/src/components/NameCard.tsx b/remotion/src/components/NameCard.tsx new file mode 100644 index 0000000..21044b8 --- /dev/null +++ b/remotion/src/components/NameCard.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { useCurrentFrame, useVideoConfig } from "remotion"; +import { captionScale } from "../types"; +import { fadeInOut } from "../motion"; + +export interface NameCardProps { + /** Who is speaking, and what they are. One line each. */ + title: string; + subtitle?: string; + /** How long it stays, from the top of the clip. */ + seconds?: number; + background?: string; + color?: string; + accent?: string; +} + +/** + * The lower third that says who this is. + * + * A clip lifted out of an hour of conversation opens on a stranger. Every + * show solves it the same way and podcli had no answer at all, so the name + * card was drawn somewhere else and burned in by hand. + * + * Anchored to the bottom rather than centred, sized off the composition the + * way captions are, and gone by the time anyone would tire of it. + */ +export const NameCard: React.FC = ({ + title, + subtitle, + seconds = 3, + background = "rgba(0,0,0,0.85)", + color = "#FFFFFF", + accent = "#2ED9C3", +}) => { + const frame = useCurrentFrame(); + const { fps, height } = useVideoConfig(); + const s = captionScale(height); + + if (!title) return null; + + const opacity = fadeInOut({ + frame, fps, start: 0, end: seconds, inFrames: 8, outFrames: 10, + }); + if (opacity <= 0) return null; + + return ( +
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+ ); +}; diff --git a/remotion/src/components/SubtleCaptions.tsx b/remotion/src/components/SubtleCaptions.tsx index 3f985dd..afe7f36 100644 --- a/remotion/src/components/SubtleCaptions.tsx +++ b/remotion/src/components/SubtleCaptions.tsx @@ -3,6 +3,7 @@ import { useCurrentFrame, useVideoConfig, interpolate } from "remotion"; import type { Word, CaptionStyle } from "../types"; import { captionScale } from "../types"; import { buildChunks, activeChunkAt } from "../chunks"; +import { fadeInOut } from "../motion"; interface Props { words: Word[]; @@ -31,12 +32,9 @@ export const SubtleCaptions: React.FC = ({ words, style }) => { if (!activeChunk) return null; const entryFrame = Math.round(activeChunk.start * fps); - const opacity = interpolate( - frame - entryFrame, - [0, 5], - [0, 1], - { extrapolateRight: "clamp" } - ); + const opacity = fadeInOut({ + frame, fps, start: activeChunk.start, end: activeChunk.end, inFrames: 5, outFrames: 5, + }); // Slight upward slide on entry const translateY = interpolate( diff --git a/remotion/src/components/Watermark.tsx b/remotion/src/components/Watermark.tsx new file mode 100644 index 0000000..de5d7d1 --- /dev/null +++ b/remotion/src/components/Watermark.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Img, staticFile } from "remotion"; +import { captionScale } from "../types"; + +/** + * The show's logo, on every clip. + * + * It used to live inside the branded caption component, which meant `--logo` + * did nothing at all on the other three styles: the flag was accepted, the + * file was resolved, and the renderer dropped it. Same position and size as + * before, one level up, so a branded render is unchanged and the rest finally + * carry the mark they were told to. + */ +export const Watermark: React.FC<{ src?: string; height: number }> = ({ src, height }) => { + if (!src) return null; + const s = captionScale(height); + + return ( + + ); +}; diff --git a/remotion/src/motion.ts b/remotion/src/motion.ts new file mode 100644 index 0000000..8ea4c15 --- /dev/null +++ b/remotion/src/motion.ts @@ -0,0 +1,42 @@ +import { interpolate } from "remotion"; + +/** + * Fade a thing in when it arrives and out before it leaves. + * + * Captions faded in and then cut, which reads as a flicker at every chunk + * boundary: the outgoing line vanishes on the same frame the incoming one + * starts at zero. Ramping the tail down inside the chunk's own window turns + * that into a crossfade without moving a single caption timing. + * + * The out ramp is skipped on a chunk too short to hold full opacity, since a + * line that fades in and straight back out never reads at all. + */ +export function fadeInOut({ + frame, fps, start, end, inFrames = 5, outFrames = 5, +}: { + frame: number; + fps: number; + /** Seconds, on the composition's clock. */ + start: number; + end: number; + inFrames?: number; + outFrames?: number; +}): number { + const startFrame = Math.round(start * fps); + const endFrame = Math.round(end * fps); + + const rising = interpolate(frame - startFrame, [0, inFrames], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const held = endFrame - startFrame; + if (!outFrames || held < inFrames + outFrames + 2) return rising; + + const falling = interpolate(frame, [endFrame - outFrames, endFrame], [1, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + return Math.min(rising, falling); +} diff --git a/tests/test_clip_generator.py b/tests/test_clip_generator.py index 2aca949..b6ef7ee 100644 --- a/tests/test_clip_generator.py +++ b/tests/test_clip_generator.py @@ -41,6 +41,70 @@ def test_kept_caption_overlay_path_matches_remotion_contract(self): self.assertTrue(expected.endswith("_captions.mov")) self.assertIn("captioned", expected) + def _render_args(self, **kwargs): + """The argv one Remotion render was invoked with.""" + real_exists = os.path.exists + ok = subprocess.CompletedProcess(args=["node"], returncode=0, stdout="", stderr="") + + with tempfile.TemporaryDirectory() as td: + video_path = os.path.join(td, "video.mp4") + output_path = os.path.join(td, "captioned.mp4") + logo_path = os.path.join(td, "logo.png") + for path in (video_path, logo_path): + with open(path, "wb"): + pass + + with mock.patch.object(cg.os.path, "exists", side_effect=self._fake_exists(real_exists)), \ + mock.patch.object(cg.shutil, "which", return_value="/usr/bin/node"), \ + mock.patch("subprocess.run", return_value=ok) as mock_run: + cg._render_with_remotion( + video_path=video_path, + words=[{"word": "hello", "start": 0.0, "end": 0.5}], + output_path=output_path, + logo_path=logo_path, + **kwargs, + ) + + for call in mock_run.call_args_list: + argv = call.args[0] if call.args else call.kwargs.get("args", []) + if any(str(part).endswith("render.mjs") for part in argv): + return [str(part) for part in argv] + return [] + + def test_logo_reaches_the_renderer_whatever_the_caption_style(self): + """A logo belongs to the show, not to one caption style. + + `--logo` was accepted, resolved and then dropped for every style except + branded, so three of the four rendered no watermark at all. + """ + for style in ("branded", "hormozi", "karaoke", "subtle"): + argv = self._render_args(caption_style=style) + self.assertIn("--logo", argv, f"{style} lost the logo") + + def test_no_caption_style_gates_the_logo(self): + """The gate that dropped it lived in the style config, so guard that. + + Every style carried `logo_support`, and only branded said true. A logo + is the show's; if a per-style opt-out comes back, three quarters of + renders silently lose their watermark again. + """ + from config.caption_styles import STYLES + + for name, config in STYLES.items(): + self.assertNotIn( + "logo_support", config, + f"{name} decides whether the show's logo is drawn", + ) + + def test_name_card_reaches_the_renderer(self): + argv = self._render_args( + caption_style="subtle", + name_card={"title": "Jamie Gull", "subtitle": "Wave Function Ventures"}, + ) + self.assertIn("--name-card", argv) + self.assertIn("Jamie Gull", argv) + self.assertIn("--name-card-sub", argv) + def test_remotion_runtime_failure_does_not_disable_future_clips(self): real_exists = os.path.exists fail_result = subprocess.CompletedProcess(