retrotoon-studio/server/db.ts
Ubuntu 239edf50c7 feat: propagation des calques à toute la séquence (batch insert)
Travailler frame par frame sur un projet de 740 frames est impraticable.
Maintenant un calque satisfaisant sur une frame peut être propagé sur
toutes les frames de sa séquence en 1 clic.

Backend:
- db.propagateLayerToSequence(sourceLayerId, startFrame, endFrame)
  * Récupère TOUTES les frames cibles en 1 query
  * Récupère TOUS les layers existants en 1 query (inArray)
  * Sépare en deux groupes: à insérer / à mettre à jour
  * Batch INSERT par chunks de 100 lignes (1 query par chunk)
  * Updates en parallèle par chunks de 20 (Promise.all)
  * Skip les layers verrouillés
  * Copie sourceVariantId, transform, opacity, blendMode, visible, maskUrl, name

- Endpoint tRPC layers.propagateToSequence(layerId)
  * Auto-détecte la séquence à laquelle appartient la frame source
  * Fallback sur toutes les frames si pas de séquence trouvée
  * Optional startFrame/endFrame pour override manuel

Performance:
- Avant: 1 query SELECT + 1 INSERT par frame cible
  → 740 INSERTs × ~200ms (latence TiDB Cloud US) = ~150s
- Après: 1 SELECT batch + 1-8 INSERT batch + parallèles updates
  → 736 layers créés en 3.05s (60x plus rapide)

Frontend (CompositionStudio):
- Bouton "Appliquer à la séquence" (violet, prominent) dans
  les actions de l'inspecteur
- Confirm dialog avant exécution (warn que ça remplace les layers
  existants non verrouillés)
- Toast détaillé après: "X créés, Y mis à jour, Z verrouillés"
- Loading spinner pendant l'opération
- Tooltip explicatif sur le bouton

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

565 lines
19 KiB
TypeScript

