feat(protocol): 可美 KM Seedream - 接入图片生成与编辑

This commit is contained in:
Codex
2026-10-09 16:42:58 +08:00
parent 85c2aa178b
commit 1b08bd64dd
24 changed files with 3277 additions and 21 deletions

View File

@@ -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");

View File

@@ -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>>>({});

View File

@@ -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 }) {

View File

@@ -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 {

View 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 };
}),
);
}

View File

@@ -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))));

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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"]) {