mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 07:28:11 +02:00
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:
54 files changed
+2
-13174
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}` : ""}`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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 */ });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user