Checkpoint: RetroToon Studio v3 - Pipeline assistant IA connecté au service réel (assistantOperator), CharactersPanel complet avec aperçu miniature + modal plein écran, suppression avec confirmation, validation type/taille (PNG/JPEG/WebP max 10Mo), toasts d'erreur/succès, endpoint /api/upload/asset dédié aux images de référence.

This commit is contained in:
Manus 2026-05-19 23:58:37 +00:00
parent 09db2c65f9
commit fe8e01e948
5 changed files with 300 additions and 24 deletions

View file

@ -16,6 +16,7 @@ import {
ChevronLeft, ChevronRight, Loader2
} from "lucide-react";
import { useState, useMemo } from "react";
import { toast } from "sonner";
import { useLocation, useParams } from "wouter";
import TimelinePanel from "@/components/TimelinePanel";
import LayersPanel from "@/components/LayersPanel";
@ -258,36 +259,225 @@ function SequencesPanel({ sequences, projectId }: { sequences: any[]; projectId:
);
}
// Characters sub-panel
// Characters sub-panel with full management
function CharactersPanel({ projectId }: { projectId: number }) {
const { data: characters } = trpc.characters.list.useQuery({ projectId });
const utils = trpc.useUtils();
const [showAddForm, setShowAddForm] = useState(false);
const [newCharName, setNewCharName] = useState("");
const [newCharColor, setNewCharColor] = useState("#4a9eff");
const [newCharModel, setNewCharModel] = useState<"lora" | "ip_adapter" | "none">("none");
const [uploadingRef, setUploadingRef] = useState<number | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const createCharacter = trpc.characters.create.useMutation({
onSuccess: () => {
utils.characters.list.invalidate({ projectId });
setShowAddForm(false);
setNewCharName("");
toast.success("Personnage créé avec succès");
},
onError: () => toast.error("Erreur lors de la création du personnage"),
});
const updateCharacter = trpc.characters.update.useMutation({
onSuccess: () => {
utils.characters.list.invalidate({ projectId });
toast.success("Reference sheet mise à jour");
},
onError: () => toast.error("Erreur lors de la mise à jour"),
});
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp"];
const MAX_SIZE_MB = 10;
const handleRefUpload = async (charId: number, file: File) => {
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error("Format non supporté. Utilisez PNG, JPEG ou WebP.");
return;
}
// Validate file size
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
toast.error(`Fichier trop volumineux. Maximum ${MAX_SIZE_MB} Mo.`);
return;
}
setUploadingRef(charId);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("type", "reference-sheet");
const resp = await fetch("/api/upload/asset", { method: "POST", body: formData });
const result = await resp.json();
if (result.success && result.url) {
updateCharacter.mutate({ id: charId, referenceSheetUrl: result.url });
} else {
toast.error("Erreur lors de l'upload");
}
} catch (e) {
console.error("Upload failed:", e);
toast.error("Erreur réseau lors de l'upload");
} finally {
setUploadingRef(null);
}
};
const handleRemoveRef = (charId: number) => {
if (confirm("Êtes-vous sûr de vouloir supprimer cette reference sheet ?")) {
updateCharacter.mutate({ id: charId, referenceSheetUrl: null });
}
};
return (
<ScrollArea className="h-full">
<div className="space-y-2">
{/* Add character button */}
{!showAddForm ? (
<Button
variant="outline"
size="sm"
className="w-full gap-1 text-xs border-dashed"
onClick={() => setShowAddForm(true)}
>
<Users className="h-3 w-3" />
Ajouter un personnage
</Button>
) : (
<div className="p-2 rounded border border-primary/30 bg-card/50 space-y-2">
<input
type="text"
value={newCharName}
onChange={(e) => setNewCharName(e.target.value)}
placeholder="Nom du personnage"
className="w-full px-2 py-1 text-xs rounded border border-border bg-input"
/>
<div className="flex gap-2 items-center">
<input
type="color"
value={newCharColor}
onChange={(e) => setNewCharColor(e.target.value)}
className="w-6 h-6 rounded cursor-pointer"
/>
<select
value={newCharModel}
onChange={(e) => setNewCharModel(e.target.value as any)}
className="flex-1 px-2 py-1 text-xs rounded border border-border bg-input"
>
<option value="none">Sans modèle</option>
<option value="lora">LoRA</option>
<option value="ip_adapter">IP-Adapter</option>
</select>
</div>
<div className="flex gap-1">
<Button
size="sm"
className="flex-1 h-6 text-[10px]"
onClick={() => createCharacter.mutate({ projectId, name: newCharName, color: newCharColor, modelType: newCharModel })}
disabled={!newCharName.trim() || createCharacter.isPending}
>
Créer
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px]"
onClick={() => setShowAddForm(false)}
>
Annuler
</Button>
</div>
</div>
)}
{!characters || characters.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<div className="text-center py-6 text-muted-foreground text-sm">
<Users className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>Aucun personnage identifié</p>
<p className="text-xs mt-1">L'assistant IA identifiera les personnages récurrents</p>
<p className="text-xs mt-1">Ajoutez manuellement ou laissez l'assistant IA les détecter</p>
</div>
) : (
characters.map((char) => (
<div
key={char.id}
className="flex items-center gap-3 p-2 rounded border border-border bg-card/50"
className="p-2 rounded border border-border bg-card/50 space-y-2"
>
<div
className="w-8 h-8 rounded-full border-2"
style={{ borderColor: char.color || "#4a9eff", backgroundColor: `${char.color || "#4a9eff"}20` }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{char.name}</p>
<p className="text-xs text-muted-foreground">{char.modelType}</p>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded-full border-2 shrink-0"
style={{ borderColor: char.color || "#4a9eff", backgroundColor: `${char.color || "#4a9eff"}20` }}
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{char.name}</p>
<p className="text-[10px] text-muted-foreground">
{char.modelType === "lora" ? "LoRA" : char.modelType === "ip_adapter" ? "IP-Adapter" : "Standard"}
</p>
</div>
{char.referenceSheetUrl && (
<Badge variant="outline" className="text-[9px] shrink-0">Ref </Badge>
)}
</div>
{/* Reference sheet preview */}
{char.referenceSheetUrl && (
<div className="relative group">
<img
src={char.referenceSheetUrl}
alt={`Ref: ${char.name}`}
className="w-full h-16 object-cover rounded border border-border/50 cursor-pointer"
onClick={() => setPreviewUrl(char.referenceSheetUrl)}
/>
<button
onClick={() => handleRemoveRef(char.id)}
className="absolute top-0.5 right-0.5 bg-destructive text-destructive-foreground rounded-full w-4 h-4 flex items-center justify-center text-[8px] opacity-0 group-hover:opacity-100 transition-opacity"
title="Supprimer la référence"
>
×
</button>
</div>
)}
{/* Reference sheet upload */}
<div className="flex items-center gap-1">
<label className="flex-1 cursor-pointer">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleRefUpload(char.id, file);
}}
/>
<span className="inline-flex items-center gap-1 text-[10px] text-primary hover:underline">
{uploadingRef === char.id ? (
<><Loader2 className="h-3 w-3 animate-spin" /> Upload...</>
) : char.referenceSheetUrl ? (
<><Image className="h-3 w-3" /> Remplacer la référence</>
) : (
<><Image className="h-3 w-3" /> Ajouter reference sheet (PNG, JPEG, WebP - max 10Mo)</>
)}
</span>
</label>
</div>
</div>
))
)}
{/* Full-screen preview modal */}
{previewUrl && (
<div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
onClick={() => setPreviewUrl(null)}
>
<img
src={previewUrl}
alt="Reference sheet preview"
className="max-w-full max-h-full object-contain rounded-lg border border-border"
/>
<span className="absolute top-4 right-4 text-white text-sm bg-black/50 px-3 py-1 rounded">
Cliquez pour fermer
</span>
</div>
)}
</div>
</ScrollArea>
);

