const { onObjectFinalized } = require('firebase-functions/v2/storage') const logger = require('firebase-functions/logger') const admin = require('firebase-admin') const { FieldValue } = require('firebase-admin/firestore') const ffmpeg = require('fluent-ffmpeg') const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg') const fs = require('node:fs/promises') const os = require('node:os') const path = require('node:path') const crypto = require('node:crypto') const { refList } = require('../index') ffmpeg.setFfmpegPath(ffmpegInstaller.path) exports.generateVideoThumbnail = onObjectFinalized( { region: 'europe-west1', timeoutSeconds: 180, memory: '1GiB', cpu: 1, }, async (event) => { const file = event.data || {} const bucketName = file.bucket const objectName = file.name || '' const contentType = file.contentType || '' // Basic guards + helpful logs for debugging why events might be ignored if (!bucketName || !objectName) { logger.info('[Thumbnail] Ignored: missing bucket or object name', { bucketName, objectName, contentType, }) return } // Avoid processing our own generated thumbnails if (/_thumb9x16\.jpg$/i.test(objectName)) { logger.info('[Thumbnail] Ignored: already a thumbnail', { objectName }) return } // Accept if contentType says it's a video OR fallback to extension-based check const isVideoContentType = typeof contentType === 'string' && contentType.startsWith('video/') const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(objectName.toLowerCase()) if (!isVideoContentType && !isVideoLikeName) { logger.info('[Thumbnail] Ignored: not a video', { objectName, contentType, }) return } logger.info('[Thumbnail] Event accepted', { bucketName, objectName, contentType, }) const bucket = admin.storage().bucket(bucketName) const playbackMatch = objectName.match(/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i) const projectId = playbackMatch ? playbackMatch[1] : null // Use unique folder under /tmp to avoid name collisions const tmpDir = path.join(os.tmpdir(), `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`) const baseName = path.basename(objectName) const dirName = path.posix.dirname(objectName) const localVideoPath = path.join(tmpDir, baseName) const thumbBase = baseName.replace(/\.[^.]+$/, '') + '_thumb9x16.jpg' const localThumbPath = path.join(tmpDir, thumbBase) const remoteThumbPath = dirName && dirName !== '.' ? path.posix.join(dirName, thumbBase) : thumbBase try { await fs.mkdir(tmpDir, { recursive: true }) // Télécharger la vidéo depuis le bucket await bucket.file(objectName).download({ destination: localVideoPath }) // Extraire 1 frame en 9:16 (1080x1920) de manière robuste const vfCoverCrop = 'scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920' const vfPad = 'scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2' const extractFrame = (ssSeconds, vf) => new Promise((resolve, reject) => { ffmpeg(localVideoPath) .inputOptions([`-ss ${ssSeconds}`]) .frames(1) .outputOptions(['-vf', vf, '-q:v', '2']) .output(localThumbPath) .on('end', resolve) .on('error', reject) .run() }) try { // 1) Essai principal: 1s + cover/crop await extractFrame(1, vfCoverCrop) } catch (e1) { logger.warn('[Thumbnail] First attempt failed, retrying at 0s', { objectName, error: e1?.message || String(e1), }) try { // 2) Deuxième essai: 0s + cover/crop (si vidéo très courte) await extractFrame(0, vfCoverCrop) } catch (e2) { logger.warn('[Thumbnail] Second attempt failed, fallback to pad', { objectName, error: e2?.message || String(e2), }) // 3) Fallback: 0s + pad (aucun crop, bandes latérales si besoin) await extractFrame(0, vfPad) } } // Upload du thumbnail avec un token de téléchargement public Firebase const downloadToken = crypto.randomUUID() await bucket.upload(localThumbPath, { destination: remoteThumbPath, metadata: { contentType: 'image/jpeg', cacheControl: 'public, max-age=86400', metadata: { original: objectName, aspect: '9:16', t: '1s', firebaseStorageDownloadTokens: downloadToken, }, }, }) const encodedPath = encodeURIComponent(remoteThumbPath) const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}` if (projectId) { await refList.projects.doc(projectId).set( { thumbnailUrl, updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ) } logger.info('✅ [Thumbnail] Uploaded', { objectName, remoteThumbPath, projectId, thumbnailUrl, }) } catch (error) { logger.error('❌ [Thumbnail] Failed', { objectName, error: error?.message || String(error), }) throw error } finally { await fs.rm(tmpDir, { recursive: true, force: true }) } } )