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:
parent
09db2c65f9
commit
fe8e01e948
5 changed files with 300 additions and 24 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -291,7 +291,8 @@ describe("assistant", () => {
|
|||
projectId: 1,
|
||||
action: "detect_scenes",
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(result).toHaveProperty("success");
|
||||
expect(result).toHaveProperty("message");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}),
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
8
todo.md
8
todo.md
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue