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>
305 lines
10 KiB
TypeScript
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;
|