mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-15 03:08:44 +02:00
Service ffmpeg-api réécrit (ffmpeg-service/app/) : - Appels FFmpeg asynchrones : le service ne se fige plus pendant un rendu - Vidéo récupérée par téléchargement (GET /files/…) au lieu de base64 en JSON - Vraies erreurs HTTP ; échec explicite si la voix demandée est impossible - 30 voix Gemini + ton de lecture (dynamique, chaleureux, promo, calme), clé en en-tête, modèle configurable avec repli - Edge TTS 7 (minutage des mots restauré), secours en voix françaises - Voix traitée : filtre, compression, niveau constant ; musique bouclée et baissée automatiquement sous la voix ; mix final à -14 LUFS - Sous-titres calés mot à mot (Whisper pour Gemini et la voix d'origine, à la place de ffsubsync), style Montserrat, placés hors des boutons Reels - Vidéo : plus de retouche luminosité forcée, scaling lanczos, HDR iPhone converti, BT.709, AAC 48 kHz 192k ; la vidéo s'allonge si la voix dépasse - Grand logo de fin affiché après la voix ; FFmpeg 7.0.2 épinglé, polices et modèle Whisper intégrés à l'image ; tests pytest et ruff Application : - Sélecteur de voix partagé (4 pages) : moteur, 30 voix, ton, écoute - Reel images : minutage réel des mots, interrupteur voix respecté - sync-info ne génère plus de voix à chaque frappe (estimation locale) - Stabilisation désactivée par défaut, route /reels/preview inutilisée retirée - Log « [ReelQueue] Worker démarré » pour vérifier la version déployée Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
149 lines
5.8 KiB
TypeScript
149 lines
5.8 KiB
TypeScript
import { Router } from "express";
|
|
import multer from "multer";
|
|
import path from "path";
|
|
import fs from "fs";
|
|
import type { User } from "@shared/schema";
|
|
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
|
import { storage as dbStorage } from "../storage";
|
|
import { enqueueReelJob, getReelJob } from "../services/reels/queue";
|
|
import { REMOTION_TEMP_DIR } from "../services/reels/imagesPipeline";
|
|
import { resolveStoreName } from "../services/reels/assets";
|
|
import { publishReelToPages, storeRenderedVideo } from "../services/reels/publish";
|
|
|
|
export const remotionRouter = Router();
|
|
|
|
const uploadDir = REMOTION_TEMP_DIR;
|
|
if (!fs.existsSync(uploadDir)) {
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
}
|
|
|
|
const upload = multer({
|
|
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)}`);
|
|
},
|
|
}),
|
|
});
|
|
|
|
/**
|
|
* GET /api/remotion/render/status/:jobId
|
|
* État d'un rendu, lu dans la file persistante (survit à un redémarrage).
|
|
*/
|
|
remotionRouter.get("/render/status/:jobId", async (req, res) => {
|
|
const user = req.user as User;
|
|
const job = await getReelJob(req.params.jobId);
|
|
if (!job || job.kind !== "images" || (job.userId !== user.id && user.role !== "admin")) {
|
|
return res.status(404).json({ error: "Job introuvable" });
|
|
}
|
|
|
|
if (job.status === "completed") {
|
|
const result = job.result as ImagesReelResult;
|
|
return res.json({ status: "done", url: result.url, thumbnailUrl: result.thumbnailUrl });
|
|
}
|
|
if (job.status === "failed") {
|
|
return res.json({ status: "error", error: "Erreur lors du rendu: " + (job.error ?? "inconnue") });
|
|
}
|
|
res.json({ status: "processing", progress: job.progress, step: job.step, queued: job.status === "pending" });
|
|
});
|
|
|
|
/**
|
|
* POST /api/remotion/render
|
|
* Met en file le rendu d'un Reel à partir d'images (1 à 4).
|
|
*/
|
|
remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { name: "music", maxCount: 1 }]), async (req, res) => {
|
|
const fields = (req.files ?? {}) as Record<string, Express.Multer.File[]>;
|
|
const uploaded = [...(fields["images"] ?? []), ...(fields["music"] ?? [])];
|
|
|
|
try {
|
|
const user = req.user as User;
|
|
const existing = req.body.existingImageUrls;
|
|
const libraryUrls: string[] = existing ? (Array.isArray(existing) ? existing : [existing]) : [];
|
|
const uploadedImageUrls = (fields["images"] ?? []).map((f) => `/uploads/temp/${path.basename(f.path)}`);
|
|
|
|
const musicFile = fields["music"]?.[0];
|
|
const musicVolume = parseFloat(req.body.musicVolume ?? "0.3");
|
|
|
|
const parsed = imagesReelParamsSchema.safeParse({
|
|
imageUrls: [...libraryUrls, ...uploadedImageUrls],
|
|
overlayText: req.body.overlayText,
|
|
musicUrl: musicFile ? `/uploads/temp/${path.basename(musicFile.path)}` : req.body.musicTrackUrl,
|
|
musicVolume: Number.isFinite(musicVolume) ? musicVolume : undefined,
|
|
ttsEnabled: req.body.ttsEnabled !== "false",
|
|
ttsEngine: req.body.ttsEngine || undefined,
|
|
ttsVoice: req.body.ttsVoice,
|
|
ttsStyle: req.body.ttsStyle || undefined,
|
|
storeName: await resolveStoreName(user.id, req.body.selectedPageId),
|
|
tempFiles: uploaded.map((f) => f.path),
|
|
});
|
|
if (!parsed.success) {
|
|
await removeFiles(uploaded);
|
|
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Paramètres invalides" });
|
|
}
|
|
|
|
const job = await enqueueReelJob({ kind: "images", userId: user.id, params: parsed.data });
|
|
res.json({ jobId: job.id, message: "Rendu démarré en arrière-plan" });
|
|
} catch (err) {
|
|
console.error("❌ Remotion pre-render error:", err);
|
|
await removeFiles(uploaded);
|
|
res.status(500).json({ error: "Erreur lors du lancement du rendu: " + (err instanceof Error ? err.message : err) });
|
|
}
|
|
});
|
|
|
|
async function removeFiles(files: Express.Multer.File[]): Promise<void> {
|
|
await Promise.all(files.map((f) => fs.promises.unlink(f.path).catch(() => { /* déjà absent */ })));
|
|
}
|
|
|
|
/**
|
|
* POST /api/remotion/publish
|
|
* Range la vidéo rendue dans la médiathèque puis la publie (ou la planifie).
|
|
*/
|
|
remotionRouter.post("/publish", async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
|
|
const { videoUrl, pageIds, scheduledFor, description } = req.body as {
|
|
videoUrl: string;
|
|
pageIds: string[];
|
|
scheduledFor?: string;
|
|
description?: string;
|
|
};
|
|
|
|
if (!videoUrl) return res.status(400).json({ error: "videoUrl requis" });
|
|
if (!pageIds?.length) return res.status(400).json({ error: "Au moins une page requise" });
|
|
|
|
// Chemin local du rendu à partir de son URL temporaire
|
|
const filename = path.basename(videoUrl.split("?")[0]);
|
|
const localPath = path.join(uploadDir, filename);
|
|
if (!fs.existsSync(localPath)) {
|
|
return res.status(400).json({ error: "Fichier vidéo introuvable (expiré ?)" });
|
|
}
|
|
|
|
const videoBuffer = await fs.promises.readFile(localPath);
|
|
const mediaRecord = await storeRenderedVideo(user.id, videoBuffer, filename);
|
|
|
|
const post = await dbStorage.createPost({
|
|
userId: user.id,
|
|
content: description || "",
|
|
aiGenerated: "false",
|
|
status: scheduledFor ? "scheduled" : "draft",
|
|
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
|
|
});
|
|
await dbStorage.updatePostMedia(post.id, [mediaRecord.id]);
|
|
|
|
const results = await publishReelToPages({
|
|
postId: post.id,
|
|
pageIds,
|
|
videoBuffer,
|
|
description: description || "",
|
|
scheduledFor,
|
|
});
|
|
|
|
res.json({ success: results.some((r) => r.success), results, postId: post.id });
|
|
} catch (err) {
|
|
console.error("❌ Remotion publish error:", err);
|
|
res.status(500).json({ error: "Erreur lors de la publication: " + (err instanceof Error ? err.message : err) });
|
|
}
|
|
});
|