补齐外部编辑器 API 技能

新增 Genarrative 外部编辑器 API skill

封装本地 JSON API Key 与参考图上传 Python helper

同步 OpenAPI 的画布图层替换字段

补充外部 OpenAPI 导出测试断言
This commit is contained in:
2026-07-02 23:43:17 +08:00
parent d707613a8b
commit 4637a5e25a
6 changed files with 826 additions and 0 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"
},
@@ -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")