diff --git a/client/src/components/edit-scheduled-post-dialog.tsx b/client/src/components/edit-scheduled-post-dialog.tsx
index e482fbf..8de2630 100644
--- a/client/src/components/edit-scheduled-post-dialog.tsx
+++ b/client/src/components/edit-scheduled-post-dialog.tsx
@@ -169,6 +169,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
@@ -203,6 +204,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
diff --git a/client/src/pages/mobile/new-post.tsx b/client/src/pages/mobile/new-post.tsx
index 9cbf6a1..8ed87f1 100644
--- a/client/src/pages/mobile/new-post.tsx
+++ b/client/src/pages/mobile/new-post.tsx
@@ -81,6 +81,7 @@ function SortableMediaItem({
@@ -535,6 +536,7 @@ export default function NewPostMobile() {
diff --git a/client/src/pages/mobile/remotion-video.tsx b/client/src/pages/mobile/remotion-video.tsx
index c3d09ed..cadc0e1 100644
--- a/client/src/pages/mobile/remotion-video.tsx
+++ b/client/src/pages/mobile/remotion-video.tsx
@@ -11,6 +11,7 @@ import { MediaThumbnail } from "@/components/media-thumbnail";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import Sidebar from "@/components/sidebar";
import { Checkbox } from "@/components/ui/checkbox";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/datetime-picker";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
diff --git a/client/src/pages/new-post.tsx b/client/src/pages/new-post.tsx
index 3cec2e6..c293d09 100644
--- a/client/src/pages/new-post.tsx
+++ b/client/src/pages/new-post.tsx
@@ -80,6 +80,7 @@ function SortableMediaItem({
@@ -543,6 +544,7 @@ export default function NewPost() {
{isSelected && (
diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx
index e72e897..64717b3 100644
--- a/client/src/pages/new-reel.tsx
+++ b/client/src/pages/new-reel.tsx
@@ -568,6 +568,7 @@ export default function NewReel() {
{selectedVideoId === media.id && (
diff --git a/client/src/pages/remotion-video.tsx b/client/src/pages/remotion-video.tsx
index 6371bec..d520f41 100644
--- a/client/src/pages/remotion-video.tsx
+++ b/client/src/pages/remotion-video.tsx
@@ -12,6 +12,7 @@ import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/datetime-picker";
import type { Media, SocialPage } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
diff --git a/server/migrate.ts b/server/migrate.ts
index b10427a..69ae34e 100644
--- a/server/migrate.ts
+++ b/server/migrate.ts
@@ -161,6 +161,12 @@ export async function migrate() {
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
`);
+ // media.thumbnail_url (vignette conservée après suppression de la vidéo)
+ await client.query(`
+ ALTER TABLE "media"
+ ADD COLUMN IF NOT EXISTS "thumbnail_url" text;
+ `);
+
// tiktok_config (application développeur TikTok, globale)
await client.query(`
CREATE TABLE IF NOT EXISTS "tiktok_config" (
diff --git a/server/routes/external.ts b/server/routes/external.ts
index 39e1881..0dbf3a0 100644
--- a/server/routes/external.ts
+++ b/server/routes/external.ts
@@ -137,7 +137,7 @@ router.post("/publish", async (req, res) => {
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
fileName,
- fileSize: String(buffer.length),
+ fileSize: buffer.length,
});
}
@@ -149,7 +149,7 @@ router.post("/publish", async (req, res) => {
content,
status: scheduledFor ? "scheduled" : "scheduled",
scheduledFor: scheduledFor ?? new Date(),
- aiGenerated: false,
+ aiGenerated: "false",
});
// Lier le média si présent
@@ -415,7 +415,7 @@ router.patch("/posts/:id", async (req, res) => {
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
fileName,
- fileSize: String(buffer.length),
+ fileSize: buffer.length,
});
// Remplacer les médias existants par le nouveau
diff --git a/server/routes/reels.ts b/server/routes/reels.ts
index 5522aa0..8cd4bf1 100644
--- a/server/routes/reels.ts
+++ b/server/routes/reels.ts
@@ -8,6 +8,7 @@ import { storage } from '../storage';
import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook';
import { tiktokService } from '../services/tiktok';
+import { createVideoThumbnail } from '../services/thumbnail';
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
import { openRouterService, describeGenerationError } from '../services/openrouter';
import { db } from '../db';
@@ -15,6 +16,19 @@ import { cloudinaryConfig } from '@shared/schema';
import { eq } from 'drizzle-orm';
import { ttsSyncService } from '../services/ttsSync';
+/** Piste musicale telle qu'attendue par le client. */
+interface MusicTrack {
+ id: string;
+ title: string;
+ artist: string;
+ albumName: string;
+ duration: number;
+ previewUrl: string;
+ downloadUrl: string;
+ imageUrl: string;
+ license: string;
+}
+
export const reelsRouter = Router();
// ============================================
@@ -616,6 +630,10 @@ async function processReelBackground(
console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl);
// 5. Créer l'enregistrement Media pour la vidéo traitée
+ // La vignette est extraite maintenant : la vidéo sera supprimée du disque
+ // après publication, mais l'historique doit rester illustré.
+ const thumbnailUrl = await createVideoThumbnail(videoBuffer);
+
const processedMedia = await storage.createMedia({
userId: userId,
type: 'video',
@@ -624,6 +642,7 @@ async function processReelBackground(
facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null,
instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null,
instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null,
+ thumbnailUrl,
fileName: `reel-processed-${Date.now()}.mp4`,
fileSize: videoBuffer.length,
});
diff --git a/server/routes/remotion.ts b/server/routes/remotion.ts
index 2f96934..076d4cc 100644
--- a/server/routes/remotion.ts
+++ b/server/routes/remotion.ts
@@ -2,8 +2,6 @@ import { Router } from "express";
import multer from "multer";
import path from "path";
import fs from "fs";
-import { exec } from "child_process";
-import { promisify } from "util";
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { ffmpegService } from "../services/ffmpeg";
@@ -11,26 +9,9 @@ import { storage as dbStorage } from "../storage";
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
import { facebookService } from "../services/facebook";
import { tiktokService } from "../services/tiktok";
+import { generateVideoThumbnail, createVideoThumbnail } from "../services/thumbnail";
import * as musicMetadata from "music-metadata";
-const execAsync = promisify(exec);
-
-/**
- * Generate a thumbnail from a video file using FFmpeg
- * @param videoPath Path to the video file
- * @param outputPath Path to save the thumbnail
- * @param seekTime Time in seconds to extract the frame (default: 1s)
- */
-async function generateVideoThumbnail(videoPath: string, outputPath: string, seekTime: number = 1): Promise {
- try {
- const cmd = `ffmpeg -y -ss ${seekTime} -i "${videoPath}" -vframes 1 -q:v 2 "${outputPath}"`;
- await execAsync(cmd);
- return fs.existsSync(outputPath);
- } catch (error) {
- console.warn('⚠️ Failed to generate video thumbnail:', error);
- return false;
- }
-}
export const remotionRouter = Router();
@@ -326,10 +307,12 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
codec: "h264",
outputLocation,
inputProps,
+ // Ne pas y remettre "args" : Remotion ne lit jamais cette clé (elle est
+ // absente de ChromiumOptions et de son code), les drapeaux passés là
+ // n'atteignaient donc jamais le navigateur.
chromiumOptions: {
disableWebSecurity: true,
ignoreCertificateErrors: true,
- args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
},
concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion
});
@@ -408,6 +391,9 @@ remotionRouter.post("/publish", async (req, res) => {
});
// Create a Media record for the processed video
+ // Vignette extraite avant que la vidéo ne soit purgée du disque
+ const thumbnailUrl = await createVideoThumbnail(localPath);
+
const mediaRecord = await dbStorage.createMedia({
userId: user.id,
type: "video",
@@ -416,6 +402,7 @@ remotionRouter.post("/publish", async (req, res) => {
facebookFeedUrl: null,
instagramFeedUrl: null,
instagramStoryUrl: null,
+ thumbnailUrl,
fileName: filename,
fileSize: fs.statSync(localPath).size,
});
diff --git a/server/services/media-purge.ts b/server/services/media-purge.ts
index 44e205f..a99c2e1 100644
--- a/server/services/media-purge.ts
+++ b/server/services/media-purge.ts
@@ -43,6 +43,12 @@ export async function purgeOldMedia() {
if (item.cloudinaryPublicId) {
await storageService.deleteMedia(item.cloudinaryPublicId, item.userId, item.type === 'video' ? 'video' : 'image');
}
+ // La vignette n'a plus de raison d'être une fois la ligne supprimée
+ if (item.thumbnailUrl?.startsWith('/uploads/')) {
+ await storageService
+ .deleteMedia(item.thumbnailUrl.replace('/uploads/', ''), item.userId, 'image')
+ .catch(err => console.warn(`[Media Purge] Vignette non supprimée pour ${item.id}:`, err));
+ }
await db.delete(media).where(eq(media.id, item.id));
successCount++;
} catch (err) {
diff --git a/server/services/minio.ts b/server/services/minio.ts
index b218e9b..d99537f 100644
--- a/server/services/minio.ts
+++ b/server/services/minio.ts
@@ -106,6 +106,21 @@ class LocalStorageService {
return buildMinioUrl('', objectKey);
}
+ /**
+ * Range une vignette vidéo. Elle survit volontairement à la suppression de la
+ * vidéo source, pour que l'historique reste illustré.
+ */
+ async uploadThumbnail(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
+ const dir = path.join(UPLOADS_BASE, 'thumbnails');
+ ensureDir(dir);
+
+ const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-');
+ const objectKey = `thumbnails/${safeName}`;
+
+ fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
+ return { publicId: objectKey, url: buildMinioUrl('', objectKey) };
+ }
+
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const dir = path.join(UPLOADS_BASE, 'logos');
ensureDir(dir);
diff --git a/server/services/thumbnail.ts b/server/services/thumbnail.ts
new file mode 100644
index 0000000..1123e96
--- /dev/null
+++ b/server/services/thumbnail.ts
@@ -0,0 +1,99 @@
+/**
+ * Génération de vignettes vidéo.
+ *
+ * Les vidéos sources sont supprimées du disque une fois publiées (le
+ * planificateur libère la place, la purge quotidienne fait le reste), alors que
+ * la ligne `media` survit pour l'historique. Sans vignette persistée, l'interface
+ * tente alors de lire un fichier disparu : d'où les « Video thumbnail generation
+ * failed » et les tuiles vides.
+ *
+ * On extrait donc une image au moment de la création du média, et elle reste
+ * disponible après la disparition de la vidéo.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import { minioService } from './minio';
+
+const execAsync = promisify(exec);
+
+const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
+
+/**
+ * Extrait une image d'une vidéo avec FFmpeg.
+ *
+ * @param videoPath chemin du fichier vidéo
+ * @param outputPath chemin de l'image à écrire
+ * @param seekTime instant de la capture, en secondes
+ * @returns true si l'image a bien été écrite
+ */
+export async function generateVideoThumbnail(
+ videoPath: string,
+ outputPath: string,
+ seekTime: number = 1
+): Promise {
+ try {
+ const cmd = `ffmpeg -y -ss ${seekTime} -i "${videoPath}" -vframes 1 -q:v 2 "${outputPath}"`;
+ await execAsync(cmd);
+ return fs.existsSync(outputPath);
+ } catch (error) {
+ console.warn('⚠️ Failed to generate video thumbnail:', error);
+ return false;
+ }
+}
+
+/**
+ * Produit la vignette d'une vidéo et la range dans le stockage public.
+ *
+ * Tolérante à l'échec : une vignette manquante dégrade l'affichage mais ne doit
+ * jamais faire échouer une publication.
+ *
+ * @param source chemin du fichier vidéo, ou son contenu en mémoire
+ * @returns l'URL publique de la vignette, ou null si l'extraction a échoué
+ */
+export async function createVideoThumbnail(source: string | Buffer): Promise {
+ const stamp = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
+ const outputPath = path.join(TEMP_DIR, `thumb-${stamp}.jpg`);
+
+ // Une vidéo reçue en mémoire doit d'abord être posée sur le disque pour FFmpeg
+ let videoPath: string;
+ let tempVideoPath: string | null = null;
+
+ try {
+ if (!fs.existsSync(TEMP_DIR)) {
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
+ }
+
+ if (Buffer.isBuffer(source)) {
+ tempVideoPath = path.join(TEMP_DIR, `thumb-src-${stamp}.mp4`);
+ await fs.promises.writeFile(tempVideoPath, source);
+ videoPath = tempVideoPath;
+ } else {
+ videoPath = source;
+ if (!fs.existsSync(videoPath)) {
+ console.warn(`⚠️ [Thumbnail] Vidéo introuvable pour la vignette : ${videoPath}`);
+ return null;
+ }
+ }
+
+ const ok = await generateVideoThumbnail(videoPath, outputPath);
+ if (!ok) return null;
+
+ const buffer = await fs.promises.readFile(outputPath);
+ const { url } = await minioService.uploadThumbnail(buffer, `thumb-${stamp}.jpg`);
+
+ console.log(`🖼️ [Thumbnail] Vignette générée : ${url}`);
+ return url;
+ } catch (error) {
+ console.warn('⚠️ [Thumbnail] Génération de la vignette impossible :', error);
+ return null;
+ } finally {
+ for (const file of [outputPath, tempVideoPath]) {
+ if (file) {
+ await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
+ }
+ }
+ }
+}
diff --git a/shared/schema.ts b/shared/schema.ts
index 8fead36..d4d48c3 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -161,6 +161,9 @@ export const media = pgTable("media", {
facebookFeedUrl: text("facebook_feed_url"),
instagramFeedUrl: text("instagram_feed_url"),
instagramStoryUrl: text("instagram_story_url"),
+ // Image extraite d'une vidéo, conservée après la suppression du fichier source
+ // pour que l'historique reste illustré.
+ thumbnailUrl: text("thumbnail_url"),
fileName: text("file_name").notNull(),
fileSize: integer("file_size").notNull(),
createdAt: timestamp("created_at").defaultNow(),