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,22 @@
import { describe, expect, it } from 'vitest'
import { createAudioGenerator, createImageGenerator, createVideoGenerator } from '@/lib/generators/factory'
import { GoogleVeoVideoGenerator } from '@/lib/generators/video/google'
import { OpenAICompatibleVideoGenerator } from '@/lib/generators/video/openai-compatible'
import { BailianAudioGenerator, BailianImageGenerator, BailianVideoGenerator, SiliconFlowAudioGenerator } from '@/lib/generators/official'
describe('generator factory', () => {
it('routes gemini-compatible video provider to Google video generator', () => {
const generator = createVideoGenerator('gemini-compatible:gm-1')
expect(generator).toBeInstanceOf(GoogleVeoVideoGenerator)
})
it('routes bailian official providers to official generators', () => {
expect(createImageGenerator('bailian')).toBeInstanceOf(BailianImageGenerator)
expect(createVideoGenerator('bailian')).toBeInstanceOf(BailianVideoGenerator)
expect(createAudioGenerator('bailian')).toBeInstanceOf(BailianAudioGenerator)
})
it('routes siliconflow audio provider to official generator', () => {
expect(createAudioGenerator('siliconflow')).toBeInstanceOf(SiliconFlowAudioGenerator)
})
})

View File

@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const apiConfigMock = vi.hoisted(() => ({
getProviderConfig: vi.fn(async () => ({ apiKey: 'fal-key' })),
}))
const asyncSubmitMock = vi.hoisted(() => ({
submitFalTask: vi.fn(async () => 'req_kling_1'),
}))
vi.mock('@/lib/api-config', () => apiConfigMock)
vi.mock('@/lib/async-submit', () => asyncSubmitMock)
import { FalVideoGenerator } from '@/lib/generators/fal'
type KlingModelCase = {
modelId: string
endpoint: string
imageField: 'image_url' | 'start_image_url'
}
const KLING_MODEL_CASES: KlingModelCase[] = [
{
modelId: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',
endpoint: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',
imageField: 'image_url',
},
{
modelId: 'fal-ai/kling-video/v3/standard/image-to-video',
endpoint: 'fal-ai/kling-video/v3/standard/image-to-video',
imageField: 'start_image_url',
},
{
modelId: 'fal-ai/kling-video/v3/pro/image-to-video',
endpoint: 'fal-ai/kling-video/v3/pro/image-to-video',
imageField: 'start_image_url',
},
]
describe('FalVideoGenerator kling presets', () => {
beforeEach(() => {
vi.clearAllMocks()
apiConfigMock.getProviderConfig.mockResolvedValue({ apiKey: 'fal-key' })
asyncSubmitMock.submitFalTask.mockResolvedValue('req_kling_1')
})
it.each(KLING_MODEL_CASES)('submits $modelId to expected endpoint and payload', async ({ modelId, endpoint, imageField }) => {
const generator = new FalVideoGenerator()
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/start.png',
prompt: 'test prompt',
options: {
modelId,
duration: 5,
aspectRatio: '16:9',
},
})
expect(result.success).toBe(true)
expect(result.endpoint).toBe(endpoint)
expect(result.requestId).toBe('req_kling_1')
expect(result.externalId).toBe(`FAL:VIDEO:${endpoint}:req_kling_1`)
expect(apiConfigMock.getProviderConfig).toHaveBeenCalledWith('user-1', 'fal')
const submitCall = asyncSubmitMock.submitFalTask.mock.calls.at(0) as
| [string, Record<string, unknown>, string]
| undefined
expect(submitCall).toBeTruthy()
if (!submitCall) {
throw new Error('submitFalTask should be called')
}
expect(submitCall[0]).toBe(endpoint)
expect(submitCall[2]).toBe('fal-key')
const payload = submitCall[1]
expect(payload.prompt).toBe('test prompt')
expect(payload.duration).toBe('5')
if (imageField === 'image_url') {
expect(payload.image_url).toBe('https://example.com/start.png')
expect(payload.start_image_url).toBeUndefined()
expect(payload.negative_prompt).toBe('blur, distort, and low quality')
expect(payload.cfg_scale).toBe(0.5)
return
}
expect(payload.start_image_url).toBe('https://example.com/start.png')
expect(payload.image_url).toBeUndefined()
expect(payload.aspect_ratio).toBe('16:9')
expect(payload.generate_audio).toBe(false)
})
})