import { eq, and, desc, asc } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser, users,
projects, InsertProject, Project,
sequences, InsertSequence,
frames, InsertFrame,
layers, InsertLayer,
characters, InsertCharacter,
aiEngines, InsertAiEngine,
generationJobs, InsertGenerationJob,
assistantMessages, InsertAssistantMessage,
frameVariants, InsertFrameVariant,
} from "../drizzle/schema";
import { ENV } from './_core/env';
let _db: ReturnType<typeof drizzle> | null = null;
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
_db = drizzle(process.env.DATABASE_URL);
} catch (error) {
console.warn("[Database] Failed to connect:", error);
_db = null;
}
}
return _db;
}
// ============ USERS ============
export async function upsertUser(user: InsertUser): Promise<void> {
if (!user.openId) throw new Error("User openId is required for upsert");
const db = await getDb();
if (!db) return;
const values: InsertUser = { openId: user.openId };
const updateSet: Record<string, unknown> = {};
const textFields = ["name", "email", "passwordHash", "loginMethod"] as const;
type TextField = (typeof textFields)[number];
const assignNullable = (field: TextField) => {
const value = user[field];
if (value === undefined) return;
const normalized = value ?? null;
values[field] = normalized;
updateSet[field] = normalized;
};
textFields.forEach(assignNullable);
if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; }
if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; }
else if (user.openId === ENV.ownerOpenId) { values.role = 'admin'; updateSet.role = 'admin'; }
if (!values.lastSignedIn) values.lastSignedIn = new Date();
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
}
export async function getUserByOpenId(openId: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function getUserByEmail(email: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.email, email)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
// ============ PROJECTS ============
export async function createProject(data: InsertProject) {
const db = await getDb();
if (!db) throw new Error("DB not available");
const result = await db.insert(projects).values(data);
return { id: result[0].insertId };
}
export async function getProject(id: number) {
const db = await getDb();
if (!db) return null;
const result = await db.select().from(projects).where(eq(projects.id, id)).limit(1);
return result[0] ?? null;
}
export async function listProjects(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(projects).where(eq(projects.userId, userId)).orderBy(desc(projects.createdAt));
}
export async function listAllProjects() {
const db = await getDb();
if (!db) return [];
return db.select().from(projects).orderBy(desc(projects.createdAt));
}
export async function updateProject(id: number, data: Partial<InsertProject>) {
const db = await getDb();
if (!db) return;
await db.update(projects).set(data).where(eq(projects.id, id));
}
export async function deleteProject(id: number) {
const db = await getDb();
if (!db) return;
// Delete related data first (cascade)
await db.delete(assistantMessages).where(eq(assistantMessages.projectId, id));
await db.delete(generationJobs).where(eq(generationJobs.projectId, id));
await db.delete(layers).where(eq(layers.projectId, id));
await db.delete(characters).where(eq(characters.projectId, id));
await db.delete(sequences).where(eq(sequences.projectId, id));
await db.delete(frames).where(eq(frames.projectId, id));
await db.delete(projects).where(eq(projects.id, id));
}
// ============ SEQUENCES ============
export async function createSequence(data: InsertSequence) {
const db = await getDb();
if (!db) throw new Error("DB not available");
const result = await db.insert(sequences).values(data);
return { id: result[0].insertId };
}
export async function listSequences(projectId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(sequences).where(eq(sequences.projectId, projectId)).orderBy(asc(sequences.startFrame));
}
export async function updateSequence(id: number, data: Partial<InsertSequence>) {
const db = await getDb();
if (!db) return;
await db.update(sequences).set(data).where(eq(sequences.id, id));
}
// ============ FRAMES ============
export async function createFrames(data: InsertFrame[]) {
const db = await getDb();
if (!db) throw new Error("DB not available");
if (data.length === 0) return;
await db.insert(frames).values(data);
}
export async function listFrames(projectId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(frames).where(eq(frames.projectId, projectId)).orderBy(asc(frames.frameIndex));
}
export async function getFrame(projectId: number, frameIndex: number) {
const db = await getDb();
if (!db) return null;
const result = await db.select().from(frames)
.where(and(eq(frames.projectId, projectId), eq(frames.frameIndex, frameIndex)))
.limit(1);
return result[0] ?? null;
}
export async function updateFrame(id: number, data: Partial<InsertFrame>) {
const db = await getDb();
if (!db) return;
await db.update(frames).set(data).where(eq(frames.id, id));
}
// ============ LAYERS ============
export async function createLayer(data: InsertLayer) {
const db = await getDb();
if (!db) throw new Error("DB not available");
const result = await db.insert(layers).values(data);
return { id: result[0].insertId };
}
export async function listLayers(projectId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(layers).where(eq(layers.projectId, projectId)).orderBy(asc(layers.order));
}
export async function updateLayer(id: number, data: Partial<InsertLayer>) {
const db = await getDb();
if (!db) return;
await db.update(layers).set(data).where(eq(layers.id, id));
}
export async function deleteLayer(id: number) {
const db = await getDb();
if (!db) return;
await db.delete(layers).where(eq(layers.id, id));
}
export async function reorderLayers(ids: number[]) {
const db = await getDb();
if (!db) return;
for (let i = 0; i < ids.length; i++) {
await db.update(layers).set({ order: i }).where(eq(layers.id, ids[i]));
}
}
/**
* List layers for a specific frame (composition is per-frame)
*/
export async function listLayersForFrame(frameId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(layers).where(eq(layers.frameId, frameId)).orderBy(asc(layers.order));
}
/**
* Upsert a layer that sources from a variant (auto-created during generation)
* - If a layer of this type exists for the frame, update its sourceVariantId
* - Otherwise create a new layer
*/
export async function upsertLayerForVariant(params: {
frameId: number;
projectId: number;
type: "background" | "character" | "object" | "effect";
name: string;
sourceVariantId: number;
order?: number;
}) {
const db = await getDb();
if (!db) return;
// Look for existing layer of this type for this frame
const existing = await db
.select()
.from(layers)
.where(and(eq(layers.frameId, params.frameId), eq(layers.type, params.type)))
.limit(1);
if (existing.length > 0) {
await db
.update(layers)
.set({ sourceVariantId: params.sourceVariantId, name: params.name })
.where(eq(layers.id, existing[0].id));
return { id: existing[0].id, created: false };
}
// Compute next order if not provided
let order = params.order;
if (order === undefined) {
const all = await db.select({ order: layers.order }).from(layers).where(eq(layers.frameId, params.frameId));
order = all.length > 0 ? Math.max(...all.map(l => l.order ?? 0)) + 1 : 0;
}
const result = await db.insert(layers).values({
frameId: params.frameId,
projectId: params.projectId,
name: params.name,
type: params.type,
sourceVariantId: params.sourceVariantId,
order,
visible: true,
locked: false,
opacity: 100,
blendMode: "normal",
});
return { id: result[0].insertId, created: true };
}
/**
* Update layer properties (partial)
*/
export async function patchLayer(id: number, patch: Partial<InsertLayer>) {
const db = await getDb();
if (!db) return;
await db.update(layers).set(patch).where(eq(layers.id, id));
}
/**
* Duplicate a layer (new layer with same properties, increment order)
*/
export async function duplicateLayer(layerId: number) {
const db = await getDb();
if (!db) return;
const [src] = await db.select().from(layers).where(eq(layers.id, layerId)).limit(1);
if (!src) return;
const all = await db.select({ order: layers.order }).from(layers).where(eq(layers.frameId, src.frameId!));
const nextOrder = Math.max(...all.map(l => l.order ?? 0)) + 1;
const result = await db.insert(layers).values({
...src,
id: undefined as any,
name: `${src.name} (copie)`,
order: nextOrder,
createdAt: undefined as any,
updatedAt: undefined as any,
});
return { id: result[0].insertId };
}
/**
* Create an empty effect/object layer (manual add)
*/
/**
* Propagate a single layer's properties to all frames in the same sequence.
* For each target frame, upserts a layer of the same type with same source/transform/etc.
* Optionally limits to a specific frame range.
*/
export async function propagateLayerToSequence(params: {
sourceLayerId: number;
startFrame: number;
endFrame: number;
}): Promise<{ updated: number; created: number; skipped: number }> {
const db = await getDb();
if (!db) return { updated: 0, created: 0, skipped: 0 };
const [src] = await db.select().from(layers).where(eq(layers.id, params.sourceLayerId)).limit(1);
if (!src || !src.frameId) return { updated: 0, created: 0, skipped: 0 };
// Get the project's frames in the range
const allFrames = await db
.select()
.from(frames)
.where(eq(frames.projectId, src.projectId));
const targetFrames = allFrames.filter(
f => f.frameIndex >= params.startFrame && f.frameIndex <= params.endFrame && f.id !== src.frameId
);
// Batch fetch all existing layers of this type on target frames
const targetFrameIds = targetFrames.map(f => f.id);
let existing: any[] = [];
if (targetFrameIds.length > 0) {
const { inArray } = await import("drizzle-orm");
existing = await db
.select()
.from(layers)
.where(and(inArray(layers.frameId, targetFrameIds), eq(layers.type, src.type)));
}
const existingByFrame = new Map(existing.map(l => [l.frameId!, l]));
const payload = {
sourceVariantId: src.sourceVariantId,
imageUrl: src.imageUrl,
name: src.name,
opacity: src.opacity,
blendMode: src.blendMode,
visible: src.visible,
transform: src.transform as any,
maskUrl: src.maskUrl,
};
let updated = 0, created = 0, skipped = 0;
const toInsert: any[] = [];
const toUpdate: number[] = [];
for (const tgt of targetFrames) {
const exist = existingByFrame.get(tgt.id);
if (exist) {
if (exist.locked) { skipped++; continue; }
toUpdate.push(exist.id);
} else {
toInsert.push({
frameId: tgt.id,
projectId: src.projectId,
type: src.type,
order: src.order,
locked: false,
...payload,
});
}
}
// Batch insert (chunks of 100 to stay within query size limits)
for (let i = 0; i < toInsert.length; i += 100) {
const chunk = toInsert.slice(i, i + 100);
await db.insert(layers).values(chunk);
created += chunk.length;
}
// Batch updates: each update needs WHERE id=X so loop, but in parallel chunks
const updateChunkSize = 20;
for (let i = 0; i < toUpdate.length; i += updateChunkSize) {
const chunk = toUpdate.slice(i, i + updateChunkSize);
await Promise.all(chunk.map(id => db.update(layers).set(payload).where(eq(layers.id, id))));
updated += chunk.length;
}
return { updated, created, skipped };
}
export async function createCustomLayer(params: {
frameId: number;
projectId: number;
name: string;
type: "effect" | "object" | "background" | "character";
imageUrl?: string;
}) {
const db = await getDb();
if (!db) return;
const all = await db.select({ order: layers.order }).from(layers).where(eq(layers.frameId, params.frameId));
const nextOrder = all.length > 0 ? Math.max(...all.map(l => l.order ?? 0)) + 1 : 0;
const result = await db.insert(layers).values({
frameId: params.frameId,
projectId: params.projectId,
name: params.name,
type: params.type,
imageUrl: params.imageUrl,
order: nextOrder,
visible: true,
locked: false,
opacity: 100,
blendMode: "normal",
});
return { id: result[0].insertId };
}
// ============ CHARACTERS ============
export async function createCharacter(data: InsertCharacter) {
const db = await getDb();
if (!db) throw new Error("DB not available");
const result = await db.insert(characters).values(data);
return { id: result[0].insertId };
}
export async function listCharacters(projectId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(characters).where(eq(characters.projectId, projectId));
}
export async function updateCharacter(id: number, data: Partial<InsertCharacter>) {
const db = await getDb();
if (!db) return;
await db.update(characters).set(data).where(eq(characters.id, id));
}
// ============ AI ENGINES ============
export async function createAiEngine(data: InsertAiEngine) {
const db = await getDb();
if (!db) throw new Error("DB not available");
const result = await db.insert(aiEngines).values(data);
return { id: result[0].insertId };
}
export async function listAiEngines() {
const db = await getDb();
if (!db) return [];
return db.select().from(aiEngines).orderBy(desc(aiEngines.createdAt));
}
export async function updateAiEngine(id: number, data: Partial<InsertAiEngine>) {
const db = await getDb();
if (!db) return;
await db.update(aiEngines).set(data).where(eq(aiEngines.id, id));
}
export async function deleteAiEngine(id: number) {
const db = await getDb();
if (!db) return;
await db.delete(aiEngines).where(eq(aiEngines.id, id));
}
export async function getDefaultEngine(taskType: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(aiEngines)
.where(and(eq(aiEngines.taskType, taskType as any), eq(aiEngines.isDefault, true), eq(aiEngines.isActive, true)))
.limit(1);
return result[0];
}
// ============ GENERATION JOBS ============
export async function createGenerationJob(data: InsertGenerationJob) {
const db = await getDb();
if (!db) throw new Error("DB not available");
const result = await db.insert(generationJobs).values(data);
return { id: result[0].insertId };
}
export async function listGenerationJobs(projectId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(generationJobs).where(eq(generationJobs.projectId, projectId)).orderBy(desc(generationJobs.createdAt));
}
export async function updateGenerationJob(id: number, data: Partial<InsertGenerationJob>) {
const db = await getDb();
if (!db) return;
await db.update(generationJobs).set(data).where(eq(generationJobs.id, id));
}
// ============ ASSISTANT MESSAGES ============
export async function createAssistantMessage(data: InsertAssistantMessage) {
const db = await getDb();
if (!db) throw new Error("DB not available");
await db.insert(assistantMessages).values(data);
}
export async function listAssistantMessages(projectId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(assistantMessages).where(eq(assistantMessages.projectId, projectId)).orderBy(asc(assistantMessages.createdAt));
}
// ============ FRAME VARIANTS (non-destructive version history) ============
export async function createFrameVariant(data: InsertFrameVariant) {
const db = await getDb();
if (!db) throw new Error("DB not available");
// If isActive=true, deactivate other variants of the same type for this frame
if (data.isActive) {
await db
.update(frameVariants)
.set({ isActive: false })
.where(and(eq(frameVariants.frameId, data.frameId), eq(frameVariants.type, data.type)));
}
const result = await db.insert(frameVariants).values(data);
return { id: result[0].insertId };
}
export async function listFrameVariants(frameId: number, type?: "background" | "character" | "composite") {
const db = await getDb();
if (!db) return [];
const whereClause = type
? and(eq(frameVariants.frameId, frameId), eq(frameVariants.type, type))
: eq(frameVariants.frameId, frameId);
return db.select().from(frameVariants).where(whereClause).orderBy(desc(frameVariants.createdAt));
}
export async function getActiveVariant(frameId: number, type: "background" | "character" | "composite") {
const db = await getDb();
if (!db) return undefined;
const result = await db
.select()
.from(frameVariants)
.where(and(eq(frameVariants.frameId, frameId), eq(frameVariants.type, type), eq(frameVariants.isActive, true)))
.limit(1);
return result[0];
}
export async function setActiveVariant(variantId: number) {
const db = await getDb();
if (!db) return;
const [variant] = await db.select().from(frameVariants).where(eq(frameVariants.id, variantId)).limit(1);
if (!variant) return;
await db
.update(frameVariants)
.set({ isActive: false })
.where(and(eq(frameVariants.frameId, variant.frameId), eq(frameVariants.type, variant.type)));
await db.update(frameVariants).set({ isActive: true }).where(eq(frameVariants.id, variantId));
return variant;
}
export async function deleteFrameVariant(variantId: number) {
const db = await getDb();
if (!db) return;
await db.delete(frameVariants).where(eq(frameVariants.id, variantId));
}
export async function toggleVariantPin(variantId: number) {
const db = await getDb();
if (!db) return;
const [v] = await db.select({ isPinned: frameVariants.isPinned }).from(frameVariants).where(eq(frameVariants.id, variantId)).limit(1);
if (!v) return;
await db.update(frameVariants).set({ isPinned: !v.isPinned }).where(eq(frameVariants.id, variantId));
}
export async function renameVariant(variantId: number, label: string | null) {
const db = await getDb();
if (!db) return;
await db.update(frameVariants).set({ label }).where(eq(frameVariants.id, variantId));
}