From e7b4f01573aecfc6d342a92ea9062ee2f7770cfe Mon Sep 17 00:00:00 2001 From: leon-morival Date: Thu, 2 Oct 2025 13:55:01 +0200 Subject: [PATCH] upload and thumbnail fix --- functions/src/thumbnail.js | 101 ++++++++++++++++++++++++++++--------- functions/src/upload.js | 20 +++++--- 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/functions/src/thumbnail.js b/functions/src/thumbnail.js index 6aa6327..92553ec 100644 --- a/functions/src/thumbnail.js +++ b/functions/src/thumbnail.js @@ -23,21 +23,52 @@ exports.generateVideoThumbnail = onObjectFinalized( const objectName = file.name || ""; const contentType = file.contentType || ""; - if (!bucketName) return; - if (!contentType.startsWith("video/")) return; - if (!objectName) return; - if (/_thumb9x16\.jpg$/i.test(objectName)) return; + // 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, + /^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)}`, + `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}` ); const baseName = path.basename(objectName); const dirName = path.posix.dirname(objectName); @@ -56,22 +87,44 @@ exports.generateVideoThumbnail = onObjectFinalized( // Télécharger la vidéo depuis le bucket await bucket.file(objectName).download({ destination: localVideoPath }); - // Extraire 1 frame à 1 seconde, forcer 9:16 (1080x1920) par scale+crop centré - await new Promise((resolve, reject) => { - ffmpeg(localVideoPath) - .inputOptions(["-ss 1"]) - .frames(1) - .outputOptions([ - "-vf", - "scale='if(gt(a,9/16),1080,-2)':'if(gt(a,9/16),-2,1920)',crop=1080:1920", - "-q:v", - "2", - ]) - .output(localThumbPath) - .on("end", resolve) - .on("error", reject) - .run(); - }); + // 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(); @@ -98,7 +151,7 @@ exports.generateVideoThumbnail = onObjectFinalized( thumbnailUrl, updatedAt: admin.firestore.FieldValue.serverTimestamp(), }, - { merge: true }, + { merge: true } ); } @@ -117,5 +170,5 @@ exports.generateVideoThumbnail = onObjectFinalized( } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } - }, + } ); diff --git a/functions/src/upload.js b/functions/src/upload.js index 38000f0..6f1f6a9 100644 --- a/functions/src/upload.js +++ b/functions/src/upload.js @@ -201,7 +201,8 @@ exports.uploadProjectMedia = onCall( detectedContentType = meta?.contentType || ""; } catch {} } - const ext = path.extname(sourcePath) || guessExtension(detectedContentType); + const ext = + path.extname(sourcePath) || guessExtension(detectedContentType); inputPath = `${baseInputPath}${ext || ""}`; await sourceFile.download({ destination: inputPath }); const { size } = await fs.stat(inputPath); @@ -228,7 +229,9 @@ exports.uploadProjectMedia = onCall( } else if (/^https?:\/\//i.test(uri)) { const downloaded = await downloadFromHttp(uri); buffer = downloaded.buffer; - if (!detectedContentType) detectedContentType = downloaded.contentType; + if (!detectedContentType) { + detectedContentType = downloaded.contentType; + } const ext = guessExtension(downloaded.contentType); inputPath = `${baseInputPath}${ext}`; logger.info("[uploadProjectMedia] Downloaded remote resource", { @@ -288,8 +291,13 @@ exports.uploadProjectMedia = onCall( const downloadToken = crypto.randomUUID(); const sanitizedMetadata = sanitizeMetadata(metadata); + // If we are writing the canonical playback file, make sure contentType is correct + const forceVideoMp4 = /\/playback\.mp4$/i.test(storagePath); + const effectiveContentType = forceVideoMp4 + ? "video/mp4" + : finalContentType; const uploadMetadata = { - contentType: finalContentType, + contentType: effectiveContentType, cacheControl: "public,max-age=86400", metadata: { ...sanitizedMetadata, @@ -302,7 +310,7 @@ exports.uploadProjectMedia = onCall( logger.info("[uploadProjectMedia] Uploading to bucket", { projectId, storagePath, - finalContentType, + finalContentType: effectiveContentType, finalSize, }); @@ -341,13 +349,13 @@ exports.uploadProjectMedia = onCall( logger.info("✅ [uploadProjectMedia] Upload terminé", { projectId, storagePath, - contentType: finalContentType, + contentType: effectiveContentType, }); return { success: true, url: fileUrl, - contentType: finalContentType, + contentType: effectiveContentType, storagePath, downloadToken, sourcePath: hasSourcePath ? sourcePath : null,