import { AbsoluteFill, Img, Sequence, useVideoConfig, useCurrentFrame, Html5Audio, spring, interpolate } from "remotion"; export type WordTiming = { word: string; startFrame: number; endFrame: number; }; export type ImageCompositionProps = { images: string[]; overlayText?: string; audioUrl?: string; wordTimings?: WordTiming[]; musicUrl?: string; musicVolume?: number; logoUrl?: string; storeName?: string; endingFrames?: number; }; /** * 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 ( ); }; /** * 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 (
{words.map((w, i) => { const isActive = i === activeIdx; return ( {w.word} ); })}
); }; export const ImageComposition = ({ images, audioUrl, wordTimings, musicUrl, musicVolume = 0.3, logoUrl, storeName, endingFrames = 90, }: ImageCompositionProps) => { const { fps, durationInFrames } = useVideoConfig(); const frame = useCurrentFrame(); const hasEnding = !!(logoUrl || storeName); const effectiveEndingFrames = hasEnding ? endingFrames : 0; const contentFrames = durationInFrames - effectiveEndingFrames; const durationPerImage = Math.floor(contentFrames / Math.max(images.length, 1)); const endingStart = contentFrames; // Ending slide spring animation const endingProgress = spring({ frame: frame - endingStart, fps, config: { damping: 20, stiffness: 80 }, durationInFrames: 30, }); // Group words into sets of 3 for caption display const WORDS_PER_GROUP = 3; const activeWordIdx = wordTimings ? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame) : -1; // Only show caption when a word is actively being spoken const renderCaption = frame < endingStart && activeWordIdx >= 0; const groupIdx = activeWordIdx >= 0 ? Math.floor(activeWordIdx / WORDS_PER_GROUP) : 0; const groupStart = groupIdx * WORDS_PER_GROUP; const groupWords = wordTimings ? wordTimings.slice(groupStart, groupStart + WORDS_PER_GROUP) : []; const activeIdxInGroup = activeWordIdx - groupStart; // First frame of the current group (for fade-in) const groupFirstFrame = groupWords.length > 0 ? groupWords[0].startFrame : 0; return ( {/* Images — displayed immediately, no entrance transition */} {images.map((imgUrl, index) => ( ))} {/* TTS Audio */} {audioUrl && } {/* Background music */} {musicUrl && } {/* Caption overlay — CapCut style */} {renderCaption && ( {/* Semi-transparent dark background pill */}
)} {/* Logo watermark — bottom right during content */} {logoUrl && frame < endingStart && ( )} {/* Ending slide — large centered logo + store name */} {hasEnding && ( {logoUrl && ( )} {storeName && (
{storeName}
)}
)}
); };