View File

@@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const googleGenerateContentMock = vi.hoisted(() => vi.fn())
const getProviderConfigMock = vi.hoisted(() => vi.fn())
const getImageBase64CachedMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,UkVG'))
const arkImageGenerationMock = vi.hoisted(() => vi.fn())
const normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn(async () => 'UkVG'))
vi.mock('@google/genai', () => ({
GoogleGenAI: class GoogleGenAI {
models = {
generateContent: googleGenerateContentMock,
}
},
HarmCategory: {
HARM_CATEGORY_HARASSMENT: 'HARM_CATEGORY_HARASSMENT',
HARM_CATEGORY_HATE_SPEECH: 'HARM_CATEGORY_HATE_SPEECH',
HARM_CATEGORY_SEXUALLY_EXPLICIT: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
HARM_CATEGORY_DANGEROUS_CONTENT: 'HARM_CATEGORY_DANGEROUS_CONTENT',
},
HarmBlockThreshold: {
BLOCK_NONE: 'BLOCK_NONE',
},
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
vi.mock('@/lib/image-cache', () => ({
getImageBase64Cached: getImageBase64CachedMock,
}))
vi.mock('@/lib/ark-api', () => ({
arkImageGeneration: arkImageGenerationMock,
}))
vi.mock('@/lib/media/outbound-image', () => ({
normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock,
}))
import { ArkSeedreamGenerator } from '@/lib/generators/ark'
import { GeminiCompatibleImageGenerator } from '@/lib/generators/image/gemini-compatible'
import { GoogleGeminiImageGenerator } from '@/lib/generators/image/google'
describe('image provider smoke tests', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('Google Gemini 官方文生图可用 -> 返回 data URL', async () => {
getProviderConfigMock.mockResolvedValueOnce({
id: 'google',
apiKey: 'google-key',
})
googleGenerateContentMock.mockResolvedValueOnce({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: 'image/png',
data: 'R09PR0xF',
},
},
],
},
},
],
})
const generator = new GoogleGeminiImageGenerator('gemini-3-pro-image-preview')
const result = await generator.generate({
userId: 'user-1',
prompt: 'draw a mountain',
options: {
aspectRatio: '3:4',
},
})
expect(result).toEqual({
success: true,
imageBase64: 'R09PR0xF',
imageUrl: 'data:image/png;base64,R09PR0xF',
})
expect(googleGenerateContentMock).toHaveBeenCalledWith({
model: 'gemini-3-pro-image-preview',
contents: [{ parts: [{ text: 'draw a mountain' }] }],
config: expect.objectContaining({
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: { aspectRatio: '3:4' },
}),
})
})
it('Seedream 图生图可用 -> 返回 ARK 图片 URL', async () => {
getProviderConfigMock.mockResolvedValueOnce({
id: 'ark',
apiKey: 'ark-key',
})
arkImageGenerationMock.mockResolvedValueOnce({
data: [{ url: 'https://seedream.test/image.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.png',
})
expect(arkImageGenerationMock).toHaveBeenCalledWith({
model: 'doubao-seedream-4-5-251128',
prompt: 'refine this style',
sequential_image_generation: 'disabled',
response_format: 'url',
stream: false,
watermark: false,
size: '3544x4728',
image: ['UkVG'],
}, {
apiKey: 'ark-key',
logPrefix: '[ARK Image]',
})
})
it('Gemini 兼容层文生图可用 -> 直连 Gemini SDK 协议返回图片', async () => {
getProviderConfigMock.mockResolvedValueOnce({
id: 'gemini-compatible:gm-1',
apiKey: 'gm-key',
baseUrl: 'https://gm.test',
})
googleGenerateContentMock.mockResolvedValueOnce({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: 'image/webp',
data: 'R01fVEVYVA==',
},
},
],
},
},
],
})
const generator = new GeminiCompatibleImageGenerator('gemini-2.5-flash-image-preview', 'gemini-compatible:gm-1')
const result = await generator.generate({
userId: 'user-1',
prompt: 'draw a cat',
options: {
aspectRatio: '1:1',
},
})
expect(result).toEqual({
success: true,
imageBase64: 'R01fVEVYVA==',
imageUrl: 'data:image/webp;base64,R01fVEVYVA==',
})
expect(googleGenerateContentMock).toHaveBeenCalledWith({
model: 'gemini-2.5-flash-image-preview',
contents: [{ parts: [{ text: 'draw a cat' }] }],
config: expect.objectContaining({
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: { aspectRatio: '1:1' },
}),
})
})
it('Gemini 兼容层图生图可用 -> 参考图会注入 inlineData', async () => {
getProviderConfigMock.mockResolvedValueOnce({
id: 'gemini-compatible:gm-1',
apiKey: 'gm-key',
baseUrl: 'https://gm.test',
})
googleGenerateContentMock.mockResolvedValueOnce({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: 'image/png',
data: 'R01fSTJJPQ==',
},
},
],
},
},
],
})
const generator = new GeminiCompatibleImageGenerator('gemini-2.5-flash-image-preview', 'gemini-compatible:gm-1')
const result = await generator.generate({
userId: 'user-1',
prompt: 'restyle this portrait',
referenceImages: ['/api/files/ref-image'],
options: {
resolution: '2K',
},
})
expect(result).toEqual({
success: true,
imageBase64: 'R01fSTJJPQ==',
imageUrl: 'data:image/png;base64,R01fSTJJPQ==',
})
const call = googleGenerateContentMock.mock.calls[0]
expect(call).toBeTruthy()
if (!call) {
throw new Error('Gemini generateContent should be called')
}
const content = call[0] as {
contents: Array<{ parts: Array<{ inlineData?: { mimeType: string; data: string }; text?: string }> }>
config: { imageConfig?: { imageSize?: string } }
}
expect(content.contents[0].parts[0].inlineData).toEqual({ mimeType: 'image/png', data: 'UkVG' })
expect(content.contents[0].parts[1].text).toBe('restyle this portrait')
expect(content.config.imageConfig).toEqual({ imageSize: '2K' })
})
})

