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(),