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(