From 14d23144c4725fd99080de3cca3833e793b3ad03 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 04:59:16 +0000 Subject: [PATCH] Refactor poses and expressions to be fully database-driven - Removed hardcoded `BUILTIN_POSES` and `BUILTIN_EXPRESSIONS` constants. - Updated `PoseService` and `ExpressionService` to only list and delete items from the database. - Removed the `type` field ('builtin' | 'custom') from `PoseItem` and `ExpressionItem`. - Updated `GenerationService` to resolve pose and expression assets strictly from the database (`data/files`) instead of falling back to `public/`. - Updated `Poses.tsx` and `Expressions.tsx` to remove "SYSTEM" vs "CUSTOM" UI distinction and enable deletion for all items. - Updated `pose.test.ts` and created `expression.test.ts` to verify the new behavior. --- bun.lock | 2 +- package.json | 2 +- src/service/expression.test.ts | 82 +++++++++++++++++++++++ src/service/expression.ts | 61 +++-------------- src/service/generation.ts | 118 ++++++++++++++------------------- src/service/pose.test.ts | 45 ++++++------- src/service/pose.ts | 59 +++-------------- src/ui/pages/Expressions.tsx | 24 +++---- src/ui/pages/Poses.tsx | 24 +++---- 9 files changed, 194 insertions(+), 223 deletions(-) create mode 100644 src/service/expression.test.ts diff --git a/bun.lock b/bun.lock index 510fab8..0c98846 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "@google/genai": "^1.40.0", "@hono/zod-validator": "^0.7.6", "@libsql/client": "^0.17.0", - "@prisma/adapter-libsql": "^7.3.0", + "@prisma/adapter-libsql": "7.3.0", "@prisma/client": "^7.3.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", diff --git a/package.json b/package.json index 902f84e..39e3d17 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@google/genai": "^1.40.0", "@hono/zod-validator": "^0.7.6", "@libsql/client": "^0.17.0", - "@prisma/adapter-libsql": "^7.3.0", + "@prisma/adapter-libsql": "7.3.0", "@prisma/client": "^7.3.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", diff --git a/src/service/expression.test.ts b/src/service/expression.test.ts new file mode 100644 index 0000000..e3df25a --- /dev/null +++ b/src/service/expression.test.ts @@ -0,0 +1,82 @@ +import { expect, test, describe, mock, beforeEach } from 'bun:test' +import { prisma } from '../db' +import { assetService } from './asset' +import { ExpressionService } from './expression' + +// Mock prisma +mock.module('../db', () => ({ + prisma: { + expression: { + findMany: mock(), + create: mock(), + findUnique: mock(), + delete: mock(), + }, + }, +})) + +// Mock assetService +mock.module('./asset', () => ({ + assetService: { + createAsset: mock(), + deleteAsset: mock(), + }, +})) + +describe('ExpressionService', () => { + let expressionService: ExpressionService + + beforeEach(() => { + expressionService = new ExpressionService() + }) + + test('listExpressions returns custom expressions', async () => { + const customExpressions = [ + { id: 'custom1', name: 'Custom 1', image: { path: 'path1' } }, + { id: 'custom2', name: 'Custom 2', image: { path: 'path2' } }, + ] + ;(prisma.expression.findMany as any).mockResolvedValue(customExpressions) + + const expressions = await expressionService.listExpressions() + + expect(expressions.length).toBe(2) + expect(expressions.find((e) => e.id === 'custom1')).toBeDefined() + expect(expressions.find((e) => e.id === 'custom2')).toBeDefined() + }) + + test('createExpression creates asset and expression', async () => { + const file = new File([''], 'test.png', { type: 'image/png' }) + const assetId = 'asset1' + + ;(assetService.createAsset as any).mockResolvedValue({ id: assetId }) + ;(prisma.expression.create as any).mockResolvedValue({ + id: 'expr1', + name: 'Test Expression', + image: { path: 'path1' }, + }) + + const result = await expressionService.createExpression('Test Expression', file) + + // @ts-ignore + expect(assetService.createAsset).toHaveBeenCalledWith(file, { + name: 'Test Expression', + type: 'image/png', + }) + // @ts-ignore + expect(prisma.expression.create).toHaveBeenCalled() + expect(result.id).toBe('expr1') + }) + + test('deleteExpression deletes expression', async () => { + const customId = 'custom1' + ;(prisma.expression.findUnique as any).mockResolvedValue({ id: customId, imageId: 'asset1' }) + ;(prisma.expression.delete as any).mockResolvedValue({ id: customId }) + + await expressionService.deleteExpression(customId) + + // @ts-ignore + expect(prisma.expression.delete).toHaveBeenCalledWith({ where: { id: customId } }) + // @ts-ignore + expect(assetService.deleteAsset).toHaveBeenCalledWith('asset1') + }) +}) diff --git a/src/service/expression.ts b/src/service/expression.ts index b487a9b..1f29b76 100644 --- a/src/service/expression.ts +++ b/src/service/expression.ts @@ -4,19 +4,9 @@ import { assetService } from './asset' export interface ExpressionItem { id: string name: string - type: 'builtin' | 'custom' imageUrl: string } -export const BUILTIN_EXPRESSIONS: ExpressionItem[] = Array.from({ length: 9 }, (_, i) => i + 1).map( - (i) => ({ - id: `expression_${i}.webp`, - name: `Expression ${i}`, - type: 'builtin', - imageUrl: `/expressions/expression_${i}.webp`, - }), -) - export class ExpressionService { async listExpressions( options: { page?: number; limit?: number } = {}, @@ -24,38 +14,18 @@ export class ExpressionService { const { page = 1, limit = 20 } = options const skip = (page - 1) * limit - // Logic for mixed pagination - const builtinCount = BUILTIN_EXPRESSIONS.length + const customExpressions = await prisma.expression.findMany({ + include: { image: true }, + orderBy: { id: 'desc' }, + skip: skip, + take: limit, + }) - // Calculate how many builtin items to include - const builtinStart = skip - const builtinEnd = Math.min(builtinCount, skip + limit) - const builtinSlice = - builtinStart < builtinCount ? BUILTIN_EXPRESSIONS.slice(builtinStart, builtinEnd) : [] - - // Calculate how many db items to fetch - const remainingLimit = limit - builtinSlice.length - const dbSkip = Math.max(0, skip - builtinCount) - - let formattedCustomExpressions: ExpressionItem[] = [] - - if (remainingLimit > 0) { - const customExpressions = await prisma.expression.findMany({ - include: { image: true }, - orderBy: { id: 'desc' }, - skip: dbSkip, - take: remainingLimit, - }) - - formattedCustomExpressions = customExpressions.map((e) => ({ - id: e.id, - name: e.name, - type: 'custom', - imageUrl: `/files/${e.image.path}`, - })) - } - - return [...builtinSlice, ...formattedCustomExpressions] + return customExpressions.map((e) => ({ + id: e.id, + name: e.name, + imageUrl: `/files/${e.image.path}`, + })) } async createExpression(name: string, file: File) { @@ -75,16 +45,11 @@ export class ExpressionService { return { id: expression.id, name: expression.name, - type: 'custom', imageUrl: `/files/${expression.image.path}`, } as ExpressionItem } async deleteExpression(id: string) { - if (BUILTIN_EXPRESSIONS.some((e) => e.id === id)) { - throw new Error('Cannot delete builtin expression') - } - const expression = await prisma.expression.findUnique({ where: { id }, }) @@ -100,10 +65,6 @@ export class ExpressionService { // Delete image asset await assetService.deleteAsset(expression.imageId) } - - getBuiltinExpression(id: string) { - return BUILTIN_EXPRESSIONS.find((e) => e.id === id) - } } export const expressionService = new ExpressionService() diff --git a/src/service/generation.ts b/src/service/generation.ts index 2c59b72..33798aa 100644 --- a/src/service/generation.ts +++ b/src/service/generation.ts @@ -3,8 +3,6 @@ import { prisma } from '../db' import { Prisma } from '../generated/prisma/client' import { aiService, type AIPart } from './ai' import { assetService } from './asset' -import { expressionService } from './expression' -import { poseService } from './pose' export class GenerationService { async createGeneration( @@ -75,84 +73,64 @@ category: ${eq.category} let poseInstruction = '6. Maintain the exact pose and angle of the Base Character.' if (poseId) { - let poseBuffer: ArrayBuffer - let poseMimeType: string - - const builtinPose = poseService.getBuiltinPose(poseId) - if (builtinPose) { - const posePath = join('public/poses', poseId) - const file = Bun.file(posePath) - poseBuffer = await file.arrayBuffer() - poseMimeType = file.type || 'image/webp' - } else { - const customPose = await prisma.pose.findUnique({ - where: { id: poseId }, - include: { image: true }, - }) - - if (!customPose) { - throw new Error(`Pose not found: ${poseId}`) - } - - const posePath = join('data/files', customPose.image.path) - const file = Bun.file(posePath) - poseBuffer = await file.arrayBuffer() - poseMimeType = customPose.image.type - } - - const poseBase64 = Buffer.from(poseBuffer).toString('base64') - parts.push({ - inlineData: { - mimeType: poseMimeType, - data: poseBase64, - }, + const pose = await prisma.pose.findUnique({ + where: { id: poseId }, + include: { image: true }, }) - parts.push({ text: 'Target Pose Reference' }) - poseInstruction = - "6. Use the 'Target Pose Reference' image for the character's pose and angle." + if (pose) { + const posePath = join('data/files', pose.image.path) + const file = Bun.file(posePath) + const poseBuffer = await file.arrayBuffer() + const poseBase64 = Buffer.from(poseBuffer).toString('base64') + + parts.push({ + inlineData: { + mimeType: pose.image.type, + data: poseBase64, + }, + }) + parts.push({ text: 'Target Pose Reference' }) + + poseInstruction = + "6. Use the 'Target Pose Reference' image for the character's pose and angle." + } else { + // Fallback for transition: if user hasn't migrated example data yet, poseId might be missing. + // We log a warning but proceed without the pose, or we could throw. + // Given strict requirement, throwing might be better, but let's be safe and just ignore if not found? + // Actually, if the user explicitly selected a pose, they expect it to work. Throwing is better. + throw new Error(`Pose not found: ${poseId}`) + } } // Handle Expression let expressionInstruction = '7. Maintain the facial expression of the Base Character.' if (expressionId) { - let expressionBuffer: ArrayBuffer - let expressionMimeType: string - - const builtinExpression = expressionService.getBuiltinExpression(expressionId) - if (builtinExpression) { - const expressionPath = join('public/expressions', expressionId) - const file = Bun.file(expressionPath) - expressionBuffer = await file.arrayBuffer() - expressionMimeType = file.type || 'image/webp' - } else { - const customExpression = await prisma.expression.findUnique({ - where: { id: expressionId }, - include: { image: true }, - }) - - if (!customExpression) { - throw new Error(`Expression not found: ${expressionId}`) - } - - const expressionPath = join('data/files', customExpression.image.path) - const file = Bun.file(expressionPath) - expressionBuffer = await file.arrayBuffer() - expressionMimeType = customExpression.image.type - } - - const expressionBase64 = Buffer.from(expressionBuffer).toString('base64') - parts.push({ - inlineData: { - mimeType: expressionMimeType, - data: expressionBase64, - }, + const expression = await prisma.expression.findUnique({ + where: { id: expressionId }, + include: { image: true }, }) - parts.push({ text: 'Target Expression Reference' }) - expressionInstruction = - "7. Use the 'Target Expression Reference' image for the character's facial expression." + if (expression) { + const expressionPath = join('data/files', expression.image.path) + const file = Bun.file(expressionPath) + const expressionBuffer = await file.arrayBuffer() + const expressionBase64 = Buffer.from(expressionBuffer).toString('base64') + + parts.push({ + inlineData: { + mimeType: expression.image.type, + data: expressionBase64, + }, + }) + parts.push({ text: 'Target Expression Reference' }) + + expressionInstruction = + "7. Use the 'Target Expression Reference' image for the character's facial expression." + } else { + throw new Error(`Expression not found: ${expressionId}`) + } } let prompt = ` diff --git a/src/service/pose.test.ts b/src/service/pose.test.ts index 6d431a1..21d86ee 100644 --- a/src/service/pose.test.ts +++ b/src/service/pose.test.ts @@ -1,6 +1,7 @@ -import { expect, test, describe, mock } from 'bun:test' +import { expect, test, describe, mock, beforeEach } from 'bun:test' import { prisma } from '../db' import { assetService } from './asset' +import { PoseService } from './pose' // Mock prisma mock.module('../db', () => ({ @@ -23,26 +24,27 @@ mock.module('./asset', () => ({ })) describe('PoseService', () => { - test('listPoses returns builtin + custom poses', async () => { - // @ts-ignore - const { poseService, BUILTIN_POSES } = await import(`./pose?v=${Date.now()}`) + let poseService: PoseService - // Setup custom poses mock - const customPoses = [{ id: 'custom1', name: 'Custom 1', image: { path: 'path1' } }] + beforeEach(() => { + poseService = new PoseService() + }) + + test('listPoses returns custom poses', async () => { + const customPoses = [ + { id: 'custom1', name: 'Custom 1', image: { path: 'path1' } }, + { id: 'custom2', name: 'Custom 2', image: { path: 'path2' } }, + ] ;(prisma.pose.findMany as any).mockResolvedValue(customPoses) const poses = await poseService.listPoses() - expect(poses.length).toBe(BUILTIN_POSES.length + 1) - expect(poses.find((p: any) => p.id === 'custom1')).toBeDefined() - expect(poses.find((p: any) => p.type === 'custom')).toBeDefined() - expect(poses.find((p: any) => p.type === 'builtin')).toBeDefined() + expect(poses.length).toBe(2) + expect(poses.find((p) => p.id === 'custom1')).toBeDefined() + expect(poses.find((p) => p.id === 'custom2')).toBeDefined() }) test('createPose creates asset and pose', async () => { - // @ts-ignore - const { poseService } = await import(`./pose?v=${Date.now()}`) - const file = new File([''], 'test.png', { type: 'image/png' }) const assetId = 'asset1' @@ -55,33 +57,26 @@ describe('PoseService', () => { const result = await poseService.createPose('Test Pose', file) + // @ts-ignore expect(assetService.createAsset).toHaveBeenCalledWith(file, { name: 'Test Pose', type: 'image/png', }) + // @ts-ignore expect(prisma.pose.create).toHaveBeenCalled() expect(result.id).toBe('pose1') }) - test('deletePose throws for builtin pose', async () => { - // @ts-ignore - const { poseService, BUILTIN_POSES } = await import(`./pose?v=${Date.now()}`) - - const builtinId = BUILTIN_POSES[0].id - expect(poseService.deletePose(builtinId)).rejects.toThrow('Cannot delete builtin pose') - }) - - test('deletePose deletes custom pose', async () => { - // @ts-ignore - const { poseService } = await import(`./pose?v=${Date.now()}`) - + test('deletePose deletes pose', async () => { const customId = 'custom1' ;(prisma.pose.findUnique as any).mockResolvedValue({ id: customId, imageId: 'asset1' }) ;(prisma.pose.delete as any).mockResolvedValue({ id: customId }) await poseService.deletePose(customId) + // @ts-ignore expect(prisma.pose.delete).toHaveBeenCalledWith({ where: { id: customId } }) + // @ts-ignore expect(assetService.deleteAsset).toHaveBeenCalledWith('asset1') }) }) diff --git a/src/service/pose.ts b/src/service/pose.ts index 1ecd283..a53ca38 100644 --- a/src/service/pose.ts +++ b/src/service/pose.ts @@ -4,54 +4,26 @@ import { assetService } from './asset' export interface PoseItem { id: string name: string - type: 'builtin' | 'custom' imageUrl: string } -export const BUILTIN_POSES: PoseItem[] = Array.from({ length: 9 }, (_, i) => i + 1).map((i) => ({ - id: `character_pose_${i}.webp`, - name: `Pose ${i}`, - type: 'builtin', - imageUrl: `/poses/character_pose_${i}.webp`, -})) - export class PoseService { async listPoses(options: { page?: number; limit?: number } = {}): Promise { const { page = 1, limit = 20 } = options const skip = (page - 1) * limit - // Logic for mixed pagination - const builtinCount = BUILTIN_POSES.length + const customPoses = await prisma.pose.findMany({ + include: { image: true }, + orderBy: { id: 'desc' }, + skip: skip, + take: limit, + }) - // Calculate how many builtin items to include - const builtinStart = skip - const builtinEnd = Math.min(builtinCount, skip + limit) - const builtinSlice = - builtinStart < builtinCount ? BUILTIN_POSES.slice(builtinStart, builtinEnd) : [] - - // Calculate how many db items to fetch - const remainingLimit = limit - builtinSlice.length - const dbSkip = Math.max(0, skip - builtinCount) - - let formattedCustomPoses: PoseItem[] = [] - - if (remainingLimit > 0) { - const customPoses = await prisma.pose.findMany({ - include: { image: true }, - orderBy: { id: 'desc' }, - skip: dbSkip, - take: remainingLimit, - }) - - formattedCustomPoses = customPoses.map((p) => ({ - id: p.id, - name: p.name, - type: 'custom', - imageUrl: `/files/${p.image.path}`, - })) - } - - return [...builtinSlice, ...formattedCustomPoses] + return customPoses.map((p) => ({ + id: p.id, + name: p.name, + imageUrl: `/files/${p.image.path}`, + })) } async createPose(name: string, file: File) { @@ -71,16 +43,11 @@ export class PoseService { return { id: pose.id, name: pose.name, - type: 'custom', imageUrl: `/files/${pose.image.path}`, } as PoseItem } async deletePose(id: string) { - if (BUILTIN_POSES.some((p) => p.id === id)) { - throw new Error('Cannot delete builtin pose') - } - const pose = await prisma.pose.findUnique({ where: { id }, }) @@ -96,10 +63,6 @@ export class PoseService { // Delete image asset await assetService.deleteAsset(pose.imageId) } - - getBuiltinPose(id: string) { - return BUILTIN_POSES.find((p) => p.id === id) - } } export const poseService = new PoseService() diff --git a/src/ui/pages/Expressions.tsx b/src/ui/pages/Expressions.tsx index 8cd078f..b7304da 100644 --- a/src/ui/pages/Expressions.tsx +++ b/src/ui/pages/Expressions.tsx @@ -63,22 +63,18 @@ export default function Expressions() { {}} - actions={ - expression.type === 'custom' - ? [ - { - name: 'Delete', - onClick: () => setDeleteId(expression.id), - variant: 'destructive', - icon: , - }, - ] - : [] - } + actions={[ + { + name: 'Delete', + onClick: () => setDeleteId(expression.id), + variant: 'destructive', + icon: , + }, + ]} /> ))} diff --git a/src/ui/pages/Poses.tsx b/src/ui/pages/Poses.tsx index 6e1e92d..77c2ef0 100644 --- a/src/ui/pages/Poses.tsx +++ b/src/ui/pages/Poses.tsx @@ -63,22 +63,18 @@ export default function Poses() { {}} - actions={ - pose.type === 'custom' - ? [ - { - name: 'Delete', - onClick: () => setDeleteId(pose.id), - variant: 'destructive', - icon: , - }, - ] - : [] - } + actions={[ + { + name: 'Delete', + onClick: () => setDeleteId(pose.id), + variant: 'destructive', + icon: , + }, + ]} /> ))}