新增画布完美像素功能
在图片工具栏接入完美像素交互、占位流程与历史保护 新增严格像素网格检测和登录态同步处理接口 补齐素材类型校验、持久化边界、并发限制及技术文档 修复 Windows rlib 门禁长文件名解析
This commit is contained in:
@@ -4583,3 +4583,14 @@
|
||||
- 失败与限制:两条图标拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,并在持久化前完成校验。自动拆分仍是 best-effort,失败后保留整张透明图集并返回 `sliceWarning`;手动拆分失败返回接口错误。UI 设计图素材提取继续使用全连通域识别,不受提示词数量影响。
|
||||
- 验证方式:调整既有前端提交、Prompt、连通域切片、上限和响应契约测试,不新增仅用于证明旧解析函数已删除的测试;运行前后端定向测试、类型与 Rust 检查、编码检查和 `git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/openapi/genarrative-external-v1.openapi.json`。
|
||||
|
||||
## 2026-07-30 已有静态图片增加免费一键完美像素化
|
||||
|
||||
- UI 决策:图片选中浮动工具栏的栅格处理顺序固定为 `裁扩 → 去除背景 → 完美像素`。完美像素只对当前活动的静态栅格图层一键执行,不打开参数面板;音频、视频、图片序列和 `character-animation` 不显示。请求期间按 layer id 禁用并显示 busy,首个 await 前用同步 ref 防双击重复提交;结果保留源图并在右侧新增同尺寸 PNG。
|
||||
- API 与执行边界:新增登录态 `POST /api/editor/images/pixel-art-snaps`,复用 `platform-image` 纯内存 snapper、进程级并发 2、30 秒排队加处理总预算以及既有输入尺寸上限。该入口免费 inline,不调用外部 provider,不创建 `external_generation_job`,不打开或刷新任务侧栏,也不进入泥点扣费 / 退款;它与生成请求 `style="pixelArt"` 的 best-effort 后处理是两个契约。
|
||||
- 媒体与归属:前端先创建关闭 composer 的右侧占位,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前项目布局;正式请求使用 `sourceImageSrc` 承载源图 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`。请求禁止 `data:` / `blob:`、signed URL 和普通外链。BFF 下载前必须将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属。
|
||||
- 失败与持久化:已有图片入口使用 strict 语义,只接受静态 PNG / JPEG / WebP,拒绝 GIF、APNG、动画 WebP 和非静态素材;必须检测到双轴一致且达到置信门槛的既有逻辑像素网格,普通照片或未识别网格不能使用生成风格的统一网格兜底。读取、解码、输入校验、并发排队、像素规整或 PNG 编码失败 / 超时 / 不适用时,不保存原图副本冒充成功,不执行最终 OSS PUT,也不创建 asset object、project resource、账号素材或结果 layer。成功时只对最终 PNG 做一次 PUT,至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果以 `source_resource_id` 关联该资源,再由 `canvasCompletion` 写入至多一个右侧派生 layer;不保存逻辑低分辨率图、诊断图或前后对比图。
|
||||
- 非事务边界:strict 零写入只覆盖首个最终 PNG PUT 前的引用 / owner / 项目 / 类型 / 静态编码 / 元数据 / 网格适用性 / CPU 处理门禁。进入持久化后沿用现有 `OSS + asset object → project resource → editor asset → canvas completion` 非事务顺序,后段失败可能保留此前已确认对象或记录;不做删除补偿或 unsafe POST 自动重放,按 `task_id / object_key / resource_id` 读取权威快照排障,跨系统单事务留待独立 procedure 方案。
|
||||
- 占位删除与重试:completion 必须读取当前权威 dialog;若删除已先持久化,只跳过画布 layer / dialog 写回,不得使用请求中的旧 placeholder 复活图层,已经成功持久化的 project resource / 账号素材允许保留。若回包时本地占位已删除,前端不得应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,因此 completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口,绝对“删除意图胜出”留待 targeted delete / tombstone 方案。该路由是 unsafe POST,客户端不得配置 `EDITOR_REQUEST_RETRY_OPTIONS`;请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放,Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行。
|
||||
- 历史边界:成功加入画布时写一条 `perfect-pixel` 历史,中文标签为“完美像素”,并纳入新增结果保护;撤销不得让派生 PNG 消失。像素处理失败或 completion 因占位删除未落画布时不写该历史。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -244,9 +244,12 @@ npm run check:server-rs-ddd
|
||||
7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。
|
||||
8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
||||
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
|
||||
10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。
|
||||
|
||||
## 外部服务与资产
|
||||
|
||||
- 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、进程级并发 `2`、30 秒排队加处理总预算、单边 `10000` 与总像素 `8294400` 上限,但该入口采用 strict 而非生成风格的 best-effort 语义:必须检测到双轴一致且达到置信门槛的既有逻辑像素网格,不能对普通照片或未识别网格使用生成风格中的 `min(width,height)/64` 兜底;读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。
|
||||
- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。进入持久化后沿用既有跨 OSS 与 SpacetimeDB 的非事务边界,依次确认 PNG / asset object、project resource、账号素材和 completion;后段失败可能保留此前已经确认的对象或记录,不做破坏性删除补偿,也不自动重放 unsafe POST。排障按响应或日志中的 `task_id / object_key / resource_id` 重新读取权威项目与素材快照;跨系统单事务 completion 留待独立 procedure 方案收口。
|
||||
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。
|
||||
- 图片生成:VectorEngine 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;逻辑 SKU 与 provider 首选模型均固定为 `gpt-image-2`,只在明确模型不可用、408 / 非拒绝类 429 / 5xx、响应解析失败或非拒绝类缺图时切换兜底模型 `gpt-image-2-c`。401 / 403、普通参数或安全拒绝、本地配置 / 参考图错误、无法确认上游是否已受理的发送错误、request budget 耗尽和生成成功后的图片下载失败不得切模型。一次业务请求总发送上限仍为 5 次;切换兜底模型会消耗后续 attempt,不允许两个模型各重试 5 次。`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id、结果摘要和 recovered failure 数量;首选模型失败但兜底模型成功时,首选失败仍落 `external_api_call_failure`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`fallback_from_model`、`fallback_to_model`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。
|
||||
- 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。
|
||||
@@ -562,6 +565,7 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`EditorCanvasGenerationDialog`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:图片画布生成对话框表,保存 canvas / project / owner、生成模式、状态、可选 source / generated layer、占位几何和有界扩展字段;typed 列为快照真相,`dialog_json` 只保留未结构化参数。当前 worker completion 以读取时 revision 调用 CAS 保存,冲突时拒绝覆盖;`job_id + worker_id + lease_token` 栅栏下的资源 / layer / dialog / job 单事务完成仍是后续收口。
|
||||
- 完美像素 completion:同步处理成功后按当前 revision 重新读取权威 dialog;目标 dialog 存在时只写入一个派生 layer 并关联结果,目标 dialog 的删除已先持久化时跳过 layer / dialog 写回,不得按请求快照重建占位。该分支不属于 external job completion,允许此前已成功创建的 resource / asset 保留。
|
||||
- 索引:按 canvas 和 project 读取结构化行;当前未建立 external job 二级索引。
|
||||
|
||||
### `editor_canvas_layout_migration`
|
||||
@@ -576,6 +580,7 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`EditorProjectResource`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和历史 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation` 的 `陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组和资源引用以 `editor_canvas_layer` 为权威,生成器对象以 `editor_canvas_generation_dialog` 为权威;legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取。新写入不再把素材生成输入快照作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-run,apply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。
|
||||
- 完美像素资源:处理成功时只允许一个最终 PNG 对象对应一个新 project resource;源图已有正式 project resource 时,`source_resource_id` 指向该资源;结果尺寸与最终 PNG 一致,不写逻辑低分辨率图、诊断图或前后对比图。成功时同时创建一个同源 `editor_asset`,请求省略素材文件夹时落入默认素材文件夹;completion 因权威 dialog 的删除已先持久化而跳过画布写回时,这两类已确认资源无需回滚。
|
||||
- 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。
|
||||
|
||||
### `editor_asset_folder`
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整画布视图。删除、剪切和隐藏允许撤销,是因为目标只会让内容重新出现。
|
||||
|
||||
添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。
|
||||
添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、完美像素新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。
|
||||
|
||||
恢复同样执行动态安全检查。移动、层级、分组、锁定、翻转和视图等不会减少内容的操作可以恢复;重新执行删除、剪切、隐藏、删除生成结果或替换当前素材时必须被阻止。
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- 撤销栈和恢复栈均保存操作类型、目标 `CanvasHistorySnapshot` 和创建时间,分别最多保留 60 条。
|
||||
- 新画布操作把操作前快照写入撤销栈并清空恢复栈;成功撤销把当前快照写入恢复栈,成功恢复把当前快照写回撤销栈。
|
||||
- 操作类型用于生成用户提示,并对添加、上传、生成和替换等明确会移除当前结果的撤销做保护;其它操作能否应用由当前快照与目标快照的差异检查决定。
|
||||
- 已有图片完美像素化成功落入画布前记录独立 `perfect-pixel` 历史类型,中文提示使用“完美像素”。该类型与 `generate-image`、`expand-image`、`remove-background` 一样属于新增结果保护操作:撤销不得删除派生 PNG,源图继续保留也不改变这一保护语义。像素处理失败、超时或不适用时不写历史。
|
||||
- 安全检查以稳定的 `layer.id` 判断当前图层是否仍存在;内容身份优先比较对象存储 key、对象标识和媒体地址,序列帧结果比较完整帧列表。`resourceId`、`sourceResourceId`、`sourceAssetId` 等内部关联 ID 的延迟回填不视为图片替换。
|
||||
- 恢复历史快照时,相同 ID 的图层以当前对象为权威,只从目标快照覆盖 `x`、`y`、`zIndex`、`groupId`、`assetKind`、`hidden`、`locked`、`flipX`、`flipY`。当前图层的资源关联、内容、媒体、生成元数据、`width` / `height` / `originalWidth` / `originalHeight` 和标题必须保留,不能被异步回填前的旧快照覆盖。
|
||||
- 当前生成对话框和非活动生成结果按稳定 ID 纳入内容存在性检查,避免恢复操作删除当前生成结果。相同 ID 的生成对话框只从目标快照恢复占位框 `x` / `y` 以及 active / inactive 槽位对应的 `composerOpen`,当前占位框的 `width` / `height` / `originalWidth` / `originalHeight`、当前比例与清晰度等参数、`generating` / `failed` / 完成态、提示词、参考图和任务结果继续以当前状态为准,不能被旧历史快照降级。
|
||||
@@ -34,6 +35,7 @@
|
||||
- 修改素材类型的撤销与恢复仍以当前图层内容为权威,但每次成功恢复类型后都要创建与恢复类型一致的正式项目 resource,再回填新 `resourceId`。同一图层的 resource 创建请求使用单调版本号,迟到的旧类型响应不得覆盖更晚的 undo / redo 结果。
|
||||
- 鼠标拖动在按下时暂存操作前快照;屏幕位移达到点击阈值后才开始改变画布坐标并只提交一条历史,阈值内的指针抖动和单击都不产生位移或历史记录。
|
||||
- 本地即时结果与后端项目快照结果都必须在生成图层加入画布前写入一条生成历史;生成完成后的自动适合视图不再额外压入视口历史,保证用户第一次撤销就命中生成保护。
|
||||
- 完美像素的关闭 composer 占位沿用 generation dialog 的内容存在性保护。占位删除已先持久化时,后端 completion 不得用旧 placeholder 复活占位或派生图层;回包时本地占位已删除则前端不应用完成快照或写 `perfect-pixel` 历史,已经持久化的 project resource / 账号素材仍可由资源或素材入口读取。现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。
|
||||
- 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。
|
||||
|
||||
## 验收重点
|
||||
@@ -55,3 +57,5 @@
|
||||
15. 图层移动后从素材库删除关联素材,随后撤销或恢复都不得把已删除图层重新加入画布;普通画布删除未伴随素材库删除时仍可撤销。
|
||||
16. 打开“修改图片”并输入未提交提示词后,从按钮等非输入控件触发撤销不得关闭弹窗或回退当前草稿。
|
||||
17. 修改素材类型后立即撤销或快速撤销再恢复,最终只允许最新类型的 resource 响应回填;刷新项目后类型与最后一次成功历史操作一致。
|
||||
18. 完美像素结果加入画布后只写一条 `perfect-pixel` 历史;第一次撤销命中保护提示,源图和派生 PNG 都不消失。
|
||||
19. 完美像素处理中删除占位后,本次完成回包不得在本地重新应用结果或写 `perfect-pixel` 历史;删除已先持久化时,后端不得复活占位或结果图层;已成功持久化的资源或账号素材允许保留。
|
||||
|
||||
@@ -61,6 +61,21 @@ worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存
|
||||
|
||||
重复 completion 必须返回同一资源、layer 和 dialog 终态,不得重复插入,也不能因 dialog 暂时缺失而返回 `changed=false` 后仍把任务标记完成。任一步失败时整笔业务写回回滚,任务保留可诊断的失败或可重试状态。
|
||||
|
||||
### 3.6 免费同步栅格派生完成
|
||||
|
||||
`POST /api/editor/images/pixel-art-snaps` 的完美像素化不是 external job completion:它免费、在当前 HTTP 请求内 inline 执行,不创建任务行,也没有 `job_id / worker_id / lease_token`。前端仍须先创建关闭 composer 的右侧 generation dialog,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前布局,最后把稳定源媒体引用和带非空 `dialogId` 的 `canvasCompletion` 一次提交;结构化 / legacy canvas 的完成分流继续由后端决定,前端不能直接写表或本地补造正式 layer。
|
||||
|
||||
像素读取、静态 PNG / JPEG / WebP 编码门禁、解码、输入限制、双轴网格置信检测、并发排队、规整和 PNG 编码全部发生在持久化前,并采用 strict 语义。GIF、APNG、动画 WebP、普通照片或未识别到既有像素网格的输入不得进入生成风格的统一网格兜底。任一步失败、超时或不适用时不执行最终 OSS PUT,不创建 asset object、`editor_project_resource`、`editor_asset` 或结果 layer;不得保存原图副本、逻辑低分辨率图、诊断图或前后对比图冒充结果。处理成功时只 PUT 一张最终 PNG,并至多各创建一个 project resource 和一个账号素材;源图已有正式 project resource 时,结果资源的 `source_resource_id` 指向该资源。
|
||||
|
||||
该零写入保证只覆盖首个最终 PNG PUT 前的可预判与处理阶段。进入持久化后,PNG / asset object、project resource、账号素材与 canvas completion 仍跨 OSS 和多个 SpacetimeDB procedure,沿用既有非事务顺序;后段失败可以保留此前已经确认的对象或记录,不做自动删除补偿,也不由客户端重放请求。调用方应按 `task_id / object_key / resource_id` 重新读取权威项目和素材快照后显式收口。
|
||||
|
||||
同步 completion 写画布前必须读取当前 revision 和 dialog,而不能信任请求中提交时的旧 placeholder:
|
||||
|
||||
1. dialog 仍存在时,在同一当前布局上完成一个结果 layer,保留源图,并关联结果 resource;
|
||||
2. dialog 的删除已先持久化时,跳过 layer / dialog 写回,不重建占位、不复活派生图层;已成功持久化的 project resource / 账号素材允许保留;
|
||||
3. CAS 冲突时不得拿新 revision 原样重放旧整包;按当前项目保存冲突规则重新读取权威快照并显式收口;
|
||||
4. 客户端回包时若本地 dialog 已删除,不应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`。请求字节可能已发出后的 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 不自动重放,先通过 GET 核对项目 / 素材快照,再由用户显式决定是否再次执行;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。
|
||||
|
||||
## 4. 存量迁移
|
||||
|
||||
迁移按 canvas 执行 `backfill → hash 核对 → activate`,并保持幂等:
|
||||
@@ -102,5 +117,6 @@ SpacetimeDB 必须先于依赖新 procedure / bindings 的 API 发布;前端
|
||||
- structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用和 dialog 状态;两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。
|
||||
- worker completion 当前以读取时 revision 做 CAS,冲突时拒绝覆盖;V2 保存和保存后快照在同一 procedure 结果内返回,避免“已提交但后续 GET 失败”的不确定结果。lease-fenced 资源 / layer / dialog / job 单事务 completion 仍是后续收口项。
|
||||
- structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。
|
||||
- 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个最终 PNG、至多一个 project resource 和一个账号素材。处理中占位删除已先持久化时,完成请求不复活 dialog 或结果 layer;回包时本地占位已删除则不应用完成快照,已成功创建的资源 / 素材仍可读取;传输结果未知时客户端不自动重放 unsafe POST。
|
||||
- 回滚重组结果经 schema 校验、canonical hash / 资源引用核对且不超过 2 MiB;超限或不一致时明确拒绝且 structured 快照仍可读取。
|
||||
- 完成 `npm run spacetime:generate`、`npm run check:spacetime-runtime-access`、`npm run check:spacetime-schema`、相关 Rust / API / 前端定向测试、`npm run check:encoding` 和 `git diff --check`。
|
||||
|
||||
@@ -152,8 +152,15 @@ function parseArchiveObjectMembers(artifact) {
|
||||
longNameTable = artifact.subarray(contentStart, contentEnd);
|
||||
} else if (/^\/\d+$/u.test(rawName) && longNameTable) {
|
||||
const nameOffset = Number.parseInt(rawName.slice(1), 10);
|
||||
const nameEnd = longNameTable.indexOf(0x0a, nameOffset);
|
||||
const resolvedEnd = nameEnd >= 0 ? nameEnd : longNameTable.length;
|
||||
// GNU archives use newline-terminated names; MSVC COFF archives use NUL.
|
||||
const candidateNameEnds = [
|
||||
longNameTable.indexOf(0x00, nameOffset),
|
||||
longNameTable.indexOf(0x0a, nameOffset),
|
||||
].filter((nameEnd) => nameEnd >= 0);
|
||||
const resolvedEnd =
|
||||
candidateNameEnds.length > 0
|
||||
? Math.min(...candidateNameEnds)
|
||||
: longNameTable.length;
|
||||
memberName = longNameTable
|
||||
.subarray(nameOffset, resolvedEnd)
|
||||
.toString('utf8')
|
||||
|
||||
@@ -1963,6 +1963,147 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_pixel_art_snap_requires_bearer_auth() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let request_body = serde_json::json!({
|
||||
"sourceImageSrc": "editor-resource-source",
|
||||
"projectId": "proj-source",
|
||||
"canvasCompletion": {
|
||||
"dialogId": "dialog-pixel-art",
|
||||
"title": "完美像素",
|
||||
"placeholder": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 128,
|
||||
"height": 128,
|
||||
"originalWidth": 128,
|
||||
"originalHeight": 128
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/images/pixel-art-snaps")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(request_body.to_string()))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_pixel_art_snap_rejects_inline_source_before_processing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Queue,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138230", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_body");
|
||||
let app = build_router(state);
|
||||
let request_body = serde_json::json!({
|
||||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||||
"projectId": "proj-source",
|
||||
"sourceResourceId": "editor-resource-source",
|
||||
"assetKind": "character",
|
||||
"generationInputs": { "fields": [], "references": [] },
|
||||
"canvasCompletion": {
|
||||
"dialogId": "dialog-pixel-art",
|
||||
"title": "完美像素",
|
||||
"placeholder": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 128,
|
||||
"height": 128,
|
||||
"originalWidth": 128,
|
||||
"originalHeight": 128
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/images/pixel-art-snaps")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(request_body.to_string()))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("先上传 OSS"),
|
||||
"handler should reject inline pixel-art sources: {body_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_pixel_art_snap_requires_dialog_id_before_project_or_media_work() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_dialog");
|
||||
let app = build_router(state);
|
||||
let request_body = serde_json::json!({
|
||||
"sourceImageSrc": "editor-resource-source",
|
||||
"projectId": "proj-does-not-exist",
|
||||
"canvasCompletion": {
|
||||
"title": "完美像素",
|
||||
"placeholder": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 128,
|
||||
"height": 128,
|
||||
"originalWidth": 128,
|
||||
"originalHeight": 128
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/images/pixel-art-snaps")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(request_body.to_string()))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("canvasCompletion.dialogId"),
|
||||
"handler should reject missing dialog ids before project lookup: {body_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_generation_json_validation_preserves_unsupported_media_type() {
|
||||
let state = AppState::new(AppConfig {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,9 +22,9 @@ use crate::{
|
||||
get_editor_asset_library, get_editor_generation_pricing, get_editor_project,
|
||||
list_editor_projects, list_public_editor_project_resources, load_recent_editor_project,
|
||||
remove_editor_image_background, rename_editor_project, save_editor_project_layout,
|
||||
split_editor_icon_spritesheet, submit_editor_asset_showcase,
|
||||
toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder,
|
||||
update_editor_project_resource_showcase,
|
||||
snap_editor_image_to_pixel_art, split_editor_icon_spritesheet,
|
||||
submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset,
|
||||
update_editor_asset_folder, update_editor_project_resource_showcase,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -205,6 +205,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/images/pixel-art-snaps",
|
||||
post(snap_editor_image_to_pixel_art).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/icon-spritesheets/generations",
|
||||
post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -5,7 +5,8 @@ pub mod vector_engine;
|
||||
|
||||
pub use pixel_art_snapper::{
|
||||
PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT,
|
||||
PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art, snap_pixel_art_with_deadline,
|
||||
PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art, snap_pixel_art_strict,
|
||||
snap_pixel_art_strict_with_deadline, snap_pixel_art_with_deadline,
|
||||
};
|
||||
pub use vector_engine::{
|
||||
DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -195,6 +195,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onPerfectPixel: vi.fn(),
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onUiAssetExtractionToolChange: vi.fn(),
|
||||
|
||||
@@ -289,6 +289,7 @@ export type CanvasHistoryActionType =
|
||||
| 'generate-image'
|
||||
| 'expand-image'
|
||||
| 'remove-background'
|
||||
| 'perfect-pixel'
|
||||
| 'split-atlas'
|
||||
| 'replace-image'
|
||||
| 'show-image'
|
||||
|
||||
@@ -1464,6 +1464,7 @@ export function ImageCanvasEditorView({
|
||||
assetFolderId: activeUploadFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot: applyGeneratedProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
onWalletBalanceMayHaveChanged: refreshEditorWalletState,
|
||||
});
|
||||
const handleEditorAgentConfirmSent = useCallback(() => {
|
||||
@@ -1581,6 +1582,8 @@ export function ImageCanvasEditorView({
|
||||
openRedrawPanel,
|
||||
openCropExpandPanel,
|
||||
removeSelectedLayerBackground,
|
||||
snapSelectedLayerToPerfectPixels,
|
||||
perfectPixelLayerIds,
|
||||
splitSelectedIconSpritesheet,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
extractUiDesignAssets,
|
||||
@@ -2357,6 +2360,7 @@ export function ImageCanvasEditorView({
|
||||
quickEditSelectionSourceLayer,
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
perfectPixelLayerIds,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
persistingAssetKindLayerIds,
|
||||
uploadDropTarget,
|
||||
@@ -2409,6 +2413,7 @@ export function ImageCanvasEditorView({
|
||||
onOpenRedrawPanel: openRedrawPanel,
|
||||
onOpenCropExpandPanel: openCropExpandPanel,
|
||||
onRemoveBackground: removeSelectedLayerBackground,
|
||||
onPerfectPixel: snapSelectedLayerToPerfectPixels,
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => {
|
||||
if (persistingAssetKindLayerIdsRef.current.has(layer.id)) {
|
||||
return;
|
||||
|
||||
@@ -625,6 +625,12 @@ describe('ImageCanvasHistoryModel', () => {
|
||||
expect(isProtectedCanvasHistoryAction({ type: 'replace-image' })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(formatCanvasHistoryAction({ type: 'perfect-pixel' })).toBe(
|
||||
'完美像素',
|
||||
);
|
||||
expect(isProtectedCanvasHistoryAction({ type: 'perfect-pixel' })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isProtectedCanvasHistoryAction({ type: 'move-image' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const CANVAS_HISTORY_ACTION_LABELS: Record<
|
||||
'generate-image': '生成图片',
|
||||
'expand-image': '扩展图片',
|
||||
'remove-background': '移除背景',
|
||||
'perfect-pixel': '完美像素',
|
||||
'split-atlas': '拆分图集',
|
||||
'replace-image': '替换图片',
|
||||
'show-image': '显示图片',
|
||||
@@ -46,6 +47,7 @@ const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set<
|
||||
'generate-image',
|
||||
'expand-image',
|
||||
'remove-background',
|
||||
'perfect-pixel',
|
||||
'split-atlas',
|
||||
'replace-image',
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
type EditorBackgroundRemovalInput,
|
||||
type EditorBackgroundRemovalResult,
|
||||
type EditorPixelArtSnapInput,
|
||||
type EditorPixelArtSnapResult,
|
||||
removeEditorImageBackground,
|
||||
snapEditorImageToPixelArt,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
|
||||
export type CropExpandInsets = {
|
||||
@@ -222,3 +225,9 @@ export async function removeImageBackground(
|
||||
typeof input === 'string' ? { sourceImageSrc: input } : input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function snapImageToPerfectPixels(
|
||||
input: EditorPixelArtSnapInput,
|
||||
): Promise<EditorPixelArtSnapResult> {
|
||||
return snapEditorImageToPixelArt(input);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ function renderSelectedToolbar(
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onPerfectPixel: vi.fn(),
|
||||
isPerfectPixelProcessing: false,
|
||||
isSplittingIconSpritesheet: false,
|
||||
isPersistingAssetKind: false,
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
@@ -66,6 +68,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
'快速编辑',
|
||||
'裁扩按钮',
|
||||
'去除背景按钮',
|
||||
'完美像素',
|
||||
'改造',
|
||||
'下载按钮',
|
||||
]);
|
||||
@@ -75,6 +78,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '去除背景按钮' }),
|
||||
);
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '完美像素' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '改造' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' }));
|
||||
|
||||
@@ -85,6 +89,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
props.selectedLayer,
|
||||
);
|
||||
expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onPerfectPixel).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(
|
||||
@@ -104,6 +109,19 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
.getByRole('button', { name: '去除背景按钮' })
|
||||
.querySelector('.lucide-image-off'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '完美像素' })
|
||||
.querySelector('.lucide-grid-2x2'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '完美像素' }).textContent,
|
||||
).toContain('完美像素');
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '完美像素' })
|
||||
.getAttribute('title'),
|
||||
).toBe('自动识别并规整像素网格');
|
||||
});
|
||||
|
||||
it('renders UI design asset extraction after remove background', () => {
|
||||
@@ -119,6 +137,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
'快速编辑',
|
||||
'裁扩按钮',
|
||||
'去除背景按钮',
|
||||
'完美像素',
|
||||
'提取素材',
|
||||
'改造',
|
||||
'下载按钮',
|
||||
@@ -142,6 +161,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
'快速编辑',
|
||||
'裁扩按钮',
|
||||
'去除背景按钮',
|
||||
'完美像素',
|
||||
'拆分图集',
|
||||
'改造',
|
||||
'下载按钮',
|
||||
@@ -184,6 +204,28 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a disabled loading state while perfect pixel is processing', () => {
|
||||
const props = renderSelectedToolbar({
|
||||
isPerfectPixelProcessing: true,
|
||||
});
|
||||
const button = screen.getByRole('button', {
|
||||
name: '完美像素处理中',
|
||||
});
|
||||
|
||||
expect(button.getAttribute('aria-busy')).toBe('true');
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(
|
||||
button.querySelector('.lucide-loader-circle.animate-spin'),
|
||||
).toBeTruthy();
|
||||
expect(button.querySelector('.lucide-grid-2x2')).toBeNull();
|
||||
expect(button.textContent).toContain('处理中');
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(props.onPerfectPixel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invoke atlas splitting while the selected asset kind is persisting', () => {
|
||||
const layer = createLayer({ assetKind: 'icon-spritesheet' });
|
||||
const props = renderSelectedToolbar({
|
||||
@@ -227,6 +269,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '完美像素' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '生成动画' }),
|
||||
).toBeNull();
|
||||
@@ -255,6 +300,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(
|
||||
within(videoToolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(videoToolbar).queryByRole('button', { name: '完美像素' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(videoToolbar)
|
||||
.getAllByRole('button')
|
||||
@@ -278,6 +326,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(
|
||||
within(actionToolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(actionToolbar).queryByRole('button', { name: '完美像素' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(actionToolbar)
|
||||
.getAllByRole('button')
|
||||
@@ -341,6 +392,8 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
onPerfectPixel={vi.fn()}
|
||||
isPerfectPixelProcessing={false}
|
||||
isSplittingIconSpritesheet={false}
|
||||
isPersistingAssetKind={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
@@ -360,6 +413,8 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
onPerfectPixel={vi.fn()}
|
||||
isPerfectPixelProcessing={false}
|
||||
isSplittingIconSpritesheet={false}
|
||||
isPersistingAssetKind={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Crop,
|
||||
Download,
|
||||
Grid2X2,
|
||||
ImageOff,
|
||||
Loader2,
|
||||
PersonStanding,
|
||||
@@ -22,6 +23,8 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
onPerfectPixel: (layer: CanvasLayer) => void;
|
||||
isPerfectPixelProcessing: boolean;
|
||||
isSplittingIconSpritesheet: boolean;
|
||||
isPersistingAssetKind: boolean;
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
||||
@@ -37,6 +40,8 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onOpenRedrawPanel,
|
||||
onOpenCropExpandPanel,
|
||||
onRemoveBackground,
|
||||
onPerfectPixel,
|
||||
isPerfectPixelProcessing,
|
||||
isSplittingIconSpritesheet,
|
||||
isPersistingAssetKind,
|
||||
onSplitIconSpritesheet,
|
||||
@@ -120,6 +125,27 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
icon={ImageOff}
|
||||
onClick={() => onRemoveBackground(selectedLayer)}
|
||||
/>
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label={isPerfectPixelProcessing ? '完美像素处理中' : '完美像素'}
|
||||
title={
|
||||
isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: '自动识别并规整像素网格'
|
||||
}
|
||||
icon={
|
||||
isPerfectPixelProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Grid2X2 className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
disabled={isPerfectPixelProcessing}
|
||||
aria-busy={isPerfectPixelProcessing}
|
||||
onClick={() => onPerfectPixel(selectedLayer)}
|
||||
>
|
||||
<span>{isPerfectPixelProcessing ? '处理中' : '完美像素'}</span>
|
||||
</PlatformIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' ? (
|
||||
|
||||
@@ -78,6 +78,7 @@ export type ImageCanvasStageViewProps = {
|
||||
generationComposerStyle: CSSProperties | null;
|
||||
selectedToolbarStyle: CSSProperties | null;
|
||||
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
|
||||
perfectPixelLayerIds?: ReadonlySet<string>;
|
||||
persistingAssetKindLayerIds?: ReadonlySet<string>;
|
||||
uploadDropTarget: 'canvas' | 'assets' | null;
|
||||
contextMenu: CanvasContextMenuState | null;
|
||||
@@ -146,6 +147,7 @@ export type ImageCanvasStageViewProps = {
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
onPerfectPixel: (layer: CanvasLayer) => void;
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
||||
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
|
||||
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void;
|
||||
@@ -233,6 +235,7 @@ export function ImageCanvasStageView({
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
|
||||
perfectPixelLayerIds = EMPTY_LAYER_ID_SET,
|
||||
persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
@@ -280,6 +283,7 @@ export function ImageCanvasStageView({
|
||||
onOpenRedrawPanel,
|
||||
onOpenCropExpandPanel,
|
||||
onRemoveBackground,
|
||||
onPerfectPixel,
|
||||
onSplitIconSpritesheet,
|
||||
onExtractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange,
|
||||
@@ -404,10 +408,14 @@ export function ImageCanvasStageView({
|
||||
isPersistingAssetKind={Boolean(
|
||||
selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
isPerfectPixelProcessing={Boolean(
|
||||
selectedLayer && perfectPixelLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
onOpenQuickEditPanel={onOpenQuickEditPanel}
|
||||
onOpenRedrawPanel={onOpenRedrawPanel}
|
||||
onOpenCropExpandPanel={onOpenCropExpandPanel}
|
||||
onRemoveBackground={onRemoveBackground}
|
||||
onPerfectPixel={onPerfectPixel}
|
||||
onSplitIconSpritesheet={onSplitIconSpritesheet}
|
||||
onExtractUiDesignAssets={onExtractUiDesignAssets}
|
||||
onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel}
|
||||
|
||||
@@ -106,6 +106,7 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
project: EditorProjectSnapshot,
|
||||
action?: CanvasHistoryAction,
|
||||
) => void;
|
||||
flushProjectPersistence?: () => Promise<void>;
|
||||
onWalletBalanceMayHaveChanged?: () => void;
|
||||
};
|
||||
|
||||
@@ -182,6 +183,7 @@ export function useImageCanvasGenerationSurface({
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
}: ImageCanvasGenerationSurfaceOptions) {
|
||||
const toolbarOptionCloseTimerRef = useRef<ReturnType<
|
||||
@@ -218,6 +220,7 @@ export function useImageCanvasGenerationSurface({
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
|
||||
const removeImageBackgroundMock = vi.hoisted(() => vi.fn());
|
||||
const snapImageToPerfectPixelsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorImageReference', async () => {
|
||||
@@ -77,6 +78,7 @@ vi.mock('./ImageCanvasRasterEditModel', async () => {
|
||||
...actual,
|
||||
removeImageBackground: removeImageBackgroundMock,
|
||||
renderCropExpandImage: renderCropExpandImageMock,
|
||||
snapImageToPerfectPixels: snapImageToPerfectPixelsMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -119,6 +121,8 @@ function GenerationWorkflowHarness({
|
||||
projectId,
|
||||
currentUserId,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
upsertGeneratedAsset,
|
||||
}: {
|
||||
initialLayers?: CanvasLayer[];
|
||||
initialViewport?: { x: number; y: number; scale: number };
|
||||
@@ -127,6 +131,12 @@ function GenerationWorkflowHarness({
|
||||
applyProjectSnapshot?: Parameters<
|
||||
typeof useImageCanvasGenerationWorkflow
|
||||
>[0]['applyProjectSnapshot'];
|
||||
flushProjectPersistence?: Parameters<
|
||||
typeof useImageCanvasGenerationWorkflow
|
||||
>[0]['flushProjectPersistence'];
|
||||
upsertGeneratedAsset?: Parameters<
|
||||
typeof useImageCanvasGenerationWorkflow
|
||||
>[0]['upsertGeneratedAsset'];
|
||||
}) {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
|
||||
const [viewport, setViewport] = useState(initialViewport);
|
||||
@@ -175,6 +185,8 @@ function GenerationWorkflowHarness({
|
||||
projectId,
|
||||
currentUserId,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
upsertGeneratedAsset,
|
||||
});
|
||||
|
||||
const activeDialog = dialogs.generateDialog;
|
||||
@@ -292,6 +304,9 @@ function GenerationWorkflowHarness({
|
||||
{fitLayersMockRef.current.mock.calls.length}
|
||||
</span>
|
||||
<span data-testid="task-refresh-key">{workflow.taskListRefreshKey}</span>
|
||||
<output aria-label="完美像素状态">
|
||||
{workflow.perfectPixelLayerIds.has(layers[0]!.id) ? '处理中' : '空闲'}
|
||||
</output>
|
||||
<button type="button" onClick={workflow.openGenerateDialog}>
|
||||
打开生成
|
||||
</button>
|
||||
@@ -529,6 +544,24 @@ function GenerationWorkflowHarness({
|
||||
>
|
||||
去除背景
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void workflow.snapSelectedLayerToPerfectPixels(layers[0]!)
|
||||
}
|
||||
>
|
||||
完美像素
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (activeCanvasDialog) {
|
||||
dialogs.removeCanvasGenerationDialogById(activeCanvasDialog.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除处理占位
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void workflow.splitSelectedIconSpritesheet(layers[0]!)}
|
||||
@@ -1443,7 +1476,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'-',
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -1474,7 +1509,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
vi.advanceTimersByTime(2_999);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'-',
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -2010,9 +2047,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('status', { name: '拆图状态' }).textContent,
|
||||
).toBe('空闲');
|
||||
expect(screen.getByRole('status', { name: '拆图状态' }).textContent).toBe(
|
||||
'空闲',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2109,6 +2146,314 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes a closed perfect-pixel placeholder before submitting the stable source', async () => {
|
||||
const events: string[] = [];
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
const upsertGeneratedAsset = vi.fn();
|
||||
const project = {
|
||||
projectId: 'project-1',
|
||||
title: '未命名画布',
|
||||
canvas: {
|
||||
canvasId: 'project-1:canvas:default',
|
||||
projectId: 'project-1',
|
||||
title: '默认画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
updatedAt: '2026-07-30T00:00:00.000Z',
|
||||
},
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-07-30T00:00:00.000Z',
|
||||
};
|
||||
const asset = {
|
||||
assetId: 'asset-perfect-pixel',
|
||||
label: '源图 · 完美像素',
|
||||
imageSrc: '/generated-images/editor/perfect-pixel.png',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
sourceType: 'generated' as const,
|
||||
};
|
||||
const flushProjectPersistence = vi.fn(async () => {
|
||||
events.push('flush');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'quick-edit:generating:closed:-:placeholder',
|
||||
);
|
||||
});
|
||||
snapImageToPerfectPixelsMock.mockImplementationOnce(async () => {
|
||||
events.push('post');
|
||||
return {
|
||||
imageSrc: '/generated-images/editor/perfect-pixel.png',
|
||||
objectKey: 'generated-images/editor/perfect-pixel.png',
|
||||
assetObjectId: 'asset-object-perfect-pixel',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
sourceType: 'generated',
|
||||
taskId: 'perfect-pixel-1',
|
||||
elapsedMs: 250,
|
||||
provider: 'Genarrative',
|
||||
resource: { resourceId: 'resource-perfect-pixel' },
|
||||
asset,
|
||||
project,
|
||||
};
|
||||
});
|
||||
const sourceLayer = createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '红发骑士' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
initialLayers={[sourceLayer]}
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
flushProjectPersistence={flushProjectPersistence}
|
||||
upsertGeneratedAsset={upsertGeneratedAsset}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(events).toEqual(['flush', 'post']);
|
||||
expect(flushProjectPersistence).toHaveBeenCalledTimes(1);
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'generated-images/editor/source.png',
|
||||
projectId: 'project-1',
|
||||
sourceResourceId: 'resource-source',
|
||||
assetKind: 'character',
|
||||
generationInputs: sourceLayer.generationInputs,
|
||||
assetLabel: '源图 · 完美像素',
|
||||
canvasCompletion: expect.objectContaining({
|
||||
dialogId: 'generation-dialog-1',
|
||||
title: '源图 · 完美像素',
|
||||
placeholder: expect.objectContaining({
|
||||
width: 320,
|
||||
height: 240,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(upsertGeneratedAsset).toHaveBeenCalledWith(asset);
|
||||
expect(applyProjectSnapshot).toHaveBeenCalledWith(project, {
|
||||
type: 'perfect-pixel',
|
||||
count: 1,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('status', { name: '完美像素状态' }).textContent,
|
||||
).toBe('空闲');
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads an inline perfect-pixel source before flushing and posting', async () => {
|
||||
const order: string[] = [];
|
||||
uploadEditorMediaAssetFileMock.mockImplementationOnce(async () => {
|
||||
order.push('upload');
|
||||
return {
|
||||
src: 'https://signed.example.test/perfect-pixel-source.png',
|
||||
objectKey: 'generated-images/editor/uploaded-source.png',
|
||||
assetObjectId: 'asset-object-uploaded-source',
|
||||
legacyPublicPath: '/generated-images/editor/uploaded-source.png',
|
||||
};
|
||||
});
|
||||
const flushProjectPersistence = vi.fn(async () => {
|
||||
order.push('flush');
|
||||
});
|
||||
snapImageToPerfectPixelsMock.mockImplementationOnce(async () => {
|
||||
order.push('post');
|
||||
return {
|
||||
imageSrc: '/generated-images/editor/perfect-pixel.png',
|
||||
objectKey: 'generated-images/editor/perfect-pixel.png',
|
||||
assetObjectId: 'asset-object-perfect-pixel',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
sourceType: 'generated',
|
||||
taskId: 'perfect-pixel-1',
|
||||
elapsedMs: 250,
|
||||
provider: 'Genarrative',
|
||||
resource: { resourceId: 'resource-perfect-pixel' },
|
||||
asset: { assetId: 'asset-perfect-pixel' },
|
||||
project: null,
|
||||
};
|
||||
});
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={vi.fn()}
|
||||
flushProjectPersistence={flushProjectPersistence}
|
||||
initialLayers={[createLayer({ resourceId: 'local-resource-source' })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snapImageToPerfectPixelsMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(order).toEqual(['upload', 'flush', 'post']);
|
||||
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'generated-images/editor/uploaded-source.png',
|
||||
sourceResourceId: undefined,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'完美像素结果已保存到素材库,画布占位已不存在。',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a failed perfect-pixel placeholder and releases busy state', async () => {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
|
||||
new Error('像素网格无法识别'),
|
||||
);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'quick-edit:failed:closed:-:placeholder',
|
||||
);
|
||||
});
|
||||
expect(applyProjectSnapshot).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'/generated-images/editor/source.png',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('status', { name: '完美像素状态' }).textContent,
|
||||
).toBe('空闲');
|
||||
});
|
||||
|
||||
it('does not apply a completed project after the perfect-pixel placeholder was deleted', async () => {
|
||||
let resolvePerfectPixel:
|
||||
| ((value: {
|
||||
imageSrc: string;
|
||||
objectKey: string;
|
||||
assetObjectId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: 'generated';
|
||||
taskId: string;
|
||||
elapsedMs: number;
|
||||
provider: 'Genarrative';
|
||||
resource: { resourceId: string };
|
||||
asset: { assetId: string };
|
||||
project: {
|
||||
projectId: string;
|
||||
title: string;
|
||||
canvas: {
|
||||
canvasId: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
viewport: { x: number; y: number; scale: number };
|
||||
layers: never[];
|
||||
updatedAt: string;
|
||||
};
|
||||
viewport: { x: number; y: number; scale: number };
|
||||
layers: never[];
|
||||
resources: never[];
|
||||
updatedAt: string;
|
||||
};
|
||||
}) => void)
|
||||
| undefined;
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
const upsertGeneratedAsset = vi.fn();
|
||||
snapImageToPerfectPixelsMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePerfectPixel = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
||||
upsertGeneratedAsset={upsertGeneratedAsset}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
await waitFor(() => {
|
||||
expect(snapImageToPerfectPixelsMock).toHaveBeenCalled();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除处理占位' }));
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
|
||||
resolvePerfectPixel?.({
|
||||
imageSrc: '/generated-images/editor/perfect-pixel.png',
|
||||
objectKey: 'generated-images/editor/perfect-pixel.png',
|
||||
assetObjectId: 'asset-object-perfect-pixel',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
sourceType: 'generated',
|
||||
taskId: 'perfect-pixel-1',
|
||||
elapsedMs: 250,
|
||||
provider: 'Genarrative',
|
||||
resource: { resourceId: 'resource-perfect-pixel' },
|
||||
asset: { assetId: 'asset-perfect-pixel' },
|
||||
project: {
|
||||
projectId: 'project-1',
|
||||
title: '未命名画布',
|
||||
canvas: {
|
||||
canvasId: 'project-1:canvas:default',
|
||||
projectId: 'project-1',
|
||||
title: '默认画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
updatedAt: '2026-07-30T00:00:00.000Z',
|
||||
},
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-07-30T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('status', { name: '完美像素状态' }).textContent,
|
||||
).toBe('空闲');
|
||||
});
|
||||
expect(upsertGeneratedAsset).toHaveBeenCalledWith({
|
||||
assetId: 'asset-perfect-pixel',
|
||||
});
|
||||
expect(applyProjectSnapshot).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('opens UI design extraction as a mark selection state before submitting', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
buildCropExpandInsetsFromFrame,
|
||||
removeImageBackground,
|
||||
renderCropExpandImage,
|
||||
snapImageToPerfectPixels,
|
||||
} from './ImageCanvasRasterEditModel';
|
||||
import {
|
||||
createUiAssetExtractionDraftMark,
|
||||
@@ -628,6 +629,7 @@ type GenerationWorkflowOptions = {
|
||||
project: EditorProjectSnapshot,
|
||||
action?: CanvasHistoryAction,
|
||||
) => void;
|
||||
flushProjectPersistence?: () => Promise<void>;
|
||||
onWalletBalanceMayHaveChanged?: () => void;
|
||||
};
|
||||
|
||||
@@ -662,6 +664,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
}: GenerationWorkflowOptions) {
|
||||
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
|
||||
@@ -677,6 +680,10 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
|
||||
const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
const perfectPixelLayerIdsRef = useRef(new Set<string>());
|
||||
const [perfectPixelLayerIds, setPerfectPixelLayerIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||||
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
||||
useState(false);
|
||||
@@ -1734,6 +1741,160 @@ export function useImageCanvasGenerationWorkflow({
|
||||
],
|
||||
);
|
||||
|
||||
const snapSelectedLayerToPerfectPixels = useCallback(
|
||||
async (sourceLayer: CanvasLayer) => {
|
||||
const normalizedProjectId = projectId?.trim();
|
||||
const isStaticRasterLayer =
|
||||
(sourceLayer.mediaType === undefined ||
|
||||
sourceLayer.mediaType === 'image') &&
|
||||
sourceLayer.assetKind !== 'character-animation';
|
||||
if (
|
||||
!isStaticRasterLayer ||
|
||||
!normalizedProjectId ||
|
||||
!applyProjectSnapshot ||
|
||||
!flushProjectPersistence ||
|
||||
perfectPixelLayerIdsRef.current.has(sourceLayer.id)
|
||||
) {
|
||||
if (
|
||||
isStaticRasterLayer &&
|
||||
(!normalizedProjectId ||
|
||||
!applyProjectSnapshot ||
|
||||
!flushProjectPersistence)
|
||||
) {
|
||||
showGenerationWarning('项目尚未准备好,暂时无法执行完美像素。');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
perfectPixelLayerIdsRef.current.add(sourceLayer.id);
|
||||
setPerfectPixelLayerIds((currentLayerIds) => {
|
||||
const nextLayerIds = new Set(currentLayerIds);
|
||||
nextLayerIds.add(sourceLayer.id);
|
||||
return nextLayerIds;
|
||||
});
|
||||
closeGenerationTransientState();
|
||||
setImageContextMenu(null);
|
||||
setMetadataLayer(null);
|
||||
setCropExpandPanel(null);
|
||||
setQuickEditPanel(null);
|
||||
setCharacterAnimationPanel(null);
|
||||
|
||||
let perfectPixelDialogId: string | undefined;
|
||||
try {
|
||||
const assetLabel = `${sourceLayer.title} · 完美像素`;
|
||||
const placement = openPlacedCanvasGenerationDialog({
|
||||
...createQuickEditGenerationDialogDraft({
|
||||
sourceLayer,
|
||||
prompt: '完美像素',
|
||||
assetLabel,
|
||||
status: 'generating',
|
||||
frame: {
|
||||
width: sourceLayer.width,
|
||||
height: sourceLayer.height,
|
||||
},
|
||||
}),
|
||||
composerOpen: false,
|
||||
});
|
||||
perfectPixelDialogId = placement.dialogId;
|
||||
if (!placement.placeholder) {
|
||||
throw new Error('无法创建完美像素处理占位');
|
||||
}
|
||||
|
||||
const sourceImageSrc = await resolveEditorGenerationMediaReference(
|
||||
sourceLayer,
|
||||
'image',
|
||||
normalizedProjectId,
|
||||
);
|
||||
await flushProjectPersistence();
|
||||
const sourceResourceId = sourceLayer.resourceId.trim();
|
||||
const result = await snapImageToPerfectPixels({
|
||||
sourceImageSrc,
|
||||
projectId: normalizedProjectId,
|
||||
sourceResourceId:
|
||||
sourceResourceId &&
|
||||
!sourceResourceId.startsWith('local-resource-') &&
|
||||
!sourceResourceId.startsWith('generation-dialog:')
|
||||
? sourceResourceId
|
||||
: undefined,
|
||||
assetKind: sourceLayer.assetKind,
|
||||
generationInputs: sourceLayer.generationInputs,
|
||||
assetFolderId,
|
||||
assetLabel,
|
||||
canvasCompletion: {
|
||||
dialogId: perfectPixelDialogId,
|
||||
title: assetLabel,
|
||||
placeholder: placement.placeholder,
|
||||
},
|
||||
});
|
||||
upsertGeneratedAsset?.(result.asset);
|
||||
if (!result.project) {
|
||||
if (hasCanvasGenerationDialogById(perfectPixelDialogId)) {
|
||||
updateCanvasGenerationDialogById(perfectPixelDialogId, () => null);
|
||||
showGenerationWarning(
|
||||
'完美像素结果已保存到素材库,画布占位已不存在。',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
|
||||
return;
|
||||
}
|
||||
applyProjectSnapshot(result.project, {
|
||||
type: 'perfect-pixel',
|
||||
count: 1,
|
||||
});
|
||||
setActiveTool('select');
|
||||
setActiveSidebarPanel('layers');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: '完美像素处理失败';
|
||||
if (
|
||||
perfectPixelDialogId &&
|
||||
hasCanvasGenerationDialogById(perfectPixelDialogId)
|
||||
) {
|
||||
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
|
||||
...dialog,
|
||||
status: 'failed',
|
||||
errorMessage,
|
||||
}));
|
||||
} else if (!perfectPixelDialogId) {
|
||||
showGenerationWarning(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
perfectPixelLayerIdsRef.current.delete(sourceLayer.id);
|
||||
setPerfectPixelLayerIds((currentLayerIds) => {
|
||||
if (!currentLayerIds.has(sourceLayer.id)) {
|
||||
return currentLayerIds;
|
||||
}
|
||||
const nextLayerIds = new Set(currentLayerIds);
|
||||
nextLayerIds.delete(sourceLayer.id);
|
||||
return nextLayerIds;
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
applyProjectSnapshot,
|
||||
assetFolderId,
|
||||
closeGenerationTransientState,
|
||||
flushProjectPersistence,
|
||||
hasCanvasGenerationDialogById,
|
||||
openPlacedCanvasGenerationDialog,
|
||||
projectId,
|
||||
setActiveSidebarPanel,
|
||||
setActiveTool,
|
||||
setCharacterAnimationPanel,
|
||||
setCropExpandPanel,
|
||||
setImageContextMenu,
|
||||
setMetadataLayer,
|
||||
setQuickEditPanel,
|
||||
showGenerationWarning,
|
||||
updateCanvasGenerationDialogById,
|
||||
upsertGeneratedAsset,
|
||||
],
|
||||
);
|
||||
|
||||
const splitSelectedIconSpritesheet = useCallback(
|
||||
async (sourceLayer: CanvasLayer) => {
|
||||
if (
|
||||
@@ -2576,6 +2737,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
openCropExpandPanel,
|
||||
startCropExpandFrameResize,
|
||||
removeSelectedLayerBackground,
|
||||
snapSelectedLayerToPerfectPixels,
|
||||
perfectPixelLayerIds,
|
||||
splitSelectedIconSpritesheet,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
taskListRefreshKey,
|
||||
@@ -2679,6 +2842,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
quickEditPanel,
|
||||
quickEditSourceLayer,
|
||||
removeSelectedLayerBackground,
|
||||
snapSelectedLayerToPerfectPixels,
|
||||
perfectPixelLayerIds,
|
||||
splitSelectedIconSpritesheet,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
submitCharacterAnimation,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
removeEditorImageBackground,
|
||||
renameEditorProject,
|
||||
saveEditorProjectLayout,
|
||||
snapEditorImageToPixelArt,
|
||||
splitEditorIconSpritesheet,
|
||||
submitEditorAssetShowcase,
|
||||
toggleEditorShowcaseAssetLike,
|
||||
@@ -1741,4 +1742,103 @@ describe('editorProjectClient', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits a stable perfect-pixel source without unsafe POST retries', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
imageSrc: '/generated-images/editor/perfect-pixel.png',
|
||||
objectKey: 'generated-images/editor/perfect-pixel.png',
|
||||
assetObjectId: 'asset-object-perfect-pixel',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
sourceType: 'generated',
|
||||
taskId: 'perfect-pixel-1',
|
||||
elapsedMs: 321,
|
||||
provider: 'Genarrative',
|
||||
resource: { resourceId: 'resource-perfect-pixel' },
|
||||
asset: { assetId: 'asset-perfect-pixel' },
|
||||
project: null,
|
||||
});
|
||||
|
||||
const result = await snapEditorImageToPixelArt({
|
||||
sourceImageSrc: 'generated-images/editor/source.png',
|
||||
projectId: 'editor-project-1',
|
||||
sourceResourceId: 'resource-source',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '红发骑士' }],
|
||||
references: [],
|
||||
},
|
||||
assetFolderId: 'project',
|
||||
assetLabel: '源图 · 完美像素',
|
||||
canvasCompletion: {
|
||||
dialogId: 'generation-dialog-perfect-pixel',
|
||||
title: '源图 · 完美像素',
|
||||
placeholder: {
|
||||
x: 472,
|
||||
y: 140,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.taskId).toBe('perfect-pixel-1');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/images/pixel-art-snaps',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceImageSrc: 'generated-images/editor/source.png',
|
||||
projectId: 'editor-project-1',
|
||||
sourceResourceId: 'resource-source',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '红发骑士' }],
|
||||
references: [],
|
||||
},
|
||||
assetFolderId: 'project',
|
||||
assetLabel: '源图 · 完美像素',
|
||||
canvasCompletion: {
|
||||
dialogId: 'generation-dialog-perfect-pixel',
|
||||
title: '源图 · 完美像素',
|
||||
placeholder: {
|
||||
x: 472,
|
||||
y: 140,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
'完美像素处理失败',
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects inline perfect-pixel media before sending the request', async () => {
|
||||
await expect(
|
||||
snapEditorImageToPixelArt({
|
||||
sourceImageSrc: 'data:image/png;base64,source',
|
||||
projectId: 'editor-project-1',
|
||||
canvasCompletion: {
|
||||
dialogId: 'generation-dialog-perfect-pixel',
|
||||
title: '源图 · 完美像素',
|
||||
placeholder: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('待完美像素处理图片必须先上传 OSS');
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const EDITOR_PROJECT_RESOURCE_API_BASE = '/api/editor/project-resources';
|
||||
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
|
||||
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
|
||||
const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals';
|
||||
const EDITOR_PIXEL_ART_SNAP_API = '/api/editor/images/pixel-art-snaps';
|
||||
const EDITOR_ICON_SPRITESHEET_GENERATION_API =
|
||||
'/api/editor/icon-spritesheets/generations';
|
||||
const EDITOR_ICON_SPRITESHEET_SLICE_API =
|
||||
@@ -294,6 +295,19 @@ export type EditorBackgroundRemovalInput = {
|
||||
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
|
||||
};
|
||||
|
||||
export type EditorPixelArtSnapInput = {
|
||||
sourceImageSrc: string;
|
||||
projectId: string;
|
||||
sourceResourceId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
assetFolderId?: string | null;
|
||||
assetLabel?: string | null;
|
||||
canvasCompletion: EditorCanvasGenerationCompletionInput & {
|
||||
dialogId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type EditorImageGenerationResult = {
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
@@ -317,6 +331,21 @@ export type EditorBackgroundRemovalResult = {
|
||||
queueState: ExternalGenerationJobStatusRecord;
|
||||
};
|
||||
|
||||
export type EditorPixelArtSnapResult = {
|
||||
imageSrc: string;
|
||||
objectKey: string;
|
||||
assetObjectId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: 'generated';
|
||||
taskId: string;
|
||||
elapsedMs: number;
|
||||
provider: 'Genarrative';
|
||||
resource: EditorProjectResourceSnapshot;
|
||||
asset: EditorAssetSnapshot;
|
||||
project: EditorProjectSnapshot | null;
|
||||
};
|
||||
|
||||
export type EditorIconSpritesheetIconResult = {
|
||||
name: string;
|
||||
imageSrc: string;
|
||||
@@ -659,6 +688,7 @@ type EditorShowcaseAssetResponse = {
|
||||
|
||||
type EditorImageGenerationResponse = EditorImageGenerationResult;
|
||||
type EditorBackgroundRemovalResponse = EditorBackgroundRemovalResult;
|
||||
type EditorPixelArtSnapResponse = EditorPixelArtSnapResult;
|
||||
type EditorIconSpritesheetGenerationResponse =
|
||||
EditorIconSpritesheetGenerationResult;
|
||||
type EditorVideoGenerationResponse = EditorVideoGenerationResult;
|
||||
@@ -1114,6 +1144,33 @@ export async function removeEditorImageBackground(
|
||||
);
|
||||
}
|
||||
|
||||
export async function snapEditorImageToPixelArt(
|
||||
input: EditorPixelArtSnapInput,
|
||||
) {
|
||||
assertStableEditorMediaReference(input.sourceImageSrc, '待完美像素处理图片');
|
||||
return requestJson<EditorPixelArtSnapResponse>(
|
||||
EDITOR_PIXEL_ART_SNAP_API,
|
||||
jsonRequest('POST', {
|
||||
sourceImageSrc: input.sourceImageSrc,
|
||||
projectId: input.projectId,
|
||||
...(input.sourceResourceId
|
||||
? { sourceResourceId: input.sourceResourceId }
|
||||
: {}),
|
||||
...(input.assetKind ? { assetKind: input.assetKind } : {}),
|
||||
...(input.generationInputs
|
||||
? { generationInputs: input.generationInputs }
|
||||
: {}),
|
||||
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
|
||||
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
|
||||
canvasCompletion: input.canvasCompletion,
|
||||
}),
|
||||
'完美像素处理失败',
|
||||
{
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateEditorCharacterAnimation(
|
||||
input: EditorCharacterAnimationGenerationInput,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user