retrotoon-studio/server/uploadRoute.ts
Ubuntu c1606ad4c9 feat: migration complète Manus -> auto-hébergé (MinIO + Gemini)
Infrastructure:
- MinIO déployé en local pour le stockage S3 (docker-compose)
- Storage proxy réécrit: sert les fichiers depuis MinIO en streaming
  (plus de 307 redirect vers CDN externe)
- Legacy /manus-storage/ redirige vers /storage/

LLM & Image Generation:
- LLM: Gemini uniquement (suppression du fallback Forge)
- Image generation: Gemini Imagen direct (suppression Forge GenerateImage)
- llmConfig simplifié, un seul provider

Nettoyage Manus:
- Modules Forge stubbés (dataApi, heartbeat, map, notification, voiceTranscription)
- ENV simplifié (suppression forgeApiUrl, forgeApiKey)
- Analytics Manus supprimées du HTML
- systemRouter simplifié

Migration données:
- 750 fichiers migrés de Forge S3 vers MinIO (69.8 MB)
- URLs DB mises à jour: /manus-storage/ -> /storage/
- Script de migration inclus (scripts/migrate-to-minio.mjs)

Performance:
- Frame load: 500ms -> 62ms (8x plus rapide)
- Plus aucune dépendance réseau transatlantique pour le stockage

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

305 lines
10 KiB
TypeScript

