feat(M4): Workflow itératif - batch N variantes + history + iterate
Génération unique remplacée par un cycle créatif:
- Sélecteur "1x/2x/4x" en haut du panneau actions
- Quand >1, l'app génère N variantes en parallèle avec
variations légères du prompt (composition, lighting, colors, mood...)
- Pour 2x: prompt original + alternative composition
- Pour 4x: 4 explorations distinctes (composition, lighting, colors, mood)
- Toutes les variantes apparaissent dans la galerie M1
- La dernière devient active par défaut, user peut switcher
Prompt history:
- Endpoint frameVariants.promptHistory(frameId, type?) retourne
les 20 derniers prompts uniques utilisés sur cette frame
- Dropdown "↺ Réutiliser un prompt..." sous chaque textarea
- Cliquer un prompt l'injecte dans la textarea
Iterate from variant:
- Bouton GitBranch (icône) au hover de chaque variante
- Click = copie le prompt de la variante dans le textarea
- Permet itération "je raffine cette idée" en un clic
Backend:
- regenerateBackgroundBatch / regenerateCharacterBatch
- Promise.allSettled pour exécution parallèle robuste (échecs partiels OK)
- Chaque résultat crée une variante (M1)
- Retourne {generated, failed, variants, errors}
- Sync legacy field avec la dernière variante générée
- Validation max 8 variantes pour éviter l'abus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d18424a416
commit
c07ee892e7
3 changed files with 296 additions and 12 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Check, Star, Trash2, Loader2, History } from "lucide-react";
|
||||
import { Check, Star, Trash2, Loader2, History, GitBranch } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
|
||||
|
|
@ -12,9 +12,10 @@ interface VersionsGalleryProps {
|
|||
type: VariantType;
|
||||
label?: string;
|
||||
onVariantSelected?: (url: string) => void;
|
||||
onIterateFromVariant?: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export default function VersionsGallery({ frameId, type, label, onVariantSelected }: VersionsGalleryProps) {
|
||||
export default function VersionsGallery({ frameId, type, label, onVariantSelected, onIterateFromVariant }: VersionsGalleryProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
||||
|
||||
|
|
@ -104,6 +105,19 @@ export default function VersionsGallery({ frameId, type, label, onVariantSelecte
|
|||
{/* Hover actions */}
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center gap-0.5 transition-opacity">
|
||||
{onIterateFromVariant && variant.prompt && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-primary/40"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onIterateFromVariant(variant.prompt || "");
|
||||
toast.info("Prompt copié pour itération");
|
||||
}}
|
||||
title="Itérer à partir de ce prompt"
|
||||
>
|
||||
<GitBranch className="h-3 w-3 text-white" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-white/20"
|
||||
onClick={(e) => { e.stopPropagation(); togglePin.mutate({ variantId: variant.id }); }}
|
||||
|
|
|
|||
|
|
@ -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, AlertCircle,
|
||||
Wand2, MessageSquare, Settings, Film, Sparkles, AlertCircle, GitBranch,
|
||||
Scissors, Image, Users, Bot, Download,
|
||||
ChevronLeft, ChevronRight, Loader2
|
||||
} from "lucide-react";
|
||||
|
|
@ -674,6 +674,7 @@ function GenerationPanel({ projectId, selectedFrame }: { projectId: number; sele
|
|||
const [bgStyle, setBgStyle] = useState("Studio Ghibli");
|
||||
const [bigPreview, setBigPreview] = useState<"original" | "bg" | "fg" | "composite">("composite");
|
||||
const [zoomImage, setZoomImage] = useState<string | null>(null);
|
||||
const [variantsCount, setVariantsCount] = useState<1 | 2 | 4>(1);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: frame } = trpc.frames.getByIndex.useQuery(
|
||||
|
|
@ -683,9 +684,23 @@ function GenerationPanel({ projectId, selectedFrame }: { projectId: number; sele
|
|||
const { data: characters } = trpc.characters.list.useQuery({ projectId });
|
||||
const [selectedCharId, setSelectedCharId] = useState<number | undefined>();
|
||||
|
||||
// Prompt history
|
||||
const { data: promptHistoryBg } = trpc.frameVariants.promptHistory.useQuery(
|
||||
{ frameId: frame?.id || 0, type: "background" },
|
||||
{ enabled: !!frame?.id, staleTime: 10_000 }
|
||||
);
|
||||
const { data: promptHistoryChar } = trpc.frameVariants.promptHistory.useQuery(
|
||||
{ frameId: frame?.id || 0, type: "character" },
|
||||
{ enabled: !!frame?.id, staleTime: 10_000 }
|
||||
);
|
||||
|
||||
const invalidateFrame = () => {
|
||||
utils.frames.getByIndex.invalidate({ projectId, frameIndex: selectedFrame });
|
||||
utils.frames.list.invalidate({ projectId });
|
||||
if (frame?.id) {
|
||||
utils.frameVariants.list.invalidate({ frameId: frame.id });
|
||||
utils.frameVariants.promptHistory.invalidate({ frameId: frame.id });
|
||||
}
|
||||
};
|
||||
|
||||
const regenBg = trpc.generation.regenerateBackground.useMutation({
|
||||
|
|
@ -693,17 +708,72 @@ function GenerationPanel({ projectId, selectedFrame }: { projectId: number; sele
|
|||
onError: (err) => toast.error(`Fond: ${err.message}`, { duration: 8000 }),
|
||||
});
|
||||
|
||||
const regenBgBatch = trpc.generation.regenerateBackgroundBatch.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.generated} variante(s) générée(s)${data.failed ? `, ${data.failed} échec(s)` : ""}`);
|
||||
invalidateFrame();
|
||||
setBigPreview("bg");
|
||||
},
|
||||
onError: (err) => toast.error(`Batch fond: ${err.message}`, { duration: 8000 }),
|
||||
});
|
||||
|
||||
const regenChar = trpc.generation.regenerateCharacter.useMutation({
|
||||
onSuccess: () => { toast.success("Personnage redessiné !"); invalidateFrame(); setBigPreview("fg"); },
|
||||
onError: (err) => toast.error(`Personnage: ${err.message}`, { duration: 8000 }),
|
||||
});
|
||||
|
||||
const regenCharBatch = trpc.generation.regenerateCharacterBatch.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.generated} variante(s) générée(s)${data.failed ? `, ${data.failed} échec(s)` : ""}`);
|
||||
invalidateFrame();
|
||||
setBigPreview("fg");
|
||||
},
|
||||
onError: (err) => toast.error(`Batch perso: ${err.message}`, { duration: 8000 }),
|
||||
});
|
||||
|
||||
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 isGenerating = regenBg.isPending || regenChar.isPending || composeFrame.isPending || regenBgBatch.isPending || regenCharBatch.isPending;
|
||||
|
||||
/**
|
||||
* Generate slight variations of the same prompt for batch generation
|
||||
* Creates N "explorations" of the same idea
|
||||
*/
|
||||
const buildBatchPrompts = (basePrompt: string, count: number): string[] => {
|
||||
if (count === 1) return [basePrompt];
|
||||
const variations = [
|
||||
basePrompt,
|
||||
`${basePrompt}, alternative composition`,
|
||||
`${basePrompt}, different angle and lighting`,
|
||||
`${basePrompt}, more vibrant colors`,
|
||||
`${basePrompt}, softer atmosphere`,
|
||||
`${basePrompt}, more dramatic mood`,
|
||||
`${basePrompt}, simpler details`,
|
||||
`${basePrompt}, richer textures`,
|
||||
];
|
||||
return variations.slice(0, count);
|
||||
};
|
||||
|
||||
const handleGenerateBg = () => {
|
||||
if (variantsCount === 1) {
|
||||
regenBg.mutate({ projectId, frameIndex: selectedFrame, prompt: bgPrompt, style: bgStyle });
|
||||
} else {
|
||||
const prompts = buildBatchPrompts(bgPrompt, variantsCount);
|
||||
regenBgBatch.mutate({ projectId, frameIndex: selectedFrame, prompts, style: bgStyle });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateChar = () => {
|
||||
if (variantsCount === 1) {
|
||||
regenChar.mutate({ projectId, frameIndex: selectedFrame, prompt: charPrompt, characterId: selectedCharId });
|
||||
} else {
|
||||
const prompts = buildBatchPrompts(charPrompt, variantsCount);
|
||||
regenCharBatch.mutate({ projectId, frameIndex: selectedFrame, prompts, characterId: selectedCharId });
|
||||
}
|
||||
};
|
||||
|
||||
const versions = [
|
||||
{ key: "original" as const, label: "Original", icon: Film, url: frame?.originalUrl, color: "border-blue-500/50 text-blue-400" },
|
||||
|
|
@ -804,6 +874,29 @@ function GenerationPanel({ projectId, selectedFrame }: { projectId: number; sele
|
|||
|
||||
{/* RIGHT: Actions panel */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Global: N variants selector */}
|
||||
<div className="p-1.5 rounded-lg border border-border bg-card/30 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-mono flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
Variantes:
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 4].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setVariantsCount(n as 1 | 2 | 4)}
|
||||
className={`text-[10px] font-mono px-2 py-0.5 rounded ${variantsCount === n ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"}`}
|
||||
title={n === 1 ? "Une variante" : `${n} variantes explorées en parallèle`}
|
||||
>
|
||||
{n}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground/70 ml-auto">
|
||||
{variantsCount > 1 ? "Exploration auto" : "Single"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
|
|
@ -824,16 +917,33 @@ function GenerationPanel({ projectId, selectedFrame }: { projectId: number; sele
|
|||
placeholder="Style court"
|
||||
className="w-full px-2 py-1 text-[11px] rounded border border-border bg-input"
|
||||
/>
|
||||
{promptHistoryBg && promptHistoryBg.length > 0 && (
|
||||
<select
|
||||
onChange={(e) => { if (e.target.value) setBgPrompt(e.target.value); e.target.selectedIndex = 0; }}
|
||||
className="w-full px-2 py-0.5 text-[10px] rounded border border-border bg-input text-muted-foreground"
|
||||
title="Prompts utilisés précédemment"
|
||||
>
|
||||
<option value="">↺ Réutiliser un prompt...</option>
|
||||
{promptHistoryBg.map((h, i) => (
|
||||
<option key={i} value={h.prompt}>{h.prompt.slice(0, 60)}{h.prompt.length > 60 ? "..." : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full gap-1 text-[10px] h-7"
|
||||
disabled={isGenerating || !bgPrompt.trim()}
|
||||
onClick={() => regenBg.mutate({ projectId, frameIndex: selectedFrame, prompt: bgPrompt, style: bgStyle })}
|
||||
onClick={handleGenerateBg}
|
||||
>
|
||||
{regenBg.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{frame?.regeneratedBgUrl ? "Régénérer le fond" : "Générer le fond"}
|
||||
{regenBg.isPending || regenBgBatch.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{variantsCount > 1 ? `Générer ${variantsCount} variantes` : (frame?.regeneratedBgUrl ? "Régénérer le fond" : "Générer le fond")}
|
||||
</Button>
|
||||
<VersionsGallery frameId={frame?.id} type="background" label="Versions du fond" />
|
||||
<VersionsGallery
|
||||
frameId={frame?.id}
|
||||
type="background"
|
||||
label="Versions du fond"
|
||||
onIterateFromVariant={(p) => setBgPrompt(p)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Character generation */}
|
||||
|
|
@ -861,16 +971,33 @@ function GenerationPanel({ projectId, selectedFrame }: { projectId: number; sele
|
|||
))}
|
||||
</select>
|
||||
)}
|
||||
{promptHistoryChar && promptHistoryChar.length > 0 && (
|
||||
<select
|
||||
onChange={(e) => { if (e.target.value) setCharPrompt(e.target.value); e.target.selectedIndex = 0; }}
|
||||
className="w-full px-2 py-0.5 text-[10px] rounded border border-border bg-input text-muted-foreground"
|
||||
title="Prompts utilisés précédemment"
|
||||
>
|
||||
<option value="">↺ Réutiliser un prompt...</option>
|
||||
{promptHistoryChar.map((h, i) => (
|
||||
<option key={i} value={h.prompt}>{h.prompt.slice(0, 60)}{h.prompt.length > 60 ? "..." : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full gap-1 text-[10px] h-7"
|
||||
disabled={isGenerating || !charPrompt.trim()}
|
||||
onClick={() => regenChar.mutate({ projectId, frameIndex: selectedFrame, prompt: charPrompt, characterId: selectedCharId })}
|
||||
onClick={handleGenerateChar}
|
||||
>
|
||||
{regenChar.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{frame?.regeneratedFgUrl ? "Régénérer le perso" : "Générer le perso"}
|
||||
{regenChar.isPending || regenCharBatch.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{variantsCount > 1 ? `Générer ${variantsCount} variantes` : (frame?.regeneratedFgUrl ? "Régénérer le perso" : "Générer le perso")}
|
||||
</Button>
|
||||
<VersionsGallery frameId={frame?.id} type="character" label="Versions du perso" />
|
||||
<VersionsGallery
|
||||
frameId={frame?.id}
|
||||
type="character"
|
||||
label="Versions du perso"
|
||||
onIterateFromVariant={(p) => setCharPrompt(p)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Compositing */}
|
||||
|
|
|
|||
|
|
@ -589,6 +589,63 @@ Réponds de manière concise et technique, en français. Propose des actions con
|
|||
return { success: true, resultUrl, variantId: variant.id };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Generate N background variants in parallel
|
||||
* Returns successes + failures so user sees partial results
|
||||
*/
|
||||
regenerateBackgroundBatch: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.number(),
|
||||
frameIndex: z.number(),
|
||||
prompts: z.array(z.string().min(1)).min(1).max(8),
|
||||
style: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const frame = await db.getFrame(input.projectId, input.frameIndex);
|
||||
if (!frame?.originalUrl) throw new TRPCError({ code: "NOT_FOUND", message: "Frame not found" });
|
||||
const project = await db.getProject(input.projectId);
|
||||
const targetDimensions = project?.width && project?.height
|
||||
? { width: project.width, height: project.height }
|
||||
: undefined;
|
||||
|
||||
const { regenerateBackground } = await import("./segmentationService");
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
input.prompts.map(async (prompt, idx) => {
|
||||
const start = Date.now();
|
||||
const url = await regenerateBackground(
|
||||
frame.originalUrl!,
|
||||
prompt,
|
||||
input.style || "same art style",
|
||||
targetDimensions
|
||||
);
|
||||
const generationTimeMs = Date.now() - start;
|
||||
const variant = await db.createFrameVariant({
|
||||
frameId: frame.id,
|
||||
type: "background",
|
||||
url,
|
||||
prompt,
|
||||
provider: "openai",
|
||||
generationTimeMs,
|
||||
// Only the last one becomes active so user can choose later
|
||||
isActive: idx === input.prompts.length - 1,
|
||||
metadata: { style: input.style, batchIndex: idx, batchSize: input.prompts.length },
|
||||
});
|
||||
return { variantId: variant.id, url, prompt, success: true };
|
||||
})
|
||||
);
|
||||
|
||||
const succeeded = results.filter(r => r.status === "fulfilled").map(r => (r as any).value);
|
||||
const failed = results.filter(r => r.status === "rejected").map(r => (r as any).reason?.message || "error");
|
||||
|
||||
// Sync legacy field with the latest successful one
|
||||
if (succeeded.length > 0) {
|
||||
await db.updateFrame(frame.id, { regeneratedBgUrl: succeeded[succeeded.length - 1].url });
|
||||
}
|
||||
|
||||
return { success: succeeded.length > 0, generated: succeeded.length, failed: failed.length, variants: succeeded, errors: failed };
|
||||
}),
|
||||
|
||||
regenerateCharacter: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.number(),
|
||||
|
|
@ -651,6 +708,73 @@ Réponds de manière concise et technique, en français. Propose des actions con
|
|||
return { success: true, resultUrl, variantId: variant.id };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Generate N character variants in parallel
|
||||
*/
|
||||
regenerateCharacterBatch: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.number(),
|
||||
frameIndex: z.number(),
|
||||
prompts: z.array(z.string().min(1)).min(1).max(8),
|
||||
characterId: z.number().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const frame = await db.getFrame(input.projectId, input.frameIndex);
|
||||
if (!frame?.originalUrl) throw new TRPCError({ code: "NOT_FOUND", message: "Frame not found" });
|
||||
const project = await db.getProject(input.projectId);
|
||||
const targetDimensions = project?.width && project?.height
|
||||
? { width: project.width, height: project.height }
|
||||
: undefined;
|
||||
|
||||
let characterSheet: string | undefined;
|
||||
let characterConfig: { name: string; modelType: "lora" | "ip_adapter" | "none" } | undefined;
|
||||
if (input.characterId) {
|
||||
const chars = await db.listCharacters(input.projectId);
|
||||
const char = chars.find(c => c.id === input.characterId);
|
||||
if (char) {
|
||||
characterSheet = char.referenceSheetUrl || undefined;
|
||||
characterConfig = { name: char.name, modelType: (char.modelType as any) || "none" };
|
||||
}
|
||||
}
|
||||
|
||||
const { regenerateCharacter } = await import("./segmentationService");
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
input.prompts.map(async (prompt, idx) => {
|
||||
const start = Date.now();
|
||||
const url = await regenerateCharacter(
|
||||
frame.originalUrl!,
|
||||
prompt,
|
||||
characterSheet,
|
||||
frame.maskUrl || undefined,
|
||||
characterConfig,
|
||||
targetDimensions
|
||||
);
|
||||
const generationTimeMs = Date.now() - start;
|
||||
const variant = await db.createFrameVariant({
|
||||
frameId: frame.id,
|
||||
type: "character",
|
||||
url,
|
||||
prompt,
|
||||
provider: "openai",
|
||||
generationTimeMs,
|
||||
isActive: idx === input.prompts.length - 1,
|
||||
metadata: { characterId: input.characterId, characterSheet, characterConfig, batchIndex: idx, batchSize: input.prompts.length },
|
||||
});
|
||||
return { variantId: variant.id, url, prompt, success: true };
|
||||
})
|
||||
);
|
||||
|
||||
const succeeded = results.filter(r => r.status === "fulfilled").map(r => (r as any).value);
|
||||
const failed = results.filter(r => r.status === "rejected").map(r => (r as any).reason?.message || "error");
|
||||
|
||||
if (succeeded.length > 0) {
|
||||
await db.updateFrame(frame.id, { regeneratedFgUrl: succeeded[succeeded.length - 1].url });
|
||||
}
|
||||
|
||||
return { success: succeeded.length > 0, generated: succeeded.length, failed: failed.length, variants: succeeded, errors: failed };
|
||||
}),
|
||||
|
||||
inpaintBackground: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.number(),
|
||||
|
|
@ -813,6 +937,25 @@ Réponds de manière concise et technique, en français. Propose des actions con
|
|||
await dbInstance.update(frameVariants).set({ transform: input.transform as any }).where(eq(frameVariants.id, input.variantId));
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get unique prompts used for this frame across all variants
|
||||
* Returns most-recent first, deduplicated
|
||||
*/
|
||||
promptHistory: protectedProcedure
|
||||
.input(z.object({ frameId: z.number(), type: z.enum(["background", "character", "composite"]).optional() }))
|
||||
.query(async ({ input }) => {
|
||||
const variants = await db.listFrameVariants(input.frameId, input.type);
|
||||
const seen = new Set<string>();
|
||||
const prompts: Array<{ prompt: string; createdAt: Date; type: string }> = [];
|
||||
for (const v of variants) {
|
||||
if (v.prompt && !seen.has(v.prompt)) {
|
||||
seen.add(v.prompt);
|
||||
prompts.push({ prompt: v.prompt, createdAt: v.createdAt, type: v.type });
|
||||
}
|
||||
}
|
||||
return prompts.slice(0, 20);
|
||||
}),
|
||||
}),
|
||||
|
||||
// ============ COMPOSITING ============
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue