retrotoon-studio/server/ffmpegLocal.ts
Ubuntu 20a643c4ce fix: audit complet et pipeline fonctionnel RetroToon Studio
Corrections critiques:
- Fix titre HTML {{project_title}} -> %VITE_APP_TITLE%
- Suppression vitePluginManusRuntime (360KB -> 4KB index.html)
- Upload vidéo: multer au lieu du parsing binary maison (anti-corruption)
- Extraction audio ffmpeg + sauvegarde sourceAudioUrl en DB
- Page /login dédiée + correction redirect auth
- Test moteurs IA: vrai HEAD request avec latence
- Suppression spam logs [Auth] Missing session cookie
- Fix fuite passwordHash dans auth.me
- Cookie sameSite: none -> lax (CSRF)

Sécurité:
- Endpoints admin protégés par adminProcedure (role=admin requis)
- Sidebar admin masquée pour non-admins
- AdminPanel: page accès refusé pour non-admins
- Bootstrap admin optimisé (skip rehash si identique)

Fonctionnalités:
- Export vidéo MP4 réel via ffmpeg local (H.264 + AAC audio)
- Download parallèle par batch de 20 (export 10x plus rapide)
- Détection de scènes réelle via ffmpeg scene detect
- Analyse arrière-plans via Gemini Vision (remplace Math.random)
- Gemini: conservation du role system + support image_url
- Suppression thinking.budget_tokens:128 (LLM config)
- Thumbnails de frames dans la timeline
- Toast export avec bouton télécharger
- Endpoint extraction audio à la demande

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 01:37:08 +00:00

212 lines
5.6 KiB
TypeScript

import { execFile } from "child_process";
import { promisify } from "util";
import { mkdtemp, readdir, rm, writeFile, stat } from "fs/promises";
import { createWriteStream } from "fs";
import { tmpdir } from "os";
import { join } from "path";
const execFileAsync = promisify(execFile);
export interface ProbeResult {
duration: number;
width: number;
height: number;
fps: number;
codec: string;
}
export async function probeVideo(filePath: string): Promise<ProbeResult> {
const { stdout } = await execFileAsync("ffprobe", [
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
]);
const info = JSON.parse(stdout);
const videoStream = info.streams?.find((s: any) => s.codec_type === "video");
if (!videoStream) throw new Error("No video stream found");
let fps = 24;
if (videoStream.r_frame_rate) {
const [num, den] = videoStream.r_frame_rate.split("/").map(Number);
if (den > 0) fps = num / den;
}
return {
duration: parseFloat(info.format?.duration || videoStream.duration || "0"),
width: videoStream.width || 0,
height: videoStream.height || 0,
fps: Math.round(fps * 100) / 100,
codec: videoStream.codec_name || "unknown",
};
}
export interface ExtractOptions {
fps: number;
width: number;
height: number;
format: "jpeg" | "png";
quality?: number;
}
export async function extractFramesToDir(
videoPath: string,
options: ExtractOptions,
): Promise<{ dir: string; files: string[] }> {
const dir = await mkdtemp(join(tmpdir(), "retrotoon-frames-"));
const ext = options.format === "png" ? "png" : "jpg";
const pattern = join(dir, `frame_%06d.${ext}`);
const filters = [`fps=${options.fps}`];
if (options.width > 0 && options.height > 0) {
filters.push(`scale=${options.width}:${options.height}`);
}
const args = [
"-i", videoPath,
"-vf", filters.join(","),
];
if (options.format === "jpeg") {
args.push("-q:v", String(options.quality ?? 5));
}
args.push("-y", pattern);
await execFileAsync("ffmpeg", args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 600_000,
});
const allFiles = await readdir(dir);
const frameFiles = allFiles
.filter((f) => f.startsWith("frame_") && f.endsWith(`.${ext}`))
.sort();
return { dir, files: frameFiles };
}
export async function extractAudioFile(videoPath: string, outputPath: string): Promise<string> {
await execFileAsync("ffmpeg", [
"-i", videoPath,
"-vn",
"-acodec", "pcm_s16le",
"-ar", "44100",
"-ac", "2",
"-y", outputPath,
], {
maxBuffer: 50 * 1024 * 1024,
timeout: 300_000,
});
return outputPath;
}
export interface AssembleOptions {
fps: number;
format: "mp4" | "webm";
audioPath?: string | null;
}
export async function detectSceneCutsFromVideo(
videoPath: string,
threshold: number = 0.3,
): Promise<Array<{ frame: number; score: number; time: number }>> {
const { stdout } = await execFileAsync("ffprobe", [
"-v", "quiet",
"-show_frames",
"-of", "json",
"-f", "lavfi",
`movie=${videoPath},select='gt(scene,${threshold})'`,
], {
maxBuffer: 100 * 1024 * 1024,
timeout: 300_000,
}).catch(() => ({ stdout: "" }));
if (!stdout) {
const { stdout: stdout2 } = await execFileAsync("ffmpeg", [
"-i", videoPath,
"-vf", `select='gt(scene,${threshold})',showinfo`,
"-vsync", "vfr",
"-f", "null", "-",
], {
maxBuffer: 100 * 1024 * 1024,
timeout: 300_000,
}).catch((err) => ({ stdout: "", stderr: err.stderr || "" }));
const lines = (stdout2 || "").split("\n");
const cuts: Array<{ frame: number; score: number; time: number }> = [];
for (const line of lines) {
const nMatch = line.match(/n:\s*(\d+)/);
const ptsMatch = line.match(/pts_time:\s*([\d.]+)/);
if (nMatch && ptsMatch) {
cuts.push({
frame: parseInt(nMatch[1]),
score: threshold,
time: parseFloat(ptsMatch[1]),
});
}
}
return cuts;
}
try {
const data = JSON.parse(stdout);
return (data.frames || []).map((f: any, i: number) => ({
frame: i,
score: parseFloat(f.tags?.lavfi_scene_score || "0"),
time: parseFloat(f.pts_time || f.best_effort_timestamp_time || "0"),
}));
} catch {
return [];
}
}
export async function assembleVideo(
frameDir: string,
frameFiles: string[],
outputPath: string,
options: AssembleOptions,
): Promise<{ path: string; duration: number; size: number }> {
const ext = frameFiles[0]?.split(".").pop() || "jpg";
const concatListPath = join(frameDir, "concat.txt");
const lines = frameFiles.map(f => `file '${join(frameDir, f)}'`);
lines.forEach((_, i) => { lines[i] += `\nduration ${1 / options.fps}`; });
await writeFile(concatListPath, lines.join("\n"), "utf-8");
const args = [
"-f", "concat",
"-safe", "0",
"-i", concatListPath,
];
if (options.audioPath) {
args.push("-i", options.audioPath);
args.push("-c:a", "aac", "-b:a", "192k");
args.push("-shortest");
}
if (options.format === "mp4") {
args.push("-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "medium", "-crf", "23");
} else {
args.push("-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0");
}
args.push("-r", String(options.fps));
args.push("-movflags", "+faststart");
args.push("-y", outputPath);
await execFileAsync("ffmpeg", args, {
maxBuffer: 100 * 1024 * 1024,
timeout: 600_000,
});
const info = await stat(outputPath);
const duration = frameFiles.length / options.fps;
return { path: outputPath, duration, size: info.size };
}
export async function cleanupDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true });
}