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: "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" && (
|
||||||
|
|||||||
@ -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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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"]);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@ -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) => ``)
|
.map((image) => ``)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user