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 { Buffer } = require("node:buffer"); const { spawn } = require("node:child_process"); const { URL } = require("node:url"); 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"); } const [, contentType, base64Data] = match; const buffer = Buffer.from(base64Data, "base64"); return { buffer, contentType: contentType || "" }; } 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) => { ffmpeg() .input(videoPath) .input(audioPath) .outputOptions([ "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", "-shortest", ]) .on("end", resolve) .on("error", reject) .save(outputPath); }); } 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 = {} }) => { const uid = auth?.uid; if (!uid) { throw new HttpsError("unauthenticated", "Authentification requise"); } const { uri = "", sourcePath = "", deleteSource = true, projectId, storagePath, contentType: providedContentType = "", projectField = "", metadata = {}, } = data || {}; const hasUri = typeof uri === "string" && uri.trim() !== ""; const hasSourcePath = typeof sourcePath === "string" && sourcePath.trim() !== ""; if (!hasUri && !hasSourcePath) { throw new HttpsError( "invalid-argument", "Paramètre uri ou sourcePath est requis" ); } 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}/`; if (!storagePath.startsWith(expectedPrefix)) { throw new HttpsError( "permission-denied", "storagePath doit être dans le dossier du projet utilisateur" ); } 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; 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)" ); } await fs.writeFile(inputPath, buffer); const { size: inputSize } = await fs.stat(inputPath); logger.info("[uploadProjectMedia] Local file written", { projectId, inputPath, inputSize, detectedContentType, }); } 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" ); } } 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, { destination: storagePath, metadata: uploadMetadata, }); 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 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, }); return { success: true, url: fileUrl, contentType: effectiveContentType, 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" ); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } } );