feat: add props system and refactor asset library architecture

This commit is contained in:
saturn
2026-03-19 15:37:47 +08:00
parent 9aff44e37a
commit f364bbc9e4
139 changed files with 9112 additions and 2827 deletions

View File

@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
$queryRaw: vi.fn(),
$executeRaw: vi.fn(),
$transaction: vi.fn(),
}))
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
describe('location-backed assets service', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.$queryRaw
.mockResolvedValueOnce([
{
id: 'location-1',
novelPromotionProjectId: 'novel-project-1',
name: 'Bronze Dagger',
summary: 'Old bronze dagger',
selectedImageId: null,
sourceGlobalLocationId: null,
assetKind: 'prop',
},
])
.mockResolvedValueOnce([])
})
it('queries project location-backed assets with real schema column names', async () => {
const mod = await import('@/lib/assets/services/location-backed-assets')
await mod.listProjectLocationBackedAssets('novel-project-1', 'prop')
const assetQuery = prismaMock.$queryRaw.mock.calls[0]?.[0] as { strings?: ReadonlyArray<string>; sql?: string }
const imageQuery = prismaMock.$queryRaw.mock.calls[1]?.[0] as { strings?: ReadonlyArray<string>; sql?: string }
const assetSql = assetQuery.strings?.join(' ') ?? assetQuery.sql ?? ''
const imageSql = imageQuery.strings?.join(' ') ?? imageQuery.sql ?? ''
expect(assetSql).toContain('FROM novel_promotion_locations')
expect(assetSql).toContain('novelPromotionProjectId')
expect(assetSql).not.toContain('projectId')
expect(imageSql).toContain('FROM location_images')
expect(imageSql).toContain('NULL AS previousImageMediaId')
})
})

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest'
import { mapGlobalVoiceToAsset, mapProjectCharacterToAsset, mapProjectPropToAsset } from '@/lib/assets/mappers'
import { groupAssetsByKind } from '@/lib/assets/grouping'
describe('asset mappers', () => {
it('maps project characters into the unified character asset contract', () => {
const asset = mapProjectCharacterToAsset({
id: 'character-1',
name: '林夏',
introduction: '主角',
voiceType: 'custom',
voiceId: 'voice-1',
customVoiceUrl: 'https://example.com/voice.mp3',
media: null,
profileConfirmed: true,
appearances: [
{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: '初始形象',
description: '短发,风衣',
imageUrl: 'https://example.com/char.jpg',
media: null,
imageUrls: ['https://example.com/char.jpg'],
imageMedias: [],
selectedIndex: 0,
previousImageUrl: null,
previousMedia: null,
previousImageUrls: [],
previousImageMedias: [],
},
],
})
expect(asset).toEqual(expect.objectContaining({
id: 'character-1',
scope: 'project',
kind: 'character',
introduction: '主角',
profileConfirmed: true,
voice: expect.objectContaining({
voiceType: 'custom',
voiceId: 'voice-1',
}),
}))
expect(asset.variants[0]).toEqual(expect.objectContaining({
id: 'appearance-1',
index: 0,
label: '初始形象',
}))
})
it('maps global voices into the unified audio asset contract', () => {
const asset = mapGlobalVoiceToAsset({
id: 'voice-1',
name: '旁白',
description: '低沉稳重',
voiceId: 'voice-provider-1',
voiceType: 'designed',
customVoiceUrl: 'https://example.com/voice.mp3',
media: null,
voicePrompt: '低沉稳重',
gender: 'male',
language: 'zh',
folderId: 'folder-1',
})
expect(asset).toEqual(expect.objectContaining({
id: 'voice-1',
scope: 'global',
kind: 'voice',
voiceMeta: expect.objectContaining({
voiceType: 'designed',
gender: 'male',
language: 'zh',
}),
}))
})
it('maps project props into the unified visual asset contract and groups them by kind', () => {
const propAsset = mapProjectPropToAsset({
id: 'prop-1',
name: '青铜匕首',
summary: '古旧短刃,雕纹手柄',
images: [
{
id: 'prop-image-1',
imageIndex: 0,
description: '古旧短刃,雕纹手柄',
imageUrl: 'https://example.com/prop.jpg',
media: null,
previousImageUrl: null,
previousMedia: null,
isSelected: true,
},
],
})
const voiceAsset = mapGlobalVoiceToAsset({
id: 'voice-1',
name: '旁白',
description: '低沉稳重',
voiceId: 'voice-provider-1',
voiceType: 'designed',
customVoiceUrl: 'https://example.com/voice.mp3',
media: null,
voicePrompt: '低沉稳重',
gender: 'male',
language: 'zh',
folderId: 'folder-1',
})
expect(propAsset).toEqual(expect.objectContaining({
id: 'prop-1',
scope: 'project',
kind: 'prop',
summary: '古旧短刃,雕纹手柄',
selectedVariantId: 'prop-image-1',
}))
expect(propAsset.variants[0]).toEqual(expect.objectContaining({
id: 'prop-image-1',
index: 0,
description: '古旧短刃,雕纹手柄',
}))
const groups = groupAssetsByKind([propAsset, voiceAsset])
expect(groups.prop.map((asset) => asset.id)).toEqual(['prop-1'])
expect(groups.voice.map((asset) => asset.id)).toEqual(['voice-1'])
})
})

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { buildPromptAssetContext, compileAssetPromptFragments } from '@/lib/assets/services/asset-prompt-context'
describe('asset prompt context', () => {
it('compiles subject, environment, and prop prompt fragments from the centralized asset context', () => {
const context = buildPromptAssetContext({
characters: [
{
name: '小雨/雨',
appearances: [
{
changeReason: '初始形象',
descriptions: ['黑色短发,校服,冷静表情'],
selectedIndex: 0,
description: 'fallback description',
},
],
},
],
locations: [
{
name: '天台',
images: [
{
isSelected: true,
description: '夜晚天台,冷风,霓虹远景',
},
],
},
],
props: [
{
name: '青铜匕首',
summary: '古旧短刃,雕纹手柄',
},
],
clipCharacters: [{ name: '雨' }],
clipLocation: '天台',
clipProps: ['青铜匕首'],
})
expect(compileAssetPromptFragments(context)).toEqual({
appearanceListText: '小雨/雨: ["初始形象"]',
fullDescriptionText: '【小雨/雨 - 初始形象】黑色短发,校服,冷静表情',
locationDescriptionText: '夜晚天台,冷风,霓虹远景',
propsDescriptionText: '【青铜匕首】古旧短刃,雕纹手柄',
charactersIntroductionText: '暂无角色介绍',
})
})
})

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { assetKindRegistry, getAssetKindRegistration } from '@/lib/assets/kinds/registry'
describe('asset kind registry', () => {
it('declares the supported asset kinds with stable capability contracts', () => {
expect(Object.keys(assetKindRegistry)).toEqual(['character', 'location', 'prop', 'voice'])
expect(getAssetKindRegistration('character')).toEqual(expect.objectContaining({
kind: 'character',
family: 'visual',
supportsMultipleVariants: true,
supportsVoiceBinding: true,
capabilities: expect.objectContaining({
canGenerate: true,
canBindVoice: true,
}),
}))
expect(getAssetKindRegistration('location')).toEqual(expect.objectContaining({
kind: 'location',
family: 'visual',
supportsMultipleVariants: true,
supportsVoiceBinding: false,
}))
expect(getAssetKindRegistration('prop')).toEqual(expect.objectContaining({
kind: 'prop',
family: 'visual',
supportsMultipleVariants: true,
supportsVoiceBinding: false,
capabilities: expect.objectContaining({
canGenerate: true,
canSelectRender: true,
canCopyFromGlobal: true,
}),
}))
expect(getAssetKindRegistration('voice')).toEqual(expect.objectContaining({
kind: 'voice',
family: 'audio',
supportsMultipleVariants: false,
capabilities: expect.objectContaining({
canGenerate: false,
canSelectRender: false,
}),
}))
})
})