合并 master 并保留画布背景色改动

保留画布生图 screenColor 背景色选择、传参和文档约定。

合并 master 快速编辑、音频生成和外部 API 相关更新。

补齐 UI 提取测试默认背景色并完成冲突验证。
This commit is contained in:
2026-07-03 07:49:24 +00:00
41 changed files with 1785 additions and 361 deletions
@@ -0,0 +1,274 @@
---
name: genarrative-external-editor-api
description: Guide use of Genarrative's external editor/canvas OpenAPI. Use when a user describes a canvas/editor integration need and Codex must infer the right `/api/external/v1` API automatically, draft curl/HTTP/SDK requests, clarify missing generation/upload/project/asset fields, or set up and safely handle a Genarrative developer API Key.
---
# Genarrative External Editor API
Use the live OpenAPI contract as the source of truth: `GET https://www.genarrative.world/api/external/v1/openapi.json`. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`. If exact fields or enums matter, read the contract before emitting final code.
Prefer the bundled Python helper for runnable examples: `scripts/genarrative_external_api.py`. It uses only Python stdlib, reads the local JSON API Key file, fixes the production base URL, and wraps upload/confirm/generation routes.
## Workflow
1. Classify the user's natural-language intent first. Do not ask the user to choose an API:
- "生成/生图/做一张图" -> image generation
- "重绘/修改这张图" -> image edit
- "用这张参考图/基于本地图生成" -> upload local reference image, then generation or edit
- "上传本地素材" -> upload ticket, OSS form upload, object confirm
- "保存画板/更新布局" -> canvas save
- "读取私有素材" -> signed read URL
2. Ask only for missing inputs that affect the request body or an actually ambiguous route:
- credentials JSON path only if the user cannot use the default local path
- existing `projectId`, folder/resource IDs, and whether output should update the canvas
- media type, prompt, references, dimensions, model, ratio, duration, and resolution
- whether referenced media is already uploaded as `objectKey` or still local
3. If the user lacks an API Key, guide setup before request design.
4. Read `references/api-selection.md` before finalizing any request. Use the core table below for fast routing, then verify details in the reference.
5. Use `scripts/genarrative_external_api.py` when the user wants runnable Python, reference image upload, or a chain that should execute with fewer hand-written curl steps.
6. Keep to `/api/external/v1` unless the user explicitly asks for internal profile/admin APIs.
## Core Routes
| Intent | Method and path | Required fields |
| --- | --- | --- |
| List/create projects | `GET/POST /api/external/v1/editor/projects` | create: optional `title` |
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
| Upload local media | `POST /api/external/v1/assets/direct-upload-tickets` -> OSS form -> `POST /api/external/v1/assets/objects/confirm` | ticket: `legacyPrefix`, `fileName`; confirm: `objectKey`, `assetKind` |
| Read private media | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
| Image generation | `POST /api/external/v1/editor/images/generations` | `prompt` |
| Image edit/redraw | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` |
| Icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` |
| UI asset extraction | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize`; use `assetFolderId` for library folder |
| Character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` |
| Video generation | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` |
| Sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` |
| Background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` |
## API Key
The external OpenAPI uses:
```text
Authorization: Bearer <tnr_sk_...>
```
The OpenAPI JSON endpoint is public; every other external endpoint requires the Bearer API Key.
Use this fixed production base URL:
```text
https://www.genarrative.world/
```
Guide the user to create a key from the logged-in product UI under `开发者 API Key`. The raw key is shown only once; never ask the user to paste it into chat. Tell them to store it in this local private JSON file, outside the repository:
```text
~/.config/genarrative/external-editor-api.json
```
```json
{
"apiKey": "tnr_sk_..."
}
```
Set the file readable only by the current user where possible: `chmod 600 ~/.config/genarrative/external-editor-api.json`. Do not use environment variables for this API.
Smoke test by reading the JSON file, without printing the key:
```bash
api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$HOME/.config/genarrative/external-editor-api.json")"
curl -fsS "https://www.genarrative.world/api/external/v1/editor/projects" \
-H "Authorization: Bearer $api_key"
```
For generated client code, read `apiKey` from the JSON file, fail with a clear missing-config error, and redact keys in logs.
Python smoke without printing the key:
```bash
python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects
```
## Request Patterns
For Python callers, prefer:
```python
from genarrative_external_api import GenarrativeExternalClient
client = GenarrativeExternalClient()
project = client.create_project("新画板")
```
Use the helper directly from this skill path, or copy it into the caller's project. Do not change the fixed base URL or move the API Key into environment variables.
Use this shared base:
```bash
api="https://www.genarrative.world"
credentials_file="$HOME/.config/genarrative/external-editor-api.json"
api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$credentials_file")"
auth=(-H "Authorization: Bearer $api_key")
json=(-H "Content-Type: application/json")
```
Create a project:
```bash
curl -fsS "$api/api/external/v1/editor/projects" \
"${auth[@]}" "${json[@]}" \
-d '{"title":"新画板"}'
```
Generate an image and save it into a project/canvas when the user supplies placement:
```json
{
"prompt": "一张横版幻想森林背景,适合游戏主视觉",
"kind": "spec",
"aspectRatio": "16:9",
"imageSize": "1K",
"projectId": "<projectId>",
"canvasCompletion": {
"title": "森林背景",
"placeholder": {
"x": 0,
"y": 0,
"width": 1024,
"height": 576,
"originalWidth": 1024,
"originalHeight": 576
}
}
}
```
Then call `POST /api/external/v1/editor/images/generations`.
## Reference Images
When the user provides a local reference image path/file, upload it first; do not ask the user to convert it to base64.
Python helper path:
```python
from genarrative_external_api import GenarrativeExternalClient
client = GenarrativeExternalClient()
ref = client.upload_reference_image("/path/to/reference.png")
client.generate_image(
"基于参考图生成一张 16:9 游戏背景",
aspectRatio="16:9",
imageSize="1K",
referenceImageSrcs=[ref["objectKey"]],
)
```
Use the normal upload flow with:
```json
{
"legacyPrefix": "generated-character-drafts",
"pathSegments": ["editor", "external-editor-references"],
"fileName": "<original-file-name>",
"contentType": "image/png",
"access": "private"
}
```
After OSS form upload, confirm the object with `assetKind: "editor_reference_image"`. Put the returned `objectKey` into the generation request:
- image generation: `referenceImageSrcs`
- image edit/redraw: `sourceImageSrc`; extra references go in `referenceImageSrcs`
- icon spritesheet: `referenceImageSrc`
- UI asset extraction: `sourceImageSrc`; extra references go in `referenceImageSrcs`
- character animation: `sourceImageSrc`
- video generation image references: `referenceImageSrcs`
Use `signedUrl` only for display/download. For generation requests, use `objectKey`, project resource ID, asset ID, public URL, or Data URL as the endpoint allows; prefer uploaded `objectKey` for local/private reference images.
OSS form upload shape, using the ticket response saved as `ticket.json`. The default response has `upload`; if the caller explicitly requested the API response envelope, use `data.upload`:
```bash
node - <<'NODE' ticket.json /path/to/reference.png
const fs = require('fs');
const path = require('path');
(async () => {
const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const ticket = body.upload || body.data?.upload;
if (!ticket) throw new Error('Upload ticket response missing upload payload');
const filePath = process.argv[3];
const form = new FormData();
for (const [key, value] of Object.entries(ticket.formFields)) {
if (value != null) form.append(key, value);
}
const bytes = fs.readFileSync(filePath);
form.append(
'file',
new Blob([bytes], { type: ticket.contentType || 'application/octet-stream' }),
path.basename(filePath),
);
const response = await fetch(ticket.host, { method: 'POST', body: form });
if (!response.ok) {
throw new Error(`OSS upload failed: ${response.status} ${await response.text()}`);
}
})().catch((error) => {
console.error(error.message);
process.exit(1);
});
NODE
```
Then confirm with `contentLength`:
```json
{
"objectKey": "<ticket upload.objectKey>",
"contentType": "image/png",
"contentLength": 12345,
"assetKind": "editor_reference_image",
"accessPolicy": "private"
}
```
`contentLength` is a JSON number from the local file byte size, not a quoted string.
For character animation from an uploaded local image, set:
```json
{
"sourceLayerId": "external-reference-hero",
"sourceImageSrc": "<uploaded objectKey>",
"sourceWidth": 720,
"sourceHeight": 1280,
"promptText": "让角色自然呼吸并轻微转身",
"resolution": "720p",
"ratio": "9:16",
"frameCount": 40,
"durationSeconds": 5,
"model": "seedance2.0-fast"
}
```
Use an existing canvas layer ID when the image came from a project layer. If it came only from a local upload, derive a stable synthetic `sourceLayerId` from the file name, for example `external-reference-hero`. Read `sourceWidth` and `sourceHeight` from the actual image before upload; ask the user only if the dimensions cannot be determined.
For video generation, always include `mode: "std"`. When using image/video/audio references, default to `model: "seedance2.0-fast"` unless the user asks for another listed model, because reference media support is limited to the Seedance 2.0 family.
For image edit/redraw that should replace an existing canvas layer, pass `projectId` and `targetLayerId`. If the user instead gives an explicit `canvasCompletion`, let that placement win.
For sound effects and BGM, `assetFolderId` and `assetLabel` can write the generated audio to the account asset library, same as image/video generation.
## Guardrails
- Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes.
- Do not put API Keys in repository files, generated project files, command history snippets with literal secrets, logs, docs, commits, or screenshots. The only default storage is the user's local private JSON credentials file.
- Do not use account JWT endpoints as the default external integration path. The profile API can create/revoke keys for logged-in product users, but it is not part of the external editor OpenAPI.
- When an endpoint returns `project`, `resource`, or `asset`, treat those as the authoritative updated project/resource/asset snapshots.
## Resources
- `references/api-selection.md`: intent routing and required-field cheat sheet.
- `scripts/genarrative_external_api.py`: stdlib Python helper for OpenAPI fetch, API Key loading, local reference upload, object confirm, project/canvas calls, and generation requests.
@@ -0,0 +1,6 @@
interface:
display_name: "Genarrative External Editor API"
short_description: "Auto-route external canvas API usage"
default_prompt: "Use $genarrative-external-editor-api to infer the right external canvas API and draft a request."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,180 @@
# External Editor API Routing
Source of truth: `docs/openapi/genarrative-external-v1.openapi.json`.
## Base
- Fixed base URL: `https://www.genarrative.world/`.
- Public contract: `GET /api/external/v1/openapi.json`.
- Authenticated calls: `Authorization: Bearer <tnr_sk_...>`.
- Default credentials file: `~/.config/genarrative/external-editor-api.json` with an `apiKey` string.
## Intent Routing
Infer the endpoint from the user's description. Do not present this as a menu unless the request is genuinely ambiguous.
| User says | Route |
| --- | --- |
| "生成图片", "生图", "做一张背景/角色/宣发图" | `POST /api/external/v1/editor/images/generations` |
| "重绘", "调整这张图", "基于这张图修改" | `POST /api/external/v1/editor/images/edits` |
| "用这张参考图", "参考本地图片生成", "基于本地图做图" | Upload local image first, then pass returned `objectKey` into the generation/edit reference field |
| "按规范图生成图标", "拆图标" | `POST /api/external/v1/editor/icon-spritesheets/generations` |
| "从 UI 设计图提取素材" | `POST /api/external/v1/editor/ui-designs/assets/extractions` |
| "让角色动起来", "生成角色动画帧" | `POST /api/external/v1/editor/character-animations/generations` |
| "生成视频" | `POST /api/external/v1/editor/videos/generations` |
| "生成音效" | `POST /api/external/v1/editor/audios/sound-effects/generations` |
| "生成背景音乐/BGM" | `POST /api/external/v1/editor/audios/background-music/generations` |
| "上传本地素材/图片/音频/视频" | Upload flow: direct upload ticket -> OSS form upload -> object confirm |
| "保存画板布局" | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` |
| "创建/读取/删除画板项目" | Project endpoints |
| "素材库/文件夹/素材记录" | Asset library endpoints |
| "读取私有素材/拿可访问链接" | `GET /api/external/v1/assets/read-url` |
Ask a follow-up only when two routes could both be correct and produce different artifacts, for example "处理这张图" without saying edit, extract UI assets, or use it as a reference for new generation.
## Endpoint Map
| User intent | Endpoint | Minimum request |
| --- | --- | --- |
| Read contract | `GET /api/external/v1/openapi.json` | No auth required |
| List projects | `GET /api/external/v1/editor/projects` | API Key |
| Create project | `POST /api/external/v1/editor/projects` | Optional `title` |
| Load recent project | `GET /api/external/v1/editor/projects/recent` | API Key |
| Get/delete project | `GET` or `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` |
| Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` |
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
| Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` |
| Create upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` |
| Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` |
| Get signed read URL | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
| Read asset library | `GET /api/external/v1/editor/assets/library` | API Key |
| Create/update/delete folder | `POST /api/external/v1/editor/assets/folders`, `PATCH`/`DELETE /api/external/v1/editor/assets/folders/{folderId}` | create: `label`; update: `label` or `collapsed` |
| Create asset record | `POST /api/external/v1/editor/assets` | `folderId`, `label`, `imageSrc`, `width`, `height`, `sourceType` |
| Update/delete asset | `PATCH`/`DELETE /api/external/v1/editor/assets/{assetId}` | update: `label` or `folderId` |
## Generation Endpoints
| User intent | Endpoint | Required fields | Common optional fields |
| --- | --- | --- | --- |
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` |
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` |
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion` |
| Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Generate background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
## Reference Image Upload
If the user provides a local file as a reference image, run upload before the generation request:
1. `POST /api/external/v1/assets/direct-upload-tickets`.
Use `legacyPrefix: "generated-character-drafts"`, `pathSegments: ["editor", "external-editor-references"]`, original `fileName`, detected image `contentType`, and `access: "private"`.
2. Upload the file to the returned OSS form endpoint with all returned `formFields`.
3. `POST /api/external/v1/assets/objects/confirm` with returned `objectKey`, detected `contentType`, `contentLength` if known, `assetKind: "editor_reference_image"`, and `accessPolicy: "private"`.
4. Use the returned `objectKey` in the actual editor request.
OSS form upload uses `upload.host` and every non-null `upload.formFields` entry, then the file part named `file`. Default responses expose `upload`; envelope responses expose `data.upload`. Save the upload ticket response as `ticket.json`:
```bash
node - <<'NODE' ticket.json /path/to/reference.png
const fs = require('fs');
const path = require('path');
(async () => {
const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const ticket = body.upload || body.data?.upload;
if (!ticket) throw new Error('Upload ticket response missing upload payload');
const filePath = process.argv[3];
const form = new FormData();
for (const [key, value] of Object.entries(ticket.formFields)) {
if (value != null) form.append(key, value);
}
const bytes = fs.readFileSync(filePath);
form.append(
'file',
new Blob([bytes], { type: ticket.contentType || 'application/octet-stream' }),
path.basename(filePath),
);
const response = await fetch(ticket.host, { method: 'POST', body: form });
if (!response.ok) {
throw new Error(`OSS upload failed: ${response.status} ${await response.text()}`);
}
})().catch((error) => {
console.error(error.message);
process.exit(1);
});
NODE
```
Field mapping after upload:
| Target API | Put uploaded `objectKey` in |
| --- | --- |
| Image generation | `referenceImageSrcs` |
| Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` |
| Icon spritesheet | `referenceImageSrc`; additional style refs in `referenceImageSrcs` |
| UI design extraction | `sourceImageSrc`; additional refs in `referenceImageSrcs` |
| Character animation | `sourceImageSrc` |
| Video generation with image references | `referenceImageSrcs` |
Do not put the signed read URL into generation fields. Signed URLs are for user-visible preview/download; generation fields should use the stable `objectKey` for uploaded private references.
## Common Enums
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`.
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
- Video `aspectRatio`: `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `21:9`.
- Video `resolution`: `480p`, `720p`, `1080p`.
- Video `mode`: always `std`.
- Video `sound`: `on`, `off`.
- Character animation `model`: always `seedance2.0-fast`.
- Character animation `resolution`: `480p`, `720p`; `frameCount`: `32`, `40`, `48`; `durationSeconds`: `4`, `5`, `6`; `ratio`: `same`, `1:1`, `4:3`, `16:9`, `9:16`, `3:4`.
## Local Reference Media Details
- `contentLength` in object confirm is a JSON number from local byte size, not a string.
- For character animation, use an existing project layer ID as `sourceLayerId` when available.
- If the source is only an uploaded local image, derive `sourceLayerId` from the file name, such as `external-reference-hero`, and keep it stable across retries.
- Read `sourceWidth` and `sourceHeight` from the local image. If dimensions cannot be read, ask instead of inventing dimensions.
- UI design extraction uses fixed `aspectRatio: "1:1"`; choose `imageSize: "1K"` for normal/small extractions and `2K` for dense designs.
- Video image/video/audio references are supported only by the Seedance 2.0 family; default referenced-media video requests to `model: "seedance2.0-fast"`, `mode: "std"`, and explicit `sound`.
- Image edit/redraw can pass `targetLayerId` with `projectId` to replace an existing canvas layer when no explicit `canvasCompletion` is supplied.
- Image, video, sound effect, and BGM generation can pass `assetFolderId` and `assetLabel`; response `asset` is the created/updated library record.
## Canvas Completion
Use `canvasCompletion` only when the generated result should be written back into a project canvas by the backend.
Required:
```json
{
"title": "素材名称",
"placeholder": {
"x": 0,
"y": 0,
"width": 512,
"height": 512,
"originalWidth": 512,
"originalHeight": 512
}
}
```
`dialogId` is optional. If the response includes `project`, `resource`, or `asset`, use those snapshots instead of reconstructing canvas/resource/library state locally.
## Upload Flow
For a local file that should become a project resource or library asset:
1. `POST /api/external/v1/assets/direct-upload-tickets` with `legacyPrefix`, `fileName`, and optional `contentType`, `access`, `maxSizeBytes`.
2. Submit the file to the returned OSS form endpoint with returned `formFields`.
3. `POST /api/external/v1/assets/objects/confirm` with returned `objectKey` and an `assetKind`.
4. Create a project resource or library asset with the confirmed `assetObjectId`/`objectKey`.
For reading private/generated assets, call `GET /api/external/v1/assets/read-url?objectKey=...` and use the returned `signedUrl`.
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""Tiny stdlib client for Genarrative external editor APIs."""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import re
import struct
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Any
BASE_URL = "https://www.genarrative.world/"
DEFAULT_CREDENTIALS_FILE = Path.home() / ".config/genarrative/external-editor-api.json"
class GenarrativeApiError(RuntimeError):
pass
def load_api_key(credentials_file: str | os.PathLike[str] = DEFAULT_CREDENTIALS_FILE) -> str:
path = Path(credentials_file).expanduser()
try:
value = json.loads(path.read_text(encoding="utf-8")).get("apiKey", "")
except FileNotFoundError as error:
raise GenarrativeApiError(
f"Missing API key file: {path}. Create JSON like {{\"apiKey\":\"tnr_sk_...\"}}."
) from error
except json.JSONDecodeError as error:
raise GenarrativeApiError(f"Invalid JSON in API key file: {path}.") from error
if not isinstance(value, str) or not value.strip():
raise GenarrativeApiError(f"Missing apiKey string in API key file: {path}.")
return value.strip()
def unwrap_envelope(body: Any) -> Any:
if isinstance(body, dict) and body.get("ok") is True and "data" in body:
return body["data"]
return body
def guess_content_type(file_path: str | os.PathLike[str]) -> str:
return mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
def source_layer_id_from_path(file_path: str | os.PathLike[str]) -> str:
stem = Path(file_path).stem.lower()
slug = re.sub(r"[^a-z0-9]+", "-", stem).strip("-") or "image"
return f"external-reference-{slug}"
def image_dimensions(file_path: str | os.PathLike[str]) -> tuple[int, int] | None:
path = Path(file_path)
with path.open("rb") as fh:
header = fh.read(32)
if header.startswith(b"\x89PNG\r\n\x1a\n") and header[12:16] == b"IHDR":
return struct.unpack(">II", header[16:24])
if not header.startswith(b"\xff\xd8"):
return None
fh.seek(2)
while True:
marker_prefix = fh.read(1)
if not marker_prefix:
return None
if marker_prefix != b"\xff":
continue
marker = fh.read(1)
while marker == b"\xff":
marker = fh.read(1)
if marker in {b"\xd8", b"\xd9"}:
continue
length_bytes = fh.read(2)
if len(length_bytes) != 2:
return None
length = struct.unpack(">H", length_bytes)[0]
if marker and marker[0] in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
data = fh.read(5)
if len(data) != 5:
return None
height, width = struct.unpack(">HH", data[1:5])
return width, height
fh.seek(max(length - 2, 0), os.SEEK_CUR)
class GenarrativeExternalClient:
def __init__(
self,
api_key: str | None = None,
credentials_file: str | os.PathLike[str] = DEFAULT_CREDENTIALS_FILE,
base_url: str = BASE_URL,
) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key if api_key is not None else load_api_key(credentials_file)
def request_json(
self,
method: str,
path: str,
body: dict[str, Any] | None = None,
query: dict[str, Any] | None = None,
auth: bool = True,
timeout: int = 60,
) -> Any:
url = f"{self.base_url}{path}"
if query:
url = f"{url}?{urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})}"
data = None if body is None else json.dumps(body).encode("utf-8")
headers = {"Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
if auth:
headers["Authorization"] = f"Bearer {self.api_key}"
request = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = response.read()
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise GenarrativeApiError(f"{method.upper()} {path} failed: HTTP {error.code}: {detail}") from error
if not payload:
return None
return unwrap_envelope(json.loads(payload.decode("utf-8")))
def openapi(self) -> Any:
return self.request_json("GET", "/api/external/v1/openapi.json", auth=False)
def list_projects(self) -> Any:
return self.request_json("GET", "/api/external/v1/editor/projects")
def create_project(self, title: str | None = None) -> Any:
body = {} if title is None else {"title": title}
return self.request_json("POST", "/api/external/v1/editor/projects", body)
def save_canvas(self, project_id: str, viewport: dict[str, Any], layers: dict[str, Any]) -> Any:
return self.request_json(
"PATCH",
f"/api/external/v1/editor/projects/{urllib.parse.quote(project_id, safe='')}/canvas",
{"viewport": viewport, "layers": layers},
)
def create_upload_ticket(self, file_path: str | os.PathLike[str], access: str = "private") -> Any:
path = Path(file_path)
return self.request_json(
"POST",
"/api/external/v1/assets/direct-upload-tickets",
{
"legacyPrefix": "generated-character-drafts",
"pathSegments": ["editor", "external-editor-references"],
"fileName": path.name,
"contentType": guess_content_type(path),
"access": access,
},
)
def upload_to_oss(self, ticket_response: Any, file_path: str | os.PathLike[str]) -> None:
payload = unwrap_envelope(ticket_response)
ticket = payload.get("upload") if isinstance(payload, dict) else None
if not isinstance(ticket, dict):
raise GenarrativeApiError("Upload ticket response missing upload payload.")
fields = ticket.get("formFields")
if not isinstance(fields, dict):
raise GenarrativeApiError("Upload ticket response missing formFields.")
boundary = f"----genarrative-{uuid.uuid4().hex}"
path = Path(file_path)
content_type = ticket.get("contentType") or guess_content_type(path)
chunks: list[bytes] = []
for key, value in fields.items():
if value is None:
continue
chunks.extend(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode(),
str(value).encode(),
b"\r\n",
]
)
chunks.extend(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode(),
f"Content-Type: {content_type}\r\n\r\n".encode(),
path.read_bytes(),
b"\r\n",
f"--{boundary}--\r\n".encode(),
]
)
request = urllib.request.Request(
ticket["host"],
data=b"".join(chunks),
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=300) as response:
if response.status not in {200, 201, 204}:
raise GenarrativeApiError(f"OSS upload failed: HTTP {response.status}")
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise GenarrativeApiError(f"OSS upload failed: HTTP {error.code}: {detail}") from error
def confirm_asset_object(
self,
object_key: str,
file_path: str | os.PathLike[str],
asset_kind: str = "editor_reference_image",
access_policy: str = "private",
) -> Any:
path = Path(file_path)
return self.request_json(
"POST",
"/api/external/v1/assets/objects/confirm",
{
"objectKey": object_key,
"contentType": guess_content_type(path),
"contentLength": path.stat().st_size,
"assetKind": asset_kind,
"accessPolicy": access_policy,
},
)
def upload_reference_image(self, file_path: str | os.PathLike[str]) -> dict[str, Any]:
ticket = self.create_upload_ticket(file_path)
upload = unwrap_envelope(ticket)["upload"]
self.upload_to_oss(ticket, file_path)
confirmed = self.confirm_asset_object(upload["objectKey"], file_path)
return {
"objectKey": upload["objectKey"],
"ticket": ticket,
"assetObject": unwrap_envelope(confirmed).get("assetObject"),
"dimensions": image_dimensions(file_path),
"sourceLayerId": source_layer_id_from_path(file_path),
}
def read_url(self, object_key: str) -> Any:
return self.request_json("GET", "/api/external/v1/assets/read-url", query={"objectKey": object_key})
def generate_image(self, prompt: str, **fields: Any) -> Any:
return self.request_json("POST", "/api/external/v1/editor/images/generations", {"prompt": prompt, **fields})
def edit_image(self, prompt: str, source_image_src: str, **fields: Any) -> Any:
return self.request_json(
"POST",
"/api/external/v1/editor/images/edits",
{"prompt": prompt, "sourceImageSrc": source_image_src, **fields},
)
def extract_ui_assets(self, source_image_src: str, image_size: str = "1K", **fields: Any) -> Any:
fields.pop("aspectRatio", None)
return self.request_json(
"POST",
"/api/external/v1/editor/ui-designs/assets/extractions",
{"sourceImageSrc": source_image_src, "imageSize": image_size, **fields, "aspectRatio": "1:1"},
)
def animate_character(
self,
source_image_src: str,
source_width: int,
source_height: int,
prompt_text: str,
source_layer_id: str,
**fields: Any,
) -> Any:
body = {
"sourceLayerId": source_layer_id,
"sourceImageSrc": source_image_src,
"sourceWidth": source_width,
"sourceHeight": source_height,
"promptText": prompt_text,
"resolution": fields.pop("resolution", "720p"),
"ratio": fields.pop("ratio", "same"),
"frameCount": fields.pop("frameCount", 40),
"durationSeconds": fields.pop("durationSeconds", 5),
**fields,
"model": "seedance2.0-fast",
}
return self.request_json("POST", "/api/external/v1/editor/character-animations/generations", body)
def generate_video(self, prompt: str, **fields: Any) -> Any:
fields.pop("mode", None)
body = {
"prompt": prompt,
"model": fields.pop("model", "seedance2.0-fast"),
"aspectRatio": fields.pop("aspectRatio", "16:9"),
"durationSeconds": fields.pop("durationSeconds", 5),
"resolution": fields.pop("resolution", "720p"),
"sound": fields.pop("sound", "off"),
**fields,
"mode": "std",
}
return self.request_json("POST", "/api/external/v1/editor/videos/generations", body)
def generate_sound_effect(self, prompt: str, duration: int, **fields: Any) -> Any:
return self.request_json(
"POST",
"/api/external/v1/editor/audios/sound-effects/generations",
{"prompt": prompt, "duration": duration, **fields},
)
def generate_background_music(self, description: str, **fields: Any) -> Any:
return self.request_json(
"POST",
"/api/external/v1/editor/audios/background-music/generations",
{"gptDescriptionPrompt": description, **fields, "makeInstrumental": True},
)
def _self_test() -> None:
png = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x02\x00\x00\x00\x03\x08\x06\x00\x00\x00"
)
with tempfile.NamedTemporaryFile(suffix="Hero Image.png") as fh:
fh.write(png)
fh.flush()
assert image_dimensions(fh.name) == (2, 3)
assert source_layer_id_from_path(fh.name).startswith("external-reference-")
assert unwrap_envelope({"ok": True, "data": {"upload": 1}}) == {"upload": 1}
print("self-test ok")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--self-test", action="store_true")
parser.add_argument("--credentials-file", default=str(DEFAULT_CREDENTIALS_FILE))
parser.add_argument("command", nargs="?", choices=["openapi", "list-projects"])
args = parser.parse_args(argv)
if args.self_test:
_self_test()
return 0
if args.command is None:
parser.print_help()
return 0
if args.command == "openapi":
client = GenarrativeExternalClient(api_key="", credentials_file=args.credentials_file)
print(json.dumps(client.openapi(), ensure_ascii=False, indent=2))
elif args.command == "list-projects":
client = GenarrativeExternalClient(credentials_file=args.credentials_file)
print(json.dumps(client.list_projects(), ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -2719,6 +2719,13 @@
"null"
]
},
"targetLayerId": {
"type": [
"string",
"null"
],
"description": "带 projectId 且未提供 canvasCompletion 时,服务端用生成结果替换该画布图层。"
},
"size": {
"type": "string"
},
@@ -3643,6 +3650,20 @@
},
"generationInputs": {
"$ref": "#/components/schemas/JsonValue"
},
"assetFolderId": {
"type": [
"string",
"null"
],
"description": "传 project 时写入默认项目素材文件夹;传具体 folderId 时写入该文件夹。"
},
"assetLabel": {
"type": [
"string",
"null"
],
"description": "写入素材库时使用的素材名称。"
}
},
"additionalProperties": false
@@ -3681,6 +3702,20 @@
},
"generationInputs": {
"$ref": "#/components/schemas/JsonValue"
},
"assetFolderId": {
"type": [
"string",
"null"
],
"description": "传 project 时写入默认项目素材文件夹;传具体 folderId 时写入该文件夹。"
},
"assetLabel": {
"type": [
"string",
"null"
],
"description": "写入素材库时使用的素材名称。"
}
},
"additionalProperties": false
@@ -3771,6 +3806,28 @@
],
"description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。"
},
"resource": {
"anyOf": [
{
"$ref": "#/components/schemas/EditorProjectResource"
},
{
"type": "null"
}
],
"description": "请求携带 projectId 时返回写入的画布资源快照。"
},
"asset": {
"anyOf": [
{
"$ref": "#/components/schemas/EditorAsset"
},
{
"type": "null"
}
],
"description": "请求携带 assetFolderId 时返回写入的账号素材快照。"
},
"queueState": {
"anyOf": [
{
+23 -7
View File
@@ -66,8 +66,8 @@
- 现象:画板生成、快速编辑、图标素材或 UI 素材提取如果允许直接提交 generated objectKey,用户只要知道其他账号的私有 objectKey,就可能让 api-server 签名读取并送给外部生成供应商。
- 原因:Data URL 参考图可以直接解析,但 objectKey 是服务端私有对象引用;只校验 generated 前缀、mime 和大小不能证明它属于当前账号。
- 处理:所有编辑器参考图入口统一走 `parse_editor_reference_image(state, owner_user_id, source)`;objectKey 分支必须先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key,再读取 OSS。快速编辑和图标素材额外参考图必须真实传到 provider,不只写 metadata。
- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入快速编辑 / 图标生成请求。
- 处理:所有编辑器参考图入口统一走 `parse_editor_reference_image(state, owner_user_id, source)`;objectKey 分支必须先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key,再读取 OSS。图标素材额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验
- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入图标生成请求;若快速编辑重开额外参考图,再补对应请求覆盖
- 关联:`server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/spacetime-client/src/assets.rs``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
@@ -311,14 +311,30 @@
- 验证:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`
- 关联:`src/components/image-editor/ImageCanvasOverlayModel.ts``src/components/image-editor/useImageCanvasGenerationSurface.tsx``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 图片画布图片改造也必须创建独立生成器占位
## 图片画布重绘创建独立占位,快速编辑不要新建生成器
- 现象:点击图片图层的“改造”后,输入框直接挂在原图上,提交时既不像其它生成入口一样有独立占位,也容易让用户误以为会覆盖源图。
- 原因:图片改造复用了 `QuickEditPanelState` / redraw 面板路径,只把源图选中并在原图附近打开快速编辑框,没有进入统一的 `CanvasGenerationDialogState` 占位链路
- 处理:图片和用户快照图层的“改造”统一创建 `mode="quick-edit"` 的 generation dialog,占位仍走 `ImageCanvasGenerationPlacementModel`;源图作为隐式最后一张参考图提交,并把“当前图”提示词归一到对应参考图编号。音频改造继续走音频生成器路径,非图片 fallback 才保留旧面板。参考图 Data URL 提交前可压缩,但浏览器图片解码卡住时必须超时透传原图,不能阻塞生成请求
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorImageReference.test.ts -- --runInBand`,以及 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "hides quick edit and redraw panels|opens generated image info|shows the quick edit generator" -- --runInBand`
- 现象:用户点击图片素材的“快速编辑”后,画布上额外出现 `Quick Edit Generator` 占位,像是新建了一个生成器;但用户预期是在原图下方框选区域、填写一个提示词和模型,然后直接修改当前图。
- 原因:快速编辑入口和提交链路误用了 `createQuickEditGenerationDialogDraft(...)` / `CanvasGenerationDialogState`,把“覆盖源图”的快速编辑伪装成会产出新图层的生成器占位
- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,把当前图片或带编号标注的图片作为 `sourceImageSrc`,成功后覆盖源图,失败时保留快速编辑面板。图片重绘、去背景、视频快速编辑等会产出新图层或异步占位的入口仍可走 generation dialog / placement 链路
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx -- --runInBand`,以及按需运行 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "快速编辑|quick edit" -- --runInBand`
- 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts``src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/services/image-editor/editorImageReference.ts`
## 图片画布快速编辑完成必须按目标图层回写
- 现象:图片快速编辑任务成功后,刷新页面素材库能看到新图,但画布上的源图没有替换。
- 原因:`/api/editor/images/edits` 只保存生成图、项目资源和素材;没有 `canvasCompletion` 时不会写 `editor_canvas.layers_json``sourceResourceId` 只能表示溯源,同一资源可出现在多个图层,不能用它来决定替换哪一层。
- 处理:图片快速编辑请求必须传 `targetLayerId`;后端在没有 `canvasCompletion` 的快速编辑完成分支里,用目标 layer id 和生成资源写回项目 layout。
- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand`;后端验证至少覆盖 `editor_image_edit_request_omits_price_mud_points``editor_image_edit_can_complete_by_replacing_target_layer`
- 关联:`src/services/image-editor/editorProjectClient.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``server-rs/crates/api-server/src/editor_project.rs`
## 图片画布快速编辑元数据必须记录原图引用
- 现象:快速编辑生成的新图可以替换画布,但打开图片信息时“生成输入”里看不到被修改的原图。
- 原因:信息面板直接渲染 `generationInputs.references`;快速编辑虽然把原图作为 `sourceImageSrc` 传给 provider,但如果 `buildQuickEditGenerationInputs(...)` 不把源图写成引用,后端资源和画布层都没有可展示的原图引用。
- 处理:快速编辑的 `generationInputs.references` 必须始终包含 `原图`,再追加用户额外参考图;关闭额外参考图入口时也不能删除这条源图引用。
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx -- --runInBand`
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``src/components/image-editor/ImageCanvasMetadataModalView.tsx``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`
## 图片画布生成完成应用项目快照后也要刷新素材库
- 现象:部分素材生成成功后画布上已经出现结果,但左侧素材库没有立刻出现新素材,刷新页面后才显示。
File diff suppressed because one or more lines are too long
@@ -202,7 +202,7 @@ npm run check:server-rs-ddd
2. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;实际扣费真相以后端运行时模型定价配置为准,前端按钮泥点只作为展示。
3. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
4. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
5. 编辑器图片生成、图片修改、图标 spritesheet 和 UI 设计图提取素材的参考图可以提交 Data URL 或已登记的 generated objectKeyobjectKey 必须归属于当前账号的 `editor_project_resource``editor_asset``asset_object`,后端通过归属校验后才签名读取 OSS。快速编辑、图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,快速编辑 / 图标素材上限 8 张额外参考图。
5. 编辑器图片生成、图片修改、图标 spritesheet 和 UI 设计图提取素材的参考图可以提交 Data URL 或已登记的 generated objectKeyobjectKey 必须归属于当前账号的 `editor_project_resource``editor_asset``asset_object`,后端通过归属校验后才签名读取 OSS。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图,只提交原图或红框序号标注图作为 `sourceImageSrc`UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。
## 外部服务与资产
@@ -99,13 +99,13 @@ SpacetimeDB procedure
- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations``/api/editor/images/edits` 的校验、模型归一、计费和持久化规则。
- 图标 spritesheet 和 UI 设计图素材提取复用站内拆分逻辑,生成图集后按连通域切片,并把图集与切片都按请求写入项目资源和素材库。
- 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;音频类外部调用使用 API Key 所属账号作为 asset owner。
- 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;请求携带 `assetFolderId` 时按站内规则写入素材库,音频类外部调用使用 API Key 所属账号作为 asset owner。
- API Key 管理接口仍只属于登录态个人中心,不进入外部 OpenAPI JSON。
图片类外部生成成功后,后端拿到素材后:
素材外部生成成功后,后端拿到素材后:
1. 通过 OSS / asset object adapter 持久化图片
2. 写入 `editor_asset`,让生成进入账号级素材库。
1. 通过 OSS / asset object adapter 持久化媒体文件
2. 写入 `editor_asset`,让生成素材进入账号级素材库。
3. 如果请求带 `projectId`,写入 `editor_project_resource`
4. 返回图片读取地址、素材 ID、资源 ID、尺寸、prompt、model、provider 和 taskId。
@@ -136,7 +136,7 @@ docs/openapi/genarrative-external-v1.openapi.json
- API Key 创建只返回一次明文,列表不返回明文。
- 撤销后的 API Key 调用外部接口返回 `401`
- 外部图片生成、重绘、图标拆分UI 素材拆分成功后,生成结果按请求同时出现在画布资源和账号级素材库。
- 外部图片生成、重绘、图标拆分UI 素材拆分、视频、音效和音乐生成成功后,生成结果按请求同时出现在画布资源和账号级素材库。
- 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。
- OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。
- OpenAPI JSON 不包含 `/api/profile/api-keys``UserAccessToken` 或 API Key 管理 schema。
@@ -18,7 +18,7 @@
- `快速编辑`
`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部重绘或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
`快速编辑` 由选中图片后的浮动工具栏显式打开,面板结构、参考图首行、提示词输入区和底部参数 / 生成区对齐其它生成类面板
`快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 模型选择的修改面板,不再恢复原来源生成器,也不展示参考图或尺寸控件
## 统一布局
@@ -40,7 +40,7 @@
- 生成视频:`你希望生成什么视频?`
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终作为 `/api/editor/images/edits``sourceImageSrc` 直接提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`
10. 图片快速编辑不展示额外参考图入口;原图或绘制了红框和序号的标注图始终作为 `/api/editor/images/edits``sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`
11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`
## 参数交互
@@ -68,7 +68,7 @@
- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。
- 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
- 宣发素材的 `游戏首图``详情五图``运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`
- 快速编辑默认从原图分辨率和模型初始化比例、尺寸和模型,但底部胶囊必须可点击修改;左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮
- 图片快速编辑只保留一个提示词输入框和模型选择;提示词 placeholder 为 `写下每个编号要怎么改`,提交按钮显示 `修改`,不展示比例 / 尺寸或参考图控件
- 不再在底部常驻展开全部可选项。
## 泥点显示
@@ -98,7 +98,7 @@
- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p``854 x 480`,切换比例、`720p``1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。
- 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。
- 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;该占位必须复用新建图片的 placement 避让逻辑,和已有素材 / 生成占位至少保留 32px 画布间距,不允许固定放到原图右侧后压住其它素材;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板
- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口
- 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。
## 画布悬浮信息
@@ -175,12 +175,12 @@
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
- 生成规范类图片底部展示禁用态参数按钮 `16:9·2K``gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
- 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为 `sourceImageSrc` 直接编辑,不参考图条里固定显示 `图x`
- 图片快速编辑底部展示模型选择和 `修改` 按钮;原图或红框序号标注图作为 `sourceImageSrc` 直接编辑,不展示参考图条或比例 / 尺寸控件
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`
- 快速编辑提交前保留提示词里对原图的 `原图``当前图片``当前图``图1` 引用,不再改写成 `图N`
- 快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`;额外参考图进入输入快照,并作为图片编辑请求`referenceImageSrcs` 一起提交
- 快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`,不提交隐藏`referenceImageSrcs`
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
- 快速编辑生成中占位图同样只支持键盘 `Delete` / `Backspace` 删除,不新增 UI 删除按钮;删除后异步结果不得再落回画布
- 快速编辑不创建生成中占位图;提交后当前面板显示修改中,异步结果只允许回填到源图
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
@@ -230,6 +230,7 @@ pub struct EditorImageEditRequest {
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) source_resource_id: Option<String>,
pub(crate) target_layer_id: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
@@ -1845,14 +1846,25 @@ pub(crate) async fn edit_editor_image_for_owner(
},
)
.await?;
let completed_project = complete_editor_canvas_generation(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
generated_asset.resource.as_ref(),
)
.await?;
let completed_project = if payload.canvas_completion.is_some() {
complete_editor_canvas_generation(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
generated_asset.resource.as_ref(),
)
.await?
} else {
complete_editor_canvas_background_removal(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.target_layer_id.as_deref(),
generated_asset.resource.as_ref(),
)
.await?
};
Ok(json_success_body(
Some(&request_context),
@@ -6425,7 +6437,8 @@ mod tests {
"sourceImageSrc": "data:image/png;base64,AAAA",
"size": "2048x1152",
"model": "gpt-image-2",
"referenceImageSrcs": ["data:image/png;base64,BBBB"]
"referenceImageSrcs": ["data:image/png;base64,BBBB"],
"targetLayerId": "layer-source"
}))
.expect("image edit request should deserialize without price");
@@ -6436,6 +6449,22 @@ mod tests {
request.reference_image_srcs,
Some(vec!["data:image/png;base64,BBBB".to_string()])
);
assert_eq!(request.target_layer_id.as_deref(), Some("layer-source"));
}
#[test]
fn editor_image_edit_can_complete_by_replacing_target_layer() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn remove_editor_image_background",
&[
"payload.canvas_completion.is_some()",
"complete_editor_canvas_background_removal",
"payload.target_layer_id.as_deref()",
],
);
}
#[test]
@@ -864,6 +864,11 @@ mod tests {
.get("priceMudPoints")
.is_none()
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
.get("targetLayerId")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/icon-spritesheets/generations")
-1
View File
@@ -10,7 +10,6 @@ use std::{
use axum::extract::FromRef;
use module_ai::{AiTaskService, InMemoryAiTaskStore};
#[cfg(not(test))]
use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService,
@@ -20,8 +20,9 @@ use crate::{
editor_generation_source_entity_id, enqueue_editor_generation_job,
},
editor_project::{
EditorCanvasGeneratedLayerInput, build_editor_canvas_generated_layer_item,
complete_editor_canvas_generation_with_items,
EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest,
build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items,
persist_editor_generated_media_asset,
},
http_error::AppError,
request_context::RequestContext,
@@ -191,6 +192,8 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
let project_id = payload.project_id.clone();
let canvas_completion = payload.canvas_completion.clone();
let generation_inputs = payload.generation_inputs.clone();
let asset_folder_id = payload.asset_folder_id.clone();
let asset_label = payload.asset_label.clone();
let pricing = state
.editor_generation_pricing()
.map_err(|error| {
@@ -239,13 +242,47 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let (resource, asset) = persist_editor_generated_media_asset(
&state,
PersistEditorGeneratedAssetRequest {
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id,
label: asset_label
.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
})
.unwrap_or_else(|| "生成音效".to_string()),
image_src: audio_src.clone(),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
width: EDITOR_AUDIO_WIDTH,
height: EDITOR_AUDIO_HEIGHT,
prompt: normalized.prompt.clone(),
actual_prompt: Some(normalized.prompt.clone()),
model: normalized.model.clone(),
provider: generated.provider.clone(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("sound-effect".to_string()),
generation_inputs: generation_inputs.clone(),
thumbnail_src: None,
},
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let completed_project = if let Some(completion) = canvas_completion.as_ref() {
let layer_id = format!("layer-editor-sound-effect-{}", generated.task_id);
let resource_id = resource
.as_ref()
.map(|resource| resource.resource_id.clone())
.unwrap_or_else(|| format!("local-resource-editor-sound-effect-{}", generated.task_id));
let item = build_editor_canvas_generated_layer_item(
completion,
EditorCanvasGeneratedLayerInput {
layer_id: layer_id.clone(),
resource_id: format!("local-resource-editor-sound-effect-{}", generated.task_id),
resource_id,
title: completion.title.trim().to_string(),
src: audio_src.clone(),
media_type: Some("audio".to_string()),
@@ -285,6 +322,8 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
};
let completed_project =
completed_project.and_then(|project| serde_json::to_value(project).ok());
let resource = resource.and_then(|resource| serde_json::to_value(resource).ok());
let asset = asset.and_then(|asset| serde_json::to_value(asset).ok());
Ok(json_success_body(
Some(&request_context),
@@ -304,6 +343,8 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
price_mud_points: normalized.price_mud_points,
audio_kind: "sound-effect".to_string(),
project: completed_project,
resource,
asset,
queue_state: None,
},
))
@@ -371,6 +412,8 @@ pub(crate) async fn generate_editor_background_music_for_owner(
let project_id = payload.project_id.clone();
let canvas_completion = payload.canvas_completion.clone();
let generation_inputs = payload.generation_inputs.clone();
let asset_folder_id = payload.asset_folder_id.clone();
let asset_label = payload.asset_label.clone();
let pricing = state
.editor_generation_pricing()
.map_err(|error| {
@@ -417,16 +460,51 @@ pub(crate) async fn generate_editor_background_music_for_owner(
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let (resource, asset) = persist_editor_generated_media_asset(
&state,
PersistEditorGeneratedAssetRequest {
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id,
label: asset_label
.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
})
.unwrap_or_else(|| "生成背景音乐".to_string()),
image_src: audio_src.clone(),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
width: EDITOR_AUDIO_WIDTH,
height: EDITOR_AUDIO_HEIGHT,
prompt: normalized.gpt_description_prompt.clone(),
actual_prompt: Some(normalized.gpt_description_prompt.clone()),
model: platform_audio::SUNO_DEFAULT_MODEL.to_string(),
provider: generated.provider.clone(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("background-music".to_string()),
generation_inputs: generation_inputs.clone(),
thumbnail_src: None,
},
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let completed_project = if let Some(completion) = canvas_completion.as_ref() {
let layer_id = format!("layer-editor-background-music-{}", generated.task_id);
let resource_id = resource
.as_ref()
.map(|resource| resource.resource_id.clone());
let item = build_editor_canvas_generated_layer_item(
completion,
EditorCanvasGeneratedLayerInput {
layer_id: layer_id.clone(),
resource_id: format!(
"local-resource-editor-background-music-{}",
generated.task_id
),
resource_id: resource_id.unwrap_or_else(|| {
format!(
"local-resource-editor-background-music-{}",
generated.task_id
)
}),
title: completion.title.trim().to_string(),
src: audio_src.clone(),
media_type: Some("audio".to_string()),
@@ -466,6 +544,8 @@ pub(crate) async fn generate_editor_background_music_for_owner(
};
let completed_project =
completed_project.and_then(|project| serde_json::to_value(project).ok());
let resource = resource.and_then(|resource| serde_json::to_value(resource).ok());
let asset = asset.and_then(|asset| serde_json::to_value(asset).ok());
Ok(json_success_body(
Some(&request_context),
@@ -485,6 +565,8 @@ pub(crate) async fn generate_editor_background_music_for_owner(
price_mud_points: normalized.price_mud_points,
audio_kind: "background-music".to_string(),
project: completed_project,
resource,
asset,
queue_state: None,
},
))
@@ -112,6 +112,8 @@ fn editor_sound_effect_request_normalizes_prompt_duration_and_resolves_price() {
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect("editor sound effect request should normalize");
@@ -131,6 +133,8 @@ fn editor_sound_effect_request_accepts_only_vidu_audio_model() {
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect("Vidu audio model should be accepted");
@@ -143,6 +147,8 @@ fn editor_sound_effect_request_accepts_only_vidu_audio_model() {
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect_err("Suno text-to-sound should be disabled for editor sound effects");
@@ -160,6 +166,8 @@ fn editor_sound_effect_request_rejects_duration_outside_vidu_range() {
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect_err("duration outside 2-10 seconds should fail");
@@ -198,6 +206,8 @@ fn editor_background_music_request_forces_instrumental_and_resolves_price() {
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect("editor background music request should normalize");
@@ -215,6 +225,8 @@ fn editor_background_music_request_rejects_prompt_over_documented_limit() {
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect_err("Suno gpt_description_prompt should follow Apifox 200 char limit");
@@ -503,6 +503,10 @@ pub struct EditorSoundEffectGenerateRequest {
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_inputs: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -516,6 +520,10 @@ pub struct EditorBackgroundMusicGenerateRequest {
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_inputs: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -541,6 +549,10 @@ pub struct EditorAudioGenerateResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
@@ -1299,11 +1311,15 @@ mod tests {
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: Some("project".to_string()),
asset_label: Some("游戏音效 1".to_string()),
})
.expect("sound request should serialize");
assert_eq!(sound_payload["prompt"], json!("金币掉落叮当声"));
assert_eq!(sound_payload["model"], json!("audio1.0"));
assert_eq!(sound_payload["duration"], json!(7));
assert_eq!(sound_payload["assetFolderId"], json!("project"));
assert_eq!(sound_payload["assetLabel"], json!("游戏音效 1"));
assert!(sound_payload.get("priceMudPoints").is_none());
assert!(sound_payload.get("sound").is_none());
assert!(sound_payload.get("type").is_none());
@@ -1322,6 +1338,8 @@ mod tests {
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect("sound request with unset model should serialize");
assert!(unset_model_payload.get("model").is_none());
@@ -1333,6 +1351,8 @@ mod tests {
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect("background music request should serialize");
assert_eq!(
@@ -1357,6 +1377,17 @@ mod tests {
price_mud_points: 10,
audio_kind: "sound-effect".to_string(),
project: None,
resource: None,
asset: Some(json!({
"assetId": "asset-audio-1",
"folderId": "user-1:asset-folder:project",
"label": "游戏音效 1",
"imageSrc": "/generated-character-drafts/editor-audios/sfx.mp3",
"width": 420,
"height": 120,
"sourceType": "generated",
"assetKind": "sound-effect"
})),
queue_state: None,
})
.expect("audio response should serialize");
@@ -1370,6 +1401,10 @@ mod tests {
);
assert_eq!(response_payload["assetObjectId"], json!("assetobj_audio_1"));
assert_eq!(response_payload["audioKind"], json!("sound-effect"));
assert_eq!(
response_payload["asset"]["assetKind"],
json!("sound-effect")
);
}
#[test]
@@ -183,6 +183,48 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
expect(submitButton.textContent).toBe('生成5泥点');
});
it('keeps quick edit to one prompt box and model selection', () => {
render(
<BasicGenerationHarness
initialDialog={createDialog({
mode: 'quick-edit',
sourceLayerId: 'layer-source',
generationReferences: [
{
id: 'ref-a',
label: '旧参考图',
src: 'data:image/png;base64,cmVmQQ==',
},
],
imageModel: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
})}
/>,
);
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(
within(panel).getByRole('textbox', { name: '快速编辑提示词' }),
).toBeTruthy();
expect(
within(panel).queryByRole('button', { name: '添加参考图' }),
).toBeNull();
expect(within(panel).queryByText('旧参考图')).toBeNull();
expect(
within(panel).queryByRole('button', {
name: //u,
}),
).toBeNull();
expect(
within(panel).getByRole('button', {
name: '快速编辑图片模型 gpt-image-2',
}),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy();
});
it('clicks the parent generation panel to collapse an opened option panel', () => {
render(<BasicGenerationHarness />);
@@ -145,13 +145,23 @@ export function ImageCanvasBasicGenerationComposerView({
promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词');
const resolvedPromptPlaceholder =
promptPlaceholder ??
(isQuickEdit ? '想怎么编辑这张图?' : '今天想生成什么画面?');
(isQuickEdit ? '写下每个编号要怎么改' : '今天想生成什么画面?');
const resolvedOptionLabelPrefix =
optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
const resolvedReferenceButtonLabel = referenceButtonLabel;
const resolvedReferenceButtonAriaLabel =
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
const hasReferenceMenu = includeReferences && onRequestUpload;
const shouldIncludeReferences = isQuickEdit ? false : includeReferences;
const shouldIncludeDimensions = isQuickEdit ? false : includeDimensions;
const resolvedSubmitLabel =
isQuickEdit && submitLabel === '生成' ? '修改' : submitLabel;
const resolvedSubmitAriaLabel =
isQuickEdit && submitAriaLabel === '生成' ? '修改' : submitAriaLabel;
const resolvedSubmittingStatusLabel =
isQuickEdit && submittingStatusLabel === '生成中'
? '修改中'
: submittingStatusLabel;
const hasReferenceMenu = shouldIncludeReferences && onRequestUpload;
const finalFormClassName =
formClassName ??
'image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image';
@@ -179,7 +189,7 @@ export function ImageCanvasBasicGenerationComposerView({
}
}}
>
{includeReferences ? (
{shouldIncludeReferences ? (
<div className="image-canvas-editor__reference-strip">
{references.map((reference, index) => {
const label =
@@ -259,7 +269,7 @@ export function ImageCanvasBasicGenerationComposerView({
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions={includeDimensions}
includeDimensions={shouldIncludeDimensions}
includeModel={includeModel}
onRememberImageModel={onRememberImageModel}
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
@@ -269,8 +279,8 @@ export function ImageCanvasBasicGenerationComposerView({
model: dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel={submitLabel}
submitAriaLabel={submitAriaLabel}
submitLabel={resolvedSubmitLabel}
submitAriaLabel={resolvedSubmitAriaLabel}
submitButtonClassName={submitButtonClassName}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
@@ -284,7 +294,7 @@ export function ImageCanvasBasicGenerationComposerView({
className="image-canvas-editor__generate-status"
role="status"
>
{submittingStatusLabel}
{resolvedSubmittingStatusLabel}
</PlatformStatusMessage>
) : null}
{dialog.status === 'failed' ? (
@@ -566,14 +566,12 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
expect(
await screen.findByRole('dialog', { name: '生成角色形象' }),
await screen.findByRole('dialog', { name: '快速编辑图片' }),
).toBeTruthy();
expect(
(screen.getByLabelText('角色设定') as HTMLTextAreaElement).value,
).toBe('黑衣剑士');
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
});
it('opens the matching generator from image context quick edit for tagged uploaded images', async () => {
it('opens quick edit from image context for tagged uploaded images', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-context-tagged-layer',
title: '右键标签素材画布',
@@ -617,13 +615,10 @@ describe('ImageCanvasEditorView generation integration', () => {
),
);
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
expect(
await screen.findByRole('dialog', { name: '生成角色形象' }),
await screen.findByRole('dialog', { name: '快速编辑图片' }),
).toBeTruthy();
expect(
(screen.getByLabelText('角色设定') as HTMLTextAreaElement).value,
).toBe('红披风骑士');
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
});
it('restores publication material composer fields and references from saved generator snapshots', async () => {
@@ -1161,6 +1161,7 @@ export function ImageCanvasEditorView() {
setIsPickingUiDesignSpecFromCanvas,
openCharacterAnimationPanel,
openRedrawPanel,
openQuickEditPanel,
openCropExpandPanel,
removeSelectedLayerBackground,
extractUiDesignAssets,
@@ -1904,7 +1905,7 @@ export function ImageCanvasEditorView() {
onFocusExternalTask: focusExternalGenerationTask,
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
onOpenQuickEditPanel: openLayerGenerationDialog,
onOpenQuickEditPanel: openQuickEditPanel,
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
@@ -117,7 +117,7 @@ function renderComposer(
}
describe('ImageCanvasGenerationComposerView', () => {
it('让快速编辑复用普通图片生成面板结构', () => {
it('让快速编辑只保留提示词和模型选择', () => {
renderComposer({
mode: 'quick-edit',
prompt: '',
@@ -142,6 +142,20 @@ describe('ImageCanvasGenerationComposerView', () => {
);
expect(within(panel).getByRole('textbox', { name: '快速编辑提示词' }))
.toBeTruthy();
expect(
within(panel).queryByRole('button', { name: '添加参考图' }),
).toBeNull();
expect(
within(panel).queryByRole('button', {
name: //u,
}),
).toBeNull();
expect(
within(panel).getByRole('button', {
name: '快速编辑图片模型 gpt-image-2',
}),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy();
expect(
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
).toBeTruthy();
@@ -1198,11 +1198,7 @@ export function ImageCanvasGenerationComposerView({
</div>
) : null}
{quickEditPanel &&
(quickEditPanel.mode === 'redraw' ||
quickEditPanel.status !== 'generating') &&
quickEditSourceLayer &&
quickEditPanelStyle ? (
{quickEditPanel && quickEditSourceLayer && quickEditPanelStyle ? (
<ImageCanvasQuickEditPanelView
panel={quickEditPanel}
sourceLayer={quickEditSourceLayer}
@@ -37,7 +37,7 @@ import {
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import {
IMAGE_MODEL_NANOBANANA2,
IMAGE_MODEL_GPT_IMAGE_2,
} from './ImageCanvasGenerationModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
@@ -655,21 +655,21 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('normalizes legacy nanobanana model aliases in quick edit drafts', () => {
it('locks quick edit drafts to gpt-image-2 despite legacy source models', () => {
expect(
createQuickEditPanelDraft(
createLayer({
model: 'nano-banana',
}),
).model,
).toBe(IMAGE_MODEL_NANOBANANA2);
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
expect(
createQuickEditPanelDraft(
createLayer({
model: 'nanobanana2',
}),
).model,
).toBe(IMAGE_MODEL_NANOBANANA2);
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
});
it('creates character animation generation dialog drafts for character layers', () => {
@@ -1197,9 +1197,7 @@ export function createQuickEditPanelDraft(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const model = normalizeEditorImageModel(
options.imageModel ?? sourceLayer.model,
);
const model = IMAGE_MODEL_GPT_IMAGE_2;
return {
mode: 'quick-edit',
sourceLayerId: sourceLayer.id,
@@ -1221,7 +1219,6 @@ export function createQuickEditGenerationDialogDraft({
prompt,
status = 'idle',
references = [],
model,
aspectRatio,
imageSize,
frame,
@@ -1244,7 +1241,7 @@ export function createQuickEditGenerationDialogDraft({
composerOpen: false,
sourceLayerId: sourceLayer.id,
generationReferences: references.slice(0, 8),
imageModel: normalizeEditorImageModel(model ?? sourceLayer.model),
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio,
imageSize,
placeholder: {
@@ -1269,6 +1266,7 @@ export function createRedrawPanelDraft(
return {
...createQuickEditPanelDraft(sourceLayer, options),
mode: 'redraw',
model: normalizeEditorImageModel(options.imageModel ?? sourceLayer.model),
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
};
}
@@ -22,6 +22,7 @@ import {
IMAGE_MODEL_NANOBANANA2,
normalizeEditorGenerationBackgroundColor,
normalizeEditorImageModel,
QUICK_EDIT_MODEL_OPTIONS,
resizeGenerationPlaceholderToImageSelection,
resolveEditorImageSizeLabel,
} from './ImageCanvasGenerationModel';
@@ -141,6 +142,7 @@ export function ImageCanvasGenerationImageOptionsView({
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
const backgroundColorButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const isQuickEdit = dialog.mode === 'quick-edit';
const normalizedLockedModel = lockedModel
? normalizeEditorImageModel(lockedModel)
: null;
@@ -149,7 +151,17 @@ export function ImageCanvasGenerationImageOptionsView({
? { ...dialog, imageModel: normalizedLockedModel }
: dialog,
);
const selectedModelLabel = getEditorImageModelDisplayName(selection.model);
const modelOptions = isQuickEdit
? [...QUICK_EDIT_MODEL_OPTIONS]
: [...EDITOR_IMAGE_MODEL_OPTIONS];
const selectedModel = modelOptions.some(
(option) => option.value === selection.model,
)
? selection.model
: (modelOptions[0]?.value ?? selection.model);
const selectedModelLabel =
modelOptions.find((option) => option.value === selectedModel)?.label ??
getEditorImageModelDisplayName(selectedModel);
const selectedBackgroundColor = getEditorGenerationBackgroundColorOption(
dialog.screenColor,
);
@@ -434,8 +446,8 @@ export function ImageCanvasGenerationImageOptionsView({
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
const selected = selection.model === option.value;
{modelOptions.map((option) => {
const selected = selectedModel === option.value;
return (
<OptionChoice
key={option.value}

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