diff --git a/deploy/nginx/snippets/genarrative-maintenance.conf b/deploy/nginx/snippets/genarrative-maintenance.conf index bd4f16e77..81da0cff5 100644 --- a/deploy/nginx/snippets/genarrative-maintenance.conf +++ b/deploy/nginx/snippets/genarrative-maintenance.conf @@ -9,6 +9,17 @@ if ($genarrative_internal_client) { set $genarrative_maintenance 0; } +# 维护页自身依赖的品牌图片必须在维护期间保持可读;只放行精确文件,避免扩大公网静态面。 +location = /branding/taonier-maintenance-page.png { + root /srv/genarrative/web; + try_files /branding/taonier-maintenance-page.png =404; +} + +location = /branding/taonier-product-ip.png { + root /srv/genarrative/web; + try_files /branding/taonier-product-ip.png =404; +} + location = /maintenance.html { root /var/lib/genarrative/maintenance; try_files /page.html @genarrative_default_maintenance; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8604b6bdc..58d5efdbb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -227,6 +227,14 @@ - 验证方式:`npm run check:database-backup`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`;dev 现场必须完成逐文件 full catalog、重复 full 零 PUT、history dry-run、上传后清理、STDB 重启和按 catalog 隔离恢复 roundtrip。 - 关联:。 +## 2026-07-27 逐文件备份本地元数据采用去重 state 与 gzip 保留 + +- 背景:files v1 本地 state 同时在 `baselineCatalog`、`latestCatalog` 和每个 `historyCatalogs[]` 中嵌入完整文件清单,且每次成功 history 的本地 catalog 与 `--result-file` 再复制同一清单;release 独立 work-dir 已由此累积约 840 MiB JSON,但 OSS-only 恢复实际只依赖 `latest.json`、catalog 引用和 CAS 对象。 +- 决策:OSS catalog/latest schema、对象 key、序列化字节与恢复链保持不变。本地 state 升级为 gzip v2,只保存去重后的 catalog 引用;旧 v1 JSON 可读,并且只在非 dry-run 成功发布、验真 latest、原子写入 v2 后删除。full 增量复用从本地 latest full catalog gzip 缓存读取,缓存缺失时退化为逐对象 OSS HEAD,不影响正确性;缓存长度或 SHA 与 state 不一致时失败关闭。 +- 清理边界:成功运行后只压缩保留 latest full catalog;已验真的本地 history catalog、旧 full catalog和严格文件名匹配的失败/dry-run 遗留 catalog 清理。`--result-file` 只写紧凑引用和计数。任一 state 压缩或本地 metadata 清理失败都发生在 `/stdb` history 源文件删除之前;OSS catalog、latest 和 CAS 对象永不由本地 metadata 清理删除。 +- 兼容与验证:`--restore-files-state` 同时接受 v1 JSON 与 v2 gzip,`--restore-files-latest` 不受本地格式影响。门禁覆盖 v1 迁移、gzip/state/catalog 损坏拒绝、full 增量复用、history catalog 本地清理、紧凑 result、pointer 失败不清源和 OSS-only 恢复。 +- 关联:`scripts/database-backup-to-oss.mjs`、`scripts/check-database-backup-to-oss.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权 - 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。 @@ -4299,7 +4307,7 @@ ## 2026-07-13 临时维护公告改为 release 外运行态覆盖 - 背景:一次性停服公告曾直接提交到 `public/maintenance.html`,后续 Web Build 将它持续打入 `web.tar.gz`,每次 Web Deploy 或再次进入维护都会重新显示已经过期的公告。 -- 决策:`public/maintenance.html` 永久作为无日期、无具体时段的默认维护页,并使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉;生产 Web 打包必须对最终 `web/maintenance.html` 执行临时文案门禁。临时公告通过 `maintenance-on.sh --page-file <公告HTML>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`,Nginx 与 Pingora 优先读取该运行态文件,缺失时回退 Web 制品默认页。 +- 决策:`public/maintenance.html` 永久作为无日期、无具体时段的默认维护页,并使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉;生产 Web 打包必须对最终 `web/maintenance.html` 执行临时文案门禁。临时公告通过 `maintenance-on.sh --page-file <公告HTML>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`,Nginx 与 Pingora 优先读取该运行态文件,缺失时回退 Web 制品默认页。维护期间只精确放行 `/branding/taonier-maintenance-page.png` 与 `/branding/taonier-product-ip.png`,不得扩大到整个品牌或静态资源目录;网关 smoke 必须验证这两个路径仍返回 PNG,同时其它公网页面、API 与后台静态资源继续命中维护门禁。 - 生命周期:新维护窗口未提供 `--page-file` 时清理 marker 外残留公告;同一窗口内 Stdb / API 发布重复调用 `maintenance-on.sh` 时保留已安装公告;`maintenance-off.sh` 同时清理 marker 和公告页。Web Deploy 不再拥有临时公告事实源。 - 影响范围:默认维护页、维护开关脚本、Nginx snippet、Pingora 配置与 smoke、生产 Web 发布包门禁和生产运维文档。 - 验证方式:`npm run check:maintenance-page`、`npm run check:nginx-spa-routes`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 94fe87ed5..cb4dbfd78 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3239,8 +3239,8 @@ - 现象:看到最新 `N.snapshot_dir` 后,把所有起始 offset 小于 `N` 的 `.stdb.log` 删除,或者只把旧日志上传 OSS 就宣称已有完整增量灾备。 - 原因:segment 文件名只表示该段最早事务;起始 offset 小于等于最新 snapshot 的最后一个 segment 可能跨越 snapshot 边界,重启仍需要它。历史归档也不会及时覆盖 control-db、program bytes、最新 snapshot 和 active segment。 -- 处理:latest snapshot 必须是未锁定且存在同 offset `.snapshot_bsatn` 的完整目录,空目录或同名 `.lock` 存在时忽略。每个 replica 独立保留 `max(segment_start <= latest_snapshot)` 及全部后缀,只处理更早 segment 对;旧 snapshot 只保留最新一个。`--storage-format files` 必须先发布完整 full catalog;history 对每个候选文件 CAS 对象、history catalog 和 full catalog 执行 HEAD 长度/SHA 验真,再复算边界与 stat fingerprint,最后发布并验真固定 `latest.json`;pointer 失败时不得推进 state 或删除源文件。不要把在线逐文件 full 扫描当成跨文件一致备份,基线必须来自停库目录或已验证冻结副本。SSH 或工具超时后先检查 work-dir PID lock 与原进程,不要直接并发重跑;不要在 history 模式传 `--stop-service`。定时任务通过 Server-Provision 的显式 profile 和仓库 drop-in 管理,启用前 dry-run 验证 baseline,切回 archive 时同时移除托管与现场遗留 drop-in;不要在 `/etc/systemd/system` 长期保留手写覆盖,release 必须建立和验证自己的 full baseline 与 work-dir,不能直接复用 dev 的本地 state。 -- 验证:dry-run 输出 replica 的 `latestSnapshot`、`boundarySegment` 和候选清单;从另一台机器仅凭 OSS `latest.json` 自动定位 full catalog,创建目录、下载文件并逐项校验长度/SHA,启动隔离 data-dir 验证 `/v1/ping`、snapshot restore、commitlog replay、module launch、代表性 SQL 与 reducer。 +- 处理:latest snapshot 必须是未锁定且存在同 offset `.snapshot_bsatn` 的完整目录,空目录或同名 `.lock` 存在时忽略。每个 replica 独立保留 `max(segment_start <= latest_snapshot)` 及全部后缀,只处理更早 segment 对;旧 snapshot 只保留最新一个。`--storage-format files` 必须先发布完整 full catalog;history 对每个候选文件 CAS 对象、history catalog 和 full catalog 执行 HEAD 长度/SHA 验真,再复算边界与 stat fingerprint,最后发布并验真固定 `latest.json`;pointer 失败时不得推进 state 或删除源文件。不要把在线逐文件 full 扫描当成跨文件一致备份,基线必须来自停库目录或已验证冻结副本。SSH 或工具超时后先检查 work-dir PID lock 与原进程,不要直接并发重跑;不要在 history 模式传 `--stop-service`。定时任务通过 Server-Provision 的显式 profile 和仓库 drop-in 管理,启用前 dry-run 验证 baseline,切回 archive 时同时移除托管与现场遗留 drop-in;不要在 `/etc/systemd/system` 长期保留手写覆盖,release 必须建立和验证自己的 full baseline 与 work-dir,不能直接复用 dev 的本地 state。files 本地 state 只能保存去重后的 catalog 引用并使用 gzip 原子落盘;本地只保留 latest full catalog 压缩缓存,history/旧 full catalog 和紧凑 result 不得再次复制完整清单。metadata 压缩或清理失败必须早于 `/stdb` history 源文件删除。 +- 验证:dry-run 输出 replica 的 `latestSnapshot`、`boundarySegment` 和候选清单;从另一台机器仅凭 OSS `latest.json` 自动定位 full catalog,创建目录、下载文件并逐项校验长度/SHA,启动隔离 data-dir 验证 `/v1/ping`、snapshot restore、commitlog replay、module launch、代表性 SQL 与 reducer。备份门禁还必须覆盖 v1 JSON 到 v2 gzip 迁移、损坏 gzip 不回退、full 增量复用、本地 catalog SHA 校验、history catalog 清理与紧凑 result。 - 关联:`scripts/database-backup-to-oss.mjs`、`scripts/check-database-backup-to-oss.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## Procedure 事务鉴权要兼容 SpacetimeDB 2.6.0 的匿名 TxContext diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 50ab585e2..3bff57d46 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -92,11 +92,11 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成必须把当前 K 档对应的真实像素直接传给 provider,前端占位与该请求尺寸使用同一映射;不得先请求固定 1K 再放大为 2K。普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程先保存带纯色背景源图,随后只以 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连);透明处理成功时生成透明 PNG,最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。角色、图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,只重采样透明图的 alpha 蒙版并应用回 provider 原图的原始分辨率 RGB,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程先按 provider 原始分辨率保存带纯色背景源图,随后只以 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连);透明处理成功时只重采样透明图的 alpha 蒙版并应用回 provider 原图 RGB,最后把透明主结果归一到统一业务像素;最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并尝试拆分。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 -- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸;nanobanana2 路径保留 provider 按比例和清晰度返回的几何尺寸。成功时只上传最终结果,尺寸恢复失败时只上传 provider 原图;无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 +- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都以统一业务目标尺寸尝试归一。只允许缩小和轻微裁切;回图任意一边小于目标或比例偏差过大时保留 provider 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 - `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、provider、taskId、duration、歌词和 `priceMudPoints`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 96f99c19b..6eb03dbc5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -92,6 +92,8 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。 +维护门禁启用时,Nginx 与 Pingora 只精确放行默认维护页依赖的 `/branding/taonier-maintenance-page.png` 和 `/branding/taonier-product-ip.png`;不得放开整个 `/branding/`、`/assets/` 或后台静态目录。公网验收除主站、后台和 API 继续返回维护响应外,还必须确认这两个品牌图片返回 `200 image/png`,避免维护页 HTML 正常但背景图请求被再次改写成 `503`。 + 需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `source_module = editor-canvas`、`job_kind = worker_smoke_unsupported` 的测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,用队列概览 BFF、单 job 状态接口和 worker 日志确认任务从 queued/running 收敛到预期失败态。 本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev` 或 `npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun`,`POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。 @@ -398,6 +400,8 @@ files full 会递归扫描 data-dir,保留空目录、每个普通文件的相 history 的安全边界按每个 replica 独立计算。设最新完整且未锁定的 snapshot offset 为 `S`;数字更大但缺少同 offset `.snapshot_bsatn`、仍存在同名 `.lock` 的目录不能参与边界计算。脚本必须保留起始 offset 小于等于 `S` 的最后一个 commitlog segment,以及它之后的全部 segment;只处理更早的 `.stdb.log` / `.stdb.ofs`,snapshot 只处理最新目录之前的旧目录。files history 会递归展开候选目录,逐对象复用或上传,随后依次验真候选对象、history catalog 与 full baseline catalog,再重新扫描边界和 stat fingerprint,最后覆盖发布并验真 `latest.json`;任何一步失败都不推进 state 或删除源文件。脚本在 work-dir 使用 PID lock 拒绝同库并发上传,SSH 超时后必须先检查原进程,不能直接重跑。 +files 本地续跑 state 使用 `-files-state.json.gz` v2:只保存 full/history catalog 的 object key、长度、SHA 与验真时间,不再重复嵌入每份 catalog 的完整 `files` / `symlinks` 清单。旧 v1 `.json` 仍可读取,并且只在一次非 dry-run 备份的 OSS catalog、`latest.json` 与本地新 state 全部成功后原子迁移为 gzip v2,再删除旧 state;gzip 已存在但损坏时必须失败,不能回退到可能过期的旧 JSON。成功运行后,本地只压缩保留 latest full catalog 作为 full 增量复用缓存,已上传并验真的 history catalog、旧 full catalog、失败或 dry-run 遗留 catalog 自动清理;OSS catalog、CAS 对象与 `latest.json` 不删除、不改 schema。`--result-file` 只写 catalog 引用和计数,不再复制完整文件清单。metadata 压缩或清理失败时不得继续删除 `/stdb` history 源文件。 + ```bash # 从停库目录或已验证冻结副本建立逐文件完整基线;相同 work-dir 重跑只传变化内容。 node -- scripts/database-backup-to-oss.mjs \ @@ -431,7 +435,7 @@ node -- scripts/database-backup-to-oss.mjs \ dev 出口过慢时,可以把冻结基线经内网 rsync 到 release 独立 staging,再由 release 上传 dev bucket。staging 必须位于 `/var/lib/genarrative/dev-database-backup-staging/` 一类隔离目录,命令显式传 staging `--data-dir`、独立 `--work-dir`、dev `--bucket`,且不得传 `--stop-service`;禁止指向或修改 release `/stdb`。中转 key 只为本次传输临时授权,结束后从 dev 私钥和 release `authorized_keys` 同时移除。上传完成后把整个 files work-dir/state 回传 dev,history 才能延续同一 baseline catalog。 -完整恢复默认从 OSS 固定 `latest.json` 读取最新 full catalog:先创建 `directories`,再把每个 `files[].objectKey` 下载到 `/` 并逐项核对 `sizeBytes` / `sha256`;history catalog 用于证明已清理历史仍有 OSS 对象,不需要把已被 full baseline 覆盖的旧文件叠回当前恢复目录。本地 state 仍可作为兼容入口,但不再是异机恢复的前置条件。随后用隔离 data-dir 启动同版本 standalone,验证 `/v1/ping`、日志中的 snapshot restore / commitlog replay / module launch、代表性 SQL 和 reducer。dev 已完成这轮 OSS-only 异机恢复与重启演练;当前 live release 仍保持 `archive-full`,需要切换时先为 release 建立并恢复验证独立 full baseline,再通过 Server-Provision 显式选择 `files-history`,无需修改代码或解除额外硬门禁。 +完整恢复默认从 OSS 固定 `latest.json` 读取最新 full catalog:先创建 `directories`,再把每个 `files[].objectKey` 下载到 `/` 并逐项核对 `sizeBytes` / `sha256`;history catalog 用于证明已清理历史仍有 OSS 对象,不需要把已被 full baseline 覆盖的旧文件叠回当前恢复目录。本地 state 仍可作为兼容入口,并同时支持旧 v1 JSON 与 v2 gzip,但不再是异机恢复的前置条件。随后用隔离 data-dir 启动同版本 standalone,验证 `/v1/ping`、日志中的 snapshot restore / commitlog replay / module launch、代表性 SQL 和 reducer。dev 已完成这轮 OSS-only 异机恢复与重启演练;release 已使用独立 `/var/lib/genarrative/database-backups/release-files` full baseline 和 `files-history` profile,现场最终 `ExecStart`、timer 状态与最近备份结果仍须在变更时重新核对。 ```bash node -- scripts/database-backup-to-oss.mjs \ diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 26bc34cb6..a3dfcd624 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -97,7 +97,7 @@ - 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。 - 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,角色动作使用角色动作图标和动作角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。 - 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,快速编辑用闪光图标,不能默认全部回退为图片图标。 -- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。 +- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 的统一业务像素矩阵计算;所有共用规格都不因模型不同改变画布占地,例如 `nanobanana2` 与 `gpt-image-2` 的 `16:9·2K` 占位和普通最终结果都为 `2048 x 1152`。provider 请求尺寸可因接口合法值不同,但不得泄漏为正常完成的画布尺寸;回图更大时只允许缩小和轻微裁切,回图低于目标时保留实际像素并告警,禁止放大伪造所选档位;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。 - 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。 - 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。 - 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。 @@ -124,6 +124,7 @@ - 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色重绘遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 - 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。 - 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,与 `sliceWarning` 互斥;`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。 +- 画布顶部的生成 / 参考图选择 warning toast 保留手动关闭按钮,并在每次 warning 事件进入显示态后 `3` 秒自动消失,避免一次错误提示持续遮挡画布。同样文案在未消失时再次触发也必须重新计时,不能沿用上一次事件的剩余时间。 - 普通图片、图片修改、规范、角色、图标、UI 设计、宣发素材、视频、音效、背景音乐和角色动作生成面板不展示“资源名称”输入,默认继续使用现有“类型 + 编号”名称;提示词输入保持统一可见边框。状态与请求契约仍兼容可选 `assetLabel`,内部调用或历史状态携带名称时最多 80 个字符并在提交时 trim,最终解析出的同一个名称必须同时写入画布图层、`editor_project_resource`、`editor_asset` 和 `canvasCompletion.title`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。 - 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。 - 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。 diff --git a/scripts/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs index 809d54266..9bae19c58 100644 --- a/scripts/check-database-backup-to-oss.mjs +++ b/scripts/check-database-backup-to-oss.mjs @@ -6,6 +6,7 @@ import {chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, import {tmpdir} from 'node:os'; import path from 'node:path'; import {Readable} from 'node:stream'; +import {gunzipSync, gzipSync} from 'node:zlib'; import { buildAuthorization, @@ -65,12 +66,17 @@ async function main() { await assertHistorySuccessfulUploadCleansAndIsIdempotent(); await assertHistoryResumeReverifiesArchiveAndManifest(); await assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload(); + await assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs(); await assertDirectFilesConcurrencyIsBounded(); await assertDirectHistoryPublishesCatalogBeforeCleanup(); await assertDirectHistoryWithoutCandidatesPublishesLatest(); await assertDirectFilesRestoreDownloadsCatalogAndObjects(); } +function readGzipJson(filePath) { + return JSON.parse(gunzipSync(readFileSync(filePath)).toString('utf8')); +} + function createDirectOssHarness() { const objects = new Map(); const uploadedKeys = []; @@ -217,6 +223,13 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload( const first = await runDirectFilesBackup(options); assertEqual(first.uploadedCount, 2, '首次 files full 应上传全部普通文件。'); assertTrue(!Object.hasOwn(first.catalog, 'dataDir'), '远端 files catalog 不得绑定 staging 主机的绝对 data-dir。'); + assertTrue(first.statePath.endsWith('.json.gz'), 'files state 必须使用 gzip 压缩文件。'); + const compactState = readGzipJson(first.statePath); + assertEqual(compactState.schemaVersion, 2, 'files state 必须使用去重后的 v2 契约。'); + assertTrue(!Object.hasOwn(compactState.baselineCatalog, 'files'), 'baseline ref 不得重复嵌入 files。'); + assertTrue(!Object.hasOwn(compactState.latestCatalog, 'files'), 'latest ref 不得重复嵌入 files。'); + assertTrue(!existsSync(first.catalogPath), '本地 full catalog 原始 JSON 应在成功后压缩。'); + assertTrue(existsSync(`${first.catalogPath}.gz`), '本地应保留压缩后的 latest full catalog 供增量复用。'); const latestObjectKey = 'database-backups/test-db/latest.json'; const latest = JSON.parse(harness.objects.get(latestObjectKey).body.toString('utf8')); assertEqual(latest.latestFullCatalog.catalogId, first.catalogId, 'latest pointer 必须指向已验真的最新 full catalog。'); @@ -231,11 +244,77 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload( '相同 catalog 重跑不得重复 PUT 文件或 catalog,但应覆盖验真 latest pointer。', ); + rmSync(`${first.catalogPath}.gz`, {force: false}); writeFileSync(path.join(dataDir, 'control-db'), 'control changed'); writeFileSync(path.join(dataDir, 'new-program.bin'), 'new program'); const incremental = await runDirectFilesBackup(options); assertEqual(incremental.uploadedCount, 2, '增量 files full 只应上传新增和变化文件。'); - assertEqual(incremental.reusedCount, 1, '增量 files full 应复用未变化 snapshot 文件。'); + assertEqual(incremental.reusedCount, 1, '本地 full catalog 缓存缺失时仍应通过 OSS HEAD 复用未变化文件。'); +} + +async function assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs() { + const root = path.join(tmpRoot, 'direct-files-state-migration'); + const dataDir = path.join(root, 'stdb'); + const workDir = path.join(root, 'work'); + mkdirSync(dataDir, {recursive: true}); + writeFileSync(path.join(dataDir, 'control-db'), 'control'); + const harness = createDirectOssHarness(); + const options = { + mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', + uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + }; + const first = await runDirectFilesBackup(options); + const compactState = readGzipJson(first.statePath); + const catalog = JSON.parse(gunzipSync(readFileSync(`${first.catalogPath}.gz`)).toString('utf8')); + const legacyCatalogRef = { + ...compactState.latestCatalog, + files: catalog.files, + symlinks: catalog.symlinks, + }; + const legacyStatePath = first.statePath.slice(0, -3); + writeFileSync(legacyStatePath, `${JSON.stringify({ + ...compactState, + schemaVersion: 1, + baselineCatalog: legacyCatalogRef, + latestCatalog: legacyCatalogRef, + }, null, 2)}\n`); + rmSync(first.statePath, {force: false}); + + const migrated = await runDirectFilesBackup(options); + assertTrue(migrated.unchanged, '旧 state 迁移不得改变相同 full catalog 的零上传语义。'); + assertTrue(existsSync(migrated.statePath), '旧 state 成功运行后必须生成压缩 state。'); + assertTrue(!existsSync(legacyStatePath), '压缩 state 原子落盘后应删除旧未压缩 state。'); + const migratedState = readGzipJson(migrated.statePath); + assertEqual(migratedState.schemaVersion, 2, '旧 state 必须迁移到 v2。'); + assertTrue(!Object.hasOwn(migratedState.latestCatalog, 'files'), '迁移后 state 不得保留重复 files 清单。'); + + const latestCatalogPath = `${migrated.catalogPath}.gz`; + const validCatalogBody = readFileSync(latestCatalogPath); + writeFileSync(latestCatalogPath, gzipSync(Buffer.from('{}\n'))); + writeFileSync(path.join(dataDir, 'control-db'), 'control changed'); + let corruptCatalogFailure = null; + try { + await runDirectFilesBackup(options); + } catch (error) { + corruptCatalogFailure = error; + } + assertIncludes( + corruptCatalogFailure?.message ?? '', + '长度或 SHA 与 state 引用不匹配', + '本地 latest full catalog 损坏时不得作为增量复用缓存。', + ); + writeFileSync(latestCatalogPath, validCatalogBody); + + writeFileSync(legacyStatePath, `${JSON.stringify({...migratedState, schemaVersion: 1})}\n`); + writeFileSync(migrated.statePath, 'not-a-gzip-state'); + let corruptStateFailure = null; + try { + await runDirectFilesBackup(options); + } catch (error) { + corruptStateFailure = error; + } + assertTrue(corruptStateFailure instanceof Error, '压缩 state 损坏时必须失败。'); + assertTrue(existsSync(legacyStatePath), '压缩 state 损坏时不得静默回退并删除旧 state。'); } async function assertDirectFilesConcurrencyIsBounded() { @@ -290,7 +369,14 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { uploadFn: harness.uploadFn, verifyFn: harness.verifyFn, }; - await runDirectFilesBackup({...common, mode: 'full', uploadManifestFn: harness.uploadManifestFn}); + const baseline = await runDirectFilesBackup({...common, mode: 'full', uploadManifestFn: harness.uploadManifestFn}); + const legacyResultFile = path.join(fixture.workDir, 'legacy-full-result.json'); + const legacyCatalogWithoutSymlinks = {...baseline.catalog}; + delete legacyCatalogWithoutSymlinks.symlinks; + writeFileSync(legacyResultFile, `${JSON.stringify({ + ...baseline, + catalog: legacyCatalogWithoutSymlinks, + }, null, 2)}\n`); const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); let failure = null; try { @@ -330,7 +416,13 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `latest pointer 发布失败不得删除: ${candidate.path}`); } - const success = await runDirectFilesBackup({...common, mode: 'history', uploadManifestFn: harness.uploadManifestFn}); + const resultFile = path.join(fixture.workDir, 'history-result.json'); + const success = await runDirectFilesBackup({ + ...common, + mode: 'history', + resultFile, + uploadManifestFn: harness.uploadManifestFn, + }); assertEqual(success.uploadedCount, 0, 'history 文件已在 full CAS baseline 时不应重复上传内容。'); for (const file of success.catalog.files) { assertTrue( @@ -340,7 +432,19 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { } assertEqual(success.cleanup?.deletedCount, plan.candidates.length, 'catalog 和 baseline 验真后才应清理全部安全候选。'); - const state = JSON.parse(readFileSync(success.statePath, 'utf8')); + const state = readGzipJson(success.statePath); + assertEqual(state.schemaVersion, 2, 'files state 必须迁移为去重后的 v2 契约。'); + assertTrue(!Object.hasOwn(state.latestCatalog, 'files'), 'files state latest ref 不得重复嵌入 files。'); + assertTrue(state.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), 'files state history ref 不得重复嵌入 files。'); + assertTrue(!existsSync(success.catalogPath), '已上传并验真的 history catalog 本地 JSON 应被清理。'); + assertTrue(!existsSync(`${success.catalogPath}.gz`), 'history catalog 本地压缩副本也不应保留。'); + const diskResult = JSON.parse(readFileSync(resultFile, 'utf8')); + assertTrue(!Object.hasOwn(diskResult.catalog, 'files'), 'files result 文件不得重复写入完整 files 清单。'); + assertEqual(diskResult.catalog.fileCount, success.fileCount, '紧凑 result 仍应保留文件计数。'); + const compactedLegacyResult = JSON.parse(readFileSync(legacyResultFile, 'utf8')); + assertTrue(!Object.hasOwn(compactedLegacyResult.catalog, 'files'), '旧 result 中重复的 files 清单应在成功运行后压缩。'); + assertEqual(compactedLegacyResult.catalog.symlinkCount, 0, '缺少 symlinks 的旧 result 应按零个符号链接兼容迁移。'); + assertTrue((success.metadataCleanup?.compactedResultCount ?? 0) >= 1, 'metadata 清理应报告已压缩旧 result。'); const historyCatalogObjectKey = state.historyCatalogs[0].objectKey; harness.objects.delete(historyCatalogObjectKey); let brokenHistoryFailure = null; @@ -425,6 +529,29 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { assertTrue(lstatSync(path.join(restoreDir, 'bin', 'current')).isSymbolicLink(), 'files restore 必须重建符号链接。'); assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'files restore 必须保留符号链接目标。'); + rmSync(restoreDir, {recursive: true, force: true}); + const legacyRestoreStatePath = path.join(workDir, 'legacy-restore-state.json'); + writeFileSync(legacyRestoreStatePath, `${JSON.stringify({ + ...readGzipJson(latestFull.statePath), + schemaVersion: 1, + })}\n`); + const legacyRestored = await restoreDirectFilesBackup({ + statePath: legacyRestoreStatePath, + restoreDir, + database: 'test-db', + bucket: 'backup-bucket', + uploadOptions: {}, + downloadBufferFn: async ({objectKey}) => Buffer.from(harness.objects.get(objectKey)?.body ?? ''), + downloadFileFn: async ({objectKey, destinationPath}) => { + const object = harness.objects.get(objectKey); + if (!object) { + throw new Error(`missing ${objectKey}`); + } + writeFileSync(destinationPath, object.body); + }, + }); + assertEqual(legacyRestored.downloadedCount, 1, 'files restore 必须继续兼容 v1 JSON state。'); + rmSync(restoreDir, {recursive: true, force: true}); const downloadBufferFn = async ({objectKey}) => { const object = harness.objects.get(objectKey); diff --git a/scripts/check-maintenance-page.mjs b/scripts/check-maintenance-page.mjs index 1109f99c0..d82e1f2e4 100644 --- a/scripts/check-maintenance-page.mjs +++ b/scripts/check-maintenance-page.mjs @@ -148,6 +148,10 @@ function validateGatewayConfiguration() { 'try_files /page.html @genarrative_default_maintenance;', 'location @genarrative_default_maintenance', 'root /srv/genarrative/web;', + 'location = /branding/taonier-maintenance-page.png', + 'try_files /branding/taonier-maintenance-page.png =404;', + 'location = /branding/taonier-product-ip.png', + 'try_files /branding/taonier-product-ip.png =404;', ]) { if (!nginxSnippet.includes(expected)) { fail(`Nginx 维护页配置缺少运行态覆盖约束: ${expected}`); @@ -161,6 +165,7 @@ function validateGatewayConfiguration() { for (const expected of [ 'GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE', 'maintenance_page_file', + 'is_maintenance_page_asset(path)', ]) { if (!pingoraSource.includes(expected)) { fail(`Pingora 维护页配置缺少运行态覆盖约束: ${expected}`); diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index c1307e26b..7a1411443 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -164,6 +164,10 @@ function validateMaintenanceInternalBypass() { fail(`${MAINTENANCE_SNIPPET_PATH} 不应保留仅后台使用的维护变量。`); } for (const fragment of [ + 'location = /branding/taonier-maintenance-page.png {', + 'try_files /branding/taonier-maintenance-page.png =404;', + 'location = /branding/taonier-product-ip.png {', + 'try_files /branding/taonier-product-ip.png =404;', 'location = /404.html {', 'if ($http_accept !~* "text/html") {', 'try_files /404.html =404;', diff --git a/scripts/check-pingora-gateway-smoke.mjs b/scripts/check-pingora-gateway-smoke.mjs index a9b69e170..3cceac128 100644 --- a/scripts/check-pingora-gateway-smoke.mjs +++ b/scripts/check-pingora-gateway-smoke.mjs @@ -347,6 +347,19 @@ async function prepareStaticRoots(webRoot, acmeRoot) { path.join(webRoot, 'maintenance.html'), '
default-maintenance
', ); + await mkdir(path.join(webRoot, 'branding'), { recursive: true }); + for (const fileName of [ + 'taonier-maintenance-page.png', + 'taonier-product-ip.png', + ]) { + await writeFile( + path.join(webRoot, 'branding', fileName), + Buffer.concat([ + PNG_MAGIC_BYTES, + Buffer.from([0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]), + ]), + ); + } await writeFile(path.join(webRoot, '404.html'), '
not-found-page
'); await writeFile( path.join(acmeRoot, '.well-known', 'acme-challenge', 'token'), @@ -1204,6 +1217,14 @@ async function runSmokeCases( }, ); } + for (const path of [ + '/branding/taonier-maintenance-page.png', + '/branding/taonier-product-ip.png', + ]) { + await expectStaticPng(baseUrl, path, `维护模式放行品牌图片 ${path}`, { + headers: publicClientHeaders, + }); + } await rm(maintenancePageFile, { force: true }); await expectHttp( baseUrl, @@ -1433,9 +1454,10 @@ async function expectNotCompressedResponse(baseUrl, route, bodyNeedle, label) { } } -async function expectStaticPng(baseUrl, route, label) { +async function expectStaticPng(baseUrl, route, label, options = {}) { console.log(`[pingora-gateway-smoke] ${label}`); const response = await requestHttp(`${baseUrl}${route}`, { + headers: options.headers, rawBody: true, }); diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index 80fb5392c..95e6b54f9 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -26,6 +26,7 @@ import {Readable} from 'node:stream'; import {pipeline} from 'node:stream/promises'; import {setTimeout as sleep} from 'node:timers/promises'; import {fileURLToPath} from 'node:url'; +import {gunzipSync, gzipSync} from 'node:zlib'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -53,7 +54,8 @@ const DIRECT_FILES_SINGLE_PUT_MAX_BYTES = 16 * 1024 * 1024; const RETRYABLE_OSS_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504]); const HISTORY_STATE_SCHEMA_VERSION = 1; const HISTORY_MANIFEST_SCHEMA_VERSION = 1; -const DIRECT_FILES_STATE_SCHEMA_VERSION = 1; +const DIRECT_FILES_STATE_SCHEMA_VERSION = 2; +const LEGACY_DIRECT_FILES_STATE_SCHEMA_VERSION = 1; const DIRECT_FILES_CATALOG_SCHEMA_VERSION = 1; const DIRECT_FILES_LATEST_SCHEMA_VERSION = 1; @@ -370,14 +372,23 @@ function buildBackupNames({database, dataDir, objectPrefix}) { return {fileName, objectKey}; } -function atomicWriteJson(filePath, payload) { +function atomicWriteBuffer(filePath, body) { mkdirSync(dirname(filePath), {recursive: true}); const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); + writeFileSync(tempPath, body, {mode: 0o600}); chmodSync(tempPath, 0o600); renameSync(tempPath, filePath); } +function atomicWriteJson(filePath, payload) { + atomicWriteBuffer(filePath, Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8')); +} + +function atomicWriteGzipJson(filePath, payload) { + const body = Buffer.from(`${JSON.stringify(payload)}\n`, 'utf8'); + atomicWriteBuffer(filePath, gzipSync(body, {level: 9})); +} + function processIsAlive(pid) { try { process.kill(pid, 0); @@ -1070,9 +1081,127 @@ async function sha256FileHex(filePath) { } function directFilesStatePath({workDir, database}) { + return join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json.gz`); +} + +function legacyDirectFilesStatePath({workDir, database}) { return join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json`); } +function directCatalogLocalPaths({workDir, database, catalog}) { + const baseName = `${sanitizeObjectPart(database, 'spacetimedb')}-${catalog.mode}-${catalog.catalogId}.catalog.json`; + return { + jsonPath: join(workDir, baseName), + gzipPath: join(workDir, `${baseName}.gz`), + }; +} + +function readLocalDirectCatalog({workDir, database, catalog}) { + const {jsonPath, gzipPath} = directCatalogLocalPaths({workDir, database, catalog}); + let body = null; + if (existsSync(jsonPath)) { + body = readFileSync(jsonPath); + } else if (existsSync(gzipPath)) { + body = gunzipSync(readFileSync(gzipPath)); + } + if (!body) { + return null; + } + if (body.length !== catalog.contentLength || sha256Hex(body) !== catalog.sha256) { + throw new Error(`本地 files catalog 长度或 SHA 与 state 引用不匹配: ${jsonPath}`); + } + const payload = JSON.parse(body.toString('utf8')); + if ( + payload.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION + || payload.database !== database + || payload.mode !== catalog.mode + || payload.catalogId !== catalog.catalogId + || !Array.isArray(payload.files) + ) { + throw new Error(`本地 files catalog 与 state 引用不匹配: ${jsonPath}`); + } + return payload; +} + +function readJsonOrGzip(filePath) { + const body = readFileSync(filePath); + const decoded = filePath.endsWith('.gz') || (body[0] === 0x1f && body[1] === 0x8b) + ? gunzipSync(body) + : body; + return JSON.parse(decoded.toString('utf8')); +} + +function compactDirectCatalogFile({workDir, database, catalog}) { + const {jsonPath, gzipPath} = directCatalogLocalPaths({workDir, database, catalog}); + if (!existsSync(jsonPath)) { + return existsSync(gzipPath) ? {compressed: false, gzipPath} : null; + } + const body = readFileSync(jsonPath); + atomicWriteBuffer(gzipPath, gzipSync(body, {level: 9})); + rmSync(jsonPath, {force: false}); + return {compressed: true, gzipPath}; +} + +function compactDirectFilesLocalMetadata({workDir, database, nextState, transientCatalogPaths = []}) { + const keepCatalog = nextState.latestCatalog; + const keepCatalogIds = new Set([keepCatalog?.catalogId].filter(Boolean)); + const databasePart = sanitizeObjectPart(database, 'spacetimedb'); + const catalogPattern = new RegExp(`^${databasePart.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}-(full|history)-([a-f0-9]{64})\\.catalog\\.json(?:\\.gz)?$`, 'u'); + let compressedCatalogCount = 0; + let deletedCatalogCount = 0; + let compactedResultCount = 0; + for (const entry of readdirSync(workDir, {withFileTypes: true})) { + if (!entry.isFile()) { + continue; + } + const match = catalogPattern.exec(entry.name); + if (!match) { + continue; + } + const catalog = {mode: match[1], catalogId: match[2]}; + if (keepCatalogIds.has(catalog.catalogId) && catalog.mode === 'full') { + readLocalDirectCatalog({workDir, database, catalog: keepCatalog}); + if (!entry.name.endsWith('.gz') && compactDirectCatalogFile({workDir, database, catalog})?.compressed) { + compressedCatalogCount += 1; + } + continue; + } + rmSync(join(workDir, entry.name), {force: false}); + deletedCatalogCount += 1; + } + for (const filePath of transientCatalogPaths) { + if (existsSync(filePath)) { + rmSync(filePath, {force: false}); + deletedCatalogCount += 1; + } + } + for (const entry of readdirSync(workDir, {withFileTypes: true})) { + if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.endsWith('.catalog.json')) { + continue; + } + const filePath = join(workDir, entry.name); + let payload = null; + try { + payload = JSON.parse(readFileSync(filePath, 'utf8')); + } catch { + continue; + } + if ( + payload?.catalog?.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION + || payload.catalog.database !== database + || payload.catalog.bucket !== nextState.bucket + || !Array.isArray(payload.catalog.files) + ) { + continue; + } + atomicWriteJson(filePath, compactDirectFilesResult(payload)); + compactedResultCount += 1; + } + const result = {compressedCatalogCount, deletedCatalogCount, compactedResultCount}; + console.log(`[database-backup] files 本地元数据清理: ${JSON.stringify(result)}`); + return result; +} + function normalizeObjectPrefix(objectPrefix, database) { const prefix = String(objectPrefix || 'database-backups') .trim() @@ -1185,12 +1314,16 @@ function directCatalogIdentity({mode, baselineCatalogId, rootName, directories, } function readDirectFilesState(statePath, {database, bucket}) { - if (!existsSync(statePath)) { + const candidates = statePath.endsWith('.gz') + ? [statePath, statePath.slice(0, -3)] + : [statePath, `${statePath}.gz`]; + const existingPath = candidates.find((candidate) => existsSync(candidate)); + if (!existingPath) { return null; } - const state = readManifest(statePath); + const state = readJsonOrGzip(existingPath); if ( - state.schemaVersion !== DIRECT_FILES_STATE_SCHEMA_VERSION + ![LEGACY_DIRECT_FILES_STATE_SCHEMA_VERSION, DIRECT_FILES_STATE_SCHEMA_VERSION].includes(state.schemaVersion) || state.backupKind !== 'spacetimedb-direct-files-state' || state.database !== database || state.bucket !== bucket @@ -1200,6 +1333,16 @@ function readDirectFilesState(statePath, {database, bucket}) { return state; } +function directPreviousFiles({state, workDir, database}) { + if (!state?.latestCatalog) { + return []; + } + if (Array.isArray(state?.latestCatalog?.files)) { + return state.latestCatalog.files; + } + return readLocalDirectCatalog({workDir, database, catalog: state?.latestCatalog})?.files ?? []; +} + async function ensureDirectObject({ file, dataDir, @@ -1290,6 +1433,52 @@ function directCatalogRef(catalog) { }; } +function normalizeDirectFilesState({state, dataDir, database, bucket}) { + return { + schemaVersion: DIRECT_FILES_STATE_SCHEMA_VERSION, + backupKind: 'spacetimedb-direct-files-state', + database, + dataDir, + bucket, + updatedAt: new Date().toISOString(), + baselineCatalog: assertDirectCatalogRef(state?.baselineCatalog, 'full', 'baseline full'), + latestCatalog: assertDirectCatalogRef(state?.latestCatalog, 'full', 'latest full'), + historyCatalogs: (state?.historyCatalogs ?? []).map((catalog) => ( + assertDirectCatalogRef(catalog, 'history', 'history') + )), + }; +} + +function persistDirectFilesState({statePath, legacyStatePath, state}) { + atomicWriteGzipJson(statePath, state); + if (legacyStatePath !== statePath && existsSync(legacyStatePath)) { + rmSync(legacyStatePath, {force: false}); + } +} + +function compactDirectFilesResult(result) { + if (!result.catalog) { + return result; + } + const {catalog, ...rest} = result; + return { + ...rest, + catalog: { + schemaVersion: catalog.schemaVersion, + backupKind: catalog.backupKind, + database: catalog.database, + bucket: catalog.bucket, + mode: catalog.mode, + catalogId: catalog.catalogId, + catalogObjectKey: catalog.catalogObjectKey, + baselineCatalogId: catalog.baselineCatalogId, + rootName: catalog.rootName, + fileCount: Array.isArray(catalog.files) ? catalog.files.length : 0, + symlinkCount: Array.isArray(catalog.symlinks) ? catalog.symlinks.length : 0, + }, + }; +} + function assertDirectCatalogRef(catalog, expectedMode, label) { if ( !catalog @@ -1397,6 +1586,7 @@ export async function runDirectFilesBackup({ }) { mkdirSync(workDir, {recursive: true}); const statePath = directFilesStatePath({workDir, database}); + const legacyStatePath = legacyDirectFilesStatePath({workDir, database}); const state = readDirectFilesState(statePath, {database, bucket}); if (mode === 'history' && (!state?.baselineCatalog || state?.latestCatalog?.mode !== 'full')) { throw new Error(`files history 模式缺少已发布 full baseline catalog: ${statePath}`); @@ -1464,10 +1654,26 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); + const compactedState = normalizeDirectFilesState({state, dataDir, database, bucket}); + persistDirectFilesState({statePath, legacyStatePath, state: compactedState}); + const metadataCleanup = compactDirectFilesLocalMetadata({ + workDir, + database, + nextState: compactedState, + transientCatalogPaths: [catalogPath], + }); console.log('[database-backup] files history 没有可归档候选。'); - const emptyResult = {...summary, catalog, latestPointer, uploadedCount: 0, reusedCount: 0, cleanup: null}; + const emptyResult = { + ...summary, + catalog, + latestPointer, + uploadedCount: 0, + reusedCount: 0, + cleanup: null, + metadataCleanup, + }; if (resultFile) { - atomicWriteJson(resolvePath(resultFile), emptyResult); + atomicWriteJson(resolvePath(resultFile), compactDirectFilesResult(emptyResult)); } return emptyResult; } @@ -1485,8 +1691,23 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); + const compactedState = normalizeDirectFilesState({state, dataDir, database, bucket}); + persistDirectFilesState({statePath, legacyStatePath, state: compactedState}); + const metadataCleanup = compactDirectFilesLocalMetadata({ + workDir, + database, + nextState: compactedState, + }); console.log('[database-backup] files catalog 未变化,无文件需要上传。'); - return {...summary, catalog, latestPointer, uploadedCount: 0, reusedCount: collected.files.length, unchanged: true}; + return { + ...summary, + catalog, + latestPointer, + uploadedCount: 0, + reusedCount: collected.files.length, + unchanged: true, + metadataCleanup, + }; } } @@ -1499,7 +1720,7 @@ export async function runDirectFilesBackup({ }); } - const previousFiles = new Map((state?.latestCatalog?.files ?? []).map((file) => [file.path, file])); + const previousFiles = new Map(directPreviousFiles({state, workDir, database}).map((file) => [file.path, file])); let uploadedCount = 0; let reusedCount = 0; let nextIndex = 0; @@ -1555,8 +1776,6 @@ export async function runDirectFilesBackup({ contentLength: catalogUpload.contentLength, sha256: catalogUpload.archiveSha256, verifiedAt: catalogUpload.verifiedAt, - files: catalog.files, - symlinks: catalog.symlinks, }; const nextState = { schemaVersion: DIRECT_FILES_STATE_SCHEMA_VERSION, @@ -1565,11 +1784,16 @@ export async function runDirectFilesBackup({ dataDir, bucket, updatedAt: new Date().toISOString(), - baselineCatalog: state?.baselineCatalog ?? catalogRef, - latestCatalog: mode === 'full' ? catalogRef : state.latestCatalog, + baselineCatalog: directCatalogRef(state?.baselineCatalog ?? catalogRef), + latestCatalog: directCatalogRef(mode === 'full' ? catalogRef : state.latestCatalog), historyCatalogs: mode === 'history' - ? [...(state.historyCatalogs ?? []).filter((item) => item.catalogId !== catalogId), catalogRef] - : (state?.historyCatalogs ?? []), + ? [ + ...(state.historyCatalogs ?? []) + .filter((item) => item.catalogId !== catalogId) + .map((item) => directCatalogRef(item)), + directCatalogRef(catalogRef), + ] + : (state?.historyCatalogs ?? []).map((item) => directCatalogRef(item)), }; const latestPointer = await publishDirectFilesLatest({ workDir, @@ -1581,14 +1805,27 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); - atomicWriteJson(statePath, nextState); + persistDirectFilesState({statePath, legacyStatePath, state: nextState}); + const metadataCleanup = compactDirectFilesLocalMetadata({ + workDir, + database, + nextState, + }); let cleanup = null; if (mode === 'history') { cleanup = cleanupHistoryCandidates({dataDir, candidates: plan.candidates}); } - const finalResult = {...summary, catalog, latestPointer, uploadedCount, reusedCount, cleanup}; + const finalResult = { + ...summary, + catalog, + latestPointer, + uploadedCount, + reusedCount, + cleanup, + metadataCleanup, + }; if (resultFile) { - atomicWriteJson(resolvePath(resultFile), finalResult); + atomicWriteJson(resolvePath(resultFile), compactDirectFilesResult(finalResult)); } return finalResult; } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 144b94149..5f3c0c041 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -122,6 +122,7 @@ const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_ const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved"; +const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1; const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; @@ -631,6 +632,19 @@ fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGeneration } } +fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarningResponse { + let reason = error + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .unwrap_or("provider 回图未能归一到目标交付尺寸,已保留实际原图尺寸。") + .to_string(); + EditorGenerationWarningResponse { + code: EDITOR_GENERATION_DIMENSION_WARNING_CODE, + reason, + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageGenerationResponse { @@ -1582,6 +1596,12 @@ pub(crate) async fn generate_editor_image_for_owner( has_dimension_options, &generation_options, ); + let provider_request_size = resolve_editor_image_provider_request_size( + normalized_kind, + payload.size.as_deref(), + has_dimension_options, + &generation_options, + ); let failure_context = match normalized_kind { Some("character") => "图片画布生成角色形象", Some("spec") => "图片画布生成规范", @@ -1633,6 +1653,11 @@ pub(crate) async fn generate_editor_image_for_owner( } else { image_size }; + let provider_request_size = if is_ui_design_generation { + Cow::Owned(generation_options.provider_size.clone()) + } else { + provider_request_size + }; let configured_price_mud_points = state .editor_generation_pricing() .await @@ -1714,7 +1739,7 @@ pub(crate) async fn generate_editor_image_for_owner( generation_options.model, submitted_prompt.as_str(), negative_prompt, - image_size.as_ref(), + provider_request_size.as_ref(), 1, &[], failure_context, @@ -1727,7 +1752,7 @@ pub(crate) async fn generate_editor_image_for_owner( generation_options.model, submitted_prompt.as_str(), negative_prompt, - image_size.as_ref(), + provider_request_size.as_ref(), 1, reference_images.as_slice(), failure_context, @@ -1769,6 +1794,37 @@ pub(crate) async fn generate_editor_image_for_owner( "message": format!("素材生成结果不是有效图片:{error}"), })) })?; + // 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回 + // 2752x1536;画布业务规格统一使用 512 / 1024 / 2048 长边像素矩阵,持久化前归一, + // 保证不同模型的完成图与生成前占位标注一致。 + let (restored_image, dimension_restore_error) = if is_character_generation { + // 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存; + // 透明主结果会在后处理完成后再归一到画布业务规格。 + (image, None) + } else { + restore_editor_generated_image_output_dimensions_or_original( + image, + generation_options.model, + image_size.as_ref(), + ) + }; + let mut dimension_warning = dimension_restore_error + .as_ref() + .map(editor_dimension_restore_warning); + if let Some(error) = dimension_restore_error { + tracing::warn!( + task_id = %generated.task_id, + provider_width, + provider_height, + target_size = %image_size, + error = ?error, + "画板生成图片尺寸恢复失败,保留并上传 provider 原图" + ); + } + image = restored_image; + let (delivery_width, delivery_height) = image::load_from_memory(image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .unwrap_or((provider_width, provider_height)); let storage_profile = editor_generated_image_storage_profile(normalized_kind); let mut output_prompt = submitted_prompt.clone(); let mut output_actual_prompt = generated.actual_prompt.clone(); @@ -1803,8 +1859,8 @@ pub(crate) async fn generate_editor_image_for_owner( .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), label: editor_generated_asset_variant_label(asset_label.as_str(), "原图"), - width: provider_width, - height: provider_height, + width: delivery_width, + height: delivery_height, prompt: role_setting.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), @@ -1863,8 +1919,8 @@ pub(crate) async fn generate_editor_image_for_owner( image_src: source_image_src, object_key: Some(source_object_key), asset_object_id: Some(source_asset_object_id), - width: provider_width, - height: provider_height, + width: delivery_width, + height: delivery_height, source_type: "generated", prompt: role_setting, actual_prompt: generated.actual_prompt, @@ -1886,8 +1942,8 @@ pub(crate) async fn generate_editor_image_for_owner( apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, source_object_key.as_str(), - provider_width, - provider_height, + delivery_width, + delivery_height, removal.image, ) .await; @@ -1897,6 +1953,8 @@ pub(crate) async fn generate_editor_image_for_owner( provider = removal_provider, provider_width, provider_height, + delivery_width, + delivery_height, error = ?error, "角色透明图尺寸恢复失败,保留去背景服务原始输出" ); @@ -1913,26 +1971,27 @@ pub(crate) async fn generate_editor_image_for_owner( None }; - // 中文注释:带模型尺寸选项的用户生成已经把目标像素直接传给 provider,不能在回图后靠缩放伪造 K 档。 - // 只有宣发素材的固定交付尺寸和没有新尺寸字段的历史请求继续执行交付尺寸恢复。 - let (restored_image, dimension_restore_error) = - if is_publication_material_generation || !has_dimension_options { + if is_character_generation { + let (restored_image, dimension_restore_error) = restore_editor_generated_image_output_dimensions_or_original( image, generation_options.model, image_size.as_ref(), - ) - } else { - (image, None) - }; - if let Some(error) = dimension_restore_error { - tracing::warn!( - task_id = %generated.task_id, - error = ?error, - "画板生成图片尺寸恢复失败,保留并上传 provider 原图" - ); + ); + if let Some(error) = dimension_restore_error { + dimension_warning = Some(editor_dimension_restore_warning(&error)); + tracing::warn!( + task_id = %generated.task_id, + provider_width, + provider_height, + target_size = %image_size, + error = ?error, + "画板角色透明主图尺寸恢复失败,保留后处理原图" + ); + } + image = restored_image; } - image = restored_image; + let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); @@ -2025,7 +2084,7 @@ pub(crate) async fn generate_editor_image_for_owner( resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, - warning: None, + warning: dimension_warning, }, )) } @@ -2262,6 +2321,25 @@ fn resolve_editor_image_request_size( legacy_size } +fn resolve_editor_image_provider_request_size( + normalized_kind: Option<&str>, + payload_size: Option<&str>, + has_dimension_options: bool, + generation_options: &EditorGenerationOptions, +) -> Cow<'static, str> { + let legacy_size = normalize_editor_image_generation_size(payload_size); + let has_explicit_payload_size = payload_size + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + if matches!(normalized_kind, Some("publication-material")) && has_explicit_payload_size { + return legacy_size; + } + if has_dimension_options { + return Cow::Owned(generation_options.provider_size.clone()); + } + legacy_size +} + fn is_editor_custom_image_size(value: &str) -> bool { let Some((width, height)) = value.split_once('x') else { return false; @@ -2294,7 +2372,9 @@ fn normalize_editor_generation_options( }; let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio); let image_size = normalize_editor_generation_image_size(normalized_model, image_size); - let size = editor_generation_size_for_model(normalized_model, aspect_ratio, image_size); + let size = editor_generation_delivery_size(aspect_ratio, image_size); + let provider_size = + editor_generation_provider_size_for_model(normalized_model, aspect_ratio, image_size); let provider_image_size = editor_generation_provider_image_size_for_model(normalized_model, image_size); @@ -2303,6 +2383,7 @@ fn normalize_editor_generation_options( aspect_ratio, image_size, size, + provider_size, provider_image_size, } } @@ -2332,17 +2413,42 @@ fn normalize_editor_generation_image_size(model: &str, image_size: Option<&str>) } } -fn editor_generation_size_for_model(model: &str, aspect_ratio: &str, image_size: &str) -> String { - if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - return match image_size { - // 中文注释:VectorEngine 的 nanobanana2 文档要求 0.5K 传入 512。 - "0.5K" => "512", - "2K" => "2048", - _ => "1024", - } - .to_string(); - } +fn editor_generation_delivery_size(aspect_ratio: &str, image_size: &str) -> String { + let long_edge = match image_size { + "0.5K" => 512_u32, + "2K" => 2048_u32, + _ => 1024_u32, + }; + let (ratio_width, ratio_height) = match aspect_ratio { + "2:3" => (2_u32, 3_u32), + "3:2" => (3_u32, 2_u32), + "4:3" => (4_u32, 3_u32), + "9:16" => (9_u32, 16_u32), + "16:9" => (16_u32, 9_u32), + _ => (1_u32, 1_u32), + }; + let (width, height) = if ratio_width >= ratio_height { + ( + long_edge, + (long_edge * ratio_height + ratio_width / 2) / ratio_width, + ) + } else { + ( + (long_edge * ratio_width + ratio_height / 2) / ratio_height, + long_edge, + ) + }; + format!("{width}x{height}") +} +fn editor_generation_provider_size_for_model( + model: &str, + aspect_ratio: &str, + image_size: &str, +) -> String { + if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + return editor_generation_delivery_size(aspect_ratio, image_size); + } match (image_size, aspect_ratio) { ("2K", "1:1") => "2048x2048", ("2K", "2:3") => "1376x2048", @@ -2578,6 +2684,65 @@ fn prepare_editor_image_edit_references( }) } +fn ensure_editor_output_can_restore_without_upscaling( + source_width: u32, + source_height: u32, + target_width: u32, + target_height: u32, + operation: &str, +) -> Result<(), AppError> { + if target_width == 0 || target_height == 0 { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "code": "invalid-delivery-size", + "message": "目标交付尺寸必须大于 0", + "operation": operation, + "targetWidth": target_width, + "targetHeight": target_height, + })), + ); + } + if source_width < target_width || source_height < target_height { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "code": "provider-image-under-delivery", + "message": "provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位", + "operation": operation, + "sourceWidth": source_width, + "sourceHeight": source_height, + "targetWidth": target_width, + "targetHeight": target_height, + })), + ); + } + + let source_ratio = f64::from(source_width) / f64::from(source_height); + let target_ratio = f64::from(target_width) / f64::from(target_height); + let crop_fraction = if source_ratio >= target_ratio { + 1.0 - target_ratio / source_ratio + } else { + 1.0 - source_ratio / target_ratio + }; + if crop_fraction > 0.05 { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "code": "provider-image-aspect-ratio-mismatch", + "message": "provider 回图比例偏差过大,禁止大幅裁切为目标交付尺寸", + "operation": operation, + "sourceWidth": source_width, + "sourceHeight": source_height, + "targetWidth": target_width, + "targetHeight": target_height, + "cropFraction": crop_fraction, + })), + ); + } + Ok(()) +} + fn restore_editor_image_edit_output_dimensions( output: &DownloadedOpenAiImage, dimensions: &EditorImageEditDimensions, @@ -2591,6 +2756,13 @@ fn restore_editor_image_edit_output_dimensions( if decoded.width() == dimensions.target_width && decoded.height() == dimensions.target_height { return Ok(None); } + ensure_editor_output_can_restore_without_upscaling( + decoded.width(), + decoded.height(), + dimensions.target_width, + dimensions.target_height, + "editor-image-edit", + )?; let restored = if decoded.width() == dimensions.provider_width && decoded.height() == dimensions.provider_height @@ -2741,11 +2913,6 @@ fn restore_editor_generated_image_output_dimensions( model: &str, target_size: &str, ) -> Result, AppError> { - // nanobanana 使用标量清晰度档位和独立比例;provider 回图已经是目标几何尺寸, - // 不能再把 512 / 1024 / 2K 当作 WIDTHxHEIGHT 解析。 - if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - return Ok(None); - } let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", @@ -2773,6 +2940,13 @@ fn restore_editor_generated_image_output_dimensions( if decoded.width() == target_width && decoded.height() == target_height { return Ok(None); } + ensure_editor_output_can_restore_without_upscaling( + decoded.width(), + decoded.height(), + target_width, + target_height, + model, + )?; let restored = decoded.resize_to_fill( target_width, @@ -2783,7 +2957,7 @@ fn restore_editor_generated_image_output_dimensions( bytes: encode_editor_image_edit_png( restored, StatusCode::BAD_GATEWAY, - "恢复宣发素材交付尺寸失败", + "恢复图片生成目标尺寸失败", )?, mime_type: "image/png".to_string(), extension: "png".to_string(), @@ -2885,7 +3059,18 @@ pub(crate) async fn edit_editor_image_for_owner( payload.image_size.as_deref(), payload.size.as_deref(), ); + let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); let requested_image_size = normalize_editor_image_generation_size(payload.size.as_deref()); + let delivery_size = if has_dimension_options { + Cow::Owned(generation_options.size.clone()) + } else { + requested_image_size.clone() + }; + let provider_requested_image_size = if has_dimension_options { + Cow::Owned(generation_options.provider_size.clone()) + } else { + requested_image_size.clone() + }; let mut reference_images = Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len)); reference_images.push( @@ -2918,7 +3103,7 @@ pub(crate) async fn edit_editor_image_for_owner( } else { Some(prepare_editor_image_edit_references( &mut reference_images, - requested_image_size.as_ref(), + provider_requested_image_size.as_ref(), )?) }; let provider_size = edit_dimensions @@ -2995,11 +3180,20 @@ pub(crate) async fn edit_editor_image_for_owner( })?; let asset_label = resolve_editor_generated_asset_label(payload.asset_label.clone(), "图片修改结果"); - let (image, dimension_restore_error) = if let Some(edit_dimensions) = edit_dimensions.as_ref() { + let (image, dimension_restore_error) = if let Some(edit_dimensions) = + edit_dimensions.as_ref().filter(|_| !has_dimension_options) + { restore_editor_image_edit_output_dimensions_or_original(provider_image, edit_dimensions) } else { - (provider_image, None) + restore_editor_generated_image_output_dimensions_or_original( + provider_image, + generation_options.model, + delivery_size.as_ref(), + ) }; + let dimension_warning = dimension_restore_error + .as_ref() + .map(editor_dimension_restore_warning); if let Some(error) = dimension_restore_error { tracing::warn!( task_id = %generated.task_id, @@ -3007,6 +3201,7 @@ pub(crate) async fn edit_editor_image_for_owner( provider_height, target_width = edit_dimensions.map(|dimensions| dimensions.target_width), target_height = edit_dimensions.map(|dimensions| dimensions.target_height), + target_size = %delivery_size, error = ?error, "画板快速编辑尺寸恢复失败,保留并上传 provider 原图" ); @@ -3096,7 +3291,7 @@ pub(crate) async fn edit_editor_image_for_owner( resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, - warning: None, + warning: dimension_warning, }, )) } @@ -3833,7 +4028,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( Some(generation_options.image_size), ) .await?; - let size = generation_options.size.as_str(); + let size = generation_options.provider_size.as_str(); let settings = require_openai_image_settings(state)?.with_external_api_audit_context( request_context, @@ -4655,7 +4850,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( generation_options.model, prompt.as_str(), None, - generation_options.size.as_str(), + generation_options.provider_size.as_str(), 1, reference_images.as_slice(), "图片画布提取UI设计图素材 spritesheet", @@ -6792,7 +6987,9 @@ struct EditorGenerationOptions { model: &'static str, aspect_ratio: &'static str, image_size: &'static str, + // 中文注释:size 是模型无关的画布交付像素;provider_size 只用于满足 GPT 图片接口的合法像素矩阵。 size: String, + provider_size: String, provider_image_size: &'static str, } @@ -8466,8 +8663,8 @@ mod tests { } #[test] - fn nanobanana_generation_keeps_provider_dimensions_for_scalar_size_tiers() { - let image = image::DynamicImage::new_rgba8(896, 896); + fn nanobanana_generation_restores_shared_business_pixel_size() { + let image = image::DynamicImage::new_rgba8(2752, 1536); let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) @@ -8482,14 +8679,78 @@ mod tests { restore_editor_generated_image_output_dimensions_or_original( provider_output, EDITOR_IMAGE_MODEL_NANOBANANA2, - "1024", + "2048x1152", ); assert!(restore_error.is_none()); let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap(); assert_eq!( (restored_image.width(), restored_image.height()), - (896, 896) + (2048, 1152) + ); + } + + #[test] + fn provider_1k_under_delivery_is_not_upscaled_to_2k() { + let image = image::DynamicImage::new_rgba8(1024, 576); + let mut bytes = Cursor::new(Vec::new()); + image + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("test image should encode"); + let provider_output = DownloadedOpenAiImage { + bytes: bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let expected_bytes = provider_output.bytes.clone(); + + let (delivery_output, restore_error) = + restore_editor_generated_image_output_dimensions_or_original( + provider_output, + EDITOR_IMAGE_MODEL_NANOBANANA2, + "2048x1152", + ); + + let restore_error = restore_error.expect("under delivery should be recorded"); + assert_eq!(delivery_output.bytes, expected_bytes); + assert_eq!( + restore_error + .details() + .and_then(|details| details.get("code")), + Some(&json!("provider-image-under-delivery")) + ); + let warning = editor_dimension_restore_warning(&restore_error); + assert_eq!(warning.code, EDITOR_GENERATION_DIMENSION_WARNING_CODE); + assert!(warning.reason.contains("禁止放大伪造清晰度档位")); + let delivery_image = image::load_from_memory(delivery_output.bytes.as_slice()).unwrap(); + assert_eq!( + (delivery_image.width(), delivery_image.height()), + (1024, 576) + ); + } + + #[test] + fn generated_dimension_restore_allows_only_slight_aspect_ratio_crop() { + ensure_editor_output_can_restore_without_upscaling( + 2064, + 1152, + 2048, + 1152, + "test-generation", + ) + .expect("provider rounding drift should allow slight crop"); + + let error = ensure_editor_output_can_restore_without_upscaling( + 2048, + 2048, + 2048, + 1152, + "test-generation", + ) + .expect_err("large aspect ratio crop should be rejected"); + assert_eq!( + error.details().and_then(|details| details.get("code")), + Some(&json!("provider-image-aspect-ratio-mismatch")) ); } @@ -8532,7 +8793,7 @@ mod tests { Some("0.5K"), ); assert_eq!(nanobanana.model, EDITOR_IMAGE_MODEL_NANOBANANA2); - assert_eq!(nanobanana.size, "512"); + assert_eq!(nanobanana.size, "512x512"); assert_eq!(nanobanana.aspect_ratio, "1:1"); assert_eq!(nanobanana.image_size, "0.5K"); assert_eq!(nanobanana.provider_image_size, "512"); @@ -8540,7 +8801,7 @@ mod tests { let nanobanana_alias = normalize_editor_generation_options(Some("nanobanana2"), Some("1:1"), Some("0.5K")); assert_eq!(nanobanana_alias.model, EDITOR_IMAGE_MODEL_NANOBANANA2); - assert_eq!(nanobanana_alias.size, "512"); + assert_eq!(nanobanana_alias.size, "512x512"); assert_eq!(nanobanana_alias.provider_image_size, "512"); let legacy_nanobanana_alias = @@ -8549,12 +8810,13 @@ mod tests { legacy_nanobanana_alias.model, EDITOR_IMAGE_MODEL_NANOBANANA2 ); - assert_eq!(legacy_nanobanana_alias.size, "2048"); + assert_eq!(legacy_nanobanana_alias.size, "2048x1152"); assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9"); let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K")); assert_eq!(gpt.model, GPT_IMAGE_2_MODEL); - assert_eq!(gpt.size, "688x1024"); + assert_eq!(gpt.size, "683x1024"); + assert_eq!(gpt.provider_size, "688x1024"); assert_eq!(gpt.aspect_ratio, "2:3"); assert_eq!(gpt.image_size, "1K"); assert_eq!(gpt.provider_image_size, "1K"); @@ -8562,13 +8824,14 @@ mod tests { let nanobanana_cover = normalize_editor_generation_options(Some("nanobanana2"), Some("4:3"), Some("1K")); assert_eq!(nanobanana_cover.model, EDITOR_IMAGE_MODEL_NANOBANANA2); - assert_eq!(nanobanana_cover.size, "1024"); + assert_eq!(nanobanana_cover.size, "1024x768"); assert_eq!(nanobanana_cover.aspect_ratio, "4:3"); let gpt_cover = normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K")); assert_eq!(gpt_cover.model, GPT_IMAGE_2_MODEL); assert_eq!(gpt_cover.size, "1024x768"); + assert_eq!(gpt_cover.provider_size, "1024x768"); assert_eq!(gpt_cover.aspect_ratio, "4:3"); let gpt_landscape_2k = @@ -8588,14 +8851,14 @@ mod tests { for (image_size, aspect_ratio, expected_size) in [ ("1K", "1:1", "1024x1024"), ("1K", "4:3", "1024x768"), - ("1K", "3:2", "1024x688"), - ("1K", "2:3", "688x1024"), - ("1K", "9:16", "608x1088"), - ("1K", "16:9", "1088x608"), + ("1K", "3:2", "1024x683"), + ("1K", "2:3", "683x1024"), + ("1K", "9:16", "576x1024"), + ("1K", "16:9", "1024x576"), ("2K", "1:1", "2048x2048"), ("2K", "4:3", "2048x1536"), - ("2K", "3:2", "2048x1376"), - ("2K", "2:3", "1376x2048"), + ("2K", "3:2", "2048x1365"), + ("2K", "2:3", "1365x2048"), ("2K", "9:16", "1152x2048"), ("2K", "16:9", "2048x1152"), ] { @@ -8607,15 +8870,70 @@ mod tests { assert_eq!(options.size, expected_size); } + for (image_size, aspect_ratio, expected_delivery_size) in [ + ("1K", "16:9", "1024x576"), + ("1K", "2:3", "683x1024"), + ("2K", "3:2", "2048x1365"), + ("2K", "9:16", "1152x2048"), + ] { + let gpt = normalize_editor_generation_options( + Some(GPT_IMAGE_2_MODEL), + Some(aspect_ratio), + Some(image_size), + ); + let nanobanana = normalize_editor_generation_options( + Some(EDITOR_IMAGE_MODEL_NANOBANANA2), + Some(aspect_ratio), + Some(image_size), + ); + assert_eq!(gpt.size, expected_delivery_size); + assert_eq!(nanobanana.size, expected_delivery_size); + assert_eq!(gpt.size, nanobanana.size); + } + + let gpt_landscape_1k = + normalize_editor_generation_options(Some(GPT_IMAGE_2_MODEL), Some("16:9"), Some("1K")); + assert_eq!(gpt_landscape_1k.provider_size, "1088x608"); + assert_eq!(gpt_landscape_1k.size, "1024x576"); + + for (image_size, aspect_ratio, expected_size) in [ + ("0.5K", "1:1", "512x512"), + ("0.5K", "4:3", "512x384"), + ("0.5K", "3:2", "512x341"), + ("0.5K", "2:3", "341x512"), + ("0.5K", "9:16", "288x512"), + ("0.5K", "16:9", "512x288"), + ("1K", "1:1", "1024x1024"), + ("1K", "4:3", "1024x768"), + ("1K", "3:2", "1024x683"), + ("1K", "2:3", "683x1024"), + ("1K", "9:16", "576x1024"), + ("1K", "16:9", "1024x576"), + ("2K", "1:1", "2048x2048"), + ("2K", "4:3", "2048x1536"), + ("2K", "3:2", "2048x1365"), + ("2K", "2:3", "1365x2048"), + ("2K", "9:16", "1152x2048"), + ("2K", "16:9", "2048x1152"), + ] { + let options = normalize_editor_generation_options( + Some(EDITOR_IMAGE_MODEL_NANOBANANA2), + Some(aspect_ratio), + Some(image_size), + ); + assert_eq!(options.size, expected_size); + } + let fallback = normalize_editor_generation_options( Some("unknown-model"), Some("bad-ratio"), Some("bad-size"), ); assert_eq!(fallback.model, EDITOR_IMAGE_MODEL_NANOBANANA2); - assert_eq!(fallback.size, "1024"); + assert_eq!(fallback.size, "1024x1024"); assert_eq!(fallback.aspect_ratio, "1:1"); assert_eq!(fallback.image_size, "1K"); + assert_eq!(fallback.provider_size, "1024x1024"); assert_eq!(fallback.provider_image_size, "1K"); } @@ -8637,6 +8955,15 @@ mod tests { resolve_editor_image_request_size(Some("generate"), Some("720x540"), true, &options), "1024x768" ); + assert_eq!( + resolve_editor_image_provider_request_size( + Some("publication-material"), + Some("720x540"), + true, + &options, + ), + "720x540" + ); } #[test] diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 52ed84135..88af466f2 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -1117,6 +1117,7 @@ impl ProxyHttp for GenarrativeGateway { let internal_bypass = allows_internal_maintenance_bypass(request_source_ip(session).as_ref()); let should_apply_maintenance = !internal_bypass + && !is_maintenance_page_asset(path) && (is_admin_request_path(path) || ctx.route.applies_maintenance_gate()); if should_apply_maintenance { respond_maintenance( @@ -1887,6 +1888,13 @@ fn is_main_spa_path(path: &str) -> bool { .any(|candidate| normalized.eq_ignore_ascii_case(candidate)) } +fn is_maintenance_page_asset(path: &str) -> bool { + matches!( + path, + "/branding/taonier-maintenance-page.png" | "/branding/taonier-product-ip.png" + ) +} + fn classify_http_redirect_path(path: &str) -> RouteDecision { if path.starts_with("/.well-known/acme-challenge/") { return RouteDecision::Local(LocalResponse::Static { @@ -3308,6 +3316,23 @@ mod tests { assert!(!allows_internal_maintenance_bypass(None)); } + #[test] + fn maintenance_only_allows_required_branding_assets() { + for path in [ + "/branding/taonier-maintenance-page.png", + "/branding/taonier-product-ip.png", + ] { + assert!(is_maintenance_page_asset(path)); + } + for path in [ + "/branding/other.png", + "/branding/taonier-maintenance-page.png/extra", + "/assets/app.js", + ] { + assert!(!is_maintenance_page_asset(path)); + } + } + #[test] fn normalizes_gateway_hosts_for_matching() { assert_eq!( diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index a4011bc1b..6ffcdefcf 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -233,10 +233,10 @@ describe('ImageCanvasGenerationDialogModel', () => { imageSize: '1K', uiDesignSpecReference: null, placeholder: { - x: -64, - y: 56, - width: 1088, - height: 608, + x: -32, + y: 72, + width: 1024, + height: 576, }, }); }); @@ -622,9 +622,9 @@ describe('ImageCanvasGenerationDialogModel', () => { placeholder: { x: 472, y: 140, - width: 1376, + width: 1365, height: 2048, - originalWidth: 1376, + originalWidth: 1365, originalHeight: 2048, }, }); @@ -662,9 +662,9 @@ describe('ImageCanvasGenerationDialogModel', () => { placeholder: { x: 472, y: 140, - width: 1376, + width: 1365, height: 2048, - originalWidth: 1376, + originalWidth: 1365, originalHeight: 2048, }, }); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 7e8309e9a..c2e21ecbf 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -108,18 +108,18 @@ describe('ImageCanvasGenerationModel', () => { [IMAGE_MODEL_NANOBANANA2, '2K', '2:3', 1365, 2048], [IMAGE_MODEL_GPT_IMAGE_2, '1K', '1:1', 1024, 1024], [IMAGE_MODEL_GPT_IMAGE_2, '1K', '4:3', 1024, 768], - [IMAGE_MODEL_GPT_IMAGE_2, '1K', '3:2', 1024, 688], - [IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 688, 1024], - [IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 608, 1088], - [IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1088, 608], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '3:2', 1024, 683], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 683, 1024], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 576, 1024], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1024, 576], [IMAGE_MODEL_GPT_IMAGE_2, '2K', '1:1', 2048, 2048], [IMAGE_MODEL_GPT_IMAGE_2, '2K', '4:3', 2048, 1536], - [IMAGE_MODEL_GPT_IMAGE_2, '2K', '3:2', 2048, 1376], - [IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1376, 2048], + [IMAGE_MODEL_GPT_IMAGE_2, '2K', '3:2', 2048, 1365], + [IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1365, 2048], [IMAGE_MODEL_GPT_IMAGE_2, '2K', '9:16', 1152, 2048], [IMAGE_MODEL_GPT_IMAGE_2, '2K', '16:9', 2048, 1152], ] as const)( - '%s 的 %s %s 占位尺寸与 provider 直接请求尺寸一致', + '%s 的 %s %s 占位尺寸与统一业务交付尺寸一致', (model, imageSize, aspectRatio, width, height) => { expect( resolveEditorImageGenerationPixelSize({ diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index e04b43e91..4b589d6de 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -173,38 +173,8 @@ function resolveImageFrameSizeFromRatio({ }; } -function alignImageDimensionUp(value: number, alignment = 16) { - return Math.ceil(value / alignment) * alignment; -} - -function resolveGptImage2FrameSize({ - aspectRatio, - imageSize, -}: { - aspectRatio: string | null | undefined; - imageSize: string | null | undefined; -}): EditorImageFrameSize { - const requested = resolveImageFrameSizeFromRatio({ aspectRatio, imageSize }); - let width = requested.width; - let height = requested.height; - const pixels = width * height; - - // 中文注释:gpt-image-2 要求总像素不少于 655360,且宽高均为 16 的倍数。 - // 这里只把用户选择的 K 档和比例转换为 provider 可直接生成的真实尺寸,不做生成后放大。 - if (pixels < 655_360) { - const scale = Math.sqrt(655_360 / pixels); - width *= scale; - height *= scale; - } - - return { - width: alignImageDimensionUp(width), - height: alignImageDimensionUp(height), - }; -} - export function resolveEditorImageGenerationPixelSize({ - model, + model: _model, aspectRatio, imageSize, }: { @@ -212,9 +182,6 @@ export function resolveEditorImageGenerationPixelSize({ aspectRatio: string | null | undefined; imageSize: string | null | undefined; }): EditorImageFrameSize { - if (model?.trim() === IMAGE_MODEL_GPT_IMAGE_2) { - return resolveGptImage2FrameSize({ aspectRatio, imageSize }); - } return resolveImageFrameSizeFromRatio({ aspectRatio, imageSize }); } diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index b7b497ff8..3e55a91d6 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -671,6 +671,42 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); }); + it('shows dimension restore warnings after non-character quick edits', async () => { + editEditorImageMock.mockResolvedValueOnce( + createGenerated({ + prompt: '改成雨天', + width: 1024, + height: 576, + warning: { + code: 'dimension-restore-fallback', + reason: 'provider 回图小于目标尺寸,已保留实际回图。', + }, + }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); + + await waitFor(() => { + expect(screen.getByTestId('generation-warning').textContent).toBe( + 'provider 回图小于目标尺寸,已保留实际回图。', + ); + }); + }); + it('rejects direct quick edit submission for icon assets', async () => { render( {generationWorkflow.generationWarning ? ( { expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); }); - it('keeps reference pick warnings visible until the user takes a follow-up action', () => { + it('dismisses reference pick warnings after three seconds', () => { vi.useFakeTimers(); try { render(); @@ -1430,12 +1430,49 @@ describe('useImageCanvasGenerationWorkflow', () => { ); act(() => { - vi.advanceTimersByTime(10_000); + vi.advanceTimersByTime(2_999); }); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是角色规范图,请选择生成规范里的角色规范。', ); + + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); + } finally { + vi.useRealTimers(); + } + }); + + it('restarts the dismissal timer when the same warning is shown again', () => { + vi.useFakeTimers(); + try { + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' })); + + act(() => { + vi.advanceTimersByTime(2_999); + }); + + fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' })); + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '选择的图片不是角色规范图,请选择生成规范里的角色规范。', + ); + + act(() => { + vi.advanceTimersByTime(2_999); + }); + + expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); } finally { vi.useRealTimers(); } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 1e4a630f1..96ebc61d0 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -720,6 +720,25 @@ export function useImageCanvasGenerationWorkflow({ const [generationWarning, setGenerationWarning] = useState( null, ); + const [generationWarningVersion, setGenerationWarningVersion] = useState(0); + const showGenerationWarning = useCallback((warning: string) => { + setGenerationWarning(warning); + setGenerationWarningVersion((version) => version + 1); + }, []); + + useEffect(() => { + if (!generationWarning) { + return; + } + + const timer = window.setTimeout(() => { + setGenerationWarning(null); + }, 3_000); + + return () => { + window.clearTimeout(timer); + }; + }, [generationWarning, generationWarningVersion]); const [cropExpandPanel, setCropExpandPanel] = useState(null); const [uiAssetExtractionState, setUiAssetExtractionState] = @@ -1321,7 +1340,7 @@ export function useImageCanvasGenerationWorkflow({ return; } if (sourceLayer.assetKind === 'character-animation') { - setGenerationWarning('未找到角色动作关联的原角色图层,无法改造。'); + showGenerationWarning('未找到角色动作关联的原角色图层,无法改造。'); return; } const redrawPanel = createRedrawPanelDraft(sourceLayer, { @@ -1361,6 +1380,7 @@ export function useImageCanvasGenerationWorkflow({ setActiveTool, setImageContextMenu, setMetadataLayer, + showGenerationWarning, canvasGenerationDialogs, canvasSize, layers, @@ -1689,7 +1709,7 @@ export function useImageCanvasGenerationWorkflow({ }), refreshTaskListForQueuedGeneration, onWalletBalanceMayHaveChanged, - setGenerationWarning, + showGenerationWarning, backgroundRemovalDialogId, applyBackgroundRemovalProjectSnapshot ? (project) => @@ -1722,6 +1742,7 @@ export function useImageCanvasGenerationWorkflow({ refreshTaskListForQueuedGeneration, setImageContextMenu, setMetadataLayer, + showGenerationWarning, updateCanvasGenerationDialogById, ], ); @@ -1805,7 +1826,7 @@ export function useImageCanvasGenerationWorkflow({ const pickCharacterSpecFromLayer = useCallback( (layer: CanvasLayer) => { if (!isCharacterSpecReferenceLayer(layer)) { - setGenerationWarning(INVALID_CHARACTER_SPEC_WARNING); + showGenerationWarning(INVALID_CHARACTER_SPEC_WARNING); return; } setGenerationWarning(null); @@ -1824,7 +1845,7 @@ export function useImageCanvasGenerationWorkflow({ setIsCharacterSpecMenuOpen(false); setImageContextMenu(null); }, - [setGenerateDialog, setImageContextMenu], + [setGenerateDialog, setImageContextMenu, showGenerationWarning], ); const pickGenerationReferenceFromLayer = useCallback( @@ -1865,7 +1886,7 @@ export function useImageCanvasGenerationWorkflow({ const pickIconSpecFromLayer = useCallback( (layer: CanvasLayer) => { if (!isIconSpecReferenceLayer(layer)) { - setGenerationWarning(INVALID_ICON_SPEC_WARNING); + showGenerationWarning(INVALID_ICON_SPEC_WARNING); return; } setGenerationWarning(null); @@ -1881,13 +1902,18 @@ export function useImageCanvasGenerationWorkflow({ setIsIconSpecMenuOpen(false); setImageContextMenu(null); }, - [generateDialog, setGenerateDialog, setImageContextMenu], + [ + generateDialog, + setGenerateDialog, + setImageContextMenu, + showGenerationWarning, + ], ); const pickUiDesignSpecFromLayer = useCallback( (layer: CanvasLayer) => { if (!isIconSpecReferenceLayer(layer)) { - setGenerationWarning(INVALID_ICON_SPEC_WARNING); + showGenerationWarning(INVALID_ICON_SPEC_WARNING); return; } setGenerationWarning(null); @@ -1906,7 +1932,12 @@ export function useImageCanvasGenerationWorkflow({ setIsUiDesignSpecMenuOpen(false); setImageContextMenu(null); }, - [generateDialog, setGenerateDialog, setImageContextMenu], + [ + generateDialog, + setGenerateDialog, + setImageContextMenu, + showGenerationWarning, + ], ); const pickPublicationReferenceFromLayer = useCallback( @@ -1972,7 +2003,7 @@ export function useImageCanvasGenerationWorkflow({ applyProjectSnapshot, onQueuedGenerationTask: refreshTaskListForQueuedGeneration, onWalletBalanceMayHaveChanged, - onGenerationWarning: setGenerationWarning, + onGenerationWarning: showGenerationWarning, }); const setEffectiveCharacterAnimationPanel: Dispatch< @@ -2540,7 +2571,8 @@ export function useImageCanvasGenerationWorkflow({ isPickingPublicationReferenceFromCanvas, setIsPickingPublicationReferenceFromCanvas, generationWarning, - showGenerationWarning: setGenerationWarning, + generationWarningVersion, + showGenerationWarning, clearGenerationWarning: () => setGenerationWarning(null), openGenerateDialog, openSpecDialog, @@ -2635,6 +2667,7 @@ export function useImageCanvasGenerationWorkflow({ isSpecMenuOpen, isPickingPublicationReferenceFromCanvas, generationWarning, + generationWarningVersion, openBackgroundMusicGenerationDialog, openCharacterAnimationPanel, openCharacterGenerationDialog, @@ -2672,6 +2705,7 @@ export function useImageCanvasGenerationWorkflow({ updateIconDescriptionsText, updateSpecFormValue, rememberImageModel, + showGenerationWarning, ], ); }