feat(protocol): 可美 KM Seedream - 接入图片生成与编辑
This commit is contained in:
@@ -2,12 +2,13 @@ import { useState } from "react";
|
||||
import "./image-size-picker.css";
|
||||
import { Input, Button } from "antd";
|
||||
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { IMAGE_RESOLUTIONS, imagePresetForRatio, imagePresetValue, imageQualityForSelection, imageQualityForTier, imageResolutionUsesQuality, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
|
||||
import { imageResolutionTiers, imagePresetForRatio, imagePresetValue, imageQualityForSelection, imageQualityForTier, imageResolutionUsesQuality, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
|
||||
import { buildImageResolutionOptions, type ImageResolutionOption, type ImageResolutionTier } from "@/lib/image-resolution-tiers";
|
||||
|
||||
import { resolveImageRequestSize, validateImageSize } from "@/services/api/image-validation";
|
||||
|
||||
export function ImageSizePicker({ profile, size, quality, onChange }: { profile: ImageCapabilityConfig; size: string; quality?: string; onChange: (size: string, quality?: string) => void }) {
|
||||
const IMAGE_RESOLUTIONS = imageResolutionTiers(profile);
|
||||
const presets = imageSizePresets(profile).filter((preset) => imageTierAvailable(profile, preset.tier));
|
||||
const visibleTiers = IMAGE_RESOLUTIONS.filter((value) => presets.some((preset) => preset.tier === value));
|
||||
const [chosenTier, setChosenTier] = useState<ImageResolutionTier>("1k");
|
||||
|
||||
@@ -4,10 +4,11 @@ import { Check, Plus } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/base/switch";
|
||||
import "./image-size-picker.css";
|
||||
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
|
||||
import { IMAGE_RATIOS, imageResolutionTiers, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
|
||||
import type { ImageResolutionTier } from "@/lib/image-resolution-tiers";
|
||||
|
||||
export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) {
|
||||
const IMAGE_RESOLUTIONS = imageResolutionTiers(profile);
|
||||
const presets = imageSizePresets(profile);
|
||||
const id = useId();
|
||||
const [drafts, setDrafts] = useState<Partial<Record<ImageResolutionTier, string>>>({});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ImageResolutionTier = "1k" | "2k" | "4k";
|
||||
export type ImageResolutionTier = "1k" | "1.5k" | "2k" | "4k";
|
||||
|
||||
export type ImageResolutionOption = {
|
||||
size: string;
|
||||
@@ -10,7 +10,7 @@ export type ImageResolutionOption = {
|
||||
|
||||
export type ImageResolutionChoice = "auto" | ImageResolutionTier;
|
||||
|
||||
const tierOrder: ImageResolutionTier[] = ["1k", "2k", "4k"];
|
||||
const tierOrder: ImageResolutionTier[] = ["1k", "1.5k", "2k", "4k"];
|
||||
const ratioOrder = ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "2:1", "1:2", "21:9"];
|
||||
|
||||
export function supportsImageResolutionPresets(size: { parameter: string; values: string[]; allowCustom?: boolean }) {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { buildImageResolutionOptions, type ImageResolutionOption, type ImageReso
|
||||
import type { ImageCapabilityConfig } from "./model-capabilities";
|
||||
|
||||
export const IMAGE_RESOLUTIONS: ImageResolutionTier[] = ["1k", "2k", "4k"];
|
||||
// 1.5K 只在模型明确配置此档位时出现,既有模型的档位保持原有顺序。
|
||||
export function imageResolutionTiers(profile: ImageCapabilityConfig): ImageResolutionTier[] {
|
||||
return profile.size.presets?.some((preset) => preset.tier === "1.5k") || profile.quality.values.includes("1.5k") ? ["1k", "1.5k", "2k", "4k"] : IMAGE_RESOLUTIONS;
|
||||
}
|
||||
export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9"];
|
||||
const standardSizes: Record<string, string[]> = {
|
||||
"1:1": ["1024x1024", "2048x2048", "2880x2880"],
|
||||
@@ -33,9 +37,9 @@ export function imagePresetForRatio(tier: ImageResolutionTier, input: string): I
|
||||
return width * h === height * w;
|
||||
}) || `${w}:${h}`;
|
||||
const index = IMAGE_RESOLUTIONS.indexOf(tier);
|
||||
if (index < 0) throw new Error("分辨率仅支持 1K、2K、4K");
|
||||
const standard = standardSizes[ratio]?.[index];
|
||||
const pixels = [1_048_576, 4_194_304, 8_294_400][index];
|
||||
if (index < 0 && tier !== "1.5k") throw new Error("分辨率仅支持 1K、1.5K、2K、4K");
|
||||
const standard = tier === "1.5k" ? undefined : standardSizes[ratio]?.[index];
|
||||
const pixels = tier === "1.5k" ? 2_359_296 : [1_048_576, 4_194_304, 8_294_400][index];
|
||||
let width = Math.round(Math.sqrt((pixels * w) / h) / 16) * 16;
|
||||
let height = Math.round((width * h) / w / 16) * 16;
|
||||
while (width * height > pixels || Math.max(width, height) > 3840) {
|
||||
@@ -52,12 +56,12 @@ export function imagePresetForRatio(tier: ImageResolutionTier, input: string): I
|
||||
}
|
||||
|
||||
export function imageQualityForTier(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
|
||||
const aliases = { "1k": ["1k", "low"], "2k": ["2k", "medium"], "4k": ["4k", "high"] }[tier];
|
||||
const aliases = { "1k": ["1k", "low"], "1.5k": ["1.5k"], "2k": ["2k", "medium"], "4k": ["4k", "high"] }[tier];
|
||||
return profile.quality.supported ? profile.quality.values.find((value) => aliases.includes(value.toLowerCase())) : undefined;
|
||||
}
|
||||
|
||||
export function imageResolutionUsesQuality(profile: ImageCapabilityConfig) {
|
||||
return profile.size.parameter === "aspect_ratio" && IMAGE_RESOLUTIONS.some((tier) => imageQualityForTier(profile, tier));
|
||||
return profile.size.parameter === "aspect_ratio" && imageResolutionTiers(profile).some((tier) => imageQualityForTier(profile, tier));
|
||||
}
|
||||
|
||||
export function imageTierAvailable(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
|
||||
@@ -82,7 +86,7 @@ export function imageSizePresets(profile: ImageCapabilityConfig): ImageResolutio
|
||||
if (profile.size.parameter === "none") return [];
|
||||
if (profile.size.presets) return profile.size.presets;
|
||||
const pixels = buildImageResolutionOptions(profile.size.values);
|
||||
const tiers = profile.size.parameter === "aspect_ratio" ? IMAGE_RESOLUTIONS.filter((tier) => imageQualityForTier(profile, tier)) : [];
|
||||
const tiers = profile.size.parameter === "aspect_ratio" ? imageResolutionTiers(profile).filter((tier) => imageQualityForTier(profile, tier)) : [];
|
||||
const ratios = tiers.flatMap((tier) =>
|
||||
profile.size.values.flatMap((ratio) => {
|
||||
try {
|
||||
|
||||
17
web/src/lib/kemei-image-presets.ts
Normal file
17
web/src/lib/kemei-image-presets.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ImageResolutionOption, ImageResolutionTier } from "./image-resolution-tiers";
|
||||
|
||||
// Seedream 5.0 官方像素表;不能按通用像素阈值把 2K 横屏误分为 4K。
|
||||
export function kemeiSeedreamPresets(): ImageResolutionOption[] {
|
||||
const ratios = ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"];
|
||||
const tiers: [ImageResolutionTier, string[]][] = [
|
||||
["1k", ["1024x1024", "1152x864", "864x1152", "1424x800", "800x1424", "1248x832", "832x1248", "1568x672"]],
|
||||
["1.5k", ["1536x1536", "1792x1344", "1344x1792", "2048x1152", "1152x2048", "1872x1248", "1248x1872", "2352x1008"]],
|
||||
["2k", ["2048x2048", "2368x1776", "1776x2368", "2816x1584", "1584x2816", "2496x1664", "1664x2496", "3136x1344"]],
|
||||
];
|
||||
return tiers.flatMap(([tier, sizes]) =>
|
||||
sizes.map((size, index) => {
|
||||
const [width, height] = size.split("x").map(Number);
|
||||
return { tier, ratio: ratios[index], width, height, size };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
|
||||
import type { ImageResolutionOption, ImageResolutionTier } from "@/lib/image-resolution-tiers";
|
||||
import { imagePresetForRatio } from "./image-size-presets";
|
||||
import { kemeiSeedreamPresets } from "./kemei-image-presets";
|
||||
import { type WorkflowVideoFieldLike, workflowImageCapabilityConfig, workflowVideoCapabilityConfig } from "./model-capabilities-workflow";
|
||||
import { resolveWorkflowVideoScreenSpec } from "./video-screen-specs";
|
||||
|
||||
@@ -220,6 +221,19 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
outputFormat: { supported: true },
|
||||
maxOutputs: 15,
|
||||
};
|
||||
if (protocol === "km-kemei-seedream") {
|
||||
const presets = kemeiSeedreamPresets();
|
||||
image.references = { ...image.references, maxImages: 10, maskSupported: false };
|
||||
image.size = { parameter: "size", values: presets.map((preset) => preset.size), default: "2048x2048", allowCustom: true, presets };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
// 透明输出要求一张含 alpha 的参考图,宿主通用开关不能保证此条件。
|
||||
// 此能力通过插件命名空间的 background + output_format 显式配置。
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
image.responseFormat = { supported: true };
|
||||
image.outputFormat = { supported: true };
|
||||
image.maxOutputs = 1;
|
||||
return image;
|
||||
}
|
||||
if (protocol === "kacang-midjourney-special" || protocol === "kacang-midjourney-v7" || protocol === "kacang-midjourney") {
|
||||
const stable = protocol === "kacang-midjourney";
|
||||
const extendedRatios = stable || protocol === "kacang-midjourney-v7";
|
||||
@@ -493,7 +507,7 @@ export function normalizeImageValue(profile: ImageCapabilityConfig, value: { siz
|
||||
: value.quality && profile.quality.values.includes(value.quality)
|
||||
? value.quality
|
||||
: profile.quality.default || "auto"
|
||||
: requestedQuality === "1k" || requestedQuality === "2k" || requestedQuality === "4k"
|
||||
: requestedQuality === "1k" || requestedQuality === "1.5k" || requestedQuality === "2k" || requestedQuality === "4k"
|
||||
? requestedQuality
|
||||
: presetTier || profile.quality.default || "auto";
|
||||
const count = String(Math.max(1, Math.min(profile.maxOutputs, Math.floor(Math.abs(Number(value.count)) || 1))));
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { http } from "@/services/api/request";
|
||||
import type { ModelTag } from "@/lib/model-tags";
|
||||
import type { ImageResolutionOption } from "@/lib/image-resolution-tiers";
|
||||
|
||||
export type InputConstraint = { min: number; max: number };
|
||||
export type OptionConstraint = { values?: unknown[]; min?: number; max?: number; step?: number };
|
||||
export type CapabilityImageSizePreset = {
|
||||
size: string;
|
||||
tier: "1k" | "2k" | "4k";
|
||||
ratio: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
export type CapabilityImageSizePreset = ImageResolutionOption;
|
||||
export type CapabilityImageSize = {
|
||||
parameter?: "size" | "aspect_ratio";
|
||||
allowCustom?: boolean;
|
||||
|
||||
@@ -9,6 +9,13 @@ const protocols = [definition("disabled-text", "text", false), definition("text"
|
||||
const protocolsWithAudio = [...protocols, definition("audio", "audio")];
|
||||
|
||||
describe("channel model editor drafts", () => {
|
||||
test("Kemei Seedream selection initializes the image profile and preserves pricing", () => {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
draft.priceTiers[0].unitPrice = 19;
|
||||
const next = changeChannelModelCapability({ ...draft, capability: "image", protocol: "km-kemei-seedream", modelKey: "doubao-seedream-5-0-pro-260628" }, [definition("km-kemei-seedream", "image")]);
|
||||
expect(next.capabilityConfig?.image).toMatchObject({ references: { maxImages: 10, maskSupported: false }, maxOutputs: 1, size: { default: "2048x2048" } });
|
||||
expect(next.priceTiers[0].unitPrice).toBe(19);
|
||||
});
|
||||
test("new drafts select an enabled protocol and never share price state", () => {
|
||||
const first = initialChannelModelValues(null, protocols);
|
||||
first.priceTiers[0].unitPrice = 42;
|
||||
|
||||
@@ -11,6 +11,19 @@ import { resolveImageRequestSize, validateImageSize } from "../src/services/api/
|
||||
import { buildGeminiImageGenerationConfig } from "../src/lib/gemini-image";
|
||||
|
||||
describe("统一图片分辨率与宽高比", () => {
|
||||
test("Seedream 5.0 shows 1.5K and retains the exact official 2K wide size", () => {
|
||||
const profile = defaultImageCapabilityConfig("km-kemei-seedream", "doubao-seedream-5-0-pro-260628");
|
||||
const intermediate = renderToStaticMarkup(<ImageSizePicker profile={profile} size="1536x1536" onChange={() => {}} />);
|
||||
expect(intermediate).toMatch(/aria-pressed="true"[^>]*>1\.5K/);
|
||||
expect(intermediate).toContain("1536 × 1536 px");
|
||||
expect(intermediate).not.toMatch(/<button[^>]*>4K/);
|
||||
const wide = renderToStaticMarkup(<ImageSizePicker profile={profile} size="2816x1584" onChange={() => {}} />);
|
||||
expect(wide).toMatch(/aria-pressed="true"[^>]*>2K/);
|
||||
expect(wide).toContain("2816 × 1584 px");
|
||||
const reopened = normalizeModelCapabilityConfig({ version: 1, image: profile });
|
||||
expect(reopened.image?.size.presets?.filter((p) => p.tier === "1.5k")).toHaveLength(8);
|
||||
expect(resolveImageRequestSize(profile, "auto", "1536x1536")?.value).toBe("1536x1536");
|
||||
});
|
||||
test("管理员档位开关按已有比例启用,部分比例不显示为关闭", () => {
|
||||
for (const model of ["midjourney-1k", "midjourney-2k"]) {
|
||||
const profile = defaultImageCapabilityConfig("cangyuan-midjourney-v82", model);
|
||||
|
||||
@@ -3,7 +3,23 @@ import test from "node:test";
|
||||
|
||||
// Bun 直接执行 TypeScript 测试时需要保留扩展名;生产 tsconfig 不包含 test/。
|
||||
import { DEFAULT_VIDEO_PROMPT_MAX_CHARS, defaultModelCapabilityConfig, normalizeImageValue, normalizeVideoValue } from "../src/lib/model-capabilities.ts";
|
||||
import { imageTierAvailable } from "../src/lib/image-size-presets.ts";
|
||||
import { imageTierAvailable, imageResolutionTiers } from "../src/lib/image-size-presets.ts";
|
||||
|
||||
test("Kemei Seedream defaults retain official 1K/1.5K/2K sizes without a 4K preset", () => {
|
||||
for (const model of ["doubao-seedream-5-0-pro-260628", "doubao-seedream-5-0-flash-260915"]) {
|
||||
const image = defaultModelCapabilityConfig("km-kemei-seedream", model).image!;
|
||||
assert.equal(image.references.maxImages, 10);
|
||||
assert.equal(image.references.maskSupported, false);
|
||||
assert.equal(image.maxOutputs, 1);
|
||||
assert.equal(image.size.default, "2048x2048");
|
||||
assert.equal(image.quality.supported, false);
|
||||
assert.equal(image.size.presets!.length, 24);
|
||||
assert.deepEqual([...new Set(image.size.presets!.map((p) => p.tier))], ["1k", "1.5k", "2k"]);
|
||||
assert.equal(image.size.presets!.find((p) => p.size === "2816x1584")?.tier, "2k");
|
||||
assert.ok(imageResolutionTiers(image).includes("1.5k"));
|
||||
}
|
||||
assert.deepEqual(imageResolutionTiers(defaultModelCapabilityConfig("openai-image").image!), ["1k", "2k", "4k"]);
|
||||
});
|
||||
|
||||
test("text multimodal capability is not guessed from a model name", () => {
|
||||
for (const model of ["gpt-4o", "gemini-2.5-pro", "doubao-seed"]) {
|
||||
|
||||
Reference in New Issue
Block a user