feat: implement Remotion image composition with dynamic text, audio, and branding, and a server-side rendering API.

This commit is contained in:
Michael committed 2026-03-25 12:53:52 +01:00
1 parent 1957445225
commit 0c525665d8
2 files changed
+77 -135

No files matched your search

+65 -120
View File
@@ -15,66 +15,14 @@ export type ImageCompositionProps = {
musicVolume?: number; musicVolume?: number;
logoUrl?: string; logoUrl?: string;
storeName?: string; storeName?: string;
endingFrames?: number; // frames reserved for ending slide (default 90 = 3s at 30fps) endingFrames?: number;
}; };
// 6 modern, simple entrance transitions // Sub-component: one image displayed directly, no entrance transition
type Transition = "fade" | "slideLeft" | "slideRight" | "zoomIn" | "zoomOut" | "slideUp"; const ImageSlide: React.FC<{ src: string }> = ({ src }) => {
const TRANSITIONS: Transition[] = ["zoomIn", "slideLeft", "fade", "slideRight", "zoomOut", "slideUp"];
// Deterministic "random" — varied order that never repeats two in a row
const TRANSITION_ORDER = [0, 1, 2, 3, 4, 5, 1, 3, 0, 4, 2, 5];
function pickTransition(index: number): Transition {
return TRANSITIONS[TRANSITION_ORDER[index % TRANSITION_ORDER.length]];
}
// Sub-component: one image with its entrance spring animation
const ImageSlide: React.FC<{ src: string; transition: Transition }> = ({ src, transition }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame, fps, config: { damping: 22, stiffness: 160 }, durationInFrames: 22 });
let wrapStyle: React.CSSProperties = {};
switch (transition) {
case "fade":
wrapStyle = { opacity: progress };
break;
case "slideLeft":
wrapStyle = {
opacity: interpolate(progress, [0, 0.4], [0, 1], { extrapolateRight: "clamp" }),
transform: `translateX(${interpolate(progress, [0, 1], [110, 0])}%)`,
};
break;
case "slideRight":
wrapStyle = {
opacity: interpolate(progress, [0, 0.4], [0, 1], { extrapolateRight: "clamp" }),
transform: `translateX(${interpolate(progress, [0, 1], [-110, 0])}%)`,
};
break;
case "slideUp":
wrapStyle = {
opacity: interpolate(progress, [0, 0.4], [0, 1], { extrapolateRight: "clamp" }),
transform: `translateY(${interpolate(progress, [0, 1], [80, 0])}%)`,
};
break;
case "zoomIn":
wrapStyle = {
opacity: interpolate(progress, [0, 0.5], [0, 1], { extrapolateRight: "clamp" }),
transform: `scale(${interpolate(progress, [0, 1], [0.75, 1])})`,
};
break;
case "zoomOut":
wrapStyle = {
opacity: interpolate(progress, [0, 0.5], [0, 1], { extrapolateRight: "clamp" }),
transform: `scale(${interpolate(progress, [0, 1], [1.25, 1])})`,
};
break;
}
return ( return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden", ...wrapStyle }}> <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden", backgroundColor: "black" }}>
<Img src={src} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} /> <Img src={src} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</AbsoluteFill> </AbsoluteFill>
); );
}; };
@@ -86,7 +34,6 @@ export const ImageComposition = ({
const { fps, durationInFrames } = useVideoConfig(); const { fps, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame(); const frame = useCurrentFrame();
// Reserve last endingFrames for the ending slide; spread images across the rest
const hasEnding = !!(logoUrl || storeName); const hasEnding = !!(logoUrl || storeName);
const effectiveEndingFrames = hasEnding ? endingFrames : 0; const effectiveEndingFrames = hasEnding ? endingFrames : 0;
const contentFrames = durationInFrames - effectiveEndingFrames; const contentFrames = durationInFrames - effectiveEndingFrames;
@@ -101,16 +48,17 @@ export const ImageComposition = ({
durationInFrames: 30, durationInFrames: 30,
}); });
// Current active word (wordTimings only contains spoken words — no hashtags/emojis)
const activeWordIdx = wordTimings const activeWordIdx = wordTimings
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame) ? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
: -1; : -1;
return ( return (
<AbsoluteFill style={{ backgroundColor: "black" }}> <AbsoluteFill style={{ backgroundColor: "black" }}>
{/* Images with entrance transitions */} {/* Images — displayed immediately, no entrance transition */}
{images.map((imgUrl, index) => ( {images.map((imgUrl, index) => (
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}> <Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}>
<ImageSlide src={imgUrl} transition={pickTransition(index)} /> <ImageSlide src={imgUrl} />
</Sequence> </Sequence>
))} ))}
@@ -120,73 +68,70 @@ export const ImageComposition = ({
{/* Background music */} {/* Background music */}
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />} {musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
{/* TikTok-style 3-words-at-a-time overlay (content section only) */} {/* Word overlay — TikTok style, 3 spoken words at a time, synced to TTS */}
{frame < endingStart && ( {frame < endingStart && wordTimings && wordTimings.length > 0 && activeWordIdx >= 0 && (() => {
wordTimings && wordTimings.length > 0 && activeWordIdx >= 0 ? (() => { const groupIdx = Math.floor(activeWordIdx / 3);
// Group index: which chunk of 3 is currently active const groupStart = groupIdx * 3;
const groupIdx = Math.floor(activeWordIdx / 3); const groupWords = wordTimings.slice(groupStart, groupStart + 3);
const groupStart = groupIdx * 3; return (
const groupWords = wordTimings.slice(groupStart, groupStart + 3); <AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
return ( <div
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}> style={{
<div maxWidth: "88%",
style={{ textAlign: "center",
maxWidth: "88%", display: "flex",
textAlign: "center", flexWrap: "wrap",
display: "flex", justifyContent: "center",
flexWrap: "wrap", gap: "0 18px",
justifyContent: "center", alignItems: "baseline",
gap: "0 18px", filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
alignItems: "baseline", }}
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))", >
}} {groupWords.map((w, gi) => {
> const isActive = groupStart + gi === activeWordIdx;
{groupWords.map((w, gi) => { return (
const isActive = groupStart + gi === activeWordIdx; <span
return ( key={groupStart + gi}
<span style={{
key={groupStart + gi} color: isActive ? "#FFE600" : "white",
style={{ fontFamily: "'Arial Black', 'Impact', sans-serif",
color: isActive ? "#FFE600" : "white", fontSize: 76,
fontFamily: "'Arial Black', 'Impact', sans-serif", fontWeight: 900,
fontSize: 76, lineHeight: 1.2,
fontWeight: 900, WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.8)" : "2px rgba(0,0,0,0.7)",
lineHeight: 1.2, textShadow: "0 4px 20px rgba(0,0,0,0.9)",
WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.8)" : "2px rgba(0,0,0,0.7)", display: "inline-block",
textShadow: "0 4px 20px rgba(0,0,0,0.9)", textTransform: "uppercase",
display: "inline-block", transform: isActive ? "scale(1.08)" : "scale(1)",
textTransform: "uppercase", }}
transform: isActive ? "scale(1.08)" : "scale(1)", >
}} {w.word}
> </span>
{w.word} );
</span> })}
); </div>
})} </AbsoluteFill>
</div> );
</AbsoluteFill> })()}
);
})() : null
)}
{/* Logo watermark — bottom right, always visible during content */} {/* Logo watermark — bottom right during content */}
{logoUrl && frame < endingStart && ( {logoUrl && frame < endingStart && (
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}> <AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}>
<Img <Img
src={logoUrl} src={logoUrl}
style={{ style={{
width: 130, width: 150,
height: 130, height: 150,
objectFit: "contain", objectFit: "contain",
opacity: 0.82, opacity: 0.85,
borderRadius: 16, borderRadius: 20,
filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.5))", filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.5))",
}} }}
/> />
</AbsoluteFill> </AbsoluteFill>
)} )}
{/* Ending slide — centered logo + store name */} {/* Ending slide — large centered logo + store name */}
{hasEnding && ( {hasEnding && (
<Sequence from={endingStart} durationInFrames={effectiveEndingFrames}> <Sequence from={endingStart} durationInFrames={effectiveEndingFrames}>
<AbsoluteFill <AbsoluteFill
@@ -195,20 +140,20 @@ export const ImageComposition = ({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
flexDirection: "column", flexDirection: "column",
gap: 40, gap: 48,
}} }}
> >
{logoUrl && ( {logoUrl && (
<Img <Img
src={logoUrl} src={logoUrl}
style={{ style={{
width: 300, width: 520,
height: 300, height: 520,
objectFit: "contain", objectFit: "contain",
opacity: endingProgress, opacity: endingProgress,
transform: `scale(${interpolate(endingProgress, [0, 1], [0.65, 1])})`, transform: `scale(${interpolate(endingProgress, [0, 1], [0.65, 1])})`,
borderRadius: 24, borderRadius: 32,
filter: "drop-shadow(0 4px 24px rgba(255,255,255,0.15))", filter: "drop-shadow(0 4px 32px rgba(255,255,255,0.2))",
}} }}
/> />
)} )}
@@ -216,7 +161,7 @@ export const ImageComposition = ({
<div <div
style={{ style={{
color: "white", color: "white",
fontSize: 80, fontSize: 90,
fontFamily: "'Arial Black', Impact, sans-serif", fontFamily: "'Arial Black', Impact, sans-serif",
fontWeight: 900, fontWeight: 900,
textAlign: "center", textAlign: "center",
+12 -15
View File
@@ -54,8 +54,8 @@ function stripForTTS(text: string): string {
} }
/** /**
* Calculates word timings (in frames at 30fps) based on audio duration. * Calculates word timings (in frames) for SPOKEN words only (no hashtags, no emojis).
* Each word appears only when spoken, then disappears (no accumulation). * Distributes audio duration evenly across words → tight sync with TTS voice.
*/ */
function computeWordTimings( function computeWordTimings(
displayText: string, displayText: string,
@@ -63,22 +63,19 @@ function computeWordTimings(
fps: number, fps: number,
startFrame: number startFrame: number
): Array<{ word: string; startFrame: number; endFrame: number }> { ): Array<{ word: string; startFrame: number; endFrame: number }> {
const displayWords = displayText.split(/\s+/).filter(Boolean); // Only keep words that the TTS will actually speak
const ttsText = stripForTTS(displayText); const ttsText = stripForTTS(displayText);
const ttsWords = ttsText.split(/\s+/).filter(Boolean); const spokenWords = ttsText.split(/\s+/).filter(Boolean);
if (spokenWords.length === 0) return [];
const totalTTSFrames = audioDurationSeconds * fps; const totalFrames = audioDurationSeconds * fps;
const framesPerTTSWord = ttsWords.length > 0 ? totalTTSFrames / ttsWords.length : fps; const framesPerWord = totalFrames / spokenWords.length;
let currentFrame = startFrame; return spokenWords.map((word, i) => ({
word,
return displayWords.map((word) => { startFrame: startFrame + Math.round(i * framesPerWord),
const wordStart = currentFrame; endFrame: startFrame + Math.round((i + 1) * framesPerWord),
const isSpoken = !/^#/.test(word) && !(/[\uD800-\uDFFF\u2600-\u27BF]/.test(word)); }));
const frameDuration = isSpoken ? framesPerTTSWord : framesPerTTSWord * 0.4;
currentFrame += Math.round(frameDuration);
return { word, startFrame: wordStart, endFrame: currentFrame };
});
} }
// Duration constants // Duration constants