feat: fixes and formatter
This commit is contained in:
+72
-83
@@ -1,151 +1,140 @@
|
||||
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");
|
||||
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);
|
||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
|
||||
|
||||
exports.generateVideoThumbnail = onObjectFinalized(
|
||||
{
|
||||
region: "europe-west1",
|
||||
region: 'europe-west1',
|
||||
timeoutSeconds: 180,
|
||||
memory: "1GiB",
|
||||
memory: '1GiB',
|
||||
cpu: 1,
|
||||
},
|
||||
async (event) => {
|
||||
const file = event.data || {};
|
||||
const bucketName = file.bucket;
|
||||
const objectName = file.name || "";
|
||||
const contentType = file.contentType || "";
|
||||
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", {
|
||||
logger.info('[Thumbnail] Ignored: missing bucket or object name', {
|
||||
bucketName,
|
||||
objectName,
|
||||
contentType,
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid processing our own generated thumbnails
|
||||
if (/_thumb9x16\.jpg$/i.test(objectName)) {
|
||||
logger.info("[Thumbnail] Ignored: already a thumbnail", { objectName });
|
||||
return;
|
||||
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()
|
||||
);
|
||||
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", {
|
||||
logger.info('[Thumbnail] Ignored: not a video', {
|
||||
objectName,
|
||||
contentType,
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("[Thumbnail] Event accepted", {
|
||||
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;
|
||||
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 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 thumbBase = baseName.replace(/\.[^.]+$/, '') + '_thumb9x16.jpg'
|
||||
const localThumbPath = path.join(tmpDir, thumbBase)
|
||||
const remoteThumbPath =
|
||||
dirName && dirName !== "."
|
||||
? path.posix.join(dirName, thumbBase)
|
||||
: thumbBase;
|
||||
dirName && dirName !== '.' ? path.posix.join(dirName, thumbBase) : thumbBase
|
||||
|
||||
try {
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
await fs.mkdir(tmpDir, { recursive: true })
|
||||
|
||||
// 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 en 9:16 (1080x1920) de manière robuste
|
||||
const vfCoverCrop =
|
||||
"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920";
|
||||
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";
|
||||
'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"])
|
||||
.outputOptions(['-vf', vf, '-q:v', '2'])
|
||||
.output(localThumbPath)
|
||||
.on("end", resolve)
|
||||
.on("error", reject)
|
||||
.run();
|
||||
});
|
||||
.on('end', resolve)
|
||||
.on('error', reject)
|
||||
.run()
|
||||
})
|
||||
|
||||
try {
|
||||
// 1) Essai principal: 1s + cover/crop
|
||||
await extractFrame(1, vfCoverCrop);
|
||||
await extractFrame(1, vfCoverCrop)
|
||||
} catch (e1) {
|
||||
logger.warn("[Thumbnail] First attempt failed, retrying at 0s", {
|
||||
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);
|
||||
await extractFrame(0, vfCoverCrop)
|
||||
} catch (e2) {
|
||||
logger.warn("[Thumbnail] Second attempt failed, fallback to pad", {
|
||||
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);
|
||||
await extractFrame(0, vfPad)
|
||||
}
|
||||
}
|
||||
|
||||
// Upload du thumbnail avec un token de téléchargement public Firebase
|
||||
const downloadToken = crypto.randomUUID();
|
||||
const downloadToken = crypto.randomUUID()
|
||||
await bucket.upload(localThumbPath, {
|
||||
destination: remoteThumbPath,
|
||||
metadata: {
|
||||
contentType: "image/jpeg",
|
||||
cacheControl: "public, max-age=86400",
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: 'public, max-age=86400',
|
||||
metadata: {
|
||||
original: objectName,
|
||||
aspect: "9:16",
|
||||
t: "1s",
|
||||
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}`;
|
||||
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(
|
||||
@@ -154,23 +143,23 @@ exports.generateVideoThumbnail = onObjectFinalized(
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
logger.info("✅ [Thumbnail] Uploaded", {
|
||||
logger.info('✅ [Thumbnail] Uploaded', {
|
||||
objectName,
|
||||
remoteThumbPath,
|
||||
projectId,
|
||||
thumbnailUrl,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error("❌ [Thumbnail] Failed", {
|
||||
logger.error('❌ [Thumbnail] Failed', {
|
||||
objectName,
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
throw error;
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user