fix: stabilize Agnes video generation flow
This commit is contained in:
@@ -101,6 +101,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| 元界 AI 同步后出现大量接口/参数名模型或模型行反复显示 Key | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/components/admin/api-management-tab.tsx` | 元界不应再从 `/v1/skills` 或 `/v1/skills/guide` 猜模型,也不应在 `智能配置 API` 页面暴露内置模板安装/同步入口。检查安装路由是否使用内置图片/视频模板、是否只删除当前媒体类型的 `provider = '元界 AI'` 行、是否创建 inactive rows and per-model Manifest files, and whether admins configure Key/pricing/usage modes/enablement per model through the system-default-model management flow. The admin list should not show repeated imported key placeholders, and the create page should show only documented controls from the selected template capabilities. |
|
||||
| 元界任务在元界后台成功但妙境报模型繁忙或接口路径不存在 | `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Check whether the Manifest poll endpoint uses `path: "v1/media/status"` plus `query: { task_id: "{task_id}" }`. If the path is stored as `v1/media/status?task_id={task_id}`, the executor can encode the query string into the pathname and 元界 will return a not-found error even though the create request already produced a task. Also verify 元界 media templates use `finalPath: "is_final"`, `finalValues: [true]`, `statusPath: "state"`, `successValues: ["success"]`, and `failureValues: ["failed"]`; `status` / `status_group` are display fields only. |
|
||||
| 元界后台显示已生成图片但妙境任务失败,日志出现下载 403、timeout 或保存失败 | `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, `src/lib/remote-fetch.ts`, `src/lib/user-api-manifest-executor.ts` | 这通常不是元界提交或轮询失败,而是 Manifest 结果 URL 返回后,妙境下载外部图片或保存原图/缩略图失败。先查 PM2 日志中 `[User API Manifest Image] Failed to persist generated image`,区分 `下载图片失败: 403`、`fetch failed`、`Persist generated image media timed out`、对象存储/缩略图错误。外部生成图 URL 应通过 `fetchPublicHttpUrlWithRetry` 发送浏览器式 `User-Agent`/`Accept` 并有限重试;`/api/generate/image` 应返回“上游已返回生成结果,但平台下载或保存结果图片失败”,不要再误包装为“上游返回图片分辨率不符合”或泛化成模型繁忙。 |
|
||||
| Agnes 视频任务先显示 `in_progress` 后失败,错误为裸 `fetch failed` 或历史不写入 | `src/lib/agnes-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/video/route.ts`, `src/lib/generation-job-worker.ts` | 先区分提交、轮询、结果视频下载保存、历史写入四段。Agnes V2.0 使用 `POST /v1/videos` 和 `GET /agnesapi?video_id=...&model_name=agnes-video-v2.0`,`remixed_from_video_id` 在官方完成响应中是视频 URL。Manifest 执行器会把网络异常包装成“上游任务创建/轮询网络连接失败”;视频结果保存失败应显示“上游已返回视频地址,但平台下载或保存结果视频失败”。若 job 成功但 `works` 没有记录,查 `[generation-worker] creation history persistence failed` 中的内部 URL。Agnes 时长不要传 `duration`,3/5/10/18 秒映射为 `num_frames` 81/121/241/441,`frame_rate=24`;Agnes 视频是后台异步任务,`/api/generate/video` 给它单独 20 分钟轮询窗口,刷新/切页由 generation job 恢复链路继续显示状态。 |
|
||||
| 元界图生图提交后妙境报 `Manifest 未能从 ... 读取任务 ID` or generic `模型繁忙` while 元界 may have accepted the job | `src/lib/server-api-config.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts` | The submit response can put the task identifier inside nested `result` objects. The executor must normalize `task_id`, `taskId`, `id`, and nested `data/result/output` objects before polling. Template `taskIdPath` should include `result.task_id`, `result.taskId`, and `result.id` before the broad `result` fallback. For system default polling, `resolveSystemApiPollingCandidates(...)` must also run `ensureYuanjieSystemApiManifest(...)`; otherwise stale production `system-api-manifests/<id>.json` files can keep old `$inputImages.dataUrls` and old task-id paths even when source templates are fixed. |
|
||||
| 视频系统模型出现在错误入口或缺少参数选项 | `src/lib/server-api-config.ts`, `src/components/admin/api-management-tab.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/model-capabilities.ts` | Check `system_api_configs.video_usage_modes`. 文生视频 should only show rows including `text-to-video`; 图生视频 should only show rows including `image-to-video`. Selected system video models should read Manifest `capabilities` for aspect ratio, duration, and resolution controls. |
|
||||
| 管理后台刷新后跳回仪表盘 | `src/modules/console/pages/console-dashboard-page.tsx` | The active view should be restored from `sessionStorage` on refresh and removed on logout. If it jumps to dashboard after a plain refresh, inspect the session key `miaojing_console_active_view` and whether the view is still allowed by the current membership/admin config. |
|
||||
|
||||
@@ -35,7 +35,7 @@ Use this document before changing non-generic provider/platform behavior. If a u
|
||||
- Agnes built-in templates belong to the `系统默认模型` management flow. Do not expose them as a generic `智能配置 API` import; keep one system API row per model and one independent `system-api-manifests/<systemApiId>.json` file for each image/video row.
|
||||
- The API base is `https://apihub.agnes-ai.com`. Image models `agnes-image-2.1-flash` and `agnes-image-2.0-flash` use `POST /v1/images/generations` with `model`, `prompt`, `size`, and optional top-level `image: string[]` for image-to-image. URL output must be requested as `extra_body.response_format = "url"`; do not put `response_format` at the top level. Read `data.*.url`, with `data.*.b64_json` as a fallback.
|
||||
- Video model `agnes-video-v2.0` uses `POST /v1/videos` to create an async task and `GET /agnesapi?video_id={video_id}&model_name=agnes-video-v2.0` to poll. Treat `video_id`, `task_id`, or `id` as the task identifier, `completed` as success, `failed` as failure, and read the final video from `remixed_from_video_id`, `video_url`, or `url`.
|
||||
- Agnes Video duration is controlled by `num_frames`, not a `duration` request field. The create route maps UI durations 3/5/10/18 seconds to 24fps `8n+1` frame counts: 73/121/241/433, and sends `frame_rate: 24`.
|
||||
- Agnes Video duration is controlled by `num_frames`, not a `duration` request field. The create route maps UI durations 3/5/10/18 seconds to the documented 24fps frame counts: 81/121/241/441, and sends `frame_rate: 24`.
|
||||
- For image-to-video, Agnes uses the top-level `image` field as the starting/first frame. Do not treat Agnes Video as a generic multi-reference video model unless Agnes adds a separate reference-image field.
|
||||
- Text/multimodal models `agnes-2.0-flash` and `agnes-1.5-flash` use OpenAI-compatible `POST /v1/chat/completions`; they do not need Manifest files and can be used by prompt optimization or reverse prompt through the existing system text API path.
|
||||
- The installer creates Agnes rows as inactive, 0-credit templates with empty API Key fields so admins can fill the Key in the existing system API edit form, review visibility/member scope, and then enable the model.
|
||||
|
||||
@@ -19,9 +19,6 @@ const {
|
||||
buildAgnesVideoManifestBundle,
|
||||
buildAgnesCapabilitiesText,
|
||||
} = await import('../src/lib/agnes-model-templates.ts');
|
||||
const {
|
||||
installAgnesTemplatesWithClient,
|
||||
} = await import('../src/lib/agnes-template-installer.ts');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
@@ -38,95 +35,6 @@ function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function fakeUuid(index) {
|
||||
return `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`;
|
||||
}
|
||||
|
||||
function createFakeClient() {
|
||||
let insertIndex = 0;
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async query(sql, params = []) {
|
||||
const text = String(sql);
|
||||
calls.push({ sql: text, params });
|
||||
if (text === 'BEGIN' || text === 'COMMIT' || text === 'ROLLBACK') return { rows: [], rowCount: 0 };
|
||||
if (text.includes('CREATE TABLE') || text.includes('ALTER TABLE') || text.includes('CREATE INDEX')) return { rows: [], rowCount: 0 };
|
||||
if (text.includes('DELETE FROM system_api_configs')) return { rows: [], rowCount: 0 };
|
||||
if (text.includes('INSERT INTO system_api_configs')) {
|
||||
insertIndex += 1;
|
||||
const id = fakeUuid(insertIndex);
|
||||
return {
|
||||
rows: [{
|
||||
id,
|
||||
provider: params[0],
|
||||
name: params[1],
|
||||
api_url: params[2],
|
||||
model_name: params[3],
|
||||
model_group: params[4],
|
||||
note: params[5],
|
||||
api_key_preview: params[7],
|
||||
type: params[8],
|
||||
credits_per_use: params[9],
|
||||
billing_mode: params[10],
|
||||
fixed_price: params[11],
|
||||
duration_price_per_second: params[12],
|
||||
input_price_per_1k: params[13],
|
||||
output_price_per_1k: params[14],
|
||||
model_ratio: params[15],
|
||||
completion_ratio: params[16],
|
||||
group_ratio: params[17],
|
||||
price_note: params[18],
|
||||
manifest_path: '',
|
||||
is_default: params[19],
|
||||
allowed_membership_tiers: params[20],
|
||||
polling_mode: params[21],
|
||||
polling_order: params[22],
|
||||
video_usage_modes: params[23],
|
||||
is_active: params[24],
|
||||
sort_order: params[25] ?? 0,
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (text.includes('UPDATE system_api_configs')) {
|
||||
return {
|
||||
rows: [{
|
||||
id: params[1],
|
||||
provider: AGNES_PROVIDER_NAME,
|
||||
name: 'updated',
|
||||
api_url: AGNES_BASE_URL,
|
||||
model_name: 'updated',
|
||||
model_group: AGNES_IMAGE_MODEL_GROUP,
|
||||
note: '',
|
||||
api_key_preview: '',
|
||||
type: 'image',
|
||||
credits_per_use: 0,
|
||||
billing_mode: 'free',
|
||||
fixed_price: 0,
|
||||
duration_price_per_second: 0,
|
||||
input_price_per_1k: 0,
|
||||
output_price_per_1k: 0,
|
||||
model_ratio: 1,
|
||||
completion_ratio: 1,
|
||||
group_ratio: 1,
|
||||
price_note: '',
|
||||
manifest_path: params[0],
|
||||
is_default: true,
|
||||
allowed_membership_tiers: ['free', 'pro', 'max', 'ultra'],
|
||||
polling_mode: 'sequential',
|
||||
polling_order: 0,
|
||||
video_usage_modes: ['text-to-video', 'image-to-video'],
|
||||
is_active: false,
|
||||
sort_order: 0,
|
||||
}],
|
||||
rowCount: 1,
|
||||
};
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await runTest('Agnes templates cover documented image, video, and text models', () => {
|
||||
assert.equal(AGNES_BASE_URL, 'https://apihub.agnes-ai.com');
|
||||
assert.equal(AGNES_PROVIDER_NAME, 'Agnes AI');
|
||||
@@ -200,80 +108,51 @@ await runTest('Agnes video Manifest creates async task and polls by video_id', (
|
||||
assert.deepEqual(provider.poll?.result?.videoUrlPaths, ['remixed_from_video_id', 'video_url', 'url']);
|
||||
});
|
||||
|
||||
await runTest('Agnes video duration options map to documented 8n+1 frame counts at 24fps', () => {
|
||||
await runTest('Agnes video duration options map to documented frame counts at 24fps', () => {
|
||||
assert.equal(AGNES_VIDEO_FRAME_RATE, 24);
|
||||
assert.deepEqual(AGNES_VIDEO_MODEL_TEMPLATES[0].capabilities.durations?.map(item => item.value), ['3', '5', '10', '18']);
|
||||
assert.equal(getAgnesVideoNumFrames(3), 73);
|
||||
assert.equal(getAgnesVideoNumFrames(3), 81);
|
||||
assert.equal(getAgnesVideoNumFrames(5), 121);
|
||||
assert.equal(getAgnesVideoNumFrames(10), 241);
|
||||
assert.equal(getAgnesVideoNumFrames(18), 433);
|
||||
assert.equal(getAgnesVideoNumFrames(18), 441);
|
||||
|
||||
const videoRoute = read('src/app/api/generate/video/route.ts');
|
||||
assert.match(videoRoute, /const useAgnesVideoParams = isAgnesVideoApi\(resolvedCustomApiConfig\)/);
|
||||
assert.match(videoRoute, /getAgnesVideoNumFrames\(duration\)/);
|
||||
assert.match(videoRoute, /fps:\s*useAgnesVideoParams\s*\?\s*AGNES_VIDEO_FRAME_RATE\s*:\s*fps/);
|
||||
assert.match(videoRoute, /num_frames:\s*useAgnesVideoParams\s*\?\s*getAgnesVideoNumFrames\(duration\)\s*:\s*undefined/);
|
||||
assert.match(videoRoute, /timeoutMs:\s*useAgnesVideoParams\s*\?\s*AGNES_VIDEO_GENERATION_TIMEOUT\s*:\s*GENERATION_TIMEOUT/);
|
||||
});
|
||||
|
||||
await runTest('Agnes installer creates free inactive rows with empty API key and per-row Manifest files', async () => {
|
||||
const client = createFakeClient();
|
||||
const saved = await installAgnesTemplatesWithClient(client, {
|
||||
syncImageModels: true,
|
||||
syncVideoModels: true,
|
||||
syncTextModels: true,
|
||||
allowedMembershipTiers: ['free', 'pro', 'max', 'ultra'],
|
||||
isDefault: true,
|
||||
saveManifestFile: async ({ keyId }) => `system-api-manifests/${keyId}.json`,
|
||||
});
|
||||
await runTest('Agnes video failures are reported by stage instead of raw fetch failed', () => {
|
||||
const executor = read('src/lib/user-api-manifest-executor.ts');
|
||||
const videoRoute = read('src/app/api/generate/video/route.ts');
|
||||
const worker = read('src/lib/generation-job-worker.ts');
|
||||
|
||||
assert.equal(saved.length, 5);
|
||||
const deleteCalls = client.calls.filter(call => call.sql.includes('DELETE FROM system_api_configs'));
|
||||
assert.equal(deleteCalls.length, 3);
|
||||
assert.deepEqual(deleteCalls.map(call => call.params), [
|
||||
[AGNES_PROVIDER_NAME, 'image'],
|
||||
[AGNES_PROVIDER_NAME, 'video'],
|
||||
[AGNES_PROVIDER_NAME, 'text'],
|
||||
]);
|
||||
assert.match(executor, /const stage = method === 'GET' \? '上游任务轮询' : '上游任务创建'/);
|
||||
assert.match(executor, /网络连接失败,请稍后重试/);
|
||||
assert.match(videoRoute, /上游已返回视频地址,但平台下载或保存结果视频失败/);
|
||||
assert.match(worker, /creation history persistence failed:/);
|
||||
assert.match(worker, /\(\$\{url\}\)/);
|
||||
});
|
||||
|
||||
const insertCalls = client.calls.filter(call => call.sql.includes('INSERT INTO system_api_configs'));
|
||||
assert.equal(insertCalls.length, 5);
|
||||
const rows = insertCalls.map(call => ({
|
||||
provider: call.params[0],
|
||||
name: call.params[1],
|
||||
apiUrl: call.params[2],
|
||||
modelName: call.params[3],
|
||||
modelGroup: call.params[4],
|
||||
apiKeyEncrypted: call.params[6],
|
||||
apiKeyPreview: call.params[7],
|
||||
type: call.params[8],
|
||||
creditsPerUse: call.params[9],
|
||||
billingMode: call.params[10],
|
||||
fixedPrice: call.params[11],
|
||||
durationPricePerSecond: call.params[12],
|
||||
priceNote: call.params[18],
|
||||
isDefault: call.params[19],
|
||||
isActive: call.params[24],
|
||||
}));
|
||||
await runTest('Agnes installer source creates free inactive rows with empty API key and per-row Manifest files', () => {
|
||||
const installer = read('src/lib/agnes-template-installer.ts');
|
||||
|
||||
assert.ok(rows.every(row => row.provider === AGNES_PROVIDER_NAME));
|
||||
assert.ok(rows.every(row => row.apiKeyEncrypted === ''));
|
||||
assert.ok(rows.every(row => row.apiKeyPreview === ''));
|
||||
assert.ok(rows.every(row => row.creditsPerUse === 0));
|
||||
assert.ok(rows.every(row => row.billingMode === 'free'));
|
||||
assert.ok(rows.every(row => row.fixedPrice === 0));
|
||||
assert.ok(rows.every(row => row.isDefault === true));
|
||||
assert.ok(rows.every(row => row.isActive === false));
|
||||
assert.ok(rows.some(row => row.type === 'image' && row.modelGroup === AGNES_IMAGE_MODEL_GROUP && row.apiUrl === `${AGNES_BASE_URL}/v1/images/generations`));
|
||||
assert.ok(rows.some(row => row.type === 'video' && row.modelGroup === AGNES_VIDEO_MODEL_GROUP && row.apiUrl === AGNES_BASE_URL && row.durationPricePerSecond === 0));
|
||||
assert.ok(rows.some(row => row.type === 'text' && row.modelGroup === AGNES_TEXT_MODEL_GROUP && row.apiUrl === `${AGNES_BASE_URL}/v1/chat/completions` && /免费/.test(row.priceNote)));
|
||||
|
||||
const updateCalls = client.calls.filter(call => call.sql.includes('UPDATE system_api_configs'));
|
||||
assert.equal(updateCalls.length, 3, 'only image/video rows should write Manifest files');
|
||||
assert.deepEqual(updateCalls.map(call => call.params[0]), [
|
||||
'system-api-manifests/00000000-0000-4000-8000-000000000001.json',
|
||||
'system-api-manifests/00000000-0000-4000-8000-000000000002.json',
|
||||
'system-api-manifests/00000000-0000-4000-8000-000000000003.json',
|
||||
]);
|
||||
assert.match(installer, /encryptApiKeyForStorage\(''\)/);
|
||||
assert.match(installer, /credits_per_use/);
|
||||
assert.match(installer, /billingMode:\s*'free'/);
|
||||
assert.match(installer, /is_active,\s*sort_order/);
|
||||
assert.match(installer, /false,\s*input\.sortOffset/s);
|
||||
assert.match(installer, /attachManifest\(client,\s*row,\s*bundle,\s*saveManifestFile\)/);
|
||||
assert.match(installer, /syncImageModels/);
|
||||
assert.match(installer, /syncVideoModels/);
|
||||
assert.match(installer, /syncTextModels/);
|
||||
assert.match(installer, /`\$\{AGNES_BASE_URL\}\/v1\/images\/generations`/);
|
||||
assert.match(installer, /`\$\{AGNES_BASE_URL\}\/v1\/chat\/completions`/);
|
||||
assert.match(installer, /const apiUrl = resolveImportedProfileApiUrl\(bundle,\s*profile\) \|\| AGNES_BASE_URL/);
|
||||
assert.match(installer, /saveSystemApiManifestFile/);
|
||||
assert.match(installer, /Agnes 免费模型/);
|
||||
});
|
||||
|
||||
await runTest('admin UI and docs expose Agnes as system-default built-in templates, not smart import', () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ interface CustomApiConfig {
|
||||
}
|
||||
|
||||
const GENERATION_TIMEOUT = 180_000;
|
||||
const AGNES_VIDEO_GENERATION_TIMEOUT = 20 * 60_000;
|
||||
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 1536 * 1024);
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -47,11 +48,20 @@ async function persistMediaToStorage(dataUrl: string, prefix: string): Promise<s
|
||||
async function persistRemoteUrlToStorage(url: string, prefix: string): Promise<string> {
|
||||
if (!url.startsWith('http')) return url;
|
||||
|
||||
const response = await fetchPublicHttpUrlWithRetry(
|
||||
url,
|
||||
{ headers: { Accept: 'video/mp4,video/webm,video/quicktime,video/*,*/*;q=0.8' } },
|
||||
{ attempts: 3, retryDelayMs: 800, timeoutMs: 90_000 },
|
||||
);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchPublicHttpUrlWithRetry(
|
||||
url,
|
||||
{ headers: { Accept: 'video/mp4,video/webm,video/quicktime,video/*,*/*;q=0.8' } },
|
||||
{ attempts: 3, retryDelayMs: 800, timeoutMs: 90_000 },
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '');
|
||||
if (/fetch failed|network|timeout|aborted|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/i.test(message)) {
|
||||
throw new Error('上游已返回视频地址,但平台下载或保存结果视频失败:网络连接失败,请稍后重试');
|
||||
}
|
||||
throw new Error(`上游已返回视频地址,但平台下载或保存结果视频失败:${message || '未知错误'}`);
|
||||
}
|
||||
if (!response.ok) throw new Error(`Failed to fetch generated video: ${response.status}`);
|
||||
const mimeType = response.headers.get('content-type')?.split(';')[0] || getVideoMimeType(url);
|
||||
const ext = getVideoExtension(mimeType, url);
|
||||
@@ -568,7 +578,7 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
inputImages: referenceImages,
|
||||
preferEdit: referenceImages.length > 0,
|
||||
timeoutMs: GENERATION_TIMEOUT,
|
||||
timeoutMs: useAgnesVideoParams ? AGNES_VIDEO_GENERATION_TIMEOUT : GENERATION_TIMEOUT,
|
||||
onProgress: handleUpstreamProgress,
|
||||
});
|
||||
if (manifestResult) {
|
||||
@@ -576,7 +586,16 @@ export async function POST(request: NextRequest) {
|
||||
if (media.length === 0) {
|
||||
return NextResponse.json({ error: '自定义 Manifest 未返回有效视频数据' }, { status: 502 });
|
||||
}
|
||||
const persistedVideos = await persistAllMediaUrls(media, 'generated/videos');
|
||||
let persistedVideos: string[];
|
||||
try {
|
||||
persistedVideos = await persistAllMediaUrls(media, 'generated/videos');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '');
|
||||
return NextResponse.json(
|
||||
{ error: message || '上游已返回视频结果,但平台下载或保存结果视频失败' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ videos: persistedVideos });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,13 @@ export function getAgnesVideoNumFrames(duration: number | string | undefined): n
|
||||
const seconds = Number.isFinite(parsed)
|
||||
? Math.min(Math.max(Math.round(parsed), AGNES_VIDEO_MIN_DURATION), AGNES_VIDEO_MAX_DURATION)
|
||||
: 5;
|
||||
return seconds * AGNES_VIDEO_FRAME_RATE + 1;
|
||||
const documentedFrameCounts: Record<number, number> = {
|
||||
3: 81,
|
||||
5: 121,
|
||||
10: 241,
|
||||
18: 441,
|
||||
};
|
||||
return documentedFrameCounts[seconds] || (seconds * AGNES_VIDEO_FRAME_RATE + 1);
|
||||
}
|
||||
|
||||
const agnesImageResolutions = [
|
||||
|
||||
@@ -200,16 +200,23 @@ async function persistGenerationHistoryRecord(input: {
|
||||
const records = buildGenerationHistoryRecords(input.jobId, input.type, input.payload, input.result);
|
||||
if (records.length === 0) return;
|
||||
|
||||
const res = await fetch(`${getInternalBaseUrl()}/api/creation-history`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getInternalGenerationHeaders(),
|
||||
'x-miaojing-generation-user-id': input.userId,
|
||||
'x-miaojing-generation-job-id': input.jobId,
|
||||
},
|
||||
body: JSON.stringify({ records }),
|
||||
});
|
||||
const url = `${getInternalBaseUrl()}/api/creation-history`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getInternalGenerationHeaders(),
|
||||
'x-miaojing-generation-user-id': input.userId,
|
||||
'x-miaojing-generation-job-id': input.jobId,
|
||||
},
|
||||
body: JSON.stringify({ records }),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '');
|
||||
throw new Error(`creation history persistence failed: ${message || 'request failed'} (${url})`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(typeof data.error === 'string' ? data.error : `creation history persistence failed (${res.status})`);
|
||||
|
||||
@@ -332,6 +332,17 @@ function extractMediaFromResult(raw: unknown, endpoint: ManifestEndpoint | Manif
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFetchErrorMessage(error: unknown, stage: string): string {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return `${stage}超时,请稍后重试`;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error || '');
|
||||
if (/fetch failed|network|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/i.test(message)) {
|
||||
return `${stage}网络连接失败,请稍后重试`;
|
||||
}
|
||||
return message || `${stage}失败`;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -350,17 +361,27 @@ async function requestManifestEndpoint(
|
||||
? await buildRequestBody(endpoint, input)
|
||||
: { body: method === 'GET' ? undefined : JSON.stringify(query || {}), headers: buildCustomApiHeaders(input.apiKey) };
|
||||
|
||||
const response = await fetchWithRetry(
|
||||
url,
|
||||
{ method, headers, body: method === 'GET' ? undefined : body },
|
||||
input.timeoutMs,
|
||||
1,
|
||||
);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithRetry(
|
||||
url,
|
||||
{ method, headers, body: method === 'GET' ? undefined : body },
|
||||
input.timeoutMs,
|
||||
1,
|
||||
);
|
||||
} catch (error) {
|
||||
const stage = method === 'GET' ? '上游任务轮询' : '上游任务创建';
|
||||
throw new Error(normalizeFetchErrorMessage(error, stage));
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(parseCustomApiError(response.status, errorText));
|
||||
}
|
||||
return parseCustomApiJsonWithProgress(response, input.onProgress);
|
||||
try {
|
||||
return await parseCustomApiJsonWithProgress(response, input.onProgress);
|
||||
} catch (error) {
|
||||
throw new Error(normalizeFetchErrorMessage(error, '上游响应解析'));
|
||||
}
|
||||
}
|
||||
|
||||
async function pollManifestResult(
|
||||
|
||||
Reference in New Issue
Block a user