thumbnailUrl
This commit is contained in:
+43
-43
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user