fusion mp4

This commit is contained in:
2025-10-23 11:27:40 +02:00
parent 6947195a9a
commit 02dbf98928
2 changed files with 82 additions and 514 deletions
+56 -503
View File
@@ -1,568 +1,121 @@
// functions/mergeVideoAndAudio.js (ou dans index.js)
const { onCall, HttpsError } = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const axios = require("axios");
const ffmpeg = require("fluent-ffmpeg");
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const ffmpeg = require("fluent-ffmpeg");
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
const { Buffer } = require("node:buffer");
const { spawn } = require("node:child_process");
const { URL } = require("node:url");
if (!admin.apps.length) admin.initializeApp();
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
const ALLOWED_PROJECT_FIELDS = new Set([
"songUrl",
"playbackUrl",
"recordingUrl",
]);
function parseDataUri(uri) {
const match = /^data:([^;]+);base64,(.*)$/.exec(uri || "");
if (!match) {
throw new Error("URI data: invalide ou non supportée");
async function downloadToFile(url, destPath) {
if (!/^https?:\/\//i.test(url || "")) {
throw new HttpsError("invalid-argument", `URL non supportée: ${url}`);
}
const [, contentType, base64Data] = match;
const buffer = Buffer.from(base64Data, "base64");
return { buffer, contentType: contentType || "" };
const res = await axios.get(url, { responseType: "arraybuffer" });
await fs.writeFile(destPath, Buffer.from(res.data));
return res.headers?.["content-type"] || "";
}
async function downloadFromHttp(uri) {
const response = await axios.get(uri, { responseType: "arraybuffer" });
const buffer = Buffer.from(response.data);
const contentType = response.headers?.["content-type"] || "";
return { buffer, contentType };
}
function isLikelyWebm(contentType = "", uri = "", storagePath = "") {
if (/webm/i.test(contentType)) return true;
if (/\.webm($|\?)/i.test(uri || "")) return true;
if (/\.webm$/i.test(storagePath || "")) return true;
return false;
}
function guessExtension(contentType = "") {
if (!contentType) return ".bin";
if (/mp4/i.test(contentType)) return ".mp4";
if (/webm/i.test(contentType)) return ".webm";
if (/mpeg/i.test(contentType)) return ".mp3";
if (/ogg/i.test(contentType)) return ".ogg";
if (/wav/i.test(contentType)) return ".wav";
return ".bin";
}
function sanitizeMetadata(metadata = {}) {
const output = {};
if (!metadata || typeof metadata !== "object") return output;
for (const [key, value] of Object.entries(metadata)) {
if (!key) continue;
if (value == null) continue;
output[key] = typeof value === "string" ? value : JSON.stringify(value);
}
return output;
}
async function convertWebmToMp4(inputPath, outputPath) {
await new Promise((resolve, reject) => {
ffmpeg(inputPath)
.outputOptions([
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
"-pix_fmt",
"yuv420p",
])
.on("end", resolve)
.on("error", reject)
.save(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) => {
async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
return new Promise((resolve, reject) => {
ffmpeg()
.input(videoPath)
.input(audioPath)
.input(videoPath) // 0:v
.input(audioPath) // 1:a
.outputOptions([
"-map",
"0:v:0",
"0:v:0", // garder la 1re piste vidéo de l'entrée 0
"-map",
"1:a:0",
"1:a:0", // prendre la 1re piste audio de l'entrée 1
"-c:v",
"copy",
"copy", // ne pas réencoder la vidéo
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
"-shortest",
"-shortest", // couper à la plus courte des 2 sources
])
.on("end", resolve)
.on("error", reject)
.save(outputPath);
.on("end", resolve)
.save(outPath);
});
}
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()
.collection("projects")
.doc(projectId)
.get();
if (!snap.exists) {
throw new HttpsError("not-found", "Projet introuvable");
}
const data = snap.data() || {};
if (data.userId && data.userId !== uid) {
throw new HttpsError("permission-denied", "Accès non autorisé à ce projet");
}
return { snap, data };
}
exports.uploadProjectMedia = onCall(
{
timeoutSeconds: 540,
memory: "2GiB",
},
async ({ data = {}, auth = {} }) => {
exports.mergeVideoAndAudio = onCall(
{ timeoutSeconds: 540, memory: "1GiB" },
async ({ data = {}, auth }) => {
const uid = auth?.uid;
if (!uid) {
if (!uid)
throw new HttpsError("unauthenticated", "Authentification requise");
}
const {
uri = "",
sourcePath = "",
deleteSource = true,
projectId,
storagePath,
contentType: providedContentType = "",
projectField = "",
metadata = {},
} = data || {};
const { videoUrl, audioUrl, storagePath } = data || {};
const hasUri = typeof uri === "string" && uri.trim() !== "";
const hasSourcePath =
typeof sourcePath === "string" && sourcePath.trim() !== "";
if (!hasUri && !hasSourcePath) {
if (!videoUrl || !audioUrl || !storagePath) {
throw new HttpsError(
"invalid-argument",
"Paramètre uri ou sourcePath est requis"
"Requis: { videoUrl, audioUrl, storagePath }"
);
}
if (!projectId || typeof projectId !== "string") {
throw new HttpsError("invalid-argument", "projectId est requis");
}
if (!storagePath || typeof storagePath !== "string") {
throw new HttpsError("invalid-argument", "storagePath est requis");
}
const expectedPrefix = `users/${uid}/projects/${projectId}/`;
// sécurité simple: forcer dans le dossier de l'utilisateur si tu veux
const expectedPrefix = `users/${uid}/`;
if (!storagePath.startsWith(expectedPrefix)) {
throw new HttpsError(
"permission-denied",
"storagePath doit être dans le dossier du projet utilisateur"
`storagePath doit commencer par ${expectedPrefix}`
);
}
if (projectField) {
if (!ALLOWED_PROJECT_FIELDS.has(projectField)) {
throw new HttpsError("invalid-argument", "projectField non supporté");
}
}
if (hasSourcePath && !sourcePath.startsWith(expectedPrefix)) {
throw new HttpsError(
"permission-denied",
"sourcePath doit être dans le dossier du projet utilisateur"
);
}
logger.info("[uploadProjectMedia] Call received", {
uid,
projectId,
storagePath,
projectField: projectField || null,
providedContentType,
sourcePath: hasSourcePath ? sourcePath : null,
payloadKind: hasUri
? /^data:/i.test(uri)
? "data-uri"
: /^https?:\/\//i.test(uri)
? "remote-url"
: "raw-string"
: hasSourcePath
? "storage-object"
: "none",
metadataKeys: metadata ? Object.keys(metadata) : [],
});
const { snap: projectSnap, data: projectData } =
await ensureProjectOwnership(uid, projectId);
const bucket = admin.storage().bucket();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "upload-"));
const baseInputPath = path.join(tmpDir, "input");
let inputPath = baseInputPath;
let detectedContentType = providedContentType;
const sourceFile = hasSourcePath ? bucket.file(sourcePath) : null;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-"));
const videoPath = path.join(tmpDir, "video.mp4");
const audioPath = path.join(tmpDir, "audio.mp3");
const outPath = path.join(tmpDir, "output.mp4");
try {
if (hasSourcePath) {
if (!detectedContentType) {
try {
const [meta] = await sourceFile.getMetadata();
detectedContentType = meta?.contentType || "";
} catch {}
}
const ext =
path.extname(sourcePath) || guessExtension(detectedContentType);
inputPath = `${baseInputPath}${ext || ""}`;
await sourceFile.download({ destination: inputPath });
const { size } = await fs.stat(inputPath);
logger.info("[uploadProjectMedia] Source downloaded", {
projectId,
sourcePath,
inputPath,
size,
detectedContentType,
});
} else {
let buffer;
if (/^data:/i.test(uri)) {
const parsed = parseDataUri(uri);
buffer = parsed.buffer;
if (!detectedContentType) detectedContentType = parsed.contentType;
const ext = guessExtension(parsed.contentType);
inputPath = `${baseInputPath}${ext}`;
logger.info("[uploadProjectMedia] Parsed data URI", {
projectId,
approxSize: buffer.length,
detectedContentType,
});
} else if (/^https?:\/\//i.test(uri)) {
const downloaded = await downloadFromHttp(uri);
buffer = downloaded.buffer;
if (!detectedContentType) {
detectedContentType = downloaded.contentType;
}
const ext = guessExtension(downloaded.contentType);
inputPath = `${baseInputPath}${ext}`;
logger.info("[uploadProjectMedia] Downloaded remote resource", {
projectId,
approxSize: buffer.length,
detectedContentType,
});
} else {
throw new HttpsError(
"invalid-argument",
"URI non supportée. Utiliser une data URI ou une URL HTTP(S)"
);
}
logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl });
await fs.writeFile(inputPath, buffer);
const { size: inputSize } = await fs.stat(inputPath);
logger.info("[uploadProjectMedia] Local file written", {
projectId,
inputPath,
inputSize,
detectedContentType,
});
}
await downloadToFile(videoUrl, videoPath);
await downloadToFile(audioUrl, audioPath);
const conversionHint = hasUri ? uri : sourcePath;
const shouldConvert = isLikelyWebm(
detectedContentType,
conversionHint,
storagePath
);
logger.info("[uploadProjectMedia] Conversion decision", {
projectId,
shouldConvert,
detectedContentType,
});
let finalPath = inputPath;
let finalContentType = detectedContentType || "application/octet-stream";
if (shouldConvert) {
const targetPath = path.join(tmpDir, "output.mp4");
logger.info("[uploadProjectMedia] Conversion WebM → MP4", {
projectId,
storagePath,
});
await convertWebmToMp4(inputPath, targetPath);
finalPath = targetPath;
finalContentType = "video/mp4";
const { size: convertedSize } = await fs.stat(finalPath);
logger.info("[uploadProjectMedia] Conversion done", {
projectId,
convertedSize,
finalContentType,
});
}
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"
);
}
}
logger.info("[merge] fusion ffmpeg (mux)");
await muxAudioIntoVideo({ videoPath, audioPath, outPath });
const bucket = admin.storage().bucket();
const downloadToken = crypto.randomUUID();
const sanitizedMetadata = sanitizeMetadata(metadata);
// If we are writing the canonical playback file, make sure contentType is correct
const forceVideoMp4 = /\/playback\.mp4$/i.test(storagePath);
const effectiveContentType = forceVideoMp4
? "video/mp4"
: finalContentType;
const uploadMetadata = {
contentType: effectiveContentType,
cacheControl: "public,max-age=86400",
metadata: {
...sanitizedMetadata,
originalContentType: detectedContentType || "",
firebaseStorageDownloadTokens: downloadToken,
},
};
const { size: finalSize } = await fs.stat(finalPath);
logger.info("[uploadProjectMedia] Uploading to bucket", {
projectId,
storagePath,
finalContentType: effectiveContentType,
finalSize,
});
await bucket.upload(finalPath, {
await bucket.upload(outPath, {
destination: storagePath,
metadata: uploadMetadata,
metadata: {
contentType: "video/mp4",
cacheControl: "public,max-age=86400",
metadata: { firebaseStorageDownloadTokens: downloadToken },
},
});
if (hasSourcePath && deleteSource) {
try {
await sourceFile.delete({ ignoreNotFound: true });
logger.info("[uploadProjectMedia] Source deleted", {
projectId,
sourcePath,
});
} catch (cleanupErr) {
logger.warn("[uploadProjectMedia] Failed to delete source", {
projectId,
sourcePath,
error: cleanupErr?.message || String(cleanupErr),
});
}
}
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
storagePath
)}?alt=media&token=${downloadToken}`;
const encodedPath = encodeURIComponent(storagePath);
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodedPath}?alt=media&token=${downloadToken}`;
const updatePayload = {
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
};
if (projectField) {
updatePayload[projectField] = fileUrl;
}
await projectSnap.ref.set(updatePayload, { merge: true });
logger.info("✅ [uploadProjectMedia] Upload terminé", {
projectId,
storagePath,
contentType: effectiveContentType,
});
logger.info("[merge] upload terminé", { storagePath });
return {
success: true,
url: fileUrl,
contentType: effectiveContentType,
contentType: "video/mp4",
storagePath,
downloadToken,
sourcePath: hasSourcePath ? sourcePath : null,
sourceDeleted: hasSourcePath ? deleteSource : false,
};
} catch (error) {
logger.error("❌ [uploadProjectMedia] Échec", {
projectId,
sourcePath: hasSourcePath ? sourcePath : null,
error: error?.message || String(error),
});
if (error instanceof HttpsError) throw error;
throw new HttpsError(
"internal",
error?.message || "Échec du traitement de l'upload"
);
} catch (err) {
logger.error("[merge] échec", { error: err?.message || String(err) });
if (err instanceof HttpsError) throw err;
throw new HttpsError("internal", err?.message || "Fusion échouée");
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
+26 -11
View File
@@ -49,14 +49,14 @@ const uploadSourceRecording = async ({ uri, uid, projectId }) => {
sourcePath,
});
await uploadFileToFirebase({
const { resultURI: videoUrl } = await uploadFileToFirebase({
uri,
path: sourcePath,
shouldCompress: false,
fileType: "VIDEO",
});
return { sourcePath };
return { sourcePath, videoUrl };
};
const DownloadSongs = ({ route }) => {
@@ -78,10 +78,18 @@ const DownloadSongs = ({ route }) => {
uri,
currentUID,
});
const audioUrl = project?.songUrl || null;
if (!audioUrl) {
setTooltip({
type: "error",
text: "Aucune piste audio disponible pour ce projet",
});
return;
}
let tempSourcePath = null;
try {
setIsLoading(true);
const { sourcePath } = await uploadSourceRecording({
const { sourcePath, videoUrl } = await uploadSourceRecording({
uri,
uid: currentUID,
projectId: project.id,
@@ -90,22 +98,19 @@ const DownloadSongs = ({ route }) => {
const callable = firebase
.functions()
.httpsCallable("upload-uploadProjectMedia");
.httpsCallable("upload-mergeVideoAndAudio");
const payload = {
sourcePath,
projectId: project.id,
videoUrl,
audioUrl,
storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`,
projectField: "playbackUrl",
deleteSource: true,
metadata: { source: "downloadSongs" },
};
console.log("[DownloadSongs] calling upload-uploadProjectMedia", payload);
console.log("[DownloadSongs] calling upload-mergeVideoAndAudio", payload);
const { data: result } = await callable(payload);
console.log("[DownloadSongs] upload-uploadProjectMedia result", result);
console.log("[DownloadSongs] upload-mergeVideoAndAudio result", result);
const resultURI = result?.url || null;
@@ -121,6 +126,16 @@ const DownloadSongs = ({ route }) => {
type: "success",
text: "Vidéo uploadée",
});
if (tempSourcePath) {
try {
await firebase.storage().ref(tempSourcePath).delete();
} catch (cleanupError) {
console.log("[DownloadSongs] unable to delete temp source", {
message: cleanupError?.message,
code: cleanupError?.code,
});
}
}
} else {
setTooltip({
type: "error",