feat(reels): file d'attente persistante des rendus (reel_jobs)

L'ancienne file comptait les posts « processing » : un redémarrage pendant
un rendu la bloquait définitivement. Les rendus passent désormais par une
table reel_jobs, réservée avec FOR UPDATE SKIP LOCKED, avec heartbeat et
reprise des jobs interrompus au démarrage (2 tentatives maximum).

- Pipeline vidéo et pipeline images (Remotion) extraits des routes vers
  server/services/reels/, les routes ne font que valider (zod) et mettre en file
- Rendus d'images suivis en base au lieu d'une Map en mémoire
- storeName conservé pour les jobs mis en attente
- Publication Facebook en binaire : l'URL relative /uploads/... transmise
  auparavant n'était pas téléchargeable par Facebook
- Job en échec si toutes les pages échouent ou si la voix demandée manque
  (le service Python renseigne enfin tts_error)
- La voix choisie sur la page images est réellement utilisée
- sync-info mesure la voix Gemini avec sa clé
- Timeouts sur les appels au service FFmpeg, vignettes via execFile,
  /debug-ffmpeg protégé par la clé API, plus de voix anglaise en secours

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
Claude committed 2026-09-24 09:31:31 +00:00
1 parent fcbc6b706f
commit d24ab0d4b7
19 files changed
+1071 -901

No files matched your search

+27 -1
View File
@@ -281,6 +281,31 @@ export const userPagePermissions = pgTable("user_page_permissions", {
createdAt: timestamp("created_at").defaultNow(),
});
/**
* File d'attente persistante des rendus de Reels.
*
* Un job survit à un redémarrage du serveur : le worker le réserve avec
* `FOR UPDATE SKIP LOCKED`, entretient `heartbeat_at` pendant le rendu, et les
* jobs dont le cœur ne bat plus sont remis en file au démarrage suivant.
*/
export const reelJobs = pgTable("reel_jobs", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
// Post suivi par l'interface (reel vidéo). Absent pour un rendu d'images non publié.
postId: varchar("post_id").references(() => posts.id, { onDelete: "cascade" }),
kind: text("kind").notNull(), // 'video' | 'images'
params: jsonb("params").notNull(),
status: text("status").notNull().default("pending"), // 'pending' | 'processing' | 'completed' | 'failed'
step: text("step"),
progress: integer("progress").notNull().default(0),
attempts: integer("attempts").notNull().default(0),
result: jsonb("result"),
error: text("error"),
heartbeatAt: timestamp("heartbeat_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const socialPagesRelations = relations(socialPages, ({ one, many }) => ({
user: one(users, {
fields: [socialPages.userId],
@@ -558,4 +583,5 @@ export type TiktokConfig = typeof tiktokConfig.$inferSelect;
export type InsertTiktokConfig = z.infer<typeof insertTiktokConfigSchema>;
export type UpdateTiktokConfig = z.infer<typeof updateTiktokConfigSchema>;
export type ReelJob = typeof reelJobs.$inferSelect;
export type InsertReelJob = typeof reelJobs.$inferInsert;