feat: Add Remotion video rendering API and composition for dynamic video generation from images, text, and audio.

This commit is contained in:
Michael committed 2026-03-25 16:52:58 +01:00
1 parent 56210687a2
commit 487a8ee352
2 files changed
+182 -55

No files matched your search

+174 -51
View File
@@ -18,15 +18,126 @@ export type ImageCompositionProps = {
endingFrames?: number; endingFrames?: number;
}; };
// Sub-component: one image displayed directly, no entrance transition /**
const ImageSlide: React.FC<{ src: string }> = ({ src }) => { * Ken Burns presets — deterministic per image index.
* Each entry: starting transform → ending transform over the slide duration.
* Scale > 1 to avoid black edges when translating.
*/
const KB_PRESETS = [
// zoom in, center
{ fromScale: 1.0, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
// zoom in + drift right
{ fromScale: 1.06, toScale: 1.18, fromX: "-3%", toX: "3%", fromY: "0%", toY: "0%" },
// zoom in + drift left
{ fromScale: 1.06, toScale: 1.18, fromX: "3%", toX: "-3%", fromY: "0%", toY: "0%" },
// zoom out, center
{ fromScale: 1.16, toScale: 1.04, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
// pan up + slight zoom
{ fromScale: 1.1, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "3%", toY: "-3%" },
// pan down + zoom in
{ fromScale: 1.06, toScale: 1.16, fromX: "0%", toX: "0%", fromY: "-3%", toY: "3%" },
// diagonal drift bottom-right
{ fromScale: 1.08, toScale: 1.18, fromX: "-2%", toX: "2%", fromY: "-2%", toY: "2%" },
// diagonal drift top-left + zoom out
{ fromScale: 1.18, toScale: 1.06, fromX: "2%", toX: "-2%", fromY: "2%", toY: "-2%" },
];
/**
* ImageSlide with Ken Burns effect (slow zoom / pan).
* The effect index is derived from the image index for deterministic behaviour in Remotion.
*/
const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effectIndex }) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const preset = KB_PRESETS[effectIndex % KB_PRESETS.length];
// progress: 0 → 1 over the slide duration, using easeInOut via interpolate extrapolate clamp
const progress = interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: "clamp" });
const scale = interpolate(progress, [0, 1], [preset.fromScale, preset.toScale]);
const transX = interpolate(progress, [0, 1], [parseFloat(preset.fromX), parseFloat(preset.toX)]);
const transY = interpolate(progress, [0, 1], [parseFloat(preset.fromY), parseFloat(preset.toY)]);
return ( return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden", backgroundColor: "black" }}> <AbsoluteFill style={{ overflow: "hidden", backgroundColor: "black" }}>
<Img src={src} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> <Img
src={src}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale}) translate(${transX}%, ${transY}%)`,
transformOrigin: "center center",
willChange: "transform",
}}
/>
</AbsoluteFill> </AbsoluteFill>
); );
}; };
/**
* CaptionGroup — renders a group of words (CapCut-style).
* Active word is highlighted in yellow with glow; others are white.
* Background: semi-transparent dark pill.
*/
const CaptionGroup: React.FC<{
words: WordTiming[];
activeIdx: number; // index within this group (0-3), -1 if none active
groupFirstFrame: number;
fps: number;
}> = ({ words, activeIdx, groupFirstFrame, fps }) => {
const frame = useCurrentFrame();
// Fade in when this group first appears
const fadeIn = spring({
frame: frame - groupFirstFrame,
fps,
config: { damping: 30, stiffness: 120 },
durationInFrames: 10,
});
return (
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
alignItems: "center",
gap: "0 22px",
maxWidth: "90%",
opacity: fadeIn,
}}
>
{words.map((w, i) => {
const isActive = i === activeIdx;
return (
<span
key={i}
style={{
fontFamily: "'Arial Black', 'Impact', 'Helvetica Neue', sans-serif",
fontSize: isActive ? 82 : 74,
fontWeight: 900,
lineHeight: 1.15,
color: isActive ? "#FFE600" : "white",
textTransform: "uppercase",
textShadow: isActive
? "0 0 30px rgba(255,230,0,0.6), 0 4px 24px rgba(0,0,0,0.95)"
: "0 3px 18px rgba(0,0,0,0.9)",
WebkitTextStroke: isActive ? "3px rgba(0,0,0,0.85)" : "2px rgba(0,0,0,0.75)",
display: "inline-block",
transform: isActive ? "scale(1.1)" : "scale(1)",
transition: "transform 0.05s",
}}
>
{w.word}
</span>
);
})}
</div>
);
};
export const ImageComposition = ({ export const ImageComposition = ({
images, audioUrl, wordTimings, musicUrl, musicVolume = 0.3, images, audioUrl, wordTimings, musicUrl, musicVolume = 0.3,
logoUrl, storeName, endingFrames = 90, logoUrl, storeName, endingFrames = 90,
@@ -48,17 +159,43 @@ export const ImageComposition = ({
durationInFrames: 30, durationInFrames: 30,
}); });
// Current active word (wordTimings only contains spoken words — no hashtags/emojis) // Group words into sets of 3 for caption display
const WORDS_PER_GROUP = 3;
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;
// Determine which group is currently active
const currentGroupIdx = activeWordIdx >= 0 ? Math.floor(activeWordIdx / WORDS_PER_GROUP) : -1;
// Determine the last group that was visible (for holding display between words)
const lastActiveWordIdx = wordTimings
? [...wordTimings].reverse().findIndex(w => frame >= w.startFrame)
: -1;
const resolvedLastIdx = lastActiveWordIdx >= 0 && wordTimings
? wordTimings.length - 1 - lastActiveWordIdx
: -1;
const displayGroupIdx = activeWordIdx >= 0
? currentGroupIdx
: resolvedLastIdx >= 0
? Math.floor(resolvedLastIdx / WORDS_PER_GROUP)
: -1;
const renderCaption = frame < endingStart && wordTimings && wordTimings.length > 0 && displayGroupIdx >= 0;
const groupStart = displayGroupIdx * WORDS_PER_GROUP;
const groupWords = wordTimings ? wordTimings.slice(groupStart, groupStart + WORDS_PER_GROUP) : [];
const activeIdxInGroup = activeWordIdx >= 0 ? activeWordIdx - groupStart : -1;
// First frame of the current group (for fade-in)
const groupFirstFrame = groupWords.length > 0 ? groupWords[0].startFrame : 0;
return ( return (
<AbsoluteFill style={{ backgroundColor: "black" }}> <AbsoluteFill style={{ backgroundColor: "black" }}>
{/* Images — displayed immediately, no entrance transition */} {/* 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} /> <ImageSlide src={imgUrl} effectIndex={index} />
</Sequence> </Sequence>
))} ))}
@@ -68,51 +205,37 @@ export const ImageComposition = ({
{/* Background music */} {/* Background music */}
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />} {musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
{/* Word overlay — TikTok style, 3 spoken words at a time, synced to TTS */} {/* Caption overlay — CapCut style */}
{frame < endingStart && wordTimings && wordTimings.length > 0 && activeWordIdx >= 0 && (() => { {renderCaption && (
const groupIdx = Math.floor(activeWordIdx / 3); <AbsoluteFill
const groupStart = groupIdx * 3; style={{
const groupWords = wordTimings.slice(groupStart, groupStart + 3); justifyContent: "flex-end",
return ( alignItems: "center",
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}> paddingBottom: 180,
<div }}
style={{ >
maxWidth: "88%", {/* Semi-transparent dark background pill */}
textAlign: "center", <div
display: "flex", style={{
flexWrap: "wrap", backgroundColor: "rgba(0,0,0,0.55)",
justifyContent: "center", borderRadius: 28,
gap: "0 18px", paddingTop: 22,
alignItems: "baseline", paddingBottom: 22,
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))", paddingLeft: 36,
}} paddingRight: 36,
> maxWidth: "92%",
{groupWords.map((w, gi) => { backdropFilter: "blur(4px)",
const isActive = groupStart + gi === activeWordIdx; }}
return ( >
<span <CaptionGroup
key={groupStart + gi} words={groupWords}
style={{ activeIdx={activeIdxInGroup}
color: isActive ? "#FFE600" : "white", groupFirstFrame={groupFirstFrame}
fontFamily: "'Arial Black', 'Impact', sans-serif", fps={fps}
fontSize: 76, />
fontWeight: 900, </div>
lineHeight: 1.2, </AbsoluteFill>
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 during content */} {/* Logo watermark — bottom right during content */}
{logoUrl && frame < endingStart && ( {logoUrl && frame < endingStart && (
+8 -4
View File
@@ -42,13 +42,17 @@ async function getBundle(): Promise<string> {
/** /**
* Strips hashtags and emojis from text for TTS synthesis. * Strips hashtags and emojis from text for TTS synthesis.
* Replaces with a space to avoid concatenating adjacent words. * Uses Unicode-aware regex to handle French accented chars in hashtags
* (e.g. #AménagementExtérieur must be fully removed, not just #Am).
*/ */
function stripForTTS(text: string): string { function stripForTTS(text: string): string {
return text return text
.replace(/#\w+/g, ' ') // hashtags → space (not empty) // Hashtags with Unicode letters (covers all French accented chars)
.replace(/[\uD800-\uDFFF]/g, ' ') // surrogate pairs (emojis) → space .replace(/#[\w\u00C0-\u024F\u1E00-\u1EFF]*/g, ' ')
.replace(/[\u2600-\u27BF]/g, ' ') // misc symbols → space // Emoji: surrogate pairs (covers virtually all emoji in UTF-16 strings)
.replace(/[\uD800-\uDFFF][\uDC00-\uDFFF]/g, ' ') // surrogate pairs (most emojis)
.replace(/[\u2600-\u27BF]/g, ' ') // misc symbols & arrows
.replace(/[\u2B00-\u2BFF]/g, ' ') // misc symbols extended
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .trim();
} }