From ba9a306df7daf2e42bd5475117002ff7ca5d7fe4 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 21 May 2026 06:00:11 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Studio=20de=20g=C3=A9n=C3=A9ration=20-?= =?UTF-8?q?=204=20versions=20visibles=20en=20temps=20r=C3=A9el?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le panneau Génération IA était inutilisable : on lançait une régénération mais on ne voyait jamais le résultat dans l'interface (juste un toast). Le mode 'Composite' du viewport ne marchait pas car il dépendait de la table layers qui était vide. Refonte complète du GenerationPanel: - Big preview central : affiche la version actuellement sélectionnée (Original / Fond IA / Perso IA / Composite) - 4 boutons en haut pour switcher entre versions, désactivés si pas encore générées - 4 thumbnails en bas pour preview rapide + zoom au clic - Auto-switch vers la version qui vient d'être générée - Bouton "Composer" qui lance compositing.composeFrame - Badge "généré" sur chaque action quand l'URL existe - Modal de zoom plein écran CompositePreview amélioré pour fonctionner SANS layers DB: - Si compositedUrl existe : l'affiche directement (chemin rapide) - Sinon empile bg + fg via avec mixBlendMode CSS - Plus de dépendance à la table layers vide Co-Authored-By: Claude Opus 4.7 (1M context) --- client/src/components/CompositePreview.tsx | 99 +++++--- client/src/pages/ProjectWorkspace.tsx | 268 +++++++++++++++------ 2 files changed, 259 insertions(+), 108 deletions(-) diff --git a/client/src/components/CompositePreview.tsx b/client/src/components/CompositePreview.tsx index 39cf490..d530fdb 100644 --- a/client/src/components/CompositePreview.tsx +++ b/client/src/components/CompositePreview.tsx @@ -10,7 +10,7 @@ interface CompositePreviewProps { maskUrl?: string | null; compositedUrl?: string | null; } | null | undefined; - layers: Array<{ + layers?: Array<{ id: number; name: string; type: string; @@ -20,6 +20,7 @@ interface CompositePreviewProps { blendMode?: string | null; }>; className?: string; + preferFinalComposite?: boolean; } const blendModeMap: Record = { @@ -29,13 +30,7 @@ const blendModeMap: Record = { overlay: "overlay", }; -export default function CompositePreview({ frame, layers, className }: CompositePreviewProps) { - const visibleLayers = useMemo(() => { - return [...layers] - .filter(l => l.visible !== false) - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); - }, [layers]); - +export default function CompositePreview({ frame, layers = [], className, preferFinalComposite = true }: CompositePreviewProps) { if (!frame) { return (
@@ -44,6 +39,24 @@ export default function CompositePreview({ frame, layers, className }: Composite ); } + // If a final composite exists and we prefer it, show it directly (fastest path) + if (preferFinalComposite && frame.compositedUrl) { + return ( + Composite final + ); + } + + // Build layer stack from explicit layers (DB) if any + const visibleDbLayers = useMemo(() => { + return [...layers] + .filter(l => l.visible !== false) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + }, [layers]); + const getLayerUrl = (layerType: string): string | null => { switch (layerType) { case "background": @@ -58,39 +71,55 @@ export default function CompositePreview({ frame, layers, className }: Composite } }; - // Fallback: if no visible layers, just show the original or composited URL - if (visibleLayers.length === 0) { - const fallback = frame.compositedUrl || frame.originalUrl; - if (!fallback) return null; + // If we have DB layers, render via layer stack + if (visibleDbLayers.length > 0) { return ( - Frame +
+ {visibleDbLayers.map((layer, idx) => { + const url = getLayerUrl(layer.type); + if (!url) return null; + const blendMode = blendModeMap[layer.blendMode || "normal"] || "normal"; + return ( + {layer.name} + ); + })} +
); } + // Fallback: stack the natural layers from frame URLs (bg + fg) + // This works without any DB layers + const naturalLayers: Array<{ url: string; key: string; opacity?: number }> = []; + const bgUrl = frame.regeneratedBgUrl || frame.backgroundUrl || frame.originalUrl; + if (bgUrl) naturalLayers.push({ url: bgUrl, key: "bg" }); + const fgUrl = frame.regeneratedFgUrl || frame.foregroundUrl; + if (fgUrl && fgUrl !== bgUrl) naturalLayers.push({ url: fgUrl, key: "fg" }); + + if (naturalLayers.length === 0) { + return null; + } + return (
- {visibleLayers.map((layer, idx) => { - const url = getLayerUrl(layer.type); - if (!url) return null; - const blendMode = blendModeMap[layer.blendMode || "normal"] || "normal"; - return ( - {layer.name} - ); - })} + {naturalLayers.map((layer, idx) => ( + {layer.key} + ))}
); } diff --git a/client/src/pages/ProjectWorkspace.tsx b/client/src/pages/ProjectWorkspace.tsx index faa0e81..8182e7d 100644 --- a/client/src/pages/ProjectWorkspace.tsx +++ b/client/src/pages/ProjectWorkspace.tsx @@ -11,7 +11,7 @@ import { trpc } from "@/lib/trpc"; import { ArrowLeft, Play, Pause, SkipBack, SkipForward, Layers, Eye, EyeOff, Lock, Unlock, - Wand2, MessageSquare, Settings, Film, Sparkles, + Wand2, MessageSquare, Settings, Film, Sparkles, AlertCircle, Scissors, Image, Users, Bot, Download, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; @@ -666,128 +666,250 @@ function CharactersPanel({ projectId }: { projectId: number }) { ); } -// Generation panel for AI-powered background/character regeneration +// Generation panel - Studio de comparaison: Original / Fond IA / Perso IA / Composite function GenerationPanel({ projectId, selectedFrame }: { projectId: number; selectedFrame: number }) { - const [bgPrompt, setBgPrompt] = useState("Redessiner cet arrière-plan dans un style Studio Ghibli, couleurs douces et atmosphère onirique"); - const [charPrompt, setCharPrompt] = useState("Redessiner ce personnage dans un style moderne, lignes nettes et couleurs vives"); + const [bgPrompt, setBgPrompt] = useState("Style Studio Ghibli, aquarelle douce, atmosphère onirique"); + const [charPrompt, setCharPrompt] = useState("Style anime moderne, lignes nettes, couleurs vives"); const [bgStyle, setBgStyle] = useState("Studio Ghibli"); - const [resultUrl, setResultUrl] = useState(null); + const [bigPreview, setBigPreview] = useState<"original" | "bg" | "fg" | "composite">("composite"); + const [zoomImage, setZoomImage] = useState(null); + const utils = trpc.useUtils(); + const { data: frame } = trpc.frames.getByIndex.useQuery( + { projectId, frameIndex: selectedFrame }, + { enabled: projectId > 0, staleTime: 5_000 } + ); const { data: characters } = trpc.characters.list.useQuery({ projectId }); const [selectedCharId, setSelectedCharId] = useState(); + const invalidateFrame = () => { + utils.frames.getByIndex.invalidate({ projectId, frameIndex: selectedFrame }); + utils.frames.list.invalidate({ projectId }); + }; + const regenBg = trpc.generation.regenerateBackground.useMutation({ - onSuccess: (data) => { - setResultUrl(data.resultUrl); - toast.success("Arrière-plan regénéré !"); - }, - onError: (err) => { - console.error("[Generation] BG error:", err); - toast.error(`Erreur génération fond: ${err.message}`, { duration: 8000 }); - }, + onSuccess: () => { toast.success("Arrière-plan regénéré !"); invalidateFrame(); setBigPreview("bg"); }, + onError: (err) => toast.error(`Fond: ${err.message}`, { duration: 8000 }), }); const regenChar = trpc.generation.regenerateCharacter.useMutation({ - onSuccess: (data) => { - setResultUrl(data.resultUrl); - toast.success("Personnage redessiné !"); - }, - onError: (err) => { - console.error("[Generation] Char error:", err); - toast.error(`Erreur génération personnage: ${err.message}`, { duration: 8000 }); - }, + onSuccess: () => { toast.success("Personnage redessiné !"); invalidateFrame(); setBigPreview("fg"); }, + onError: (err) => toast.error(`Personnage: ${err.message}`, { duration: 8000 }), }); - const isGenerating = regenBg.isPending || regenChar.isPending; + const composeFrame = trpc.compositing.composeFrame.useMutation({ + onSuccess: () => { toast.success("Composite généré !"); invalidateFrame(); setBigPreview("composite"); }, + onError: (err) => toast.error(`Compositing: ${err.message}`, { duration: 8000 }), + }); + + const isGenerating = regenBg.isPending || regenChar.isPending || composeFrame.isPending; + + const versions = [ + { key: "original" as const, label: "Original", icon: Film, url: frame?.originalUrl, color: "border-blue-500/50 text-blue-400" }, + { key: "bg" as const, label: "Fond IA", icon: Image, url: frame?.regeneratedBgUrl || frame?.backgroundUrl, color: "border-purple-500/50 text-purple-400", loading: regenBg.isPending }, + { key: "fg" as const, label: "Perso IA", icon: Users, url: frame?.regeneratedFgUrl || frame?.foregroundUrl, color: "border-green-500/50 text-green-400", loading: regenChar.isPending }, + { key: "composite" as const, label: "Composite", icon: Sparkles, url: frame?.compositedUrl, color: "border-amber-500/50 text-amber-400", loading: composeFrame.isPending }, + ]; + + const bigUrl = versions.find(v => v.key === bigPreview)?.url; + const canCompose = !!(frame?.regeneratedBgUrl || frame?.backgroundUrl) && !!(frame?.regeneratedFgUrl || frame?.foregroundUrl); return ( -
-
- - Frame sélectionnée : #{selectedFrame + 1} +
+ {/* LEFT: Big preview + version selector */} +
+
+
+ + Frame #{selectedFrame + 1} +
+
+ {versions.map(v => { + const VIcon = v.icon; + const active = bigPreview === v.key; + const hasContent = !!v.url; + return ( + + ); + })} +
+
+ + {/* Big preview */} +
bigUrl && setZoomImage(bigUrl)} + > + {bigUrl ? ( + {bigPreview} + ) : ( +
+ +

Aucune image pour "{versions.find(v=>v.key===bigPreview)?.label}"

+

Lancez la génération à droite →

+
+ )} + + {versions.find(v=>v.key===bigPreview)?.label} + +
+ + {/* Thumbnails strip */} +
+ {versions.map(v => { + const VIcon = v.icon; + const active = bigPreview === v.key; + return ( + + ); + })} +
- {/* Background regeneration */} -
-
- - Regénérer l'arrière-plan -
-