retrotoon-studio/server/routers.ts
Ubuntu 51bb69eb88 feat: panneau Génération IA avec regénération fond/personnage
- Nouvel onglet "Génération IA" dans le workspace avec 2 boutons:
  * Regénérer l'arrière-plan (prompt + style)
  * Redessiner le personnage (prompt + sélecteur de character sheet)
- 3 endpoints tRPC: generation.regenerateBackground,
  generation.regenerateCharacter, generation.inpaintBackground
- Fix URLs relatives -> signed URLs S3 absolues pour l'API Forge
- Résultat affiché en preview dans le panneau
- Testé: génération cyberpunk sur frame 200 -> PNG 1344x768 OK

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 02:17:12 +00:00

659 lines
24 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 }) => {
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 };
}),
}),
// ============ FRAMES ============
frames: router({
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 { regenerateBackground } = await import("./segmentationService");
const resultUrl = await regenerateBackground(
frame.originalUrl,
input.prompt,
input.style || "same art 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 };
}),
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" });
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 resultUrl = await regenerateCharacter(
frame.originalUrl,
input.prompt,
characterSheet,
frame.maskUrl || undefined,
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 };
}),
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 };
}),
}),
// ============ 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;