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