add flow steps

This commit is contained in:
2025-10-13 14:15:19 +02:00
parent 707c30817c
commit 63629017d7
5 changed files with 203 additions and 10 deletions
+2 -2
View File
@@ -190,8 +190,8 @@ const App = () => {
documentTitle={{
formatter: (options) =>
options?.title
? `${options?.title} - minuit.starter`
: "minuit.starter",
? `${options?.title} - MusicLand`
: "MusicLand",
}}
>
<MainStack />
+192 -1
View File
@@ -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
+1 -1
View File
@@ -19,7 +19,7 @@ const EmotionConvey = ({
return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader
title={`Quelle émotion veux-tu\ntransmettre ?`}
title={`Étape 3: Quelle émotion veux-tu\ntransmettre ?`}
subTitle="Sélectionne une seule intention émotionnelle."
/>
+5 -2
View File
@@ -38,7 +38,10 @@ const Goals = ({
return (
<View style={{ flex: 1, gap: 16, marginTop: 16 }}>
<View style={{ gap: 10 }}>
<CreateLyricsHeader title="Quel est ton objectif ?" />
<CreateLyricsHeader
title="Étape 2: Quel est le contexte ?"
subTitle="Besoin d'idées ? Pense à un anniversaire, une équipe de sport ou ton entreprise."
/>
<ItemContainer>
<ListSelection
options={goalsOptions}
@@ -52,7 +55,7 @@ const Goals = ({
</ItemContainer>
</View>
<CustomInput
label="Tu as un autre objectif?"
label="As-tu un autre objectif ?"
placeholder="Décrire lobjectif"
value={otherObjective}
setValue={handleOtherObjectiveChange}
+3 -4
View File
@@ -1,18 +1,17 @@
import React, { useEffect, useState } from "react";
import { Image, StyleSheet, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { ai } from "../../assets";
import { ai, background } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
import { isWeb } from "../../hooks/useLayoutType";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUserData } from "../../providers/UserDataProvider";
import { gutters } from "../../styles";
import { background } from "../../assets";
import { isWeb } from "../../hooks/useLayoutType";
import Page from "../../layouts/Page";
const WritingLyrics = () => {
const { createNewProject, selectedProject, updateProjectData } =