From 9145bc594170f51b1a956355b5e12b96e47a1f48 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 24 Mar 2026 19:13:07 +0100 Subject: [PATCH] feat: add logo watermark, ending slide, 25-30s duration, and word-sync text - Video duration forced to 25-30s (content 22-27s + 3s ending) - Logo watermark (bottom-right) visible throughout content section - Ending slide: logo centered + store name with spring animation (3s) - Text overlay now shows only the active spoken word (true TikTok sync) - Server fetches logo from Cloudinary config and store name from user's pages - Player preview also shows logo/storeName via /api/cloudinary/config + /api/pages Co-Authored-By: Claude Sonnet 4.6 --- client/src/pages/mobile/remotion-video.tsx | 26 ++- client/src/pages/remotion-video.tsx | 33 +++- client/src/remotion/ImageComposition.tsx | 204 +++++++++++++++------ server/routes/remotion.ts | 87 ++++++--- 4 files changed, 250 insertions(+), 100 deletions(-) diff --git a/client/src/pages/mobile/remotion-video.tsx b/client/src/pages/mobile/remotion-video.tsx index b666f85..b52ed23 100644 --- a/client/src/pages/mobile/remotion-video.tsx +++ b/client/src/pages/mobile/remotion-video.tsx @@ -61,6 +61,13 @@ export default function MobileRemotionVideoPage() { const { data: allMedia = [] } = useQuery({ queryKey: ['/api/media'] }); const { data: audioTracks = [], isLoading: tracksLoading } = useQuery({ queryKey: ['/api/audio-tracks'] }); + const { data: cloudinaryConfig } = useQuery<{ cloudName?: string; logoPublicId?: string } | null>({ queryKey: ['/api/cloudinary/config'] }); + const { data: socialPages = [] } = useQuery<{ pageName: string }[]>({ queryKey: ['/api/pages'] }); + + const previewLogoUrl = cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId + ? `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}` + : undefined; + const previewStoreName = (socialPages[0] as any)?.pageName as string | undefined; const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20); const generateTextMutation = useMutation({ @@ -90,11 +97,13 @@ export default function MobileRemotionVideoPage() { const totalSelected = images.length + selectedLibraryImages.length; const previewWordTimings = useMemo(() => overlayText ? estimateWordTimings(overlayText) : undefined, [overlayText]); + const ENDING_FRAMES = (previewLogoUrl || previewStoreName) ? 90 : 0; const totalFrames = useMemo(() => { - if (previewWordTimings?.length) - return Math.max(combinedUrls.length * 3 * 30, previewWordTimings[previewWordTimings.length - 1].endFrame + 30); - return combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300; - }, [previewWordTimings, combinedUrls]); + const ttsEnd = previewWordTimings?.length ? previewWordTimings[previewWordTimings.length - 1].endFrame : 0; + const naturalContent = Math.max(ttsEnd / 30, combinedUrls.length * 3); + const contentSeconds = Math.min(Math.max(naturalContent, 22), 27); + return Math.round(contentSeconds * 30) + ENDING_FRAMES; + }, [previewWordTimings, combinedUrls, ENDING_FRAMES]); const handleTtsPreview = async () => { const ttsText = stripForTTS(overlayText); @@ -297,7 +306,14 @@ export default function MobileRemotionVideoPage() { {combinedUrls.length > 0 ? ( ({ queryKey: ['/api/audio-tracks'], }); + const { data: cloudinaryConfig } = useQuery<{ cloudName?: string; logoPublicId?: string } | null>({ + queryKey: ['/api/cloudinary/config'], + }); + const { data: socialPages = [] } = useQuery<{ pageName: string }[]>({ + queryKey: ['/api/pages'], + }); + + const previewLogoUrl = cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId + ? `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}` + : undefined; + const previewStoreName = (socialPages[0] as any)?.pageName as string | undefined; const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20); const generateTextMutation = useMutation({ @@ -109,12 +120,15 @@ export default function RemotionVideoPage() { const previewWordTimings = useMemo(() => overlayText ? estimateWordTimings(overlayText) : undefined, [overlayText]); + const ENDING_FRAMES = (previewLogoUrl || previewStoreName) ? 90 : 0; const totalFrames = useMemo(() => { - if (previewWordTimings?.length) { - return Math.max(combinedUrls.length * 3 * 30, previewWordTimings[previewWordTimings.length - 1].endFrame + 30); - } - return combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300; - }, [previewWordTimings, combinedUrls]); + const ttsEnd = previewWordTimings?.length + ? previewWordTimings[previewWordTimings.length - 1].endFrame + : 0; + const naturalContent = Math.max(ttsEnd / 30, combinedUrls.length * 3); + const contentSeconds = Math.min(Math.max(naturalContent, 22), 27); + return Math.round(contentSeconds * 30) + ENDING_FRAMES; + }, [previewWordTimings, combinedUrls, ENDING_FRAMES]); const handleTtsPreview = async () => { if (!overlayText) return; @@ -368,7 +382,14 @@ export default function RemotionVideoPage() { {combinedUrls.length > 0 ? ( = ({ src, tr }; export const ImageComposition = ({ - images, overlayText, audioUrl, wordTimings, musicUrl, musicVolume = 0.3 + images, overlayText, audioUrl, wordTimings, musicUrl, musicVolume = 0.3, + logoUrl, storeName, endingFrames = 90, }: ImageCompositionProps) => { - const { fps } = useVideoConfig(); + const { fps, durationInFrames } = useVideoConfig(); const frame = useCurrentFrame(); - const durationPerImage = 3 * fps; + + // 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; + 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, + }); const activeWordIdx = wordTimings ? wordTimings.findIndex(w => frame >= w.startFrame && frame < w.endFrame) @@ -102,68 +120,132 @@ export const ImageComposition = ({ {/* Background music */} {musicUrl && } - {/* TikTok-style word-by-word text overlay */} - {wordTimings && wordTimings.length > 0 ? ( - -
0 ? ( + +
+ {wordTimings.map((w, i) => { + const isActive = i === activeWordIdx; + if (!isActive) return null; + return ( + + {w.word} + + ); + })} +
+
+ ) : overlayText ? ( + +
+ {overlayText} +
+
+ ) : null + )} + + {/* Logo watermark — bottom right, always visible during content */} + {logoUrl && frame < endingStart && ( + + - {wordTimings.map((w, i) => { - const isActive = i === activeWordIdx; - const isVisible = w.startFrame <= frame; - return ( - - {w.word} - - ); - })} -
-
- ) : overlayText ? ( - -
+ + )} + + {/* Ending slide — centered logo + store name */} + {hasEnding && ( + + - {overlayText} -
-
- ) : null} + {logoUrl && ( + + )} + {storeName && ( +
+ {storeName} +
+ )} + + + )} ); }; diff --git a/server/routes/remotion.ts b/server/routes/remotion.ts index 571afe4..a5e6325 100644 --- a/server/routes/remotion.ts +++ b/server/routes/remotion.ts @@ -5,6 +5,7 @@ import fs from "fs"; import { bundle } from "@remotion/bundler"; import { renderMedia, selectComposition } from "@remotion/renderer"; import { ffmpegService } from "../services/ffmpeg"; +import { storage as dbStorage } from "../storage"; export const remotionRouter = Router(); @@ -51,8 +52,7 @@ function stripForTTS(text: string): string { /** * Calculates word timings (in frames at 30fps) based on audio duration. - * Distributes frames proportionally across display words. - * Hashtags and emojis are display-only (shown but voice skips them). + * Each word appears only when spoken, then disappears (no accumulation). */ function computeWordTimings( displayText: string, @@ -60,31 +60,30 @@ function computeWordTimings( fps: number, startFrame: number ): Array<{ word: string; startFrame: number; endFrame: number }> { - // Split into display words (preserving hashtags, emojis) const displayWords = displayText.split(/\s+/).filter(Boolean); - - // TTS words (stripped - this matches what audio says) const ttsText = stripForTTS(displayText); const ttsWords = ttsText.split(/\s+/).filter(Boolean); - // Frames per TTS word (based on actual audio duration) const totalTTSFrames = audioDurationSeconds * fps; const framesPerTTSWord = ttsWords.length > 0 ? totalTTSFrames / ttsWords.length : fps; let currentFrame = startFrame; - let ttsIdx = 0; return displayWords.map((word) => { const wordStart = currentFrame; - // Use TTS pacing if this word is a real spoken word const isSpoken = !/^#/.test(word) && !(/[\uD800-\uDFFF\u2600-\u27BF]/.test(word)); - const frameDuration = isSpoken ? framesPerTTSWord : framesPerTTSWord * 0.4; // hashtags flash briefly + const frameDuration = isSpoken ? framesPerTTSWord : framesPerTTSWord * 0.4; currentFrame += Math.round(frameDuration); - if (isSpoken) ttsIdx++; return { word, startFrame: wordStart, endFrame: currentFrame }; }); } +// Duration constants +const FPS = 30; +const ENDING_SECONDS = 3; // ending slide (logo + store name) +const MIN_CONTENT_SECONDS = 22; // so total >= 25s +const MAX_CONTENT_SECONDS = 27; // so total <= 30s + remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { name: "music", maxCount: 1 }]), async (req, res) => { try { const fields = req.files as Record; @@ -113,20 +112,42 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { const ttsVoice: string = req.body.ttsVoice || "fr-FR-VivienneMultilingualNeural"; const musicVolume: number = parseFloat(req.body.musicVolume ?? "0.3"); - // Music: uploaded file takes priority, else use catalog track URL directly + // Music: uploaded file takes priority, else use catalog track URL (make relative paths absolute) const musicFile = fields["music"]?.[0]; const rawMusicTrackUrl = req.body.musicTrackUrl as string | undefined; const musicUrl: string | undefined = musicFile ? `${host}/uploads/temp/${path.basename(musicFile.path)}` : rawMusicTrackUrl?.startsWith('/') ? `${host}${rawMusicTrackUrl}` : rawMusicTrackUrl || undefined; - const fps = 30; - const totalVideoDuration = imageUrls.length * 3; // 3s per image + // --- Fetch logo and store name from config --- + let logoUrl: string | undefined; + let storeName: string | undefined; + try { + const cloudinaryConfig = await dbStorage.getCloudinaryConfig(); + if (cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId) { + logoUrl = `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`; + console.log("🏢 Logo URL:", logoUrl); + } + } catch (e) { + console.warn("⚠️ Could not fetch logo config:", e); + } + try { + const user = req.user as any; + if (user?.id) { + const pages = await dbStorage.getSocialPages(user.id); + if (pages.length > 0) { + storeName = pages[0].pageName; + console.log("🏪 Store name:", storeName); + } + } + } catch (e) { + console.warn("⚠️ Could not fetch store name:", e); + } // --- TTS Audio Generation --- let audioUrl: string | undefined; let wordTimings: Array<{ word: string; startFrame: number; endFrame: number }> | undefined; - let totalFrames = totalVideoDuration * fps; + let estimatedAudioDuration = 0; if (overlayText) { const ttsText = stripForTTS(overlayText); @@ -135,7 +156,6 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { try { const ttsResult = await ffmpegService.previewTTS(ttsText, ttsVoice); if (ttsResult.success && ttsResult.audioBase64) { - // Save audio to temp file const audioFilename = `tts-${Date.now()}.mp3`; const audioPath = path.join(uploadDir, audioFilename); fs.writeFileSync(audioPath, Buffer.from(ttsResult.audioBase64, "base64")); @@ -143,14 +163,9 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { // Estimate audio duration (MP3 at 128kbps ~ 16KB/s) const audioBytes = Buffer.from(ttsResult.audioBase64, "base64").length; - const estimatedAudioDuration = Math.max(audioBytes / 16000, ttsText.split(/\s+/).length * 0.5); - - // Expand video if text needs more time than images - if (estimatedAudioDuration > totalVideoDuration) { - totalFrames = Math.ceil(estimatedAudioDuration * fps) + fps; // +1s buffer - } + estimatedAudioDuration = Math.max(audioBytes / 16000, ttsText.split(/\s+/).length * 0.5); - wordTimings = computeWordTimings(overlayText, estimatedAudioDuration, fps, 0); + wordTimings = computeWordTimings(overlayText, estimatedAudioDuration, FPS, 0); console.log(`✅ TTS generated, ~${estimatedAudioDuration.toFixed(1)}s, ${wordTimings.length} words`); } else { console.warn("⚠️ TTS failed:", ttsResult.error); @@ -161,10 +176,29 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { } } + // --- Duration: 25-30s total --- + // content = max(tts duration, images*3s), clamped to 22-27s; ending = 3s + const naturalContent = Math.max(estimatedAudioDuration, imageUrls.length * 3); + const contentSeconds = Math.min(Math.max(naturalContent, MIN_CONTENT_SECONDS), MAX_CONTENT_SECONDS); + const endingFrames = (logoUrl || storeName) ? ENDING_SECONDS * FPS : 0; + const totalFrames = Math.round(contentSeconds * FPS) + endingFrames; + + console.log(`🎬 Duration: ${contentSeconds.toFixed(1)}s content + ${ENDING_SECONDS}s ending = ${(totalFrames / FPS).toFixed(1)}s total`); + console.log("🎬 Getting Remotion bundle (cached)..."); const bundleLocation = await getBundle(); - const inputProps = { images: imageUrls, overlayText, audioUrl, wordTimings, musicUrl, musicVolume }; + const inputProps = { + images: imageUrls, + overlayText, + audioUrl, + wordTimings, + musicUrl, + musicVolume, + logoUrl, + storeName, + endingFrames, + }; console.log("🎬 Selecting composition..."); const composition = await selectComposition({ @@ -173,16 +207,13 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { inputProps, }); - // Override duration if TTS needs more frames - const effectiveDurationInFrames = Math.max(totalFrames, composition.durationInFrames); - const outputFilename = `out-${Date.now()}.mp4`; const outputLocation = path.join(uploadDir, outputFilename); - console.log("🎬 Rendering media...", effectiveDurationInFrames, "frames"); + console.log("🎬 Rendering media...", totalFrames, "frames"); await renderMedia({ - composition: { ...composition, durationInFrames: effectiveDurationInFrames }, + composition: { ...composition, durationInFrames: totalFrames }, serveUrl: bundleLocation, codec: "h264", outputLocation,