mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-17 04:09:51 +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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user