Merge branch 'master' into feat/artagent-artifact-jump-to-focus
This commit is contained in:
@@ -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 图片结果单击直接定位
|
||||
|
||||
@@ -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 reason;tool 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`,只生成并写入素材库;调用方可随后创建项目或自行保存画板布局。
|
||||
|
||||
|
||||
@@ -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=birefnet,provider=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
Reference in New Issue
Block a user