more fixes web + mobile

This commit is contained in:
Thomas Demirdjian
2025-11-20 11:26:24 +01:00
parent fe9b2b9e8d
commit 70c94c97b4
32 changed files with 1214 additions and 226 deletions
+156 -47
View File
@@ -11,9 +11,14 @@ const crypto = require("node:crypto");
const ffmpeg = require("fluent-ffmpeg");
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
const { Buffer } = require("node:buffer");
const { FieldValue } = require("firebase-admin/firestore");
if (!admin.apps.length) admin.initializeApp();
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
const db = admin.firestore();
const PLAYBACK_CODEC_TAG = "h264-v1";
async function downloadToFile(url, destPath) {
if (!/^https?:\/\//i.test(url || "")) {
throw new HttpsError("invalid-argument", `URL non supportée: ${url}`);
@@ -23,6 +28,9 @@ async function downloadToFile(url, destPath) {
return res.headers?.["content-type"] || "";
}
const SCALE_FILTER =
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease";
async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
return new Promise((resolve, reject) => {
ffmpeg()
@@ -33,8 +41,21 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
"0:v:0", // garder la 1re piste vidéo de l'entrée 0
"-map",
"1:a:0", // prendre la 1re piste audio de l'entrée 1
// Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème)
"-vf",
SCALE_FILTER,
"-c:v",
"copy", // ne pas réencoder la vidéo
"libx264",
"-preset",
"veryfast",
"-crf",
"22",
"-pix_fmt",
"yuv420p",
"-profile:v",
"high",
"-level:v",
"4.1",
"-c:a",
"aac",
"-b:a",
@@ -42,6 +63,8 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
"-movflags",
"+faststart",
"-shortest", // couper à la plus courte des 2 sources
"-tag:v",
"avc1",
])
.on("error", reject)
.on("end", resolve)
@@ -49,6 +72,67 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
});
}
async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) {
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 {
logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl });
await downloadToFile(videoUrl, videoPath);
await downloadToFile(audioUrl, audioPath);
logger.info("[merge] transcodage/mux ffmpeg");
await muxAudioIntoVideo({ videoPath, audioPath, outPath });
const bucket = admin.storage().bucket();
const downloadToken = crypto.randomUUID();
await bucket.upload(outPath, {
destination: storagePath,
metadata: {
contentType: "video/mp4",
cacheControl: "public,max-age=86400",
metadata: { firebaseStorageDownloadTokens: downloadToken },
},
});
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
storagePath
)}?alt=media&token=${downloadToken}`;
logger.info("[merge] upload terminé", { storagePath });
return {
success: true,
url: fileUrl,
contentType: "video/mp4",
storagePath,
};
} 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 });
}
}
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
if (!projectId) return;
const docRef = db.collection("projects").doc(projectId);
await docRef.set(
{
"playbackCompatibility.codec": PLAYBACK_CODEC_TAG,
"playbackCompatibility.migratedAt": FieldValue.serverTimestamp(),
"playbackCompatibility.migratedBy": initiatorUid,
},
{ merge: true }
);
}
exports.mergeVideoAndAudio = onCall(
{ timeoutSeconds: 540, memory: "1GiB" },
async ({ data = {}, auth }) => {
@@ -56,7 +140,7 @@ exports.mergeVideoAndAudio = onCall(
if (!uid)
throw new HttpsError("unauthenticated", "Authentification requise");
const { videoUrl, audioUrl, storagePath } = data || {};
const { videoUrl, audioUrl, storagePath, projectId } = data || {};
if (!videoUrl || !audioUrl || !storagePath) {
throw new HttpsError(
@@ -65,7 +149,6 @@ exports.mergeVideoAndAudio = onCall(
);
}
// sécurité simple: forcer dans le dossier de l'utilisateur si tu veux
const expectedPrefix = `users/${uid}/`;
if (!storagePath.startsWith(expectedPrefix)) {
throw new HttpsError(
@@ -74,50 +157,76 @@ exports.mergeVideoAndAudio = onCall(
);
}
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 {
logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl });
await downloadToFile(videoUrl, videoPath);
await downloadToFile(audioUrl, audioPath);
logger.info("[merge] fusion ffmpeg (mux)");
await muxAudioIntoVideo({ videoPath, audioPath, outPath });
const bucket = admin.storage().bucket();
const downloadToken = crypto.randomUUID();
await bucket.upload(outPath, {
destination: storagePath,
metadata: {
contentType: "video/mp4",
cacheControl: "public,max-age=86400",
metadata: { firebaseStorageDownloadTokens: downloadToken },
},
});
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
storagePath
)}?alt=media&token=${downloadToken}`;
logger.info("[merge] upload terminé", { storagePath });
return {
success: true,
url: fileUrl,
contentType: "video/mp4",
storagePath,
};
} 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 });
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
if (projectId) {
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
}
return result;
}
);
exports.reencodePlayback = onCall(
{ timeoutSeconds: 540, memory: "1GiB" },
async ({ data = {}, auth }) => {
const uid = auth?.uid;
if (!uid)
throw new HttpsError("unauthenticated", "Authentification requise");
const projectId = data?.projectId;
if (!projectId) {
throw new HttpsError(
"invalid-argument",
"Requis: { projectId } pour relancer le transcodage"
);
}
logger.info("[reencodePlayback] request received", {
projectId,
initiator: uid,
});
const projectRef = db.collection("projects").doc(projectId);
const projectSnap = await projectRef.get();
if (!projectSnap.exists) {
logger.warn("[reencodePlayback] project not found", {
projectId,
initiator: uid,
});
throw new HttpsError("not-found", "Projet introuvable");
}
const project = projectSnap.data() || {};
const videoUrl = project.playbackUrl;
const audioUrl = project.songUrl;
const ownerId = project.userId;
if (!videoUrl || !audioUrl || !ownerId) {
logger.warn("[reencodePlayback] missing fields", {
projectId,
hasPlaybackUrl: !!videoUrl,
hasSongUrl: !!audioUrl,
ownerId,
});
throw new HttpsError(
"failed-precondition",
"playbackUrl, songUrl ou userId manquant"
);
}
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`;
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
await projectRef.set(
{
playbackUrl: result.url,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
logger.info("[reencodePlayback] success", {
projectId,
initiator: uid,
storagePath,
});
return result;
}
);