View File

@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const openAIState = vi.hoisted(() => ({
generate: vi.fn(),
edit: vi.fn(),
toFile: vi.fn(async () => ({ name: 'mock-file' })),
}))
const getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({
id: 'openai-compatible:oa-1',
apiKey: 'oa-key',
baseUrl: 'https://oa.test/v1',
})))
const getImageBase64CachedMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,QQ=='))
vi.mock('openai', () => ({
default: class OpenAI {
images = {
generate: openAIState.generate,
edit: openAIState.edit,
}
},
toFile: openAIState.toFile,
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
vi.mock('@/lib/image-cache', () => ({
getImageBase64Cached: getImageBase64CachedMock,
}))
import { OpenAICompatibleImageGenerator } from '@/lib/generators/image/openai-compatible'
describe('OpenAICompatibleImageGenerator', () => {
beforeEach(() => {
vi.clearAllMocks()
getProviderConfigMock.mockResolvedValue({
id: 'openai-compatible:oa-1',
apiKey: 'oa-key',
baseUrl: 'https://oa.test/v1',
})
})
it('uses official images.generate payload parameters', async () => {
openAIState.generate.mockResolvedValueOnce({
data: [{ b64_json: 'YmFzZTY0' }],
})
const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
prompt: 'draw a lighthouse',
options: {
size: '1024x1024',
quality: 'high',
outputFormat: 'png',
responseFormat: 'b64_json',
},
})
expect(result.success).toBe(true)
expect(result.imageBase64).toBe('YmFzZTY0')
expect(result.imageUrl).toBe('data:image/png;base64,YmFzZTY0')
expect(openAIState.generate).toHaveBeenCalledWith({
model: 'gpt-image-1',
prompt: 'draw a lighthouse',
response_format: 'b64_json',
output_format: 'png',
quality: 'high',
size: '1024x1024',
})
})
it('uses official images.edit payload when reference images are provided', async () => {
openAIState.edit.mockResolvedValueOnce({
data: [{ b64_json: 'ZWRpdA==' }],
})
const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
prompt: 'edit this image',
referenceImages: ['data:image/png;base64,QQ=='],
options: {
quality: 'medium',
},
})
expect(result.success).toBe(true)
expect(openAIState.toFile).toHaveBeenCalledTimes(1)
const call = openAIState.edit.mock.calls[0]
expect(call).toBeTruthy()
if (!call) {
throw new Error('images.edit should be called')
}
expect(call[0]).toMatchObject({
model: 'gpt-image-1',
prompt: 'edit this image',
response_format: 'b64_json',
quality: 'medium',
})
expect(Array.isArray((call[0] as { image?: unknown }).image)).toBe(true)
})
it('fails explicitly on unsupported option values', async () => {
const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
prompt: 'draw',
options: {
quality: 'ultra',
},
})
expect(result.success).toBe(false)
expect(result.error).toContain('OPENAI_COMPAT_IMAGE_OPTION_UNSUPPORTED')
})
})

