feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions

This commit is contained in:
saturn
2026-04-02 17:39:16 +08:00
parent c3e74c228a
commit 9703714b69
153 changed files with 4472 additions and 1088 deletions

View File

@@ -185,6 +185,7 @@ describe('worker analyze-novel behavior', () => {
locationId: 'loc-new-1',
imageIndex: 0,
description: '雨夜街道',
availableSlots: '[]',
},
],
})
@@ -194,6 +195,7 @@ describe('worker analyze-novel behavior', () => {
locationId: 'prop-new-1',
imageIndex: 0,
description: '一根两头包裹金片的黑铁长棍',
availableSlots: '[]',
},
],
})

View File

@@ -78,7 +78,10 @@ describe('worker asset-hub-ai-design behavior', () => {
projectId: 'global-asset-hub',
skipBilling: true,
}))
expect(result).toEqual({ prompt: 'generated prompt' })
expect(result).toEqual({
prompt: 'generated prompt',
availableSlots: [],
})
})
it('location type success -> passes location assetType', async () => {

View File

@@ -124,6 +124,7 @@ describe('worker asset-hub-ai-modify behavior', () => {
expect(result).toEqual({
success: true,
modifiedDescription: 'modified description',
availableSlots: [],
})
expect(llmStreamMock.flush).toHaveBeenCalled()
})
@@ -140,6 +141,7 @@ describe('worker asset-hub-ai-modify behavior', () => {
expect(result).toEqual({
success: true,
modifiedDescription: 'modified description',
availableSlots: [],
})
})
})

View File

@@ -63,6 +63,9 @@ describe('worker location-image-task-handler behavior', () => {
locationId: 'location-1',
imageIndex: 0,
description: '雨夜街道',
availableSlots: JSON.stringify([
'街道左侧靠墙的留白位置',
]),
location: { name: 'Old Town' },
})
@@ -75,6 +78,9 @@ describe('worker location-image-task-handler behavior', () => {
locationId: 'location-1',
imageIndex: 0,
description: '雨夜街道',
availableSlots: JSON.stringify([
'街道左侧靠墙的留白位置',
]),
},
],
})
@@ -96,12 +102,27 @@ describe('worker location-image-task-handler behavior', () => {
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: `雨夜街道,${animeStylePrompt}`,
prompt: expect.stringContaining('雨夜街道'),
label: 'Old Town',
targetId: 'location-image-1',
options: expect.objectContaining({ aspectRatio: '1:1' }),
}),
)
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('可站位置:'),
}),
)
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('街道左侧靠墙的留白位置'),
}),
)
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('必须使用宽广完整的场景全景构图'),
}),
)
const generationCall = sharedMock.generateLabeledImageToCos.mock.calls[0] as unknown as [{ prompt: string }] | undefined
expect(generationCall).toBeTruthy()
if (!generationCall) throw new Error('expected generateLabeledImageToCos call')
@@ -119,7 +140,7 @@ describe('worker location-image-task-handler behavior', () => {
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: `雨夜街道,${getArtStylePrompt('realistic', 'zh')}`,
prompt: expect.stringContaining(getArtStylePrompt('realistic', 'zh')),
}),
)
})

View File

