diff --git a/client/src/remotion/ImageComposition.tsx b/client/src/remotion/ImageComposition.tsx index 07522e1..ba4d30a 100644 --- a/client/src/remotion/ImageComposition.tsx +++ b/client/src/remotion/ImageComposition.tsx @@ -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 ( - - + + ); }; @@ -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 ( - {/* Images with entrance transitions */} + {/* Images — displayed immediately, no entrance transition */} {images.map((imgUrl, index) => ( - + ))} @@ -120,73 +68,70 @@ export const ImageComposition = ({ {/* Background music */} {musicUrl && } - {/* 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 ( - -
- {groupWords.map((w, gi) => { - const isActive = groupStart + gi === activeWordIdx; - return ( - - {w.word} - - ); - })} -
-
- ); - })() : 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 ( + +
+ {groupWords.map((w, gi) => { + const isActive = groupStart + gi === activeWordIdx; + return ( + + {w.word} + + ); + })} +
+
+ ); + })()} - {/* Logo watermark — bottom right, always visible during content */} + {/* Logo watermark — bottom right during content */} {logoUrl && frame < endingStart && ( )} - {/* Ending slide — centered logo + store name */} + {/* Ending slide — large centered logo + store name */} {hasEnding && ( {logoUrl && ( )} @@ -216,7 +161,7 @@ export const ImageComposition = ({
{ - 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