feat: Implement Remotion bundling and rendering scripts.

This commit is contained in:
Michael committed 2026-03-24 13:25:22 +01:00
1 parent 526e608077
commit 1a7cbe6af9
6 files changed
+401 -100

No files matched your search

+113 -23
View File
@@ -4,6 +4,7 @@ import path from "path";
import fs from "fs";
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { ffmpegService } from "../services/ffmpeg";
export const remotionRouter = Router();
@@ -22,6 +23,54 @@ const storage = multer.diskStorage({
const upload = multer({ storage });
/**
* Strips hashtags and emojis from text for TTS synthesis.
* Visual display keeps the full text.
*/
function stripForTTS(text: string): string {
return text
.replace(/#\w+/g, '') // remove hashtags
.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE00}-\u{FEFF}]/gu, '') // emojis
.replace(/\s+/g, ' ')
.trim();
}
/**
* 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).
*/
function computeWordTimings(
displayText: string,
audioDurationSeconds: number,
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) && !/^\p{Emoji}/u.test(word);
const frameDuration = isSpoken ? framesPerTTSWord : framesPerTTSWord * 0.4; // hashtags flash briefly
currentFrame += Math.round(frameDuration);
if (isSpoken) ttsIdx++;
return { word, startFrame: wordStart, endFrame: currentFrame };
});
}
remotionRouter.post("/render", upload.array("images", 4), async (req, res) => {
try {
const files = req.files as Express.Multer.File[];
@@ -29,59 +78,100 @@ remotionRouter.post("/render", upload.array("images", 4), async (req, res) => {
const existing = req.body.existingImageUrls;
if (existing) {
if (Array.isArray(existing)) {
imageUrls.push(...existing);
} else {
imageUrls.push(existing);
}
if (Array.isArray(existing)) imageUrls.push(...existing);
else imageUrls.push(existing);
}
const host = req.protocol + "://" + req.get("host");
if (files && files.length > 0) {
const uploadedUrls = files.map(file => `${host}/uploads/temp/${path.basename(file.path)}`);
imageUrls.push(...uploadedUrls);
imageUrls.push(...files.map(f => `${host}/uploads/temp/${path.basename(f.path)}`));
}
if (imageUrls.length === 0) {
return res.status(400).json({ error: "Aucune image fournie" });
}
console.log("Bundling Remotion project...");
const overlayText: string | undefined = typeof req.body.overlayText === 'string' && req.body.overlayText.trim()
? req.body.overlayText.trim()
: undefined;
const ttsVoice: string = req.body.ttsVoice || "fr-FR-VivienneMultilingualNeural";
const fps = 30;
const totalVideoDuration = imageUrls.length * 3; // 3s per image
// --- TTS Audio Generation ---
let audioUrl: string | undefined;
let wordTimings: Array<{ word: string; startFrame: number; endFrame: number }> | undefined;
let totalFrames = totalVideoDuration * fps;
if (overlayText) {
const ttsText = stripForTTS(overlayText);
if (ttsText) {
console.log("🎙️ Generating TTS for:", ttsText);
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"));
audioUrl = `${host}/uploads/temp/${audioFilename}`;
// 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
}
wordTimings = computeWordTimings(overlayText, estimatedAudioDuration, fps, 0);
console.log(`✅ TTS generated, ~${estimatedAudioDuration.toFixed(1)}s, ${wordTimings.length} words`);
} else {
console.warn("⚠️ TTS failed:", ttsResult.error);
}
} catch (ttsErr) {
console.warn("⚠️ TTS error (rendering without audio):", ttsErr);
}
}
}
console.log("🎬 Bundling Remotion project...");
const bundleLocation = await bundle({
entryPoint: path.resolve(process.cwd(), "client/src/remotion/index.ts"),
});
console.log("Selecting composition...");
const inputProps = { images: imageUrls, overlayText, audioUrl, wordTimings };
console.log("🎬 Selecting composition...");
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "ImageVideo",
inputProps: {
images: imageUrls,
},
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 to", outputLocation);
console.log("🎬 Rendering media...", effectiveDurationInFrames, "frames");
await renderMedia({
composition,
composition: { ...composition, durationInFrames: effectiveDurationInFrames },
serveUrl: bundleLocation,
codec: "h264",
outputLocation,
inputProps: {
images: imageUrls,
},
chromiumOptions: {
disableWebSecurity: true
}
inputProps,
chromiumOptions: { disableWebSecurity: true }
});
console.log("Render completed:", outputFilename);
console.log("✅ Render completed:", outputFilename);
res.json({ url: `${host}/uploads/temp/${outputFilename}` });
} catch (err: any) {
console.error("Remotion render error:", err);
res.status(500).json({ error: "Erreur lors du rendu de la vidéo: " + err.message });
console.error("❌ Remotion render error:", err);
res.status(500).json({ error: "Erreur lors du rendu: " + err.message });
}
});