feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const createChatCompletionMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'chatcmpl_bailian',
object: 'chat.completion',
created: 1,
model: 'qwen3.5-plus',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'ok' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
},
})),
)
const openAiCtorMock = vi.hoisted(() =>
vi.fn(() => ({
chat: {
completions: {
create: createChatCompletionMock,
},
},
})),
)
vi.mock('openai', () => ({
default: openAiCtorMock,
}))
import { completeBailianLlm } from '@/lib/providers/bailian/llm'
describe('bailian llm provider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('calls dashscope openai-compatible endpoint for registered qwen model', async () => {
const completion = await completeBailianLlm({
modelId: 'qwen3.5-plus',
messages: [{ role: 'user', content: 'hello' }],
apiKey: 'bl-key',
temperature: 0.2,
})
expect(openAiCtorMock).toHaveBeenCalledWith({
apiKey: 'bl-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeout: 30_000,
})
expect(createChatCompletionMock).toHaveBeenCalledWith({
model: 'qwen3.5-plus',
messages: [{ role: 'user', content: 'hello' }],
temperature: 0.2,
})
expect(completion.choices[0]?.message?.content).toBe('ok')
})
it('fails fast when model is not in official bailian catalog', async () => {
await expect(
completeBailianLlm({
modelId: 'qwen-plus',
messages: [{ role: 'user', content: 'hello' }],
apiKey: 'bl-key',
}),
).rejects.toThrow(/MODEL_NOT_REGISTERED/)
expect(openAiCtorMock).not.toHaveBeenCalled()
expect(createChatCompletionMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { synthesizeWithBailianTTS } from '@/lib/providers/bailian/tts'
function buildWavBuffer(durationMs: number): Buffer {
const sampleRate = 8000
const channels = 1
const bitsPerSample = 16
const byteRate = sampleRate * channels * (bitsPerSample / 8)
const dataLength = Math.round((durationMs / 1000) * byteRate)
const pcmData = Buffer.alloc(dataLength, 0)
const output = Buffer.alloc(44 + dataLength)
output.write('RIFF', 0, 'ascii')
output.writeUInt32LE(36 + dataLength, 4)
output.write('WAVE', 8, 'ascii')
output.write('fmt ', 12, 'ascii')
output.writeUInt32LE(16, 16)
output.writeUInt16LE(1, 20)
output.writeUInt16LE(channels, 22)
output.writeUInt32LE(sampleRate, 24)
output.writeUInt32LE(byteRate, 28)
output.writeUInt16LE(channels * (bitsPerSample / 8), 32)
output.writeUInt16LE(bitsPerSample, 34)
output.write('data', 36, 'ascii')
output.writeUInt32LE(dataLength, 40)
pcmData.copy(output, 44)
return output
}
describe('bailian tts synthesis', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('synthesizes one segment and returns wav buffer', async () => {
const wav = buildWavBuffer(120)
const fetchMock = vi.fn(async (input: string) => {
if (input.includes('/multimodal-generation/generation')) {
return {
ok: true,
text: async () => JSON.stringify({
output: {
audio: {
url: 'https://audio.example/segment-1.wav',
},
},
usage: { characters: 10 },
request_id: 'req-1',
}),
}
}
if (input === 'https://audio.example/segment-1.wav') {
return {
ok: true,
status: 200,
arrayBuffer: async () => wav.buffer.slice(wav.byteOffset, wav.byteOffset + wav.byteLength),
}
}
throw new Error(`unexpected fetch url: ${input}`)
})
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await synthesizeWithBailianTTS({
text: '你好,世界',
voiceId: 'voice_1',
}, 'bl-key')
expect(result.success).toBe(true)
expect(result.audioData).toBeDefined()
expect(result.audioDuration).toBe(120)
expect(result.audioUrl).toBe('https://audio.example/segment-1.wav')
expect(result.characters).toBe(10)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('splits text over 600 chars and merges audio segments', async () => {
const wavA = buildWavBuffer(100)
const wavB = buildWavBuffer(200)
let generationCallCount = 0
const fetchMock = vi.fn(async (input: string) => {
if (input.includes('/multimodal-generation/generation')) {
generationCallCount += 1
const audioUrl = generationCallCount === 1
? 'https://audio.example/segment-a.wav'
: 'https://audio.example/segment-b.wav'
return {
ok: true,
text: async () => JSON.stringify({
output: {
audio: { url: audioUrl },
},
usage: { characters: 600 },
request_id: `req-${generationCallCount}`,
}),
}
}
if (input === 'https://audio.example/segment-a.wav') {
return {
ok: true,
status: 200,
arrayBuffer: async () => wavA.buffer.slice(wavA.byteOffset, wavA.byteOffset + wavA.byteLength),
}
}
if (input === 'https://audio.example/segment-b.wav') {
return {
ok: true,
status: 200,
arrayBuffer: async () => wavB.buffer.slice(wavB.byteOffset, wavB.byteOffset + wavB.byteLength),
}
}
throw new Error(`unexpected fetch url: ${input}`)
})
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await synthesizeWithBailianTTS({
text: 'a'.repeat(601),
voiceId: 'voice_2',
}, 'bl-key')
expect(result.success).toBe(true)
expect(result.audioData).toBeDefined()
expect(result.audioDuration).toBe(300)
expect(result.audioUrl).toBeUndefined()
expect(result.characters).toBe(1200)
expect(generationCallCount).toBe(2)
})
it('fails explicitly when voiceId is missing', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await synthesizeWithBailianTTS({
text: 'hello',
voiceId: '',
}, 'bl-key')
expect(result).toEqual({
success: false,
error: 'BAILIAN_TTS_VOICE_ID_REQUIRED',
})
expect(fetchMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,179 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getProviderConfigMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'bailian',
apiKey: 'bl-key',
})),
)
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
import { generateBailianVideo } from '@/lib/providers/bailian/video'
describe('bailian video provider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('submits i2v task and returns async externalId', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
request_id: 'req-1',
output: {
task_id: 'task-123',
task_status: 'PENDING',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await generateBailianVideo({
userId: 'user-1',
imageUrl: 'https://example.com/frame.png',
prompt: '让人物向前走',
options: {
provider: 'bailian',
modelId: 'wan2.6-i2v-flash',
modelKey: 'bailian::wan2.6-i2v-flash',
duration: 5,
resolution: '720P',
promptExtend: true,
},
})
expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')
expect(fetchMock).toHaveBeenCalledTimes(1)
const firstCall = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit] | undefined
expect(firstCall).toBeDefined()
if (!firstCall) {
throw new Error('missing fetch call')
}
const requestUrl = firstCall[0]
const requestInit = firstCall[1]
expect(requestUrl).toBe('https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis')
expect(requestInit.method).toBe('POST')
expect(requestInit.headers).toEqual({
Authorization: 'Bearer bl-key',
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable',
})
expect(requestInit.body).toBe(JSON.stringify({
model: 'wan2.6-i2v-flash',
input: {
img_url: 'https://example.com/frame.png',
prompt: '让人物向前走',
},
parameters: {
resolution: '720P',
prompt_extend: true,
duration: 5,
},
}))
expect(result).toEqual({
success: true,
async: true,
requestId: 'task-123',
externalId: 'BAILIAN:VIDEO:task-123',
})
})
it('submits kf2v task with first and last frame', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
output: {
task_id: 'task-kf2v-1',
task_status: 'PENDING',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await generateBailianVideo({
userId: 'user-1',
imageUrl: 'https://example.com/first.png',
prompt: '让人物从左走到右',
options: {
provider: 'bailian',
modelId: 'wan2.2-kf2v-flash',
modelKey: 'bailian::wan2.2-kf2v-flash',
lastFrameImageUrl: 'https://example.com/last.png',
duration: 5,
},
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const firstCall = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit] | undefined
expect(firstCall).toBeDefined()
if (!firstCall) {
throw new Error('missing fetch call')
}
const requestUrl = firstCall[0]
const requestInit = firstCall[1]
expect(requestUrl).toBe('https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis')
expect(requestInit.body).toBe(JSON.stringify({
model: 'wan2.2-kf2v-flash',
input: {
first_frame_url: 'https://example.com/first.png',
last_frame_url: 'https://example.com/last.png',
prompt: '让人物从左走到右',
},
parameters: {
duration: 5,
},
}))
expect(result).toEqual({
success: true,
async: true,
requestId: 'task-kf2v-1',
externalId: 'BAILIAN:VIDEO:task-kf2v-1',
})
})
it('fails fast when kf2v model misses last frame', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
await expect(
generateBailianVideo({
userId: 'user-1',
imageUrl: 'https://example.com/first.png',
prompt: 'test',
options: {
provider: 'bailian',
modelId: 'wanx2.1-kf2v-plus',
modelKey: 'bailian::wanx2.1-kf2v-plus',
},
}),
).rejects.toThrow(/BAILIAN_VIDEO_LAST_FRAME_IMAGE_URL_REQUIRED/)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails fast when options contain unsupported field', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
await expect(
generateBailianVideo({
userId: 'user-1',
imageUrl: 'https://example.com/frame.png',
prompt: 'test',
options: {
provider: 'bailian',
modelId: 'wan2.6-i2v',
modelKey: 'bailian::wan2.6-i2v',
fps: 24,
},
}),
).rejects.toThrow(/BAILIAN_VIDEO_OPTION_UNSUPPORTED: fps/)
expect(fetchMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
novelPromotionProject: {
findUnique: vi.fn(),
},
novelPromotionCharacter: {
findMany: vi.fn(),
},
globalCharacter: {
findMany: vi.fn(),
},
globalVoice: {
findMany: vi.fn(),
},
novelPromotionEpisode: {
findMany: vi.fn(),
},
}))
const getProviderConfigMock = vi.hoisted(() => vi.fn())
const deleteBailianVoiceMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
vi.mock('@/lib/providers/bailian/voice-manage', () => ({
deleteBailianVoice: deleteBailianVoiceMock,
}))
import {
collectBailianManagedVoiceIds,
collectProjectBailianManagedVoiceIds,
cleanupUnreferencedBailianVoices,
isBailianManagedVoiceBinding,
} from '@/lib/providers/bailian/voice-cleanup'
describe('bailian voice cleanup', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.novelPromotionCharacter.findMany.mockResolvedValue([])
prismaMock.globalCharacter.findMany.mockResolvedValue([])
prismaMock.globalVoice.findMany.mockResolvedValue([])
prismaMock.novelPromotionEpisode.findMany.mockResolvedValue([])
getProviderConfigMock.mockResolvedValue({
apiKey: 'bl-key',
})
deleteBailianVoiceMock.mockResolvedValue({ requestId: 'req-1' })
})
it('identifies managed voice bindings by voiceType or id prefix', () => {
expect(isBailianManagedVoiceBinding({ voiceType: 'qwen-designed', voiceId: 'voice-a' })).toBe(true)
expect(isBailianManagedVoiceBinding({ voiceType: 'uploaded', voiceId: 'qwen-tts-vd-voice-b' })).toBe(true)
expect(isBailianManagedVoiceBinding({ voiceType: 'uploaded', voiceId: 'custom-voice-b' })).toBe(false)
})
it('collects and deduplicates managed voice ids', () => {
const voiceIds = collectBailianManagedVoiceIds([
{ voiceType: 'qwen-designed', voiceId: 'qwen-tts-vd-1' },
{ voiceType: 'qwen-designed', voiceId: 'qwen-tts-vd-1' },
{ voiceType: 'uploaded', voiceId: 'custom-1' },
{ voiceType: null, voiceId: 'qwen-tts-vd-2' },
])
expect(voiceIds).toEqual(['qwen-tts-vd-1', 'qwen-tts-vd-2'])
})
it('deletes only unreferenced managed voices', async () => {
prismaMock.globalVoice.findMany.mockResolvedValue([
{ voiceId: 'qwen-tts-vd-1' },
])
const result = await cleanupUnreferencedBailianVoices({
voiceIds: ['qwen-tts-vd-1', 'qwen-tts-vd-2'],
scope: {
userId: 'user-1',
},
})
expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')
expect(deleteBailianVoiceMock).toHaveBeenCalledTimes(1)
expect(deleteBailianVoiceMock).toHaveBeenCalledWith({
apiKey: 'bl-key',
voiceId: 'qwen-tts-vd-2',
})
expect(result).toEqual({
requestedVoiceIds: ['qwen-tts-vd-1', 'qwen-tts-vd-2'],
skippedReferencedVoiceIds: ['qwen-tts-vd-1'],
deletedVoiceIds: ['qwen-tts-vd-2'],
})
})
it('collects managed voice ids from project characters and speaker voices', async () => {
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
characters: [
{ voiceId: 'qwen-tts-vd-character', voiceType: 'qwen-designed' },
{ voiceId: 'plain-custom', voiceType: 'uploaded' },
],
episodes: [
{
speakerVoices: JSON.stringify({
Narrator: { voiceType: 'qwen-designed', voiceId: 'qwen-tts-vd-inline' },
Guest: { voiceType: 'uploaded', voiceId: 'uploaded-id' },
}),
},
],
})
const voiceIds = await collectProjectBailianManagedVoiceIds('project-1')
expect(voiceIds).toEqual(['qwen-tts-vd-character', 'qwen-tts-vd-inline'])
})
})

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createVoiceDesign } from '@/lib/providers/bailian/voice-design'
describe('bailian voice design', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('uses qwen3-tts-vd-2026-01-26 as target model', async () => {
const fetchMock = vi.fn(async (_input: unknown, _init?: unknown) => ({
ok: true,
json: async () => ({
output: {
voice: 'voice_1',
target_model: 'qwen3-tts-vd-2026-01-26',
preview_audio: {
data: 'base64',
sample_rate: 24000,
response_format: 'wav',
},
},
usage: { count: 1 },
request_id: 'req-1',
}),
text: async () => '',
status: 200,
headers: new Headers(),
redirected: false,
type: 'basic',
url: '',
bodyUsed: false,
clone: () => undefined as unknown as Response,
body: null,
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
await createVoiceDesign({
voicePrompt: '成熟稳重男声',
previewText: '你好测试',
}, 'bl-key')
const firstCall = fetchMock.mock.calls[0] as [unknown, RequestInit?] | undefined
const requestBodyRaw = firstCall?.[1]?.body
expect(typeof requestBodyRaw).toBe('string')
const requestBody = JSON.parse(requestBodyRaw as string) as {
input?: { target_model?: string }
}
expect(requestBody.input?.target_model).toBe('qwen3-tts-vd-2026-01-26')
})
})

View File

@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
assertOfficialModelRegistered,
isOfficialModelRegistered,
registerOfficialModel,
resetOfficialModelRegistryForTest,
} from '@/lib/providers/official/model-registry'
describe('official model registry', () => {
beforeEach(() => {
resetOfficialModelRegistryForTest()
})
it('throws MODEL_NOT_REGISTERED when model is absent', () => {
expect(() =>
assertOfficialModelRegistered({
provider: 'bailian',
modality: 'llm',
modelId: 'qwen-plus',
}),
).toThrow(/MODEL_NOT_REGISTERED/)
})
it('accepts registered official model', () => {
registerOfficialModel({
provider: 'siliconflow',
modality: 'image',
modelId: 'sf-image',
})
expect(
isOfficialModelRegistered({
provider: 'siliconflow',
modality: 'image',
modelId: 'sf-image',
}),
).toBe(true)
})
})