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>
1100 lines
42 KiB
TypeScript
1100 lines
42 KiB
TypeScript
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
|
import { getSessionCookieOptions } from "./_core/cookies";
|
|
import { normalizeEmail, ensureBootstrapLocalAdmin, verifyPassword } from "./_core/localAuth";
|
|
import { sdk } from "./_core/sdk";
|
|
import { systemRouter } from "./_core/systemRouter";
|
|
import { publicProcedure, protectedProcedure, adminProcedure, router } from "./_core/trpc";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import * as db from "./db";
|
|
import { invokeLLM } from "./_core/llm";
|
|
import { invokeConfiguredLLM, invalidateLlmConfigCache } from "./llmConfig";
|
|
|
|
export const appRouter = router({
|
|
system: systemRouter,
|
|
auth: router({
|
|
me: publicProcedure.query((opts) => {
|
|
if (!opts.ctx.user) return null;
|
|
const { passwordHash, ...safeUser } = opts.ctx.user;
|
|
return safeUser;
|
|
}),
|
|
localLogin: publicProcedure
|
|
.input(z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(8),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const email = normalizeEmail(input.email);
|
|
|
|
await ensureBootstrapLocalAdmin(email, input.password);
|
|
|
|
const user = await db.getUserByEmail(email);
|
|
const isValid = !!user?.passwordHash && verifyPassword(input.password, user.passwordHash);
|
|
|
|
if (!user || !isValid) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid email or password" });
|
|
}
|
|
|
|
await db.upsertUser({
|
|
openId: user.openId,
|
|
lastSignedIn: new Date(),
|
|
});
|
|
|
|
const sessionToken = await sdk.createSessionToken(user.openId, {
|
|
name: user.name || user.email || "RetroToon User",
|
|
expiresInMs: ONE_YEAR_MS,
|
|
});
|
|
|
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
|
ctx.res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
|
|
|
|
return {
|
|
id: user.id,
|
|
openId: user.openId,
|
|
email: user.email,
|
|
name: user.name,
|
|
loginMethod: user.loginMethod,
|
|
role: user.role,
|
|
createdAt: user.createdAt,
|
|
updatedAt: user.updatedAt,
|
|
lastSignedIn: user.lastSignedIn,
|
|
};
|
|
}),
|
|
logout: publicProcedure.mutation(({ ctx }) => {
|
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
|
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
|
return { success: true } as const;
|
|
}),
|
|
}),
|
|
|
|
// ============ PROJECTS ============
|
|
projects: router({
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
if (ctx.user.role === "admin") {
|
|
return db.listAllProjects();
|
|
}
|
|
return db.listProjects(ctx.user.id);
|
|
}),
|
|
get: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const project = await db.getProject(input.id);
|
|
if (!project) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
|
}
|
|
return project;
|
|
}),
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
name: z.string().min(1),
|
|
description: z.string().optional(),
|
|
sourceVideoUrl: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const result = await db.createProject({
|
|
userId: ctx.user.id,
|
|
name: input.name,
|
|
description: input.description || null,
|
|
sourceVideoUrl: input.sourceVideoUrl || null,
|
|
status: "importing",
|
|
});
|
|
return result;
|
|
}),
|
|
updateStatus: protectedProcedure
|
|
.input(z.object({ id: z.number(), status: z.string() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.updateProject(input.id, { status: input.status as any });
|
|
return { success: true };
|
|
}),
|
|
delete: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.deleteProject(input.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============ SEQUENCES ============
|
|
sequences: router({
|
|
list: protectedProcedure
|
|
.input(z.object({ projectId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.listSequences(input.projectId);
|
|
}),
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number(),
|
|
name: z.string().optional(),
|
|
startFrame: z.number(),
|
|
endFrame: z.number(),
|
|
isStaticBackground: z.boolean().optional(),
|
|
referenceFrameIndex: z.number().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
return db.createSequence(input);
|
|
}),
|
|
update: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
name: z.string().optional(),
|
|
startFrame: z.number().optional(),
|
|
endFrame: z.number().optional(),
|
|
isStaticBackground: z.boolean().optional(),
|
|
referenceFrameIndex: z.number().optional(),
|
|
status: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
await db.updateSequence(id, data as any);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============ LAYERS ============
|
|
layers: router({
|
|
list: protectedProcedure
|
|
.input(z.object({ projectId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.listLayers(input.projectId);
|
|
}),
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
projectId: z.number(),
|
|
name: z.string(),
|
|
type: z.enum(["background", "character", "object", "effect"]),
|
|
order: z.number().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
return db.createLayer(input);
|
|
}),
|
|
toggleVisibility: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const dbInstance = await db.getDb();
|
|
if (dbInstance) {
|
|
const { layers: layersTable } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
const [current] = await dbInstance.select({ visible: layersTable.visible }).from(layersTable).where(eq(layersTable.id, input.id)).limit(1);
|
|
if (current) {
|
|
await dbInstance.update(layersTable).set({ visible: !current.visible }).where(eq(layersTable.id, input.id));
|
|
}
|
|
}
|
|
return { success: true };
|
|
}),
|
|
toggleLock: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const dbInstance = await db.getDb();
|
|
if (dbInstance) {
|
|
const { layers: layersTable } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
const [current] = await dbInstance.select({ locked: layersTable.locked }).from(layersTable).where(eq(layersTable.id, input.id)).limit(1);
|
|
if (current) {
|
|
await dbInstance.update(layersTable).set({ locked: !current.locked }).where(eq(layersTable.id, input.id));
|
|
}
|
|
}
|
|
return { success: true };
|
|
}),
|
|
delete: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.deleteLayer(input.id);
|
|
return { success: true };
|
|
}),
|
|
reorder: protectedProcedure
|
|
.input(z.object({ ids: z.array(z.number()) }))
|
|
.mutation(async ({ input }) => {
|
|
await db.reorderLayers(input.ids);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============ FRAMES ============
|
|
frames: router({
|
|
saveMask: protectedProcedure
|
|
.input(z.object({
|
|
frameId: z.number(),
|
|
maskUrl: z.string().nullable(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
await db.updateFrame(input.frameId, { maskUrl: input.maskUrl });
|
|
return { success: true };
|
|
}),
|
|
getByIndex: protectedProcedure
|
|
.input(z.object({ projectId: z.number(), frameIndex: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.getFrame(input.projectId, input.frameIndex);
|
|
}),
|
|
list: protectedProcedure
|
|
.input(z.object({ projectId: z.number(), limit: z.number().optional() }))
|
|
.query(async ({ input }) => {
|
|
const allFrames = await db.listFrames(input.projectId);
|
|
if (input.limit) return allFrames.slice(0, input.limit);
|
|
return allFrames;
|
|
}),
|
|
}),
|
|
|
|
// ============ CHARACTERS ============
|
|
characters: router({
|
|
list: protectedProcedure
|
|
.input(z.object({ projectId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.listCharacters(input.projectId);
|
|
}),
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number(),
|
|
name: z.string(),
|
|
description: z.string().optional(),
|
|
color: z.string().optional(),
|
|
modelType: z.enum(["lora", "ip_adapter", "none"]).optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
return db.createCharacter(input);
|
|
}),
|
|
update: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
name: z.string().optional(),
|
|
description: z.string().optional(),
|
|
color: z.string().optional(),
|
|
modelType: z.enum(["lora", "ip_adapter", "none"]).optional(),
|
|
referenceSheetUrl: z.string().nullish(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
await db.updateCharacter(id, data as any);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============ ADMIN - AI ENGINES ============
|
|
admin: router({
|
|
listEngines: adminProcedure.query(async () => {
|
|
return db.listAiEngines();
|
|
}),
|
|
createEngine: adminProcedure
|
|
.input(z.object({
|
|
name: z.string(),
|
|
provider: z.string(),
|
|
taskType: z.string(),
|
|
apiEndpoint: z.string().optional(),
|
|
modelName: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
return db.createAiEngine({
|
|
name: input.name,
|
|
provider: input.provider,
|
|
taskType: input.taskType as any,
|
|
apiEndpoint: input.apiEndpoint || null,
|
|
modelName: input.modelName || null,
|
|
});
|
|
}),
|
|
toggleEngine: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const dbInstance = await db.getDb();
|
|
if (dbInstance) {
|
|
const { aiEngines: enginesTable } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
const [current] = await dbInstance.select({ isActive: enginesTable.isActive }).from(enginesTable).where(eq(enginesTable.id, input.id)).limit(1);
|
|
if (current) {
|
|
await dbInstance.update(enginesTable).set({ isActive: !current.isActive }).where(eq(enginesTable.id, input.id));
|
|
}
|
|
}
|
|
return { success: true };
|
|
}),
|
|
deleteEngine: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.deleteAiEngine(input.id);
|
|
return { success: true };
|
|
}),
|
|
testEngine: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }): Promise<{ success: boolean; message?: string; error?: string; latencyMs?: number }> => {
|
|
try {
|
|
const dbInstance = await db.getDb();
|
|
if (!dbInstance) return { success: false, error: "Database unavailable" };
|
|
const { aiEngines: enginesTable } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
const [engine] = await dbInstance.select().from(enginesTable).where(eq(enginesTable.id, input.id)).limit(1);
|
|
if (!engine) return { success: false, error: "Engine not found" };
|
|
if (!engine.apiEndpoint) return { success: false, error: "No API endpoint configured" };
|
|
|
|
const start = Date.now();
|
|
const resp = await fetch(engine.apiEndpoint, {
|
|
method: "HEAD",
|
|
signal: AbortSignal.timeout(10_000),
|
|
}).catch(() => null);
|
|
const latencyMs = Date.now() - start;
|
|
|
|
if (!resp) return { success: false, error: `Endpoint unreachable (timeout 10s)`, latencyMs };
|
|
if (resp.status >= 500) return { success: false, error: `Server error: ${resp.status}`, latencyMs };
|
|
|
|
return { success: true, message: `Endpoint responded (HTTP ${resp.status}, ${latencyMs}ms)`, latencyMs };
|
|
} catch (e: any) {
|
|
return { success: false, error: e.message };
|
|
}
|
|
}),
|
|
runTestGeneration: adminProcedure
|
|
.input(z.object({
|
|
taskType: z.string(),
|
|
prompt: z.string(),
|
|
}))
|
|
.mutation(async ({ input }): Promise<{ success: boolean; resultUrl?: string; error?: string }> => {
|
|
try {
|
|
const { generateImage } = await import("./_core/imageGeneration");
|
|
const result = await generateImage({
|
|
prompt: input.prompt,
|
|
});
|
|
if (result.url) {
|
|
return { success: true, resultUrl: result.url };
|
|
}
|
|
return { success: true, resultUrl: "/manus-storage/test-simulation-result.png" };
|
|
} catch (e: any) {
|
|
return { success: false, error: e.message || "Échec de la génération de test" };
|
|
}
|
|
}),
|
|
saveConfig: adminProcedure
|
|
.input(z.object({
|
|
key: z.string(),
|
|
value: z.string(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const dbInstance = await db.getDb();
|
|
if (dbInstance) {
|
|
const { appConfig } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
// Upsert: try update first, then insert
|
|
const existing = await dbInstance.select().from(appConfig).where(eq(appConfig.key, input.key)).limit(1);
|
|
if (existing.length > 0) {
|
|
await dbInstance.update(appConfig).set({ value: input.value }).where(eq(appConfig.key, input.key));
|
|
} else {
|
|
await dbInstance.insert(appConfig).values({ key: input.key, value: input.value });
|
|
}
|
|
}
|
|
// Invalidate services config cache when services_config is updated
|
|
if (input.key === "services_config") {
|
|
const { invalidateServicesConfigCache } = await import("./servicesConfig");
|
|
invalidateServicesConfigCache();
|
|
}
|
|
// Invalidate LLM config cache when llm_config is updated
|
|
if (input.key === "llm_config") {
|
|
invalidateLlmConfigCache();
|
|
}
|
|
return { success: true };
|
|
}),
|
|
getConfig: adminProcedure
|
|
.input(z.object({ key: z.string() }))
|
|
.query(async ({ input }) => {
|
|
const dbInstance = await db.getDb();
|
|
if (dbInstance) {
|
|
const { appConfig } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
const result = await dbInstance.select().from(appConfig).where(eq(appConfig.key, input.key)).limit(1);
|
|
return result[0]?.value || null;
|
|
}
|
|
return null;
|
|
}),
|
|
}),
|
|
|
|
// ============ AI ASSISTANT ============
|
|
assistant: router({
|
|
getMessages: protectedProcedure
|
|
.input(z.object({ projectId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.listAssistantMessages(input.projectId);
|
|
}),
|
|
sendMessage: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number(),
|
|
content: z.string(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
// Save user message
|
|
await db.createAssistantMessage({
|
|
projectId: input.projectId,
|
|
role: "user",
|
|
content: input.content,
|
|
});
|
|
|
|
// Get project context
|
|
const project = await db.getProject(input.projectId);
|
|
const sequences = await db.listSequences(input.projectId);
|
|
const layers = await db.listLayers(input.projectId);
|
|
|
|
// Generate AI response
|
|
const systemPrompt = `Tu es l'assistant opérateur de RetroToon Studio, un logiciel professionnel de recomposition de dessins animés.
|
|
Tu aides l'utilisateur à :
|
|
- Détecter et segmenter les séquences vidéo
|
|
- Identifier les arrière-plans statiques
|
|
- Séparer les personnages du décor
|
|
- Orchestrer la regénération par IA des éléments visuels
|
|
- Guider le processus de recomposition
|
|
|
|
Contexte du projet actuel :
|
|
- Nom: ${project?.name || "Inconnu"}
|
|
- Status: ${project?.status || "Inconnu"}
|
|
- Frames totales: ${project?.totalFrames || 0}
|
|
- FPS: ${project?.fps || 0}
|
|
- Séquences détectées: ${sequences.length}
|
|
- Calques: ${layers.length}
|
|
|
|
Réponds de manière concise et technique, en français. Propose des actions concrètes.`;
|
|
|
|
try {
|
|
const response = await invokeConfiguredLLM({
|
|
messages: [
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: input.content },
|
|
],
|
|
});
|
|
|
|
const rawContent = response.choices?.[0]?.message?.content;
|
|
const assistantContent = (typeof rawContent === "string" ? rawContent : null) || "Je suis prêt à vous assister. Que souhaitez-vous faire ?";
|
|
|
|
await db.createAssistantMessage({
|
|
projectId: input.projectId,
|
|
role: "assistant",
|
|
content: assistantContent,
|
|
});
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
const fallbackMessage = "Je suis prêt à vous assister dans la recomposition de votre projet. Décrivez l'opération souhaitée.";
|
|
await db.createAssistantMessage({
|
|
projectId: input.projectId,
|
|
role: "assistant",
|
|
content: fallbackMessage,
|
|
});
|
|
return { success: true };
|
|
}
|
|
}),
|
|
runAutoCompose: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number(),
|
|
action: z.enum(["detect_scenes", "analyze_backgrounds", "segment_all", "auto_compose"]),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { runAutonomousPipeline } = await import("./assistantOperator");
|
|
|
|
// Map frontend action names to pipeline actions
|
|
const actionMap: Record<string, any> = {
|
|
detect_scenes: "detect_scenes",
|
|
analyze_backgrounds: "analyze_backgrounds",
|
|
segment_all: "segment_characters",
|
|
auto_compose: "full_auto",
|
|
};
|
|
|
|
const pipelineAction = actionMap[input.action] || input.action;
|
|
const result = await runAutonomousPipeline(input.projectId, pipelineAction);
|
|
|
|
// Save the assistant message with the pipeline result
|
|
await db.createAssistantMessage({
|
|
projectId: input.projectId,
|
|
role: "assistant",
|
|
content: result.message,
|
|
});
|
|
// Create a generation job for tracking
|
|
await db.createGenerationJob({
|
|
projectId: input.projectId,
|
|
type: input.action === "detect_scenes" ? "scene_analysis" : input.action === "segment_all" ? "segmentation" : "auto_compose",
|
|
status: result.success ? "completed" : "failed",
|
|
prompt: input.action,
|
|
});
|
|
|
|
return { success: result.success, message: result.message };
|
|
}),
|
|
}),
|
|
|
|
// ============ GENERATION JOBS ============
|
|
jobs: router({
|
|
list: protectedProcedure
|
|
.input(z.object({ projectId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.listGenerationJobs(input.projectId);
|
|
}),
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number(),
|
|
sequenceId: z.number().optional(),
|
|
engineId: z.number().optional(),
|
|
type: z.string(),
|
|
prompt: z.string().optional(),
|
|
isTestMode: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
return db.createGenerationJob({
|
|
projectId: input.projectId,
|
|
sequenceId: input.sequenceId || null,
|
|
engineId: input.engineId || null,
|
|
type: input.type as any,
|
|
prompt: input.prompt || null,
|
|
isTestMode: input.isTestMode || false,
|
|
status: "queued",
|
|
});
|
|
}),
|
|
}),
|
|
|
|
// ============ GENERATION ============
|
|
generation: router({
|
|
regenerateBackground: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number(),
|
|
frameIndex: z.number(),
|
|
prompt: z.string().min(1),
|
|
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 start = Date.now();
|
|
const { regenerateBackground } = await import("./segmentationService");
|
|
const resultUrl = await regenerateBackground(
|
|
frame.originalUrl,
|
|
input.prompt,
|
|
input.style || "same art style",
|
|
targetDimensions
|
|
);
|
|
const generationTimeMs = Date.now() - start;
|
|
|
|
// Create variant (non-destructive) + set as active + sync legacy field
|
|
const variant = await db.createFrameVariant({
|
|
frameId: frame.id,
|
|
type: "background",
|
|
url: resultUrl,
|
|
prompt: input.prompt,
|
|
provider: "openai", // could be detected from generateImage result
|
|
generationTimeMs,
|
|
isActive: true,
|
|
metadata: { style: input.style },
|
|
});
|
|
await db.updateFrame(frame.id, { regeneratedBgUrl: resultUrl });
|
|
await db.createGenerationJob({
|
|
projectId: input.projectId,
|
|
type: "background_gen",
|
|
prompt: input.prompt,
|
|
status: "completed",
|
|
resultUrl,
|
|
});
|
|
|
|
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(),
|
|
frameIndex: z.number(),
|
|
prompt: z.string().min(1),
|
|
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 start = Date.now();
|
|
const { regenerateCharacter } = await import("./segmentationService");
|
|
const resultUrl = await regenerateCharacter(
|
|
frame.originalUrl,
|
|
input.prompt,
|
|
characterSheet,
|
|
frame.maskUrl || undefined,
|
|
characterConfig,
|
|
targetDimensions
|
|
);
|
|
const generationTimeMs = Date.now() - start;
|
|
|
|
const variant = await db.createFrameVariant({
|
|
frameId: frame.id,
|
|
type: "character",
|
|
url: resultUrl,
|
|
prompt: input.prompt,
|
|
provider: "openai",
|
|
generationTimeMs,
|
|
isActive: true,
|
|
metadata: { characterId: input.characterId, characterSheet, characterConfig },
|
|
});
|
|
await db.updateFrame(frame.id, { regeneratedFgUrl: resultUrl });
|
|
await db.createGenerationJob({
|
|
projectId: input.projectId,
|
|
type: "character_gen",
|
|
prompt: input.prompt,
|
|
status: "completed",
|
|
resultUrl,
|
|
});
|
|
|
|
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(),
|
|
frameIndex: z.number(),
|
|
prompt: 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 { inpaintBackground } = await import("./segmentationService");
|
|
const resultUrl = await inpaintBackground(
|
|
frame.originalUrl,
|
|
frame.maskUrl || frame.originalUrl,
|
|
input.prompt
|
|
);
|
|
|
|
await db.updateFrame(frame.id, { backgroundUrl: resultUrl });
|
|
return { success: true, resultUrl };
|
|
}),
|
|
|
|
/**
|
|
* Localized inpainting: regenerate only the masked zone of a source image
|
|
* @param sourceType which version to inpaint: "original", "bg", "fg", or "composite"
|
|
*/
|
|
inpaintZone: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number(),
|
|
frameIndex: z.number(),
|
|
maskUrl: z.string().min(1),
|
|
prompt: z.string().min(1),
|
|
sourceType: z.enum(["original", "bg", "fg", "composite"]).optional().default("original"),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const frame = await db.getFrame(input.projectId, input.frameIndex);
|
|
if (!frame) throw new TRPCError({ code: "NOT_FOUND", message: "Frame not found" });
|
|
|
|
// Pick source image based on sourceType
|
|
const sourceUrl =
|
|
input.sourceType === "bg" ? (frame.regeneratedBgUrl || frame.backgroundUrl || frame.originalUrl) :
|
|
input.sourceType === "fg" ? (frame.regeneratedFgUrl || frame.foregroundUrl || frame.originalUrl) :
|
|
input.sourceType === "composite" ? (frame.compositedUrl || frame.originalUrl) :
|
|
frame.originalUrl;
|
|
|
|
if (!sourceUrl) throw new TRPCError({ code: "BAD_REQUEST", message: "No source image available for this type" });
|
|
|
|
const project = await db.getProject(input.projectId);
|
|
const targetDimensions = project?.width && project?.height
|
|
? { width: project.width, height: project.height }
|
|
: undefined;
|
|
|
|
// Resolve URLs to absolute
|
|
const { storageGetSignedUrl } = await import("./storage");
|
|
const absSourceUrl = sourceUrl.startsWith("http")
|
|
? sourceUrl
|
|
: await storageGetSignedUrl(sourceUrl.replace(/^\/(manus-)?storage\//, ""));
|
|
const absMaskUrl = input.maskUrl.startsWith("http")
|
|
? input.maskUrl
|
|
: await storageGetSignedUrl(input.maskUrl.replace(/^\/(manus-)?storage\//, ""));
|
|
|
|
const start = Date.now();
|
|
const { generateImage } = await import("./_core/imageGeneration");
|
|
const result = await generateImage({
|
|
prompt: input.prompt,
|
|
originalImages: [{ url: absSourceUrl, mimeType: "image/png" }],
|
|
maskUrl: absMaskUrl,
|
|
targetAspectRatio: targetDimensions ? `${targetDimensions.width}:${targetDimensions.height}` : undefined,
|
|
targetWidth: targetDimensions?.width,
|
|
targetHeight: targetDimensions?.height,
|
|
});
|
|
const generationTimeMs = Date.now() - start;
|
|
|
|
if (!result.url) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Inpainting failed" });
|
|
|
|
// Create variant matching the source type
|
|
const variantType =
|
|
input.sourceType === "bg" ? "background" :
|
|
input.sourceType === "fg" ? "character" :
|
|
input.sourceType === "composite" ? "composite" :
|
|
"composite"; // original inpainted -> consider it a new composite version
|
|
|
|
const variant = await db.createFrameVariant({
|
|
frameId: frame.id,
|
|
type: variantType as any,
|
|
url: result.url,
|
|
prompt: `[Inpaint] ${input.prompt}`,
|
|
provider: result.provider,
|
|
generationTimeMs,
|
|
isActive: true,
|
|
metadata: { inpaintMaskUrl: input.maskUrl, sourceType: input.sourceType, sourceUrl },
|
|
});
|
|
|
|
// Sync legacy fields
|
|
if (variantType === "background") await db.updateFrame(frame.id, { regeneratedBgUrl: result.url });
|
|
else if (variantType === "character") await db.updateFrame(frame.id, { regeneratedFgUrl: result.url });
|
|
else await db.updateFrame(frame.id, { compositedUrl: result.url });
|
|
|
|
return { success: true, resultUrl: result.url, variantId: variant.id };
|
|
}),
|
|
}),
|
|
|
|
// ============ FRAME VARIANTS ============
|
|
frameVariants: router({
|
|
list: protectedProcedure
|
|
.input(z.object({ frameId: z.number(), type: z.enum(["background", "character", "composite"]).optional() }))
|
|
.query(async ({ input }) => {
|
|
return db.listFrameVariants(input.frameId, input.type);
|
|
}),
|
|
setActive: protectedProcedure
|
|
.input(z.object({ variantId: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const variant = await db.setActiveVariant(input.variantId);
|
|
if (variant) {
|
|
// Sync legacy fields on frames table for backward-compat
|
|
if (variant.type === "background") {
|
|
await db.updateFrame(variant.frameId, { regeneratedBgUrl: variant.url });
|
|
} else if (variant.type === "character") {
|
|
await db.updateFrame(variant.frameId, { regeneratedFgUrl: variant.url });
|
|
} else if (variant.type === "composite") {
|
|
await db.updateFrame(variant.frameId, { compositedUrl: variant.url });
|
|
}
|
|
}
|
|
return { success: true };
|
|
}),
|
|
delete: protectedProcedure
|
|
.input(z.object({ variantId: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.deleteFrameVariant(input.variantId);
|
|
return { success: true };
|
|
}),
|
|
togglePin: protectedProcedure
|
|
.input(z.object({ variantId: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.toggleVariantPin(input.variantId);
|
|
return { success: true };
|
|
}),
|
|
rename: protectedProcedure
|
|
.input(z.object({ variantId: z.number(), label: z.string().nullable() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.renameVariant(input.variantId, input.label);
|
|
return { success: true };
|
|
}),
|
|
updateTransform: protectedProcedure
|
|
.input(z.object({
|
|
variantId: z.number(),
|
|
transform: z.object({
|
|
x: z.number().optional(),
|
|
y: z.number().optional(),
|
|
scale: z.number().optional(),
|
|
rotation: z.number().optional(),
|
|
flipH: z.boolean().optional(),
|
|
flipV: z.boolean().optional(),
|
|
}).nullable(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const dbInstance = await db.getDb();
|
|
if (!dbInstance) return { success: false };
|
|
const { frameVariants } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
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 ============
|
|
compositing: router({
|
|
composeFrame: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number(),
|
|
frameIndex: z.number(),
|
|
featherRadius: z.number().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const frame = await db.getFrame(input.projectId, input.frameIndex);
|
|
if (!frame) throw new TRPCError({ code: "NOT_FOUND", message: "Frame not found" });
|
|
|
|
const projectLayers = await db.listLayers(input.projectId);
|
|
const visibleLayers = projectLayers.filter(l => l.visible).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
|
|
const composites: Array<{ imageUrl: string; opacity: number; blendMode?: "normal" | "multiply" | "screen" | "overlay"; maskUrl?: string; transform?: any }> = [];
|
|
|
|
const bgUrl = frame.regeneratedBgUrl || frame.backgroundUrl || frame.originalUrl;
|
|
if (bgUrl) {
|
|
composites.push({ imageUrl: bgUrl, opacity: 100, blendMode: "normal" });
|
|
}
|
|
|
|
const fgUrl = frame.regeneratedFgUrl || frame.foregroundUrl;
|
|
if (fgUrl) {
|
|
// Look up active character variant for its transform
|
|
const charVariant = await db.getActiveVariant(frame.id, "character");
|
|
composites.push({
|
|
imageUrl: fgUrl,
|
|
opacity: 100,
|
|
blendMode: "normal",
|
|
maskUrl: frame.maskUrl || undefined,
|
|
transform: (charVariant?.transform as any) || undefined,
|
|
});
|
|
}
|
|
|
|
if (composites.length === 0) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "No layers to composite" });
|
|
}
|
|
|
|
const start = Date.now();
|
|
const { compositeLayers } = await import("./segmentationService");
|
|
const resultUrl = await compositeLayers(composites, {
|
|
featherRadius: input.featherRadius ?? 2,
|
|
outputKey: `composited/frame_${input.projectId}_${input.frameIndex}_${Date.now()}.png`,
|
|
});
|
|
const generationTimeMs = Date.now() - start;
|
|
|
|
const variant = await db.createFrameVariant({
|
|
frameId: frame.id,
|
|
type: "composite",
|
|
url: resultUrl,
|
|
provider: "sharp",
|
|
generationTimeMs,
|
|
isActive: true,
|
|
metadata: { sourceLayers: composites.map(c => ({ url: c.imageUrl, blendMode: c.blendMode })) },
|
|
});
|
|
await db.updateFrame(frame.id, { compositedUrl: resultUrl });
|
|
return { success: true, resultUrl, variantId: variant.id };
|
|
}),
|
|
|
|
composeSequence: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number(),
|
|
sequenceId: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const sequences = await db.listSequences(input.projectId);
|
|
const seq = sequences.find(s => s.id === input.sequenceId);
|
|
if (!seq) throw new TRPCError({ code: "NOT_FOUND", message: "Sequence not found" });
|
|
|
|
const frames = await db.listFrames(input.projectId);
|
|
const seqFrames = frames.filter(f => f.frameIndex >= seq.startFrame && f.frameIndex <= seq.endFrame);
|
|
|
|
const { compositeLayers } = await import("./segmentationService");
|
|
const layersList = await db.listLayers(input.projectId);
|
|
const visibleLayers = layersList.filter(l => l.visible && l.sequenceId === input.sequenceId);
|
|
|
|
let done = 0;
|
|
for (const frame of seqFrames) {
|
|
try {
|
|
const composites: any[] = [];
|
|
const bgUrl = frame.regeneratedBgUrl || frame.backgroundUrl || frame.originalUrl;
|
|
if (bgUrl) composites.push({ imageUrl: bgUrl, opacity: 100, blendMode: "normal" });
|
|
const fgUrl = frame.regeneratedFgUrl || frame.foregroundUrl;
|
|
if (fgUrl) composites.push({ imageUrl: fgUrl, opacity: 100, blendMode: "normal", maskUrl: frame.maskUrl || undefined });
|
|
|
|
if (composites.length > 0) {
|
|
const resultUrl = await compositeLayers(composites, {
|
|
outputKey: `composited/seq_${input.sequenceId}_f${frame.frameIndex}_${Date.now()}.png`,
|
|
});
|
|
await db.updateFrame(frame.id, { compositedUrl: resultUrl });
|
|
done++;
|
|
}
|
|
} catch (err: any) {
|
|
console.warn(`[Compose] Frame ${frame.frameIndex} failed:`, err.message);
|
|
}
|
|
}
|
|
return { success: true, framesComposited: done, totalFrames: seqFrames.length };
|
|
}),
|
|
}),
|
|
|
|
// ============ EXPORT ============
|
|
export: router({
|
|
start: protectedProcedure
|
|
.input(z.object({
|
|
projectId: z.number(),
|
|
format: z.enum(["mp4", "webm", "gif"]).optional(),
|
|
includeAudio: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { exportVideo } = await import("./videoProcessor");
|
|
const project = await db.getProject(input.projectId);
|
|
if (!project) throw new Error("Project not found");
|
|
|
|
const frames = await db.listFrames(input.projectId);
|
|
const frameUrls = frames.map(f => f.compositedUrl || f.originalUrl).filter((u): u is string => u !== null);
|
|
const audioUrl = input.includeAudio !== false ? project.sourceAudioUrl : null;
|
|
|
|
const result = await exportVideo(
|
|
input.projectId,
|
|
frameUrls,
|
|
audioUrl,
|
|
project.fps || 24,
|
|
`project_${input.projectId}_export_${Date.now()}`
|
|
);
|
|
|
|
// Update project status
|
|
await db.updateProject(input.projectId, { status: "exported" as any });
|
|
|
|
return {
|
|
success: true,
|
|
videoUrl: result.videoUrl,
|
|
duration: result.duration,
|
|
frameCount: result.frameCount,
|
|
};
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|