const { onObjectFinalized } = require("firebase-functions/v2/storage"); const logger = require("firebase-functions/logger"); const admin = require("firebase-admin"); 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: admin.firestore.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 }); } } );