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:
Ubuntu 2026-05-21 12:09:57 +00:00
parent d18424a416
commit c07ee892e7
3 changed files with 296 additions and 12 deletions

View file

@ -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 }); }}

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, 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 */}

View file

@ -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 ============