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
10 changes: 10 additions & 0 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,12 @@ def cmd_process(args):
"seconds": getattr(args, "name_card_seconds", None),
"accent": getattr(args, "name_card_accent", None),
}
if getattr(args, "motion", None):
try:
config["motion"] = json.loads(args.motion)
except (TypeError, ValueError):
print(" Warning: --motion is not valid JSON; using each style's own motion",
file=sys.stderr)
if getattr(args, "bookend_fade", None) is not None:
config["bookend_fade"] = args.bookend_fade
if getattr(args, "thumbnails", None) is not None:
Expand Down Expand Up @@ -1065,6 +1071,7 @@ def _transcribe_progress(pct, msg):
outro_path=config.get("outro_path") or None,
intro_path=config.get("intro_path") or None,
name_card=config.get("name_card"),
motion=config.get("motion"),
bookend_fade=config.get("bookend_fade", 0.0),
keep_segments=clip.get("segments"),
face_map=face_map,
Expand Down Expand Up @@ -4008,6 +4015,9 @@ def main():
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("--motion", dest="motion",
help='How each part arrives and leaves, as JSON: '
'{"captions":{"enter":"rise","exit":"fade","duration":5,"feel":"soft"}}')
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)")
Expand Down
5 changes: 5 additions & 0 deletions backend/services/clip_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ def _render_with_remotion(
time_offset: float = 0.0,
logo_path: Optional[str] = None,
name_card: Optional[dict] = None,
motion: Optional[dict] = None,
keep_caption_overlay: bool = False,
) -> tuple[bool, Optional[str]]:
"""
Expand Down Expand Up @@ -599,6 +600,8 @@ def _render_with_remotion(
cmd.extend(["--name-card-seconds", str(name_card["seconds"])])
if name_card.get("accent"):
cmd.extend(["--name-card-accent", str(name_card["accent"])])
if motion:
cmd.extend(["--motion", json.dumps(motion)])
if keep_caption_overlay:
cmd.append("--keep-overlay")

Expand Down Expand Up @@ -662,6 +665,7 @@ def generate_clip(
outro_path: Optional[str] = None,
intro_path: Optional[str] = None,
name_card: Optional[dict] = None,
motion: Optional[dict] = None,
bookend_fade: float = 0.0,
clean_fillers: bool = True,
keep_segments: list[dict] = None,
Expand Down Expand Up @@ -922,6 +926,7 @@ def generate_clip(
time_offset=caption_time_offset,
logo_path=logo_path or None,
name_card=name_card,
motion=motion,
keep_caption_overlay=keep_caption_overlay,
)

Expand Down
12 changes: 12 additions & 0 deletions remotion/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,25 @@ async function main() {
}
: null;

// How each part arrives and leaves. A JSON object rather than a flag per
// property: it is one value in a template, and it travels as one.
let motion = null;
if (opts.motion) {
try {
motion = JSON.parse(opts.motion);
} catch {
console.error("Ignoring --motion: not valid JSON");
}
}

const inputProps = {
videoSrc,
words,
styleName,
logoSrc,
faceY,
nameCard,
motion,
durationInFrames,
fps,
};
Expand Down
13 changes: 11 additions & 2 deletions remotion/src/CaptionedClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { BrandedCaptions } from "./components/BrandedCaptions";
import { NameCard } from "./components/NameCard";
import type { NameCardProps } from "./components/NameCard";
import { Watermark } from "./components/Watermark";
import { MOTION } from "./motion";
import type { Motion } from "./motion";
import type { Word, CaptionStyle } from "./types";

export interface CaptionedClipProps {
Expand All @@ -17,6 +19,8 @@ export interface CaptionedClipProps {
faceY?: number | null;
/** Who is speaking, shown for the first few seconds. */
nameCard?: NameCardProps | null;
/** Per-part overrides; each part falls back to its style's own motion. */
motion?: { captions?: Partial<Motion>; nameCard?: Partial<Motion> } | null;
}

export const CaptionedClip: React.FC<CaptionedClipProps> = ({
Expand All @@ -25,8 +29,13 @@ export const CaptionedClip: React.FC<CaptionedClipProps> = ({
logoSrc,
faceY,
nameCard,
motion,
}) => {
const { height } = useVideoConfig();
const captionMotion: Motion = {
...(MOTION[style.name] ?? MOTION.subtle), ...(motion?.captions ?? {}),
};
const cardMotion: Motion = { ...MOTION.nameCard, ...(motion?.nameCard ?? {}) };
const CaptionComponent = {
hormozi: HormoziCaptions,
karaoke: KaraokeCaptions,
Expand All @@ -40,9 +49,9 @@ export const CaptionedClip: React.FC<CaptionedClipProps> = ({
{style.name === "branded" ? (
<BrandedCaptions words={words} style={style} faceY={faceY} />
) : (
<CaptionComponent words={words} style={style} />
<CaptionComponent words={words} style={style} motion={captionMotion} />
)}
{nameCard?.title && <NameCard {...nameCard} />}
{nameCard?.title && <NameCard {...nameCard} motion={cardMotion} />}
</AbsoluteFill>
);
};
5 changes: 5 additions & 0 deletions remotion/src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ const inputProps = getInputProps() as {
color?: string;
accent?: string;
} | null;
motion?: {
captions?: Record<string, unknown>;
nameCard?: Record<string, unknown>;
} | null;
};

export const RemotionRoot: React.FC = () => {
Expand All @@ -71,6 +75,7 @@ export const RemotionRoot: React.FC = () => {
logoSrc: inputProps.logoSrc,
faceY: inputProps.faceY ?? null,
nameCard: inputProps.nameCard ?? null,
motion: inputProps.motion ?? null,
}}
/>
<Composition
Expand Down
25 changes: 9 additions & 16 deletions remotion/src/components/HormoziCaptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@ import React from "react";
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
} from "remotion";
import type { Word, CaptionStyle } from "../types";
import { captionScale } from "../types";
import { buildChunks, activeChunkAt } from "../chunks";
import { MOTION, motionAt } from "../motion";
import type { Motion } from "../motion";

interface Props {
words: Word[];
style: CaptionStyle;
motion?: Motion;
}

export const HormoziCaptions: React.FC<Props> = ({ words, style }) => {
export const HormoziCaptions: React.FC<Props> = ({ words, style, motion }) => {
const frame = useCurrentFrame();
const { fps, height, durationInFrames } = useVideoConfig();
const s = captionScale(height);
Expand All @@ -29,21 +30,13 @@ export const HormoziCaptions: React.FC<Props> = ({ words, style }) => {

if (!activeChunk) return null;

const entryFrame = Math.round(activeChunk.start * fps);

const scale = spring({
frame: frame - entryFrame,
fps,
config: { damping: 12, stiffness: 180, mass: 0.5 },
const { opacity, scale } = motionAt({
frame, fps,
start: activeChunk.start,
end: activeChunk.end,
motion: motion ?? MOTION.hormozi,
});

const opacity = interpolate(
frame - entryFrame,
[0, 3],
[0, 1],
{ extrapolateRight: "clamp" }
);

return (
<div
style={{
Expand Down
2 changes: 2 additions & 0 deletions remotion/src/components/KaraokeCaptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { buildChunks, activeChunkAt } from "../chunks";
interface Props {
words: Word[];
style: CaptionStyle;
/** Accepted for one shape across the caption components; karaoke holds still. */
motion?: unknown;
}

function splitIntoLines(words: Word[]): [Word[], Word[]] {
Expand Down
13 changes: 9 additions & 4 deletions remotion/src/components/NameCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from "react";
import { useCurrentFrame, useVideoConfig } from "remotion";
import { captionScale } from "../types";
import { fadeInOut } from "../motion";
import { MOTION, motionAt } from "../motion";
import type { Motion } from "../motion";

export interface NameCardProps {
/** Who is speaking, and what they are. One line each. */
Expand All @@ -12,6 +13,7 @@ export interface NameCardProps {
background?: string;
color?: string;
accent?: string;
motion?: Motion;
}

/**
Expand All @@ -31,15 +33,18 @@ export const NameCard: React.FC<NameCardProps> = ({
background = "rgba(0,0,0,0.85)",
color = "#FFFFFF",
accent = "#2ED9C3",
motion,
}) => {
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,
const { opacity, shift } = motionAt({
frame, fps, start: 0, end: seconds,
motion: motion ?? MOTION.nameCard,
scale: 12 * s,
});
if (opacity <= 0) return null;

Expand All @@ -56,7 +61,7 @@ export const NameCard: React.FC<NameCardProps> = ({
opacity,
// Rises the last few pixels as it arrives, which reads as arriving
// rather than appearing.
transform: `translateY(${(1 - opacity) * 12 * s}px)`,
transform: `translateY(${shift}px)`,
}}
>
<div
Expand Down
25 changes: 11 additions & 14 deletions remotion/src/components/SubtleCaptions.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React from "react";
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
import { useCurrentFrame, useVideoConfig } from "remotion";
import type { Word, CaptionStyle } from "../types";
import { captionScale } from "../types";
import { buildChunks, activeChunkAt } from "../chunks";
import { fadeInOut } from "../motion";
import { MOTION, motionAt } from "../motion";
import type { Motion } from "../motion";

interface Props {
words: Word[];
style: CaptionStyle;
motion?: Motion;
}

function splitIntoLines(words: Word[]): [Word[], Word[]] {
Expand All @@ -16,7 +18,7 @@ function splitIntoLines(words: Word[]): [Word[], Word[]] {
return [words.slice(0, mid), words.slice(mid)];
}

export const SubtleCaptions: React.FC<Props> = ({ words, style }) => {
export const SubtleCaptions: React.FC<Props> = ({ words, style, motion }) => {
const frame = useCurrentFrame();
const { fps, height, durationInFrames } = useVideoConfig();
const s = captionScale(height);
Expand All @@ -31,19 +33,14 @@ export const SubtleCaptions: React.FC<Props> = ({ words, style }) => {

if (!activeChunk) return null;

const entryFrame = Math.round(activeChunk.start * fps);
const opacity = fadeInOut({
frame, fps, start: activeChunk.start, end: activeChunk.end, inFrames: 5, outFrames: 5,
const { opacity, shift: translateY } = motionAt({
frame, fps,
start: activeChunk.start,
end: activeChunk.end,
motion: motion ?? MOTION.subtle,
scale: 8 * s,
});

// Slight upward slide on entry
const translateY = interpolate(
frame - entryFrame,
[0, 6],
[8 * s, 0],
{ extrapolateRight: "clamp" }
);

const [line1, line2] = splitIntoLines(activeChunk.words);
const text1 = line1.map((w) => w.word).join(" ");
const text2 = line2.map((w) => w.word).join(" ");
Expand Down
Loading