diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1b8337812..fcaf726ad 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4589,7 +4589,7 @@ - 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 / JPEG / WebP,拒绝 GIF、APNG、动画 WebP 和非静态素材。strict 完全复用生成风格的 legacy profile、峰值估算、单轴步长补全、walker、采样与编码;唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,strict 与 legacy 行为及输出必须一致。读取、解码、输入校验、并发排队、像素规整或 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 因占位删除未落画布时不写该历史。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index e3325f508..c1cd39be8 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -45,7 +45,7 @@ - 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。 - 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、并发许可、输入尺寸上限和 30 秒 CPU 总预算。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。 - 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前项目布局,最后把承载稳定候选引用的 `sourceImageSrc`、必填 `projectId / canvasCompletion`(其中 `canvasCompletion.dialogId` 必须非空)以及可选 `sourceResourceId / assetFolderId / assetLabel` 一次提交。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片必须先上传,正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。 -- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝;必须检测到双轴一致且达到置信门槛的既有逻辑像素网格,普通照片或未识别网格不能套用生成风格中的 `min(width,height)/64` 兜底像素化。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。 +- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。 - strict 的零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败。进入 OSS / SpacetimeDB 持久化后沿用现有非事务顺序,后段 resource / asset / completion 失败可能保留此前已确认对象或记录;客户端不做自动重放或删除补偿,而是按 `taskId / objectKey / resourceId` 重新读取项目和素材快照。 - `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。结果未知时先重新读取项目 / 素材快照,由用户显式决定是否再次执行。处理成功后若对应 generation dialog 的删除已先持久化,后端沿用现有 completion 语义跳过画布插入,不得复活占位或结果图层;回包时本地占位已删除则前端不应用完成快照,已经成功持久化的 project resource / 账号素材可以保留。现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8bec4f3ba..0349cf182 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -248,7 +248,7 @@ npm run check:server-rs-ddd ## 外部服务与资产 -- 已有图片完美像素化:登录态 `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 权威项目 / 素材快照。 +- 已有图片完美像素化:登录态 `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 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `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` 当成上游业务错误。 diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md index 9a4bafa93..78b29f69d 100644 --- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -65,7 +65,7 @@ worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存 `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 / JPEG / WebP 编码门禁、解码、输入限制、legacy 网格步长估算、并发排队、规整和 PNG 编码全部发生在持久化前。strict 与生成风格使用同一 profile、峰值估算、单轴步长补全、walker、采样和编码;仅在横纵两轴都未检测到步长、legacy 即将进入统一网格兜底时拒绝,任一轴已检测到步长时行为和输出完全一致。任一步失败、超时或不适用时不执行最终 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` 重新读取权威项目和素材快照后显式收口。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 3ef06ab7f..719d649b0 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -10303,7 +10303,7 @@ mod tests { } #[tokio::test] - async fn pixel_art_strict_rejects_undetected_grids_but_best_effort_keeps_legacy_fallback() { + async fn pixel_art_strict_rejects_only_when_both_legacy_axes_are_undetected() { let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( 128, 128, @@ -10321,7 +10321,7 @@ mod tests { let strict_error = snap_editor_pixel_art_strict(Arc::new(source.clone()), None) .await - .expect_err("explicit action should reject an undetected pixel grid"); + .expect_err("strict action should reject the legacy uniform-grid fallback"); let (legacy_output, legacy_error) = snap_editor_pixel_art_or_original(source.clone(), None).await; diff --git a/server-rs/crates/platform-image/src/pixel_art_snapper.rs b/server-rs/crates/platform-image/src/pixel_art_snapper.rs index b7d388465..499cb0325 100644 --- a/server-rs/crates/platform-image/src/pixel_art_snapper.rs +++ b/server-rs/crates/platform-image/src/pixel_art_snapper.rs @@ -38,14 +38,6 @@ const WALKER_STRENGTH_THRESHOLD: f64 = 0.5; const MIN_CUTS_PER_AXIS: usize = 4; const FALLBACK_TARGET_SEGMENTS: usize = 64; const MAX_STEP_RATIO: f64 = 1.8; -const STRICT_GRID_MIN_PEAKS_PER_AXIS: usize = 4; -const STRICT_GRID_MAX_AXIS_STEP_RATIO: f64 = 1.25; -const STRICT_GRID_MAX_INTERVAL_RESIDUAL: f64 = 0.2; -const STRICT_GRID_MIN_INTERVAL_HIT_RATIO: f64 = 0.7; -const STRICT_GRID_MIN_PHASE_COHERENCE: f64 = 0.65; -const STRICT_GRID_MIN_STEP: f64 = 2.0; -const STRICT_GRID_MAX_STEP_SEARCH: f64 = 512.0; -const STRICT_GRID_STEP_SEARCH_INCREMENT: f64 = 0.25; // 0.375 expressed as an exact rational avoids a floating-point boundary // ambiguity when coverage lands exactly on the configured threshold. @@ -171,12 +163,11 @@ pub fn snap_pixel_art( snap_pixel_art_with_deadline(grid_source, rgba_source, None) } -/// Snap an image only when the analysis detects an existing logical-pixel grid. +/// Snap an image unless legacy analysis detects no grid step on either axis. /// /// Unlike [`snap_pixel_art`], this entry does not synthesize a uniform -/// min-dimension/64 grid when neither axis contains a detectable step. It is -/// intended for explicit "perfect pixel" actions where ordinary raster images -/// must fail instead of being turned into a pixelation filter. +/// min-dimension/64 grid when neither axis contains a detectable step. Every +/// other detection, walking, sampling, and encoding behavior is identical. pub fn snap_pixel_art_strict( grid_source: &DownloadedImage, rgba_source: &DownloadedImage, @@ -195,7 +186,7 @@ pub fn snap_pixel_art_with_deadline( rgba_source: &DownloadedImage, deadline: Option, ) -> Result { - snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, true) + snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, false) } /// Deadline-aware variant of [`snap_pixel_art_strict`]. @@ -204,14 +195,14 @@ pub fn snap_pixel_art_strict_with_deadline( rgba_source: &DownloadedImage, deadline: Option, ) -> Result { - snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, false) + snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, true) } fn snap_pixel_art_with_grid_policy( grid_source: &DownloadedImage, rgba_source: &DownloadedImage, deadline: Option, - allow_undetected_grid_fallback: bool, + reject_uniform_grid_fallback: bool, ) -> Result { let deadline = DeadlineGuard::new(deadline); deadline.check("输入解码")?; @@ -235,39 +226,21 @@ fn snap_pixel_art_with_grid_policy( let config = SnapConfig::PRODUCTION; let quantized_grid = quantize_for_analysis(grid_image, config, deadline)?; - let (profile_x, profile_y) = if allow_undetected_grid_fallback { - compute_profiles(&quantized_grid, deadline)? - } else { - compute_strict_boundary_profiles(&quantized_grid, deadline)? - }; - let plateau_aware = !allow_undetected_grid_fallback; - let estimated_x = estimate_step_size(&profile_x, config, plateau_aware); - let estimated_y = estimate_step_size(&profile_y, config, plateau_aware); - if !allow_undetected_grid_fallback { - validate_strict_pixel_grid(estimated_x.as_ref(), estimated_y.as_ref())?; + let (profile_x, profile_y) = compute_profiles(&quantized_grid, deadline)?; + let estimated_x = estimate_step_size(&profile_x, config); + let estimated_y = estimate_step_size(&profile_y, config); + if reject_uniform_grid_fallback && estimated_x.is_none() && estimated_y.is_none() { + return Err(PixelArtSnapError::GridNotDetected); } let (step_x, step_y) = resolve_step_sizes( - estimated_x.as_ref().map(|estimate| estimate.step), - estimated_y.as_ref().map(|estimate| estimate.step), + estimated_x, + estimated_y, rgba_image.width(), rgba_image.height(), config, ); - let prefer_target_on_ties = !allow_undetected_grid_fallback; - let raw_columns = walk( - &profile_x, - step_x, - rgba_image.width() as usize, - config, - prefer_target_on_ties, - )?; - let raw_rows = walk( - &profile_y, - step_y, - rgba_image.height() as usize, - config, - prefer_target_on_ties, - )?; + let raw_columns = walk(&profile_x, step_x, rgba_image.width() as usize, config)?; + let raw_rows = walk(&profile_y, step_y, rgba_image.height() as usize, config)?; let (columns, rows) = stabilize_both_axes( &profile_x, &profile_y, @@ -618,42 +591,6 @@ fn compute_profiles( Ok((profile_x, profile_y)) } -fn compute_strict_boundary_profiles( - source: &RgbaImage, - deadline: DeadlineGuard, -) -> Result<(Vec, Vec), PixelArtSnapError> { - deadline.check("严格网格边缘分析")?; - let (width, height) = source.dimensions(); - if width < 3 || height < 3 { - return Err(PixelArtSnapError::Processing( - "网格分析要求图片至少为 3×3".to_string(), - )); - } - - let width = width as usize; - let height = height as usize; - let pixels = source.as_raw(); - let mut profile_x = vec![0.0f64; width]; - let mut profile_y = vec![0.0f64; height]; - for y in 0..height { - deadline.check("严格网格横向边缘分析")?; - for (x, value) in profile_x.iter_mut().enumerate().take(width).skip(1) { - let left = rgba_luminance(pixels, y * width + x - 1); - let current = rgba_luminance(pixels, y * width + x); - *value += (current - left).abs(); - } - } - for (y, value) in profile_y.iter_mut().enumerate().take(height).skip(1) { - deadline.check("严格网格纵向边缘分析")?; - for x in 0..width { - let previous = rgba_luminance(pixels, (y - 1) * width + x); - let current = rgba_luminance(pixels, y * width + x); - *value += (current - previous).abs(); - } - } - Ok((profile_x, profile_y)) -} - fn rgba_luminance(pixels: &[u8], pixel_index: usize) -> f64 { let offset = pixel_index * 4; if pixels[offset + 3] == 0 { @@ -665,45 +602,26 @@ fn rgba_luminance(pixels: &[u8], pixel_index: usize) -> f64 { } } -#[derive(Debug)] -struct StepEstimate { - step: f64, - clean_peaks: Vec, -} - -fn estimate_step_size( - profile: &[f64], - config: SnapConfig, - plateau_aware: bool, -) -> Option { +fn estimate_step_size(profile: &[f64], config: SnapConfig) -> Option { let maximum = profile.iter().copied().fold(0.0f64, f64::max); if maximum <= 0.0 { return None; } let threshold = maximum * config.peak_threshold_multiplier; - let peaks = if plateau_aware { - find_profile_peaks_with_plateaus(profile, threshold) - } else { - (1..profile.len().saturating_sub(1)) - .filter(|&index| { - profile[index] > threshold - && profile[index] > profile[index - 1] - && profile[index] > profile[index + 1] - }) - .collect::>() - }; + let peaks = (1..profile.len().saturating_sub(1)) + .filter(|&index| { + profile[index] > threshold + && profile[index] > profile[index - 1] + && profile[index] > profile[index + 1] + }) + .collect::>(); if peaks.len() < 2 { return None; } - let peak_distance_filter = if plateau_aware { - 1 - } else { - config.peak_distance_filter - }; let mut clean_peaks = vec![peaks[0]]; for peak in peaks.into_iter().skip(1) { - if peak - clean_peaks[clean_peaks.len() - 1] > peak_distance_filter - 1 { + if peak - clean_peaks[clean_peaks.len() - 1] > config.peak_distance_filter - 1 { clean_peaks.push(peak); } } @@ -721,118 +639,10 @@ fn estimate_step_size( let lower_index = percentile_position.floor() as usize; let upper_index = percentile_position.ceil() as usize; let interpolation = percentile_position - lower_index as f64; - let step = if plateau_aware { - resolve_strict_grid_step(clean_peaks.as_slice())? - } else { + Some( differences[lower_index] - + (differences[upper_index] - differences[lower_index]) * interpolation - }; - Some(StepEstimate { step, clean_peaks }) -} - -fn find_profile_peaks_with_plateaus(profile: &[f64], threshold: f64) -> Vec { - let mut peaks = Vec::new(); - let mut index = 1usize; - while index < profile.len().saturating_sub(1) { - let value = profile[index]; - if value <= threshold { - index += 1; - continue; - } - let start = index; - let mut end = index; - while end + 1 < profile.len().saturating_sub(1) - && (profile[end + 1] - value).abs() <= f64::EPSILON * value.abs().max(1.0) - { - end += 1; - } - if value > profile[start - 1] && value > profile[end + 1] { - peaks.push(start + (end - start).div_ceil(2)); - } - index = end + 1; - } - peaks -} - -fn validate_strict_pixel_grid( - estimated_x: Option<&StepEstimate>, - estimated_y: Option<&StepEstimate>, -) -> Result<(), PixelArtSnapError> { - let (Some(estimated_x), Some(estimated_y)) = (estimated_x, estimated_y) else { - return Err(PixelArtSnapError::GridNotDetected); - }; - if !strict_grid_axis_is_confident(estimated_x) || !strict_grid_axis_is_confident(estimated_y) { - return Err(PixelArtSnapError::GridNotDetected); - } - let axis_step_ratio = - estimated_x.step.max(estimated_y.step) / estimated_x.step.min(estimated_y.step); - if !axis_step_ratio.is_finite() || axis_step_ratio > STRICT_GRID_MAX_AXIS_STEP_RATIO { - return Err(PixelArtSnapError::GridNotDetected); - } - Ok(()) -} - -fn resolve_strict_grid_step(clean_peaks: &[usize]) -> Option { - if clean_peaks.len() < STRICT_GRID_MIN_PEAKS_PER_AXIS { - return None; - } - let max_interval = clean_peaks - .windows(2) - .map(|pair| (pair[1] - pair[0]) as f64) - .fold(0.0f64, f64::max) - .min(STRICT_GRID_MAX_STEP_SEARCH); - let mut candidate = (max_interval / STRICT_GRID_STEP_SEARCH_INCREMENT).ceil() - * STRICT_GRID_STEP_SEARCH_INCREMENT; - while candidate >= STRICT_GRID_MIN_STEP { - let (interval_hit_ratio, phase_coherence) = - strict_grid_axis_confidence(clean_peaks, candidate); - if interval_hit_ratio >= STRICT_GRID_MIN_INTERVAL_HIT_RATIO - && phase_coherence >= STRICT_GRID_MIN_PHASE_COHERENCE - { - return Some(candidate); - } - candidate -= STRICT_GRID_STEP_SEARCH_INCREMENT; - } - None -} - -fn strict_grid_axis_is_confident(estimate: &StepEstimate) -> bool { - if !estimate.step.is_finite() - || estimate.step < 1.0 - || estimate.clean_peaks.len() < STRICT_GRID_MIN_PEAKS_PER_AXIS - { - return false; - } - - let (interval_hit_ratio, phase_coherence) = - strict_grid_axis_confidence(estimate.clean_peaks.as_slice(), estimate.step); - interval_hit_ratio >= STRICT_GRID_MIN_INTERVAL_HIT_RATIO - && phase_coherence >= STRICT_GRID_MIN_PHASE_COHERENCE -} - -fn strict_grid_axis_confidence(clean_peaks: &[usize], step: f64) -> (f64, f64) { - let interval_count = clean_peaks.len().saturating_sub(1); - if interval_count == 0 || !step.is_finite() || step < STRICT_GRID_MIN_STEP { - return (0.0, 0.0); - } - let interval_hits = clean_peaks - .windows(2) - .filter(|pair| { - let normalized = (pair[1] - pair[0]) as f64 / step; - (normalized - normalized.round()).abs() <= STRICT_GRID_MAX_INTERVAL_RESIDUAL - }) - .count(); - let interval_hit_ratio = interval_hits as f64 / interval_count as f64; - - let (phase_cosine, phase_sine) = - clean_peaks - .iter() - .fold((0.0f64, 0.0f64), |(cosine, sine), peak| { - let phase = std::f64::consts::TAU * *peak as f64 / step; - (cosine + phase.cos(), sine + phase.sin()) - }); - let phase_coherence = phase_cosine.hypot(phase_sine) / clean_peaks.len() as f64; - (interval_hit_ratio, phase_coherence) + + (differences[upper_index] - differences[lower_index]) * interpolation, + ) } fn resolve_step_sizes( @@ -867,7 +677,6 @@ fn walk( step_size: f64, limit: usize, config: SnapConfig, - prefer_target_on_ties: bool, ) -> Result, PixelArtSnapError> { if profile.is_empty() { return Err(PixelArtSnapError::Processing( @@ -905,16 +714,7 @@ fn walk( let mut best_index = start; let mut best_value = -1.0f64; for (index, value) in profile.iter().enumerate().take(end).skip(start) { - let tied = (*value - best_value).abs() - <= f64::EPSILON * value.abs().max(best_value.abs()).max(1.0); - let closer_to_target = - (index as f64 - target).abs() < (best_index as f64 - target).abs(); - let equally_close_and_later = (index as f64 - target).abs() - == (best_index as f64 - target).abs() - && index > best_index; - if *value > best_value - || (prefer_target_on_ties && tied && (closer_to_target || equally_close_and_later)) - { + if *value > best_value { best_value = *value; best_index = index; } @@ -1338,150 +1138,7 @@ mod tests { } #[test] - fn strict_mode_accepts_a_nearest_upscaled_sprite_grid() { - let palette = [ - Rgba([20, 30, 40, 255]), - Rgba([230, 90, 40, 255]), - Rgba([60, 190, 100, 255]), - Rgba([245, 220, 90, 255]), - ]; - let mut logical = RgbaImage::new(16, 16); - for y in 0..16 { - for x in 0..16 { - let palette_index = ((x * 3 + y * 5 + (x ^ y)) % palette.len() as u32) as usize; - logical.put_pixel(x, y, palette[palette_index]); - } - } - let sprite = imageops::resize(&logical, 128, 128, FilterType::Nearest); - let source = downloaded_png(sprite.clone()); - - let output = snap_pixel_art_strict(&source, &source) - .expect("nearest-upscaled sprite should expose a confident grid"); - - assert_eq!(decode_output(&output), sprite); - } - - #[test] - fn strict_mode_preserves_dense_nearest_sprites_at_small_scales() { - let palette = [ - Rgba([15, 25, 35, 255]), - Rgba([220, 70, 45, 255]), - Rgba([55, 180, 100, 255]), - Rgba([245, 215, 85, 255]), - ]; - let mut logical = RgbaImage::new(16, 16); - for y in 0..16 { - for x in 0..16 { - logical.put_pixel(x, y, palette[((x + y * 2) % 4) as usize]); - } - } - - for scale in [2, 3, 8] { - let sprite = imageops::resize( - &logical, - logical.width() * scale, - logical.height() * scale, - FilterType::Nearest, - ); - let source = downloaded_png(sprite.clone()); - let output = snap_pixel_art_strict(&source, &source) - .expect("dense nearest sprite should retain its detected logical grid"); - - assert_eq!( - decode_output(&output), - sprite, - "{scale}× nearest sprite should remain pixel-exact" - ); - } - } - - #[test] - fn strict_mode_accepts_a_slightly_jittered_sprite_grid() { - let palette = [ - Rgba([30, 40, 55, 255]), - Rgba([220, 75, 45, 255]), - Rgba([65, 175, 110, 255]), - Rgba([240, 215, 80, 255]), - ]; - let mut boundaries = (0..=16) - .map(|index| { - if index == 0 { - 0 - } else if index == 16 { - 128 - } else { - let jitter = match index % 3 { - 0 => -1, - 1 => 0, - _ => 1, - }; - (index * 8) as i32 + jitter - } - }) - .map(|value| value as u32) - .collect::>(); - boundaries.sort_unstable(); - let mut sprite = RgbaImage::new(128, 128); - for cell_y in 0..16 { - for cell_x in 0..16 { - let palette_index = - ((cell_x * 5 + cell_y * 3 + (cell_x ^ cell_y)) % palette.len()) as usize; - for y in boundaries[cell_y]..boundaries[cell_y + 1] { - for x in boundaries[cell_x]..boundaries[cell_x + 1] { - sprite.put_pixel(x, y, palette[palette_index]); - } - } - } - } - let source = downloaded_png(sprite); - - let output = snap_pixel_art_strict(&source, &source) - .expect("small grid-boundary jitter should remain a confident pixel grid"); - - assert_eq!(decode_output(&output).dimensions(), (128, 128)); - } - - #[test] - fn strict_mode_rejects_smooth_and_photo_like_inputs() { - let mut gradient = RgbaImage::new(128, 128); - for y in 0..128 { - for x in 0..128 { - gradient.put_pixel( - x, - y, - Rgba([ - (x * 255 / 127) as u8, - (y * 255 / 127) as u8, - ((x + y) * 255 / 254) as u8, - 255, - ]), - ); - } - } - - let mut seed = 0x6d2b_79f5u32; - let mut noise = RgbaImage::new(128, 128); - for pixel in noise.pixels_mut() { - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - let red = (seed >> 24) as u8; - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - let green = (seed >> 24) as u8; - seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - let blue = (seed >> 24) as u8; - *pixel = Rgba([red, green, blue, 255]); - } - - for source in [downloaded_png(gradient), downloaded_png(noise)] { - snap_pixel_art(&source, &source) - .expect("legacy generation style should keep its existing fallback behavior"); - let error = snap_pixel_art_strict(&source, &source) - .expect_err("non-grid raster should not become a pixelation filter"); - assert!(matches!(error, PixelArtSnapError::GridNotDetected)); - } - } - - #[test] - fn legacy_mode_keeps_uniform_grid_fallback() { + fn strict_mode_rejects_only_when_legacy_would_use_uniform_fallback() { let source = downloaded_png(RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255]))); let legacy = snap_pixel_art(&source, &source) @@ -1493,6 +1150,45 @@ mod tests { assert!(matches!(strict, PixelArtSnapError::GridNotDetected)); } + #[test] + fn strict_mode_matches_legacy_when_either_axis_has_a_detected_step() { + for (vertical_lines, horizontal_lines, expected_axes) in [ + (true, false, (true, false)), + (false, true, (false, true)), + (true, true, (true, true)), + ] { + let mut image = RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255])); + for y in 0..128 { + for x in 0..128 { + if (vertical_lines && x % 8 == 0) || (horizontal_lines && y % 8 == 0) { + image.put_pixel(x, y, Rgba([240, 220, 80, 255])); + } + } + } + let config = SnapConfig::PRODUCTION; + let quantized = quantize_for_analysis(&image, config, DeadlineGuard::new(None)) + .expect("test image should quantize"); + let (profile_x, profile_y) = compute_profiles(&quantized, DeadlineGuard::new(None)) + .expect("test profiles should compute"); + assert_eq!( + ( + estimate_step_size(&profile_x, config).is_some(), + estimate_step_size(&profile_y, config).is_some(), + ), + expected_axes, + ); + + let source = downloaded_png(image); + let legacy = snap_pixel_art(&source, &source) + .expect("legacy processing should succeed with a detected step"); + let strict = snap_pixel_art_strict(&source, &source) + .expect("strict processing should reuse the detected legacy step"); + assert_eq!(strict.bytes, legacy.bytes); + assert_eq!(strict.mime_type, legacy.mime_type); + assert_eq!(strict.extension, legacy.extension); + } + } + #[test] fn output_keeps_physical_size_and_uses_nearest_blocks() { let grid_image = RgbaImage::from_pixel(128, 128, Rgba([0, 0, 0, 255]));