feat: Implement Remotion video generation feature, allowing users to combine images with text overlay and text-to-speech.

This commit is contained in:
Michael committed 2026-03-24 16:44:06 +01:00
1 parent 4f69a2edda
commit 24a1d39e88
4 files changed
+479 -253

No files matched your search

+75 -26
View File
@@ -1,8 +1,8 @@
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Audio } from "remotion";
import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Html5Audio, spring, interpolate } from "remotion";
export type WordTiming = {
word: string; // display word (may include emoji/hashtag)
startFrame: number; // frame this word becomes active
word: string;
startFrame: number;
endFrame: number;
};
@@ -11,48 +11,100 @@ export type ImageCompositionProps = {
overlayText?: string;
audioUrl?: string;
wordTimings?: WordTiming[];
musicUrl?: string;
musicVolume?: 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;
}
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden", ...wrapStyle }}>
<Img src={src} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
</AbsoluteFill>
);
};
export const ImageComposition = ({
images, overlayText, audioUrl, wordTimings
images, overlayText, audioUrl, wordTimings, musicUrl, musicVolume = 0.3
}: ImageCompositionProps) => {
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
const durationPerImage = 3 * fps;
// Determine which word is currently "active" for TikTok highlight
const activeWordIdx = wordTimings
? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame)
: -1;
// Words displayed up to the current active one (progressive reveal)
const visibleWords = wordTimings
? wordTimings.filter(w => w.startFrame <= frame)
: [];
return (
<AbsoluteFill style={{ backgroundColor: "black" }}>
{/* Images sequence */}
{/* Images with entrance transitions */}
{images.map((imgUrl, index) => (
<Sequence key={index} from={index * durationPerImage} durationInFrames={durationPerImage}>
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<Img src={imgUrl} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
</AbsoluteFill>
<ImageSlide src={imgUrl} transition={pickTransition(index)} />
</Sequence>
))}
{/* TTS Audio */}
{audioUrl && <Audio src={audioUrl} startFrom={0} />}
{audioUrl && <Html5Audio src={audioUrl} />}
{/* Background music */}
{musicUrl && <Html5Audio src={musicUrl} volume={musicVolume} />}
{/* TikTok-style word-by-word text overlay */}
{wordTimings && wordTimings.length > 0 ? (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 120,
}}
>
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}>
<div
style={{
maxWidth: "88%",
@@ -92,10 +144,7 @@ export const ImageComposition = ({
</div>
</AbsoluteFill>
) : overlayText ? (
/* Fallback: static text if no word timings */
<AbsoluteFill
style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}
>
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}>
<div
style={{
backgroundColor: "rgba(0,0,0,0.55)",