mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-20 21:48:40 +02:00
feat: Implement Remotion bundling and rendering scripts.
This commit is contained in:
1 parent
526e608077
commit
1a7cbe6af9
6 files changed
+401
-100
No files matched your search
+113
-23
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user