Compare commits
2 Commits
main
...
codex/atla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b05d82963f | ||
|
|
13ad80d0a4 |
@ -18,6 +18,11 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
|||||||
"image/heic",
|
"image/heic",
|
||||||
"image/heif",
|
"image/heif",
|
||||||
]);
|
]);
|
||||||
|
const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
|
||||||
|
"video/mp4",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/webm",
|
||||||
|
]);
|
||||||
|
|
||||||
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
|
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
|
||||||
|
|
||||||
@ -197,6 +202,9 @@ export async function POST(request: Request) {
|
|||||||
let filename = originalFilename;
|
let filename = originalFilename;
|
||||||
let contentType = originalContentType;
|
let contentType = originalContentType;
|
||||||
const uploadPurpose = String(formData.get("purpose") || "");
|
const uploadPurpose = String(formData.get("purpose") || "");
|
||||||
|
const isAtlasCloudReferenceVideo =
|
||||||
|
uploadPurpose === "canvas-video-process" &&
|
||||||
|
ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
|
||||||
const clientUploadId =
|
const clientUploadId =
|
||||||
String(formData.get("clientUploadId") || "").trim() || null;
|
String(formData.get("clientUploadId") || "").trim() || null;
|
||||||
const isSvgUpload = contentType === "image/svg+xml";
|
const isSvgUpload = contentType === "image/svg+xml";
|
||||||
@ -227,7 +235,10 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
|
if (
|
||||||
|
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
|
||||||
|
!isAtlasCloudReferenceVideo
|
||||||
|
) {
|
||||||
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
||||||
logger.warn("[Upload API] Unsupported upload content type", {
|
logger.warn("[Upload API] Unsupported upload content type", {
|
||||||
contentType,
|
contentType,
|
||||||
@ -361,18 +372,19 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const asset = await upsertMediaAsset({
|
const asset = await upsertMediaAsset({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
mediaType: "image",
|
mediaType: isAtlasCloudReferenceVideo ? "video" : "image",
|
||||||
sourceType: "upload",
|
sourceType: "upload",
|
||||||
status: "ready",
|
status: "ready",
|
||||||
originalUrl: data.url,
|
originalUrl: data.url,
|
||||||
previewUrl: data.url,
|
previewUrl: isAtlasCloudReferenceVideo ? null : data.url,
|
||||||
thumbnailUrl: data.url,
|
thumbnailUrl: isAtlasCloudReferenceVideo ? null : data.url,
|
||||||
mimeType: data.contentType || contentType || null,
|
mimeType: data.contentType || contentType || null,
|
||||||
displayName: filename,
|
displayName: filename,
|
||||||
sourceTable: "upload",
|
sourceTable: "upload",
|
||||||
metadata: {
|
metadata: {
|
||||||
pathname: data.pathname,
|
pathname: data.pathname,
|
||||||
size: optimizedBuffer.length,
|
size: optimizedBuffer.length,
|
||||||
|
purpose: uploadPurpose || null,
|
||||||
clientUploadId,
|
clientUploadId,
|
||||||
convertedFrom,
|
convertedFrom,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2197,6 +2197,39 @@ function isLikelyVideoAttachment(url: string, contentType: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectMessageVideoAttachments(message: ChatMessage) {
|
||||||
|
const attachments: PickedObjectAttachment[] = [];
|
||||||
|
const seenUrls = new Set<string>();
|
||||||
|
const append = (value: any, fallbackName: string) => {
|
||||||
|
const url = String(value?.url || value?.data || "").trim();
|
||||||
|
if (!isValidChatHttpUrl(url) || seenUrls.has(url)) return;
|
||||||
|
const contentType = normalizeChatMediaType(
|
||||||
|
value?.contentType || value?.mediaType || value?.mimeType
|
||||||
|
);
|
||||||
|
if (!isLikelyVideoAttachment(url, contentType)) return;
|
||||||
|
const attachment = sanitizeChatAttachment({
|
||||||
|
url,
|
||||||
|
name: normalizeChatAttachmentName(value?.name || fallbackName),
|
||||||
|
contentType,
|
||||||
|
assetId:
|
||||||
|
typeof value?.assetId === "string" && value.assetId.trim()
|
||||||
|
? value.assetId.trim()
|
||||||
|
: undefined,
|
||||||
|
} as PickedObjectAttachment);
|
||||||
|
if (!attachment) return;
|
||||||
|
seenUrls.add(url);
|
||||||
|
attachments.push(attachment);
|
||||||
|
};
|
||||||
|
|
||||||
|
(message.parts || []).forEach((part: any, index: number) => {
|
||||||
|
if (part?.type === "file") append(part, `参考视频-${index + 1}.mp4`);
|
||||||
|
});
|
||||||
|
((message as any).attachments || []).forEach((attachment: any, index: number) => {
|
||||||
|
append(attachment, `参考视频-${index + 1}.mp4`);
|
||||||
|
});
|
||||||
|
return attachments;
|
||||||
|
}
|
||||||
|
|
||||||
function buildCopiedMessageAttachment(
|
function buildCopiedMessageAttachment(
|
||||||
value: any,
|
value: any,
|
||||||
fallbackName: string
|
fallbackName: string
|
||||||
@ -4206,6 +4239,7 @@ export function CanvasChatPanel({
|
|||||||
videoToolCapability?: VideoToolCapabilityContract | null;
|
videoToolCapability?: VideoToolCapabilityContract | null;
|
||||||
imageGenConfig?: {
|
imageGenConfig?: {
|
||||||
maxImages?: number;
|
maxImages?: number;
|
||||||
|
maxVideoReferences?: number;
|
||||||
supportsSize?: boolean;
|
supportsSize?: boolean;
|
||||||
supports1K?: boolean;
|
supports1K?: boolean;
|
||||||
supports2K?: boolean;
|
supports2K?: boolean;
|
||||||
@ -4234,6 +4268,7 @@ export function CanvasChatPanel({
|
|||||||
isMultimodal?: boolean;
|
isMultimodal?: boolean;
|
||||||
imageGenConfig?: {
|
imageGenConfig?: {
|
||||||
maxImages?: number;
|
maxImages?: number;
|
||||||
|
maxVideoReferences?: number;
|
||||||
supportsSize?: boolean;
|
supportsSize?: boolean;
|
||||||
supports1K?: boolean;
|
supports1K?: boolean;
|
||||||
supports2K?: boolean;
|
supports2K?: boolean;
|
||||||
@ -4731,6 +4766,25 @@ export function CanvasChatPanel({
|
|||||||
[visualModels]
|
[visualModels]
|
||||||
);
|
);
|
||||||
const currentModel = allModels.find((model) => model.id === selectedModelId);
|
const currentModel = allModels.find((model) => model.id === selectedModelId);
|
||||||
|
const atlasVideoReferenceLimit =
|
||||||
|
currentModel?.providerType === "atlascloud"
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
4,
|
||||||
|
Number(
|
||||||
|
currentModel.capabilities?.maxVideoReferences ||
|
||||||
|
currentModel.imageGenConfig?.maxVideoReferences ||
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
const supportsAtlasVideoReference = atlasVideoReferenceLimit > 0;
|
||||||
|
const requiresAtlasVideoReference = Boolean(
|
||||||
|
supportsAtlasVideoReference &&
|
||||||
|
currentModel?.capabilities?.requiresVideoReference === true
|
||||||
|
);
|
||||||
const isCanvasVideoModel = useCallback(
|
const isCanvasVideoModel = useCallback(
|
||||||
(model?: (typeof allModels)[number] | null) =>
|
(model?: (typeof allModels)[number] | null) =>
|
||||||
Boolean(
|
Boolean(
|
||||||
@ -11295,6 +11349,20 @@ export function CanvasChatPanel({
|
|||||||
let effectiveAttachments = attachments
|
let effectiveAttachments = attachments
|
||||||
.map((attachment) => sanitizeChatAttachment(attachment))
|
.map((attachment) => sanitizeChatAttachment(attachment))
|
||||||
.filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment));
|
.filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment));
|
||||||
|
if (supportsAtlasVideoReference) {
|
||||||
|
let keptVideoCount = 0;
|
||||||
|
effectiveAttachments = effectiveAttachments.filter((attachment) => {
|
||||||
|
if (!isLikelyVideoAttachment(attachment.url, attachment.contentType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
keptVideoCount += 1;
|
||||||
|
return keptVideoCount <= atlasVideoReferenceLimit;
|
||||||
|
});
|
||||||
|
if (requiresAtlasVideoReference && keptVideoCount === 0) {
|
||||||
|
toast.error("当前模型需要先上传 1 个参考视频");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (hasAgentUploadSlots) {
|
if (hasAgentUploadSlots) {
|
||||||
if (effectiveAttachments.length > agentRequiredUploadCount) {
|
if (effectiveAttachments.length > agentRequiredUploadCount) {
|
||||||
effectiveAttachments = effectiveAttachments.slice(0, agentRequiredUploadCount);
|
effectiveAttachments = effectiveAttachments.slice(0, agentRequiredUploadCount);
|
||||||
@ -11918,6 +11986,57 @@ export function CanvasChatPanel({
|
|||||||
}
|
}
|
||||||
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
|
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
|
||||||
|
|
||||||
|
const uploadVideoFileForComposerAttachment = useCallback(async (
|
||||||
|
file: File,
|
||||||
|
progressOptions?: CanvasUploadProgressOptions
|
||||||
|
): Promise<ComposerUploadedImage | null> => {
|
||||||
|
if (!file.type.startsWith("video/")) {
|
||||||
|
toast.error("请选择视频文件");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (file.size > 15 * 1024 * 1024) {
|
||||||
|
toast.error("参考视频大小不能超过 15MB");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("purpose", "canvas-video-process");
|
||||||
|
if (progressOptions) {
|
||||||
|
updateComposerUploadProgress(progressOptions);
|
||||||
|
}
|
||||||
|
const response = await fetchUploadWithRetry(
|
||||||
|
formData,
|
||||||
|
progressOptions
|
||||||
|
? {
|
||||||
|
onProgress: (progress) => {
|
||||||
|
updateComposerUploadProgress(progressOptions, progress);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("上传失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
clearComposerUploadProgress(250);
|
||||||
|
return {
|
||||||
|
imageUrl: String(data.url || ""),
|
||||||
|
name: file.name || `参考视频-${Date.now()}.mp4`,
|
||||||
|
contentType: file.type || "video/mp4",
|
||||||
|
assetId:
|
||||||
|
typeof data.assetId === "string" && data.assetId.trim()
|
||||||
|
? data.assetId.trim()
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("参考视频上传失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
|
||||||
|
|
||||||
const handlePastedImages = useCallback(async (files: File[]) => {
|
const handlePastedImages = useCallback(async (files: File[]) => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
@ -12250,6 +12369,58 @@ export function CanvasChatPanel({
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (supportsAtlasVideoReference) {
|
||||||
|
const selectedVideoFiles = selectedFiles.filter((file) =>
|
||||||
|
file.type.startsWith("video/")
|
||||||
|
);
|
||||||
|
const existingVideoCount = attachments.filter((attachment) =>
|
||||||
|
isLikelyVideoAttachment(attachment.url, attachment.contentType)
|
||||||
|
).length;
|
||||||
|
const availableVideoSlots = Math.max(
|
||||||
|
0,
|
||||||
|
atlasVideoReferenceLimit - existingVideoCount
|
||||||
|
);
|
||||||
|
const acceptedVideoFiles = selectedVideoFiles.slice(0, availableVideoSlots);
|
||||||
|
|
||||||
|
if (selectedVideoFiles.length > acceptedVideoFiles.length) {
|
||||||
|
toast.info(`当前模型最多支持 ${atlasVideoReferenceLimit} 个参考视频`);
|
||||||
|
}
|
||||||
|
if (acceptedVideoFiles.length > 0) {
|
||||||
|
let uploadedCount = 0;
|
||||||
|
try {
|
||||||
|
for (const [index, file] of acceptedVideoFiles.entries()) {
|
||||||
|
const uploaded = await uploadVideoFileForComposerAttachment(file, {
|
||||||
|
title: "正在上传参考视频",
|
||||||
|
fileName: file.name,
|
||||||
|
fileIndex: index,
|
||||||
|
fileTotal: acceptedVideoFiles.length,
|
||||||
|
target: "composer-reference",
|
||||||
|
});
|
||||||
|
if (!uploaded?.imageUrl) continue;
|
||||||
|
addUploadedImageAttachmentToComposer(uploaded);
|
||||||
|
uploadedCount += 1;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearComposerUploadProgress(450);
|
||||||
|
}
|
||||||
|
if (uploadedCount > 0) {
|
||||||
|
toast.success(
|
||||||
|
uploadedCount === 1
|
||||||
|
? "参考视频已添加到对话"
|
||||||
|
: `已添加 ${uploadedCount} 个参考视频`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFiles = selectedFiles.filter(
|
||||||
|
(file) => !file.type.startsWith("video/")
|
||||||
|
);
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
e.target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let firstImageUrl: string | null = null;
|
let firstImageUrl: string | null = null;
|
||||||
// 记录本次上传开始时的节点数量,用于计算新图片位置
|
// 记录本次上传开始时的节点数量,用于计算新图片位置
|
||||||
@ -13802,6 +13973,10 @@ export function CanvasChatPanel({
|
|||||||
message.role === "user"
|
message.role === "user"
|
||||||
? collectMessageImageAttachments(message as any)
|
? collectMessageImageAttachments(message as any)
|
||||||
: [];
|
: [];
|
||||||
|
const userVideoAttachments =
|
||||||
|
message.role === "user"
|
||||||
|
? collectMessageVideoAttachments(message as any)
|
||||||
|
: [];
|
||||||
const assistantTextParts = message.role === "assistant"
|
const assistantTextParts = message.role === "assistant"
|
||||||
? (message.parts || []).filter((part: any) => part.type === "text" && part.text)
|
? (message.parts || []).filter((part: any) => part.type === "text" && part.text)
|
||||||
: [];
|
: [];
|
||||||
@ -13954,6 +14129,25 @@ export function CanvasChatPanel({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
userVideoAttachments.forEach((attachment, idx) => {
|
||||||
|
renderedUserParts.push(
|
||||||
|
<span
|
||||||
|
key={`video-${attachment.url}-${idx}`}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-[var(--ui-radius-control)] px-1.5 py-1 text-[11px]",
|
||||||
|
isVideoWorkspace
|
||||||
|
? "border border-border/60 bg-background"
|
||||||
|
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Video className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="max-w-[72px] truncate text-current">
|
||||||
|
{attachment.name || "参考视频"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
message.parts?.forEach((part: any, idx: number) => {
|
message.parts?.forEach((part: any, idx: number) => {
|
||||||
if (part.type === 'text' && part.text) {
|
if (part.type === 'text' && part.text) {
|
||||||
const text = String(part.text)
|
const text = String(part.text)
|
||||||
@ -15619,8 +15813,8 @@ export function CanvasChatPanel({
|
|||||||
className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#8f97a3] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12 dark:hover:text-white"
|
className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#8f97a3] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12 dark:hover:text-white"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
title="添加图片"
|
title={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
|
||||||
aria-label="添加图片"
|
aria-label={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
{composerReferenceUploadProgress ? (
|
{composerReferenceUploadProgress ? (
|
||||||
@ -15926,7 +16120,13 @@ export function CanvasChatPanel({
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={isClawdMode ? `image/*,${CLAWD_TABLE_ACCEPT}` : "image/*"}
|
accept={
|
||||||
|
isClawdMode
|
||||||
|
? `image/*,${CLAWD_TABLE_ACCEPT}`
|
||||||
|
: supportsAtlasVideoReference
|
||||||
|
? "image/*,video/mp4,video/quicktime,video/webm"
|
||||||
|
: "image/*"
|
||||||
|
}
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
|
|||||||
@ -17,6 +17,10 @@ const ALLOWED_CHAT_MEDIA_TYPES = new Set([
|
|||||||
"image/jpg",
|
"image/jpg",
|
||||||
"image/bmp",
|
"image/bmp",
|
||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
|
"video/mp4",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/webm",
|
||||||
|
"video/x-matroska",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const GENERIC_CANVAS_PROMPT_SET = new Set([
|
const GENERIC_CANVAS_PROMPT_SET = new Set([
|
||||||
|
|||||||
@ -60,11 +60,18 @@ export interface AtlasCloudRuntimeConfig {
|
|||||||
}>;
|
}>;
|
||||||
imageInputKey?: string;
|
imageInputKey?: string;
|
||||||
imageInputMode?: "array" | "single";
|
imageInputMode?: "array" | "single";
|
||||||
|
videoClipInput?: {
|
||||||
|
key: string;
|
||||||
|
maxItems: number;
|
||||||
|
required: boolean;
|
||||||
|
itemDefaults: Record<string, string | number | boolean>;
|
||||||
|
};
|
||||||
resolutionKey?: string;
|
resolutionKey?: string;
|
||||||
aspectRatioKey?: string;
|
aspectRatioKey?: string;
|
||||||
outputCountKey?: string;
|
outputCountKey?: string;
|
||||||
aspectRatioValueMap: Record<string, string>;
|
aspectRatioValueMap: Record<string, string>;
|
||||||
maxReferenceImages: number;
|
maxReferenceImages: 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>>;
|
||||||
@ -196,6 +203,39 @@ function getImageInputs(properties: JsonRecord, requiredFields: string[]) {
|
|||||||
.sort((a, b) => Number(b.required) - Number(a.required));
|
.sort((a, b) => Number(b.required) - Number(a.required));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVideoClipInput(properties: JsonRecord, requiredFields: string[]) {
|
||||||
|
const entry = Object.entries(properties).find(([key, property]) => {
|
||||||
|
const normalizedKey = key.toLowerCase();
|
||||||
|
const record = asRecord(property);
|
||||||
|
const itemProperties = asRecord(asRecord(record.items).properties);
|
||||||
|
return (
|
||||||
|
record.type === "array" &&
|
||||||
|
normalizedKey.includes("video") &&
|
||||||
|
("url" in itemProperties || normalizedKey.includes("clip"))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!entry) return undefined;
|
||||||
|
|
||||||
|
const [key, property] = entry;
|
||||||
|
const record = asRecord(property);
|
||||||
|
const itemProperties = asRecord(asRecord(record.items).properties);
|
||||||
|
const itemDefaults: Record<string, string | number | boolean> = {};
|
||||||
|
for (const [itemKey, itemProperty] of Object.entries(itemProperties)) {
|
||||||
|
if (itemKey === "url") continue;
|
||||||
|
const value = asRecord(itemProperty).default;
|
||||||
|
if (["string", "number", "boolean"].includes(typeof value)) {
|
||||||
|
itemDefaults[itemKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
maxItems: Math.max(1, Math.min(4, Number(record.maxItems || 1))),
|
||||||
|
required: requiredFields.includes(key),
|
||||||
|
itemDefaults,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getEnumValues(property: unknown) {
|
function getEnumValues(property: unknown) {
|
||||||
const record = asRecord(property);
|
const record = asRecord(property);
|
||||||
const values = Array.isArray(record.enum) ? record.enum : [];
|
const values = Array.isArray(record.enum) ? record.enum : [];
|
||||||
@ -396,6 +436,7 @@ export function createAtlasCloudImagePreset(
|
|||||||
const properties = asRecord(input.properties);
|
const properties = asRecord(input.properties);
|
||||||
const requiredFields = uniqueStrings(Array.isArray(input.required) ? input.required : []);
|
const requiredFields = uniqueStrings(Array.isArray(input.required) ? input.required : []);
|
||||||
const imageInputs = getImageInputs(properties, requiredFields);
|
const imageInputs = getImageInputs(properties, requiredFields);
|
||||||
|
const videoClipInput = getVideoClipInput(properties, requiredFields);
|
||||||
const imageInputKey = imageInputs[0]?.key;
|
const imageInputKey = imageInputs[0]?.key;
|
||||||
const resolutionKey = RESOLUTION_KEYS.find((key) => {
|
const resolutionKey = RESOLUTION_KEYS.find((key) => {
|
||||||
if (!(key in properties)) return false;
|
if (!(key in properties)) return false;
|
||||||
@ -420,6 +461,7 @@ export function createAtlasCloudImagePreset(
|
|||||||
14,
|
14,
|
||||||
imageInputs.reduce((total, input) => total + input.maxItems, 0)
|
imageInputs.reduce((total, input) => total + input.maxItems, 0)
|
||||||
);
|
);
|
||||||
|
const maxVideoReferences = videoClipInput?.maxItems || 0;
|
||||||
const maxImages = deriveMaxImages(properties, outputCountKey);
|
const maxImages = deriveMaxImages(properties, outputCountKey);
|
||||||
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
|
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
|
||||||
value.toLowerCase()
|
value.toLowerCase()
|
||||||
@ -436,6 +478,7 @@ export function createAtlasCloudImagePreset(
|
|||||||
categories.some((value) => value.includes("image-to-image"))
|
categories.some((value) => value.includes("image-to-image"))
|
||||||
? ["image-edit", "reference-images"]
|
? ["image-edit", "reference-images"]
|
||||||
: []),
|
: []),
|
||||||
|
...(videoClipInput ? ["reference-video"] : []),
|
||||||
];
|
];
|
||||||
if (mediaKind === "image" && supportedModes.length === 0) {
|
if (mediaKind === "image" && supportedModes.length === 0) {
|
||||||
supportedModes.push("text-to-image");
|
supportedModes.push("text-to-image");
|
||||||
@ -449,11 +492,13 @@ export function createAtlasCloudImagePreset(
|
|||||||
imageInputs,
|
imageInputs,
|
||||||
imageInputKey,
|
imageInputKey,
|
||||||
imageInputMode: imageProperty.type === "array" ? "array" : "single",
|
imageInputMode: imageProperty.type === "array" ? "array" : "single",
|
||||||
|
videoClipInput,
|
||||||
resolutionKey,
|
resolutionKey,
|
||||||
aspectRatioKey,
|
aspectRatioKey,
|
||||||
outputCountKey,
|
outputCountKey,
|
||||||
aspectRatioValueMap: aspectRatioConfig.map,
|
aspectRatioValueMap: aspectRatioConfig.map,
|
||||||
maxReferenceImages,
|
maxReferenceImages,
|
||||||
|
maxVideoReferences,
|
||||||
maxImages,
|
maxImages,
|
||||||
defaults: buildScalarDefaults(properties),
|
defaults: buildScalarDefaults(properties),
|
||||||
enums: buildEnums(properties),
|
enums: buildEnums(properties),
|
||||||
@ -495,7 +540,10 @@ export function createAtlasCloudImagePreset(
|
|||||||
mediaType: mediaKind,
|
mediaType: mediaKind,
|
||||||
supportedModes,
|
supportedModes,
|
||||||
maxReferenceImages,
|
maxReferenceImages,
|
||||||
|
maxVideoReferences,
|
||||||
supportsMultiImageReference: maxReferenceImages > 1,
|
supportsMultiImageReference: maxReferenceImages > 1,
|
||||||
|
supportsVideoReference: maxVideoReferences > 0,
|
||||||
|
requiresVideoReference: videoClipInput?.required === true,
|
||||||
provider: "atlascloud",
|
provider: "atlascloud",
|
||||||
schemaUrl: catalogModel.schema || null,
|
schemaUrl: catalogModel.schema || null,
|
||||||
},
|
},
|
||||||
@ -509,6 +557,7 @@ export function createAtlasCloudImagePreset(
|
|||||||
apiFormat: "atlascloud-unified-image",
|
apiFormat: "atlascloud-unified-image",
|
||||||
maxImages,
|
maxImages,
|
||||||
maxReferenceImages,
|
maxReferenceImages,
|
||||||
|
maxVideoReferences,
|
||||||
supportsSize,
|
supportsSize,
|
||||||
supports1K: supportedImageSizes.includes("1K"),
|
supports1K: supportedImageSizes.includes("1K"),
|
||||||
supports2K: supportedImageSizes.includes("2K"),
|
supports2K: supportedImageSizes.includes("2K"),
|
||||||
|
|||||||
209
lib/ai/atlascloud-request.ts
Normal file
209
lib/ai/atlascloud-request.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import type { AtlasCloudRuntimeConfig } from "@/lib/ai/atlascloud-models";
|
||||||
|
|
||||||
|
export interface AtlasCloudImageParams {
|
||||||
|
aspectRatio?: string;
|
||||||
|
imageSize?: string;
|
||||||
|
imageCount?: number;
|
||||||
|
negativePrompt?: string;
|
||||||
|
promptExtend?: boolean;
|
||||||
|
quality?: string;
|
||||||
|
seed?: number;
|
||||||
|
watermark?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AtlasCloudModelConfig {
|
||||||
|
maxReferenceImages?: number;
|
||||||
|
maxVideoReferences?: number;
|
||||||
|
supportedAspectRatios?: string[];
|
||||||
|
supportedImageSizes?: string[];
|
||||||
|
atlasCloud?: AtlasCloudRuntimeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAspectRatio(
|
||||||
|
requested: string | undefined,
|
||||||
|
supported: string[] | undefined
|
||||||
|
) {
|
||||||
|
const values = (supported || []).map(String);
|
||||||
|
const normalized = String(requested || "auto").trim().toLowerCase();
|
||||||
|
const exact = values.find((value) => value.toLowerCase() === normalized);
|
||||||
|
if (exact) return exact;
|
||||||
|
return (
|
||||||
|
values.find((value) => value === "auto") ||
|
||||||
|
values.find((value) => value === "1:1") ||
|
||||||
|
values[0] ||
|
||||||
|
"1:1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImageSize(requested: string | undefined, supported?: string[]) {
|
||||||
|
const values = (supported || []).map((value) => String(value).trim().toUpperCase());
|
||||||
|
const normalized = String(requested || "2K").trim().toUpperCase();
|
||||||
|
if (values.includes(normalized)) return normalized;
|
||||||
|
return values.includes("2K") ? "2K" : values[0] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchEnumValue(values: Array<string | number> | undefined, value: unknown) {
|
||||||
|
if (!values?.length) return value;
|
||||||
|
const normalized = String(value || "").trim().toLowerCase();
|
||||||
|
return values.find(
|
||||||
|
(item) => String(item).trim().toLowerCase() === normalized
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRatio(value: string) {
|
||||||
|
const match = value.match(/^(\d+):(\d+)$/);
|
||||||
|
if (!match) return 1;
|
||||||
|
return Number(match[1]) / Number(match[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickClosestSizeValue(
|
||||||
|
map: Record<string, Record<string, string>>,
|
||||||
|
imageSize: string,
|
||||||
|
aspectRatio: string
|
||||||
|
) {
|
||||||
|
const tiers = [imageSize, "2K", "1K", "4K"];
|
||||||
|
const values = tiers.map((tier) => map[tier]).find(Boolean);
|
||||||
|
if (!values) return undefined;
|
||||||
|
if (values[aspectRatio]) return values[aspectRatio];
|
||||||
|
const target = parseRatio(aspectRatio);
|
||||||
|
return Object.entries(values).sort(
|
||||||
|
([a], [b]) =>
|
||||||
|
Math.abs(Math.log(target / parseRatio(a))) -
|
||||||
|
Math.abs(Math.log(target / parseRatio(b)))
|
||||||
|
)[0]?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAtlasCloudRequestBody(params: {
|
||||||
|
modelId: string;
|
||||||
|
prompt: string;
|
||||||
|
referenceImages: string[];
|
||||||
|
referenceVideos?: string[];
|
||||||
|
imageParams?: AtlasCloudImageParams;
|
||||||
|
modelConfig: AtlasCloudModelConfig;
|
||||||
|
}) {
|
||||||
|
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.prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspectRatio = normalizeAspectRatio(
|
||||||
|
params.imageParams?.aspectRatio,
|
||||||
|
params.modelConfig.supportedAspectRatios
|
||||||
|
);
|
||||||
|
const imageSize = normalizeImageSize(
|
||||||
|
params.imageParams?.imageSize,
|
||||||
|
params.modelConfig.supportedImageSizes
|
||||||
|
);
|
||||||
|
|
||||||
|
if (runtime.aspectRatioKey) {
|
||||||
|
body[runtime.aspectRatioKey] =
|
||||||
|
runtime.aspectRatioValueMap?.[aspectRatio] ||
|
||||||
|
matchEnumValue(runtime.enums[runtime.aspectRatioKey], aspectRatio) ||
|
||||||
|
aspectRatio;
|
||||||
|
}
|
||||||
|
if (runtime.resolutionKey) {
|
||||||
|
body[runtime.resolutionKey] =
|
||||||
|
matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize.toLowerCase()) ||
|
||||||
|
matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize) ||
|
||||||
|
imageSize.toLowerCase();
|
||||||
|
} else {
|
||||||
|
const sizeValue = pickClosestSizeValue(
|
||||||
|
runtime.sizeValueMap,
|
||||||
|
imageSize,
|
||||||
|
aspectRatio === "auto" ? "1:1" : aspectRatio
|
||||||
|
);
|
||||||
|
if (sizeValue) body.size = sizeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageInputs = runtime.imageInputs?.length
|
||||||
|
? runtime.imageInputs
|
||||||
|
: runtime.imageInputKey
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: runtime.imageInputKey,
|
||||||
|
mode: runtime.imageInputMode || ("single" as const),
|
||||||
|
maxItems: Math.max(1, runtime.maxReferenceImages || 1),
|
||||||
|
required: runtime.requiredFields.includes(runtime.imageInputKey),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
let referenceCursor = 0;
|
||||||
|
for (const input of imageInputs) {
|
||||||
|
const values = params.referenceImages.slice(
|
||||||
|
referenceCursor,
|
||||||
|
referenceCursor + input.maxItems
|
||||||
|
);
|
||||||
|
if (values.length > 0) {
|
||||||
|
body[input.key] = input.mode === "single" ? values[0] : values;
|
||||||
|
referenceCursor += values.length;
|
||||||
|
} else if (input.required) {
|
||||||
|
throw new Error(
|
||||||
|
`该 Atlas Cloud 模型至少需要 ${referenceCursor + 1} 张参考图`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imageInputs.length === 0 && params.referenceImages.length > 0) {
|
||||||
|
throw new Error("当前 Atlas Cloud 模型不支持参考图,请选择图片编辑模型");
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceVideos = params.referenceVideos || [];
|
||||||
|
if (runtime.videoClipInput) {
|
||||||
|
const values = referenceVideos.slice(0, runtime.videoClipInput.maxItems);
|
||||||
|
if (values.length > 0) {
|
||||||
|
body[runtime.videoClipInput.key] = values.map((url) => ({
|
||||||
|
...runtime.videoClipInput?.itemDefaults,
|
||||||
|
url,
|
||||||
|
}));
|
||||||
|
} else if (runtime.videoClipInput.required) {
|
||||||
|
throw new Error("该 Atlas Cloud 模型至少需要 1 个参考视频");
|
||||||
|
}
|
||||||
|
} else if (referenceVideos.length > 0) {
|
||||||
|
throw new Error("当前 Atlas Cloud 模型不支持参考视频");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime.outputCountKey) {
|
||||||
|
const requestedCount = Math.max(1, params.imageParams?.imageCount || 1);
|
||||||
|
body[runtime.outputCountKey] = Math.min(runtime.maxImages, requestedCount);
|
||||||
|
}
|
||||||
|
const optionalParams: Array<[string, unknown]> = [
|
||||||
|
["negative_prompt", params.imageParams?.negativePrompt],
|
||||||
|
["prompt_extend", params.imageParams?.promptExtend],
|
||||||
|
["quality", params.imageParams?.quality],
|
||||||
|
["seed", params.imageParams?.seed],
|
||||||
|
["watermark", params.imageParams?.watermark],
|
||||||
|
];
|
||||||
|
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, imageSize };
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import { ATLASCLOUD_BASE_URL } from "@/lib/ai/atlascloud-models";
|
||||||
import {
|
import {
|
||||||
ATLASCLOUD_BASE_URL,
|
createAtlasCloudRequestBody,
|
||||||
type AtlasCloudRuntimeConfig,
|
type AtlasCloudImageParams,
|
||||||
} from "@/lib/ai/atlascloud-models";
|
type AtlasCloudModelConfig,
|
||||||
|
} 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";
|
||||||
|
|
||||||
@ -13,24 +15,6 @@ interface AtlasCloudConfig {
|
|||||||
baseURL?: string;
|
baseURL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AtlasCloudImageParams {
|
|
||||||
aspectRatio?: string;
|
|
||||||
imageSize?: string;
|
|
||||||
imageCount?: number;
|
|
||||||
negativePrompt?: string;
|
|
||||||
promptExtend?: boolean;
|
|
||||||
quality?: string;
|
|
||||||
seed?: number;
|
|
||||||
watermark?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AtlasCloudModelConfig {
|
|
||||||
maxReferenceImages?: number;
|
|
||||||
supportedAspectRatios?: string[];
|
|
||||||
supportedImageSizes?: string[];
|
|
||||||
atlasCloud?: AtlasCloudRuntimeConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadedImage {
|
interface UploadedImage {
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
original: string;
|
original: string;
|
||||||
@ -116,18 +100,19 @@ function extractOutputUrls(payload: any) {
|
|||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPromptAndImages(
|
function extractPromptAndMedia(
|
||||||
messages: Array<{ role: string; content?: any; parts?: any[] }>
|
messages: Array<{ role: string; content?: any; parts?: any[] }>
|
||||||
) {
|
) {
|
||||||
const message = messages.filter((item) => item.role === "user").pop();
|
const message = messages.filter((item) => item.role === "user").pop();
|
||||||
if (!message) throw new Error("未找到用户消息");
|
if (!message) throw new Error("未找到用户消息");
|
||||||
const texts: string[] = [];
|
const texts: string[] = [];
|
||||||
const imageUrls: string[] = [];
|
const imageUrls: string[] = [];
|
||||||
|
const videoUrls: string[] = [];
|
||||||
|
|
||||||
const pushText = (value: unknown) => {
|
const pushText = (value: unknown) => {
|
||||||
if (typeof value === "string" && value.trim()) texts.push(value.trim());
|
if (typeof value === "string" && value.trim()) texts.push(value.trim());
|
||||||
};
|
};
|
||||||
const pushImage = (value: unknown) => {
|
const pushMedia = (value: unknown, mediaType?: unknown) => {
|
||||||
const url = String(
|
const url = String(
|
||||||
typeof value === "string"
|
typeof value === "string"
|
||||||
? value
|
? value
|
||||||
@ -137,24 +122,31 @@ function extractPromptAndImages(
|
|||||||
(value as any)?.src ||
|
(value as any)?.src ||
|
||||||
""
|
""
|
||||||
).trim();
|
).trim();
|
||||||
if (
|
if (!url || !/^(?:https?:\/\/|data:)/i.test(url)) return;
|
||||||
url &&
|
const normalizedMediaType = String(mediaType || "").toLowerCase();
|
||||||
(/^https?:\/\//i.test(url) || url.startsWith("data:image/")) &&
|
const isVideo =
|
||||||
!imageUrls.includes(url)
|
normalizedMediaType.startsWith("video/") ||
|
||||||
) {
|
url.startsWith("data:video/") ||
|
||||||
imageUrls.push(url);
|
/\.(?:mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(url);
|
||||||
}
|
const target = isVideo ? videoUrls : imageUrls;
|
||||||
|
if (!target.includes(url)) target.push(url);
|
||||||
};
|
};
|
||||||
const visitPart = (part: any) => {
|
const visitPart = (part: any) => {
|
||||||
if (!part) return;
|
if (!part) return;
|
||||||
if (typeof part === "string") return pushText(part);
|
if (typeof part === "string") return pushText(part);
|
||||||
if (part.type === "text" || typeof part.text === "string") pushText(part.text);
|
if (part.type === "text" || typeof part.text === "string") pushText(part.text);
|
||||||
if (part.type === "image_url") pushImage(part.image_url);
|
if (part.type === "image_url") pushMedia(part.image_url, "image/jpeg");
|
||||||
if (part.type === "image") pushImage(part.image || part.url);
|
if (part.type === "image") pushMedia(part.image || part.url, "image/jpeg");
|
||||||
if (part.type === "file") pushImage(part.url || part.file || part.data);
|
if (part.type === "file") {
|
||||||
|
pushMedia(
|
||||||
|
part.url || part.file || part.data,
|
||||||
|
part.mediaType || part.contentType || part.mimeType
|
||||||
|
);
|
||||||
|
}
|
||||||
if (part.inlineData?.data) {
|
if (part.inlineData?.data) {
|
||||||
pushImage(
|
pushMedia(
|
||||||
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`
|
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`,
|
||||||
|
part.inlineData.mimeType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -165,31 +157,31 @@ function extractPromptAndImages(
|
|||||||
|
|
||||||
const prompt = texts.join("\n").trim();
|
const prompt = texts.join("\n").trim();
|
||||||
if (!prompt) throw new Error("请输入图片生成或编辑要求");
|
if (!prompt) throw new Error("请输入图片生成或编辑要求");
|
||||||
return { prompt, imageUrls };
|
return { prompt, imageUrls, videoUrls };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDataUrl(value: string) {
|
function parseDataUrl(value: string) {
|
||||||
const match = value.match(/^data:([^;,]+);base64,(.+)$/s);
|
const match = value.match(/^data:([^;,]+);base64,(.+)$/s);
|
||||||
if (!match) throw new Error("Atlas Cloud 参考图 Base64 格式无效");
|
if (!match) throw new Error("Atlas Cloud 参考素材 Base64 格式无效");
|
||||||
return {
|
return {
|
||||||
mimeType: match[1] || "image/png",
|
mimeType: match[1] || "image/png",
|
||||||
buffer: Buffer.from(match[2] || "", "base64"),
|
buffer: Buffer.from(match[2] || "", "base64"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadImageSource(imageUrl: string) {
|
async function loadMediaSource(mediaUrl: string) {
|
||||||
if (imageUrl.startsWith("data:")) return parseDataUrl(imageUrl);
|
if (mediaUrl.startsWith("data:")) return parseDataUrl(mediaUrl);
|
||||||
const response = await fetch(imageUrl, {
|
const response = await fetch(mediaUrl, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "image/avif,image/webp,image/png,image/jpeg,image/*,*/*;q=0.8",
|
Accept: "image/*,video/*,*/*;q=0.8",
|
||||||
"User-Agent": "yedir-ai-atlascloud/1.0",
|
"User-Agent": "yedir-ai-atlascloud/1.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Atlas Cloud 参考图下载失败(HTTP ${response.status})`);
|
throw new Error(`Atlas Cloud 参考素材下载失败(HTTP ${response.status})`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mimeType: response.headers.get("content-type")?.split(";")[0] || "image/png",
|
mimeType: response.headers.get("content-type")?.split(";")[0] || "image/png",
|
||||||
@ -201,16 +193,19 @@ function detectExtension(mimeType: string) {
|
|||||||
if (/jpe?g/i.test(mimeType)) return "jpg";
|
if (/jpe?g/i.test(mimeType)) return "jpg";
|
||||||
if (/webp/i.test(mimeType)) return "webp";
|
if (/webp/i.test(mimeType)) return "webp";
|
||||||
if (/gif/i.test(mimeType)) return "gif";
|
if (/gif/i.test(mimeType)) return "gif";
|
||||||
|
if (/quicktime/i.test(mimeType)) return "mov";
|
||||||
|
if (/webm/i.test(mimeType)) return "webm";
|
||||||
|
if (/mp4/i.test(mimeType)) return "mp4";
|
||||||
return "png";
|
return "png";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadReferenceImage(
|
async function uploadReferenceMedia(
|
||||||
baseURL: string | undefined,
|
baseURL: string | undefined,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
imageUrl: string
|
mediaUrl: string
|
||||||
) {
|
) {
|
||||||
if (/^https:\/\//i.test(imageUrl)) return imageUrl;
|
if (/^https:\/\//i.test(mediaUrl)) return mediaUrl;
|
||||||
const source = await loadImageSource(imageUrl);
|
const source = await loadMediaSource(mediaUrl);
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append(
|
form.append(
|
||||||
"file",
|
"file",
|
||||||
@ -226,171 +221,17 @@ async function uploadReferenceImage(
|
|||||||
const payload = await parseJsonResponse(response);
|
const payload = await parseJsonResponse(response);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${extractErrorMessage(payload, "Atlas Cloud 参考图上传失败")}(HTTP ${response.status})`
|
`${extractErrorMessage(payload, "Atlas Cloud 参考素材上传失败")}(HTTP ${response.status})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = payload?.data && typeof payload.data === "object" ? payload.data : payload;
|
const data = payload?.data && typeof payload.data === "object" ? payload.data : payload;
|
||||||
const url = String(
|
const url = String(
|
||||||
data?.url || data?.file_url || data?.fileUrl || data?.media_url || data?.mediaUrl || ""
|
data?.url || data?.file_url || data?.fileUrl || data?.media_url || data?.mediaUrl || ""
|
||||||
).trim();
|
).trim();
|
||||||
if (!url) throw new Error("Atlas Cloud 参考图上传成功但未返回文件地址");
|
if (!url) throw new Error("Atlas Cloud 参考素材上传成功但未返回文件地址");
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAspectRatio(
|
|
||||||
requested: string | undefined,
|
|
||||||
supported: string[] | undefined
|
|
||||||
) {
|
|
||||||
const values = (supported || []).map(String);
|
|
||||||
const normalized = String(requested || "auto").trim().toLowerCase();
|
|
||||||
const exact = values.find((value) => value.toLowerCase() === normalized);
|
|
||||||
if (exact) return exact;
|
|
||||||
return values.find((value) => value === "auto") || values.find((value) => value === "1:1") || values[0] || "1:1";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeImageSize(requested: string | undefined, supported?: string[]) {
|
|
||||||
const values = (supported || []).map((value) => String(value).trim().toUpperCase());
|
|
||||||
const normalized = String(requested || "2K").trim().toUpperCase();
|
|
||||||
if (values.includes(normalized)) return normalized;
|
|
||||||
return values.includes("2K") ? "2K" : values[0] || normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchEnumValue(values: Array<string | number> | undefined, value: unknown) {
|
|
||||||
if (!values?.length) return value;
|
|
||||||
const normalized = String(value || "").trim().toLowerCase();
|
|
||||||
return values.find((item) => String(item).trim().toLowerCase() === normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRatio(value: string) {
|
|
||||||
const match = value.match(/^(\d+):(\d+)$/);
|
|
||||||
if (!match) return 1;
|
|
||||||
return Number(match[1]) / Number(match[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickClosestSizeValue(
|
|
||||||
map: Record<string, Record<string, string>>,
|
|
||||||
imageSize: string,
|
|
||||||
aspectRatio: string
|
|
||||||
) {
|
|
||||||
const tiers = [imageSize, "2K", "1K", "4K"];
|
|
||||||
const values = tiers.map((tier) => map[tier]).find(Boolean);
|
|
||||||
if (!values) return undefined;
|
|
||||||
if (values[aspectRatio]) return values[aspectRatio];
|
|
||||||
const target = parseRatio(aspectRatio);
|
|
||||||
return Object.entries(values).sort(
|
|
||||||
([a], [b]) =>
|
|
||||||
Math.abs(Math.log(target / parseRatio(a))) -
|
|
||||||
Math.abs(Math.log(target / parseRatio(b)))
|
|
||||||
)[0]?.[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRequestBody(params: {
|
|
||||||
modelId: string;
|
|
||||||
prompt: string;
|
|
||||||
referenceImages: string[];
|
|
||||||
imageParams?: AtlasCloudImageParams;
|
|
||||||
modelConfig: AtlasCloudModelConfig;
|
|
||||||
}) {
|
|
||||||
const runtime = params.modelConfig.atlasCloud;
|
|
||||||
if (!runtime) throw new Error("Atlas Cloud 模型运行参数缺失,请重新同步模型");
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
...runtime.defaults,
|
|
||||||
};
|
|
||||||
if (runtime.propertyNames.includes("model")) body.model = params.modelId;
|
|
||||||
if (runtime.propertyNames.includes("prompt")) body.prompt = params.prompt;
|
|
||||||
const aspectRatio = normalizeAspectRatio(
|
|
||||||
params.imageParams?.aspectRatio,
|
|
||||||
params.modelConfig.supportedAspectRatios
|
|
||||||
);
|
|
||||||
const imageSize = normalizeImageSize(
|
|
||||||
params.imageParams?.imageSize,
|
|
||||||
params.modelConfig.supportedImageSizes
|
|
||||||
);
|
|
||||||
|
|
||||||
if (runtime.aspectRatioKey) {
|
|
||||||
body[runtime.aspectRatioKey] =
|
|
||||||
runtime.aspectRatioValueMap?.[aspectRatio] ||
|
|
||||||
matchEnumValue(runtime.enums[runtime.aspectRatioKey], aspectRatio) || aspectRatio;
|
|
||||||
}
|
|
||||||
if (runtime.resolutionKey) {
|
|
||||||
const resolutionValue =
|
|
||||||
matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize.toLowerCase()) ||
|
|
||||||
matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize) ||
|
|
||||||
imageSize.toLowerCase();
|
|
||||||
body[runtime.resolutionKey] = resolutionValue;
|
|
||||||
} else {
|
|
||||||
const sizeValue = pickClosestSizeValue(
|
|
||||||
runtime.sizeValueMap,
|
|
||||||
imageSize,
|
|
||||||
aspectRatio === "auto" ? "1:1" : aspectRatio
|
|
||||||
);
|
|
||||||
if (sizeValue) body.size = sizeValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageInputs = runtime.imageInputs?.length
|
|
||||||
? runtime.imageInputs
|
|
||||||
: runtime.imageInputKey
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: runtime.imageInputKey,
|
|
||||||
mode: runtime.imageInputMode || "single",
|
|
||||||
maxItems: Math.max(1, runtime.maxReferenceImages || 1),
|
|
||||||
required: runtime.requiredFields.includes(runtime.imageInputKey),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
let referenceCursor = 0;
|
|
||||||
for (const input of imageInputs) {
|
|
||||||
const values = params.referenceImages.slice(
|
|
||||||
referenceCursor,
|
|
||||||
referenceCursor + input.maxItems
|
|
||||||
);
|
|
||||||
if (values.length > 0) {
|
|
||||||
body[input.key] = input.mode === "single" ? values[0] : values;
|
|
||||||
referenceCursor += values.length;
|
|
||||||
} else if (input.required) {
|
|
||||||
throw new Error(
|
|
||||||
`该 Atlas Cloud 模型至少需要 ${referenceCursor + 1} 张参考图`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (imageInputs.length === 0 && params.referenceImages.length > 0) {
|
|
||||||
throw new Error("当前 Atlas Cloud 模型不支持参考图,请选择图片编辑模型");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runtime.outputCountKey) {
|
|
||||||
const requestedCount = Math.max(1, params.imageParams?.imageCount || 1);
|
|
||||||
body[runtime.outputCountKey] = Math.min(runtime.maxImages, requestedCount);
|
|
||||||
}
|
|
||||||
const optionalParams: Array<[string, unknown]> = [
|
|
||||||
["negative_prompt", params.imageParams?.negativePrompt],
|
|
||||||
["prompt_extend", params.imageParams?.promptExtend],
|
|
||||||
["quality", params.imageParams?.quality],
|
|
||||||
["seed", params.imageParams?.seed],
|
|
||||||
["watermark", params.imageParams?.watermark],
|
|
||||||
];
|
|
||||||
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, imageSize };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitTask(params: {
|
async function submitTask(params: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseURL?: string;
|
baseURL?: string;
|
||||||
@ -540,7 +381,9 @@ export function createAtlasCloud({ apiKey, baseURL }: AtlasCloudConfig) {
|
|||||||
if (!modelConfig.atlasCloud) {
|
if (!modelConfig.atlasCloud) {
|
||||||
throw new Error("Atlas Cloud 模型能力配置缺失,请在系统设置中重新同步模型");
|
throw new Error("Atlas Cloud 模型能力配置缺失,请在系统设置中重新同步模型");
|
||||||
}
|
}
|
||||||
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
const { prompt, imageUrls, videoUrls } = extractPromptAndMedia(
|
||||||
|
options.messages
|
||||||
|
);
|
||||||
const maxReferenceImages = Math.max(
|
const maxReferenceImages = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(14, modelConfig.maxReferenceImages ?? modelConfig.atlasCloud.maxReferenceImages)
|
Math.min(14, modelConfig.maxReferenceImages ?? modelConfig.atlasCloud.maxReferenceImages)
|
||||||
@ -548,12 +391,27 @@ export function createAtlasCloud({ apiKey, baseURL }: AtlasCloudConfig) {
|
|||||||
const referenceImages = await Promise.all(
|
const referenceImages = await Promise.all(
|
||||||
imageUrls
|
imageUrls
|
||||||
.slice(0, maxReferenceImages)
|
.slice(0, maxReferenceImages)
|
||||||
.map((url) => uploadReferenceImage(baseURL, apiKey, url))
|
.map((url) => uploadReferenceMedia(baseURL, apiKey, url))
|
||||||
);
|
);
|
||||||
const { body, imageSize } = createRequestBody({
|
const maxVideoReferences = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
4,
|
||||||
|
modelConfig.maxVideoReferences ??
|
||||||
|
modelConfig.atlasCloud.maxVideoReferences ??
|
||||||
|
0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const referenceVideos = await Promise.all(
|
||||||
|
videoUrls
|
||||||
|
.slice(0, maxVideoReferences)
|
||||||
|
.map((url) => uploadReferenceMedia(baseURL, apiKey, url))
|
||||||
|
);
|
||||||
|
const { body, imageSize } = createAtlasCloudRequestBody({
|
||||||
modelId: runtimeModelId,
|
modelId: runtimeModelId,
|
||||||
prompt,
|
prompt,
|
||||||
referenceImages,
|
referenceImages,
|
||||||
|
referenceVideos,
|
||||||
imageParams: options.imageParams,
|
imageParams: options.imageParams,
|
||||||
modelConfig,
|
modelConfig,
|
||||||
});
|
});
|
||||||
|
|||||||
118
scripts/sync-atlascloud-models.ts
Normal file
118
scripts/sync-atlascloud-models.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { fetchAtlasCloudImagePresets } from "../lib/ai/atlascloud-models";
|
||||||
|
import { db } from "../lib/db";
|
||||||
|
import { aiModel, aiProvider } from "../lib/db/schema";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const providers = await db
|
||||||
|
.select()
|
||||||
|
.from(aiProvider)
|
||||||
|
.where(and(eq(aiProvider.type, "atlascloud"), eq(aiProvider.isEnabled, true)));
|
||||||
|
if (providers.length === 0) {
|
||||||
|
throw new Error("没有找到已启用的 AtlasCloud 服务商");
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await fetchAtlasCloudImagePresets();
|
||||||
|
if (catalog.errors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`AtlasCloud 目录有 ${catalog.errors.length} 个 schema 加载失败:${catalog.errors
|
||||||
|
.map((item) => item.modelId)
|
||||||
|
.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaries = [];
|
||||||
|
const catalogModelIds = new Set(
|
||||||
|
catalog.presets.map((preset) => preset.modelId)
|
||||||
|
);
|
||||||
|
for (const provider of providers) {
|
||||||
|
let added = 0;
|
||||||
|
let updated = 0;
|
||||||
|
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 values = {
|
||||||
|
displayName: preset.displayName,
|
||||||
|
avatarUrl: preset.avatarUrl || null,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities,
|
||||||
|
defaultParams: preset.defaultParams,
|
||||||
|
imageGenConfig: preset.imageGenConfig,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set(values)
|
||||||
|
.where(eq(aiModel.id, existing.id));
|
||||||
|
updated += 1;
|
||||||
|
} else {
|
||||||
|
await db.insert(aiModel).values({
|
||||||
|
providerId: provider.id,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
...values,
|
||||||
|
isDefault: false,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
catalogModels: catalog.catalogCount,
|
||||||
|
availableModels: catalog.presets.length,
|
||||||
|
providers: summaries,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
204
scripts/verify-atlascloud-frontend.ts
Normal file
204
scripts/verify-atlascloud-frontend.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { fetchAtlasCloudImagePresets } from "../lib/ai/atlascloud-models";
|
||||||
|
import { createAtlasCloudRequestBody } from "../lib/ai/atlascloud-request";
|
||||||
|
import {
|
||||||
|
is3DModelDescriptor,
|
||||||
|
isImageGenerationModelDescriptor,
|
||||||
|
isVideoModelDescriptor,
|
||||||
|
} from "../lib/ai/model-classification";
|
||||||
|
|
||||||
|
const FAKE_IMAGES = Array.from(
|
||||||
|
{ length: 14 },
|
||||||
|
(_, index) => `https://example.com/reference-${index + 1}.png`
|
||||||
|
);
|
||||||
|
const FAKE_VIDEOS = Array.from(
|
||||||
|
{ length: 4 },
|
||||||
|
(_, index) => `https://example.com/reference-${index + 1}.mp4`
|
||||||
|
);
|
||||||
|
|
||||||
|
type Failure = {
|
||||||
|
modelId: string;
|
||||||
|
scenario: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateBody(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
runtime: any
|
||||||
|
) {
|
||||||
|
const missing = runtime.requiredFields.filter(
|
||||||
|
(key: string) => body[key] === undefined
|
||||||
|
);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`缺少必填参数:${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
for (const [key, values] of Object.entries(
|
||||||
|
runtime.enums as Record<string, Array<string | number>>
|
||||||
|
)) {
|
||||||
|
if (body[key] === undefined || !values.length) continue;
|
||||||
|
if (!values.some((value) => String(value) === String(body[key]))) {
|
||||||
|
throw new Error(`${key}=${String(body[key])} 不在官方枚举中`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (runtime.videoClipInput && body[runtime.videoClipInput.key] !== undefined) {
|
||||||
|
const clips = body[runtime.videoClipInput.key];
|
||||||
|
if (!Array.isArray(clips) || clips.length === 0) {
|
||||||
|
throw new Error("video_clips 请求格式无效");
|
||||||
|
}
|
||||||
|
for (const clip of clips) {
|
||||||
|
if (!clip || typeof clip !== "object" || !("url" in clip)) {
|
||||||
|
throw new Error("video_clips 缺少 url");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const catalog = await fetchAtlasCloudImagePresets();
|
||||||
|
const failures: Failure[] = catalog.errors.map((item) => ({
|
||||||
|
modelId: item.modelId,
|
||||||
|
scenario: "catalog-schema",
|
||||||
|
error: item.error,
|
||||||
|
}));
|
||||||
|
let simulatedCases = 0;
|
||||||
|
let imageModels = 0;
|
||||||
|
let model3dModels = 0;
|
||||||
|
let videoReferenceModels = 0;
|
||||||
|
|
||||||
|
for (const preset of catalog.presets) {
|
||||||
|
const capabilities = preset.capabilities as Record<string, any>;
|
||||||
|
const descriptor = {
|
||||||
|
providerType: "atlascloud",
|
||||||
|
modelId: preset.modelId,
|
||||||
|
capabilities,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
};
|
||||||
|
const mediaType = String(capabilities.mediaType || "");
|
||||||
|
|
||||||
|
if (mediaType === "3d") {
|
||||||
|
model3dModels += 1;
|
||||||
|
simulatedCases += 1;
|
||||||
|
if (
|
||||||
|
!is3DModelDescriptor(descriptor) ||
|
||||||
|
isImageGenerationModelDescriptor(descriptor) ||
|
||||||
|
isVideoModelDescriptor(descriptor)
|
||||||
|
) {
|
||||||
|
failures.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
scenario: "3d-classification",
|
||||||
|
error: "3D 模型被错误归入图片或视频模型",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageModels += 1;
|
||||||
|
if (
|
||||||
|
!isImageGenerationModelDescriptor(descriptor) ||
|
||||||
|
is3DModelDescriptor(descriptor) ||
|
||||||
|
isVideoModelDescriptor(descriptor)
|
||||||
|
) {
|
||||||
|
failures.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
scenario: "image-classification",
|
||||||
|
error: "图片模型分类不正确",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageGenConfig = preset.imageGenConfig as Record<string, any>;
|
||||||
|
const runtime = imageGenConfig.atlasCloud;
|
||||||
|
if (!runtime) {
|
||||||
|
failures.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
scenario: "runtime-config",
|
||||||
|
error: "缺少 AtlasCloud 运行配置",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratios = imageGenConfig.supportedAspectRatios?.length
|
||||||
|
? imageGenConfig.supportedAspectRatios
|
||||||
|
: [String(preset.defaultParams.aspectRatio || "1:1")];
|
||||||
|
const sizes = imageGenConfig.supportedImageSizes?.length
|
||||||
|
? imageGenConfig.supportedImageSizes
|
||||||
|
: [String(preset.defaultParams.imageSize || "1K")];
|
||||||
|
const counts = runtime.outputCountKey && runtime.maxImages > 1
|
||||||
|
? [1, runtime.maxImages]
|
||||||
|
: [1];
|
||||||
|
const requiredImageCount = runtime.imageInputs
|
||||||
|
.filter((input: any) => input.required)
|
||||||
|
.reduce((total: number, input: any) => total + input.maxItems, 0);
|
||||||
|
const imageReferenceCounts = new Set<number>([
|
||||||
|
requiredImageCount,
|
||||||
|
...(runtime.maxReferenceImages > requiredImageCount
|
||||||
|
? [runtime.maxReferenceImages]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
const requiredVideoCount = runtime.videoClipInput?.required
|
||||||
|
? runtime.videoClipInput.maxItems
|
||||||
|
: 0;
|
||||||
|
const videoReferenceCounts = new Set<number>([
|
||||||
|
requiredVideoCount,
|
||||||
|
...(runtime.maxVideoReferences > requiredVideoCount
|
||||||
|
? [runtime.maxVideoReferences]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
if (runtime.maxVideoReferences > 0) videoReferenceModels += 1;
|
||||||
|
|
||||||
|
for (const ratio of ratios) {
|
||||||
|
for (const size of sizes) {
|
||||||
|
for (const imageCount of counts) {
|
||||||
|
for (const referenceImageCount of imageReferenceCounts) {
|
||||||
|
for (const referenceVideoCount of videoReferenceCounts) {
|
||||||
|
const scenario = [
|
||||||
|
`ratio=${ratio}`,
|
||||||
|
`size=${size}`,
|
||||||
|
`count=${imageCount}`,
|
||||||
|
`images=${referenceImageCount}`,
|
||||||
|
`videos=${referenceVideoCount}`,
|
||||||
|
].join(",");
|
||||||
|
simulatedCases += 1;
|
||||||
|
try {
|
||||||
|
const { body } = createAtlasCloudRequestBody({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
prompt: "AtlasCloud 前端请求模拟测试",
|
||||||
|
referenceImages: FAKE_IMAGES.slice(0, referenceImageCount),
|
||||||
|
referenceVideos: FAKE_VIDEOS.slice(0, referenceVideoCount),
|
||||||
|
imageParams: {
|
||||||
|
aspectRatio: ratio,
|
||||||
|
imageSize: size,
|
||||||
|
imageCount,
|
||||||
|
},
|
||||||
|
modelConfig: imageGenConfig,
|
||||||
|
});
|
||||||
|
validateBody(body, runtime);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
scenario,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
catalogModels: catalog.catalogCount,
|
||||||
|
availableModels: catalog.presets.length,
|
||||||
|
imageModels,
|
||||||
|
model3dModels,
|
||||||
|
videoReferenceModels,
|
||||||
|
simulatedCases,
|
||||||
|
passedCases: simulatedCases - failures.length,
|
||||||
|
failedCases: failures.length,
|
||||||
|
failures,
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
if (failures.length > 0) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
@ -3,6 +3,7 @@ import {
|
|||||||
createAtlasCloudImagePreset,
|
createAtlasCloudImagePreset,
|
||||||
inferAtlasCloudMediaKind,
|
inferAtlasCloudMediaKind,
|
||||||
} from "@/lib/ai/atlascloud-models";
|
} from "@/lib/ai/atlascloud-models";
|
||||||
|
import { createAtlasCloudRequestBody } from "@/lib/ai/atlascloud-request";
|
||||||
import {
|
import {
|
||||||
is3DModelDescriptor,
|
is3DModelDescriptor,
|
||||||
isImageGenerationModelDescriptor,
|
isImageGenerationModelDescriptor,
|
||||||
@ -120,4 +121,71 @@ test.describe("AI model media classification", () => {
|
|||||||
expect(isVideoModelDescriptor(videoDescriptor)).toBe(true);
|
expect(isVideoModelDescriptor(videoDescriptor)).toBe(true);
|
||||||
expect(isImageGenerationModelDescriptor(videoDescriptor)).toBe(false);
|
expect(isImageGenerationModelDescriptor(videoDescriptor)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("maps Atlas reference-video image models to a valid frontend request", () => {
|
||||||
|
const preset = createAtlasCloudImagePreset(
|
||||||
|
{
|
||||||
|
model: "google/nano-banana-2/reference-to-image",
|
||||||
|
type: "Image",
|
||||||
|
displayName: "Nano Banana 2 Reference to Image",
|
||||||
|
categories: ["IMAGE-TO-IMAGE"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
paths: {
|
||||||
|
"/api/v1/model/generateImage": { post: {} },
|
||||||
|
"/api/v1/model/prediction/{request_id}": { get: {} },
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
Input: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
model: { type: "string" },
|
||||||
|
prompt: { type: "string" },
|
||||||
|
resolution: { type: "string", enum: ["1k", "2k", "4k"] },
|
||||||
|
video_clips: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 1,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
url: { type: "string" },
|
||||||
|
start: { type: "number", default: 0 },
|
||||||
|
ends: { type: "number", default: 0 },
|
||||||
|
fps: { type: "number", default: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["model", "prompt", "video_clips"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(preset.capabilities).toMatchObject({
|
||||||
|
mediaType: "image",
|
||||||
|
supportsVideoReference: true,
|
||||||
|
requiresVideoReference: true,
|
||||||
|
maxVideoReferences: 1,
|
||||||
|
});
|
||||||
|
const { body } = createAtlasCloudRequestBody({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
prompt: "参考视频生成商品图",
|
||||||
|
referenceImages: [],
|
||||||
|
referenceVideos: ["https://example.com/reference.mp4"],
|
||||||
|
imageParams: { imageSize: "2K", aspectRatio: "1:1" },
|
||||||
|
modelConfig: preset.imageGenConfig,
|
||||||
|
});
|
||||||
|
expect(body.video_clips).toEqual([
|
||||||
|
{
|
||||||
|
url: "https://example.com/reference.mp4",
|
||||||
|
start: 0,
|
||||||
|
ends: 0,
|
||||||
|
fps: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user