// 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') if (!admin.apps.length) admin.initializeApp() ffmpeg.setFfmpegPath(ffmpegInstaller.path) 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}`) } 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" async function muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset = 0 }) { return new Promise((resolve, reject) => { const command = ffmpeg() const offset = Number(syncOffset) || 0 // Si offset > 0 : la vidéo est en avance (ou capturée en retard dans le passé relatif ?), on veut la retarder pour qu'elle commence plus tard // c-à-d on décale le flux vidéo. // Si offset < 0 : on décale l'audio. if (offset > 0) { command.inputOptions(['-itsoffset', String(offset)]) } command.input(videoPath) // 0:v if (offset < 0) { command.inputOptions(['-itsoffset', String(Math.abs(offset))]) } command.input(audioPath) // 1:a command .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 // Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème) '-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', ]) .on('error', reject) .on('end', resolve) .save(outPath) }) } async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset = 0 }) { 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] transcodage/mux ffmpeg', { syncOffset }) await muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset }) 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 }) } } async function markPlaybackCompatibility({ projectId, initiatorUid = null }) { if (!projectId) return const docRef = db.collection('projects').doc(projectId) await docRef.set( { playbackCompatibility: { codec: PLAYBACK_CODEC_TAG, migratedAt: FieldValue.serverTimestamp(), migratedBy: initiatorUid, }, }, { merge: true } ) } 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, projectId, syncOffset } = data || {} if (!videoUrl || !audioUrl || !storagePath) { throw new HttpsError('invalid-argument', 'Requis: { videoUrl, audioUrl, storagePath }') } const expectedPrefix = `users/${uid}/` if (!storagePath.startsWith(expectedPrefix)) { throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`) } const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset }) if (projectId) { await markPlaybackCompatibility({ projectId, initiatorUid: uid }) } return result } ) exports.reencodePlayback = onCall( { timeoutSeconds: 540, memory: '1GiB' }, async ({ data = {}, auth }) => { const uid = auth?.uid if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise') const projectId = data?.projectId if (!projectId) { throw new HttpsError('invalid-argument', 'Requis: { projectId } pour relancer le transcodage') } logger.info('[reencodePlayback] request received', { projectId, initiator: uid, }) const projectRef = db.collection('projects').doc(projectId) const projectSnap = await projectRef.get() if (!projectSnap.exists) { logger.warn('[reencodePlayback] project not found', { projectId, initiator: uid, }) throw new HttpsError('not-found', 'Projet introuvable') } 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', { projectId, hasPlaybackUrl: !!videoUrl, hasSongUrl: !!audioUrl, ownerId, }) 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 }) await projectRef.set( { playbackUrl: result.url, updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ) await markPlaybackCompatibility({ projectId, initiatorUid: uid }) logger.info('[reencodePlayback] success', { projectId, initiator: uid, storagePath, }) return result } )