mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 22:48:09 +02:00
fix: vignettes vidéo persistantes et remise à zéro des erreurs de types
Deux corrections demandées ensemble. 1. Vignettes vidéo Le planificateur supprime la vidéo locale une fois publiée, alors que la ligne `media` survit pour l'historique. L'interface tentait donc de lire un fichier disparu pour en extraire une image : d'où les avertissements « Video thumbnail generation failed » et les tuiles vides. Une vignette JPEG est désormais extraite à la création du reel, dans les deux flux de génération, et stockée à part : elle reste disponible quand la vidéo ne l'est plus. La purge quotidienne la nettoie en même temps que la ligne qu'elle illustre. L'extraction est tolérante à l'échec — une vignette manquante dégrade l'affichage mais ne fait jamais échouer une publication — et le composant retombe sur son icône de remplacement pour les médias créés avant ce changement. La fonction d'extraction, jusque-là privée à la route Remotion, devient un service partagé. 2. Erreurs de compilation préexistantes (27 → 0) - remotion-video.tsx (bureau et mobile) : les composants Select étaient utilisés sans être importés, ce qui faisait planter le sélecteur de voix à l'exécution. - external.ts : fileSize et aiGenerated étaient transmis dans un type que le schéma ne déclare pas, et ne fonctionnaient que par conversion implicite de PostgreSQL. - reels.ts : l'interface MusicTrack, référencée mais jamais déclarée dans ce module. - remotion.ts : chromiumOptions.args retiré. Remotion ne lit jamais cette clé, les drapeaux Chrome qu'elle contenait n'atteignaient donc pas le navigateur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
This commit is contained in:
16 files changed
+173
-25
No files matched your search
@@ -169,6 +169,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.instagramFeedUrl || media.originalUrl}
|
src={media.instagramFeedUrl || media.originalUrl}
|
||||||
alt="Selected"
|
alt="Selected"
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={media.type === 'video' ? 'video' : 'image'}
|
type={media.type === 'video' ? 'video' : 'image'}
|
||||||
className="w-full h-24 object-cover rounded-lg border-2 border-primary"
|
className="w-full h-24 object-cover rounded-lg border-2 border-primary"
|
||||||
/>
|
/>
|
||||||
@@ -203,6 +204,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.instagramFeedUrl || media.originalUrl}
|
src={media.instagramFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={media.type === 'video' ? 'video' : 'image'}
|
type={media.type === 'video' ? 'video' : 'image'}
|
||||||
className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${
|
className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${
|
||||||
isSelected ? 'border-primary' : 'border-border hover:border-primary/50'
|
isSelected ? 'border-primary' : 'border-border hover:border-primary/50'
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ export function MediaThumbnail({ src, alt, type, className = "w-full h-full obje
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
console.warn('Video thumbnail generation failed for:', src);
|
// Cas courant et attendu : la vidéo a été supprimée du disque après
|
||||||
|
// publication. Les médias créés depuis embarquent une vignette persistée,
|
||||||
|
// qui évite d'en arriver là.
|
||||||
|
console.warn('Aperçu vidéo indisponible (fichier absent ou illisible):', src);
|
||||||
setError(true);
|
setError(true);
|
||||||
video.remove();
|
video.remove();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ export default function MediaUpload() {
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={media.type === 'video' ? 'video' : 'image'}
|
type={media.type === 'video' ? 'video' : 'image'}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-2 right-2 flex gap-2">
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ function SortableMediaItem({
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={isVideo ? 'video' : 'image'}
|
type={isVideo ? 'video' : 'image'}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -535,6 +536,7 @@ export default function NewPostMobile() {
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={isVideo ? 'video' : 'image'}
|
type={isVideo ? 'video' : 'image'}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { MediaThumbnail } from "@/components/media-thumbnail";
|
|||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { DateTimePicker } from "@/components/datetime-picker";
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ function SortableMediaItem({
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={isVideo ? 'video' : 'image'}
|
type={isVideo ? 'video' : 'image'}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -543,6 +544,7 @@ export default function NewPost() {
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={isVideo ? 'video' : 'image'}
|
type={isVideo ? 'video' : 'image'}
|
||||||
/>
|
/>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
|
|||||||
@@ -568,6 +568,7 @@ export default function NewReel() {
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.originalUrl}
|
src={media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type="video"
|
type="video"
|
||||||
/>
|
/>
|
||||||
{selectedVideoId === media.id && (
|
{selectedVideoId === media.id && (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { DateTimePicker } from "@/components/datetime-picker";
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
import type { Media, SocialPage } from "@shared/schema";
|
import type { Media, SocialPage } from "@shared/schema";
|
||||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||||
|
|||||||
@@ -161,6 +161,12 @@ export async function migrate() {
|
|||||||
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
|
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)
|
// tiktok_config (application développeur TikTok, globale)
|
||||||
await client.query(`
|
await client.query(`
|
||||||
CREATE TABLE IF NOT EXISTS "tiktok_config" (
|
CREATE TABLE IF NOT EXISTS "tiktok_config" (
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ router.post("/publish", async (req, res) => {
|
|||||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize: String(buffer.length),
|
fileSize: buffer.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ router.post("/publish", async (req, res) => {
|
|||||||
content,
|
content,
|
||||||
status: scheduledFor ? "scheduled" : "scheduled",
|
status: scheduledFor ? "scheduled" : "scheduled",
|
||||||
scheduledFor: scheduledFor ?? new Date(),
|
scheduledFor: scheduledFor ?? new Date(),
|
||||||
aiGenerated: false,
|
aiGenerated: "false",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lier le média si présent
|
// Lier le média si présent
|
||||||
@@ -415,7 +415,7 @@ router.patch("/posts/:id", async (req, res) => {
|
|||||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize: String(buffer.length),
|
fileSize: buffer.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remplacer les médias existants par le nouveau
|
// Remplacer les médias existants par le nouveau
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { storage } from '../storage';
|
|||||||
import { ffmpegService } from '../services/ffmpeg';
|
import { ffmpegService } from '../services/ffmpeg';
|
||||||
import { facebookService } from '../services/facebook';
|
import { facebookService } from '../services/facebook';
|
||||||
import { tiktokService } from '../services/tiktok';
|
import { tiktokService } from '../services/tiktok';
|
||||||
|
import { createVideoThumbnail } from '../services/thumbnail';
|
||||||
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
|
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
|
||||||
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
@@ -15,6 +16,19 @@ import { cloudinaryConfig } from '@shared/schema';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { ttsSyncService } from '../services/ttsSync';
|
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();
|
export const reelsRouter = Router();
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -616,6 +630,10 @@ async function processReelBackground(
|
|||||||
console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl);
|
console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl);
|
||||||
|
|
||||||
// 5. Créer l'enregistrement Media pour la vidéo traitée
|
// 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({
|
const processedMedia = await storage.createMedia({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
type: 'video',
|
type: 'video',
|
||||||
@@ -624,6 +642,7 @@ async function processReelBackground(
|
|||||||
facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null,
|
facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null,
|
||||||
instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null,
|
instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null,
|
||||||
instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null,
|
instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null,
|
||||||
|
thumbnailUrl,
|
||||||
fileName: `reel-processed-${Date.now()}.mp4`,
|
fileName: `reel-processed-${Date.now()}.mp4`,
|
||||||
fileSize: videoBuffer.length,
|
fileSize: videoBuffer.length,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { Router } from "express";
|
|||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { exec } from "child_process";
|
|
||||||
import { promisify } from "util";
|
|
||||||
import { bundle } from "@remotion/bundler";
|
import { bundle } from "@remotion/bundler";
|
||||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||||
import { ffmpegService } from "../services/ffmpeg";
|
import { ffmpegService } from "../services/ffmpeg";
|
||||||
@@ -11,26 +9,9 @@ import { storage as dbStorage } from "../storage";
|
|||||||
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
|
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
|
||||||
import { facebookService } from "../services/facebook";
|
import { facebookService } from "../services/facebook";
|
||||||
import { tiktokService } from "../services/tiktok";
|
import { tiktokService } from "../services/tiktok";
|
||||||
|
import { generateVideoThumbnail, createVideoThumbnail } from "../services/thumbnail";
|
||||||
import * as musicMetadata from "music-metadata";
|
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<boolean> {
|
|
||||||
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();
|
export const remotionRouter = Router();
|
||||||
|
|
||||||
@@ -326,10 +307,12 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
|
|||||||
codec: "h264",
|
codec: "h264",
|
||||||
outputLocation,
|
outputLocation,
|
||||||
inputProps,
|
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: {
|
chromiumOptions: {
|
||||||
disableWebSecurity: true,
|
disableWebSecurity: true,
|
||||||
ignoreCertificateErrors: true,
|
ignoreCertificateErrors: true,
|
||||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
|
|
||||||
},
|
},
|
||||||
concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion
|
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
|
// 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({
|
const mediaRecord = await dbStorage.createMedia({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: "video",
|
type: "video",
|
||||||
@@ -416,6 +402,7 @@ remotionRouter.post("/publish", async (req, res) => {
|
|||||||
facebookFeedUrl: null,
|
facebookFeedUrl: null,
|
||||||
instagramFeedUrl: null,
|
instagramFeedUrl: null,
|
||||||
instagramStoryUrl: null,
|
instagramStoryUrl: null,
|
||||||
|
thumbnailUrl,
|
||||||
fileName: filename,
|
fileName: filename,
|
||||||
fileSize: fs.statSync(localPath).size,
|
fileSize: fs.statSync(localPath).size,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ export async function purgeOldMedia() {
|
|||||||
if (item.cloudinaryPublicId) {
|
if (item.cloudinaryPublicId) {
|
||||||
await storageService.deleteMedia(item.cloudinaryPublicId, item.userId, item.type === 'video' ? 'video' : 'image');
|
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));
|
await db.delete(media).where(eq(media.id, item.id));
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -106,6 +106,21 @@ class LocalStorageService {
|
|||||||
return buildMinioUrl('', objectKey);
|
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 }> {
|
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
|
||||||
const dir = path.join(UPLOADS_BASE, 'logos');
|
const dir = path.join(UPLOADS_BASE, 'logos');
|
||||||
ensureDir(dir);
|
ensureDir(dir);
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
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<string | null> {
|
||||||
|
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 */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,9 @@ export const media = pgTable("media", {
|
|||||||
facebookFeedUrl: text("facebook_feed_url"),
|
facebookFeedUrl: text("facebook_feed_url"),
|
||||||
instagramFeedUrl: text("instagram_feed_url"),
|
instagramFeedUrl: text("instagram_feed_url"),
|
||||||
instagramStoryUrl: text("instagram_story_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(),
|
fileName: text("file_name").notNull(),
|
||||||
fileSize: integer("file_size").notNull(),
|
fileSize: integer("file_size").notNull(),
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
|||||||
Reference in new issue
Block a user