upload and thumbnail fix

This commit is contained in:
2025-10-02 13:55:01 +02:00
parent db2a159eb5
commit e7b4f01573
2 changed files with 91 additions and 30 deletions
+77 -24
View File
@@ -23,21 +23,52 @@ exports.generateVideoThumbnail = onObjectFinalized(
const objectName = file.name || ""; const objectName = file.name || "";
const contentType = file.contentType || ""; const contentType = file.contentType || "";
if (!bucketName) return; // Basic guards + helpful logs for debugging why events might be ignored
if (!contentType.startsWith("video/")) return; if (!bucketName || !objectName) {
if (!objectName) return; logger.info("[Thumbnail] Ignored: missing bucket or object name", {
if (/_thumb9x16\.jpg$/i.test(objectName)) return; 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 bucket = admin.storage().bucket(bucketName);
const playbackMatch = objectName.match( const playbackMatch = objectName.match(
/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i, /^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i
); );
const projectId = playbackMatch ? playbackMatch[1] : null; const projectId = playbackMatch ? playbackMatch[1] : null;
// Use unique folder under /tmp to avoid name collisions // Use unique folder under /tmp to avoid name collisions
const tmpDir = path.join( const tmpDir = path.join(
os.tmpdir(), os.tmpdir(),
`thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`, `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`
); );
const baseName = path.basename(objectName); const baseName = path.basename(objectName);
const dirName = path.posix.dirname(objectName); const dirName = path.posix.dirname(objectName);
@@ -56,22 +87,44 @@ exports.generateVideoThumbnail = onObjectFinalized(
// Télécharger la vidéo depuis le bucket // Télécharger la vidéo depuis le bucket
await bucket.file(objectName).download({ destination: localVideoPath }); await bucket.file(objectName).download({ destination: localVideoPath });
// Extraire 1 frame à 1 seconde, forcer 9:16 (1080x1920) par scale+crop centré // Extraire 1 frame en 9:16 (1080x1920) de manière robuste
await new Promise((resolve, reject) => { const vfCoverCrop =
ffmpeg(localVideoPath) "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920";
.inputOptions(["-ss 1"]) const vfPad =
.frames(1) "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2";
.outputOptions([
"-vf", const extractFrame = (ssSeconds, vf) =>
"scale='if(gt(a,9/16),1080,-2)':'if(gt(a,9/16),-2,1920)',crop=1080:1920", new Promise((resolve, reject) => {
"-q:v", ffmpeg(localVideoPath)
"2", .inputOptions([`-ss ${ssSeconds}`])
]) .frames(1)
.output(localThumbPath) .outputOptions(["-vf", vf, "-q:v", "2"])
.on("end", resolve) .output(localThumbPath)
.on("error", reject) .on("end", resolve)
.run(); .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 // Upload du thumbnail avec un token de téléchargement public Firebase
const downloadToken = crypto.randomUUID(); const downloadToken = crypto.randomUUID();
@@ -98,7 +151,7 @@ exports.generateVideoThumbnail = onObjectFinalized(
thumbnailUrl, thumbnailUrl,
updatedAt: admin.firestore.FieldValue.serverTimestamp(), updatedAt: admin.firestore.FieldValue.serverTimestamp(),
}, },
{ merge: true }, { merge: true }
); );
} }
@@ -117,5 +170,5 @@ exports.generateVideoThumbnail = onObjectFinalized(
} finally { } finally {
await fs.rm(tmpDir, { recursive: true, force: true }); await fs.rm(tmpDir, { recursive: true, force: true });
} }
}, }
); );
+14 -6
View File
@@ -201,7 +201,8 @@ exports.uploadProjectMedia = onCall(
detectedContentType = meta?.contentType || ""; detectedContentType = meta?.contentType || "";
} catch {} } catch {}
} }
const ext = path.extname(sourcePath) || guessExtension(detectedContentType); const ext =
path.extname(sourcePath) || guessExtension(detectedContentType);
inputPath = `${baseInputPath}${ext || ""}`; inputPath = `${baseInputPath}${ext || ""}`;
await sourceFile.download({ destination: inputPath }); await sourceFile.download({ destination: inputPath });
const { size } = await fs.stat(inputPath); const { size } = await fs.stat(inputPath);
@@ -228,7 +229,9 @@ exports.uploadProjectMedia = onCall(
} else if (/^https?:\/\//i.test(uri)) { } else if (/^https?:\/\//i.test(uri)) {
const downloaded = await downloadFromHttp(uri); const downloaded = await downloadFromHttp(uri);
buffer = downloaded.buffer; buffer = downloaded.buffer;
if (!detectedContentType) detectedContentType = downloaded.contentType; if (!detectedContentType) {
detectedContentType = downloaded.contentType;
}
const ext = guessExtension(downloaded.contentType); const ext = guessExtension(downloaded.contentType);
inputPath = `${baseInputPath}${ext}`; inputPath = `${baseInputPath}${ext}`;
logger.info("[uploadProjectMedia] Downloaded remote resource", { logger.info("[uploadProjectMedia] Downloaded remote resource", {
@@ -288,8 +291,13 @@ exports.uploadProjectMedia = onCall(
const downloadToken = crypto.randomUUID(); const downloadToken = crypto.randomUUID();
const sanitizedMetadata = sanitizeMetadata(metadata); 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 = { const uploadMetadata = {
contentType: finalContentType, contentType: effectiveContentType,
cacheControl: "public,max-age=86400", cacheControl: "public,max-age=86400",
metadata: { metadata: {
...sanitizedMetadata, ...sanitizedMetadata,
@@ -302,7 +310,7 @@ exports.uploadProjectMedia = onCall(
logger.info("[uploadProjectMedia] Uploading to bucket", { logger.info("[uploadProjectMedia] Uploading to bucket", {
projectId, projectId,
storagePath, storagePath,
finalContentType, finalContentType: effectiveContentType,
finalSize, finalSize,
}); });
@@ -341,13 +349,13 @@ exports.uploadProjectMedia = onCall(
logger.info("✅ [uploadProjectMedia] Upload terminé", { logger.info("✅ [uploadProjectMedia] Upload terminé", {
projectId, projectId,
storagePath, storagePath,
contentType: finalContentType, contentType: effectiveContentType,
}); });
return { return {
success: true, success: true,
url: fileUrl, url: fileUrl,
contentType: finalContentType, contentType: effectiveContentType,
storagePath, storagePath,
downloadToken, downloadToken,
sourcePath: hasSourcePath ? sourcePath : null, sourcePath: hasSourcePath ? sourcePath : null,