From cdaf7b27279be7b2537bdf529e13a35e07784c40 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 27 Feb 2026 13:42:14 +0100 Subject: [PATCH] feat: fix merge audio --- functions/src/upload.js | 5 +- src/helpers/uploadToFirebase.js | 135 ++++++++++++++++++++- src/providers/PlayerProvider.js | 1 + src/screens/Production/PlaybackDownload.js | 46 +++++-- src/screens/Publishing/PublishYoutube.js | 8 +- 5 files changed, 177 insertions(+), 18 deletions(-) diff --git a/functions/src/upload.js b/functions/src/upload.js index bc16069..2bd5ff6 100644 --- a/functions/src/upload.js +++ b/functions/src/upload.js @@ -12,6 +12,7 @@ const ffmpeg = require('fluent-ffmpeg') const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg') const { Buffer } = require('node:buffer') const { FieldValue } = require('firebase-admin/firestore') +const { REGION } = require('../index') if (!admin.apps.length) admin.initializeApp() ffmpeg.setFfmpegPath(ffmpegInstaller.path) @@ -151,7 +152,7 @@ async function markPlaybackCompatibility({ projectId, initiatorUid = null }) { } exports.mergeVideoAndAudio = onCall( - { timeoutSeconds: 540, memory: '1GiB' }, + { region: REGION, timeoutSeconds: 540, memory: '1GiB' }, async ({ data = {}, auth }) => { const uid = auth?.uid if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise') @@ -176,7 +177,7 @@ exports.mergeVideoAndAudio = onCall( ) exports.reencodePlayback = onCall( - { timeoutSeconds: 540, memory: '1GiB' }, + { region: REGION, timeoutSeconds: 540, memory: '1GiB' }, async ({ data = {}, auth }) => { const uid = auth?.uid if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise') diff --git a/src/helpers/uploadToFirebase.js b/src/helpers/uploadToFirebase.js index 656c8f7..fa45024 100644 --- a/src/helpers/uploadToFirebase.js +++ b/src/helpers/uploadToFirebase.js @@ -1,7 +1,24 @@ import { Platform } from 'react-native' +import * as FileSystem from 'expo-file-system' import Compressor from 'react-native-compressor' import firebase from '../config/firebase' +const fetchBlobFromUri = (uri) => + new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.onload = () => resolve(xhr.response) + xhr.onerror = () => reject(new TypeError('Network request failed')) + xhr.responseType = 'blob' + xhr.open('GET', uri, true) + xhr.send(null) + }) + +const getContentTypeForFile = (fileType) => { + if (fileType === 'VIDEO') return 'video/mp4' + if (fileType === 'IMAGE') return 'image/jpeg' + return 'application/octet-stream' +} + export function uploadFileToFirebase({ uri, path, @@ -11,53 +28,163 @@ export function uploadFileToFirebase({ }) { return new Promise(async (resolve, reject) => { try { + console.log('[uploadToFirebase] start', { + path, + shouldCompress, + fileType, + hasBlob: Boolean(providedBlob), + uri, + }) let workingURI = uri let uploadBlob = providedBlob + let uploadTask = null // Optionally compress before upload try { if (shouldCompress && Platform.OS !== 'web') { if (fileType === 'VIDEO') { - console.log('Compressing video...') + const compressStart = Date.now() + console.log('[uploadToFirebase] compress video start') workingURI = await Compressor.Video.compress(workingURI) + console.log('[uploadToFirebase] compress video done', { + durationMs: Date.now() - compressStart, + workingURI, + }) } else if (fileType === 'IMAGE') { // Basic image compression + const compressStart = Date.now() + console.log('[uploadToFirebase] compress image start') workingURI = await Compressor.Image.compress(workingURI, { compressionMethod: 'auto', }) + console.log('[uploadToFirebase] compress image done', { + durationMs: Date.now() - compressStart, + workingURI, + }) } } } catch (e) { - console.warn('Compression failed, uploading original file', e?.message) + console.warn('[uploadToFirebase] compression failed, using original file', { + message: e?.message, + code: e?.code, + name: e?.name, + }) workingURI = uri } - if (!uploadBlob) { + const isLocalUri = + Platform.OS !== 'web' && + typeof workingURI === 'string' && + (workingURI.startsWith('file://') || workingURI.startsWith('content://')) + + if (!uploadBlob && isLocalUri) { + try { + const bucket = firebase.app()?.options?.storageBucket + const token = await firebase.auth().currentUser?.getIdToken?.() + if (!bucket || !token) { + throw new Error('missing_bucket_or_token') + } + const contentType = getContentTypeForFile(fileType) + const uploadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket}/o?uploadType=media&name=${encodeURIComponent( + path + )}` + console.log('[uploadToFirebase] upload via FileSystem', { + uploadUrl, + contentType, + }) + const uploadResult = await FileSystem.uploadAsync(uploadUrl, workingURI, { + httpMethod: 'POST', + uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': contentType, + }, + }) + console.log('[uploadToFirebase] uploadAsync result', { + status: uploadResult?.status, + }) + if (uploadResult?.status < 200 || uploadResult?.status >= 300) { + throw new Error(`upload_failed_${uploadResult?.status || 'unknown'}`) + } + const resultURI = await firebase.storage().ref(path).getDownloadURL() + console.log('[uploadToFirebase] upload success (uploadAsync)', { resultURI }) + resolve({ resultURI }) + return + } catch (e) { + console.log('[uploadToFirebase] uploadAsync failed, fallback to xhr', { + message: e?.message, + code: e?.code, + name: e?.name, + }) + } + console.log('[uploadToFirebase] fetching blob via xhr', { workingURI }) + uploadBlob = await fetchBlobFromUri(workingURI) + console.log('[uploadToFirebase] blob ready (xhr)', { + size: uploadBlob?.size, + type: uploadBlob?.type, + }) + } else if (!uploadBlob) { + console.log('[uploadToFirebase] fetching blob', { workingURI }) const response = await fetch(workingURI) + console.log('[uploadToFirebase] fetch blob response', { + ok: response?.ok, + status: response?.status, + type: response?.type, + }) uploadBlob = await response.blob() + console.log('[uploadToFirebase] blob ready', { + size: uploadBlob?.size, + type: uploadBlob?.type, + }) } - const uploadTask = firebase.storage().ref(path).put(uploadBlob) + if (!uploadTask) { + console.log('[uploadToFirebase] upload start', { path }) + uploadTask = firebase.storage().ref(path).put(uploadBlob) + } uploadTask.on( Platform.OS === 'web' ? 'state_changed' : firebase.storage.TaskEvent.STATE_CHANGED, (snapshot) => { + console.log('[uploadToFirebase] upload progress', { + state: snapshot?.state, + bytesTransferred: snapshot?.bytesTransferred, + totalBytes: snapshot?.totalBytes, + }) if (Platform.OS !== 'web' && snapshot.state === firebase.storage.TaskState.SUCCESS) { snapshot.ref.getDownloadURL().then((resultURI) => { + console.log('[uploadToFirebase] upload success (native)', { resultURI }) + if (uploadBlob && typeof uploadBlob.close === 'function') { + uploadBlob.close() + } resolve({ resultURI }) }) } }, (error) => { + console.log('[uploadToFirebase] upload error', { + message: error?.message, + code: error?.code, + name: error?.name, + }) reject(error) }, async () => { const url = await firebase.storage().ref(path).getDownloadURL() + console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url }) + if (uploadBlob && typeof uploadBlob.close === 'function') { + uploadBlob.close() + } resolve({ resultURI: url }) } ) } catch (e) { + console.log('[uploadToFirebase] unexpected error', { + message: e?.message, + code: e?.code, + name: e?.name, + }) reject(e) } }) diff --git a/src/providers/PlayerProvider.js b/src/providers/PlayerProvider.js index 378f882..2cd0f52 100644 --- a/src/providers/PlayerProvider.js +++ b/src/providers/PlayerProvider.js @@ -41,6 +41,7 @@ const HIDDEN_ROUTE_NAMES = new Set([ Routes.ChooseDecor, Routes.CreatingDecor, Routes.VideoFinalize, + Routes.PublishYoutube, Routes.FlowSelection, Routes.ChooseCoverType, Routes.Settings, diff --git a/src/screens/Production/PlaybackDownload.js b/src/screens/Production/PlaybackDownload.js index a7d649e..892ab30 100644 --- a/src/screens/Production/PlaybackDownload.js +++ b/src/screens/Production/PlaybackDownload.js @@ -9,7 +9,7 @@ import BorderGradientButton from '../../components/BorderGradientButton' import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import AppCheckbox from '../../components/AppCheckbox' -import firebase, { projectsRef, serverTimestamp } from '../../config/firebase' +import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase' import { uploadFileToFirebase } from '../../helpers/uploadToFirebase' import Page from '../../layouts/Page' import { Routes } from '../../navigation' @@ -135,6 +135,12 @@ const uploadSourceRecording = async ({ uri, uid, projectId }) => { return { sourcePath, videoUrl } } +const FUNCTIONS_REGION = 'europe-west1' +const callMergeVideoAndAudio = (payload) => { + const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('upload-mergeVideoAndAudio') + return callable(payload) +} + const PlaybackDownload = ({ route }) => { const { currentUID, selectedProject } = useUserData() const { hasActiveSubscription, hasPurchased, videos } = useUser() || {} @@ -282,8 +288,6 @@ const PlaybackDownload = ({ route }) => { }) tempSourcePath = sourcePath - const callable = firebase.functions().httpsCallable('upload-mergeVideoAndAudio') - const payload = { projectId: projectForDownload?.id, videoUrl, @@ -293,7 +297,7 @@ const PlaybackDownload = ({ route }) => { console.log('[PlaybackDownload] calling upload-mergeVideoAndAudio', payload) - const { data: result } = await callable(payload) + const { data: result } = await callMergeVideoAndAudio(payload) console.log('[PlaybackDownload] upload-mergeVideoAndAudio result', result) @@ -431,8 +435,12 @@ const PlaybackDownload = ({ route }) => { }, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading]) const handlePublish = async () => { - if (isPublishing) return + if (isPublishing) { + console.log('[PlaybackDownload] publish blocked: already publishing') + return + } if (!hasAcceptedPublication) { + console.log('[PlaybackDownload] publish blocked: publication not accepted') setTooltip({ type: 'error', text: 'Confirme la diffusion de ton playback sur Musicland avant de publier', @@ -440,6 +448,9 @@ const PlaybackDownload = ({ route }) => { return } if (!projectForDownload?.id) { + console.log('[PlaybackDownload] publish: missing project id, navigating directly', { + projectForDownload: projectForDownload?.id, + }) setTooltip({ type: 'success', text: publishSuccessMessage, @@ -451,6 +462,10 @@ const PlaybackDownload = ({ route }) => { let playbackUrlToSave = pendingPlaybackUrl || projectForDownload?.playbackUrl || null const audioUrl = resolveAudioUrl() if (!audioUrl && !playbackUrlToSave) { + console.log('[PlaybackDownload] publish blocked: missing audioUrl and playbackUrl', { + audioUrl, + playbackUrlToSave, + }) setTooltip({ type: 'error', text: 'Aucune piste audio disponible pour ce projet', @@ -458,6 +473,7 @@ const PlaybackDownload = ({ route }) => { return } if (!currentUID) { + console.log('[PlaybackDownload] publish blocked: missing currentUID') setTooltip({ type: 'error', text: 'Utilisateur non authentifié', @@ -466,10 +482,18 @@ const PlaybackDownload = ({ route }) => { } try { + console.log('[PlaybackDownload] publish start', { + projectId: projectForDownload?.id, + hasPendingPlaybackUrl: Boolean(pendingPlaybackUrl), + hasPlaybackUrl: Boolean(projectForDownload?.playbackUrl), + hasAudioUrl: Boolean(audioUrl), + hasUri: Boolean(uri), + }) setIsPublishing(true) setIsLoading(true) if (!playbackUrlToSave) { + console.log('[PlaybackDownload] publish: no playback url, uploading source') const { sourcePath, videoUrl } = await uploadSourceRecording({ uri, uid: currentUID, @@ -477,8 +501,6 @@ const PlaybackDownload = ({ route }) => { }) tempSourcePath = sourcePath - const callable = firebase.functions().httpsCallable('upload-mergeVideoAndAudio') - const payload = { projectId: projectForDownload?.id, videoUrl, @@ -488,15 +510,20 @@ const PlaybackDownload = ({ route }) => { console.log('[PlaybackDownload] publish: calling merge', payload) - const { data: result } = await callable(payload) + const { data: result } = await callMergeVideoAndAudio(payload) playbackUrlToSave = result?.url || null if (!playbackUrlToSave) { + console.log('[PlaybackDownload] publish: merge result missing url', result) throw new Error('merge_failed') } setPendingPlaybackUrl(playbackUrlToSave) } + console.log('[PlaybackDownload] publish: saving playback url', { + projectId: projectForDownload.id, + playbackUrlToSave, + }) await projectsRef.doc(projectForDownload.id).set( { playbackUrl: playbackUrlToSave, @@ -509,6 +536,9 @@ const PlaybackDownload = ({ route }) => { type: 'success', text: publishSuccessMessage, }) + console.log('[PlaybackDownload] publish: navigate to PublishYoutube', { + projectId: projectForDownload.id, + }) navigate(Routes.PublishYoutube, { projectId: projectForDownload.id }) } catch (error) { console.log('[PlaybackDownload] publish error', { diff --git a/src/screens/Publishing/PublishYoutube.js b/src/screens/Publishing/PublishYoutube.js index a76f5e3..4655471 100644 --- a/src/screens/Publishing/PublishYoutube.js +++ b/src/screens/Publishing/PublishYoutube.js @@ -119,7 +119,7 @@ const PublishYoutube = () => { if (!hasAcceptedPublication) { setTooltip?.({ type: 'error', - text: 'Confirme la diffusion sur Musicland et YouTube avant de publier', + text: 'Confirme la diffusion sur YouTube avant de publier', }) return } @@ -131,7 +131,7 @@ const PublishYoutube = () => { : 'Confirmer la publication' const confirmDescription = hasYoutubePublication ? 'La vidéo existe déjà sur YouTube. Confirme que tu souhaites remplacer la version publiée.' - : 'Confirme que tu souhaites publier ce playback sur la chaîne YouTube MusicLand. Il sera visible publiquement.' + : 'Confirme que tu souhaites publier ce playback sur YouTube. Il sera visible publiquement.' alert(confirmTitle, confirmDescription, [ { text: 'Annuler', style: 'cancel' }, { @@ -249,10 +249,10 @@ const PublishYoutube = () => { setHasAcceptedPublication((prevState) => !prevState)} - label="J'accepte la diffusion de mon contenu sur Musicland et YouTube." + label="J'accepte la diffusion de mon contenu sur YouTube." /> - Cette confirmation est requise avant toute mise en ligne. + Cette confirmation est requise avant toute publication sur YouTube.