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,
}),
}))
})
})

View File

@@ -57,12 +57,14 @@ describe('project character voice mutations', () => {
expect(requestJsonWithErrorMock).toHaveBeenCalledTimes(1)
expect(requestJsonWithErrorMock).toHaveBeenCalledWith(
'/api/novel-promotion/project-1/character-voice',
'/api/assets/character-1',
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
characterId: 'character-1',
scope: 'project',
kind: 'character',
projectId: 'project-1',
voiceType: 'qwen-designed',
voiceId: 'voice-1',
customVoiceUrl: 'https://example.com/audio.wav',

View File

@@ -77,6 +77,7 @@ function buildAssets(selectedIndex: number | null): ProjectAssetsData {
return {
characters: [buildCharacter(selectedIndex)],
locations: [] as Location[],
props: [],
}
}
@@ -85,6 +86,7 @@ function buildProject(selectedIndex: number | null): Project {
novelPromotionData: {
characters: [buildCharacter(selectedIndex)],
locations: [],
props: [],
},
} as unknown as Project
}

View File

@@ -4,12 +4,14 @@ import { buildProjectLocationGenerateImageBody } from '@/lib/query/mutations/loc
describe('buildProjectLocationGenerateImageBody', () => {
it('includes artStyle when generating a project location image', () => {
expect(buildProjectLocationGenerateImageBody({
projectId: 'project-1',
locationId: 'location-1',
count: 1,
artStyle: 'japanese-anime',
})).toEqual({
type: 'location',
id: 'location-1',
scope: 'project',
kind: 'location',
projectId: 'project-1',
imageIndex: undefined,
count: 1,
artStyle: 'japanese-anime',

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { getAllClipsAssets, parseClipAssets } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/clip-asset-utils'
describe('clip asset utils', () => {
it('parses prop names from clip JSON payloads', () => {
const parsed = parseClipAssets({
characters: '[{"name":"小雨","appearance":"初始形象"}]',
location: '天台',
props: '["青铜匕首","录音笔"]',
})
expect(Array.from(parsed.propNames)).toEqual(['青铜匕首', '录音笔'])
})
it('aggregates prop names across clips', () => {
const all = getAllClipsAssets([
{ props: '["青铜匕首"]' },
{ props: '["录音笔","红绳手链"]' },
])
expect(Array.from(all.allPropNames)).toEqual(['青铜匕首', '录音笔', '红绳手链'])
})
})

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
reuseStringArrayIfEqual,
reuseStringSetIfEqual,
} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/selection-sync'
describe('script view selection sync', () => {
it('reuses the previous array reference when ids are unchanged', () => {
const previous = ['char-1', 'char-2']
const next = ['char-1', 'char-2']
const result = reuseStringArrayIfEqual(previous, next)
expect(result).toBe(previous)
})
it('returns the next array when ids changed', () => {
const previous = ['char-1', 'char-2']
const next = ['char-1', 'prop-1']
const result = reuseStringArrayIfEqual(previous, next)
expect(result).toBe(next)
})
it('reuses the previous set reference when selected appearance keys are unchanged', () => {
const previous = new Set(['char-1::Base', 'char-2::Alt'])
const next = new Set(['char-2::Alt', 'char-1::Base'])
const result = reuseStringSetIfEqual(previous, next)
expect(result).toBe(previous)
})
it('returns the next set when selected appearance keys changed', () => {
const previous = new Set(['char-1::Base'])
const next = new Set(['char-1::Base', 'char-2::Alt'])
const result = reuseStringSetIfEqual(previous, next)
expect(result).toBe(next)
})
})

View File

@@ -21,6 +21,7 @@ const parseMock = vi.hoisted(() => ({
chunkContent: vi.fn(() => ['chunk-1', 'chunk-2']),
safeParseCharactersResponse: vi.fn(() => ({ new_characters: [] })),
safeParseLocationsResponse: vi.fn(() => ({ locations: [] })),
safeParsePropsResponse: vi.fn(() => ({ props: [] })),
}))
const persistMock = vi.hoisted(() => ({
@@ -30,12 +31,15 @@ const persistMock = vi.hoisted(() => ({
newCharacters: 0,
updatedCharacters: 0,
newLocations: 0,
newProps: 0,
skippedCharacters: 0,
skippedLocations: 0,
skippedProps: 0,
})),
persistAnalyzeGlobalChunk: vi.fn(async (args: { stats: { newCharacters: number; newLocations: number } }) => {
persistAnalyzeGlobalChunk: vi.fn(async (args: { stats: { newCharacters: number; newLocations: number; newProps: number } }) => {
args.stats.newCharacters += 1
args.stats.newLocations += 1
args.stats.newProps += 1
}),
}))
@@ -63,12 +67,14 @@ vi.mock('@/lib/workers/handlers/analyze-global-parse', () => ({
readText: (value: unknown) => (typeof value === 'string' ? value : ''),
safeParseCharactersResponse: parseMock.safeParseCharactersResponse,
safeParseLocationsResponse: parseMock.safeParseLocationsResponse,
safeParsePropsResponse: parseMock.safeParsePropsResponse,
}))
vi.mock('@/lib/workers/handlers/analyze-global-prompt', () => ({
loadAnalyzeGlobalPromptTemplates: vi.fn(() => ({ characterTemplate: 'c', locationTemplate: 'l' })),
loadAnalyzeGlobalPromptTemplates: vi.fn(() => ({ characterTemplate: 'c', locationTemplate: 'l', propTemplate: 'p' })),
buildAnalyzeGlobalPrompts: vi.fn(() => ({
characterPrompt: 'character prompt',
locationPrompt: 'location prompt',
propPrompt: 'prop prompt',
})),
}))
vi.mock('@/lib/workers/handlers/analyze-global-persist', () => ({
@@ -105,7 +111,7 @@ describe('worker analyze-global behavior', () => {
analysisModel: 'llm::analysis-1',
globalAssetText: '全局设定',
characters: [{ id: 'char-1', name: 'Hero', aliases: null, introduction: 'hero intro' }],
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'old town summary' }],
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'old town summary', assetKind: 'location' }],
episodes: [{ id: 'ep-1', name: '第一集', novelText: 'episode text' }],
})
})
@@ -136,10 +142,13 @@ describe('worker analyze-global behavior', () => {
newCharacters: 2,
updatedCharacters: 0,
newLocations: 2,
newProps: 2,
skippedCharacters: 0,
skippedLocations: 0,
skippedProps: 0,
totalCharacters: 1,
totalLocations: 1,
totalProps: 0,
},
})
})

View File

@@ -137,8 +137,10 @@ describe('worker analyze-novel behavior', () => {
success: true,
characters: [{ id: 'char-new-1' }],
locations: [{ id: 'loc-new-1' }],
props: [],
characterCount: 1,
locationCount: 1,
propCount: 0,
})
expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith(

View File

@@ -137,6 +137,7 @@ describe('worker clips-build behavior', () => {
summary: 'first clip',
location: 'Old Town',
characters: JSON.stringify(['Hero']),
props: null,
content: 'START one END',
},
select: { id: true },

View File

@@ -19,6 +19,9 @@ describe('story-to-script orchestrator retry', () => {
if (action === 'analyze_locations') {
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
}
if (action === 'analyze_props') {
return { text: JSON.stringify({ props: [] }), reasoning: '' }
}
if (action === 'split_clips') {
return {
text: JSON.stringify([
@@ -44,6 +47,7 @@ describe('story-to-script orchestrator retry', () => {
promptTemplates: {
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
locationPromptTemplate: '{input} {locations_lib_name}',
propPromptTemplate: '{input} {props_lib_name}',
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
},
@@ -78,6 +82,7 @@ describe('story-to-script orchestrator retry', () => {
promptTemplates: {
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
locationPromptTemplate: '{input} {locations_lib_name}',
propPromptTemplate: '{input} {props_lib_name}',
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
},
@@ -109,6 +114,7 @@ describe('story-to-script orchestrator retry', () => {
promptTemplates: {
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
locationPromptTemplate: '{input} {locations_lib_name}',
propPromptTemplate: '{input} {props_lib_name}',
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
},
@@ -133,6 +139,12 @@ describe('story-to-script orchestrator retry', () => {
reasoning: '',
}
}
if (action === 'analyze_props') {
return {
text: '{"props":[]}\n{"extra":"ignored"}',
reasoning: '',
}
}
if (action === 'split_clips') {
return {
text: '[{"start":"甲在门口","end":"乙回答","summary":"片段摘要","location":"地点A","characters":["甲"]}]\n{"extra":"ignored"}',
@@ -156,6 +168,7 @@ describe('story-to-script orchestrator retry', () => {
promptTemplates: {
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
locationPromptTemplate: '{input} {locations_lib_name}',
propPromptTemplate: '{input} {props_lib_name}',
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
},
@@ -182,6 +195,9 @@ describe('story-to-script orchestrator retry', () => {
if (action === 'analyze_locations') {
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
}
if (action === 'analyze_props') {
return { text: JSON.stringify({ props: [] }), reasoning: '' }
}
if (action === 'split_clips') {
return {
text: JSON.stringify([
@@ -213,6 +229,7 @@ describe('story-to-script orchestrator retry', () => {
promptTemplates: {
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
locationPromptTemplate: '{input} {locations_lib_name}',
propPromptTemplate: '{input} {props_lib_name}',
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
},
@@ -239,6 +256,9 @@ describe('story-to-script orchestrator retry', () => {
if (action === 'analyze_locations') {
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
}
if (action === 'analyze_props') {
return { text: JSON.stringify({ props: [] }), reasoning: '' }
}
if (action === 'split_clips') {
return {
text: JSON.stringify([
@@ -268,6 +288,7 @@ describe('story-to-script orchestrator retry', () => {
promptTemplates: {
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
locationPromptTemplate: '{input} {locations_lib_name}',
propPromptTemplate: '{input} {props_lib_name}',
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
},

View File

@@ -29,6 +29,7 @@ const orchestratorMock = vi.hoisted(() => ({
const helperMock = vi.hoisted(() => ({
persistAnalyzedCharacters: vi.fn(async () => [{ id: 'character-new-1' }]),
persistAnalyzedLocations: vi.fn(async () => [{ id: 'location-new-1' }]),
persistAnalyzedProps: vi.fn(async () => [{ id: 'prop-new-1' }]),
persistClips: vi.fn(async () => [{ clipKey: 'clip-1', id: 'clip-row-1' }]),
}))
const workflowLeaseMock = vi.hoisted(() => ({
@@ -68,8 +69,9 @@ vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: {
NP_AGENT_CHARACTER_PROFILE: 'a',
NP_SELECT_LOCATION: 'b',
NP_AGENT_CLIP: 'c',
NP_SCREENPLAY_CONVERSION: 'd',
NP_SELECT_PROP: 'c',
NP_AGENT_CLIP: 'd',
NP_SCREENPLAY_CONVERSION: 'e',
},
getPromptTemplate: vi.fn(() => 'prompt-template'),
}))
@@ -79,6 +81,7 @@ vi.mock('@/lib/workers/handlers/story-to-script-helpers', () => ({
parseTemperature: vi.fn(() => 0.7),
persistAnalyzedCharacters: helperMock.persistAnalyzedCharacters,
persistAnalyzedLocations: helperMock.persistAnalyzedLocations,
persistAnalyzedProps: helperMock.persistAnalyzedProps,
persistClips: helperMock.persistClips,
resolveClipRecordId: (clipIdMap: Map<string, string>, clipId: string) => clipIdMap.get(clipId) ?? null,
}))
@@ -128,7 +131,7 @@ describe('worker story-to-script behavior', () => {
id: 'np-project-1',
analysisModel: 'llm::analysis-1',
characters: [{ id: 'char-1', name: 'Hero', introduction: 'hero intro' }],
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'town' }],
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'town', assetKind: 'location' }],
})
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
@@ -140,7 +143,9 @@ describe('worker story-to-script behavior', () => {
orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValue({
analyzedCharacters: [{ name: 'New Hero' }],
analyzedLocations: [{ name: 'Market' }],
clipList: [{ clipId: 'clip-1', content: 'clip content' }],
analyzedProps: [{ name: 'Knife', summary: 'bronze dagger' }],
propsObject: { props: [{ name: 'Knife', summary: 'bronze dagger' }] },
clipList: [{ clipId: 'clip-1', content: 'clip content', props: ['Knife'] }],
screenplayResults: [
{
clipId: 'clip-1',
@@ -152,6 +157,7 @@ describe('worker story-to-script behavior', () => {
clipCount: 1,
screenplaySuccessCount: 1,
screenplayFailedCount: 0,
propCount: 1,
},
})
})
@@ -172,12 +178,13 @@ describe('worker story-to-script behavior', () => {
screenplayFailedCount: 0,
persistedCharacters: 1,
persistedLocations: 1,
persistedProps: 1,
persistedClips: 1,
})
expect(helperMock.persistClips).toHaveBeenCalledWith({
episodeId: 'episode-1',
clipList: [{ clipId: 'clip-1', content: 'clip content' }],
clipList: [{ clipId: 'clip-1', content: 'clip content', props: ['Knife'] }],
})
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
@@ -192,6 +199,8 @@ describe('worker story-to-script behavior', () => {
orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValueOnce({
analyzedCharacters: [],
analyzedLocations: [],
analyzedProps: [],
propsObject: { props: [] },
clipList: [],
screenplayResults: [
{