diff --git a/client/src/components/VersionsGallery.tsx b/client/src/components/VersionsGallery.tsx index e5ac16a..a63a622 100644 --- a/client/src/components/VersionsGallery.tsx +++ b/client/src/components/VersionsGallery.tsx @@ -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(null); @@ -104,6 +105,19 @@ export default function VersionsGallery({ frameId, type, label, onVariantSelecte {/* Hover actions */} {isHovered && (
+ {onIterateFromVariant && variant.prompt && ( + + )} + ))} +
+ + {variantsCount > 1 ? "Exploration auto" : "Single"} + + + {/* Background generation */}
@@ -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 && ( + + )} - + setBgPrompt(p)} + />
{/* Character generation */} @@ -861,16 +971,33 @@ function GenerationPanel({ projectId, selectedFrame }: { projectId: number; sele ))} )} + {promptHistoryChar && promptHistoryChar.length > 0 && ( + + )} - + setCharPrompt(p)} + />
{/* Compositing */} diff --git a/server/routers.ts b/server/routers.ts index d3c8b38..1475381 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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(); + 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 ============