convet webm to mp4 okay

This commit is contained in:
2025-10-02 12:13:14 +02:00
parent 11adfa3e21
commit db2a159eb5
3 changed files with 430 additions and 20 deletions
+371
View File
@@ -0,0 +1,371 @@
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 { Buffer } = require("node:buffer");
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");
}
const [, contentType, base64Data] = match;
const buffer = Buffer.from(base64Data, "base64");
return { buffer, contentType: contentType || "" };
}
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 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 = {} }) => {
const uid = auth?.uid;
if (!uid) {
throw new HttpsError("unauthenticated", "Authentification requise");
}
const {
uri = "",
sourcePath = "",
deleteSource = true,
projectId,
storagePath,
contentType: providedContentType = "",
projectField = "",
metadata = {},
} = data || {};
const hasUri = typeof uri === "string" && uri.trim() !== "";
const hasSourcePath =
typeof sourcePath === "string" && sourcePath.trim() !== "";
if (!hasUri && !hasSourcePath) {
throw new HttpsError(
"invalid-argument",
"Paramètre uri ou sourcePath est requis"
);
}
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}/`;
if (!storagePath.startsWith(expectedPrefix)) {
throw new HttpsError(
"permission-denied",
"storagePath doit être dans le dossier du projet utilisateur"
);
}
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 } = 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;
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)"
);
}
await fs.writeFile(inputPath, buffer);
const { size: inputSize } = await fs.stat(inputPath);
logger.info("[uploadProjectMedia] Local file written", {
projectId,
inputPath,
inputSize,
detectedContentType,
});
}
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 downloadToken = crypto.randomUUID();
const sanitizedMetadata = sanitizeMetadata(metadata);
const uploadMetadata = {
contentType: finalContentType,
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,
finalSize,
});
await bucket.upload(finalPath, {
destination: storagePath,
metadata: uploadMetadata,
});
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 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: finalContentType,
});
return {
success: true,
url: fileUrl,
contentType: finalContentType,
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"
);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
);