mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
208 lines
7.7 KiB
TypeScript
208 lines
7.7 KiB
TypeScript
import { Router } from "express";
|
|
import multer from "multer";
|
|
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();
|
|
|
|
const uploadDir = path.join(process.cwd(), "uploads", "temp");
|
|
if (!fs.existsSync(uploadDir)) {
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
}
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: uploadDir,
|
|
filename: (req, file, cb) => {
|
|
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
|
|
cb(null, `remotion-${uniqueSuffix}${path.extname(file.originalname)}`);
|
|
},
|
|
});
|
|
|
|
const upload = multer({ storage });
|
|
|
|
/** Cached Remotion bundle URL — only bundle once per process lifecycle */
|
|
let remotionBundleCache: string | null = null;
|
|
|
|
async function getBundle(): Promise<string> {
|
|
if (remotionBundleCache) return remotionBundleCache;
|
|
// Use process.cwd() (= /app in Docker, project root in dev) — works in both CJS and ESM
|
|
const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts");
|
|
console.log("📦 Bundling Remotion from:", entryPoint);
|
|
remotionBundleCache = await bundle({ entryPoint });
|
|
console.log("📦 Bundle cached at:", remotionBundleCache);
|
|
return remotionBundleCache;
|
|
}
|
|
|
|
/**
|
|
* 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(/[\uD800-\uDFFF]/g, '') // surrogate pairs (most emojis)
|
|
.replace(/[\u2600-\u27BF]/g, '') // misc symbols
|
|
.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) && !(/[\uD800-\uDFFF\u2600-\u27BF]/.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[];
|
|
let imageUrls: string[] = [];
|
|
|
|
const existing = req.body.existingImageUrls;
|
|
if (existing) {
|
|
if (Array.isArray(existing)) imageUrls.push(...existing);
|
|
else imageUrls.push(existing);
|
|
}
|
|
|
|
const host = req.protocol + "://" + req.get("host");
|
|
if (files && files.length > 0) {
|
|
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" });
|
|
}
|
|
|
|
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("🎬 Getting Remotion bundle (cached)...");
|
|
const bundleLocation = await getBundle();
|
|
|
|
const inputProps = { images: imageUrls, overlayText, audioUrl, wordTimings };
|
|
|
|
console.log("🎬 Selecting composition...");
|
|
const composition = await selectComposition({
|
|
serveUrl: bundleLocation,
|
|
id: "ImageVideo",
|
|
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");
|
|
|
|
// Find system Chromium: path written during Docker build, or fallback to common locations
|
|
let browserExecutable: string | undefined;
|
|
const savedPath = "/app/.chromium-path";
|
|
if (fs.existsSync(savedPath)) {
|
|
const p = fs.readFileSync(savedPath, "utf-8").trim();
|
|
if (p && fs.existsSync(p)) browserExecutable = p;
|
|
}
|
|
if (!browserExecutable) {
|
|
const chromiumPaths = ["/usr/bin/chromium-browser", "/usr/bin/chromium"];
|
|
browserExecutable = chromiumPaths.find(p => fs.existsSync(p));
|
|
}
|
|
console.log("🌐 Browser executable:", browserExecutable ?? "remotion bundled (fallback)");
|
|
|
|
await renderMedia({
|
|
composition: { ...composition, durationInFrames: effectiveDurationInFrames },
|
|
serveUrl: bundleLocation,
|
|
codec: "h264",
|
|
outputLocation,
|
|
inputProps,
|
|
browserExecutable,
|
|
chromiumOptions: {
|
|
disableWebSecurity: true,
|
|
ignoreCertificateErrors: true,
|
|
},
|
|
});
|
|
|
|
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: " + err.message });
|
|
}
|
|
});
|