thumbnailUrl

This commit is contained in:
2025-10-01 10:23:49 +02:00
parent d2607b0755
commit 61f95a15fe
5 changed files with 306 additions and 92 deletions
+43 -43
View File
@@ -1,20 +1,20 @@
const { onObjectFinalized } = require('firebase-functions/v2/storage');
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const path = require('path');
const os = require('os');
const fs = require('fs');
const { onObjectFinalized } = require("firebase-functions/v2/storage");
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const path = require("path");
const os = require("os");
const fs = require("fs");
// These deps must be installed in functions/: ffmpeg-static, fluent-ffmpeg
let ffmpeg;
let ffmpegPath;
try {
ffmpeg = require('fluent-ffmpeg');
ffmpegPath = require('ffmpeg-static');
ffmpeg = require("fluent-ffmpeg");
ffmpegPath = require("ffmpeg-static");
ffmpeg.setFfmpegPath(ffmpegPath);
} catch (e) {
console.log(
'ffmpeg modules not installed yet — deploy after npm i',
"ffmpeg modules not installed yet — deploy after npm i",
e?.message
);
}
@@ -23,56 +23,56 @@ const bucket = admin.storage().bucket();
exports.transcodePlaybackToHLS = onObjectFinalized(
{
region: 'europe-west1',
memory: '2GiB',
region: "europe-west1",
memory: "2GiB",
timeoutSeconds: 540,
cpu: 2,
},
async (event) => {
const object = event.data;
const filePath = object?.name || ''; // e.g., musics/{projectId}/source.mp4
const contentType = object?.contentType || '';
const filePath = object?.name || ""; // e.g., musics/{projectId}/source.mp4
const contentType = object?.contentType || "";
try {
// Only process our target path + video files
const match = filePath.match(/^musics\/(.+?)\/source\.mp4$/i);
if (!match || !contentType.startsWith('video/')) {
if (!match || !contentType.startsWith("video/")) {
return;
}
const projectId = match[1];
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), `hls_${projectId}_`)
);
const localSource = path.join(tempDir, 'source.mp4');
const hlsDir = path.join(tempDir, 'hls');
const localSource = path.join(tempDir, "source.mp4");
const hlsDir = path.join(tempDir, "hls");
fs.mkdirSync(hlsDir);
// Download source
await bucket.file(filePath).download({ destination: localSource });
if (!ffmpeg) {
console.warn('ffmpeg not available; skip transcode');
console.warn("ffmpeg not available; skip transcode");
return;
}
// Transcode to HLS (single rendition for simplicity)
const masterPath = path.join(hlsDir, 'master.m3u8');
const segmentPattern = path.join(hlsDir, 'segment_%03d.ts');
const masterPath = path.join(hlsDir, "master.m3u8");
const segmentPattern = path.join(hlsDir, "segment_%03d.ts");
await new Promise((resolve, reject) => {
ffmpeg(localSource)
.outputOptions([
'-profile:v baseline',
'-level 3.0',
'-start_number 0',
'-hls_time 4',
'-hls_list_size 0',
'-f hls',
"-profile:v baseline",
"-level 3.0",
"-start_number 0",
"-hls_time 4",
"-hls_list_size 0",
"-f hls",
`-hls_segment_filename ${segmentPattern}`,
])
.output(masterPath)
.on('end', resolve)
.on('error', reject)
.on("end", resolve)
.on("error", reject)
.run();
});
@@ -82,28 +82,28 @@ exports.transcodePlaybackToHLS = onObjectFinalized(
// Upload all segments first
const uploads = fileNames
.filter((n) => n.endsWith('.ts'))
.filter((n) => n.endsWith(".ts"))
.map((name) => {
const local = path.join(hlsDir, name);
const dest = `${destPrefix}${name}`;
return bucket.upload(local, {
destination: dest,
metadata: {
contentType: 'video/MP2T',
cacheControl: 'public, max-age=3600',
contentType: "video/MP2T",
cacheControl: "public, max-age=3600",
},
});
});
await Promise.all(uploads);
// Generate signed URLs for segments and rewrite playlist with absolute URLs
const segmentNames = fileNames.filter((n) => n.endsWith('.ts'));
const segmentNames = fileNames.filter((n) => n.endsWith(".ts"));
const signedSegments = {};
await Promise.all(
segmentNames.map(async (name) => {
const file = bucket.file(`${destPrefix}${name}`);
const [url] = await file.getSignedUrl({
action: 'read',
action: "read",
expires: Date.now() + 1000 * 60 * 60 * 24 * 365,
});
signedSegments[name] = url;
@@ -111,24 +111,24 @@ exports.transcodePlaybackToHLS = onObjectFinalized(
);
// Rewrite master playlist to use absolute signed segment URLs
let masterContent = fs.readFileSync(masterPath, 'utf8');
let masterContent = fs.readFileSync(masterPath, "utf8");
masterContent = masterContent
.split(/\r?\n/)
.map((line) =>
line.endsWith('.ts') && signedSegments[line]
line.endsWith(".ts") && signedSegments[line]
? signedSegments[line]
: line
)
.join('\n');
const rewrittenMasterLocal = path.join(hlsDir, 'master.abs.m3u8');
fs.writeFileSync(rewrittenMasterLocal, masterContent, 'utf8');
.join("\n");
const rewrittenMasterLocal = path.join(hlsDir, "master.abs.m3u8");
fs.writeFileSync(rewrittenMasterLocal, masterContent, "utf8");
// Upload rewritten playlist
await bucket.upload(rewrittenMasterLocal, {
destination: `${destPrefix}master.m3u8`,
metadata: {
contentType: 'application/vnd.apple.mpegurl',
cacheControl: 'public, max-age=3600',
contentType: "application/vnd.apple.mpegurl",
cacheControl: "public, max-age=3600",
},
});
@@ -136,15 +136,15 @@ exports.transcodePlaybackToHLS = onObjectFinalized(
const [signedUrl] = await bucket
.file(`${destPrefix}master.m3u8`)
.getSignedUrl({
action: 'read',
action: "read",
expires: Date.now() + 1000 * 60 * 60 * 24 * 365,
});
// Update Firestore project doc with playbackUrl
await admin.firestore().collection('projects').doc(projectId).set(
await admin.firestore().collection("projects").doc(projectId).set(
{
playbackUrl: signedUrl,
playbackStatus: 'ready',
playbackStatus: "ready",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
@@ -155,7 +155,7 @@ exports.transcodePlaybackToHLS = onObjectFinalized(
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (e) {}
} catch (e) {
console.error('HLS transcode error', e);
console.error("HLS transcode error", e);
}
}
);
+119
View File
@@ -0,0 +1,119 @@
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");
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 || "";
if (!bucketName) return;
if (!contentType.startsWith("video/")) return;
if (!objectName) return;
if (/_thumb9x16\.jpg$/i.test(objectName)) return;
const bucket = admin.storage().bucket(bucketName);
const playbackMatch = objectName.match(/^musics\/([^/]+)\/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 à 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();
});
// 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 admin.firestore().collection("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 });
}
}
);