diff --git a/App.js b/App.js index cf00f86..c9d3272 100644 --- a/App.js +++ b/App.js @@ -190,8 +190,8 @@ const App = () => { documentTitle={{ formatter: (options) => options?.title - ? `${options?.title} - minuit.starter` - : "minuit.starter", + ? `${options?.title} - MusicLand` + : "MusicLand", }} > diff --git a/functions/src/upload.js b/functions/src/upload.js index 6f1f6a9..b75ea8f 100644 --- a/functions/src/upload.js +++ b/functions/src/upload.js @@ -9,6 +9,8 @@ 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); @@ -88,6 +90,115 @@ async function convertWebmToMp4(inputPath, 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() @@ -184,7 +295,8 @@ exports.uploadProjectMedia = onCall( metadataKeys: metadata ? Object.keys(metadata) : [], }); - const { snap: projectSnap } = await ensureProjectOwnership(uid, projectId); + const { snap: projectSnap, data: projectData } = + await ensureProjectOwnership(uid, projectId); const bucket = admin.storage().bucket(); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "upload-")); @@ -289,6 +401,85 @@ exports.uploadProjectMedia = onCall( }); } + 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 diff --git a/src/screens/Writing/EmotionConvey.js b/src/screens/Writing/EmotionConvey.js index 86b2b63..0aa593c 100644 --- a/src/screens/Writing/EmotionConvey.js +++ b/src/screens/Writing/EmotionConvey.js @@ -19,7 +19,7 @@ const EmotionConvey = ({ return ( diff --git a/src/screens/Writing/Goals.js b/src/screens/Writing/Goals.js index ab12df3..4a63776 100644 --- a/src/screens/Writing/Goals.js +++ b/src/screens/Writing/Goals.js @@ -38,7 +38,10 @@ const Goals = ({ return ( - + { const { createNewProject, selectedProject, updateProjectData } =