diff --git a/client/src/remotion/ImageComposition.tsx b/client/src/remotion/ImageComposition.tsx index ba4d30a..06caf3f 100644 --- a/client/src/remotion/ImageComposition.tsx +++ b/client/src/remotion/ImageComposition.tsx @@ -18,15 +18,126 @@ export type ImageCompositionProps = { 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 ( - - + + ); }; +/** + * 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, @@ -48,17 +159,43 @@ export const ImageComposition = ({ 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 ? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame) : -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 ( {/* Images — displayed immediately, no entrance transition */} {images.map((imgUrl, index) => ( - + ))} @@ -68,51 +205,37 @@ export const ImageComposition = ({ {/* Background music */} {musicUrl && } - {/* 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} - - ); - })} -
-
- ); - })()} + {/* Caption overlay — CapCut style */} + {renderCaption && ( + + {/* Semi-transparent dark background pill */} +
+ +
+
+ )} {/* Logo watermark — bottom right during content */} {logoUrl && frame < endingStart && ( diff --git a/server/routes/remotion.ts b/server/routes/remotion.ts index e10b41b..d0ce9fc 100644 --- a/server/routes/remotion.ts +++ b/server/routes/remotion.ts @@ -42,13 +42,17 @@ async function getBundle(): Promise { /** * 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 { return text - .replace(/#\w+/g, ' ') // hashtags → space (not empty) - .replace(/[\uD800-\uDFFF]/g, ' ') // surrogate pairs (emojis) → space - .replace(/[\u2600-\u27BF]/g, ' ') // misc symbols → space + // Hashtags with Unicode letters (covers all French accented chars) + .replace(/#[\w\u00C0-\u024F\u1E00-\u1EFF]*/g, ' ') + // 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, ' ') .trim(); }