feat: Studio de génération - 4 versions visibles en temps réel

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 <img> avec mixBlendMode CSS
- Plus de dépendance à la table layers vide

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-05-21 06:00:11 +00:00
parent a40690cabb
commit ba9a306df7
2 changed files with 259 additions and 108 deletions

View file

@ -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<string, string> = {
@ -29,13 +30,7 @@ const blendModeMap: Record<string, string> = {
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 (
<div className={`absolute inset-0 flex items-center justify-center bg-black/40 ${className || ""}`}>
@ -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 (
<img
src={frame.compositedUrl}
alt="Composite final"
className={`absolute inset-0 w-full h-full object-contain ${className || ""}`}
/>
);
}
// 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 (
<img
src={fallback}
alt="Frame"
className={`absolute inset-0 w-full h-full object-contain ${className || ""}`}
/>
<div className={`absolute inset-0 ${className || ""}`}>
{visibleDbLayers.map((layer, idx) => {
const url = getLayerUrl(layer.type);
if (!url) return null;
const blendMode = blendModeMap[layer.blendMode || "normal"] || "normal";
return (
<img
key={layer.id}
src={url}
alt={layer.name}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
style={{
opacity: (layer.opacity ?? 100) / 100,
mixBlendMode: blendMode as any,
zIndex: idx,
}}
/>
);
})}
</div>
);
}
// 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 (
<div className={`absolute inset-0 ${className || ""}`}>
{visibleLayers.map((layer, idx) => {
const url = getLayerUrl(layer.type);
if (!url) return null;
const blendMode = blendModeMap[layer.blendMode || "normal"] || "normal";
return (
<img
key={layer.id}
src={url}
alt={layer.name}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
style={{
opacity: (layer.opacity ?? 100) / 100,
mixBlendMode: blendMode as any,
zIndex: idx,
}}
/>
);
})}
{naturalLayers.map((layer, idx) => (
<img
key={layer.key}
src={layer.url}
alt={layer.key}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
style={{ opacity: layer.opacity ?? 1, zIndex: idx }}
/>
))}
</div>
);
}

View file

