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
12 changes: 6 additions & 6 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3923,7 +3923,7 @@ def print_help():
print(f" {green}-n{reset}, {green}--top{reset} {gray}<N>{reset} Export top N clips {dim}(default: 5){reset}")
print(f" {green}-o{reset}, {green}--output{reset} {gray}<dir>{reset} Output directory {dim}(default: ./clips){reset}")
print(f" {green}-p{reset}, {green}--preset{reset} {gray}<name>{reset} Load a saved preset")
print(f" {green}--caption-style{reset} {gray}<style>{reset} branded | hormozi | karaoke | subtle")
print(f" {green}--caption-style{reset} {gray}<style>{reset} branded | hormozi | karaoke | outline | subtle")
print(f" {green}--crop{reset} {gray}<strategy>{reset} speaker | speaker-hardcut | face | center")
print(f" {green}--fast{reset} Draft mode: tiny Whisper, heuristic clips, low quality")
print(f" {green}--logo{reset} {gray}<asset|path>{reset} Overlay logo image")
Expand Down Expand Up @@ -4470,7 +4470,7 @@ def main():
help="Where the thumbnail goes in the video itself. "
"off keeps the pictures and leaves the video alone (default: start)")
proc.add_argument("--template", help="Cut in a saved look (podcli Pro). Name or id.")
proc.add_argument("--caption-style", choices=["branded", "hormozi", "karaoke", "subtle"])
proc.add_argument("--caption-style", choices=["branded", "hormozi", "karaoke", "subtle", "outline"])
proc.add_argument("--caption-position", choices=["auto", "upper", "center", "lower"],
help="Caption placement (default: follows the chosen style)")
proc.add_argument("--caption-scale", type=float, choices=[0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5],
Expand Down Expand Up @@ -4563,7 +4563,7 @@ def main():
studio.add_argument("--language", help="Transcription language (e.g. es). Auto-detect if omitted.")
studio.add_argument("--engine", choices=["whisper-py", "whispercpp", "assemblyai"], help="Transcription engine")
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("--caption-style", choices=["hormozi", "karaoke", "subtle", "branded", "outline"], default="hormozi")
studio.add_argument("--caption-position", choices=["auto", "upper", "center", "lower"], default="auto")
studio.add_argument("--caption-scale", type=float, default=1.0)
studio.add_argument("--crop", choices=["center", "face", "speaker", "speaker-hardcut", "manual"], default="face")
Expand Down Expand Up @@ -4633,7 +4633,7 @@ def main():
pre_save.add_argument("--video", help="Default video path")
pre_save.add_argument("--transcript", help="Default transcript path")
pre_save.add_argument("--output", help="Default output directory")
pre_save.add_argument("--caption-style", choices=["branded", "hormozi", "karaoke", "subtle"])
pre_save.add_argument("--caption-style", choices=["branded", "hormozi", "karaoke", "subtle", "outline"])
pre_save.add_argument("--crop", choices=["center", "face", "speaker", "speaker-hardcut"])
pre_save.add_argument("--logo", help="Logo (asset name or path)")
pre_save.add_argument("--outro", help="Outro (asset name or path)")
Expand Down Expand Up @@ -4787,7 +4787,7 @@ def main():
clips_edit.add_argument("clip_id", help="Clip id (full or 8-char prefix)")
clips_edit.add_argument("--title", help="New title")
clips_edit.add_argument(
"--caption-style", choices=["branded", "hormozi", "karaoke", "subtle"], help="New caption style"
"--caption-style", choices=["branded", "hormozi", "karaoke", "subtle", "outline"], help="New caption style"
)
clips_edit.add_argument("--thumbnail-config", help="Per-clip thumbnail config as a JSON string")
clips_reopen = clips_sub.add_parser(
Expand Down Expand Up @@ -5761,7 +5761,7 @@ def _interactive_presets():
# Caption style
caption_style = questionary.select(
"Caption style:",
choices=["branded", "hormozi", "karaoke", "subtle"],
choices=["branded", "hormozi", "karaoke", "subtle", "outline"],
default=config.get("caption_style", "branded"),
style=qstyle,
).ask()
Expand Down
2 changes: 1 addition & 1 deletion backend/clip_studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ def main():
ap.add_argument("--language", default=None, help="Transcription language (e.g. es). Auto-detect if omitted.")
ap.add_argument("--engine", choices=["whisper-py", "whispercpp", "assemblyai"], default=None, help="Transcription engine")
ap.add_argument("--transcript", default=None, help="Word timings JSON for this video (list of {word,start,end} or {words:[...]}); skips transcription")
ap.add_argument("--caption-style", default="hormozi", choices=["hormozi", "karaoke", "subtle", "branded"])
ap.add_argument("--caption-style", default="hormozi", choices=["hormozi", "karaoke", "subtle", "branded", "outline"])
Comment thread
nmbrthirteen marked this conversation as resolved.
ap.add_argument("--caption-position", default="auto", choices=["auto", "upper", "center", "lower"])
ap.add_argument("--caption-scale", type=float, default=1.0)
ap.add_argument("--crop", default="face", choices=["center", "face", "speaker", "speaker-hardcut"])
Expand Down
17 changes: 17 additions & 0 deletions backend/config/caption_styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,23 @@ def _detect_font() -> str:
"uppercase": False,
"gradient_overlay": False,
},
"outline": {
"description": "A whole line at a time in white with a heavy black outline, no box. Stays readable without covering the shot.",
"font_name": DETECTED_FONT,
"font_size": 78,
"primary_color": "&H00FFFFFF", # White
"active_color": None, # The line is the unit, no sweep
"outline_color": "&H00000000", # Black
"back_color": "&H00000000", # Transparent: the outline does the work
"bold": True,
"outline_width": 6, # Heavy enough to carry over any footage
"shadow_depth": 0, # An outline and a shadow together read as mud
"alignment": 2,
"margin_v": 460, # Clears the app's own title and handle
"words_per_chunk": 7,
"uppercase": False,
"gradient_overlay": False,
},
"branded": {
"description": "Large bold text, 5-7 words wrapping across 2 lines, dark rounded pill on active word. Clean, no gradient.",
"font_name": DETECTED_FONT,
Expand Down
4 changes: 2 additions & 2 deletions backend/services/caption_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,15 @@ def render_captions(
style = dict(get_style(caption_style))
scale = max(60, min(160, int(caption_font_scale))) / 100
style["font_size"] = round(style["font_size"] * scale)
position_margins = {"upper": 760, "center": 480, "lower": 220}
position_margins = {"upper": 1120, "center": 820, "lower": 460}
if caption_position in position_margins:
style["margin_v"] = position_margins[caption_position]

if caption_style == "hormozi":
content = _render_hormozi(words, style, time_offset)
elif caption_style == "karaoke":
content = _render_karaoke(words, style, time_offset)
elif caption_style == "subtle":
elif caption_style in ("subtle", "outline"):
content = _render_subtle(words, style, time_offset)
elif caption_style == "branded":
content = _render_branded(words, style, time_offset)
Expand Down
2 changes: 1 addition & 1 deletion cli/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.7.33
2.7.34
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "podcli",
"version": "2.7.33",
"version": "2.7.34",
"private": true,
"description": "AI-powered podcast clip generator for TikTok/YouTube Shorts. Transcribe, find viral moments, export vertical clips with burned captions.",
"type": "module",
Expand Down
125 changes: 97 additions & 28 deletions remotion/src/CaptionedClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import { TopicChip } from "./components/TopicChip";
import type { TopicChipProps } from "./components/TopicChip";
import { ProgressBar } from "./components/ProgressBar";
import type { ProgressBarProps } from "./components/ProgressBar";
import { Cards, CARD_CAPTION_MARGIN } from "./components/Cards";
import { Cards } from "./components/Cards";
import { cardAt } from "./cards";
import type { Card } from "./cards";
import type { Brand } from "./components/Cards";
import { MOTION } from "./motion";
import { MOTION, motionAt } from "./motion";
import type { Motion } from "./motion";
import {
brandCaptions, captionZone, LOGO_CAPTION_GAP, LOGO_HEIGHT, LOGO_INSET, safeFor,
} from "./types";
import type { Word, CaptionStyle, CaptionPosition, LogoPosition } from "./types";

export interface CaptionedClipProps {
Expand Down Expand Up @@ -68,37 +71,95 @@ export const CaptionedClip: React.FC<CaptionedClipProps> = ({
brand,
motion,
}) => {
const { fps, height } = useVideoConfig();
const { fps, height, width } = useVideoConfig();
const SAFE = safeFor(width, height);
const frame = useCurrentFrame();

/*
* Captions drop toward the bottom edge while a card holds the frame.
*
* Their usual margin keeps them clear of a speaker's chin and hands. There
* is no chin down there when a card is up, so the margin is only empty
* surface, and it was the single biggest thing pushing the speaker's band
* short enough to cut a face in half.
*/
const nameCardSeconds = nameCard?.title ? (nameCard.seconds ?? 3) : 0;
const pastNameCard = frame / fps >= nameCardSeconds;
const cardUp = pastNameCard && Boolean(cards?.length) && Boolean(cardAt(cards ?? [], frame / fps));
const cardPlanned = Boolean(cards?.length);
const captionShrink = cardUp ? 0.6 : cardPlanned ? 0.75 : 1;
const baseCaptionStyle: CaptionStyle = cardUp
? { ...style, marginBottom: Math.min(style.marginBottom, CARD_CAPTION_MARGIN) }
: style;
// Keep a simple four-stop placement model. It is easier to reason about
// than pixels, and stays proportional across every output shape.
const placementMargin = captionPosition === "upper" ? 760
: captionPosition === "center" ? 480
: captionPosition === "lower" ? 220
: baseCaptionStyle.marginBottom;
const upNow = pastNameCard ? cardAt(cards ?? [], frame / fps) : null;
/*
* How far in the card is, rather than whether it is.
*
* The card cross-fades over six frames and the captions used to answer on
* the frame it started: shrinking and jumping while the thing they were
* getting out of the way of was still arriving. Reading the card's own fade
* means the two move together, so a card coming up looks like one move
* instead of a card fading under captions that already snapped.
*/
const cardIn = upNow
? motionAt({
frame, fps, start: upNow.start, end: upNow.end,
motion: upNow.motion ?? MOTION.card,
}).opacity
: 0;
const restingShrink = cardPlanned ? 0.75 : 1;
const captionShrink = restingShrink + (0.6 - restingShrink) * cardIn;
/*
* Captions used to drop toward the bottom edge while a card held the frame,
* on the reasoning that there is no chin down there to clear. There is no
* chin, but there is a YouTube title, a handle and a link chip, and the
* margin that bought the speaker a taller band was spending the one part of
* the frame the viewer never sees. They hold above the chrome now, and the
* band the speaker lost is taken off its own floor instead.
*/
/*
* A four-stop placement model, easier to reason about than pixels.
*
* The three named stops are fractions of the frame and travel between
* shapes on their own. Auto does not. A style's own margin was authored
* against a 1920-tall phone, and the same number on a 1080-tall landscape
* frame is half the picture. Off a phone, auto means as low as the
* furniture allows.
*/
const placementMargin = captionPosition === "upper" ? 1120
: captionPosition === "center" ? 820
: captionPosition === "lower" ? SAFE.bottom
: width < height ? style.marginBottom : SAFE.bottom;
const captionStyle: CaptionStyle = {
...baseCaptionStyle,
marginBottom: placementMargin,
fontSize: baseCaptionStyle.fontSize * captionSize * captionShrink,
...brandCaptions(style, brand),
marginBottom: Math.max(placementMargin, SAFE.bottom),
fontSize: style.fontSize * captionSize * captionShrink,
};

/*
* What the card lays itself out against.
*
* Not the caption that is halfway through shrinking. The reserved band is a
* function of the caption's size, so handing the card the interpolating one
* made its whole body creep and resize through the six frames of its own
* fade. It reserves for the settled size instead: the card holds still and
* only the captions move.
*/
const settledStyle: CaptionStyle = {
...captionStyle,
fontSize: style.fontSize * captionSize * 0.6,
};

/*
* Who owns the top corners, so nothing else writes into them.
*
* The logo and the chip both default to the same corner at the same inset,
* which drew one straight through the other on any clip carrying both. The
* chip drops below the logo when they collide, and a card given the whole
* frame is told how much of the top is already spoken for.
*/
const logoTop = Boolean(logoSrc) && logoPosition.startsWith("top-");
const chipTop = Boolean(topic?.label)
&& (topic?.position ?? "top-left").startsWith("top-");
const chipClashes = logoTop
&& chipTop
&& (topic?.position ?? "top-left").slice(4) === logoPosition.slice(4);
const chipInset = LOGO_INSET
+ (chipClashes ? LOGO_HEIGHT * logoScale + LOGO_CAPTION_GAP : 0);
const CHIP_HEIGHT = 48;
const topTaken = Math.max(
SAFE.top,
logoTop ? LOGO_INSET + LOGO_HEIGHT * logoScale + LOGO_CAPTION_GAP : 0,
chipTop ? chipInset + CHIP_HEIGHT + LOGO_CAPTION_GAP : 0,
);

const captionMotion: Motion = {
...(MOTION[style.name] ?? MOTION.subtle), ...(motion?.captions ?? {}),
};
Expand All @@ -108,6 +169,7 @@ export const CaptionedClip: React.FC<CaptionedClipProps> = ({
karaoke: KaraokeCaptions,
subtle: SubtleCaptions,
branded: BrandedCaptions,
outline: SubtleCaptions,
}[style.name];

return (
Expand All @@ -118,7 +180,8 @@ export const CaptionedClip: React.FC<CaptionedClipProps> = ({
cards={cards}
videoSrc={videoSrc}
startFrom={startFrom}
style={captionStyle}
style={settledStyle}
topInset={topTaken}
faceY={faceY}
faceH={faceH}
brand={brand}
Expand All @@ -133,8 +196,14 @@ export const CaptionedClip: React.FC<CaptionedClipProps> = ({
<CaptionComponent words={words} style={captionStyle} motion={captionMotion}
singleLine={singleLine} />
)}
{nameCard?.title && <NameCard {...nameCard} motion={cardMotion} />}
{topic?.label && <TopicChip {...topic} />}
{nameCard?.title && (
<NameCard
{...nameCard}
bottom={nameCard.bottom ?? captionZone(captionStyle) + 24}
motion={cardMotion}
/>
)}
{topic?.label && <TopicChip {...topic} inset={chipInset} />}
{progress && <ProgressBar {...progress} />}
</AbsoluteFill>
);
Expand Down
32 changes: 30 additions & 2 deletions remotion/src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ interface CardBase {
* could read at half the height.
*/
speaker?: null;
/**
* Whether the card is a surface laid over the shot or ink written on it.
*
* "panel" is the default and the safe one: an opaque band, legible over any
* footage because none of it shows through. "over" keeps the whole shot and
* writes into it, which is what an interview channel does when the frame
* has room to spare. It costs the guarantee: a card written over a bright
* kitchen needs the scrim to do work a panel did for free, so it is a
* choice somebody makes about a shot rather than a default.
*/
place?: "panel" | "over";
}

/**
Expand All @@ -42,8 +53,13 @@ export type Card =
kind: "headline";
eyebrow?: string;
lead: string;
/** The payload word, set apart from the lead. */
emphasis?: string;
/**
* The payload words, set apart from the lead.
*
* Coloured where they stand when they are in the lead, appended when
* they are not. See the text block for why both.
*/
emphasis?: string | string[];
sub?: string;
})
| (CardBase & { kind: "bullets"; eyebrow?: string; items: string[] })
Expand Down Expand Up @@ -110,6 +126,16 @@ export type Card =
* and is for a photograph, where the edges carry nothing.
*/
fit?: "fit" | "fill";
/**
* Whether the file takes the whole frame rather than a band of it.
*
* Only ever honoured once the card has given up the speaker, because a
* cutaway that leaves the person on screen is a smaller picture with
* extra steps. A band too short to read falls back to this on its own:
* a letterbox slit of a photograph says nothing while still costing the
* frame it sits in.
*/
bleed?: boolean;
caption?: string;
})
/**
Expand Down Expand Up @@ -139,6 +165,8 @@ export type Card =
fit?: "fit" | "fill";
/** Where in the file to start, in seconds. */
startAt?: number;
/** The whole frame rather than the band. See the image card. */
bleed?: boolean;
caption?: string;
});

Expand Down
6 changes: 4 additions & 2 deletions remotion/src/components/BrandedCaptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ const WordWithPill: React.FC<{
frame: number;
fps: number;
emphasisColor?: string;
}> = ({ word, isActive, frame, fps, emphasisColor }) => {
background?: string;
}> = ({ word, isActive, frame, fps, emphasisColor, background }) => {
const { height } = useVideoConfig();
const s = captionScale(height);
const wordEntryFrame = Math.round(word.start * fps);
Expand All @@ -61,7 +62,7 @@ const WordWithPill: React.FC<{
left: -16 * s,
right: -16 * s,
bottom: -4 * s,
backgroundColor: "rgba(0, 0, 0, 0.85)",
backgroundColor: background ?? "rgba(0, 0, 0, 0.85)",
borderRadius: 18 * s,
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.5)",
opacity: pillOpacity,
Expand Down Expand Up @@ -120,6 +121,7 @@ const CaptionLine: React.FC<{
frame={frame}
fps={fps}
emphasisColor={style.emphasisColor}
background={style.background}
/>
</React.Fragment>
);
Expand Down
Loading