Files
musicland/functions/src/upload.js
T
2026-01-20 11:16:36 +01:00

236 lines
7.2 KiB
JavaScript

// 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
}
)