From 7b2903496ef02400001cbd05fe48308aac448690 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 22 Jun 2026 17:50:17 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20API=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=BD=92=E6=A1=A3=E4=B8=AD=E7=9A=84=20Pingora=20TLS?= =?UTF-8?q?=20=E5=90=8C=E6=AD=A5=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Build 归档加入 pingora-tls-cert-sync.mjs API Deploy 复制并授权 Pingora TLS 证书同步脚本 生产运维门禁覆盖 TLS 同步脚本归档要求 共享记忆记录 Jenkins 归档清单漏项排障经验 --- docs/project-memory/shared-memory/pitfalls.md | 9 ++++++++- jenkins/Jenkinsfile.production-api-build | 2 +- jenkins/Jenkinsfile.production-api-deploy | 4 ++-- scripts/check-production-ops-guardrails.mjs | 12 ++++++++++++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2da702408..b38971d58 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -15,6 +15,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## API Build / Deploy 归档清单不能漏掉随包 Pingora 脚本 + +- 现象:`Genarrative-Api-Deploy` 在发布阶段报 `发布产物缺少 Pingora TLS 证书同步脚本: build//scripts/deploy/pingora-tls-cert-sync.mjs`。 +- 原因:`scripts/build-production-release.sh` 已经把脚本复制进 `build//scripts/deploy/`,但 Jenkins API Build 的 `archiveArtifacts` 和 API Deploy 的 `copyArtifacts` 过滤清单仍可能漏掉新增随包脚本,导致 Deploy 工作区拿到的是残缺发布包。 +- 处理:新增随包部署脚本时,必须同时更新 `jenkins/Jenkinsfile.production-api-build` 的归档清单、`jenkins/Jenkinsfile.production-api-deploy` 的复制清单和 `scripts/check-production-ops-guardrails.mjs` 的字符串门禁;不要在 Deploy Job 里从工作区根目录或源码 checkout 兜底补脚本。 +- 验证:运行 `npm run check:production-ops`、`npm run check:production-api-release` 和 `npm run check:production-api-deploy`,确认构建包、Jenkins 归档链路和 deploy fail-fast 检查口径一致。 +- 关联:`jenkins/Jenkinsfile.production-api-build`、`jenkins/Jenkinsfile.production-api-deploy`、`scripts/deploy/production-api-deploy.sh`、`scripts/check-production-ops-guardrails.mjs`。 + ## 图片画布角色动作结果不能把首帧当主媒体 - 现象:画板里 `生成角色动作` 返回后显示成一张不可播放图片,点击下载或素材 ZIP 导出时拿到的也是 PNG,而不是动作视频。 @@ -158,7 +166,6 @@ - 处理:Windows 本地 dev 脚本应把 `RUSTC_WRAPPER` 和 `CARGO_BUILD_RUSTC_WRAPPER` 显式设为空字符串,让 Cargo 覆盖项目配置并直连真实 rustc;Linux 保持 `/usr/bin/env` 绕过 sccache。 - 验证:`npm run test -- scripts/dev.test.ts -t "Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache"`,并用 `npm run dev:api-server` 拉起后访问实际 api 端口的 `/healthz` 返回 200。 - 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - ## Pingora 直连 80/443 不能只改 env - 现象:`/etc/genarrative/pingora-gateway.env` 已把 `GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN` / `HTTP_REDIRECT_LISTEN` 改到 `0.0.0.0:443` / `0.0.0.0:80`,但 `genarrative-pingora-gateway.service` 启动失败,日志出现低端口绑定权限错误。 diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 92591ec19..0a1b0c84a 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -138,7 +138,7 @@ pipeline { stage('Archive') { steps { - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/database-backup-to-oss.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/production-health-patrol.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-current-release-audit.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-direct-rehearsal-status.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-status-snapshot.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-bundle.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-command-evidence.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-verify.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-audit.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-production-health-patrol-env.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-release-readiness.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-direct-preflight.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-direct-live.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-canary-live.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-canary-access-log-parity.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/production-api-deploy.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/maintenance-on.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/maintenance-off.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/systemd/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/nginx/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/env/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/pingora/**", fingerprint: true + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/api-server,build/${env.EFFECTIVE_BUILD_VERSION}/api-server.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/database-backup-to-oss.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/production-health-patrol.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-current-release-audit.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-direct-rehearsal-status.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-status-snapshot.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-bundle.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-command-evidence.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-verify.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-audit.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-production-health-patrol-env.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-release-readiness.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-direct-preflight.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-direct-live.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-canary-live.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/check-pingora-canary-access-log-parity.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/production-api-deploy.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/maintenance-on.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/maintenance-off.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-tls-cert-sync.mjs,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/systemd/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/nginx/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/env/**,build/${env.EFFECTIVE_BUILD_VERSION}/deploy/pingora/**", fingerprint: true script { if (params.INCLUDE_PINGORA_GATEWAY) { archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/pingora-gateway,build/${env.EFFECTIVE_BUILD_VERSION}/pingora-gateway.sha256", fingerprint: true diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index 39fc7e70b..e116474b1 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -66,7 +66,7 @@ pipeline { copyArtifacts( projectName: params.BUILD_JOB_NAME, selector: specific(params.BUILD_NUMBER_TO_DEPLOY), - filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json,build/${params.BUILD_VERSION}/scripts/database-backup-to-oss.mjs,build/${params.BUILD_VERSION}/scripts/ops/production-health-patrol.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-current-release-audit.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-direct-rehearsal-status.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-status-snapshot.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-bundle.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-command-evidence.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-verify.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-audit.mjs,build/${params.BUILD_VERSION}/scripts/check-production-health-patrol-env.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-release-readiness.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-direct-preflight.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-direct-live.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-canary-live.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-canary-access-log-parity.mjs,build/${params.BUILD_VERSION}/scripts/deploy/production-api-deploy.sh,build/${params.BUILD_VERSION}/scripts/deploy/maintenance-on.sh,build/${params.BUILD_VERSION}/scripts/deploy/maintenance-off.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs,build/${params.BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs,build/${params.BUILD_VERSION}/deploy/systemd/**,build/${params.BUILD_VERSION}/deploy/nginx/**,build/${params.BUILD_VERSION}/deploy/env/**,build/${params.BUILD_VERSION}/deploy/pingora/**", + filter: "build/${params.BUILD_VERSION}/api-server,build/${params.BUILD_VERSION}/api-server.sha256,build/${params.BUILD_VERSION}/release-manifest.json,build/${params.BUILD_VERSION}/scripts/database-backup-to-oss.mjs,build/${params.BUILD_VERSION}/scripts/ops/production-health-patrol.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-current-release-audit.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-direct-rehearsal-status.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-status-snapshot.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-bundle.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-command-evidence.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-verify.mjs,build/${params.BUILD_VERSION}/scripts/ops/pingora-cutover-evidence-audit.mjs,build/${params.BUILD_VERSION}/scripts/check-production-health-patrol-env.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-release-readiness.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-direct-preflight.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-direct-live.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-canary-live.mjs,build/${params.BUILD_VERSION}/scripts/check-pingora-canary-access-log-parity.mjs,build/${params.BUILD_VERSION}/scripts/deploy/production-api-deploy.sh,build/${params.BUILD_VERSION}/scripts/deploy/maintenance-on.sh,build/${params.BUILD_VERSION}/scripts/deploy/maintenance-off.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh,build/${params.BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs,build/${params.BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs,build/${params.BUILD_VERSION}/scripts/deploy/pingora-tls-cert-sync.mjs,build/${params.BUILD_VERSION}/deploy/systemd/**,build/${params.BUILD_VERSION}/deploy/nginx/**,build/${params.BUILD_VERSION}/deploy/env/**,build/${params.BUILD_VERSION}/deploy/pingora/**", target: '.', fingerprintArtifacts: true ) @@ -92,7 +92,7 @@ pipeline { sh ''' bash -lc ' set -euo pipefail - chmod +x "build/${BUILD_VERSION}/scripts/deploy/production-api-deploy.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-on.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-off.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs" "build/${BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs" + chmod +x "build/${BUILD_VERSION}/scripts/deploy/production-api-deploy.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-on.sh" "build/${BUILD_VERSION}/scripts/deploy/maintenance-off.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-direct-rollback.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-enable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-realpath-canary-disable.sh" "build/${BUILD_VERSION}/scripts/deploy/pingora-health-patrol-env-switch.mjs" "build/${BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs" "build/${BUILD_VERSION}/scripts/deploy/pingora-tls-cert-sync.mjs" "build/${BUILD_VERSION}/scripts/deploy/production-api-deploy.sh" \ --source-dir "build/${BUILD_VERSION}" \ --version "${BUILD_VERSION}" \ diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 4ab1e3ff1..948de009a 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -6098,6 +6098,12 @@ const checks = [ 'build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs', reason: 'API Build 归档必须包含 Pingora gateway env shadow 切换脚本。', }, + { + file: 'jenkins/Jenkinsfile.production-api-build', + includes: + 'build/${env.EFFECTIVE_BUILD_VERSION}/scripts/deploy/pingora-tls-cert-sync.mjs', + reason: 'API Build 归档必须包含 Pingora TLS 证书同步脚本。', + }, { file: 'jenkins/Jenkinsfile.production-api-build', includes: @@ -6246,6 +6252,12 @@ const checks = [ 'build/${params.BUILD_VERSION}/scripts/deploy/pingora-gateway-env-shadow-switch.mjs', reason: 'API Deploy 必须复制 build 产物内的 Pingora gateway env shadow 切换脚本。', }, + { + file: 'jenkins/Jenkinsfile.production-api-deploy', + includes: + 'build/${params.BUILD_VERSION}/scripts/deploy/pingora-tls-cert-sync.mjs', + reason: 'API Deploy 必须复制 build 产物内的 Pingora TLS 证书同步脚本。', + }, { file: 'jenkins/Jenkinsfile.production-api-deploy', includes: 'build/${params.BUILD_VERSION}/scripts/deploy/maintenance-off.sh', From b03b6b0c130ef8f67b15a2bc8634fdfedb6ea668 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 22 Jun 2026 19:09:55 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=BC=95=E5=AF=BC=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E5=88=9B=E4=BD=9C=E4=B8=BB=E9=A1=B5=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动端直达 /creation 时展示桌面端打开引导 补充移动端创作主页回归测试 同步创作主页与玩法链路文档口径 --- .../shared-memory/decision-log.md | 2 +- ...作】创作主页与项目入口改版计划-2026-06-18.md | 10 ++-- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 51 ++++++++++++++++++- ...gEntryFlowShell.agent.interaction.test.tsx | 22 +++++++- 5 files changed, 78 insertions(+), 9 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7724126d4..bc7c21bf1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -67,7 +67,7 @@ ## 2026-06-18 `/creation` 独立为陶泥儿创作工具主页 - 背景:图片画布项目已经成为独立项目资产,旧“创作”站内 Tab 和一级“草稿”入口不能清晰表达桌面端创作工具主页与项目管理入口。 -- 决策:桌面端新增独立 `/creation` 创作工具主页,桌面端直接打开站点首页 `/` 时也默认展示新版创作主页;顶级“创作”入口跳转 `/creation`,原“草稿”入口替换为“项目”并跳转 `/project`。移动端首页 `/` 继续保持原推荐首页,移动端隐藏“创作”和“项目”入口,但不拦截直接 URL。页面文案统一使用“陶泥儿”,外部品牌只作为设计参考,不进入用户可见 UI、测试名、产品文案或验收口径。`陶泥儿精选` 是用户素材瀑布流,不展示玩法入口列表;创作入口事实源继续来自 `/api/creation-entry/config`,项目入口通过 `createEditorProject` 和 `/editor/canvas?projectid=xxx` 链路进入画布。 +- 决策:桌面端新增独立 `/creation` 创作工具主页,桌面端直接打开站点首页 `/` 时也默认展示新版创作主页;顶级“创作”入口跳转 `/creation`,原“草稿”入口替换为“项目”并跳转 `/project`。移动端首页 `/` 继续保持原推荐首页,移动端隐藏“创作”和“项目”入口;移动端直达 `/creation` 时不加载创作主页,显示桌面端打开引导,`/creation/` 玩法工作台直达仍按原链路进入。页面文案统一使用“陶泥儿”,外部品牌只作为设计参考,不进入用户可见 UI、测试名、产品文案或验收口径。`陶泥儿精选` 是用户素材瀑布流,不展示玩法入口列表;创作入口事实源继续来自 `/api/creation-entry/config`,项目入口通过 `createEditorProject` 和 `/editor/canvas?projectid=xxx` 链路进入画布。 - 影响范围:平台入口导航、`SelectionStage` 路由、`/creation` 首页、`/project` 项目入口、`/editor/canvas` 项目打开链路、“我的”页快捷入口和创作入口相关测试。 - 验证方式:桌面 `/` 初始阶段和 `/creation` 解析为创作主页,移动端 `/` 仍是推荐首页,`/creation/` 仍进入对应玩法工作台;桌面导航显示“创作 / 项目”且不显示“草稿”;移动端底部不显示“创作 / 项目”;页面不出现外站品牌或 `Discord` 字样;新建项目进入 `/editor/canvas?projectid=xxx`。 - 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index a8d98456b..363ac3602 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -13,7 +13,7 @@ - 新增桌面端独立 `/creation` 创作工具主页。 - 桌面端直接打开站点首页 `/` 时默认展示新版创作主页内容;移动端继续保持原推荐首页。 - 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。 -- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口。 +- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。 - 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。 - `陶泥儿精选` 展示真实账号素材瀑布流,不使用 mock 素材。 - 现有 `/creation/` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。 @@ -128,7 +128,7 @@ Tab: - 桌面端从 `/creation` 切换到 `/project` 后,“项目”页签进入选中态,“创作”页签必须恢复为普通默认态,不保留创作主页的主按钮强调表现。 - `/project` 项目页头部必须提供 `返回主站` 按钮,点击后切回平台首页 `/`,行为与桌面侧栏切回推荐页保持一致。 - 移动端导航列表不展示“创作”和“项目”。 -- 移动端不拦截用户直接访问 `/creation` 或 `/project`。 +- 移动端直接访问 `/creation` 时显示桌面端打开引导,不加载创作工具主页;`/creation/` 继续直达对应玩法工作台。`/project` 暂不新增移动端拦截。 ## 实施拆分 @@ -137,7 +137,7 @@ Tab: - 增加 `/creation` 路由映射和测试。 - 增加桌面 `/` 初始阶段映射测试,确认不会影响移动端首页。 - 调整桌面导航文案与点击行为。 -- 移动端导航移除“创作”和“项目”入口。 +- 移动端导航移除“创作”和“项目”入口,并为直达 `/creation` 增加桌面端打开引导。 - 保持现有 `/creation/` 工作台、生成页、结果页和运行态路由不回归。 ### 创作主页 @@ -194,6 +194,7 @@ git diff --check - 桌面导航显示“项目”,不显示“草稿”。 - `/project` 头部点击“返回主站”回到 `/`,并退出项目页主内容。 - 移动视口底部不显示“创作”和“项目”。 +- 移动端直达 `/creation` 显示“请在桌面端打开创作主页”,点击“返回首页”回到 `/`。 - “我的”页没有项目快捷入口。 - 陶泥儿精选素材瀑布流不出现 mock 素材、假作者或假泥点成本。 - 陶泥儿精选素材卡按真实素材宽高呈现瀑布流,不退化为固定等高网格。 @@ -205,4 +206,5 @@ git diff --check - 不删除现有玩法草稿、作品架、生成恢复、发布或运行态能力。 - 不恢复前端硬编码创作入口配置。 - 不用 mock 素材填充用户素材展示。 -- 不在移动端禁止直接 URL 访问 `/creation` 或 `/project`。 +- 不影响 `/creation/` 玩法工作台、生成页、结果页和运行态直达恢复。 +- 不在本轮拦截移动端直接 URL 访问 `/project`。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 7b01094e4..66c975cff 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -8,6 +8,8 @@ 当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播;旧 `eventBanner` 只保留字段回显与旧客户端兼容,不再作为前端公告数组的兜底来源。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下显示 `creationTypes[].unifiedCreationSpec.mudPointCost` 经前端格式化后的泥点消耗、下方白底标题/描述”结构展示,旧契约缺少该字段时兜底 `10` 并由前端显示为 `10泥点数`,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。 +`/creation` 是桌面端创作工具主页;移动端直达 `/creation` 时只显示桌面端打开引导,不加载桌面创作主页和项目 / 素材数据。`/creation/` 仍按各玩法路由直达对应工作台、生成页或结果页,不受该引导影响。 + 旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片。 创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index d060c9c6e..b62f82cb3 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1,4 +1,4 @@ -import { Loader2 } from 'lucide-react'; +import { Home, Loader2, Monitor } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { @@ -1636,6 +1636,45 @@ function CreationResultRecoveryPanel({ ); } +function MobileCreationDesktopGuide({ + onReturnHome, +}: { + onReturnHome: () => void; +}) { + return ( +
+ + + +

