diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index ed16889a0..ce81cfce2 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -1587,7 +1587,7 @@ const PROVIDER_TYPES = [
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "bananaimage", label: "BananaImage 生图" },
{ value: "visionary", label: "Visionary 生图" },
- { value: "atlascloud", label: "Atlas Cloud 图片 / 3D 模型" },
+ { value: "atlascloud", label: "Atlas Cloud 图片 / 视频 / 3D 模型" },
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
{ value: "bltcy-video", label: "柏拉图统一视频" },
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
@@ -6473,7 +6473,7 @@ export function AdminSettingsClient() {
} catch (modelError) {
console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
toast.warning(
- 'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 图片 / 3D 模型"按钮'
+ 'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 模型"按钮'
);
}
}
@@ -7534,7 +7534,7 @@ export function AdminSettingsClient() {
if (
!options?.skipConfirm &&
!confirm(
- "将从 Atlas Cloud 官方目录同步全部开放图片与 3D 模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
+ "将从 Atlas Cloud 官方目录同步图片、视频与 3D 模型,并读取每个模型的参数 Schema。已删除的模型不会被恢复。同步数量较多,可能需要几十秒。\n\n继续吗?"
)
) {
return;
@@ -7553,7 +7553,7 @@ export function AdminSettingsClient() {
const totalSynced =
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
toast.success(
- `已同步 ${totalSynced} 个 Atlas Cloud 图片 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
+ `已同步 ${totalSynced} 个 Atlas Cloud 图片 / 视频 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
);
mutateModels();
if (data.errors?.length) {
@@ -7561,7 +7561,7 @@ export function AdminSettingsClient() {
}
} catch (error: any) {
console.error("[Quick Add Atlas Cloud Models] Error:", error);
- toast.error(error.message || "同步 Atlas Cloud 图片 / 3D 模型失败");
+ toast.error(error.message || "同步 Atlas Cloud 模型失败");
throw error;
}
};
@@ -15943,7 +15943,7 @@ export function AdminSettingsClient() {
variant="secondary"
>
- 同步 Atlas Cloud 图片 / 3D 模型
+ 同步 Atlas Cloud 模型
)}
{provider.type === "bltcy-video" && (
diff --git a/app/api/admin/atlascloud-models/route.ts b/app/api/admin/atlascloud-models/route.ts
index 44ae614a1..965957638 100644
--- a/app/api/admin/atlascloud-models/route.ts
+++ b/app/api/admin/atlascloud-models/route.ts
@@ -1,8 +1,9 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import {
- fetchAtlasCloudImagePresets,
+ fetchAtlasCloudModelPresets,
resolveAtlasCloudDisplayName,
+ shouldInsertMissingAtlasCloudPreset,
} from "@/lib/ai/atlascloud-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
@@ -15,7 +16,7 @@ export const maxDuration = 300;
export async function GET() {
try {
await requireAdmin();
- const result = await fetchAtlasCloudImagePresets();
+ const result = await fetchAtlasCloudModelPresets();
return NextResponse.json({
presets: result.presets,
errors: result.errors.length > 0 ? result.errors : undefined,
@@ -32,7 +33,7 @@ export async function GET() {
error:
error instanceof Error
? error.message
- : "Failed to fetch Atlas Cloud image models",
+ : "Failed to fetch Atlas Cloud models",
},
{ status: 500 }
);
@@ -61,22 +62,39 @@ export async function POST(request: Request) {
);
}
- const catalog = await fetchAtlasCloudImagePresets();
+ const catalog = await fetchAtlasCloudModelPresets();
const presets = requestedModelIds
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
: catalog.presets;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors = [...catalog.errors];
+ const providerModels = await db.query.aiModel.findMany({
+ where: eq(aiModel.providerId, providerId),
+ });
+ const existingByModelId = new Map(
+ providerModels.map((model) => [model.modelId, model])
+ );
+ const existingMediaKinds = new Set(
+ providerModels.map((model) => {
+ const capabilities = model.capabilities as Record | null;
+ return String(capabilities?.mediaType || "image");
+ })
+ );
+ let preservedDeletedCount = 0;
for (const preset of presets) {
try {
- const existing = await db.query.aiModel.findFirst({
- where: and(
- eq(aiModel.providerId, providerId),
- eq(aiModel.modelId, preset.modelId)
- ),
- });
+ const existing = existingByModelId.get(preset.modelId);
+ const presetCapabilities = preset.capabilities as Record;
+ const mediaKind = String(presetCapabilities.mediaType || "image");
+ if (
+ !existing &&
+ !shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
+ ) {
+ preservedDeletedCount += 1;
+ continue;
+ }
const values = {
displayName: resolveAtlasCloudDisplayName(
existing?.displayName,
@@ -87,9 +105,7 @@ export async function POST(request: Request) {
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
- isEnabled: true,
- isHidden: false,
- sortOrder: preset.sortOrder,
+ sortOrder: existing?.sortOrder ?? preset.sortOrder,
capabilities: preset.capabilities,
defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig,
@@ -115,6 +131,8 @@ export async function POST(request: Request) {
providerId,
modelId: preset.modelId,
...values,
+ isEnabled: true,
+ isHidden: false,
isDefault: false,
isSubModel: false,
parentModelId: null,
@@ -145,6 +163,8 @@ export async function POST(request: Request) {
requested: presets.length,
total: catalog.catalogCount,
available: catalog.presets.length,
+ skippedUnsupported: catalog.skipped.length,
+ preservedDeleted: preservedDeletedCount,
},
});
} catch (error) {
@@ -154,7 +174,7 @@ export async function POST(request: Request) {
error:
error instanceof Error
? error.message
- : "Failed to sync Atlas Cloud image models",
+ : "Failed to sync Atlas Cloud models",
},
{ status: 500 }
);
diff --git a/lib/ai/atlascloud-models.ts b/lib/ai/atlascloud-models.ts
index 342865628..5a2cc4970 100644
--- a/lib/ai/atlascloud-models.ts
+++ b/lib/ai/atlascloud-models.ts
@@ -29,8 +29,25 @@ const IMAGE_INPUT_KEYS = [
"reference_image",
];
const RESOLUTION_KEYS = ["resolution", "image_size", "output_resolution"];
-const ASPECT_RATIO_KEYS = ["aspect_ratio", "aspectRatio"];
+const VIDEO_RESOLUTION_KEYS = [
+ "resolution",
+ "output_resolution",
+ "target_resolution",
+];
+const ASPECT_RATIO_KEYS = ["aspect_ratio", "aspectRatio", "ratio"];
const OUTPUT_COUNT_KEYS = ["num_images", "number_of_images", "n", "output_count"];
+const VIDEO_DURATION_KEYS = ["duration", "video_duration", "seconds"];
+const VIDEO_REQUEST_KEYS = new Set([
+ "model",
+ "prompt",
+ ...VIDEO_RESOLUTION_KEYS,
+ ...ASPECT_RATIO_KEYS,
+ ...VIDEO_DURATION_KEYS,
+ "seed",
+ "watermark",
+ "camera_fixed",
+ "return_last_frame",
+]);
export interface AtlasCloudCatalogModel {
uuid?: string;
@@ -67,14 +84,17 @@ export interface AtlasCloudRuntimeConfig {
itemDefaults: Record;
};
resolutionKey?: string;
+ durationKey?: string;
aspectRatioKey?: string;
outputCountKey?: string;
aspectRatioValueMap: Record;
+ resolutionValueMap?: Record;
maxReferenceImages: number;
maxVideoReferences: number;
maxImages: number;
defaults: Record;
enums: Record>;
+ numericRanges?: Record;
sizeValueMap: Record>;
}
@@ -106,6 +126,13 @@ export function resolveAtlasCloudDisplayName(
return String(catalogDisplayName || "").trim() || modelId;
}
+export function shouldInsertMissingAtlasCloudPreset(
+ existingMediaKinds: ReadonlySet,
+ mediaKind: string
+) {
+ return !existingMediaKinds.has(mediaKind);
+}
+
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
@@ -342,6 +369,7 @@ function closestCommonRatio(ratio: string) {
function normalizeAspectRatioEnum(value: unknown) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "auto") return "auto";
+ if (normalized === "adaptive") return "adaptive";
if (/^\d+:\d+$/.test(normalized)) return normalized;
if (normalized.startsWith("square")) return "1:1";
const namedRatio = normalized.match(
@@ -352,6 +380,115 @@ function normalizeAspectRatioEnum(value: unknown) {
return orientation === "portrait" ? `${second}:${first}` : `${first}:${second}`;
}
+function normalizeVideoResolution(value: unknown) {
+ const normalized = String(value || "").trim().toLowerCase();
+ const match = normalized.match(/(?:^|[^0-9])(480|720|1080)p(?:$|[^0-9])/);
+ return match?.[1] ? `${match[1]}p` : "";
+}
+
+function buildVideoResolutionValueMap(
+ properties: JsonRecord,
+ resolutionKey?: string
+) {
+ if (!resolutionKey) return {};
+ const map: Record = {};
+ for (const value of getEnumValues(properties[resolutionKey])) {
+ const normalized = normalizeVideoResolution(value);
+ if (!normalized) continue;
+ const raw = String(value);
+ const current = map[normalized];
+ if (!current || raw.toLowerCase() === normalized) {
+ map[normalized] = raw;
+ }
+ }
+ return map;
+}
+
+function buildNumericRanges(properties: JsonRecord) {
+ const ranges: Record = {};
+ for (const [key, property] of Object.entries(properties)) {
+ const record = asRecord(property);
+ const min = Number(record.minimum ?? record.min);
+ const max = Number(record.maximum ?? record.max);
+ const range = {
+ ...(Number.isFinite(min) ? { min } : {}),
+ ...(Number.isFinite(max) ? { max } : {}),
+ };
+ if (Object.keys(range).length > 0) ranges[key] = range;
+ }
+ return ranges;
+}
+
+function deriveVideoDurationOptions(
+ properties: JsonRecord,
+ durationKey?: string
+) {
+ if (!durationKey) return [];
+ return Array.from(
+ new Set(
+ getEnumValues(properties[durationKey])
+ .map(Number)
+ .filter((value) => Number.isFinite(value) && value > 0)
+ .map((value) => Math.round(value))
+ )
+ ).sort((a, b) => a - b);
+}
+
+function deriveAtlasCloudVideoModes(params: {
+ catalogModel: AtlasCloudCatalogModel;
+ properties: JsonRecord;
+ requiredFields: string[];
+ imageInputs: AtlasCloudRuntimeConfig["imageInputs"];
+}) {
+ const { catalogModel, properties, requiredFields, imageInputs } = params;
+ const intentText = uniqueStrings([
+ catalogModel.model,
+ catalogModel.displayName,
+ ...(catalogModel.categories || []),
+ ...(catalogModel.tags || []),
+ ])
+ .map(normalizeIntentToken)
+ .join(" ");
+ const imageKeys = new Set(imageInputs.map((input) => input.key));
+ const hasUnsupportedRequiredInput = requiredFields.some((key) => {
+ if (imageKeys.has(key) || VIDEO_REQUEST_KEYS.has(key)) return false;
+ const defaultValue = asRecord(properties[key]).default;
+ return !["string", "number", "boolean"].includes(typeof defaultValue);
+ });
+ const requiredImages = imageInputs.filter((input) => input.required);
+ const singleImageInputs = imageInputs.filter((input) => input.mode === "single");
+ const arrayImageInput = imageInputs.find(
+ (input) => input.mode === "array" && input.maxItems > 1
+ );
+ const modes: string[] = [];
+
+ if (
+ !hasUnsupportedRequiredInput &&
+ requiredImages.length === 0 &&
+ ("prompt" in properties || intentText.includes("text-to-video"))
+ ) {
+ modes.push("text-to-video");
+ }
+ if (!hasUnsupportedRequiredInput && imageInputs.length > 0) {
+ if (requiredImages.length <= 1) modes.push("first-frame");
+ if (
+ singleImageInputs.length >= 2 ||
+ /(?:first|start).*(?:last|end)|(?:last|end).*(?:first|start)/.test(intentText)
+ ) {
+ modes.push("first-last-frame");
+ }
+ if (
+ arrayImageInput ||
+ imageInputs.length > 2 ||
+ /reference-to-video|reference-images/.test(intentText)
+ ) {
+ modes.push("reference-images");
+ }
+ }
+
+ return uniqueStrings(modes);
+}
+
function buildAspectRatioValueMap(properties: JsonRecord) {
for (const key of [...ASPECT_RATIO_KEYS, "image_size"]) {
if (!(key in properties)) continue;
@@ -449,20 +586,37 @@ export function createAtlasCloudImagePreset(
const imageInputs = getImageInputs(properties, requiredFields);
const videoClipInput = getVideoClipInput(properties, requiredFields);
const imageInputKey = imageInputs[0]?.key;
- const resolutionKey = RESOLUTION_KEYS.find((key) => {
+ const mediaKind = inferAtlasCloudMediaKind(catalogModel);
+ const resolutionCandidates =
+ mediaKind === "video" ? VIDEO_RESOLUTION_KEYS : RESOLUTION_KEYS;
+ const resolutionKey = resolutionCandidates.find((key) => {
if (!(key in properties)) return false;
const values = getEnumValues(properties[key]);
- return values.length === 0 || values.some((value) => Boolean(normalizeKSize(value)));
+ return (
+ values.length === 0 ||
+ values.some((value) =>
+ mediaKind === "video"
+ ? Boolean(normalizeVideoResolution(value))
+ : Boolean(normalizeKSize(value))
+ )
+ );
});
+ const durationKey = findFirstKey(properties, VIDEO_DURATION_KEYS);
const aspectRatioConfig = buildAspectRatioValueMap(properties);
const aspectRatioKey = aspectRatioConfig.key;
const outputCountKey = findFirstKey(properties, OUTPUT_COUNT_KEYS);
const sizeValueMap = buildSizeValueMap(getEnumValues(properties.size));
- const supportedImageSizes = deriveSupportedImageSizes(
+ const supportedImageSizes =
+ mediaKind === "video"
+ ? []
+ : deriveSupportedImageSizes(properties, resolutionKey, sizeValueMap);
+ const resolutionValueMap = buildVideoResolutionValueMap(
properties,
- resolutionKey,
- sizeValueMap
+ mediaKind === "video" ? resolutionKey : undefined
);
+ const supportedVideoResolutions = Object.keys(resolutionValueMap);
+ const durationOptions = deriveVideoDurationOptions(properties, durationKey);
+ const numericRanges = buildNumericRanges(properties);
const supportedAspectRatios = deriveAspectRatios(
aspectRatioConfig.map,
sizeValueMap
@@ -477,10 +631,16 @@ export function createAtlasCloudImagePreset(
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
value.toLowerCase()
);
- const mediaKind = inferAtlasCloudMediaKind(catalogModel);
const supportedModes =
mediaKind === "3d"
? derive3DSupportedModes(catalogModel)
+ : mediaKind === "video"
+ ? deriveAtlasCloudVideoModes({
+ catalogModel,
+ properties,
+ requiredFields,
+ imageInputs,
+ })
: [
...(categories.some((value) => value.includes("text-to-image"))
? ["text-to-image"]
@@ -505,14 +665,17 @@ export function createAtlasCloudImagePreset(
imageInputMode: imageProperty.type === "array" ? "array" : "single",
videoClipInput,
resolutionKey,
+ durationKey,
aspectRatioKey,
outputCountKey,
aspectRatioValueMap: aspectRatioConfig.map,
+ resolutionValueMap,
maxReferenceImages,
maxVideoReferences,
maxImages,
defaults: buildScalarDefaults(properties),
enums: buildEnums(properties),
+ numericRanges,
sizeValueMap,
};
@@ -524,10 +687,23 @@ export function createAtlasCloudImagePreset(
supportedImageSizes[0] ||
"1K";
const defaultAspectRatio =
- supportedAspectRatios.find((value) => value === "auto") ||
+ supportedAspectRatios.find((value) =>
+ mediaKind === "video" ? value === "adaptive" : value === "auto"
+ ) ||
supportedAspectRatios.find((value) => value === "1:1") ||
supportedAspectRatios[0] ||
- "1:1";
+ (mediaKind === "video" ? "16:9" : "1:1");
+ const defaultVideoResolution =
+ supportedVideoResolutions.find((value) => value === "720p") ||
+ supportedVideoResolutions[0] ||
+ "720p";
+ const durationRange = numericRanges[durationKey || ""] || {};
+ const defaultDuration =
+ Number(runtimeConfig.defaults[durationKey || ""]) ||
+ durationOptions.find((value) => value === 5) ||
+ durationOptions[0] ||
+ durationRange.min ||
+ 5;
return {
modelId: catalogModel.model,
@@ -550,6 +726,27 @@ export function createAtlasCloudImagePreset(
: "text-to-image",
mediaType: mediaKind,
supportedModes,
+ ...(mediaKind === "video"
+ ? {
+ supportedResolutions: supportedVideoResolutions,
+ supportedRatios: supportedAspectRatios,
+ durationOptions,
+ durationRange: {
+ min:
+ durationOptions[0] ||
+ durationRange.min ||
+ defaultDuration,
+ max:
+ durationOptions[durationOptions.length - 1] ||
+ durationRange.max ||
+ defaultDuration,
+ },
+ supportWatermark: "watermark" in properties,
+ supportCameraFixed: "camera_fixed" in properties,
+ supportSeed: "seed" in properties,
+ supportReturnLastFrame: "return_last_frame" in properties,
+ }
+ : {}),
maxReferenceImages,
maxVideoReferences,
supportsMultiImageReference: maxReferenceImages > 1,
@@ -559,13 +756,24 @@ export function createAtlasCloudImagePreset(
schemaUrl: catalogModel.schema || null,
},
defaultParams: {
- imageSize: defaultImageSize,
- aspectRatio: defaultAspectRatio,
- imageCount: 1,
+ ...(mediaKind === "video"
+ ? {
+ resolution: defaultVideoResolution,
+ ratio: defaultAspectRatio,
+ duration: defaultDuration,
+ }
+ : {
+ imageSize: defaultImageSize,
+ aspectRatio: defaultAspectRatio,
+ imageCount: 1,
+ }),
runtimeModelId: catalogModel.model,
},
imageGenConfig: {
- apiFormat: "atlascloud-unified-image",
+ apiFormat:
+ mediaKind === "video"
+ ? "atlascloud-unified-video"
+ : "atlascloud-unified-image",
maxImages,
maxReferenceImages,
maxVideoReferences,
@@ -575,6 +783,7 @@ export function createAtlasCloudImagePreset(
supports4K: supportedImageSizes.includes("4K"),
supportedImageSizes,
supportedAspectRatios,
+ videoDurationOptions: durationOptions,
supportsSeed: "seed" in properties,
supportsNegativePrompt: "negative_prompt" in properties,
supportsWatermark: "watermark" in properties,
@@ -602,7 +811,7 @@ async function mapWithConcurrency(
return results;
}
-export async function fetchAtlasCloudImagePresets() {
+async function fetchAtlasCloudPresets(mediaTypes: Array<"image" | "video">) {
const catalogResponse = await fetch(ATLASCLOUD_CATALOG_URL, {
cache: "no-store",
headers: { Accept: "application/json", "X-Source-From": "website" },
@@ -618,7 +827,9 @@ export async function fetchAtlasCloudImagePresets() {
const models = catalogModels
.filter(
(model: AtlasCloudCatalogModel) =>
- String(model?.type || "").toLowerCase() === "image" &&
+ mediaTypes.includes(
+ String(model?.type || "").toLowerCase() as "image" | "video"
+ ) &&
model?.display_console !== false &&
Boolean(model?.model) &&
Boolean(model?.schema)
@@ -638,10 +849,28 @@ export async function fetchAtlasCloudImagePresets() {
if (!schemaResponse.ok) {
throw new Error(`HTTP ${schemaResponse.status}`);
}
- return {
- preset: createAtlasCloudImagePreset(model, await schemaResponse.json(), index),
- error: null,
- };
+ const preset = createAtlasCloudImagePreset(
+ model,
+ await schemaResponse.json(),
+ index
+ );
+ const supportedModes = Array.isArray(preset.capabilities.supportedModes)
+ ? preset.capabilities.supportedModes
+ : [];
+ if (
+ preset.capabilities.mediaType === "video" &&
+ supportedModes.length === 0
+ ) {
+ return {
+ preset: null,
+ error: null,
+ skipped: {
+ modelId: model.model,
+ reason: "当前视频工具不支持该模型要求的视频或音频输入",
+ },
+ };
+ }
+ return { preset, error: null, skipped: null };
} catch (error) {
return {
preset: null,
@@ -649,6 +878,7 @@ export async function fetchAtlasCloudImagePresets() {
modelId: model.model,
error: error instanceof Error ? error.message : String(error),
},
+ skipped: null,
};
}
});
@@ -656,6 +886,19 @@ export async function fetchAtlasCloudImagePresets() {
return {
presets: settled.flatMap((result) => (result.preset ? [result.preset] : [])),
errors: settled.flatMap((result) => (result.error ? [result.error] : [])),
+ skipped: settled.flatMap((result) => (result.skipped ? [result.skipped] : [])),
catalogCount: models.length,
};
}
+
+export function fetchAtlasCloudImagePresets() {
+ return fetchAtlasCloudPresets(["image"]);
+}
+
+export function fetchAtlasCloudVideoPresets() {
+ return fetchAtlasCloudPresets(["video"]);
+}
+
+export function fetchAtlasCloudModelPresets() {
+ return fetchAtlasCloudPresets(["image", "video"]);
+}
diff --git a/lib/ai/atlascloud-request.ts b/lib/ai/atlascloud-request.ts
index 4607c4853..79455fe64 100644
--- a/lib/ai/atlascloud-request.ts
+++ b/lib/ai/atlascloud-request.ts
@@ -1,4 +1,5 @@
import type { AtlasCloudRuntimeConfig } from "@/lib/ai/atlascloud-models";
+import type { VideoGenerationOptions } from "@/types/ai";
export interface AtlasCloudImageParams {
aspectRatio?: string;
@@ -19,6 +20,10 @@ export interface AtlasCloudModelConfig {
atlasCloud?: AtlasCloudRuntimeConfig;
}
+export interface AtlasCloudVideoModelConfig extends AtlasCloudModelConfig {
+ videoDurationOptions?: number[];
+}
+
function normalizeAspectRatio(
requested: string | undefined,
supported: string[] | undefined
@@ -207,3 +212,121 @@ export function createAtlasCloudRequestBody(params: {
}
return { body, imageSize };
}
+
+function setAtlasCloudImages(params: {
+ body: Record;
+ runtime: AtlasCloudRuntimeConfig;
+ images: string[];
+}) {
+ const imageInputs = params.runtime.imageInputs?.length
+ ? params.runtime.imageInputs
+ : params.runtime.imageInputKey
+ ? [
+ {
+ key: params.runtime.imageInputKey,
+ mode: params.runtime.imageInputMode || ("single" as const),
+ maxItems: Math.max(1, params.runtime.maxReferenceImages || 1),
+ required: params.runtime.requiredFields.includes(
+ params.runtime.imageInputKey
+ ),
+ },
+ ]
+ : [];
+ let cursor = 0;
+ for (const input of imageInputs) {
+ const values = params.images.slice(cursor, cursor + input.maxItems);
+ if (values.length > 0) {
+ params.body[input.key] = input.mode === "single" ? values[0] : values;
+ cursor += values.length;
+ } else if (input.required) {
+ throw new Error(`该 Atlas Cloud 视频模型至少需要 ${cursor + 1} 张参考图`);
+ }
+ }
+ if (imageInputs.length === 0 && params.images.length > 0) {
+ throw new Error("当前 Atlas Cloud 视频模型不支持参考图");
+ }
+}
+
+export function createAtlasCloudVideoRequestBody(params: {
+ modelId: string;
+ options: VideoGenerationOptions;
+ referenceImages: string[];
+ modelConfig: AtlasCloudVideoModelConfig;
+}) {
+ const runtime = params.modelConfig.atlasCloud;
+ if (!runtime) {
+ throw new Error("Atlas Cloud 视频模型运行参数缺失,请重新同步模型");
+ }
+ const body: Record = { ...runtime.defaults };
+ if (
+ runtime.propertyNames.includes("model") ||
+ runtime.requiredFields.includes("model")
+ ) {
+ body.model = params.modelId;
+ }
+ if (
+ runtime.propertyNames.includes("prompt") ||
+ runtime.requiredFields.includes("prompt")
+ ) {
+ body.prompt = params.options.prompt;
+ }
+
+ if (runtime.resolutionKey && params.options.resolution) {
+ body[runtime.resolutionKey] =
+ runtime.resolutionValueMap?.[params.options.resolution] ||
+ matchEnumValue(
+ runtime.enums[runtime.resolutionKey],
+ params.options.resolution
+ ) ||
+ params.options.resolution;
+ }
+ if (runtime.aspectRatioKey && params.options.ratio) {
+ const requestedRatio = params.options.ratio;
+ body[runtime.aspectRatioKey] =
+ runtime.aspectRatioValueMap?.[requestedRatio] ||
+ runtime.aspectRatioValueMap?.[
+ requestedRatio === "adaptive" ? "auto" : requestedRatio
+ ] ||
+ matchEnumValue(runtime.enums[runtime.aspectRatioKey], requestedRatio) ||
+ requestedRatio;
+ }
+ if (runtime.durationKey && params.options.duration !== undefined) {
+ body[runtime.durationKey] =
+ matchEnumValue(runtime.enums[runtime.durationKey], params.options.duration) ??
+ params.options.duration;
+ }
+
+ setAtlasCloudImages({
+ body,
+ runtime,
+ images: params.referenceImages,
+ });
+
+ const optionalParams: Array<[string, unknown]> = [
+ ["seed", params.options.seed],
+ ["watermark", params.options.watermark],
+ ["camera_fixed", params.options.cameraFixed],
+ ["return_last_frame", params.options.returnLastFrame],
+ ];
+ for (const [key, value] of optionalParams) {
+ if (value !== undefined && runtime.propertyNames.includes(key)) {
+ body[key] = matchEnumValue(runtime.enums[key], value) ?? value;
+ }
+ }
+ if (runtime.propertyNames.includes("enable_sync_mode")) {
+ body.enable_sync_mode = false;
+ }
+ if (runtime.propertyNames.includes("enable_base64_output")) {
+ body.enable_base64_output = false;
+ }
+
+ const unsupportedRequiredFields = runtime.requiredFields.filter(
+ (key) => body[key] === undefined
+ );
+ if (unsupportedRequiredFields.length > 0) {
+ throw new Error(
+ `当前视频界面暂不支持该 Atlas Cloud 模型的必填参数:${unsupportedRequiredFields.join(", ")}`
+ );
+ }
+ return { body };
+}
diff --git a/lib/ai/providers/atlascloud.ts b/lib/ai/providers/atlascloud.ts
index 81a0c79f2..6a7bbb623 100644
--- a/lib/ai/providers/atlascloud.ts
+++ b/lib/ai/providers/atlascloud.ts
@@ -4,11 +4,14 @@ import sharp from "sharp";
import { ATLASCLOUD_BASE_URL } from "@/lib/ai/atlascloud-models";
import {
createAtlasCloudRequestBody,
+ createAtlasCloudVideoRequestBody,
type AtlasCloudImageParams,
type AtlasCloudModelConfig,
+ type AtlasCloudVideoModelConfig,
} from "@/lib/ai/atlascloud-request";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
+import type { VideoGenerationOptions } from "@/types/ai";
interface AtlasCloudConfig {
apiKey: string;
@@ -82,6 +85,10 @@ function extractOutputUrls(payload: any) {
prediction?.outputs,
prediction?.output,
prediction?.images,
+ prediction?.video,
+ prediction?.video_url,
+ prediction?.videoUrl,
+ prediction?.url,
prediction?.urls?.outputs,
];
const urls: string[] = [];
@@ -91,8 +98,16 @@ function extractOutputUrls(payload: any) {
const url =
typeof value === "string"
? value
- : String(value?.url || value?.image_url || value?.src || "");
- if (/^(?:https?:\/\/|data:image\/)/i.test(url) && !urls.includes(url)) {
+ : String(
+ value?.url ||
+ value?.video_url ||
+ value?.videoUrl ||
+ value?.image_url ||
+ value?.file_url ||
+ value?.src ||
+ ""
+ );
+ if (/^(?:https?:\/\/|data:(?:image|video)\/)/i.test(url) && !urls.includes(url)) {
urls.push(url);
}
}
@@ -358,6 +373,64 @@ async function archiveOutputImage(imageUrl: string): Promise {
);
}
+export async function executeAtlasCloudVideoGeneration(params: {
+ apiKey: string;
+ baseURL?: string;
+ modelId: string;
+ modelConfig: AtlasCloudVideoModelConfig;
+ options: VideoGenerationOptions;
+}) {
+ const runtime = params.modelConfig.atlasCloud;
+ if (!runtime) {
+ throw new Error("Atlas Cloud 视频模型能力配置缺失,请重新同步模型");
+ }
+ const inputImages = [
+ ...(params.options.firstFrameImage ? [params.options.firstFrameImage] : []),
+ ...(params.options.lastFrameImage ? [params.options.lastFrameImage] : []),
+ ...(params.options.referenceImages || []),
+ ];
+ const uniqueImages = Array.from(new Set(inputImages)).slice(
+ 0,
+ Math.max(0, Math.min(14, runtime.maxReferenceImages || 0))
+ );
+ const referenceImages = await Promise.all(
+ uniqueImages.map((url) => uploadReferenceMedia(params.baseURL, params.apiKey, url))
+ );
+ const { body } = createAtlasCloudVideoRequestBody({
+ modelId: params.modelId,
+ options: params.options,
+ referenceImages,
+ modelConfig: params.modelConfig,
+ });
+ const submitted = await submitTask({
+ apiKey: params.apiKey,
+ baseURL: params.baseURL,
+ endpoint: runtime.submitEndpoint,
+ body,
+ });
+ const outputUrls = submitted.outputs.length
+ ? submitted.outputs
+ : await waitForTask({
+ apiKey: params.apiKey,
+ baseURL: params.baseURL,
+ endpoint: runtime.pollEndpoint,
+ taskId: submitted.taskId,
+ });
+ const videoUrl = outputUrls[0];
+ if (!videoUrl) {
+ throw new Error("Atlas Cloud 视频任务完成但未返回视频地址");
+ }
+ return {
+ url: videoUrl,
+ metadata: {
+ provider: "atlascloud",
+ taskId: submitted.taskId || null,
+ temporaryVideoUrl: videoUrl,
+ request: body,
+ },
+ };
+}
+
function formatMarkdown(images: UploadedImage[], imageSize: string) {
return images
.map((image) => ``)
diff --git a/lib/ai/tools/generate-video.ts b/lib/ai/tools/generate-video.ts
index 150971222..0bae08c25 100644
--- a/lib/ai/tools/generate-video.ts
+++ b/lib/ai/tools/generate-video.ts
@@ -6,6 +6,7 @@
import { tool } from "ai";
import { z } from "zod";
import { doGenerateStream } from "@/lib/ai/providers/volcano-video";
+import { executeAtlasCloudVideoGeneration } from "@/lib/ai/providers/atlascloud";
import {
getEnabledAiModelsWithProvider,
getSubModelsByParentModelId
@@ -109,8 +110,22 @@ function isVolcanoVideoModel(item: any) {
return item?.provider?.type === "volcano-video" || item?.provider?.type === "doubao-video";
}
+function isAtlasCloudVideoModel(item: any) {
+ const capabilities = item?.model?.capabilities;
+ return (
+ item?.provider?.type === "atlascloud" &&
+ capabilities &&
+ typeof capabilities === "object" &&
+ String((capabilities as Record).mediaType || "") === "video"
+ );
+}
+
function isDirectExecutableVideoModel(item: any) {
- return isVolcanoVideoModel(item) || isAgnesVideoModel(item);
+ return (
+ isVolcanoVideoModel(item) ||
+ isAgnesVideoModel(item) ||
+ isAtlasCloudVideoModel(item)
+ );
}
/**
@@ -275,6 +290,7 @@ function validateParamsAgainstCapabilities(
// 检查分辨率
if (
params.resolution &&
+ capabilities.supportedResolutions.length > 0 &&
!capabilities.supportedResolutions.includes(params.resolution)
) {
throw new Error(
@@ -283,7 +299,11 @@ function validateParamsAgainstCapabilities(
}
// 检查宽高比
- if (params.ratio && !capabilities.supportedRatios.includes(params.ratio)) {
+ if (
+ params.ratio &&
+ capabilities.supportedRatios.length > 0 &&
+ !capabilities.supportedRatios.includes(params.ratio)
+ ) {
throw new Error(
`模型不支持 ${params.ratio} 宽高比,支持的宽高比:${capabilities.supportedRatios.join(", ")}`
);
@@ -621,6 +641,18 @@ export async function executeDirectVideoGeneration(
...(agnesResult.metadata || {}),
temporaryVideoUrl: agnesResult.url,
};
+ } else if (isAtlasCloudVideoModel(model)) {
+ const atlasResult = await executeAtlasCloudVideoGeneration({
+ apiKey: model.provider.apiKey,
+ baseURL: model.provider.baseUrl,
+ modelId: model.model.modelId,
+ modelConfig: model.model.imageGenConfig || {},
+ options,
+ });
+ videoMetadata = {
+ ...(atlasResult.metadata || {}),
+ temporaryVideoUrl: atlasResult.url,
+ };
} else {
for await (const chunk of doGenerateStream(model.provider.apiKey, model.provider.baseUrl, options)) {
await params.onProgress?.(chunk);
diff --git a/scripts/sync-atlascloud-models.ts b/scripts/sync-atlascloud-models.ts
index dabcf5309..55a57cbd7 100644
--- a/scripts/sync-atlascloud-models.ts
+++ b/scripts/sync-atlascloud-models.ts
@@ -1,7 +1,8 @@
-import { and, eq, inArray } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import {
- fetchAtlasCloudImagePresets,
+ fetchAtlasCloudModelPresets,
resolveAtlasCloudDisplayName,
+ shouldInsertMissingAtlasCloudPreset,
} from "../lib/ai/atlascloud-models";
import { db } from "../lib/db";
import { aiModel, aiProvider } from "../lib/db/schema";
@@ -15,7 +16,7 @@ async function main() {
throw new Error("没有找到已启用的 AtlasCloud 服务商");
}
- const catalog = await fetchAtlasCloudImagePresets();
+ const catalog = await fetchAtlasCloudModelPresets();
if (catalog.errors.length > 0) {
throw new Error(
`AtlasCloud 目录有 ${catalog.errors.length} 个 schema 加载失败:${catalog.errors
@@ -25,19 +26,34 @@ async function main() {
}
const summaries = [];
- const catalogModelIds = new Set(
- catalog.presets.map((preset) => preset.modelId)
- );
for (const provider of providers) {
let added = 0;
let updated = 0;
+ let preservedDeleted = 0;
+ const providerModels = await db
+ .select()
+ .from(aiModel)
+ .where(eq(aiModel.providerId, provider.id));
+ const existingByModelId = new Map(
+ providerModels.map((model) => [model.modelId, model])
+ );
+ const existingMediaKinds = new Set(
+ providerModels.map((model) => {
+ const capabilities = model.capabilities as Record | null;
+ return String(capabilities?.mediaType || "image");
+ })
+ );
for (const preset of catalog.presets) {
- const existing = await db.query.aiModel.findFirst({
- where: and(
- eq(aiModel.providerId, provider.id),
- eq(aiModel.modelId, preset.modelId)
- ),
- });
+ const existing = existingByModelId.get(preset.modelId);
+ const presetCapabilities = preset.capabilities as Record;
+ const mediaKind = String(presetCapabilities.mediaType || "image");
+ if (
+ !existing &&
+ !shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
+ ) {
+ preservedDeleted += 1;
+ continue;
+ }
const values = {
displayName: resolveAtlasCloudDisplayName(
existing?.displayName,
@@ -48,9 +64,7 @@ async function main() {
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
- isEnabled: true,
- isHidden: false,
- sortOrder: preset.sortOrder,
+ sortOrder: existing?.sortOrder ?? preset.sortOrder,
capabilities: preset.capabilities,
defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig,
@@ -68,6 +82,8 @@ async function main() {
providerId: provider.id,
modelId: preset.modelId,
...values,
+ isEnabled: true,
+ isHidden: false,
isDefault: false,
isSubModel: false,
parentModelId: null,
@@ -75,34 +91,12 @@ async function main() {
added += 1;
}
}
- const providerModels = await db
- .select({ modelId: aiModel.modelId })
- .from(aiModel)
- .where(eq(aiModel.providerId, provider.id));
- const staleModelIds = providerModels
- .map((model) => model.modelId)
- .filter((modelId) => !catalogModelIds.has(modelId));
- if (staleModelIds.length > 0) {
- await db
- .update(aiModel)
- .set({
- isEnabled: false,
- isHidden: true,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(aiModel.providerId, provider.id),
- inArray(aiModel.modelId, staleModelIds)
- )
- );
- }
summaries.push({
providerId: provider.id,
providerName: provider.name,
added,
updated,
- disabledStale: staleModelIds.length,
+ preservedDeleted,
});
}
@@ -111,6 +105,7 @@ async function main() {
{
catalogModels: catalog.catalogCount,
availableModels: catalog.presets.length,
+ skippedUnsupportedVideoModels: catalog.skipped.length,
providers: summaries,
},
null,
diff --git a/tests/routes/model-classification.test.ts b/tests/routes/model-classification.test.ts
index f8b8a697a..d8039ed87 100644
--- a/tests/routes/model-classification.test.ts
+++ b/tests/routes/model-classification.test.ts
@@ -3,8 +3,12 @@ import {
createAtlasCloudImagePreset,
inferAtlasCloudMediaKind,
resolveAtlasCloudDisplayName,
+ shouldInsertMissingAtlasCloudPreset,
} from "@/lib/ai/atlascloud-models";
-import { createAtlasCloudRequestBody } from "@/lib/ai/atlascloud-request";
+import {
+ createAtlasCloudRequestBody,
+ createAtlasCloudVideoRequestBody,
+} from "@/lib/ai/atlascloud-request";
import {
is3DModelDescriptor,
isImageGenerationModelDescriptor,
@@ -37,6 +41,13 @@ test.describe("AI model media classification", () => {
).toBe("Flux Kontext Pro");
});
+ test("adds a missing Atlas media kind without restoring deleted models in an existing kind", () => {
+ const existingKinds = new Set(["image", "3d"]);
+ expect(shouldInsertMissingAtlasCloudPreset(existingKinds, "video")).toBe(true);
+ expect(shouldInsertMissingAtlasCloudPreset(existingKinds, "image")).toBe(false);
+ expect(shouldInsertMissingAtlasCloudPreset(existingKinds, "3d")).toBe(false);
+ });
+
test("keeps multi-reference BananaImage models classified as image models", () => {
const descriptor = {
providerType: "bananaimage",
@@ -206,4 +217,92 @@ test.describe("AI model media classification", () => {
},
]);
});
+
+ test("maps Atlas video schema capabilities and frontend parameters", () => {
+ const preset = createAtlasCloudImagePreset(
+ {
+ model: "bytedance/seedance-2.0-mini/image-to-video",
+ type: "Video",
+ displayName: "Seedance 2.0 Mini Image to Video",
+ categories: ["IMAGE-TO-VIDEO"],
+ },
+ {
+ paths: {
+ "/api/v1/model/generateVideo": { post: {} },
+ "/api/v1/model/prediction/{request_id}": { get: {} },
+ },
+ components: {
+ schemas: {
+ Input: {
+ type: "object",
+ properties: {
+ model: { type: "string" },
+ prompt: { type: "string" },
+ image: { type: "string", "x-format": "image" },
+ last_image: { type: "string", "x-format": "image" },
+ resolution: {
+ type: "string",
+ enum: ["480p", "720p", "1080p"],
+ default: "720p",
+ },
+ ratio: {
+ type: "string",
+ enum: ["16:9", "9:16", "adaptive"],
+ default: "adaptive",
+ },
+ duration: {
+ type: "integer",
+ enum: [5, 10],
+ default: 5,
+ },
+ seed: { type: "integer" },
+ watermark: { type: "boolean", default: false },
+ },
+ required: ["model", "prompt", "image"],
+ },
+ },
+ },
+ }
+ );
+
+ expect(preset.supportsImageGen).toBe(false);
+ expect(preset.capabilities).toMatchObject({
+ kind: "video",
+ mediaType: "video",
+ supportedModes: ["first-frame", "first-last-frame"],
+ supportedResolutions: ["480p", "720p", "1080p"],
+ supportedRatios: ["16:9", "9:16", "adaptive"],
+ durationOptions: [5, 10],
+ });
+
+ const { body } = createAtlasCloudVideoRequestBody({
+ modelId: preset.modelId,
+ options: {
+ model: preset.modelId,
+ prompt: "让商品缓慢旋转",
+ mode: "first-last-frame",
+ firstFrameImage: "https://example.com/first.png",
+ lastFrameImage: "https://example.com/last.png",
+ resolution: "1080p",
+ ratio: "9:16",
+ duration: 10,
+ watermark: true,
+ },
+ referenceImages: [
+ "https://example.com/first.png",
+ "https://example.com/last.png",
+ ],
+ modelConfig: preset.imageGenConfig,
+ });
+ expect(body).toMatchObject({
+ model: preset.modelId,
+ prompt: "让商品缓慢旋转",
+ image: "https://example.com/first.png",
+ last_image: "https://example.com/last.png",
+ resolution: "1080p",
+ ratio: "9:16",
+ duration: 10,
+ watermark: true,
+ });
+ });
});