Retrait de l'ancien serveur Node/TypeScript

Le serveur Rust (server/src) le remplace entièrement : routes, services,
migrations Drizzle et scripts SQL d'initialisation sont supprimés. Le
schéma est désormais créé et migré par server/src/migrate.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
This commit is contained in:
Claude committed 2026-10-12 17:49:03 +00:00
1 parent a0f800101c
commit b451826ec5
54 files changed
+2 -13174

No files matched your search

-94
View File
@@ -1,94 +0,0 @@
/**
* Résolution des ressources d'un Reel (musique, logo, clé Gemini, nom du
* magasin), jusqu'ici recopiée dans chaque route.
*/
import type { QwenTarget } from "../ffmpeg";
import { storage } from "../../storage";
import { buildMinioUrl, resolveInternalUrl } from "../minio";
/**
* URL de la musique téléchargeable par le service FFmpeg (réseau Docker
* interne : l'URL publique HTTPS n'y est pas joignable).
*/
export async function resolveMusicUrl(
musicTrackId?: string,
musicUrl?: string,
): Promise<string | undefined> {
if (musicUrl) return resolveInternalUrl(musicUrl);
if (!musicTrackId?.startsWith("internal_")) return undefined;
try {
const track = await storage.getAudioTrack(musicTrackId.slice("internal_".length));
return track ? resolveInternalUrl(track.url) : undefined;
} catch (error) {
console.error("⚠️ [Reels] Piste audio introuvable :", error);
return undefined;
}
}
/** Chemin relatif (/uploads/...) du logo configuré, s'il existe. */
export async function resolveLogoPath(): Promise<string | undefined> {
try {
const config = await storage.getCloudinaryConfig();
if (config?.logoPublicId) {
return buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl);
}
} catch (error) {
console.error("⚠️ [Reels] Configuration du logo illisible :", error);
}
return undefined;
}
/**
* Clé Gemini : configuration de l'application, sinon variable d'environnement.
* Aussi transmise avec Qwen, qui se replie sur Gemini si le service local échoue.
*/
export async function resolveGeminiApiKey(ttsEngine?: string): Promise<string | undefined> {
if (ttsEngine !== "gemini" && ttsEngine !== "qwen") return undefined;
const appConfig = await storage.getAppConfig();
return appConfig?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
}
/** Service Qwen TTS réglé dans les paramètres (sinon ffmpeg-api prend QWEN_TTS_URL). */
export async function resolveQwenTarget(ttsEngine?: string): Promise<QwenTarget | undefined> {
if (ttsEngine !== "qwen") return undefined;
return qwenTargetFromConfig(await storage.getAppConfig());
}
/** Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; null si invalide. */
export function normalizeQwenUrl(value: unknown): string | null {
if (typeof value !== "string" || !value.trim()) return null;
try {
const url = new URL(value.trim());
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.toString().replace(/\/+$/, "");
} catch {
return null;
}
}
export function qwenTargetFromConfig(
appConfig: { qwenTtsUrl?: string | null; qwenTtsApiKey?: string | null } | undefined,
): QwenTarget | undefined {
if (!appConfig?.qwenTtsUrl) return undefined;
return { url: appConfig.qwenTtsUrl, apiKey: appConfig.qwenTtsApiKey ?? undefined };
}
/** Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. */
export async function resolveStoreName(
userId: string,
preferredPageId?: string,
): Promise<string | undefined> {
try {
if (preferredPageId) {
const page = await storage.getSocialPage(preferredPageId);
if (page) return page.pageName;
}
const pages = await storage.getSocialPages(userId);
return pages[0]?.pageName;
} catch (error) {
console.error("⚠️ [Reels] Nom du magasin introuvable :", error);
return undefined;
}
}
-12
View File
@@ -1,12 +0,0 @@
/**
* Point d'entrée des Reels : enregistre les traitements de la file et démarre
* le worker.
*/
import { registerReelJobHandler, startReelWorker } from "./queue";
import { runVideoReelJob } from "./videoPipeline";
export function startReelProcessing(): Promise<void> {
registerReelJobHandler("video", runVideoReelJob);
return startReelWorker();
}
-189
View File
@@ -1,189 +0,0 @@
/**
* Enregistrement et publication d'un Reel rendu, commun aux Reels vidéo et
* images.
*
* La vidéo est toujours envoyée en binaire : les fichiers vivent sous
* /uploads, une URL relative que Facebook ne peut pas télécharger (c'était le
* cas de l'ancien `publishReel(originalUrl)`).
*/
import type { Media, SocialPage } from "@shared/schema";
import { storage } from "../../storage";
import { minioService } from "../minio";
import { createVideoThumbnail } from "../thumbnail";
import { facebookService } from "../facebook";
import { tiktokService } from "../tiktok";
export interface PagePublishResult {
pageId: string;
success: boolean;
reelId?: string;
error?: string;
/** Échec de la story associée : n'empêche pas le Reel d'être publié. */
storyError?: string;
}
/** Range la vidéo rendue dans la médiathèque, avec sa vignette. */
export async function storeRenderedVideo(
userId: string,
videoBuffer: Buffer,
fileName: string,
): Promise<Media> {
const uploaded = await minioService.uploadMedia(videoBuffer, fileName, userId, "video/mp4");
// Vignette extraite maintenant : la vidéo sera supprimée du disque après
// publication, mais l'historique doit rester illustré.
const thumbnailUrl = await createVideoThumbnail(videoBuffer);
return storage.createMedia({
userId,
type: "video",
cloudinaryPublicId: uploaded.publicId,
originalUrl: uploaded.originalUrl,
facebookFeedUrl: uploaded.facebookFeedUrl,
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
thumbnailUrl,
fileName,
fileSize: videoBuffer.length,
});
}
/**
* Publie (ou planifie) la vidéo sur chaque page, crée les entrées
* `scheduled_posts` et met à jour le statut du post.
*
* Avec `alsoStory`, chaque page Facebook reçoit aussi la vidéo en story (TikTok
* n'a pas de stories).
*/
export async function publishReelToPages(options: {
postId: string;
pageIds: string[];
videoBuffer: Buffer;
description: string;
scheduledFor?: string;
alsoStory?: boolean;
}): Promise<PagePublishResult[]> {
const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options;
const results: PagePublishResult[] = [];
for (const pageId of pageIds) {
try {
const page = await storage.getSocialPage(pageId);
if (!page) {
results.push({ pageId, success: false, error: "Page non trouvée" });
continue;
}
if (page.platform !== "facebook" && page.platform !== "tiktok") {
results.push({
pageId,
success: false,
error: `Plateforme non supportée pour les reels : ${page.platform}`,
});
continue;
}
const scheduledPost = await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "reel",
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
const withStory = alsoStory && page.platform === "facebook";
if (scheduledFor) {
// Le planificateur publiera à l'heure dite
if (withStory) {
await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(scheduledFor),
});
}
results.push({ pageId, success: true, reelId: "scheduled" });
continue;
}
console.log(`🚀 [Reels] Publication sur ${page.platform} ${page.pageName}...`);
if (page.platform === "facebook") {
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description);
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: reelId,
});
const storyError = withStory ? await publishStory(postId, page, videoBuffer) : undefined;
results.push({ pageId, success: true, reelId, storyError });
} else {
// TikTok finalise la publication de son côté : l'envoi est marqué comme
// effectué pour ne pas republier, et le poller de statut renseignera
// l'identifiant définitif du post.
const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description);
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: "PROCESSING_UPLOAD",
});
results.push({ pageId, success: true, reelId: publishId });
}
} catch (error) {
console.error(`❌ [Reels] Publication sur la page ${pageId} impossible :`, error);
results.push({
pageId,
success: false,
error: error instanceof Error ? error.message : "Erreur inconnue",
});
}
}
const succeeded = results.filter((r) => r.success).length;
const status = scheduledFor && succeeded > 0 ? "scheduled" : succeeded > 0 ? "published" : "failed";
await storage.updatePost(postId, { status });
return results;
}
/**
* Publie la story d'une page juste après son Reel : un échec est consigné sur
* son entrée planifiée (visible dans l'historique) et renvoyé, sans faire
* échouer le Reel.
*/
async function publishStory(
postId: string,
page: SocialPage,
videoBuffer: Buffer,
): Promise<string | undefined> {
// Créée seulement maintenant : en attente pendant l'envoi du Reel, le
// planificateur aurait pu la publier une seconde fois.
let scheduledStoryId: string | undefined;
try {
({ id: scheduledStoryId } = await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(),
}));
const storyId = await facebookService.publishVideoStoryFromBuffer(page, videoBuffer);
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
externalPostId: storyId,
});
return undefined;
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
console.error(`❌ [Reels] Story sur ${page.pageName} impossible :`, error);
if (scheduledStoryId) {
// Marquée comme traitée pour que le planificateur ne la republie pas en boucle
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
error: message,
}).catch((e) => console.error(`⚠️ [Reels] Échec de la story non enregistré :`, e));
}
return message;
}
}
/** Message d'erreur lisible lorsque toutes les publications ont échoué. */
export function describePublishFailure(results: PagePublishResult[]): string {
const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
return `Publication impossible sur toutes les pages${details ? ` : ${details}` : ""}`;
}
-278
View File
@@ -1,278 +0,0 @@
/**
* File d'attente persistante des rendus de Reels.
*
* L'ancienne file comptait les posts « processing » pour décider de lancer un
* rendu : un redémarrage pendant un job laissait ce compteur à 1 pour toujours
* et bloquait tous les Reels suivants. Ici, chaque job est une ligne de
* `reel_jobs` :
* - le worker la réserve atomiquement (`FOR UPDATE SKIP LOCKED`), ce qui exclut
* tout double traitement, même avec plusieurs instances ;
* - un battement de cœur est écrit pendant le rendu ; au démarrage, les jobs
* dont le cœur ne bat plus sont remis en file (ou échouent après
* MAX_ATTEMPTS tentatives).
*/
import { and, eq, sql } from "drizzle-orm";
import { db } from "../../db";
import { posts, reelJobs, type ReelJob } from "@shared/schema";
import type { ReelJobKind, ReelJobStatus } from "@shared/reel";
const HEARTBEAT_INTERVAL_MS = 15_000;
/** Au-delà, un job « processing » est considéré comme orphelin. */
const STALE_AFTER_MS = 2 * 60_000;
const POLL_INTERVAL_MS = 10_000;
const MAX_ATTEMPTS = 2;
/** Outils fournis à un handler pour rendre compte de son avancement. */
export interface JobContext {
job: ReelJob;
progress(progress: number, step?: string): Promise<void>;
}
export type JobHandler = (ctx: JobContext) => Promise<unknown>;
const handlers = new Map<ReelJobKind, JobHandler>();
let running = false;
let started = false;
let pollTimer: NodeJS.Timeout | null = null;
export function registerReelJobHandler(kind: ReelJobKind, handler: JobHandler): void {
handlers.set(kind, handler);
}
/** Ajoute un job à la file et réveille le worker. */
export async function enqueueReelJob(input: {
kind: ReelJobKind;
userId: string;
postId?: string;
params: unknown;
}): Promise<ReelJob> {
const [job] = await db
.insert(reelJobs)
.values({
kind: input.kind,
userId: input.userId,
postId: input.postId,
params: input.params,
})
.returning();
if (job.postId) {
await syncPost(job.postId, "pending", 0);
}
kick();
return job;
}
export async function getReelJob(id: string): Promise<ReelJob | undefined> {
const [job] = await db.select().from(reelJobs).where(eq(reelJobs.id, id));
return job;
}
/** Nombre de jobs qui passeront avant un nouveau job (utile pour informer l'utilisateur). */
export async function countActiveReelJobs(): Promise<number> {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(reelJobs)
.where(sql`${reelJobs.status} IN ('pending', 'processing')`);
return row?.count ?? 0;
}
/**
* Démarre le worker : récupère les jobs orphelins puis traite la file.
* Idempotent.
*/
export async function startReelWorker(): Promise<void> {
if (started) return;
started = true;
try {
await recoverStaleJobs();
await failLegacyStuckPosts();
} catch (error) {
console.error("❌ [ReelQueue] Récupération des jobs orphelins impossible :", error);
}
console.log(`🎬 [ReelQueue] Worker démarré (traitements : ${Array.from(handlers.keys()).join(", ")})`);
pollTimer = setInterval(kick, POLL_INTERVAL_MS);
pollTimer.unref();
kick();
}
/** Relance la boucle si elle est à l'arrêt. */
function kick(): void {
if (!started || running) return;
running = true;
drain()
.catch((error) => console.error("❌ [ReelQueue] Erreur de la boucle :", error))
.finally(() => {
running = false;
});
}
async function drain(): Promise<void> {
for (;;) {
const job = await claimNextJob();
if (!job) return;
await runJob(job);
}
}
async function claimNextJob(): Promise<ReelJob | undefined> {
const result = await db.execute<{ id: string }>(sql`
UPDATE reel_jobs
SET status = 'processing',
attempts = attempts + 1,
heartbeat_at = now(),
updated_at = now(),
error = NULL
WHERE id = (
SELECT id FROM reel_jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id
`);
const id = result.rows[0]?.id;
return id ? getReelJob(id) : undefined;
}
async function runJob(job: ReelJob): Promise<void> {
const handler = handlers.get(job.kind as ReelJobKind);
console.log(`🎬 [ReelQueue] Job ${job.id} (${job.kind}), tentative ${job.attempts}`);
const heartbeat = setInterval(() => {
db.update(reelJobs)
.set({ heartbeatAt: new Date() })
.where(eq(reelJobs.id, job.id))
.catch((error) => console.warn(`⚠️ [ReelQueue] Heartbeat ${job.id} :`, error));
}, HEARTBEAT_INTERVAL_MS);
const ctx: JobContext = {
job,
async progress(progress, step) {
await updateJob(job, { progress, step });
},
};
try {
if (!handler) {
throw new Error(`Aucun traitement enregistré pour les jobs « ${job.kind} »`);
}
if (job.postId) {
await syncPost(job.postId, "processing", 0);
}
const result = await handler(ctx);
await updateJob(job, { status: "completed", progress: 100, step: "done", result: result ?? null });
console.log(`✅ [ReelQueue] Job ${job.id} terminé`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`❌ [ReelQueue] Job ${job.id} en échec :`, error);
await updateJob(job, { status: "failed", error: message }).catch((e) =>
console.error(`⚠️ [ReelQueue] Statut d'échec non enregistré pour ${job.id} :`, e),
);
} finally {
clearInterval(heartbeat);
}
}
async function updateJob(
job: ReelJob,
patch: {
status?: ReelJobStatus;
progress?: number;
step?: string;
result?: unknown;
error?: string;
},
): Promise<void> {
await db
.update(reelJobs)
.set({ ...patch, heartbeatAt: new Date(), updatedAt: new Date() })
.where(eq(reelJobs.id, job.id));
if (job.postId) {
const status = patch.status ?? "processing";
const progress = patch.status === "failed" ? 0 : patch.progress;
await syncPost(job.postId, status, progress, patch.error);
}
}
/**
* Reflète l'état du job sur le post : l'interface (`/reels/ongoing`) lit les
* colonnes `generation_*` du post.
*/
async function syncPost(
postId: string,
status: ReelJobStatus,
progress?: number,
error?: string,
): Promise<void> {
const patch: Partial<typeof posts.$inferInsert> = {
generationStatus: status,
updatedAt: new Date(),
};
if (progress !== undefined) patch.generationProgress = progress;
if (error !== undefined) patch.generationError = error;
if (status === "failed") patch.status = "failed";
await db.update(posts).set(patch).where(eq(posts.id, postId));
}
/**
* Remet en file les jobs interrompus (serveur arrêté pendant un rendu), ou les
* fait échouer s'ils ont déjà épuisé leurs tentatives : un job qui fait tomber
* le serveur ne doit pas le faire tomber en boucle.
*/
async function recoverStaleJobs(): Promise<void> {
const staleBefore = new Date(Date.now() - STALE_AFTER_MS);
const stale = await db
.select()
.from(reelJobs)
.where(
and(
eq(reelJobs.status, "processing"),
sql`(${reelJobs.heartbeatAt} IS NULL OR ${reelJobs.heartbeatAt} < ${staleBefore})`,
),
);
for (const job of stale) {
if (job.attempts >= MAX_ATTEMPTS) {
console.warn(`⚠️ [ReelQueue] Job ${job.id} abandonné après ${job.attempts} tentatives`);
await updateJob(job, {
status: "failed",
error: "Le rendu a été interrompu à plusieurs reprises (redémarrage du serveur).",
});
} else {
console.warn(`♻️ [ReelQueue] Job ${job.id} interrompu, remis en file`);
await db
.update(reelJobs)
.set({ status: "pending", step: null, progress: 0, updatedAt: new Date() })
.where(eq(reelJobs.id, job.id));
if (job.postId) await syncPost(job.postId, "pending", 0);
}
}
}
/**
* Posts laissés « processing »/« pending » par l'ancienne file (avant
* reel_jobs) : ils n'ont aucun job pour les reprendre et bloquaient l'affichage
* des Reels en cours. On les marque en échec pour que l'utilisateur relance.
*/
async function failLegacyStuckPosts(): Promise<void> {
const result = await db.execute(sql`
UPDATE posts
SET generation_status = 'failed',
generation_progress = 0,
generation_error = 'Génération interrompue, veuillez relancer le Reel.',
status = 'failed',
updated_at = now()
WHERE generation_status IN ('processing', 'pending')
AND NOT EXISTS (SELECT 1 FROM reel_jobs j WHERE j.post_id = posts.id)
`);
if (result.rowCount) {
console.warn(`⚠️ [ReelQueue] ${result.rowCount} Reel(s) de l'ancienne file marqués en échec`);
}
}
@@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import { toDataUrl } from "./remotionAssets";
describe("toDataUrl", () => {
it("refuse un chemin qui sort du dossier uploads", async () => {
await expect(toDataUrl("/uploads/../../etc/passwd")).rejects.toThrow(/refusé/);
});
it("laisse les URL externes telles quelles", async () => {
await expect(toDataUrl("https://exemple.fr/logo.png")).resolves.toBe("https://exemple.fr/logo.png");
});
});
-58
View File
@@ -1,58 +0,0 @@
/**
* Ressources fournies aux compositions Remotion pendant un rendu serveur.
*/
import fs from "fs";
import path from "path";
/** Dossier temporaire servi sous /uploads/temp (voir server/index.ts). */
export const REMOTION_TEMP_DIR =
process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), "uploads", "temp");
/**
* URL HTTP locale d'un fichier servi par l'application. Vidéo et audio sont
* lus côté Node par le moteur de rendu : l'adresse locale suffit.
*/
export function localHttpUrl(url: string): string {
if (!url.startsWith("/")) return url;
const port = process.env.PORT || "5555";
return `http://localhost:${port}${url}`;
}
/** URL servie d'un fichier du dossier temporaire. */
export function tempFileUrl(absolutePath: string): string {
const relative = path.relative(REMOTION_TEMP_DIR, absolutePath).split(path.sep).join("/");
return localHttpUrl(`/uploads/temp/${relative}`);
}
const MIME_BY_EXT: Record<string, string> = {
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
};
/**
* Convertit un chemin local /uploads/... en data URL : les images sont
* affichées par Chromium, qui ne lit pas http://localhost de façon fiable
* dans Docker. Refuse tout chemin sortant du dossier uploads.
*/
export async function toDataUrl(relativeUrl: string): Promise<string> {
if (!relativeUrl.startsWith("/uploads/")) return relativeUrl;
const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep;
const tempRoot = path.resolve(REMOTION_TEMP_DIR) + path.sep;
const filePath = relativeUrl.startsWith("/uploads/temp/")
? path.resolve(REMOTION_TEMP_DIR, "." + relativeUrl.slice("/uploads/temp".length))
: path.resolve(process.cwd(), "." + relativeUrl);
if (!filePath.startsWith(uploadsRoot) && !filePath.startsWith(tempRoot)) {
throw new Error(`Chemin d'image refusé : ${relativeUrl}`);
}
try {
const buffer = await fs.promises.readFile(filePath);
const ext = path.extname(filePath).slice(1).toLowerCase();
return `data:${MIME_BY_EXT[ext] ?? "image/jpeg"};base64,${buffer.toString("base64")}`;
} catch {
console.warn(`⚠️ [Remotion] Image introuvable : ${filePath}`);
return relativeUrl;
}
}
-85
View File
@@ -1,85 +0,0 @@
/**
* Rendu Remotion côté serveur, commun aux Reels vidéo et images : bundle mis
* en cache, réglages d'encodage des réseaux sociaux, progression.
*/
import os from "os";
import path from "path";
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
let bundleCache: Promise<string> | null = null;
/** Bundle des compositions, construit une fois par processus. */
export function getRemotionBundle(): Promise<string> {
if (!bundleCache) {
// process.cwd() = /app dans Docker, racine du projet en dev
const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts");
console.log("📦 [Remotion] Bundle de", entryPoint);
bundleCache = bundle({
entryPoint,
// Mêmes alias que Vite : les compositions importent @shared/*
webpackOverride: (config) => ({
...config,
resolve: {
...config.resolve,
alias: {
...(config.resolve?.alias ?? {}),
"@shared": path.resolve(process.cwd(), "shared"),
},
},
}),
}).catch((error) => {
bundleCache = null; // réessayer au prochain rendu
throw error;
});
}
return bundleCache;
}
/** Rendus en parallèle dans Chromium : par défaut la moitié des cœurs, 1 au minimum. */
function renderConcurrency(): number {
const configured = Number(process.env.RENDER_CONCURRENCY);
if (Number.isFinite(configured) && configured > 0) return Math.floor(configured);
return Math.max(1, Math.floor(os.cpus().length / 2));
}
export async function renderReelComposition(options: {
compositionId: "ReelVideo";
inputProps: Record<string, unknown>;
outputLocation: string;
onProgress?: (ratio: number) => void;
}): Promise<void> {
const serveUrl = await getRemotionBundle();
const composition = await selectComposition({
serveUrl,
id: options.compositionId,
inputProps: options.inputProps,
});
const startedAt = Date.now();
await renderMedia({
composition,
serveUrl,
inputProps: options.inputProps,
outputLocation: options.outputLocation,
codec: "h264",
// Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok
crf: 18,
x264Preset: "medium",
pixelFormat: "yuv420p",
colorSpace: "bt709",
audioCodec: "aac",
audioBitrate: "192k",
concurrency: renderConcurrency(),
onProgress: ({ progress }) => options.onProgress?.(progress),
// Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu
timeoutInMilliseconds: 60_000,
onBrowserLog: (log) => {
if (log.type === "error") console.warn("⚠️ [Remotion] Navigateur :", log.text.slice(0, 300));
},
});
console.log(
`🎞️ [Remotion] ${options.compositionId} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`,
);
}
-160
View File
@@ -1,160 +0,0 @@
/**
* Rendu d'un Reel à partir d'une vidéo, puis médiathèque et publication.
* Exécuté par la file `reel_jobs` (voir queue.ts).
*
* Deux moteurs :
* - « remotion » (défaut) : le service Python prépare l'image (recadrage, HDR,
* stabilisation) et la piste son finale, Remotion compose les sous-titres
* animés, le logo et l'effet de fin — le même rendu que l'aperçu ;
* - « ffmpeg » : tout est fait par le service Python (sous-titres ASS),
* plus rapide, conservé en secours (REEL_RENDERER=ffmpeg).
*/
import fs from "fs";
import path from "path";
import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel";
import { storage } from "../../storage";
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
import { resolveInternalUrl } from "../minio";
import type { JobContext } from "./queue";
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl, resolveQwenTarget } from "./assets";
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
import { renderReelComposition } from "./remotionRenderer";
export type ReelRenderer = "remotion" | "ffmpeg";
export function configuredRenderer(): ReelRenderer {
return process.env.REEL_RENDERER === "ffmpeg" ? "ffmpeg" : "remotion";
}
export async function runVideoReelJob({ job, progress }: JobContext) {
if (!job.postId) throw new Error("Job vidéo sans post associé");
const postId = job.postId;
const params = videoReelParamsSchema.parse(job.params);
await progress(5, "prepare");
const media = await storage.getMediaById(params.videoMediaId);
if (!media || media.type !== "video") {
throw new Error("Vidéo source introuvable ou invalide");
}
const [musicUrl, logoPath, geminiApiKey, qwen] = await Promise.all([
resolveMusicUrl(params.musicTrackId, params.musicUrl),
resolveLogoPath(),
resolveGeminiApiKey(params.ttsEngine),
resolveQwenTarget(params.ttsEngine),
]);
const options: ReelRenderOptions = {
text: params.srtCues ? undefined : params.overlayText,
srtCues: params.srtCues,
musicUrl,
ttsEnabled: params.ttsEnabled,
ttsVoice: params.ttsVoice,
ttsEngine: params.ttsEngine,
ttsStyle: params.ttsStyle,
geminiApiKey,
qwen,
fontSize: params.fontSize,
musicVolume: params.musicVolume,
drawText: params.drawText,
stabilize: params.stabilize,
storeName: params.storeName,
enableEndingEffect: params.enableEndingEffect,
showLogo: params.showLogo,
};
const renderer = configuredRenderer();
const startedAt = Date.now();
const videoBuffer =
renderer === "remotion"
? await renderWithRemotion(job.id, resolveInternalUrl(media.originalUrl), options, params, logoPath, progress)
: await renderWithFfmpeg(resolveInternalUrl(media.originalUrl), options, logoPath, progress);
console.log(`⏱️ [Reels] Rendu ${renderer} : ${((Date.now() - startedAt) / 1000).toFixed(1)} s`);
await progress(88, "store");
const processedMedia = await storeRenderedVideo(job.userId, videoBuffer, `reel-${Date.now()}.mp4`);
await storage.updatePostMedia(postId, [processedMedia.id]);
await progress(93, "publish");
const results = await publishReelToPages({
postId,
pageIds: params.pageIds,
videoBuffer,
description: params.description || params.overlayText || "",
scheduledFor: params.scheduledFor,
alsoStory: params.alsoStory,
});
if (!results.some((r) => r.success)) {
throw new Error(describePublishFailure(results));
}
return { mediaId: processedMedia.id, renderer, results };
}
async function renderWithFfmpeg(
videoUrl: string,
options: ReelRenderOptions,
logoPath: string | undefined,
progress: JobContext["progress"],
): Promise<Buffer> {
await progress(15, "render");
// Le logo sert au coin de la vidéo et/ou à l'effet de fin : inutile de l'envoyer si aucun des deux
const logoUsed = Boolean(logoPath) && (options.showLogo !== false || options.enableEndingEffect !== false);
const rendered = await ffmpegService.renderReel(videoUrl, {
...options,
watermarkUrl: logoUsed && logoPath ? resolveInternalUrl(logoPath) : undefined,
});
return rendered.video;
}
async function renderWithRemotion(
jobId: string,
videoUrl: string,
options: ReelRenderOptions,
params: VideoReelParams,
logoPath: string | undefined,
progress: JobContext["progress"],
): Promise<Buffer> {
const workdir = path.join(REMOTION_TEMP_DIR, `reel-${jobId}`);
try {
// 1. Image recadrée à la durée finale, piste son finale, mots minutés
await progress(10, "voice");
const prepared = await ffmpegService.prepareReel(videoUrl, { ...options, hasLogo: Boolean(logoPath) }, workdir);
// 2. Composition : sous-titres animés, logo, effet de fin
await progress(40, "render");
const outputLocation = path.join(workdir, "reel.mp4");
let lastReported = 40;
await renderReelComposition({
compositionId: "ReelVideo",
outputLocation,
inputProps: {
videoUrl: tempFileUrl(prepared.videoPath),
videoDuration: prepared.totalDuration,
totalDuration: prepared.totalDuration,
words: prepared.words,
captionStyle: params.captionStyle,
logoUrl: logoPath ? await toDataUrl(logoPath) : undefined,
showWatermark: params.showLogo,
storeName: params.enableEndingEffect ? params.storeName : undefined,
logoStart: prepared.logoStart,
fadeStart: prepared.fadeStart,
fadeDuration: prepared.fadeDuration,
mixedAudioUrl: prepared.audioPath ? tempFileUrl(prepared.audioPath) : undefined,
},
onProgress: (ratio) => {
const pct = 40 + Math.floor(ratio * 45);
if (pct >= lastReported + 5) {
lastReported = pct;
progress(pct, "render").catch(() => { /* non bloquant */ });
}
},
});
return await fs.promises.readFile(outputLocation);
} finally {
await fs.promises.rm(workdir, { recursive: true, force: true }).catch(() => { /* déjà absent */ });
}
}