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>
212 lines
5.6 KiB
TypeScript
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 });
|
|
}
|