Fix Seedance runtime model handling
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user