diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index a733e57a4..60bf3100e 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3,7 +3,7 @@ "info": { "title": "陶泥儿外部编辑器 OpenAPI", "version": "1.0.0", - "description": "外部系统调用陶泥儿图片画布项目、画布布局保存和编辑器美术生图能力的 v1 契约。" + "description": "外部系统调用陶泥儿图片画布项目、画布布局、素材库,以及图片、视频、音效、音乐等编辑器素材生成/编辑能力的 v1 契约。" }, "servers": [ { @@ -20,19 +20,33 @@ "name": "Editor Canvas", "description": "图片画布布局" }, + { + "name": "Editor Assets", + "description": "账号级编辑器素材库" + }, + { + "name": "Asset Objects", + "description": "素材对象上传、确认与签名读取" + }, { "name": "Editor Images", "description": "编辑器美术生图" }, { - "name": "API Keys", - "description": "登录态 API Key 管理接口,路径位于 /api/profile" + "name": "Editor Videos", + "description": "编辑器视频与角色动画生成" + }, + { + "name": "Editor Audio", + "description": "编辑器音效与音乐生成" } ], "paths": { "/api/external/v1/openapi.json": { "get": { - "tags": ["Editor Projects"], + "tags": [ + "Editor Projects" + ], "operationId": "getExternalOpenApiJson", "summary": "读取外部 API OpenAPI JSON", "security": [], @@ -50,9 +64,195 @@ } } }, - "/api/external/v1/editor/projects": { + "/api/external/v1/assets/direct-upload-tickets": { "post": { - "tags": ["Editor Projects"], + "tags": [ + "Asset Objects" + ], + "operationId": "createExternalDirectUploadTicket", + "summary": "创建素材直传 OSS 凭证", + "description": "为外部编辑器素材上传创建一次性 OSS 表单直传参数。上传完成后应调用 objects/confirm 确认 asset object,再创建素材记录。", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDirectUploadTicketRequest" + } + } + } + }, + "responses": { + "200": { + "description": "直传凭证", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDirectUploadTicketResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/external/v1/assets/objects/confirm": { + "post": { + "tags": [ + "Asset Objects" + ], + "operationId": "confirmExternalAssetObject", + "summary": "确认素材对象", + "description": "确认已上传的 OSS 对象并写入 asset_object;ownerUserId 固定使用 API Key 所属账号,请求体中的 ownerUserId 会被忽略。", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmAssetObjectRequest" + } + } + } + }, + "responses": { + "200": { + "description": "素材对象记录", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmAssetObjectResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/external/v1/assets/read-url": { + "get": { + "tags": [ + "Asset Objects" + ], + "operationId": "getExternalAssetReadUrl", + "summary": "获取私有素材读取签名 URL", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "name": "objectKey", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "legacyPublicPath", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "expireSeconds", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "签名读取 URL", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAssetReadUrlResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/external/v1/editor/projects": { + "get": { + "tags": [ + "Editor Projects" + ], + "operationId": "listEditorProjects", + "summary": "列出当前账号图片画布项目", + "security": [ + { + "ExternalApiKey": [] + } + ], + "responses": { + "200": { + "description": "项目列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": [ + "Editor Projects" + ], "operationId": "createEditorProject", "summary": "创建图片画布项目", "security": [ @@ -90,9 +290,43 @@ } } }, + "/api/external/v1/editor/projects/recent": { + "get": { + "tags": [ + "Editor Projects" + ], + "operationId": "loadRecentEditorProject", + "summary": "读取当前账号最近图片画布项目", + "security": [ + { + "ExternalApiKey": [] + } + ], + "responses": { + "200": { + "description": "最近项目;当前账号没有项目时 project 为 null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectRecentResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, "/api/external/v1/editor/projects/{projectId}": { "get": { - "tags": ["Editor Projects"], + "tags": [ + "Editor Projects" + ], "operationId": "getEditorProject", "summary": "读取图片画布项目", "security": [ @@ -126,11 +360,105 @@ "$ref": "#/components/responses/NotFound" } } + }, + "delete": { + "tags": [ + "Editor Projects" + ], + "operationId": "deleteEditorProject", + "summary": "删除图片画布项目", + "description": "删除项目时级联清理默认画布与项目资源元数据。", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "description": "已删除项目", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectDeleteResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/external/v1/editor/projects/{projectId}/metadata": { + "patch": { + "tags": [ + "Editor Projects" + ], + "operationId": "renameEditorProject", + "summary": "更新图片画布项目标题", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectRenameRequest" + } + } + } + }, + "responses": { + "200": { + "description": "更新后的项目快照", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } } }, "/api/external/v1/editor/projects/{projectId}/canvas": { "patch": { - "tags": ["Editor Canvas"], + "tags": [ + "Editor Canvas" + ], "operationId": "saveEditorProjectCanvas", "summary": "保存默认画布布局", "security": [ @@ -164,6 +492,368 @@ } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/external/v1/editor/projects/{projectId}/resources": { + "post": { + "tags": [ + "Editor Assets" + ], + "operationId": "createEditorProjectResource", + "summary": "创建项目画布资源记录", + "description": "把上传、生成或外部已有图片资源写入指定项目的画布资源表,供画布图层引用。", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectResourceCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "已创建项目画布资源", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectResourceResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/external/v1/editor/assets/library": { + "get": { + "tags": [ + "Editor Assets" + ], + "operationId": "getEditorAssetLibrary", + "summary": "读取当前账号编辑器素材库", + "security": [ + { + "ExternalApiKey": [] + } + ], + "responses": { + "200": { + "description": "素材库快照", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetLibraryResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/external/v1/editor/assets/folders": { + "post": { + "tags": [ + "Editor Assets" + ], + "operationId": "createEditorAssetFolder", + "summary": "创建素材文件夹", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetFolderCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "已创建素材文件夹", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/external/v1/editor/assets/folders/{folderId}": { + "patch": { + "tags": [ + "Editor Assets" + ], + "operationId": "updateEditorAssetFolder", + "summary": "更新素材文件夹", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FolderId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetFolderUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "已更新素材文件夹", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "tags": [ + "Editor Assets" + ], + "operationId": "deleteEditorAssetFolder", + "summary": "删除素材文件夹", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FolderId" + } + ], + "responses": { + "200": { + "description": "删除后的素材库快照", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetFolderDeleteResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/external/v1/editor/assets": { + "post": { + "tags": [ + "Editor Assets" + ], + "operationId": "createEditorAsset", + "summary": "创建素材记录", + "description": "创建账号级编辑器素材记录。图片二进制上传仍应先通过已有资产上传链路完成,随后在这里保存素材元数据。", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "已创建素材", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/external/v1/editor/assets/{assetId}": { + "patch": { + "tags": [ + "Editor Assets" + ], + "operationId": "updateEditorAsset", + "summary": "更新素材名称或所在文件夹", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/AssetId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "已更新素材", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "tags": [ + "Editor Assets" + ], + "operationId": "deleteEditorAsset", + "summary": "删除素材记录", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/AssetId" + } + ], + "responses": { + "200": { + "description": "已删除素材", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetResponse" + } + } + } + }, "401": { "$ref": "#/components/responses/Unauthorized" }, @@ -178,9 +868,11 @@ }, "/api/external/v1/editor/images/generations": { "post": { - "tags": ["Editor Images"], - "operationId": "generateEditorImage", - "summary": "生成编辑器美术图片", + "tags": [ + "Editor Images" + ], + "operationId": "generateExternalEditorImage", + "summary": "生成编辑器图片素材", "security": [ { "ExternalApiKey": [] @@ -191,7 +883,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalEditorImageGenerationRequest" + "$ref": "#/components/schemas/EditorImageGenerationRequest" } } } @@ -202,7 +894,56 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalEditorImageGenerationResponse" + "$ref": "#/components/schemas/EditorImageGenerationResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" + } + }, + "description": "支持普通生图、规范图、角色图、快速编辑参考图、UI 设计图和宣发素材生成。kind 可取 spec、character、quick-edit、ui-design、publication-material。" + } + }, + "/api/external/v1/editor/images/edits": { + "post": { + "tags": [ + "Editor Images" + ], + "operationId": "editExternalEditorImage", + "summary": "重绘/调整编辑器图片素材", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorImageEditRequest" + } + } + } + }, + "responses": { + "200": { + "description": "重绘结果与落库资源", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorImageGenerationResponse" } } } @@ -222,95 +963,290 @@ } } }, - "/api/profile/api-keys": { - "get": { - "tags": ["API Keys"], - "operationId": "listExternalApiKeys", - "summary": "列出当前登录账号的外部 API Key", - "description": "实际路径为 /api/profile/api-keys,使用登录态 Bearer JWT,不使用外部 API Key。", - "security": [ - { - "UserAccessToken": [] - } - ], - "responses": { - "200": { - "description": "API Key 列表", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalApiKeyListResponse" - } - } - } - } - } - }, + "/api/external/v1/editor/icon-spritesheets/generations": { "post": { - "tags": ["API Keys"], - "operationId": "createExternalApiKey", - "summary": "创建外部 API Key", - "description": "实际路径为 /api/profile/api-keys,明文 apiKey 只在本响应返回一次。", + "tags": [ + "Editor Images" + ], + "operationId": "generateExternalEditorIconSpritesheet", + "summary": "按规范图生成并拆分图标素材", "security": [ { - "UserAccessToken": [] + "ExternalApiKey": [] } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalApiKeyCreateRequest" + "$ref": "#/components/schemas/EditorIconSpritesheetGenerationRequest" } } } }, "responses": { "200": { - "description": "新建 API Key", + "description": "图标 spritesheet、切片结果与落库资源", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalApiKeyCreateResponse" + "$ref": "#/components/schemas/EditorIconSpritesheetGenerationResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" } } } }, - "/api/profile/api-keys/{keyId}": { - "delete": { - "tags": ["API Keys"], - "operationId": "revokeExternalApiKey", - "summary": "撤销外部 API Key", - "description": "实际路径为 /api/profile/api-keys/{keyId},使用登录态 Bearer JWT。", + "/api/external/v1/editor/ui-designs/assets/extractions": { + "post": { + "tags": [ + "Editor Images" + ], + "operationId": "extractExternalEditorUiDesignAssets", + "summary": "从 UI 设计图拆分素材", "security": [ { - "UserAccessToken": [] + "ExternalApiKey": [] } ], - "parameters": [ - { - "name": "keyId", - "in": "path", - "required": true, - "schema": { - "type": "string" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorUiDesignAssetExtractionRequest" + } } } - ], + }, "responses": { "200": { - "description": "已撤销 API Key", + "description": "UI 设计图素材 spritesheet、切片结果与落库资源", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalApiKeyResponse" + "$ref": "#/components/schemas/EditorIconSpritesheetGenerationResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" + } + } + } + }, + "/api/external/v1/editor/character-animations/generations": { + "post": { + "tags": [ + "Editor Videos" + ], + "operationId": "generateExternalEditorCharacterAnimation", + "summary": "生成编辑器角色动画帧", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorCharacterAnimationGenerationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "角色动画视频预览与抽帧结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorCharacterAnimationGenerationResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" + } + } + } + }, + "/api/external/v1/editor/videos/generations": { + "post": { + "tags": [ + "Editor Videos" + ], + "operationId": "generateExternalEditorVideo", + "summary": "生成编辑器视频素材", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorVideoGenerationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "视频生成结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorVideoGenerationResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" + } + } + } + }, + "/api/external/v1/editor/audios/sound-effects/generations": { + "post": { + "tags": [ + "Editor Audio" + ], + "operationId": "generateExternalEditorSoundEffect", + "summary": "生成编辑器音效素材", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorSoundEffectGenerationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "音效生成结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorAudioGenerationResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" + } + } + } + }, + "/api/external/v1/editor/audios/background-music/generations": { + "post": { + "tags": [ + "Editor Audio" + ], + "operationId": "generateExternalEditorBackgroundMusic", + "summary": "生成编辑器背景音乐素材", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorBackgroundMusicGenerationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "背景音乐生成结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorAudioGenerationResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" } } } @@ -322,11 +1258,6 @@ "type": "http", "scheme": "bearer", "bearerFormat": "tnr_sk" - }, - "UserAccessToken": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" } }, "parameters": { @@ -337,6 +1268,22 @@ "schema": { "type": "string" } + }, + "FolderId": { + "name": "folderId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + "AssetId": { + "name": "assetId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } } }, "responses": { @@ -402,9 +1349,407 @@ }, "additionalProperties": false }, + "CreateDirectUploadTicketRequest": { + "type": "object", + "required": [ + "legacyPrefix", + "fileName" + ], + "properties": { + "legacyPrefix": { + "type": "string", + "description": "历史资源前缀,例如 generated-character-drafts。" + }, + "pathSegments": { + "type": "array", + "items": { + "type": "string" + } + }, + "fileName": { + "type": "string" + }, + "contentType": { + "type": [ + "string", + "null" + ] + }, + "access": { + "type": [ + "string", + "null" + ], + "enum": [ + "public", + "private", + null + ], + "default": "private" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "maxSizeBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "expireSeconds": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "successActionStatus": { + "type": [ + "integer", + "null" + ], + "enum": [ + 200, + 201, + 204, + null + ] + } + }, + "additionalProperties": false + }, + "CreateDirectUploadTicketResponse": { + "type": "object", + "required": [ + "upload" + ], + "properties": { + "upload": { + "$ref": "#/components/schemas/DirectUploadTicket" + } + } + }, + "DirectUploadTicket": { + "type": "object", + "required": [ + "signatureVersion", + "provider", + "bucket", + "endpoint", + "host", + "objectKey", + "legacyPublicPath", + "access", + "keyPrefix", + "expiresAt", + "maxSizeBytes", + "successActionStatus", + "formFields" + ], + "properties": { + "signatureVersion": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "host": { + "type": "string" + }, + "objectKey": { + "type": "string" + }, + "legacyPublicPath": { + "type": "string" + }, + "contentType": { + "type": [ + "string", + "null" + ] + }, + "access": { + "type": "string", + "enum": [ + "public", + "private" + ] + }, + "keyPrefix": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "maxSizeBytes": { + "type": "integer", + "minimum": 1 + }, + "successActionStatus": { + "type": "integer" + }, + "formFields": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "ConfirmAssetObjectRequest": { + "type": "object", + "required": [ + "objectKey", + "assetKind" + ], + "properties": { + "bucket": { + "type": [ + "string", + "null" + ] + }, + "objectKey": { + "type": "string" + }, + "contentType": { + "type": [ + "string", + "null" + ] + }, + "contentLength": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "contentHash": { + "type": [ + "string", + "null" + ] + }, + "assetKind": { + "type": "string" + }, + "accessPolicy": { + "type": [ + "string", + "null" + ], + "enum": [ + "private", + "public_read", + null + ], + "default": "private" + }, + "sourceJobId": { + "type": [ + "string", + "null" + ] + }, + "ownerUserId": { + "type": [ + "string", + "null" + ], + "deprecated": true, + "description": "外部接口会忽略该字段并使用 API Key 所属账号。" + }, + "profileId": { + "type": [ + "string", + "null" + ] + }, + "entityId": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "ConfirmAssetObjectResponse": { + "type": "object", + "required": [ + "assetObject" + ], + "properties": { + "assetObject": { + "$ref": "#/components/schemas/AssetObject" + } + } + }, + "AssetObject": { + "type": "object", + "required": [ + "assetObjectId", + "bucket", + "objectKey", + "accessPolicy", + "contentLength", + "version", + "assetKind", + "createdAt", + "updatedAt" + ], + "properties": { + "assetObjectId": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "objectKey": { + "type": "string" + }, + "accessPolicy": { + "type": "string" + }, + "contentType": { + "type": [ + "string", + "null" + ] + }, + "contentLength": { + "type": "integer", + "minimum": 0 + }, + "contentHash": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "integer", + "minimum": 1 + }, + "sourceJobId": { + "type": [ + "string", + "null" + ] + }, + "ownerUserId": { + "type": [ + "string", + "null" + ] + }, + "profileId": { + "type": [ + "string", + "null" + ] + }, + "entityId": { + "type": [ + "string", + "null" + ] + }, + "assetKind": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "GetAssetReadUrlResponse": { + "type": "object", + "required": [ + "read" + ], + "properties": { + "read": { + "$ref": "#/components/schemas/AssetReadUrl" + } + } + }, + "AssetReadUrl": { + "type": "object", + "required": [ + "provider", + "bucket", + "endpoint", + "host", + "objectKey", + "expiresAt", + "signedUrl" + ], + "properties": { + "provider": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "host": { + "type": "string" + }, + "objectKey": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "signedUrl": { + "type": "string" + } + } + }, + "ExternalEditorProjectRenameRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "additionalProperties": false + }, "ExternalEditorCanvasSaveRequest": { "type": "object", - "required": ["viewport", "layers"], + "required": [ + "viewport", + "layers" + ], "properties": { "viewport": { "$ref": "#/components/schemas/EditorCanvasViewport" @@ -416,69 +1761,66 @@ }, "additionalProperties": false }, - "ExternalEditorImageGenerationRequest": { + "ExternalEditorAssetFolderCreateRequest": { "type": "object", - "required": ["prompt"], + "required": [ + "label" + ], "properties": { - "prompt": { - "type": "string", - "minLength": 1 - }, - "projectId": { + "label": { "type": "string" }, - "title": { - "type": "string", - "description": "写入素材库时使用的素材名称。" - }, - "size": { - "type": "string", - "enum": ["1024x1024", "1536x1024", "1024x1536", "2048x1152", "2048x2048"], - "default": "1024x1024" - }, - "referenceImageSrcs": { - "type": "array", - "maxItems": 5, - "items": { - "type": "string", - "description": "图片 Data URL。" - } - }, - "layers": { - "type": "object", - "description": "可选。带 projectId 时,生图完成后顺带保存画布图层 JSON。" - }, - "viewport": { - "$ref": "#/components/schemas/EditorCanvasViewport" + "sortOrder": { + "type": "integer", + "minimum": 0, + "default": 100 } }, "additionalProperties": false }, - "ExternalEditorProjectResponse": { + "ExternalEditorAssetFolderUpdateRequest": { "type": "object", - "required": ["project"], "properties": { - "project": { - "$ref": "#/components/schemas/EditorProject" + "label": { + "type": "string" + }, + "collapsed": { + "type": "boolean" } - } + }, + "additionalProperties": false }, - "ExternalEditorImageGenerationResponse": { + "ExternalEditorAssetCreateRequest": { "type": "object", "required": [ + "folderId", + "label", "imageSrc", "width", "height", - "prompt", - "model", - "provider", - "taskId", - "asset" + "sourceType" ], "properties": { + "folderId": { + "type": "string" + }, + "label": { + "type": "string" + }, "imageSrc": { - "type": "string", - "description": "生成图片 Data URL。" + "type": "string" + }, + "objectKey": { + "type": [ + "string", + "null" + ] + }, + "assetObjectId": { + "type": [ + "string", + "null" + ] }, "width": { "type": "integer", @@ -488,34 +1830,163 @@ "type": "integer", "minimum": 1 }, + "sourceType": { + "type": "string" + }, "prompt": { - "type": "string" - }, - "actualPrompt": { - "type": ["string", "null"] - }, - "model": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "taskId": { - "type": "string" - }, - "asset": { - "$ref": "#/components/schemas/EditorAsset" - }, - "resource": { - "anyOf": [ - { - "$ref": "#/components/schemas/EditorProjectResource" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, + "actualPrompt": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "taskId": { + "type": [ + "string", + "null" + ] + }, + "assetKind": { + "type": [ + "string", + "null" + ] + }, + "generationInputs": { + "$ref": "#/components/schemas/JsonValue" + } + }, + "additionalProperties": false + }, + "ExternalEditorProjectResourceCreateRequest": { + "type": "object", + "required": [ + "imageSrc", + "width", + "height", + "sourceType" + ], + "properties": { + "imageSrc": { + "type": "string" + }, + "objectKey": { + "type": [ + "string", + "null" + ] + }, + "assetObjectId": { + "type": [ + "string", + "null" + ] + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "sourceType": { + "type": "string" + }, + "prompt": { + "type": [ + "string", + "null" + ] + }, + "actualPrompt": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "taskId": { + "type": [ + "string", + "null" + ] + }, + "sourceResourceId": { + "type": [ + "string", + "null" + ] + }, + "assetKind": { + "type": [ + "string", + "null" + ] + }, + "generationInputs": { + "$ref": "#/components/schemas/JsonValue" + } + }, + "additionalProperties": false + }, + "ExternalEditorAssetUpdateRequest": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "folderId": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ExternalEditorProjectResponse": { + "type": "object", + "required": [ + "project" + ], + "properties": { + "project": { + "$ref": "#/components/schemas/EditorProject" + } + } + }, + "ExternalEditorProjectRecentResponse": { + "type": "object", + "required": [ + "project" + ], + "properties": { "project": { "anyOf": [ { @@ -528,9 +1999,97 @@ } } }, + "ExternalEditorProjectListResponse": { + "type": "object", + "required": [ + "projects" + ], + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EditorProject" + } + } + } + }, + "ExternalEditorProjectDeleteResponse": { + "type": "object", + "required": [ + "deletedProjectId" + ], + "properties": { + "deletedProjectId": { + "type": "string" + } + } + }, + "ExternalEditorAssetLibraryResponse": { + "type": "object", + "required": [ + "library" + ], + "properties": { + "library": { + "$ref": "#/components/schemas/EditorAssetLibrary" + } + } + }, + "ExternalEditorAssetFolderResponse": { + "type": "object", + "required": [ + "folder" + ], + "properties": { + "folder": { + "$ref": "#/components/schemas/EditorAssetFolder" + } + } + }, + "ExternalEditorAssetFolderDeleteResponse": { + "type": "object", + "required": [ + "library" + ], + "properties": { + "library": { + "$ref": "#/components/schemas/EditorAssetLibrary" + } + } + }, + "ExternalEditorAssetResponse": { + "type": "object", + "required": [ + "asset" + ], + "properties": { + "asset": { + "$ref": "#/components/schemas/EditorAsset" + } + } + }, + "ExternalEditorProjectResourceResponse": { + "type": "object", + "required": [ + "resource" + ], + "properties": { + "resource": { + "$ref": "#/components/schemas/EditorProjectResource" + } + } + }, "EditorProject": { "type": "object", - "required": ["projectId", "title", "canvas", "viewport", "layers", "resources", "updatedAt"], + "required": [ + "projectId", + "title", + "canvas", + "viewport", + "layers", + "resources", + "updatedAt" + ], "properties": { "projectId": { "type": "string" @@ -561,7 +2120,15 @@ }, "EditorCanvas": { "type": "object", - "required": ["canvasId", "projectId", "title", "viewport", "layers", "createdAt", "updatedAt"], + "required": [ + "canvasId", + "projectId", + "title", + "viewport", + "layers", + "createdAt", + "updatedAt" + ], "properties": { "canvasId": { "type": "string" @@ -590,7 +2157,11 @@ }, "EditorCanvasViewport": { "type": "object", - "required": ["x", "y", "scale"], + "required": [ + "x", + "y", + "scale" + ], "properties": { "x": { "type": "number" @@ -608,7 +2179,16 @@ }, "EditorProjectResource": { "type": "object", - "required": ["resourceId", "projectId", "imageSrc", "width", "height", "sourceType", "createdAt", "updatedAt"], + "required": [ + "resourceId", + "projectId", + "imageSrc", + "width", + "height", + "sourceType", + "createdAt", + "updatedAt" + ], "properties": { "resourceId": { "type": "string" @@ -620,10 +2200,16 @@ "type": "string" }, "objectKey": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "assetObjectId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "width": { "type": "integer", @@ -637,22 +2223,107 @@ "type": "string" }, "prompt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "actualPrompt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "model": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "provider": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "taskId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "sourceResourceId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] + }, + "assetKind": { + "type": [ + "string", + "null" + ] + }, + "generationInputs": { + "$ref": "#/components/schemas/JsonValue" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "EditorAssetLibrary": { + "type": "object", + "required": [ + "folders", + "assets" + ], + "properties": { + "folders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EditorAssetFolder" + } + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EditorAsset" + } + } + } + }, + "EditorAssetFolder": { + "type": "object", + "required": [ + "folderId", + "label", + "sortOrder", + "collapsed", + "systemDefault", + "createdAt", + "updatedAt" + ], + "properties": { + "folderId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "collapsed": { + "type": "boolean" + }, + "systemDefault": { + "type": "boolean" }, "createdAt": { "type": "string", @@ -666,7 +2337,17 @@ }, "EditorAsset": { "type": "object", - "required": ["assetId", "folderId", "label", "imageSrc", "width", "height", "sourceType", "createdAt", "updatedAt"], + "required": [ + "assetId", + "folderId", + "label", + "imageSrc", + "width", + "height", + "sourceType", + "createdAt", + "updatedAt" + ], "properties": { "assetId": { "type": "string" @@ -681,10 +2362,16 @@ "type": "string" }, "objectKey": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "assetObjectId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "width": { "type": "integer", @@ -698,19 +2385,43 @@ "type": "string" }, "prompt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "actualPrompt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "model": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "provider": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "taskId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] + }, + "assetKind": { + "type": [ + "string", + "null" + ] + }, + "generationInputs": { + "$ref": "#/components/schemas/JsonValue" }, "createdAt": { "type": "string", @@ -722,86 +2433,8 @@ } } }, - "ExternalApiKeyCreateRequest": { - "type": "object", - "properties": { - "name": { - "type": "string", - "maxLength": 80 - } - }, - "additionalProperties": false - }, - "ExternalApiKeyCreateResponse": { - "type": "object", - "required": ["apiKey", "key"], - "properties": { - "apiKey": { - "type": "string", - "description": "仅创建时返回一次。" - }, - "key": { - "$ref": "#/components/schemas/ExternalApiKey" - } - } - }, - "ExternalApiKeyListResponse": { - "type": "object", - "required": ["keys"], - "properties": { - "keys": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExternalApiKey" - } - } - } - }, - "ExternalApiKeyResponse": { - "type": "object", - "required": ["key"], - "properties": { - "key": { - "$ref": "#/components/schemas/ExternalApiKey" - } - } - }, - "ExternalApiKey": { - "type": "object", - "required": ["keyId", "name", "keyPrefix", "scopes", "createdAt", "updatedAt"], - "properties": { - "keyId": { - "type": "string" - }, - "name": { - "type": "string" - }, - "keyPrefix": { - "type": "string" - }, - "scopes": { - "type": "array", - "items": { - "type": "string" - } - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "lastUsedAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "revokedAt": { - "type": ["string", "null"], - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } + "JsonValue": { + "description": "任意 JSON 值。" }, "ErrorResponse": { "type": "object", @@ -813,7 +2446,934 @@ "type": "string" }, "details": { - "type": ["object", "null"] + "type": [ + "object", + "null" + ] + } + } + }, + "EditorImageGenerationRequest": { + "type": "object", + "required": [ + "prompt" + ], + "properties": { + "prompt": { + "type": "string", + "minLength": 1 + }, + "size": { + "type": "string", + "description": "兼容旧 size 入参;未传 aspectRatio/imageSize 时生效。", + "enum": [ + "1024x1024", + "1536x1024", + "1024x1536", + "2048x1152", + "2048x2048", + "2k-16:9", + "1:1", + "16:9", + "9:16" + ] + }, + "kind": { + "type": "string", + "enum": [ + "spec", + "character", + "quick-edit", + "ui-design", + "publication-material" + ], + "default": "spec" + }, + "model": { + "type": "string", + "description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时沿用编辑器默认。" + }, + "aspectRatio": { + "type": "string", + "enum": [ + "1:1", + "2:3", + "3:2", + "9:16", + "16:9" + ], + "default": "1:1" + }, + "imageSize": { + "type": "string", + "enum": [ + "0.5K", + "1K", + "2K" + ], + "default": "1K" + }, + "referenceImageSrcs": { + "type": "array", + "items": { + "type": "string", + "description": "图片 Data URL。quick-edit 最多 9 张,其它模式最多使用前 5 张。" + }, + "maxItems": 9 + }, + "projectId": { + "type": [ + "string", + "null" + ] + }, + "assetKind": { + "type": [ + "string", + "null" + ] + }, + "generationInputs": { + "$ref": "#/components/schemas/JsonValue" + }, + "assetFolderId": { + "type": [ + "string", + "null" + ], + "description": "传 project 时写入默认项目素材文件夹;传具体 folderId 时写入该文件夹。" + }, + "assetLabel": { + "type": [ + "string", + "null" + ], + "description": "写入素材库时使用的素材名称。" + }, + "sourceResourceId": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "EditorImageEditRequest": { + "type": "object", + "required": [ + "prompt", + "sourceImageSrc" + ], + "properties": { + "prompt": { + "type": "string", + "minLength": 1 + }, + "sourceImageSrc": { + "type": "string", + "description": "待重绘/调整的图片 Data URL。" + }, + "projectId": { + "type": [ + "string", + "null" + ] + }, + "assetKind": { + "type": [ + "string", + "null" + ] + }, + "generationInputs": { + "$ref": "#/components/schemas/JsonValue" + }, + "assetFolderId": { + "type": [ + "string", + "null" + ] + }, + "assetLabel": { + "type": [ + "string", + "null" + ] + }, + "sourceResourceId": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "EditorImageGenerationResponse": { + "type": "object", + "required": [ + "imageSrc", + "width", + "height", + "sourceType", + "prompt", + "model", + "provider", + "taskId" + ], + "properties": { + "imageSrc": { + "type": "string" + }, + "objectKey": { + "type": [ + "string", + "null" + ] + }, + "assetObjectId": { + "type": [ + "string", + "null" + ] + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "sourceType": { + "type": "string", + "const": "generated" + }, + "prompt": { + "type": "string" + }, + "actualPrompt": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "resource": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorProjectResource" + }, + { + "type": "null" + } + ] + }, + "asset": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorAsset" + }, + { + "type": "null" + } + ] + } + } + }, + "EditorIconSpritesheetGenerationRequest": { + "type": "object", + "required": [ + "referenceImageSrc", + "iconDescriptions", + "priceMudPoints" + ], + "properties": { + "referenceImageSrc": { + "type": "string", + "description": "规范图/参考图 Data URL。" + }, + "iconDescriptions": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + }, + "model": { + "type": "string", + "default": "gemini-3.1-flash-image-preview" + }, + "aspectRatio": { + "type": "string", + "enum": [ + "1:1", + "2:3", + "3:2", + "9:16", + "16:9" + ], + "default": "1:1" + }, + "imageSize": { + "type": "string", + "enum": [ + "0.5K", + "1K", + "2K" + ], + "default": "1K" + }, + "priceMudPoints": { + "type": "integer", + "minimum": 0 + }, + "projectId": { + "type": [ + "string", + "null" + ] + }, + "generationInputs": { + "$ref": "#/components/schemas/JsonValue" + }, + "assetFolderId": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "EditorUiDesignAssetExtractionRequest": { + "type": "object", + "required": [ + "sourceImageSrc" + ], + "properties": { + "sourceImageSrc": { + "type": "string", + "description": "UI 设计图 Data URL。" + }, + "projectId": { + "type": [ + "string", + "null" + ] + }, + "generationInputs": { + "$ref": "#/components/schemas/JsonValue" + }, + "assetFolderId": { + "type": [ + "string", + "null" + ] + }, + "spritesheetLabel": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "EditorIconSpritesheetIconResult": { + "type": "object", + "required": [ + "name", + "imageSrc", + "width", + "height" + ], + "properties": { + "name": { + "type": "string" + }, + "imageSrc": { + "type": "string" + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "resource": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorProjectResource" + }, + { + "type": "null" + } + ] + }, + "asset": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorAsset" + }, + { + "type": "null" + } + ] + } + } + }, + "EditorIconSpritesheetGenerationResponse": { + "type": "object", + "required": [ + "spritesheetImageSrc", + "spritesheetWidth", + "spritesheetHeight", + "iconImageSrcs", + "prompt", + "model", + "provider", + "taskId", + "priceMudPoints" + ], + "properties": { + "spritesheetImageSrc": { + "type": "string" + }, + "spritesheetWidth": { + "type": "integer", + "minimum": 1 + }, + "spritesheetHeight": { + "type": "integer", + "minimum": 1 + }, + "iconImageSrcs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" + } + }, + "prompt": { + "type": "string" + }, + "actualPrompt": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "priceMudPoints": { + "type": "integer", + "minimum": 0 + }, + "spritesheetResource": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorProjectResource" + }, + { + "type": "null" + } + ] + }, + "spritesheetAsset": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorAsset" + }, + { + "type": "null" + } + ] + } + } + }, + "EditorCharacterAnimationGenerationRequest": { + "type": "object", + "required": [ + "sourceLayerId", + "sourceImageSrc", + "sourceWidth", + "sourceHeight", + "promptText", + "resolution", + "ratio", + "frameCount", + "durationSeconds", + "priceMudPoints", + "model" + ], + "properties": { + "sourceLayerId": { + "type": "string" + }, + "sourceImageSrc": { + "type": "string" + }, + "sourceWidth": { + "type": "integer", + "minimum": 1 + }, + "sourceHeight": { + "type": "integer", + "minimum": 1 + }, + "promptText": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "resolution": { + "type": "string", + "enum": [ + "480p", + "720p" + ], + "default": "480p" + }, + "ratio": { + "type": "string", + "enum": [ + "same", + "1:1", + "4:3", + "16:9", + "9:16", + "3:4" + ], + "default": "same" + }, + "frameCount": { + "type": "integer", + "enum": [ + 32, + 40, + 48 + ] + }, + "durationSeconds": { + "type": "integer", + "enum": [ + 4, + 5, + 6 + ] + }, + "priceMudPoints": { + "type": "integer", + "minimum": 0 + }, + "model": { + "type": "string", + "const": "seedance2.0-fast" + } + }, + "additionalProperties": false + }, + "EditorCharacterAnimationFrame": { + "type": "object", + "required": [ + "frameIndex", + "imageSrc", + "width", + "height" + ], + "properties": { + "frameIndex": { + "type": "integer", + "minimum": 0 + }, + "imageSrc": { + "type": "string" + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + } + } + }, + "EditorCharacterAnimationGenerationResponse": { + "type": "object", + "required": [ + "ok", + "taskId", + "model", + "prompt", + "previewVideoPath", + "frames", + "frameCount", + "durationSeconds", + "frameWidth", + "frameHeight", + "fps", + "priceMudPoints" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "taskId": { + "type": "string" + }, + "model": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "previewVideoPath": { + "type": "string" + }, + "frames": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EditorCharacterAnimationFrame" + } + }, + "frameCount": { + "type": "integer", + "minimum": 1 + }, + "durationSeconds": { + "type": "integer", + "minimum": 1 + }, + "frameWidth": { + "type": "integer", + "minimum": 1 + }, + "frameHeight": { + "type": "integer", + "minimum": 1 + }, + "fps": { + "type": "integer", + "minimum": 1 + }, + "priceMudPoints": { + "type": "integer", + "minimum": 0 + } + } + }, + "EditorVideoGenerationRequest": { + "type": "object", + "required": [ + "prompt", + "model", + "aspectRatio", + "durationSeconds", + "resolution", + "mode", + "sound", + "priceMudPoints" + ], + "properties": { + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "model": { + "type": "string", + "enum": [ + "seedance2.0", + "seedance2.0-fast", + "kling3.0", + "kling3.0-omni", + "veo3.1", + "veo3.1-fast" + ] + }, + "aspectRatio": { + "type": "string", + "const": "16:9" + }, + "durationSeconds": { + "type": "integer", + "enum": [ + 4, + 5 + ] + }, + "resolution": { + "type": "string", + "enum": [ + "480p", + "720p" + ] + }, + "mode": { + "type": "string", + "const": "std" + }, + "sound": { + "type": "string", + "const": "off" + }, + "priceMudPoints": { + "type": "integer", + "minimum": 0 + }, + "referenceImageSrcs": { + "type": "array", + "items": { + "type": "string", + "description": "公网 URL、asset://、OSS object key 或图片 Data URL。仅 Seedance 2.0 系列支持。" + }, + "maxItems": 9 + }, + "referenceVideoSrcs": { + "type": "array", + "items": { + "type": "string", + "description": "公网 URL、asset:// 或 OSS object key。仅 Seedance 2.0 系列支持。" + }, + "maxItems": 3 + }, + "referenceAudioSrcs": { + "type": "array", + "items": { + "type": "string", + "description": "公网 URL、asset://、OSS object key 或音频 Data URL。仅 Seedance 2.0 系列支持。" + }, + "maxItems": 3 + } + }, + "additionalProperties": false + }, + "EditorVideoGenerationResponse": { + "type": "object", + "required": [ + "ok", + "videoSrc", + "width", + "height", + "sourceType", + "prompt", + "model", + "provider", + "taskId", + "durationSeconds", + "resolution", + "priceMudPoints" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "videoSrc": { + "type": "string" + }, + "objectKey": { + "type": [ + "string", + "null" + ] + }, + "assetObjectId": { + "type": [ + "string", + "null" + ] + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "sourceType": { + "type": "string", + "const": "generated" + }, + "prompt": { + "type": "string" + }, + "actualPrompt": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "durationSeconds": { + "type": "integer", + "minimum": 1 + }, + "resolution": { + "type": "string" + }, + "priceMudPoints": { + "type": "integer", + "minimum": 0 + } + } + }, + "EditorSoundEffectGenerationRequest": { + "type": "object", + "required": [ + "prompt", + "duration", + "priceMudPoints" + ], + "properties": { + "prompt": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": [ + "string", + "null" + ], + "default": "audio1.0" + }, + "duration": { + "type": "integer", + "minimum": 2, + "maximum": 10 + }, + "priceMudPoints": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "EditorBackgroundMusicGenerationRequest": { + "type": "object", + "required": [ + "gptDescriptionPrompt", + "makeInstrumental", + "priceMudPoints" + ], + "properties": { + "gptDescriptionPrompt": { + "type": "string", + "minLength": 1 + }, + "makeInstrumental": { + "type": "boolean", + "const": true + }, + "priceMudPoints": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "EditorAudioGenerationResponse": { + "type": "object", + "required": [ + "ok", + "audioSrc", + "width", + "height", + "sourceType", + "prompt", + "model", + "provider", + "taskId", + "priceMudPoints", + "audioKind" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "audioSrc": { + "type": "string" + }, + "objectKey": { + "type": [ + "string", + "null" + ] + }, + "assetObjectId": { + "type": [ + "string", + "null" + ] + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "sourceType": { + "type": "string", + "const": "generated" + }, + "prompt": { + "type": "string" + }, + "actualPrompt": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "priceMudPoints": { + "type": "integer", + "minimum": 0 + }, + "audioKind": { + "type": "string", + "enum": [ + "sound-effect", + "background-music" + ] } } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1dd0d737f..04a621a11 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -19,7 +19,7 @@ ## 2026-06-19 外部 OpenAPI 与 API Key 管理走 server-rs 正式链路 - 背景:外部调用方需要稳定调用图片画布项目创建、画布布局保存和编辑器美术生图能力,同时需要可撤销的开发者凭据,不能依赖前端临时状态或人工分发密钥。 -- 决策:外部 API 固定放在 `/api/external/v1` 命名空间,v1 暴露项目创建 / 读取、默认画布保存、编辑器美术生图和 `/api/external/v1/openapi.json`。API Key 管理走登录态 `/api/profile/api-keys`,外部调用使用 `Authorization: Bearer tnr_sk_xxx`;后端只保存 `key_hash` 和 `key_prefix`,明文只在创建响应返回一次。外部 API 鉴权、项目 / 画布 / 素材写回全部经 `api-server -> spacetime-client -> spacetime-module`,生成图片成功后同时写入账号级 `editor_asset`,带 `projectId` 时写入 `editor_project_resource`。 +- 决策:外部 API 固定放在 `/api/external/v1` 命名空间,v1 暴露素材直传凭证 / asset object 确认 / 签名读取、项目列表 / 最近 / 创建 / 读取 / 重命名 / 删除、默认画布保存、账号级素材库、项目资源记录、编辑器图片 / 视频 / 音频生成和 `/api/external/v1/openapi.json`。API Key 管理走登录态 `/api/profile/api-keys`,外部调用使用 `Authorization: Bearer tnr_sk_xxx`;后端只保存 `key_hash` 和 `key_prefix`,明文只在创建响应返回一次。外部 API 鉴权、项目 / 画布 / 素材写回全部经 `api-server -> spacetime-client -> spacetime-module`,生成素材成功后按请求写入账号级 `editor_asset`,带 `projectId` 时写入 `editor_project_resource`;外部确认 asset object 时 owner 固定为 API Key 所属账号。API Key 管理接口不进入外部 OpenAPI JSON。 - 影响范围:`server-rs/crates/api-server/src/external_*`、`server-rs/crates/api-server/src/modules/external_api.rs`、`server-rs/crates/spacetime-module/src/external_api_key_storage.rs`、`server-rs/crates/spacetime-client/src/external_api_key.rs`、`docs/openapi/genarrative-external-v1.openapi.json` 和后端数据契约文档。 - 验证方式:`cargo test -p api-server external_api --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server external_editor_api --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 922ac32b9..956b80f2b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -59,6 +59,7 @@ npm run check:server-rs-ddd - 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。 - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 - 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-*`,负责直传、确认、绑定和读取。 +- 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。 - 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`。 - 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。 - 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。 @@ -433,7 +434,7 @@ npm run check:server-rs-ddd - Rust 结构体:`ExternalApiKey` - 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs` -- 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB。 +- 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB,且 API Key 管理接口不写入外部 OpenAPI JSON。v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。 - 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。 ### `editor_project` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 34a4b8daf..f0a8aa06a 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -2,7 +2,7 @@ ## 背景 -外部调用方需要通过稳定 HTTP 契约使用图片画布编辑器内的美术生图能力,并能创建项目、保存画板布局。该能力必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态或生成结果放到前端临时状态中。 +外部调用方需要通过稳定 HTTP 契约使用图片画布编辑器内的素材生成、编辑和管理能力,并能创建项目、保存画板布局和管理账号级素材库。该能力必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态、素材状态或生成结果放到前端临时状态中。 ## v1 范围 @@ -14,13 +14,35 @@ v1 只开放以下能力: +- `POST /api/external/v1/assets/direct-upload-tickets`:创建素材直传 OSS 凭证。 +- `POST /api/external/v1/assets/objects/confirm`:确认已上传素材对象,`ownerUserId` 固定为 API Key 所属账号。 +- `GET /api/external/v1/assets/read-url`:获取私有素材读取签名 URL。 +- `GET /api/external/v1/editor/projects`:列出当前 API Key 所属账号的图片画布项目。 - `POST /api/external/v1/editor/projects`:创建图片画布项目。 +- `GET /api/external/v1/editor/projects/recent`:读取当前账号最近图片画布项目。 - `GET /api/external/v1/editor/projects/{projectId}`:读取项目与默认画布。 +- `PATCH /api/external/v1/editor/projects/{projectId}/metadata`:更新图片画布项目标题。 +- `DELETE /api/external/v1/editor/projects/{projectId}`:删除图片画布项目,并级联清理默认画布和项目资源元数据。 - `PATCH /api/external/v1/editor/projects/{projectId}/canvas`:保存默认画布的 viewport 和 layers。 -- `POST /api/external/v1/editor/images/generations`:调用编辑器美术生图能力;可选传入 `projectId`,生成后自动写入 `editor_project_resource`,同时写入账号级 `editor_asset` 素材库。 +- `POST /api/external/v1/editor/projects/{projectId}/resources`:创建项目画布资源记录。 +- `GET /api/external/v1/editor/assets/library`:读取账号级编辑器素材库。 +- `POST /api/external/v1/editor/assets/folders`:创建素材文件夹。 +- `PATCH /api/external/v1/editor/assets/folders/{folderId}`:更新素材文件夹名称或折叠状态。 +- `DELETE /api/external/v1/editor/assets/folders/{folderId}`:删除素材文件夹并返回最新素材库。 +- `POST /api/external/v1/editor/assets`:创建素材记录。 +- `PATCH /api/external/v1/editor/assets/{assetId}`:更新素材名称或所在文件夹。 +- `DELETE /api/external/v1/editor/assets/{assetId}`:删除素材记录。 +- `POST /api/external/v1/editor/images/generations`:调用编辑器图片素材生成能力;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`。可选传入 `projectId` 和 `assetFolderId`,生成后按站内编辑器规则写入 `editor_project_resource` 和账号级 `editor_asset`。 +- `POST /api/external/v1/editor/images/edits`:重绘 / 调整已有图片,结果可写入项目资源和素材库。 +- `POST /api/external/v1/editor/icon-spritesheets/generations`:按规范图生成图标 spritesheet,并拆分为独立图标素材。 +- `POST /api/external/v1/editor/ui-designs/assets/extractions`:从 UI 设计图中提取 / 拆分素材。 +- `POST /api/external/v1/editor/character-animations/generations`:基于角色图片生成角色动画预览和帧序列。 +- `POST /api/external/v1/editor/videos/generations`:生成编辑器视频素材,支持现有 Seedance / Kling / Veo 模型参数和参考媒体限制。 +- `POST /api/external/v1/editor/audios/sound-effects/generations`:生成编辑器音效素材。 +- `POST /api/external/v1/editor/audios/background-music/generations`:生成编辑器背景音乐素材。 - `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。 -管理 API Key 的登录态接口: +管理 API Key 的登录态接口保留在站内个人中心链路,但不写入外部 OpenAPI JSON: ```text GET /api/profile/api-keys @@ -28,7 +50,7 @@ POST /api/profile/api-keys DELETE /api/profile/api-keys/{keyId} ``` -前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。 +前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。外部 OpenAPI 只描述 `/api/external/v1` 下可由 API Key 调用的接口,不混入登录态 API Key 管理接口。 ## 鉴权 @@ -61,7 +83,7 @@ external_api_key - `name`:用户可识别名称。 - `key_prefix`:前缀片段,用于列表展示和排障。 - `key_hash`:完整 Key 的 SHA-256 十六进制摘要,唯一。 -- `scopes_json`:作用域 JSON,v1 固定包含 `editor:project`、`editor:canvas`、`editor:image-generate`。 +- `scopes_json`:作用域 JSON,v1 固定包含 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`。其中 `editor:image-generate` 覆盖图片生成、重绘、规范图、宣发图、图标拆分、UI 素材拆分、角色动画、视频、音效和音乐生成。 - `created_at` / `last_used_at` / `revoked_at` / `updated_at`。 SpacetimeDB procedure: @@ -71,9 +93,16 @@ SpacetimeDB procedure: - `revoke_external_api_key_and_return` - `authenticate_external_api_key_and_return` -## 生成图落库 +## 素材生成与落库 -外部生图接口复用编辑器内 `VectorEngine` / `gpt-image-2` 生成链路,后端拿到图片后: +外部生成接口复用站内编辑器已有 handler 和 DTO,不维护第二套生成语义: + +- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则。 +- 图标 spritesheet 和 UI 设计图素材提取复用站内拆分逻辑,生成图集后按连通域切片,并把图集与切片都按请求写入项目资源和素材库。 +- 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;音频类外部调用使用 API Key 所属账号作为 asset owner。 +- API Key 管理接口仍只属于登录态个人中心,不进入外部 OpenAPI JSON。 + +图片类外部生成成功后,后端拿到素材后: 1. 通过 OSS / asset object adapter 持久化图片。 2. 写入 `editor_asset`,让生成图进入账号级素材库。 @@ -82,6 +111,17 @@ SpacetimeDB procedure: 如果请求未带 `projectId`,只生成并写入素材库;调用方可随后创建项目或自行保存画板布局。 +## 素材与项目资源操作 + +外部素材操作复用站内图片画布素材库和项目资源的后端事实源: + +- 外部上传素材时先调用 `POST /api/external/v1/assets/direct-upload-tickets` 获取 OSS 表单直传参数,上传完成后调用 `POST /api/external/v1/assets/objects/confirm` 写入 `asset_object`,再用 `assetObjectId` / `objectKey` 创建账号级素材或项目资源记录。 +- 外部读取私有 generated / uploaded 素材预览时调用 `GET /api/external/v1/assets/read-url` 获取短期签名 URL;不开放浏览器 STS 写权限。 +- 素材库读取、文件夹创建 / 更新 / 删除、素材创建 / 更新 / 删除、项目画布资源创建全部通过 `api-server -> spacetime-client -> spacetime-module`。 +- 所有外部素材接口使用 API Key 所属的 `owner_user_id`,不能由请求体传入 owner。 +- 素材库仍是账号级事实源,不归属于单个项目;项目画布内资源继续使用 `editor_project_resource`。 +- 素材创建和项目资源创建接口只保存记录和元数据;图片二进制上传、签名读取和生成图持久化继续复用已有资产 / 生成链路。 + ## OpenAPI 导出 OpenAPI 3.1 JSON 固定落在: @@ -96,6 +136,11 @@ docs/openapi/genarrative-external-v1.openapi.json - API Key 创建只返回一次明文,列表不返回明文。 - 撤销后的 API Key 调用外部接口返回 `401`。 -- 外部生图成功后,生成结果同时出现在画布资源和账号级素材库。 +- 外部图片生成、重绘、图标拆分和 UI 素材拆分成功后,生成结果按请求同时出现在画布资源和账号级素材库。 +- 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。 - OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。 +- OpenAPI JSON 不包含 `/api/profile/api-keys`、`UserAccessToken` 或 API Key 管理 schema。 +- 外部素材库接口覆盖当前已有素材操作:直传凭证、素材对象确认、签名读取、读取素材库、创建 / 更新 / 删除文件夹、创建 / 更新 / 删除素材、创建项目画布资源。 +- 外部项目接口覆盖当前已有项目管理操作:项目列表、最近项目、创建、读取、重命名、删除和默认画布保存。 +- 外部素材生成接口覆盖当前已有编辑器素材操作:生图、重绘 / 调整、规范图生成、宣发素材生成、图标素材生成与拆分、UI 设计图生成与拆分、角色动画、视频、音效和背景音乐。 - 修改 SpacetimeDB schema 后运行 `npm run spacetime:generate` 与 `npm run check:spacetime-schema`。 diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 9a759b6b6..908576bae 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -56,6 +56,21 @@ pub async fn create_direct_upload_ticket( Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, +) -> Result, AppError> { + create_direct_upload_ticket_for_owner( + &state, + &request_context, + authenticated.claims().user_id(), + payload, + ) + .await +} + +pub(crate) async fn create_direct_upload_ticket_for_owner( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + payload: CreateDirectUploadTicketRequest, ) -> Result, AppError> { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ @@ -95,9 +110,9 @@ pub async fn create_direct_upload_ticket( let upload = direct_upload_ticket_payload_from_oss(signed); record_asset_tracking_event( - &state, - &request_context, - &authenticated, + state, + request_context, + owner_user_id, "asset_upload_ticket_create", json!({ "asset": { @@ -117,7 +132,7 @@ pub async fn create_direct_upload_ticket( .await; Ok(json_success_body( - Some(&request_context), + Some(request_context), CreateDirectUploadTicketResponse { upload }, )) } @@ -126,6 +141,14 @@ pub async fn get_asset_read_url( State(state): State, Extension(request_context): Extension, Query(query): Query, +) -> Result, AppError> { + get_asset_read_url_with_query(&state, &request_context, query).await +} + +pub(crate) async fn get_asset_read_url_with_query( + state: &AppState, + request_context: &RequestContext, + query: GetReadUrlQuery, ) -> Result, AppError> { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ @@ -154,7 +177,7 @@ pub async fn get_asset_read_url( })?; Ok(json_success_body( - Some(&request_context), + Some(request_context), GetAssetReadUrlResponse { read: asset_read_url_payload_from_oss(signed), }, @@ -384,6 +407,21 @@ pub async fn confirm_asset_object( Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, +) -> Result, AppError> { + confirm_asset_object_for_owner( + &state, + &request_context, + authenticated.claims().user_id(), + payload, + ) + .await +} + +pub(crate) async fn confirm_asset_object_for_owner( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + payload: ConfirmAssetObjectRequest, ) -> Result, AppError> { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ @@ -395,13 +433,9 @@ pub async fn confirm_asset_object( let result = state .spacetime_client() .confirm_asset_object( - build_confirm_asset_object_upsert_input( - oss_client, - payload, - authenticated.claims().user_id(), - ) - .await - .map_err(map_confirm_asset_object_prepare_error)?, + build_confirm_asset_object_upsert_input(oss_client, payload, owner_user_id) + .await + .map_err(map_confirm_asset_object_prepare_error)?, ) .await .map_err(map_confirm_asset_object_error)?; @@ -425,9 +459,9 @@ pub async fn confirm_asset_object( }; record_asset_tracking_event( - &state, - &request_context, - &authenticated, + state, + request_context, + owner_user_id, "asset_upload_confirm", json!({ "asset": { @@ -451,7 +485,7 @@ pub async fn confirm_asset_object( .await; Ok(json_success_body( - Some(&request_context), + Some(request_context), ConfirmAssetObjectResponse { asset_object }, )) } @@ -498,7 +532,7 @@ pub async fn bind_asset_object_to_entity( record_asset_tracking_event( &state, &request_context, - &authenticated, + authenticated.claims().user_id(), "asset_bind", json!({ "asset": { @@ -526,12 +560,11 @@ pub async fn bind_asset_object_to_entity( async fn record_asset_tracking_event( state: &AppState, request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, + user_id: &str, event_key: &'static str, metadata: Value, ) { - let user_id = authenticated.claims().user_id().to_string(); - let mut draft = TrackingEventDraft::user(event_key, "asset", user_id.as_str()); + let mut draft = TrackingEventDraft::user(event_key, "asset", user_id); draft.metadata = metadata; record_tracking_event_after_success(state, request_context, draft).await; } diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 2741db010..4cc3f89d9 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -520,6 +520,21 @@ pub async fn generate_editor_character_animation( State(state): State, Extension(request_context): Extension, payload: Result, JsonRejection>, +) -> Result, Response> { + generate_editor_character_animation_for_owner( + state, + request_context, + "editor-character-animation".to_string(), + payload, + ) + .await +} + +pub(crate) async fn generate_editor_character_animation_for_owner( + state: AppState, + request_context: RequestContext, + owner_user_id: String, + payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { character_animation_error_response( @@ -538,7 +553,6 @@ pub async fn generate_editor_character_animation( let extraction_settings = resolve_backend_frame_extraction_settings(&state); let http_client = build_upstream_http_client(settings.ark.request_timeout_ms) .map_err(|error| character_animation_error_response(&request_context, error))?; - let owner_user_id = "editor-character-animation".to_string(); let task_id = generate_ai_task_id(current_utc_micros()); let result = async { @@ -601,6 +615,16 @@ pub async fn generate_editor_video( State(state): State, Extension(request_context): Extension, payload: Result, JsonRejection>, +) -> Result, Response> { + generate_editor_video_for_owner(state, request_context, "editor-video".to_string(), payload) + .await +} + +pub(crate) async fn generate_editor_video_for_owner( + state: AppState, + request_context: RequestContext, + owner_user_id: String, + payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { editor_video_error_response( @@ -624,7 +648,7 @@ pub async fn generate_editor_video( &state, &http_client, &settings, - "editor-video", + owner_user_id.as_str(), task_id.as_str(), &normalized, ) diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 48eeb0f5a..2cb3735be 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -81,7 +81,11 @@ fn editor_video_model_resolution_mud_points_per_second( EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND, ), }; - if resolution == "720p" { rate_720p } else { rate_480p } + if resolution == "720p" { + rate_720p + } else { + rate_480p + } } pub(crate) fn editor_video_model_generation_mud_points( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 6d9502cf2..72f9d1403 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -57,7 +57,7 @@ use crate::{ pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-"; pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-"; -const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-"; +pub(crate) const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-"; pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-"; const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024; pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; @@ -164,56 +164,73 @@ pub struct EditorAssetUpdateRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageGenerationRequest { - prompt: String, - size: Option, - kind: Option, - model: Option, - aspect_ratio: Option, - image_size: Option, - reference_image_srcs: Option>, - project_id: Option, - asset_kind: Option, - generation_inputs: Option, - asset_folder_id: Option, - asset_label: Option, - source_resource_id: Option, + pub(crate) prompt: String, + pub(crate) size: Option, + pub(crate) kind: Option, + pub(crate) model: Option, + pub(crate) aspect_ratio: Option, + pub(crate) image_size: Option, + pub(crate) reference_image_srcs: Option>, + pub(crate) project_id: Option, + pub(crate) asset_kind: Option, + pub(crate) generation_inputs: Option, + pub(crate) asset_folder_id: Option, + pub(crate) asset_label: Option, + pub(crate) source_resource_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageEditRequest { - prompt: String, - source_image_src: String, - project_id: Option, - asset_kind: Option, - generation_inputs: Option, - asset_folder_id: Option, - asset_label: Option, - source_resource_id: Option, + pub(crate) prompt: String, + pub(crate) source_image_src: String, + pub(crate) project_id: Option, + pub(crate) asset_kind: Option, + pub(crate) generation_inputs: Option, + pub(crate) asset_folder_id: Option, + pub(crate) asset_label: Option, + pub(crate) source_resource_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetGenerationRequest { - reference_image_src: String, - icon_descriptions: Vec, - model: Option, - aspect_ratio: Option, - image_size: Option, - price_mud_points: u32, - project_id: Option, - generation_inputs: Option, - asset_folder_id: Option, + pub(crate) reference_image_src: String, + pub(crate) icon_descriptions: Vec, + pub(crate) model: Option, + pub(crate) aspect_ratio: Option, + pub(crate) image_size: Option, + pub(crate) price_mud_points: u32, + pub(crate) project_id: Option, + pub(crate) generation_inputs: Option, + pub(crate) asset_folder_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorUiDesignAssetExtractionRequest { - source_image_src: String, - project_id: Option, - generation_inputs: Option, - asset_folder_id: Option, - spritesheet_label: Option, + pub(crate) source_image_src: String, + pub(crate) project_id: Option, + pub(crate) generation_inputs: Option, + pub(crate) asset_folder_id: Option, + pub(crate) spritesheet_label: Option, +} + +pub(crate) struct EditorGenerationCaller { + pub(crate) owner_user_id: String, + pub(crate) audit_subject_user_id: Option, + pub(crate) audit_project_id: Option, +} + +impl EditorGenerationCaller { + fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self { + let owner_user_id = authenticated.claims().user_id().to_string(); + Self { + audit_subject_user_id: Some(owner_user_id.clone()), + owner_user_id, + audit_project_id: None, + } + } } #[derive(Debug, Serialize)] @@ -835,6 +852,21 @@ pub async fn generate_editor_image( Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, +) -> Result, AppError> { + generate_editor_image_for_owner( + &state, + &request_context, + EditorGenerationCaller::from_authenticated(&authenticated), + payload, + ) + .await +} + +pub(crate) async fn generate_editor_image_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: EditorGenerationCaller, + payload: EditorImageGenerationRequest, ) -> Result, AppError> { let role_setting = payload.prompt.trim().to_string(); if role_setting.is_empty() { @@ -899,10 +931,13 @@ pub async fn generate_editor_image( .filter(|source| !source.is_empty()) .take(reference_limit) .collect::>(); - let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( - &request_context, - Some(authenticated.claims().user_id().to_string()), - None, + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + caller.audit_subject_user_id.clone(), + caller + .audit_project_id + .clone() + .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"); @@ -977,8 +1012,8 @@ pub async fn generate_editor_image( }; let generated = if is_publication_material_generation { execute_billable_asset_operation_with_cost( - &state, - authenticated.claims().user_id(), + state, + caller.owner_user_id.as_str(), EDITOR_PUBLICATION_MATERIAL_ASSET_KIND, request_context.request_id(), u64::from(configured_price_mud_points), @@ -1004,8 +1039,8 @@ pub async fn generate_editor_image( let persisted = if is_character_generation { Some( persist_editor_generated_image( - &state, - authenticated.claims().user_id(), + state, + caller.owner_user_id.as_str(), generated.task_id.as_str(), &image, submitted_prompt.as_str(), @@ -1025,10 +1060,10 @@ pub async fn generate_editor_image( BASE64_STANDARD.encode(image.bytes.as_slice()) ); let generated_asset = persist_editor_generated_asset( - &state, + state, PersistEditorGeneratedAssetInput { project_id: payload.project_id, - owner_user_id: current_owner_user_id(&authenticated), + owner_user_id: caller.owner_user_id, folder_id: payload.asset_folder_id, label: normalize_optional_string(payload.asset_label) .unwrap_or_else(|| role_setting.clone()), @@ -1197,6 +1232,21 @@ pub async fn edit_editor_image( Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, +) -> Result, AppError> { + edit_editor_image_for_owner( + &state, + &request_context, + EditorGenerationCaller::from_authenticated(&authenticated), + payload, + ) + .await +} + +pub(crate) async fn edit_editor_image_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: EditorGenerationCaller, + payload: EditorImageEditRequest, ) -> Result, AppError> { let prompt = payload.prompt.trim().to_string(); if prompt.is_empty() { @@ -1209,10 +1259,13 @@ pub async fn edit_editor_image( } let reference_image = parse_editor_reference_image(payload.source_image_src.as_str())?; - let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( - &request_context, - Some(authenticated.claims().user_id().to_string()), - None, + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + caller.audit_subject_user_id.clone(), + caller + .audit_project_id + .clone() + .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_edit_with_references( @@ -1241,10 +1294,10 @@ pub async fn edit_editor_image( BASE64_STANDARD.encode(image.bytes.as_slice()) ); let generated_asset = persist_editor_generated_asset( - &state, + state, PersistEditorGeneratedAssetInput { project_id: payload.project_id, - owner_user_id: current_owner_user_id(&authenticated), + owner_user_id: caller.owner_user_id, folder_id: payload.asset_folder_id, label: normalize_optional_string(payload.asset_label) .unwrap_or_else(|| "图片修改结果".to_string()), @@ -1290,6 +1343,21 @@ pub async fn generate_editor_icon_spritesheet( Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, +) -> Result, AppError> { + generate_editor_icon_spritesheet_for_owner( + &state, + &request_context, + EditorGenerationCaller::from_authenticated(&authenticated), + payload, + ) + .await +} + +pub(crate) async fn generate_editor_icon_spritesheet_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: EditorGenerationCaller, + payload: EditorIconSpritesheetGenerationRequest, ) -> Result, AppError> { let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; let reference_image = parse_editor_reference_image(payload.reference_image_src.as_str()) @@ -1312,10 +1380,13 @@ pub async fn generate_editor_icon_spritesheet( let size = generation_options.size.as_str(); let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions); - let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( - &request_context, - Some(authenticated.claims().user_id().to_string()), - None, + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + caller.audit_subject_user_id.clone(), + caller + .audit_project_id + .clone() + .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { @@ -1372,9 +1443,9 @@ pub async fn generate_editor_icon_spritesheet( let spritesheet_image_src = data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice()); let generation_inputs = payload.generation_inputs.clone(); - let owner_user_id = current_owner_user_id(&authenticated); + let owner_user_id = caller.owner_user_id; let spritesheet_record = persist_editor_generated_asset( - &state, + state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), @@ -1400,7 +1471,7 @@ pub async fn generate_editor_icon_spritesheet( for icon in icon_slices { let icon_response = editor_icon_response_from_slice(icon); let icon_record = persist_editor_generated_asset( - &state, + state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), @@ -1453,6 +1524,21 @@ pub async fn extract_editor_ui_design_assets( Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, +) -> Result, AppError> { + extract_editor_ui_design_assets_for_owner( + &state, + &request_context, + EditorGenerationCaller::from_authenticated(&authenticated), + payload, + ) + .await +} + +pub(crate) async fn extract_editor_ui_design_assets_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: EditorGenerationCaller, + payload: EditorUiDesignAssetExtractionRequest, ) -> Result, AppError> { let reference_image = parse_editor_reference_image(payload.source_image_src.as_str()).map_err(|error| { @@ -1462,10 +1548,13 @@ pub async fn extract_editor_ui_design_assets( "message": "UI设计图必须是图片 Data URL。", })) })?; - let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( - &request_context, - Some(authenticated.claims().user_id().to_string()), - None, + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + caller.audit_subject_user_id.clone(), + caller + .audit_project_id + .clone() + .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_edit_with_references_and_model( @@ -1505,9 +1594,9 @@ pub async fn extract_editor_ui_design_assets( data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice()); let prompt = EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT.to_string(); let generation_inputs = payload.generation_inputs.clone(); - let owner_user_id = current_owner_user_id(&authenticated); + let owner_user_id = caller.owner_user_id; let spritesheet_record = persist_editor_generated_asset( - &state, + state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), @@ -1534,7 +1623,7 @@ pub async fn extract_editor_ui_design_assets( for icon in icon_slices { let icon_response = editor_icon_response_from_slice(icon); let icon_record = persist_editor_generated_asset( - &state, + state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), @@ -1649,7 +1738,7 @@ pub(crate) fn editor_project_resource_payload_from_record( } } -fn editor_asset_library_payload_from_record( +pub(crate) fn editor_asset_library_payload_from_record( record: EditorAssetLibraryRecord, ) -> EditorAssetLibraryPayload { EditorAssetLibraryPayload { @@ -1666,7 +1755,7 @@ fn editor_asset_library_payload_from_record( } } -fn editor_asset_folder_payload_from_record( +pub(crate) fn editor_asset_folder_payload_from_record( record: EditorAssetFolderRecord, ) -> EditorAssetFolderPayload { EditorAssetFolderPayload { @@ -1825,7 +1914,9 @@ pub(crate) fn serialize_editor_layers(layers: Value) -> Result Ok(payload) } -fn serialize_editor_asset_metadata(metadata: Option) -> Result, AppError> { +pub(crate) fn serialize_editor_asset_metadata( + metadata: Option, +) -> Result, AppError> { let Some(metadata) = metadata else { return Ok(None); }; diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index 8b9c99ebf..7d620c6a6 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -21,8 +21,12 @@ use crate::{ const EXTERNAL_API_KEY_ID_PREFIX: &str = "external-api-key-"; const EXTERNAL_API_KEY_SECRET_PREFIX: &str = "tnr_sk_"; const EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS: usize = 18; -const EXTERNAL_API_KEY_SCOPES: [&str; 3] = - ["editor:project", "editor:canvas", "editor:image-generate"]; +const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [ + "editor:project", + "editor:canvas", + "editor:image-generate", + "editor:asset", +]; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -237,6 +241,7 @@ mod tests { "editor:project".to_string(), "editor:canvas".to_string(), "editor:image-generate".to_string(), + "editor:asset".to_string(), ] ); } diff --git a/server-rs/crates/api-server/src/external_assets_api.rs b/server-rs/crates/api-server/src/external_assets_api.rs new file mode 100644 index 000000000..bca6422fe --- /dev/null +++ b/server-rs/crates/api-server/src/external_assets_api.rs @@ -0,0 +1,75 @@ +use axum::{ + Json, + extract::{Extension, Query, State}, + http::StatusCode, +}; +use serde_json::{Value, json}; +use shared_contracts::assets::{ + ConfirmAssetObjectRequest, CreateDirectUploadTicketRequest, GetReadUrlQuery, +}; + +use crate::{ + assets::{ + confirm_asset_object_for_owner, create_direct_upload_ticket_for_owner, + get_asset_read_url_with_query, + }, + external_api_auth::ExternalApiPrincipal, + http_error::AppError, + request_context::RequestContext, + state::AppState, +}; + +const SCOPE_EDITOR_ASSET: &str = "editor:asset"; +const EXTERNAL_ASSET_PROVIDER: &str = "external-assets-api"; + +pub async fn create_external_direct_upload_ticket( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + create_direct_upload_ticket_for_owner( + &state, + &request_context, + principal.owner_user_id(), + payload, + ) + .await +} + +pub async fn confirm_external_asset_object( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(mut payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + payload.owner_user_id = Some(principal.owner_user_id().to_string()); + confirm_asset_object_for_owner(&state, &request_context, principal.owner_user_id(), payload) + .await +} + +pub async fn get_external_asset_read_url( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + Query(query): Query, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + get_asset_read_url_with_query(&state, &request_context, query).await +} + +fn require_scope(principal: &ExternalApiPrincipal, scope: &str) -> Result<(), AppError> { + if principal.has_scope(scope) { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ + "provider": EXTERNAL_ASSET_PROVIDER, + "keyId": principal.key_id(), + "scope": scope, + "message": "API Key 缺少所需权限", + })), + ) +} diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index ec82a9b0b..fd3cba2e4 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Extension, Path, State}, + extract::{Extension, Path, State, rejection::JsonRejection}, http::{StatusCode, header::CONTENT_TYPE}, response::{IntoResponse, Response}, }; @@ -8,41 +8,46 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ - EditorAssetCreateRecordInput, EditorProjectCreateRecordInput, EditorProjectGetRecordInput, - EditorProjectLayoutSaveRecordInput, EditorProjectResourceCreateRecordInput, + EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, + EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, + EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, + EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, }; use crate::{ api_response::json_success_body, + character_animation_assets::{ + generate_editor_character_animation_for_owner, generate_editor_video_for_owner, + }, editor_project::{ - EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE, EDITOR_PROJECT_ID_PREFIX, - EDITOR_RESOURCE_ID_PREFIX, EditorAssetPayload, EditorCanvasViewportPayload, - EditorProjectPayload, EditorProjectResourcePayload, current_utc_micros, - data_url_from_image_bytes, editor_asset_payload_from_record, - editor_project_payload_from_record, editor_project_resource_payload_from_record, - map_editor_project_error, normalize_optional_string, persist_editor_generated_image, + EDITOR_ASSET_FOLDER_ID_PREFIX, EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE, + EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload, + EditorAssetLibraryPayload, EditorAssetPayload, EditorCanvasViewportPayload, + EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, + EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload, + EditorUiDesignAssetExtractionRequest, current_utc_micros, edit_editor_image_for_owner, + editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record, + editor_asset_payload_from_record, editor_project_payload_from_record, + editor_project_resource_payload_from_record, extract_editor_ui_design_assets_for_owner, + generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, + map_editor_project_error, normalize_optional_string, serialize_editor_asset_metadata, serialize_editor_layers, }, external_api_auth::ExternalApiPrincipal, http_error::AppError, - openai_image_generation::{ - GPT_IMAGE_2_MODEL, build_openai_image_http_client, - create_openai_image_edit_with_references_and_model, - create_openai_image_generation_with_model, require_openai_image_settings, - }, request_context::RequestContext, state::AppState, + vector_engine_audio_generation::{ + generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner, + }, }; const EXTERNAL_EDITOR_PROVIDER: &str = "external-editor-api"; -const EXTERNAL_EDITOR_IMAGE_ASSET_KIND: &str = "editor_external_image"; -const EXTERNAL_EDITOR_IMAGE_SLOT: &str = "external-image"; -const EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE: &str = "generated"; -const EXTERNAL_EDITOR_IMAGE_PROVIDER: &str = "VectorEngine"; -const EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE: &str = "1024x1024"; const SCOPE_EDITOR_PROJECT: &str = "editor:project"; const SCOPE_EDITOR_CANVAS: &str = "editor:canvas"; const SCOPE_EDITOR_IMAGE_GENERATE: &str = "editor:image-generate"; +const SCOPE_EDITOR_ASSET: &str = "editor:asset"; const OPENAPI_JSON: &str = include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json"); @@ -61,14 +66,68 @@ pub struct ExternalEditorCanvasSaveRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ExternalEditorImageGenerationRequest { - prompt: String, - project_id: Option, - title: Option, - size: Option, - reference_image_srcs: Option>, - layers: Option, - viewport: Option, +pub struct ExternalEditorProjectRenameRequest { + title: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorAssetFolderCreateRequest { + label: String, + sort_order: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorAssetFolderUpdateRequest { + label: Option, + collapsed: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorAssetCreateRequest { + folder_id: String, + label: String, + image_src: String, + object_key: Option, + asset_object_id: Option, + width: u32, + height: u32, + source_type: String, + prompt: Option, + actual_prompt: Option, + model: Option, + provider: Option, + task_id: Option, + asset_kind: Option, + generation_inputs: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorProjectResourceCreateRequest { + image_src: String, + object_key: Option, + asset_object_id: Option, + width: u32, + height: u32, + source_type: String, + prompt: Option, + actual_prompt: Option, + model: Option, + provider: Option, + task_id: Option, + source_resource_id: Option, + asset_kind: Option, + generation_inputs: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorAssetUpdateRequest { + label: Option, + folder_id: Option, } #[derive(Debug, Serialize)] @@ -79,20 +138,52 @@ pub struct ExternalEditorProjectResponse { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct ExternalEditorImageGenerationResponse { - image_src: String, - width: u32, - height: u32, - prompt: String, - actual_prompt: Option, - model: String, - provider: &'static str, - task_id: String, - asset: EditorAssetPayload, - resource: Option, +pub struct ExternalEditorProjectRecentResponse { project: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorProjectListResponse { + projects: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorProjectDeleteResponse { + deleted_project_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorAssetLibraryResponse { + library: EditorAssetLibraryPayload, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorAssetFolderResponse { + folder: EditorAssetFolderPayload, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorAssetFolderDeleteResponse { + library: EditorAssetLibraryPayload, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorAssetResponse { + asset: EditorAssetPayload, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorProjectResourceResponse { + resource: EditorProjectResourcePayload, +} + pub async fn openapi_json() -> Response { ( [(CONTENT_TYPE, "application/json; charset=utf-8")], @@ -127,6 +218,46 @@ pub async fn create_external_editor_project( )) } +pub async fn list_external_editor_projects( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_PROJECT)?; + let projects = state + .spacetime_client() + .list_editor_projects(principal.owner_user_id().to_string()) + .await + .map_err(map_editor_project_error)? + .into_iter() + .map(editor_project_payload_from_record) + .collect(); + + Ok(json_success_body( + Some(&request_context), + ExternalEditorProjectListResponse { projects }, + )) +} + +pub async fn load_recent_external_editor_project( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_PROJECT)?; + let project = state + .spacetime_client() + .get_recent_editor_project(principal.owner_user_id().to_string()) + .await + .map_err(map_editor_project_error)? + .map(editor_project_payload_from_record); + + Ok(json_success_body( + Some(&request_context), + ExternalEditorProjectRecentResponse { project }, + )) +} + pub async fn get_external_editor_project( State(state): State, Path(project_id): Path, @@ -151,6 +282,55 @@ pub async fn get_external_editor_project( )) } +pub async fn rename_external_editor_project( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_PROJECT)?; + let project = state + .spacetime_client() + .rename_editor_project(EditorProjectRenameRecordInput { + project_id, + owner_user_id: principal.owner_user_id().to_string(), + title: payload.title, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorProjectResponse { + project: editor_project_payload_from_record(project), + }, + )) +} + +pub async fn delete_external_editor_project( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_PROJECT)?; + let deleted_project_id = state + .spacetime_client() + .delete_editor_project(EditorProjectDeleteRecordInput { + project_id, + owner_user_id: principal.owner_user_id().to_string(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorProjectDeleteResponse { deleted_project_id }, + )) +} + pub async fn save_external_editor_canvas( State(state): State, Path(project_id): Path, @@ -179,184 +359,391 @@ pub async fn save_external_editor_canvas( )) } -pub async fn generate_external_editor_image( +pub async fn get_external_editor_asset_library( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, - Json(payload): Json, ) -> Result, AppError> { - require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; - let prompt = payload.prompt.trim().to_string(); - if prompt.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": EXTERNAL_EDITOR_PROVIDER, - "message": "生成提示词不能为空", - })), - ); - } - let project_id = normalize_optional_string(payload.project_id); - let asset_title = normalize_asset_title(payload.title.as_deref(), prompt.as_str()); - let size = normalize_external_image_size(payload.size.as_deref()); - let reference_images = payload - .reference_image_srcs - .unwrap_or_default() - .into_iter() - .filter_map(|source| { - let trimmed = source.trim().to_string(); - (!trimmed.is_empty()).then_some(trimmed) + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let library = state + .spacetime_client() + .get_editor_asset_library(principal.owner_user_id().to_string(), current_utc_micros()) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorAssetLibraryResponse { + library: editor_asset_library_payload_from_record(library), + }, + )) +} + +pub async fn create_external_editor_asset_folder( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let folder = state + .spacetime_client() + .create_editor_asset_folder(EditorAssetFolderCreateRecordInput { + folder_id: build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX), + owner_user_id: principal.owner_user_id().to_string(), + label: payload.label, + sort_order: payload.sort_order.unwrap_or(100), + now_micros: current_utc_micros(), }) - .take(5) - .map(|source| crate::editor_project::parse_editor_reference_image(source.as_str())) - .collect::, _>>()?; - let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( - &request_context, - Some(principal.owner_user_id().to_string()), - project_id.clone(), - ); - let http_client = build_openai_image_http_client(&settings)?; - let generated = if reference_images.is_empty() { - create_openai_image_generation_with_model( - &http_client, - &settings, - GPT_IMAGE_2_MODEL, - prompt.as_str(), - None, - size.as_str(), - 1, - &[], - "外部 OpenAPI 编辑器美术生图", - ) - .await? - } else { - create_openai_image_edit_with_references_and_model( - &http_client, - &settings, - GPT_IMAGE_2_MODEL, - prompt.as_str(), - None, - size.as_str(), - 1, - reference_images.as_slice(), - "外部 OpenAPI 编辑器参考图美术生图", - ) - .await? - }; - let image = generated.images.into_iter().next().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "VectorEngine 未返回图片", - })) - })?; - let (width, height) = image::load_from_memory(image.bytes.as_slice()) - .map(|image| (image.width(), image.height())) - .unwrap_or((1024, 1024)); - let image_src = data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice()); - let persisted = persist_editor_generated_image( - &state, - principal.owner_user_id(), - generated.task_id.as_str(), - &image, - prompt.as_str(), - generated.actual_prompt.as_deref(), - EXTERNAL_EDITOR_IMAGE_ASSET_KIND, - "external-images", - EXTERNAL_EDITOR_IMAGE_SLOT, - ) - .await?; - let now_micros = current_utc_micros(); + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorAssetFolderResponse { + folder: editor_asset_folder_payload_from_record(folder), + }, + )) +} + +pub async fn update_external_editor_asset_folder( + State(state): State, + Path(folder_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let folder = state + .spacetime_client() + .update_editor_asset_folder(EditorAssetFolderUpdateRecordInput { + folder_id, + owner_user_id: principal.owner_user_id().to_string(), + label: normalize_optional_string(payload.label), + collapsed: payload.collapsed, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorAssetFolderResponse { + folder: editor_asset_folder_payload_from_record(folder), + }, + )) +} + +pub async fn delete_external_editor_asset_folder( + State(state): State, + Path(folder_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let library = state + .spacetime_client() + .delete_editor_asset_folder(EditorAssetFolderDeleteRecordInput { + folder_id, + owner_user_id: principal.owner_user_id().to_string(), + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorAssetFolderDeleteResponse { + library: editor_asset_library_payload_from_record(library), + }, + )) +} + +pub async fn create_external_editor_asset( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let generation_inputs_json = + serialize_editor_asset_metadata(payload.generation_inputs.clone())?; let asset = state .spacetime_client() .create_editor_asset(EditorAssetCreateRecordInput { asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX), owner_user_id: principal.owner_user_id().to_string(), - folder_id: default_asset_folder_id(principal.owner_user_id()), - label: asset_title, - asset_object_id: Some(persisted.asset_object_id.clone()), - image_src: image_src.clone(), - object_key: Some(persisted.object_key.clone()), - width, - height, - source_type: EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE.to_string(), - prompt: Some(prompt.clone()), - actual_prompt: generated.actual_prompt.clone(), - model: Some(GPT_IMAGE_2_MODEL.to_string()), - provider: Some(EXTERNAL_EDITOR_IMAGE_PROVIDER.to_string()), - task_id: Some(generated.task_id.clone()), - asset_kind: Some(EXTERNAL_EDITOR_IMAGE_ASSET_KIND.to_string()), - generation_inputs_json: None, - now_micros, + folder_id: payload.folder_id, + label: payload.label, + asset_object_id: normalize_optional_string(payload.asset_object_id), + image_src: payload.image_src, + object_key: normalize_optional_string(payload.object_key), + width: payload.width, + height: payload.height, + source_type: payload.source_type, + prompt: normalize_optional_string(payload.prompt), + actual_prompt: normalize_optional_string(payload.actual_prompt), + model: normalize_optional_string(payload.model), + provider: normalize_optional_string(payload.provider), + task_id: normalize_optional_string(payload.task_id), + asset_kind: normalize_optional_string(payload.asset_kind), + generation_inputs_json, + now_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; - let resource = if let Some(project_id) = project_id.clone() { - Some( - state - .spacetime_client() - .create_editor_project_resource(EditorProjectResourceCreateRecordInput { - resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX), - project_id, - owner_user_id: principal.owner_user_id().to_string(), - asset_object_id: Some(persisted.asset_object_id.clone()), - image_src: image_src.clone(), - object_key: Some(persisted.object_key.clone()), - width, - height, - source_type: EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE.to_string(), - prompt: Some(prompt.clone()), - actual_prompt: generated.actual_prompt.clone(), - model: Some(GPT_IMAGE_2_MODEL.to_string()), - provider: Some(EXTERNAL_EDITOR_IMAGE_PROVIDER.to_string()), - task_id: Some(generated.task_id.clone()), - source_resource_id: None, - asset_kind: Some(EXTERNAL_EDITOR_IMAGE_ASSET_KIND.to_string()), - generation_inputs_json: None, - updated_at_micros: now_micros, - }) - .await - .map_err(map_editor_project_error)?, - ) - } else { - None - }; - let project = if let (Some(project_id), Some(layers), Some(viewport)) = - (project_id, payload.layers, payload.viewport) - { - Some( - state - .spacetime_client() - .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { - project_id, - owner_user_id: principal.owner_user_id().to_string(), - viewport: viewport.into_record(), - layers_json: serialize_editor_layers(layers)?, - updated_at_micros: now_micros, - }) - .await - .map_err(map_editor_project_error)?, - ) - } else { - None - }; Ok(json_success_body( Some(&request_context), - ExternalEditorImageGenerationResponse { - image_src, - width, - height, - prompt, - actual_prompt: generated.actual_prompt, - model: GPT_IMAGE_2_MODEL.to_string(), - provider: EXTERNAL_EDITOR_IMAGE_PROVIDER, - task_id: generated.task_id, + ExternalEditorAssetResponse { asset: editor_asset_payload_from_record(asset), - resource: resource.map(editor_project_resource_payload_from_record), - project: project.map(editor_project_payload_from_record), }, )) } +pub async fn update_external_editor_asset( + State(state): State, + Path(asset_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let asset = state + .spacetime_client() + .update_editor_asset(EditorAssetUpdateRecordInput { + asset_id, + owner_user_id: principal.owner_user_id().to_string(), + label: normalize_optional_string(payload.label), + folder_id: normalize_optional_string(payload.folder_id), + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorAssetResponse { + asset: editor_asset_payload_from_record(asset), + }, + )) +} + +pub async fn delete_external_editor_asset( + State(state): State, + Path(asset_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let asset = state + .spacetime_client() + .delete_editor_asset(EditorAssetDeleteRecordInput { + asset_id, + owner_user_id: principal.owner_user_id().to_string(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorAssetResponse { + asset: editor_asset_payload_from_record(asset), + }, + )) +} + +pub async fn create_external_editor_project_resource( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let generation_inputs_json = + serialize_editor_asset_metadata(payload.generation_inputs.clone())?; + let resource = state + .spacetime_client() + .create_editor_project_resource(EditorProjectResourceCreateRecordInput { + resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX), + project_id, + owner_user_id: principal.owner_user_id().to_string(), + asset_object_id: normalize_optional_string(payload.asset_object_id), + image_src: payload.image_src, + object_key: normalize_optional_string(payload.object_key), + width: payload.width, + height: payload.height, + source_type: payload.source_type, + prompt: normalize_optional_string(payload.prompt), + actual_prompt: normalize_optional_string(payload.actual_prompt), + model: normalize_optional_string(payload.model), + provider: normalize_optional_string(payload.provider), + task_id: normalize_optional_string(payload.task_id), + source_resource_id: normalize_optional_string(payload.source_resource_id), + asset_kind: normalize_optional_string(payload.asset_kind), + generation_inputs_json, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorProjectResourceResponse { + resource: editor_project_resource_payload_from_record(resource), + }, + )) +} + +pub async fn generate_external_editor_image( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; + generate_editor_image_for_owner( + &state, + &request_context, + editor_generation_caller(&principal, payload.project_id.clone()), + payload, + ) + .await +} + +pub async fn edit_external_editor_image( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; + edit_editor_image_for_owner( + &state, + &request_context, + editor_generation_caller(&principal, payload.project_id.clone()), + payload, + ) + .await +} + +pub async fn generate_external_editor_icon_spritesheet( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; + generate_editor_icon_spritesheet_for_owner( + &state, + &request_context, + editor_generation_caller(&principal, payload.project_id.clone()), + payload, + ) + .await +} + +pub async fn extract_external_editor_ui_design_assets( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; + extract_editor_ui_design_assets_for_owner( + &state, + &request_context, + editor_generation_caller(&principal, payload.project_id.clone()), + payload, + ) + .await +} + +pub async fn generate_external_editor_character_animation( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + payload: Result< + Json, + JsonRejection, + >, +) -> Result, Response> { + require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; + generate_editor_character_animation_for_owner( + state, + request_context, + principal.owner_user_id().to_string(), + payload, + ) + .await +} + +pub async fn generate_external_editor_video( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; + generate_editor_video_for_owner( + state, + request_context, + principal.owner_user_id().to_string(), + payload, + ) + .await +} + +pub async fn generate_external_editor_sound_effect( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + payload: Result< + Json, + JsonRejection, + >, +) -> Result, Response> { + require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; + generate_editor_sound_effect_for_owner( + state, + request_context, + principal.owner_user_id().to_string(), + payload, + ) + .await +} + +pub async fn generate_external_editor_background_music( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + payload: Result< + Json, + JsonRejection, + >, +) -> Result, Response> { + require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; + generate_editor_background_music_for_owner( + state, + request_context, + principal.owner_user_id().to_string(), + payload, + ) + .await +} + +fn editor_generation_caller( + principal: &ExternalApiPrincipal, + project_id: Option, +) -> EditorGenerationCaller { + EditorGenerationCaller { + owner_user_id: principal.owner_user_id().to_string(), + audit_subject_user_id: Some(principal.owner_user_id().to_string()), + audit_project_id: normalize_optional_string(project_id), + } +} + fn require_scope(principal: &ExternalApiPrincipal, scope: &str) -> Result<(), AppError> { if principal.has_scope(scope) { return Ok(()); @@ -371,6 +758,15 @@ fn require_scope(principal: &ExternalApiPrincipal, scope: &str) -> Result<(), Ap ) } +fn require_scope_response( + request_context: &RequestContext, + principal: &ExternalApiPrincipal, + scope: &str, +) -> Result<(), Response> { + require_scope(principal, scope) + .map_err(|error| error.into_response_with_context(Some(request_context))) +} + fn normalize_project_title(title: Option) -> String { title .map(|value| value.trim().to_string()) @@ -378,28 +774,6 @@ fn normalize_project_title(title: Option) -> String { .unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string()) } -fn normalize_asset_title(title: Option<&str>, prompt: &str) -> String { - title - .map(str::trim) - .filter(|value| !value.is_empty()) - .or_else(|| prompt.trim().split('\n').next()) - .map(|value| value.chars().take(40).collect()) - .unwrap_or_else(|| "外部生成图片".to_string()) -} - -fn normalize_external_image_size(size: Option<&str>) -> String { - match size.map(str::trim).filter(|value| !value.is_empty()) { - Some(value @ ("1024x1024" | "1536x1024" | "1024x1536" | "2048x1152" | "2048x2048")) => { - value.to_string() - } - _ => EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE.to_string(), - } -} - -fn default_asset_folder_id(owner_user_id: &str) -> String { - format!("{owner_user_id}:asset-folder:project") -} - #[cfg(test)] mod tests { use super::*; @@ -414,30 +788,110 @@ mod tests { .get("/api/external/v1/editor/projects") .is_some() ); + assert!( + parsed["paths"] + .get("/api/external/v1/assets/direct-upload-tickets") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/assets/objects/confirm") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/assets/read-url") + .is_some() + ); + assert!( + parsed["paths"]["/api/external/v1/editor/projects"] + .get("get") + .is_some() + ); + assert!( + parsed["paths"]["/api/external/v1/editor/projects"] + .get("post") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/projects/recent") + .is_some() + ); + assert!( + parsed["paths"]["/api/external/v1/editor/projects/{projectId}"] + .get("delete") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/projects/{projectId}/metadata") + .is_some() + ); assert!( parsed["paths"] .get("/api/external/v1/editor/images/generations") .is_some() ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/images/edits") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/icon-spritesheets/generations") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/ui-designs/assets/extractions") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/videos/generations") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/audios/sound-effects/generations") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/audios/background-music/generations") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/assets/library") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/projects/{projectId}/resources") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/assets/folders") + .is_some() + ); + assert!( + parsed["paths"] + .get("/api/external/v1/editor/assets/{assetId}") + .is_some() + ); + assert!(parsed["paths"].get("/api/profile/api-keys").is_none()); + assert!( + parsed["components"]["securitySchemes"] + .get("UserAccessToken") + .is_none() + ); assert_eq!( parsed["components"]["securitySchemes"]["ExternalApiKey"]["scheme"], "bearer" ); } - - #[test] - fn external_image_size_falls_back_to_default_for_unknown_values() { - assert_eq!( - normalize_external_image_size(Some("1536x1024")), - "1536x1024" - ); - assert_eq!( - normalize_external_image_size(Some("4096x4096")), - EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE - ); - assert_eq!( - normalize_external_image_size(None), - EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE - ); - } } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 01c0fe18f..e9da1150a 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -44,6 +44,7 @@ mod error_middleware; mod external_api_audit; mod external_api_auth; mod external_api_keys; +mod external_assets_api; mod external_editor_api; mod external_generation; mod external_generation_worker; diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs index 9c09e1d7d..3841ffb25 100644 --- a/server-rs/crates/api-server/src/modules/external_api.rs +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -7,28 +7,82 @@ use axum::{ use crate::{ external_api_auth::require_external_api_key, + external_assets_api::{ + confirm_external_asset_object, create_external_direct_upload_ticket, + get_external_asset_read_url, + }, external_editor_api::{ - create_external_editor_project, generate_external_editor_image, - get_external_editor_project, openapi_json, save_external_editor_canvas, + create_external_editor_asset, create_external_editor_asset_folder, + create_external_editor_project, create_external_editor_project_resource, + delete_external_editor_asset, delete_external_editor_asset_folder, + delete_external_editor_project, edit_external_editor_image, + extract_external_editor_ui_design_assets, generate_external_editor_background_music, + generate_external_editor_character_animation, generate_external_editor_icon_spritesheet, + generate_external_editor_image, generate_external_editor_sound_effect, + generate_external_editor_video, get_external_editor_asset_library, + get_external_editor_project, list_external_editor_projects, + load_recent_external_editor_project, openapi_json, rename_external_editor_project, + save_external_editor_canvas, update_external_editor_asset, + update_external_editor_asset_folder, }, state::AppState, }; const EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; +const EXTERNAL_EDITOR_VIDEO_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024; pub fn router(state: AppState) -> Router { Router::new() .route("/api/external/v1/openapi.json", get(openapi_json)) + .route( + "/api/external/v1/assets/direct-upload-tickets", + post(create_external_direct_upload_ticket).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/assets/objects/confirm", + post(confirm_external_asset_object).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/assets/read-url", + get(get_external_asset_read_url).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) .route( "/api/external/v1/editor/projects", - post(create_external_editor_project).route_layer(middleware::from_fn_with_state( + get(list_external_editor_projects) + .post(create_external_editor_project) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/projects/recent", + get(load_recent_external_editor_project).route_layer(middleware::from_fn_with_state( state.clone(), require_external_api_key, )), ) .route( "/api/external/v1/editor/projects/{project_id}", - get(get_external_editor_project).route_layer(middleware::from_fn_with_state( + get(get_external_editor_project) + .delete(delete_external_editor_project) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/projects/{project_id}/metadata", + patch(rename_external_editor_project).route_layer(middleware::from_fn_with_state( state.clone(), require_external_api_key, )), @@ -40,6 +94,51 @@ pub fn router(state: AppState) -> Router { require_external_api_key, )), ) + .route( + "/api/external/v1/editor/projects/{project_id}/resources", + post(create_external_editor_project_resource).route_layer( + middleware::from_fn_with_state(state.clone(), require_external_api_key), + ), + ) + .route( + "/api/external/v1/editor/assets/library", + get(get_external_editor_asset_library).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/assets/folders", + post(create_external_editor_asset_folder).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/assets/folders/{folder_id}", + patch(update_external_editor_asset_folder) + .delete(delete_external_editor_asset_folder) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/assets", + post(create_external_editor_asset).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/assets/{asset_id}", + patch(update_external_editor_asset) + .delete(delete_external_editor_asset) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) .route( "/api/external/v1/editor/images/generations", post(generate_external_editor_image) @@ -47,8 +146,75 @@ pub fn router(state: AppState) -> Router { EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, )) .route_layer(middleware::from_fn_with_state( - state, + state.clone(), require_external_api_key, )), ) + .route( + "/api/external/v1/editor/images/edits", + post(edit_external_editor_image) + .layer(DefaultBodyLimit::max( + EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/icon-spritesheets/generations", + post(generate_external_editor_icon_spritesheet) + .layer(DefaultBodyLimit::max( + EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/ui-designs/assets/extractions", + post(extract_external_editor_ui_design_assets) + .layer(DefaultBodyLimit::max( + EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/character-animations/generations", + post(generate_external_editor_character_animation) + .layer(DefaultBodyLimit::max( + EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/videos/generations", + post(generate_external_editor_video) + .layer(DefaultBodyLimit::max( + EXTERNAL_EDITOR_VIDEO_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) + .route( + "/api/external/v1/editor/audios/sound-effects/generations", + post(generate_external_editor_sound_effect).route_layer( + middleware::from_fn_with_state(state.clone(), require_external_api_key), + ), + ) + .route( + "/api/external/v1/editor/audios/background-music/generations", + post(generate_external_editor_background_music).route_layer( + middleware::from_fn_with_state(state, require_external_api_key), + ), + ) } diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation.rs index 48a2f15bd..ed9704ddf 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation.rs @@ -12,6 +12,9 @@ mod tests; mod types; pub use generation::{generate_editor_background_music, generate_editor_sound_effect}; +pub(crate) use generation::{ + generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner, +}; pub use handlers::{ create_background_music_task, create_sound_effect_task, create_visual_novel_background_music_task, create_visual_novel_sound_effect_task, diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index d5e637992..524a08608 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -121,6 +121,21 @@ pub async fn generate_editor_sound_effect( Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, +) -> Result, Response> { + generate_editor_sound_effect_for_owner( + state, + request_context, + authenticated.claims().user_id().to_string(), + payload, + ) + .await +} + +pub(crate) async fn generate_editor_sound_effect_for_owner( + state: AppState, + request_context: RequestContext, + owner_user_id: String, + payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; let normalized = normalize_editor_sound_effect_request(payload) @@ -145,7 +160,7 @@ pub async fn generate_editor_sound_effect( .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let generated = wait_for_generated_audio_asset_with_task_kind( &state, - authenticated.claims().user_id(), + owner_user_id.as_str(), task.task_id.clone(), AudioAssetSlot::SoundEffect, task.kind, @@ -184,6 +199,21 @@ pub async fn generate_editor_background_music( Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, +) -> Result, Response> { + generate_editor_background_music_for_owner( + state, + request_context, + authenticated.claims().user_id().to_string(), + payload, + ) + .await +} + +pub(crate) async fn generate_editor_background_music_for_owner( + state: AppState, + request_context: RequestContext, + owner_user_id: String, + payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; let normalized = normalize_editor_background_music_request(payload) @@ -207,7 +237,7 @@ pub async fn generate_editor_background_music( .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let generated = wait_for_generated_audio_asset( &state, - authenticated.claims().user_id(), + owner_user_id.as_str(), task.task_id.clone(), AudioAssetSlot::BackgroundMusic, build_editor_audio_target(&task.task_id, "background_music", "editor_background_music"), diff --git a/server-rs/crates/spacetime-module/src/external_api_key_storage.rs b/server-rs/crates/spacetime-module/src/external_api_key_storage.rs index 3b54d2bb0..b54abff6f 100644 --- a/server-rs/crates/spacetime-module/src/external_api_key_storage.rs +++ b/server-rs/crates/spacetime-module/src/external_api_key_storage.rs @@ -3,7 +3,7 @@ use crate::*; const EXTERNAL_API_KEY_MAX_NAME_CHARS: usize = 80; const EXTERNAL_API_KEY_DEFAULT_NAME: &str = "外部 API Key"; const EXTERNAL_API_KEY_DEFAULT_SCOPES_JSON: &str = - "[\"editor:project\",\"editor:canvas\",\"editor:image-generate\"]"; + "[\"editor:project\",\"editor:canvas\",\"editor:image-generate\",\"editor:asset\"]"; #[spacetimedb::table( accessor = external_api_key,