Initial TrueGrowth source import
This commit is contained in:
672
docs/IMAGE_GENERATION_API_PARAMS_LESSONS.md
Normal file
672
docs/IMAGE_GENERATION_API_PARAMS_LESSONS.md
Normal file
@@ -0,0 +1,672 @@
|
||||
# 图片生成 API 参数兼容性与比例转换修复经验
|
||||
|
||||
## 问题概述
|
||||
|
||||
本次修复涉及三个关联问题,全部为项目预存代码问题,非近期修改引入:
|
||||
|
||||
1. **rix API 400 错误**:`response_format` 参数不被 rix API 支持,导致图片生成失败
|
||||
2. **非 1:1 比例全部生成 1:1**:用户选择 16:9、4:3 等比例时,API 实际生成正方形图片
|
||||
3. **图片插入画布后强制 1:1 显示**:`foreignObject` 容器始终 400x400,竖版长图被压缩
|
||||
|
||||
---
|
||||
|
||||
## 一、架构背景
|
||||
|
||||
### 请求路由架构
|
||||
|
||||
```
|
||||
用户输入 → AI Input Parser → Workflow Engine → Image Generation Service
|
||||
↓
|
||||
executorParams (size/quality/...)
|
||||
↓
|
||||
Fallback Executor
|
||||
↙ ↘
|
||||
buildImageRequestBody resolveAdapterForInvocation
|
||||
(通用请求体构建) (专用适配器路由)
|
||||
↓ ↓
|
||||
providerTransport.send gpt-image-adapter.ts
|
||||
→ 直接 fetch 到 API → 专项处理 request body
|
||||
```
|
||||
|
||||
### 关键设计点
|
||||
|
||||
- **`buildImageRequestBody`** 是统一的请求体构建函数,被 `fallback-executor.ts` 和 `generateImageSync` 两个路径调用
|
||||
- **适配器层**(`gpt-image-adapter.ts`、`tuzi-gpt-image-adapter.ts`)有独立的 `response_format` 处理逻辑,只在使用专属适配器时生效
|
||||
- **提供商路由**(`provider-transport.ts`)直接将请求 fetch 到外部 API 服务商,不做参数转换
|
||||
|
||||
---
|
||||
|
||||
## 二、问题 1:`response_format` 硬编码导致 rix API 报错
|
||||
|
||||
### 错误日志
|
||||
|
||||
```
|
||||
[TaskQueueService] Task execution failed: Error: Image generation failed: 400 -
|
||||
{"error":{"message":"Unknown parameter: 'response_format'.",
|
||||
"type":"rix_api_error","param":"response_format","code":"unknown_parameter"}}
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
`buildImageRequestBody` 无条件向请求体中添加了 `response_format: 'url'`:
|
||||
|
||||
```typescript
|
||||
// image-api.ts L54-61(修复前)
|
||||
export function buildImageRequestBody(params: ImageGenerationParams) {
|
||||
const body: Record<string, unknown> = {
|
||||
prompt: params.prompt,
|
||||
model: params.model,
|
||||
response_format: 'url', // ← 硬编码,rix API 不支持
|
||||
};
|
||||
```
|
||||
|
||||
该参数是 OpenAI 官方 API 的专有参数。当 `provider-transport.ts` 将请求路由到 rix API 提供商时,服务端不识别此参数,返回 400。
|
||||
|
||||
### 修复
|
||||
|
||||
```typescript
|
||||
// image-api.ts L54-60(修复后)
|
||||
export function buildImageRequestBody(params: ImageGenerationParams) {
|
||||
const body: Record<string, unknown> = {
|
||||
prompt: params.prompt,
|
||||
model: params.model,
|
||||
};
|
||||
// response_format 已移除,由各适配器自行处理
|
||||
```
|
||||
|
||||
### 影响范围审计
|
||||
|
||||
| 代码路径 | 是否受影响 | 说明 |
|
||||
|---------|-----------|------|
|
||||
| `fallback-executor.ts` L267 → `buildImageRequestBody` | 已修复 | 不再发送 `response_format` |
|
||||
| `image-api.ts` L151 → `generateImageSync` → `buildImageRequestBody` | 已修复 | 不再发送 `response_format` |
|
||||
| `gpt-image-adapter.ts` L176-179 | **不受影响** | 有自己的 `getGPTImageResponseFormat` 处理,只在用户显式传入时才添加 |
|
||||
| `tuzi-gpt-image-adapter.ts` | **不受影响** | 继承 GPT Image 适配器逻辑 |
|
||||
| `image-api.ts` L35-48 `normalizeImageResultUrl` | **不受影响** | 同时支持 `url` 和 `b64_json` 两种返回格式 |
|
||||
|
||||
### 风险评估
|
||||
|
||||
**零风险**。原因:
|
||||
|
||||
1. OpenAI API 的 `response_format` **默认值就是 `'url'`**,不传等效于传 `'url'`
|
||||
2. `parseImageResponse` + `normalizeImageResultUrl` 兼容 `url` 和 `b64_json` 两种格式
|
||||
3. GPT Image 适配器有自己的 `response_format` 处理,不依赖 `buildImageRequestBody`
|
||||
|
||||
---
|
||||
|
||||
## 三、问题 2:非 1:1 比例全部生成 1:1
|
||||
|
||||
### 现象
|
||||
|
||||
用户选择 16:9,但 API 生成 816x816 正方形图片,下方内容空白。下载后图片确实是正方形。
|
||||
|
||||
### 根因分析:参数链路断裂
|
||||
|
||||
```
|
||||
用户选 16:9
|
||||
→ normalizeSize('16:9') → '16x9'
|
||||
→ ParsedGenerationParams.size = '16x9'
|
||||
→ step.args.size = '16x9'
|
||||
→ workflow engine: options.size = '16x9'
|
||||
→ image-generation-service: executorParams.size = '16x9'
|
||||
→ fallback-executor: params.size = '16x9'
|
||||
→ buildImageRequestBody({ size: '16x9' })
|
||||
→ body.size = '16x9' ← BUG:比例字符串当作像素尺寸
|
||||
→ API 收到 size: '16x9' ← 无效像素尺寸,回退默认 1:1
|
||||
```
|
||||
|
||||
`buildImageRequestBody` 的逻辑缺陷:
|
||||
|
||||
```typescript
|
||||
// 修复前
|
||||
if (params.size) {
|
||||
body.size = params.size; // 直接透传,无论内容是比例还是像素
|
||||
} else if (params.aspectRatio) {
|
||||
body.size = aspectRatioToSize(params.aspectRatio); // 转换只在这个分支
|
||||
}
|
||||
```
|
||||
|
||||
上游链路将比例格式(`'16x9'`)放进 `size` 字段,导致 `aspectRatioToSize` 转换分支被跳过。
|
||||
|
||||
### 全部受影响比例
|
||||
|
||||
| 用户选择 | 传入 size | 期望尺寸 | 修复前实际 |
|
||||
|---------|----------|---------|-----------|
|
||||
| 16:9 | `16x9` | `1792x1024` | 1:1 |
|
||||
| 9:16 | `9x16` | `1024x1792` | 1:1 |
|
||||
| 4:3 | `4x3` | `1536x1152` | 1:1 |
|
||||
| 3:4 | `3x4` | `1152x1536` | 1:1 |
|
||||
| 3:2 | `3x2` | `1536x1024` | 1:1 |
|
||||
| 2:3 | `2x3` | `1024x1536` | 1:1 |
|
||||
| 4:5 | `4x5` | `1024x1280` | 1:1 |
|
||||
| 5:4 | `5x4` | `1280x1024` | 1:1 |
|
||||
| 21:9 | `21x9` | `1792x768` | 1:1 |
|
||||
| 1:4 | `1x4` | `512x2048` | 1:1 |
|
||||
| 4:1 | `4x1` | `2048x512` | 1:1 |
|
||||
|
||||
仅 `1:1` → `1x1` → `1024x1024` 碰巧正常。
|
||||
|
||||
### 修复
|
||||
|
||||
```typescript
|
||||
// image-api.ts L66-69(修复后)
|
||||
if (params.size) {
|
||||
body.size = aspectRatioToSize(params.size) || params.size;
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
// 先尝试比例→像素转换,失败则透传(认为是像素尺寸)
|
||||
} else if (params.aspectRatio) {
|
||||
body.size = aspectRatioToSize(params.aspectRatio);
|
||||
}
|
||||
```
|
||||
|
||||
### `aspectRatioToSize` 安全分析
|
||||
|
||||
| 输入 | 查表结果 | 最终输出 | 正确? |
|
||||
|------|---------|---------|--------|
|
||||
| `'16x9'` | `'1792x1024'` | `'1792x1024'` | ✓ |
|
||||
| `'1024x1024'` | `undefined` | `'1024x1024'` | ✓ |
|
||||
| `'1920x1080'` | `undefined` | `'1920x1080'` | ✓ |
|
||||
| `undefined` | 函数短路返回 `undefined` | 不进入 if 块 | ✓ |
|
||||
| `'auto'` | 函数短路返回 `undefined` | 不进入 if 块 | ✓ |
|
||||
|
||||
### 风险评估
|
||||
|
||||
**零风险**。`||` 运算符确保任何无法转换的值都会透传原值。
|
||||
|
||||
### 跨 AI 提供商兼容性分析
|
||||
|
||||
核心问题:OpenAI 需要像素尺寸(如 `1792x1024`),其他 AI 可能不同,修改后会不会影响其他 AI?
|
||||
|
||||
**答案:不会。** 反过来,修改**前**发送的 `'16x9'` 才是对所有 API 都无效的值。
|
||||
|
||||
#### 三种 `size` 格式与各 API 接受情况
|
||||
|
||||
| 格式类型 | 示例 | 接受的 API |
|
||||
|---------|------|-----------|
|
||||
| 像素尺寸 | `1792x1024`、`1024x1024` | OpenAI、Gemini、Flux、Midjourney、rix 等几乎所有 API |
|
||||
| 比例字符串(冒号) | `16:9`、`4:3` | 少数国产模型 API |
|
||||
| 混合格式(x 分隔) | `16x9`、`4x3` | **没有 API 接受这种格式** |
|
||||
|
||||
#### 修改前后对比
|
||||
|
||||
| 阶段 | 用户选择 | 发送给 API | API 行为 |
|
||||
|------|---------|-----------|---------|
|
||||
| 修改前 | 16:9 | `'16x9'`(无效格式) | 任何 API 都不认,统一回退 1:1 |
|
||||
| 修改后 | 16:9 | `'1792x1024'`(标准像素) | 所有 API 都支持 |
|
||||
|
||||
#### 为什么修改前不是发送 `'16:9'` 而是 `'16x9'`?
|
||||
|
||||
链路中有一步 `normalizeSize` 将用户输入的 `'16:9'` 转为 `'16x9'`,这一步本身没有问题(统一格式)。但 `buildImageRequestBody` 收到 `'16x9'` 后直接透传给 API,没有调用 `aspectRatioToSize` 将其转为 `'1792x1024'`。`'16x9'` 这个格式对任何 API 都不是合法参数——它既不是像素尺寸,也不是标准比例格式。
|
||||
|
||||
#### 原始设计意图本身就是转换像素
|
||||
|
||||
原代码中 `aspectRatio` 分支已经正确地调用了 `aspectRatioToSize`:
|
||||
|
||||
```typescript
|
||||
} else if (params.aspectRatio) {
|
||||
body.size = aspectRatioToSize(params.aspectRatio); // 转换为像素尺寸
|
||||
}
|
||||
```
|
||||
|
||||
说明原始设计的意图就是**发送像素尺寸给 API**。问题只是上游把比例字符串错放进 `size` 字段,跳过了 `aspectRatio` 这个转换分支。本次修复只是**把被跳过的转换补回来**,并没有改变语义——最终发送的仍然是像素尺寸。
|
||||
|
||||
**结论**:修改前发送的是无效值(所有 API 都不认),修改后发送的是标准像素尺寸(所有 API 都认)。不存在跨提供商兼容性问题。
|
||||
|
||||
---
|
||||
|
||||
## 四、问题 3:图片插入画布后强制 1:1 显示
|
||||
|
||||
### 现象
|
||||
|
||||
`foreignObject` 容器始终 400x400,非正方形图片被压缩。图片加载瞬间显示正确比例,随后缩为 1:1。
|
||||
|
||||
### 根因
|
||||
|
||||
`insertImageFromUrl` 的 `lockReferenceDimensions` 参数为 `true` 时,跳过图片加载后的尺寸更新:
|
||||
|
||||
```typescript
|
||||
// image.ts L399
|
||||
shouldUpdateSizeAfterLoad = !lockReferenceDimensions; // true → false,不更新
|
||||
```
|
||||
|
||||
### 修复
|
||||
|
||||
将所有自动插入场景的 `lockReferenceDimensions` 改为 `false`:
|
||||
|
||||
| 文件 | 行号 | 场景 | 修改 |
|
||||
|------|------|------|------|
|
||||
| `canvas-insertion.ts` | 226 | Services 批量插入 | `true` → `false` |
|
||||
| `handler.ts` | 231, 311 | SW 自动插入 | 明确 `false` |
|
||||
| `mcp/tools/canvas-insertion.ts` | 225 | MCP 协议插入 | `true` → `false` |
|
||||
|
||||
### 全部插入路径审计
|
||||
|
||||
```
|
||||
insertImageFromUrl 调用者 17 处
|
||||
│
|
||||
├── 自动插入场景(lockReferenceDimensions=false)
|
||||
│ ├── canvas-insertion.ts:226 ✓ false
|
||||
│ ├── handler.ts:231 ✓ false
|
||||
│ ├── handler.ts:311 ✓ false
|
||||
│ ├── mcp/tools/canvas-insertion.ts:225 ✓ false(本次补修)
|
||||
│ └── media-quick-insert.ts:67 ✓ undefined(等同 false)
|
||||
│
|
||||
├── 用户手动插入场景(lockReferenceDimensions 不传/undefined)
|
||||
│ ├── useWorkflowSubmission.ts:103 ✓ undefined(图片尺寸已知)
|
||||
│ ├── useWorkflowSubmission.ts:112 ✓ undefined
|
||||
│ ├── drawnix.tsx:1307 ✓ undefined(传入 naturalWidth/Height)
|
||||
│ ├── popup-toolbar.tsx:2551 ✓ undefined(传入 naturalWidth/Height)
|
||||
│ ├── quick-creation-toolbar.tsx:211 ✓ 不传
|
||||
│ ├── creation-toolbar.tsx:295 ✓ 不传
|
||||
│ ├── MediaLibraryGrid.tsx:1280 ✓ 不传
|
||||
│ ├── MediaLibraryGrid.tsx:1324 ✓ 不传
|
||||
│ ├── VideoAnalyzer.tsx:122 ✓ 不传
|
||||
│ ├── TaskQueuePanel.tsx:607 ✓ 不传
|
||||
│ ├── TaskQueuePanel.tsx:974 ✓ 不传
|
||||
│ └── DialogTaskList.tsx:229 ✓ 不传
|
||||
```
|
||||
|
||||
**说明**:手动插入场景传入的是图片 `naturalWidth`/`naturalHeight`(已加载的真实尺寸),无需 `lockReferenceDimensions=false` 也能正确显示。`lockReferenceDimensions` 主要影响使用固定参考尺寸(400x400)的自动插入场景。
|
||||
|
||||
### 尺寸更新机制
|
||||
|
||||
`updateImageSizeAfterLoad`([image.ts#L522-590](file:///d:/工作/opentu_new/packages/drawnix/src/data/image.ts#L522-L590)):
|
||||
|
||||
1. 异步加载图片获取 `naturalWidth`/`naturalHeight`
|
||||
2. 计算实际宽高比与参考宽高比差异(< 1% 跳过)
|
||||
3. 以参考宽度为基准重新计算高度
|
||||
4. 通过 `Transforms.setNode` 更新元素 `points`(影响 `foreignObject` 尺寸)
|
||||
|
||||
### 风险评估
|
||||
|
||||
**低风险**。影响仅限画布渲染层:
|
||||
- 不影响 API 调用、图片生成流程
|
||||
- 初始 400x400 是暂时的,图片加载后异步更新
|
||||
- 极端网络条件下 `updateImageSizeAfterLoad` 可能加载失败(已有 `.catch` 保护),自动降级为参考尺寸
|
||||
|
||||
---
|
||||
|
||||
## 四-B、问题 4:`updateImageSizeAfterLoad` 尺寸限制导致图片截断
|
||||
|
||||
### 现象
|
||||
|
||||
竖版长图(9:16)实际生成 1024x1792,但插入画布后 `foreignObject` 容器仅为 400x400 或 400x711(被参考尺寸限制),导致图片被截断或显示不完整。
|
||||
|
||||
### 根因
|
||||
|
||||
`updateImageSizeAfterLoad` 的旧实现将尺寸限制在 `referenceDimensions` 范围内:
|
||||
|
||||
```typescript
|
||||
// 修改前(image.ts L552-561)
|
||||
let newWidth = referenceDimensions.width;
|
||||
let newHeight = newWidth / imageAspectRatio;
|
||||
|
||||
// 如果新高度超过预设高度,则以高度为基准
|
||||
if (newHeight > referenceDimensions.height) {
|
||||
newHeight = referenceDimensions.height;
|
||||
newWidth = newHeight * imageAspectRatio;
|
||||
}
|
||||
```
|
||||
|
||||
问题:图片真实尺寸大于参考尺寸时被强制压缩。例如:
|
||||
- API 返回 1024×1792 图片
|
||||
- 插入时 `referenceDimensions` = 400×400
|
||||
- 旧逻辑:`newWidth = 400`, `newHeight = 400 / (1024/1792) ≈ 700`,但 `700 > 400` → 被限制为 `400×400`
|
||||
|
||||
### 修复
|
||||
|
||||
直接使用图片的 `naturalWidth`/`naturalHeight`,仅设置上限 2048px 防止超大图片:
|
||||
|
||||
```typescript
|
||||
// 修改后(image.ts L538-554)
|
||||
const MAX_IMAGE_WIDTH = 2048;
|
||||
const MAX_IMAGE_HEIGHT = 2048;
|
||||
|
||||
let newWidth = naturalWidth;
|
||||
let newHeight = naturalHeight;
|
||||
|
||||
if (newWidth > MAX_IMAGE_WIDTH) {
|
||||
const scale = MAX_IMAGE_WIDTH / newWidth;
|
||||
newWidth = MAX_IMAGE_WIDTH;
|
||||
newHeight = Math.round(newHeight * scale);
|
||||
}
|
||||
if (newHeight > MAX_IMAGE_HEIGHT) {
|
||||
const scale = MAX_IMAGE_HEIGHT / newHeight;
|
||||
newHeight = MAX_IMAGE_HEIGHT;
|
||||
newWidth = Math.round(newWidth * scale);
|
||||
}
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
|
||||
**低风险**。该函数已充分考虑各种边界情况,具备完善的容错机制:
|
||||
|
||||
1. **元素已被删除的保护**:`updateImageSizeAfterLoad` 是异步执行的(等待图片加载),用户可能在此期间删除元素。代码通过 `findIndex` 检查元素是否存在,不存在则直接返回,不会报错。
|
||||
|
||||
2. **用户手动移动元素的保护**:用户可能在图片加载过程中拖动元素到新位置。代码使用 `currentTopLeft` 获取当前位置,只调整右下角坐标,不会覆盖用户手动调整的位置。
|
||||
|
||||
3. **图片加载失败的保护**:网络问题或链接失效可能导致加载失败。代码使用 `.catch()` 捕获异常,仅打印警告日志,不抛出异常影响其他功能。
|
||||
|
||||
4. **超大图片的保护**:API 可能返回超大尺寸图片(如 4096×4096)。代码设置 2048px 上限,自动按比例缩小,避免内存占用过高。
|
||||
|
||||
5. **无效尺寸的保护**:如果图片尺寸为 0 或负数,代码在开头就会检测并返回,不会执行无效的更新操作。
|
||||
|
||||
6. **触发条件控制**:该函数仅在 `skipImageLoad=true && lockReferenceDimensions=false` 时触发,避免不必要的重复调用。
|
||||
|
||||
---
|
||||
|
||||
## 四-C、问题 5:`getTaskImageDimensions` 忽略任务返回的真实尺寸
|
||||
|
||||
### 现象
|
||||
|
||||
任务执行后 `task.result` 包含真实图片尺寸(`width`/`height`),但插入时使用的仍是 `parseSizeToPixels(task.params.size)` 返回的估计值,而非真实值。
|
||||
|
||||
### 根因
|
||||
|
||||
`getTaskImageDimensions` 的优先级顺序不当——`fallback` 参数永远有值(来自 `parseSizeToPixels`),导致 `task.result` 的检查被跳过:
|
||||
|
||||
```typescript
|
||||
// 修改前
|
||||
if (fallback) {
|
||||
return fallback; // ← 直接返回,跳过 task.result
|
||||
}
|
||||
const result = task.result...; // ← 永远执行不到
|
||||
```
|
||||
|
||||
### 修复
|
||||
|
||||
将 `task.result` 的真实尺寸提到最优先:
|
||||
|
||||
```typescript
|
||||
// 修改后(useAutoInsertToCanvas.ts L248-267)
|
||||
// 优先使用任务返回的真实尺寸
|
||||
const result = task.result as { width?: number; height?: number } | undefined;
|
||||
if (result?.width > 0 && result?.height > 0) {
|
||||
return { width: result.width, height: result.height };
|
||||
}
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
return parseSizeToPixels(task.params.size);
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
|
||||
**零风险**。
|
||||
- `task.result` 无有效尺寸时降级到 `fallback`,行为不变 ✓
|
||||
- 两个调用点(L683、L1070)的 fallback 均来自 `parseSizeToPixels`,语义一致 ✓
|
||||
- 下游 `insertedSize`/`syncImageAnchorGeometry` 拿到更准确的尺寸,无副作用 ✓
|
||||
|
||||
---
|
||||
|
||||
## 四-D、问题 6:素材库批量插入时图片叠加
|
||||
|
||||
### 现象
|
||||
|
||||
从素材库批量插入多张不同比例的图片到画布时,图片彼此堆叠覆盖,而非分开排列。
|
||||
|
||||
### 根因:尺寸链路不同步
|
||||
|
||||
```
|
||||
批量插入
|
||||
→ estimateInsertionItemSize: 无 dimensions → 默认 400×400
|
||||
→ advanceBatchInsertionFlow: 按 400×400 估算间距排位
|
||||
→ insertImageToCanvas: 用 400×400 插入
|
||||
→ updateImageSizeAfterLoad (异步): 扩展为真实尺寸 (如 1024×1792)
|
||||
→ 真实尺寸 >> 排位间距 → 图片叠加
|
||||
```
|
||||
|
||||
核心矛盾:排位系统以 400×400 为基准计算间距,但图片最终显示为真实尺寸(修复后),导致超出分配空间。
|
||||
|
||||
### 修复
|
||||
|
||||
在批量插入前预加载每张素材图片的真实尺寸,传给 `executeCanvasInsertion` 的 `item.dimensions`:
|
||||
|
||||
```typescript
|
||||
// quick-creation-toolbar.tsx L238-280
|
||||
const loadImageDimensions = (url: string) =>
|
||||
new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
img.onerror = () => resolve({ width: 400, height: 400 }); // 降级
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
// 并行预加载所有图片尺寸
|
||||
const imageDimensionsMap = new Map();
|
||||
const results = await Promise.all(imageAssets.map(a => loadImageDimensions(a.url)));
|
||||
// 传给 executeCanvasInsertion 的 item.dimensions
|
||||
```
|
||||
|
||||
### 效果链路
|
||||
|
||||
```
|
||||
预加载真实尺寸 (1024×1792)
|
||||
→ estimateInsertionItemSize: 使用真实尺寸
|
||||
→ advanceBatchInsertionFlow: 按真实间距排位 → 互不重叠 ✓
|
||||
→ insertImageToCanvas: 用真实尺寸插入
|
||||
→ updateImageSizeAfterLoad: 尺寸已匹配 → 几乎无变化 ✓
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
|
||||
**零风险**。
|
||||
- 失败降级:`img.onerror` → 400×400,不阻塞 ✓
|
||||
- 性能:本地缓存图片,`Promise.all` 并行加载,几乎无延迟 ✓
|
||||
- 影响范围:**仅素材库「批量插入画布」这一个入口**,其他插入路径完全不变 ✓
|
||||
- 视频/音频:`loadImageDimensions` 仅处理 `AssetType.IMAGE`,不触发 ✓
|
||||
- `dimensions` 为可选字段,不传时行为不变 ✓
|
||||
|
||||
---
|
||||
|
||||
## 四-E、问题 7:批量插入流式布局导致不同尺寸素材间距不均 / 可能叠加
|
||||
|
||||
### 现象
|
||||
|
||||
素材库批量插入多张不同比例图片(如 9:16 竖版 + 16:9 横版)时:
|
||||
- **间距不均**:同行中高素材(竖版)与矮素材(横版)顶部对齐,形成锯齿状布局
|
||||
- **潜在叠加**:当某些素材的 `updateImageSizeAfterLoad` 异步扩展尺寸后,可能侵入相邻素材的空间
|
||||
|
||||
### 根因
|
||||
|
||||
旧的 `advanceBatchInsertionFlow` 是**逐项流式布局**(Flow Layout):
|
||||
|
||||
```
|
||||
流式布局过程(迭代调用):
|
||||
第1项: 放入 (100, 100), cursorX → 520, rowMaxHeight = 500
|
||||
第2项: 放入 (520, 100), cursorX → 820, rowMaxHeight = 500(同行,顶部对齐)
|
||||
第3项: 放入 (820, 100), cursorX → 1120 > rowRightLimit → 换行
|
||||
第4项: 放入 (100, 650), cursorX → 520(新行从上一行 rowMaxHeight 开始)
|
||||
```
|
||||
|
||||
**问题**:
|
||||
1. 布局是基于"顺序迭代"而非"全局规划",无法预先知道所有素材的尺寸分布
|
||||
2. 同行素材仅顶部对齐,高度不同导致底部参差不齐
|
||||
3. 行高由当前行最高素材决定后固定,后续无法调整
|
||||
4. 单张插入后才异步更新为真实尺寸(`updateImageSizeAfterLoad`),容易超出流式布局预留空间
|
||||
|
||||
### 修复
|
||||
|
||||
新增 `precalculateGridLayout` 函数,采用**网格预计算**(Grid Pre-calculation)替代逐项流式布局:
|
||||
|
||||
```typescript
|
||||
// canvas-insertion-layout.ts L228-328
|
||||
export function precalculateGridLayout(
|
||||
startPoint: Point,
|
||||
itemSizes: { width: number; height: number }[],
|
||||
options: {
|
||||
canvasWidth?: number;
|
||||
maxColumns?: number;
|
||||
horizontalGap?: number;
|
||||
verticalGap?: number;
|
||||
} = {}
|
||||
): { positions: Point[]; bounds: FlowBounds }
|
||||
```
|
||||
|
||||
**算法流程**:
|
||||
|
||||
```
|
||||
步骤1: 根据画布宽度和素材平均宽度计算最优列数
|
||||
columns = max(1, min(5, floor((canvasWidth + gap) / (avgWidth + gap))))
|
||||
|
||||
步骤2: 将素材按行分组(先填第一行,再填第二行...)
|
||||
6个素材, 3列 → 行0: [素材0, 素材1, 素材2], 行1: [素材3, 素材4, 素材5]
|
||||
|
||||
步骤3: 计算每列最大宽度(该列所有素材的最宽值)
|
||||
columnWidths[c] = max(列c中所有素材的宽度)
|
||||
|
||||
步骤4: 计算每行最大高度(该行所有素材的最高值)
|
||||
rowHeights[r] = max(行r中所有素材的高度)
|
||||
|
||||
步骤5: 计算坐标
|
||||
X偏移: colX[c] = colX[c-1] + columnWidths[c-1] + horizontalGap
|
||||
Y偏移: rowY[r] = rowY[r-1] + rowHeights[r-1] + verticalGap
|
||||
素材i位置: [colX[i % columns], rowY[floor(i / columns)]]
|
||||
|
||||
步骤6: 计算整体边界(用于滚动定位)
|
||||
```
|
||||
|
||||
**与旧布局的关键区别**:
|
||||
|
||||
| 特性 | 旧:advanceBatchInsertionFlow | 新:precalculateGridLayout |
|
||||
|------|------------------------------|---------------------------|
|
||||
| 计算方式 | 逐项迭代 | 一次性全局预计算 |
|
||||
| 列数 | 自适应(溢出换行) | 根据画布宽度动态计算 |
|
||||
| 同行对齐 | 顶部对齐 | 顶部对齐(每个格子左上角) |
|
||||
| 列宽 | 按该项实际宽度 | 按该列最大宽度 |
|
||||
| 行高 | 按该行最高项 | 按该行最高项 |
|
||||
| 可预测性 | 低(依赖顺序和逐个计算结果) | 高(所有位置一次性确定) |
|
||||
|
||||
**示例**:4 张素材(2 张 400×700 竖版 + 2 张 700×400 横版),画布宽 2000:
|
||||
|
||||
```
|
||||
旧布局(流式):
|
||||
[竖400x700] [竖400x700] [横700x400] ← 第3项换行
|
||||
[横700x400] ← 单占一行,大量空白
|
||||
|
||||
新布局(网格,2列):
|
||||
[竖400x700] [横700x400] ← 行0, 行高=max(700,400)=700
|
||||
[竖400x700] [横700x400] ← 行1, 列0宽=max(400,400)=400, 列1宽=max(700,700)=700
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
|
||||
**零风险**。
|
||||
- 向后兼容:`advanceBatchInsertionFlow` 和 `createBatchInsertionFlowState` **保留原样**,仅不再被批量插入调用,其他使用方不受影响 ✓
|
||||
- 空数组安全:`itemSizes.length === 0` 时返回空 positions 和零宽高 bounds ✓
|
||||
- 单列降级:`maxColumns=5` 且画布极窄时自动降为 1 列(`Math.max(1, ...)` 保护) ✓
|
||||
- 无画布宽度时降级:`canvasWidth` 未提供时使用 `Math.min(maxColumns, itemSizes.length)` ✓
|
||||
- `flowState.bounds` 替换:新算法产生的 `bounds` 与旧格式完全兼容,`getBatchInsertionFlowCenter` 无缝工作 ✓
|
||||
- Lint 0 errors ✓
|
||||
- 影响范围仅批量插入(`executeCanvasInsertion` 的两份实现) ✓
|
||||
|
||||
---
|
||||
|
||||
## 五、修改文件清单(完整)
|
||||
|
||||
### 第一轮修复(已提交)
|
||||
|
||||
| 文件 | 修改内容 | 影响范围 |
|
||||
|------|---------|---------|
|
||||
| `packages/drawnix/src/services/media-api/image-api.ts` | 删除 `response_format: 'url'`;`size` 参数增加比例→像素自动转换 | API 请求体构建 |
|
||||
| `packages/drawnix/src/services/canvas-operations/canvas-insertion.ts` | `lockReferenceDimensions: false` | 批量图片插入 |
|
||||
| `packages/drawnix/src/services/sw-capabilities/handler.ts` | 2 处 `lockReferenceDimensions: false` | SW 自动插入 |
|
||||
| `packages/drawnix/src/mcp/tools/canvas-insertion.ts` | `lockReferenceDimensions: true → false` | MCP 协议插入 |
|
||||
|
||||
### 第二轮修复(当前,未提交)
|
||||
|
||||
| 文件 | 修改内容 | 影响范围 |
|
||||
|------|---------|---------|
|
||||
| `packages/drawnix/src/data/image.ts` | `updateImageSizeAfterLoad` 改用图片真实 `naturalWidth/naturalHeight`,上限 2048px | 异步尺寸更新 |
|
||||
| `packages/drawnix/src/hooks/useAutoInsertToCanvas.ts` | `getTaskImageDimensions` 优先使用 `task.result` 真实尺寸 | AI 生成图片插入 |
|
||||
| `packages/drawnix/src/components/toolbar/quick-creation-toolbar/quick-creation-toolbar.tsx` | 批量插入前预加载素材库图片真实尺寸 | 素材库批量插入 |
|
||||
|
||||
### 第三轮修复(批量插入图片叠加,2026-05-25)
|
||||
|
||||
> ⚠️ **重要**:此修复已单独文档化,详见 [批量插入图片叠加问题修复经验总结](./CANVAS_BATCH_INSERTION_FIX_LESSONS.md)
|
||||
|
||||
**问题**:
|
||||
- 素材库批量插入多张图片时,图片全部叠加在一起
|
||||
- 根本原因:预计算的网格布局尺寸与图片加载后更新的尺寸不一致
|
||||
|
||||
**核心解决方案**:
|
||||
- 新增 `calculateImageDisplayDimensions` 函数,统一尺寸计算逻辑
|
||||
- `updateImageSizeAfterLoad` 和 `loadImageDimensions` 使用同一函数
|
||||
- 确保预计算和异步更新使用完全相同的尺寸
|
||||
|
||||
**新增文件**:
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| `packages/drawnix/src/utils/canvas-insertion-layout.ts` | 新增 `calculateImageDisplayDimensions` 函数 |
|
||||
|
||||
**修改文件**:
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `packages/drawnix/src/data/image.ts` | `updateImageSizeAfterLoad` 使用 `calculateImageDisplayDimensions` |
|
||||
| `packages/drawnix/src/components/toolbar/quick-creation-toolbar/quick-creation-toolbar.tsx` | `loadImageDimensions` 使用 `calculateImageDisplayDimensions`;添加 `horizontalGap: 30` 和 `verticalGap: 40` |
|
||||
|
||||
### 未修改的文件(已验证不受影响)
|
||||
|
||||
- `packages/drawnix/src/services/model-adapters/gpt-image-adapter.ts` — 自有 `response_format` 处理
|
||||
- `packages/drawnix/src/services/model-adapters/tuzi-gpt-image-adapter.ts` — 继承 GPT 适配器
|
||||
- `packages/drawnix/src/services/media-api/utils.ts` — 仅 `aspectRatioToSize` 被引用,逻辑不变
|
||||
- `packages/drawnix/src/hooks/useWorkflowSubmission.ts` — 手动插入场景,传入真实尺寸
|
||||
- 其他手动插入路径 — `lockReferenceDimensions` 未传或使用真实尺寸
|
||||
- 其他批量插入入口 — 不走素材库,不受预加载修改影响
|
||||
|
||||
---
|
||||
|
||||
## 六、修改间依赖关系
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ buildImageRequestBody │
|
||||
│ (问题1+2) 已提交 │
|
||||
│ - 删除 response_format│
|
||||
│ - size 比例转换 │
|
||||
└─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ insertImageFromUrl │
|
||||
│ (问题3) 已提交 │
|
||||
│ - lockReferenceDimensions = false │
|
||||
└───────────┬─────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────────┐ ┌──────────────┐ ┌────────────────────┐
|
||||
│ updateImageSize │ │getTaskImage │ │ 素材库批量预加载 │
|
||||
│ AfterLoad │ │Dimensions │ │ (问题6) 当前 │
|
||||
│ (问题4) 当前 │ │(问题5) 当前 │ │ - 预加载真实尺寸 │
|
||||
│ - 使用 natural │ │- task.result │ │ - 传给 dimensions │
|
||||
│ Width/Height │ │ 优先级提前 │ │ │
|
||||
└──────────────────┘ └──────────────┘ └────────────────────┘
|
||||
```
|
||||
|
||||
**独立性**:
|
||||
- 问题 1+2(API 层)与问题 3-6(画布层)完全独立
|
||||
- 问题 4(`updateImageSizeAfterLoad`)是问题 6(批量插入叠加)的前提——如果恢复旧的尺寸限制,批量插入预加载的间距也将失效
|
||||
- 问题 5(`getTaskImageDimensions`)独立于其他修改,仅优化 AI 生成场景的尺寸获取
|
||||
- **问题 7(批量插入图片叠加)**:
|
||||
- 核心修复:新增 `calculateImageDisplayDimensions` 函数,统一所有尺寸计算逻辑
|
||||
- 依赖关系:`loadImageDimensions` → `precalculateGridLayout` → `updateImageSizeAfterLoad`
|
||||
- 关键保证:预计算和异步更新使用完全相同的尺寸计算函数
|
||||
|
||||
---
|
||||
|
||||
## 七、经验总结
|
||||
|
||||
### 关键设计原则
|
||||
|
||||
1. **不要硬编码特定提供商的参数**:`response_format: 'url'` 对 OpenAI 是默认值,对 rix 是致命错误
|
||||
2. **API 边界做参数规范化**:上游可能传入比例格式(`'16x9'`)或像素尺寸(`'1792x1024'`),应在发送前统一转换
|
||||
3. **`||` 降级模式比 `if/else` 更安全**:`aspectRatioToSize(x) || x` 保证无法转换的值会透传
|
||||
4. **异步尺寸更新优于同步锁定**:`lockReferenceDimensions=false` 允许图片加载后自适应,用户体验更好
|
||||
5. **全局预计算优于逐项流式布局**:批量操作中先收集所有尺寸再一次性计算网格布局,比逐项迭代更可预测、间距更均匀
|
||||
|
||||
### 排查方法论
|
||||
|
||||
- **参数链路追踪**:从 UI → Parser → Engine → Service → Executor → API Body,逐层验证
|
||||
- **边界层防御**:在 API 边界(`buildImageRequestBody`)做参数校验和转换,不依赖上游正确性
|
||||
- **全局调用审计**:修改参数签名后需 grep 全部调用者,确保所有路径一致
|
||||
Reference in New Issue
Block a user