upload and thumbnail fix
This commit is contained in:
+77
-24
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user