修复编辑器生成幂等与参考图契约
生成队列按稳定请求标识去重并保持外部幂等哈希兼容 统一拒绝参考图超限并同步前端、后端、Provider 与 OpenAPI 契约 按真实归属重建生成引用并阻止直接持久化伪造来源 锁定参考图在途上传上下文并保留批量部分成功结果 关闭内部生成 POST 自动重试并补齐回归测试与项目文档 修正最新主线开发者密钥弹窗的导入排序门禁
This commit is contained in:
@@ -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 模式在响应丢失后重复调用 provider;api-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、完美像素及直接创建资源 / 素材入口删除客户端 references,worker / 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。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 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
Reference in New Issue
Block a user