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.
This commit is contained in:
google-labs-jules[bot] committed 2026-02-17 04:59:16 +00:00
1 parent 044764fcf7
commit 14d23144c4
9 files changed
+194 -223

No files matched your search

+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+82
View File
@@ -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')
})
})
+11 -50
View File
@@ -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()
+48 -70
View File
@@ -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 = `
+20 -25
View File
@@ -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')
})
})
+11 -48
View File
@@ -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<PoseItem[]> {
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()
+10 -14
View File
@@ -63,22 +63,18 @@ export default function Expressions() {
<VibeCard
index={index}
name={expression.name}
subtitle={expression.type === 'builtin' ? 'SYSTEM' : 'CUSTOM'}
subtitle="CUSTOM"
image={expression.imageUrl}
color={expression.type === 'builtin' ? '#10b981' : '#f59e0b'}
color="#f59e0b"
onClick={() => {}}
actions={
expression.type === 'custom'
? [
{
name: 'Delete',
onClick: () => setDeleteId(expression.id),
variant: 'destructive',
icon: <Trash2 className="w-4 h-4" />,
},
]
: []
}
actions={[
{
name: 'Delete',
onClick: () => setDeleteId(expression.id),
variant: 'destructive',
icon: <Trash2 className="w-4 h-4" />,
},
]}
/>
</div>
))}
+10 -14
View File
@@ -63,22 +63,18 @@ export default function Poses() {
<VibeCard
index={index}
name={pose.name}
subtitle={pose.type === 'builtin' ? 'SYSTEM' : 'CUSTOM'}
subtitle="CUSTOM"
image={pose.imageUrl}
color={pose.type === 'builtin' ? '#3b82f6' : '#f59e0b'}
color="#f59e0b"
onClick={() => {}}
actions={
pose.type === 'custom'
? [
{
name: 'Delete',
onClick: () => setDeleteId(pose.id),
variant: 'destructive',
icon: <Trash2 className="w-4 h-4" />,
},
]
: []
}
actions={[
{
name: 'Delete',
onClick: () => setDeleteId(pose.id),
variant: 'destructive',
icon: <Trash2 className="w-4 h-4" />,
},
]}
/>
</div>
))}