diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b577cc17b..6c295f94c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index d7774522e..67270e580 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,7 +21,7 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 +- 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的用户路径都固定使用 `screenColor=auto`,但用户可见 `generationInputs.fields` 不记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求由前端自动提交默认 `segModel=birefnet`,api-server 负责 allowlist 校验并在缺失时回落默认值;角色动作逐帧去背的 `seg_model` 由后端固定。四条 flat 路径再由 api-server 向 worker 显式传递 `background_mode=flat` 与 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值。前端不展示抠图模型选择,`segModel` 不进入 `generationInputs`、响应、搜索、详情或导出;`background_mode`、`cross_check` 只存在于 api-server 到 worker 的内部 RPC。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 - 请求与可见性边界:同源画布 BFF 的角色、图标 spritesheet 和 UI 设计图素材提取 request DTO 保留 `segModel`,前端固定自动填充 `birefnet`,该字段不是用户选择或用户可见生成输入。External OpenAPI 是否接受 `segModel` 是独立契约,不能由站内 BFF 自动外推。`background_mode`、`cross_check` 以及角色动作的 `seg_model` 只由 api-server 决定并通过 loopback RPC 传给 worker。 - 普通用户生成结果不携带生成 provider:图片、图标图集、视频、音频和角色动画完成响应的公开类型均无 `provider`,前端也不得把 provider 写入新建结果图层;项目资源、素材和历史画布快照中的 provider 由后端 User/Public mapper 统一省略。真实 provider 仅留在服务端持久化、tracking / tracing 与后台原始审计。历史派生画布资源的内部处理模型被 User mapper 清空后,图层 hydration 必须继续沿 `sourceResourceId` 回溯第一个正常生成模型,供图片信息和画布 ZIP 展示;来源链缺失或没有正常模型时显示 `-`。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 05ebebede..9457a2004 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -3950,22 +3950,15 @@ pub(crate) async fn remove_editor_image_background_for_owner( sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); let started_at = Instant::now(); caller.report_processing_phase(state).await?; - let source_object_key = resolve_editor_reference_object_key_for_owner( - state, - caller.owner_user_id.as_str(), - payload.source_image_src.as_str(), - ) - .await?; - let source_model = resolve_editor_background_removal_source_model( + let source = resolve_editor_background_removal_source( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.source_resource_id.as_deref(), payload.source_image_src.as_str(), - source_object_key.as_str(), ) .await?; - validate_editor_background_removal_source(state, source_object_key.as_str()).await?; + validate_editor_background_removal_source(state, source.object_key.as_str()).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller @@ -3977,7 +3970,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( }; let removed = request_editor_background_removal_image_with_bgfilter_worker( state, - source_object_key.as_str(), + source.object_key.as_str(), &matting_audit, ) .await?; @@ -4019,7 +4012,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( height: removed.height, prompt: "remove background".to_string(), actual_prompt: None, - model: source_model.unwrap_or_default(), + model: source.model.unwrap_or_default(), provider: "BgFilter".to_string(), task_id: task_id.clone(), group_task_id: None, @@ -8655,14 +8648,28 @@ pub(crate) async fn resolve_editor_reference_object_key_for_owner( Ok(object_key) } -async fn resolve_editor_background_removal_source_model( +struct ResolvedEditorBackgroundRemovalSource { + object_key: String, + model: Option, +} + +async fn resolve_editor_background_removal_source( state: &AppState, owner_user_id: &str, project_id: Option<&str>, source_resource_id: Option<&str>, source_reference: &str, - source_object_key: &str, -) -> Result, AppError> { +) -> Result { + ensure_editor_reference_image_source_is_stable( + source_reference, + "editor-reference-image", + "sourceImageSrc", + "图片参考图", + )?; + let owner_user_id = owner_user_id.trim(); + if owner_user_id.is_empty() { + return Err(editor_reference_image_forbidden(source_reference.trim())); + } let projects = state .spacetime_client() .list_editor_projects(owner_user_id.to_string()) @@ -8673,14 +8680,39 @@ async fn resolve_editor_background_removal_source_model( .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) .await .map_err(map_editor_project_error)?; - resolve_editor_background_removal_source_model_from_records( + let source_object_key = match normalize_editor_reference_object_key(source_reference) { + Ok(object_key) => object_key, + Err(error) => find_editor_reference_object_key_by_registered_id_from_records( + projects.as_slice(), + library.assets.as_slice(), + source_reference.trim(), + ) + .ok_or(error)?, + }; + if !editor_reference_object_key_is_registered_for_owner( + projects.as_slice(), + library.assets.as_slice(), + source_object_key.as_str(), + ) { + ensure_editor_reference_asset_object_owned( + state, + owner_user_id, + source_object_key.as_str(), + ) + .await?; + } + let model = resolve_editor_background_removal_source_model_from_records( projects.as_slice(), library.assets.as_slice(), project_id, source_resource_id, source_reference, - source_object_key, - ) + source_object_key.as_str(), + )?; + Ok(ResolvedEditorBackgroundRemovalSource { + object_key: source_object_key, + model, + }) } fn resolve_editor_background_removal_source_model_from_records( @@ -8891,10 +8923,8 @@ async fn find_editor_reference_object_key_by_registered_id( .list_editor_projects(owner_user_id.to_string()) .await .map_err(map_editor_project_error)?; - if let Some(object_key) = projects - .iter() - .flat_map(|project| project.resources.iter()) - .find_map(|resource| editor_reference_resource_object_key(resource, source)) + if let Some(object_key) = + find_editor_reference_resource_object_key_by_registered_id(projects.as_slice(), source) { return Ok(Some(object_key)); } @@ -8903,10 +8933,38 @@ async fn find_editor_reference_object_key_by_registered_id( .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) .await .map_err(map_editor_project_error)?; - Ok(library - .assets + Ok(find_editor_reference_asset_object_key_by_registered_id( + library.assets.as_slice(), + source, + )) +} + +fn find_editor_reference_object_key_by_registered_id_from_records( + projects: &[EditorProjectRecord], + assets: &[EditorAssetRecord], + source: &str, +) -> Option { + find_editor_reference_resource_object_key_by_registered_id(projects, source) + .or_else(|| find_editor_reference_asset_object_key_by_registered_id(assets, source)) +} + +fn find_editor_reference_resource_object_key_by_registered_id( + projects: &[EditorProjectRecord], + source: &str, +) -> Option { + projects .iter() - .find_map(|asset| editor_reference_asset_object_key(asset, source))) + .flat_map(|project| project.resources.iter()) + .find_map(|resource| editor_reference_resource_object_key(resource, source)) +} + +fn find_editor_reference_asset_object_key_by_registered_id( + assets: &[EditorAssetRecord], + source: &str, +) -> Option { + assets + .iter() + .find_map(|asset| editor_reference_asset_object_key(asset, source)) } fn editor_reference_resource_object_key( @@ -8957,15 +9015,7 @@ async fn ensure_editor_reference_object_key_owned( .list_editor_projects(owner_user_id.to_string()) .await .map_err(map_editor_project_error)?; - if projects.iter().any(|project| { - project.resources.iter().any(|resource| { - editor_record_object_key_matches( - resource.object_key.as_deref(), - resource.image_src.as_str(), - object_key, - ) - }) - }) { + if editor_reference_object_key_is_registered_in_projects(projects.as_slice(), object_key) { return Ok(()); } let library = state @@ -8973,15 +9023,54 @@ async fn ensure_editor_reference_object_key_owned( .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) .await .map_err(map_editor_project_error)?; - if library.assets.iter().any(|asset| { + if editor_reference_object_key_is_registered_in_assets(library.assets.as_slice(), object_key) { + return Ok(()); + } + ensure_editor_reference_asset_object_owned(state, owner_user_id, object_key).await +} + +fn editor_reference_object_key_is_registered_for_owner( + projects: &[EditorProjectRecord], + assets: &[EditorAssetRecord], + object_key: &str, +) -> bool { + editor_reference_object_key_is_registered_in_projects(projects, object_key) + || editor_reference_object_key_is_registered_in_assets(assets, object_key) +} + +fn editor_reference_object_key_is_registered_in_projects( + projects: &[EditorProjectRecord], + object_key: &str, +) -> bool { + projects.iter().any(|project| { + project.resources.iter().any(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + object_key, + ) + }) + }) +} + +fn editor_reference_object_key_is_registered_in_assets( + assets: &[EditorAssetRecord], + object_key: &str, +) -> bool { + assets.iter().any(|asset| { editor_record_object_key_matches( asset.object_key.as_deref(), asset.image_src.as_str(), object_key, ) - }) { - return Ok(()); - } + }) +} + +async fn ensure_editor_reference_asset_object_owned( + state: &AppState, + owner_user_id: &str, + object_key: &str, +) -> Result<(), AppError> { let Some(oss_client) = state.oss_client() else { return Err(editor_reference_image_forbidden(object_key)); }; @@ -13680,6 +13769,31 @@ mod tests { ); } + #[test] + fn editor_manual_background_removal_reuses_single_account_snapshot_pair() { + let source = include_str!("editor_project.rs"); + assert_function_not_contains( + source, + "pub(crate) async fn remove_editor_image_background_for_owner", + "struct EditorBackgroundRemovalImage", + &["resolve_editor_reference_object_key_for_owner"], + ); + assert_function_occurrence_count( + source, + "async fn resolve_editor_background_removal_source(", + "fn resolve_editor_background_removal_source_model_from_records(", + ".list_editor_projects(", + 1, + ); + assert_function_occurrence_count( + source, + "async fn resolve_editor_background_removal_source(", + "fn resolve_editor_background_removal_source_model_from_records(", + ".get_editor_asset_library(", + 1, + ); + } + #[test] fn editor_canvas_screen_background_generation_uses_bgfilter_postprocess() { let source = include_str!("editor_project.rs"); @@ -13760,7 +13874,7 @@ mod tests { "struct EditorBackgroundRemovalImage", &[ "caller.report_processing_phase(state).await?", - "resolve_editor_reference_object_key_for_owner", + "resolve_editor_background_removal_source", "validate_editor_background_removal_source", "request_editor_background_removal_image_with_bgfilter_worker", ],