diff --git a/apps/web/src/workbench/WorkbenchShell.tsx b/apps/web/src/workbench/WorkbenchShell.tsx index 68b9cfe..ee2b57f 100644 --- a/apps/web/src/workbench/WorkbenchShell.tsx +++ b/apps/web/src/workbench/WorkbenchShell.tsx @@ -2748,9 +2748,7 @@ function PlayableVideo({ autoPlay={autoPlay} playsInline preload="metadata" - style={ - aspectRatio ? ({ aspectRatio } as CSSProperties) : undefined - } + style={aspectRatio ? ({ aspectRatio } as CSSProperties) : undefined} onLoadedMetadata={(event) => { const video = event.currentTarget; if (video.videoWidth > 0 && video.videoHeight > 0) { @@ -9331,14 +9329,30 @@ function OpenTuGenerationPage({ kind === 'image' ? settings.imageModel : settings.videoModel; const selectableModels = useSelectableModels(kind); const [selectedModel, setSelectedModel] = useState( - isConfiguredSettingsValue(initialModel) ? initialModel : undefined + () => + resolveSelectableModelFallback( + selectableModels, + isConfiguredSettingsValue(initialModel) ? initialModel : null, + createModelRef( + activeRoute?.profileId || null, + activeRoute?.modelId || initialModel || null + ) + )?.modelId ); const [selectedModelRef, setSelectedModelRef] = useState( - () => - createModelRef( + () => { + const initialModelRef = createModelRef( activeRoute?.profileId || null, activeRoute?.modelId || initialModel || null - ) + ); + return ( + resolveSelectableModelFallback( + selectableModels, + isConfiguredSettingsValue(initialModel) ? initialModel : null, + initialModelRef + )?.modelRef || initialModelRef + ); + } ); const [hasUserSelectedModel, setHasUserSelectedModel] = useState(false); useEffect(() => { @@ -10370,7 +10384,9 @@ function FunClipEditingPage({ pendingHandoffNoticeRef.current !== detail.createdAt ) { pendingHandoffNoticeRef.current = detail.createdAt || Date.now(); - MessagePlugin.info('已打开口播精修,正在等待这个数字人视频同步到素材列表。'); + MessagePlugin.info( + '已打开口播精修,正在等待这个数字人视频同步到素材列表。' + ); } return false; } diff --git a/apps/web/src/workbench/avatar-golden.scss b/apps/web/src/workbench/avatar-golden.scss index 8ad9a6b..10e873b 100644 --- a/apps/web/src/workbench/avatar-golden.scss +++ b/apps/web/src/workbench/avatar-golden.scss @@ -2227,6 +2227,7 @@ body .tg-avatar-page--compose .tg-task-row--aigcpanel.tg-task-row--with-media { position: relative !important; + container-type: inline-size !important; grid-template-columns: 32px minmax(0, 1fr) auto !important; gap: 10px 14px !important; padding: 18px 20px !important; diff --git a/packages/drawnix/src/services/__tests__/seedance-adapter.test.ts b/packages/drawnix/src/services/__tests__/seedance-adapter.test.ts new file mode 100644 index 0000000..10d9dff --- /dev/null +++ b/packages/drawnix/src/services/__tests__/seedance-adapter.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + resolveSeedanceActualModel, + seedanceVideoAdapter, +} from '../model-adapters/seedance-adapter'; +import type { AdapterContext } from '../model-adapters/types'; + +describe('seedance video adapter', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps runtime Doubao Seedance model ids unchanged', () => { + expect( + resolveSeedanceActualModel('doubao-seedance-2-0-fast-260128', '720p') + ).toBe('doubao-seedance-2-0-fast-260128'); + }); + + it('maps built-in Seedance aliases to concrete API model ids', () => { + expect(resolveSeedanceActualModel('seedance-1.5-pro', '720p')).toBe( + 'doubao-seedance-1-5-pro_720p' + ); + }); + + it('submits runtime Doubao Seedance model ids without adding a second prefix', async () => { + const requests: Array<{ url: string; init: RequestInit }> = []; + const fetcher = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), init: init || {} }); + if ((init?.method || 'GET') === 'POST') { + return new Response( + JSON.stringify({ + id: 'task-1', + status: 'queued', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return new Response( + JSON.stringify({ + id: 'task-1', + status: 'succeeded', + progress: 100, + url: 'https://cdn.example.com/seedance.mp4', + seconds: '5', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + ); + + const context: AdapterContext = { + baseUrl: 'https://truemodel.benchu.cloud/v1', + apiKey: 'sk-test', + authType: 'bearer', + operation: 'video', + fetcher, + }; + + const resultPromise = seedanceVideoAdapter.generateVideo(context, { + model: 'doubao-seedance-2-0-fast-260128', + prompt: 'tiny city at night', + size: '720p@16:9', + duration: 5, + }); + + await vi.advanceTimersByTimeAsync(5000); + const result = await resultPromise; + + const body = requests[0]?.init.body as FormData; + expect(result.url).toBe('https://cdn.example.com/seedance.mp4'); + expect(requests[0]?.url).toBe('/api/truemodel/v1/videos'); + expect(body.get('model')).toBe('doubao-seedance-2-0-fast-260128'); + expect(body.get('model')).not.toBe( + 'doubao-doubao-seedance-2-0-fast-260128_720p' + ); + expect(body.get('size')).toBe('16:9'); + expect(requests[1]?.url).toBe('/api/truemodel/v1/videos/task-1'); + }); +}); diff --git a/packages/drawnix/src/services/model-adapters/seedance-adapter.ts b/packages/drawnix/src/services/model-adapters/seedance-adapter.ts index ed6236d..43fda02 100644 --- a/packages/drawnix/src/services/model-adapters/seedance-adapter.ts +++ b/packages/drawnix/src/services/model-adapters/seedance-adapter.ts @@ -15,14 +15,28 @@ const SEEDANCE_MODELS = [ type SeedanceSubmitResponse = { id: string; - status: 'queued' | 'processing' | 'completed' | 'failed'; + status: + | 'queued' + | 'processing' + | 'in_progress' + | 'completed' + | 'succeeded' + | 'failed' + | 'error'; created_at?: number; error?: string | { code: string; message: string }; }; type SeedanceQueryResponse = { id: string; - status: 'queued' | 'processing' | 'completed' | 'failed'; + status: + | 'queued' + | 'processing' + | 'in_progress' + | 'completed' + | 'succeeded' + | 'failed' + | 'error'; progress?: number; video_url?: string; url?: string; @@ -36,10 +50,20 @@ const DEFAULT_POLL_MAX_ATTEMPTS = 1080; // ~90 min /** * 将逻辑模型 ID + 分辨率拼接为 API 实际模型名 * seedance-1.5-pro + 720p → doubao-seedance-1-5-pro_720p + * 运行时发现的 doubao-seedance-* 已经是真实供应商模型名,必须原样发送。 */ -const resolveActualModel = (logicalId: string, resolution: string): string => { +export const resolveSeedanceActualModel = ( + logicalId: string, + resolution: string +): string => { + const normalizedLogicalId = logicalId.trim(); + const lowerId = normalizedLogicalId.toLowerCase(); + if (lowerId.startsWith('doubao-seedance-')) { + return normalizedLogicalId; + } + // 将 "." 替换为 "-":seedance-1.5-pro → seedance-1-5-pro - const normalized = logicalId.replace(/\./g, '-'); + const normalized = normalizedLogicalId.replace(/\./g, '-'); return `doubao-${normalized}_${resolution}`; }; @@ -208,12 +232,14 @@ export const seedanceVideoAdapter: VideoModelAdapter = { async generateVideo(context, request: VideoGenerationRequest) { const logicalModel = request.model || 'seedance-1.5-pro'; const resolution = extractResolution(request.size); - const actualModel = resolveActualModel(logicalModel, resolution); + const actualModel = resolveSeedanceActualModel(logicalModel, resolution); const parsedSize = parseSeedanceSize(request.size); // 宽高比优先取显式参数,其次回退到 size 中的组合值 const aspectRatio = - normalizeAspectRatio(request.params?.aspect_ratio as string | undefined) || + normalizeAspectRatio( + request.params?.aspect_ratio as string | undefined + ) || normalizeAspectRatio(request.params?.aspectRatio as string | undefined) || parsedSize.aspectRatio || '16:9'; @@ -253,7 +279,7 @@ export const seedanceVideoAdapter: VideoModelAdapter = { onSubmitted?.(taskId); // 提交时已失败 - if (submitResult.status === 'failed') { + if (submitResult.status === 'failed' || submitResult.status === 'error') { throw new Error(extractErrorMessage(submitResult.error)); } @@ -281,11 +307,11 @@ export const seedanceVideoAdapter: VideoModelAdapter = { (status.status === 'failed' ? 100 : status.status === 'completed' - ? 100 - : 0); + ? 100 + : 0); onProgress?.(progress, status.status); - if (status.status === 'completed') { + if (status.status === 'completed' || status.status === 'succeeded') { const url = status.video_url || status.url; if (!url) { throw new Error('Seedance 结果缺少视频 URL'); @@ -298,7 +324,7 @@ export const seedanceVideoAdapter: VideoModelAdapter = { }; } - if (status.status === 'failed') { + if (status.status === 'failed' || status.status === 'error') { isBusinessFailure = true; throw new Error(extractErrorMessage(status.error)); }