add flow steps
This commit is contained in:
@@ -190,8 +190,8 @@ const App = () => {
|
||||
documentTitle={{
|
||||
formatter: (options) =>
|
||||
options?.title
|
||||
? `${options?.title} - minuit.starter`
|
||||
: "minuit.starter",
|
||||
? `${options?.title} - MusicLand`
|
||||
: "MusicLand",
|
||||
}}
|
||||
>
|
||||
<MainStack />
|
||||
|
||||
+192
-1
@@ -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
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 l’objectif"
|
||||
value={otherObjective}
|
||||
setValue={handleOtherObjectiveChange}
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
Reference in New Issue
Block a user