feat(*): 创作与运维 - 新增图片尺寸预设和系统性能面板并优化图片组动画
This commit is contained in:
46
web/test/canvas-batch-node-animation.test.tsx
Normal file
46
web/test/canvas-batch-node-animation.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CanvasNode } from "../src/components/canvas/canvas-node";
|
||||
import { CanvasNodeType } from "../src/types/canvas";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function renderChild(options: { selected?: boolean; opening?: boolean; closing?: boolean } = {}) {
|
||||
return renderToStaticMarkup(
|
||||
<CanvasNode
|
||||
data={{ id: "child", type: CanvasNodeType.Image, title: "子图", position: { x: 600, y: 100 }, width: 340, height: 240, metadata: { batchRootId: "root", status: "idle" } }}
|
||||
scale={1}
|
||||
isSelected={Boolean(options.selected)}
|
||||
isRelated={false}
|
||||
isFocusRelated={false}
|
||||
isConnectionTarget={false}
|
||||
showImageInfo={false}
|
||||
batchOpening={options.opening}
|
||||
batchClosing={options.closing}
|
||||
batchMotion={{ x: -566, y: -86, index: 0 }}
|
||||
onMouseDown={noop}
|
||||
onHoverStart={noop}
|
||||
onHoverEnd={noop}
|
||||
onConnectStart={noop}
|
||||
onResize={noop}
|
||||
onContentChange={noop}
|
||||
onContextMenu={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("图片组子图动画", () => {
|
||||
test("普通挂载与选中子图都不播放展开动画", () => {
|
||||
for (const selected of [false, true]) {
|
||||
const markup = renderChild({ selected });
|
||||
expect(markup).not.toContain("canvas-batch-child-in");
|
||||
expect(markup).not.toContain("canvas-batch-child-out");
|
||||
}
|
||||
});
|
||||
|
||||
test("仅在图片组展开或收起期间播放对应动画", () => {
|
||||
expect(renderChild({ opening: true })).toContain("canvas-batch-child-in");
|
||||
expect(renderChild({ closing: true })).toContain("canvas-batch-child-out");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
supportsImageResolutionPresets,
|
||||
} from "../src/lib/image-resolution-tiers";
|
||||
import { defaultImageCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { resolveImageRequestSize } from "../src/services/api/image-validation";
|
||||
|
||||
const sizes = [
|
||||
"1024x1024", "1360x1024", "1024x1360", "1536x1024", "1024x1536", "1024x1280", "1280x1024", "2048x878", "1824x1024", "1024x1824",
|
||||
@@ -17,6 +18,17 @@ const sizes = [
|
||||
];
|
||||
|
||||
describe("image resolution tiers", () => {
|
||||
test("精确像素预设保持请求原值,比例协议发送比例", () => {
|
||||
const profile = defaultImageCapabilityConfig("openai-image", "test");
|
||||
profile.size = { parameter: "size", values: ["1920x1080", "3840x2160", "2160x3840", "1824x1024"], default: "1920x1080", allowCustom: false };
|
||||
expect(imageResolutionChoices(profile.size.values)).toEqual(["1k", "2k", "4k"]);
|
||||
expect(imageSizeForResolution(buildImageResolutionOptions(profile.size.values), "1k", "16:9")).toBe("1824x1024");
|
||||
expect(resolveImageRequestSize(profile, undefined, "1920x1080")).toEqual({ parameter: "size", value: "1920x1080" });
|
||||
profile.size = { parameter: "aspect_ratio", values: ["16:9"], default: "16:9", allowCustom: false };
|
||||
expect(resolveImageRequestSize(profile, undefined, "16:9")).toEqual({ parameter: "aspect_ratio", value: "16:9" });
|
||||
profile.size = { parameter: "size", values: [], default: "auto", allowCustom: true };
|
||||
expect(() => resolveImageRequestSize(profile, undefined, "1920x1080")).toThrow("16 的倍数");
|
||||
});
|
||||
test("将 Xiaobaishu 的精确尺寸整理为 1K、2K、4K 各十种比例", () => {
|
||||
const options = buildImageResolutionOptions(sizes);
|
||||
|
||||
|
||||
156
web/test/image-size-presets.test.tsx
Normal file
156
web/test/image-size-presets.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ImageSizePicker } from "../src/components/image-size-picker";
|
||||
import { ImageSizePresetsEditor } from "../src/components/image-size-presets-editor";
|
||||
import { ModelCapabilityEditor } from "../src/components/model-capability-editor";
|
||||
import { defaultImageCapabilityConfig, normalizeModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imagePresetValue, imageQualityForTier, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "../src/lib/image-size-presets";
|
||||
import { buildImageResolutionOptions } from "../src/lib/image-resolution-tiers";
|
||||
import { resolveImageRequestSize, validateImageSize } from "../src/services/api/image-validation";
|
||||
import { buildGeminiImageGenerationConfig } from "../src/lib/gemini-image";
|
||||
|
||||
describe("统一图片分辨率与宽高比", () => {
|
||||
test("像素协议从旧比例配置切换 4K 后发送真实像素,不回退到 1K", () => {
|
||||
const profile = defaultImageCapabilityConfig();
|
||||
profile.size = { parameter: "size", values: ["16:9"], default: "16:9", allowCustom: true };
|
||||
const value = imagePresetValue(profile, imagePresetForRatio("4k", "16:9"));
|
||||
expect(value).toBe("3840x2160");
|
||||
expect(resolveImageRequestSize(profile, "auto", value)).toEqual({ parameter: "size", value: "3840x2160" });
|
||||
const legacy = renderToStaticMarkup(<ImageSizePicker profile={profile} size="16:9" onChange={() => {}} />);
|
||||
expect(legacy).toContain("1824 × 1024 px");
|
||||
const reopened = renderToStaticMarkup(<ImageSizePicker profile={profile} size="3840x1920" onChange={() => {}} />);
|
||||
expect(reopened).not.toMatch(/<button[^>]*>4K/);
|
||||
expect(reopened).toContain("2:1 · 3840 × 1920 px");
|
||||
});
|
||||
|
||||
test("常用比例保持明确尺寸,等价比例归一", () => {
|
||||
expect(imagePresetForRatio("1k", "16:9").size).toBe("1824x1024");
|
||||
expect(imagePresetForRatio("4k", "9:16").size).toBe("2160x3840");
|
||||
expect(imagePresetForRatio("1k", "32:18")).toEqual(imagePresetForRatio("1k", "16:9"));
|
||||
expect(imagePresetForRatio("2k", "7:3").ratio).toBe("21:9");
|
||||
expect(imagePresetForRatio("2k", "34:22").ratio).toBe("17:11");
|
||||
});
|
||||
|
||||
test("三档预设与任意合理比例都满足像素范围", () => {
|
||||
for (const tier of IMAGE_RESOLUTIONS)
|
||||
for (const ratio of [...IMAGE_RATIOS, "17:11", "3:1", "1:3", "99999:99998"]) {
|
||||
const preset = imagePresetForRatio(tier, ratio);
|
||||
expect(() => validateImageSize(preset.width, preset.height, false)).not.toThrow();
|
||||
}
|
||||
for (const ratio of ["0:9", "4:1", "1:4", "NaN:1", "1.5:1", "999999999:1"]) {
|
||||
expect(() => imagePresetForRatio("1k", ratio)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("管理员设置同步支持值、默认值,并保持精确尺寸", () => {
|
||||
const profile = defaultImageCapabilityConfig();
|
||||
profile.size = { parameter: "size", values: ["1920x1080"], default: "1920x1080", allowCustom: false };
|
||||
const existing = imageSizePresets(profile);
|
||||
const added = imagePresetForRatio("1k", "17:11");
|
||||
profile.size = imageSizeConfigWithPresets(profile, [...existing, added]);
|
||||
expect(profile.size.values).toEqual(["1920x1080", added.size]);
|
||||
expect(profile.size.default).toBe("1920x1080");
|
||||
expect(resolveImageRequestSize(profile, undefined, "1920x1080")?.value).toBe("1920x1080");
|
||||
expect(normalizeModelCapabilityConfig({ version: 1, image: profile }).image?.size.presets).toEqual(profile.size.presets);
|
||||
expect(imageSizeConfigWithPresets(profile, [added]).default).toBe(added.size);
|
||||
});
|
||||
|
||||
test("非标准精确像素不会从选项中消失", () => {
|
||||
expect(buildImageResolutionOptions(["1360x880"])[0]).toMatchObject({ size: "1360x880", ratio: "17:11" });
|
||||
expect(buildImageResolutionOptions(["0x1024", "1024x0"])).toEqual([]);
|
||||
});
|
||||
|
||||
test("比例协议分别发送比例和分辨率,不把自定义权限当作 4K 能力", () => {
|
||||
const grok = defaultImageCapabilityConfig("grok-image", "grok-imagine-image");
|
||||
grok.size.allowCustom = true;
|
||||
expect(imageTierAvailable(grok, "4k")).toBe(false);
|
||||
expect(imageSizePresets(grok).some((preset) => preset.tier === "4k")).toBe(false);
|
||||
const gemini = defaultImageCapabilityConfig("gemini-image", "test");
|
||||
const preset = imagePresetForRatio("4k", "16:9");
|
||||
expect(buildGeminiImageGenerationConfig(imagePresetValue(gemini, preset), imageQualityForTier(gemini, "4k")).imageConfig).toEqual({ aspectRatio: "16:9", imageSize: "4K" });
|
||||
gemini.size.allowCustom = true;
|
||||
const reopened = renderToStaticMarkup(<ImageSizePicker profile={gemini} size="17:11" quality="high" onChange={() => {}} />);
|
||||
expect(reopened).toMatch(/aria-pressed="true"[^>]*>4K/);
|
||||
expect(reopened).toContain("17:11");
|
||||
});
|
||||
|
||||
test("比例与档位的组合必须在该档位配置中,允许自定义则放开比例", () => {
|
||||
const profile = defaultImageCapabilityConfig("gemini-image", "test");
|
||||
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("1k", "1:1"), imagePresetForRatio("2k", "16:9")]);
|
||||
expect(() => resolveImageRequestSize(profile, "medium", "1:1")).toThrow("当前分辨率");
|
||||
expect(resolveImageRequestSize(profile, "medium", "16:9")).toEqual({ parameter: "aspect_ratio", value: "16:9" });
|
||||
profile.size.allowCustom = true;
|
||||
expect(resolveImageRequestSize(profile, "medium", "1:1")?.value).toBe("1:1");
|
||||
});
|
||||
|
||||
test("管理员固定显示三档,用户端隐藏未支持档位,不发送尺寸时隐藏", () => {
|
||||
const profile = defaultImageCapabilityConfig("grok-image", "grok-imagine-image");
|
||||
profile.size.allowCustom = true;
|
||||
const editor = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} onChange={() => {}} />);
|
||||
for (const label of ["1K", "2K", "4K"]) expect(editor).toMatch(new RegExp(`<strong[^>]*>${label}</strong>`));
|
||||
const picker = renderToStaticMarkup(<ImageSizePicker profile={profile} size="16:9" quality="2k" onChange={() => {}} />);
|
||||
expect(picker).not.toMatch(/<button[^>]*>4K/);
|
||||
expect(picker).toContain("换算参考");
|
||||
profile.size.parameter = "none";
|
||||
expect(renderToStaticMarkup(<ImageSizePicker profile={profile} size="auto" onChange={() => {}} />)).toBe("");
|
||||
});
|
||||
|
||||
test("空档位始终隐藏,允许自定义也不显示;旧尺寸不导致比例区域为空", () => {
|
||||
for (const protocol of ["openai-image", "gemini-image"]) {
|
||||
const profile = defaultImageCapabilityConfig(protocol, "test");
|
||||
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("1k", "1:1"), imagePresetForRatio("4k", "16:9")]);
|
||||
for (const allowCustom of [false, true]) {
|
||||
profile.size.allowCustom = allowCustom;
|
||||
const picker = renderToStaticMarkup(<ImageSizePicker profile={profile} size={profile.size.parameter === "aspect_ratio" ? "16:9" : "2752x1536"} quality="medium" onChange={() => {}} />);
|
||||
expect(picker).toMatch(/<button[^>]*>1K/);
|
||||
expect(picker).toMatch(/<button[^>]*>4K/);
|
||||
expect(picker).not.toMatch(/<button[^>]*>2K/);
|
||||
expect(picker).not.toContain("未配置比例");
|
||||
expect(picker).toContain('title="1:1 · 1024 × 1024"');
|
||||
expect(picker).not.toMatch(/aria-pressed="true"[^>]*>1K/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("仅配置一档时只显示该档;全空时保留自动尺寸与自定义入口", () => {
|
||||
const profile = defaultImageCapabilityConfig();
|
||||
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("4k", "16:9")]);
|
||||
const picker = renderToStaticMarkup(<ImageSizePicker profile={profile} size="3840x2160" onChange={() => {}} />);
|
||||
expect(picker).not.toMatch(/<button[^>]*>[12]K/);
|
||||
expect(picker).toMatch(/aria-pressed="true"[^>]*>4K/);
|
||||
profile.size = imageSizeConfigWithPresets(profile, []);
|
||||
profile.size.allowCustom = true;
|
||||
const empty = renderToStaticMarkup(<ImageSizePicker profile={profile} size="auto" onChange={() => {}} />);
|
||||
expect(empty).not.toContain('class="image-size-tiers"');
|
||||
expect(empty).not.toContain('class="image-size-label"');
|
||||
expect(empty).toContain("自动尺寸");
|
||||
expect(empty).toContain("自定义比例或尺寸");
|
||||
});
|
||||
|
||||
test("管理员比例直接点选和输入,默认输出不再使用下拉框", () => {
|
||||
const profile = defaultImageCapabilityConfig();
|
||||
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("1k", "16:9")]);
|
||||
const editor = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} onChange={() => {}} />);
|
||||
expect(editor).not.toContain('role="combobox"');
|
||||
expect(editor).not.toContain("ant-select");
|
||||
for (const tier of ["1K", "2K", "4K"]) {
|
||||
expect(editor).toContain(`aria-label="${tier} 自定义比例"`);
|
||||
expect(editor).toContain(`aria-label="添加 ${tier} 比例"`);
|
||||
expect(editor).toContain(`aria-label="${tier} 16:9"`);
|
||||
}
|
||||
expect(editor).toContain('aria-label="默认输出"');
|
||||
const readOnly = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} disabled onChange={() => {}} />);
|
||||
const buttons = [...readOnly.matchAll(/<button\b[^>]*>/g)];
|
||||
expect(buttons.length).toBeGreaterThan(24);
|
||||
expect(buttons.every(([button]) => button.includes('disabled=""'))).toBe(true);
|
||||
});
|
||||
|
||||
test("图片协议尺寸独占整行且先于辅助参数,视频布局不受影响", () => {
|
||||
const image = renderToStaticMarkup(<ModelCapabilityEditor capability="image" section="protocol" />);
|
||||
expect(image).toContain("admin-image-protocol-grid");
|
||||
expect(image).toContain("admin-image-size-card");
|
||||
expect(image.indexOf("尺寸参数")).toBeLessThan(image.indexOf("输出数量"));
|
||||
const video = renderToStaticMarkup(<ModelCapabilityEditor capability="video" section="protocol" />);
|
||||
expect(video).not.toContain("admin-image-protocol-grid");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user