Merge branch 'codex/editor-asset-library' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into codex/editor-asset-library

This commit is contained in:
2026-06-22 21:27:54 +08:00
42 changed files with 2271 additions and 763 deletions
@@ -24,6 +24,14 @@
- 验证方式:运行 `editor_generation_config`、公开定价路由、图标素材价格校验、图片画布定价模型和后台定价页相关测试。
- 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 2026-06-22 图片画布角色动作主媒体改为透明序列帧
- 背景:角色动作生成后端已经在视频生成后抽取透明 PNG 帧并完成绿幕去背;画板继续把 `previewVideoPath` 当主媒体会让用户看到未扣绿幕视频,下载也拿不到可直接用于游戏素材的帧序列。
- 决策:`/api/editor/character-animations/generations` 的上游预览视频继续保留为来源信息,但画板落层主类型固定为 `mediaType="image-sequence"``assetKind="character-animation"`;图层 `src` / `thumbnailSrc` 使用首帧,完整 `frames` 保存到 `imageSequenceFrames`,画布展示使用序列帧播放器循环播放。单图层下载生成序列帧 ZIP,画布素材 ZIP 中角色动作写入 `sequences/<编号-标题>/frames/`,不再把预览视频作为角色动作下载产物。
- 影响范围:图片画布角色动作生成、画布图层快照、序列帧播放器、素材导出、角色动作设计文档和排障记忆。
- 验证方式:运行角色动作图层工厂、画布展示、画布持久化、生成提交和素材导出相关前端测试,执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## 2026-06-21 图片画布生成完成态由后端写入画布布局
- 背景:图片画布角色形象等长耗时生成在服务端完成后,如果浏览器已刷新或原 HTTP 回调丢失,前端无法再把生成结果图层和 `generation-dialog` 完成态写回 `editor_canvas.layers_json`,用户会继续看到“生成中”卡片。
@@ -75,7 +83,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/<play>` 玩法工作台直达仍按原链路进入。页面文案统一使用“陶泥儿”,外部品牌只作为设计参考,不进入用户可见 UI、测试名、产品文案或验收口径。`陶泥儿精选` 是用户素材瀑布流,不展示玩法入口列表;创作入口事实源继续来自 `/api/creation-entry/config`,项目入口通过 `createEditorProject``/editor/canvas?projectid=xxx` 链路进入画布。
- 影响范围:平台入口导航、`SelectionStage` 路由、`/creation` 首页、`/project` 项目入口、`/editor/canvas` 项目打开链路、“我的”页快捷入口和创作入口相关测试。
- 验证方式:桌面 `/` 初始阶段和 `/creation` 解析为创作主页,移动端 `/` 仍是推荐首页,`/creation/<play>` 仍进入对应玩法工作台;桌面导航显示“创作 / 项目”且不显示“草稿”;移动端底部不显示“创作 / 项目”;页面不出现外站品牌或 `Discord` 字样;新建项目进入 `/editor/canvas?projectid=xxx`
- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
@@ -254,8 +262,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`;不再把 `https://git.genarrative.world/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`;不再把 `https://git.genarrative.world/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`
@@ -263,7 +271,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`
@@ -768,10 +776,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 只做服务器初始化,全程运行在目标部署 agentdevelopment 使用 `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 只做服务器初始化,真实初始化阶段运行在目标部署 agentdevelopment 使用 `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
@@ -1382,14 +1391,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/` 模板仍是正式线上来源。
- 生产 Collectorserver-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置,但二进制由 Jenkins 构建机先准备 `provision-tools/otelcol-contrib` 再上传到 release 部署 agent,目标机不从 GitHub 下载api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。
- 生产 Collectorserver-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 脚本被立即重写时触发 `拒绝访问`
+18 -11
View File
@@ -23,12 +23,20 @@
- 验证:`http://127.0.0.1:3101/v1/ping` 可访问、`http://127.0.0.1:8082/healthz` 返回 200、`http://127.0.0.1:3000/``http://127.0.0.1:3102/admin/` 可打开。
- 关联:`scripts/dev.mjs``.app/dev-stack.json``docs/project-memory/shared-memory/development-workflow.md`
## 图片画布角色动作结果不能把首帧当主媒体
## API Build / Deploy 归档清单不能漏掉随包 Pingora 脚本
- 现象:画板里 `生成角色动作` 返回后显示成一张不可播放图片,点击下载或素材 ZIP 导出时拿到的也是 PNG,而不是动作视频
- 原因:后端已经生成 `previewVideoPath`,并继续执行抽帧、绿幕去背和帧素材 OSS 落盘;前端落层时却把 `frames[0].imageSrc` 当作图层主 `src`,且没有设置 `mediaType: "video"`,导致预览和导出都按图片处理
- 处理:角色动作结果图层主 `src` 必须使用 `previewVideoPath``mediaType` 固定为 `video``assetKind` 固定为 `character-animation`;首帧透明 PNG 只写入 `thumbnailSrc`,用于 `<video poster>` 和后续动作素材二次生成源。不得移除后端原有视频生成、抽帧、绿幕去背和帧落盘流程
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src` 为预览视频且 `thumbnailSrc` 为首帧;画布集成测试应出现 `<video controls poster=...>`;导出模型应按 `mediaType="video"` 输出 mp4/webm/mov 等真实视频扩展名
- 现象:`Genarrative-Api-Deploy` 在发布阶段报 `发布产物缺少 Pingora TLS 证书同步脚本: build/<version>/scripts/deploy/pingora-tls-cert-sync.mjs`
- 原因:`scripts/build-production-release.sh` 已经把脚本复制进 `build/<version>/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`
## 图片画布角色动作结果主类型是序列帧
- 现象:产品要求画板 `生成角色动作` 返回后按透明序列帧播放和下载,但旧实现或旧测试可能继续把结果当作预览视频处理。
- 原因:后端仍需要先生成 `previewVideoPath` 再抽帧、绿幕去背和落 OSS;如果前端把预览视频当主媒体,就会绕过已经扣绿幕的 PNG 帧,也无法按序列帧打包下载。
- 处理:角色动作结果图层主 `src` 使用 `frames[0].imageSrc``mediaType` 固定为 `image-sequence``assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames``previewVideoPath` 只作为来源信息保留。单图层下载必须生成序列帧 ZIP;画布素材 ZIP 中角色动作写入 `sequences/<编号-标题>/frames/`。不得移除后端原有视频生成、抽帧、绿幕去背和帧落盘流程。
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src` 为首帧且 `mediaType="image-sequence"`;画布集成测试应出现 `画布序列帧:角色动作` 图片播放器,不应出现角色动作 `<video>`;导出测试应断言角色动作下载和画布素材导出都包含序列帧 ZIP / frames 目录。
- 关联:`src/components/image-editor/ImageCanvasGenerationLayerModel.ts``src/components/image-editor/ImageCanvasWorldView.tsx``src/components/image-editor/ImageCanvasExportModel.ts``server-rs/crates/api-server/src/character_animation_assets.rs``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## Vidu 文生音频线上网关可能要求 sound 字段
@@ -166,7 +174,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` 启动失败,日志出现低端口绑定权限错误。
@@ -1646,12 +1653,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 全程运行在目标部署 agentdevelopment 使用 `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` 必须运行在目标部署 agentdevelopment 使用 `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 不要无条件下载工具包
@@ -22,6 +22,7 @@
- 导出当前画布中有效图层引用的图片。
- 包含上传图、生成图、修改生成结果。
- 角色动作 `image-sequence` 图层导出完整透明 PNG 序列帧。
- 默认包含隐藏图层。
- 跳过已被素材库删除且已判定无效的上传图层。
- 锁定、分组、翻转等状态不影响图片文件导出,但写入元数据。
@@ -50,6 +51,13 @@
│ ├─ 001-拼图素材.png
│ ├─ 002-生成图片.png
│ └─ 003-修改结果.png
├─ sequences/
│ └─ 004-角色动作/
│ ├─ frames/
│ │ ├─ frame-01.png
│ │ └─ frame-02.png
│ ├─ metadata.json
│ └─ manifest.txt
├─ metadata.json
└─ manifest.txt
```
@@ -120,10 +128,11 @@ assetObjectId > objectKey > sourceAssetId > src
1. 从当前 `layers` 取目标图层。
2. 过滤无效图层,保留隐藏图层。
3. 按去重 key 合并图片源。
4. 对每个图片源读取 Blob
3. 按去重 key 合并图片源或序列帧源
4. 对每个素材源读取 Blob
- `data:image/...` 直接转换为 Blob。
- 同源或可访问 URL 使用 `fetch` 拉取 Blob。
- `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`
- 拉取失败时记录失败项,不中断整个导出。
5. 使用 `JSZip` 写入 `images/``metadata.json``manifest.txt`
6. `zip.generateAsync({ type: 'blob' })` 生成文件。
File diff suppressed because one or more lines are too long
@@ -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,也不再默认使用 `https://git.genarrative.world/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,也不再默认使用 `https://git.genarrative.world/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。若机器仍保留旧的 `https://git.genarrative.world/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 BashStdb 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`
@@ -13,7 +13,7 @@
- 新增桌面端独立 `/creation` 创作工具主页。
- 桌面端直接打开站点首页 `/` 时默认展示新版创作主页内容;移动端继续保持原推荐首页。
- 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`
- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口。
- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开
- 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`
- `陶泥儿精选` 展示真实账号素材瀑布流,不使用 mock 素材。
- 现有 `/creation/<play>` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。
@@ -128,7 +128,7 @@ Tab
- 桌面端从 `/creation` 切换到 `/project` 后,“项目”页签进入选中态,“创作”页签必须恢复为普通默认态,不保留创作主页的主按钮强调表现。
- `/project` 项目页头部必须提供 `返回主站` 按钮,点击后切回平台首页 `/`,行为与桌面侧栏切回推荐页保持一致。
- 移动端导航列表不展示“创作”和“项目”。
- 移动端不拦截用户直接访问 `/creation` `/project`
- 移动端直接访问 `/creation` 时显示桌面端打开引导,不加载创作工具主页;`/creation/<play>` 继续直达对应玩法工作台。`/project` 暂不新增移动端拦截
## 实施拆分
@@ -137,7 +137,7 @@ Tab
- 增加 `/creation` 路由映射和测试。
- 增加桌面 `/` 初始阶段映射测试,确认不会影响移动端首页。
- 调整桌面导航文案与点击行为。
- 移动端导航移除“创作”和“项目”入口。
- 移动端导航移除“创作”和“项目”入口,并为直达 `/creation` 增加桌面端打开引导
- 保持现有 `/creation/<play>` 工作台、生成页、结果页和运行态路由不回归。
### 创作主页
@@ -194,6 +194,7 @@ git diff --check
- 桌面导航显示“项目”,不显示“草稿”。
- `/project` 头部点击“返回主站”回到 `/`,并退出项目页主内容。
- 移动视口底部不显示“创作”和“项目”。
- 移动端直达 `/creation` 显示“请在桌面端打开创作主页”,点击“返回首页”回到 `/`
- “我的”页没有项目快捷入口。
- 陶泥儿精选素材瀑布流不出现 mock 素材、假作者或假泥点成本。
- 陶泥儿精选素材卡按真实素材宽高呈现瀑布流,不退化为固定等高网格。
@@ -205,4 +206,5 @@ git diff --check
- 不删除现有玩法草稿、作品架、生成恢复、发布或运行态能力。
- 不恢复前端硬编码创作入口配置。
- 不用 mock 素材填充用户素材展示。
-在移动端禁止直接 URL 访问 `/creation``/project`
-影响 `/creation/<play>` 玩法工作台、生成页、结果页和运行态直达恢复
- 不在本轮拦截移动端直接 URL 访问 `/project`
File diff suppressed because one or more lines are too long
@@ -163,6 +163,7 @@
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
- 每帧必须执行绿幕去背,输出透明背景 PNG。
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
- 画板前端回填角色动作结果时,必须以 `previewVideoPath` 创建 `mediaType: "video"``assetKind: "character-animation"` 图层,复用视频素材的 `<video controls preload="metadata" playsInline>` 预览和视频下载 / 导出口径;不得把 `frames[0].imageSrc` 作为图层主 `src`
- `frames[0].imageSrc` 只作为动作视频图层的 `thumbnailSrc` / poster,以及后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、绿幕去背和帧素材落盘流程
- 画板前端不把帧序列自动铺到画布上;后续若要展示逐帧图层,必须继续复用画布图层与素材库资源模型
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"``assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames``previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体
- 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `<video>` 预览
- 点击角色动作图层下载时,必须打包下载序列帧 ZIP;画布素材 ZIP 导出时,角色动作写入 `sequences/<编号-标题>/frames/`,而不是导出预览视频
- `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、绿幕去背和帧素材落盘流程。
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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}" \
+129 -92
View File
@@ -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}"
}
}
}
@@ -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',
File diff suppressed because it is too large Load Diff
@@ -317,14 +317,29 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('serializes and hydrates video thumbnails for character animation layers', () => {
it('serializes and hydrates image sequence frames for character animation layers', () => {
const layer: CanvasLayer = {
id: 'layer-action',
resourceId: 'resource-action',
title: '角色动作',
src: '/generated-character-drafts/editor/preview.mp4',
mediaType: 'video',
src: '/generated-character-drafts/editor/frame01.png',
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame01.png',
width: 1024,
height: 1024,
},
{
frameIndex: 2,
imageSrc: '/generated-character-drafts/editor/frame02.png',
width: 1024,
height: 1024,
},
],
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
x: 10,
y: 20,
width: 420,
@@ -341,6 +356,8 @@ describe('ImageCanvasEditorModel', () => {
expect(snapshot.thumbnailSrc).toBe(
'/generated-character-drafts/editor/frame01.png',
);
expect(snapshot.mediaType).toBe('image-sequence');
expect(snapshot.imageSequenceFrames).toHaveLength(2);
const hydrated = hydrateLayer(
snapshot,
@@ -356,8 +373,10 @@ describe('ImageCanvasEditorModel', () => {
);
expect(hydrated).toMatchObject({
mediaType: 'video',
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: layer.imageSequenceFrames,
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
assetKind: 'character-animation',
durationSeconds: 4,
});
@@ -202,6 +202,8 @@ export function serializeLayer(
sourceType: layer.sourceType,
mediaType: layer.mediaType,
thumbnailSrc: layer.thumbnailSrc,
imageSequenceFrames: layer.imageSequenceFrames,
previewVideoPath: layer.previewVideoPath,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
@@ -531,7 +533,11 @@ export function hydrateLayer(
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
const resource = resourcesById.get(resourceId);
const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : '';
const src = snapshotSrc || resource?.imageSrc || '';
const imageSequenceFrames = hydrateImageSequenceFrames(
snapshot.imageSequenceFrames,
);
const firstSequenceFrame = imageSequenceFrames[0]?.imageSrc ?? '';
const src = snapshotSrc || firstSequenceFrame || resource?.imageSrc || '';
const title =
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
if (!resourceId || !layerId || !src) {
@@ -561,11 +567,12 @@ export function hydrateLayer(
sourceType: isCanvasSourceType(snapshot.sourceType)
? snapshot.sourceType
: 'uploaded',
mediaType:
snapshot.mediaType === 'video' || snapshot.mediaType === 'audio'
? snapshot.mediaType
: 'image',
mediaType: resolveHydratedLayerMediaType(snapshot, imageSequenceFrames),
thumbnailSrc: stringOrNull(snapshot.thumbnailSrc),
imageSequenceFrames: imageSequenceFrames.length
? imageSequenceFrames
: undefined,
previewVideoPath: stringOrNull(snapshot.previewVideoPath),
prompt: stringOrNull(snapshot.prompt),
actualPrompt: stringOrNull(snapshot.actualPrompt),
model: stringOrNull(snapshot.model),
@@ -655,6 +662,25 @@ export function inferEditorAssetMediaType(
return 'image';
}
function resolveHydratedLayerMediaType(
snapshot: EditorProjectLayerSnapshot,
imageSequenceFrames: CanvasLayer['imageSequenceFrames'],
): CanvasMediaType {
if (snapshot.mediaType === 'image-sequence') {
return 'image-sequence';
}
if (
snapshot.assetKind === 'character-animation' &&
(imageSequenceFrames?.length || stringOrNull(snapshot.thumbnailSrc))
) {
return 'image-sequence';
}
if (snapshot.mediaType === 'video' || snapshot.mediaType === 'audio') {
return snapshot.mediaType;
}
return 'image';
}
function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
return label.includes('背景音乐') || label.toLowerCase().includes('music')
? 'background-music'
@@ -788,6 +814,33 @@ function hydrateCharacterAnimationResult(
return result as EditorCharacterAnimationGenerationResult;
}
function hydrateImageSequenceFrames(
value: unknown,
): NonNullable<CanvasLayer['imageSequenceFrames']> {
if (!Array.isArray(value)) {
return [];
}
const frames = value.flatMap((frame, index) => {
if (!frame || typeof frame !== 'object') {
return [];
}
const snapshot = frame as Record<string, unknown>;
const imageSrc = stringOrNull(snapshot.imageSrc);
if (!imageSrc) {
return [];
}
return [
{
frameIndex: numberFromSnapshot(snapshot.frameIndex, index + 1),
imageSrc,
width: numberFromSnapshot(snapshot.width, 0),
height: numberFromSnapshot(snapshot.height, 0),
},
];
});
return frames;
}
export function booleanFromSnapshot(value: unknown) {
return value === true;
}
@@ -98,6 +98,8 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
isProjectRenameSaving: false,
projectRenameError: null,
layers: [],
walletBalanceLabel: '0泥点',
isWalletBalanceLoading: false,
assetExportStatus: null,
isExportingAssets: false,
setProjectRenameValue: vi.fn(),
@@ -107,6 +109,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
resetProjectRenameError: vi.fn(),
exportCanvasAssets: vi.fn(),
onOpenShortcuts: vi.fn(),
onOpenWallet: vi.fn(),
};
}
@@ -1,6 +1,7 @@
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationFrameResult,
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
@@ -25,7 +26,7 @@ export type CanvasAssetKind =
| 'sound-effect'
| 'background-music';
export type CanvasMediaType = 'image' | 'video' | 'audio';
export type CanvasMediaType = 'image' | 'video' | 'audio' | 'image-sequence';
export type EditorAsset = {
id: string;
@@ -100,6 +101,8 @@ export type CanvasLayer = {
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
imageSequenceFrames?: EditorCharacterAnimationFrameResult[];
previewVideoPath?: string | null;
hidden?: boolean;
locked?: boolean;
flipX?: boolean;
@@ -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,67 @@ describe('ImageCanvasEditorView', () => {
});
});
it('shows the live mud point balance in the canvas topbar when logged in', async () => {
render(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy();
expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
});
});
it('opens the same account wallet entry from the canvas topbar mud point button', async () => {
render(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
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();
@@ -281,6 +360,7 @@ describe('ImageCanvasEditorView', () => {
originalHeight: 1024,
zIndex: 3,
sourceType: 'generated',
sourceResourceId: 'resource-generated',
prompt: '明亮主视觉',
model: 'gpt-image-2',
provider: 'VectorEngine',
@@ -299,6 +379,7 @@ describe('ImageCanvasEditorView', () => {
originalHeight: 120,
zIndex: 4,
sourceType: 'generated',
sourceResourceId: 'resource-failed',
},
],
resources: [],
@@ -328,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 });
@@ -14,8 +14,18 @@ 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,
generationInputsOrNull,
@@ -66,6 +76,10 @@ import {
export function ImageCanvasEditorView() {
const authUi = useAuthUi();
const [, setGenerationPricingVersion] = useState(0);
const [walletBalanceLabel, setWalletBalanceLabel] = useState<string | null>(
null,
);
const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false);
const editorRootRef = useRef<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
@@ -87,6 +101,7 @@ export function ImageCanvasEditorView() {
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const selectedLayerIdRef = useRef<string | null>(null);
const showRechargeEntry = shouldShowRechargeEntry();
useEffect(() => {
let isMounted = true;
@@ -161,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) {
@@ -175,6 +240,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 +1304,8 @@ export function ImageCanvasEditorView() {
isProjectRenameSaving,
projectRenameError,
layers,
walletBalanceLabel,
isWalletBalanceLoading,
assetExportStatus,
isExportingAssets,
setProjectRenameValue,
@@ -1188,6 +1315,7 @@ export function ImageCanvasEditorView() {
resetProjectRenameError,
exportCanvasAssets,
onOpenShortcuts: () => setIsShortcutDialogOpen(true),
onOpenWallet: openRechargeOrRewardCodeModal,
};
const stageProps = {
canvasViewportRef,
@@ -1332,6 +1460,43 @@ export function ImageCanvasEditorView() {
open={isShortcutDialogOpen}
onClose={() => setIsShortcutDialogOpen(false)}
/>
{isRewardCodeOpen ? (
<PlatformProfileRewardCodeRedeemModal
value={rewardCodeInput}
isSubmitting={isSubmittingRewardCode}
error={rewardCodeError}
success={rewardCodeSuccess}
onChange={setRewardCodeInput}
onSubmit={submitRewardCode}
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null}
{isRechargeOpen ? (
<PlatformProfileRechargeModal
center={rechargeCenter}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
submittingProductId={submittingRechargeProductId}
nativePayment={nativeWechatPayment}
activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct}
onConfirmNativePayment={confirmNativeWechatPayment}
/>
) : null}
{rechargePaymentResult ? (
<PlatformRechargePaymentResultDialog
result={rechargePaymentResult}
onClose={() => setRechargePaymentResult(null)}
/>
) : null}
{wechatRechargeOrderConfirmationState ? (
<PlatformRechargePaymentConfirmationMask
orderId={wechatRechargeOrderConfirmationState.orderId}
/>
) : null}
</>
);
}
@@ -41,6 +41,21 @@ describe('ImageCanvasExportModel', () => {
src: 'data:image/png;base64,one',
}),
).toBe('object-key');
expect(
getLayerExportKey({
...buildLayer(),
mediaType: 'image-sequence',
taskId: 'sequence-task',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame01.png',
width: 1024,
height: 1024,
},
],
}),
).toBe('image-sequence:sequence-task');
});
it('detects image extensions from content type before falling back to src', () => {
@@ -70,6 +85,13 @@ describe('ImageCanvasExportModel', () => {
'/generated-audios/effect.wav?token=1',
),
).toBe('wav');
expect(
getLayerAssetExtensionFromTypeOrSrc(
'image-sequence',
'',
'/generated-character-drafts/editor/frame01.png',
),
).toBe('zip');
});
it('converts data URLs and builds layer metadata for manifest files', async () => {
@@ -25,6 +25,14 @@ export function formatExportDate(date: Date) {
}
export function getLayerExportKey(layer: CanvasLayer) {
if (layer.mediaType === 'image-sequence') {
return `image-sequence:${
layer.taskId ||
layer.sourceResourceId ||
layer.imageSequenceFrames?.map((frame) => frame.imageSrc).join('|') ||
layer.src
}`;
}
return (
layer.assetObjectId ||
layer.objectKey ||
@@ -49,7 +57,9 @@ function pickKnownExtension(
allowedExtensions: string[],
fallback: string,
) {
return extension && allowedExtensions.includes(extension) ? extension : fallback;
return extension && allowedExtensions.includes(extension)
? extension
: fallback;
}
export function getLayerAssetExtensionFromTypeOrSrc(
@@ -59,6 +69,9 @@ export function getLayerAssetExtensionFromTypeOrSrc(
) {
const normalizedType = type.toLowerCase();
const extensionFromSrc = getExtensionFromSrc(src);
if (mediaType === 'image-sequence') {
return 'zip';
}
if (mediaType === 'audio') {
if (normalizedType.includes('mpeg') || normalizedType.includes('mp3')) {
return 'mp3';
@@ -129,26 +142,77 @@ export function dataUrlToBlob(dataUrl: string) {
return new Blob([bytes], { type });
}
export async function readLayerAssetBlob(layer: CanvasLayer) {
if (layer.src.startsWith('data:')) {
return dataUrlToBlob(layer.src);
export async function readAssetSourceBlob({
source,
objectKey,
refreshKey,
}: {
source: string;
objectKey?: string | null;
refreshKey?: string | number | null;
}) {
if (source.startsWith('data:')) {
return dataUrlToBlob(source);
}
const source = layer.objectKey
? await getSignedAssetReadUrl(
{ objectKey: layer.objectKey },
undefined,
{ cacheVersion: layer.taskId ?? layer.resourceId },
)
: await resolveAssetReadUrl(layer.src);
const response = await fetch(source);
const resolvedSource = objectKey
? await getSignedAssetReadUrl({ objectKey }, undefined, {
cacheVersion: refreshKey,
})
: await resolveAssetReadUrl(source, { refreshKey });
const response = await fetch(resolvedSource);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.blob();
}
export async function readLayerAssetBlob(layer: CanvasLayer) {
return readAssetSourceBlob({
source: layer.src,
objectKey: layer.objectKey,
refreshKey: layer.taskId ?? layer.resourceId,
});
}
export const readLayerImageBlob = readLayerAssetBlob;
export function getLayerImageSequenceFrames(layer: CanvasLayer) {
if (layer.imageSequenceFrames?.length) {
return layer.imageSequenceFrames;
}
const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim();
return fallbackSrc
? [
{
frameIndex: 1,
imageSrc: fallbackSrc,
width: layer.originalWidth,
height: layer.originalHeight,
},
]
: [];
}
export async function readLayerImageSequenceFrameBlob(
layer: CanvasLayer,
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
) {
return readAssetSourceBlob({
source: frame.imageSrc,
refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`,
});
}
export function getImageSequenceFrameFileName(
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
index: number,
blobType = '',
) {
const extension = getImageExtensionFromTypeOrSrc(blobType, frame.imageSrc);
const frameNumber = String(frame.frameIndex || index + 1).padStart(2, '0');
return `frame-${frameNumber}.${extension}`;
}
export async function blobToUint8Array(blob: Blob) {
if (typeof blob.arrayBuffer === 'function') {
return new Uint8Array(await blob.arrayBuffer());
@@ -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<CanvasLayer['generationInputs']>['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(
@@ -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),
};
}
@@ -466,7 +466,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
});
});
it('creates character animation result layers with playable video preview and frame metadata', () => {
it('creates character animation result layers as playable image sequences', () => {
const layer = createCharacterAnimationResultLayer({
generated: {
taskId: 'character-animation-task-1',
@@ -508,9 +508,18 @@ describe('ImageCanvasGenerationLayerModel', () => {
id: 'layer-character-animation-15',
resourceId: 'local-resource-character-animation-15',
title: '角色动作',
src: '/generated-character-drafts/editor/preview.mp4',
mediaType: 'video',
src: '/generated-character-drafts/editor/frame1.png',
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame1.png',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame1.png',
width: 1024,
height: 1024,
},
],
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
x: -222,
y: -242,
width: 1024,
@@ -494,9 +494,11 @@ export function createCharacterAnimationResultLayer({
id: `layer-character-animation-${generatedIndex}`,
resourceId: `local-resource-character-animation-${generatedIndex}`,
title,
src: generated.previewVideoPath,
mediaType: 'video',
src: previewFrame.imageSrc,
mediaType: 'image-sequence',
thumbnailSrc: previewFrame.imageSrc,
imageSequenceFrames: generated.frames,
previewVideoPath: generated.previewVideoPath,
x: frameX ?? worldCenter.x - width / 2,
y: frameY ?? worldCenter.y - height / 2,
width,

Some files were not shown because too many files have changed in this diff Show More