@ -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<string | null>(null);
const [bigPreview, setBigPreview] = useState<"original" | "bg" | "fg" | "composite">("composite");
const [zoomImage, setZoomImage] = useState<string | null>(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<number | undefined>();
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 (
<ScrollArea className="h-full">
<div className="grid grid-cols-2 gap-3">
<div className="text-xs text-muted-foreground font-mono col-span-2 flex items-center gap-2">
<Wand2 className="h-3.5 w-3.5 text-primary" />
Frame sélectionnée : #{selectedFrame + 1}
<div className="p-2 grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-3 h-full">
{/* LEFT: Big preview + version selector */}
<div className="flex flex-col gap-2 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
<Wand2 className="h-3.5 w-3.5 text-primary" />
Frame #{selectedFrame + 1}
</div>
<div className="flex gap-1">
{versions.map(v => {
const VIcon = v.icon;
const active = bigPreview === v.key;
const hasContent = !!v.url;
return (
<Button
key={v.key}
variant={active ? "default" : "outline"}
size="sm"
className={`h-6 text-[10px] px-2 gap-1 ${!hasContent ? "opacity-40" : ""} ${active ? "" : v.color}`}
onClick={() => hasContent && setBigPreview(v.key)}
disabled={!hasContent}
>
<VIcon className="h-3 w-3" />
{v.label}
</Button>
);
})}
</div>
</div>
{/* Big preview */}
<div
className="relative flex-1 min-h-[200px] bg-black/40 rounded border border-border overflow-hidden cursor-zoom-in"
onClick={() => bigUrl && setZoomImage(bigUrl)}
>
{bigUrl ? (
<img src={bigUrl} alt={bigPreview} className="absolute inset-0 w-full h-full object-contain" />
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-muted-foreground">
<Sparkles className="h-8 w-8 opacity-30" />
<p className="text-xs font-mono">Aucune image pour "{versions.find(v=>v.key===bigPreview)?.label}"</p>
<p className="text-[10px]">Lancez la génération à droite </p>
</div>
)}
<Badge className="absolute top-2 left-2 text-[9px] bg-black/70 border-border/50 backdrop-blur-sm">
{versions.find(v=>v.key===bigPreview)?.label}
</Badge>
</div>
{/* Thumbnails strip */}
<div className="grid grid-cols-4 gap-1.5">
{versions.map(v => {
const VIcon = v.icon;
const active = bigPreview === v.key;
return (
<button
key={v.key}
onClick={() => v.url && setBigPreview(v.key)}
className={`relative aspect-video rounded border overflow-hidden bg-black/40 transition-all ${active ? "ring-2 ring-primary" : "border-border/50 hover:border-border"} ${!v.url ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
disabled={!v.url}
>
{v.url ? (
<img src={v.url} alt={v.label} className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<VIcon className="h-4 w-4 text-muted-foreground/40" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-1">
<span className="text-[8px] font-mono text-white flex items-center gap-0.5">
<VIcon className="h-2.5 w-2.5" />
{v.label}
</span>
</div>
{v.loading && (
<div className="absolute inset-0 bg-black/70 flex items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
</div>
)}
</button>
);
})}
</div>
</div>
{/* Background regeneration */}
<div className="p-3 rounded-lg border border-border bg-card/50 space-y-2">
<div className="flex items-center gap-2">
<Image className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium">Regénérer l'arrière-plan</span>
</div>
<textarea
value={bgPrompt}
onChange={(e) => setBgPrompt(e.target.value)}
placeholder="Décrivez le nouveau style d'arrière-plan..."
className="w-full px-2 py-1.5 text-xs rounded border border-border bg-input resize-none h-12"
/>
<div className="flex items-center gap-2">
{/* RIGHT: Actions panel */}
<div className="flex flex-col gap-2">
{/* Background generation */}
<div className="p-2.5 rounded-lg border border-purple-500/30 bg-card/50 space-y-1.5">
<div className="flex items-center gap-2">
<Image className="h-3.5 w-3.5 text-purple-400" />
<span className="text-xs font-semibold">Arrière-plan</span>
{frame?.regeneratedBgUrl && <Badge variant="outline" className="text-[8px] h-3.5 ml-auto bg-purple-500/10">généré</Badge>}
</div>
<textarea
value={bgPrompt}
onChange={(e) => setBgPrompt(e.target.value)}
placeholder="Style du fond..."
className="w-full px-2 py-1.5 text-[11px] rounded border border-border bg-input resize-none h-12"
/>
<input
type="text"
value={bgStyle}
onChange={(e) => setBgStyle(e.target.value)}
placeholder="Style"
className="flex-1 px-2 py-1 text-xs rounded border border-border bg-input"
placeholder="Style court"
className="w-full px-2 py-1 text-[11px] rounded border border-border bg-input"
/>
<Button
size="sm"
className="gap-1 text-xs h-7"
className="w-full gap-1 text-[10px] h-7"
disabled={isGenerating || !bgPrompt.trim()}
onClick={() => regenBg.mutate({ projectId, frameIndex: selectedFrame, prompt: bgPrompt, style: bgStyle })}
>
{regenBg.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
Générer le fond
{frame?.regeneratedBgUrl ? "Régénérer le fond" : "Générer le fond"}
</Button>
</div>
</div>
{/* Character regeneration */}
<div className="p-3 rounded-lg border border-border bg-card/50 space-y-2">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium">Redessiner le personnage</span>
</div>
<textarea
value={charPrompt}
onChange={(e) => setCharPrompt(e.target.value)}
placeholder="Décrivez le nouveau style du personnage..."
className="w-full px-2 py-1.5 text-xs rounded border border-border bg-input resize-none h-12"
/>
<div className="flex items-center gap-2">
{/* Character generation */}
<div className="p-2.5 rounded-lg border border-green-500/30 bg-card/50 space-y-1.5">
<div className="flex items-center gap-2">
<Users className="h-3.5 w-3.5 text-green-400" />
<span className="text-xs font-semibold">Personnage</span>
{frame?.regeneratedFgUrl && <Badge variant="outline" className="text-[8px] h-3.5 ml-auto bg-green-500/10">généré</Badge>}
</div>
<textarea
value={charPrompt}
onChange={(e) => setCharPrompt(e.target.value)}
placeholder="Style du personnage..."
className="w-full px-2 py-1.5 text-[11px] rounded border border-border bg-input resize-none h-12"
/>
{characters && characters.length > 0 && (
<select
value={selectedCharId || ""}
onChange={(e) => setSelectedCharId(e.target.value ? Number(e.target.value) : undefined)}
className="flex-1 px-2 py-1 text-xs rounded border border-border bg-input"
className="w-full px-2 py-1 text-[11px] rounded border border-border bg-input"
>
<option value="">Sans référence</option>
<option value="">Aucune référence</option>
{characters.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
<option key={c.id} value={c.id}>{c.name}{c.referenceSheetUrl ? " (avec ref)" : ""}</option>
))}
</select>
)}
<Button
size="sm"
className="gap-1 text-xs h-7"
className="w-full gap-1 text-[10px] h-7"
disabled={isGenerating || !charPrompt.trim()}
onClick={() => regenChar.mutate({ projectId, frameIndex: selectedFrame, prompt: charPrompt, characterId: selectedCharId })}
>
{regenChar.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
Générer le personnage
{frame?.regeneratedFgUrl ? "Régénérer le perso" : "Générer le perso"}
</Button>
</div>
{/* Compositing */}
<div className="p-2.5 rounded-lg border border-amber-500/30 bg-card/50 space-y-1.5">
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-amber-400" />
<span className="text-xs font-semibold">Compositing</span>
{frame?.compositedUrl && <Badge variant="outline" className="text-[8px] h-3.5 ml-auto bg-amber-500/10">composé</Badge>}
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Fusionne le fond IA + le personnage IA en une seule image finale via sharp.
</p>
{!canCompose && (
<p className="text-[10px] text-amber-400/70 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
Générez d'abord le fond et le personnage
</p>
)}
<Button
size="sm"
variant="default"
className="w-full gap-1 text-[10px] h-7 bg-amber-600 hover:bg-amber-700"
disabled={isGenerating || !canCompose}
onClick={() => composeFrame.mutate({ projectId, frameIndex: selectedFrame, featherRadius: 2 })}
>
{composeFrame.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
{frame?.compositedUrl ? "Recomposer" : "Composer"}
</Button>
</div>
</div>
{/* Result preview */}
{resultUrl && (
<div className="p-3 rounded-lg border border-primary/30 bg-card/50 space-y-2 col-span-2">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Résultat généré</span>
</div>
<img src={resultUrl} alt="Résultat" className="max-h-32 rounded border border-border" />
</div>
)}
</div>
{/* Full-screen zoom modal */}
{zoomImage && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-6 cursor-pointer"
onClick={() => setZoomImage(null)}
>
<img src={zoomImage} alt="Zoom" className="max-w-full max-h-full object-contain rounded-lg" />
<Badge className="absolute top-4 right-4 bg-black/70">Cliquez pour fermer</Badge>
</div>
)}
</ScrollArea>
);
}