Merge branch 'master' into feat/artagent-artifact-jump-to-focus
Project CI / Repository checks (pull_request) Successful in 45s
Project CI / Frontend tests (pull_request) Successful in 3m48s
Project CI / Backend tests (pull_request) Successful in 4m25s
Project CI / Native shell tests (pull_request) Successful in 11m50s

This commit is contained in:
2026-07-31 16:48:05 +08:00
29 changed files with 1839 additions and 215 deletions
@@ -321,24 +321,26 @@ fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() {
let root = temporary.path().join("project");
init_local_game_project_at(&root, "manifest-waiting-project", TASK)
.expect("init manifest waiting project");
start_game_creator_supervisor_background_task_for_session_at(
let session_id = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve autonomous parent session");
let task_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&session_id,
TASK,
RUN_ID,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue autonomous parent task");
update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running)
.expect("start first manifest task");
let task_record = read_latest_game_creator_agent_runtime_task_by_run_id(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
RUN_ID,
)
.expect("read autonomous parent task")
.expect("autonomous parent task exists");
let mut runtime = agent_runtime_state_from_task_record(&task_record);
let mut observations = Vec::new();
let continuation = AgentRuntimeContinuationContext::default();
@@ -3,7 +3,7 @@
"info": {
"title": "陶泥儿外部编辑器 OpenAPI",
"version": "1.0.0",
"description": "外部系统调用陶泥儿图片画布项目、画布布局、素材库,以及图片、视频、音效、音乐等编辑器素材生成/编辑能力的 v1 契约。新建 projectId 使用 proj- 前缀,新建 taskId / operationId 使用 task- 前缀;历史 editor-project-*、aitask_*、extgen-* ID 仍可作为既有资源标识传入。"
"description": "外部系统调用陶泥儿图片画布项目、画布布局、素材库,以及图片、视频、音效、音乐等编辑器素材生成/编辑能力的 v1 契约。新建 projectId 使用 proj- 前缀,新建 taskId / operationId 使用 task- 前缀;历史 editor-project-*、aitask_*、extgen-* ID 仍可作为既有资源标识传入。\n\n兼容性说明:v1 当前处于无外部存量调用方阶段,正式对外发放 API Key 之前,契约可能在不升 info.version、不设弃用期的情况下发生包含字段移除在内的破坏性变更。生成客户端时请勿假定本文档已冻结。"
},
"servers": [
{
@@ -2293,12 +2293,6 @@
"null"
]
},
"provider": {
"type": [
"string",
"null"
]
},
"taskId": {
"type": [
"string",
@@ -2476,12 +2470,6 @@
"null"
]
},
"provider": {
"type": [
"string",
"null"
]
},
"taskId": {
"type": [
"string",
@@ -2852,7 +2840,6 @@
"sourceType",
"prompt",
"model",
"provider",
"taskId"
],
"properties": {
@@ -2895,9 +2882,6 @@
"model": {
"type": "string"
},
"provider": {
"type": "string"
},
"taskId": {
"type": "string"
},
@@ -3231,7 +3215,6 @@
"spritesheetHeight",
"prompt",
"model",
"provider",
"taskId",
"priceMudPoints"
],
@@ -3277,9 +3260,6 @@
"model": {
"type": "string"
},
"provider": {
"type": "string"
},
"taskId": {
"type": "string"
},
@@ -3718,7 +3698,6 @@
"sourceType",
"prompt",
"model",
"provider",
"taskId",
"durationSeconds",
"resolution",
@@ -3767,9 +3746,6 @@
"model": {
"type": "string"
},
"provider": {
"type": "string"
},
"taskId": {
"type": "string"
},
@@ -3955,7 +3931,6 @@
"sourceType",
"prompt",
"model",
"provider",
"taskId",
"priceMudPoints",
"audioKind"
@@ -4003,9 +3978,6 @@
"model": {
"type": "string"
},
"provider": {
"type": "string"
},
"taskId": {
"type": "string"
},
@@ -16,6 +16,56 @@
---
## 2026-07-30 抠图实际后端作为 generationInputs 顶层内部元数据保存
- 背景:角色、图标图集和 UI 图集抠图派生资产需要保留最终实际执行的处理后端,供后台诊断 BgFilter、阿里云通用抠图和本地键色的降级结果;把抠图模型写成 `generationInputs.fields` 的“处理模型”会进入图片信息,与用户可见输入快照语义冲突,而覆盖正式资产 `model` 又会丢失源生图模型。
- 决策:继续使用现有 `generation_inputs_json` JSON 包络,不修改 SpacetimeDB schema。`fields` / `references` 只保存用户可见生成输入;仿照顶层 `screenColorHex`,抠图派生资产在顶层写入 `mattingProvider` / `mattingModel`。BgFilter 记录本次实际 `seg_model`;阿里云记录 `Aliyun Matting / segment-common-image`;本地键色记录 `Genarrative Local / screen-color-keying`。三条链路的正式资产 `model` 继续继承源生图模型,图片信息不读取顶层内部字段。`screenColorHex``mattingProvider``mattingModel` 和素材顶层 `provider` 属于内部执行信息:普通用户(包括素材 owner)、精选提交 / 点赞响应与匿名公开读取统一省略,只有后台管理和服务端 raw 审计读取原始值;历史数据不迁移,在 User/Public mapper 边界清理。角色动作逐帧可能混用多个 fallback,本次不把单帧结果提升为整组动画模型。
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的抠图结果和角色 / 图标 / UI 派生资产持久化、图片信息兼容测试、后端数据契约与图片画布 MVP 文档;不修改前端生产展示逻辑、请求 DTO、SpacetimeDB 表、迁移或生成绑定。
- 验证方式:后端单测覆盖三种实际结果映射、顶层元数据不改写 `fields`,结构断言覆盖三条派生资产持久化链路仍保留源生图模型;User/Public mapper 测试同时证明素材 owner、精选提交 / 点赞与匿名响应均省略 `provider` 和三个内部键,Admin raw payload 保留原始值;前端测试覆盖顶层字段不在图片信息或搜索索引出现。运行 `cargo test -p api-server editor_project::tests --manifest-path server-rs/Cargo.toml`、图片信息定向前端测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
## 2026-07-31 修正抠图内部元数据的普通用户读取边界
- 背景:2026-07-30 的记录误把素材 owner 与后台审计并列为原始抠图执行信息的读取方。owner 是普通用户,前端不展示字段不能阻止其从项目资源、素材库、精选提交 / 点赞回包、画布布局或任务完成响应的网络 payload 读取 BgFilter、阿里云、本地键色、具体分割模型或背景色。
- 决策:本条取代 2026-07-30 决策中“素材 owner、精选提交响应仍可读取原始值”的表述。普通用户(包括素材 owner)和匿名公开读取必须共同过滤素材顶层 `provider`、内部处理 `model`,以及 `generationInputs` 顶层 `screenColorHex``mattingProvider``mattingModel`;正常用户可见生成 `model` 和其他合法功能性顶层字段(例如 `characterAnimation`)保持不变。User/Owner mapper 先完成该清理,public mapper 在其基础上叠加公开字段规则;后台管理与服务端审计继续使用 raw mapper 和持久化原值。历史数据不迁移,统一在读取边界清理。
- 入站与持久化:客户端提交的 `generationInputs` 不得伪造上述内部键,服务端在实际处理完成后才写入可信值。手动去背景的正式素材 `model` 必须继承经服务端验证的正常源生图模型;若来源或祖先链不存在正常模型则为 `null`,不得写入 `BgFilter complex` 等内部处理模型。内部抠图 provider / model 可继续持久化供后台审计,普通用户完成响应和用户可见错误文本均不得暴露它们;手动去背景与角色动作透明化失败在 Owner HTTP / 任务状态边界统一替换为稳定业务文案,原始错误只留在任务记录、tracing 和后台审计。
- 影响范围:项目资源、素材库、精选提交 / 点赞、图片 / 图标 / 视频 / 音频 / 角色动画生成完成、Agent 紧凑结果和识别出的画布资源 / 图层快照的 User/Public mapper;手动去背景持久化和完成响应;External Editor API 创建素材 / 资源时的保留键入站清理;相应响应 / OpenAPI 契约、前端搜索 / 详情 / ZIP 过滤测试与后台 raw 审计测试。同源画布 BFF 的角色、图标和 UI 请求继续由前端自动提交默认 `segModel=birefnet`,后端继续校验并在缺失时回落默认值;该请求控制字段不进入 `generationInputs`、普通用户响应、搜索、详情、导出或错误详情。角色动作的 `seg_model` 继续由后端固定。不修改 SpacetimeDB schema、迁移或 bindings。
- 验证方式:Owner 和匿名响应覆盖无 `provider`、无内部 `model`、无三个内部 `generationInputs` 键,且正常 `model``characterAnimation` 仍保留;Admin raw payload 保持完整。覆盖历史 `BgFilter complex`、三类入站伪造键、手动去背景源模型回溯和用户错误文本过滤;运行 api-server 定向测试、前端定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
## 2026-07-31 接受外部 OpenAPI v1 的未版本化 breaking change
- 背景:2026-07-31「修正抠图内部元数据的普通用户读取边界」把 OpenAPI 更新列为影响范围内的机械同步,但实际动作是从已发布的 `/api/external/v1` 契约中删除四个生成响应里原本 `required``provider`,另从 `EditorProjectResource` / `EditorAsset` 删除可选 `provider`,而 `info.version` 仍为 `1.0.0`、路径前缀未变。严格反序列化或由 OpenAPI 生成的调用方会在服务端上线瞬间直接失败,且不需要调用方做任何动作。脱敏目标本身成立,但契约处理方式当时没有单独定性。
- 决策:确认这是 breaking change 而非文档同步,并接受本次不升版本、不提供兼容字段、不设弃用期。唯一依据是截至 2026-07-31 `external_api_key` 无属于外部第三方的存量调用方。该豁免不具一般性:API Key 由用户在个人中心自助发放,`/api/external/v1/openapi.json` 又是该批路由中唯一免鉴权端点,因此「无外部调用方」不是受控状态,出现非内部账号活跃密钥、对外公布契约或与外部团队联调后立即失效。今后删除响应字段、把字段移出 `required`、收窄类型或取值、改变字段语义、新增请求必填字段均视为 breaking,存量调用方出现后必须按兼容值、弃用期或 `/api/external/v2` 三选一处理,只更新 JSON 不构成合规变更流程。本次不追加代码改动。
- 影响范围:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md` 新增「版本与兼容策略」一节;`docs/openapi/genarrative-external-v1.openapi.json``info.description` 补充面向集成方的兼容性说明;`pitfalls.md` 记录「无调用方」不可当长期前提。不修改响应结构、请求 DTO、路由或 `external_editor_api.rs` 断言。
- 验证方式:`info.version` 保持 `1.0.0` 且 JSON 仍可被 `serde_json` / `json.load` 解析;`external_editor_api.rs` 既有 openapi 断言继续通过。该断言只校验 schema 形状、不校验兼容性,因此通过不等于契约安全,判定仍以上述 breaking 清单为准。正式对外发放第一个外部密钥前需复核本条是否仍成立。
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md``docs/openapi/genarrative-external-v1.openapi.json``docs/project-memory/shared-memory/pitfalls.md`
---
## 2026-07-31 历史素材的内部处理模型不做回溯清理
- 背景:用户可见性过滤 `isEditorUserVisibleGenerationInputField` 的实现是标题精确匹配 `处理模型`,既不识别语义也不探测取值;服务端 User/Public mapper 只清理 `generationInputs` 顶层的 `screenColorHex` / `mattingProvider` / `mattingModel`,不遍历 `fields` 数组。历史资产中存在标题为「抠图模型」、取值形如 `动漫风格 anime-seg` 的字段,两侧都拦不住,因此仍会出现在图片信息弹窗和画布 ZIP 导出元数据中。MVP 接入方案原文「前端同时过滤历史项目中已持久化的处理模型字段」读起来是全覆盖保证,与实际实现和历史数据存在显式冲突。
- 决策:维持产品决策——历史素材不迁移、不回溯清理,本次不扩大过滤范围,不修改 `isEditorUserVisibleGenerationInputField`。改为收敛文档口径:明确过滤是标题精确匹配而非语义识别,明确「抠图模型」为已知例外且属于接受状态,不得据此判定为缺陷。约束只对新写入生效,新产生的 `generationInputs.fields` 不得再写入任何内部处理模型字段,无论标题为何。若将来决定扩大过滤范围,必须先对生产 `generation_inputs_json` 做标题去重查询枚举真实存在的历史标题,不得仅凭测试夹具推断清单。
- 影响范围:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` 收敛该句表述并补充已知例外;`pitfalls.md` 记录过滤口径与排查方式。不修改前端过滤实现、服务端 mapper、素材数据或导出结构。
- 验证方式:图片信息弹窗与画布 ZIP 共用同一过滤口径,任何一侧改动必须同时覆盖另一侧;既有 `ImageCanvasMetadataModalView``ImageCanvasExportModel` 测试保持通过,不新增针对历史「抠图模型」的过滤断言,以免与本决策冲突。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/project-memory/shared-memory/pitfalls.md`
---
## 2026-07-30 图片画布搜索不得索引内部模型和 Provider
- 背景:素材和图层详情虽已把内部处理模型显示为 `-`,搜索仍索引原始 `model``provider`,导致 `BgFilter complex``segment-common-image``BgFilter``Aliyun Matting` 等隐藏信息可被查询命中,并出现“命中但无可见匹配字段”的异常体验。
- 决策:素材与图层搜索只索引用户可见生成模型;统一复用 `isEditorInternalProcessingModel(...)` 排除内部处理模型,并完全排除 `provider`。该规则只约束前端临时搜索值,不删除或改写素材、图层及后端保存的原始审计字段;`gpt-image-2``audio1.0` 等正常模型继续支持搜索。
- 影响范围:`ImageCanvasAssetLibraryModel.ts` 的素材与图层搜索值、图片画布素材 / 图层侧栏搜索和对应前端架构文档;不改变持久化、详情展示、删除、移动或画布保存行为。
- 验证方式:模型单测覆盖内部模型和 Provider 不命中、正常模型继续命中且原对象元数据不变;侧栏交互测试覆盖素材与图层两类入口。运行对应 Vitest、`npm run typecheck``npm run lint:eslint``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
## 2026-07-31 每日免费泥点基础额度纳入后台钱包配置
- 背景:每日免费泥点已是独立余额桶,但基础发放量仍在运行时固定为 `20`,后台“账号配置”只能维护注册初始泥点,运营调整需要改代码。
@@ -35,7 +85,6 @@
- 影响范围:`scripts/deploy/production-stdb-publish.sh``scripts/database-backup-to-oss.mjs`、生产运维门禁和本文档。
- 验证方式:`npm run check:database-backup``npm run check:production-ops``npm run check:encoding``git diff --check`dev 现场还必须确认 transient unit 不在 Jenkins session scope,旧 deferred 归档逐份变为 OSS 已验真对象后被删除,备份锁清空,核心服务与公开接口健康。
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
---
## 2026-07-31 画布 Agent 图片结果单击直接定位
+17 -1
View File
@@ -260,7 +260,7 @@
- 现象:画板生成、快速编辑、图标素材或 UI 素材提取如果允许直接提交 generated objectKey,用户只要知道其他账号的私有 objectKey,就可能让 api-server 签名读取并送给外部生成供应商。
- 原因:Data URL/Blob URL 只允许停留在浏览器临时态,正式编辑器引用必须先上传并经统一 resolver 校验归属。
- 处理:所有私有对象引用在读取字节或签发 URL 前统一走 `resolve_editor_reference_object_key_for_owner(state, owner_user_id, source)`,先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key。只有确实需要图片字节的入口(生成 / 重绘 / 图标 / UI 提取等交给 provider 的路径)再走 `parse_editor_reference_image`(内部仍先 resolve,再下载 OSS 字节);手动去背景等只签发短期 URL 的入口不要 `parse` 整图。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。
- 处理:所有私有对象引用在读取字节或签发 URL 前统一走 `resolve_editor_reference_object_key_for_owner(state, owner_user_id, source)`,先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key。只有确实需要图片字节的入口(生成 / 重绘 / 图标 / UI 提取等交给 provider 的路径)再走 `parse_editor_reference_image`(内部仍先 resolve,再下载 OSS 字节);手动去背景等只签发短期 URL 的入口不要 `parse` 整图。手动去背景还要恢复源模型时,object key 解析、所有权校验和源模型回溯必须复用同一轮账号项目 / 素材快照,项目与素材快照各最多读取一次;不得先走通用 resolver 全量读取,再为模型回溯重复拉取完整画布和素材库。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。
- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入图标生成请求;若快速编辑重开额外参考图,再补对应请求覆盖。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/spacetime-client/src/assets.rs``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`
@@ -3982,3 +3982,19 @@
- 现象:为了让 Runtime 调用中立 trait,在 core 或 AGC 内再拼一次 URL/header/request body,或自己消费 SSE;它会与 `platform-llm` 的重试、脱敏、工具分片和错误分类迅速漂移。
- 处理:adapter 只做 core DTO 与现有 `Llm*` DTO 转换,网络调用唯一落到 `LlmClient::run/stream_run`。流式 sink 保留累计文本、当前增量和 finish reasontool calls 继续从最终 response 读取。
- 验证:三种 descriptor/capability、request/response round-trip、stream callback 和稳定 error kind 单测后,仍必须运行 `platform-llm` 全量 parser 测试;只有 adapter fake 通过不能证明 wire 协议没有回归。
## 内部处理模型的可见性过滤是标题精确匹配,不是语义识别
- 现象:读文档以为「内部处理模型不会展示给普通用户」是全覆盖保证,实际历史素材的图片信息弹窗和画布 ZIP 导出里仍能看到抠图模型,例如标题“抠图模型”、取值 `动漫风格 anime-seg`
- 原因:`isEditorUserVisibleGenerationInputField` 的实现是 `field.title.trim() !== '处理模型'`,只按这一个标题字符串精确排除,既不识别语义也不探测取值。历史数据里存在标题不同但语义相同的字段,直接穿过过滤器;服务端 User/Public mapper 只清理 `generationInputs` 顶层的 `screenColorHex` / `mattingProvider` / `mattingModel`,不遍历 `fields` 数组,所以两侧都不会拦。
- 处理:本条目前不修——历史素材不迁移、不回溯清理是明确的产品决策,不得据此判定为缺陷或提交「修复」。约束只对新写入生效:新产生的 `generationInputs.fields` 不得再写入任何内部处理模型字段,无论标题叫什么。若将来要扩大过滤范围,先对生产 `generation_inputs_json` 做一次标题去重查询枚举真实存在的历史标题,不要仅凭测试夹具推断清单。
- 验证:`ImageCanvasMetadataModalView``ImageCanvasExportModel` 共用同一过滤口径,改动其一必须同时覆盖另一侧;新增过滤标题时需同时确认图片信息弹窗与画布 ZIP 两条路径。
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``isEditorUserVisibleGenerationInputField`)、`src/components/image-editor/ImageCanvasExportModel.ts``src/components/image-editor/ImageCanvasMetadataModalView.tsx``src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 外部 OpenAPI v1 的「无调用方」豁免不是受控状态
- 现象:认为外部 v1 契约可以随内部脱敏需要直接改,因为「反正没人接」;`docs/openapi/genarrative-external-v1.openapi.json` 被当成内部文档同步,不走版本流程。
- 原因:API Key 由用户在个人中心 `我的 → 开发者 API Key` 自助发放,`/api/external/v1/openapi.json` 又是该批路由里唯一不要求鉴权的端点,任何登录用户都能拉规格并生成客户端。因此「无外部调用方」随时可能在无人决策的情况下变为假,不能当作长期前提。
- 处理:改外部 v1 响应前先确认 `external_api_key` 是否已有非内部账号的活跃密钥。仍无调用方时可按现行豁免直接改,但必须同步更新接入方案的「版本与兼容策略」;已有调用方时按该节规则择一处理(兼容值 / 弃用期 / 升 v2),只改 JSON 不构成合规变更。
- 验证:`external_editor_api.rs` 的 openapi 断言只校验 schema 形状,不校验兼容性,通过不等于契约安全;判定 breaking 与否以「删字段、移出 required、收窄类型、改语义、新增必填」为准。
- 关联:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md``docs/openapi/genarrative-external-v1.openapi.json``server-rs/crates/api-server/src/external_editor_api.rs``server-rs/crates/api-server/src/modules/external_api.rs`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -61,6 +61,44 @@ DELETE /api/profile/api-keys/{keyId}
前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。外部 OpenAPI 只描述 `/api/external/v1` 下可由 API Key 调用的接口,不混入登录态 API Key 管理接口。
## 版本与兼容策略
### 当前状态:v1 尚无外部存量调用方
截至 2026-07-31`external_api_key` 表内没有属于外部第三方的存量调用方,v1 处于「已发布但无存量集成」阶段。本节记录的豁免只在该前提成立时有效。
### 已接受的未版本化 breaking change
2026-07-31「收紧编辑器内部处理元数据边界」从下列响应中移除了原本 `required``provider``info.version` 保持 `1.0.0`,路径前缀保持 `/api/external/v1`
- `EditorImageGenerationResponse``EditorIconSpritesheetGenerationResponse``EditorVideoGenerationResponse``EditorAudioGenerationResponse``provider` 原为 `required` 且非空 `string`,属性整体移除,JSON key 不再出现。
- `EditorProjectResource``EditorAsset``provider` 原为可选 nullable,属性移除后同样不再出现在响应中。
受影响端点为 `POST /api/external/v1/editor/images/generations``.../images/edits``.../icon-spritesheets/generations``.../ui-designs/assets/extractions``.../videos/generations``.../audios/sound-effects/generations``.../audios/background-music/generations`,以及全部引用上述两个资源 schema 的读取端点。
这是 breaking change,不是文档同步:严格反序列化的调用方(OpenAPI Generator 生成的 Java / Kotlin / C#、pydantic、serde 非 `Option` 字段)在 `required` 字段缺失时直接失败,且失败发生在服务端上线瞬间,不需要调用方做任何动作。脱敏目标本身成立,接受不升版本、不设弃用期的唯一依据是当前无存量调用方。
### 豁免的失效条件
API Key 由用户在个人中心自助发放,因此「无外部调用方」不是受控状态,可能在无人决策的情况下变为假。本节豁免在下列任一条件出现后立即失效:
- `external_api_key` 出现属于非内部账号的活跃密钥;
- 对外公布 v1 契约、接入文档或示例代码;
- 与外部团队开始基于 v1 的联调。
正式对外发放第一个外部密钥之前,必须先确认下节规则已生效且 v1 契约已冻结。
### 今后的变更规则
下列改动视为 breaking,不得在同一 `info.version` 与同一路径前缀下直接发布:
- 删除响应字段,或把响应字段移出 `required`
- 收窄字段类型、取值枚举或长度约束;
- 在不改字段名的前提下改变字段语义;
- 新增请求必填字段,或收紧既有请求字段的校验。
存量调用方出现后,上述改动按以下顺序择一处理:保留字段并返回兼容值(可为 `null` 或占位值);标注 `deprecated` 并公告弃用期后再移除;或开设 `/api/external/v2` 并冻结 v1。仅更新 `docs/openapi/genarrative-external-v1.openapi.json` 不构成合规的变更流程。
## 鉴权
外部调用使用 Bearer API Key
@@ -116,7 +154,7 @@ SpacetimeDB procedure
1. 通过 OSS / asset object adapter 持久化媒体文件。
2. 写入 `editor_asset`,让生成素材进入账号级素材库。
3. 如果请求带 `projectId`,写入 `editor_project_resource`
4. 返回图片读取地址、素材 ID、资源 ID、尺寸、prompt、model、provider 和 taskId。
4. 返回图片读取地址、素材 ID、资源 ID、尺寸、prompt、model 和 taskId;普通 External API 响应、项目资源与素材 read model 不返回生成 provider 或内部抠图审计字段。同源画布前端自动提交默认 `segModel` 属于站内 BFF 请求契约,不因此向 External OpenAPI 开放该字段
如果请求未带 `projectId`,只生成并写入素材库;调用方可随后创建项目或自行保存画板布局。
+41 -4
View File
@@ -4043,10 +4043,11 @@ mod tests {
use super::{
AdminDashboardGranularity, AdminDisplayNameDirectory, EditorShowcaseAssetRecord,
admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result,
admin_editor_asset_group_payload, admin_editor_showcase_asset_payload_from_record,
append_spacetime_sql_response_chunk, apply_admin_database_table_filters,
build_admin_asset_read_url_audit, build_admin_dashboard_chart,
build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request,
admin_editor_asset_group_payload, admin_editor_asset_payload_from_record,
admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk,
apply_admin_database_table_filters, build_admin_asset_read_url_audit,
build_admin_dashboard_chart, build_admin_database_table_row,
build_admin_editor_showcase_campaign_image_confirm_request,
build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview,
build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit,
clamp_admin_tracking_event_limit, enforce_admin_request_permission,
@@ -4154,6 +4155,42 @@ mod tests {
}
}
#[test]
fn admin_editor_asset_payload_keeps_internal_matting_audit_metadata() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let mut record = test_admin_editor_asset_record(
"asset-matting",
"task-matting",
"抠图结果",
"generated-character-drafts/editor/matting.png",
0,
);
record.model = Some("BgFilter complex".to_string());
record.provider = Some("BgFilter".to_string());
record.generation_inputs = Some(json!({
"fields": [],
"references": [],
"screenColorHex": "#CFEFFF",
"mattingProvider": "BgFilter",
"mattingModel": "birefnet"
}));
let payload = admin_editor_asset_payload_from_record(&state, record);
let value = serde_json::to_value(payload).expect("admin payload should serialize");
assert_eq!(value["model"], json!("BgFilter complex"));
assert_eq!(value["provider"], json!("BgFilter"));
assert_eq!(
value["generationInputs"]["screenColorHex"],
json!("#CFEFFF")
);
assert_eq!(
value["generationInputs"]["mattingProvider"],
json!("BgFilter")
);
assert_eq!(value["generationInputs"]["mattingModel"], json!("birefnet"));
}
#[test]
fn admin_asset_read_url_audit_keeps_actor_and_target_without_signed_url() {
let draft = build_admin_asset_read_url_audit(
@@ -1164,7 +1164,6 @@ pub(crate) async fn generate_editor_video_for_owner(
prompt: normalized.prompt,
actual_prompt: Some(generated.submitted_prompt),
model: normalized.model,
provider: "VectorEngine".to_string(),
task_id,
duration_seconds: normalized.duration_seconds,
resolution: normalized.resolution,
@@ -5827,7 +5826,22 @@ fn character_animation_error_response(
request_context: &RequestContext,
error: AppError,
) -> Response {
error.into_response_with_context(Some(request_context))
user_visible_character_animation_error(error).into_response_with_context(Some(request_context))
}
fn user_visible_character_animation_error(error: AppError) -> AppError {
if !error.status_code().is_server_error() {
return error;
}
tracing::error!(
status = %error.status_code(),
error = %error,
"editor_character_animation_internal_error_hidden_from_user"
);
AppError::from_status(error.status_code()).with_details(json!({
"message": "角色动作生成失败,请稍后重试。",
}))
}
fn editor_video_error_response(request_context: &RequestContext, error: AppError) -> Response {
@@ -5985,6 +5999,23 @@ mod tests {
use super::*;
use crate::AppConfig;
#[test]
fn character_animation_server_error_hides_internal_matting_details() {
let error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "BgFilter",
"message": "BgFilter birefnet provider 调用失败",
}));
let sanitized = user_visible_character_animation_error(error);
assert_eq!(sanitized.status_code(), StatusCode::BAD_GATEWAY);
assert_eq!(sanitized.body_text(), "角色动作生成失败,请稍后重试。");
let details = sanitized.details().expect("稳定错误应保留用户文案");
assert!(details.get("provider").is_none());
let lower = details.to_string().to_ascii_lowercase();
assert!(!lower.contains("bgfilter"));
assert!(!lower.contains("birefnet"));
}
#[test]
fn parse_video_data_url_accepts_mp4_payload() {
let parsed =
File diff suppressed because it is too large Load Diff
@@ -32,8 +32,8 @@ use crate::{
editor_project_resource_payload_from_record, extract_editor_ui_design_assets_for_owner,
generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner,
map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string,
parse_editor_generation_json_payload, save_editor_project_layout_with_revision_and_get,
serialize_editor_asset_metadata,
parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
},
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
@@ -467,7 +467,7 @@ pub async fn create_external_editor_asset(
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let generation_inputs_json =
serialize_editor_asset_metadata(payload.generation_inputs.clone())?;
serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?;
let object_key = normalize_optional_string(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let asset = state
@@ -569,7 +569,7 @@ pub async fn create_external_editor_project_resource(
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let generation_inputs_json =
serialize_editor_asset_metadata(payload.generation_inputs.clone())?;
serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?;
let object_key = normalize_optional_string(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let resource = state
@@ -787,6 +787,12 @@ fn normalize_project_title(title: Option<String>) -> String {
.unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string())
}
fn serialize_external_editor_generation_inputs(
generation_inputs: Option<Value>,
) -> Result<Option<String>, AppError> {
serialize_editor_asset_metadata(sanitize_editor_client_generation_inputs(generation_inputs))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -809,6 +815,26 @@ mod tests {
assert_eq!(request.expected_revision, 7);
}
#[test]
fn external_editor_generation_inputs_cannot_forge_internal_audit_fields() {
let serialized = serialize_external_editor_generation_inputs(Some(json!({
"fields": [{"label": "角色设定", "value": "骑士"}],
"screenColorHex": "#00FF00",
"mattingProvider": "forged-provider",
"mattingModel": "forged-model",
"characterAnimation": {"durationSeconds": 4}
})))
.expect("外部编辑器生成输入应可序列化")
.expect("非空生成输入应保留");
let parsed: Value = serde_json::from_str(&serialized).expect("生成输入应为合法 JSON");
assert_eq!(parsed["fields"][0]["value"], "骑士");
assert_eq!(parsed["characterAnimation"]["durationSeconds"], 4);
assert!(parsed.get("screenColorHex").is_none());
assert!(parsed.get("mattingProvider").is_none());
assert!(parsed.get("mattingModel").is_none());
}
#[test]
fn exported_openapi_json_contains_external_editor_routes_and_security() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
@@ -1025,5 +1051,25 @@ mod tests {
parsed["components"]["securitySchemes"]["ExternalApiKey"]["scheme"],
"bearer"
);
for schema_name in [
"EditorProjectResource",
"EditorAsset",
"EditorImageGenerationResponse",
"EditorIconSpritesheetGenerationResponse",
"EditorVideoGenerationResponse",
"EditorAudioGenerationResponse",
] {
let schema = &parsed["components"]["schemas"][schema_name];
assert!(
schema["properties"].get("provider").is_none(),
"{schema_name} 不应向普通用户公开 provider"
);
assert!(
schema["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("provider"))),
"{schema_name} 不应要求 provider"
);
}
}
}
@@ -19,6 +19,9 @@ use spacetime_client::{
ExternalGenerationJobSummaryRecord, SpacetimeClientError,
};
use crate::editor_generation_queue::{
EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
@@ -180,7 +183,10 @@ fn map_external_generation_job_status(
ExternalGenerationJobStatus::Failed,
"生成失败。",
0,
job.last_error_message.clone(),
user_visible_external_generation_error(
job.job_kind.as_str(),
job.last_error_message.clone(),
),
),
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8, None),
};
@@ -196,6 +202,21 @@ fn map_external_generation_job_status(
}
}
/// 外部任务表保存的失败文本同时服务于 worker 诊断和用户通知。抠图及角色动作透明化任务的
/// 原始失败文本可能包含内部 provider、分割模型或实现细节,因此只能在普通用户读取边界
/// 替换成稳定文案。
/// 原始值仍留在任务记录、tracing 和后台审计路径中。
fn user_visible_external_generation_error(job_kind: &str, error: Option<String>) -> Option<String> {
if job_kind == EDITOR_BACKGROUND_REMOVAL_JOB_KIND && error.is_some() {
return Some("去除背景失败,请稍后重试。".to_string());
}
if job_kind == EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND && error.is_some() {
return Some("角色动作生成失败,请稍后重试。".to_string());
}
error
}
fn map_external_generation_job_status_detail(
job: ExternalGenerationJobSummaryRecord,
) -> ExternalGenerationJobStatusDetailRecord {
@@ -392,4 +413,70 @@ mod tests {
let generating = map_external_generation_job_status(job);
assert_eq!(generating.phase_detail, "正在生成。");
}
#[test]
fn background_removal_failure_hides_internal_processing_details_from_owner() {
let job = ExternalGenerationJobSummaryRecord {
job_id: "task-matting-failed".to_string(),
job_kind: EDITOR_BACKGROUND_REMOVAL_JOB_KIND.to_string(),
owner_user_id: "user-1".to_string(),
source_module: "editor-canvas".to_string(),
source_entity_id: "project-1".to_string(),
request_label: "去除背景".to_string(),
request_prompt: None,
status: "failed".to_string(),
last_error_message: Some(
"BgFilter birefnet provider 返回失败,model=segment-common-image".to_string(),
),
created_at: "2026-07-31T08:00:00Z".to_string(),
started_at: Some("2026-07-31T08:00:01Z".to_string()),
completed_at: Some("2026-07-31T08:00:10Z".to_string()),
updated_at: "2026-07-31T08:00:10Z".to_string(),
updated_at_micros: 1_000,
price_mud_points: 0,
refund_ledger_id: None,
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: None,
warning_message: None,
};
let status = map_external_generation_job_status(job.clone());
let task = map_external_generation_task_record(job);
assert_eq!(status.error.as_deref(), Some("去除背景失败,请稍后重试。"));
assert_eq!(task.error.as_deref(), Some("去除背景失败,请稍后重试。"));
for value in [status.error, task.error].into_iter().flatten() {
let lower = value.to_ascii_lowercase();
assert!(!lower.contains("bgfilter"));
assert!(!lower.contains("birefnet"));
assert!(!lower.contains("segment-common-image"));
}
}
#[test]
fn character_animation_failure_hides_internal_matting_details_from_owner() {
let message = user_visible_external_generation_error(
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
Some("解析 BgFilter 输入动作帧失败,seg_model=birefnetprovider=BgFilter".to_string()),
);
assert_eq!(message.as_deref(), Some("角色动作生成失败,请稍后重试。"));
let lower = message
.expect("失败任务应返回稳定文案")
.to_ascii_lowercase();
assert!(!lower.contains("bgfilter"));
assert!(!lower.contains("birefnet"));
assert!(!lower.contains("provider"));
}
#[test]
fn unrelated_generation_failure_keeps_its_user_visible_message() {
let message = user_visible_external_generation_error(
"editor_image_generation",
Some("生成服务暂时不可用。".to_string()),
);
assert_eq!(message.as_deref(), Some("生成服务暂时不可用。"));
}
}
@@ -1237,6 +1237,24 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
let Some(object) = result.as_object_mut() else {
return result;
};
// 紧凑结果会回传给普通用户的 Agent 工具调用卡片,不能把供应商或内部后处理实现
// 当作可见生成信息下发。正常的用户可见模型仍然保留,以便卡片恢复原有展示。
object.remove("provider");
if object
.get("model")
.and_then(Value::as_str)
.is_some_and(is_editor_internal_processing_model)
{
object.remove("model");
}
if let Some(generation_inputs) = object
.get_mut("generationInputs")
.and_then(Value::as_object_mut)
{
for field in ["screenColorHex", "mattingProvider", "mattingModel"] {
generation_inputs.remove(field);
}
}
object.remove("project");
object.remove("asset");
object.remove("spritesheetAsset");
@@ -1271,6 +1289,18 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
result
}
fn is_editor_internal_processing_model(model: &str) -> bool {
matches!(
model.trim().to_ascii_lowercase().as_str(),
"anime-seg"
| "bgfilter complex"
| "birefnet"
| "connected-components"
| "screen-color-keying"
| "segment-common-image"
)
}
fn extract_editor_generation_warning_fields(
warning: Option<&Value>,
is_slice_warning: bool,
@@ -1757,6 +1787,13 @@ mod tests {
"actualPrompt": null,
"model": "gpt-image-2",
"provider": "VectorEngine",
"generationInputs": {
"fields": { "prompt": "castle" },
"characterAnimation": { "frameCount": 8 },
"screenColorHex": "#CFEFFF",
"mattingProvider": "BgFilter",
"mattingModel": "birefnet",
},
"taskId": "provider-task-1",
"resource": {
"resourceId": "resource-1",
@@ -1794,9 +1831,45 @@ mod tests {
.get("project")
.is_none()
);
assert_eq!(
payload["editor-agent-tool-call-result"]["model"],
json!("gpt-image-2")
);
assert!(
payload["editor-agent-tool-call-result"]
.get("provider")
.is_none()
);
assert_eq!(
payload["editor-agent-tool-call-result"]["generationInputs"],
json!({
"fields": { "prompt": "castle" },
"characterAnimation": { "frameCount": 8 },
})
);
assert!(!payload.to_string().contains("data:image"));
}
#[test]
fn compact_editor_generation_result_removes_internal_models_case_insensitively() {
for model in [
"anime-seg",
" BgFilter Complex ",
"BIREFNET",
"connected-components",
"screen-color-keying",
"segment-common-image",
] {
let compact = compact_editor_generation_result(json!({
"model": model,
"provider": "internal-provider",
}));
assert!(compact.get("model").is_none(), "model={model}");
assert!(compact.get("provider").is_none(), "model={model}");
}
}
#[test]
fn editor_agent_spritesheet_result_keeps_all_persisted_slices() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
@@ -338,7 +338,6 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
prompt: normalized.prompt.clone(),
actual_prompt: Some(normalized.prompt),
model: normalized.model,
provider: generated.provider,
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
audio_kind: "sound-effect".to_string(),
@@ -562,7 +561,6 @@ pub(crate) async fn generate_editor_background_music_for_owner(
prompt: normalized.gpt_description_prompt.clone(),
actual_prompt: Some(normalized.gpt_description_prompt),
model: platform_audio::SUNO_DEFAULT_MODEL.to_string(),
provider: generated.provider,
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
audio_kind: "background-music".to_string(),
@@ -424,7 +424,6 @@ pub struct EditorVideoGenerateResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_prompt: Option<String>,
pub model: String,
pub provider: String,
pub task_id: String,
pub duration_seconds: u32,
pub resolution: String,
@@ -503,7 +502,6 @@ pub struct EditorIconSpritesheetGenerateResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_prompt: Option<String>,
pub model: String,
pub provider: String,
pub task_id: String,
pub price_mud_points: u32,
}
@@ -560,7 +558,6 @@ pub struct EditorAudioGenerateResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_prompt: Option<String>,
pub model: String,
pub provider: String,
pub task_id: String,
pub price_mud_points: u32,
pub audio_kind: String,
@@ -1260,7 +1257,6 @@ mod tests {
prompt: "让角色向镜头挥手".to_string(),
actual_prompt: Some("让角色向镜头挥手".to_string()),
model: "kling3.0-omni".to_string(),
provider: "VectorEngine".to_string(),
task_id: "task-1".to_string(),
duration_seconds: 5,
resolution: "480p".to_string(),
@@ -1287,6 +1283,7 @@ mod tests {
assert_eq!(payload["assetObjectId"], json!("assetobj_video_1"));
assert_eq!(payload["sourceType"], json!("generated"));
assert_eq!(payload["durationSeconds"], json!(5));
assert!(payload.get("provider").is_none());
}
#[test]
@@ -1323,7 +1320,6 @@ mod tests {
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
model: "gemini-3.1-flash-image-preview".to_string(),
provider: "VectorEngine".to_string(),
task_id: "task-icons".to_string(),
price_mud_points: 12,
})
@@ -1331,6 +1327,7 @@ mod tests {
assert_eq!(response_payload["priceMudPoints"], json!(12));
assert_eq!(response_payload["iconImageSrcs"], json!([]));
assert!(response_payload.get("provider").is_none());
assert_eq!(
response_payload["sliceWarning"],
json!({
@@ -1410,7 +1407,6 @@ mod tests {
prompt: "金币掉落叮当声".to_string(),
actual_prompt: Some("金币掉落叮当声".to_string()),
model: "audio1.0".to_string(),
provider: "VectorEngine".to_string(),
task_id: "sound-task-1".to_string(),
price_mud_points: 10,
audio_kind: "sound-effect".to_string(),
@@ -1439,6 +1435,7 @@ mod tests {
);
assert_eq!(response_payload["assetObjectId"], json!("assetobj_audio_1"));
assert_eq!(response_payload["audioKind"], json!("sound-effect"));
assert!(response_payload.get("provider").is_none());
assert_eq!(
response_payload["asset"]["assetKind"],
json!("sound-effect")
@@ -171,7 +171,6 @@ function getAssetHaystack(asset: EditorAssetSnapshot) {
asset.prompt,
asset.actualPrompt,
asset.model,
asset.provider,
asset.sourceType,
asset.assetKind,
...(asset.generationInputs?.fields ?? []).flatMap((field) => [
@@ -246,7 +245,6 @@ function projectResourceToShowcaseAsset(
prompt: resource.prompt ?? null,
actualPrompt: resource.actualPrompt ?? null,
model: resource.model ?? null,
provider: resource.provider ?? null,
taskId: resource.taskId ?? null,
durationSeconds: resource.durationSeconds,
sourceResourceId: resource.sourceResourceId,
@@ -9,6 +9,8 @@ import {
filterGroupedAssetsBySearch,
getSelectableAssets,
groupAssetsByFolder,
layerMatchesSearch,
layerStartsWithSearch,
moveAssetMarqueeToPointer,
moveAssetToFolderLocally,
removeAssetById,
@@ -28,6 +30,7 @@ import {
toggleAssetSelection,
} from './ImageCanvasAssetLibraryModel';
import type {
CanvasLayer,
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
@@ -60,6 +63,24 @@ function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
};
}
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-a',
resourceId: 'resource-a',
title: '图层A',
src: 'data:image/png;base64,YQ==',
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
...overrides,
};
}
describe('ImageCanvasAssetLibraryModel', () => {
it('groups assets by folder and resolves selectable uploaded assets', () => {
const folders = [
@@ -184,6 +205,60 @@ describe('ImageCanvasAssetLibraryModel', () => {
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b', 'asset-c'] }]);
});
it('excludes internal processing models and providers from asset and layer search without deleting metadata', () => {
const internalAsset = createAsset({
id: 'asset-internal',
label: '透明角色',
sourceType: 'generated',
model: 'BgFilter complex',
provider: 'BgFilter',
});
const visibleAsset = createAsset({
id: 'asset-visible',
label: '生成角色',
sourceType: 'generated',
model: 'gpt-image-2',
provider: 'VectorEngine',
});
const groupedAssets = groupAssetsByFolder(
[createFolder()],
[internalAsset, visibleAsset],
);
const internalLayer = createLayer({
model: 'segment-common-image',
provider: 'Aliyun Matting',
});
const visibleLayer = createLayer({
id: 'layer-visible',
model: 'audio1.0',
provider: 'VectorEngine',
});
expect(filterGroupedAssetsBySearch(groupedAssets, 'bgfilter')).toEqual([]);
expect(filterGroupedAssetsBySearch(groupedAssets, 'vectorengine')).toEqual(
[],
);
expect(
filterGroupedAssetsBySearch(groupedAssets, 'gpt-image-2')[0]?.assets.map(
(asset) => asset.id,
),
).toEqual(['asset-visible']);
expect(layerMatchesSearch(internalLayer, 'segment-common-image')).toBe(
false,
);
expect(layerStartsWithSearch(internalLayer, 'aliyun')).toBe(false);
expect(layerMatchesSearch(visibleLayer, 'audio1.0')).toBe(true);
expect(layerMatchesSearch(visibleLayer, 'vectorengine')).toBe(false);
expect(internalAsset).toMatchObject({
model: 'BgFilter complex',
provider: 'BgFilter',
});
expect(internalLayer).toMatchObject({
model: 'segment-common-image',
provider: 'Aliyun Matting',
});
});
it('renames assets, toggles folders and creates local folders', () => {
expect(renameAssetById([createAsset()], 'asset-a', '新名字')[0]).toMatchObject(
{ label: '新名字' },
@@ -1,3 +1,4 @@
import { isEditorInternalProcessingModel } from './ImageCanvasEditorModel';
import type {
AssetMarqueeState,
CanvasLayer,
@@ -204,13 +205,19 @@ function startsWithSearchText(query: string, values: Array<string | null | undef
return values.some((value) => value?.trim().toLowerCase().startsWith(query));
}
function resolveSearchableEditorModel(model: string | null | undefined) {
const normalizedModel = model?.trim();
return normalizedModel && !isEditorInternalProcessingModel(normalizedModel)
? normalizedModel
: undefined;
}
function buildAssetSearchValues(asset: EditorAsset) {
return [
asset.label,
asset.prompt,
asset.actualPrompt,
asset.model,
asset.provider,
resolveSearchableEditorModel(asset.model),
asset.taskId,
asset.assetKind,
asset.mediaType,
@@ -222,8 +229,7 @@ function buildLayerSearchValues(layer: CanvasLayer) {
layer.title,
layer.prompt,
layer.actualPrompt,
layer.model,
layer.provider,
resolveSearchableEditorModel(layer.model),
layer.taskId,
layer.assetKind,
layer.mediaType,
@@ -282,6 +282,8 @@ describe('ImageCanvasEditorModel', () => {
zIndex: 9,
sourceType: 'generated',
objectKey: 'generated/object.png',
model: 'birefnet',
sourceResourceId: 'resource-provider-source',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '骑士' }],
@@ -307,6 +309,15 @@ describe('ImageCanvasEditorModel', () => {
fields: [{ title: '角色设定', value: '骑士' }],
references: [],
},
model: 'birefnet',
sourceResourceId: 'resource-provider-source',
},
],
[
'resource-provider-source',
{
imageSrc: '/read/provider-source.png',
model: 'gpt-image-2',
},
],
]),
@@ -318,11 +329,49 @@ describe('ImageCanvasEditorModel', () => {
sourceType: 'generated',
assetKind: 'character',
objectKey: 'generated/object.png',
model: 'gpt-image-2',
locked: true,
});
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
});
it('recovers the normal source model after the owner payload removes an internal model', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-derived',
resourceId: 'resource-derived',
title: '去背景结果',
x: 10,
y: 20,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
zIndex: 9,
sourceType: 'generated',
sourceResourceId: 'resource-source',
},
new Map([
[
'resource-derived',
{
imageSrc: '/read/derived.png',
sourceResourceId: 'resource-source',
},
],
[
'resource-source',
{
imageSrc: '/read/source.png',
model: 'gpt-image-2',
},
],
]),
);
expect(hydrated?.model).toBe('gpt-image-2');
});
it('hydrates object metadata from project resources when the saved layer is lean', () => {
const hydrated = hydrateLayer(
{
@@ -43,6 +43,24 @@ export const MINIMAP_SIZE = { width: 132, height: 84 };
export const MINIMAP_PADDING = 8;
export const MINIMAP_DRAG_SENSITIVITY = 0.3;
export const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset';
const INTERNAL_EDITOR_PROCESSING_MODELS = new Set([
'anime-seg',
'bgfilter complex',
'birefnet',
'connected-components',
'screen-color-keying',
'segment-common-image',
]);
export function isEditorInternalProcessingModel(
model: string | null | undefined,
) {
const normalizedModel = model?.trim().toLowerCase();
return Boolean(
normalizedModel && INTERNAL_EDITOR_PROCESSING_MODELS.has(normalizedModel),
);
}
export const MAX_HISTORY_STEPS = 60;
export const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
export const CONTEXT_MENU_SIZE = {
@@ -734,7 +752,7 @@ export function hydrateLayer(
prompt: stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt),
actualPrompt:
stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt),
model: stringOrNull(snapshot.model) ?? stringOrNull(resource?.model),
model: resolveHydratedLayerModel(snapshot, resource, resourcesById),
provider: stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider),
taskId: stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId),
objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
@@ -761,6 +779,33 @@ export function hydrateLayer(
};
}
function resolveHydratedLayerModel(
snapshot: EditorProjectLayerSnapshot,
resource: CanvasLayerResourceMetadata | undefined,
resourcesById: Map<string, CanvasLayerResourceMetadata>,
) {
const persistedModel =
stringOrNull(snapshot.model) ?? stringOrNull(resource?.model);
if (persistedModel && !isEditorInternalProcessingModel(persistedModel)) {
return persistedModel;
}
let sourceResourceId =
stringOrNull(snapshot.sourceResourceId) ??
stringOrNull(resource?.sourceResourceId);
const visitedResourceIds = new Set<string>();
while (sourceResourceId && !visitedResourceIds.has(sourceResourceId)) {
visitedResourceIds.add(sourceResourceId);
const sourceResource = resourcesById.get(sourceResourceId);
const sourceModel = stringOrNull(sourceResource?.model);
if (sourceModel && !isEditorInternalProcessingModel(sourceModel)) {
return sourceModel;
}
sourceResourceId = stringOrNull(sourceResource?.sourceResourceId);
}
return persistedModel;
}
export function mapAssetLibrarySnapshot(
library: EditorAssetLibrarySnapshot,
): {
@@ -175,6 +175,7 @@ describe('ImageCanvasExportModel', () => {
it('filters built-in prompts from exported visible generation inputs', () => {
const metadata = buildLayerExportMetadata(
buildLayer({
model: 'gpt-image-2',
generationInputs: {
fields: [
{ title: '生成提示词', value: 'AI 生成图片' },
@@ -195,6 +196,7 @@ describe('ImageCanvasExportModel', () => {
{ title: '用户输入', value: '用户填写的视觉要求' },
{ title: '游戏分类', value: '游戏音效' },
{ title: 'duration', value: '5秒' },
{ title: '处理模型', value: 'birefnet' },
],
references: [],
},
@@ -210,6 +212,7 @@ describe('ImageCanvasExportModel', () => {
],
references: [],
});
expect(metadata.visible.model).toBe('gpt-image-2');
});
it('builds a Spine JSON attachment timeline from image sequence frames', () => {
@@ -8,7 +8,8 @@ import type {
import {
DEFAULT_ICON_DESCRIPTIONS,
formatLayerImageType,
getEditorImageModelDisplayName,
getEditorLayerModelDisplayName,
isEditorUserVisibleGenerationInputField,
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
} from './ImageCanvasGenerationModel';
import { formatCanvasDurationMetric } from './ImageCanvasMediaModel';
@@ -543,6 +544,7 @@ function isBuiltInGenerationInputField(field: CanvasGenerationInputField) {
const builtInValues =
BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE.get(normalizedTitle);
return (
!isEditorUserVisibleGenerationInputField(field) ||
BUILT_IN_GENERATION_INPUT_TITLES.has(normalizedTitle) ||
normalizedValue === UI_DESIGN_ASSET_EXTRACTION_PROMPT ||
Boolean(builtInValues?.has(normalizedValue))
@@ -572,7 +574,7 @@ export function buildLayerVisibleExportMetadata(layer: CanvasLayer) {
const base = {
type: formatLayerImageType(layer),
generationInputs: buildVisibleGenerationInputs(layer),
model: layer.model ? getEditorImageModelDisplayName(layer.model) : '-',
model: getEditorLayerModelDisplayName(layer.model),
task: formatTaskIdForDisplay(layer.taskId),
object: layer.objectKey ?? layer.assetObjectId ?? '-',
};
@@ -341,7 +341,6 @@ describe('ImageCanvasGenerationLayerModel', () => {
prompt: '图标 prompt',
actualPrompt: '图标 actual prompt',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
priceMudPoints: 20,
},
@@ -432,7 +431,6 @@ describe('ImageCanvasGenerationLayerModel', () => {
prompt: '图标 prompt',
actualPrompt: '图标 actual prompt',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
priceMudPoints: 20,
},
@@ -464,7 +462,6 @@ describe('ImageCanvasGenerationLayerModel', () => {
prompt: '金币掉落叮当声',
actualPrompt: '金币掉落叮当声',
model: 'audio1.0',
provider: 'VectorEngine',
taskId: 'sound-task-1',
priceMudPoints: 10,
audioKind: 'sound-effect',
@@ -525,7 +522,6 @@ describe('ImageCanvasGenerationLayerModel', () => {
sourceType: 'generated',
prompt: '金币掉落叮当声',
model: 'audio1.0',
provider: 'VectorEngine',
taskId: 'sound-task-1',
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
assetObjectId: 'assetobj-audio-1',
@@ -535,6 +531,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
assetId: 'asset-audio-bff',
}),
});
expect(layer.provider).toBeUndefined();
});
it('creates video result layers with OSS asset identity', () => {
@@ -549,7 +546,6 @@ describe('ImageCanvasGenerationLayerModel', () => {
prompt: '开场动画',
actualPrompt: '开场动画',
model: 'seedance2.0-fast',
provider: 'VectorEngine',
taskId: 'video-task-1',
priceMudPoints: 40,
durationSeconds: 5,
@@ -586,6 +582,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
durationSeconds: 5,
});
expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-video-bff');
expect(layer.provider).toBeUndefined();
});
it('creates character animation result layers as playable image sequences', () => {
@@ -653,11 +650,11 @@ describe('ImageCanvasGenerationLayerModel', () => {
prompt: '待机动作',
actualPrompt: '待机动作',
model: 'seedance2.0-fast',
provider: 'ark',
taskId: 'character-animation-task-1',
assetKind: 'character-animation',
durationSeconds: 4,
});
expect(layer?.generationInputs?.fields[0]?.value).toBe('待机动作');
expect(layer?.provider).toBeUndefined();
});
});
@@ -114,7 +114,6 @@ function applyGeneratedMetadata(
prompt: generated.prompt,
actualPrompt: generated.actualPrompt ?? generated.prompt,
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
objectKey: resource?.objectKey ?? generated.objectKey,
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
@@ -333,7 +332,6 @@ export function createIconSpritesheetResultLayers({
prompt: generated.prompt,
actualPrompt: generated.actualPrompt ?? generated.prompt,
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
assetKind: 'icon-spritesheet',
sourceAssetId: spritesheetAsset?.assetId,
@@ -382,7 +380,6 @@ export function createIconSpritesheetResultLayers({
prompt: generated.prompt,
actualPrompt: generated.actualPrompt ?? generated.prompt,
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
assetKind: 'icon',
sourceAssetId: asset?.assetId,
@@ -451,7 +448,6 @@ export function createVideoResultLayer({
prompt: generated.prompt,
actualPrompt: generated.actualPrompt ?? generated.prompt,
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
objectKey: resource?.objectKey ?? generated.objectKey,
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
@@ -513,7 +509,6 @@ export function createAudioResultLayer({
prompt: generated.prompt,
actualPrompt: generated.actualPrompt ?? generated.prompt,
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
objectKey: resource?.objectKey ?? generated.objectKey,
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
@@ -575,7 +570,6 @@ export function createCharacterAnimationResultLayer({
prompt: generated.prompt,
actualPrompt: generated.prompt,
model: generated.model,
provider: 'ark',
taskId: generated.taskId,
assetKind: 'character-animation',
durationSeconds: generated.durationSeconds,
@@ -4,7 +4,10 @@ import type {
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
} from '../../services/image-editor/editorProjectClient';
import { isGeneratedLayer } from './ImageCanvasEditorModel';
import {
isEditorInternalProcessingModel,
isGeneratedLayer,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputField,
@@ -548,6 +551,21 @@ export function getEditorImageModelDisplayName(
);
}
export function isEditorUserVisibleGenerationInputField(
field: CanvasGenerationInputField,
) {
return field.title.trim() !== '处理模型';
}
export function getEditorLayerModelDisplayName(
model: string | null | undefined,
) {
if (!model?.trim() || isEditorInternalProcessingModel(model)) {
return '-';
}
return getEditorImageModelDisplayName(model);
}
export function getEditorGenerationModelDisplayName(
model: string | null | undefined,
) {

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