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
23 changes: 23 additions & 0 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if getattr(args, "thumbnails", None) is not None:
config["generate_thumbnails"] = args.thumbnails
if args.top:
Expand Down Expand Up @@ -1056,6 +1065,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),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
keep_segments=clip.get("segments"),
face_map=face_map,
allow_ass_fallback=config.get("allow_ass_fallback", False),
Expand Down Expand Up @@ -1274,6 +1285,8 @@ def _transcribe_progress(pct, msg):
face_map=face_map,
allow_ass_fallback=config.get("allow_ass_fallback", False),
use_ass_captions=config.get("use_ass_captions", False),
name_card=config.get("name_card"),
bookend_fade=config.get("bookend_fade", 0.0),
)
results[-1] = result
print(f" ✓ Re-rendered: {result['file_size_mb']}MB")
Expand Down Expand Up @@ -3990,6 +4003,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=None,
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")
Expand Down
4 changes: 0 additions & 4 deletions backend/config/caption_styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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.",
Expand All @@ -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
Expand Down
37 changes: 33 additions & 4 deletions backend/services/clip_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
caption_position: str = "auto",
caption_font_scale: int = 100,
Expand Down Expand Up @@ -596,6 +597,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")

Expand Down Expand Up @@ -661,6 +670,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,
Expand Down Expand Up @@ -886,6 +897,21 @@ def generate_clip(
else:
fit_to_frame(segment_path, cropped_path, target_dims=spec.dims)

# Only Remotion draws the name card, and it is reached only when there
# are words to caption. Refusing beats returning a clip that quietly
# lacks the overlay that was asked for.
if name_card and name_card.get("title"):
if use_ass_captions or allow_ass_fallback:
raise ValueError(
"A name card can only be drawn by the Remotion renderer. "
"Drop --name-card, or drop --fast and --allow-ass-fallback."
)
if not transcript_words:
raise ValueError(
"A name card needs the caption renderer, which only runs when "
"there is a transcript. Drop --name-card for an uncaptioned render."
)

# Step 3: Render captions (Remotion-first; ASS fallback optional)
if transcript_words:
if progress_callback:
Expand Down Expand Up @@ -918,7 +944,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,
Comment thread
nmbrthirteen marked this conversation as resolved.
keep_caption_overlay=keep_caption_overlay,
caption_position=caption_position,
caption_font_scale=caption_font_scale,
Expand All @@ -945,7 +972,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,
Expand Down Expand Up @@ -985,15 +1012,17 @@ 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):
if progress_callback:
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
Expand Down
12 changes: 12 additions & 0 deletions remotion/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,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,
captionPosition: opts["caption-position"] || "auto",
Expand Down
11 changes: 10 additions & 1 deletion remotion/src/CaptionedClip.tsx
Original file line number Diff line number Diff line change
@@ -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, CaptionPosition, LogoPosition } from "./types";

export interface CaptionedClipProps {
Expand All @@ -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;
captionPosition?: CaptionPosition;
logoPosition?: LogoPosition;
singleLine?: boolean;
Expand All @@ -22,10 +27,12 @@ export const CaptionedClip: React.FC<CaptionedClipProps> = ({
style,
logoSrc,
faceY,
nameCard,
captionPosition = "auto",
logoPosition = "top-left",
singleLine = false,
}) => {
const { height } = useVideoConfig();
const CaptionComponent = {
hormozi: HormoziCaptions,
karaoke: KaraokeCaptions,
Expand All @@ -35,12 +42,14 @@ export const CaptionedClip: React.FC<CaptionedClipProps> = ({

return (
<AbsoluteFill style={{ backgroundColor: "transparent" }}>
<Watermark src={logoSrc} height={height} position={logoPosition} />
{style.name === "branded" ? (
<BrandedCaptions words={words} style={style} logoSrc={logoSrc} faceY={faceY}
captionPosition={captionPosition} logoPosition={logoPosition} singleLine={singleLine} />
) : (
<CaptionComponent words={words} style={style} singleLine={singleLine} />
)}
{nameCard?.title && <NameCard {...nameCard} />}
</AbsoluteFill>
);
};
9 changes: 9 additions & 0 deletions remotion/src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,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 = () => {
Expand Down Expand Up @@ -80,6 +88,7 @@ export const RemotionRoot: React.FC = () => {
style,
logoSrc: inputProps.logoSrc,
faceY: inputProps.faceY ?? null,
nameCard: inputProps.nameCard ?? null,
captionPosition,
logoPosition: inputProps.logoPosition || "top-left",
singleLine: inputProps.singleLine === true,
Expand Down
29 changes: 3 additions & 26 deletions remotion/src/components/BrandedCaptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ import {
useCurrentFrame,
useVideoConfig,
spring,
Img,
staticFile,
} from "remotion";
import type { Word, CaptionStyle, CaptionPosition, LogoPosition } from "../types";
import { captionScale } from "../types";
import { buildChunks, activeChunkAt, splitCaptionLines } from "../chunks";
import { LOGO_CAPTION_GAP, LOGO_HEIGHT, LOGO_INSET } from "./Watermark";

interface Props {
words: Word[];
style: CaptionStyle;
// The logo is drawn by Watermark now, but the caption margin still has to
// know it is there.
logoSrc?: string;
faceY?: number | null; // normalized 0-1 (0=top, 1=bottom)
captionPosition?: CaptionPosition;
Expand All @@ -22,12 +23,6 @@ interface Props {

const MAX_CHARS_PER_CHUNK = 18;

// Unscaled logo box, shared by the logo style and the caption-margin guard so
// the two cannot drift apart.
const LOGO_INSET = 180;
const LOGO_HEIGHT = 126;
const LOGO_CAPTION_GAP = 24;

/**
* Active pill rendered as an absolutely positioned background behind the word.
* The word itself is always rendered as plain inline text so layout doesn't shift.
Expand Down Expand Up @@ -161,24 +156,6 @@ export const BrandedCaptions: React.FC<Props> = ({

return (
<>
{logoSrc && (
<Img
src={logoSrc.startsWith("http") ? logoSrc : staticFile(logoSrc)}
style={{
position: "absolute",
...(logoPosition.startsWith("top-") ? { top: LOGO_INSET * s } : { bottom: LOGO_INSET * s }),
...(logoPosition.endsWith("-left")
? { left: 108 * s }
: logoPosition.endsWith("-right")
? { right: 108 * s }
: { left: "50%", transform: "translateX(-50%)" }),
width: 255 * s,
height: LOGO_HEIGHT * s,
objectFit: "contain",
}}
/>
)}

{activeChunk && (() => {
const [line1, line2] = splitCaptionLines(activeChunk.words, 2, singleLine);

Expand Down
Loading