convet webm to mp4 okay
This commit is contained in:
@@ -17,3 +17,4 @@ exports.lyrics = require("./src/lyrics");
|
|||||||
exports.cover = require("./src/cover");
|
exports.cover = require("./src/cover");
|
||||||
exports.projects = require("./src/project");
|
exports.projects = require("./src/project");
|
||||||
exports.thumbnail = require("./src/thumbnail");
|
exports.thumbnail = require("./src/thumbnail");
|
||||||
|
exports.upload = require("./src/upload");
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -12,7 +12,7 @@ import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
|||||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||||
import { background, img } from "../../assets";
|
import { background, img } from "../../assets";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
import { projectsRef, serverTimestamp } from "../../config/firebase";
|
import firebase, { projectsRef, serverTimestamp } from "../../config/firebase";
|
||||||
import { uploadFileToFirebase } from "../../helpers/uploadToFirebase";
|
import { uploadFileToFirebase } from "../../helpers/uploadToFirebase";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
@@ -23,6 +23,31 @@ import { FONT_FAMILY } from "../../styles/Fonts";
|
|||||||
import { size } from "../../styles/Style";
|
import { size } from "../../styles/Style";
|
||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
|
|
||||||
|
const guessExtension = (inputUri = "") => {
|
||||||
|
const cleaned = inputUri.split("?")[0] || "";
|
||||||
|
const match = cleaned.match(/\.([a-z0-9]+)$/i);
|
||||||
|
if (match && match[1]) return match[1].toLowerCase();
|
||||||
|
if (Platform.OS === "web") return "webm";
|
||||||
|
return "mp4";
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadSourceRecording = async ({ uri, uid, projectId }) => {
|
||||||
|
if (!uri) throw new Error("Aucune vidéo trouvée");
|
||||||
|
if (!uid) throw new Error("Utilisateur non authentifié");
|
||||||
|
|
||||||
|
const extension = guessExtension(uri);
|
||||||
|
const sourcePath = `users/${uid}/projects/${projectId}/recordings/source-${Date.now()}.${extension}`;
|
||||||
|
|
||||||
|
await uploadFileToFirebase({
|
||||||
|
uri,
|
||||||
|
path: sourcePath,
|
||||||
|
shouldCompress: false,
|
||||||
|
fileType: "VIDEO",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sourcePath };
|
||||||
|
};
|
||||||
|
|
||||||
const DownloadSongs = ({ route }) => {
|
const DownloadSongs = ({ route }) => {
|
||||||
const { currentUID } = useUserData();
|
const { currentUID } = useUserData();
|
||||||
const { action, uri, project } = route.params || {};
|
const { action, uri, project } = route.params || {};
|
||||||
@@ -33,16 +58,30 @@ const DownloadSongs = ({ route }) => {
|
|||||||
if (action === "playback" && project?.id) {
|
if (action === "playback" && project?.id) {
|
||||||
// Publication du playback
|
// Publication du playback
|
||||||
console.log("project id : ", project.id);
|
console.log("project id : ", project.id);
|
||||||
|
let tempSourcePath = null;
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const { resultURI = null } = await uploadFileToFirebase({
|
const { sourcePath } = await uploadSourceRecording({
|
||||||
uri: uri,
|
uri,
|
||||||
path: `users/${currentUID}/projects/${project.id}/playback.mp4`,
|
uid: currentUID,
|
||||||
shouldCompress: true,
|
projectId: project.id,
|
||||||
fileType: "VIDEO",
|
});
|
||||||
|
tempSourcePath = sourcePath;
|
||||||
|
|
||||||
|
const callable = firebase
|
||||||
|
.functions()
|
||||||
|
.httpsCallable("upload-uploadProjectMedia");
|
||||||
|
|
||||||
|
const { data: result } = await callable({
|
||||||
|
sourcePath,
|
||||||
|
projectId: project.id,
|
||||||
|
storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`,
|
||||||
|
projectField: "playbackUrl",
|
||||||
|
deleteSource: true,
|
||||||
|
metadata: { source: "downloadSongs" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resultURI) throw new Error("Téléversement de l'image impossible");
|
const resultURI = result?.url || null;
|
||||||
|
|
||||||
if (resultURI) {
|
if (resultURI) {
|
||||||
await projectsRef.doc(project.id).set(
|
await projectsRef.doc(project.id).set(
|
||||||
@@ -63,10 +102,15 @@ const DownloadSongs = ({ route }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("error upload playback", error);
|
console.log("[DownloadSongs] error upload playback", error);
|
||||||
|
if (tempSourcePath) {
|
||||||
|
try {
|
||||||
|
await firebase.storage().ref(tempSourcePath).delete();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
setTooltip({
|
setTooltip({
|
||||||
type: "error",
|
type: "error",
|
||||||
text: "Erreur lors de la publication du playback",
|
text: String(error?.message || "Erreur lors de la publication du playback"),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -109,17 +153,11 @@ const DownloadSongs = ({ route }) => {
|
|||||||
<View style={{ gap: 10 }}>
|
<View style={{ gap: 10 }}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.itemContainer}
|
style={styles.itemContainer}
|
||||||
onPress={
|
onPress={handleDownloadUri}
|
||||||
() => {
|
// navigate(Routes.DownloadPrices, {
|
||||||
console.log("test");
|
// action,
|
||||||
handleDownloadUri();
|
// uri,
|
||||||
}
|
// })
|
||||||
|
|
||||||
// navigate(Routes.DownloadPrices, {
|
|
||||||
// action,
|
|
||||||
// uri,
|
|
||||||
// })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<BlurView
|
<BlurView
|
||||||
style={styles.blurView}
|
style={styles.blurView}
|
||||||
|
|||||||
Reference in New Issue
Block a user