feat: add AtlasCloud video model support

This commit is contained in:
134 2026-07-23 10:52:29 +08:00
parent 974a4b1b59
commit 3096eb6752
8 changed files with 667 additions and 82 deletions

View File

@ -1587,7 +1587,7 @@ const PROVIDER_TYPES = [
{ value: "openai-image", label: "OpenAI 图片兼容" }, { value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "bananaimage", label: "BananaImage 生图" }, { value: "bananaimage", label: "BananaImage 生图" },
{ value: "visionary", label: "Visionary 生图" }, { value: "visionary", label: "Visionary 生图" },
{ value: "atlascloud", label: "Atlas Cloud 图片 / 3D 模型" }, { value: "atlascloud", label: "Atlas Cloud 图片 / 视频 / 3D 模型" },
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" }, { value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
{ value: "bltcy-video", label: "柏拉图统一视频" }, { value: "bltcy-video", label: "柏拉图统一视频" },
{ value: "grsai-gpt-image", label: "Grsai GPT Image" }, { value: "grsai-gpt-image", label: "Grsai GPT Image" },
@ -6473,7 +6473,7 @@ export function AdminSettingsClient() {
} catch (modelError) { } catch (modelError) {
console.error("[Auto Add Atlas Cloud Models] Error:", modelError); console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
toast.warning( toast.warning(
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 图片 / 3D 模型"按钮' 'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 模型"按钮'
); );
} }
} }
@ -7534,7 +7534,7 @@ export function AdminSettingsClient() {
if ( if (
!options?.skipConfirm && !options?.skipConfirm &&
!confirm( !confirm(
"将从 Atlas Cloud 官方目录同步全部开放图片与 3D 模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?" "将从 Atlas Cloud 官方目录同步图片、视频与 3D 模型,并读取每个模型的参数 Schema。已删除的模型不会被恢复。同步数量较多,可能需要几十秒。\n\n继续吗?"
) )
) { ) {
return; return;
@ -7553,7 +7553,7 @@ export function AdminSettingsClient() {
const totalSynced = const totalSynced =
Number(data.addedCount || 0) + Number(data.updatedCount || 0); Number(data.addedCount || 0) + Number(data.updatedCount || 0);
toast.success( toast.success(
`已同步 ${totalSynced} 个 Atlas Cloud 图片 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)` `已同步 ${totalSynced} 个 Atlas Cloud 图片 / 视频 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
); );
mutateModels(); mutateModels();
if (data.errors?.length) { if (data.errors?.length) {
@ -7561,7 +7561,7 @@ export function AdminSettingsClient() {
} }
} catch (error: any) { } catch (error: any) {
console.error("[Quick Add Atlas Cloud Models] Error:", error); console.error("[Quick Add Atlas Cloud Models] Error:", error);
toast.error(error.message || "同步 Atlas Cloud 图片 / 3D 模型失败"); toast.error(error.message || "同步 Atlas Cloud 模型失败");
throw error; throw error;
} }
}; };
@ -15943,7 +15943,7 @@ export function AdminSettingsClient() {
variant="secondary" variant="secondary"
> >
<ImageIcon className="mr-1 h-4 w-4" /> <ImageIcon className="mr-1 h-4 w-4" />
同步 Atlas Cloud 图片 / 3D 模型 同步 Atlas Cloud 模型
</Button> </Button>
)} )}
{provider.type === "bltcy-video" && ( {provider.type === "bltcy-video" && (

View File

@ -1,8 +1,9 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { import {
fetchAtlasCloudImagePresets, fetchAtlasCloudModelPresets,
resolveAtlasCloudDisplayName, resolveAtlasCloudDisplayName,
shouldInsertMissingAtlasCloudPreset,
} from "@/lib/ai/atlascloud-models"; } from "@/lib/ai/atlascloud-models";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries"; import { getAiProviderById } from "@/lib/db/queries";
@ -15,7 +16,7 @@ export const maxDuration = 300;
export async function GET() { export async function GET() {
try { try {
await requireAdmin(); await requireAdmin();
const result = await fetchAtlasCloudImagePresets(); const result = await fetchAtlasCloudModelPresets();
return NextResponse.json({ return NextResponse.json({
presets: result.presets, presets: result.presets,
errors: result.errors.length > 0 ? result.errors : undefined, errors: result.errors.length > 0 ? result.errors : undefined,
@ -32,7 +33,7 @@ export async function GET() {
error: error:
error instanceof Error error instanceof Error
? error.message ? error.message
: "Failed to fetch Atlas Cloud image models", : "Failed to fetch Atlas Cloud models",
}, },
{ status: 500 } { status: 500 }
); );
@ -61,22 +62,39 @@ export async function POST(request: Request) {
); );
} }
const catalog = await fetchAtlasCloudImagePresets(); const catalog = await fetchAtlasCloudModelPresets();
const presets = requestedModelIds const presets = requestedModelIds
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId)) ? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
: catalog.presets; : catalog.presets;
const addedModels: AiModel[] = []; const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = []; const updatedModels: AiModel[] = [];
const errors = [...catalog.errors]; 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<string, unknown> | null;
return String(capabilities?.mediaType || "image");
})
);
let preservedDeletedCount = 0;
for (const preset of presets) { for (const preset of presets) {
try { try {
const existing = await db.query.aiModel.findFirst({ const existing = existingByModelId.get(preset.modelId);
where: and( const presetCapabilities = preset.capabilities as Record<string, unknown>;
eq(aiModel.providerId, providerId), const mediaKind = String(presetCapabilities.mediaType || "image");
eq(aiModel.modelId, preset.modelId) if (
), !existing &&
}); !shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
) {
preservedDeletedCount += 1;
continue;
}
const values = { const values = {
displayName: resolveAtlasCloudDisplayName( displayName: resolveAtlasCloudDisplayName(
existing?.displayName, existing?.displayName,
@ -87,9 +105,7 @@ export async function POST(request: Request) {
description: preset.description, description: preset.description,
supportsVision: preset.supportsVision, supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen, supportsImageGen: preset.supportsImageGen,
isEnabled: true, sortOrder: existing?.sortOrder ?? preset.sortOrder,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities, capabilities: preset.capabilities,
defaultParams: preset.defaultParams, defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig, imageGenConfig: preset.imageGenConfig,
@ -115,6 +131,8 @@ export async function POST(request: Request) {
providerId, providerId,
modelId: preset.modelId, modelId: preset.modelId,
...values, ...values,
isEnabled: true,
isHidden: false,
isDefault: false, isDefault: false,
isSubModel: false, isSubModel: false,
parentModelId: null, parentModelId: null,
@ -145,6 +163,8 @@ export async function POST(request: Request) {
requested: presets.length, requested: presets.length,
total: catalog.catalogCount, total: catalog.catalogCount,
available: catalog.presets.length, available: catalog.presets.length,
skippedUnsupported: catalog.skipped.length,
preservedDeleted: preservedDeletedCount,
}, },
}); });
} catch (error) { } catch (error) {
@ -154,7 +174,7 @@ export async function POST(request: Request) {
error: error:
error instanceof Error error instanceof Error
? error.message ? error.message
: "Failed to sync Atlas Cloud image models", : "Failed to sync Atlas Cloud models",
}, },
{ status: 500 } { status: 500 }
); );

View File

@ -29,8 +29,25 @@ const IMAGE_INPUT_KEYS = [
"reference_image", "reference_image",
]; ];
const RESOLUTION_KEYS = ["resolution", "image_size", "output_resolution"]; 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 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 { export interface AtlasCloudCatalogModel {
uuid?: string; uuid?: string;
@ -67,14 +84,17 @@ export interface AtlasCloudRuntimeConfig {
itemDefaults: Record<string, string | number | boolean>; itemDefaults: Record<string, string | number | boolean>;
}; };
resolutionKey?: string; resolutionKey?: string;
durationKey?: string;
aspectRatioKey?: string; aspectRatioKey?: string;
outputCountKey?: string; outputCountKey?: string;
aspectRatioValueMap: Record<string, string>; aspectRatioValueMap: Record<string, string>;
resolutionValueMap?: Record<string, string>;
maxReferenceImages: number; maxReferenceImages: number;
maxVideoReferences: number; maxVideoReferences: number;
maxImages: number; maxImages: number;
defaults: Record<string, string | number | boolean>; defaults: Record<string, string | number | boolean>;
enums: Record<string, Array<string | number>>; enums: Record<string, Array<string | number>>;
numericRanges?: Record<string, { min?: number; max?: number }>;
sizeValueMap: Record<string, Record<string, string>>; sizeValueMap: Record<string, Record<string, string>>;
} }
@ -106,6 +126,13 @@ export function resolveAtlasCloudDisplayName(
return String(catalogDisplayName || "").trim() || modelId; return String(catalogDisplayName || "").trim() || modelId;
} }
export function shouldInsertMissingAtlasCloudPreset(
existingMediaKinds: ReadonlySet<string>,
mediaKind: string
) {
return !existingMediaKinds.has(mediaKind);
}
function asRecord(value: unknown): JsonRecord { function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord) ? (value as JsonRecord)
@ -342,6 +369,7 @@ function closestCommonRatio(ratio: string) {
function normalizeAspectRatioEnum(value: unknown) { function normalizeAspectRatioEnum(value: unknown) {
const normalized = String(value || "").trim().toLowerCase(); const normalized = String(value || "").trim().toLowerCase();
if (normalized === "auto") return "auto"; if (normalized === "auto") return "auto";
if (normalized === "adaptive") return "adaptive";
if (/^\d+:\d+$/.test(normalized)) return normalized; if (/^\d+:\d+$/.test(normalized)) return normalized;
if (normalized.startsWith("square")) return "1:1"; if (normalized.startsWith("square")) return "1:1";
const namedRatio = normalized.match( const namedRatio = normalized.match(
@ -352,6 +380,115 @@ function normalizeAspectRatioEnum(value: unknown) {
return orientation === "portrait" ? `${second}:${first}` : `${first}:${second}`; 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<string, string> = {};
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<string, { min?: number; max?: number }> = {};
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) { function buildAspectRatioValueMap(properties: JsonRecord) {
for (const key of [...ASPECT_RATIO_KEYS, "image_size"]) { for (const key of [...ASPECT_RATIO_KEYS, "image_size"]) {
if (!(key in properties)) continue; if (!(key in properties)) continue;
@ -449,20 +586,37 @@ export function createAtlasCloudImagePreset(
const imageInputs = getImageInputs(properties, requiredFields); const imageInputs = getImageInputs(properties, requiredFields);
const videoClipInput = getVideoClipInput(properties, requiredFields); const videoClipInput = getVideoClipInput(properties, requiredFields);
const imageInputKey = imageInputs[0]?.key; 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; if (!(key in properties)) return false;
const values = getEnumValues(properties[key]); 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 aspectRatioConfig = buildAspectRatioValueMap(properties);
const aspectRatioKey = aspectRatioConfig.key; const aspectRatioKey = aspectRatioConfig.key;
const outputCountKey = findFirstKey(properties, OUTPUT_COUNT_KEYS); const outputCountKey = findFirstKey(properties, OUTPUT_COUNT_KEYS);
const sizeValueMap = buildSizeValueMap(getEnumValues(properties.size)); const sizeValueMap = buildSizeValueMap(getEnumValues(properties.size));
const supportedImageSizes = deriveSupportedImageSizes( const supportedImageSizes =
mediaKind === "video"
? []
: deriveSupportedImageSizes(properties, resolutionKey, sizeValueMap);
const resolutionValueMap = buildVideoResolutionValueMap(
properties, properties,
resolutionKey, mediaKind === "video" ? resolutionKey : undefined
sizeValueMap
); );
const supportedVideoResolutions = Object.keys(resolutionValueMap);
const durationOptions = deriveVideoDurationOptions(properties, durationKey);
const numericRanges = buildNumericRanges(properties);
const supportedAspectRatios = deriveAspectRatios( const supportedAspectRatios = deriveAspectRatios(
aspectRatioConfig.map, aspectRatioConfig.map,
sizeValueMap sizeValueMap
@ -477,10 +631,16 @@ export function createAtlasCloudImagePreset(
const categories = uniqueStrings(catalogModel.categories || []).map((value) => const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
value.toLowerCase() value.toLowerCase()
); );
const mediaKind = inferAtlasCloudMediaKind(catalogModel);
const supportedModes = const supportedModes =
mediaKind === "3d" mediaKind === "3d"
? derive3DSupportedModes(catalogModel) ? derive3DSupportedModes(catalogModel)
: mediaKind === "video"
? deriveAtlasCloudVideoModes({
catalogModel,
properties,
requiredFields,
imageInputs,
})
: [ : [
...(categories.some((value) => value.includes("text-to-image")) ...(categories.some((value) => value.includes("text-to-image"))
? ["text-to-image"] ? ["text-to-image"]
@ -505,14 +665,17 @@ export function createAtlasCloudImagePreset(
imageInputMode: imageProperty.type === "array" ? "array" : "single", imageInputMode: imageProperty.type === "array" ? "array" : "single",
videoClipInput, videoClipInput,
resolutionKey, resolutionKey,
durationKey,
aspectRatioKey, aspectRatioKey,
outputCountKey, outputCountKey,
aspectRatioValueMap: aspectRatioConfig.map, aspectRatioValueMap: aspectRatioConfig.map,
resolutionValueMap,
maxReferenceImages, maxReferenceImages,
maxVideoReferences, maxVideoReferences,
maxImages, maxImages,
defaults: buildScalarDefaults(properties), defaults: buildScalarDefaults(properties),
enums: buildEnums(properties), enums: buildEnums(properties),
numericRanges,
sizeValueMap, sizeValueMap,
}; };
@ -524,10 +687,23 @@ export function createAtlasCloudImagePreset(
supportedImageSizes[0] || supportedImageSizes[0] ||
"1K"; "1K";
const defaultAspectRatio = const defaultAspectRatio =
supportedAspectRatios.find((value) => value === "auto") || supportedAspectRatios.find((value) =>
mediaKind === "video" ? value === "adaptive" : value === "auto"
) ||
supportedAspectRatios.find((value) => value === "1:1") || supportedAspectRatios.find((value) => value === "1:1") ||
supportedAspectRatios[0] || 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 { return {
modelId: catalogModel.model, modelId: catalogModel.model,
@ -550,6 +726,27 @@ export function createAtlasCloudImagePreset(
: "text-to-image", : "text-to-image",
mediaType: mediaKind, mediaType: mediaKind,
supportedModes, 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, maxReferenceImages,
maxVideoReferences, maxVideoReferences,
supportsMultiImageReference: maxReferenceImages > 1, supportsMultiImageReference: maxReferenceImages > 1,
@ -559,13 +756,24 @@ export function createAtlasCloudImagePreset(
schemaUrl: catalogModel.schema || null, schemaUrl: catalogModel.schema || null,
}, },
defaultParams: { defaultParams: {
imageSize: defaultImageSize, ...(mediaKind === "video"
aspectRatio: defaultAspectRatio, ? {
imageCount: 1, resolution: defaultVideoResolution,
ratio: defaultAspectRatio,
duration: defaultDuration,
}
: {
imageSize: defaultImageSize,
aspectRatio: defaultAspectRatio,
imageCount: 1,
}),
runtimeModelId: catalogModel.model, runtimeModelId: catalogModel.model,
}, },
imageGenConfig: { imageGenConfig: {
apiFormat: "atlascloud-unified-image", apiFormat:
mediaKind === "video"
? "atlascloud-unified-video"
: "atlascloud-unified-image",
maxImages, maxImages,
maxReferenceImages, maxReferenceImages,
maxVideoReferences, maxVideoReferences,
@ -575,6 +783,7 @@ export function createAtlasCloudImagePreset(
supports4K: supportedImageSizes.includes("4K"), supports4K: supportedImageSizes.includes("4K"),
supportedImageSizes, supportedImageSizes,
supportedAspectRatios, supportedAspectRatios,
videoDurationOptions: durationOptions,
supportsSeed: "seed" in properties, supportsSeed: "seed" in properties,
supportsNegativePrompt: "negative_prompt" in properties, supportsNegativePrompt: "negative_prompt" in properties,
supportsWatermark: "watermark" in properties, supportsWatermark: "watermark" in properties,
@ -602,7 +811,7 @@ async function mapWithConcurrency<T, R>(
return results; return results;
} }
export async function fetchAtlasCloudImagePresets() { async function fetchAtlasCloudPresets(mediaTypes: Array<"image" | "video">) {
const catalogResponse = await fetch(ATLASCLOUD_CATALOG_URL, { const catalogResponse = await fetch(ATLASCLOUD_CATALOG_URL, {
cache: "no-store", cache: "no-store",
headers: { Accept: "application/json", "X-Source-From": "website" }, headers: { Accept: "application/json", "X-Source-From": "website" },
@ -618,7 +827,9 @@ export async function fetchAtlasCloudImagePresets() {
const models = catalogModels const models = catalogModels
.filter( .filter(
(model: AtlasCloudCatalogModel) => (model: AtlasCloudCatalogModel) =>
String(model?.type || "").toLowerCase() === "image" && mediaTypes.includes(
String(model?.type || "").toLowerCase() as "image" | "video"
) &&
model?.display_console !== false && model?.display_console !== false &&
Boolean(model?.model) && Boolean(model?.model) &&
Boolean(model?.schema) Boolean(model?.schema)
@ -638,10 +849,28 @@ export async function fetchAtlasCloudImagePresets() {
if (!schemaResponse.ok) { if (!schemaResponse.ok) {
throw new Error(`HTTP ${schemaResponse.status}`); throw new Error(`HTTP ${schemaResponse.status}`);
} }
return { const preset = createAtlasCloudImagePreset(
preset: createAtlasCloudImagePreset(model, await schemaResponse.json(), index), model,
error: null, 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) { } catch (error) {
return { return {
preset: null, preset: null,
@ -649,6 +878,7 @@ export async function fetchAtlasCloudImagePresets() {
modelId: model.model, modelId: model.model,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}, },
skipped: null,
}; };
} }
}); });
@ -656,6 +886,19 @@ export async function fetchAtlasCloudImagePresets() {
return { return {
presets: settled.flatMap((result) => (result.preset ? [result.preset] : [])), presets: settled.flatMap((result) => (result.preset ? [result.preset] : [])),
errors: settled.flatMap((result) => (result.error ? [result.error] : [])), errors: settled.flatMap((result) => (result.error ? [result.error] : [])),
skipped: settled.flatMap((result) => (result.skipped ? [result.skipped] : [])),
catalogCount: models.length, catalogCount: models.length,
}; };
} }
export function fetchAtlasCloudImagePresets() {
return fetchAtlasCloudPresets(["image"]);
}
export function fetchAtlasCloudVideoPresets() {
return fetchAtlasCloudPresets(["video"]);
}
export function fetchAtlasCloudModelPresets() {
return fetchAtlasCloudPresets(["image", "video"]);
}

View File

@ -1,4 +1,5 @@
import type { AtlasCloudRuntimeConfig } from "@/lib/ai/atlascloud-models"; import type { AtlasCloudRuntimeConfig } from "@/lib/ai/atlascloud-models";
import type { VideoGenerationOptions } from "@/types/ai";
export interface AtlasCloudImageParams { export interface AtlasCloudImageParams {
aspectRatio?: string; aspectRatio?: string;
@ -19,6 +20,10 @@ export interface AtlasCloudModelConfig {
atlasCloud?: AtlasCloudRuntimeConfig; atlasCloud?: AtlasCloudRuntimeConfig;
} }
export interface AtlasCloudVideoModelConfig extends AtlasCloudModelConfig {
videoDurationOptions?: number[];
}
function normalizeAspectRatio( function normalizeAspectRatio(
requested: string | undefined, requested: string | undefined,
supported: string[] | undefined supported: string[] | undefined
@ -207,3 +212,121 @@ export function createAtlasCloudRequestBody(params: {
} }
return { body, imageSize }; return { body, imageSize };
} }
function setAtlasCloudImages(params: {
body: Record<string, unknown>;
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<string, unknown> = { ...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 };
}

View File

@ -4,11 +4,14 @@ import sharp from "sharp";
import { ATLASCLOUD_BASE_URL } from "@/lib/ai/atlascloud-models"; import { ATLASCLOUD_BASE_URL } from "@/lib/ai/atlascloud-models";
import { import {
createAtlasCloudRequestBody, createAtlasCloudRequestBody,
createAtlasCloudVideoRequestBody,
type AtlasCloudImageParams, type AtlasCloudImageParams,
type AtlasCloudModelConfig, type AtlasCloudModelConfig,
type AtlasCloudVideoModelConfig,
} from "@/lib/ai/atlascloud-request"; } from "@/lib/ai/atlascloud-request";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import type { VideoGenerationOptions } from "@/types/ai";
interface AtlasCloudConfig { interface AtlasCloudConfig {
apiKey: string; apiKey: string;
@ -82,6 +85,10 @@ function extractOutputUrls(payload: any) {
prediction?.outputs, prediction?.outputs,
prediction?.output, prediction?.output,
prediction?.images, prediction?.images,
prediction?.video,
prediction?.video_url,
prediction?.videoUrl,
prediction?.url,
prediction?.urls?.outputs, prediction?.urls?.outputs,
]; ];
const urls: string[] = []; const urls: string[] = [];
@ -91,8 +98,16 @@ function extractOutputUrls(payload: any) {
const url = const url =
typeof value === "string" typeof value === "string"
? value ? value
: String(value?.url || value?.image_url || value?.src || ""); : String(
if (/^(?:https?:\/\/|data:image\/)/i.test(url) && !urls.includes(url)) { 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); urls.push(url);
} }
} }
@ -358,6 +373,64 @@ async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
); );
} }
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) { function formatMarkdown(images: UploadedImage[], imageSize: string) {
return images return images
.map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`) .map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`)

View File

@ -6,6 +6,7 @@
import { tool } from "ai"; import { tool } from "ai";
import { z } from "zod"; import { z } from "zod";
import { doGenerateStream } from "@/lib/ai/providers/volcano-video"; import { doGenerateStream } from "@/lib/ai/providers/volcano-video";
import { executeAtlasCloudVideoGeneration } from "@/lib/ai/providers/atlascloud";
import { import {
getEnabledAiModelsWithProvider, getEnabledAiModelsWithProvider,
getSubModelsByParentModelId getSubModelsByParentModelId
@ -109,8 +110,22 @@ function isVolcanoVideoModel(item: any) {
return item?.provider?.type === "volcano-video" || item?.provider?.type === "doubao-video"; 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<string, unknown>).mediaType || "") === "video"
);
}
function isDirectExecutableVideoModel(item: any) { function isDirectExecutableVideoModel(item: any) {
return isVolcanoVideoModel(item) || isAgnesVideoModel(item); return (
isVolcanoVideoModel(item) ||
isAgnesVideoModel(item) ||
isAtlasCloudVideoModel(item)
);
} }
/** /**
@ -275,6 +290,7 @@ function validateParamsAgainstCapabilities(
// 检查分辨率 // 检查分辨率
if ( if (
params.resolution && params.resolution &&
capabilities.supportedResolutions.length > 0 &&
!capabilities.supportedResolutions.includes(params.resolution) !capabilities.supportedResolutions.includes(params.resolution)
) { ) {
throw new Error( 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( throw new Error(
`模型不支持 ${params.ratio} 宽高比,支持的宽高比:${capabilities.supportedRatios.join(", ")}` `模型不支持 ${params.ratio} 宽高比,支持的宽高比:${capabilities.supportedRatios.join(", ")}`
); );
@ -621,6 +641,18 @@ export async function executeDirectVideoGeneration(
...(agnesResult.metadata || {}), ...(agnesResult.metadata || {}),
temporaryVideoUrl: agnesResult.url, 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 { } else {
for await (const chunk of doGenerateStream(model.provider.apiKey, model.provider.baseUrl, options)) { for await (const chunk of doGenerateStream(model.provider.apiKey, model.provider.baseUrl, options)) {
await params.onProgress?.(chunk); await params.onProgress?.(chunk);

View File

@ -1,7 +1,8 @@
import { and, eq, inArray } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { import {
fetchAtlasCloudImagePresets, fetchAtlasCloudModelPresets,
resolveAtlasCloudDisplayName, resolveAtlasCloudDisplayName,
shouldInsertMissingAtlasCloudPreset,
} from "../lib/ai/atlascloud-models"; } from "../lib/ai/atlascloud-models";
import { db } from "../lib/db"; import { db } from "../lib/db";
import { aiModel, aiProvider } from "../lib/db/schema"; import { aiModel, aiProvider } from "../lib/db/schema";
@ -15,7 +16,7 @@ async function main() {
throw new Error("没有找到已启用的 AtlasCloud 服务商"); throw new Error("没有找到已启用的 AtlasCloud 服务商");
} }
const catalog = await fetchAtlasCloudImagePresets(); const catalog = await fetchAtlasCloudModelPresets();
if (catalog.errors.length > 0) { if (catalog.errors.length > 0) {
throw new Error( throw new Error(
`AtlasCloud 目录有 ${catalog.errors.length} 个 schema 加载失败:${catalog.errors `AtlasCloud 目录有 ${catalog.errors.length} 个 schema 加载失败:${catalog.errors
@ -25,19 +26,34 @@ async function main() {
} }
const summaries = []; const summaries = [];
const catalogModelIds = new Set(
catalog.presets.map((preset) => preset.modelId)
);
for (const provider of providers) { for (const provider of providers) {
let added = 0; let added = 0;
let updated = 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<string, unknown> | null;
return String(capabilities?.mediaType || "image");
})
);
for (const preset of catalog.presets) { for (const preset of catalog.presets) {
const existing = await db.query.aiModel.findFirst({ const existing = existingByModelId.get(preset.modelId);
where: and( const presetCapabilities = preset.capabilities as Record<string, unknown>;
eq(aiModel.providerId, provider.id), const mediaKind = String(presetCapabilities.mediaType || "image");
eq(aiModel.modelId, preset.modelId) if (
), !existing &&
}); !shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
) {
preservedDeleted += 1;
continue;
}
const values = { const values = {
displayName: resolveAtlasCloudDisplayName( displayName: resolveAtlasCloudDisplayName(
existing?.displayName, existing?.displayName,
@ -48,9 +64,7 @@ async function main() {
description: preset.description, description: preset.description,
supportsVision: preset.supportsVision, supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen, supportsImageGen: preset.supportsImageGen,
isEnabled: true, sortOrder: existing?.sortOrder ?? preset.sortOrder,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities, capabilities: preset.capabilities,
defaultParams: preset.defaultParams, defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig, imageGenConfig: preset.imageGenConfig,
@ -68,6 +82,8 @@ async function main() {
providerId: provider.id, providerId: provider.id,
modelId: preset.modelId, modelId: preset.modelId,
...values, ...values,
isEnabled: true,
isHidden: false,
isDefault: false, isDefault: false,
isSubModel: false, isSubModel: false,
parentModelId: null, parentModelId: null,
@ -75,34 +91,12 @@ async function main() {
added += 1; 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({ summaries.push({
providerId: provider.id, providerId: provider.id,
providerName: provider.name, providerName: provider.name,
added, added,
updated, updated,
disabledStale: staleModelIds.length, preservedDeleted,
}); });
} }
@ -111,6 +105,7 @@ async function main() {
{ {
catalogModels: catalog.catalogCount, catalogModels: catalog.catalogCount,
availableModels: catalog.presets.length, availableModels: catalog.presets.length,
skippedUnsupportedVideoModels: catalog.skipped.length,
providers: summaries, providers: summaries,
}, },
null, null,

View File

@ -3,8 +3,12 @@ import {
createAtlasCloudImagePreset, createAtlasCloudImagePreset,
inferAtlasCloudMediaKind, inferAtlasCloudMediaKind,
resolveAtlasCloudDisplayName, resolveAtlasCloudDisplayName,
shouldInsertMissingAtlasCloudPreset,
} from "@/lib/ai/atlascloud-models"; } from "@/lib/ai/atlascloud-models";
import { createAtlasCloudRequestBody } from "@/lib/ai/atlascloud-request"; import {
createAtlasCloudRequestBody,
createAtlasCloudVideoRequestBody,
} from "@/lib/ai/atlascloud-request";
import { import {
is3DModelDescriptor, is3DModelDescriptor,
isImageGenerationModelDescriptor, isImageGenerationModelDescriptor,
@ -37,6 +41,13 @@ test.describe("AI model media classification", () => {
).toBe("Flux Kontext Pro"); ).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", () => { test("keeps multi-reference BananaImage models classified as image models", () => {
const descriptor = { const descriptor = {
providerType: "bananaimage", 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,
});
});
}); });