@@ -21,7 +21,20 @@ const sharedMock = vi.hoisted(() => ({
resolveNovelData: vi.fn(async () => ({
videoRatio: '16:9',
characters: [],
locations: [],
locations: [
{
name: 'Old Town',
images: [
{
isSelected: true,
description: '雨夜街道',
availableSlots: JSON.stringify([
'街道左侧靠墙的留白位置',
]),
},
],
},
],
})),
}))
@@ -29,6 +42,10 @@ const outboundMock = vi.hoisted(() => ({
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-ref-1']),
}))
const promptMock = vi.hoisted(() => ({
buildPrompt: vi.fn(() => 'panel-image-prompt'),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/media/outbound-image', () => outboundMock)
@@ -56,7 +73,7 @@ vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
})
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_SINGLE_PANEL_IMAGE: 'np_single_panel_image' },
buildPrompt: vi.fn(() => 'panel-image-prompt'),
buildPrompt: promptMock.buildPrompt,
}))
import { handlePanelImageTask } from '@/lib/workers/handlers/panel-image-task-handler'
@@ -88,9 +105,10 @@ describe('worker panel-image-task-handler behavior', () => {
shotType: 'close-up',
cameraMove: 'static',
description: 'hero close-up',
imagePrompt: 'panel anchor prompt',
videoPrompt: 'dramatic',
location: 'Old Town',
characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]),
characters: JSON.stringify([{ name: 'Hero', appearance: 'default', slot: '街道左侧靠墙的留白位置' }]),
srtSegment: '台词片段',
photographyRules: null,
actingNotes: null,
@@ -134,6 +152,16 @@ describe('worker panel-image-task-handler behavior', () => {
}),
}),
)
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.objectContaining({
storyboard_text_json_input: expect.stringContaining('"slot": "街道左侧靠墙的留白位置"'),
}),
}))
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.objectContaining({
storyboard_text_json_input: expect.stringContaining('"available_slots"'),
}),
}))
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
where: { id: 'panel-1' },
@@ -155,6 +183,7 @@ describe('worker panel-image-task-handler behavior', () => {
shotType: 'close-up',
cameraMove: 'static',
description: 'hero close-up',
imagePrompt: null,
videoPrompt: 'dramatic',
location: 'Old Town',
characters: '[]',

View File

@@ -30,7 +30,16 @@ const sharedMock = vi.hoisted(() => ({
imageUrl: 'cos/hero-default.png',
}],
}],
locations: [{ name: 'Old Town', images: [] }],
locations: [{
name: 'Old Town',
images: [{
isSelected: true,
description: '老街中央留出明确人物站位',
availableSlots: JSON.stringify([
'街道左侧靠墙的留白位置',
]),
}],
}],
})),
}))
@@ -63,12 +72,15 @@ vi.mock('@/lib/prompt-i18n', () => ({
import { handlePanelVariantTask } from '@/lib/workers/handlers/panel-variant-task-handler'
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
function buildJob(
payload: Record<string, unknown>,
locale: TaskJobData['locale'] = 'zh',
): Job<TaskJobData> {
return {
data: {
taskId: 'task-panel-variant-1',
type: TASK_TYPE.PANEL_VARIANT,
locale: 'zh',
locale,
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
@@ -90,7 +102,7 @@ describe('worker panel-variant-task-handler behavior', () => {
storyboardId: 'storyboard-1',
imageUrl: null,
location: 'Old Town',
characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]),
characters: JSON.stringify([{ name: 'Hero', appearance: 'default', slot: '街道左侧靠墙的留白位置' }]),
}
}
if (args.where.id === 'panel-source') {
@@ -145,6 +157,12 @@ describe('worker panel-variant-task-handler behavior', () => {
where: { id: 'panel-new' },
data: { imageUrl: 'cos/panel-variant-new.png' },
})
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.objectContaining({
characters_info: expect.stringContaining('固定位置:街道左侧靠墙的留白位置'),
location_asset: expect.stringContaining('街道左侧靠墙的留白位置'),
}),
}))
expect(result).toEqual({
panelId: 'panel-new',
@@ -178,4 +196,30 @@ describe('worker panel-variant-task-handler behavior', () => {
}),
}))
})
it('uses localized slot labels in english variant prompts', async () => {
const payload = {
newPanelId: 'panel-new',
sourcePanelId: 'panel-source',
variant: {
title: 'Rainy night version',
description: 'Keep the same staging but change the mood',
video_prompt: 'Keep the same staging but change the mood',
},
}
await handlePanelVariantTask(buildJob(payload, 'en'))
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
locale: 'en',
variables: expect.objectContaining({
location_asset: expect.stringContaining('Available character slots:'),
}),
}))
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.objectContaining({
location_asset: expect.not.stringContaining('可站位置:'),
}),
}))
})
})

View File

