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:
parent
a40690cabb
commit
ba9a306df7
2 changed files with 259 additions and 108 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue