diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 69ea2448c..2b91e6ed7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -240,21 +240,21 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx --reporter verbose`。 - 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx`、`src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。 -## 图片画布快速编辑不要直接提交普通图片 URL +## 图片画布生成请求不要直接提交临时媒体源 -- 现象:图片画布快速编辑站内示例图、历史 generated 图或 OSS generated 图时,后端返回 `修改图片参考图必须是图片 Data URL。`。 -- 原因:快速编辑直接把图层 `src` 塞进 `/api/editor/images/generations` 的 `referenceImageSrcs`;默认示例图和部分持久化图层的 `src` 是 `/creation-type-references/*.webp`、`/generated-*` 或 OSS URL,而 `api-server` 的编辑参考图解析只接收 `data:image/*;base64,...`。 -- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 先走 `/api/assets/read-url` 换签后由浏览器直读 OSS,直读失败时才 fallback 到 `/api/assets/read-bytes`,普通 public 路径直接 fetch。 -- 验证:`npm run test -- src/services/image-editor/editorImageReference.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx -t "editorImageReference|converts non-data-url quick edit source images before submitting references"`。 -- 关联:`src/services/image-editor/editorImageReference.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +- 现象:图片画布快速编辑、参考生成、去背景或角色动画提交 Data URL / Blob URL 时,前端或后端返回“必须先上传 OSS”。 +- 原因:Data URL / Blob URL 体积大且不能作为持久引用;如果写入外部生成队列,请求体会膨胀,worker 也无法稳定复用浏览器临时资源。 +- 处理:提交前优先复用图层已有 `objectKey`、项目资源 ID 或素材 ID;未登记的本地图片和普通 public 图片路径先通过 `resolveEditorGenerationMediaReference(...)` 读取并上传 OSS,再把稳定引用交给生成接口。Data URL 只允许用于浏览器内压缩、标注等临时处理,不能进入 API 请求、队列载荷或项目持久化。 +- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`;`cargo test -p api-server inline_data_url --manifest-path server-rs/Cargo.toml`。 +- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/editor_generation_queue.rs`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 -## 图片编辑器角色动画不要默认提交大图 Data URL +## 图片编辑器角色动画必须提交稳定图片引用 -- 现象:图片编辑器里对角色图点击 `生成动画` 后,后端返回 `Failed to buffer the request body: length limit exceeded`,请求还没进入角色动画 handler。 -- 原因:角色动画生成请求曾把角色图片 `src` 原样作为 `sourceImageSrc` 放进 JSON;角色图如果是较大的 Data URL,会超过 Axum 默认 `2MB` body limit,在 `Json` 提取器阶段被拦截。 -- 处理:前端在角色图已持久化时优先提交 `objectKey`,只把 Data URL 作为未持久化本地临时图兜底;后端 `/api/editor/character-animations/generations` 单独配置 `12MB` body limit 兼容旧请求,但新链路不应依赖传大图 JSON。 -- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "only exposes character animation"`;`cargo test -p api-server editor_character_animation_accepts_character_image_body_above_default_limit --manifest-path server-rs/Cargo.toml`。 -- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`server-rs/crates/api-server/src/modules/play_flow.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 +- 现象:图片编辑器里对尚未上传的角色图点击 `生成动画` 后,前端或后端返回 `sourceImageSrc 必须先上传 OSS`。 +- 原因:角色动画会进入外部生成队列,浏览器 Data URL / Blob URL 既不适合持久任务,也会放大 JSON 请求体。 +- 处理:前端统一通过 `resolveEditorGenerationMediaReference(...)` 取得 `objectKey` 或画板资源引用;本地临时角色图必须先上传 OSS。后端在入队前同步拒绝内联媒体,不再通过放宽 body limit 兼容 Data URL。 +- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`;`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`。 +- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 ## 图片编辑器角色动画抽帧不要采到视频尾点 @@ -328,11 +328,11 @@ - 验证:`npm run test -- src/services/assetReadUrlService.test.ts --reporter verbose` 应覆盖大量不同 objectKey 同时换签时首批限量放行、后续按间隔派发;发布现场同类页面刷新时,Nginx `GET /api/assets/read-url` 429 应从 `upstream_status=-` / `genarrative_api_rps` 收敛。 - 关联:`src/services/assetReadUrlService.ts`、`src/hooks/useResolvedAssetReadUrl.ts`、`src/components/ResolvedAssetImage.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 -## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL +## 图片编辑器 Seedance 2.0 参考媒体只提交稳定引用 - 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。 -- 原因:火山 Seedance 2.0 参考视频只支持公网 URL 或 `asset://` 素材 ID,项目内画板资源应以 `objectKey` 由后端换签;视频不支持 Base64 / `data:video`,且 50MB 视频转 Base64 后会逼近或超过 64MB 请求体上限。参考音频虽然支持 Base64,但也不能单独输入,且大文件同样不应塞进 JSON。 -- 处理:参考视频 / 音频上传先走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 确认;前端保留 signed URL 做预览,提交生成时优先使用 `objectKey`。后端归一化必须拒绝 `data:video/*`,非 Seedance 模型携带参考字段也必须拒绝;Ark body 显式带 `generate_audio:false`。 +- 原因:画板生成会进入持久队列,Base64 / Data URL / Blob URL 会放大请求和任务 JSON;直接签名客户端给出的 objectKey 又会绕过跨账号素材归属校验。项目资源 ID、素材 ID 和 objectKey 必须先解析到当前 owner 的正式对象,公网 URL 与 `asset://` 才能按供应商契约直接透传。 +- 处理:本地参考图片 / 视频 / 音频先走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 确认;前端保留 signed URL 做预览,提交生成时使用 `objectKey`、项目资源 ID 或素材 ID。后端归一化拒绝全部 `data:*` / `blob:*`,签名 OSS URL 前统一校验 owner;稳定引用字段总长度限制为 `256KB`,非 Seedance 模型携带参考字段也必须拒绝;Ark body 显式带 `generate_audio:false`。 - 验证:`npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`;`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`;`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`。 - 关联:`src/services/image-editor/editorReferenceUploadClient.ts`、`src/components/image-editor/useImageCanvasUploadWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index dd0e28295..84c136ca2 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,7 +20,7 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新计时,第二次仍失败才进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 @@ -55,8 +55,8 @@ - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 - 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 -- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 -- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 +- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 ID;Data URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 - 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 @@ -132,7 +132,7 @@ - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 通过共享 BgFilter `background_mode=complex` 链路去背景并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 -- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;只有尚未登记的浏览器本地图片才先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 +- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 - 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。 - 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 38297a241..6b5d6a55c 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -160,11 +160,11 @@ - 参考媒体只对 `seedance2.0-fast` / `seedance2.0` 开放;切换到 Kling 等非 Seedance 模型时,前端不提交 `referenceImageSrcs` / `referenceVideoSrcs` / `referenceAudioSrcs`,后端收到非 Seedance 参考字段必须拒绝。 - 多模态参考按火山 Seedance 2.0 文档限制:参考图片 0~9 张,参考视频 0~3 个,参考音频 0~3 段;音频不可单独输入,必须至少搭配 1 张参考图片或 1 个参考视频。 -- 参考图片支持 URL / Base64 / `asset://` / 画板资源路径;格式为 jpeg、png、webp、bmp、tiff、gif、heic、heif,单张小于 30MB,请求体总大小不超过 64MB。 -- 参考视频只支持 URL / `asset://` / 画板资源路径,不支持 Base64 / `data:video/*`;格式为 mp4、mov,单个文件不超过 50MB,单个时长 [2, 15] 秒,最多 3 个且总时长不超过 15 秒。 -- 参考音频支持 URL / Base64 / `asset://` / 画板资源路径;格式为 wav、mp3,单个文件不超过 15MB,单个时长 [2, 15] 秒,最多 3 段且总时长不超过 15 秒。 -- 前端上传参考视频 / 参考音频必须走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 入库;前端状态保存 signed URL 供预览,同时保存 `objectKey` / `assetObjectId`,提交生成时优先使用 `objectKey`。 -- 后端接收画板资源 `objectKey` 后统一重新签名为 Ark 可读 URL,再按文档构造 `content`:`image_url` + `role=reference_image`、`video_url` + `role=reference_video`、`audio_url` + `role=reference_audio`。 +- 参考图片支持公网 URL、`asset://`、当前账号的 `objectKey`、项目资源 ID 或素材 ID;格式为 jpeg、png、webp、bmp、tiff、gif、heic、heif,单张小于 30MB,不接受 Base64 / Data URL / Blob URL。 +- 参考视频支持公网 URL、`asset://`、当前账号的 `objectKey`、项目资源 ID 或素材 ID;格式为 mp4、mov,单个文件不超过 50MB,单个时长 [2, 15] 秒,最多 3 个且总时长不超过 15 秒,不接受 Base64 / Data URL / Blob URL。 +- 参考音频支持公网 URL、`asset://`、当前账号的 `objectKey`、项目资源 ID 或素材 ID;格式为 wav、mp3,单个文件不超过 15MB,单个时长 [2, 15] 秒,最多 3 段且总时长不超过 15 秒,不接受 Base64 / Data URL / Blob URL。三类参考媒体最终提交的稳定引用字段总长度不得超过 `256KB`。 +- 前端本地参考媒体必须走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 入库;前端状态保存 signed URL 供预览,同时保存 `objectKey` / `assetObjectId`,提交生成时使用稳定引用。 +- 后端接收 `objectKey`、项目资源 ID 或素材 ID 后,先解析并校验对象属于当前 owner,再重新签名为 Ark 可读 URL,并按文档构造 `content`:`image_url` + `role=reference_image`、`video_url` + `role=reference_video`、`audio_url` + `role=reference_audio`。 - 画板生成视频按静音 toggle 映射 Ark `generate_audio`:`sound=on` 时传 `true`,`sound=off` 时传 `false`,不能只依赖上游默认值。 - Seedance 2.0 文档提示不支持直接上传含真人人脸的参考图 / 视频;当前画板尚未做真人脸授权证明、来源声明或服务端人脸拦截,后续开放真人素材前必须补授权/来源确认链路。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 5e4a3465b..549481bcd 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -147,9 +147,9 @@ ### Prompt 与生成契约 - 前端提交到 `POST /api/editor/character-animations/generations`。 -- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长,并可通过 `assetFolderId` 指定中间视频素材落点。角色图片已经持久化到 OSS 时,`sourceImageSrc` 必须优先传 `objectKey`;只有未持久化的本地临时图片才允许传 Data URL。 +- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长,并可通过 `assetFolderId` 指定中间视频素材落点。`sourceImageSrc` 只允许传当前账号的 `objectKey`、项目资源 ID 或素材 ID;未持久化的本地临时图片必须先上传 OSS,不能提交 Data URL 或 Blob URL。后端解析稳定引用后必须校验对象属于当前 owner,再读取 OSS 内容。 - 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 `doubao-seedance-2-0-fast-260128`。 -- 后端路由兼容旧 Data URL 请求并单独放宽 JSON body limit 到 `12MB`,但该限额只作为兼容兜底,不作为新链路默认传大图的方式。 +- 后端在内联执行和队列入队前都会拒绝 Data URL / Blob URL;路由使用默认 JSON body limit,不再为内联图片请求单独放宽。 - 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后: ```text diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 976c67481..0424aa252 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1725,19 +1725,18 @@ mod tests { } #[tokio::test] - async fn editor_character_animation_accepts_character_image_body_above_default_limit() { + async fn editor_character_animation_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { - external_generation_mode: ExternalGenerationMode::Inline, + external_generation_mode: ExternalGenerationMode::Queue, ..AppConfig::default() }) .expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138190", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_editor_character_body"); let app = build_router(state); - let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ "sourceLayerId": "layer-character-large", - "sourceImageSrc": source_image_src, + "sourceImageSrc": "data:image/png;base64,AAAA", "sourceWidth": 1024, "sourceHeight": 1024, "promptText": "待机呼吸循环。", @@ -1748,7 +1747,6 @@ mod tests { "model": "seedance2.0-fast" }) .to_string(); - assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( @@ -1763,7 +1761,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() @@ -1772,10 +1770,9 @@ mod tests { .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( - body_text.contains("ARK_CHARACTER_VIDEO_BASE_URL"), - "handler should parse the oversized character source image before checking Ark config: {body_text}" + body_text.contains("先上传 OSS"), + "handler should reject inline character sources: {body_text}" ); - assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] @@ -1811,23 +1808,21 @@ mod tests { } #[tokio::test] - async fn editor_ui_design_asset_extraction_accepts_image_body_above_default_limit() { + async fn editor_ui_design_asset_extraction_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { - external_generation_mode: ExternalGenerationMode::Inline, + external_generation_mode: ExternalGenerationMode::Queue, ..AppConfig::default() }) .expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138188", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_editor_ui_extract_body"); let app = build_router(state); - let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ - "sourceImageSrc": source_image_src, + "sourceImageSrc": "data:image/png;base64,AAAA", "aspectRatio": "1:1", "imageSize": "1K", }) .to_string(); - assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( @@ -1842,7 +1837,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() @@ -1851,33 +1846,28 @@ mod tests { .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( - body_text.contains("VECTOR_ENGINE") - || body_text.contains("OPENAI") - || body_text.contains("配置"), - "handler should parse the oversized UI design image before checking image config: {body_text}" + body_text.contains("先上传 OSS"), + "handler should reject inline UI design sources: {body_text}" ); - assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] - async fn editor_image_generation_accepts_reference_body_above_default_limit() { + async fn editor_image_generation_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { - external_generation_mode: ExternalGenerationMode::Inline, + external_generation_mode: ExternalGenerationMode::Queue, ..AppConfig::default() }) .expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_reference_body"); let app = build_router(state); - let reference_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ "prompt": "生成宣发素材", "kind": "publication-material", "size": "720x540", - "referenceImageSrcs": [reference_image_src], + "referenceImageSrcs": ["data:image/png;base64,AAAA"], }) .to_string(); - assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( @@ -1892,7 +1882,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() @@ -1901,31 +1891,28 @@ mod tests { .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( - body_text.contains("VECTOR_ENGINE_BASE_URL"), - "handler should parse the oversized editor reference before checking VectorEngine config: {body_text}" + body_text.contains("先上传 OSS"), + "handler should reject inline editor references: {body_text}" ); - assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] - async fn editor_image_edit_accepts_reference_body_above_default_limit() { + async fn editor_image_edit_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { - external_generation_mode: ExternalGenerationMode::Inline, + external_generation_mode: ExternalGenerationMode::Queue, ..AppConfig::default() }) .expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138029", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_edit_body"); let app = build_router(state); - let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ "prompt": "快速编辑图片", - "sourceImageSrc": source_image_src, + "sourceImageSrc": "data:image/png;base64,AAAA", "size": "1024x1024", "model": "gpt-image-2" }) .to_string(); - assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( @@ -1940,7 +1927,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() @@ -1949,10 +1936,9 @@ mod tests { .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( - body_text.contains("VECTOR_ENGINE_BASE_URL"), - "handler should parse the oversized editor edit image before checking VectorEngine config: {body_text}" + body_text.contains("先上传 OSS"), + "handler should reject inline editor edit sources: {body_text}" ); - assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 965d100db..d1e8e4bb6 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -64,13 +64,15 @@ use crate::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, }, editor_project::{ - EDITOR_BGFILTER_CROSS_CHECK_ENABLED, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, + EDITOR_ASSET_ID_PREFIX, EDITOR_BGFILTER_CROSS_CHECK_ENABLED, + EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_RESOURCE_ID_PREFIX, EditorCanvasGeneratedLayerInput, EditorGenerationPhaseReporter, PersistEditorGeneratedAssetRequest, apply_editor_screen_background_decision_to_generation_inputs, build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, persist_editor_generated_media_asset, remove_editor_generated_screen_background_with_bgfilter_with_request_timeout, + resolve_editor_reference_object_key_for_owner, }, editor_screen_background_decision::{ EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, @@ -130,7 +132,7 @@ const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast"; const EDITOR_VIDEO_REFERENCE_IMAGE_LIMIT: usize = 9; const EDITOR_VIDEO_REFERENCE_VIDEO_LIMIT: usize = 3; const EDITOR_VIDEO_REFERENCE_AUDIO_LIMIT: usize = 3; -const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES: usize = 64 * 1024 * 1024; +const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES: usize = 256 * 1024; const EDITOR_VIDEO_REFERENCE_READ_EXPIRE_SECONDS: u64 = 60 * 60; const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [ @@ -568,6 +570,14 @@ pub async fn generate_editor_character_animation( ) })?; let owner_user_id = authenticated.claims().user_id().to_string(); + if matches_inline_media_source(payload.source_image_src.as_str()) { + return Err(character_animation_error_response( + &request_context, + editor_character_animation_bad_request( + "sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。", + ), + )); + } if !state.config.external_generation_mode.is_inline() { let pricing = state.editor_generation_pricing().await.map_err(|error| { character_animation_error_response( @@ -641,6 +651,14 @@ pub(crate) async fn generate_editor_character_animation_for_owner( let asset_folder_id = payload.asset_folder_id.clone(); let asset_label = resolve_editor_character_animation_asset_label(payload.asset_label.as_deref()); + if matches_inline_media_source(payload.source_image_src.as_str()) { + return Err(character_animation_error_response( + &request_context, + editor_character_animation_bad_request( + "sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。", + ), + )); + } let pricing = state.editor_generation_pricing().await.map_err(|error| { character_animation_error_response( @@ -654,10 +672,17 @@ pub(crate) async fn generate_editor_character_animation_for_owner( // 先解析源角色图:图生视频的主体颜色完全由源图决定,背景色决策必须看到这张图。 let source_resolve_client = build_upstream_http_client(SOURCE_IMAGE_RESOLVE_TIMEOUT_MS) .map_err(|error| character_animation_error_response(&request_context, error))?; + let source_object_key = resolve_editor_reference_object_key_for_owner( + &state, + owner_user_id.as_str(), + payload.source_image_src.trim(), + ) + .await + .map_err(|error| character_animation_error_response(&request_context, error))?; let source_data_url = resolve_media_source_as_data_url( &state, &source_resolve_client, - payload.source_image_src.trim(), + source_object_key.as_str(), "sourceImageSrc", ) .await @@ -2014,7 +2039,8 @@ async fn request_editor_video_preview( request: &NormalizedEditorVideoRequest, ) -> Result { let upstream_task_id = - create_editor_text_to_video_task(state, http_client, settings, request).await?; + create_editor_text_to_video_task(state, http_client, settings, owner_user_id, request) + .await?; let video_url = wait_for_ark_content_generation_task(http_client, &settings.ark, upstream_task_id.as_str()) .await?; @@ -2038,9 +2064,10 @@ async fn create_editor_text_to_video_task( state: &AppState, http_client: &reqwest::Client, settings: &EditorVideoSettings, + owner_user_id: &str, request: &NormalizedEditorVideoRequest, ) -> Result { - let request_body = build_editor_video_ark_request_body(state, request)?; + let request_body = build_editor_video_ark_request_body(state, owner_user_id, request).await?; let response = http_client .post(format!( "{}/contents/generations/tasks", @@ -3402,11 +3429,12 @@ fn normalize_editor_video_request_with_pricing( }) } -fn build_editor_video_ark_request_body( +async fn build_editor_video_ark_request_body( state: &AppState, + owner_user_id: &str, request: &NormalizedEditorVideoRequest, ) -> Result { - let content = build_editor_video_ark_content(state, request)?; + let content = build_editor_video_ark_content(state, owner_user_id, request).await?; Ok(json!({ "model": request.provider_model, "content": content, @@ -3418,8 +3446,9 @@ fn build_editor_video_ark_request_body( })) } -fn build_editor_video_ark_content( +async fn build_editor_video_ark_content( state: &AppState, + owner_user_id: &str, request: &NormalizedEditorVideoRequest, ) -> Result, AppError> { let mut content = Vec::new(); @@ -3431,22 +3460,25 @@ fn build_editor_video_ark_content( } let reference_image_urls = resolve_editor_video_reference_urls( state, + owner_user_id, request.reference_image_srcs.as_slice(), "参考图片", - true, - )?; + ) + .await?; let reference_video_urls = resolve_editor_video_reference_urls( state, + owner_user_id, request.reference_video_srcs.as_slice(), "参考视频", - false, - )?; + ) + .await?; let reference_audio_urls = resolve_editor_video_reference_urls( state, + owner_user_id, request.reference_audio_srcs.as_slice(), "参考音频", - true, - )?; + ) + .await?; content.extend(reference_image_urls.iter().map(|url| { json!({ "type": "image_url", @@ -3471,31 +3503,30 @@ fn build_editor_video_ark_content( Ok(content) } -fn resolve_editor_video_reference_urls( +async fn resolve_editor_video_reference_urls( state: &AppState, + owner_user_id: &str, sources: &[String], label: &str, - allow_data_url: bool, ) -> Result, AppError> { - sources - .iter() - .map(|source| { - resolve_editor_video_reference_url(state, source.as_str(), label, allow_data_url) - }) - .collect() + let mut resolved = Vec::with_capacity(sources.len()); + for source in sources { + resolved.push( + resolve_editor_video_reference_url(state, owner_user_id, source.as_str(), label) + .await?, + ); + } + Ok(resolved) } -fn resolve_editor_video_reference_url( +async fn resolve_editor_video_reference_url( state: &AppState, + owner_user_id: &str, source: &str, label: &str, - allow_data_url: bool, ) -> Result { let value = source.trim(); if value.starts_with("data:") { - if allow_data_url { - return Ok(value.to_string()); - } return Err(editor_video_bad_request(format!( "{label}格式不支持,请使用官方支持的 URL、素材 ID 或画板资源路径。" ))); @@ -3507,17 +3538,24 @@ fn resolve_editor_video_reference_url( return Ok(value.to_string()); } - let object_key = value.trim_start_matches('/'); - if LegacyAssetPrefix::from_object_key(object_key).is_none() { - let format_hint = if allow_data_url { - "公网 URL、素材 ID、data URL 或画板资源路径" - } else { - "公网 URL、素材 ID 或画板资源路径" - }; - return Err(editor_video_bad_request(format!( - "{label}必须是官方支持的{format_hint}。" - ))); - } + let object_key = resolve_editor_reference_object_key_for_owner(state, owner_user_id, value) + .await + .map_err(|error| { + error.with_details(json!({ + "provider": "editor-video", + "field": label, + "message": format!( + "{label}必须是当前账号的 objectKey、项目资源 ID、素材 ID,或官方支持的公网 URL。" + ), + })) + })?; + sign_editor_video_object_key_url(state, object_key.as_str()) +} + +fn sign_editor_video_object_key_url( + state: &AppState, + object_key: &str, +) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", @@ -3582,9 +3620,9 @@ fn normalize_editor_video_references( validate_editor_video_reference_src( src, "参考图片", - &["data:image/", "http://", "https://", "asset://"], + &["http://", "https://", "asset://"], true, - "对应媒体 data URL", + "URL、素材 ID 或画板资源路径", )?; } for src in &video_srcs { @@ -3600,9 +3638,9 @@ fn normalize_editor_video_references( validate_editor_video_reference_src( src, "参考音频", - &["data:audio/", "http://", "https://", "asset://"], + &["http://", "https://", "asset://"], true, - "对应媒体 data URL", + "URL、素材 ID 或画板资源路径", )?; } @@ -3613,7 +3651,9 @@ fn normalize_editor_video_references( .map(String::len) .sum::(); if request_bytes > EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES { - return Err(editor_video_bad_request("参考文件总请求体不能超过 64MB。")); + return Err(editor_video_bad_request( + "参考素材引用字段总长度不能超过 256KB。", + )); } Ok(NormalizedEditorVideoReferences { @@ -3631,6 +3671,11 @@ fn normalize_reference_srcs(values: Vec) -> Vec { .collect() } +fn matches_inline_media_source(value: &str) -> bool { + let value = value.trim_start().to_ascii_lowercase(); + value.starts_with("data:") || value.starts_with("blob:") +} + fn validate_editor_video_reference_src( value: &str, label: &str, @@ -3649,6 +3694,9 @@ fn validate_editor_video_reference_src( { return Ok(()); } + if value.starts_with(EDITOR_RESOURCE_ID_PREFIX) || value.starts_with(EDITOR_ASSET_ID_PREFIX) { + return Ok(()); + } Err(editor_video_bad_request(format!( "{label}格式不支持,请使用官方支持的{format_hint}。" ))) @@ -6072,6 +6120,34 @@ mod tests { ); } + #[test] + fn editor_character_animation_rejects_inline_media_before_queueing() { + let source = include_str!("character_animation_assets.rs"); + assert_function_contains_in_order( + source, + "pub async fn generate_editor_character_animation", + "pub(crate) async fn generate_editor_character_animation_for_owner", + &[ + "matches_inline_media_source", + "enqueue_editor_generation_job", + ], + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_character_animation_for_owner", + "pub async fn generate_editor_video", + &[ + "resolve_editor_reference_object_key_for_owner", + "resolve_media_source_as_data_url", + ], + ); + assert!(matches_inline_media_source("data:image/png;base64,AAAA")); + assert!(matches_inline_media_source(" BLOB:local-preview")); + assert!(!matches_inline_media_source( + "/generated-images/editor/character.png" + )); + } + #[test] fn editor_character_animation_reports_processing_after_video_generation() { let source = include_str!("character_animation_assets.rs"); @@ -6532,11 +6608,15 @@ mod tests { mode: "std".to_string(), sound: "off".to_string(), web_search_enabled: true, - reference_image_srcs: vec!["data:image/png;base64,image".to_string()], + reference_image_srcs: vec![ + "generated-character-drafts/editor/seedance-references/image/demo.png".to_string(), + ], reference_video_srcs: vec![ "generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(), ], - reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()], + reference_audio_srcs: vec![ + "generated-character-drafts/editor/seedance-references/audio/demo.mp3".to_string(), + ], project_id: None, canvas_completion: None, generation_inputs: None, @@ -6549,7 +6629,7 @@ mod tests { assert_eq!( normalized.reference_image_srcs, - vec!["data:image/png;base64,image"] + vec!["generated-character-drafts/editor/seedance-references/image/demo.png"] ); assert_eq!( normalized.reference_video_srcs, @@ -6557,7 +6637,7 @@ mod tests { ); assert_eq!( normalized.reference_audio_srcs, - vec!["data:audio/mpeg;base64,audio"] + vec!["generated-character-drafts/editor/seedance-references/audio/demo.mp3"] ); } @@ -6589,7 +6669,46 @@ mod tests { } #[test] - fn editor_video_ark_body_sets_generate_audio_false_without_mode() { + fn editor_video_rejects_seedance_image_and_audio_data_urls() { + for (image_srcs, audio_srcs, expected_message) in [ + ( + vec!["data:image/png;base64,image".to_string()], + Vec::new(), + "参考图片格式不支持", + ), + ( + vec!["generated-character-drafts/editor/reference.png".to_string()], + vec!["data:audio/mpeg;base64,audio".to_string()], + "参考音频格式不支持", + ), + ] { + let error = normalize_editor_video_request(EditorVideoGenerateRequest { + prompt: "参考素材生成视频".to_string(), + model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(), + aspect_ratio: "16:9".to_string(), + duration_seconds: 4, + resolution: "480p".to_string(), + mode: "std".to_string(), + sound: "off".to_string(), + web_search_enabled: true, + reference_image_srcs: image_srcs, + reference_video_srcs: Vec::new(), + reference_audio_srcs: audio_srcs, + project_id: None, + canvas_completion: None, + generation_inputs: None, + source_resource_id: None, + asset_kind: None, + asset_folder_id: None, + asset_label: None, + }) + .expect_err("Seedance references must not accept data URLs"); + assert!(error.body_text().contains(expected_message)); + } + } + + #[tokio::test] + async fn editor_video_ark_body_sets_generate_audio_false_without_mode() { let state = AppState::new(AppConfig::default()).expect("state should build"); let request = normalize_editor_video_request(EditorVideoGenerateRequest { prompt: "参考素材生成视频".to_string(), @@ -6600,9 +6719,9 @@ mod tests { mode: "std".to_string(), sound: "off".to_string(), web_search_enabled: true, - reference_image_srcs: vec!["data:image/png;base64,image".to_string()], + reference_image_srcs: vec!["https://assets.example.com/reference.png".to_string()], reference_video_srcs: vec!["https://assets.example.com/reference.mp4".to_string()], - reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()], + reference_audio_srcs: vec!["https://assets.example.com/reference.mp3".to_string()], project_id: None, canvas_completion: None, generation_inputs: None, @@ -6613,8 +6732,9 @@ mod tests { }) .expect("seedance multimodal request should normalize"); - let body = - build_editor_video_ark_request_body(&state, &request).expect("ark body should build"); + let body = build_editor_video_ark_request_body(&state, "video-owner", &request) + .await + .expect("ark body should build"); assert_eq!(body["generate_audio"], json!(false)); assert!(body.get("mode").is_none()); @@ -6628,7 +6748,7 @@ mod tests { } #[test] - fn editor_video_ark_content_signs_generated_object_keys() { + fn editor_video_reference_resolution_checks_owner_before_signing() { let state = AppState::new(AppConfig { oss_bucket: Some("genarrative-assets".to_string()), oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), @@ -6637,7 +6757,14 @@ mod tests { ..AppConfig::default() }) .expect("state should build with OSS"); - let request = normalize_editor_video_request(EditorVideoGenerateRequest { + let object_key = "generated-character-drafts/editor/seedance-references/video/demo.mp4"; + let signed_url = sign_editor_video_object_key_url(&state, object_key) + .expect("validated object keys should be signable"); + assert!(signed_url.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/")); + assert!(signed_url.contains(object_key)); + assert!(signed_url.contains("x-oss-signature=")); + + let registered_reference = normalize_editor_video_request(EditorVideoGenerateRequest { prompt: "参考视频生成视频".to_string(), model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(), aspect_ratio: "16:9".to_string(), @@ -6647,9 +6774,7 @@ mod tests { sound: "off".to_string(), web_search_enabled: true, reference_image_srcs: Vec::new(), - reference_video_srcs: vec![ - "generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(), - ], + reference_video_srcs: vec!["editor-resource-video-reference".to_string()], reference_audio_srcs: Vec::new(), project_id: None, canvas_completion: None, @@ -6659,23 +6784,21 @@ mod tests { asset_folder_id: None, asset_label: None, }) - .expect("seedance objectKey request should normalize"); - - let content = build_editor_video_ark_content(&state, &request) - .expect("ark content should sign object keys"); - let signed_url = content[1]["video_url"]["url"] - .as_str() - .expect("video url should be string"); - - assert!(signed_url.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/")); - assert!( - signed_url - .contains("generated-character-drafts/editor/seedance-references/video/demo.mp4") + .expect("registered editor resource IDs should normalize"); + assert_eq!( + registered_reference.reference_video_srcs, + vec!["editor-resource-video-reference"] ); - assert!(signed_url.contains("x-oss-signature=")); - assert_ne!( - signed_url, - "generated-character-drafts/editor/seedance-references/video/demo.mp4" + + let source = include_str!("character_animation_assets.rs"); + assert_function_contains_in_order( + source, + "async fn resolve_editor_video_reference_url", + "fn sign_editor_video_object_key_url", + &[ + "resolve_editor_reference_object_key_for_owner", + "sign_editor_video_object_key_url", + ], ); } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 322e235b1..4d5e82c72 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -11,7 +11,6 @@ use axum::{ extract::{Extension, Path, Query, State}, http::StatusCode, }; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input, generate_asset_object_id, @@ -1442,6 +1441,12 @@ pub async fn generate_editor_image( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + )?; let normalized_kind = payload.kind.as_deref().map(str::trim); let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); let is_publication_material_generation = @@ -1502,6 +1507,12 @@ pub(crate) async fn generate_editor_image_for_owner( caller: EditorGenerationCaller, payload: EditorImageGenerationRequest, ) -> Result, AppError> { + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + )?; let role_setting = payload.prompt.trim().to_string(); if role_setting.is_empty() { return Err( @@ -1568,15 +1579,6 @@ pub(crate) async fn generate_editor_image_for_owner( // 决策移入闭包后 payload.reference_image_srcs 已被 reference_sources 消费, // 这里从过滤后的 reference_sources 预先固化 ui-design 的「是否带参考图」布尔。 let ui_has_references = !reference_sources.is_empty(); - let settings = require_openai_image_settings(state)?.with_external_api_audit_context( - request_context, - caller.audit_subject_user_id.clone(), - caller - .audit_project_id - .clone() - .or_else(|| payload.project_id.clone()), - ); - let http_client = build_openai_image_http_client(&settings)?; let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"); let reference_images = if reference_sources.is_empty() { Vec::new() @@ -1590,6 +1592,15 @@ pub(crate) async fn generate_editor_image_for_owner( } parsed_reference_images }; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + caller.audit_subject_user_id.clone(), + caller + .audit_project_id + .clone() + .or_else(|| payload.project_id.clone()), + ); + let http_client = build_openai_image_http_client(&settings)?; let image_size = if is_ui_design_generation { Cow::Owned(generation_options.size.clone()) } else { @@ -2622,6 +2633,18 @@ pub async fn edit_editor_image( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-image-edit", + "sourceImageSrc", + "待修改图片", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-image-edit", + "referenceImageSrcs", + "修改参考图", + )?; ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload) .await?; let generation_options = normalize_editor_image_edit_generation_options( @@ -2669,6 +2692,18 @@ pub(crate) async fn edit_editor_image_for_owner( caller: EditorGenerationCaller, payload: EditorImageEditRequest, ) -> Result, AppError> { + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-image-edit", + "sourceImageSrc", + "待修改图片", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-image-edit", + "referenceImageSrcs", + "修改参考图", + )?; ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?; let prompt = payload.prompt.trim().to_string(); if prompt.is_empty() { @@ -2908,6 +2943,12 @@ pub async fn remove_editor_image_background( Json(payload): Json, ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-background-removal", + "sourceImageSrc", + "待去除背景图片", + )?; let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-background-removal", @@ -2937,6 +2978,12 @@ pub(crate) async fn remove_editor_image_background_for_owner( caller: EditorGenerationCaller, payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-background-removal", + "sourceImageSrc", + "待去除背景图片", + )?; let started_at = Instant::now(); caller.report_processing_phase(state).await?; let source_image = parse_editor_reference_image( @@ -3885,6 +3932,18 @@ pub async fn generate_editor_icon_spritesheet( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + ensure_editor_reference_image_source_is_stable( + payload.reference_image_src.as_str(), + "editor-icon-spritesheet", + "referenceImageSrc", + "图标素材规范", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-icon-spritesheet", + "referenceImageSrcs", + "图标素材参考图", + )?; let generation_options = normalize_editor_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), @@ -3929,6 +3988,18 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( caller: EditorGenerationCaller, payload: EditorIconSpritesheetGenerationRequest, ) -> Result, AppError> { + ensure_editor_reference_image_source_is_stable( + payload.reference_image_src.as_str(), + "editor-icon-spritesheet", + "referenceImageSrc", + "图标素材规范", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-icon-spritesheet", + "referenceImageSrcs", + "图标素材参考图", + )?; let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 @@ -3948,7 +4019,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( error.with_details(json!({ "provider": "editor-icon-spritesheet", "field": "referenceImageSrc", - "message": "图标素材规范必须是图片 Data URL、objectKey、资源或素材引用。", + "message": "图标素材规范必须是 objectKey、资源或素材引用。", })) })?, ); @@ -3962,7 +4033,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( error.with_details(json!({ "provider": "editor-icon-spritesheet", "field": "referenceImageSrcs", - "message": "图标素材参考图必须是图片 Data URL、objectKey、资源或素材引用。", + "message": "图标素材参考图必须是 objectKey、资源或素材引用。", })) })?, ); @@ -4595,6 +4666,18 @@ pub async fn extract_editor_ui_design_assets( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-ui-design-asset-extraction", + "sourceImageSrc", + "UI设计图", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-ui-design-asset-extraction", + "referenceImageSrcs", + "UI素材参考图", + )?; let generation_options = normalize_editor_ui_design_asset_extraction_options( payload.model.as_deref(), payload.aspect_ratio.as_str(), @@ -4639,6 +4722,18 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( caller: EditorGenerationCaller, payload: EditorUiDesignAssetExtractionRequest, ) -> Result, AppError> { + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-ui-design-asset-extraction", + "sourceImageSrc", + "UI设计图", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-ui-design-asset-extraction", + "referenceImageSrcs", + "UI素材参考图", + )?; // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 let requested_screen_color = payload.screen_color.clone(); @@ -4658,7 +4753,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( error.with_details(json!({ "provider": "editor-ui-design-asset-extraction", "field": "sourceImageSrc", - "message": "UI设计图必须是图片 Data URL、objectKey、资源或素材引用。", + "message": "UI设计图必须是 objectKey、资源或素材引用。", })) })?; let mut reference_images = vec![reference_image]; @@ -4673,7 +4768,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( error.with_details(json!({ "provider": "editor-ui-design-asset-extraction", "field": "referenceImageSrcs", - "message": "UI素材参考图必须是图片 Data URL、objectKey、资源或素材引用。", + "message": "UI素材参考图必须是 objectKey、资源或素材引用。", })) })?, ); @@ -7031,20 +7126,71 @@ const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION: u32 = 8192; +fn is_editor_inline_media_source(source: &str) -> bool { + let normalized = source.trim_start().to_ascii_lowercase(); + normalized.starts_with("data:") || normalized.starts_with("blob:") +} + +fn ensure_editor_reference_image_source_is_stable( + source: &str, + provider: &str, + field: &str, + label: &str, +) -> Result<(), AppError> { + if !is_editor_inline_media_source(source) { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": provider, + "field": field, + "message": format!("{label}必须先上传 OSS,并使用 objectKey、项目资源 ID 或素材 ID 引用。"), + })), + ) +} + +fn ensure_editor_reference_image_sources_are_stable( + sources: Option<&[String]>, + provider: &str, + field: &str, + label: &str, +) -> Result<(), AppError> { + for source in sources.unwrap_or(&[]) { + ensure_editor_reference_image_source_is_stable(source, provider, field, label)?; + } + Ok(()) +} + pub(crate) async fn parse_editor_reference_image( state: &AppState, owner_user_id: &str, source: &str, ) -> Result { let trimmed = source.trim(); - if trimmed.starts_with("data:") { - return parse_editor_reference_image_data_url(trimmed); + if is_editor_inline_media_source(trimmed) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-reference-image", + "field": "sourceImageSrc", + "message": "图片参考图必须先上传 OSS,并使用 objectKey、项目资源 ID 或素材 ID 引用。", + })), + ); } - let object_key = resolve_editor_reference_object_key(state, owner_user_id, trimmed).await?; - ensure_editor_reference_object_key_owned(state, owner_user_id, object_key.as_str()).await?; + let object_key = + resolve_editor_reference_object_key_for_owner(state, owner_user_id, trimmed).await?; read_editor_reference_image_object(state, object_key.as_str()).await } +pub(crate) async fn resolve_editor_reference_object_key_for_owner( + state: &AppState, + owner_user_id: &str, + source: &str, +) -> Result { + let object_key = resolve_editor_reference_object_key(state, owner_user_id, source).await?; + ensure_editor_reference_object_key_owned(state, owner_user_id, object_key.as_str()).await?; + Ok(object_key) +} + fn normalize_editor_reference_image_sources(sources: Option<&[String]>, limit: usize) -> Vec<&str> { sources .unwrap_or(&[]) @@ -7063,7 +7209,7 @@ fn normalize_editor_reference_object_key(source: &str) -> Result AppError { })) } -fn parse_editor_reference_image_data_url(source: &str) -> Result { - let Some((header, data)) = source.trim().split_once(',') else { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-image-edit", - "field": "sourceImageSrc", - "message": "修改图片参考图必须是图片 Data URL。", - })), - ); - }; - let Some(mime_type) = header - .strip_prefix("data:") - .and_then(|value| value.strip_suffix(";base64")) - .filter(|value| value.starts_with("image/")) - else { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-image-edit", - "field": "sourceImageSrc", - "message": "修改图片参考图必须是 base64 图片 Data URL。", - })), - ); - }; - let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-image-edit", - "field": "sourceImageSrc", - "message": format!("修改图片参考图解码失败:{error}"), - })) - })?; - if bytes.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-image-edit", - "field": "sourceImageSrc", - "message": "修改图片参考图为空。", - })), - ); - } - let extension = match mime_type { - "image/jpeg" => "jpg", - "image/png" => "png", - "image/webp" => "webp", - _ => "png", - }; - Ok(OpenAiReferenceImage { - bytes, - mime_type: mime_type.to_string(), - file_name: format!("editor-reference.{extension}"), - }) -} - async fn read_editor_reference_image_object( state: &AppState, source: &str, @@ -9477,13 +9571,11 @@ mod tests { } #[test] - fn editor_reference_image_data_url_stays_compatible() { - let parsed = parse_editor_reference_image_data_url("data:image/png;base64,AAAA") - .expect("legacy data URL reference should still parse"); + fn editor_reference_image_data_url_is_not_an_object_reference() { + let error = normalize_editor_reference_object_key("data:image/png;base64,AAAA") + .expect_err("inline image should require upload before generation"); - assert_eq!(parsed.mime_type, "image/png"); - assert_eq!(parsed.file_name, "editor-reference.png"); - assert_eq!(parsed.bytes, vec![0, 0, 0]); + assert!(error.body_text().contains("objectKey")); } #[test] @@ -9769,7 +9861,7 @@ mod tests { async fn editor_ui_design_asset_extraction_price_uses_selected_model_and_size() { let state = AppState::new(AppConfig::default()).expect("state should build"); let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({ - "sourceImageSrc": "data:image/png;base64,AAAA", + "sourceImageSrc": "/generated-images/editor/ui-design.png", "model": "gpt-image-2", "screenColor": "#E6D8FF", "segModel": "anime-seg", @@ -9851,12 +9943,12 @@ mod tests { fn editor_image_edit_request_omits_price_mud_points() { let request: EditorImageEditRequest = serde_json::from_value(json!({ "prompt": "修改图片", - "sourceImageSrc": "data:image/png;base64,AAAA", + "sourceImageSrc": "/generated-images/editor/source.png", "size": "2048x1152", "model": "gemini-3.1-flash-image-preview", "aspectRatio": "16:9", "imageSize": "2K", - "referenceImageSrcs": ["data:image/png;base64,BBBB"], + "referenceImageSrcs": ["/generated-images/editor/style.png"], "targetLayerId": "layer-source" })) .expect("image edit request should deserialize without price"); @@ -9879,7 +9971,7 @@ mod tests { assert_eq!(legacy_options.image_size, "2K"); assert_eq!( request.reference_image_srcs, - Some(vec!["data:image/png;base64,BBBB".to_string()]) + Some(vec!["/generated-images/editor/style.png".to_string()]) ); assert_eq!(request.target_layer_id.as_deref(), Some("layer-source")); } @@ -9943,6 +10035,68 @@ mod tests { ); } + #[test] + fn editor_inline_media_sources_are_rejected_before_queueing() { + let source = include_str!("editor_project.rs"); + for (start, end) in [ + ( + "pub async fn generate_editor_image(", + "pub(crate) async fn generate_editor_image_for_owner", + ), + ( + "pub async fn edit_editor_image(", + "pub(crate) async fn edit_editor_image_for_owner", + ), + ( + "pub async fn remove_editor_image_background(", + "pub(crate) async fn remove_editor_image_background_for_owner", + ), + ( + "pub async fn generate_editor_icon_spritesheet(", + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + ), + ( + "pub async fn extract_editor_ui_design_assets(", + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + ), + ] { + assert_function_contains_in_order( + source, + start, + end, + &[ + "ensure_editor_reference_image_source", + "enqueue_editor_generation_job", + ], + ); + } + + for inline_source in [ + "data:image/png;base64,AAAA", + " DATA:image/png;base64,AAAA", + "blob:browser-only", + ] { + let error = ensure_editor_reference_image_source_is_stable( + inline_source, + "editor-test", + "sourceImageSrc", + "测试图片", + ) + .expect_err("inline media must require upload before generation"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("先上传 OSS")); + } + assert!( + ensure_editor_reference_image_source_is_stable( + "/generated-images/editor/reference.png", + "editor-test", + "sourceImageSrc", + "测试图片", + ) + .is_ok() + ); + } + #[test] fn editor_image_edit_can_complete_by_replacing_target_layer() { let source = include_str!("editor_project.rs"); @@ -9959,10 +10113,10 @@ mod tests { } #[test] - fn editor_icon_spritesheet_request_accepts_extra_references() { + fn editor_icon_spritesheet_request_accepts_stable_extra_references() { let request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ - "referenceImageSrc": "data:image/png;base64,spec", - "referenceImageSrcs": ["data:image/png;base64,style"], + "referenceImageSrc": "/generated-images/editor/spec.png", + "referenceImageSrcs": ["/generated-images/editor/style.png"], "iconDescriptions": ["返回按钮"], "screenColor": "#7FB3FF", "segModel": "anime-seg", @@ -9972,7 +10126,7 @@ mod tests { assert_eq!( request.reference_image_srcs, - Some(vec!["data:image/png;base64,style".to_string()]) + Some(vec!["/generated-images/editor/style.png".to_string()]) ); assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF")); assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 3842c5c53..c5c086b61 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -27,7 +27,6 @@ use crate::{ state::AppState, }; -const EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; const EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024; pub fn router(state: AppState) -> Router { @@ -168,49 +167,31 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/editor/images/generations", - post(generate_editor_image) - .layer(DefaultBodyLimit::max( - // 中文注释:图片画布生图仍兼容参考图 Data URL 入参,宣发素材提交前会压缩,后端保留有限兜底。 - EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(generate_editor_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/images/edits", - post(edit_editor_image) - .layer(DefaultBodyLimit::max( - EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(edit_editor_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/images/background-removals", - post(remove_editor_image_background) - .layer(DefaultBodyLimit::max( - EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(remove_editor_image_background).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/icon-spritesheets/generations", - post(generate_editor_icon_spritesheet) - // 中文注释:图标规范参考图会以 Data URL 提交,需避免默认 2MB body limit 在 handler 前拦截。 - .layer(DefaultBodyLimit::max( - EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/icon-spritesheets/slices", @@ -221,14 +202,9 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/editor/ui-designs/assets/extractions", - post(extract_editor_ui_design_assets) - // 中文注释:UI 设计图提取素材复用图片 Data URL 入参,需兼容较大的画板生成图。 - .layer(DefaultBodyLimit::max( - EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(extract_editor_ui_design_assets).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) } diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs index 3841ffb25..497ade677 100644 --- a/server-rs/crates/api-server/src/modules/external_api.rs +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -1,7 +1,5 @@ use axum::{ - Router, - extract::DefaultBodyLimit, - middleware, + Router, middleware, routing::{get, patch, post}, }; @@ -28,9 +26,6 @@ use crate::{ state::AppState, }; -const EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; -const EXTERNAL_EDITOR_VIDEO_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024; - pub fn router(state: AppState) -> Router { Router::new() .route("/api/external/v1/openapi.json", get(openapi_json)) @@ -141,69 +136,42 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/external/v1/editor/images/generations", - post(generate_external_editor_image) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(generate_external_editor_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), ) .route( "/api/external/v1/editor/images/edits", - post(edit_external_editor_image) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(edit_external_editor_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), ) .route( "/api/external/v1/editor/icon-spritesheets/generations", - post(generate_external_editor_icon_spritesheet) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(generate_external_editor_icon_spritesheet).route_layer( + middleware::from_fn_with_state(state.clone(), require_external_api_key), + ), ) .route( "/api/external/v1/editor/ui-designs/assets/extractions", - post(extract_external_editor_ui_design_assets) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(extract_external_editor_ui_design_assets).route_layer( + middleware::from_fn_with_state(state.clone(), require_external_api_key), + ), ) .route( "/api/external/v1/editor/character-animations/generations", - post(generate_external_editor_character_animation) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(generate_external_editor_character_animation).route_layer( + middleware::from_fn_with_state(state.clone(), require_external_api_key), + ), ) .route( "/api/external/v1/editor/videos/generations", - post(generate_external_editor_video) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_VIDEO_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(generate_external_editor_video).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), ) .route( "/api/external/v1/editor/audios/sound-effects/generations", diff --git a/server-rs/crates/api-server/src/modules/play_flow.rs b/server-rs/crates/api-server/src/modules/play_flow.rs index cb05b06a3..371bb24c2 100644 --- a/server-rs/crates/api-server/src/modules/play_flow.rs +++ b/server-rs/crates/api-server/src/modules/play_flow.rs @@ -52,8 +52,6 @@ use crate::{ }; const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024; -const EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; -const EDITOR_VIDEO_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct PlayFlowDomainAdapter { @@ -460,24 +458,17 @@ fn play_flow_support_router(state: AppState) -> Router { ) .route( "/api/editor/character-animations/generations", - post(generate_editor_character_animation) - .layer(DefaultBodyLimit::max( - // 中文注释:画板角色动画首版仍兼容角色图 Data URL 入参,避免大于 Axum 默认 2MB 的角色图在 handler 前被拦截。 - EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(generate_editor_character_animation).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/videos/generations", - post(generate_editor_video) - .layer(DefaultBodyLimit::max(EDITOR_VIDEO_BODY_LIMIT_BYTES)) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(generate_editor_video).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/audios/sound-effects/generations", diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 58e0b3958..27ca2036d 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -1201,7 +1201,7 @@ mod tests { } #[test] - fn editor_video_request_supports_seedance_multimodal_references() { + fn editor_video_request_supports_stable_seedance_multimodal_references() { let payload = serde_json::to_value(EditorVideoGenerateRequest { prompt: "参考素材生成视频".to_string(), model: "seedance2.0-fast".to_string(), @@ -1211,11 +1211,15 @@ mod tests { mode: "std".to_string(), sound: "off".to_string(), web_search_enabled: true, - reference_image_srcs: vec!["data:image/png;base64,image".to_string()], + reference_image_srcs: vec![ + "generated-character-drafts/editor/seedance-references/image/demo.png".to_string(), + ], reference_video_srcs: vec![ "generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(), ], - reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()], + reference_audio_srcs: vec![ + "generated-character-drafts/editor/seedance-references/audio/demo.mp3".to_string(), + ], project_id: None, canvas_completion: None, generation_inputs: None, @@ -1228,7 +1232,7 @@ mod tests { assert_eq!( payload["referenceImageSrcs"], - json!(["data:image/png;base64,image"]) + json!(["generated-character-drafts/editor/seedance-references/image/demo.png"]) ); assert_eq!( payload["referenceVideoSrcs"], @@ -1236,7 +1240,7 @@ mod tests { ); assert_eq!( payload["referenceAudioSrcs"], - json!(["data:audio/mpeg;base64,audio"]) + json!(["generated-character-drafts/editor/seedance-references/audio/demo.mp3"]) ); } @@ -1288,8 +1292,8 @@ mod tests { #[test] fn editor_icon_spritesheet_request_omits_price_and_response_includes_price() { let request_payload = serde_json::to_value(EditorIconSpritesheetGenerateRequest { - reference_image_src: "data:image/png;base64,spec".to_string(), - reference_image_srcs: vec!["data:image/png;base64,style".to_string()], + reference_image_src: "generated-images/editor/spec.png".to_string(), + reference_image_srcs: vec!["generated-images/editor/style.png".to_string()], icon_descriptions: vec!["返回按钮".to_string(), "设置按钮".to_string()], model: Some("gemini-3.1-flash-image-preview".to_string()), aspect_ratio: Some("1:1".to_string()), @@ -1299,16 +1303,16 @@ mod tests { assert_eq!( request_payload["referenceImageSrc"], - json!("data:image/png;base64,spec") + json!("generated-images/editor/spec.png") ); assert_eq!( request_payload["referenceImageSrcs"], - json!(["data:image/png;base64,style"]) + json!(["generated-images/editor/style.png"]) ); assert!(request_payload.get("priceMudPoints").is_none()); let response_payload = serde_json::to_value(EditorIconSpritesheetGenerateResponse { - spritesheet_image_src: "data:image/png;base64,sheet".to_string(), + spritesheet_image_src: "/generated-images/editor/sheet.png".to_string(), spritesheet_width: 512, spritesheet_height: 512, icon_image_srcs: Vec::new(), diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 105528bbd..0d7287103 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -411,7 +411,6 @@ export const SEEDANCE_VIDEO_REFERENCE_LIMITS = { image: 9, video: 3, audio: 3, - requestBytes: 64 * 1024 * 1024, } as const; export const QUICK_EDIT_REFERENCE_LIMIT = 8; export const CHARACTER_ANIMATION_ACTION_PROMPTS = [ diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 4b073e855..2b7fa8485 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -91,11 +91,6 @@ export function resolveImageReferenceSubmissionSource(reference: { return reference.objectKey?.trim() || reference.src; } -function estimateReferenceRequestBytes(references: Array<{ src: string }>) { - // 中文注释:JSON 包装字段还有少量额外字节;这里按 src 字符串长度估算,前后端都会再兜底校验 64MB。 - return references.reduce((sum, reference) => sum + reference.src.length, 0); -} - function buildSeedanceVideoReferenceInput( references: NonNullable, ) { @@ -129,13 +124,6 @@ function buildSeedanceVideoReferenceInput( ) { throw new Error('参考音频必须搭配参考图片或参考视频'); } - if ( - estimateReferenceRequestBytes(references) > - SEEDANCE_VIDEO_REFERENCE_LIMITS.requestBytes - ) { - throw new Error('参考文件总请求体不能超过 64MB'); - } - return { referenceImageSrcs: imageReferences.map(resolveReferenceSource), referenceVideoSrcs: videoReferences.map(resolveReferenceSource), diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index bfcb403dd..441f8d784 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -537,6 +537,31 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments); }); + it('uploads image references without an object reference before generation', async () => { + resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce( + 'data:image/png;base64,ZXhhbXBsZQ==', + ); + const result = await resolveEditorGenerationMediaReference( + { src: '/creation-type-references/example.webp' }, + 'image', + 'project-1', + ); + + expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith( + '/creation-type-references/example.webp', + ); + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect.any(File), + 'image', + expect.objectContaining({ + assetKind: 'editor_generation_reference_image', + }), + ); + expect(result).toBe( + 'generated-character-drafts/editor/generation-references/reference.png', + ); + }); + it('submits quick edits and updates the source layer directly', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index f571fefdf..dc9ce9f12 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -218,20 +218,27 @@ export async function resolveEditorGenerationMediaReference( projectId?: string | null, options: EditorGenerationMediaReferenceOptions = {}, ) { + const resourceId = reference.resourceId?.trim(); + const hasRegisteredReference = + options.allowRegisteredIds !== false && + ((resourceId && + !resourceId.startsWith('local-resource-') && + !resourceId.startsWith('generation-dialog:')) || + reference.sourceAssetId?.trim()); const source = resolveEditorGenerationMediaReferenceSource( reference, options.allowRegisteredIds !== false, ); const inlineSource = /^(?:data|blob):/iu.test(source); - const remoteImageSource = - options.requireImageObjectReference === true && + const imageSourceRequiresUpload = + options.requireImageObjectReference !== false && mediaType === 'image' && !reference.objectKey?.trim() && - /^(?:https?:|\/api\/)/iu.test(source); - if (!inlineSource && !remoteImageSource) { + !hasRegisteredReference; + if (!inlineSource && !imageSourceRequiresUpload) { return source; } - const uploadSource = remoteImageSource + const uploadSource = imageSourceRequiresUpload && !inlineSource ? await resolveEditorImageReferenceDataUrl(source) : source; return uploadEditorGenerationInlineMediaSource( diff --git a/src/services/image-editor/editorImageReference.ts b/src/services/image-editor/editorImageReference.ts index 37d1c6619..67fcc0ce7 100644 --- a/src/services/image-editor/editorImageReference.ts +++ b/src/services/image-editor/editorImageReference.ts @@ -28,9 +28,8 @@ function encodeBytesAsBase64(bytes: Uint8Array) { } /** - * 图片画布的生成编辑接口只接收图片 Data URL。 - * 画布里既有上传 / 生成图,也有内置 public 参考图和历史 generated 路径; - * 提交给后端前统一读取成 Data URL,避免后端误收到普通 URL 后进入 Data URL 校验错误。 + * 将画布图片读取为浏览器临时 Data URL,供压缩、标注等本地处理使用。 + * 生成请求提交前必须再上传 OSS,并把 objectKey 传给后端;Data URL 不属于接口契约。 */ export async function resolveEditorImageReferenceDataUrl( source: string, @@ -121,7 +120,7 @@ export async function compressEditorImageReferenceDataUrlForGeneration( return dataUrl; } - // 中文注释:参考图只用于模型理解视觉方向,提交前压缩可避免原图 Data URL 撑爆 JSON 请求体。 + // 中文注释:参考图只用于模型理解视觉方向,上传 OSS 前压缩可减少传输与存储体积。 if (mimeType === 'image/jpeg') { context.fillStyle = '#fff'; context.fillRect(0, 0, targetWidth, targetHeight); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index ca07f33e6..71ba9169c 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -18,8 +18,8 @@ import { generateEditorVideo, listEditorProjects, listPublicEditorProjectResources, - loadEditorGenerationPricing, loadEditorAssetLibrary, + loadEditorGenerationPricing, loadEditorProject, loadOrCreateRecentEditorProject, removeEditorImageBackground, @@ -774,6 +774,47 @@ describe('editorProjectClient', () => { ); }); + it('rejects inline media before submitting editor generation requests', async () => { + await expect( + generateEditorImage({ + prompt: '一张画布图片', + referenceImageSrcs: ['data:image/png;base64,inline'], + }), + ).rejects.toThrow('必须先上传 OSS'); + + await expect( + generateEditorVideo({ + prompt: '生成视频', + model: 'seedance2.0-fast', + aspectRatio: '16:9', + durationSeconds: 5, + resolution: '720p', + mode: 'std', + sound: 'off', + referenceVideoSrcs: ['blob:local-video'], + }), + ).rejects.toThrow('必须先上传 OSS'); + + expect(requestJsonMock).not.toHaveBeenCalled(); + }); + + it('rejects oversized stable video reference fields before submission', async () => { + await expect( + generateEditorVideo({ + prompt: '生成视频', + model: 'seedance2.0-fast', + aspectRatio: '16:9', + durationSeconds: 5, + resolution: '720p', + mode: 'std', + sound: 'off', + referenceVideoSrcs: [`https://assets.example.test/${'a'.repeat(256 * 1024)}`], + }), + ).rejects.toThrow('稳定引用字段总长度不能超过 256KB'); + + expect(requestJsonMock).not.toHaveBeenCalled(); + }); + it('passes image model options to editor image generation', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,character', @@ -919,8 +960,8 @@ describe('editorProjectClient', () => { }); const result = await generateEditorIconSpritesheet({ - referenceImageSrc: 'data:image/png;base64,spec', - referenceImageSrcs: ['data:image/png;base64,icon-ref'], + referenceImageSrc: '/generated-images/editor/spec.png', + referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], assetLabel: '冒险游戏图标', }); @@ -936,8 +977,8 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - referenceImageSrc: 'data:image/png;base64,spec', - referenceImageSrcs: ['data:image/png;base64,icon-ref'], + referenceImageSrc: '/generated-images/editor/spec.png', + referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], model: 'gemini-3.1-flash-image-preview', assetLabel: '冒险游戏图标', @@ -965,7 +1006,7 @@ describe('editorProjectClient', () => { }); await generateEditorIconSpritesheet({ - referenceImageSrc: 'data:image/png;base64,spec', + referenceImageSrc: '/generated-images/editor/spec.png', iconDescriptions: ['返回按钮'], model: 'gpt-image-2', screenColor: '#E6D8FF', @@ -986,7 +1027,7 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - referenceImageSrc: 'data:image/png;base64,spec', + referenceImageSrc: '/generated-images/editor/spec.png', iconDescriptions: ['返回按钮'], model: 'gpt-image-2', screenColor: '#E6D8FF', @@ -1105,11 +1146,11 @@ describe('editorProjectClient', () => { }); const result = await extractEditorUiDesignAssets({ - sourceImageSrc: 'data:image/png;base64,ui-design', + sourceImageSrc: '/generated-images/editor/ui-design.png', model: 'gpt-image-2', screenColor: '#7FB3FF', segModel: 'anime-seg', - referenceImageSrcs: ['data:image/png;base64,ui-ref'], + referenceImageSrcs: ['/generated-images/editor/ui-ref.png'], projectId: 'editor-project-1', assetFolderId: 'project', spritesheetLabel: '战斗UI设计图 素材图集', @@ -1128,11 +1169,11 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - sourceImageSrc: 'data:image/png;base64,ui-design', + sourceImageSrc: '/generated-images/editor/ui-design.png', model: 'gpt-image-2', screenColor: '#7FB3FF', segModel: 'anime-seg', - referenceImageSrcs: ['data:image/png;base64,ui-ref'], + referenceImageSrcs: ['/generated-images/editor/ui-ref.png'], projectId: 'editor-project-1', generationInputs: { fields: [{ title: '来源', value: '战斗UI设计图' }], @@ -1212,7 +1253,7 @@ describe('editorProjectClient', () => { kind: 'publication-material', aspectRatio: '9:16', imageSize: '1K', - referenceImageSrcs: ['data:image/png;base64,ref'], + referenceImageSrcs: ['/generated-images/editor/publication-ref.png'], }); expect(result.height).toBe(1280); @@ -1227,7 +1268,7 @@ describe('editorProjectClient', () => { kind: 'publication-material', aspectRatio: '9:16', imageSize: '1K', - referenceImageSrcs: ['data:image/png;base64,ref'], + referenceImageSrcs: ['/generated-images/editor/publication-ref.png'], }), }), '生成图片失败', @@ -1259,7 +1300,7 @@ describe('editorProjectClient', () => { const result = await generateEditorCharacterAnimation({ sourceLayerId: 'layer-character', - sourceImageSrc: 'data:image/png;base64,character', + sourceImageSrc: '/generated-images/editor/character.png', sourceWidth: 1024, sourceHeight: 1024, promptText: '待机', @@ -1279,7 +1320,7 @@ describe('editorProjectClient', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceLayerId: 'layer-character', - sourceImageSrc: 'data:image/png;base64,character', + sourceImageSrc: '/generated-images/editor/character.png', sourceWidth: 1024, sourceHeight: 1024, promptText: '待机', @@ -1395,7 +1436,7 @@ describe('editorProjectClient', () => { resolution: '720p', mode: 'std', sound: 'off', - referenceImageSrcs: ['data:image/png;base64,image'], + referenceImageSrcs: ['/generated-images/editor/video-reference.png'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', ], @@ -1418,7 +1459,7 @@ describe('editorProjectClient', () => { mode: 'std', sound: 'off', webSearchEnabled: true, - referenceImageSrcs: ['data:image/png;base64,image'], + referenceImageSrcs: ['/generated-images/editor/video-reference.png'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', ], @@ -1578,12 +1619,12 @@ describe('editorProjectClient', () => { const result = await editEditorImage({ prompt: '把画面改成黄昏光线', - sourceImageSrc: 'data:image/png;base64,source', + sourceImageSrc: '/generated-images/editor/source.png', size: '1024x1024', model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', - referenceImageSrcs: ['data:image/png;base64,style'], + referenceImageSrcs: ['/generated-images/editor/style.png'], projectId: 'editor-project-1', assetKind: 'character', assetFolderId: 'project', @@ -1604,12 +1645,12 @@ describe('editorProjectClient', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '把画面改成黄昏光线', - sourceImageSrc: 'data:image/png;base64,source', + sourceImageSrc: '/generated-images/editor/source.png', size: '1024x1024', model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', - referenceImageSrcs: ['data:image/png;base64,style'], + referenceImageSrcs: ['/generated-images/editor/style.png'], projectId: 'editor-project-1', assetKind: 'character', generationInputs: { @@ -1643,7 +1684,7 @@ describe('editorProjectClient', () => { }); const response = await removeEditorImageBackground({ - sourceImageSrc: 'data:image/png;base64,source', + sourceImageSrc: '/generated-images/editor/source.png', projectId: 'editor-project-1', targetLayerId: 'layer-source', canvasCompletion: { @@ -1668,7 +1709,7 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - sourceImageSrc: 'data:image/png;base64,source', + sourceImageSrc: '/generated-images/editor/source.png', projectId: 'editor-project-1', targetLayerId: 'layer-source', canvasCompletion: { diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 58120cd19..93a5c90ed 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -1,5 +1,5 @@ -import { requestJson } from '../apiClient'; import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration'; +import { requestJson } from '../apiClient'; import { EDITOR_REQUEST_RETRY_OPTIONS } from './editorRetryOptions'; const EDITOR_PROJECT_API_BASE = '/api/editor/projects'; @@ -25,8 +25,38 @@ const EDITOR_BACKGROUND_MUSIC_GENERATION_API = '/api/editor/audios/background-music/generations'; const EDITOR_GENERATION_PRICING_API = '/api/editor/generation-pricing'; const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; +const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES = 256 * 1024; const DEFAULT_PROJECT_TITLE = '未命名画布'; +function assertStableEditorMediaReference(value: string, fieldLabel: string) { + const normalized = value.trimStart().toLowerCase(); + if (normalized.startsWith('data:') || normalized.startsWith('blob:')) { + throw new Error( + `${fieldLabel}必须先上传 OSS,并使用 objectKey、项目资源、素材 ID 或支持的 URL 引用。`, + ); + } +} + +function assertStableEditorMediaReferences( + values: readonly string[] | undefined, + fieldLabel: string, +) { + values?.forEach((value) => assertStableEditorMediaReference(value, fieldLabel)); +} + +function assertStableEditorVideoReferences(input: EditorVideoGenerationInput) { + const references = [ + ...(input.referenceImageSrcs ?? []), + ...(input.referenceVideoSrcs ?? []), + ...(input.referenceAudioSrcs ?? []), + ]; + assertStableEditorMediaReferences(references, '视频参考素材'); + const requestBytes = new TextEncoder().encode(references.join('')).byteLength; + if (requestBytes > EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES) { + throw new Error('视频参考素材稳定引用字段总长度不能超过 256KB'); + } +} + export type EditorCanvasViewport = { x: number; y: number; @@ -874,6 +904,7 @@ export async function toggleEditorShowcaseAssetLike( } export async function generateEditorImage(input: EditorImageGenerationInput) { + assertStableEditorMediaReferences(input.referenceImageSrcs, '生成参考图'); return requestJson( EDITOR_IMAGE_GENERATION_API, jsonRequest('POST', { @@ -913,6 +944,11 @@ export async function generateEditorImage(input: EditorImageGenerationInput) { export async function generateEditorIconSpritesheet( input: EditorIconSpritesheetGenerationInput, ) { + assertStableEditorMediaReference(input.referenceImageSrc, '图标素材规范'); + assertStableEditorMediaReferences( + input.referenceImageSrcs, + '图标素材参考图', + ); return requestJson( EDITOR_ICON_SPRITESHEET_GENERATION_API, jsonRequest('POST', { @@ -962,6 +998,8 @@ export async function splitEditorIconSpritesheet( export async function extractEditorUiDesignAssets( input: EditorUiDesignAssetExtractionInput, ) { + assertStableEditorMediaReference(input.sourceImageSrc, 'UI设计图'); + assertStableEditorMediaReferences(input.referenceImageSrcs, 'UI素材参考图'); return requestJson( EDITOR_UI_DESIGN_ASSET_EXTRACTION_API, jsonRequest('POST', { @@ -995,6 +1033,8 @@ export async function extractEditorUiDesignAssets( } export async function editEditorImage(input: EditorImageEditInput) { + assertStableEditorMediaReference(input.sourceImageSrc, '待修改图片'); + assertStableEditorMediaReferences(input.referenceImageSrcs, '修改参考图'); return requestJson( EDITOR_IMAGE_EDIT_API, jsonRequest('POST', { @@ -1033,6 +1073,7 @@ export async function editEditorImage(input: EditorImageEditInput) { export async function removeEditorImageBackground( input: EditorBackgroundRemovalInput, ) { + assertStableEditorMediaReference(input.sourceImageSrc, '待去除背景图片'); return requestJson( EDITOR_BACKGROUND_REMOVAL_API, jsonRequest('POST', { @@ -1064,6 +1105,7 @@ export async function removeEditorImageBackground( export async function generateEditorCharacterAnimation( input: EditorCharacterAnimationGenerationInput, ) { + assertStableEditorMediaReference(input.sourceImageSrc, '角色动画源图'); return requestJson( EDITOR_CHARACTER_ANIMATION_GENERATION_API, jsonRequest('POST', { @@ -1088,6 +1130,7 @@ export async function generateEditorCharacterAnimation( } export async function generateEditorVideo(input: EditorVideoGenerationInput) { + assertStableEditorVideoReferences(input); return requestJson( EDITOR_VIDEO_GENERATION_API, jsonRequest('POST', {