feat: add asset library download button, fix env ports, update README, optimize semantics, support multi-image reading, and allow voiceover analysis for silent segments

This commit is contained in:
saturn
2026-03-13 17:37:52 +08:00
parent be1853534a
commit eec27fbabf
41 changed files with 977 additions and 187 deletions

View File

@@ -76,6 +76,7 @@ const runScriptToStoryboardAtomicRetryMock = vi.hoisted(() => vi.fn())
const txState = vi.hoisted(() => ({
createdRows: [] as Array<Record<string, unknown>>,
deletedWhereClauses: [] as Array<Record<string, unknown>>,
}))
const prismaMock = vi.hoisted(() => ({
@@ -241,6 +242,7 @@ describe('worker script-to-storyboard behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
txState.createdRows = []
txState.deletedWhereClauses = []
parseStoryboardRetryTargetMock.mockReturnValue(null)
runScriptToStoryboardAtomicRetryMock.mockReset()
@@ -274,13 +276,16 @@ describe('worker script-to-storyboard behavior', () => {
prismaMock.$transaction.mockImplementation(async (fn: (tx: {
novelPromotionVoiceLine: {
deleteMany: (args: { where: { episodeId: string } }) => Promise<unknown>
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 () => undefined,
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}` }
@@ -328,6 +333,12 @@ describe('worker script-to-storyboard behavior', () => {
matchedStoryboardId: 'storyboard-1',
matchedPanelIndex: 1,
}))
expect(txState.deletedWhereClauses[0]).toEqual({
episodeId: 'episode-1',
lineIndex: {
notIn: [1],
},
})
})
it('voice 解析失败后会重试一次再成功', async () => {
@@ -371,6 +382,24 @@ describe('worker script-to-storyboard behavior', () => {
)
})
it('空台词数组 -> 成功完成并清空旧台词', async () => {
parseVoiceLinesJsonMock.mockReturnValue([])
const job = buildJob({ episodeId: 'episode-1' })
const result = await handleScriptToStoryboardTask(job)
expect(result).toEqual({
episodeId: 'episode-1',
storyboardCount: 1,
panelCount: 1,
voiceLineCount: 0,
})
expect(txState.createdRows).toEqual([])
expect(txState.deletedWhereClauses[0]).toEqual({
episodeId: 'episode-1',
})
})
it('phase 级重试: 仅执行原子 phase不走整图重跑', async () => {
parseStoryboardRetryTargetMock.mockReturnValue({
stepKey: 'clip_clip-1_phase3_detail',

View File

@@ -4,6 +4,7 @@ import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const txState = vi.hoisted(() => ({
createdRows: [] as Array<Record<string, unknown>>,
deletedWhereClauses: [] as Array<Record<string, unknown>>,
}))
const prismaMock = vi.hoisted(() => ({
@@ -79,6 +80,7 @@ describe('worker voice-analyze behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
txState.createdRows = []
txState.deletedWhereClauses = []
prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion' })
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
@@ -121,7 +123,7 @@ describe('worker voice-analyze behavior', () => {
prismaMock.$transaction.mockImplementation(async (fn: (tx: {
novelPromotionVoiceLine: {
deleteMany: (args: { where: { episodeId: string } }) => Promise<unknown>
deleteMany: (args: { where: Record<string, unknown> }) => Promise<unknown>
create: (args: { data: Record<string, unknown>; select: { id: boolean; speaker: boolean; matchedStoryboardId: boolean } }) => Promise<{
id: string
speaker: string
@@ -131,7 +133,10 @@ describe('worker voice-analyze behavior', () => {
}) => Promise<unknown>) => {
const tx = {
novelPromotionVoiceLine: {
deleteMany: async () => undefined,
deleteMany: async (args: { where: Record<string, unknown> }) => {
txState.deletedWhereClauses.push(args.where)
return undefined
},
create: async (args: { data: Record<string, unknown>; select: { id: boolean; speaker: boolean; matchedStoryboardId: boolean } }) => {
txState.createdRows.push(args.data)
const speaker = typeof args.data.speaker === 'string' ? args.data.speaker : 'unknown'
@@ -178,6 +183,30 @@ describe('worker voice-analyze behavior', () => {
matchedStoryboardId: 'storyboard-1',
matchedPanelIndex: 0,
}))
expect(txState.deletedWhereClauses[0]).toEqual({
episodeId: 'episode-1',
lineIndex: {
notIn: [1, 2],
},
})
})
it('empty voice lines -> success with zero rows and clears existing lines', async () => {
helperMock.parseVoiceLinesJson.mockReturnValue([])
const job = buildJob({ episodeId: 'episode-1' })
const result = await handleVoiceAnalyzeTask(job)
expect(result).toEqual({
episodeId: 'episode-1',
count: 0,
matchedCount: 0,
speakerStats: {},
})
expect(txState.createdRows).toEqual([])
expect(txState.deletedWhereClauses[0]).toEqual({
episodeId: 'episode-1',
})
})
it('line references non-existent storyboard panel -> explicit error', async () => {

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { parseVoiceLinesJson as parseStoryboardVoiceLinesJson } from '@/lib/workers/handlers/script-to-storyboard-helpers'
import { parseVoiceLinesJson as parseStandaloneVoiceLinesJson } from '@/lib/workers/handlers/voice-analyze-helpers'
describe('voice line parse helpers', () => {
it('script-to-storyboard parser accepts explicit empty array', () => {
expect(parseStoryboardVoiceLinesJson('[]')).toEqual([])
})
it('script-to-storyboard parser rejects non-object array payload', () => {
expect(() => parseStoryboardVoiceLinesJson('[1,2]')).toThrow('voice_analyze: invalid payload')
})
it('voice-analyze parser accepts explicit empty array', () => {
expect(parseStandaloneVoiceLinesJson('[]')).toEqual([])
})
it('voice-analyze parser rejects non-object array payload', () => {
expect(() => parseStandaloneVoiceLinesJson('[1,2]')).toThrow('Invalid voice lines data structure')
})
})