more fixes web + mobile
This commit is contained in:
@@ -62,6 +62,45 @@ exports.analyseLyrics = async ({ title = "", lyrics }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Détection heuristique d'insultes explicites pour durcir la modération avant l'IA
|
||||
const sanitizedLyrics = lyricsText
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9\s]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const vulgarTerms = [
|
||||
"trou du cul",
|
||||
"trouduc",
|
||||
"encule",
|
||||
"enculer",
|
||||
"connard",
|
||||
"connasse",
|
||||
"con",
|
||||
"fdp",
|
||||
"fils de pute",
|
||||
"pute",
|
||||
"putain",
|
||||
"salope",
|
||||
"salaud",
|
||||
"enfoire",
|
||||
"batard",
|
||||
"ordure",
|
||||
"ta gueule",
|
||||
"nique ta mere",
|
||||
"ntm",
|
||||
];
|
||||
const detectedVulgarities = vulgarTerms.filter((term) => {
|
||||
const pattern = new RegExp(`\\b${escapeRegex(term)}\\b`, "i");
|
||||
return pattern.test(sanitizedLyrics);
|
||||
});
|
||||
const vulgarityHints = detectedVulgarities.length
|
||||
? `<VULGARITES_DETECTEES>${detectedVulgarities.join(", ")}</VULGARITES_DETECTEES>`
|
||||
: "<VULGARITES_DETECTEES>Aucune détectée heuristiquement.</VULGARITES_DETECTEES>";
|
||||
|
||||
// --- Schéma de sortie (inchangé pour compatibilité) ---
|
||||
const moderationSchema = z.object({
|
||||
title: z.string().describe("Titre analysé (copie du titre fourni)"),
|
||||
@@ -101,6 +140,7 @@ Objectif: protéger les utilisateurs en BLOQUANT uniquement les cas réellement
|
||||
|
||||
Sois STRICT sur les risques graves, mais NE SOIS PAS PUNITIF envers l'expression artistique.
|
||||
Évalue TOUJOURS: 1) l'intention, 2) le contexte (récit, citation, dénonciation, rôle/persona), 3) la cible, 4) la probabilité de tort réel.
|
||||
Ne laisse PAS passer des insultes vulgaires explicites (connard, trou du cul, enculé, fdp, salope, etc.): au minimum flagged=true avec un score significatif; blocked=true si c'est dirigé ou répété sans distance.
|
||||
|
||||
Retourne UNIQUEMENT un JSON conforme au schéma. Pas de texte hors JSON.
|
||||
`.trim();
|
||||
@@ -135,6 +175,12 @@ Retourne UNIQUEMENT un JSON conforme au schéma. Pas de texte hors JSON.
|
||||
<CAS>Si le passage relève de la narration, satire, critique ou roleplay: privilégie flagged=true et blocked=false.</CAS>
|
||||
<CAS>Si le passage constitue une injonction réelle ou un appel clair au tort: blocked=true.</CAS>
|
||||
</SECTION>
|
||||
<SECTION id="E">
|
||||
<TITRE>INSULTES ET VULGARITES</TITRE>
|
||||
<CAS>Insultes vulgaires explicites (trou du cul, connard, enculé, fdp, salope, ta gueule, nique ta mere, etc.) = flagged=true minimum et score ≥ 0.45.</CAS>
|
||||
<CAS>Si ces insultes visent quelqu'un de manière directe ou répétée, ou incitent à rabaisser/attaquer: blocked=true ou severity high/critical (au moins high si pas certain).</CAS>
|
||||
<CAS>Si c'est de l'auto-dérision ou du langage cru sans cible ni incitation: flagged=true mais blocked=false, score limité.</CAS>
|
||||
</SECTION>
|
||||
</POLITIQUE>
|
||||
`.trim();
|
||||
|
||||
@@ -153,9 +199,15 @@ ${lyricsText}
|
||||
<REGLE>"blocked" = true UNIQUEMENT pour les cas graves listés dans la section A.</REGLE>
|
||||
<REGLE>Si le contenu relève du récit, de la critique, de la satire ou d'une mise en contexte artistique, laisse blocked=false (flagged=true si nécessaire).</REGLE>
|
||||
<REGLE>Fournis des "excerpts" courts en citant exactement les passages sensibles.</REGLE>
|
||||
<REGLE>Si des insultes vulgaires explicites sont présentes, flagged=true au minimum (score >=0.45) et blocked=true si elles visent clairement quelqu'un.</REGLE>
|
||||
</DIRECTIVES>
|
||||
|
||||
${decisionPolicy}
|
||||
|
||||
<INDICES_SUPPLEMENTAIRES>
|
||||
${vulgarityHints}
|
||||
<REGLE_VULGARITES>Si la liste ci-dessus n'est pas vide, considère ces termes comme signaux forts de harcèlement verbal: flagged=true au minimum et score ajusté en conséquence.</REGLE_VULGARITES>
|
||||
</INDICES_SUPPLEMENTAIRES>
|
||||
`.trim();
|
||||
|
||||
const { output } = await genkit({
|
||||
@@ -167,6 +219,30 @@ ${decisionPolicy}
|
||||
output: { schema: moderationSchema },
|
||||
});
|
||||
|
||||
if (output && detectedVulgarities.length) {
|
||||
output.flagged = true;
|
||||
const bumpScore = detectedVulgarities.length >= 3 ? 0.65 : 0.5;
|
||||
const baseScore = Number.isFinite(output.score) ? output.score : 0;
|
||||
output.score = Math.min(1, Math.max(baseScore, bumpScore));
|
||||
|
||||
const reasons = Array.isArray(output.reasons) ? output.reasons : [];
|
||||
const reason = `Vulgarités détectées (${detectedVulgarities.join(", ")})`;
|
||||
if (!reasons.includes(reason)) reasons.push(reason);
|
||||
output.reasons = reasons;
|
||||
|
||||
const excerpts = Array.isArray(output.excerpts) ? output.excerpts : [];
|
||||
const slots = Math.max(0, 10 - excerpts.length);
|
||||
if (slots > 0) {
|
||||
const severity = detectedVulgarities.length > 2 ? "high" : "medium";
|
||||
const newExcerpts = detectedVulgarities.slice(0, slots).map((term) => ({
|
||||
quote: term,
|
||||
category: "harassment",
|
||||
severity,
|
||||
}));
|
||||
output.excerpts = [...excerpts, ...newExcerpts];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
|
||||
+156
-47
@@ -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;
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user