Fix Seedance runtime model handling

This commit is contained in:
2026-07-07 09:38:08 +08:00
parent 3b6781d695
commit 13f44a4bbc
4 changed files with 153 additions and 19 deletions

View File

@@ -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<ModelRef | null>(
() =>
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;
}

View File

@@ -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;

View File

@@ -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');
});
});

View File

@@ -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));
}