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 } =