View File

@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const openAIState = vi.hoisted(() => ({
create: vi.fn(),
toFile: vi.fn(async () => ({ name: 'reference-file' })),
}))
const getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({
id: 'openai-compatible:oa-1',
apiKey: 'oa-key',
baseUrl: 'https://oa.test/v1',
})))
const normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,QQ=='))
vi.mock('openai', () => ({
default: class OpenAI {
videos = {
create: openAIState.create,
}
},
toFile: openAIState.toFile,
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
vi.mock('@/lib/media/outbound-image', () => ({
normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock,
}))
import { OpenAICompatibleVideoGenerator } from '@/lib/generators/video/openai-compatible'
describe('OpenAICompatibleVideoGenerator', () => {
beforeEach(() => {
vi.clearAllMocks()
getProviderConfigMock.mockResolvedValue({
id: 'openai-compatible:oa-1',
apiKey: 'oa-key',
baseUrl: 'https://oa.test/v1',
})
})
it('submits official videos.create payload and returns OPENAI externalId', async () => {
openAIState.create.mockResolvedValueOnce({ id: 'vid_123' })
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate this character',
options: {
modelId: 'sora-2',
duration: 8,
resolution: '720p',
aspectRatio: '16:9',
},
})
expect(result.success).toBe(true)
expect(result.async).toBe(true)
expect(result.requestId).toBe('vid_123')
const expectedProviderToken = Buffer.from('openai-compatible:oa-1', 'utf8').toString('base64url')
expect(result.externalId).toBe(`OPENAI:VIDEO:${expectedProviderToken}:vid_123`)
const createCall = openAIState.create.mock.calls[0]
expect(createCall).toBeTruthy()
if (!createCall) {
throw new Error('videos.create should be called')
}
expect(createCall[0]).toMatchObject({
prompt: 'animate this character',
model: 'sora-2',
seconds: '8',
size: '1280x720',
})
expect((createCall[0] as { input_reference?: unknown }).input_reference).toBeDefined()
})
it('allows custom model ids for openai-compatible gateways', async () => {
openAIState.create.mockResolvedValueOnce({ id: 'vid_custom' })
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate',
options: {
modelId: 'veo_3_1-fast-4K',
},
})
expect(result.success).toBe(true)
const createCall = openAIState.create.mock.calls.at(0)
expect(createCall).toBeTruthy()
if (!createCall) {
throw new Error('videos.create should be called')
}
expect((createCall[0] as { model?: string }).model).toBe('veo_3_1-fast-4K')
})
it('maps 3:2 to landscape size explicitly', async () => {
openAIState.create.mockResolvedValueOnce({ id: 'vid_32' })
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate',
options: {
resolution: '1080p',
aspectRatio: '3:2',
},
})
expect(result.success).toBe(true)
const createCall = openAIState.create.mock.calls.at(0)
expect(createCall).toBeTruthy()
if (!createCall) {
throw new Error('videos.create should be called')
}
expect((createCall[0] as { size?: string }).size).toBe('1792x1024')
})
it('maps 2:3 to portrait size explicitly', async () => {
openAIState.create.mockResolvedValueOnce({ id: 'vid_23' })
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate',
options: {
resolution: '720p',
aspectRatio: '2:3',
},
})
expect(result.success).toBe(true)
const createCall = openAIState.create.mock.calls.at(0)
expect(createCall).toBeTruthy()
if (!createCall) {
throw new Error('videos.create should be called')
}
expect((createCall[0] as { size?: string }).size).toBe('720x1280')
})
it('fails explicitly on unsupported aspect ratios', async () => {
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate',
options: {
resolution: '720p',
aspectRatio: '5:4',
},
})
expect(result.success).toBe(false)
expect(result.error).toContain('OPENAI_COMPAT_VIDEO_ASPECT_RATIO_UNSUPPORTED')
})
})