- 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>
659 lines
24 KiB
TypeScript
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;
|