From 02dbf98928ef3dc442d78c0329dae1b206726515 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Thu, 23 Oct 2025 11:27:40 +0200 Subject: [PATCH] fusion mp4 --- functions/src/upload.js | 559 +++--------------------- src/screens/Production/DownloadSongs.js | 37 +- 2 files changed, 82 insertions(+), 514 deletions(-) diff --git a/functions/src/upload.js b/functions/src/upload.js index b75ea8f..22aaff4 100644 --- a/functions/src/upload.js +++ b/functions/src/upload.js @@ -1,568 +1,121 @@ +// 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 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 ffmpeg = require("fluent-ffmpeg"); +const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg"); const { Buffer } = require("node:buffer"); -const { spawn } = require("node:child_process"); -const { URL } = require("node:url"); - +if (!admin.apps.length) admin.initializeApp(); ffmpeg.setFfmpegPath(ffmpegInstaller.path); -const ALLOWED_PROJECT_FIELDS = new Set([ - "songUrl", - "playbackUrl", - "recordingUrl", -]); - -function parseDataUri(uri) { - const match = /^data:([^;]+);base64,(.*)$/.exec(uri || ""); - if (!match) { - throw new Error("URI data: invalide ou non supportée"); +async function downloadToFile(url, destPath) { + if (!/^https?:\/\//i.test(url || "")) { + throw new HttpsError("invalid-argument", `URL non supportée: ${url}`); } - const [, contentType, base64Data] = match; - const buffer = Buffer.from(base64Data, "base64"); - return { buffer, contentType: contentType || "" }; + const res = await axios.get(url, { responseType: "arraybuffer" }); + await fs.writeFile(destPath, Buffer.from(res.data)); + return res.headers?.["content-type"] || ""; } -async function downloadFromHttp(uri) { - const response = await axios.get(uri, { responseType: "arraybuffer" }); - const buffer = Buffer.from(response.data); - const contentType = response.headers?.["content-type"] || ""; - return { buffer, contentType }; -} - -function isLikelyWebm(contentType = "", uri = "", storagePath = "") { - if (/webm/i.test(contentType)) return true; - if (/\.webm($|\?)/i.test(uri || "")) return true; - if (/\.webm$/i.test(storagePath || "")) return true; - return false; -} - -function guessExtension(contentType = "") { - if (!contentType) return ".bin"; - if (/mp4/i.test(contentType)) return ".mp4"; - if (/webm/i.test(contentType)) return ".webm"; - if (/mpeg/i.test(contentType)) return ".mp3"; - if (/ogg/i.test(contentType)) return ".ogg"; - if (/wav/i.test(contentType)) return ".wav"; - return ".bin"; -} - -function sanitizeMetadata(metadata = {}) { - const output = {}; - if (!metadata || typeof metadata !== "object") return output; - for (const [key, value] of Object.entries(metadata)) { - if (!key) continue; - if (value == null) continue; - output[key] = typeof value === "string" ? value : JSON.stringify(value); - } - return output; -} - -async function convertWebmToMp4(inputPath, outputPath) { - await new Promise((resolve, reject) => { - ffmpeg(inputPath) - .outputOptions([ - "-c:v", - "libx264", - "-preset", - "veryfast", - "-crf", - "23", - "-c:a", - "aac", - "-b:a", - "192k", - "-movflags", - "+faststart", - "-pix_fmt", - "yuv420p", - ]) - .on("end", resolve) - .on("error", reject) - .save(outputPath); - }); -} - -async function hasAudioStream(inputPath) { - return new Promise((resolve) => { - const ffmpegProcess = spawn( - ffmpegInstaller.path, - ["-hide_banner", "-i", inputPath], - { - windowsHide: true, - } - ); - - let stderr = ""; - - ffmpegProcess.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - - ffmpegProcess.on("error", () => { - resolve(false); - }); - - ffmpegProcess.on("close", () => { - const hasAudio = /Stream #\d+:\d+.*Audio:/i.test(stderr); - resolve(hasAudio); - }); - }); -} - -async function muxAudioIntoVideo(videoPath, audioPath, outputPath) { - await new Promise((resolve, reject) => { +async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) { + return new Promise((resolve, reject) => { ffmpeg() - .input(videoPath) - .input(audioPath) + .input(videoPath) // 0:v + .input(audioPath) // 1:a .outputOptions([ "-map", - "0:v:0", + "0:v:0", // garder la 1re piste vidéo de l'entrée 0 "-map", - "1:a:0", + "1:a:0", // prendre la 1re piste audio de l'entrée 1 "-c:v", - "copy", + "copy", // ne pas réencoder la vidéo "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", - "-shortest", + "-shortest", // couper à la plus courte des 2 sources ]) - .on("end", resolve) .on("error", reject) - .save(outputPath); + .on("end", resolve) + .save(outPath); }); } -async function downloadUriToFile({ uri, tmpDir, bucket, filenameBase }) { - if (!uri) { - throw new Error("URI audio vide"); - } - - const trimmed = String(uri || "").trim(); - if (!trimmed) { - throw new Error("URI audio vide"); - } - - const uriWithoutParams = trimmed.split("#")[0] || ""; - const uriPath = uriWithoutParams.split("?")[0] || ""; - const extFromUri = path.extname(uriPath); - let destination = path.join(tmpDir, `${filenameBase}${extFromUri || ""}`); - - const ensureExtension = (contentType) => { - if (extFromUri) return destination; - const guessed = guessExtension(contentType); - destination = `${destination}${guessed}`; - return destination; - }; - - if (/^gs:\/\//i.test(trimmed)) { - const parsed = new URL(trimmed); - const bucketName = parsed.host; - const objectPath = parsed.pathname.replace(/^\/+/, ""); - const targetBucket = - bucketName && bucketName !== bucket.name - ? admin.storage().bucket(bucketName) - : bucket; - - let contentType = ""; - try { - const [meta] = await targetBucket.file(objectPath).getMetadata(); - contentType = meta?.contentType || ""; - } catch (error) { - logger.warn("[uploadProjectMedia] Impossible de récupérer les métadonnées audio", { - uri: trimmed, - error: error?.message || String(error), - }); - } - - ensureExtension(contentType); - await targetBucket.file(objectPath).download({ destination }); - return { filePath: destination, contentType }; - } - - const downloaded = await downloadFromHttp(trimmed); - ensureExtension(downloaded.contentType); - await fs.writeFile(destination, downloaded.buffer); - return { - filePath: destination, - contentType: downloaded.contentType || "", - }; -} - -async function ensureProjectOwnership(uid, projectId) { - const snap = await admin - .firestore() - .collection("projects") - .doc(projectId) - .get(); - if (!snap.exists) { - throw new HttpsError("not-found", "Projet introuvable"); - } - const data = snap.data() || {}; - if (data.userId && data.userId !== uid) { - throw new HttpsError("permission-denied", "Accès non autorisé à ce projet"); - } - return { snap, data }; -} - -exports.uploadProjectMedia = onCall( - { - timeoutSeconds: 540, - memory: "2GiB", - }, - async ({ data = {}, auth = {} }) => { +exports.mergeVideoAndAudio = onCall( + { timeoutSeconds: 540, memory: "1GiB" }, + async ({ data = {}, auth }) => { const uid = auth?.uid; - if (!uid) { + if (!uid) throw new HttpsError("unauthenticated", "Authentification requise"); - } - const { - uri = "", - sourcePath = "", - deleteSource = true, - projectId, - storagePath, - contentType: providedContentType = "", - projectField = "", - metadata = {}, - } = data || {}; + const { videoUrl, audioUrl, storagePath } = data || {}; - const hasUri = typeof uri === "string" && uri.trim() !== ""; - const hasSourcePath = - typeof sourcePath === "string" && sourcePath.trim() !== ""; - - if (!hasUri && !hasSourcePath) { + if (!videoUrl || !audioUrl || !storagePath) { throw new HttpsError( "invalid-argument", - "Paramètre uri ou sourcePath est requis" + "Requis: { videoUrl, audioUrl, storagePath }" ); } - if (!projectId || typeof projectId !== "string") { - throw new HttpsError("invalid-argument", "projectId est requis"); - } - if (!storagePath || typeof storagePath !== "string") { - throw new HttpsError("invalid-argument", "storagePath est requis"); - } - - const expectedPrefix = `users/${uid}/projects/${projectId}/`; + // 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 être dans le dossier du projet utilisateur" + `storagePath doit commencer par ${expectedPrefix}` ); } - if (projectField) { - if (!ALLOWED_PROJECT_FIELDS.has(projectField)) { - throw new HttpsError("invalid-argument", "projectField non supporté"); - } - } - - if (hasSourcePath && !sourcePath.startsWith(expectedPrefix)) { - throw new HttpsError( - "permission-denied", - "sourcePath doit être dans le dossier du projet utilisateur" - ); - } - - logger.info("[uploadProjectMedia] Call received", { - uid, - projectId, - storagePath, - projectField: projectField || null, - providedContentType, - sourcePath: hasSourcePath ? sourcePath : null, - payloadKind: hasUri - ? /^data:/i.test(uri) - ? "data-uri" - : /^https?:\/\//i.test(uri) - ? "remote-url" - : "raw-string" - : hasSourcePath - ? "storage-object" - : "none", - metadataKeys: metadata ? Object.keys(metadata) : [], - }); - - const { snap: projectSnap, data: projectData } = - await ensureProjectOwnership(uid, projectId); - - const bucket = admin.storage().bucket(); - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "upload-")); - const baseInputPath = path.join(tmpDir, "input"); - let inputPath = baseInputPath; - let detectedContentType = providedContentType; - const sourceFile = hasSourcePath ? bucket.file(sourcePath) : null; + 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 { - if (hasSourcePath) { - if (!detectedContentType) { - try { - const [meta] = await sourceFile.getMetadata(); - detectedContentType = meta?.contentType || ""; - } catch {} - } - const ext = - path.extname(sourcePath) || guessExtension(detectedContentType); - inputPath = `${baseInputPath}${ext || ""}`; - await sourceFile.download({ destination: inputPath }); - const { size } = await fs.stat(inputPath); - logger.info("[uploadProjectMedia] Source downloaded", { - projectId, - sourcePath, - inputPath, - size, - detectedContentType, - }); - } else { - let buffer; - if (/^data:/i.test(uri)) { - const parsed = parseDataUri(uri); - buffer = parsed.buffer; - if (!detectedContentType) detectedContentType = parsed.contentType; - const ext = guessExtension(parsed.contentType); - inputPath = `${baseInputPath}${ext}`; - logger.info("[uploadProjectMedia] Parsed data URI", { - projectId, - approxSize: buffer.length, - detectedContentType, - }); - } else if (/^https?:\/\//i.test(uri)) { - const downloaded = await downloadFromHttp(uri); - buffer = downloaded.buffer; - if (!detectedContentType) { - detectedContentType = downloaded.contentType; - } - const ext = guessExtension(downloaded.contentType); - inputPath = `${baseInputPath}${ext}`; - logger.info("[uploadProjectMedia] Downloaded remote resource", { - projectId, - approxSize: buffer.length, - detectedContentType, - }); - } else { - throw new HttpsError( - "invalid-argument", - "URI non supportée. Utiliser une data URI ou une URL HTTP(S)" - ); - } + logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl }); - await fs.writeFile(inputPath, buffer); - const { size: inputSize } = await fs.stat(inputPath); - logger.info("[uploadProjectMedia] Local file written", { - projectId, - inputPath, - inputSize, - detectedContentType, - }); - } + await downloadToFile(videoUrl, videoPath); + await downloadToFile(audioUrl, audioPath); - const conversionHint = hasUri ? uri : sourcePath; - const shouldConvert = isLikelyWebm( - detectedContentType, - conversionHint, - storagePath - ); - - logger.info("[uploadProjectMedia] Conversion decision", { - projectId, - shouldConvert, - detectedContentType, - }); - - let finalPath = inputPath; - let finalContentType = detectedContentType || "application/octet-stream"; - - if (shouldConvert) { - const targetPath = path.join(tmpDir, "output.mp4"); - logger.info("[uploadProjectMedia] Conversion WebM → MP4", { - projectId, - storagePath, - }); - await convertWebmToMp4(inputPath, targetPath); - finalPath = targetPath; - finalContentType = "video/mp4"; - const { size: convertedSize } = await fs.stat(finalPath); - logger.info("[uploadProjectMedia] Conversion done", { - projectId, - convertedSize, - finalContentType, - }); - } - - const audioPresent = await hasAudioStream(finalPath); - logger.info("[uploadProjectMedia] Audio stream inspection", { - projectId, - audioPresent, - }); - - if (!audioPresent) { - const audioCandidates = [ - { field: "playbackAudioUrl", uri: projectData?.playbackAudioUrl }, - { field: "songUrl", uri: projectData?.songUrl }, - { field: "recordingUrl", uri: projectData?.recordingUrl }, - ].filter(({ uri }) => typeof uri === "string" && uri.trim() !== ""); - - if (audioCandidates.length === 0) { - throw new HttpsError( - "failed-precondition", - "Aucune source audio disponible pour ce projet" - ); - } - - let injected = false; - let lastError = null; - - for (const candidate of audioCandidates) { - try { - logger.info("[uploadProjectMedia] Tentative d'injection audio", { - projectId, - field: candidate.field, - }); - - const { filePath: audioPath } = await downloadUriToFile({ - uri: candidate.uri, - tmpDir, - bucket, - filenameBase: `audio-${candidate.field}`, - }); - - const muxedPath = path.join( - tmpDir, - `muxed-${crypto.randomUUID()}.mp4` - ); - - await muxAudioIntoVideo(finalPath, audioPath, muxedPath); - - const hasAudioAfterMux = await hasAudioStream(muxedPath); - if (!hasAudioAfterMux) { - throw new Error( - "Audio absent après tentative d'injection dans la vidéo" - ); - } - - finalPath = muxedPath; - finalContentType = "video/mp4"; - injected = true; - - logger.info("[uploadProjectMedia] Audio injecté dans la vidéo", { - projectId, - field: candidate.field, - }); - break; - } catch (error) { - lastError = error; - logger.warn("[uploadProjectMedia] Échec d'injection audio", { - projectId, - field: candidate.field, - error: error?.message || String(error), - }); - } - } - - if (!injected) { - throw new HttpsError( - "internal", - lastError?.message || - "Échec de l'injection audio dans la vidéo uploadée" - ); - } - } + logger.info("[merge] fusion ffmpeg (mux)"); + await muxAudioIntoVideo({ videoPath, audioPath, outPath }); + const bucket = admin.storage().bucket(); const downloadToken = crypto.randomUUID(); - const sanitizedMetadata = sanitizeMetadata(metadata); - // If we are writing the canonical playback file, make sure contentType is correct - const forceVideoMp4 = /\/playback\.mp4$/i.test(storagePath); - const effectiveContentType = forceVideoMp4 - ? "video/mp4" - : finalContentType; - const uploadMetadata = { - contentType: effectiveContentType, - cacheControl: "public,max-age=86400", - metadata: { - ...sanitizedMetadata, - originalContentType: detectedContentType || "", - firebaseStorageDownloadTokens: downloadToken, - }, - }; - const { size: finalSize } = await fs.stat(finalPath); - logger.info("[uploadProjectMedia] Uploading to bucket", { - projectId, - storagePath, - finalContentType: effectiveContentType, - finalSize, - }); - - await bucket.upload(finalPath, { + await bucket.upload(outPath, { destination: storagePath, - metadata: uploadMetadata, + metadata: { + contentType: "video/mp4", + cacheControl: "public,max-age=86400", + metadata: { firebaseStorageDownloadTokens: downloadToken }, + }, }); - if (hasSourcePath && deleteSource) { - try { - await sourceFile.delete({ ignoreNotFound: true }); - logger.info("[uploadProjectMedia] Source deleted", { - projectId, - sourcePath, - }); - } catch (cleanupErr) { - logger.warn("[uploadProjectMedia] Failed to delete source", { - projectId, - sourcePath, - error: cleanupErr?.message || String(cleanupErr), - }); - } - } + const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( + storagePath + )}?alt=media&token=${downloadToken}`; - const encodedPath = encodeURIComponent(storagePath); - const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodedPath}?alt=media&token=${downloadToken}`; - - const updatePayload = { - updatedAt: admin.firestore.FieldValue.serverTimestamp(), - }; - if (projectField) { - updatePayload[projectField] = fileUrl; - } - await projectSnap.ref.set(updatePayload, { merge: true }); - - logger.info("✅ [uploadProjectMedia] Upload terminé", { - projectId, - storagePath, - contentType: effectiveContentType, - }); + logger.info("[merge] upload terminé", { storagePath }); return { success: true, url: fileUrl, - contentType: effectiveContentType, + contentType: "video/mp4", storagePath, - downloadToken, - sourcePath: hasSourcePath ? sourcePath : null, - sourceDeleted: hasSourcePath ? deleteSource : false, }; - } catch (error) { - logger.error("❌ [uploadProjectMedia] Échec", { - projectId, - sourcePath: hasSourcePath ? sourcePath : null, - error: error?.message || String(error), - }); - if (error instanceof HttpsError) throw error; - throw new HttpsError( - "internal", - error?.message || "Échec du traitement de l'upload" - ); + } 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 }); } diff --git a/src/screens/Production/DownloadSongs.js b/src/screens/Production/DownloadSongs.js index 55735ab..e6a38c8 100644 --- a/src/screens/Production/DownloadSongs.js +++ b/src/screens/Production/DownloadSongs.js @@ -49,14 +49,14 @@ const uploadSourceRecording = async ({ uri, uid, projectId }) => { sourcePath, }); - await uploadFileToFirebase({ + const { resultURI: videoUrl } = await uploadFileToFirebase({ uri, path: sourcePath, shouldCompress: false, fileType: "VIDEO", }); - return { sourcePath }; + return { sourcePath, videoUrl }; }; const DownloadSongs = ({ route }) => { @@ -78,10 +78,18 @@ const DownloadSongs = ({ route }) => { uri, currentUID, }); + const audioUrl = project?.songUrl || null; + if (!audioUrl) { + setTooltip({ + type: "error", + text: "Aucune piste audio disponible pour ce projet", + }); + return; + } let tempSourcePath = null; try { setIsLoading(true); - const { sourcePath } = await uploadSourceRecording({ + const { sourcePath, videoUrl } = await uploadSourceRecording({ uri, uid: currentUID, projectId: project.id, @@ -90,22 +98,19 @@ const DownloadSongs = ({ route }) => { const callable = firebase .functions() - .httpsCallable("upload-uploadProjectMedia"); + .httpsCallable("upload-mergeVideoAndAudio"); const payload = { - sourcePath, - projectId: project.id, + videoUrl, + audioUrl, storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`, - projectField: "playbackUrl", - deleteSource: true, - metadata: { source: "downloadSongs" }, }; - console.log("[DownloadSongs] calling upload-uploadProjectMedia", payload); + console.log("[DownloadSongs] calling upload-mergeVideoAndAudio", payload); const { data: result } = await callable(payload); - console.log("[DownloadSongs] upload-uploadProjectMedia result", result); + console.log("[DownloadSongs] upload-mergeVideoAndAudio result", result); const resultURI = result?.url || null; @@ -121,6 +126,16 @@ const DownloadSongs = ({ route }) => { type: "success", text: "Vidéo uploadée", }); + if (tempSourcePath) { + try { + await firebase.storage().ref(tempSourcePath).delete(); + } catch (cleanupError) { + console.log("[DownloadSongs] unable to delete temp source", { + message: cleanupError?.message, + code: cleanupError?.code, + }); + } + } } else { setTooltip({ type: "error",