// api_v1/scanner/thumbs.js const fs = require('fs'); const sharp = require('sharp'); const { exec } = require('child_process'); const path = require('path'); /* * ============================================================ * CONFIGURAZIONE IMMAGINE CAST * ============================================================ */ /* * Se il file originale è JPEG e pesa meno di questa soglia, * il file _cast.jpg sarà una copia identica dell'originale. * * 500 * 1024 = 512000 byte. */ const CAST_JPEG_COPY_LIMIT_BYTES = 500 * 1024; /* * Dimensioni massime dell'immagine Cast. * * L'immagine viene sempre contenuta dentro questo rettangolo, * senza crop e senza ingrandimento. */ const CAST_MAX_WIDTH = 1920; const CAST_MAX_HEIGHT = 1080; /* * Qualità JPEG usata quando è necessaria la conversione. */ const CAST_JPEG_QUALITY = 85; function deriveCastPathFromAvg(thumbAvgPath) { if (!thumbAvgPath) { return null; } /* * Caso standard: * * IMG_0241_avg.jpg * diventa: * IMG_0241_cast.jpg */ if (thumbAvgPath.toLowerCase().endsWith('_avg.jpg')) { return thumbAvgPath.replace( /_avg\.jpg$/i, '_cast.jpg' ); } /* * Fallback generico. */ const ext = path.extname(thumbAvgPath); const base = thumbAvgPath.slice( 0, -ext.length ); return `${base}_cast.jpg`; } /* * Video: crea soltanto AVG e MIN. */ function createVideoThumbnail( videoPath, thumbMinPath, thumbAvgPath ) { return new Promise(resolve => { const cmd = ` ffmpeg -y -i "${videoPath}" -ss 00:00:01.000 -vframes 1 "${thumbAvgPath}" && ffmpeg -y -i "${thumbAvgPath}" -vf "scale=100:-1" "${thumbMinPath}" `; exec(cmd, error => { if (error) { console.error( '[THUMB VIDEO] errore creazione thumbnail', { videoPath, thumbMinPath, thumbAvgPath, error: error.message, } ); } else { console.log( '[THUMB VIDEO] thumbnails create', { videoPath, thumbMinPath, thumbAvgPath, } ); } /* * Mantiene il comportamento originale: * la Promise viene risolta anche in caso di errore. */ resolve(); }); }); } /* * Crea la versione CAST dell'immagine. * * Regole: * * 1. Se l'originale è JPEG e pesa meno di 500 KB: * copia byte per byte l'originale nel file _cast.jpg. * * 2. In tutti gli altri casi: * converte l'immagine in JPEG qualità 85. * * 3. Se l'immagine supera 1920x1080: * viene ridimensionata per stare dentro 1920x1080. * * 4. Se l'immagine è già più piccola: * mantiene le sue dimensioni, senza ingrandirla. * * 5. Non viene mai effettuato crop. */ async function createCastImage( filePath, finalCastPath ) { const sourceStat = await fs.promises.stat(filePath); const metadata = await sharp(filePath).metadata(); /* * metadata.format === "jpeg" riconosce il contenuto reale * dell'immagine, indipendentemente dall'estensione .jpg o .jpeg. */ const sourceIsJpeg = metadata.format === 'jpeg'; const sourceIsUnderCopyLimit = sourceStat.size < CAST_JPEG_COPY_LIMIT_BYTES; /* * JPEG sotto 500 KB: * copia identica dell'originale. * * Non viene effettuata: * * - ricompressione; * - rotazione; * - modifica dei metadati; * - modifica delle dimensioni. */ if ( sourceIsJpeg && sourceIsUnderCopyLimit ) { await fs.promises.copyFile( filePath, finalCastPath ); const castStat = await fs.promises.stat(finalCastPath); console.log( '[THUMB CAST] originale JPEG copiato', { source: filePath, destination: finalCastPath, sourceBytes: sourceStat.size, castBytes: castStat.size, copyLimitBytes: CAST_JPEG_COPY_LIMIT_BYTES, sourceFormat: metadata.format, sourceWidth: metadata.width || null, sourceHeight: metadata.height || null, } ); return; } /* * Tutti gli altri casi: * * - JPEG da almeno 500 KB; * - PNG; * - WebP; * - HEIC/HEIF, se supportato da Sharp; * - TIFF; * - altri formati decodificabili da Sharp. * * L'immagine viene convertita in JPEG qualità 85. * * withoutEnlargement: true garantisce che un'immagine più * piccola di 1920x1080 mantenga le proprie dimensioni. */ await sharp(filePath) .rotate() .resize({ width: CAST_MAX_WIDTH, height: CAST_MAX_HEIGHT, fit: 'inside', withoutEnlargement: true, fastShrinkOnLoad: true, }) .jpeg({ quality: CAST_JPEG_QUALITY, progressive: false, optimizeCoding: true, }) .toFile(finalCastPath); const castStat = await fs.promises.stat(finalCastPath); const castMetadata = await sharp(finalCastPath).metadata(); console.log( '[THUMB CAST] immagine convertita', { source: filePath, destination: finalCastPath, sourceBytes: sourceStat.size, castBytes: castStat.size, sourceFormat: metadata.format || null, sourceWidth: metadata.width || null, sourceHeight: metadata.height || null, castFormat: castMetadata.format || null, castWidth: castMetadata.width || null, castHeight: castMetadata.height || null, quality: CAST_JPEG_QUALITY, maxWidth: CAST_MAX_WIDTH, maxHeight: CAST_MAX_HEIGHT, reason: sourceIsJpeg ? 'JPEG sopra o uguale alla soglia' : 'formato originale non JPEG', } ); } /* * Foto: crea MIN, AVG e CAST. */ async function createThumbnails( filePath, thumbMinPath, thumbAvgPath, thumbCastPath ) { try { const finalCastPath = thumbCastPath || deriveCastPathFromAvg(thumbAvgPath); /* * 1. MIN: massimo 100x100. */ await sharp(filePath) .rotate() .resize({ width: 100, height: 100, fit: 'inside', withoutEnlargement: true, }) .jpeg({ quality: 75, progressive: false, optimizeCoding: true, }) .toFile(thumbMinPath); /* * 2. AVG: larghezza massima 400px. */ await sharp(filePath) .rotate() .resize({ width: 400, fit: 'inside', withoutEnlargement: true, }) .jpeg({ quality: 78, progressive: false, optimizeCoding: true, }) .toFile(thumbAvgPath); /* * 3. CAST. */ if (finalCastPath) { await createCastImage( filePath, finalCastPath ); } } catch (err) { console.error( '[THUMB] errore creazione thumbnails', { filePath, thumbMinPath, thumbAvgPath, thumbCastPath, error: err.message, stack: err.stack, } ); } } module.exports = { createVideoThumbnail, createThumbnails, };