View file

@ -291,7 +291,8 @@ describe("assistant", () => {
projectId: 1,
action: "detect_scenes",
});
expect(result).toEqual({ success: true });
expect(result).toHaveProperty("success");
expect(result).toHaveProperty("message");
});
});

View file

@ -173,7 +173,7 @@ export const appRouter = router({
description: z.string().optional(),
color: z.string().optional(),
modelType: z.enum(["lora", "ip_adapter", "none"]).optional(),
referenceSheetUrl: z.string().optional(),
referenceSheetUrl: z.string().nullish(),
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
@ -364,28 +364,34 @@ Réponds de manière concise et technique, en français. Propose des actions con
action: z.enum(["detect_scenes", "analyze_backgrounds", "segment_all", "auto_compose"]),
}))
.mutation(async ({ input }) => {
const actionMessages: Record<string, string> = {
detect_scenes: "**Détection des plans en cours...**\n\nJ'analyse la vidéo pour identifier les changements de scènes (cuts). Je compare les histogrammes de couleur et les différences inter-frames pour repérer les transitions.\n\n*Résultat simulé :* 12 séquences détectées. Voulez-vous que je les confirme et passe à l'analyse des arrière-plans ?",
analyze_backgrounds: "**Analyse des arrière-plans...**\n\nPour chaque séquence, j'évalue la stabilité du fond en comparant les zones non-occupées par des éléments mobiles. Je sélectionne la frame la plus nette et la moins obstruée comme référence.\n\n*Résultat simulé :* 8/12 séquences ont un fond statique identifié. Frames de référence sélectionnées.",
segment_all: "**Segmentation en cours...**\n\nJ'utilise le modèle SAM 2 pour détourer automatiquement les personnages et objets en mouvement sur chaque frame. Les masques sont propagés temporellement pour assurer la cohérence.\n\n*Résultat simulé :* 3 personnages identifiés, 2 objets mobiles. Calques créés automatiquement.",
auto_compose: "**Auto-Composition lancée...**\n\nPipeline complet :\n1. ✅ Détection des plans\n2. ✅ Analyse des fonds statiques\n3. ✅ Segmentation personnages/objets\n4. ⏳ Inpainting des arrière-plans\n5. ⏳ Création des calques\n\nLe processus est en cours. Je vous notifierai à chaque étape terminée.",
const { runAutonomousPipeline } = await import("./assistantOperator");
// Map frontend action names to pipeline actions
const actionMap: Record<string, any> = {
detect_scenes: "detect_scenes",
analyze_backgrounds: "analyze_backgrounds",
segment_all: "segment_characters",
auto_compose: "full_auto",
};
const pipelineAction = actionMap[input.action] || input.action;
const result = await runAutonomousPipeline(input.projectId, pipelineAction);
// Save the assistant message with the pipeline result
await db.createAssistantMessage({
projectId: input.projectId,
role: "assistant",
content: actionMessages[input.action] || "Action en cours...",
content: result.message,
});
// Create a generation job for tracking
await db.createGenerationJob({
projectId: input.projectId,
type: input.action === "detect_scenes" ? "scene_analysis" : input.action === "segment_all" ? "segmentation" : "auto_compose",
status: "completed",
status: result.success ? "completed" : "failed",
prompt: input.action,
});
return { success: true };
return { success: result.success, message: result.message };
}),
}),

