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:
@@ -133,6 +133,36 @@ describe('image provider smoke tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Seedream 返回多图时 -> 同时返回 imageUrl 和 imageUrls', async () => {
|
||||
getProviderConfigMock.mockResolvedValueOnce({
|
||||
id: 'ark',
|
||||
apiKey: 'ark-key',
|
||||
})
|
||||
arkImageGenerationMock.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ url: 'https://seedream.test/image-1.png' },
|
||||
{ url: 'https://seedream.test/image-2.png' },
|
||||
],
|
||||
})
|
||||
|
||||
const generator = new ArkSeedreamGenerator()
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
prompt: 'refine this style',
|
||||
referenceImages: ['https://example.com/ref.png'],
|
||||
options: {
|
||||
modelId: 'doubao-seedream-4-5-251128',
|
||||
aspectRatio: '3:4',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
imageUrl: 'https://seedream.test/image-1.png',
|
||||
imageUrls: ['https://seedream.test/image-1.png', 'https://seedream.test/image-2.png'],
|
||||
})
|
||||
})
|
||||
|
||||
it('Gemini 兼容层文生图可用 -> 直连 Gemini SDK 协议返回图片', async () => {
|
||||
getProviderConfigMock.mockResolvedValueOnce({
|
||||
id: 'gemini-compatible:gm-1',
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const resolveConfigMock = vi.hoisted(() => vi.fn(async () => ({
|
||||
providerId: 'openai-compatible:test-provider',
|
||||
baseUrl: 'https://compat.example.com/v1',
|
||||
apiKey: 'sk-test',
|
||||
})))
|
||||
|
||||
vi.mock('@/lib/model-gateway/openai-compat/common', () => ({
|
||||
resolveOpenAICompatClientConfig: resolveConfigMock,
|
||||
}))
|
||||
|
||||
import { generateImageViaOpenAICompatTemplate } from '@/lib/model-gateway/openai-compat/template-image'
|
||||
|
||||
describe('openai-compat template image output urls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns all image urls when outputUrlsPath contains multiple values', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
|
||||
data: [
|
||||
{ url: 'https://cdn.test/1.png' },
|
||||
{ url: 'https://cdn.test/2.png' },
|
||||
],
|
||||
}), { status: 200 })) as unknown as typeof fetch
|
||||
|
||||
const result = await generateImageViaOpenAICompatTemplate({
|
||||
userId: 'user-1',
|
||||
providerId: 'openai-compatible:test-provider',
|
||||
modelId: 'gpt-image-1',
|
||||
modelKey: 'openai-compatible:test-provider::gpt-image-1',
|
||||
prompt: 'draw a cat',
|
||||
profile: 'openai-compatible',
|
||||
template: {
|
||||
version: 1,
|
||||
mediaType: 'image',
|
||||
mode: 'sync',
|
||||
create: {
|
||||
method: 'POST',
|
||||
path: '/images/generations',
|
||||
contentType: 'application/json',
|
||||
bodyTemplate: {
|
||||
model: '{{model}}',
|
||||
prompt: '{{prompt}}',
|
||||
},
|
||||
},
|
||||
response: {
|
||||
outputUrlPath: '$.data[0].url',
|
||||
outputUrlsPath: '$.data',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
imageUrl: 'https://cdn.test/1.png',
|
||||
imageUrls: ['https://cdn.test/1.png', 'https://cdn.test/2.png'],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps single-url output compatible when outputUrlsPath has only one image', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
|
||||
data: [{ url: 'https://cdn.test/only.png' }],
|
||||
}), { status: 200 })) as unknown as typeof fetch
|
||||
|
||||
const result = await generateImageViaOpenAICompatTemplate({
|
||||
userId: 'user-1',
|
||||
providerId: 'openai-compatible:test-provider',
|
||||
modelId: 'gpt-image-1',
|
||||
modelKey: 'openai-compatible:test-provider::gpt-image-1',
|
||||
prompt: 'draw a cat',
|
||||
profile: 'openai-compatible',
|
||||
template: {
|
||||
version: 1,
|
||||
mediaType: 'image',
|
||||
mode: 'sync',
|
||||
create: {
|
||||
method: 'POST',
|
||||
path: '/images/generations',
|
||||
contentType: 'application/json',
|
||||
bodyTemplate: {
|
||||
model: '{{model}}',
|
||||
prompt: '{{prompt}}',
|
||||
},
|
||||
},
|
||||
response: {
|
||||
outputUrlsPath: '$.data',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
imageUrl: 'https://cdn.test/only.png',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
21
tests/unit/worker/voice-line-parse-helpers.test.ts
Normal file
21
tests/unit/worker/voice-line-parse-helpers.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user