feat: add AtlasCloud video model support
This commit is contained in:
parent
974a4b1b59
commit
3096eb6752
@ -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"
|
||||
>
|
||||
<ImageIcon className="mr-1 h-4 w-4" />
|
||||
同步 Atlas Cloud 图片 / 3D 模型
|
||||
同步 Atlas Cloud 模型
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "bltcy-video" && (
|
||||
|
||||
@ -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<string, unknown> | 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<string, unknown>;
|
||||
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 }
|
||||
);
|
||||
|
||||
@ -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<string, string | number | boolean>;
|
||||
};
|
||||
resolutionKey?: string;
|
||||
durationKey?: string;
|
||||
aspectRatioKey?: string;
|
||||
outputCountKey?: string;
|
||||
aspectRatioValueMap: Record<string, string>;
|
||||
resolutionValueMap?: Record<string, string>;
|
||||
maxReferenceImages: number;
|
||||
maxVideoReferences: number;
|
||||
maxImages: number;
|
||||
defaults: Record<string, string | number | boolean>;
|
||||
enums: Record<string, Array<string | number>>;
|
||||
numericRanges?: Record<string, { min?: number; max?: number }>;
|
||||
sizeValueMap: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
@ -106,6 +126,13 @@ export function resolveAtlasCloudDisplayName(
|
||||
return String(catalogDisplayName || "").trim() || modelId;
|
||||
}
|
||||
|
||||
export function shouldInsertMissingAtlasCloudPreset(
|
||||
existingMediaKinds: ReadonlySet<string>,
|
||||
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<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) {
|
||||
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<T, R>(
|
||||
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"]);
|
||||
}
|
||||
|
||||
@ -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<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 };
|
||||
}
|
||||
|
||||
@ -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<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) {
|
||||
return images
|
||||
.map((image) => ``)
|
||||
|
||||
@ -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<string, unknown>).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);
|
||||
|
||||
@ -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<string, unknown> | 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<string, unknown>;
|
||||
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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user