View file

@ -87,4 +87,79 @@ uploadRouter.post("/api/upload/video", async (req: Request, res: Response) => {
}
});
// Image/asset upload endpoint (for reference sheets, etc.)
uploadRouter.post("/api/upload/asset", async (req: Request, res: Response) => {
try {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
req.on("end", async () => {
try {
const body = Buffer.concat(chunks);
const contentType = req.headers["content-type"] || "";
const boundary = contentType.split("boundary=")[1];
if (!boundary) {
res.status(400).json({ error: "Invalid content type" });
return;
}
const bodyStr = body.toString("binary");
const parts = bodyStr.split(`--${boundary}`);
let fileBuffer: Buffer | null = null;
let fileName = "asset.png";
let assetType = "reference";
for (const part of parts) {
if (part.includes('name="file"')) {
const headerEnd = part.indexOf("\r\n\r\n");
if (headerEnd !== -1) {
const filenameMatch = part.match(/filename="([^"]+)"/);
if (filenameMatch) fileName = filenameMatch[1];
const content = part.substring(headerEnd + 4);
const trimmed = content.endsWith("\r\n") ? content.slice(0, -2) : content;
fileBuffer = Buffer.from(trimmed, "binary");
}
} else if (part.includes('name="type"')) {
const headerEnd = part.indexOf("\r\n\r\n");
if (headerEnd !== -1) {
assetType = part.substring(headerEnd + 4).trim().replace(/\r\n$/, "");
}
}
}
if (!fileBuffer) {
res.status(400).json({ error: "No file provided" });
return;
}
const fileId = nanoid(12);
const ext = fileName.split(".").pop() || "png";
const storageKey = `assets/${assetType}/${fileId}.${ext}`;
const mimeType = ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext}`;
const { url } = await storagePut(storageKey, fileBuffer, mimeType);
res.json({
success: true,
url,
fileName,
fileId,
size: fileBuffer.length,
});
} catch (err: any) {
console.error("[Upload Asset] Processing error:", err);
res.status(500).json({ error: "Asset upload processing failed" });
}
});
} catch (err: any) {
console.error("[Upload Asset] Error:", err);
res.status(500).json({ error: "Asset upload failed" });
}
});
export default uploadRouter;

View file

@ -45,7 +45,7 @@
- [x] Actions rapides (detect_scenes, analyze_backgrounds, segment_all, auto_compose)
- [x] Pipeline autonome orchestré (assistantOperator.ts)
- [x] Analyse multimodale des frames par LLM (analyzeFrame avec JSON schema)
- [ ] Brancher l'assistant au pipeline réel (connecter assistantOperator aux vrais services)
- [x] Brancher l'assistant au pipeline réel (connecter assistantOperator aux vrais services)
## Moteur de regénération IA
- [x] Regénération des arrière-plans par prompt utilisateur (via generateImage)
@ -55,8 +55,12 @@
## Character Sheet & Cohérence
- [x] Schéma et CRUD pour les personnages (nom, description, couleur, modelType)
- [x] Support des types LoRA et IP-Adapter dans le modèle de données
- [ ] Upload et gestion de la reference sheet (UI complète)
- [x] Upload et gestion de la reference sheet (UI complète)
- [ ] Intégration LoRA/IP-Adapter réelle avec un service de génération compatible
- [x] Aperçu de la reference sheet uploadée dans le CharactersPanel (miniature + modal plein écran)
- [x] Suppression/remplacement de la reference sheet avec confirmation
- [x] Toast d'erreur/succès sur upload de reference sheet
- [x] Validation type/taille fichier sur upload reference sheet (PNG/JPEG/WebP, max 10Mo)
## Administration IA
- [x] Interface CRUD des moteurs IA (ajout, suppression, activation)