@@ -274,4 +274,97 @@ describe('script-to-storyboard orchestrator retry', () => {
expect(result.summary.clipCount).toBe(3)
expect(maxActivePhase1).toBe(1)
})
it('pipelines clips so one clip can enter phase2 before another clip finishes phase1', async () => {
let releaseClip1Phase1: (() => void) | null = null
const clip1Phase1Gate = new Promise<void>((resolve) => {
releaseClip1Phase1 = resolve
})
let clip2Phase2Started = false
let clip1Phase1ResolvedAfterClip2Phase2 = false
const runStep = vi.fn(async (meta, _prompt, action: string) => {
const stepId = String(meta.stepId)
if (action === 'storyboard_phase1_plan' && stepId === 'clip_clip-1_phase1') {
await clip1Phase1Gate
clip1Phase1ResolvedAfterClip2Phase2 = clip2Phase2Started
return {
text: JSON.stringify([{ panel_number: 1, description: '镜头1', location: '场景A', source_text: '原文1', characters: [] }]),
reasoning: '',
}
}
if (action === 'storyboard_phase1_plan' && stepId === 'clip_clip-2_phase1') {
return {
text: JSON.stringify([{ panel_number: 1, description: '镜头2', location: '场景A', source_text: '原文2', characters: [] }]),
reasoning: '',
}
}
if (action === 'storyboard_phase2_cinematography' && stepId === 'clip_clip-2_phase2_cinematography') {
clip2Phase2Started = true
releaseClip1Phase1?.()
return {
text: JSON.stringify([{ panel_number: 1, composition: '居中', lighting: '顶光', color_palette: '冷色', atmosphere: '紧张', technical_notes: 'note' }]),
reasoning: '',
}
}
if (action === 'storyboard_phase2_acting') {
return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }
}
if (action === 'storyboard_phase2_cinematography') {
return {
text: JSON.stringify([{ panel_number: 1, composition: '居中', lighting: '顶光', color_palette: '冷色', atmosphere: '紧张', technical_notes: 'note' }]),
reasoning: '',
}
}
if (action === 'storyboard_phase3_detail') {
return {
text: JSON.stringify([{ panel_number: 1, description: '细化镜头', location: '场景A', source_text: '原文', characters: [] }]),
reasoning: '',
}
}
throw new Error(`unexpected action: ${action}:${stepId}`)
})
const result = await runScriptToStoryboardOrchestrator({
concurrency: 2,
clips: [
{
id: 'clip-1',
content: '文本1',
characters: JSON.stringify([{ name: '角色A' }]),
location: '场景A',
screenplay: null,
},
{
id: 'clip-2',
content: '文本2',
characters: JSON.stringify([{ name: '角色A' }]),
location: '场景A',
screenplay: null,
},
],
novelPromotionData: {
characters: [{ name: '角色A', appearances: [] }],
locations: [{ name: '场景A', images: [] }],
},
promptTemplates: {
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
},
runStep,
})
expect(result.summary.clipCount).toBe(2)
expect(clip2Phase2Started).toBe(true)
expect(clip1Phase1ResolvedAfterClip2Phase2).toBe(true)
})
})

View File

@@ -28,13 +28,14 @@ const runScriptToStoryboardOrchestratorMock = vi.hoisted(() =>
clipPanels: [
{
clipId: 'clip-1',
panels: [
clipIndex: 0,
finalPanels: [
{
panelIndex: 1,
shotType: 'close-up',
cameraMove: 'static',
panel_number: 1,
shot_type: 'close-up',
camera_move: 'static',
description: 'panel desc',
videoPrompt: 'panel prompt',
video_prompt: 'panel prompt',
location: 'room',
characters: ['Narrator'],
},
@@ -48,7 +49,7 @@ const runScriptToStoryboardOrchestratorMock = vi.hoisted(() =>
})),
)
const parseVoiceLinesJsonMock = vi.hoisted(() => vi.fn())
const persistStoryboardsAndPanelsMock = vi.hoisted(() => vi.fn())
const persistStoryboardOutputsMock = vi.hoisted(() => vi.fn())
const parseStoryboardRetryTargetMock = vi.hoisted(() => vi.fn())
const runScriptToStoryboardAtomicRetryMock = vi.hoisted(() => vi.fn())
const workflowLeaseMock = vi.hoisted(() => ({
@@ -158,11 +159,11 @@ vi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', () => ({
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
},
buildStoryboardJson: vi.fn(() => '[]'),
buildStoryboardJsonFromClipPanels: vi.fn(() => '[]'),
parseEffort: vi.fn(() => null),
parseTemperature: vi.fn(() => 0.7),
parseVoiceLinesJson: parseVoiceLinesJsonMock,
persistStoryboardsAndPanels: persistStoryboardsAndPanelsMock,
persistStoryboardOutputs: persistStoryboardOutputsMock,
toPositiveInt: (value: unknown) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return null
const n = Math.floor(value)
@@ -255,33 +256,41 @@ describe('worker script-to-storyboard behavior', () => {
],
})
prismaMock.$transaction.mockImplementation(async (fn: (tx: {
novelPromotionVoiceLine: {
deleteMany: (args: { where: Record<string, unknown> }) => Promise<unknown>
create: (args: { data: Record<string, unknown>; select: { id: boolean } }) => Promise<{ id: string }>
}
}) => Promise<unknown>) => {
const tx = {
novelPromotionVoiceLine: {
deleteMany: async (args: { where: Record<string, unknown> }) => {
txState.deletedWhereClauses.push(args.where)
return undefined
},
create: async (args: { data: Record<string, unknown>; select: { id: boolean } }) => {
txState.createdRows.push(args.data)
return { id: `voice-${txState.createdRows.length}` }
},
},
}
return await fn(tx)
})
prismaMock.$transaction.mockReset()
persistStoryboardsAndPanelsMock.mockResolvedValue([
{
storyboardId: 'storyboard-1',
panels: [{ id: 'panel-1', panelIndex: 1 }],
},
])
persistStoryboardOutputsMock.mockImplementation(async ({ voiceLineRows }: { voiceLineRows: VoiceLineInput[] | null }) => {
const rows = voiceLineRows || []
txState.createdRows = rows.map((row) => ({
episodeId: 'episode-1',
lineIndex: row.lineIndex,
speaker: row.speaker,
content: row.content,
emotionStrength: row.emotionStrength,
matchedPanelId: 'panel-1',
matchedStoryboardId: 'storyboard-1',
matchedPanelIndex: row.matchedPanel.panelIndex,
}))
txState.deletedWhereClauses = [
rows.length === 0
? { episodeId: 'episode-1' }
: {
episodeId: 'episode-1',
lineIndex: {
notIn: rows.map((row) => row.lineIndex),
},
},
]
return {
persistedStoryboards: [
{
storyboardId: 'storyboard-1',
clipId: 'clip-1',
panels: [{ id: 'panel-1', panelIndex: 1 }],
},
],
voiceLineCount: rows.length,
}
})
parseVoiceLinesJsonMock.mockReturnValue(baseVoiceRows())
})
@@ -433,7 +442,7 @@ describe('worker script-to-storyboard behavior', () => {
})
expect(runScriptToStoryboardAtomicRetryMock).toHaveBeenCalledTimes(1)
expect(runScriptToStoryboardOrchestratorMock).not.toHaveBeenCalled()
expect(persistStoryboardsAndPanelsMock).toHaveBeenCalledWith({
expect(persistStoryboardOutputsMock).toHaveBeenCalledWith({
episodeId: 'episode-1',
clipPanels: [
{
@@ -448,6 +457,7 @@ describe('worker script-to-storyboard behavior', () => {
],
},
],
voiceLineRows: null,
})
})
})