import { Router, Request, Response } from "express";
import { createWriteStream } from "fs";
import { readFile, mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import multer from "multer";
import { storagePut, storageGetSignedUrl } from "./storage";
import { nanoid } from "nanoid";
import { probeVideo, extractFramesToDir, extractAudioFile, cleanupDir } from "./ffmpegLocal";
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 200 * 1024 * 1024 },
});
const uploadRouter = Router();
// Video upload endpoint
uploadRouter.post("/api/upload/video", upload.single("file"), async (req: Request, res: Response) => {
try {
const file = req.file;
if (!file) {
res.status(400).json({ error: "No file provided" });
return;
}
const fileName = file.originalname || "video.mp4";
const ext = fileName.split(".").pop() || "mp4";
const fileId = nanoid(12);
const storageKey = `videos/${fileId}.${ext}`;
const { url } = await storagePut(storageKey, file.buffer, file.mimetype || `video/${ext}`);
res.json({
success: true,
url,
fileName,
fileId,
size: file.buffer.length,
});
} catch (err: any) {
console.error("[Upload] Error:", err);
res.status(500).json({ error: "Upload failed" });
}
});
// Image/asset upload endpoint (for reference sheets, etc.)
uploadRouter.post("/api/upload/asset", upload.single("file"), async (req: Request, res: Response) => {
try {
const file = req.file;
if (!file) {
res.status(400).json({ error: "No file provided" });
return;
}
const fileName = file.originalname || "asset.png";
const assetType = (req.body?.type as string) || "reference";
const ext = fileName.split(".").pop() || "png";
const fileId = nanoid(12);
const storageKey = `assets/${assetType}/${fileId}.${ext}`;
const mimeType = file.mimetype || (ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext}`);
const { url } = await storagePut(storageKey, file.buffer, mimeType);
res.json({
success: true,
url,
fileName,
fileId,
size: file.buffer.length,
});
} catch (err: any) {
console.error("[Upload Asset] Error:", err);
res.status(500).json({ error: "Asset upload failed" });
}
});
// Batch frame registration endpoint - saves extracted frame URLs to DB
uploadRouter.post("/api/upload/frames-batch", async (req: Request, res: Response) => {
try {
const data = req.body;
const { projectId, frames, fps, width, height, totalFrames, duration } = data;
if (!projectId || !frames || !Array.isArray(frames)) {
res.status(400).json({ error: "Missing projectId or frames array" });
return;
}
const { createFrames, updateProject } = await import("./db");
const frameRecords = frames.map((f: { frameIndex: number; url: string }) => ({
projectId,
frameIndex: f.frameIndex,
originalUrl: f.url,
}));
if (frameRecords.length > 0) {
for (let i = 0; i < frameRecords.length; i += 50) {
const batch = frameRecords.slice(i, i + 50);
await createFrames(batch);
}
}
await updateProject(projectId, {
fps: fps || 24,
width: width || 640,
height: height || 480,
totalFrames: totalFrames || frames.length,
duration: duration || 0,
status: "ready",
});
res.json({
success: true,
framesCreated: frameRecords.length,
projectId,
});
} catch (err: any) {
console.error("[Frames Batch] Error:", err);
res.status(500).json({ error: "Frames batch processing failed: " + err.message });
}
});
// Server-side frame extraction via ffmpeg
uploadRouter.post("/api/upload/extract-frames", async (req: Request, res: Response) => {
let tempVideoDir: string | null = null;
let framesDir: string | null = null;
try {
const data = req.body;
if (!data || typeof data !== "object") {
res.status(400).json({ error: "Invalid JSON body" });
return;
}
const { videoUrl, projectId, fps, width, height, format } = data;
if (!videoUrl || !projectId) {
res.status(400).json({ error: "Missing videoUrl or projectId" });
return;
}
// 1. Download video from S3 to temp file
const videoKey = videoUrl.replace(/^\/(manus-)?storage\//, "");
const signedUrl = await storageGetSignedUrl(videoKey);
tempVideoDir = await mkdtemp(join(tmpdir(), "retrotoon-video-"));
const ext = videoKey.split(".").pop() || "mp4";
const videoPath = join(tempVideoDir, `source.${ext}`);
const videoResp = await fetch(signedUrl);
if (!videoResp.ok || !videoResp.body) {
throw new Error("Failed to download video from storage");
}
const nodeStream = Readable.fromWeb(videoResp.body as any);
await pipeline(nodeStream, createWriteStream(videoPath));
// 2. Probe video metadata with ffprobe
const metadata = await probeVideo(videoPath);
// 3. Determine extraction parameters
const targetFps = fps || 8;
const targetWidth = width && width > 0 ? width : metadata.width;
const targetHeight = height && height > 0 ? height : metadata.height;
const targetFormat: "jpeg" | "png" = format === "png" ? "png" : "jpeg";
// 4. Extract frames with ffmpeg
const result = await extractFramesToDir(videoPath, {
fps: targetFps,
width: targetWidth,
height: targetHeight,
format: targetFormat,
quality: 5,
});
framesDir = result.dir;
// 5. Extract audio track
let audioUrl: string | null = null;
try {
const audioFileId = nanoid(12);
const audioPath = await extractAudioFile(videoPath, join(tempVideoDir, `audio_${audioFileId}.wav`));
const audioBuffer = await readFile(audioPath);
const audioStorageKey = `assets/audio/${audioFileId}.wav`;
const audioResult = await storagePut(audioStorageKey, audioBuffer, "audio/wav");
audioUrl = audioResult.url;
console.log(`[Extract] Audio extracted and uploaded: ${audioUrl}`);
} catch (audioErr) {
console.warn("[Extract] Audio extraction failed (video may have no audio track):", audioErr);
}
// 6. Upload each frame to S3
const frameExt = targetFormat === "png" ? "png" : "jpg";
const mimeType = targetFormat === "png" ? "image/png" : "image/jpeg";
const frameUrls: { frameIndex: number; url: string }[] = [];
for (let i = 0; i < result.files.length; i++) {
const framePath = join(framesDir, result.files[i]);
const frameBuffer = await readFile(framePath);
const frameId = nanoid(12);
const storageKey = `assets/frame/${frameId}.${frameExt}`;
const { url } = await storagePut(storageKey, frameBuffer, mimeType);
frameUrls.push({ frameIndex: i, url });
}
// 7. Save frames to DB + update project
const { createFrames, updateProject } = await import("./db");
const frameRecords = frameUrls.map((f) => ({
projectId,
frameIndex: f.frameIndex,
originalUrl: f.url,
}));
for (let i = 0; i < frameRecords.length; i += 50) {
await createFrames(frameRecords.slice(i, i + 50));
}
await updateProject(projectId, {
fps: targetFps,
width: targetWidth,
height: targetHeight,
totalFrames: frameUrls.length,
duration: Math.round(metadata.duration * 1000),
status: "ready",
...(audioUrl ? { sourceAudioUrl: audioUrl } : {}),
});
console.log(`[Extract] ${frameUrls.length} frames extracted for project ${projectId} (${metadata.codec}, ${metadata.width}x${metadata.height})`);
res.json({
success: true,
framesExtracted: frameUrls.length,
audioExtracted: !!audioUrl,
projectId,
metadata: {
fps: metadata.fps,
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
codec: metadata.codec,
},
});
} catch (err: any) {
console.error("[Extract Frames] Error:", err);
res.status(500).json({ error: "Frame extraction failed: " + err.message });
} finally {
if (tempVideoDir) await cleanupDir(tempVideoDir).catch(() => {});
if (framesDir) await cleanupDir(framesDir).catch(() => {});
}
});
// Extract audio from an existing project's source video
uploadRouter.post("/api/upload/extract-audio", async (req: Request, res: Response) => {
let tempDir: string | null = null;
try {
const { projectId } = req.body;
if (!projectId) {
res.status(400).json({ error: "Missing projectId" });
return;
}
const { getProject, updateProject } = await import("./db");
const project = await getProject(projectId);
if (!project?.sourceVideoUrl) {
res.status(400).json({ error: "Project has no source video" });
return;
}
const videoKey = project.sourceVideoUrl.replace(/^\/(manus-)?storage\//, "");
const signedUrl = await storageGetSignedUrl(videoKey);
tempDir = await mkdtemp(join(tmpdir(), "retrotoon-audio-"));
const ext = videoKey.split(".").pop() || "mp4";
const videoPath = join(tempDir, `source.${ext}`);
const videoResp = await fetch(signedUrl);
if (!videoResp.ok || !videoResp.body) throw new Error("Failed to download video");
const { Readable } = await import("stream");
const { pipeline } = await import("stream/promises");
const { createWriteStream } = await import("fs");
const nodeStream = Readable.fromWeb(videoResp.body as any);
await pipeline(nodeStream, createWriteStream(videoPath));
const audioFileId = nanoid(12);
const audioOutputPath = join(tempDir, `audio_${audioFileId}.wav`);
await extractAudioFile(videoPath, audioOutputPath);
const audioBuffer = await readFile(audioOutputPath);
const audioStorageKey = `assets/audio/${audioFileId}.wav`;
const { url: audioUrl } = await storagePut(audioStorageKey, audioBuffer, "audio/wav");
await updateProject(projectId, { sourceAudioUrl: audioUrl });
console.log(`[Audio] Extracted audio for project ${projectId}: ${audioUrl}`);
res.json({ success: true, audioUrl, projectId });
} catch (err: any) {
console.error("[Extract Audio] Error:", err);
res.status(500).json({ error: "Audio extraction failed: " + err.message });
} finally {
if (tempDir) await cleanupDir(tempDir).catch(() => {});
}
});
export default uploadRouter;