+ 请在桌面端打开创作主页 +

+

+ 图片画布、项目管理和素材库需要更大的操作空间。 +

+ + +
+
+ ); +} + export function PlatformEntryFlowShellImpl({ selectionStage, setSelectionStage, @@ -15238,7 +15277,7 @@ export function PlatformEntryFlowShellImpl({ 'works-only', '正在加载草稿列表...', ); - const creationLandingContent = ( + const creationLandingContent = isDesktopLayout ? ( }> + ) : ( + { + pushAppHistoryPath('/'); + platformBootstrap.setPlatformTab('home'); + setSelectionStage('platform', { path: '/' }); + }} + /> ); const projectGalleryContent = ( }> diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index d2153e61a..c0dce9ae1 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -9267,13 +9267,22 @@ test('project page header can return to main site', async () => { expect(window.location.pathname).toBe('/'); }); -test('direct mobile creation home keeps landing content without mobile nav entry', async () => { +test('direct mobile creation home guides users to desktop without mobile nav entry', async () => { + const user = userEvent.setup(); window.history.replaceState(null, '', '/creation'); const { container } = render(); expect( - await screen.findByRole('main', { name: '陶泥儿创作主页' }), + await screen.findByRole('main', { name: '桌面端创作提示' }), ).toBeTruthy(); + expect(screen.getByText('请在桌面端打开创作主页')).toBeTruthy(); + expect( + screen.getByText('图片画布、项目管理和素材库需要更大的操作空间。'), + ).toBeTruthy(); + expect( + screen.queryByRole('main', { name: '陶泥儿创作主页' }), + ).toBeNull(); + const bottomNav = container.querySelector('.platform-bottom-nav'); expect(bottomNav).toBeTruthy(); expect( @@ -9282,6 +9291,15 @@ test('direct mobile creation home keeps landing content without mobile nav entry expect( within(bottomNav as HTMLElement).queryByRole('button', { name: '项目' }), ).toBeNull(); + + await user.click(screen.getByRole('button', { name: /返回首页/u })); + + await waitFor(() => { + expect(window.location.pathname).toBe('/'); + expect( + screen.queryByRole('main', { name: '桌面端创作提示' }), + ).toBeNull(); + }); }); test('embedded puzzle form timeout exits busy state and shows a readable error', async () => { From 97edb5cb1a228084abf3b2a5b463781534e8a962 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 22 Jun 2026 19:50:10 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E6=94=B9=E4=B8=BA=20Jenkins=20=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=20provision=20=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 Server-Provision 的 SOURCE_GIT_REMOTE_URL 参数和目标机 Git checkout 新增 Jenkins 构建节点准备 provision 文件并上传到目标 agent 执行 同步生产运维文档和共享记忆中的 provision 执行口径 --- .../shared-memory/decision-log.md | 15 +- docs/project-memory/shared-memory/pitfalls.md | 10 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 +- .../Jenkinsfile.production-server-provision | 221 ++++++++++-------- 4 files changed, 146 insertions(+), 108 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bc7c21bf1..a139bf74e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -246,8 +246,8 @@ ## 2026-06-19 Jenkins Git 源统一为内网 SSH - 背景:本地和 Jenkins 流水线改用 Gitea 的 `/git` 前缀内网入口后,继续在 Jenkinsfile 内保留 `http://127.0.0.1:3000/...` 主地址和 `https://git.genarrative.world/...` fallback 会让构建节点误走 localhost 或公网链路。 -- 决策:常规生产构建、数据库导入导出和 `Genarrative-Full-Build-And-Deploy` 的 Jenkinsfile 内部 checkout 统一使用 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`,并显式传入 Jenkins SSH 凭据 `genarrative-local-gitea-ssh`;不再把 `http://genarrative-station/git/GenarrativeAI/Genarrative.git` 作为默认主源或 fallback,也不再回退公网域名。`Genarrative-Server-Provision` 继续要求参数显式填写目标 agent 可访问的本机路径或内网 Git 源。 -- 影响范围:`jenkins/Jenkinsfile.production-api-build`、`jenkins/Jenkinsfile.production-web-build`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-full-build-and-deploy`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、生产 Jenkins live job SCM 配置和运维文档。 +- 决策:常规生产构建、数据库导入导出和 `Genarrative-Full-Build-And-Deploy` 的 Jenkinsfile 内部 checkout 统一使用 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`,并显式传入 Jenkins SSH 凭据 `genarrative-local-gitea-ssh`;不再把 `http://genarrative-station/git/GenarrativeAI/Genarrative.git` 作为默认主源或 fallback,也不再回退公网域名。`Genarrative-Server-Provision` 不再暴露 `SOURCE_GIT_REMOTE_URL` 参数,由 Jenkins 构建节点使用同一 SSH 源准备 provision 脚本和配置,再上传给目标部署 agent 执行。 +- 影响范围:`jenkins/Jenkinsfile.production-api-build`、`jenkins/Jenkinsfile.production-web-build`、`jenkins/Jenkinsfile.production-stdb-module-build`、`jenkins/Jenkinsfile.production-full-build-and-deploy`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`jenkins/Jenkinsfile.production-server-provision`、生产 Jenkins live job SCM 配置和运维文档。 - 验证方式:Jenkins 内部 `GitSCM checkout` 日志应显示使用 `genarrative-local-gitea-ssh`,不应出现 `No credentials specified`;`rg "git.genarrative.world|127.0.0.1:3000/GenarrativeAI/Genarrative.git|10.2.0.10/GenarrativeAI/Genarrative.git|genarrative-station/git/GenarrativeAI/Genarrative.git" jenkins scripts` 不应命中流水线源码;所有相关 Jenkinsfile 仍保留单分支 refspec、浅克隆、`noTags` 和 `honorRefspec`。 - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/pitfalls.md`。 @@ -255,7 +255,7 @@ - 背景:release / dev 目标 agent 需要从 dev 自托管 Gitea 拉取仓库;继续走 `https://git.genarrative.world/...` 会绕公网链路,`10.2.0.10:3000` 又受云侧端口策略影响不能作为稳定入口。 - 决策:dev 上 Gitea 进程保持 `HTTP_ADDR = 127.0.0.1`、`HTTP_PORT = 3000`,公网 `ROOT_URL = https://git.genarrative.world/` 不变;新增 Nginx 内网 vhost `/etc/nginx/conf.d/gitea-internal.conf`,只允许 `10.2.0.0/16` 与本机访问,并把 `http://10.2.0.10/` 反代到本机 Gitea。内网 agent 统一使用 `http://10.2.0.10/GenarrativeAI/Genarrative.git` 作为可直连 Git 源。 -- 影响范围:dev Gitea / Nginx 运维配置、Jenkins `SOURCE_GIT_REMOTE_URL`、release / dev 目标 agent checkout 口径。 +- 影响范围:dev Gitea / Nginx 运维配置、历史 `SOURCE_GIT_REMOTE_URL` 参数和旧 release / dev 目标 agent checkout 口径;`Genarrative-Server-Provision` 已于 2026-06-22 改为 Jenkins 上传脚本执行,目标 agent 不再直接 checkout Git。 - 验证方式:从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD` 应返回 HEAD;公网来源伪造 `Host: 10.2.0.10` 访问 dev 公网 80 应返回 `403`;`https://git.genarrative.world/` 原入口应保持 `200`。 - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 @@ -760,10 +760,11 @@ ## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链 - 背景:`Genarrative-Server-Provision` 的 `DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。 -- 决策:Server-Provision 只做服务器初始化,全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 与 `Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache;当前 OpenSSL 3.2 独立运行时自举会安装 `build-essential` 等最小工具,这是满足 api-server/libcurl 运行时符号的受控例外,不代表 provision 承担 api-server 构建职责。非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。 +- 决策:Server-Provision 只做服务器初始化,真实初始化阶段运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 与 `Provision Server` 在同一个目标 agent workspace 顺序执行,不再把 SpacetimeDB / otelcol 工具包放在 `linux && genarrative-build` 中转。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache;当前 OpenSSL 3.2 独立运行时自举会安装 `build-essential` 等最小工具,这是满足 api-server/libcurl 运行时符号的受控例外,不代表 provision 承担 api-server 构建职责。非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 必须使用 Jenkins controller 可访问的本机路径或内网 Git 源,不允许公网 Git fallback。 - 追加决策(2026-06-10):`Prepare Provision Tools` 必须先读取目标机现状,再准备需要的文件。目标机 `/usr/local/bin/otelcol-contrib` 版本匹配 `OTELCOL_VERSION` 时直接复用;`${SPACETIME_ROOT}/bin/current/spacetimedb-cli` 和 `spacetimedb-standalone` 存在且 CLI 版本匹配 `SPACETIME_EXPECTED_VERSION` 或 `SPACETIME_DOWNLOAD_ROOT` 中的版本时,直接复用当前安装生成 `provision-tools/`。只有目标机缺失、不可执行或版本不匹配时,才消费 `PROVISION_DOWNLOADS_DIR` 中的本地包或进入下载分支。 +- 追加决策(2026-06-22):Server-Provision 不再要求目标 agent 自己 checkout 仓库,也不再保留 `SOURCE_GIT_REMOTE_URL` 参数。流水线先在 `linux && genarrative-build` 节点使用固定内网 SSH 源 checkout 并通过 `scripts/jenkins-checkout-source.sh` 校验 `SOURCE_BRANCH` / `COMMIT_HASH`,随后只把 provision 脚本、`scripts/deploy/**`、`deploy/**` 和 `.jenkins-source-commit` stash / unstash 到目标 agent。目标 agent 只接收 Jenkins 上传的脚本和配置后执行 `Prepare Provision Tools` / `Provision Server`,不需要访问源码 Git remote。 - 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、生产运维文档、Server-Provision 排障口径。 -- 验证方式:Jenkins 日志中 Server-Provision 的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都在目标 dev / release agent 上执行;日志不出现 `Running on Jenkins`、`linux && genarrative-build`、`stash 'server-provision-tools'`、`Git 主地址拉取失败...改用备用地址`、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。 +- 验证方式:Jenkins 日志中 Server-Provision 的 `Prepare Provision Files` 在 `linux && genarrative-build` 上执行并使用 `genarrative-local-gitea-ssh`;`Provision Target` 下的 `Receive Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都在目标 dev / release agent 上执行;目标阶段日志不出现 Git checkout、`SOURCE_GIT_REMOTE_URL`、`Git 主地址拉取失败...改用备用地址`、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。 - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## 2026-06-05 api-server 重启先摘流再排空并持久化 outbox @@ -1374,14 +1375,14 @@ - 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 SpacetimeDB、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟,不替换当前生产 `systemd + Nginx + Jenkins` 路径。 - 服务器模拟参数:2026-05-18 通过 `ssh genarrative-release` 采样,目标机器为 2 vCPU / 约 2 GiB RAM / Ubuntu 24.04 / Nginx `worker_connections=768`;容器方案按待发布运行口径使用 `nofile=4096`,并在 compose 中限制 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`;Collector 镜像默认使用 `otel/opentelemetry-collector-contrib:0.151.0`。 - 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git;生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。 -- 生产 Collector:server-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置,但二进制由 Jenkins 构建机先准备 `provision-tools/otelcol-contrib` 再上传到 release 部署 agent,目标机不从 GitHub 下载;api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。 +- 生产 Collector:server-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置;当前二进制准备在目标部署 agent 的 `Prepare Provision Tools` 阶段完成,先复用目标机已有 `otelcol-contrib`,缺失或版本不匹配时再按 `PROVISION_DOWNLOADS_DIR` / `PROVISION_DOWNLOAD_PROXY` / 下载源准备。Jenkins 构建节点只上传 provision 脚本与配置,不上传 `provision-tools/otelcol-contrib`;api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。 - 影响范围:`deploy/container/`、`scripts/container-compose.mjs`、`package.json` 容器命令、开发运维文档和容器 build context 排除规则。 - 验证方式:执行 `npm run container:config` 展开 compose 配置;需要真实运行时再执行 `npm run container:build`、`npm run container:up`、`npm run container:k6`,并结合容器 Nginx log 与 OTLP debug exporter 判断瓶颈。 - 关联文档:`deploy/container/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## 2026-05-19 生产 provision 改为 Windows 下载包后由目标机本地安装 -- 后续更新:该口径已被 `2026-06-01 生产 Jenkins 流水线统一改为 Linux 优先并先查 localhost` 取代;当前 `Genarrative-Server-Provision` 不再走 Windows 下载阶段,而是在 Linux build 节点直接准备 `provision-tools/`。 +- 后续更新:该口径已被后续 Linux provision 口径取代;当前 `Genarrative-Server-Provision` 不再走 Windows 下载阶段,也不在 Linux build 节点准备 `provision-tools/`。Jenkins 构建节点只准备并上传 provision 脚本和配置,SpacetimeDB / otelcol 工具包在目标部署 agent 的 `Prepare Provision Tools` 阶段按目标机现状生成。 - 背景:当前 `development` provision 目标实际就是 Linux agent `genarrative-build-01`,之前把 `Prepare Provision Tools` 放在 `linux && genarrative-build` 会让目标机自己连 GitHub 和 `install.spacetimedb.com`,违背“Windows 本机先下载再传到目标机”的运维要求。 - 决策:`Genarrative-Server-Provision` 拆成 Windows 下载阶段和 Linux 目标机安装阶段。Windows 节点的 `Download Provision Tool Archives` 只下载 `spacetime-x86_64-unknown-linux-gnu.tar.gz` 和 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`,通过 `stash/unstash` 传到目标 Linux 节点;目标机执行 `scripts/prepare-server-provision-tools.sh` 时设置 `PROVISION_REQUIRE_LOCAL_DOWNLOADS=true`,只消费已下载件生成 `provision-tools/`,缺包直接失败,不回退外网下载。 - 追加决策:Server-Provision 的 Windows helper 不再对 Jenkins `writeFile` 刚写出的 `.ps1` 做原地 UTF-8 BOM 重写,而是由显式 `powershell.exe` 按 UTF-8 读入脚本文本,并用 `ScriptBlock::Create(...)` 在内存中执行;这样既保留中文脚本内容,又避免同一个 workspace 脚本被立即重写时触发 `拒绝访问`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b38971d58..b525f9585 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1645,12 +1645,12 @@ - 验证:运行 `git ls-files --stage scripts/prepare-server-provision-tools.sh`,确认 mode 为 `100755`;重新跑 `Genarrative-Server-Provision` 时应进入工具下载/打包日志,而不是停在 `Permission denied`。 - 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`scripts/jenkins-checkout-source.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 -## Server-Provision 工具准备只在目标部署 agent 内做一次 +## Server-Provision 目标机只接收并执行 Jenkins 上传的脚本 -- 现象:`Genarrative-Server-Provision` 选择 `DEPLOY_TARGET=development` 时如果阶段跑在 `Running on Jenkins` 或 `linux && genarrative-build`,真实 provision 会落到构建机 / controller,而不是 dev 服务器。 -- 原因:Server-Provision 是服务器初始化流水线,dev / release 都是目标服务器,不应把 development 当成 build 节点预览目标,也不应通过 build 节点 stash 工具包再切回目标机;同时公网 Git fallback 会让目标 agent 内网源不可达时悄悄改从公网拉源码,掩盖服务器路由问题。 -- 处理:Server-Provision 全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 和 `Provision Server` 在同一个目标 agent workspace 内顺序执行,不再使用 `linux && genarrative-build`,也不再 `stash/unstash` 工具包。Job 的 `Pipeline script from SCM` 与参数 `SOURCE_GIT_REMOTE_URL` 都必须指向本机路径或内网 Git 源,不允许 `https://git.genarrative.world/...` 公网地址。 -- 验证:Jenkins 日志中 `Provision Target` 下的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都应运行在目标 dev / release agent;日志不应出现 `stash 'server-provision-tools'`、目标阶段 `unstash`、`Git 主地址拉取失败...改用备用地址` 或 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。 +- 现象:`Genarrative-Server-Provision` 选择 `DEPLOY_TARGET=development` / `release` 时,目标阶段仍要求填写 `SOURCE_GIT_REMOTE_URL`,或在目标 dev / release agent 上执行 Git checkout。 +- 原因:旧流水线要求目标 agent 自己拉取 provision 脚本,导致服务器初始化依赖目标机到 Git remote 的网络可达性;公网 Git fallback 还会让目标 agent 内网源不可达时悄悄改从公网拉源码,掩盖路由问题。新口径改为 Jenkins 构建节点准备并上传脚本,目标机只接收和执行。 +- 处理:`Prepare Provision Files` 在 `linux && genarrative-build` 上使用固定内网 SSH 源和 Jenkins 凭据 `genarrative-local-gitea-ssh` checkout / 校验 `SOURCE_BRANCH` / `COMMIT_HASH`,并把 provision 脚本、`scripts/deploy/**`、`deploy/**` 和 `.jenkins-source-commit` stash 给目标 agent。`Provision Target` 下的 `Receive Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 必须运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。目标 agent 不再需要 `SOURCE_GIT_REMOTE_URL`,也不再 checkout Git。 +- 验证:Jenkins 日志中应先看到 `Prepare Provision Files` 在 `linux && genarrative-build` 上完成源码准备和 `stash 'server-provision-files'`,再看到 `Provision Target` 下的 `Receive Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 在目标 dev / release agent 上运行;目标阶段日志不应出现 Git checkout、`SOURCE_GIT_REMOTE_URL`、`Git 主地址拉取失败...改用备用地址` 或 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。 - 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## Server-Provision 不要无条件下载工具包 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index fc69301be..ac17e3b2d 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -373,7 +373,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. `Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。 -生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。构建类流水线的 Jenkinsfile 内部 checkout 统一使用 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`,并显式传入 Jenkins SSH 凭据 `genarrative-local-gitea-ssh`;不再配置 `https://git.genarrative.world/...` 公网 fallback,也不再默认使用 `http://genarrative-station/git/GenarrativeAI/Genarrative.git`。所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。API / Web / Stdb 发布类流水线不在目标机器 checkout Git,统一执行上游构建归档里的部署脚本,避免产物 commit 与部署脚本 commit 漂移。 +生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。构建类流水线和 `Genarrative-Server-Provision` 的 Jenkinsfile 内部源码准备阶段统一使用 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`,并显式传入 Jenkins SSH 凭据 `genarrative-local-gitea-ssh`;不再配置 `https://git.genarrative.world/...` 公网 fallback,也不再默认使用 `http://genarrative-station/git/GenarrativeAI/Genarrative.git`。所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。API / Web / Stdb 发布类流水线不在目标机器 checkout Git,统一执行上游构建归档里的部署脚本,避免产物 commit 与部署脚本 commit 漂移;Server-Provision 也不在目标 dev / release agent checkout Git,而是由 Jenkins 构建节点先准备 provision 脚本与配置并上传给目标 agent。 当前 Jenkins / 本机内网 Git 入口固定为 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`,用于 controller、构建节点和本机 Agent 直接拉取仓库,避免绕公网 `git.genarrative.world`。验证时在具备对应 SSH key 和 known_hosts 的环境执行 `git ls-remote ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git HEAD`,应能返回 HEAD。若机器仍保留旧的 `http://genarrative-station/git/GenarrativeAI/Genarrative.git` 或 `http://10.2.0.10/GenarrativeAI/Genarrative.git` 内网入口,只作为历史兼容和排障参考,新流水线不再默认使用。 @@ -383,7 +383,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. `Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上,Checkout 与 Build 都走 bash + cargo + sccache,不再依赖 Windows PowerShell 或 Git Bash;Stdb module 的 `CARGO_HOME`、`CARGO_TARGET_DIR` 和 `SCCACHE_DIR` 默认落在稳定缓存根 `~/caches/genarrative-jenkins/stdb-module` 下,可用 `GENARRATIVE_STDB_CACHE_ROOT` 覆盖,避免 `WORKSPACE@tmp` 被清理后无改动也触发近似冷构建。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。 -`Genarrative-Server-Provision` 只做服务器初始化,不再承担构建职责。流水线全程运行在目标服务器 agent:`DEPLOY_TARGET=development` 使用 `linux && genarrative-dev-deploy`,`DEPLOY_TARGET=release` 使用 `linux && genarrative-release-deploy`;`Prepare Provision Tools` 也在同一个目标 agent 工作区内准备 SpacetimeDB 与 `otelcol-contrib` 交付件,不再切到 `linux && genarrative-build`,也不再 stash 给后续阶段。`SOURCE_GIT_REMOTE_URL` 必须显式填写为目标 agent 可访问的本机路径、`file:///` 地址、localhost / 127.0.0.1、RFC1918 内网 HTTP Git 地址、单标签内网主机名或 `.local` / `.lan` / `.internal` 地址;这条流水线不配置公网 Git 备用地址,目标 agent 拉不到内网源就应直接失败。真实初始化会写入 `/etc` / systemd / Nginx、创建系统用户并修改服务,目标 dev / release agent 非 dry-run 时都必须具备 root 权限。 +`Genarrative-Server-Provision` 只做服务器初始化,不再承担构建职责。流水线先在 `linux && genarrative-build` 节点读取指定 `SOURCE_BRANCH` / `COMMIT_HASH` 的仓库内容,只把 `scripts/jenkins-server-provision.sh`、`scripts/prepare-server-provision-tools.sh`、`scripts/deploy/**`、`deploy/**` 和 `.jenkins-source-commit` 打包上传给目标 agent;目标 agent 不再接收 `SOURCE_GIT_REMOTE_URL`,也不再 checkout Git。真正会读取目标机现状和写系统配置的阶段仍只在目标服务器 agent 执行:`DEPLOY_TARGET=development` 使用 `linux && genarrative-dev-deploy`,`DEPLOY_TARGET=release` 使用 `linux && genarrative-release-deploy`;`Prepare Provision Tools` 与 `Provision Server` 在同一个目标 agent 工作区内顺序执行,前者准备 SpacetimeDB 与 `otelcol-contrib` 交付件,后者写入 `/etc` / systemd / Nginx、创建系统用户并修改服务。目标 dev / release agent 非 dry-run 时必须具备 root 权限。 生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。 @@ -398,8 +398,8 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. - `api-server` 正常运行时 `/healthz` 只返回进程存活状态,`/readyz` 会同时检查进程是否仍接收新流量和 SpacetimeDB 连接租约是否健康;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限。 - SpacetimeDB 健康检查默认使用 `GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS=2` 的短等待窗口,和业务 procedure 的 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 分开。`/readyz` 失败时 `details.spacetime.stage` 会标出当前卡住阶段:`pool_acquire`、`connect_build`、`connect_handshake`、`read_model_subscribe`、`procedure_result`、`reducer_result` 或 `read_cache`;`elapsedMs` / `timeoutMs` 用于确认是否命中健康检查窗口。业务请求日志也会写入 `operation_kind`、`operation_name`、`spacetime_stage` 和 `elapsed_ms`,后续 45 秒超时不再只靠 Nginx `request_time=45s` 推断。 - `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec` 和 `cat /proc/$(pidof api-server)/limits` 核对。 -- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转工具包。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:版本已满足时直接复用目标机现有文件生成 `provision-tools/`,只有缺失或版本不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备 SpacetimeDB `2.5.0` / `otelcol-contrib 0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。 -- 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源。 +- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点只负责从内网 Git 源准备 provision 脚本和配置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:版本已满足时直接复用目标机现有文件生成 `provision-tools/`,只有缺失或版本不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备 SpacetimeDB `2.5.0` / `otelcol-contrib 0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。 +- 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源,目标部署 agent 也不再需要访问源码 Git remote。 - `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。该服务必须存在系统用户 / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,再补齐 `/etc/otelcol` 配置目录并重启服务。 - Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。 - 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。 diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 18343244e..268eff789 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -7,13 +7,17 @@ pipeline { buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } + environment { + GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git' + GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' + } + parameters { choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用 dev 服务器部署 agent,release 使用正式服务器部署 agent') booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') booleanParam(name: 'CONFIRM_PROVISION', defaultValue: false, description: '确认执行服务器初始化;未勾选时只允许 dry-run') booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置') - string(name: 'SOURCE_GIT_REMOTE_URL', defaultValue: '', description: '部署脚本 Git 来源;必须是目标 agent 可访问的内网/本机 Gitea 地址,不配置公网备用') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit') string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名') @@ -36,6 +40,115 @@ pipeline { } stages { + stage('Prepare Provision Files') { + agent { + label 'linux && genarrative-build' + } + steps { + script { + if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { + error('release provision 需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。') + } + if (!params.DRY_RUN && !params.CONFIRM_PROVISION) { + error('执行服务器初始化前必须勾选 CONFIRM_PROVISION;否则请保持 DRY_RUN=true。') + } + if (!params.SERVER_NAME?.trim()) { + error('SERVER_NAME 不能为空。') + } + if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { + error("SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${params.SERVER_NAME}") + } + def serverAliases = params.SERVER_ALIASES?.trim() + if (serverAliases) { + serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName -> + if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { + error("SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${aliasName}") + } + } + } + if (!params.PROVISION_TOOLS_DIR?.trim()) { + error('PROVISION_TOOLS_DIR 不能为空。') + } + if (!(params.PROVISION_TOOLS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_TOOLS_DIR.startsWith('/') || params.PROVISION_TOOLS_DIR.contains('..') || params.PROVISION_TOOLS_DIR.trim() == '.') { + error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}") + } + if (!params.PROVISION_DOWNLOADS_DIR?.trim()) { + error('PROVISION_DOWNLOADS_DIR 不能为空。') + } + if (!(params.PROVISION_DOWNLOADS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_DOWNLOADS_DIR.startsWith('/') || params.PROVISION_DOWNLOADS_DIR.contains('..') || params.PROVISION_DOWNLOADS_DIR.trim() == '.') { + error("PROVISION_DOWNLOADS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_DOWNLOADS_DIR}") + } + def provisionToolsDir = params.PROVISION_TOOLS_DIR.trim() + def provisionDownloadsDir = params.PROVISION_DOWNLOADS_DIR.trim() + if (provisionToolsDir == provisionDownloadsDir || provisionDownloadsDir.startsWith("${provisionToolsDir}/")) { + error("PROVISION_DOWNLOADS_DIR 不能等于或位于 PROVISION_TOOLS_DIR 内,否则目标机生成工具包时会删除下载缓存: ${provisionDownloadsDir}") + } + def provisionDownloadProxy = params.PROVISION_DOWNLOAD_PROXY?.trim() + if (provisionDownloadProxy && !(provisionDownloadProxy ==~ /^https?:\/\/\S+$/)) { + error("PROVISION_DOWNLOAD_PROXY 只能填写 http:// 或 https:// 开头的代理地址,当前值: ${params.PROVISION_DOWNLOAD_PROXY}") + } + if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) { + error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}") + } + if (!(params.SPACETIME_DOWNLOAD_ROOT?.trim() ==~ /^https?:\/\/\S+$/)) { + error('SPACETIME_DOWNLOAD_ROOT 不能为空。') + } + if (!(params.SPACETIME_TARGET_HOST?.trim() ==~ /^[0-9A-Za-z._-]+$/)) { + error("SPACETIME_TARGET_HOST 只能包含字母、数字、点号、下划线和短横线: ${params.SPACETIME_TARGET_HOST}") + } + def nginxMode = params.NGINX_CONFIG_MODE?.trim() + if (!(nginxMode in ['none', 'production-https', 'development-http'])) { + error("NGINX_CONFIG_MODE 只能是 none、production-https 或 development-http,当前值: ${params.NGINX_CONFIG_MODE}") + } + if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') { + error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。') + } + if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') { + error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。') + } + + def remoteConfig = [ + url: env.GIT_REMOTE_URL, + credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, + refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}", + ] + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + userRemoteConfigs: [remoteConfig], + ]) + } + withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + GIT_REMOTE_FALLBACK_URL="" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \ + GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?GENARRATIVE_GIT_SSH_KEY 不能为空} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ + scripts/jenkins-checkout-source.sh + ' + ''' + } + script { + env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() + echo "Provision 源码 commit=${env.SOURCE_COMMIT}" + } + stash name: 'server-provision-files', + includes: '.jenkins-source-commit,deploy/**,scripts/deploy/**,scripts/jenkins-checkout-source.sh,scripts/jenkins-server-provision.sh,scripts/prepare-server-provision-tools.sh', + allowEmpty: false + } + } + stage('Provision Target') { agent { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}" @@ -44,109 +157,33 @@ pipeline { stage('Prepare') { steps { script { - if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { - error('release provision 需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。') - } - if (!params.DRY_RUN && !params.CONFIRM_PROVISION) { - error('执行服务器初始化前必须勾选 CONFIRM_PROVISION;否则请保持 DRY_RUN=true。') - } - if (!params.SERVER_NAME?.trim()) { - error('SERVER_NAME 不能为空。') - } - def sourceGitRemoteUrl = params.SOURCE_GIT_REMOTE_URL?.trim() - if (!sourceGitRemoteUrl) { - error('SOURCE_GIT_REMOTE_URL 不能为空。') - } - def isLocalGitPath = sourceGitRemoteUrl ==~ /^\/[0-9A-Za-z._\/-]+$/ - def isLocalGitFileUrl = sourceGitRemoteUrl ==~ /^file:\/\/\/\S+$/ - def isPrivateHttpGitUrl = sourceGitRemoteUrl ==~ /^https?:\/\/(localhost|127(?:\.[0-9]{1,3}){3}|10(?:\.[0-9]{1,3}){3}|192\.168(?:\.[0-9]{1,3}){2}|172\.(?:1[6-9]|2[0-9]|3[0-1])(?:\.[0-9]{1,3}){2}|[A-Za-z0-9-]+|[A-Za-z0-9.-]+\.(?:local|lan|internal))(?::[0-9]+)?\/\S+$/ - if (!isLocalGitPath && !isLocalGitFileUrl && !isPrivateHttpGitUrl) { - error('Genarrative-Server-Provision 不允许使用公网 Git 仓库;SOURCE_GIT_REMOTE_URL 只能是目标 agent 可访问的本机路径、file:/// 地址、localhost/127.0.0.1、RFC1918 内网 HTTP 地址、单标签内网主机名或 .local/.lan/.internal 地址。') - } - env.EFFECTIVE_GIT_REMOTE_URL = sourceGitRemoteUrl - if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { - error("SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${params.SERVER_NAME}") - } - def serverAliases = params.SERVER_ALIASES?.trim() - if (serverAliases) { - serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName -> - if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { - error("SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${aliasName}") - } - } - } - if (!params.PROVISION_TOOLS_DIR?.trim()) { - error('PROVISION_TOOLS_DIR 不能为空。') - } - if (!(params.PROVISION_TOOLS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_TOOLS_DIR.startsWith('/') || params.PROVISION_TOOLS_DIR.contains('..') || params.PROVISION_TOOLS_DIR.trim() == '.') { - error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}") - } - if (!params.PROVISION_DOWNLOADS_DIR?.trim()) { - error('PROVISION_DOWNLOADS_DIR 不能为空。') - } - if (!(params.PROVISION_DOWNLOADS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_DOWNLOADS_DIR.startsWith('/') || params.PROVISION_DOWNLOADS_DIR.contains('..') || params.PROVISION_DOWNLOADS_DIR.trim() == '.') { - error("PROVISION_DOWNLOADS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_DOWNLOADS_DIR}") - } - def provisionToolsDir = params.PROVISION_TOOLS_DIR.trim() - def provisionDownloadsDir = params.PROVISION_DOWNLOADS_DIR.trim() - if (provisionToolsDir == provisionDownloadsDir || provisionDownloadsDir.startsWith("${provisionToolsDir}/")) { - error("PROVISION_DOWNLOADS_DIR 不能等于或位于 PROVISION_TOOLS_DIR 内,否则目标机生成工具包时会删除下载缓存: ${provisionDownloadsDir}") - } - def provisionDownloadProxy = params.PROVISION_DOWNLOAD_PROXY?.trim() - if (provisionDownloadProxy && !(provisionDownloadProxy ==~ /^https?:\/\/\S+$/)) { - error("PROVISION_DOWNLOAD_PROXY 只能填写 http:// 或 https:// 开头的代理地址,当前值: ${params.PROVISION_DOWNLOAD_PROXY}") - } - if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) { - error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}") - } - if (!(params.SPACETIME_DOWNLOAD_ROOT?.trim() ==~ /^https?:\/\/\S+$/)) { - error('SPACETIME_DOWNLOAD_ROOT 不能为空。') - } - if (!(params.SPACETIME_TARGET_HOST?.trim() ==~ /^[0-9A-Za-z._-]+$/)) { - error("SPACETIME_TARGET_HOST 只能包含字母、数字、点号、下划线和短横线: ${params.SPACETIME_TARGET_HOST}") - } - def nginxMode = params.NGINX_CONFIG_MODE?.trim() - if (!(nginxMode in ['none', 'production-https', 'development-http'])) { - error("NGINX_CONFIG_MODE 只能是 none、production-https 或 development-http,当前值: ${params.NGINX_CONFIG_MODE}") - } - if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') { - error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。') - } - if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') { - error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。') - } + echo "Provision 文件已由 Jenkins 构建节点准备并上传,目标 agent 只接收脚本后执行。" } } } - stage('Checkout Provision Files') { + stage('Receive Provision Files') { steps { - script { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [ - [$class: 'CleanBeforeCheckout'], - [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], - ], - userRemoteConfigs: [[url: env.EFFECTIVE_GIT_REMOTE_URL, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], - ]) - } sh ''' bash <<'BASH' set -euo pipefail - chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ - COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \ - GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL}" \ - SOURCE_COMMIT_FILE=".jenkins-source-commit" \ - scripts/jenkins-checkout-source.sh + rm -rf deploy scripts/deploy \ + scripts/jenkins-checkout-source.sh \ + scripts/jenkins-server-provision.sh \ + scripts/prepare-server-provision-tools.sh \ + .jenkins-source-commit +BASH + ''' + unstash 'server-provision-files' + sh ''' + bash <<'BASH' + set -euo pipefail + chmod +x scripts/prepare-server-provision-tools.sh scripts/jenkins-server-provision.sh BASH ''' script { env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() - echo "Provision 源码 commit=${env.SOURCE_COMMIT}" + echo "收到 Jenkins 上传的 provision 文件,源码 commit=${env.SOURCE_COMMIT}" } } } From d9cd2edf924f86059a8eb21c9cc580e9976cb51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Mon, 22 Jun 2026 20:13:23 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E6=94=B9=E9=80=A0=E6=97=B6=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E7=B3=BB=E7=BB=9F=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改造和重绘面板只从用户可见输入快照恢复提示词。 没有用户输入快照的旧资源打开改造时保持输入框为空。 补充图片、音频、视频、规范和宣发素材改造提示词恢复测试。 更新图片画布编辑器文档中的系统提示词隐藏规则。 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../ImageCanvasGenerationDialogModel.test.ts | 108 ++++++++++++++++-- .../ImageCanvasGenerationDialogModel.ts | 88 ++++++++------ .../useImageCanvasGenerationWorkflow.test.tsx | 31 ++++- 4 files changed, 183 insertions(+), 46 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 1e1441550..91471a614 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -17,7 +17,7 @@ - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。 +- 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑默认从原图模型和分辨率初始化,但提交使用面板当前选择的 `model/aspectRatio/imageSize`;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材必须按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作快速编辑固定使用 `seedance2.0-fast` 动作 / 视频模型。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图 / 原素材自动作为最后一张隐式参考提交,但不在参考图条里固定展示 `图x`。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交前将提示词里对原素材的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张源素材编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 20f4757e4..5b2a8d711 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -22,6 +22,7 @@ import { createIconGenerationDialogDraft, createPublicationGenerationDialogDraft, createQuickEditPanelDraft, + createRedrawPanelDraft, createSoundEffectGenerationDialogDraft, createSpecDialogDraft, createUiDesignGenerationDialogDraft, @@ -263,7 +264,7 @@ describe('ImageCanvasGenerationDialogModel', () => { title: '游戏音效', mediaType: 'audio', assetKind: 'sound-effect', - prompt: '金币收集音', + prompt: '【系统内置】完整音效提示词:金币收集音,自动混音,内部参数。', generationInputs: { fields: [ { title: 'prompt', value: '金币收集音' }, @@ -315,20 +316,19 @@ describe('ImageCanvasGenerationDialogModel', () => { expect(createAudioRedrawGenerationDialogDraft(createLayer())).toBeNull(); }); - it('uses audio prompt templates when remodeling old audio layers without prompts', () => { + it('keeps audio remodel prompts blank when no user input snapshot exists', () => { expect( createAudioRedrawGenerationDialogDraft( createLayer({ mediaType: 'audio', assetKind: 'sound-effect', - prompt: '', + prompt: '【系统内置】完整音效提示词', generationInputs: { fields: [], references: [] }, }), ), ).toMatchObject({ mode: 'audio-sound-effect', - prompt: - '短促清脆的游戏交互音效,带轻微闪光质感,干净收尾,适合按钮确认或收集奖励。', + prompt: '', }); expect( createAudioRedrawGenerationDialogDraft( @@ -336,26 +336,33 @@ describe('ImageCanvasGenerationDialogModel', () => { mediaType: 'audio', assetKind: 'background-music', title: '游戏背景音乐', - prompt: '', - generationInputs: { fields: [], references: [] }, + prompt: '【系统内置】完整背景音乐提示词', + generationInputs: { + fields: [{ title: 'duration', value: '10秒' }], + references: [], + }, }), ), ).toMatchObject({ mode: 'audio-background-music', - prompt: '适合轻松冒险游戏的循环背景音乐,温暖明亮,旋律简洁,不包含人声。', + prompt: '', }); }); it('creates edit, quick-edit, and character animation panel drafts', () => { const sourceLayer = createLayer({ - prompt: '原图提示', + prompt: '【系统内置】完整拼接提示词:原图提示,自动画质优化。', model: 'gpt-image-2', assetKind: 'character', + generationInputs: { + fields: [{ title: '生成提示词', value: '原图提示' }], + references: [], + }, }); expect(createEditDialogDraft(sourceLayer)).toEqual({ mode: 'edit', - prompt: '原图提示,在保持主体结构的基础上优化画面细节', + prompt: '原图提示', status: 'idle', composerOpen: true, sourceLayerId: 'layer-source', @@ -371,6 +378,7 @@ describe('ImageCanvasGenerationDialogModel', () => { quickEditReferences: [], status: 'idle', }); + expect(createRedrawPanelDraft(sourceLayer).prompt).toBe('原图提示'); expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({ sourceLayerId: 'layer-source', promptText: '', @@ -383,6 +391,86 @@ describe('ImageCanvasGenerationDialogModel', () => { expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull(); }); + it('restores remodel prompts from user-visible generation input snapshots only', () => { + const cases: Array<{ + fields: NonNullable['fields']; + expectedPrompt: string; + }> = [ + { + fields: [{ title: '视频描述', value: '用户的视频描述' }], + expectedPrompt: '用户的视频描述', + }, + { + fields: [{ title: '角色设定', value: '用户的角色设定' }], + expectedPrompt: '用户的角色设定', + }, + { + fields: [{ title: '用户输入', value: '用户的 UI 需求' }], + expectedPrompt: '用户的 UI 需求', + }, + { + fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }], + expectedPrompt: '返回按钮\n设置按钮', + }, + { + fields: [{ title: '自定义规范提示词', value: '用户自定义规范' }], + expectedPrompt: '用户自定义规范', + }, + { + fields: [ + { title: '玩法设定', value: '平台跳跃' }, + { title: '美术风格', value: '像素风' }, + ], + expectedPrompt: '玩法设定:平台跳跃\n美术风格:像素风', + }, + { + fields: [ + { title: '游戏名', value: '星海远征' }, + { title: '游戏分类', value: '冒险' }, + { title: '一句话描述游戏', value: '驾驶飞船探索星云' }, + ], + expectedPrompt: + '游戏名:星海远征\n游戏分类:冒险\n一句话描述游戏:驾驶飞船探索星云', + }, + { + fields: [{ title: '动作描述', value: '待机动作' }], + expectedPrompt: '待机动作', + }, + ]; + + for (const testCase of cases) { + expect( + createRedrawPanelDraft( + createLayer({ + prompt: '【系统内置】完整拼接提示词,包含默认规则和模型约束。', + generationInputs: { + fields: testCase.fields, + references: [], + }, + }), + ).prompt, + ).toBe(testCase.expectedPrompt); + } + + expect( + createRedrawPanelDraft( + createLayer({ + prompt: '【系统内置】固定素材提取提示词', + generationInputs: { + fields: [ + { + title: '提取提示词', + value: '仅提取被红色框框选的素材并整理成spritesheet', + }, + { title: 'model', value: 'gpt-image-2' }, + ], + references: [], + }, + }), + ).prompt, + ).toBe(''); + }); + it('normalizes legacy nanobanana model aliases in quick edit drafts', () => { expect( createQuickEditPanelDraft( diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index b05b7f638..bef1636d4 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -416,37 +416,62 @@ function findGenerationInputFieldValue( ); } -function resolveDefaultAudioPrompt( - mode: Extract< - GenerateDialogState['mode'], - 'audio-sound-effect' | 'audio-background-music' - >, -) { - return mode === 'audio-background-music' - ? DEFAULT_BACKGROUND_MUSIC_PROMPT - : DEFAULT_SOUND_EFFECT_PROMPT; +const USER_PROMPT_INPUT_TITLES = new Set( + [ + '生成提示词', + '视频描述', + 'prompt', + 'sound', + 'gpt_description_prompt', + '音效提示词', + '背景音乐提示词', + '角色设定', + '用户输入', + '素材描述', + '自定义规范提示词', + '修改要求', + '快速编辑提示词', + '重绘提示词', + '动作描述', + ].map((title) => title.toLowerCase()), +); + +const STRUCTURED_USER_INPUT_TITLES = new Set( + [ + '玩法设定', + '美术风格', + '头身比', + '角色视角', + '游戏名', + '游戏分类', + '一句话描述游戏', + ].map((title) => title.toLowerCase()), +); + +function resolveUserGenerationPromptSnapshot(sourceLayer: CanvasLayer) { + const fields = sourceLayer.generationInputs?.fields ?? []; + const promptField = fields.find((field) => + USER_PROMPT_INPUT_TITLES.has(field.title.trim().toLowerCase()), + ); + const promptValue = promptField?.value.trim(); + if (promptValue) { + return promptValue; + } + + const structuredLines = fields.flatMap((field) => { + if (!STRUCTURED_USER_INPUT_TITLES.has(field.title.trim().toLowerCase())) { + return []; + } + const value = field.value.trim(); + return value ? [`${field.title.trim()}:${value}`] : []; + }); + return structuredLines.join('\n'); } function resolveAudioRedrawPrompt( sourceLayer: CanvasLayer, - mode: Extract< - GenerateDialogState['mode'], - 'audio-sound-effect' | 'audio-background-music' - >, ) { - return ( - sourceLayer.prompt?.trim() || - findGenerationInputFieldValue(sourceLayer, [ - 'prompt', - 'sound', - 'gpt_description_prompt', - '生成提示词', - '音效提示词', - '背景音乐提示词', - ])?.trim() || - sourceLayer.generationInputs?.fields[0]?.value?.trim() || - resolveDefaultAudioPrompt(mode) - ); + return resolveUserGenerationPromptSnapshot(sourceLayer); } function resolveAudioRedrawMode(sourceLayer: CanvasLayer) { @@ -493,7 +518,7 @@ export function createAudioRedrawGenerationDialogDraft( const baseDraft = { mode, sourceLayerId: sourceLayer.id, - prompt: resolveAudioRedrawPrompt(sourceLayer, mode), + prompt: resolveAudioRedrawPrompt(sourceLayer), status: 'idle' as const, composerOpen: true, placeholder: { @@ -562,9 +587,7 @@ export function createEditDialogDraft( ): GenerateDialogState { return { mode: 'edit', - prompt: sourceLayer.prompt - ? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节` - : '', + prompt: resolveUserGenerationPromptSnapshot(sourceLayer), status: 'idle', composerOpen: true, sourceLayerId: sourceLayer.id, @@ -646,10 +669,7 @@ export function createRedrawPanelDraft( return { ...createQuickEditPanelDraft(sourceLayer), mode: 'redraw', - prompt: - sourceLayer.prompt?.trim() || - sourceLayer.generationInputs?.fields[0]?.value?.trim() || - '', + prompt: resolveUserGenerationPromptSnapshot(sourceLayer), }; } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index db5acb35b..288df5b06 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -168,6 +168,7 @@ function GenerationWorkflowHarness({ ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}` : '-'} + {activeDialog?.prompt || '-'} {dialogs.canvasGenerationDialogs .map( @@ -256,7 +257,7 @@ function GenerationWorkflowHarness({ height: 120, originalWidth: 420, originalHeight: 120, - prompt: '金币跳出音', + prompt: '【系统内置】完整音效提示词:金币跳出音,内部混音参数。', generationInputs: { fields: [ { title: 'prompt', value: '金币跳出音' }, @@ -296,6 +297,23 @@ function GenerationWorkflowHarness({ > 打开图片改造 + + + {frames.length ? frameIndex + 1 : 0}/{frames.length || 0} + + + + ); +} + export function ImageCanvasWorldView({ viewport, snapGuide, @@ -730,14 +846,13 @@ export function ImageCanvasWorldView({ generateDialog.sourceLayerId === layer.id ? '修改中' : null; - const LayerElement = - layer.mediaType === 'audio' || layer.mediaType === 'video' - ? 'div' - : 'button'; + const isMediaContainer = isCanvasMediaContainerLayer(layer); + const LayerElement = isMediaContainer ? 'div' : 'button'; + const metadataMediaLabel = getLayerMetadataMediaLabel(layer); return (
onLayerMouseEnter(layer.id)} onMouseLeave={() => onLayerMouseLeave(layer.id)} @@ -778,6 +891,11 @@ export function ImageCanvasWorldView({ layer={layer} mediaTransform={mediaTransform} /> + ) : layer.mediaType === 'image-sequence' ? ( + ) : ( } style={inverseScaleStyle} onClick={(event) => { diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index 795a98e5a..694ffde12 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -77,10 +77,11 @@ describe('useImageCanvasAssetExportWorkflow', () => { it('exports canvas assets into a zip with metadata and failed image records', async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { - if (url === '/generated-ok.png') { + const pathname = new URL(url, 'http://localhost').pathname; + if (pathname === '/generated-ok.png') { return new Response(new Blob(['generated'], { type: 'image/png' })); } - if (url === '/generated-video.mp4') { + if (pathname === '/generated-video.mp4') { return new Response(new Blob(['video'], { type: 'video/mp4' })); } return new Response(null, { status: 404 }); @@ -144,10 +145,35 @@ describe('useImageCanvasAssetExportWorkflow', () => { sourceType: 'generated', zIndex: 5, }), + createLayer('sequence', { + title: '角色动作', + src: 'data:image/png;base64,c2VxMQ==', + mediaType: 'image-sequence', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + frameIndex: 1, + imageSrc: 'data:image/png;base64,c2VxMQ==', + width: 1024, + height: 1024, + }, + { + frameIndex: 2, + imageSrc: 'data:image/png;base64,c2VxMg==', + width: 1024, + height: 1024, + }, + ], + durationSeconds: 4, + previewVideoPath: + '/generated-character-drafts/editor/preview.mp4', + sourceType: 'generated', + zIndex: 6, + }), createLayer('failed', { title: '失败图', src: '/missing.png', - zIndex: 6, + zIndex: 7, }), ]} />, @@ -165,13 +191,26 @@ describe('useImageCanvasAssetExportWorkflow', () => { expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy(); expect(zip.file('导出项目-画布素材/media/003-生成视频.mp4')).toBeTruthy(); expect(zip.file('导出项目-画布素材/media/004-胜利音效.wav')).toBeTruthy(); - expect(zip.file('导出项目-画布素材/images/005-失败图.png')).toBeNull(); + expect( + zip.file( + '导出项目-画布素材/sequences/005-角色动作/frames/frame-01.png', + ), + ).toBeTruthy(); + expect( + zip.file( + '导出项目-画布素材/sequences/005-角色动作/frames/frame-02.png', + ), + ).toBeTruthy(); + expect( + zip.file('导出项目-画布素材/sequences/005-角色动作/manifest.txt'), + ).toBeTruthy(); + expect(zip.file('导出项目-画布素材/images/006-失败图.png')).toBeNull(); const metadata = JSON.parse( await readZipText(zip, '导出项目-画布素材/metadata.json'), ); expect(metadata.projectId).toBe('project-export'); - expect(metadata.layers).toHaveLength(6); + expect(metadata.layers).toHaveLength(7); expect(metadata.layers[0].file).toBe('images/001-素材 A.png'); expect(metadata.layers[1].file).toBe('images/001-素材 A.png'); expect(metadata.layers[2].mediaType).toBe('image'); @@ -184,8 +223,12 @@ describe('useImageCanvasAssetExportWorkflow', () => { expect(metadata.layers[3].mediaType).toBe('video'); expect(metadata.layers[4].file).toBe('media/004-胜利音效.wav'); expect(metadata.layers[4].mediaType).toBe('audio'); - expect(metadata.layers[5].file).toBeNull(); - expect(metadata.layers[5].exportError).toContain('404'); + expect(metadata.layers[5].file).toBe( + 'sequences/005-角色动作/manifest.txt', + ); + expect(metadata.layers[5].mediaType).toBe('image-sequence'); + expect(metadata.layers[6].file).toBeNull(); + expect(metadata.layers[6].exportError).toContain('404'); expect(metadata.failedImages).toHaveLength(1); expect( await readZipText(zip, '导出项目-画布素材/manifest.txt'), @@ -228,7 +271,9 @@ describe('useImageCanvasAssetExportWorkflow', () => { ]} />, ); - fireEvent.click(screen.getAllByRole('button', { name: '导出单图' }).at(-1)!); + fireEvent.click( + screen.getAllByRole('button', { name: '导出单图' }).at(-1)!, + ); expect(downloadName).toBe('视频导出.webm'); @@ -243,4 +288,69 @@ describe('useImageCanvasAssetExportWorkflow', () => { ); }); }); + + it('downloads character animation layers as a zipped image sequence', async () => { + let exportedBlob: Blob | null = null; + let downloadName = ''; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + exportedBlob = blob; + return 'blob:sequence-export'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + function click(this: HTMLAnchorElement) { + downloadName = this.download; + }, + ); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出单图' })); + + await waitFor(() => { + expect(exportedBlob).toBeTruthy(); + }); + expect(downloadName).toBe('角色动作 跑步-序列帧.zip'); + const zip = await JSZip.loadAsync(exportedBlob!); + expect(zip.file('frames/frame-01.png')).toBeTruthy(); + expect(zip.file('frames/frame-02.png')).toBeTruthy(); + expect(await readZipText(zip, 'manifest.txt')).toContain('类型:序列帧'); + } finally { + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); }); diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index 515c0d4a4..a44b58060 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -10,9 +10,12 @@ import { blobToUint8Array, buildLayerExportMetadata, formatExportDate, + getImageSequenceFrameFileName, getLayerAssetExtensionFromTypeOrSrc, getLayerExportKey, + getLayerImageSequenceFrames, readLayerAssetBlob, + readLayerImageSequenceFrameBlob, sanitizeExportFilePart, } from './ImageCanvasExportModel'; @@ -27,6 +30,92 @@ type UseImageCanvasAssetExportWorkflowOptions = { projectTitle: string; }; +function triggerBrowserDownload(blob: Blob, downloadName: string) { + if ( + typeof URL.createObjectURL !== 'function' || + typeof URL.revokeObjectURL !== 'function' + ) { + return false; + } + const downloadUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = downloadName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(downloadUrl); + return true; +} + +async function buildImageSequenceZip(layer: CanvasLayer) { + const frames = getLayerImageSequenceFrames(layer); + if (!frames.length) { + throw new Error('序列帧为空'); + } + const zip = new JSZip(); + const framesFolder = zip.folder('frames') ?? zip; + const failedFrames: Array<{ + frameIndex: number; + imageSrc: string; + error: string; + }> = []; + let successCount = 0; + + for (const [index, frame] of frames.entries()) { + try { + const blob = await readLayerImageSequenceFrameBlob(layer, frame); + framesFolder.file( + getImageSequenceFrameFileName(frame, index, blob.type), + await blobToUint8Array(blob), + ); + successCount += 1; + } catch (error) { + failedFrames.push({ + frameIndex: frame.frameIndex || index + 1, + imageSrc: frame.imageSrc, + error: error instanceof Error ? error.message : '序列帧读取失败', + }); + } + } + + if (successCount === 0) { + throw new Error('序列帧读取失败'); + } + + zip.file( + 'metadata.json', + JSON.stringify( + { + title: layer.title, + taskId: layer.taskId ?? null, + mediaType: 'image-sequence', + frameCount: frames.length, + exportedFrameCount: successCount, + durationSeconds: layer.durationSeconds ?? null, + previewVideoPath: layer.previewVideoPath ?? null, + failedFrames, + }, + null, + 2, + ), + ); + zip.file( + 'manifest.txt', + [ + `素材:${layer.title}`, + `类型:序列帧`, + `帧数:${successCount}/${frames.length}`, + layer.durationSeconds ? `时长:${layer.durationSeconds}s` : null, + failedFrames.length ? `失败帧数:${failedFrames.length}` : null, + ] + .filter(Boolean) + .join('\n'), + ); + + return zip.generateAsync({ type: 'blob' }); +} + export function useImageCanvasAssetExportWorkflow({ layers, projectId, @@ -40,6 +129,28 @@ export function useImageCanvasAssetExportWorkflow({ if (!layer) { return; } + if (layer.mediaType === 'image-sequence') { + void buildImageSequenceZip(layer) + .then((zipBlob) => { + const downloaded = triggerBrowserDownload( + zipBlob, + `${sanitizeExportFilePart(layer.title, '角色动作')}-序列帧.zip`, + ); + if (!downloaded) { + setAssetExportStatus({ + tone: 'error', + message: '当前浏览器不支持素材下载', + }); + } + }) + .catch(() => { + setAssetExportStatus({ + tone: 'error', + message: '序列帧导出失败', + }); + }); + return; + } const link = document.createElement('a'); link.href = layer.src; const extension = getLayerAssetExtensionFromTypeOrSrc( @@ -101,6 +212,93 @@ export function useImageCanvasAssetExportWorkflow({ : baseFileName; try { + if (layer.mediaType === 'image-sequence') { + const frames = getLayerImageSequenceFrames(layer); + if (!frames.length) { + throw new Error('序列帧为空'); + } + const sequenceFolderName = `sequences/${indexedFileName}`; + const sequenceFolder = + rootFolder.folder(sequenceFolderName) ?? rootFolder; + const framesFolder = + sequenceFolder.folder('frames') ?? sequenceFolder; + const failedFrames: Array<{ + frameIndex: number; + imageSrc: string; + error: string; + }> = []; + let successCount = 0; + + for (const [frameArrayIndex, frame] of frames.entries()) { + try { + const frameBlob = await readLayerImageSequenceFrameBlob( + layer, + frame, + ); + framesFolder.file( + getImageSequenceFrameFileName( + frame, + frameArrayIndex, + frameBlob.type, + ), + await blobToUint8Array(frameBlob), + ); + successCount += 1; + } catch (error) { + failedFrames.push({ + frameIndex: frame.frameIndex || frameArrayIndex + 1, + imageSrc: frame.imageSrc, + error: + error instanceof Error ? error.message : '序列帧读取失败', + }); + } + } + + if (successCount === 0) { + throw new Error('序列帧读取失败'); + } + + sequenceFolder.file( + 'metadata.json', + JSON.stringify( + { + layerId: layer.id, + title: layer.title, + mediaType: 'image-sequence', + taskId: layer.taskId ?? null, + frameCount: frames.length, + exportedFrameCount: successCount, + durationSeconds: layer.durationSeconds ?? null, + previewVideoPath: layer.previewVideoPath ?? null, + failedFrames, + }, + null, + 2, + ), + ); + sequenceFolder.file( + 'manifest.txt', + [ + `素材:${layer.title}`, + `类型:序列帧`, + `帧数:${successCount}/${frames.length}`, + layer.durationSeconds + ? `时长:${layer.durationSeconds}s` + : null, + failedFrames.length ? `失败帧数:${failedFrames.length}` : null, + ] + .filter(Boolean) + .join('\n'), + ); + imageByKey.set(key, { + key, + file: `${sequenceFolderName}/manifest.txt`, + layer, + blob: new Blob([], { type: 'application/zip' }), + }); + continue; + } + const blob = await readLayerAssetBlob(layer); const mediaType = layer.mediaType ?? 'image'; const extension = getLayerAssetExtensionFromTypeOrSrc( @@ -109,7 +307,8 @@ export function useImageCanvasAssetExportWorkflow({ layer.objectKey ?? layer.src, ); const folderName = mediaType === 'image' ? 'images' : 'media'; - const targetFolder = mediaType === 'image' ? imagesFolder : mediaFolder; + const targetFolder = + mediaType === 'image' ? imagesFolder : mediaFolder; const file = `${folderName}/${indexedFileName}.${extension}`; imageByKey.set(key, { key, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index cf4717bde..bbda52258 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -650,10 +650,19 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { initialLayers={[ createLayer({ title: '角色动作', - src: 'https://assets.example.test/action-preview.mp4', - mediaType: 'video', + src: 'generated-character-drafts/editor/actions/frame.png', + mediaType: 'image-sequence', thumbnailSrc: 'generated-character-drafts/editor/actions/frame.png', + imageSequenceFrames: [ + { + frameIndex: 1, + imageSrc: 'generated-character-drafts/editor/actions/frame.png', + width: 1024, + height: 1024, + }, + ], + previewVideoPath: 'https://assets.example.test/action-preview.mp4', assetKind: 'character-animation', model: 'seedance2.0', originalWidth: 1024, @@ -694,7 +703,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(generateEditorVideoMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( - 'layer-character-animation-1:角色动作 快速编辑:resource-source:character-animation:video:/generated-character-drafts/editor/action-preview.mp4:data:image/png;base64,action-frame-edited:4', + 'layer-character-animation-1:角色动作 快速编辑:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited:data:image/png;base64,action-frame-edited:4', ); }); }); @@ -1499,7 +1508,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( - 'layer-character-animation-1:角色动作:resource-source:character-animation:video:/generated-character-drafts/editor/preview.mp4:data:image/png;base64,action-frame:4', + 'layer-character-animation-1:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4', ); }); expect(screen.getByTestId('dialog').textContent).toBe( diff --git a/src/index.css b/src/index.css index 16ba75901..407cf8b38 100644 --- a/src/index.css +++ b/src/index.css @@ -5098,6 +5098,56 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { z-index: 1; } +.image-canvas-editor__layer-image-sequence-frame { + width: 100%; + height: 100%; + object-fit: contain; +} + +.image-canvas-editor__image-sequence-controls { + position: absolute; + left: 0.45rem; + bottom: 0.45rem; + z-index: 4; + display: inline-flex; + align-items: center; + gap: 0.35rem; + max-width: calc(100% - 0.9rem); + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: 999px; + background: rgba(15, 23, 42, 0.72); + padding: 0.18rem 0.45rem 0.18rem 0.2rem; + color: #fff; + box-shadow: 0 12px 26px rgba(15, 23, 42, 0.24); + backdrop-filter: blur(10px); +} + +.image-canvas-editor__image-sequence-toggle { + display: inline-flex; + width: 1.35rem; + height: 1.35rem; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: #1f2937; + cursor: pointer; +} + +.image-canvas-editor__image-sequence-toggle:disabled { + opacity: 0.58; + cursor: default; +} + +.image-canvas-editor__image-sequence-count { + min-width: 2.75rem; + font-size: 0.72rem; + font-weight: 700; + line-height: 1; + white-space: nowrap; +} + .image-canvas-editor__audio-card { position: relative; display: flex; From 7fe4a10d19beffc7c8cb5967bff492601ca82994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Mon, 22 Jun 2026 20:35:54 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E9=A1=B6=E9=83=A8=E6=A0=8F=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收窄画布顶部栏高度和按钮间距 移除未命名画布下方的画布副标题 将画布标题和重命名入口贴近返回按钮 在顶部栏右端展示当前泥点余额 补充画布顶部栏测试和技术方案说明 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 1 + .../ImageCanvasEditorShellView.test.tsx | 2 + .../ImageCanvasEditorView.test.tsx | 50 ++++- .../image-editor/ImageCanvasEditorView.tsx | 68 +++++++ .../ImageCanvasTopbarView.test.tsx | 20 ++ .../image-editor/ImageCanvasTopbarView.tsx | 173 ++++++++++-------- src/index.css | 96 ++++++++-- 7 files changed, 322 insertions(+), 88 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 91471a614..12bee8406 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -9,6 +9,7 @@ - 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 - 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=`。 - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 +- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 0742151e3..2a7c09fac 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -98,6 +98,8 @@ function createTopbarProps(): ImageCanvasTopbarViewProps { isProjectRenameSaving: false, projectRenameError: null, layers: [], + walletBalanceLabel: '0泥点', + isWalletBalanceLoading: false, assetExportStatus: null, isExportingAssets: false, setProjectRenameValue: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 126d2a053..f6fe85590 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -10,7 +10,7 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import JSZip from 'jszip'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError, @@ -38,6 +38,7 @@ const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); +const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< @@ -64,6 +65,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }; }); +vi.mock('../../services/platform-entry/platformProfileClient', () => ({ + getPlatformProfileDashboard: getPlatformProfileDashboardMock, +})); + describe('ImageCanvasEditorView', () => { setupImageCanvasEditorViewTestLifecycle({ generateEditorImageMock, @@ -84,6 +89,19 @@ describe('ImageCanvasEditorView', () => { saveEditorProjectLayoutMock, }); + beforeEach(() => { + getPlatformProfileDashboardMock.mockResolvedValue({ + walletBalance: 1234, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: null, + }); + }); + + afterEach(() => { + getPlatformProfileDashboardMock.mockReset(); + }); + it('loads the project from projectid query before falling back to recent project', async () => { loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-query', @@ -157,6 +175,36 @@ describe('ImageCanvasEditorView', () => { }); }); + it('shows the live mud point balance in the canvas topbar when logged in', async () => { + render( + + + , + ); + + expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy(); + expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({ + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }); + }); + it('opens the login modal immediately when entering the editor while logged out', async () => { const openLoginModal = vi.fn(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 4374aab68..e4e86dee0 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -14,8 +14,10 @@ import { type EditorAssetSnapshot, loadEditorGenerationPricing, } from '../../services/image-editor/editorProjectClient'; +import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; +import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation'; import { canvasAssetKindOrNull, generationInputsOrNull, @@ -66,6 +68,10 @@ import { export function ImageCanvasEditorView() { const authUi = useAuthUi(); const [, setGenerationPricingVersion] = useState(0); + const [walletBalanceLabel, setWalletBalanceLabel] = useState( + null, + ); + const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(null); @@ -175,6 +181,66 @@ export function ImageCanvasEditorView() { window.location.reload(); }); }, [authUi]); + useEffect(() => { + if (!authUi?.canAccessProtectedData || !authUi.user) { + setWalletBalanceLabel(null); + setIsWalletBalanceLoading(false); + return; + } + + let isMounted = true; + let requestId = 0; + + const refreshWalletBalance = () => { + const currentRequestId = requestId + 1; + requestId = currentRequestId; + setIsWalletBalanceLoading(true); + void getPlatformProfileDashboard({ + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }) + .then((dashboard) => { + if (!isMounted || currentRequestId !== requestId) { + return; + } + setWalletBalanceLabel(`${formatDashboardCount(dashboard.walletBalance)}泥点`); + }) + .catch(() => { + if (!isMounted || currentRequestId !== requestId) { + return; + } + setWalletBalanceLabel(null); + }) + .finally(() => { + if (!isMounted || currentRequestId !== requestId) { + return; + } + setIsWalletBalanceLoading(false); + }); + }; + + refreshWalletBalance(); + + const handleWindowFocus = () => { + refreshWalletBalance(); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + refreshWalletBalance(); + } + }; + + window.addEventListener('focus', handleWindowFocus); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + isMounted = false; + window.removeEventListener('focus', handleWindowFocus); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [authUi?.canAccessProtectedData, authUi?.user?.id]); const { projectTitle, setProjectTitle, @@ -1179,6 +1245,8 @@ export function ImageCanvasEditorView() { isProjectRenameSaving, projectRenameError, layers, + walletBalanceLabel, + isWalletBalanceLoading, assetExportStatus, isExportingAssets, setProjectRenameValue, diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 2648957eb..96a75e133 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -38,6 +38,8 @@ function renderTopbar( isProjectRenameSaving: false, projectRenameError: null, layers: [], + walletBalanceLabel: '1,234泥点', + isWalletBalanceLoading: false, assetExportStatus: null, isExportingAssets: false, setProjectRenameValue: vi.fn(), @@ -60,6 +62,7 @@ describe('ImageCanvasTopbarView', () => { const props = renderTopbar(); expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy(); + expect(screen.queryByText('画布')).toBeNull(); expect( screen.getByRole('link', { name: '返回项目页面' }).getAttribute('href'), ).toBe('/project'); @@ -73,6 +76,19 @@ describe('ImageCanvasTopbarView', () => { expect(props.onOpenShortcuts).toHaveBeenCalledTimes(1); }); + it('shows the current mud point balance in the topbar', () => { + renderTopbar({ + walletBalanceLabel: '1.2万泥点', + }); + + const walletChip = screen.getByLabelText('泥点余额 1.2万泥点'); + + expect(walletChip.textContent).toBe('1.2万泥点'); + expect(walletChip.querySelector('img')?.getAttribute('src')).toBe( + '/creation-home/topbar-wallet.png', + ); + }); + it('submits, resets, and cancels project rename edits', () => { const props = renderTopbar({ isRenamingProject: true, @@ -105,6 +121,8 @@ describe('ImageCanvasTopbarView', () => { isProjectRenameSaving={false} projectRenameError={null} layers={[]} + walletBalanceLabel="0泥点" + isWalletBalanceLoading={false} assetExportStatus={null} isExportingAssets={false} setProjectRenameValue={vi.fn()} @@ -131,6 +149,8 @@ describe('ImageCanvasTopbarView', () => { isProjectRenameSaving={false} projectRenameError={null} layers={[createLayer()]} + walletBalanceLabel="0泥点" + isWalletBalanceLoading={false} assetExportStatus={{ tone: 'success', message: '画布素材已导出', diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 3e02ab576..a75d9ec8e 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -21,6 +21,8 @@ export type ImageCanvasTopbarViewProps = { isProjectRenameSaving: boolean; projectRenameError: string | null; layers: CanvasLayer[]; + walletBalanceLabel: string | null; + isWalletBalanceLoading: boolean; assetExportStatus: AssetExportStatus | null; isExportingAssets: boolean; setProjectRenameValue: (value: string) => void; @@ -40,6 +42,8 @@ export function ImageCanvasTopbarView({ isProjectRenameSaving, projectRenameError, layers, + walletBalanceLabel, + isWalletBalanceLoading, assetExportStatus, isExportingAssets, setProjectRenameValue, @@ -53,86 +57,91 @@ export function ImageCanvasTopbarView({ const hasExportableLayer = layers.some( (layer) => layer.src.trim().length > 0, ); + const walletDisplayLabel = walletBalanceLabel ?? '--泥点'; + const walletAriaLabel = walletBalanceLabel + ? `泥点余额 ${walletBalanceLabel}` + : '泥点余额读取中'; return (
- - -
- {isRenamingProject ? ( -
{ - event.preventDefault(); - submitProjectRename(projectId); - }} - > - { - setProjectRenameValue(event.target.value); - resetProjectRenameError(); +
+ + +
+ {isRenamingProject ? ( + { + event.preventDefault(); + submitProjectRename(projectId); }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.preventDefault(); - cancelProjectRename(); - } - }} - /> - - - {projectRenameError ? ( - - {projectRenameError} - - ) : null} - - ) : ( -
- - -
- )} - 画布 + { + setProjectRenameValue(event.target.value); + resetProjectRenameError(); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelProjectRename(); + } + }} + /> + + + {projectRenameError ? ( + + {projectRenameError} + + ) : null} + + ) : ( +
+ + +
+ )} +
) : null} +
+ + {walletDisplayLabel} +
); diff --git a/src/index.css b/src/index.css index 407cf8b38..b027c8b5c 100644 --- a/src/index.css +++ b/src/index.css @@ -4775,13 +4775,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__topbar { display: flex; - min-height: 3.4rem; + min-height: 2.86rem; align-items: center; justify-content: space-between; - gap: 0.75rem; + gap: 0.55rem; border-bottom: 1px solid #d9dee8; background: #ffffff; - padding: 0.55rem 0.7rem; + padding: 0.36rem 0.58rem; +} + +.image-canvas-editor__topbar-project { + display: inline-flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 0.34rem; } .image-canvas-editor__topbar-actions { @@ -4790,7 +4798,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { flex: 0 1 auto; align-items: center; justify-content: flex-end; - gap: 0.45rem; + gap: 0.36rem; } .image-canvas-editor__shortcut-modal { @@ -4893,8 +4901,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__project-back-button { - width: 2.25rem; - height: 2.25rem; + width: 2.04rem; + height: 2.04rem; flex: 0 0 auto; border-radius: 0.45rem; text-decoration: none; @@ -4904,12 +4912,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; min-width: 0; align-items: center; - gap: 0.25rem; + gap: 0.18rem; } .image-canvas-editor__project-title-button { min-width: 0; - max-width: min(24rem, 42vw); + max-width: min(22rem, 38vw); border: 0; background: transparent; padding: 0; @@ -4939,13 +4947,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__project-title-form { display: inline-flex; min-width: 0; - max-width: min(34rem, 58vw); + max-width: min(32rem, 54vw); align-items: center; - gap: 0.35rem; + gap: 0.28rem; } .image-canvas-editor__project-title-input { - width: clamp(9rem, 28vw, 18rem); + width: clamp(8.5rem, 24vw, 16rem); } .image-canvas-editor__project-title-error { @@ -4958,6 +4966,35 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { white-space: nowrap; } +.image-canvas-editor__wallet-chip { + display: inline-flex; + min-width: 6.65rem; + height: 2.04rem; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: 0.34rem; + border: 1px solid rgba(214, 184, 159, 0.72); + border-radius: 999px; + background: rgba(255, 250, 244, 0.82); + padding: 0 0.56rem 0 0.42rem; + color: #7a4427; + font-size: 0.76rem; + font-weight: 860; + line-height: 1; + white-space: nowrap; +} + +.image-canvas-editor__wallet-chip img { + width: 1.16rem; + height: 1.16rem; + flex: 0 0 auto; + object-fit: cover; + object-position: center; + filter: drop-shadow(0 0.12rem 0.12rem rgba(112, 62, 32, 0.14)); + mix-blend-mode: multiply; +} + .image-canvas-editor__zoom-menu-wrap { margin-left: auto; position: relative; @@ -8211,7 +8248,21 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__topbar { - min-height: 3.2rem; + min-height: 2.78rem; + padding: 0.34rem 0.48rem; + } + + .image-canvas-editor__topbar-project { + gap: 0.26rem; + } + + .image-canvas-editor__topbar-actions { + gap: 0.28rem; + } + + .image-canvas-editor__project-back-button { + width: 1.94rem; + height: 1.94rem; } .image-canvas-editor__shortcut-sections { @@ -8231,6 +8282,27 @@ button.image-canvas-editor__reference-chip:disabled { font-size: 0.84rem; } + .image-canvas-editor__project-title-button { + max-width: min(14rem, 34vw); + } + + .image-canvas-editor__project-title-input { + width: clamp(7rem, 34vw, 11rem); + } + + .image-canvas-editor__wallet-chip { + min-width: 5.72rem; + height: 1.94rem; + gap: 0.26rem; + padding-inline: 0.34rem 0.42rem; + font-size: 0.7rem; + } + + .image-canvas-editor__wallet-chip img { + width: 1.02rem; + height: 1.02rem; + } + .image-canvas-editor__floating-toolbar button, .image-canvas-editor__bottom-toolbar button, .image-canvas-editor__reset-button { From 25421140bfa9d59b08efa7cba1abdb9498cdf68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Mon, 22 Jun 2026 21:04:32 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=B3=A5=E7=82=B9=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让画布顶部泥点余额按钮复用创作页充值和兑换码入口 抽取账户充值支付结果与确认弹窗组件供创作页和画布页共用 补充画布泥点入口回归测试和编辑器技术文档 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 3 +- ...CanvasEditorGenerationIntegration.test.tsx | 1 - .../ImageCanvasEditorShellView.test.tsx | 1 + .../ImageCanvasEditorView.test.tsx | 36 +- .../image-editor/ImageCanvasEditorView.tsx | 97 ++++++ .../ImageCanvasTopbarView.test.tsx | 14 +- .../image-editor/ImageCanvasTopbarView.tsx | 8 +- .../PlatformRechargePaymentStatusDialogs.tsx | 53 +++ src/components/rpg-entry/RpgEntryHomeView.tsx | 57 +--- src/index.css | 313 ++++++++++-------- 10 files changed, 391 insertions(+), 192 deletions(-) create mode 100644 src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 12bee8406..af0afaeda 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -9,10 +9,11 @@ - 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 - 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=`。 - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 -- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip。 +- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip,点击后复用创作主页右上角泥点入口的账户充值 / 兑换码弹窗与支付确认反馈。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 +- 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。 - 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index e84b43f79..35f0e8265 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3371,7 +3371,6 @@ describe('ImageCanvasEditorView generation integration', () => { if (!metadataCornerButton) { throw new Error('metadata corner button should exist'); } - expect(metadataCornerButton.className).toContain('bg-black/55'); expect(metadataCornerButton.className).toContain( 'image-canvas-editor__metadata-corner', ); diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 2a7c09fac..9d3b2d3f5 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -109,6 +109,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps { resetProjectRenameError: vi.fn(), exportCanvasAssets: vi.fn(), onOpenShortcuts: vi.fn(), + onOpenWallet: vi.fn(), }; } diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index f6fe85590..54bc91db3 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -205,6 +205,37 @@ describe('ImageCanvasEditorView', () => { }); }); + it('opens the same account wallet entry from the canvas topbar mud point button', async () => { + render( + + + , + ); + + const walletButton = await screen.findByRole('button', { + name: '泥点余额 1,234泥点', + }); + + fireEvent.click(walletButton); + + expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy(); + expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy(); + }); + it('opens the login modal immediately when entering the editor while logged out', async () => { const openLoginModal = vi.fn(); @@ -329,6 +360,7 @@ describe('ImageCanvasEditorView', () => { originalHeight: 1024, zIndex: 3, sourceType: 'generated', + sourceResourceId: 'resource-generated', prompt: '明亮主视觉', model: 'gpt-image-2', provider: 'VectorEngine', @@ -347,6 +379,7 @@ describe('ImageCanvasEditorView', () => { originalHeight: 120, zIndex: 4, sourceType: 'generated', + sourceResourceId: 'resource-failed', }, ], resources: [], @@ -376,7 +409,8 @@ describe('ImageCanvasEditorView', () => { }); const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { - if (url === '/generated-ok.png') { + const pathname = new URL(url, 'http://localhost').pathname; + if (pathname === '/generated-ok.png') { return new Response(new Blob(['generated'], { type: 'image/png' })); } return new Response(null, { status: 404 }); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e4e86dee0..70ea0f28b 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -14,9 +14,17 @@ import { type EditorAssetSnapshot, loadEditorGenerationPricing, } from '../../services/image-editor/editorProjectClient'; +import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; +import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal'; +import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal'; +import { + PlatformRechargePaymentConfirmationMask, + PlatformRechargePaymentResultDialog, +} from '../platform-entry/PlatformRechargePaymentStatusDialogs'; +import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController'; import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation'; import { canvasAssetKindOrNull, @@ -93,6 +101,7 @@ export function ImageCanvasEditorView() { const publicationReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); const selectedLayerIdRef = useRef(null); + const showRechargeEntry = shouldShowRechargeEntry(); useEffect(() => { let isMounted = true; @@ -167,6 +176,56 @@ export function ImageCanvasEditorView() { }, [], ); + const refreshEditorWalletBalance = useCallback(() => { + if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) { + return; + } + void getPlatformProfileDashboard({ + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }) + .then((dashboard) => { + setWalletBalanceLabel( + `${formatDashboardCount(dashboard.walletBalance)}泥点`, + ); + }) + .catch(() => undefined); + }, []); + const { + activeRechargeTab, + buyRechargeProduct, + confirmNativeWechatPayment, + isLoadingRechargeCenter, + isRechargeOpen, + isRewardCodeOpen, + isSubmittingRewardCode, + loadRechargeCenter, + nativeWechatPayment, + openRechargeOrRewardCodeModal, + rechargeCenter, + rechargeError, + rechargePaymentResult, + rewardCodeError, + rewardCodeInput, + rewardCodeSuccess, + setActiveRechargeTab, + setIsRechargeOpen, + setIsRewardCodeOpen, + setRechargePaymentResult, + setRewardCodeInput, + submittingRechargeProductId, + submitRewardCode, + wechatRechargeOrderConfirmationState, + } = usePlatformProfileCenterController({ + activeTab: 'editor-canvas', + isAuthenticated: Boolean(authUi?.user), + showRechargeEntry, + onRechargeSuccess: refreshEditorWalletBalance, + requestLogin: () => authUiRef.current?.openLoginModal(), + currentUser: authUi?.user ?? null, + }); useEffect(() => { if (!authUi || authUi.user || authUi.canAccessProtectedData) { @@ -1256,6 +1315,7 @@ export function ImageCanvasEditorView() { resetProjectRenameError, exportCanvasAssets, onOpenShortcuts: () => setIsShortcutDialogOpen(true), + onOpenWallet: openRechargeOrRewardCodeModal, }; const stageProps = { canvasViewportRef, @@ -1400,6 +1460,43 @@ export function ImageCanvasEditorView() { open={isShortcutDialogOpen} onClose={() => setIsShortcutDialogOpen(false)} /> + {isRewardCodeOpen ? ( + setIsRewardCodeOpen(false)} + /> + ) : null} + {isRechargeOpen ? ( + setIsRechargeOpen(false)} + onRetry={loadRechargeCenter} + onBuy={buyRechargeProduct} + onConfirmNativePayment={confirmNativeWechatPayment} + /> + ) : null} + {rechargePaymentResult ? ( + setRechargePaymentResult(null)} + /> + ) : null} + {wechatRechargeOrderConfirmationState ? ( + + ) : null} ); } diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 96a75e133..93f8ca7ca 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -49,6 +49,7 @@ function renderTopbar( resetProjectRenameError: vi.fn(), exportCanvasAssets: vi.fn(), onOpenShortcuts: vi.fn(), + onOpenWallet: vi.fn(), ...overrides, }; @@ -77,16 +78,21 @@ describe('ImageCanvasTopbarView', () => { }); it('shows the current mud point balance in the topbar', () => { - renderTopbar({ + const props = renderTopbar({ walletBalanceLabel: '1.2万泥点', }); - - const walletChip = screen.getByLabelText('泥点余额 1.2万泥点'); + const walletChip = screen.getByRole('button', { + name: '泥点余额 1.2万泥点', + }); expect(walletChip.textContent).toBe('1.2万泥点'); expect(walletChip.querySelector('img')?.getAttribute('src')).toBe( '/creation-home/topbar-wallet.png', ); + + fireEvent.click(walletChip); + + expect(props.onOpenWallet).toHaveBeenCalledTimes(1); }); it('submits, resets, and cancels project rename edits', () => { @@ -132,6 +138,7 @@ describe('ImageCanvasTopbarView', () => { resetProjectRenameError={vi.fn()} exportCanvasAssets={exportCanvasAssets} onOpenShortcuts={vi.fn()} + onOpenWallet={vi.fn()} />, ); @@ -163,6 +170,7 @@ describe('ImageCanvasTopbarView', () => { resetProjectRenameError={vi.fn()} exportCanvasAssets={exportCanvasAssets} onOpenShortcuts={vi.fn()} + onOpenWallet={vi.fn()} />, ); diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index a75d9ec8e..e4a6ff24d 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -32,6 +32,7 @@ export type ImageCanvasTopbarViewProps = { resetProjectRenameError: () => void; exportCanvasAssets: () => void | Promise; onOpenShortcuts: () => void; + onOpenWallet: () => void; }; export function ImageCanvasTopbarView({ @@ -53,6 +54,7 @@ export function ImageCanvasTopbarView({ resetProjectRenameError, exportCanvasAssets, onOpenShortcuts, + onOpenWallet, }: ImageCanvasTopbarViewProps) { const hasExportableLayer = layers.some( (layer) => layer.src.trim().length > 0, @@ -167,8 +169,10 @@ export function ImageCanvasTopbarView({ {assetExportStatus.message} ) : null} -
{walletDisplayLabel} -
+
); diff --git a/src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx b/src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx new file mode 100644 index 000000000..681413d21 --- /dev/null +++ b/src/components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx @@ -0,0 +1,53 @@ +import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog'; +import { PlatformStatusDialog } from '../common/PlatformStatusDialog'; +import type { RechargePaymentResult } from './usePlatformProfileCenterController'; + +const PROFILE_MODAL_OVERLAY_CLASS = + 'platform-modal-backdrop !items-center !justify-center !px-4 !py-6'; + +export function PlatformRechargePaymentResultDialog({ + result, + onClose, +}: { + result: RechargePaymentResult; + onClose: () => void; +}) { + const status = + result.kind === 'success' + ? 'success' + : result.kind === 'pending' + ? 'loading' + : result.kind === 'cancel' + ? 'cancel' + : 'error'; + + return ( + + ); +} + +export function PlatformRechargePaymentConfirmationMask({ + orderId, +}: { + orderId: string; +}) { + return ( + undefined} + closeDisabled + zIndexClassName="z-[95]" + overlayClassName={PROFILE_MODAL_OVERLAY_CLASS} + /> + ); +} diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 005197429..8d587a736 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -74,7 +74,6 @@ import { import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; -import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog'; import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { CopyFeedbackButton } from '../common/CopyFeedbackButton'; import { LegalDocumentModal } from '../common/LegalDocumentModal'; @@ -95,7 +94,6 @@ import { PlatformOptionSegment, PlatformUnderlineTabRail, } from '../common/PlatformSegmentedTabPresets'; -import { PlatformStatusDialog } from '../common/PlatformStatusDialog'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { PlatformTextField } from '../common/PlatformTextField'; @@ -134,13 +132,16 @@ import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfile import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal'; import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal'; import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal'; +import { + PlatformRechargePaymentConfirmationMask, + PlatformRechargePaymentResultDialog, +} from '../platform-entry/PlatformRechargePaymentStatusDialogs'; import { PlatformProfileTaskCenterModal } from '../platform-entry/PlatformProfileTaskCenterModal'; import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal'; import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel'; import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { type ProfilePopupPanel, - type RechargePaymentResult, usePlatformProfileCenterController, } from '../platform-entry/usePlatformProfileCenterController'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; @@ -2465,9 +2466,6 @@ function cropAvatarImage(params: { }); } -const PROFILE_MODAL_OVERLAY_CLASS = - 'platform-modal-backdrop !items-center !justify-center !px-4 !py-6'; - function ProfileNicknameModal({ value, error, @@ -2536,49 +2534,6 @@ function ProfileNicknameModal({ ); } -function RechargePaymentResultModal({ - result, - onClose, -}: { - result: RechargePaymentResult; - onClose: () => void; -}) { - const status = - result.kind === 'success' - ? 'success' - : result.kind === 'pending' - ? 'loading' - : result.kind === 'cancel' - ? 'cancel' - : 'error'; - - return ( - - ); -} - -function RechargePaymentConfirmationMask({ orderId }: { orderId: string }) { - return ( - undefined} - closeDisabled - zIndexClassName="z-[95]" - overlayClassName={PROFILE_MODAL_OVERLAY_CLASS} - /> - ); -} - export function RpgEntryHomeView({ activeTab, activeDesktopNavTab, @@ -4920,14 +4875,14 @@ export function RpgEntryHomeView({ /> ) : null; const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? ( - setRechargePaymentResult(null)} /> ) : null; const rechargePaymentConfirmationMask: ReactNode = wechatRechargeOrderConfirmationState ? ( - ) : null; diff --git a/src/index.css b/src/index.css index b027c8b5c..3192164aa 100644 --- a/src/index.css +++ b/src/index.css @@ -4096,6 +4096,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { overflow: hidden; background: #ffffff; color: #1f2937; + --image-canvas-brand-accent: var(--platform-accent, #c7653d); + --image-canvas-brand-accent-strong: #6f2f21; + --image-canvas-brand-fill: var( + --platform-button-primary-fill, + linear-gradient(135deg, #df7f40, #b95d3a) + ); + --image-canvas-brand-text-on-fill: var(--platform-button-primary-text, #fffaf5); + --image-canvas-brand-border: rgba(204, 117, 76, 0.34); + --image-canvas-brand-border-strong: rgba(199, 101, 61, 0.64); + --image-canvas-brand-border-soft: rgba(226, 203, 184, 0.82); + --image-canvas-brand-soft: rgba(234, 204, 179, 0.28); + --image-canvas-brand-soft-strong: rgba(238, 208, 183, 0.46); + --image-canvas-brand-shadow: rgba(182, 98, 63, 0.16); + --image-canvas-brand-focus-ring: rgba(204, 117, 76, 0.18); -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; @@ -4171,7 +4185,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { min-width: 15.5rem; flex-direction: column; overflow: hidden; - border-right: 1px solid #d9dee8; + border-right: 1px solid var(--image-canvas-brand-border-soft); background: #ffffff; } @@ -4181,7 +4195,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { align-items: center; justify-content: space-between; gap: 0.75rem; - border-bottom: 1px solid #d9dee8; + border-bottom: 1px solid var(--image-canvas-brand-border-soft); padding: 0.65rem; } @@ -4209,7 +4223,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; align-items: center; justify-content: center; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); background: #ffffff; color: #334155; transition: @@ -4224,9 +4238,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__bottom-toolbar button:hover, .image-canvas-editor__reset-button:hover { transform: translateY(-1px); - border-color: #8fb8ff; - background: #eef5ff; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__icon-button { @@ -4274,7 +4288,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__folder-rename-input { height: 1.8rem; - border: 1px solid #8fb8ff; + border: 1px solid var(--image-canvas-brand-border-strong); border-radius: 0.35rem; background: #ffffff; padding: 0 0.45rem; @@ -4297,9 +4311,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__asset-folder-header button:hover { - border-color: #d9dee8; - background: #f8fafc; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__asset-folder-header span { @@ -4316,9 +4330,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__asset-folder--move-target > .image-canvas-editor__asset-folder-header { - border-color: #93c5fd; - background: #eff6ff; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__asset-folder-sticky-target { @@ -4329,14 +4343,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { align-items: center; gap: 0.4rem; margin-bottom: 0.45rem; - border: 1px solid #93c5fd; + border: 1px solid var(--image-canvas-brand-border-strong); border-radius: 0.45rem; - background: #eff6ff; + background: var(--image-canvas-brand-soft); padding: 0.38rem 0.5rem; - color: #1d4ed8; + color: var(--image-canvas-brand-accent-strong); font-size: 0.78rem; font-weight: 880; - box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12); + box-shadow: 0 10px 24px var(--image-canvas-brand-shadow); } .image-canvas-editor__asset-folder-sticky-target span { @@ -4370,24 +4384,24 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__asset-row:hover { transform: translateY(-1px); - border-color: #8fb8ff; - background: #eef5ff; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); } .image-canvas-editor__asset-row--selected { - border-color: #2563eb; - background: #dbeafe; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft-strong); } .image-canvas-editor__asset-row--uploading { - border-color: #bfdbfe; - background: #f8fbff; + border-color: var(--image-canvas-brand-border); + background: rgba(255, 250, 245, 0.96); } .image-canvas-editor__asset-row--uploading:hover { transform: none; - border-color: #93c5fd; - background: #f8fbff; + border-color: var(--image-canvas-brand-border-strong); + background: rgba(255, 250, 245, 0.96); } .image-canvas-editor__asset-row--upload-failed { @@ -4400,11 +4414,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { z-index: 80; max-width: 11rem; transform: translate(-50%, -50%); - border: 1px solid #bfdbfe; + border: 1px solid var(--image-canvas-brand-border); border-radius: 999px; background: rgba(255, 255, 255, 0.96); padding: 0.4rem 0.65rem; - color: #1d4ed8; + color: var(--image-canvas-brand-accent-strong); font-size: 0.78rem; font-weight: 880; pointer-events: none; @@ -4417,7 +4431,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: grid; place-items: center; background: rgba(248, 250, 252, 0.74); - color: #1d4ed8; + color: var(--image-canvas-brand-accent-strong); font-size: 0.72rem; font-weight: 880; } @@ -4450,7 +4464,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__asset-upload-progress progress::-webkit-progress-value { - background: #2563eb; + background: var(--image-canvas-brand-accent); } .image-canvas-editor__asset-batch-toolbar { @@ -4463,16 +4477,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__asset-marquee { position: absolute; z-index: 4; - border: 1px solid #2563eb; - background: rgb(37 99 235 / 0.12); + border: 1px solid var(--image-canvas-brand-accent); + background: rgba(204, 117, 76, 0.12); pointer-events: none; } .image-canvas-editor__canvas-marquee { position: absolute; z-index: 7; - border: 1px solid #2563eb; - background: rgb(37 99 235 / 0.12); + border: 1px solid var(--image-canvas-brand-accent); + background: rgba(204, 117, 76, 0.12); pointer-events: none; } @@ -4483,9 +4497,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { place-items: center; align-content: center; gap: 0.25rem; - border: 1px dashed #60a5fa; - background: rgba(239, 246, 255, 0.72); - color: #1d4ed8; + border: 1px dashed var(--image-canvas-brand-border-strong); + background: rgba(255, 244, 236, 0.76); + color: var(--image-canvas-brand-accent-strong); text-align: center; pointer-events: none; backdrop-filter: blur(2px); @@ -4636,7 +4650,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__asset-rename-input { min-width: 0; height: 1.8rem; - border: 1px solid #8fb8ff; + border: 1px solid var(--image-canvas-brand-border-strong); border-radius: 0.35rem; background: #ffffff; padding: 0 0.45rem; @@ -4658,7 +4672,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { height: 1.9rem; align-items: center; justify-content: center; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.42rem; background: #ffffff; color: #475569; @@ -4666,9 +4680,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__asset-actions button:hover, .image-canvas-editor__folder-create button:hover { - border-color: #8fb8ff; + border-color: var(--image-canvas-brand-border-strong); background: #ffffff; - color: #1d4ed8; + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__folder-create { @@ -4676,7 +4690,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 0.35rem; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.45rem; background: #f8fafc; padding: 0.4rem; @@ -4685,7 +4699,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__folder-create-input { min-width: 0; height: 2rem; - border: 1px solid #8fb8ff; + border: 1px solid var(--image-canvas-brand-border-strong); border-radius: 0.35rem; background: #ffffff; padding: 0 0.5rem; @@ -4730,8 +4744,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__layer-row:hover, .image-canvas-editor__layer-row--selected { - border-color: #8fb8ff; - background: #eef5ff; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); } .image-canvas-editor__layer-row-thumb { @@ -4779,7 +4793,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { align-items: center; justify-content: space-between; gap: 0.55rem; - border-bottom: 1px solid #d9dee8; + border-bottom: 1px solid var(--image-canvas-brand-border-soft); background: #ffffff; padding: 0.36rem 0.58rem; } @@ -4859,9 +4873,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__shortcut-row dt > span { flex: 0 0 auto; border-radius: 999px; - background: #e0f2fe; + background: var(--image-canvas-brand-soft); padding: 0.08rem 0.34rem; - color: #0369a1; + color: var(--image-canvas-brand-accent-strong); font-size: 0.62rem; font-weight: 900; } @@ -4983,6 +4997,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { font-weight: 860; line-height: 1; white-space: nowrap; + transition: + transform 160ms ease, + background-color 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; +} + +.image-canvas-editor__wallet-chip:hover, +.image-canvas-editor__wallet-chip:focus-visible { + transform: translateY(-1px); + border-color: rgba(214, 184, 159, 0.92); + background: rgba(255, 250, 244, 0.96); + box-shadow: 0 0.28rem 0.78rem rgba(112, 62, 32, 0.12); } .image-canvas-editor__wallet-chip img { @@ -5006,7 +5033,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__zoom-trigger { min-width: 4.15rem; height: 2.25rem; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.45rem; background: #ffffff; padding: 0 0.65rem; @@ -5017,9 +5044,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__zoom-trigger:hover { transform: translateY(-1px); - border-color: #8fb8ff; - background: #eef5ff; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__zoom-menu-item { @@ -5055,7 +5082,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { flex: 0 0 auto; align-self: center; border-radius: 999px; - background: #d9dee8; + background: var(--image-canvas-brand-border-soft); } .image-canvas-editor__floating-toolbar-divider { @@ -5195,7 +5222,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { justify-content: flex-end; gap: 0.3rem; border-radius: 0.85rem; - background: linear-gradient(135deg, #0f172a, #263449); + background: linear-gradient(135deg, #6f2f21, #b95d3a); padding: 0.7rem; box-shadow: 0 16px 38px rgba(15, 23, 42, 0.18); } @@ -5306,7 +5333,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__layer--hovered, .image-canvas-editor__layer--selected { - border-color: #4bb5aa; + border-color: var(--image-canvas-brand-accent); } .image-canvas-editor__layer--selected { @@ -5366,7 +5393,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__generation-frame--focused { - border-color: #2f7df6; + border-color: var(--image-canvas-brand-accent); } .image-canvas-editor__generation-frame-label, @@ -5376,7 +5403,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; align-items: center; gap: 0.25rem; - color: #2f7df6; + color: var(--image-canvas-brand-accent); font-size: 0.76rem; font-weight: 850; line-height: 1; @@ -5482,10 +5509,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { ), linear-gradient( 135deg, - rgba(240, 247, 255, 0.92), + rgba(255, 247, 241, 0.94), rgba(232, 236, 244, 0.95) ); - color: #2563eb; + color: var(--image-canvas-brand-accent); cursor: progress; box-shadow: 0 20px 48px rgba(15, 23, 42, 0.12), @@ -5504,7 +5531,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { ), repeating-linear-gradient( 135deg, - rgba(47, 125, 246, 0.08) 0 0.4rem, + rgba(204, 117, 76, 0.1) 0 0.4rem, transparent 0.4rem 0.85rem ); animation: image-canvas-generation-scan 1.7s linear infinite; @@ -5514,7 +5541,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__generation-frame-label, .image-canvas-editor__generation-frame--generating .image-canvas-editor__generation-frame-size { - color: #2563eb; + color: var(--image-canvas-brand-accent); } .image-canvas-editor__generation-frame--generating @@ -5523,11 +5550,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { z-index: 1; width: 5.5rem; aspect-ratio: 1; - border: 1px solid rgba(37, 99, 235, 0.18); + border: 1px solid var(--image-canvas-brand-border); border-radius: 1.6rem; background: rgba(255, 255, 255, 0.78); - color: rgba(37, 99, 235, 0.58); - box-shadow: 0 18px 32px rgba(37, 99, 235, 0.12); + color: rgba(199, 101, 61, 0.68); + box-shadow: 0 18px 32px var(--image-canvas-brand-shadow); } .image-canvas-editor__generation-frame-progress { @@ -5538,15 +5565,15 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; align-items: center; gap: 0.45rem; - border: 1px solid rgba(37, 99, 235, 0.18); + border: 1px solid var(--image-canvas-brand-border); border-radius: 999px; background: rgba(255, 255, 255, 0.86); padding: 0.34rem 0.7rem; - color: #1d4ed8; + color: var(--image-canvas-brand-accent-strong); font-size: 0.76rem; font-weight: 850; transform: translateX(-50%); - box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12); + box-shadow: 0 12px 24px var(--image-canvas-brand-shadow); } .image-canvas-editor__generation-frame-progress::before { @@ -5646,21 +5673,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__generation-frame--quick-edit { - border-color: rgba(99, 102, 241, 0.28); + border-color: var(--image-canvas-brand-border); background: - linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(14, 165, 233, 0.08)), + linear-gradient(135deg, rgba(204, 117, 76, 0.12), rgba(240, 203, 169, 0.12)), rgba(255, 255, 255, 0.72); } .image-canvas-editor__generation-frame--quick-edit.image-canvas-editor__generation-frame--focused { box-shadow: - 0 0 0 1px rgba(99, 102, 241, 0.26), - 0 22px 54px rgba(99, 102, 241, 0.18); + 0 0 0 1px var(--image-canvas-brand-border), + 0 22px 54px var(--image-canvas-brand-shadow); } .image-canvas-editor__generation-frame--quick-edit .image-canvas-editor__generation-frame-icon { - color: rgba(79, 70, 229, 0.62); + color: rgba(199, 101, 61, 0.72); } .image-canvas-editor__generation-frame--publication { @@ -5692,14 +5719,15 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { height: 1.35rem; place-items: center; border-radius: 999px; - background: rgba(16, 15, 14, 0.78); - color: #ffffff; + border-color: rgba(255, 255, 255, 0.42); + background: rgba(199, 101, 61, 0.92); + color: var(--image-canvas-brand-text-on-fill); transform: scale(var(--image-canvas-editor-inverse-scale, 1)); transform-origin: top right; } .image-canvas-editor__metadata-corner:hover { - background: rgba(3, 105, 161, 0.88); + background: #b95d3a; } .image-canvas-editor__metadata-corner--beside-spec { @@ -5719,14 +5747,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { align-items: center; border: 1px solid rgba(255, 255, 255, 0.72); border-radius: 999px; - background: rgba(37, 99, 235, 0.9); + background: rgba(199, 101, 61, 0.92); padding: 0.22rem 0.5rem; color: #ffffff; font-size: 0.72rem; font-weight: 900; line-height: 1; letter-spacing: 0.04em; - box-shadow: 0 8px 18px rgba(37, 99, 235, 0.24); + box-shadow: 0 8px 18px var(--image-canvas-brand-shadow); pointer-events: none; } @@ -5761,8 +5789,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__kind-badge--spec { - background: rgba(37, 99, 235, 0.9); - box-shadow: 0 8px 18px rgba(37, 99, 235, 0.24); + background: rgba(199, 101, 61, 0.92); + box-shadow: 0 8px 18px var(--image-canvas-brand-shadow); } .image-canvas-editor__kind-badge--character { @@ -5835,8 +5863,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__kind-menu-item--active { - background: #eff6ff; - color: #1d4ed8; + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__size-badge { @@ -5845,8 +5873,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { top: var(--image-canvas-editor-corner-offset, 0.35rem); z-index: 2; border-color: rgba(255, 255, 255, 0.16); - background: rgba(16, 15, 14, 0.82); - color: #ffffff; + background: rgba(111, 47, 33, 0.88); + color: var(--image-canvas-brand-text-on-fill); white-space: nowrap; transform: scale(var(--image-canvas-editor-inverse-scale, 1)); transform-origin: top left; @@ -5858,7 +5886,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; align-items: center; gap: 0.3rem; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.5rem; background: #ffffff; padding: 0.28rem; @@ -5892,7 +5920,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { width: 8.25rem; height: 5.25rem; overflow: hidden; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.5rem; background: rgba(255, 255, 255, 0.92); padding: 0; @@ -5922,7 +5950,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__minimap-viewport { - border: 1.5px solid #aeb7c5; + border: 1.5px solid var(--image-canvas-brand-border-strong); background: rgba(255, 255, 255, 0.2); } @@ -5933,7 +5961,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { z-index: 18; display: inline-flex; gap: 0.35rem; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.5rem; background: #ffffff; padding: 0.35rem; @@ -5946,7 +5974,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { height: 2.25rem; align-items: center; justify-content: center; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.45rem; background: #ffffff; color: #334155; @@ -6136,8 +6164,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__background-preset[aria-pressed='true'] { - border-color: #38bdf8; - background: #e0f2fe; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); } .image-canvas-editor__background-preset @@ -6198,17 +6226,17 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__background-reset:hover { - border-color: #38bdf8; - background: #eff6ff; - color: #0369a1; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__panel-dock button:hover, .image-canvas-editor__panel-dock button[aria-pressed='true'] { transform: translateY(-1px); - border-color: #38bdf8; - background: #e0f2fe; - color: #0369a1; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__bottom-toolbar { @@ -6220,7 +6248,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { gap: 0.35rem; max-width: min(calc(100% - 6.6rem), 34rem); overflow-x: auto; - border: 1px solid #d9dee8; + border: 1px solid var(--image-canvas-brand-border-soft); border-radius: 0.5rem; background: #ffffff; padding: 0.35rem; @@ -6229,9 +6257,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__bottom-toolbar button[aria-pressed='true'] { - border-color: #38bdf8; - background: #e0f2fe; - color: #0369a1; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__bottom-toolbar-option-wrap, @@ -6322,6 +6350,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { outline: none; } +.image-canvas-editor__generation-prompt:focus, +.image-canvas-editor__video-prompt:focus { + border-color: var(--image-canvas-brand-border-strong); + background: #ffffff; + box-shadow: 0 0 0 3px var(--image-canvas-brand-focus-ring); +} + .image-canvas-editor__generation-prompt::placeholder { color: #94a3b8; } @@ -6790,8 +6825,10 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__option-popover-choice[aria-pressed='true'] { - border-color: #202124; - box-shadow: inset 0 0 0 1px #202124; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); + box-shadow: inset 0 0 0 1px var(--image-canvas-brand-border-strong); } .image-canvas-editor__option-popover--audio-options { @@ -6810,7 +6847,7 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__audio-bpm-control input[type='range'] { width: 100%; - accent-color: #4f46e5; + accent-color: var(--image-canvas-brand-accent); } .image-canvas-editor__video-duration-slider { @@ -6822,11 +6859,11 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__video-duration-slider input[type='range'] { width: 100%; - accent-color: #111827; + accent-color: var(--image-canvas-brand-accent); } .image-canvas-editor__video-duration-slider span { - color: #111827; + color: var(--image-canvas-brand-accent-strong); font-size: 0.82rem; font-weight: 760; text-align: right; @@ -6873,12 +6910,12 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__video-toggle[aria-pressed='true'] { - border-color: #202124; - box-shadow: inset 0 0 0 1px #202124; + border-color: var(--image-canvas-brand-border-strong); + box-shadow: inset 0 0 0 1px var(--image-canvas-brand-border-strong); } .image-canvas-editor__video-toggle[aria-pressed='true'] span { - background: #111827; + background: var(--image-canvas-brand-accent); } .image-canvas-editor__video-toggle[aria-pressed='true'] span::after { @@ -6898,8 +6935,8 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__audio-bpm-control input[type='number']:focus { - border-color: rgba(79, 70, 229, 0.42); - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); + border-color: var(--image-canvas-brand-border-strong); + box-shadow: 0 0 0 3px var(--image-canvas-brand-focus-ring); } .image-canvas-editor__option-popover-choice--model { @@ -6940,7 +6977,7 @@ button.image-canvas-editor__reference-chip:disabled { width: 0.92rem; height: 0.92rem; justify-self: end; - color: #111827; + color: var(--image-canvas-brand-accent-strong); stroke-width: 3; } @@ -7026,9 +7063,9 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__generation-composer .platform-inline-option-button[aria-pressed='true'] { - border-color: rgba(15, 23, 42, 0.14); - background: #0f172a; - color: #ffffff; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-fill); + color: var(--image-canvas-brand-text-on-fill); } .image-canvas-editor__generation-submit { @@ -7037,14 +7074,14 @@ button.image-canvas-editor__reference-chip:disabled { height: 2.25rem; gap: 0.4rem; border: 0; - background: #0f172a; - color: #ffffff; + background: var(--image-canvas-brand-fill); + color: var(--image-canvas-brand-text-on-fill); font-size: 0.82rem; font-weight: 820; } .image-canvas-editor__generation-submit:not(:disabled) { - color: #ffffff; + color: var(--image-canvas-brand-text-on-fill); } .image-canvas-editor__generation-submit:disabled { @@ -7494,8 +7531,8 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__context-menu button:hover:not(:disabled) { - background: #eef6ff; - color: #0f4a87; + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__context-menu button:disabled { @@ -7699,15 +7736,15 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__crop-expand-ratio:hover { - border-color: rgba(59, 130, 246, 0.46); - background: #eff6ff; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__crop-expand-ratio--selected { - border-color: rgba(37, 99, 235, 0.76); - background: #dbeafe; - color: #1d4ed8; + border-color: var(--image-canvas-brand-border-strong); + background: var(--image-canvas-brand-soft-strong); + color: var(--image-canvas-brand-accent-strong); } .image-canvas-editor__crop-expand-ratio-shape { @@ -7802,10 +7839,10 @@ button.image-canvas-editor__reference-chip:disabled { position: absolute; z-index: 999; box-sizing: border-box; - border: 2px solid #38bdf8; + border: 2px solid var(--image-canvas-brand-accent); background: - linear-gradient(90deg, rgba(14, 165, 233, 0.2) 1px, transparent 1px), - linear-gradient(rgba(14, 165, 233, 0.2) 1px, transparent 1px); + linear-gradient(90deg, rgba(204, 117, 76, 0.2) 1px, transparent 1px), + linear-gradient(rgba(204, 117, 76, 0.2) 1px, transparent 1px); background-size: 33.333% 33.333%; box-shadow: 0 0 0 99999px rgba(15, 23, 42, 0.18), @@ -7826,8 +7863,8 @@ button.image-canvas-editor__reference-chip:disabled { height: 1rem; border: 2px solid #ffffff; border-radius: 999px; - background: #0ea5e9; - box-shadow: 0 6px 16px rgba(14, 165, 233, 0.28); + background: var(--image-canvas-brand-accent); + box-shadow: 0 6px 16px var(--image-canvas-brand-shadow); pointer-events: auto; touch-action: none; } @@ -7954,8 +7991,8 @@ button.image-canvas-editor__reference-chip:disabled { height: 2.25rem; gap: 0.4rem; border: 0; - background: #0f172a; - color: #ffffff; + background: var(--image-canvas-brand-fill); + color: var(--image-canvas-brand-text-on-fill); } .image-canvas-editor__snap-guide { @@ -8024,9 +8061,9 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__generate-prompt:focus { - border-color: #38bdf8; + border-color: var(--image-canvas-brand-border-strong); background: #ffffff; - box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18); + box-shadow: 0 0 0 3px var(--image-canvas-brand-focus-ring); } .image-canvas-editor__generate-status { @@ -15826,6 +15863,16 @@ button { box-shadow: 0 24px 70px rgba(15, 23, 42, 0.18) !important; } +.image-canvas-editor .image-canvas-editor__metadata-corner { + border-color: rgba(255, 255, 255, 0.42) !important; + background: rgba(199, 101, 61, 0.92) !important; + color: var(--image-canvas-brand-text-on-fill) !important; +} + +.image-canvas-editor .image-canvas-editor__metadata-corner:hover { + background: #b95d3a !important; +} + .image-canvas-editor__generation-composer .image-canvas-editor__option-cluster { min-height: 2.25rem; background: transparent;