Files
musicland/functions/src/thumbnail.js
T
2025-11-05 15:39:15 +01:00

177 lines
5.6 KiB
JavaScript

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 });
}
}
);