修复编辑器生成幂等与参考图契约
Project CI / Frontend tests (push) Failing after 22s
Project CI / Repository checks (push) Successful in 58s
Project CI / Backend tests (push) Successful in 3m30s
Project CI / Native shell tests (push) Successful in 12m34s

生成队列按稳定请求标识去重并保持外部幂等哈希兼容
统一拒绝参考图超限并同步前端、后端、Provider 与 OpenAPI 契约
按真实归属重建生成引用并阻止直接持久化伪造来源
锁定参考图在途上传上下文并保留批量部分成功结果
关闭内部生成 POST 自动重试并补齐回归测试与项目文档
修正最新主线开发者密钥弹窗的导入排序门禁
This commit is contained in:
2026-08-05 21:33:02 +08:00
parent e5ebdf0a8e
commit 6f181b36a1
49 changed files with 3731 additions and 480 deletions
@@ -3047,7 +3047,7 @@
"type": "array",
"items": {
"type": "string",
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多使用前 5 张,数组上限为 9。"
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 gpt-image-2 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。"
},
"maxItems": 9
},
@@ -3241,7 +3241,7 @@
"type": "array",
"items": {
"type": "string",
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。"
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
},
"maxItems": 8
},
@@ -3372,7 +3372,7 @@
"type": "array",
"items": {
"type": "string",
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。最多 8 张。"
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
},
"maxItems": 8
},
@@ -3496,7 +3496,7 @@
"type": "array",
"items": {
"type": "string",
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。最多 5 张。"
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。"
},
"maxItems": 5
},
@@ -6574,3 +6574,10 @@
- 遗留(建议单开,不在本次范围):`loadProjectCoverImage` 里无 timeout / 无 AbortSignal 的 `new Image()` 本身仍是隐患,自动保存路径一样会踩。本次只是把它移出生成链的关键路径,没有消除它。
- 影响范围:`useImageCanvasProjectPersistence.ts``flushProjectPersistence`。不改服务端、不改契约。
- 验证方式:既有用例「flush 等待封面缓存」翻转为「flush 不等封面、但封面链照常跑完并完成上传与资源登记」;新增「封面永不 settle 时 flush 仍返回」——用永不 resolve 的 blob 模拟 `new Image()` 不 settle,并断言 `createProjectCoverSnapshotBlob` 确实被调用过以防用例空过。已实证:回退修复后新用例报 `expected 'false' to be 'true'`。运行 `npx vitest run src/components/image-editor src/components/platform-entry src/services`101 文件 / 1241 项)、`npm run typecheck``npm run lint:eslint``npm run check:encoding`
## 2026-08-05 编辑器生成请求与参考图权威契约
- 主站编辑器生成 POST 不做浏览器自动重试,避免 inline 模式在响应丢失后重复调用 providerapi-server 仍使用独立 namespace + owner + job kind + request id 生成队列 `dedupe_key`,让显式复用同一请求标识的队列重放原子返回已存在任务,并对同键不同 payload 返回 `409`。外部 v1 的 `Idempotency-Key` 保持独立 namespace。
- 参考图数量以产品上限与 provider 容量的较小值为准,前端添加 / 上传 / 提交、api-server 入队与执行、`platform-image` provider 边界均明确拒绝超限;任何层都不再用 `.take(...)` 把第 N+1 张静默丢弃。角色 / UI 从一开始预留主图槽位;并发上传计入在途数量并在持久化前复验。任一参考图上传批次在途时锁定模型切换、画布选图、提交生成、关联源图删除 / 剪切 / 素材删除及生成面板切换 / 关闭,并以原面板上下文标识在持久化前后复验,完成或失败并释放 reservation 后才允许继续操作;批量部分失败时仍挂接成功项并刷新素材库。模型降容或后补主图会超限时拒绝操作并保留现有引用。
- 图片类最终 `generationInputs.references` 不信任客户端输入;队列 payload、完美像素及直接创建资源 / 素材入口删除客户端 referencesworker / inline 路径按本次真实参考源与 owner 范围内的项目资源、账号素材重建 `refType/refId`。只有 owned objectKey 但没有正式行时不生成伪 provenance。完美像素继续使用升级前 canonical 客户端输入计算 operation fingerprint;新操作只持久化权威重建值,历史同 task/resource 重放复用服务端既存 metadata 通过精确比较。
- 升级前 External 幂等任务可能仍在 payload 中保留客户端 references;重放比较只对白名单内已迁移的图片生成、图片修改、去背景、图标图集和 UI 提取任务,在旧侧有 references、当前侧已删除时移除旧字段,其他字段变化仍返回 `409`。音频 / 视频 / 角色动作等未迁移 job kind 始终完整比较,不能扩大兼容面。
- 本次复用既有 `external_generation_job.dedupe_key` 唯一索引和 `spacetime-client` 查询,不改 SpacetimeDB schema、迁移或 bindings。
@@ -4195,3 +4195,10 @@
- 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 accept 后取得的 stream 设置 read timeout 无法约束 accept 本身,payload 读取也缺少总 deadline。
- 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 `Drop` 中无条件清空;测试统一使用 `manifest_invalidation_sink_isolation_` 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。
- 验证:用 `--test-threads=2` 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。
## 编辑器生成不能把传输重试、参考图截断和客户端 provenance 当成独立小问题(2026-08-05
- 现象:生成 POST 首次已经入队但响应丢失时,客户端自动重试产生第二个任务;第 6 张或更多参考图仍显示在 UI / 元数据里,却没有送给 provider;直接构造请求还能把任意资源 ID 写成最终素材引用。
- 原因:客户端虽在重试中复用 `x-request-id`,队列入口却用随机 job id 生成 dedupe key;前端允许无限追加,api-server 和 provider 用 `.take(...)` 静默截断;`generationInputs.references` 被当成可信持久 provenance。
- 处理:主站生成 POST 禁止自动重试,把显式复用的稳定 request id 接到队列唯一键并校验 replay payload;所有边界显式拒绝超限,前端还要预留主图槽位、统计在途上传,并在上传完成前拒绝模型切换、画布选图、提交生成、关联源图删除 / 剪切 / 素材删除和面板切换 / 关闭;reservation 必须绑定原面板上下文,批量部分失败时不能丢弃已经持久化的成功项。入队、完美像素及直接创建资源 / 素材时删除客户端 references,执行时按真实参考源和 owner 资源记录重建权威引用。历史任务比较必须兼容仅差已删除 references 的旧 payload,不能只保留旧 hash 却让 payload 比较误报冲突。
- 验证:覆盖同键同 payload / 不同 payload、普通图片第 6 张、带主图的 GPT-image-2 第 5 张额外引用、provider 6 / 15 张边界、伪造引用删除和 owned 资源 / 素材重建。
@@ -34,6 +34,10 @@
- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。
- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片``生成规范``生成角色形象``生成图标素材``生成视频``生成UI设计图``生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。
- `generationInputs.references``refType/refId` 是服务端权威行引用:客户端提交的 references 只属于非权威展示候选,api-server 入队及直接创建资源 / 素材时删除,生成执行时按真实参考图和当前 owner 的资源 / 素材记录重建后再持久化;裸 owned objectKey 找不到正式资源或素材行时可以参与生成,但不得制造伪引用。`title/label` 只作为展示快照,不提升为资源身份。
- 普通图片生成最多选择 5 张参考图;带主图 / 规范图的图片修改、图标素材和 UI 素材提取需要从打开面板起预留这 1 张主引用,再与 provider 容量取最小值(GPT-image-2 总计 5 张,nanobanana2 总计 14 张)。画布选择、上传和最终提交都必须阻止第 N+1 张进入请求;并发上传要把在途批次计入容量,并在创建项目资源 / 账号素材前按最新模型复验。reservation 必须绑定发起上传的 dialog / 快速编辑 / UI 提取上下文;任一参考图上传批次在途时,所有图片模型切换、从画布添加主图 / 规范图 / 参考图、提交生成、删除 / 剪切关联源图、删除其来源素材以及生成面板切换 / 关闭都要明确拒绝,批次持久化前后还要复验上下文未变化。批次部分失败时要保留并挂接已经成功持久化的引用,不能因其中一项失败而丢弃整批成功项;完成或失败并释放 reservation 后才允许继续操作。模型降容或后补主图若在操作当下已经超限,应保留原模型 / 原参考图并明确提示用户先删除,不得用 `slice` 静默丢弃;旧 dialog 或直接 API 请求由后端返回明确超限错误。
- 主站编辑器生成 POST 在浏览器端不自动重试;队列模式仍按同一 `x-request-id` 幂等重放,External v1 使用显式 `Idempotency-Key`。inline 模式没有结果级幂等时,不得因 408 / 429 / 5xx 或传输异常自动再次调用 provider。
### 静态图片风格与像素规整边界
- 普通 `生成图片``生成角色形象``生成图标素材` 三个面板增加紧凑的 `像素艺术` 勾选项;移动端可独占一行,但不增加功能说明文案。当前生成对象以 `style: "none" | "pixelArt"` 保存选择并随现有请求 / 队列 payload 传递;该字段不写入用户可见 `generationInputs`,也不新增素材元数据字段。其它生成、编辑、UI 素材提取、角色动画及画布 Agent 入口不展示或设置该选项。
@@ -243,8 +243,10 @@ npm run check:server-rs-ddd
6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。
8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource``editor_asset``asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource``editor_asset``asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。
11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。
12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 referencesworker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。
## 外部服务与资产
@@ -28,6 +28,7 @@ pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas";
const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker";
const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024;
const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation";
const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -37,7 +38,7 @@ pub(crate) struct EditorGenerationQueuedResponse {
pub(crate) async fn enqueue_editor_generation_job<T>(
state: &AppState,
_request_context: &RequestContext,
request_context: &RequestContext,
owner_user_id: &str,
job_kind: &str,
source_entity_id: impl Into<String>,
@@ -49,6 +50,12 @@ where
T: Serialize,
{
let job_id = build_prefixed_uuid_id("task-");
let dedupe_key = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
owner_user_id,
job_kind,
request_context.request_id().trim(),
);
enqueue_editor_generation_job_with_identity(
state,
owner_user_id,
@@ -58,11 +65,26 @@ where
price_mud_points,
payload,
job_id.clone(),
format!("editor-canvas:{job_kind}:{job_id}"),
dedupe_key,
)
.await
}
fn build_editor_generation_dedupe_key(
namespace: &str,
owner_user_id: &str,
job_kind: &str,
stable_key: &str,
) -> String {
let mut hasher = Sha256::new();
hasher.update(owner_user_id.trim().as_bytes());
hasher.update(b"\0");
hasher.update(job_kind.trim().as_bytes());
hasher.update(b"\0");
hasher.update(stable_key.as_bytes());
format!("{namespace}:{job_kind}:{:x}", hasher.finalize())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn enqueue_editor_generation_job_with_identity<T>(
state: &AppState,
@@ -79,18 +101,25 @@ where
T: Serialize,
{
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
enqueue_serialized_editor_generation_job_with_identity(
let job = enqueue_serialized_editor_generation_job_with_identity(
state,
owner_user_id,
job_kind,
source_entity_id,
request_label,
price_mud_points,
request_payload_json,
request_payload_json.clone(),
job_id,
dedupe_key,
)
.await
.await?;
ensure_editor_generation_job_matches_request(
job,
owner_user_id,
job_kind,
request_payload_json.as_str(),
"请求幂等键已用于不同的生成请求,请复用原请求参数或更换请求标识。",
)
}
#[allow(clippy::too_many_arguments)]
@@ -108,15 +137,11 @@ where
T: Serialize,
{
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
let mut hasher = Sha256::new();
hasher.update(owner_user_id.trim().as_bytes());
hasher.update(b"\0");
hasher.update(job_kind.trim().as_bytes());
hasher.update(b"\0");
hasher.update(idempotency_key.as_bytes());
let dedupe_key = format!(
"{EXTERNAL_API_GENERATION_DEDUPE_PREFIX}:{job_kind}:{:x}",
hasher.finalize()
let dedupe_key = build_editor_generation_dedupe_key(
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
owner_user_id,
job_kind,
idempotency_key,
);
let requested_job_id = build_prefixed_uuid_id("task-");
let job = enqueue_serialized_editor_generation_job_with_identity(
@@ -132,20 +157,97 @@ where
)
.await?;
ensure_editor_generation_job_matches_request(
job,
owner_user_id,
job_kind,
request_payload_json.as_str(),
"Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
)
}
fn ensure_editor_generation_job_matches_request(
job: ExternalGenerationJobRecord,
owner_user_id: &str,
job_kind: &str,
request_payload_json: &str,
conflict_message: &str,
) -> Result<ExternalGenerationJobRecord, AppError> {
if job.job_kind != job_kind
|| job.owner_user_id != owner_user_id
|| job.request_payload_json != request_payload_json
|| !editor_generation_request_payloads_match(
job_kind,
job.request_payload_json.as_str(),
request_payload_json,
)
{
return Err(
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
"message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
"message": conflict_message,
})),
);
}
Ok(job)
}
fn generation_input_references(value: &Value) -> Option<&Value> {
value
.as_object()
.and_then(|payload| payload.get("generationInputs"))
.and_then(Value::as_object)
.and_then(|generation_inputs| generation_inputs.get("references"))
}
fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -> bool {
let Some(generation_inputs) = value
.as_object_mut()
.and_then(|payload| payload.get_mut("generationInputs"))
.and_then(Value::as_object_mut)
else {
return false;
};
generation_inputs.remove("references").is_some()
}
fn job_kind_migrated_away_from_client_generation_references(job_kind: &str) -> bool {
matches!(
job_kind,
EDITOR_IMAGE_GENERATION_JOB_KIND
| EDITOR_IMAGE_EDIT_JOB_KIND
| EDITOR_BACKGROUND_REMOVAL_JOB_KIND
| EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND
| EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND
)
}
fn editor_generation_request_payloads_match(
job_kind: &str,
existing: &str,
requested: &str,
) -> bool {
if existing == requested {
return true;
}
let (Ok(mut existing), Ok(requested)) = (
serde_json::from_str::<Value>(existing),
serde_json::from_str::<Value>(requested),
) else {
return false;
};
// 只兼容部署前旧 payload 有 references、当前 sanitizer 已删除该字段的单向迁移。
// 音频、视频、角色动作等仍会保留 references;如果当前请求也带该字段,就必须完整
// 比较,不能把两个不同请求错误复用成同一任务。
if !job_kind_migrated_away_from_client_generation_references(job_kind)
|| generation_input_references(&existing).is_none()
|| generation_input_references(&requested).is_some()
|| !strip_untrusted_generation_input_references_from_payload(&mut existing)
{
return false;
}
existing == requested
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn enqueue_editor_generation_job_for_caller<T>(
state: &AppState,
@@ -363,6 +465,190 @@ mod tests {
}
}
#[test]
fn editor_api_request_dedupe_key_is_stable_and_namespaced() {
let first = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
let replay = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
" user-1 ",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
let other_owner = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
"user-2",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
let external = build_editor_generation_dedupe_key(
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
assert_eq!(first, replay);
assert_ne!(first, other_owner);
assert_ne!(first, external);
assert!(first.starts_with("editor-api-request-generation:editor_image_generation:"));
}
#[test]
fn external_api_dedupe_key_preserves_legacy_hash_bytes() {
let dedupe_key = build_editor_generation_dedupe_key(
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
" user-1 ",
EDITOR_IMAGE_GENERATION_JOB_KIND,
" request-1 ",
);
assert_eq!(
dedupe_key,
"external-api-generation:editor_image_generation:81e38a8eace5f098041b3c240f99053c555ccfa18139295576c9b4ba3d9acff6"
);
}
#[test]
fn replayed_editor_generation_job_must_match_original_request() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
job.request_payload_json = r#"{"prompt":"same"}"#.to_string();
assert!(
ensure_editor_generation_job_matches_request(
job.clone(),
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
r#"{"prompt":"same"}"#,
"幂等冲突",
)
.is_ok()
);
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
r#"{"prompt":"changed"}"#,
"幂等冲突",
)
.expect_err("same request id must reject a different payload");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_legacy_external_job_ignores_only_removed_client_references() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"fields": [{"title": "提示词", "value": "same"}],
"references": [{
"title": "旧客户端引用",
"refType": "asset",
"refId": "asset-forged"
}]
}
}))
.expect("legacy payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"fields": [{"title": "提示词", "value": "same"}]
}
}))
.expect("current payload should serialize");
assert!(
ensure_editor_generation_job_matches_request(
job.clone(),
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
requested.as_str(),
"幂等冲突",
)
.is_ok()
);
let changed = requested.replace("same", "changed");
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
changed.as_str(),
"幂等冲突",
)
.expect_err("non-reference payload changes must still conflict");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_jobs_with_references_on_both_sides_compare_them_strictly() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = "editor_video_generation".to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"references": [{"refType": "asset", "refId": "asset-1"}]
}
}))
.expect("existing video payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"references": [{"refType": "asset", "refId": "asset-2"}]
}
}))
.expect("requested video payload should serialize");
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
"editor_video_generation",
requested.as_str(),
"幂等冲突",
)
.expect_err("different retained references must conflict");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_video_job_does_not_use_image_reference_migration_compatibility() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_VIDEO_GENERATION_JOB_KIND.to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"references": [{"refType": "asset", "refId": "asset-1"}]
}
}))
.expect("existing video payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {}
}))
.expect("requested video payload should serialize");
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_VIDEO_GENERATION_JOB_KIND,
requested.as_str(),
"幂等冲突",
)
.expect_err("video payloads did not migrate away from references");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn serialize_payload_accepts_persistable_media_references() {
let payload = json!({
File diff suppressed because it is too large Load Diff
@@ -41,7 +41,7 @@ use crate::{
enqueue_editor_image_generation_for_owner,
enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error,
normalize_editor_persisted_media_src, normalize_optional_string,
parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs,
parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
},
external_api_auth::ExternalApiPrincipal,
@@ -991,7 +991,9 @@ fn normalize_project_title(title: Option<String>) -> 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))
serialize_editor_asset_metadata(sanitize_editor_untrusted_generation_inputs(
generation_inputs,
))
}
#[cfg(test)]
@@ -1064,6 +1066,12 @@ mod tests {
"screenColorHex": "#00FF00",
"mattingProvider": "forged-provider",
"mattingModel": "forged-model",
"references": [{
"title": "伪造引用",
"label": "其他用户素材",
"refType": "asset",
"refId": "asset-forged"
}],
"characterAnimation": {"durationSeconds": 4}
})))
.expect("外部编辑器生成输入应可序列化")
@@ -1075,6 +1083,7 @@ mod tests {
assert!(parsed.get("screenColorHex").is_none());
assert!(parsed.get("mattingProvider").is_none());
assert!(parsed.get("mattingModel").is_none());
assert!(parsed.get("references").is_none());
}
#[test]
@@ -1300,6 +1309,14 @@ mod tests {
.get("default")
.is_none()
);
let generation_references = &parsed["components"]["schemas"]["EditorImageGenerationRequest"]
["properties"]["referenceImageSrcs"];
assert_eq!(generation_references["maxItems"], 9);
assert!(
generation_references["items"]["description"]
.as_str()
.is_some_and(|description| description.contains("超限返回 400"))
);
assert_eq!(
parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"],
"array"
@@ -1333,6 +1350,21 @@ mod tests {
.get("targetLayerId")
.is_some()
);
for (schema, max_items) in [
("EditorImageEditRequest", 8),
("EditorIconSpritesheetGenerationRequest", 8),
("EditorUiDesignAssetExtractionRequest", 5),
] {
let references =
&parsed["components"]["schemas"][schema]["properties"]["referenceImageSrcs"];
assert_eq!(references["maxItems"], max_items, "{schema}");
assert!(
references["items"]["description"]
.as_str()
.is_some_and(|description| description.contains("超限返回 400")),
"{schema}"
);
}
assert!(
parsed["paths"]
.get("/api/external/v1/editor/icon-spritesheets/generations")
@@ -9,7 +9,10 @@ use super::{
effective_request_timeout_ms, request_budget_exhausted_error,
retry_delay_fits_request_deadline,
},
constants::{GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
constants::{
GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES,
VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER,
},
curl_transport::{
map_curl_error, send_vector_engine_json_request_with_curl,
send_vector_engine_multipart_edit_request_with_curl,
@@ -320,6 +323,15 @@ pub async fn create_vector_engine_nanobanana_generate_content(
reference_images: &[ReferenceImage],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
if reference_images.len() > VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!(
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。",
reference_images.len()
),
});
}
let model = normalize_vector_engine_image_model(model);
let request_url = vector_engine_nanobanana_generate_content_url(settings, model);
let request_body = build_vector_engine_nanobanana_generate_content_request_body(
@@ -329,12 +341,9 @@ pub async fn create_vector_engine_nanobanana_generate_content(
image_size,
reference_images,
);
let reference_image_count = reference_images.iter().take(14).count();
let reference_image_bytes_total: usize = reference_images
.iter()
.take(14)
.map(|image| image.bytes.len())
.sum();
let reference_image_count = reference_images.len();
let reference_image_bytes_total: usize =
reference_images.iter().map(|image| image.bytes.len()).sum();
let request_params = serde_json::json!({
"model": model,
"promptChars": prompt.trim().chars().count(),
@@ -536,16 +545,22 @@ pub async fn create_vector_engine_image_edit_with_references_and_model(
message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"),
});
}
if reference_images.len() > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!(
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。",
reference_images.len()
),
});
}
let request_url = vector_engine_images_edit_url(settings);
let normalized_size = normalize_image_size_for_model(requested_model, size);
let reference_image_count = reference_images.iter().take(5).count();
let reference_image_bytes_total: usize = reference_images
.iter()
.take(5)
.map(|image| image.bytes.len())
.sum();
let reference_image_count = reference_images.len();
let reference_image_bytes_total: usize =
reference_images.iter().map(|image| image.bytes.len()).sum();
let started_at = std::time::Instant::now();
let mut upstream_model = preferred_vector_engine_upstream_model(requested_model);
let mut recovered_failure_audits = Vec::new();
@@ -1090,6 +1105,63 @@ fn vector_engine_send_retry_jitter_ms() -> u64 {
mod tests {
use super::*;
fn reference_image(index: usize) -> ReferenceImage {
ReferenceImage {
bytes: vec![index as u8],
mime_type: "image/png".to_string(),
file_name: format!("reference-{index}.png"),
}
}
fn test_settings() -> VectorEngineImageSettings {
VectorEngineImageSettings {
base_url: "http://127.0.0.1:9".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
request_deadline: None,
}
}
#[tokio::test]
async fn gpt_image_edit_rejects_six_references_before_network_send() {
let references = (0..6).map(reference_image).collect::<Vec<_>>();
let error = create_vector_engine_image_edit_with_references_and_model(
&reqwest::Client::new(),
&test_settings(),
GPT_IMAGE_2_MODEL,
"测试提示词",
None,
"1024x1024",
1,
references.as_slice(),
"测试图片编辑失败",
)
.await
.expect_err("the provider boundary must reject the sixth reference");
assert!(matches!(error, PlatformImageError::InvalidRequest { .. }));
}
#[tokio::test]
async fn nanobanana_rejects_fifteen_references_before_network_send() {
let references = (0..15).map(reference_image).collect::<Vec<_>>();
let error = create_vector_engine_nanobanana_generate_content(
&reqwest::Client::new(),
&test_settings(),
super::super::constants::NANOBANANA_2_MODEL,
"测试提示词",
None,
"1:1",
"1K",
references.as_slice(),
"测试图片生成失败",
)
.await
.expect_err("the provider boundary must reject the fifteenth reference");
assert!(matches!(error, PlatformImageError::InvalidRequest { .. }));
}
#[tokio::test]
async fn expired_deadline_stops_generation_before_network_send() {
let settings = VectorEngineImageSettings {
@@ -3,3 +3,5 @@ pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c";
pub const NANOBANANA_2_MODEL: &str = "gemini-3.1-flash-image-preview";
pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
pub const VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES: usize = 5;
pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14;
@@ -126,7 +126,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
let prompt = prompt.to_string();
let negative_prompt = negative_prompt.map(str::to_string);
let normalized_size = normalized_size.to_string();
let reference_images = reference_images.iter().take(5).cloned().collect::<Vec<_>>();
let reference_images = reference_images.to_vec();
tokio::task::spawn_blocking(move || {
send_multipart_edit_request_with_curl_blocking(
request_url.as_str(),
@@ -4,7 +4,7 @@ use std::time::Instant;
use super::{
budget::request_budget_exhausted_error,
constants::VECTOR_ENGINE_PROVIDER,
constants::{VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER},
error::PlatformImageError,
types::{DownloadedImage, GeneratedImages, ReferenceImage},
};
@@ -129,8 +129,20 @@ pub(crate) async fn resolve_reference_images(
failure_context: &str,
request_deadline: Option<Instant>,
) -> Result<Vec<ReferenceImage>, PlatformImageError> {
let reference_count = reference_images
.iter()
.filter(|source| !source.trim().is_empty())
.count();
if reference_count > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!(
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {reference_count} 张。"
),
});
}
let mut resolved = Vec::new();
for (index, source) in reference_images.iter().take(5).enumerate() {
for (index, source) in reference_images.iter().enumerate() {
let source = source.trim();
if source.is_empty() {
continue;
@@ -56,7 +56,7 @@ pub fn build_vector_engine_nanobanana_generate_content_request_body(
) -> Value {
let prompt = build_prompt_with_negative(prompt, negative_prompt);
let mut parts = vec![json!({ "text": prompt })];
for reference_image in reference_images.iter().take(14) {
for reference_image in reference_images {
parts.push(json!({
"inline_data": {
"mime_type": reference_image.mime_type,
@@ -287,7 +287,6 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params(
.filter(|value| !value.is_empty());
let references: Vec<Value> = reference_images
.iter()
.take(5)
.enumerate()
.map(|(index, image)| {
json!({
@@ -299,11 +298,8 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params(
})
})
.collect();
let reference_image_bytes_total: usize = reference_images
.iter()
.take(5)
.map(|image| image.bytes.len())
.sum();
let reference_image_bytes_total: usize =
reference_images.iter().map(|image| image.bytes.len()).sum();
json!({
"model": model,
@@ -54,6 +54,7 @@ type ImageCanvasBasicGenerationComposerViewProps = {
onPickReferenceFromCanvas?: () => void;
onToggleReferenceMenu?: () => void;
onRememberImageModel?: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
dialogLabel?: string;
includeDimensions?: boolean;
@@ -107,6 +108,7 @@ export function ImageCanvasBasicGenerationComposerView({
onPickReferenceFromCanvas,
onToggleReferenceMenu,
onRememberImageModel = () => {},
hasPendingImageReferenceUploads = false,
onSubmit,
dialogLabel,
includeDimensions = true,
@@ -271,6 +273,7 @@ export function ImageCanvasBasicGenerationComposerView({
includeDimensions={shouldIncludeDimensions}
includeModel={includeModel}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
optionLabelPrefix={resolvedOptionLabelPrefix}
@@ -44,6 +44,7 @@ type ImageCanvasCharacterGenerationComposerViewProps = {
onOpenSpecDialog: (specType: SpecGenerationType) => void;
onRequestUpload: (target: UploadTarget) => void;
onRememberImageModel: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
};
@@ -97,6 +98,7 @@ export function ImageCanvasCharacterGenerationComposerView({
onOpenSpecDialog,
onRequestUpload,
onRememberImageModel,
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasCharacterGenerationComposerViewProps) {
useImageCanvasFloatingOptionDismiss({
@@ -289,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({
setGenerateDialog={setGenerateDialog}
includeDimensions
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
optionLabelPrefix="生成图片"
cost={calculateEditorImageGenerationPrice({
kind: 'character',
@@ -390,6 +390,7 @@ export type CanvasContextMenuState =
};
export type QuickEditPanelState = {
referenceUploadContextId?: string;
mode?: 'quick-edit' | 'redraw';
sourceLayerId: string;
prompt: string;
@@ -58,12 +58,17 @@ import type {
ImageContextMenuState,
QuickEditPanelState,
SidebarPanel,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
QUICK_EDIT_REFERENCE_LIMIT,
resolveDialogExtraImageReferenceLimit,
resolveExtraImageReferenceLimit,
UI_EXTRA_REFERENCE_LIMIT,
} from './ImageCanvasGenerationModel';
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
@@ -97,7 +102,10 @@ import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
import { useImageCanvasStageController } from './useImageCanvasStageController';
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
import {
type ImageReferenceUploadCapacity,
useImageCanvasUploadWorkflow,
} from './useImageCanvasUploadWorkflow';
import {
DEFAULT_IMAGE_CANVAS_VIEWPORT,
useImageCanvasViewportControls,
@@ -430,6 +438,9 @@ export function ImageCanvasEditorView({
const appendUiAssetExtractionReferencesRef = useRef<
(references: CharacterReferenceImage[]) => void
>(() => {});
const resolveImageReferenceUploadCapacityRef = useRef<
(target: UploadTarget) => ImageReferenceUploadCapacity | null
>(() => null);
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
() => {},
);
@@ -672,6 +683,9 @@ export function ImageCanvasEditorView({
discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer),
[],
);
const canDeleteLayersRef = useRef<(targetLayerIds: string[]) => boolean>(
() => true,
);
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
layers,
setLayers,
@@ -679,6 +693,8 @@ export function ImageCanvasEditorView({
setSelectedLayerIds,
onDeleteLayerSideEffects: (layerId) =>
clearDeletedLayerGenerationStateRef.current(layerId),
canDeleteLayers: (targetLayerIds) =>
canDeleteLayersRef.current(targetLayerIds),
discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory,
});
const {
@@ -734,6 +750,14 @@ export function ImageCanvasEditorView({
onDeleteAssets: removeCanvasLayersLinkedToAssets,
});
const pendingImageReferenceUploadCountRef = useRef(0);
const isPendingReferenceUploadContextMutationLocked = useCallback(
() => pendingImageReferenceUploadCountRef.current > 0,
[],
);
const rejectPendingReferenceUploadContextMutation = useCallback(() => {
window.alert('参考图正在上传,请等待上传完成后再切换或关闭生成面板');
}, []);
const handleActivateCanvasGenerationDialog = useCallback(() => {
closeGenerationTransientStateRef.current();
setSelectedLayerId(null);
@@ -761,6 +785,8 @@ export function ImageCanvasEditorView({
getGeneratingDialogPlaceholder,
} = useCanvasGenerationDialogs({
onActivate: handleActivateCanvasGenerationDialog,
isContextMutationLocked: isPendingReferenceUploadContextMutationLocked,
onContextMutationRejected: rejectPendingReferenceUploadContextMutation,
});
canvasGenerationDialogsRef.current = canvasGenerationDialogs;
const canvasHistoryRefs = useMemo(
@@ -1238,7 +1264,7 @@ export function ImageCanvasEditorView({
captureCanvasHistory(action);
applyProjectSnapshot(project);
if (action.type !== 'perfect-pixel') {
void refreshAssetLibrary();
void refreshAssetLibrary();
}
},
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
@@ -1344,6 +1370,7 @@ export function ImageCanvasEditorView({
const {
uploadInputRef,
uploadTarget,
pendingImageReferenceUploadCount,
requestUpload,
handleUploadInputChange,
addUploadedFiles,
@@ -1365,12 +1392,17 @@ export function ImageCanvasEditorView({
setLayers,
setGenerateDialog,
setQuickEditPanel: (updater) => setQuickEditPanelRef.current(updater),
resolveImageReferenceUploadCapacity: (target) =>
resolveImageReferenceUploadCapacityRef.current(target),
appendUiAssetExtractionReferences: (references) =>
appendUiAssetExtractionReferencesRef.current(references),
appendCanvasLayersWithResources,
captureCanvasHistory,
selectSingleLayer,
refreshAssetLibrary,
});
pendingImageReferenceUploadCountRef.current =
pendingImageReferenceUploadCount;
const generationSurface = useImageCanvasGenerationSurface({
layers,
canvasSize,
@@ -1387,6 +1419,7 @@ export function ImageCanvasEditorView({
generationReferenceButtonRef,
publicationReferenceButtonRef,
iconSpecButtonRef,
hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0,
generateDialog,
setGenerateDialog,
activeCanvasGenerationDialog,
@@ -1549,6 +1582,7 @@ export function ImageCanvasEditorView({
clearDeletedLayerGenerationState,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
quickEditPanel,
quickEditSelectionState,
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
@@ -1593,6 +1627,137 @@ export function ImageCanvasEditorView({
clearDeletedLayerGenerationState;
appendUiAssetExtractionReferencesRef.current =
appendUiAssetExtractionReferences;
resolveImageReferenceUploadCapacityRef.current = (target) => {
const imageReferenceCount = (references: CharacterReferenceImage[] = []) =>
references.filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
const generateDialogContextId = generateDialog
? (generateDialog.id ??
`${generateDialog.mode}:${generateDialog.sourceLayerId ?? 'draft'}`)
: undefined;
if (target === 'spec-reference') {
return generateDialog?.mode === 'spec'
? {
label: '规范参考图',
currentCount: 0,
limit: 1,
contextId: generateDialogContextId,
}
: null;
}
if (target === 'character-spec') {
if (generateDialog?.mode !== 'character') {
return null;
}
const referencesFit =
imageReferenceCount(generateDialog.characterReferences) <=
resolveDialogExtraImageReferenceLimit(generateDialog);
return {
label: '角色主图',
currentCount: 0,
limit: referencesFit ? 1 : 0,
contextId: generateDialogContextId,
};
}
if (target === 'icon-spec') {
if (generateDialog?.mode !== 'icon') {
return null;
}
const referencesFit =
imageReferenceCount(generateDialog.generationReferences) <=
resolveDialogExtraImageReferenceLimit(generateDialog);
return {
label: '图标规范图',
currentCount: 0,
limit: referencesFit ? 1 : 0,
contextId: generateDialogContextId,
};
}
if (target === 'ui-design-icon-spec') {
if (generateDialog?.mode !== 'ui-design') {
return null;
}
const referencesFit =
imageReferenceCount(generateDialog.generationReferences) <=
resolveDialogExtraImageReferenceLimit(generateDialog);
return {
label: 'UI设计规范图',
currentCount: 0,
limit: referencesFit ? 1 : 0,
contextId: generateDialogContextId,
};
}
if (target === 'character-reference') {
return generateDialog?.mode === 'character'
? {
label: '角色参考图',
currentCount: imageReferenceCount(
generateDialog.characterReferences,
),
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
contextId: generateDialogContextId,
}
: null;
}
if (target === 'generation-reference') {
return generateDialog && generateDialog.mode !== 'video'
? {
label: '生成参考图',
currentCount: imageReferenceCount(
generateDialog.generationReferences,
),
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
contextId: generateDialogContextId,
}
: null;
}
if (target === 'publication-reference') {
return generateDialog?.mode === 'publication'
? {
label: '宣发参考图',
currentCount: imageReferenceCount(
generateDialog.publicationReferences,
),
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
contextId: generateDialogContextId,
}
: null;
}
if (target === 'quick-edit-reference') {
return quickEditPanel && quickEditPanel.mode !== 'redraw'
? {
label: '快速编辑参考图',
currentCount: imageReferenceCount(
quickEditPanel.quickEditReferences,
),
limit: resolveExtraImageReferenceLimit(
quickEditPanel.model,
QUICK_EDIT_REFERENCE_LIMIT,
1,
),
contextId: quickEditPanel.referenceUploadContextId,
}
: null;
}
if (target === 'ui-asset-extraction-reference') {
return uiAssetExtractionState
? {
label: 'UI素材参考图',
currentCount: imageReferenceCount(
uiAssetExtractionState.references,
),
limit: resolveExtraImageReferenceLimit(
uiAssetExtractionState.model,
UI_EXTRA_REFERENCE_LIMIT,
1,
),
contextId: uiAssetExtractionState.referenceUploadContextId,
}
: null;
}
return null;
};
useEffect(() => {
if (!isProjectReady || startupIntentConsumedRef.current) {
return;
@@ -1735,6 +1900,33 @@ export function ImageCanvasEditorView({
contextMenu?.kind === 'layer'
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
: null;
const canDeleteLayersDuringReferenceUpload = useCallback(
(targetLayerIds: string[]) => {
if (pendingImageReferenceUploadCountRef.current <= 0) {
return true;
}
const protectedSourceLayerIds = [
generateDialog?.sourceLayerId,
generationSurface.quickEditPanel?.sourceLayerId,
uiAssetExtractionState?.sourceLayerId,
].filter((layerId): layerId is string => Boolean(layerId));
if (
!targetLayerIds.some((layerId) =>
protectedSourceLayerIds.includes(layerId),
)
) {
return true;
}
window.alert('参考图正在上传,请等待上传完成后再删除当前生成源图');
return false;
},
[
generateDialog?.sourceLayerId,
generationSurface.quickEditPanel?.sourceLayerId,
uiAssetExtractionState?.sourceLayerId,
],
);
canDeleteLayersRef.current = canDeleteLayersDuringReferenceUpload;
const {
canvasClipboard,
canCopyContextLayers,
@@ -1779,6 +1971,8 @@ export function ImageCanvasEditorView({
onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog,
exportLayerImage,
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
canDeleteLayers: (targetLayerIds) =>
canDeleteLayersRef.current(targetLayerIds),
});
const {
canvasMarquee,
@@ -2328,6 +2522,7 @@ export function ImageCanvasEditorView({
: uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0,
generationComposerStyle,
selectedToolbarStyle,
perfectPixelLayerIds,
@@ -81,6 +81,7 @@ type ImageCanvasGenerationComposerViewProps = {
isPickingCharacterReferenceFromCanvas: boolean;
isPickingIconSpecFromCanvas: boolean;
isPickingUiDesignSpecFromCanvas: boolean;
hasPendingImageReferenceUploads?: boolean;
generateDialog: GenerateDialogState | null;
generationComposerStyle: CSSProperties | null;
iconComposerStyle: CSSProperties | null;
@@ -994,6 +995,7 @@ export function ImageCanvasGenerationComposerView({
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
hasPendingImageReferenceUploads = false,
onOpenSpecDialog,
onRequestUpload,
onSubmitImageGeneration,
@@ -1073,6 +1075,7 @@ export function ImageCanvasGenerationComposerView({
setIsGenerationReferenceMenuOpen((open) => !open)
}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -1095,6 +1098,7 @@ export function ImageCanvasGenerationComposerView({
onOpenSpecDialog={onOpenSpecDialog}
onUpdateSpecFormValue={onUpdateSpecFormValue}
onRequestUpload={onRequestUpload}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -1119,6 +1123,7 @@ export function ImageCanvasGenerationComposerView({
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -1142,6 +1147,7 @@ export function ImageCanvasGenerationComposerView({
onUpdateSpecFormValue={onUpdateSpecFormValue}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -1202,6 +1208,7 @@ export function ImageCanvasGenerationComposerView({
onOpenSpecDialog={onOpenSpecDialog}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -1223,6 +1230,7 @@ export function ImageCanvasGenerationComposerView({
onRequestUpload={onRequestUpload}
onUpdateIconDescriptionText={onUpdateIconDescriptionText}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitIconSpritesheetGeneration}
/>
) : null}
@@ -1278,6 +1286,7 @@ export function ImageCanvasGenerationComposerView({
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
setQuickEditPanel={setQuickEditPanel}
onSubmit={onSubmitQuickEdit}
/>
@@ -1134,6 +1134,93 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('caps image references before a sixth reference can enter submission state', () => {
const existingReferences = Array.from({ length: 5 }, (_, index) => ({
id: `reference-${index}`,
label: `参考图${index + 1}`,
src: `generated-character-drafts/editor/reference-${index}.png`,
}));
const imageDialog: GenerateDialogState = {
mode: 'generate',
prompt: '',
status: 'idle',
generationReferences: existingReferences,
};
expect(
appendGenerationReference(
imageDialog,
createLayer({ id: 'reference-6', title: '参考图6' }),
),
).toMatchObject({
generationReferences: existingReferences,
});
const characterDialog: GenerateDialogState = {
mode: 'character',
prompt: '',
status: 'idle',
characterSpecReference: existingReferences[0],
characterReferences: existingReferences.slice(1),
};
expect(
appendCharacterReference(
characterDialog,
createLayer({ id: 'character-reference-6', title: '角色参考图5' }),
),
).toMatchObject({
characterReferences: existingReferences.slice(1),
});
const iconDialog: GenerateDialogState = {
mode: 'icon',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
generationReferences: existingReferences.slice(0, 4),
};
expect(
appendGenerationReference(
iconDialog,
createLayer({ id: 'icon-reference-5', title: '图标参考图5' }),
),
).toMatchObject({
generationReferences: existingReferences.slice(0, 4),
});
});
it('reserves the primary-image slot before character and UI specs are selected', () => {
const references = Array.from({ length: 5 }, (_, index) => ({
id: `reference-${index}`,
label: `参考图${index + 1}`,
src: `/reference-${index}.png`,
}));
const characterDialog: GenerateDialogState = {
mode: 'character',
prompt: '',
status: 'idle',
characterReferences: references.slice(0, 4),
};
const uiDialog: GenerateDialogState = {
mode: 'ui-design',
prompt: '',
status: 'idle',
generationReferences: references.slice(0, 4),
};
expect(
appendCharacterReference(
characterDialog,
createLayer({ id: 'character-reference-5' }),
),
).toMatchObject({ characterReferences: references.slice(0, 4) });
expect(
appendGenerationReference(
uiDialog,
createLayer({ id: 'ui-reference-5' }),
),
).toMatchObject({ generationReferences: references.slice(0, 4) });
});
it('updates failed spec and icon dialog fields back to idle state', () => {
const specDialog: GenerateDialogState = {
mode: 'spec',
@@ -11,6 +11,7 @@ import type {
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
appendLimitedImageReferences,
appendLimitedQuickEditReferences,
AUDIO_FRAME_DISPLAY_SIZE,
AUDIO_FRAME_ORIGINAL_SIZE,
@@ -36,6 +37,7 @@ import {
normalizeEditorImageModel,
PUBLICATION_FRAME_ORIGINAL_SIZE,
resizeGenerationPlaceholderToImageSelection,
resolveDialogExtraImageReferenceLimit,
resolveEditorImageGenerationPixelSize,
resolveEditorVideoGenerationPixelSize,
SPEC_FRAME_ORIGINAL_SIZE,
@@ -644,10 +646,7 @@ function resolveGeneratedSourceDialogMode({
if (sourceLayer.assetKind === 'character-animation') {
return 'character-animation';
}
if (
sourceLayer.assetKind === 'video' ||
sourceLayer.mediaType === 'video'
) {
if (sourceLayer.assetKind === 'video' || sourceLayer.mediaType === 'video') {
return 'video';
}
if (sourceLayer.assetKind === 'sound-effect') {
@@ -1005,8 +1004,7 @@ export function createSameSourceGenerationDialogDraft({
videoModel: sourceDialog?.videoModel ?? draft.videoModel,
videoAspectRatio:
sourceDialog?.videoAspectRatio ?? draft.videoAspectRatio,
videoResolution:
sourceDialog?.videoResolution ?? draft.videoResolution,
videoResolution: sourceDialog?.videoResolution ?? draft.videoResolution,
videoDurationSeconds:
sourceDialog?.videoDurationSeconds ?? draft.videoDurationSeconds,
videoMode: sourceDialog?.videoMode ?? draft.videoMode,
@@ -1424,6 +1422,7 @@ export function assignCharacterSpecReference(
? {
...resetFailedGenerationDialog(dialog),
characterSpecReference: createCanvasLayerReference(layer),
characterReferences: dialog.characterReferences,
composerOpen: true,
}
: dialog;
@@ -1437,10 +1436,11 @@ export function appendCharacterReference(
getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
characterReferences: [
...(dialog.characterReferences ?? []),
createCanvasLayerReference(layer),
],
characterReferences: appendLimitedImageReferences(
dialog.characterReferences,
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
}
: dialog;
@@ -1474,10 +1474,11 @@ export function appendGenerationReference(
}
return {
...resetFailedGenerationDialog(dialog),
generationReferences: [
...(dialog.generationReferences ?? []),
createCanvasLayerReference(layer),
],
generationReferences: appendLimitedImageReferences(
dialog.generationReferences,
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
};
}
@@ -1501,10 +1502,11 @@ export function appendPublicationReference(
getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
publicationReferences: [
...(dialog.publicationReferences ?? []),
createCanvasLayerReference(layer),
],
publicationReferences: appendLimitedImageReferences(
dialog.publicationReferences,
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
}
: dialog;
@@ -1521,6 +1523,11 @@ export function assignIconSpecReference(
? {
...resetFailedGenerationDialog(dialog),
iconSpecReference: createCanvasLayerReference(layer),
generationReferences: appendLimitedImageReferences(
[],
dialog.generationReferences ?? [],
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
}
: dialog;
@@ -1537,6 +1544,7 @@ export function assignUiDesignSpecReference(
? {
...resetFailedGenerationDialog(dialog),
uiDesignSpecReference: createCanvasLayerReference(layer),
generationReferences: dialog.generationReferences,
composerOpen: true,
}
: dialog;
@@ -15,13 +15,13 @@ import {
function ImageOptionsHarness({
initialDialog,
includeDimensions = true,
hasPendingImageReferenceUploads = false,
onRememberImageModel = vi.fn(),
lockedModel,
}: {
initialDialog: GenerateDialogState;
includeDimensions?: boolean;
hasPendingImageReferenceUploads?: boolean;
onRememberImageModel?: (model: string) => void;
lockedModel?: string;
}) {
@@ -35,6 +35,7 @@ function ImageOptionsHarness({
dialog={dialog}
setGenerateDialog={setDialog}
includeDimensions={includeDimensions}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onRememberImageModel={onRememberImageModel}
lockedModel={lockedModel}
cost={calculateEditorImageGenerationPrice({
@@ -50,6 +51,13 @@ function ImageOptionsHarness({
<output aria-label="当前风格">{dialog.style ?? '-'}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="当前参考图数量">
{dialog.mode === 'character'
? (dialog.characterReferences?.length ?? 0)
: dialog.mode === 'publication'
? (dialog.publicationReferences?.length ?? 0)
: (dialog.generationReferences?.length ?? 0)}
</output>
<output aria-label="当前占位">
{dialog.placeholder
? [
@@ -122,9 +130,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
/>,
);
expect(
screen.queryByRole('checkbox', { name: '像素艺术' }),
).toBeNull();
expect(screen.queryByRole('checkbox', { name: '像素艺术' })).toBeNull();
},
);
@@ -267,6 +273,90 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
expect(rememberImageModel).not.toHaveBeenCalled();
});
it('rejects a model switch that would discard existing references', () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const rememberImageModel = vi.fn();
render(
<ImageOptionsHarness
initialDialog={{
mode: 'icon',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
generationReferences: Array.from({ length: 8 }, (_, index) => ({
id: `reference-${index + 1}`,
label: `参考图${index + 1}`,
src: `/reference-${index + 1}.png`,
})),
}}
includeDimensions={false}
onRememberImageModel={rememberImageModel}
/>,
);
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
fireEvent.click(
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
'button',
{ name: 'gpt-image-2' },
),
);
expect(alertMock).toHaveBeenCalledWith(
'当前已有 8 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换',
);
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
expect(screen.getByLabelText('当前模型').textContent).toBe(
IMAGE_MODEL_NANOBANANA2,
);
expect(rememberImageModel).not.toHaveBeenCalled();
});
it('rejects model switching while reference uploads are pending', () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const rememberImageModel = vi.fn();
render(
<ImageOptionsHarness
initialDialog={{
mode: 'generate',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
}}
hasPendingImageReferenceUploads
onRememberImageModel={rememberImageModel}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
'button',
{ name: 'gpt-image-2' },
),
);
expect(alertMock).toHaveBeenCalledWith(
'参考图正在上传,请等待上传完成后再切换模型',
);
expect(screen.getByLabelText('当前模型').textContent).toBe(
IMAGE_MODEL_NANOBANANA2,
);
expect(
(
screen.getByRole('button', {
name: '生成角色形象',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(rememberImageModel).not.toHaveBeenCalled();
});
it('renders option menus and shows mud point text in the submit button', () => {
render(
<ImageOptionsHarness
@@ -14,6 +14,7 @@ import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import {
countDialogExtraImageReferences,
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
getEditorImageModelDisplayName,
@@ -21,6 +22,7 @@ import {
normalizeEditorImageModel,
QUICK_EDIT_MODEL_OPTIONS,
resizeGenerationPlaceholderToImageSelection,
resolveDialogExtraImageReferenceLimit,
resolveEditorImageSizeLabel,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -30,6 +32,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
includeDimensions: boolean;
includeModel?: boolean;
hasPendingImageReferenceUploads?: boolean;
onRememberImageModel: (model: string) => void;
optionLabelPrefix?: string;
dimensionRatioAriaLabelPrefix?: string;
@@ -121,6 +124,7 @@ export function ImageCanvasGenerationImageOptionsView({
setGenerateDialog,
includeDimensions,
includeModel = true,
hasPendingImageReferenceUploads = false,
onRememberImageModel,
optionLabelPrefix,
dimensionRatioAriaLabelPrefix,
@@ -192,7 +196,18 @@ export function ImageCanvasGenerationImageOptionsView({
};
const updateImageModel = (model: string) => {
onRememberImageModel(model);
if (hasPendingImageReferenceUploads) {
window.alert('参考图正在上传,请等待上传完成后再切换模型');
return;
}
const referenceLimit = resolveDialogExtraImageReferenceLimit(dialog, model);
const referenceCount = countDialogExtraImageReferences(dialog);
if (referenceCount > referenceLimit) {
window.alert(
`当前已有 ${referenceCount} 张参考图,${getEditorImageModelDisplayName(model)} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`,
);
return;
}
setGenerateDialog((currentDialog) => {
if (!currentDialog || currentDialog.mode !== dialog.mode) {
return currentDialog;
@@ -224,6 +239,7 @@ export function ImageCanvasGenerationImageOptionsView({
};
return resizeGenerationPlaceholderToImageSelection(nextDialog);
});
onRememberImageModel(model);
};
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
@@ -408,7 +424,7 @@ export function ImageCanvasGenerationImageOptionsView({
size="xs"
shape="pill"
className={submitButtonClassName}
disabled={isGenerating}
disabled={isGenerating || hasPendingImageReferenceUploads}
aria-label={submitAriaLabel}
>
{isGenerating ? (
@@ -416,6 +416,79 @@ export const SEEDANCE_VIDEO_REFERENCE_LIMITS = {
audio: 3,
} as const;
export const QUICK_EDIT_REFERENCE_LIMIT = 8;
export const IMAGE_GENERATION_REFERENCE_LIMIT = 5;
export const ICON_EXTRA_REFERENCE_LIMIT = 8;
export const UI_EXTRA_REFERENCE_LIMIT = 5;
export function resolveImageProviderReferenceLimit(
model: string | null | undefined,
) {
return normalizeEditorImageModel(model) === IMAGE_MODEL_NANOBANANA2 ? 14 : 5;
}
export function resolveExtraImageReferenceLimit(
model: string | null | undefined,
productLimit: number,
primaryReferenceCount = 0,
) {
return Math.max(
0,
Math.min(
productLimit,
resolveImageProviderReferenceLimit(model) - primaryReferenceCount,
),
);
}
export function resolveDialogExtraImageReferenceLimit(
dialog: GenerateDialogState,
model: string | null | undefined = dialog.imageModel,
) {
if (dialog.mode === 'icon') {
return resolveExtraImageReferenceLimit(
model,
ICON_EXTRA_REFERENCE_LIMIT,
1,
);
}
if (dialog.mode === 'ui-design') {
return resolveExtraImageReferenceLimit(
model,
IMAGE_GENERATION_REFERENCE_LIMIT - 1,
1,
);
}
if (dialog.mode === 'quick-edit') {
return resolveExtraImageReferenceLimit(
model,
QUICK_EDIT_REFERENCE_LIMIT,
1,
);
}
if (dialog.mode === 'character') {
return resolveExtraImageReferenceLimit(
model,
IMAGE_GENERATION_REFERENCE_LIMIT - 1,
1,
);
}
return IMAGE_GENERATION_REFERENCE_LIMIT;
}
export function countDialogExtraImageReferences(dialog: GenerateDialogState) {
const references =
dialog.mode === 'character'
? dialog.characterReferences
: dialog.mode === 'publication'
? dialog.publicationReferences
: dialog.mode === 'video'
? []
: dialog.generationReferences;
return (references ?? []).filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
).length;
}
export const CHARACTER_ANIMATION_ACTION_PROMPTS = [
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
{ label: '行走', text: '循环行走动作,步伐稳定。' },
@@ -1004,6 +1077,20 @@ export function appendLimitedQuickEditReferences(
);
}
export function appendLimitedImageReferences(
references: CharacterReferenceImage[] | undefined,
nextReferences: CharacterReferenceImage[],
limit: number,
) {
const imageReferences = nextReferences.filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
);
return [...(references ?? []), ...imageReferences].slice(
0,
Math.max(0, limit),
);
}
export function createGenerationInputField(
title: string,
value: string | null | undefined,
@@ -1298,17 +1385,17 @@ export function isCanvasGenerationDialog(
): dialog is CanvasGenerationDialogState {
return Boolean(
dialog?.id &&
(dialog.mode === 'generate' ||
dialog.mode === 'spec' ||
dialog.mode === 'character' ||
dialog.mode === 'icon' ||
dialog.mode === 'publication' ||
dialog.mode === 'ui-design' ||
dialog.mode === 'quick-edit' ||
dialog.mode === 'character-animation' ||
dialog.mode === 'video' ||
dialog.mode === 'audio-sound-effect' ||
dialog.mode === 'audio-background-music'),
(dialog.mode === 'generate' ||
dialog.mode === 'spec' ||
dialog.mode === 'character' ||
dialog.mode === 'icon' ||
dialog.mode === 'publication' ||
dialog.mode === 'ui-design' ||
dialog.mode === 'quick-edit' ||
dialog.mode === 'character-animation' ||
dialog.mode === 'video' ||
dialog.mode === 'audio-sound-effect' ||
dialog.mode === 'audio-background-music'),
);
}
@@ -19,9 +19,7 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorIconSpritesheetPrice,
} from './ImageCanvasGenerationModel';
import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -42,6 +40,7 @@ type ImageCanvasIconSpritesheetComposerViewProps = {
onRequestUpload: (target: UploadTarget) => void;
onUpdateIconDescriptionText: (value: string) => void;
onRememberImageModel: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
};
@@ -59,6 +58,7 @@ export function ImageCanvasIconSpritesheetComposerView({
onRequestUpload,
onUpdateIconDescriptionText,
onRememberImageModel,
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasIconSpritesheetComposerViewProps) {
const descriptionText =
@@ -236,6 +236,7 @@ export function ImageCanvasIconSpritesheetComposerView({
setGenerateDialog={setGenerateDialog}
includeDimensions
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
optionLabelPrefix="生成图片"
cost={calculateEditorIconSpritesheetPrice(
dialog.imageModel,
@@ -47,6 +47,7 @@ type ImageCanvasPublicationMaterialsDemoPanelViewProps = {
) => CSSProperties;
onRequestUpload: (target: UploadTarget) => void;
onRememberImageModel: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
};
@@ -116,6 +117,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
buildPortalMenuStyle,
onRequestUpload,
onRememberImageModel,
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasPublicationMaterialsDemoPanelViewProps) {
const references = dialog.publicationReferences ?? [];
@@ -302,6 +304,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
setGenerateDialog={setGenerateDialog}
includeDimensions={false}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
optionLabelPrefix="宣发素材"
cost={calculateEditorImageModelPrice(

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