View File

@@ -52,7 +52,7 @@ describe('worker shot-ai-prompt-location behavior', () => {
vi.clearAllMocks()
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
persistMock.requireProjectLocation.mockResolvedValue({ id: 'location-1', name: 'Old Town' })
runtimeMock.runShotPromptCompletion.mockResolvedValue('{"prompt":"updated location description"}')
runtimeMock.runShotPromptCompletion.mockResolvedValue('{"prompt":"updated location description","available_slots":["街道左侧靠墙的留白位置"]}')
persistMock.persistLocationDescription.mockResolvedValue({ id: 'location-1', images: [] })
})
@@ -85,10 +85,12 @@ describe('worker shot-ai-prompt-location behavior', () => {
locationId: 'location-1',
imageIndex: 2,
modifiedDescription: 'updated location description',
availableSlots: ['街道左侧靠墙的留白位置'],
})
expect(result).toEqual(expect.objectContaining({
success: true,
modifiedDescription: 'updated location description',
availableSlots: ['街道左侧靠墙的留白位置'],
location: { id: 'location-1', images: [] },
}))
})

View File

@@ -6,7 +6,9 @@ const prismaMock = vi.hoisted(() => ({
project: { findUnique: vi.fn() },
novelPromotionProject: { findUnique: vi.fn() },
novelPromotionEpisode: { findUnique: vi.fn() },
$transaction: vi.fn(),
novelPromotionClip: { update: vi.fn(async () => ({})) },
locationImage: { createMany: vi.fn(async () => ({ count: 0 })) },
}))
const workerMock = vi.hoisted(() => ({
@@ -120,6 +122,7 @@ function buildJob(payload: Record<string, unknown>, episodeId: string | null = '
describe('worker story-to-script behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.$transaction.mockImplementation(async (fn: (tx: typeof prismaMock) => Promise<unknown>) => await fn(prismaMock))
prismaMock.project.findUnique.mockResolvedValue({
id: 'project-1',
@@ -181,10 +184,10 @@ describe('worker story-to-script behavior', () => {
persistedClips: 1,
})
expect(helperMock.persistClips).toHaveBeenCalledWith({
expect(helperMock.persistClips).toHaveBeenCalledWith(expect.objectContaining({
episodeId: 'episode-1',
clipList: [{ clipId: 'clip-1', content: 'clip content', props: ['Knife'] }],
})
}))
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
where: { id: 'clip-row-1' },