offset audio after playback and add songs
This commit is contained in:
+67
-84
@@ -1,5 +1,3 @@
|
||||
// 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')
|
||||
@@ -19,33 +17,50 @@ ffmpeg.setFfmpegPath(ffmpegInstaller.path)
|
||||
|
||||
const db = admin.firestore()
|
||||
const PLAYBACK_CODEC_TAG = 'h264-v1'
|
||||
const MAX_SYNC_OFFSET_SECONDS = 2
|
||||
const SCALE_FILTER =
|
||||
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease"
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
const numericValue = Number(value ?? 0)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const clampSyncOffsetSeconds = (value) => {
|
||||
const safeValue = toFiniteNumber(value)
|
||||
return Math.min(MAX_SYNC_OFFSET_SECONDS, Math.max(-MAX_SYNC_OFFSET_SECONDS, safeValue))
|
||||
}
|
||||
|
||||
const formatSecondsForFfmpeg = (value) => clampSyncOffsetSeconds(value).toFixed(3)
|
||||
|
||||
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"
|
||||
|
||||
// functions/mergeVideoAndAudio.js
|
||||
|
||||
async function muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset = 0 }) {
|
||||
const safeSyncOffset = clampSyncOffsetSeconds(syncOffset)
|
||||
const videoTrimSeconds = safeSyncOffset > 0 ? safeSyncOffset : 0
|
||||
const audioTrimSeconds = safeSyncOffset < 0 ? Math.abs(safeSyncOffset) : 0
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let command = ffmpeg()
|
||||
const command = ffmpeg()
|
||||
|
||||
// L'offset : si > 0, on retarde la vidéo. Si < 0, on retarde l'audio.
|
||||
if (syncOffset > 0) command.inputOptions(['-itsoffset', String(syncOffset)])
|
||||
command.input(videoPath)
|
||||
if (videoTrimSeconds > 0) {
|
||||
command.inputOptions(['-ss', formatSecondsForFfmpeg(videoTrimSeconds)])
|
||||
}
|
||||
|
||||
if (syncOffset < 0) command.inputOptions(['-itsoffset', String(Math.abs(syncOffset))])
|
||||
command.input(audioPath)
|
||||
if (audioTrimSeconds > 0) {
|
||||
command.inputOptions(['-ss', formatSecondsForFfmpeg(audioTrimSeconds)])
|
||||
}
|
||||
|
||||
// Remplace le bloc .save(outPath) par celui-ci :
|
||||
command
|
||||
.outputOptions([
|
||||
'-map',
|
||||
@@ -70,16 +85,20 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset = 0
|
||||
'+faststart',
|
||||
'-shortest',
|
||||
])
|
||||
.on('error', (err) => {
|
||||
console.error('FFmpeg Error:', err)
|
||||
reject(err)
|
||||
.on('error', (error) => {
|
||||
logger.error('FFmpeg Error', { error: error?.message || String(error) })
|
||||
reject(error)
|
||||
})
|
||||
.on('end', () => {
|
||||
console.log('Processing finished !')
|
||||
logger.info('FFmpeg processing finished', {
|
||||
syncOffset: safeSyncOffset,
|
||||
videoTrimSeconds,
|
||||
audioTrimSeconds,
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
.output(outPath) // On définit la sortie ici
|
||||
.run() // Et on lance l'exécution ici
|
||||
.output(outPath)
|
||||
.run()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,6 +107,7 @@ async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset
|
||||
const videoPath = path.join(tmpDir, 'video.mp4')
|
||||
const audioPath = path.join(tmpDir, 'audio.mp3')
|
||||
const outPath = path.join(tmpDir, 'output.mp4')
|
||||
const safeSyncOffset = clampSyncOffsetSeconds(syncOffset)
|
||||
|
||||
try {
|
||||
logger.info('[merge] téléchargement des sources', { videoUrl, audioUrl })
|
||||
@@ -95,8 +115,12 @@ async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset
|
||||
await downloadToFile(videoUrl, videoPath)
|
||||
await downloadToFile(audioUrl, audioPath)
|
||||
|
||||
logger.info('[merge] transcodage/mux ffmpeg', { syncOffset })
|
||||
await muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset })
|
||||
logger.info('[merge] transcodage/mux ffmpeg', {
|
||||
syncOffset: safeSyncOffset,
|
||||
videoTrimSeconds: safeSyncOffset > 0 ? safeSyncOffset : 0,
|
||||
audioTrimSeconds: safeSyncOffset < 0 ? Math.abs(safeSyncOffset) : 0,
|
||||
})
|
||||
await muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset: safeSyncOffset })
|
||||
|
||||
const bucket = admin.storage().bucket()
|
||||
const downloadToken = crypto.randomUUID()
|
||||
@@ -122,10 +146,10 @@ async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset
|
||||
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')
|
||||
} catch (error) {
|
||||
logger.error('[merge] échec', { error: error?.message || String(error) })
|
||||
if (error instanceof HttpsError) throw error
|
||||
throw new HttpsError('internal', error?.message || 'Fusion échouée')
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -133,6 +157,7 @@ async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset
|
||||
|
||||
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
||||
if (!projectId) return
|
||||
|
||||
const docRef = db.collection('projects').doc(projectId)
|
||||
await docRef.set(
|
||||
{
|
||||
@@ -163,72 +188,26 @@ exports.mergeVideoAndAudio = onCall(
|
||||
throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`)
|
||||
}
|
||||
|
||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset })
|
||||
logger.info('[merge] request received', {
|
||||
projectId: projectId || null,
|
||||
initiator: uid,
|
||||
syncOffset: clampSyncOffsetSeconds(syncOffset),
|
||||
})
|
||||
|
||||
const result = await uploadPlaybackAsset({
|
||||
videoUrl,
|
||||
audioUrl,
|
||||
storagePath,
|
||||
syncOffset: clampSyncOffsetSeconds(syncOffset),
|
||||
})
|
||||
|
||||
if (projectId) {
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
)
|
||||
/**
|
||||
* Fusionne une vidéo et un audio avec correction de synchronisation.
|
||||
* Gère les paramètres : videoUrl, audioUrl, storagePath, projectId, syncOffset
|
||||
*/
|
||||
exports.mergeVideoAndAudio = onCall(
|
||||
{ region: REGION, timeoutSeconds: 540, memory: '1GiB' },
|
||||
async (request) => {
|
||||
// Dans v2, les arguments sont dans request.data
|
||||
const { data, auth } = request;
|
||||
const uid = auth?.uid;
|
||||
|
||||
if (!uid) {
|
||||
throw new HttpsError('unauthenticated', 'Authentification requise');
|
||||
}
|
||||
|
||||
const { videoUrl, audioUrl, storagePath, projectId, syncOffset } = data || {};
|
||||
|
||||
// 1. Validation des paramètres
|
||||
if (!videoUrl || !audioUrl || !storagePath) {
|
||||
throw new HttpsError(
|
||||
'invalid-argument',
|
||||
'Paramètres manquants : videoUrl, audioUrl et storagePath sont requis.'
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Sécurité : Vérifier que l'utilisateur écrit dans son propre dossier
|
||||
const expectedPrefix = `users/${uid}/`;
|
||||
if (!storagePath.startsWith(expectedPrefix)) {
|
||||
throw new HttpsError(
|
||||
'permission-denied',
|
||||
`Accès refusé : le chemin doit commencer par ${expectedPrefix}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`[merge] Début du traitement pour le projet : ${projectId || 'inconnu'}`);
|
||||
|
||||
// 3. Appel de la logique de traitement (download -> ffmpeg -> upload)
|
||||
// On passe le syncOffset s'il existe (ex: -0.150 pour 150ms de latence)
|
||||
const result = await uploadPlaybackAsset({
|
||||
videoUrl,
|
||||
audioUrl,
|
||||
storagePath,
|
||||
syncOffset: syncOffset || 0
|
||||
});
|
||||
|
||||
// 4. Marquer la compatibilité dans Firestore si un ID de projet est fourni
|
||||
if (projectId) {
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[merge] Erreur fatale lors de la fusion', error);
|
||||
if (error instanceof HttpsError) throw error;
|
||||
throw new HttpsError('internal', error.message || 'Erreur interne de fusion');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
exports.reencodePlayback = onCall(
|
||||
{ region: REGION, timeoutSeconds: 540, memory: '1GiB' },
|
||||
@@ -245,6 +224,7 @@ exports.reencodePlayback = onCall(
|
||||
projectId,
|
||||
initiator: uid,
|
||||
})
|
||||
|
||||
const projectRef = db.collection('projects').doc(projectId)
|
||||
const projectSnap = await projectRef.get()
|
||||
if (!projectSnap.exists) {
|
||||
@@ -254,6 +234,7 @@ exports.reencodePlayback = onCall(
|
||||
})
|
||||
throw new HttpsError('not-found', 'Projet introuvable')
|
||||
}
|
||||
|
||||
const project = projectSnap.data() || {}
|
||||
const videoUrl = project.playbackUrl
|
||||
const audioUrl = project.songUrl
|
||||
@@ -279,12 +260,14 @@ exports.reencodePlayback = onCall(
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
||||
logger.info('[reencodePlayback] success', {
|
||||
projectId,
|
||||
initiator: uid,
|
||||
storagePath,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user