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
21 changes: 21 additions & 0 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +585 to +586

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not overwrite the preset fade when the option is absent.

--bookend-fade defaults to 0.0. Therefore Line 585 always replaces a preset bookend_fade value with a hard cut.

Use default=None in the argument definition. Keep the existing is not None check so only an explicit CLI option overrides the preset.

Proposed fix
-proc.add_argument("--bookend-fade", dest="bookend_fade", type=float, default=0.0,
+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)")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cli.py` around lines 585 - 586, Update the --bookend-fade argument
definition to use default=None, preserving the existing args.bookend_fade is not
None guard in the configuration update so preset bookend_fade values are
overridden only when the CLI option is explicitly supplied.

if getattr(args, "thumbnails", None) is not None:
config["generate_thumbnails"] = args.thumbnails
if args.top:
Expand Down Expand Up @@ -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),
Comment on lines +1067 to +1068

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Forward overlay settings during re-renders.

The initial render receives name_card and bookend_fade. The interactive re-render at Lines 1270-1287 omits both arguments. After any review edit, the replacement clip loses the name card and reverts bookend joins to a hard cut.

Pass the same two configuration values to every re-render generate_clip call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cli.py` around lines 1067 - 1068, Update every interactive re-render
generate_clip call to pass the same config.get("name_card") and
config.get("bookend_fade", 0.0) values already used by the initial render,
preserving overlay settings after review edits.

keep_segments=clip.get("segments"),
face_map=face_map,
allow_ass_fallback=config.get("allow_ass_fallback", False),
Expand Down Expand Up @@ -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")
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
22 changes: 18 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,
) -> tuple[bool, Optional[str]]:
"""
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Comment on lines +923 to +924

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not silently drop name_card on the ASS render path.

name_card is sent only to Remotion. If use_ass_captions is enabled, or Remotion fails and ASS fallback is allowed, Lines 934-958 emit a clip without the requested lower third. --fast enables this path.

Render an equivalent name card in the fallback path. If that is not supported, reject this option combination instead of producing a clip that omits the requested overlay.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/clip_generator.py` around lines 923 - 924, Update the ASS
fallback path around the clip-generation logic at lines 934-958 so a supplied
name_card is rendered as an equivalent lower-third overlay. Ensure this applies
when use_ass_captions is enabled and when Remotion falls back to ASS, including
--fast; if ASS cannot support name_card, reject the option combination before
generating a clip rather than silently omitting it.

keep_caption_overlay=keep_caption_overlay,
)

Expand All @@ -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,
Expand Down Expand Up @@ -970,15 +982,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 @@ -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,
};
Expand Down
13 changes: 11 additions & 2 deletions 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 } from "./types";

export interface CaptionedClipProps {
Expand All @@ -12,14 +15,18 @@ 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<CaptionedClipProps> = ({
words,
style,
logoSrc,
faceY,
nameCard,
}) => {
const { height } = useVideoConfig();
const CaptionComponent = {
hormozi: HormoziCaptions,
karaoke: KaraokeCaptions,
Expand All @@ -29,11 +36,13 @@ export const CaptionedClip: React.FC<CaptionedClipProps> = ({

return (
<AbsoluteFill style={{ backgroundColor: "transparent" }}>
<Watermark src={logoSrc} height={height} />
{style.name === "branded" ? (
<BrandedCaptions words={words} style={style} logoSrc={logoSrc} faceY={faceY} />
<BrandedCaptions words={words} style={style} faceY={faceY} />
) : (
<CaptionComponent words={words} style={style} />
)}
{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 @@ -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 = () => {
Expand All @@ -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,
}}
/>
<Composition
Expand Down
18 changes: 0 additions & 18 deletions remotion/src/components/BrandedCaptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import {
useCurrentFrame,
useVideoConfig,
spring,
Img,
staticFile,
} from "remotion";
import type { Word, CaptionStyle } from "../types";
import { captionScale } from "../types";
Expand All @@ -13,7 +11,6 @@ import { buildChunks, activeChunkAt } from "../chunks";
interface Props {
words: Word[];
style: CaptionStyle;
logoSrc?: string;
faceY?: number | null; // normalized 0-1 (0=top, 1=bottom)
}

Expand Down Expand Up @@ -115,7 +112,6 @@ const CaptionLine: React.FC<{
export const BrandedCaptions: React.FC<Props> = ({
words,
style,
logoSrc,
faceY,
}) => {
const frame = useCurrentFrame();
Expand Down Expand Up @@ -147,20 +143,6 @@ export const BrandedCaptions: React.FC<Props> = ({

return (
<>
{logoSrc && (
<Img
src={logoSrc.startsWith("http") ? logoSrc : staticFile(logoSrc)}
style={{
position: "absolute",
top: 180 * s,
left: 108 * s,
width: 255 * s,
height: 126 * s,
objectFit: "contain",
}}
/>
)}

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

Expand Down
90 changes: 90 additions & 0 deletions remotion/src/components/NameCard.tsx
Original file line number Diff line number Diff line change
@@ -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<NameCardProps> = ({
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;
Comment on lines +41 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unmount the card after its configured duration.

If seconds is shorter than the fade ramps, fadeInOut() returns the rising opacity only. After it reaches 1, this component remains visible for the rest of the clip instead of ending at seconds.

Check the current frame against seconds * fps before rendering. Also validate or clamp unsupported short durations.

Proposed fix
   const opacity = fadeInOut({
     frame, fps, start: 0, end: seconds, inFrames: 8, outFrames: 10,
   });
-  if (opacity <= 0) return null;
+  if (frame >= Math.round(seconds * fps) || opacity <= 0) return null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const opacity = fadeInOut({
frame, fps, start: 0, end: seconds, inFrames: 8, outFrames: 10,
});
if (opacity <= 0) return null;
const opacity = fadeInOut({
frame, fps, start: 0, end: seconds, inFrames: 8, outFrames: 10,
});
if (frame >= Math.round(seconds * fps) || opacity <= 0) return null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@remotion/src/components/NameCard.tsx` around lines 41 - 44, Update the
rendering logic in NameCard around fadeInOut so frames at or beyond seconds *
fps return null, ensuring the card unmounts at its configured duration. Also
validate or clamp seconds so durations shorter than the fade ramps cannot
produce unsupported fade behavior.


return (
<div
style={{
position: "absolute",
left: 0,
bottom: 620 * s,
maxWidth: "78%",
padding: `${18 * s}px ${28 * s}px ${16 * s}px`,
background,
borderBottom: `${10 * s}px solid ${accent}`,
opacity,
// Rises the last few pixels as it arrives, which reads as arriving
// rather than appearing.
transform: `translateY(${(1 - opacity) * 12 * s}px)`,
}}
>
<div
style={{
fontFamily: "'DM Sans', sans-serif",
fontSize: 44 * s,
fontWeight: 700,
lineHeight: 1.2,
color,
}}
>
{title}
</div>
{subtitle && (
<div
style={{
fontFamily: "'DM Sans', sans-serif",
fontSize: 38 * s,
fontWeight: 400,
lineHeight: 1.25,
marginTop: 4 * s,
color,
opacity: 0.85,
}}
>
{subtitle}
</div>
)}
</div>
);
};
Loading