// 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"); if (!admin.apps.length) admin.initializeApp(); ffmpeg.setFfmpegPath(ffmpegInstaller.path); async function downloadToFile(url, destPath) { 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"] || ""; } async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) { return new Promise((resolve, reject) => { ffmpeg() .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 "-c:v", "copy", // ne pas réencoder la vidéo "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", "-shortest", // couper à la plus courte des 2 sources ]) .on("error", reject) .on("end", resolve) .save(outPath); }); } exports.mergeVideoAndAudio = onCall( { timeoutSeconds: 540, memory: "1GiB" }, async ({ data = {}, auth }) => { const uid = auth?.uid; if (!uid) throw new HttpsError("unauthenticated", "Authentification requise"); const { videoUrl, audioUrl, storagePath } = data || {}; if (!videoUrl || !audioUrl || !storagePath) { throw new HttpsError( "invalid-argument", "Requis: { videoUrl, audioUrl, storagePath }" ); } // sécurité simple: forcer dans le dossier de l'utilisateur si tu veux const expectedPrefix = `users/${uid}/`; if (!storagePath.startsWith(expectedPrefix)) { throw new HttpsError( "permission-denied", `storagePath doit commencer par ${expectedPrefix}` ); } 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 }); await downloadToFile(videoUrl, videoPath); await downloadToFile(audioUrl, audioPath); logger.info("[merge] fusion ffmpeg (mux)"); await muxAudioIntoVideo({ videoPath, audioPath, outPath }); const bucket = admin.storage().bucket(); const downloadToken = crypto.randomUUID(); await bucket.upload(outPath, { destination: storagePath, metadata: { 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}`; logger.info("[merge] upload terminé", { storagePath }); return { success: true, url: fileUrl, 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"); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } } );