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