mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-18 12:46:43 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
57c18e24df
commit
9145bc5941
4 files changed
+250
-100
No files matched your search
@@ -61,6 +61,13 @@ export default function MobileRemotionVideoPage() {
|
||||
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ 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 ? (
|
||||
<Player
|
||||
component={ImageComposition}
|
||||
inputProps={{ images: combinedUrls, overlayText: overlayText || undefined, wordTimings: previewWordTimings }}
|
||||
inputProps={{
|
||||
images: combinedUrls,
|
||||
overlayText: overlayText || undefined,
|
||||
wordTimings: previewWordTimings,
|
||||
logoUrl: previewLogoUrl,
|
||||
storeName: previewStoreName,
|
||||
endingFrames: ENDING_FRAMES,
|
||||
}}
|
||||
durationInFrames={totalFrames}
|
||||
compositionWidth={1080}
|
||||
compositionHeight={1920}
|
||||
|
||||
@@ -72,6 +72,17 @@ export default function RemotionVideoPage() {
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({
|
||||
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 ? (
|
||||
<Player
|
||||
component={ImageComposition}
|
||||
inputProps={{ images: combinedUrls, overlayText: overlayText || undefined, wordTimings: previewWordTimings }}
|
||||
inputProps={{
|
||||
images: combinedUrls,
|
||||
overlayText: overlayText || undefined,
|
||||
wordTimings: previewWordTimings,
|
||||
logoUrl: previewLogoUrl,
|
||||
storeName: previewStoreName,
|
||||
endingFrames: ENDING_FRAMES,
|
||||
}}
|
||||
durationInFrames={totalFrames}
|
||||
compositionWidth={1080}
|
||||
compositionHeight={1920}
|
||||
|
||||
@@ -13,6 +13,9 @@ export type ImageCompositionProps = {
|
||||
wordTimings?: WordTiming[];
|
||||
musicUrl?: string;
|
||||
musicVolume?: number;
|
||||
logoUrl?: string;
|
||||
storeName?: string;
|
||||
endingFrames?: number; // frames reserved for ending slide (default 90 = 3s at 30fps)
|
||||
};
|
||||
|
||||
// 6 modern, simple entrance transitions
|
||||
@@ -77,11 +80,26 @@ const ImageSlide: React.FC<{ src: string; transition: Transition }> = ({ 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 && <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 }}>
|
||||
<div
|
||||
{/* TikTok-style word-by-word text overlay (content section only) */}
|
||||
{frame < endingStart && (
|
||||
wordTimings && wordTimings.length > 0 ? (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "88%",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
gap: "0 14px",
|
||||
alignItems: "baseline",
|
||||
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
|
||||
}}
|
||||
>
|
||||
{wordTimings.map((w, i) => {
|
||||
const isActive = i === activeWordIdx;
|
||||
if (!isActive) return null;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
color: "#FFE600",
|
||||
fontFamily: "'Arial Black', 'Impact', sans-serif",
|
||||
fontSize: 80,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.2,
|
||||
WebkitTextStroke: "3px rgba(0,0,0,0.8)",
|
||||
textShadow: "0 4px 20px rgba(0,0,0,0.9)",
|
||||
display: "inline-block",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{w.word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
) : overlayText ? (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
borderRadius: 16,
|
||||
padding: "24px 32px",
|
||||
color: "white",
|
||||
fontSize: 60,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "sans-serif",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "90%",
|
||||
WebkitTextStroke: "1px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
{overlayText}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{/* Logo watermark — bottom right, always visible during content */}
|
||||
{logoUrl && frame < endingStart && (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "flex-end", padding: 48 }}>
|
||||
<Img
|
||||
src={logoUrl}
|
||||
style={{
|
||||
maxWidth: "88%",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
gap: "0 14px",
|
||||
alignItems: "baseline",
|
||||
filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.7))",
|
||||
}}
|
||||
>
|
||||
{wordTimings.map((w, i) => {
|
||||
const isActive = i === activeWordIdx;
|
||||
const isVisible = w.startFrame <= frame;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
color: isActive ? "#FFE600" : "white",
|
||||
fontFamily: "'Arial Black', 'Impact', sans-serif",
|
||||
fontSize: 72,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.15,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
WebkitTextStroke: "3px rgba(0,0,0,0.7)",
|
||||
textShadow: "0 4px 18px rgba(0,0,0,0.85)",
|
||||
transform: isActive ? "scale(1.08)" : "scale(1)",
|
||||
display: "inline-block",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
>
|
||||
{w.word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
) : overlayText ? (
|
||||
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 120 }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
width: 130,
|
||||
height: 130,
|
||||
objectFit: "contain",
|
||||
opacity: 0.82,
|
||||
borderRadius: 16,
|
||||
padding: "24px 32px",
|
||||
color: "white",
|
||||
fontSize: 60,
|
||||
fontWeight: "bold",
|
||||
fontFamily: "sans-serif",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "90%",
|
||||
WebkitTextStroke: "1px rgba(0,0,0,0.4)",
|
||||
filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.5))",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Ending slide — centered logo + store name */}
|
||||
{hasEnding && (
|
||||
<Sequence from={endingStart} durationInFrames={effectiveEndingFrames}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: "black",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
gap: 40,
|
||||
}}
|
||||
>
|
||||
{overlayText}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
) : null}
|
||||
{logoUrl && (
|
||||
<Img
|
||||
src={logoUrl}
|
||||
style={{
|
||||
width: 300,
|
||||
height: 300,
|
||||
objectFit: "contain",
|
||||
opacity: endingProgress,
|
||||
transform: `scale(${interpolate(endingProgress, [0, 1], [0.65, 1])})`,
|
||||
borderRadius: 24,
|
||||
filter: "drop-shadow(0 4px 24px rgba(255,255,255,0.15))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{storeName && (
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 80,
|
||||
fontFamily: "'Arial Black', Impact, sans-serif",
|
||||
fontWeight: 900,
|
||||
textAlign: "center",
|
||||
opacity: endingProgress,
|
||||
letterSpacing: 3,
|
||||
textShadow: "0 4px 24px rgba(255,255,255,0.2)",
|
||||
transform: `translateY(${interpolate(endingProgress, [0, 1], [30, 0])}px)`,
|
||||
}}
|
||||
>
|
||||
{storeName}
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
</Sequence>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+59
-28
@@ -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<string, Express.Multer.File[]>;
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user