增加外部API的MCP与异步生成模式

新增托管式Streamable HTTP MCP端点并复用外部API Key鉴权
统一图片视频音频等生成请求为幂等异步提交和状态查询
提供可发现的使用说明、OpenAPI资源及完整Skill下载包
同步更新OpenAPI契约并规定接口变更必须连带维护
适配AI游戏创作Shell的异步提交轮询与安全重试
补齐MCP方法映射、异步Worker和客户端回归测试
This commit is contained in:
2026-07-31 19:47:36 +08:00
parent 33ec7b2861
commit e1d031b86f
35 changed files with 3684 additions and 497 deletions
@@ -1,13 +1,15 @@
---
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, prepare canvas and asset-library context, abstract reusable art specs before generating assets, upload references, draft curl/HTTP/SDK requests, or set up and safely handle a Genarrative developer API Key.
description: Guide use of Genarrative's external editor/canvas integration through its hosted remote MCP or asynchronous `/api/external/v1` OpenAPI. Use when a user needs to discover the hosted MCP or complete Skill package, infer the right canvas or asset operation, submit and poll image/video/audio generation safely, prepare canvas and asset-library context, upload references, draft HTTP/SDK requests, or securely 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.
Use the live integration manifest as the discovery entry: `GET https://www.genarrative.world/api/external/v1/agent-integration.json`. It advertises the hosted Streamable HTTP MCP endpoint, OpenAPI document, raw Skill entry, complete Skill archive and archive SHA-256. Use the live OpenAPI contract as the field-level 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`.
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.
Prefer the hosted MCP at `https://www.genarrative.world/api/external/v1/mcp` when the Agent supports remote MCP with a custom Bearer token. The MCP exposes the OpenAPI operations as tools plus `usage`, `openapi`, and `skill` resources; it is hosted by Genarrative and does not require installing a local MCP server. Use this Skill package when the Agent does not support remote MCP or when local-file upload orchestration is required.
Prefer the bundled Python helper for runnable REST examples. It uses only Python stdlib, reads the local JSON API Key file, fixes the production base URL, and wraps upload, asynchronous submission, polling, and result retrieval.
## Workflow
@@ -25,11 +27,12 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext
- canvas name when no current canvas session exists; existing `projectId`, folder/resource IDs only when resuming a known project
- media type, prompt, references, dimensions, model, ratio, duration, and resolution
- whether referenced media is already uploaded as `objectKey` or still local
5. Every external generation must write to both the canvas and the asset library. Include `projectId`, `assetFolderId`, a display label, and `canvasCompletion` whenever the target endpoint supports them. For character animation, use the helper's two-step fallback: generate with `projectId` + `canvasCompletion`, then create a library asset from the first returned frame in the session folder.
5. Every external generation must write to both the canvas and the asset library. Include `projectId`, `assetFolderId`, a display label, and `canvasCompletion` whenever the target endpoint supports them. For character animation, use the helper's post-completion fallback to create a library asset from the first returned frame when the generated result has no direct `asset`.
6. If the user lacks an API Key, guide setup before request design.
7. Read `references/api-selection.md` before finalizing any request. Use the core table below for fast routing, then verify details in the reference.
8. Use `scripts/genarrative_external_api.py` when the user wants runnable Python, reference image upload, canvas/folder session setup, art-spec carrying, or a chain that should execute with fewer hand-written curl steps.
9. Keep to `/api/external/v1` unless the user explicitly asks for internal profile/admin APIs.
8. Treat every generation POST as asynchronous. Send a stable `Idempotency-Key`, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`. Consume `result` only after `status=completed`; surface the safe error after `failed`. A timeout or lost response is not permission to submit again with a new key.
9. Use `scripts/genarrative_external_api.py` when the user wants runnable Python, reference image upload, canvas/folder session setup, art-spec carrying, or automatic submit-and-poll behavior.
10. Keep to `/api/external/v1` unless the user explicitly asks for internal profile/admin APIs. Never call internal worker, queue, or SpacetimeDB MCP endpoints.
## Core Routes
@@ -47,6 +50,19 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext
| 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` |
| Query generation | `GET /api/external/v1/generations/{operationId}` | `operationId` returned by the submission |
All eight generation POST routes require `Idempotency-Key` and return HTTP `202`, not a completed media response.
## Hosted Integration Discovery
- Integration manifest: `GET /api/external/v1/agent-integration.json`.
- Hosted remote MCP: `POST /api/external/v1/mcp`, Streamable HTTP with the same Bearer API Key.
- OpenAPI: `GET /api/external/v1/openapi.json`.
- Raw Skill entry: `GET /api/external/v1/skill/SKILL.md`.
- Complete Skill archive: `GET /api/external/v1/skill.zip`.
The Skill archive contains `SKILL.md`, `references/api-selection.md`, `scripts/genarrative_external_api.py`, and `agents/openai.yaml`. Verify its SHA-256 against `agent-integration.json` before installing it. The discovery, OpenAPI, and Skill download routes are public; MCP and business API calls require the API Key.
## Art Spec Interface
@@ -75,7 +91,7 @@ The external OpenAPI uses:
Authorization: Bearer <tnr_sk_...>
```
The OpenAPI JSON endpoint is public; every other external endpoint requires the Bearer API Key.
The OpenAPI, integration manifest, and Skill download endpoints are public. The hosted MCP and all project, asset, upload, generation, and generation-query operations require the Bearer API Key.
Use this fixed production base URL:
@@ -113,6 +129,29 @@ Python smoke without printing the key:
python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects
```
## Asynchronous Generation Contract
Generate one stable idempotency key per logical generation. Reuse it for transport retries of the same request body. A successful submission returns HTTP `202` with:
```json
{
"operationId": "task-...",
"kind": "editor_image_generation",
"status": "queued",
"statusUrl": "/api/external/v1/generations/task-...",
"pollAfterMs": 1500,
"updatedAtMicros": 1785456000000000
}
```
Poll until a terminal status:
- `queued` / `running`: retain `operationId`; show `phaseLabel`, `phaseDetail`, and `progress`; wait at least `pollAfterMs`.
- `completed`: consume the compact stable `result`, then reload the project/library snapshot if the caller needs the complete authoritative state.
- `failed`: surface the returned safe `error`; do not infer provider internals.
The compact result may contain `objectKey`, `resourceId`, `assetId`, dimensions, media type, warnings, and other stable artifact references. It deliberately excludes a complete project/canvas snapshot, Data URL, Blob URL, expiring signed URL, worker lease data, and internal provider diagnostics. Use `/assets/read-url` when a stable `objectKey` needs a temporary download URL.
## Request Patterns
For image and icon generation, the request-body top-level `style` field controls deterministic post-processing and is distinct from `generationInputs.artSpec.style`, which describes visual style for prompting. Pass `style="pixelArt"` in Python or `"style": "pixelArt"` in JSON to enable pixel-art snapping on supported generation types; use `"none"` or omit the field otherwise. Verify compatibility and fallback semantics in `references/api-selection.md`.
@@ -144,6 +183,8 @@ client.generate_image(
)
```
The helper method blocks only in the local client while it submits and polls; the server request itself is asynchronous. For explicit control, call `client.submit_generation(...)`, persist the returned `operationId`, then call `client.get_generation(...)` or `client.wait_for_generation(...)`.
For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation:
```python
@@ -209,7 +250,19 @@ Generate an image and save it into both the canvas and the asset-library folder:
}
```
Then call `POST /api/external/v1/editor/images/generations`.
Save the JSON above as `request.json`, then submit and poll with a stable key:
```bash
idempotency_key="$(node -e 'process.stdout.write(require("node:crypto").randomUUID())')"
submission="$(curl -fsS "$api/api/external/v1/editor/images/generations" \
"${auth[@]}" "${json[@]}" \
-H "Idempotency-Key: $idempotency_key" \
-d @request.json)"
operation_id="$(node -e 'const v=JSON.parse(process.argv[1]); process.stdout.write(v.operationId || v.data?.operationId || "")' "$submission")"
curl -fsS "$api/api/external/v1/generations/$operation_id" "${auth[@]}"
```
Continue polling according to `pollAfterMs`. If the submit response is lost, retry the same body with the same `Idempotency-Key`; never generate a replacement key merely because the outcome is unknown.
For direct HTTP/curl, create or find the folder first with `GET /api/external/v1/editor/assets/library` and `POST /api/external/v1/editor/assets/folders`. The folder label should match the canvas name.
@@ -324,9 +377,9 @@ For character animation from an uploaded local image, set:
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.
The helper uses a 420 second timeout for generation calls, including character animation and video. Direct HTTP clients should not use a 70 second request timeout for animation.
The helper uses short HTTP requests for submission/status reads and an overall 1800-second local polling budget. Direct clients should use their own bounded polling budget without keeping the generation POST connection open.
Character animation currently returns canvas completion data but not a direct `asset` payload. To keep the "canvas + asset library" invariant, call `client.animate_character(..., canvasSession=session, canvasTitle="...")`; the helper creates a library asset from the first returned frame in the session folder after the animation call succeeds.
Character animation can complete without a direct `asset` payload. To keep the "canvas + asset library" invariant, call `client.animate_character(..., canvasSession=session, canvasTitle="...")`; after polling reaches `completed`, the helper creates a library asset from the first returned frame when needed.
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.
@@ -334,12 +387,12 @@ For image edit/redraw that should replace an existing canvas layer, pass `projec
For sound effects and BGM, `assetFolderId` and `assetLabel` can write the generated audio to the account asset library, same as image/video generation.
## Successful Responses with Warnings
## Completed Results with Warnings
Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction can return HTTP 2xx with an optional structured `warning`. A 2xx response means the task completed, but it does not guarantee that every requested post-processed derivative exists.
Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction can complete with warnings. The initial HTTP `202` means only that the task was accepted. Interpret warnings only after the query reaches `status=completed`: the query-level `warning` is display-ready text, while the compact `result.warning` / `result.sliceWarning` retain structured artifact semantics when present.
- Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation.
- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason.
- Apply the compact media references and then reload the authoritative project/library snapshots before interpreting optional derivatives. Character results use `resource` / `asset`, while icon spritesheet and UI extraction use `spritesheetResource` / `spritesheetAsset`. When `result.warning.code` is `postprocess-failed-source-preserved`, the saved source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction have neither a transparent spritesheet nor slices. Display the reason and do not synthesize missing derivatives or restart generation.
- `result.sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Continue using the complete transparent spritesheet and do not claim missing slices.
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because a failed transparent post-process never reaches slicing. Since 2026-07-29 a general `warning` may also come from image-style normalization (`unsupported-image-style`) or pixel-art snapping, and those can coexist with `sliceWarning` in the same response. Display both reasons; do not drop either one and do not misclassify a source-preserved result as a slicing-only warning.
For reusable transparent game/UI sheets, do not substitute ordinary image generation merely because it can draw several objects in one image. Use icon spritesheet generation when a stable visual-spec reference and `iconDescriptions` exist; use UI extraction only for an existing annotated UI design. Pass `screenColor: "auto"` unless the art direction requires one of the supported solid chroma colors. A client must verify the returned full sheet really contains transparency before treating it as a transparent spritesheet. If a source-preserved `warning` is present, do not register the opaque provider source as the requested transparent deliverable. When only `sliceWarning` is present, the full transparent sheet remains usable, but no individual slices may be claimed.
@@ -356,13 +409,14 @@ Never use `assets/ui-prototype.png` as the icon spritesheet's visual-spec refere
## Guardrails
- Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes.
- Do not invent endpoints outside the OpenAPI, especially internal worker, runtime task-list, SpacetimeDB, or queue routes. The only external generation query is `/api/external/v1/generations/{operationId}`.
- Do not omit canvas/library context for generation. New generated assets should enter both the canvas and the asset-library folder named after the canvas.
- 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.
- Never treat HTTP `202` as a generated artifact. Preserve `operationId` until a terminal query result, and do not automatically replay an unknown-outcome request with a new idempotency key.
- A completed generation result is compact. Reload `project`, `resource`, or `asset` through their normal read endpoints when a complete authoritative snapshot is needed.
## 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.
- `scripts/genarrative_external_api.py`: stdlib Python helper for API Key loading, local reference upload, object confirm, project/canvas calls, asynchronous generation submission, polling, and result retrieval.
@@ -1,6 +1,6 @@
interface:
display_name: "Genarrative External Editor API"
short_description: "Auto-route canvas API generation"
default_prompt: "Use $genarrative-external-editor-api to prepare a canvas session, infer the right API, and generate assets into the canvas and library."
short_description: "Route async canvas generation safely"
default_prompt: "Use $genarrative-external-editor-api to discover the hosted integration, prepare a canvas session, and submit and poll asset generation into the canvas and library."
policy:
allow_implicit_invocation: true
@@ -5,10 +5,14 @@ Source of truth: `docs/openapi/genarrative-external-v1.openapi.json`.
## Base
- Fixed base URL: `https://www.genarrative.world/`.
- Integration discovery: `GET /api/external/v1/agent-integration.json` returns the hosted MCP URL, OpenAPI URL, raw Skill entry, complete Skill archive, archive file list, and archive SHA-256.
- Hosted MCP: `/api/external/v1/mcp`, Streamable HTTP, authenticated with the same Bearer API Key. It exposes the REST operations as tools and the usage/OpenAPI/Skill documents as resources.
- Public contract: `GET /api/external/v1/openapi.json`.
- Public Skill fallback: `GET /api/external/v1/skill/SKILL.md` or the complete `GET /api/external/v1/skill.zip` package.
- Authenticated calls: `Authorization: Bearer <tnr_sk_...>`.
- Default credentials file: `~/.config/genarrative/external-editor-api.json` with an `apiKey` string.
- Generation clients should allow long-running responses. Use at least 420 seconds for character animation and video; 70 seconds is too short for animation.
- All eight generation POST routes are asynchronous, require `Idempotency-Key`, and return HTTP `202`. Retain `operationId` and poll `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`; do not hold the submit connection open.
- Prefer hosted MCP for Agents that support remote Streamable HTTP plus custom Bearer tokens. Use the complete Skill package and helper when remote MCP is unavailable or local-file upload needs client-side orchestration.
## Canvas Session and Art Spec
@@ -56,6 +60,8 @@ Ask a follow-up only when two routes could both be correct and produce different
| User intent | Endpoint | Minimum request |
| --- | --- | --- |
| Read contract | `GET /api/external/v1/openapi.json` | No auth required |
| Read integration manifest | `GET /api/external/v1/agent-integration.json` | No auth required |
| Download complete Skill | `GET /api/external/v1/skill.zip` | No auth required; verify `archiveSha256` from the manifest |
| 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 |
@@ -70,9 +76,12 @@ Ask a follow-up only when two routes could both be correct and produce different
| 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` |
| Query generation | `GET /api/external/v1/generations/{operationId}` | API Key and returned `operationId` |
## Generation Endpoints
Every row below requires a stable `Idempotency-Key` header and returns an `ExternalEditorGenerationSubmissionResponse`, not a media result. The request fields shown are body fields.
| User intent | Endpoint | Required fields | Common optional fields |
| --- | --- | --- | --- |
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
@@ -84,6 +93,19 @@ Ask a follow-up only when two routes could both be correct and produce different
| 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` |
## Submission and Polling
1. Generate one printable ASCII `Idempotency-Key` of 1-128 bytes for the logical request.
2. Submit the generation body. Persist `operationId`, `statusUrl`, and the idempotency key before doing more work.
3. On a lost or uncertain submission response, resend the exact body with the same key. Do not allocate a replacement key.
4. Poll `statusUrl` no faster than `pollAfterMs`:
- `queued` / `running`: display `phaseLabel`, `phaseDetail`, and `progress`, then continue polling.
- `completed`: consume `result`; if full canvas/library state is needed, reload the normal project or library read endpoint.
- `failed`: display the safe `error` and stop polling.
5. A client-side polling timeout leaves the operation pending. Keep `operationId` for later queries; it does not authorize a new generation request.
`result` contains compact stable artifact references such as `objectKey`, resource/asset IDs, dimensions, media type, task ID, and warning data. It does not contain a complete project/canvas snapshot, Data URL, Blob URL, expiring signed URL, worker lease, or internal queue/provider diagnostics. Resolve preview/download access from a stable `objectKey` through `/assets/read-url`.
## Image Post-processing Style
The request-body top-level `style` field controls deterministic image post-processing. It is separate from `generationInputs.artSpec.style`, which only describes the requested visual language for prompting.
@@ -113,14 +135,14 @@ Icon spritesheet generation with pixel-art snapping:
}
```
All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`.
All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. Character animation may complete without an `asset`; after `status=completed`, create an asset from the first returned frame only when the compact result still lacks one. The bundled helper performs this fallback.
## HTTP 2xx Warning Handling
## Completed Warning Handling
Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction may return HTTP 2xx while carrying a structured `warning`; completion does not imply that all post-processed derivatives exist.
The initial HTTP `202` only acknowledges durable submission. Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction may later reach `completed` with warning data; completion does not imply that all requested post-processed derivatives exist. The query-level `warning` is display-ready text, while compact `result.warning` / `result.sliceWarning` preserve structured artifact semantics when present.
- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch.
- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning.
- Use compact references to reload the authoritative project/library state. Character results use `resource` / `asset`, while icon spritesheet and UI extraction use `spritesheetResource` / `spritesheetAsset`. `result.warning.code: "postprocess-failed-source-preserved"` means the saved source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display the reason; do not construct missing assets or retry generation from scratch.
- `result.sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet; do not claim individual slices.
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because that failure never reaches slicing. A general `warning` produced by image-style normalization (`unsupported-image-style`) or pixel-art snapping can coexist with `sliceWarning`; render both reasons instead of picking one.
## Reference Image Upload
@@ -225,7 +247,7 @@ Required:
}
```
`dialogId` is optional. If the response includes `project`, `resource`, or `asset`, use those snapshots instead of reconstructing canvas/resource/library state locally.
`dialogId` is optional. A completed external result is compact; use returned resource/asset IDs and stable object keys, then reload the project or library endpoint instead of reconstructing canvas/resource/library state locally.
## Upload Flow
@@ -11,6 +11,7 @@ import re
import struct
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
@@ -22,7 +23,7 @@ from typing import Any
BASE_URL = "https://www.genarrative.world/"
DEFAULT_CREDENTIALS_FILE = Path.home() / ".config/genarrative/external-editor-api.json"
DEFAULT_REQUEST_TIMEOUT_SECONDS = 60
GENERATION_REQUEST_TIMEOUT_SECONDS = 420
GENERATION_WAIT_TIMEOUT_SECONDS = 1800
class GenarrativeApiError(RuntimeError):
@@ -125,17 +126,18 @@ class GenarrativeExternalClient:
query: dict[str, Any] | None = None,
auth: bool = True,
timeout: int = DEFAULT_REQUEST_TIMEOUT_SECONDS,
headers: dict[str, str] | None = None,
) -> 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"}
request_headers = {"Accept": "application/json", **(headers or {})}
if data is not None:
headers["Content-Type"] = "application/json"
request_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())
request_headers["Authorization"] = f"Bearer {self.api_key}"
request = urllib.request.Request(url, data=data, headers=request_headers, method=method.upper())
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = response.read()
@@ -440,24 +442,120 @@ class GenarrativeExternalClient:
def read_url(self, object_key: str) -> Any:
return self.request_json("GET", "/api/external/v1/assets/read-url", query={"objectKey": object_key})
def submit_generation(
self,
path: str,
body: dict[str, Any],
idempotency_key: str | None = None,
) -> dict[str, Any]:
key = normalize_optional_text(idempotency_key) or str(uuid.uuid4())
submission = None
for attempt in range(2):
try:
submission = self.request_json(
"POST",
path,
body,
headers={"Idempotency-Key": key},
)
break
except (urllib.error.URLError, TimeoutError) as error:
if attempt == 0:
time.sleep(0.5)
continue
raise GenarrativeApiError(
"Generation submission transport outcome is unknown. "
f"Retry the same body with Idempotency-Key {key}; do not create a new key."
) from error
if not isinstance(submission, dict) or not normalize_optional_text(submission.get("operationId")):
raise GenarrativeApiError("Generation submission response missing operationId.")
submission.setdefault("idempotencyKey", key)
return submission
def get_generation(self, operation_id: str) -> dict[str, Any]:
result = self.request_json(
"GET",
f"/api/external/v1/generations/{urllib.parse.quote(operation_id, safe='')}",
)
if not isinstance(result, dict):
raise GenarrativeApiError("Generation status response must be an object.")
return result
def wait_for_generation(
self,
submission_or_operation_id: dict[str, Any] | str,
timeout_seconds: int = GENERATION_WAIT_TIMEOUT_SECONDS,
) -> dict[str, Any]:
operation_id = (
submission_or_operation_id.get("operationId")
if isinstance(submission_or_operation_id, dict)
else submission_or_operation_id
)
operation_id = normalize_optional_text(operation_id)
if not operation_id:
raise GenarrativeApiError("Generation operationId is required.")
deadline = time.monotonic() + max(1, timeout_seconds)
while True:
try:
job = self.get_generation(operation_id)
except GenarrativeApiError as error:
if any(f"HTTP {status}" in str(error) for status in (429, 502, 503, 504)):
if time.monotonic() >= deadline:
raise GenarrativeApiError(
f"Generation {operation_id} is still running; keep this operationId and continue polling."
) from error
time.sleep(1.5)
continue
raise
status = job.get("status")
if status == "completed":
result = job.get("result")
if not isinstance(result, dict):
raise GenarrativeApiError(
f"Generation {operation_id} completed without a result payload."
)
return result
if status == "failed":
raise GenarrativeApiError(
f"Generation {operation_id} failed: {job.get('error') or 'unknown error'}"
)
if time.monotonic() >= deadline:
raise GenarrativeApiError(
f"Generation {operation_id} is still running; keep this operationId and continue polling."
)
poll_after_ms = job.get("pollAfterMs", 1500)
if not isinstance(poll_after_ms, (int, float)):
poll_after_ms = 1500
time.sleep(max(0.25, min(float(poll_after_ms) / 1000.0, 5.0)))
def submit_and_wait_generation(
self,
path: str,
body: dict[str, Any],
idempotency_key: str | None = None,
timeout_seconds: int = GENERATION_WAIT_TIMEOUT_SECONDS,
) -> dict[str, Any]:
submission = self.submit_generation(path, body, idempotency_key=idempotency_key)
return self.wait_for_generation(submission, timeout_seconds=timeout_seconds)
def generate_image(self, prompt: str, **fields: Any) -> Any:
self._apply_canvas_session_fields(fields, prompt, 1024, 1024)
prompt = self._apply_art_spec(fields, prompt)
return self.request_json(
"POST",
idempotency_key = fields.pop("idempotencyKey", None)
return self.submit_and_wait_generation(
"/api/external/v1/editor/images/generations",
{"prompt": prompt, **fields},
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
idempotency_key=idempotency_key,
)
def edit_image(self, prompt: str, source_image_src: str, **fields: Any) -> Any:
self._apply_canvas_session_fields(fields, prompt, 1024, 1024)
prompt = self._apply_art_spec(fields, prompt)
return self.request_json(
"POST",
idempotency_key = fields.pop("idempotencyKey", None)
return self.submit_and_wait_generation(
"/api/external/v1/editor/images/edits",
{"prompt": prompt, "sourceImageSrc": source_image_src, **fields},
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
idempotency_key=idempotency_key,
)
def generate_icon_spritesheet(
@@ -472,25 +570,25 @@ class GenarrativeExternalClient:
label = fields.get("assetLabel", "图标图集")
self._apply_canvas_session_fields(fields, label, 1024, 1024)
fields.setdefault("screenColor", "auto")
return self.request_json(
"POST",
idempotency_key = fields.pop("idempotencyKey", None)
return self.submit_and_wait_generation(
"/api/external/v1/editor/icon-spritesheets/generations",
{
"referenceImageSrc": reference_image_src,
"iconDescriptions": descriptions,
**fields,
},
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
idempotency_key=idempotency_key,
)
def extract_ui_assets(self, source_image_src: str, image_size: str = "1K", **fields: Any) -> Any:
fields.pop("aspectRatio", None)
self._apply_canvas_session_fields(fields, fields.get("spritesheetLabel", "UI 素材拆分"), 1024, 1024, "spritesheetLabel")
return self.request_json(
"POST",
idempotency_key = fields.pop("idempotencyKey", None)
return self.submit_and_wait_generation(
"/api/external/v1/editor/ui-designs/assets/extractions",
{"sourceImageSrc": source_image_src, "imageSize": image_size, **fields, "aspectRatio": "1:1"},
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
idempotency_key=idempotency_key,
)
def animate_character(
@@ -512,6 +610,7 @@ class GenarrativeExternalClient:
prompt_text = self._apply_art_spec(fields, prompt_text)
fields.pop("assetFolderId", None)
fields.pop("assetLabel", None)
idempotency_key = fields.pop("idempotencyKey", None)
body = {
"sourceLayerId": source_layer_id,
"sourceImageSrc": source_image_src,
@@ -525,11 +624,10 @@ class GenarrativeExternalClient:
**fields,
"model": "seedance2.0-fast",
}
result = self.request_json(
"POST",
result = self.submit_and_wait_generation(
"/api/external/v1/editor/character-animations/generations",
body,
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
idempotency_key=idempotency_key,
)
if isinstance(session, dict) and isinstance(result, dict) and not result.get("asset"):
frames = result.get("frames")
@@ -559,6 +657,7 @@ class GenarrativeExternalClient:
fields.pop("mode", None)
self._apply_canvas_session_fields(fields, prompt, 1280, 720)
prompt = self._apply_art_spec(fields, prompt)
idempotency_key = fields.pop("idempotencyKey", None)
body = {
"prompt": prompt,
"model": fields.pop("model", "seedance2.0-fast"),
@@ -569,31 +668,30 @@ class GenarrativeExternalClient:
**fields,
"mode": "std",
}
return self.request_json(
"POST",
return self.submit_and_wait_generation(
"/api/external/v1/editor/videos/generations",
body,
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
idempotency_key=idempotency_key,
)
def generate_sound_effect(self, prompt: str, duration: int, **fields: Any) -> Any:
self._apply_canvas_session_fields(fields, prompt, 360, 120)
prompt = self._apply_art_spec(fields, prompt)
return self.request_json(
"POST",
idempotency_key = fields.pop("idempotencyKey", None)
return self.submit_and_wait_generation(
"/api/external/v1/editor/audios/sound-effects/generations",
{"prompt": prompt, "duration": duration, **fields},
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
idempotency_key=idempotency_key,
)
def generate_background_music(self, description: str, **fields: Any) -> Any:
self._apply_canvas_session_fields(fields, description, 360, 120)
description = self._apply_art_spec(fields, description)
return self.request_json(
"POST",
idempotency_key = fields.pop("idempotencyKey", None)
return self.submit_and_wait_generation(
"/api/external/v1/editor/audios/background-music/generations",
{"gptDescriptionPrompt": description, **fields, "makeInstrumental": True},
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
idempotency_key=idempotency_key,
)
@@ -625,17 +723,29 @@ def _self_test() -> None:
query: dict[str, Any] | None = None,
auth: bool = True,
timeout: int = DEFAULT_REQUEST_TIMEOUT_SECONDS,
headers: dict[str, str] | None = None,
) -> Any:
calls.append({"method": method, "path": path, "body": body, "timeout": timeout})
calls.append({
"method": method,
"path": path,
"body": body,
"timeout": timeout,
"headers": headers,
})
if path == "/api/external/v1/editor/assets":
return {"asset": {"assetId": "editor-asset-demo"}}
return {
generated = {
"taskId": "task-demo",
"model": "seedance2.0-fast",
"prompt": "角色呼吸",
"previewVideoPath": "/generated/preview.mp4",
"frames": [{"frameIndex": 1, "imageSrc": "/generated/frame01.png", "width": 512, "height": 768}],
}
if method == "POST":
return {"operationId": "task-operation-demo", "status": "queued", "pollAfterMs": 1}
if path == "/api/external/v1/generations/task-operation-demo":
return {"operationId": "task-operation-demo", "status": "completed", "result": generated}
return generated
client.request_json = fake_request_json # type: ignore[method-assign]
result = client.animate_character(
@@ -647,10 +757,12 @@ def _self_test() -> None:
canvasSession=session,
canvasTitle="角色呼吸动画",
)
assert calls[0]["timeout"] == GENERATION_REQUEST_TIMEOUT_SECONDS
assert calls[0]["timeout"] == DEFAULT_REQUEST_TIMEOUT_SECONDS
assert calls[0]["headers"]["Idempotency-Key"]
assert calls[0]["body"]["projectId"] == "proj-demo"
assert calls[0]["body"]["canvasCompletion"]["title"] == "角色呼吸动画"
assert calls[1]["path"] == "/api/external/v1/editor/assets"
assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo"
assert calls[2]["path"] == "/api/external/v1/editor/assets"
assert result["asset"]["assetId"] == "editor-asset-demo"
calls.clear()
client.generate_icon_spritesheet(
@@ -663,6 +775,7 @@ def _self_test() -> None:
assert calls[0]["body"]["referenceImageSrc"] == "editor-resource-spec"
assert calls[0]["body"]["screenColor"] == "auto"
assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上"
assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo"
print("self-test ok")