retrotoon-studio/server/routers.ts
Ubuntu c07ee892e7 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>
2026-05-21 12:09:57 +00:00

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;