支持画布生图选择抠图背景色
新增画布生图背景色选项并在角色、图标和 UI 素材提取入口展示选择控件。 将 screenColor 贯穿前端提交、后端请求解析、prompt 构建和生成后本地后处理调用。 保留角色动作旧绿幕路径,避免影响既有动画生成流程。 补充相关前后端单元测试和编辑器文档说明。
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-02 图片画布生成抠图背景色使用 screenColor 传递
|
||||
|
||||
- 背景:画布角色、图标和 UI 素材生成过去固定要求 `#00FF00` 绿幕,后续 BGfilter 服务需要按生成时背景色做去背景,不能继续把背景色写死在 prompt 或后处理里。
|
||||
- 决策:角色形象、图标 spritesheet 和 UI 设计图素材提取新增用户可选纯色抠图背景。前端下拉展示颜色名、方块色样和 hex,默认 `浅雾蓝 #CFEFFF`,可选 `浅钢蓝 #B0C2E0`、`暖浅桃色 #FFD6C2`、`淡薰衣草紫 #E6D8FF`、`浅粉灰 #F4D8E8`、`中度天蓝 #7FB3FF`。请求字段统一为前端 / JSON `screenColor`、Rust `screen_color`;生成输入快照写入 `抠图背景色`,同源重绘可从快照恢复。api-server 只接受上述六个 hex,缺省或空值使用 `#CFEFFF`,未知值返回 `400`;本地确定性去背用所选颜色作为 key color。角色动作抽帧暂不接入该选择,继续使用 legacy `#00FF00` 绿幕。
|
||||
- 影响范围:`/editor/canvas` 角色形象生成、图标素材生成、UI 设计图素材提取、`editor_green_screen` 后处理、未来 BGfilter 服务入参、图片画布 MVP 和角色形象生成设计文档。
|
||||
- 验证方式:运行画布生成模型 / workflow / API client 定向前端测试、`cargo test -p api-server editor_green_screen --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image generated_asset_sheet_light_blue_key_color_removes_selected_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 2026-07-01 认证工作集只经 typed projection 同步正式表
|
||||
|
||||
- 背景:同手机号重复账号、兑换码白名单错配和微信资料不回写暴露出 `module-auth` 内存工作集、`auth_store_snapshot` 和正式认证表之间仍有历史互刷路径;旧 JSON 快照会把过期手机号索引或用户资料重新带回运行态。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -29,7 +29,9 @@
|
||||
- 上传后的每张常规参考图以缩略图展示。
|
||||
- 每张常规参考图右下角显示大号序号,从 `1` 开始递增。
|
||||
3. 唯一文本框为 `角色设定`。
|
||||
4. 左下角展示画面比例和大小选择按钮。
|
||||
4. 左下角展示画面比例、大小和抠图背景色选择按钮。
|
||||
- 抠图背景色下拉必须展示颜色名称、方块色样和 hex 值。
|
||||
- 默认 `浅雾蓝 #CFEFFF`;可选 `浅钢蓝 #B0C2E0`、`暖浅桃色 #FFD6C2`、`淡薰衣草紫 #E6D8FF`、`浅粉灰 #F4D8E8`、`中度天蓝 #7FB3FF`。
|
||||
5. 右下角展示模型选择和生成按钮。
|
||||
|
||||
## 普通生成面板视觉口径
|
||||
@@ -49,9 +51,10 @@
|
||||
- 角色规范与常规参考图作为 `referenceImageSrcs` 传入,顺序固定为:
|
||||
1. 角色规范图。
|
||||
2. 常规参考图列表。
|
||||
- 请求同时提交 `model`、`aspectRatio` 和 `imageSize`:
|
||||
- 请求同时提交 `model`、`screenColor`、`aspectRatio` 和 `imageSize`:
|
||||
- `model` 支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。
|
||||
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
|
||||
- `screenColor` 只允许上述六个 hex;前端默认提交 `#CFEFFF`,同源重绘从生成器快照或输入快照 `抠图背景色` 恢复,后续 BGfilter 服务也复用该字段。
|
||||
- 比例按 `x:y` 展示;大小按 `0.5K / 1K / 2K` 展示。
|
||||
- 尺寸选项来源以 VectorEngine 接入文档为准:
|
||||
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`。
|
||||
@@ -60,11 +63,11 @@
|
||||
- `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架:
|
||||
|
||||
```text
|
||||
基于图1的角色美术视觉规范指导生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光;禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
|
||||
按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯色背景 <颜色名> <hex> / RGB(<r>,<g>,<b>),只作为抠像底色;纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带与背景色相同或相近的描边、投影或反光;禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。
|
||||
角色设定:<用户输入的角色设定>
|
||||
```
|
||||
|
||||
- 角色图生成完成后,编辑器后端必须先把带绿幕源图写入 OSS,再走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法:复用 `platform-image::generated_asset_sheets` 的绿幕 / 近白背景去背能力,并开启内部绿幕 / 近白镂空检测。角色图 prompt 固定要求标准 `#00FF00 / RGB(0,255,0)` 绿幕;后端路径仍兼容生成模型把标准绿幕压成暗绿 / 灰绿背景的情况,但这类宽松识别只用于从画布边缘连通扩散出的背景,不作为全图断开绿色区域删除依据。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用远端 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。
|
||||
- 角色图生成完成后,编辑器后端必须先把带所选纯色背景的源图写入 OSS,再走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的统一透明化方法:复用 `platform-image::generated_asset_sheets` 的 key color 去背能力,并开启内部镂空检测。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB;后端对非绿色 key color 不启用近白背景清理,避免浅色主体被误删。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用远端 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。
|
||||
- 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;普通图片图层重绘仍保持 `kind: "quick-edit"`。
|
||||
|
||||
## 生成规范参考图
|
||||
@@ -102,7 +105,7 @@
|
||||
- `角色规范` 与 `上传常规参考图` 入口是带预览视觉块的参考图卡片,不是无样式文字。
|
||||
- `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。
|
||||
- 上传常规参考图后缩略图右下角显示序号。
|
||||
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`aspectRatio` 和 `imageSize`。
|
||||
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`screenColor`、`aspectRatio` 和 `imageSize`。
|
||||
- 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。
|
||||
- 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。
|
||||
|
||||
@@ -113,7 +116,7 @@
|
||||
- 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。
|
||||
- 角色图层重绘同样走 `/api/editor/images/generations` 的 `kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`。
|
||||
- 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。
|
||||
- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后通过编辑器通用抠图方法执行绿幕 / 近白背景去背和内部镂空清理、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
|
||||
- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和所选纯色抠图背景,并在生成成功后通过编辑器通用抠图方法执行 key color 去背和内部镂空清理、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
|
||||
- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。
|
||||
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
|
||||
- 本次验证命令:
|
||||
@@ -161,7 +164,7 @@
|
||||
|
||||
- 视频生成完成后,后端按面板选择抽取对应帧数:`32`、`40` 或 `48`。
|
||||
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
|
||||
- 每帧必须先把带绿幕源图写入 OSS,再执行 `editor_green_screen` 统一绿幕去背,输出透明背景 PNG。
|
||||
- 每帧必须先把带 legacy `#00FF00` 绿幕源图写入 OSS,再执行 `editor_green_screen` 统一绿幕去背,输出透明背景 PNG。角色动作暂不接入角色 / 图标生图的 `screenColor` 选择。
|
||||
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
|
||||
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。
|
||||
- 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `<video>` 预览。
|
||||
|
||||
@@ -61,7 +61,8 @@ use crate::{
|
||||
editor_generation_source_entity_id, enqueue_editor_generation_job,
|
||||
},
|
||||
editor_green_screen::{
|
||||
editor_green_screen_character_prompt_clause,
|
||||
LEGACY_EDITOR_GREEN_SCREEN_CHARACTER_PROMPT_CLAUSE,
|
||||
legacy_editor_green_screen_background_color,
|
||||
remove_editor_generated_green_screen_background,
|
||||
},
|
||||
editor_project::{
|
||||
@@ -2152,11 +2153,14 @@ fn remove_editor_character_animation_frame_backgrounds(
|
||||
) -> Result<Vec<FinalizedAnimationFrame>, AppError> {
|
||||
let mut removed_frames = Vec::with_capacity(frames.len());
|
||||
for frame in frames {
|
||||
let removed = remove_editor_generated_green_screen_background(&DownloadedOpenAiImage {
|
||||
bytes: frame.bytes,
|
||||
mime_type: frame.mime_type,
|
||||
extension: frame.extension,
|
||||
})?;
|
||||
let removed = remove_editor_generated_green_screen_background(
|
||||
&DownloadedOpenAiImage {
|
||||
bytes: frame.bytes,
|
||||
mime_type: frame.mime_type,
|
||||
extension: frame.extension,
|
||||
},
|
||||
legacy_editor_green_screen_background_color(),
|
||||
)?;
|
||||
removed_frames.push(finalize_animation_frame_payload(
|
||||
removed.bytes.as_slice(),
|
||||
removed.mime_type.as_str(),
|
||||
@@ -3383,7 +3387,7 @@ fn editor_video_bad_request(message: impl Into<String>) -> AppError {
|
||||
fn build_editor_character_animation_prompt(prompt_text: &str) -> String {
|
||||
format!(
|
||||
"生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。{};禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。\n动作描述:\n{}",
|
||||
editor_green_screen_character_prompt_clause(),
|
||||
LEGACY_EDITOR_GREEN_SCREEN_CHARACTER_PROMPT_CLAUSE,
|
||||
prompt_text.trim()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,42 +1,150 @@
|
||||
use axum::http::StatusCode;
|
||||
use platform_image::generated_asset_sheets::{
|
||||
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError,
|
||||
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
|
||||
remove_generated_asset_sheet_green_screen_background_bytes,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{http_error::AppError, openai_image_generation::DownloadedOpenAiImage};
|
||||
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_COLOR_PROMPT: &str = "单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕";
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS: &str =
|
||||
"绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具";
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS: &str =
|
||||
"素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光";
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_CHARACTER_GUARDRAILS: &str =
|
||||
"角色主体不得带绿色描边、绿色投影或绿色反光";
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct EditorScreenBackgroundColor {
|
||||
pub(crate) label: &'static str,
|
||||
pub(crate) hex: &'static str,
|
||||
pub(crate) red: u8,
|
||||
pub(crate) green: u8,
|
||||
pub(crate) blue: u8,
|
||||
}
|
||||
|
||||
pub(crate) fn editor_green_screen_asset_prompt_clause() -> String {
|
||||
const EDITOR_SCREEN_BACKGROUND_COLORS: [EditorScreenBackgroundColor; 6] = [
|
||||
EditorScreenBackgroundColor {
|
||||
label: "浅雾蓝",
|
||||
hex: "#CFEFFF",
|
||||
red: 207,
|
||||
green: 239,
|
||||
blue: 255,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "浅钢蓝",
|
||||
hex: "#B0C2E0",
|
||||
red: 176,
|
||||
green: 194,
|
||||
blue: 224,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "暖浅桃色",
|
||||
hex: "#FFD6C2",
|
||||
red: 255,
|
||||
green: 214,
|
||||
blue: 194,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "淡薰衣草紫",
|
||||
hex: "#E6D8FF",
|
||||
red: 230,
|
||||
green: 216,
|
||||
blue: 255,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "浅粉灰",
|
||||
hex: "#F4D8E8",
|
||||
red: 244,
|
||||
green: 216,
|
||||
blue: 232,
|
||||
},
|
||||
EditorScreenBackgroundColor {
|
||||
label: "中度天蓝",
|
||||
hex: "#7FB3FF",
|
||||
red: 127,
|
||||
green: 179,
|
||||
blue: 255,
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn default_editor_screen_background_color() -> EditorScreenBackgroundColor {
|
||||
EDITOR_SCREEN_BACKGROUND_COLORS[0]
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_editor_green_screen_background_color() -> EditorScreenBackgroundColor {
|
||||
EditorScreenBackgroundColor {
|
||||
label: "纯绿色",
|
||||
hex: "#00FF00",
|
||||
red: 0,
|
||||
green: 255,
|
||||
blue: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_editor_screen_background_color(
|
||||
value: Option<&str>,
|
||||
) -> Result<EditorScreenBackgroundColor, AppError> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(default_editor_screen_background_color());
|
||||
};
|
||||
let normalized_value = value.to_ascii_uppercase();
|
||||
EDITOR_SCREEN_BACKGROUND_COLORS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|option| option.hex == normalized_value)
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-screen-background",
|
||||
"message": "screenColor 必须是编辑器支持的纯色背景色。",
|
||||
"allowed": EDITOR_SCREEN_BACKGROUND_COLORS
|
||||
.iter()
|
||||
.map(|option| option.hex)
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS: &str =
|
||||
"纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具";
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS: &str =
|
||||
"素材自身不要出现与背景色相同或相近的描边、底板、投影或反光";
|
||||
pub(crate) const EDITOR_GREEN_SCREEN_CHARACTER_GUARDRAILS: &str =
|
||||
"角色主体不得带与背景色相同或相近的描边、投影或反光";
|
||||
pub(crate) const LEGACY_EDITOR_GREEN_SCREEN_CHARACTER_PROMPT_CLAUSE: &str =
|
||||
"背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光";
|
||||
|
||||
fn editor_screen_background_color_prompt(color: EditorScreenBackgroundColor) -> String {
|
||||
if color.hex == "#00FF00" {
|
||||
return "单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕".to_string();
|
||||
}
|
||||
format!(
|
||||
"单一纯色背景 {} {} / RGB({},{},{})",
|
||||
color.label, color.hex, color.red, color.green, color.blue
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_green_screen_asset_prompt_clause(
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
format!(
|
||||
"背景必须是{},且{},方便扣除背景;{}",
|
||||
EDITOR_GREEN_SCREEN_COLOR_PROMPT,
|
||||
editor_screen_background_color_prompt(color),
|
||||
EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS,
|
||||
EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_green_screen_character_prompt_clause() -> String {
|
||||
pub(crate) fn editor_green_screen_character_prompt_clause(
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
format!(
|
||||
"背景固定为{},只作为抠像底色;{};{}",
|
||||
EDITOR_GREEN_SCREEN_COLOR_PROMPT,
|
||||
editor_screen_background_color_prompt(color),
|
||||
EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS,
|
||||
EDITOR_GREEN_SCREEN_CHARACTER_GUARDRAILS
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_ui_design_asset_extraction_prompt() -> String {
|
||||
pub(crate) fn editor_ui_design_asset_extraction_prompt(
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
format!(
|
||||
"仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用{}。{},方便后续扣除背景;{}。",
|
||||
EDITOR_GREEN_SCREEN_COLOR_PROMPT,
|
||||
editor_screen_background_color_prompt(color),
|
||||
EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS,
|
||||
EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS
|
||||
)
|
||||
@@ -44,14 +152,22 @@ pub(crate) fn editor_ui_design_asset_extraction_prompt() -> String {
|
||||
|
||||
pub(crate) fn remove_editor_generated_green_screen_background(
|
||||
image: &DownloadedOpenAiImage,
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let key_color = GeneratedAssetSheetKeyColor {
|
||||
red: color.red,
|
||||
green: color.green,
|
||||
blue: color.blue,
|
||||
};
|
||||
let bytes = remove_generated_asset_sheet_green_screen_background_bytes(
|
||||
image.bytes.as_slice(),
|
||||
GeneratedAssetSheetAlphaOptions {
|
||||
remove_muted_green_screen_background: true,
|
||||
key_color,
|
||||
remove_near_white_background: key_color.is_green_screen(),
|
||||
remove_disconnected_hard_key_background: true,
|
||||
remove_muted_green_screen_background: key_color.is_green_screen(),
|
||||
detect_internal_holes: true,
|
||||
internal_hole_min_pixels: 16,
|
||||
..GeneratedAssetSheetAlphaOptions::green_screen()
|
||||
},
|
||||
)
|
||||
.map_err(map_editor_green_screen_error)?;
|
||||
@@ -85,20 +201,23 @@ mod tests {
|
||||
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
|
||||
|
||||
#[test]
|
||||
fn editor_green_screen_postprocess_turns_background_transparent() {
|
||||
let mut source = RgbaImage::from_pixel(3, 3, Rgba([0, 255, 0, 255]));
|
||||
fn editor_green_screen_postprocess_turns_selected_background_transparent() {
|
||||
let mut source = RgbaImage::from_pixel(3, 3, Rgba([207, 239, 255, 255]));
|
||||
source.put_pixel(1, 1, Rgba([240, 40, 40, 255]));
|
||||
let mut bytes = Vec::new();
|
||||
DynamicImage::ImageRgba8(source)
|
||||
.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)
|
||||
.expect("test PNG should encode");
|
||||
|
||||
let output = remove_editor_generated_green_screen_background(&DownloadedOpenAiImage {
|
||||
bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
.expect("green screen should be removed");
|
||||
let output = remove_editor_generated_green_screen_background(
|
||||
&DownloadedOpenAiImage {
|
||||
bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
},
|
||||
default_editor_screen_background_color(),
|
||||
)
|
||||
.expect("screen background should be removed");
|
||||
let image = image::load_from_memory(output.bytes.as_slice())
|
||||
.expect("output PNG should decode")
|
||||
.to_rgba8();
|
||||
@@ -106,4 +225,19 @@ mod tests {
|
||||
assert_eq!(image.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(image.get_pixel(1, 1).0[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_screen_background_color_parser_uses_default_and_rejects_unknown() {
|
||||
assert_eq!(
|
||||
parse_editor_screen_background_color(None).expect("default should parse"),
|
||||
default_editor_screen_background_color()
|
||||
);
|
||||
assert_eq!(
|
||||
parse_editor_screen_background_color(Some("#ffd6c2"))
|
||||
.expect("known lowercase hex should parse")
|
||||
.label,
|
||||
"暖浅桃色"
|
||||
);
|
||||
assert!(parse_editor_screen_background_color(Some("#00FF00")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ use crate::{
|
||||
},
|
||||
editor_green_screen::{
|
||||
editor_green_screen_asset_prompt_clause, editor_green_screen_character_prompt_clause,
|
||||
editor_ui_design_asset_extraction_prompt, remove_editor_generated_green_screen_background,
|
||||
editor_ui_design_asset_extraction_prompt, parse_editor_screen_background_color,
|
||||
remove_editor_generated_green_screen_background, EditorScreenBackgroundColor,
|
||||
},
|
||||
generated_image_assets::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||
@@ -202,6 +203,7 @@ pub struct EditorImageGenerationRequest {
|
||||
pub(crate) size: Option<String>,
|
||||
pub(crate) kind: Option<String>,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) screen_color: Option<String>,
|
||||
pub(crate) aspect_ratio: Option<String>,
|
||||
pub(crate) image_size: Option<String>,
|
||||
pub(crate) reference_image_srcs: Option<Vec<String>>,
|
||||
@@ -253,6 +255,7 @@ pub struct EditorIconSpritesheetGenerationRequest {
|
||||
pub(crate) reference_image_srcs: Option<Vec<String>>,
|
||||
pub(crate) icon_descriptions: Vec<String>,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) screen_color: Option<String>,
|
||||
pub(crate) aspect_ratio: Option<String>,
|
||||
pub(crate) image_size: Option<String>,
|
||||
pub(crate) project_id: Option<String>,
|
||||
@@ -266,6 +269,7 @@ pub struct EditorIconSpritesheetGenerationRequest {
|
||||
pub struct EditorUiDesignAssetExtractionRequest {
|
||||
pub(crate) source_image_src: String,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) screen_color: Option<String>,
|
||||
pub(crate) reference_image_srcs: Option<Vec<String>>,
|
||||
pub(crate) aspect_ratio: String,
|
||||
pub(crate) image_size: String,
|
||||
@@ -1209,6 +1213,13 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
|
||||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||||
let is_character_generation = matches!(normalized_kind, Some("character"));
|
||||
let screen_color = if is_character_generation {
|
||||
Some(parse_editor_screen_background_color(
|
||||
payload.screen_color.as_deref(),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
|
||||
let is_publication_material_generation =
|
||||
matches!(normalized_kind, Some("publication-material"));
|
||||
@@ -1229,7 +1240,10 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
&generation_options,
|
||||
);
|
||||
let submitted_prompt = if is_character_generation {
|
||||
build_editor_character_image_prompt(role_setting.as_str())
|
||||
build_editor_character_image_prompt(
|
||||
role_setting.as_str(),
|
||||
screen_color.expect("character generation should have screen color"),
|
||||
)
|
||||
} else if is_ui_design_generation {
|
||||
build_editor_ui_design_prompt(
|
||||
role_setting.as_str(),
|
||||
@@ -1373,7 +1387,10 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
"character-image",
|
||||
)
|
||||
.await?;
|
||||
image = remove_editor_generated_green_screen_background(&image)?;
|
||||
image = remove_editor_generated_green_screen_background(
|
||||
&image,
|
||||
screen_color.expect("character generation should have screen color"),
|
||||
)?;
|
||||
}
|
||||
|
||||
let (width, height) = image::load_from_memory(image.bytes.as_slice())
|
||||
@@ -2257,6 +2274,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
payload: EditorIconSpritesheetGenerationRequest,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
|
||||
let screen_color = parse_editor_screen_background_color(payload.screen_color.as_deref())?;
|
||||
let mut reference_images =
|
||||
Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len));
|
||||
reference_images.push(
|
||||
@@ -2300,7 +2318,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
Some(generation_options.image_size),
|
||||
)?;
|
||||
let size = generation_options.size.as_str();
|
||||
let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions);
|
||||
let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions, screen_color);
|
||||
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||
request_context,
|
||||
@@ -2365,7 +2383,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
"spritesheet",
|
||||
)
|
||||
.await?;
|
||||
let image = remove_editor_generated_green_screen_background(&image)?;
|
||||
let image = remove_editor_generated_green_screen_background(&image, screen_color)?;
|
||||
let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((512, 512));
|
||||
@@ -2501,6 +2519,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
caller: EditorGenerationCaller,
|
||||
payload: EditorUiDesignAssetExtractionRequest,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let screen_color = parse_editor_screen_background_color(payload.screen_color.as_deref())?;
|
||||
let reference_image = parse_editor_reference_image(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
@@ -2550,7 +2569,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
.or_else(|| payload.project_id.clone()),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let prompt = editor_ui_design_asset_extraction_prompt();
|
||||
let prompt = editor_ui_design_asset_extraction_prompt(screen_color);
|
||||
let generated = execute_billable_asset_operation_with_cost(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
@@ -2605,7 +2624,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
"spritesheet",
|
||||
)
|
||||
.await?;
|
||||
let image = remove_editor_generated_green_screen_background(&image)?;
|
||||
let image = remove_editor_generated_green_screen_background(&image, screen_color)?;
|
||||
let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((512, 512));
|
||||
@@ -4295,10 +4314,13 @@ fn resolve_editor_ui_design_asset_extraction_price(
|
||||
Ok(expected_price_mud_points)
|
||||
}
|
||||
|
||||
fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String {
|
||||
fn build_editor_icon_spritesheet_prompt(
|
||||
icon_descriptions: &[String],
|
||||
screen_color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
format!(
|
||||
"参考图1的图标素材规范,{};禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}",
|
||||
editor_green_screen_asset_prompt_clause(),
|
||||
editor_green_screen_asset_prompt_clause(screen_color),
|
||||
icon_descriptions.join("、")
|
||||
)
|
||||
}
|
||||
@@ -4314,9 +4336,12 @@ fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool
|
||||
prompt.join("\n")
|
||||
}
|
||||
|
||||
fn build_editor_character_image_prompt(role_setting: &str) -> String {
|
||||
fn build_editor_character_image_prompt(
|
||||
role_setting: &str,
|
||||
screen_color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
vec![
|
||||
format!("按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。{};禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。", editor_green_screen_character_prompt_clause()),
|
||||
format!("按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。{};禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。", editor_green_screen_character_prompt_clause(screen_color)),
|
||||
format!("角色设定:{}", role_setting.trim()),
|
||||
]
|
||||
.join("\n")
|
||||
@@ -5554,13 +5579,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn editor_character_image_prompt_appends_user_role_setting() {
|
||||
let prompt = build_editor_character_image_prompt("菜市场卖菜大妈");
|
||||
let prompt = build_editor_character_image_prompt(
|
||||
"菜市场卖菜大妈",
|
||||
parse_editor_screen_background_color(Some("#FFD6C2"))
|
||||
.expect("screen color should parse"),
|
||||
);
|
||||
|
||||
assert!(prompt.contains("严格基于图1的角色美术视觉规范"));
|
||||
assert!(prompt.contains("生成游戏角色立绘"));
|
||||
assert!(prompt.contains("背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕"));
|
||||
assert!(prompt.contains("绿幕背景必须平整无纹理、无渐变、无阴影"));
|
||||
assert!(prompt.contains("角色主体不得带绿色描边、绿色投影或绿色反光"));
|
||||
assert!(prompt.contains("背景固定为单一纯色背景 暖浅桃色 #FFD6C2 / RGB(255,214,194)"));
|
||||
assert!(prompt.contains("纯色背景必须平整无纹理、无渐变、无阴影"));
|
||||
assert!(prompt.contains("角色主体不得带与背景色相同或相近的描边、投影或反光"));
|
||||
assert!(prompt.contains("禁止镜头透视"));
|
||||
assert!(prompt.contains("角色设定:菜市场卖菜大妈"));
|
||||
}
|
||||
@@ -6048,20 +6077,26 @@ mod tests {
|
||||
"设置按钮".to_string(),
|
||||
"下一关按钮".to_string(),
|
||||
];
|
||||
let prompt = build_editor_icon_spritesheet_prompt(&descriptions);
|
||||
let prompt = build_editor_icon_spritesheet_prompt(
|
||||
&descriptions,
|
||||
parse_editor_screen_background_color(Some("#7FB3FF"))
|
||||
.expect("screen color should parse"),
|
||||
);
|
||||
|
||||
assert!(prompt.contains("参考图1的图标素材规范"));
|
||||
assert!(prompt.contains("背景必须是单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕"));
|
||||
assert!(prompt.contains("背景必须是单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255)"));
|
||||
assert!(prompt.contains("平整无纹理、无渐变、无阴影"));
|
||||
assert!(prompt.contains("素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光"));
|
||||
assert!(prompt.contains("素材自身不要出现与背景色相同或相近的描边、底板、投影或反光"));
|
||||
assert!(prompt.contains("返回按钮、设置按钮、下一关按钮"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_ui_design_asset_extraction_prompt_is_fixed() {
|
||||
assert_eq!(
|
||||
editor_ui_design_asset_extraction_prompt(),
|
||||
"仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。"
|
||||
editor_ui_design_asset_extraction_prompt(
|
||||
parse_editor_screen_background_color(None).expect("default screen color should parse")
|
||||
),
|
||||
"仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 浅雾蓝 #CFEFFF / RGB(207,239,255)。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6313,10 +6348,12 @@ mod tests {
|
||||
let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({
|
||||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||||
"model": "gpt-image-2",
|
||||
"screenColor": "#E6D8FF",
|
||||
"aspectRatio": "1:1",
|
||||
"imageSize": "2K"
|
||||
}))
|
||||
.expect("UI extraction request should deserialize dimensions");
|
||||
assert_eq!(request.screen_color.as_deref(), Some("#E6D8FF"));
|
||||
let generation_options = normalize_editor_ui_design_asset_extraction_options(
|
||||
request.model.as_deref(),
|
||||
request.aspect_ratio.as_str(),
|
||||
@@ -6371,12 +6408,14 @@ mod tests {
|
||||
let request: EditorImageGenerationRequest = serde_json::from_value(json!({
|
||||
"prompt": "生成图片",
|
||||
"model": "gpt-image-2",
|
||||
"screenColor": "#CFEFFF",
|
||||
"aspectRatio": "1:1",
|
||||
"imageSize": "1K"
|
||||
}))
|
||||
.expect("image generation request should deserialize without price");
|
||||
|
||||
assert_eq!(request.prompt, "生成图片");
|
||||
assert_eq!(request.screen_color.as_deref(), Some("#CFEFFF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6404,7 +6443,8 @@ mod tests {
|
||||
let request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
|
||||
"referenceImageSrc": "data:image/png;base64,spec",
|
||||
"referenceImageSrcs": ["data:image/png;base64,style"],
|
||||
"iconDescriptions": ["返回按钮"]
|
||||
"iconDescriptions": ["返回按钮"],
|
||||
"screenColor": "#7FB3FF"
|
||||
}))
|
||||
.expect("icon spritesheet request should deserialize references");
|
||||
|
||||
@@ -6412,6 +6452,7 @@ mod tests {
|
||||
request.reference_image_srcs,
|
||||
Some(vec!["data:image/png;base64,style".to_string()])
|
||||
);
|
||||
assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -291,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
includeBackgroundColor
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
|
||||
@@ -782,17 +782,19 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('生成中');
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '一张明亮的拼图主视觉',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
projectId: 'editor-project-default',
|
||||
assetFolderId: 'project',
|
||||
assetLabel: expect.stringMatching(/生成图片/u),
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '一张明亮的拼图主视觉',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
projectId: 'editor-project-default',
|
||||
assetFolderId: 'project',
|
||||
assetLabel: expect.stringMatching(/生成图片/u),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||||
@@ -3029,9 +3031,9 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
},
|
||||
],
|
||||
prompt:
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
actualPrompt:
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'ui-extract-task-1',
|
||||
@@ -3124,6 +3126,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
||||
screenColor: '#CFEFFF',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
projectId: 'editor-project-ui-extract',
|
||||
@@ -3134,7 +3137,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect.objectContaining({
|
||||
title: '提取提示词',
|
||||
value:
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: '抠图背景色',
|
||||
value: '浅雾蓝 #CFEFFF',
|
||||
}),
|
||||
]),
|
||||
references: expect.arrayContaining([
|
||||
@@ -3163,7 +3170,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect.objectContaining({
|
||||
title: '提取提示词',
|
||||
value:
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: '抠图背景色',
|
||||
value: '浅雾蓝 #CFEFFF',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
|
||||
@@ -551,6 +551,7 @@ export function hydrateCanvasGenerationDialog(
|
||||
currentUserId,
|
||||
),
|
||||
imageModel: stringOrUndefined(snapshot.imageModel),
|
||||
screenColor: stringOrUndefined(snapshot.screenColor),
|
||||
videoModel:
|
||||
typeof snapshot.videoModel === 'string'
|
||||
? snapshot.videoModel
|
||||
|
||||
@@ -189,6 +189,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onUiAssetExtractionToolChange: vi.fn(),
|
||||
onUiAssetExtractionModelChange: vi.fn(),
|
||||
onUiAssetExtractionScreenColorChange: vi.fn(),
|
||||
onUiAssetExtractionPointerStart: vi.fn(),
|
||||
onUiAssetExtractionPointerMove: vi.fn(),
|
||||
onUiAssetExtractionPointerEnd: vi.fn(),
|
||||
@@ -337,6 +338,7 @@ describe('ImageCanvasEditorShellView', () => {
|
||||
sourceLayerId: layer.id,
|
||||
tool: 'rect' as const,
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
screenColor: '#CFEFFF',
|
||||
marks: [],
|
||||
references: [],
|
||||
draftMark: null,
|
||||
|
||||
@@ -208,6 +208,7 @@ export type GenerateDialogState = {
|
||||
publicationReferences?: CharacterReferenceImage[];
|
||||
uiDesignSpecReference?: CharacterReferenceImage | null;
|
||||
imageModel?: string;
|
||||
screenColor?: string;
|
||||
videoModel?: EditorVideoModel;
|
||||
videoAspectRatio?: EditorVideoAspectRatio;
|
||||
videoResolution?: EditorVideoResolution;
|
||||
|
||||
@@ -1179,6 +1179,7 @@ export function ImageCanvasEditorView() {
|
||||
quickEditSelectionSourceLayer,
|
||||
changeUiAssetExtractionTool,
|
||||
changeUiAssetExtractionModel,
|
||||
changeUiAssetExtractionScreenColor,
|
||||
changeQuickEditSelectionTool,
|
||||
startUiAssetExtractionPointer,
|
||||
startQuickEditSelectionPointer,
|
||||
@@ -1910,6 +1911,7 @@ export function ImageCanvasEditorView() {
|
||||
onExtractUiDesignAssets: extractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
|
||||
onUiAssetExtractionModelChange: changeUiAssetExtractionModel,
|
||||
onUiAssetExtractionScreenColorChange: changeUiAssetExtractionScreenColor,
|
||||
onUiAssetExtractionPointerStart: startUiAssetExtractionPointer,
|
||||
onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer,
|
||||
onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer,
|
||||
|
||||
@@ -481,7 +481,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
{
|
||||
title: '提取提示词',
|
||||
value:
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
},
|
||||
{ title: 'model', value: 'gpt-image-2' },
|
||||
],
|
||||
@@ -496,13 +496,16 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
const canvasSize = { width: 960, height: 720 };
|
||||
const viewport = { x: 0, y: 0, scale: 1 };
|
||||
const characterDraft = createSameSourceGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '红发骑士' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
sourceLayer: createLayer({
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '角色设定', value: '红发骑士' },
|
||||
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
canvasSize,
|
||||
viewport,
|
||||
mode: 'redraw',
|
||||
@@ -512,6 +515,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
mode: 'character',
|
||||
prompt: '红发骑士',
|
||||
sourceLayerId: 'layer-source',
|
||||
screenColor: '#FFD6C2',
|
||||
placeholder: {
|
||||
x: 472,
|
||||
y: 140,
|
||||
@@ -579,6 +583,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
status: 'idle',
|
||||
generatedLayerId: 'layer-source',
|
||||
imageModel: 'gpt-image-2',
|
||||
screenColor: '#E6D8FF',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
},
|
||||
@@ -589,6 +594,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
prompt: '普通图',
|
||||
sourceLayerId: 'layer-source',
|
||||
imageModel: 'gpt-image-2',
|
||||
screenColor: '#E6D8FF',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE,
|
||||
createCanvasLayerReference,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
|
||||
DEFAULT_PUBLICATION_GAME_INFO,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
normalizeEditorImageModel,
|
||||
normalizeEditorGenerationBackgroundColor,
|
||||
PUBLICATION_FRAME_ORIGINAL_SIZE,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
resolveEditorVideoGenerationPixelSize,
|
||||
@@ -272,6 +274,7 @@ export function createCharacterGenerationDialogDraft({
|
||||
characterSpecReference: null,
|
||||
characterReferences: [],
|
||||
imageModel: normalizedImageModel,
|
||||
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
|
||||
aspectRatio: dimensionDefaults.aspectRatio,
|
||||
imageSize: dimensionDefaults.imageSize,
|
||||
placeholder: {
|
||||
@@ -306,6 +309,7 @@ export function createIconGenerationDialogDraft({
|
||||
generationReferences: [],
|
||||
iconDescriptions: [],
|
||||
imageModel: normalizedImageModel,
|
||||
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
|
||||
aspectRatio: dimensionDefaults.aspectRatio,
|
||||
imageSize: dimensionDefaults.imageSize,
|
||||
placeholder: {
|
||||
@@ -738,9 +742,15 @@ function restoreSharedImageOptions(
|
||||
sourceLayer: CanvasLayer,
|
||||
sourceDialog?: CanvasGenerationDialogState | null,
|
||||
): Omit<CanvasGenerationDialogState, 'id'> {
|
||||
const fields = getGenerationInputFieldValues(sourceLayer);
|
||||
const sourceScreenColor =
|
||||
sourceDialog?.screenColor ?? fields.get('抠图背景色');
|
||||
return {
|
||||
...draft,
|
||||
imageModel: sourceDialog?.imageModel ?? draft.imageModel,
|
||||
screenColor: normalizeEditorGenerationBackgroundColor(
|
||||
sourceScreenColor ?? draft.screenColor,
|
||||
),
|
||||
aspectRatio:
|
||||
sourceDialog?.aspectRatio ??
|
||||
draft.aspectRatio ??
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
function ImageOptionsHarness({
|
||||
initialDialog,
|
||||
includeDimensions = true,
|
||||
includeBackgroundColor = false,
|
||||
onRememberImageModel = vi.fn(),
|
||||
lockedModel,
|
||||
}: {
|
||||
initialDialog: GenerateDialogState;
|
||||
includeDimensions?: boolean;
|
||||
includeBackgroundColor?: boolean;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
lockedModel?: string;
|
||||
}) {
|
||||
@@ -33,6 +35,7 @@ function ImageOptionsHarness({
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setDialog}
|
||||
includeDimensions={includeDimensions}
|
||||
includeBackgroundColor={includeBackgroundColor}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
lockedModel={lockedModel}
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
@@ -45,6 +48,7 @@ function ImageOptionsHarness({
|
||||
<output aria-label="当前模型">{dialog.imageModel}</output>
|
||||
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
||||
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
||||
<output aria-label="当前背景色">{dialog.screenColor}</output>
|
||||
<output aria-label="当前状态">{dialog.status}</output>
|
||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||
<output aria-label="当前占位">
|
||||
@@ -172,6 +176,49 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
).toBe('生成3泥点');
|
||||
});
|
||||
|
||||
it('updates the keyed background color from the color menu', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
includeBackgroundColor
|
||||
initialDialog={{
|
||||
mode: 'character',
|
||||
prompt: '',
|
||||
status: 'failed',
|
||||
errorMessage: '旧错误',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
screenColor: '#CFEFFF',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '生成图片背景色 浅雾蓝 #CFEFFF',
|
||||
}),
|
||||
);
|
||||
const panel = screen.getByRole('menu', { name: '生成图片背景色选项' });
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '淡薰衣草紫 #E6D8FF' }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('menu', { name: '生成图片背景色选项' })).toBeTruthy();
|
||||
expect(
|
||||
within(panel)
|
||||
.getByRole('button', { name: '淡薰衣草紫 #E6D8FF' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '生成图片背景色 淡薰衣草紫 #E6D8FF',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前背景色').textContent).toBe('#E6D8FF');
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('locks the model trigger when a fixed model is provided', () => {
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -16,8 +16,11 @@ import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS,
|
||||
getEditorGenerationBackgroundColorOption,
|
||||
getEditorImageModelDisplayName,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
normalizeEditorGenerationBackgroundColor,
|
||||
normalizeEditorImageModel,
|
||||
resizeGenerationPlaceholderToImageSelection,
|
||||
resolveEditorImageSizeLabel,
|
||||
@@ -29,6 +32,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
includeDimensions: boolean;
|
||||
includeModel?: boolean;
|
||||
includeBackgroundColor?: boolean;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
optionLabelPrefix?: string;
|
||||
dimensionRatioAriaLabelPrefix?: string;
|
||||
@@ -45,7 +49,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
|
||||
) => CSSProperties;
|
||||
};
|
||||
|
||||
type OpenPanel = 'dimensions' | 'model' | null;
|
||||
type OpenPanel = 'dimensions' | 'model' | 'backgroundColor' | null;
|
||||
|
||||
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
return {
|
||||
@@ -120,6 +124,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
setGenerateDialog,
|
||||
includeDimensions,
|
||||
includeModel = true,
|
||||
includeBackgroundColor = false,
|
||||
onRememberImageModel,
|
||||
optionLabelPrefix,
|
||||
dimensionRatioAriaLabelPrefix,
|
||||
@@ -134,6 +139,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
}: ImageCanvasGenerationImageOptionsViewProps) {
|
||||
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
||||
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const backgroundColorButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const normalizedLockedModel = lockedModel
|
||||
? normalizeEditorImageModel(lockedModel)
|
||||
@@ -144,6 +150,9 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
: dialog,
|
||||
);
|
||||
const selectedModelLabel = getEditorImageModelDisplayName(selection.model);
|
||||
const selectedBackgroundColor = getEditorGenerationBackgroundColorOption(
|
||||
dialog.screenColor,
|
||||
);
|
||||
const selectedSizeLabel = resolveEditorImageSizeLabel({
|
||||
aspectRatio: selection.aspectRatio,
|
||||
imageSize: selection.imageSize,
|
||||
@@ -160,7 +169,11 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: openPanel !== null,
|
||||
boundaryRefs: [dimensionsButtonRef, modelButtonRef],
|
||||
boundaryRefs: [
|
||||
dimensionsButtonRef,
|
||||
backgroundColorButtonRef,
|
||||
modelButtonRef,
|
||||
],
|
||||
onDismiss: dismissOpenPanel,
|
||||
});
|
||||
|
||||
@@ -210,6 +223,17 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
});
|
||||
};
|
||||
|
||||
const updateBackgroundColor = (screenColor: string) => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog && currentDialog.mode === dialog.mode
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
screenColor: normalizeEditorGenerationBackgroundColor(screenColor),
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
};
|
||||
|
||||
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
|
||||
setOpenPanel((currentPanel) => (currentPanel === panel ? null : panel));
|
||||
};
|
||||
@@ -294,6 +318,76 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
{includeBackgroundColor ? (
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--background-color">
|
||||
<PlatformInlineOptionButton
|
||||
ref={backgroundColorButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--background-color"
|
||||
aria-label={`${labelPrefix}背景色 ${selectedBackgroundColor.label} ${selectedBackgroundColor.value}`}
|
||||
aria-expanded={openPanel === 'backgroundColor'}
|
||||
disabled={isGenerating}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => togglePanel('backgroundColor')}
|
||||
>
|
||||
<span className="image-canvas-editor__background-color-trigger-label">
|
||||
<span
|
||||
className="image-canvas-editor__background-color-swatch"
|
||||
style={{ backgroundColor: selectedBackgroundColor.value }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{selectedBackgroundColor.label}</span>
|
||||
<span>{selectedBackgroundColor.value}</span>
|
||||
</span>
|
||||
</PlatformInlineOptionButton>
|
||||
{openPanel === 'backgroundColor'
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--background-color image-canvas-editor__portal-menu"
|
||||
label={`${labelPrefix}背景色选项`}
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
backgroundColorButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--background-color">
|
||||
{EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.map(
|
||||
(option) => {
|
||||
const selected =
|
||||
selectedBackgroundColor.value === option.value;
|
||||
return (
|
||||
<OptionChoice
|
||||
key={option.value}
|
||||
selected={selected}
|
||||
className="image-canvas-editor__option-popover-choice--background-color"
|
||||
ariaLabel={`${option.label} ${option.value}`}
|
||||
onClick={() => updateBackgroundColor(option.value)}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__background-color-swatch"
|
||||
style={{ backgroundColor: option.value }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="image-canvas-editor__background-color-choice-text">
|
||||
<span>{option.label}</span>
|
||||
<span>{option.value}</span>
|
||||
</span>
|
||||
<Check
|
||||
className="image-canvas-editor__option-selected-check"
|
||||
data-visible={selected}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</OptionChoice>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
{includeModel ? (
|
||||
<div
|
||||
className={[
|
||||
|
||||
@@ -250,7 +250,10 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
],
|
||||
),
|
||||
).toEqual({
|
||||
fields: [{ title: '角色设定', value: '主角骑士' }],
|
||||
fields: [
|
||||
{ title: '角色设定', value: '主角骑士' },
|
||||
{ title: '抠图背景色', value: '浅雾蓝 #CFEFFF' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
@@ -275,7 +278,10 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
resourceId: 'resource-icon-spec',
|
||||
}),
|
||||
).toEqual({
|
||||
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
|
||||
fields: [
|
||||
{ title: '素材描述', value: '返回按钮\n设置按钮' },
|
||||
{ title: '抠图背景色', value: '浅雾蓝 #CFEFFF' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
title: '图标规范',
|
||||
@@ -313,8 +319,9 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
{
|
||||
title: '提取提示词',
|
||||
value:
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。',
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
},
|
||||
{ title: '抠图背景色', value: '浅雾蓝 #CFEFFF' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
|
||||
@@ -97,8 +97,20 @@ export const DEFAULT_ICON_DESCRIPTIONS = [
|
||||
'原图按钮',
|
||||
'冻结按钮',
|
||||
];
|
||||
export const EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS = [
|
||||
{ label: '浅雾蓝', value: '#CFEFFF' },
|
||||
{ label: '浅钢蓝', value: '#B0C2E0' },
|
||||
{ label: '暖浅桃色', value: '#FFD6C2' },
|
||||
{ label: '淡薰衣草紫', value: '#E6D8FF' },
|
||||
{ label: '浅粉灰', value: '#F4D8E8' },
|
||||
{ label: '中度天蓝', value: '#7FB3FF' },
|
||||
] as const;
|
||||
export type EditorGenerationBackgroundColorValue =
|
||||
(typeof EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS)[number]['value'];
|
||||
export const DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR =
|
||||
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS[0].value;
|
||||
export const UI_DESIGN_ASSET_EXTRACTION_PROMPT =
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。';
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。';
|
||||
export const QUICK_EDIT_SIZE_PRESETS = [
|
||||
'1024x1024',
|
||||
'1536x1024',
|
||||
@@ -547,6 +559,36 @@ export function getEditorImageModelDisplayName(
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeEditorGenerationBackgroundColor(
|
||||
value: string | null | undefined,
|
||||
): EditorGenerationBackgroundColorValue {
|
||||
const normalizedValue = value?.trim().toUpperCase();
|
||||
const normalizedHexValue = normalizedValue?.match(/#[0-9A-F]{6}/)?.[0];
|
||||
const matched = EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.find(
|
||||
(option) =>
|
||||
option.value === normalizedValue || option.value === normalizedHexValue,
|
||||
);
|
||||
return matched?.value ?? DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
|
||||
}
|
||||
|
||||
export function getEditorGenerationBackgroundColorOption(
|
||||
value: string | null | undefined,
|
||||
) {
|
||||
const normalizedValue = normalizeEditorGenerationBackgroundColor(value);
|
||||
return (
|
||||
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.find(
|
||||
(option) => option.value === normalizedValue,
|
||||
) ?? EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function formatEditorGenerationBackgroundColor(
|
||||
value: string | null | undefined,
|
||||
) {
|
||||
const option = getEditorGenerationBackgroundColorOption(value);
|
||||
return `${option.label} ${option.value}`;
|
||||
}
|
||||
|
||||
export function buildQuickEditModelOptions(currentModel: string) {
|
||||
const normalizedCurrentModel = normalizeEditorImageModel(currentModel);
|
||||
const options = [...QUICK_EDIT_MODEL_OPTIONS];
|
||||
@@ -1077,9 +1119,16 @@ export function buildCharacterGenerationInputs(
|
||||
prompt: string,
|
||||
specReference: CharacterReferenceImage | null | undefined,
|
||||
references: CharacterReferenceImage[] | undefined,
|
||||
screenColor?: string,
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: createGenerationInputField('角色设定', prompt),
|
||||
fields: [
|
||||
...createGenerationInputField('角色设定', prompt),
|
||||
...createGenerationInputField(
|
||||
'抠图背景色',
|
||||
formatEditorGenerationBackgroundColor(screenColor),
|
||||
),
|
||||
],
|
||||
references: [
|
||||
...(specReference
|
||||
? createGenerationInputReference('角色规范', specReference)
|
||||
@@ -1184,6 +1233,7 @@ export function buildIconGenerationInputs(
|
||||
iconDescriptions: string[],
|
||||
specReference: CharacterReferenceImage,
|
||||
references?: CharacterReferenceImage[],
|
||||
screenColor?: string,
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: [
|
||||
@@ -1191,6 +1241,10 @@ export function buildIconGenerationInputs(
|
||||
title: '素材描述',
|
||||
value: iconDescriptions.join('\n'),
|
||||
},
|
||||
...createGenerationInputField(
|
||||
'抠图背景色',
|
||||
formatEditorGenerationBackgroundColor(screenColor),
|
||||
),
|
||||
],
|
||||
references: [
|
||||
...createGenerationInputReference('图标规范', specReference),
|
||||
@@ -1204,12 +1258,19 @@ export function buildIconGenerationInputs(
|
||||
export function buildUiDesignAssetExtractionGenerationInputs(
|
||||
sourceLayer: CanvasLayer,
|
||||
references?: CharacterReferenceImage[],
|
||||
screenColor?: string,
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: createGenerationInputField(
|
||||
'提取提示词',
|
||||
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
|
||||
),
|
||||
fields: [
|
||||
...createGenerationInputField(
|
||||
'提取提示词',
|
||||
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
|
||||
),
|
||||
...createGenerationInputField(
|
||||
'抠图背景色',
|
||||
formatEditorGenerationBackgroundColor(screenColor),
|
||||
),
|
||||
],
|
||||
references: [
|
||||
...createLayerGenerationInputReference('UI设计图', sourceLayer),
|
||||
...(references ?? []).flatMap((reference, index) =>
|
||||
|
||||
@@ -266,6 +266,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
prompt: ' 白发骑士 ',
|
||||
status: 'idle',
|
||||
imageModel: 'gpt-image-2',
|
||||
screenColor: '#FFD6C2',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
characterSpecReference: {
|
||||
@@ -294,6 +295,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
prompt: '白发骑士',
|
||||
kind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
screenColor: '#FFD6C2',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
referenceImageSrcs: [
|
||||
@@ -309,7 +311,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
});
|
||||
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
|
||||
{
|
||||
fields: [{ title: '角色设定', value: '白发骑士' }],
|
||||
fields: [
|
||||
{ title: '角色设定', value: '白发骑士' },
|
||||
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
@@ -631,6 +636,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
prompt: ' 返回按钮 \n\n设置按钮',
|
||||
status: 'idle',
|
||||
imageModel: 'gpt-image-2',
|
||||
screenColor: '#7FB3FF',
|
||||
aspectRatio: '3:2',
|
||||
imageSize: '2K',
|
||||
iconSpecReference: {
|
||||
@@ -658,11 +664,15 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
referenceImageSrcs: ['data:image/png;base64,ref'],
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
model: 'gpt-image-2',
|
||||
screenColor: '#7FB3FF',
|
||||
aspectRatio: '3:2',
|
||||
imageSize: '2K',
|
||||
},
|
||||
generationInputs: {
|
||||
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
|
||||
fields: [
|
||||
{ title: '素材描述', value: '返回按钮\n设置按钮' },
|
||||
{ title: '抠图背景色', value: '中度天蓝 #7FB3FF' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
title: '图标规范',
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
normalizeEditorGenerationBackgroundColor,
|
||||
normalizeEditorImageModel,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
@@ -348,6 +349,9 @@ export function buildImageGenerationSubmissionPlan({
|
||||
),
|
||||
].filter((src): src is string => Boolean(src));
|
||||
const imageModel = normalizeEditorImageModel(dialog.imageModel);
|
||||
const screenColor = normalizeEditorGenerationBackgroundColor(
|
||||
dialog.screenColor,
|
||||
);
|
||||
return {
|
||||
kind: 'image',
|
||||
normalizedPrompt,
|
||||
@@ -355,6 +359,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
prompt: normalizedPrompt,
|
||||
kind: 'character',
|
||||
model: imageModel,
|
||||
screenColor,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
|
||||
@@ -366,6 +371,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
normalizedPrompt,
|
||||
dialog.characterSpecReference,
|
||||
dialog.characterReferences,
|
||||
screenColor,
|
||||
),
|
||||
},
|
||||
rememberImageModel: imageModel,
|
||||
@@ -594,6 +600,9 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
}
|
||||
|
||||
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
|
||||
const screenColor = normalizeEditorGenerationBackgroundColor(
|
||||
dialog.screenColor,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
iconDescriptions,
|
||||
@@ -610,6 +619,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
: {}),
|
||||
iconDescriptions,
|
||||
model: rememberImageModel,
|
||||
screenColor,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
},
|
||||
@@ -617,6 +627,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
iconDescriptions,
|
||||
dialog.iconSpecReference,
|
||||
dialog.generationReferences,
|
||||
screenColor,
|
||||
),
|
||||
rememberImageModel,
|
||||
};
|
||||
|
||||
@@ -239,6 +239,7 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
includeBackgroundColor
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorIconSpritesheetPrice(
|
||||
|
||||
@@ -128,6 +128,7 @@ export type ImageCanvasStageViewProps = {
|
||||
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
|
||||
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void;
|
||||
onUiAssetExtractionModelChange: (model: string) => void;
|
||||
onUiAssetExtractionScreenColorChange: (screenColor: string) => void;
|
||||
onUiAssetExtractionPointerStart: (point: { x: number; y: number }) => void;
|
||||
onUiAssetExtractionPointerMove: (point: { x: number; y: number }) => void;
|
||||
onUiAssetExtractionPointerEnd: () => void;
|
||||
@@ -250,6 +251,7 @@ export function ImageCanvasStageView({
|
||||
onExtractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange,
|
||||
onUiAssetExtractionModelChange,
|
||||
onUiAssetExtractionScreenColorChange,
|
||||
onUiAssetExtractionPointerStart,
|
||||
onUiAssetExtractionPointerMove,
|
||||
onUiAssetExtractionPointerEnd,
|
||||
@@ -366,6 +368,7 @@ export function ImageCanvasStageView({
|
||||
onPointerMove={onUiAssetExtractionPointerMove}
|
||||
onPointerEnd={onUiAssetExtractionPointerEnd}
|
||||
onModelChange={onUiAssetExtractionModelChange}
|
||||
onScreenColorChange={onUiAssetExtractionScreenColorChange}
|
||||
onRequestUpload={onRequestUiAssetExtractionReferenceUpload}
|
||||
onRemoveReference={onRemoveUiAssetExtractionReference}
|
||||
onSubmit={onSubmitUiAssetExtraction}
|
||||
|
||||
@@ -30,6 +30,7 @@ export type UiAssetExtractionState = {
|
||||
sourceLayerId: string;
|
||||
tool: UiAssetExtractionTool | null;
|
||||
model: string;
|
||||
screenColor: string;
|
||||
marks: UiAssetExtractionMark[];
|
||||
references: CharacterReferenceImage[];
|
||||
draftMark: UiAssetExtractionMark | null;
|
||||
@@ -48,12 +49,17 @@ export type UiAssetExtractionImageSize =
|
||||
|
||||
export function createUiAssetExtractionState(
|
||||
sourceLayerId: string,
|
||||
options: { initialTool?: UiAssetExtractionTool | null; model: string },
|
||||
options: {
|
||||
initialTool?: UiAssetExtractionTool | null;
|
||||
model: string;
|
||||
screenColor: string;
|
||||
},
|
||||
): UiAssetExtractionState {
|
||||
return {
|
||||
sourceLayerId,
|
||||
tool: options.initialTool === undefined ? 'rect' : options.initialTool,
|
||||
model: options.model,
|
||||
screenColor: options.screenColor,
|
||||
marks: [],
|
||||
references: [],
|
||||
draftMark: null,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
@@ -65,6 +66,7 @@ function createState(
|
||||
sourceLayerId: 'layer-source',
|
||||
tool: 'rect',
|
||||
model: IMAGE_MODEL_NANOBANANA2,
|
||||
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
|
||||
marks: [],
|
||||
references: [],
|
||||
draftMark: null,
|
||||
@@ -172,6 +174,11 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '提取素材模型 nanobanana2' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '提取素材背景色 浅雾蓝 #CFEFFF',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByLabelText('参考UI.png')).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '上传UI素材参考图' }),
|
||||
@@ -230,6 +237,50 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
|
||||
expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2);
|
||||
});
|
||||
|
||||
it('changes the UI extraction screen color from the background color menu', () => {
|
||||
const onScreenColorChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
sourceLayer={createLayer({ src: 'data:image/png;base64,ui' })}
|
||||
viewport={{ x: 10, y: 20, scale: 1 }}
|
||||
state={createState({
|
||||
marks: [
|
||||
{
|
||||
id: 'mark-1',
|
||||
tool: 'rect',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onToolChange={vi.fn()}
|
||||
onPointerStart={vi.fn()}
|
||||
onPointerMove={vi.fn()}
|
||||
onPointerEnd={vi.fn()}
|
||||
onScreenColorChange={onScreenColorChange}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '提取素材背景色 浅雾蓝 #CFEFFF',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('menu', { name: '提取素材背景色选项' }),
|
||||
).getByRole('button', {
|
||||
name: '暖浅桃色 #FFD6C2',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onScreenColorChange).toHaveBeenCalledWith('#FFD6C2');
|
||||
});
|
||||
|
||||
it('crops mark previews from the same source coordinates as the red selection', () => {
|
||||
render(
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
|
||||
@@ -20,7 +20,9 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
getEditorGenerationBackgroundColorOption,
|
||||
getEditorImageModelDisplayName,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
@@ -45,6 +47,7 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = {
|
||||
onPointerMove: (point: { x: number; y: number }) => void;
|
||||
onPointerEnd: () => void;
|
||||
onModelChange?: (model: string) => void;
|
||||
onScreenColorChange?: (screenColor: string) => void;
|
||||
onRequestUpload?: () => void;
|
||||
onRemoveReference?: (referenceId: string) => void;
|
||||
onSubmit?: () => void;
|
||||
@@ -272,11 +275,13 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
onPointerMove,
|
||||
onPointerEnd,
|
||||
onModelChange = () => {},
|
||||
onScreenColorChange = () => {},
|
||||
onRequestUpload,
|
||||
onRemoveReference,
|
||||
onSubmit,
|
||||
}: ImageCanvasUiAssetExtractionOverlayViewProps) {
|
||||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||||
const [isScreenColorMenuOpen, setIsScreenColorMenuOpen] = useState(false);
|
||||
if (!sourceLayer || !state) {
|
||||
return null;
|
||||
}
|
||||
@@ -291,6 +296,9 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
extractionPlan.imageSize,
|
||||
);
|
||||
const selectedModelLabel = getEditorImageModelDisplayName(state.model);
|
||||
const selectedScreenColor = getEditorGenerationBackgroundColorOption(
|
||||
state.screenColor,
|
||||
);
|
||||
const screenFrame = {
|
||||
left: viewport.x + sourceLayer.x * viewport.scale,
|
||||
top: viewport.y + sourceLayer.y * viewport.scale,
|
||||
@@ -451,6 +459,71 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
>
|
||||
{`${extractionPlan.aspectRatio}·${extractionPlan.imageSize}`}
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--background-color image-canvas-editor__ui-extraction-screen-color">
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--background-color"
|
||||
aria-label={`提取素材背景色 ${selectedScreenColor.label} ${selectedScreenColor.value}`}
|
||||
aria-expanded={isScreenColorMenuOpen}
|
||||
disabled={state.status === 'extracting'}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => setIsScreenColorMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__background-color-trigger-label">
|
||||
<span
|
||||
className="image-canvas-editor__background-color-swatch"
|
||||
style={{ backgroundColor: selectedScreenColor.value }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{selectedScreenColor.label}</span>
|
||||
<span>{selectedScreenColor.value}</span>
|
||||
</span>
|
||||
</PlatformInlineOptionButton>
|
||||
{isScreenColorMenuOpen ? (
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--background-color"
|
||||
label="提取素材背景色选项"
|
||||
placement="top-start"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--background-color">
|
||||
{EDITOR_GENERATION_BACKGROUND_COLOR_OPTIONS.map(
|
||||
(option) => {
|
||||
const selected =
|
||||
selectedScreenColor.value === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className="image-canvas-editor__option-popover-choice image-canvas-editor__option-popover-choice--background-color"
|
||||
aria-label={`${option.label} ${option.value}`}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
onScreenColorChange(option.value);
|
||||
setIsScreenColorMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__background-color-swatch"
|
||||
style={{ backgroundColor: option.value }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="image-canvas-editor__background-color-choice-text">
|
||||
<span>{option.label}</span>
|
||||
<span>{option.value}</span>
|
||||
</span>
|
||||
<Check
|
||||
className="image-canvas-editor__option-selected-check"
|
||||
data-visible={selected}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</PlatformFloatingMenu>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__ui-extraction-model">
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user