优化画布快速编辑参数与替换规则
快速编辑继承并支持模型、比例和尺寸参数,旧模型回落当前默认模型 仅禁止图标与图标图集快速编辑,图标规范继续按普通图片处理 按用户选定分辨率替换当前图层并保持图层中心位置 补齐后端真实图层校验、移动端避让、定向测试与项目文档
This commit is contained in:
@@ -368,12 +368,12 @@
|
||||
- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand`;后端验证至少覆盖 `editor_image_edit_request_omits_price_mud_points` 和 `editor_image_edit_can_complete_by_replacing_target_layer`。
|
||||
- 关联:`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
## 图片画布快速编辑尺寸要区分业务目标和 provider 对齐尺寸
|
||||
## 图片画布快速编辑尺寸要区分用户目标和 provider 对齐尺寸
|
||||
|
||||
- 现象:原图经过快速编辑后 Resolution 变成近似比例的 1K / 2K 预设;原图或框选标记图宽高不是 16 的倍数时,VectorEngine edits 直接拒绝请求。
|
||||
- 原因:前端已有源图精确 `originalWidth/originalHeight`,提交时却按最近常用比例和 K 档重新计算 `size`;后端又把非 16 倍数的目标尺寸和原始参考图字节直接放进 multipart,并以 provider 回图宽高落库和覆盖画布图层。
|
||||
- 处理:画布快速编辑提交源图精确尺寸作为业务目标;api-server 只在 provider 边界向右、向下复制边缘像素,把每张参考图和目标尺寸临时补齐到 16 的倍数,收到回图后裁回业务目标尺寸再持久化。若上游异常返回其他尺寸,先按目标比例裁切缩放;临时对齐尺寸不能进入 OSS 元数据、`editor_project_resource`、`editor_asset` 或画布 Resolution。前端 inline 回填也保留源图显示尺寸和 Resolution,避免旧回包再次放大图层。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖 `1537x1025` 精确提交和源图回填;`cargo test -p api-server editor_image_edit_aligns_provider_images_and_restores_source_dimensions --manifest-path server-rs/Cargo.toml` 覆盖 provider `1552x1040`、额外参考图独立对齐和回图恢复。
|
||||
- 处理:画布快速编辑展示与常规图片生成一致的模型、比例和尺寸参数,默认继承来源生成器参数;缺少来源生成器时使用图层模型,并按真实分辨率推导比例和尺寸。用户当前选定的比例和尺寸共同决定业务目标分辨率,允许覆盖源图旧分辨率;api-server 只在 provider 边界向右、向下复制边缘像素,把每张参考图和目标尺寸临时补齐到 16 的倍数,收到回图后裁回业务目标尺寸再持久化。若上游异常返回其他尺寸,先按目标比例裁切缩放;临时对齐尺寸不能进入 OSS 元数据、`editor_project_resource`、`editor_asset` 或画布 Resolution。结果覆盖目标图层时更新原始分辨率,并保持图层中心位置不跳动。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖来源参数继承、模型参数切换、目标尺寸提交和图层回填;`cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml` 覆盖图标类拒绝、provider 尺寸对齐和回图恢复。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationLayerModel.ts`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
## 图片画布快速编辑元数据必须记录原图引用
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `快速编辑`
|
||||
|
||||
`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部重绘或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
|
||||
`快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 模型选择的修改面板,不再恢复原来源生成器,也不展示参考图或尺寸控件。
|
||||
`快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 比例 / 尺寸 + 模型选择的修改面板,不再恢复原来源生成器,也不展示额外参考图。`icon` 与 `icon-spritesheet` 图标类素材不支持快速编辑,`icon-spec` 图标规范仍按普通图片支持快速编辑。
|
||||
|
||||
## 统一布局
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。
|
||||
- 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
|
||||
- 宣发素材的 `游戏首图`、`详情五图`、`运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`。
|
||||
- 图片快速编辑只保留一个提示词输入框和模型选择;提示词 placeholder 为 `写下每个编号要怎么改`,提交按钮显示 `修改`,不展示比例 / 尺寸或参考图控件。
|
||||
- 图片快速编辑保留一个提示词输入框,并展示与常规图片生成一致的比例 / 尺寸和模型选择;提示词 placeholder 为 `你希望素材如何修改?`,提交按钮显示 `修改`,不展示额外参考图控件。打开面板时优先继承原图关联生成器记录的模型、比例和尺寸;没有关联生成器时使用图层模型,并按原图真实分辨率推导比例和尺寸;模型缺失或已不受支持时回落到当前默认图片模型。切换模型后只展示该模型支持的参数,不兼容的当前值回落到该模型默认值,按钮泥点按选定模型和尺寸同步刷新。
|
||||
- 不再在底部常驻展开全部可选项。
|
||||
|
||||
## 泥点显示
|
||||
@@ -99,7 +99,7 @@
|
||||
- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。
|
||||
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。
|
||||
- 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。
|
||||
- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。
|
||||
- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。
|
||||
- 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。
|
||||
- 画布Agent对话入口例外:画布Agent对话(右侧对话面板)触发的生成不创建"即将生成"画布占位,生成中状态由对话消息流内的条目承载(阶段提示、模型标注、进行中动画);生成完成后结果图才按统一 placement 避让模型落画板为新图层,并在对话消息内显示缩略图。工具失败时也必须在对话消息内保留失败 generation record,而不是只弹一次性错误气泡。该例外仅限画布Agent对话入口,其余生成入口仍必须先落占位。详见 docs/【编辑器】画布Agent对话面板-2026-07-03.md。
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
|
||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||
- 生成规范类图片底部展示禁用态参数按钮 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
|
||||
- 图片快速编辑底部只展示模型选择和 `修改` 按钮;原图或红框序号标注图作为 `sourceImageSrc` 直接编辑,不展示参考图条或比例 / 尺寸控件。
|
||||
- 图片快速编辑底部左侧展示比例 / 尺寸组合选择,右侧展示模型选择和 `修改` 按钮;原图或红框序号标注图作为 `sourceImageSrc` 直接编辑,不展示额外参考图条。图标与图集素材不展示快速编辑入口,图标规范仍可快速编辑。
|
||||
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。
|
||||
- 快速编辑提交前保留提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用,不再改写成 `图N`。
|
||||
- 快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。
|
||||
|
||||
@@ -1967,6 +1967,93 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 {
|
||||
value.saturating_add(15) / 16 * 16
|
||||
}
|
||||
|
||||
fn ensure_editor_image_edit_asset_kind_allowed(asset_kind: Option<&str>) -> Result<(), AppError> {
|
||||
let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !matches!(asset_kind, "icon" | "icon-spritesheet") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-image-edit",
|
||||
"message": "图标与图标图集不支持快速编辑",
|
||||
"assetKind": asset_kind,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_editor_image_edit_target_layer_asset_kind<'a>(
|
||||
layers: &'a Value,
|
||||
resources: &'a [EditorProjectResourcePayload],
|
||||
target_layer_id: &str,
|
||||
) -> Result<Option<&'a str>, AppError> {
|
||||
let target_layer = layers
|
||||
.as_array()
|
||||
.and_then(|items| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item.get("layerId").and_then(Value::as_str) == Some(target_layer_id))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-image-edit",
|
||||
"message": "快速编辑目标图层不存在",
|
||||
"targetLayerId": target_layer_id,
|
||||
}))
|
||||
})?;
|
||||
if let Some(asset_kind) = target_layer.get("assetKind").and_then(Value::as_str) {
|
||||
return Ok(Some(asset_kind));
|
||||
}
|
||||
let resource_id = target_layer.get("resourceId").and_then(Value::as_str);
|
||||
Ok(resource_id.and_then(|resource_id| {
|
||||
resources
|
||||
.iter()
|
||||
.find(|resource| resource.resource_id == resource_id)
|
||||
.and_then(|resource| resource.asset_kind.as_deref())
|
||||
}))
|
||||
}
|
||||
|
||||
async fn ensure_editor_image_edit_source_allowed(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
payload: &EditorImageEditRequest,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(project_id) = payload
|
||||
.project_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
|
||||
};
|
||||
let Some(target_layer_id) = payload
|
||||
.target_layer_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
|
||||
};
|
||||
let project = state
|
||||
.spacetime_client()
|
||||
.get_editor_project(EditorProjectGetRecordInput {
|
||||
project_id: project_id.to_string(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
let project = editor_project_payload_from_record(project);
|
||||
let asset_kind = resolve_editor_image_edit_target_layer_asset_kind(
|
||||
&project.layers,
|
||||
&project.resources,
|
||||
target_layer_id,
|
||||
)?
|
||||
.or(payload.asset_kind.as_deref());
|
||||
ensure_editor_image_edit_asset_kind_allowed(asset_kind)
|
||||
}
|
||||
|
||||
fn encode_editor_image_edit_png(
|
||||
image: image::DynamicImage,
|
||||
failure_status: StatusCode,
|
||||
@@ -2105,6 +2192,8 @@ pub async fn edit_editor_image(
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload)
|
||||
.await?;
|
||||
let generation_options =
|
||||
normalize_editor_generation_options(payload.model.as_deref(), None, None);
|
||||
let image_size = normalize_editor_image_generation_size(payload.size.as_deref());
|
||||
@@ -2141,6 +2230,7 @@ pub(crate) async fn edit_editor_image_for_owner(
|
||||
caller: EditorGenerationCaller,
|
||||
payload: EditorImageEditRequest,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?;
|
||||
let prompt = payload.prompt.trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return Err(
|
||||
@@ -2275,6 +2365,7 @@ pub(crate) async fn edit_editor_image_for_owner(
|
||||
payload.project_id.as_deref(),
|
||||
payload.target_layer_id.as_deref(),
|
||||
generated_asset.resource.as_ref(),
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
@@ -2403,6 +2494,7 @@ pub(crate) async fn remove_editor_image_background_for_owner(
|
||||
payload.project_id.as_deref(),
|
||||
payload.target_layer_id.as_deref(),
|
||||
generated_asset.resource.as_ref(),
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
@@ -4603,6 +4695,7 @@ pub(crate) async fn complete_editor_canvas_background_removal(
|
||||
project_id: Option<&str>,
|
||||
target_layer_id: Option<&str>,
|
||||
resource: Option<&EditorProjectResourcePayload>,
|
||||
recenter_replacement: bool,
|
||||
) -> Result<Option<EditorProjectPayload>, AppError> {
|
||||
let (Some(project_id), Some(target_layer_id), Some(resource)) =
|
||||
(project_id, target_layer_id, resource)
|
||||
@@ -4630,6 +4723,7 @@ pub(crate) async fn complete_editor_canvas_background_removal(
|
||||
project_payload.layers,
|
||||
target_layer_id,
|
||||
resource,
|
||||
recenter_replacement,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -4653,6 +4747,7 @@ fn replace_editor_canvas_layer_with_resource(
|
||||
layers: Value,
|
||||
target_layer_id: &str,
|
||||
resource: &EditorProjectResourcePayload,
|
||||
recenter_replacement: bool,
|
||||
) -> Option<Value> {
|
||||
let mut items = match layers {
|
||||
Value::Array(items) => items,
|
||||
@@ -4671,11 +4766,34 @@ fn replace_editor_canvas_layer_with_resource(
|
||||
continue;
|
||||
}
|
||||
|
||||
let replacement_width = f64::from(resource.width.max(1));
|
||||
let replacement_height = f64::from(resource.height.max(1));
|
||||
let centered_position = recenter_replacement
|
||||
.then(|| {
|
||||
object
|
||||
.get("x")
|
||||
.and_then(Value::as_f64)
|
||||
.zip(object.get("y").and_then(Value::as_f64))
|
||||
.zip(object.get("width").and_then(Value::as_f64))
|
||||
.zip(object.get("height").and_then(Value::as_f64))
|
||||
.map(|(((x, y), width), height)| {
|
||||
(
|
||||
x + width / 2.0 - replacement_width / 2.0,
|
||||
y + height / 2.0 - replacement_height / 2.0,
|
||||
)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
|
||||
object.insert(
|
||||
"resourceId".to_string(),
|
||||
Value::String(resource.resource_id.clone()),
|
||||
);
|
||||
object.insert("src".to_string(), Value::String(resource.image_src.clone()));
|
||||
if let Some((x, y)) = centered_position {
|
||||
object.insert("x".to_string(), json!(x));
|
||||
object.insert("y".to_string(), json!(y));
|
||||
}
|
||||
object.insert("width".to_string(), json!(resource.width.max(1)));
|
||||
object.insert("height".to_string(), json!(resource.height.max(1)));
|
||||
object.insert("originalWidth".to_string(), json!(resource.width.max(1)));
|
||||
@@ -7193,8 +7311,13 @@ mod tests {
|
||||
updated_at: "2026-06-29T00:00:00.000Z".to_string(),
|
||||
};
|
||||
|
||||
let replaced = replace_editor_canvas_layer_with_resource(layers, "layer-source", &resource)
|
||||
.expect("target layer should be replaced");
|
||||
let replaced = replace_editor_canvas_layer_with_resource(
|
||||
layers.clone(),
|
||||
"layer-source",
|
||||
&resource,
|
||||
false,
|
||||
)
|
||||
.expect("target layer should be replaced");
|
||||
let items = replaced.as_array().expect("layers should stay array");
|
||||
let layer = items
|
||||
.iter()
|
||||
@@ -7218,6 +7341,18 @@ mod tests {
|
||||
assert_eq!(layer["assetKind"], json!("character"));
|
||||
assert!(layer.get("sourceAssetId").is_none());
|
||||
assert!(layer.get("thumbnailSrc").is_none());
|
||||
|
||||
let recentered =
|
||||
replace_editor_canvas_layer_with_resource(layers, "layer-source", &resource, true)
|
||||
.expect("quick edit replacement should recenter the layer");
|
||||
let recentered_layer = recentered
|
||||
.as_array()
|
||||
.and_then(|items| items.iter().find(|item| item["layerId"] == "layer-source"))
|
||||
.expect("quick edit source layer should remain");
|
||||
assert_eq!(recentered_layer["x"], json!(24.0));
|
||||
assert_eq!(recentered_layer["y"], json!(-124.0));
|
||||
assert_eq!(recentered_layer["x"].as_f64().unwrap() + 512.0 / 2.0, 280.0);
|
||||
assert_eq!(recentered_layer["y"].as_f64().unwrap() + 768.0 / 2.0, 260.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -8069,6 +8204,66 @@ mod tests {
|
||||
assert_eq!(request.target_layer_id.as_deref(), Some("layer-source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_image_edit_rejects_icons_but_allows_icon_specs() {
|
||||
for asset_kind in ["icon", "icon-spritesheet"] {
|
||||
let error = ensure_editor_image_edit_asset_kind_allowed(Some(asset_kind))
|
||||
.expect_err("icon assets should not support quick edit");
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
error.details().and_then(|details| details.get("message")),
|
||||
Some(&json!("图标与图标图集不支持快速编辑")),
|
||||
);
|
||||
assert_eq!(
|
||||
error.details().and_then(|details| details.get("assetKind")),
|
||||
Some(&json!(asset_kind)),
|
||||
);
|
||||
}
|
||||
|
||||
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spec")).is_ok());
|
||||
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("image")).is_ok());
|
||||
assert!(ensure_editor_image_edit_asset_kind_allowed(None).is_ok());
|
||||
|
||||
let layers = json!([
|
||||
{ "layerId": "layer-icon", "assetKind": "icon" },
|
||||
{ "layerId": "layer-icon-spec", "assetKind": "icon-spec" }
|
||||
]);
|
||||
let icon_kind =
|
||||
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon")
|
||||
.expect("icon layer should exist");
|
||||
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind).is_err());
|
||||
let icon_spec_kind =
|
||||
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon-spec")
|
||||
.expect("icon spec layer should exist");
|
||||
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind).is_ok());
|
||||
assert!(
|
||||
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "missing").is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_image_edit_guards_before_queue_and_owner_execution() {
|
||||
let source = include_str!("editor_project.rs");
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
"pub async fn edit_editor_image(",
|
||||
"pub(crate) async fn edit_editor_image_for_owner",
|
||||
&[
|
||||
"ensure_editor_image_edit_source_allowed(",
|
||||
"enqueue_editor_generation_job",
|
||||
],
|
||||
);
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
"pub(crate) async fn edit_editor_image_for_owner",
|
||||
"pub async fn remove_editor_image_background",
|
||||
&[
|
||||
"ensure_editor_image_edit_source_allowed(",
|
||||
"parse_editor_reference_image",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_image_edit_can_complete_by_replacing_target_layer() {
|
||||
let source = include_str!("editor_project.rs");
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
expect(submitButton.textContent).toBe('生成5泥点');
|
||||
});
|
||||
|
||||
it('keeps quick edit to one prompt box and model selection', () => {
|
||||
it('keeps quick edit to one prompt box with image parameters', () => {
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
@@ -213,10 +213,10 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
).toBeNull();
|
||||
expect(within(panel).queryByText('旧参考图')).toBeNull();
|
||||
expect(
|
||||
within(panel).queryByRole('button', {
|
||||
name: /快速编辑图片尺寸/u,
|
||||
within(panel).getByRole('button', {
|
||||
name: '快速编辑图片尺寸 1:1·1K',
|
||||
}),
|
||||
).toBeNull();
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '快速编辑图片模型 gpt-image-2',
|
||||
@@ -234,22 +234,16 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
name: '生成图片尺寸 16:9·1K',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('textbox', { name: '生成提示词' }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('menu', { name: '生成图片尺寸选项' })).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render a standalone close button', () => {
|
||||
render(<BasicGenerationHarness />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '关闭生成图片' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '关闭生成图片' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,19 +140,21 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
const references = dialog.generationReferences ?? [];
|
||||
const isQuickEdit = dialog.mode === 'quick-edit';
|
||||
const resolvedDialogLabel =
|
||||
dialogLabel ?? optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
|
||||
dialogLabel ??
|
||||
optionLabelPrefix ??
|
||||
(isQuickEdit ? '快速编辑图片' : '生成图片');
|
||||
const resolvedPromptLabel =
|
||||
promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词');
|
||||
const resolvedPromptPlaceholder =
|
||||
promptPlaceholder ??
|
||||
(isQuickEdit ? '写下每个编号要怎么改' : '今天想生成什么画面?');
|
||||
(isQuickEdit ? '你希望素材如何修改?' : '今天想生成什么画面?');
|
||||
const resolvedOptionLabelPrefix =
|
||||
optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
|
||||
const resolvedReferenceButtonLabel = referenceButtonLabel;
|
||||
const resolvedReferenceButtonAriaLabel =
|
||||
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
|
||||
const shouldIncludeReferences = isQuickEdit ? false : includeReferences;
|
||||
const shouldIncludeDimensions = isQuickEdit ? false : includeDimensions;
|
||||
const shouldIncludeDimensions = includeDimensions;
|
||||
const resolvedSubmitLabel =
|
||||
isQuickEdit && submitLabel === '生成' ? '修改' : submitLabel;
|
||||
const resolvedSubmitAriaLabel =
|
||||
|
||||
@@ -265,4 +265,30 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
|
||||
expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id);
|
||||
});
|
||||
|
||||
it.each(['icon', 'icon-spritesheet'] as const)(
|
||||
'hides quick edit from the standalone menu for %s assets',
|
||||
(assetKind) => {
|
||||
const layer = createLayer({ assetKind });
|
||||
renderContextMenus({
|
||||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||||
imageContextMenuLayer: layer,
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '查看图片信息' }),
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps quick edit in the standalone menu for icon specs', () => {
|
||||
const layer = createLayer({ assetKind: 'icon-spec' });
|
||||
renderContextMenus({
|
||||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||||
imageContextMenuLayer: layer,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: '快速编辑' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
CanvasViewport,
|
||||
ImageContextMenuState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
|
||||
|
||||
type ImageCanvasContextMenusViewProps = {
|
||||
viewport: CanvasViewport;
|
||||
@@ -477,17 +478,19 @@ export function ImageCanvasContextMenusView({
|
||||
<hr />
|
||||
{imageContextMenuLayer ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenQuickEditPanel(imageContextMenuLayer);
|
||||
onCloseContextMenu();
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
快速编辑
|
||||
</button>
|
||||
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onOpenQuickEditPanel(imageContextMenuLayer);
|
||||
onCloseContextMenu();
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
快速编辑
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -539,15 +542,17 @@ export function ImageCanvasContextMenusView({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PlatformFloatingMenu label="图片功能面板" placement="bottom-start">
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
onOpenQuickEditPanel(imageContextMenuLayer);
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
快速编辑
|
||||
</PlatformFloatingMenuItem>
|
||||
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
onOpenQuickEditPanel(imageContextMenuLayer);
|
||||
onCloseImageContextMenu();
|
||||
}}
|
||||
>
|
||||
快速编辑
|
||||
</PlatformFloatingMenuItem>
|
||||
) : null}
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1712,6 +1712,23 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the Agent conversation before opening quick edit', async () => {
|
||||
enableEditorAgentSidebarForTest();
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
|
||||
const sourceLayer = (
|
||||
await screen.findByAltText('画布图片:拼图素材')
|
||||
).closest('button')!;
|
||||
fireEvent.click(sourceLayer);
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '快速编辑图片' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the Agent conversation panel when runtime config disables it after focus', async () => {
|
||||
loadFrontendRuntimeConfigMock
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -1276,6 +1276,16 @@ export function ImageCanvasEditorView({
|
||||
}
|
||||
generationSurface.toggleTaskSidebar();
|
||||
}, [effectiveIsAgentConversationOpen, generationSurface]);
|
||||
const openQuickEditPanelWithAvailableCanvas = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (generationSurface.isTaskSidebarOpen) {
|
||||
generationSurface.toggleTaskSidebar();
|
||||
}
|
||||
setIsAgentConversationOpen(false);
|
||||
generationSurface.openQuickEditPanel(layer);
|
||||
},
|
||||
[generationSurface],
|
||||
);
|
||||
const toggleCanvasSidebarPanel = useCallback(
|
||||
(panel: SidebarPanel) => {
|
||||
toggleSidebarPanel(panel);
|
||||
@@ -1327,7 +1337,6 @@ export function ImageCanvasEditorView({
|
||||
setIsPickingUiDesignSpecFromCanvas,
|
||||
openCharacterAnimationPanel,
|
||||
openRedrawPanel,
|
||||
openQuickEditPanel,
|
||||
openCropExpandPanel,
|
||||
removeSelectedLayerBackground,
|
||||
extractUiDesignAssets,
|
||||
@@ -2136,7 +2145,7 @@ export function ImageCanvasEditorView({
|
||||
onToggleTaskSidebar: toggleTaskSidebar,
|
||||
onToggleAgentConversation: toggleAgentConversation,
|
||||
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
|
||||
onOpenQuickEditPanel: openQuickEditPanel,
|
||||
onOpenQuickEditPanel: openQuickEditPanelWithAvailableCanvas,
|
||||
onOpenRedrawPanel: openRedrawPanel,
|
||||
onOpenCropExpandPanel: openCropExpandPanel,
|
||||
onRemoveBackground: removeSelectedLayerBackground,
|
||||
|
||||
@@ -117,7 +117,7 @@ function renderComposer(
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationComposerView', () => {
|
||||
it('让快速编辑只保留提示词和模型选择', () => {
|
||||
it('让快速编辑显示提示词、尺寸和模型选择', () => {
|
||||
renderComposer({
|
||||
mode: 'quick-edit',
|
||||
prompt: '',
|
||||
@@ -146,10 +146,10 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
within(panel).queryByRole('button', { name: '添加参考图' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(panel).queryByRole('button', {
|
||||
name: /快速编辑图片尺寸/u,
|
||||
within(panel).getByRole('button', {
|
||||
name: '快速编辑图片尺寸 1:1·1K',
|
||||
}),
|
||||
).toBeNull();
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '快速编辑图片模型 gpt-image-2',
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from './ImageCanvasGenerationDialogModel';
|
||||
import {
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
@@ -195,28 +196,29 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
const canvasSize = { width: 960, height: 720 };
|
||||
const viewport = { x: 0, y: 0, scale: 1 };
|
||||
|
||||
expect(createVideoGenerationDialogDraft({ canvasSize, viewport }))
|
||||
.toMatchObject({
|
||||
mode: 'video',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generationReferences: [],
|
||||
videoModel: 'seedance2.0-fast',
|
||||
videoAspectRatio: '16:9',
|
||||
videoResolution: '480p',
|
||||
videoDurationSeconds: 4,
|
||||
videoMode: 'std',
|
||||
videoSound: 'on',
|
||||
videoWebSearchEnabled: true,
|
||||
placeholder: {
|
||||
x: 53,
|
||||
y: 120,
|
||||
width: 854,
|
||||
height: 480,
|
||||
originalWidth: 854,
|
||||
originalHeight: 480,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
createVideoGenerationDialogDraft({ canvasSize, viewport }),
|
||||
).toMatchObject({
|
||||
mode: 'video',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generationReferences: [],
|
||||
videoModel: 'seedance2.0-fast',
|
||||
videoAspectRatio: '16:9',
|
||||
videoResolution: '480p',
|
||||
videoDurationSeconds: 4,
|
||||
videoMode: 'std',
|
||||
videoSound: 'on',
|
||||
videoWebSearchEnabled: true,
|
||||
placeholder: {
|
||||
x: 53,
|
||||
y: 120,
|
||||
width: 854,
|
||||
height: 480,
|
||||
originalWidth: 854,
|
||||
originalHeight: 480,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
createUiDesignGenerationDialogDraft({
|
||||
canvasSize,
|
||||
@@ -242,37 +244,39 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
const canvasSize = { width: 960, height: 720 };
|
||||
const viewport = { x: 0, y: 0, scale: 1 };
|
||||
|
||||
expect(createSoundEffectGenerationDialogDraft({ canvasSize, viewport }))
|
||||
.toMatchObject({
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundModel: 'audio1.0',
|
||||
soundDurationSeconds: 5,
|
||||
placeholder: {
|
||||
x: 270,
|
||||
y: 300,
|
||||
width: 420,
|
||||
height: 120,
|
||||
originalWidth: 420,
|
||||
originalHeight: 120,
|
||||
},
|
||||
});
|
||||
expect(createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }))
|
||||
.toMatchObject({
|
||||
mode: 'audio-background-music',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
makeInstrumental: true,
|
||||
placeholder: {
|
||||
x: 270,
|
||||
y: 300,
|
||||
width: 420,
|
||||
height: 120,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
createSoundEffectGenerationDialogDraft({ canvasSize, viewport }),
|
||||
).toMatchObject({
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundModel: 'audio1.0',
|
||||
soundDurationSeconds: 5,
|
||||
placeholder: {
|
||||
x: 270,
|
||||
y: 300,
|
||||
width: 420,
|
||||
height: 120,
|
||||
originalWidth: 420,
|
||||
originalHeight: 120,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }),
|
||||
).toMatchObject({
|
||||
mode: 'audio-background-music',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
makeInstrumental: true,
|
||||
placeholder: {
|
||||
x: 270,
|
||||
y: 300,
|
||||
width: 420,
|
||||
height: 120,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates audio remodel drafts from generated audio layers without references', () => {
|
||||
@@ -496,17 +500,17 @@ 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: '红发骑士' },
|
||||
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
|
||||
{ title: '抠图模型', value: '动漫风格 anime-seg' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
sourceLayer: createLayer({
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '角色设定', value: '红发骑士' },
|
||||
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
|
||||
{ title: '抠图模型', value: '动漫风格 anime-seg' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
canvasSize,
|
||||
viewport,
|
||||
mode: 'redraw',
|
||||
@@ -653,21 +657,38 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('locks quick edit drafts to gpt-image-2 despite legacy source models', () => {
|
||||
it('inherits and normalizes quick edit model parameters from the source', () => {
|
||||
expect(
|
||||
createQuickEditPanelDraft(
|
||||
createLayer({
|
||||
model: 'nano-banana',
|
||||
}),
|
||||
).model,
|
||||
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
|
||||
),
|
||||
).toMatchObject({
|
||||
model: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '4:3',
|
||||
imageSize: '1K',
|
||||
});
|
||||
expect(
|
||||
createQuickEditPanelDraft(createLayer({ model: 'nanobanana2' }), {
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
}),
|
||||
).toMatchObject({
|
||||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
});
|
||||
expect(
|
||||
createQuickEditPanelDraft(
|
||||
createLayer({
|
||||
model: 'nanobanana2',
|
||||
}),
|
||||
).model,
|
||||
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
|
||||
createLayer({ originalWidth: 512, originalHeight: 512 }),
|
||||
{ imageModel: IMAGE_MODEL_GPT_IMAGE_2, imageSize: '0.5K' },
|
||||
).imageSize,
|
||||
).toBe('1K');
|
||||
expect(
|
||||
createQuickEditPanelDraft(createLayer({ model: 'legacy-image-model' })),
|
||||
).toMatchObject({ model: IMAGE_MODEL_NANOBANANA2 });
|
||||
});
|
||||
|
||||
it('creates character animation generation dialog drafts for character layers', () => {
|
||||
@@ -805,7 +826,8 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
title: '动作参考',
|
||||
src: 'https://signed.example.com/reference.mp4',
|
||||
mediaType: 'video',
|
||||
objectKey: 'generated-character-drafts/editor/seedance-references/video/reference.mp4',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/seedance-references/video/reference.mp4',
|
||||
assetObjectId: 'asset-object-video',
|
||||
});
|
||||
expect(
|
||||
@@ -864,7 +886,9 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
status: 'idle',
|
||||
generationReferences: [],
|
||||
};
|
||||
expect(appendGenerationReference(imageDialog, videoLayer)).toBe(imageDialog);
|
||||
expect(appendGenerationReference(imageDialog, videoLayer)).toBe(
|
||||
imageDialog,
|
||||
);
|
||||
const specDialog: GenerateDialogState = {
|
||||
mode: 'spec',
|
||||
prompt: '',
|
||||
@@ -978,7 +1002,9 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
errorMessage: '描述错误',
|
||||
iconDescriptions: ['旧描述'],
|
||||
};
|
||||
expect(updateIconDescriptionsTextInDialog(iconDialog, '剑\n盾、药水')).toEqual(
|
||||
expect(
|
||||
updateIconDescriptionsTextInDialog(iconDialog, '剑\n盾、药水'),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
DEFAULT_VIDEO_SOUND,
|
||||
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
ICON_FRAME_DISPLAY_SIZE,
|
||||
ICON_FRAME_ORIGINAL_SIZE,
|
||||
@@ -690,7 +691,8 @@ export function createLayerGenerationDialogDraft({
|
||||
return {
|
||||
...draft,
|
||||
prompt: draft.prompt || sourceLayer.prompt?.trim() || '',
|
||||
imageModel: sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel,
|
||||
imageModel:
|
||||
sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel,
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generatedLayerId: sourceLayer.id,
|
||||
@@ -1176,19 +1178,41 @@ export function createQuickEditPanelDraft(
|
||||
imageSize?: string;
|
||||
} = {},
|
||||
): QuickEditPanelState {
|
||||
const aspectRatio =
|
||||
options.aspectRatio ??
|
||||
inferEditorImageAspectRatio(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const imageSize =
|
||||
options.imageSize ??
|
||||
inferEditorImageSizeLabel(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const model = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
const normalizedModel = normalizeEditorImageModel(
|
||||
options.imageModel ?? sourceLayer.model,
|
||||
);
|
||||
const model = EDITOR_IMAGE_MODEL_OPTIONS.some(
|
||||
(option) => option.value === normalizedModel,
|
||||
)
|
||||
? normalizedModel
|
||||
: DEFAULT_IMAGE_MODEL;
|
||||
const dimensionOptions =
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
model as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
|
||||
const supportedAspectRatios =
|
||||
dimensionOptions.aspectRatios as readonly string[];
|
||||
const supportedImageSizes = dimensionOptions.imageSizes as readonly string[];
|
||||
const inferredAspectRatio = inferEditorImageAspectRatio(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const inferredImageSize = inferEditorImageSizeLabel(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const aspectRatio = supportedAspectRatios.includes(options.aspectRatio ?? '')
|
||||
? options.aspectRatio
|
||||
: supportedAspectRatios.includes(inferredAspectRatio)
|
||||
? inferredAspectRatio
|
||||
: (dimensionOptions.aspectRatios[0] ?? '1:1');
|
||||
const imageSize = supportedImageSizes.includes(options.imageSize ?? '')
|
||||
? options.imageSize
|
||||
: supportedImageSizes.includes(inferredImageSize)
|
||||
? inferredImageSize
|
||||
: (dimensionOptions.imageSizes.find((size) => size === '1K') ??
|
||||
dimensionOptions.imageSizes[0] ??
|
||||
'1K');
|
||||
return {
|
||||
mode: 'quick-edit',
|
||||
sourceLayerId: sourceLayer.id,
|
||||
|
||||
@@ -313,14 +313,22 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
id: 'layer-source',
|
||||
title: '源图',
|
||||
src: 'data:image/png;base64,edited',
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 1537,
|
||||
originalHeight: 1025,
|
||||
x: -488,
|
||||
y: -252,
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
originalWidth: 1536,
|
||||
originalHeight: 1024,
|
||||
resourceId: 'resource-edited',
|
||||
sourceResourceId: 'resource-source',
|
||||
objectKey: 'generated/edited.png',
|
||||
});
|
||||
expect(layer.x + layer.width / 2).toBe(
|
||||
sourceLayer.x + sourceLayer.width / 2,
|
||||
);
|
||||
expect(layer.y + layer.height / 2).toBe(
|
||||
sourceLayer.y + sourceLayer.height / 2,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates wrapped icon layers with icon metadata', () => {
|
||||
|
||||
@@ -192,7 +192,10 @@ export function createQuickEditResultLayer({
|
||||
frame,
|
||||
}: QuickEditResultLayerOptions): CanvasLayer {
|
||||
const originalWidth =
|
||||
frame?.originalWidth || generated.width || sourceLayer.originalWidth || 1024;
|
||||
frame?.originalWidth ||
|
||||
generated.width ||
|
||||
sourceLayer.originalWidth ||
|
||||
1024;
|
||||
const originalHeight =
|
||||
frame?.originalHeight ||
|
||||
generated.height ||
|
||||
@@ -247,14 +250,29 @@ export function applyImageEditResultToSourceLayer({
|
||||
sourceLayer: CanvasLayer;
|
||||
generationInputs: CanvasGenerationInputs;
|
||||
}): CanvasLayer {
|
||||
const originalWidth = sourceLayer.originalWidth || sourceLayer.width;
|
||||
const originalHeight = sourceLayer.originalHeight || sourceLayer.height;
|
||||
const width = sourceLayer.width;
|
||||
const height = sourceLayer.height;
|
||||
const originalWidth =
|
||||
generated.resource?.width ||
|
||||
generated.width ||
|
||||
sourceLayer.originalWidth ||
|
||||
1;
|
||||
const originalHeight =
|
||||
generated.resource?.height ||
|
||||
generated.height ||
|
||||
sourceLayer.originalHeight ||
|
||||
1;
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
sourceLayer,
|
||||
);
|
||||
const centerX = sourceLayer.x + sourceLayer.width / 2;
|
||||
const centerY = sourceLayer.y + sourceLayer.height / 2;
|
||||
return applyGeneratedMetadata(
|
||||
{
|
||||
...sourceLayer,
|
||||
src: generated.imageSrc,
|
||||
x: centerX - width / 2,
|
||||
y: centerY - height / 2,
|
||||
width,
|
||||
height,
|
||||
originalWidth,
|
||||
@@ -280,10 +298,8 @@ export function createIconSpritesheetResultLayers({
|
||||
const spritesheetResource = generated.spritesheetResource;
|
||||
const spritesheetAsset = generated.spritesheetAsset;
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
const startX =
|
||||
frame?.x ?? worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2;
|
||||
const startY =
|
||||
frame?.y ?? worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2;
|
||||
const startX = frame?.x ?? worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2;
|
||||
const startY = frame?.y ?? worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2;
|
||||
const spacing = 24;
|
||||
const maxRowWidth = 560;
|
||||
const spritesheetOriginalWidth =
|
||||
@@ -319,8 +335,7 @@ export function createIconSpritesheetResultLayers({
|
||||
sourceAssetId: spritesheetAsset?.assetId,
|
||||
objectKey: spritesheetResource?.objectKey ?? undefined,
|
||||
assetObjectId: spritesheetResource?.assetObjectId ?? undefined,
|
||||
generationInputs:
|
||||
spritesheetResource?.generationInputs ?? generationInputs,
|
||||
generationInputs: spritesheetResource?.generationInputs ?? generationInputs,
|
||||
generatedAssetSnapshot: spritesheetAsset,
|
||||
};
|
||||
const iconStartX = startX + spritesheetDisplaySize.width + 32;
|
||||
@@ -348,7 +363,8 @@ export function createIconSpritesheetResultLayers({
|
||||
const generatedIndex = startIndex + index + 1;
|
||||
const layer: CanvasLayer = {
|
||||
id: `layer-icon-${generatedIndex}`,
|
||||
resourceId: resource?.resourceId ?? `local-resource-icon-${generatedIndex}`,
|
||||
resourceId:
|
||||
resource?.resourceId ?? `local-resource-icon-${generatedIndex}`,
|
||||
title: icon.name,
|
||||
src: icon.imageSrc,
|
||||
x: cursorX,
|
||||
@@ -412,11 +428,14 @@ export function createVideoResultLayer({
|
||||
|
||||
return {
|
||||
id: `layer-video-${generatedIndex}`,
|
||||
resourceId: resource?.resourceId ?? `local-resource-video-${generatedIndex}`,
|
||||
resourceId:
|
||||
resource?.resourceId ?? `local-resource-video-${generatedIndex}`,
|
||||
title,
|
||||
src: generated.videoSrc,
|
||||
mediaType: 'video',
|
||||
assetKind: (resource?.assetKind as CanvasLayer['assetKind'] | undefined) ?? assetKind,
|
||||
assetKind:
|
||||
(resource?.assetKind as CanvasLayer['assetKind'] | undefined) ??
|
||||
assetKind,
|
||||
x: frameX ?? worldCenter.x - width / 2,
|
||||
y: frameY ?? worldCenter.y - height / 2,
|
||||
width,
|
||||
@@ -432,7 +451,8 @@ export function createVideoResultLayer({
|
||||
taskId: generated.taskId,
|
||||
objectKey: resource?.objectKey ?? generated.objectKey,
|
||||
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
|
||||
thumbnailSrc: generated.thumbnailSrc ?? generated.asset?.thumbnailSrc ?? undefined,
|
||||
thumbnailSrc:
|
||||
generated.thumbnailSrc ?? generated.asset?.thumbnailSrc ?? undefined,
|
||||
sourceResourceId: sourceLayer?.resourceId,
|
||||
sourceAssetId: asset?.assetId,
|
||||
groupId: sourceLayer?.groupId,
|
||||
|
||||
@@ -250,9 +250,7 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
],
|
||||
),
|
||||
).toEqual({
|
||||
fields: [
|
||||
{ title: '角色设定', value: '主角骑士' },
|
||||
],
|
||||
fields: [{ title: '角色设定', value: '主角骑士' }],
|
||||
references: [
|
||||
{
|
||||
title: '角色规范',
|
||||
@@ -381,9 +379,17 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }),
|
||||
).toBe('自定义');
|
||||
expect(buildQuickEditModelOptions('nano-banana')).toEqual([
|
||||
{
|
||||
label: 'nanobanana2',
|
||||
value: 'gemini-3.1-flash-image-preview',
|
||||
},
|
||||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||||
]);
|
||||
expect(buildQuickEditModelOptions('nanobanana2')).toEqual([
|
||||
{
|
||||
label: 'nanobanana2',
|
||||
value: 'gemini-3.1-flash-image-preview',
|
||||
},
|
||||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -111,9 +111,7 @@ export const EDITOR_IMAGE_MODEL_OPTIONS = [
|
||||
{ label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 },
|
||||
{ label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 },
|
||||
] as const;
|
||||
export const QUICK_EDIT_MODEL_OPTIONS = [
|
||||
{ label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 },
|
||||
] as const;
|
||||
export const QUICK_EDIT_MODEL_OPTIONS = EDITOR_IMAGE_MODEL_OPTIONS;
|
||||
export const EDITOR_IMAGE_DIMENSION_OPTIONS = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: {
|
||||
aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'],
|
||||
@@ -557,6 +555,10 @@ export function buildQuickEditModelOptions(currentModel: string) {
|
||||
return options;
|
||||
}
|
||||
|
||||
export function isQuickEditUnsupportedAssetKind(layer: CanvasLayer) {
|
||||
return layer.assetKind === 'icon' || layer.assetKind === 'icon-spritesheet';
|
||||
}
|
||||
|
||||
export function buildCharacterSpecPrompt(values: SpecFormValues) {
|
||||
return [
|
||||
'生成2D 角色美术视觉规范设定图,纯白底板,整齐排布全身标准立绘;固定统一头身比例、勾线粗细恒定;展示待机行走攻击基础动作帧样例,重心对齐不变位,服饰配饰分层结构示意,搭配专属角色色卡标注色号,无多余杂物,精准尺寸标注,高清矢量规范稿',
|
||||
|
||||
@@ -103,22 +103,22 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
target: { value: '新提示' },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText('写下每个编号要怎么改'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText('你希望素材如何修改?')).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前提示词').textContent).toBe('新提示');
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1024x1024');
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||
'gemini-3.1-flash-image-preview',
|
||||
);
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '快速编辑尺寸 1:1·1K' }),
|
||||
).toBeNull();
|
||||
screen.getByRole('button', { name: '快速编辑尺寸 1:1·1K' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '快速编辑模型 gpt-image-2' }),
|
||||
screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }),
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'nanobanana2' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'nanobanana2' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2');
|
||||
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
|
||||
import {
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { QUICK_EDIT_REFERENCE_LIMIT } from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
GenerateDialogState,
|
||||
@@ -58,7 +55,7 @@ function createQuickEditDialog(
|
||||
status: panel.status,
|
||||
sourceLayerId: panel.sourceLayerId,
|
||||
generationReferences: panel.quickEditReferences,
|
||||
imageModel: isQuickEdit ? IMAGE_MODEL_GPT_IMAGE_2 : panel.model,
|
||||
imageModel: panel.model,
|
||||
aspectRatio: panel.aspectRatio,
|
||||
imageSize: panel.imageSize,
|
||||
};
|
||||
@@ -70,7 +67,6 @@ function syncPanelFromDialogUpdate(
|
||||
) {
|
||||
const normalizedPanel = resetFailedPanelStatus(panel);
|
||||
const normalizedDialog = resetFailedPanelStatus(dialog);
|
||||
const isQuickEdit = (normalizedPanel.mode ?? 'quick-edit') === 'quick-edit';
|
||||
return {
|
||||
...normalizedPanel,
|
||||
size: panel.size,
|
||||
@@ -78,9 +74,7 @@ function syncPanelFromDialogUpdate(
|
||||
aspectRatio:
|
||||
normalizedDialog.aspectRatio ?? normalizedPanel.aspectRatio ?? '1:1',
|
||||
imageSize: normalizedDialog.imageSize ?? normalizedPanel.imageSize ?? '1K',
|
||||
model: isQuickEdit
|
||||
? IMAGE_MODEL_GPT_IMAGE_2
|
||||
: (normalizedDialog.imageModel ?? normalizedPanel.model),
|
||||
model: normalizedDialog.imageModel ?? normalizedPanel.model,
|
||||
quickEditReferences: normalizedDialog.generationReferences,
|
||||
status: normalizedDialog.status,
|
||||
errorMessage: normalizedDialog.errorMessage,
|
||||
@@ -102,9 +96,8 @@ export function ImageCanvasQuickEditPanelView({
|
||||
onRememberImageModel = () => {},
|
||||
onSubmit,
|
||||
}: ImageCanvasQuickEditPanelViewProps) {
|
||||
void sourceLayer;
|
||||
|
||||
const isRedraw = panel.mode === 'redraw';
|
||||
const isImageQuickEdit = !isRedraw && sourceLayer.mediaType !== 'video';
|
||||
const referenceCount = panel.quickEditReferences?.length ?? 0;
|
||||
const canAddReferences =
|
||||
!isRedraw && referenceCount < QUICK_EDIT_REFERENCE_LIMIT;
|
||||
@@ -144,12 +137,12 @@ export function ImageCanvasQuickEditPanelView({
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
onSubmit={onSubmit}
|
||||
dialogLabel={isRedraw ? '重绘图片' : '快速编辑图片'}
|
||||
includeDimensions={isRedraw}
|
||||
includeDimensions={isRedraw || isImageQuickEdit}
|
||||
includeModel={true}
|
||||
includeReferences={false}
|
||||
promptLabel={isRedraw ? '重绘提示词' : '快速编辑提示词'}
|
||||
promptPlaceholder={
|
||||
isRedraw ? '把画面重绘成什么样?' : '写下每个编号要怎么改'
|
||||
isRedraw ? '把画面重绘成什么样?' : '你希望素材如何修改?'
|
||||
}
|
||||
optionLabelPrefix={isRedraw ? '重绘' : '快速编辑'}
|
||||
dimensionRatioAriaLabelPrefix="比例"
|
||||
|
||||
@@ -31,7 +31,9 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
}
|
||||
|
||||
function renderSelectedToolbar(
|
||||
overrides: Partial<Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0]> = {},
|
||||
overrides: Partial<
|
||||
Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0]
|
||||
> = {},
|
||||
) {
|
||||
const props: Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0] = {
|
||||
selectedLayer: createLayer(),
|
||||
@@ -73,7 +75,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '改造' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' }));
|
||||
|
||||
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(
|
||||
props.selectedLayer,
|
||||
);
|
||||
expect(props.onOpenCropExpandPanel).toHaveBeenCalledWith(
|
||||
props.selectedLayer,
|
||||
);
|
||||
@@ -137,7 +141,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
'改造',
|
||||
'下载按钮',
|
||||
]);
|
||||
expect(within(toolbar).queryByRole('button', { name: '快速编辑' })).toBeNull();
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '快速编辑' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '裁扩按钮' }),
|
||||
).toBeNull();
|
||||
@@ -173,9 +179,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
within(videoToolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(videoToolbar).getAllByRole('button').map((button) =>
|
||||
button.getAttribute('aria-label'),
|
||||
),
|
||||
within(videoToolbar)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['快速编辑', '改造', '下载按钮']);
|
||||
|
||||
cleanup();
|
||||
@@ -196,9 +202,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
within(actionToolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(actionToolbar).getAllByRole('button').map((button) =>
|
||||
button.getAttribute('aria-label'),
|
||||
),
|
||||
within(actionToolbar)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['快速编辑', '改造', '下载按钮']);
|
||||
});
|
||||
|
||||
@@ -211,7 +217,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
const animationButton = screen.getByRole('button', { name: '生成动画' });
|
||||
|
||||
expect(animationButton.textContent).toContain('生成动画');
|
||||
expect(animationButton.querySelector('.lucide-person-standing')).toBeTruthy();
|
||||
expect(
|
||||
animationButton.querySelector('.lucide-person-standing'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(animationButton);
|
||||
|
||||
@@ -222,6 +230,25 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['icon', 'icon-spritesheet'] as const)(
|
||||
'hides quick edit for %s assets',
|
||||
(assetKind) => {
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({ assetKind }),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '快速编辑' })).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps quick edit available for icon specs', () => {
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({ assetKind: 'icon-spec' }),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nothing without a selected layer or toolbar position', () => {
|
||||
const { rerender } = render(
|
||||
<ImageCanvasSelectedLayerToolbarView
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CSSProperties } from 'react';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
|
||||
|
||||
type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
selectedLayer: CanvasLayer | null;
|
||||
@@ -81,19 +82,23 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
aria-label="图片工具栏"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="快速编辑"
|
||||
title="快速编辑"
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
||||
>
|
||||
<span>快速编辑</span>
|
||||
</PlatformIconButton>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
{!isQuickEditUnsupportedAssetKind(selectedLayer) ? (
|
||||
<>
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="快速编辑"
|
||||
title="快速编辑"
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
||||
>
|
||||
<span>快速编辑</span>
|
||||
</PlatformIconButton>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{canRasterEdit ? (
|
||||
<>
|
||||
<EditorIconButton
|
||||
|
||||
@@ -556,8 +556,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect.objectContaining({
|
||||
prompt: '快速修图',
|
||||
sourceImageSrc: 'resource-source',
|
||||
size: '1537x1025',
|
||||
model: 'gpt-image-2',
|
||||
size: '2048x1365',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -578,6 +578,31 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(screen.getByTestId('fit-count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
it('rejects direct quick edit submission for icon assets', async () => {
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialLayers={[createLayer({ assetKind: 'icon' })]}
|
||||
initialQuickEditPanel={{
|
||||
mode: 'quick-edit',
|
||||
sourceLayerId: 'layer-source',
|
||||
prompt: '修改图标',
|
||||
size: '1024x1024',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
model: 'gpt-image-2',
|
||||
status: 'idle',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||||
|
||||
expect(editEditorImageMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('quick-edit').textContent).toContain(
|
||||
'failed:修改图标:图标类素材不支持快速编辑',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears quick edit panel after a queued edit reloads the project snapshot', async () => {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
const refreshTaskList = vi.fn();
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
isCanvasGenerationDialog,
|
||||
isQuickEditUnsupportedAssetKind,
|
||||
normalizeEditorImageModel,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
resolveImageGenerationErrorMessage,
|
||||
@@ -1251,6 +1252,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isQuickEditUnsupportedAssetKind(quickEditSourceLayer)) {
|
||||
setQuickEditPanel({
|
||||
...quickEditPanel,
|
||||
status: 'failed',
|
||||
errorMessage: '图标类素材不支持快速编辑',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const panelMode = quickEditPanel.mode ?? 'quick-edit';
|
||||
const quickEditReferences =
|
||||
panelMode === 'redraw' || panelMode === 'quick-edit'
|
||||
@@ -1268,11 +1278,20 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
quickEditSourceLayer.originalWidth,
|
||||
quickEditSourceLayer.originalHeight,
|
||||
);
|
||||
const quickEditOutputSize = {
|
||||
width: quickEditSourceLayer.originalWidth,
|
||||
height: quickEditSourceLayer.originalHeight,
|
||||
};
|
||||
const quickEditSize = `${quickEditSourceLayer.originalWidth}x${quickEditSourceLayer.originalHeight}`;
|
||||
const normalizedQuickEditModel = normalizeEditorImageModel(
|
||||
quickEditPanel.model,
|
||||
);
|
||||
const quickEditOutputSize = isVideoQuickEditSource(quickEditSourceLayer)
|
||||
? {
|
||||
width: quickEditSourceLayer.originalWidth,
|
||||
height: quickEditSourceLayer.originalHeight,
|
||||
}
|
||||
: resolveEditorImageGenerationPixelSize({
|
||||
model: normalizedQuickEditModel,
|
||||
aspectRatio: quickEditAspectRatio,
|
||||
imageSize: quickEditImageSize,
|
||||
});
|
||||
const quickEditSize = `${quickEditOutputSize.width}x${quickEditOutputSize.height}`;
|
||||
const basePrompt =
|
||||
quickEditPanel.prompt.trim() ||
|
||||
(panelMode === 'redraw' ? '重绘图片' : '快速编辑图片');
|
||||
@@ -1484,7 +1503,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
prompt: normalizedPrompt,
|
||||
sourceImageSrc: sourceReferenceImageSrc,
|
||||
size: quickEditSize,
|
||||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
model: normalizedQuickEditModel,
|
||||
...(extraReferenceImageSrcs.length
|
||||
? { referenceImageSrcs: extraReferenceImageSrcs }
|
||||
: {}),
|
||||
@@ -1505,7 +1524,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
aspectRatio: quickEditAspectRatio,
|
||||
imageSize: quickEditImageSize,
|
||||
kind: isCharacterRedraw ? 'character' : 'quick-edit',
|
||||
model: normalizeEditorImageModel(quickEditPanel.model),
|
||||
model: normalizedQuickEditModel,
|
||||
referenceImageSrcs: [
|
||||
...extraReferenceImageSrcs,
|
||||
sourceReferenceImageSrc,
|
||||
|
||||
@@ -915,7 +915,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('opens quick edit for plain generated images without inheriting hidden source dimensions', () => {
|
||||
it('opens quick edit for plain generated images with source model parameters', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(
|
||||
@@ -929,7 +929,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
'layer-source:idle:-',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit-image-options').textContent).toBe(
|
||||
'quick-edit:gpt-image-2:4:3:1K',
|
||||
'quick-edit:gpt-image-2:2:3:2K',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
|
||||
'layer-source:none:0',
|
||||
@@ -1074,7 +1074,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps source image model and infers dimensions from the current image when opening quick edit', () => {
|
||||
it('prefers the source generation parameter snapshot when opening quick edit', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
@@ -1094,7 +1094,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit-image-options').textContent).toBe(
|
||||
'quick-edit:gpt-image-2:3:2:2K',
|
||||
'quick-edit:gpt-image-2:2:3:2K',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
isCanvasGenerationDialog,
|
||||
isQuickEditUnsupportedAssetKind,
|
||||
normalizeEditorImageModel,
|
||||
resizeGenerationPlaceholderToImageSelection,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
@@ -1119,6 +1120,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
);
|
||||
return createQuickEditPanelDraft(sourceLayer, {
|
||||
imageModel: sourceDialog?.imageModel,
|
||||
aspectRatio: sourceDialog?.aspectRatio,
|
||||
imageSize: sourceDialog?.imageSize,
|
||||
});
|
||||
},
|
||||
[canvasGenerationDialogs],
|
||||
@@ -1126,6 +1129,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
|
||||
const openQuickEditPanel = useCallback(
|
||||
(sourceLayer: CanvasLayer) => {
|
||||
if (isQuickEditUnsupportedAssetKind(sourceLayer)) {
|
||||
return;
|
||||
}
|
||||
setImageContextMenu(null);
|
||||
setMetadataLayer(null);
|
||||
setUiAssetExtractionState(null);
|
||||
|
||||
+15
-1
@@ -8621,6 +8621,13 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
.image-canvas-editor__quick-edit-footer
|
||||
.image-canvas-editor__option-popover-anchor--model {
|
||||
grid-column: 3;
|
||||
width: 9.75rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__quick-edit-footer
|
||||
.image-canvas-editor__option-cluster--model {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__quick-edit-footer
|
||||
@@ -9376,7 +9383,7 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
|
||||
.image-canvas-editor__quick-edit-panel.image-canvas-editor__generation-composer {
|
||||
width: auto;
|
||||
bottom: 3.85rem;
|
||||
bottom: calc(9rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.image-canvas-editor__generation-composer {
|
||||
@@ -9398,6 +9405,13 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.image-canvas-editor__option-popover-anchor--dimensions,
|
||||
.image-canvas-editor__option-popover-anchor--model {
|
||||
grid-column: auto;
|
||||
width: 100%;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.image-canvas-editor__option-cluster--dimensions,
|
||||
.image-canvas-editor__option-cluster--model,
|
||||
.image-canvas-editor__readonly-generation-option,
|
||||
|
||||
Reference in New Issue
Block a user