feat: fixes and formatter
This commit is contained in:
+115
-129
@@ -1,35 +1,35 @@
|
||||
// functions/mergeVideoAndAudio.js (ou dans index.js)
|
||||
|
||||
const { onCall, HttpsError } = require("firebase-functions/v2/https");
|
||||
const admin = require("firebase-admin");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const axios = require("axios");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const ffmpeg = require("fluent-ffmpeg");
|
||||
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
|
||||
const { Buffer } = require("node:buffer");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { onCall, HttpsError } = require('firebase-functions/v2/https')
|
||||
const admin = require('firebase-admin')
|
||||
const logger = require('firebase-functions/logger')
|
||||
const axios = require('axios')
|
||||
const fs = require('node:fs/promises')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const crypto = require('node:crypto')
|
||||
const ffmpeg = require('fluent-ffmpeg')
|
||||
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
|
||||
const { Buffer } = require('node:buffer')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
|
||||
if (!admin.apps.length) admin.initializeApp();
|
||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
||||
if (!admin.apps.length) admin.initializeApp()
|
||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
|
||||
|
||||
const db = admin.firestore();
|
||||
const PLAYBACK_CODEC_TAG = "h264-v1";
|
||||
const db = admin.firestore()
|
||||
const PLAYBACK_CODEC_TAG = 'h264-v1'
|
||||
|
||||
async function downloadToFile(url, destPath) {
|
||||
if (!/^https?:\/\//i.test(url || "")) {
|
||||
throw new HttpsError("invalid-argument", `URL non supportée: ${url}`);
|
||||
if (!/^https?:\/\//i.test(url || '')) {
|
||||
throw new HttpsError('invalid-argument', `URL non supportée: ${url}`)
|
||||
}
|
||||
const res = await axios.get(url, { responseType: "arraybuffer" });
|
||||
await fs.writeFile(destPath, Buffer.from(res.data));
|
||||
return res.headers?.["content-type"] || "";
|
||||
const res = await axios.get(url, { responseType: 'arraybuffer' })
|
||||
await fs.writeFile(destPath, Buffer.from(res.data))
|
||||
return res.headers?.['content-type'] || ''
|
||||
}
|
||||
|
||||
const SCALE_FILTER =
|
||||
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease";
|
||||
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease"
|
||||
|
||||
async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -37,92 +37,92 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
||||
.input(videoPath) // 0:v
|
||||
.input(audioPath) // 1:a
|
||||
.outputOptions([
|
||||
"-map",
|
||||
"0:v:0", // garder la 1re piste vidéo de l'entrée 0
|
||||
"-map",
|
||||
"1:a:0", // prendre la 1re piste audio de l'entrée 1
|
||||
'-map',
|
||||
'0:v:0', // garder la 1re piste vidéo de l'entrée 0
|
||||
'-map',
|
||||
'1:a:0', // prendre la 1re piste audio de l'entrée 1
|
||||
// Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème)
|
||||
"-vf",
|
||||
'-vf',
|
||||
SCALE_FILTER,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"22",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-profile:v",
|
||||
"high",
|
||||
"-level:v",
|
||||
"4.1",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-shortest", // couper à la plus courte des 2 sources
|
||||
"-tag:v",
|
||||
"avc1",
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'veryfast',
|
||||
'-crf',
|
||||
'22',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-profile:v',
|
||||
'high',
|
||||
'-level:v',
|
||||
'4.1',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'192k',
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
'-shortest', // couper à la plus courte des 2 sources
|
||||
'-tag:v',
|
||||
'avc1',
|
||||
])
|
||||
.on("error", reject)
|
||||
.on("end", resolve)
|
||||
.save(outPath);
|
||||
});
|
||||
.on('error', reject)
|
||||
.on('end', resolve)
|
||||
.save(outPath)
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-"));
|
||||
const videoPath = path.join(tmpDir, "video.mp4");
|
||||
const audioPath = path.join(tmpDir, "audio.mp3");
|
||||
const outPath = path.join(tmpDir, "output.mp4");
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-'))
|
||||
const videoPath = path.join(tmpDir, 'video.mp4')
|
||||
const audioPath = path.join(tmpDir, 'audio.mp3')
|
||||
const outPath = path.join(tmpDir, 'output.mp4')
|
||||
|
||||
try {
|
||||
logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl });
|
||||
logger.info('[merge] téléchargement des sources', { videoUrl, audioUrl })
|
||||
|
||||
await downloadToFile(videoUrl, videoPath);
|
||||
await downloadToFile(audioUrl, audioPath);
|
||||
await downloadToFile(videoUrl, videoPath)
|
||||
await downloadToFile(audioUrl, audioPath)
|
||||
|
||||
logger.info("[merge] transcodage/mux ffmpeg");
|
||||
await muxAudioIntoVideo({ videoPath, audioPath, outPath });
|
||||
logger.info('[merge] transcodage/mux ffmpeg')
|
||||
await muxAudioIntoVideo({ videoPath, audioPath, outPath })
|
||||
|
||||
const bucket = admin.storage().bucket();
|
||||
const downloadToken = crypto.randomUUID();
|
||||
const bucket = admin.storage().bucket()
|
||||
const downloadToken = crypto.randomUUID()
|
||||
|
||||
await bucket.upload(outPath, {
|
||||
destination: storagePath,
|
||||
metadata: {
|
||||
contentType: "video/mp4",
|
||||
cacheControl: "public,max-age=86400",
|
||||
contentType: 'video/mp4',
|
||||
cacheControl: 'public,max-age=86400',
|
||||
metadata: { firebaseStorageDownloadTokens: downloadToken },
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||
storagePath
|
||||
)}?alt=media&token=${downloadToken}`;
|
||||
)}?alt=media&token=${downloadToken}`
|
||||
|
||||
logger.info("[merge] upload terminé", { storagePath });
|
||||
logger.info('[merge] upload terminé', { storagePath })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url: fileUrl,
|
||||
contentType: "video/mp4",
|
||||
contentType: 'video/mp4',
|
||||
storagePath,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("[merge] échec", { error: err?.message || String(err) });
|
||||
if (err instanceof HttpsError) throw err;
|
||||
throw new HttpsError("internal", err?.message || "Fusion échouée");
|
||||
logger.error('[merge] échec', { error: err?.message || String(err) })
|
||||
if (err instanceof HttpsError) throw err
|
||||
throw new HttpsError('internal', err?.message || 'Fusion échouée')
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
||||
if (!projectId) return;
|
||||
const docRef = db.collection("projects").doc(projectId);
|
||||
if (!projectId) return
|
||||
const docRef = db.collection('projects').doc(projectId)
|
||||
await docRef.set(
|
||||
{
|
||||
playbackCompatibility: {
|
||||
@@ -132,89 +132,75 @@ async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
||||
},
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
exports.mergeVideoAndAudio = onCall(
|
||||
{ timeoutSeconds: 540, memory: "1GiB" },
|
||||
{ timeoutSeconds: 540, memory: '1GiB' },
|
||||
async ({ data = {}, auth }) => {
|
||||
const uid = auth?.uid;
|
||||
if (!uid)
|
||||
throw new HttpsError("unauthenticated", "Authentification requise");
|
||||
const uid = auth?.uid
|
||||
if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
|
||||
|
||||
const { videoUrl, audioUrl, storagePath, projectId } = data || {};
|
||||
const { videoUrl, audioUrl, storagePath, projectId } = data || {}
|
||||
|
||||
if (!videoUrl || !audioUrl || !storagePath) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"Requis: { videoUrl, audioUrl, storagePath }"
|
||||
);
|
||||
throw new HttpsError('invalid-argument', 'Requis: { videoUrl, audioUrl, storagePath }')
|
||||
}
|
||||
|
||||
const expectedPrefix = `users/${uid}/`;
|
||||
const expectedPrefix = `users/${uid}/`
|
||||
if (!storagePath.startsWith(expectedPrefix)) {
|
||||
throw new HttpsError(
|
||||
"permission-denied",
|
||||
`storagePath doit commencer par ${expectedPrefix}`
|
||||
);
|
||||
throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`)
|
||||
}
|
||||
|
||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
|
||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
|
||||
if (projectId) {
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
||||
}
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
exports.reencodePlayback = onCall(
|
||||
{ timeoutSeconds: 540, memory: "1GiB" },
|
||||
{ timeoutSeconds: 540, memory: '1GiB' },
|
||||
async ({ data = {}, auth }) => {
|
||||
const uid = auth?.uid;
|
||||
if (!uid)
|
||||
throw new HttpsError("unauthenticated", "Authentification requise");
|
||||
const uid = auth?.uid
|
||||
if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
|
||||
|
||||
const projectId = data?.projectId;
|
||||
const projectId = data?.projectId
|
||||
if (!projectId) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"Requis: { projectId } pour relancer le transcodage"
|
||||
);
|
||||
throw new HttpsError('invalid-argument', 'Requis: { projectId } pour relancer le transcodage')
|
||||
}
|
||||
|
||||
logger.info("[reencodePlayback] request received", {
|
||||
logger.info('[reencodePlayback] request received', {
|
||||
projectId,
|
||||
initiator: uid,
|
||||
});
|
||||
const projectRef = db.collection("projects").doc(projectId);
|
||||
const projectSnap = await projectRef.get();
|
||||
})
|
||||
const projectRef = db.collection('projects').doc(projectId)
|
||||
const projectSnap = await projectRef.get()
|
||||
if (!projectSnap.exists) {
|
||||
logger.warn("[reencodePlayback] project not found", {
|
||||
logger.warn('[reencodePlayback] project not found', {
|
||||
projectId,
|
||||
initiator: uid,
|
||||
});
|
||||
throw new HttpsError("not-found", "Projet introuvable");
|
||||
})
|
||||
throw new HttpsError('not-found', 'Projet introuvable')
|
||||
}
|
||||
const project = projectSnap.data() || {};
|
||||
const videoUrl = project.playbackUrl;
|
||||
const audioUrl = project.songUrl;
|
||||
const ownerId = project.userId;
|
||||
const project = projectSnap.data() || {}
|
||||
const videoUrl = project.playbackUrl
|
||||
const audioUrl = project.songUrl
|
||||
const ownerId = project.userId
|
||||
|
||||
if (!videoUrl || !audioUrl || !ownerId) {
|
||||
logger.warn("[reencodePlayback] missing fields", {
|
||||
logger.warn('[reencodePlayback] missing fields', {
|
||||
projectId,
|
||||
hasPlaybackUrl: !!videoUrl,
|
||||
hasSongUrl: !!audioUrl,
|
||||
ownerId,
|
||||
});
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"playbackUrl, songUrl ou userId manquant"
|
||||
);
|
||||
})
|
||||
throw new HttpsError('failed-precondition', 'playbackUrl, songUrl ou userId manquant')
|
||||
}
|
||||
|
||||
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`;
|
||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
|
||||
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`
|
||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
|
||||
|
||||
await projectRef.set(
|
||||
{
|
||||
@@ -222,13 +208,13 @@ exports.reencodePlayback = onCall(
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
|
||||
logger.info("[reencodePlayback] success", {
|
||||
)
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
||||
logger.info('[reencodePlayback] success', {
|
||||
projectId,
|
||||
initiator: uid,
|
||||
storagePath,
|
||||
});
|
||||
return result;
|
||||
})
|
||||
return result
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user