fix: map Agnes video duration to frames

This commit is contained in:
FengLee
2026-06-06 14:23:40 +08:00
parent 48666447fb
commit b15843bbee
5 changed files with 46 additions and 1 deletions

View File

@@ -184,6 +184,8 @@ Primary SQL tables touched directly in API routes include:
Yuanjie Manifest references use `$inputImages.urls` for provider-facing JSON fields. For image-to-image, `/api/generate/image` reads the primary `image` plus `extraImages` and sends all references to `src/lib/user-api-manifest-executor.ts`; for image-to-video, `/api/generate/video` reads `image`, `images`, and `extraImages` before Manifest execution. The executor uploads data URL references into storage before rendering Yuanjie `params.images`, top-level `images`, `reference_urls`, or `base64Array`. `referenceImageAnnotations` is an API payload field rather than a Manifest variable; image/video routes use `src/lib/reference-image-prompt.ts` to merge `@参考图N` token mappings into the upstream prompt so existing Manifest templates receive the mapping through `$prompt`. Yuanjie video templates keep documented model-specific fields inside `src/lib/yuanjie-video-model-templates.ts`, including first/last reference fields and mode fields such as `input_reference`, `reference_urls`, `img_url`, `image_tail`, `ratio`, `size`, and `generation_mode`.
`src/lib/agnes-model-templates.ts` is the canonical source for Agnes AI built-in free templates. Agnes Video V2.0 uses Manifest `POST /v1/videos` plus `/agnesapi` polling, but duration must be sent as `num_frames` rather than `duration`. `/api/generate/video` maps Agnes UI durations 3/5/10/18 seconds to 24fps frame counts 73/121/241/433 and sends `frame_rate: 24`; in image-to-video mode the top-level `image` is the provider's starting/first frame field, not a generic non-first-frame reference slot.
`src/lib/yuanjie-system-manifest.ts` provides the runtime bridge for existing admin system API rows that were created before Manifest-backed Yuanjie templates. It exposes built-in capabilities to `/api/model-config` even when `manifest_path` is empty, and when a known 元界 system API is resolved directly or as a default-model polling candidate it writes missing or stale `system-api-manifests/<systemApiId>.json`, normalizes `api_url` back to the 元界 base URL, and preserves the encrypted API key and administrator pricing.
Profile naming convention: `profiles.nickname` is the stable login username; `profiles.display_nickname` is the public nickname shown in navbar/gallery/profile UI. APIs return `username` plus `nickname`/`display_nickname` so older clients can keep reading `nickname` as the display name.

View File

@@ -35,6 +35,8 @@ 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`.
- 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.

View File

@@ -13,6 +13,8 @@ const {
AGNES_IMAGE_MODEL_TEMPLATES,
AGNES_VIDEO_MODEL_TEMPLATES,
AGNES_TEXT_MODEL_TEMPLATES,
AGNES_VIDEO_FRAME_RATE,
getAgnesVideoNumFrames,
buildAgnesImageManifestBundle,
buildAgnesVideoManifestBundle,
buildAgnesCapabilitiesText,
@@ -181,6 +183,7 @@ await runTest('Agnes video Manifest creates async task and polls by video_id', (
assert.equal(provider.submit?.body?.model, '$profile.model');
assert.equal(provider.submit?.body?.prompt, '$prompt');
assert.equal(provider.submit?.body?.image, '$inputImages.urls.0');
assert.equal(provider.submit?.body?.num_frames, '$params.num_frames');
assert.equal(provider.submit?.body?.negative_prompt, '$params.negative_prompt');
assert.equal(provider.submit?.body?.frame_rate, '$params.fps');
assert.equal(provider.submit?.body?.width, '$params.width');
@@ -197,6 +200,21 @@ 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', () => {
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(5), 121);
assert.equal(getAgnesVideoNumFrames(10), 241);
assert.equal(getAgnesVideoNumFrames(18), 433);
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/);
});
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, {

View File

@@ -13,6 +13,7 @@ import {
import { executeUserApiManifest } from '@/lib/user-api-manifest-executor';
import { buildReferenceImagePrompt } from '@/lib/reference-image-prompt';
import { fetchPublicHttpUrlWithRetry } from '@/lib/remote-fetch';
import { AGNES_PROVIDER_NAME, AGNES_VIDEO_FRAME_RATE, getAgnesVideoNumFrames } from '@/lib/agnes-model-templates';
interface CustomApiConfig {
apiUrl: string;
@@ -125,6 +126,14 @@ function getVideoExtension(mimeType: string, url = ''): string {
return ext && /^(mp4|webm|mov|avi|m4v)$/i.test(ext) ? ext : 'mp4';
}
function isAgnesVideoApi(config: { provider?: string; modelName?: string }): boolean {
const provider = (config.provider || '').toLowerCase().replace(/\s+/g, '');
const modelName = (config.modelName || '').toLowerCase();
return provider === AGNES_PROVIDER_NAME.toLowerCase().replace(/\s+/g, '')
|| provider.includes('agnes')
|| modelName.startsWith('agnes-video-');
}
async function uploadDataUrlAndGetPublicUrl(dataUrl: string): Promise<string | null> {
try {
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
@@ -539,6 +548,7 @@ export async function POST(request: NextRequest) {
const resolvedApiKey = resolvedCustomApiConfig.apiKey;
try {
if (resolvedCustomApiConfig.manifestPath) {
const useAgnesVideoParams = isAgnesVideoApi(resolvedCustomApiConfig);
const manifestResult = await executeUserApiManifest({
manifestPath: resolvedCustomApiConfig.manifestPath,
apiUrl: resolvedCustomApiConfig.apiUrl,
@@ -552,7 +562,8 @@ export async function POST(request: NextRequest) {
resolution,
quality: quality || mode,
mode: mode || quality,
fps,
fps: useAgnesVideoParams ? AGNES_VIDEO_FRAME_RATE : fps,
num_frames: useAgnesVideoParams ? getAgnesVideoNumFrames(duration) : undefined,
negative_prompt: negativePrompt,
},
inputImages: referenceImages,

View File

@@ -33,6 +33,18 @@ export type AgnesTextModelTemplate = {
const option = (value: string, label = value) => ({ value, label });
const options = (values: string[]) => values.map(value => option(value));
export const AGNES_VIDEO_FRAME_RATE = 24;
const AGNES_VIDEO_MIN_DURATION = 3;
const AGNES_VIDEO_MAX_DURATION = 18;
export function getAgnesVideoNumFrames(duration: number | string | undefined): number {
const parsed = Number(duration);
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 agnesImageResolutions = [
option('1024x768', '横版 1024x768'),
option('1024x1024', '正方形 1024x1024'),