diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json new file mode 100644 index 000000000..a733e57a4 --- /dev/null +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -0,0 +1,822 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "陶泥儿外部编辑器 OpenAPI", + "version": "1.0.0", + "description": "外部系统调用陶泥儿图片画布项目、画布布局保存和编辑器美术生图能力的 v1 契约。" + }, + "servers": [ + { + "url": "/", + "description": "当前部署环境" + } + ], + "tags": [ + { + "name": "Editor Projects", + "description": "图片画布项目" + }, + { + "name": "Editor Canvas", + "description": "图片画布布局" + }, + { + "name": "Editor Images", + "description": "编辑器美术生图" + }, + { + "name": "API Keys", + "description": "登录态 API Key 管理接口,路径位于 /api/profile" + } + ], + "paths": { + "/api/external/v1/openapi.json": { + "get": { + "tags": ["Editor Projects"], + "operationId": "getExternalOpenApiJson", + "summary": "读取外部 API OpenAPI JSON", + "security": [], + "responses": { + "200": { + "description": "OpenAPI 3.1 JSON", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/external/v1/editor/projects": { + "post": { + "tags": ["Editor Projects"], + "operationId": "createEditorProject", + "summary": "创建图片画布项目", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "已创建项目", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/external/v1/editor/projects/{projectId}": { + "get": { + "tags": ["Editor Projects"], + "operationId": "getEditorProject", + "summary": "读取图片画布项目", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "description": "项目快照", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectResponse" + } + } + } + }, + "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"], + "operationId": "saveEditorProjectCanvas", + "summary": "保存默认画布布局", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorCanvasSaveRequest" + } + } + } + }, + "responses": { + "200": { + "description": "保存后的项目快照", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/external/v1/editor/images/generations": { + "post": { + "tags": ["Editor Images"], + "operationId": "generateEditorImage", + "summary": "生成编辑器美术图片", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorImageGenerationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "生成结果与落库资源", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorImageGenerationResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" + } + } + } + }, + "/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" + } + } + } + } + } + }, + "post": { + "tags": ["API Keys"], + "operationId": "createExternalApiKey", + "summary": "创建外部 API Key", + "description": "实际路径为 /api/profile/api-keys,明文 apiKey 只在本响应返回一次。", + "security": [ + { + "UserAccessToken": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalApiKeyCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "新建 API Key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalApiKeyCreateResponse" + } + } + } + } + } + } + }, + "/api/profile/api-keys/{keyId}": { + "delete": { + "tags": ["API Keys"], + "operationId": "revokeExternalApiKey", + "summary": "撤销外部 API Key", + "description": "实际路径为 /api/profile/api-keys/{keyId},使用登录态 Bearer JWT。", + "security": [ + { + "UserAccessToken": [] + } + ], + "parameters": [ + { + "name": "keyId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "已撤销 API Key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalApiKeyResponse" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "ExternalApiKey": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "tnr_sk" + }, + "UserAccessToken": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "parameters": { + "ProjectId": { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + }, + "responses": { + "BadRequest": { + "description": "请求参数不合法", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "Unauthorized": { + "description": "未授权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "Forbidden": { + "description": "禁止访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "NotFound": { + "description": "资源不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "UpstreamError": { + "description": "上游生成服务失败", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "schemas": { + "ExternalEditorProjectCreateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 80 + } + }, + "additionalProperties": false + }, + "ExternalEditorCanvasSaveRequest": { + "type": "object", + "required": ["viewport", "layers"], + "properties": { + "viewport": { + "$ref": "#/components/schemas/EditorCanvasViewport" + }, + "layers": { + "type": "object", + "description": "画布图层 JSON,最大约 256KB。" + } + }, + "additionalProperties": false + }, + "ExternalEditorImageGenerationRequest": { + "type": "object", + "required": ["prompt"], + "properties": { + "prompt": { + "type": "string", + "minLength": 1 + }, + "projectId": { + "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" + } + }, + "additionalProperties": false + }, + "ExternalEditorProjectResponse": { + "type": "object", + "required": ["project"], + "properties": { + "project": { + "$ref": "#/components/schemas/EditorProject" + } + } + }, + "ExternalEditorImageGenerationResponse": { + "type": "object", + "required": [ + "imageSrc", + "width", + "height", + "prompt", + "model", + "provider", + "taskId", + "asset" + ], + "properties": { + "imageSrc": { + "type": "string", + "description": "生成图片 Data URL。" + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "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" + } + ] + }, + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorProject" + }, + { + "type": "null" + } + ] + } + } + }, + "EditorProject": { + "type": "object", + "required": ["projectId", "title", "canvas", "viewport", "layers", "resources", "updatedAt"], + "properties": { + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "canvas": { + "$ref": "#/components/schemas/EditorCanvas" + }, + "viewport": { + "$ref": "#/components/schemas/EditorCanvasViewport" + }, + "layers": { + "type": "object" + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EditorProjectResource" + } + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "EditorCanvas": { + "type": "object", + "required": ["canvasId", "projectId", "title", "viewport", "layers", "createdAt", "updatedAt"], + "properties": { + "canvasId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "viewport": { + "$ref": "#/components/schemas/EditorCanvasViewport" + }, + "layers": { + "type": "object" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "EditorCanvasViewport": { + "type": "object", + "required": ["x", "y", "scale"], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "scale": { + "type": "number", + "minimum": 0.01, + "maximum": 8 + } + }, + "additionalProperties": false + }, + "EditorProjectResource": { + "type": "object", + "required": ["resourceId", "projectId", "imageSrc", "width", "height", "sourceType", "createdAt", "updatedAt"], + "properties": { + "resourceId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "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"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "EditorAsset": { + "type": "object", + "required": ["assetId", "folderId", "label", "imageSrc", "width", "height", "sourceType", "createdAt", "updatedAt"], + "properties": { + "assetId": { + "type": "string" + }, + "folderId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "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"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "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" + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": ["object", "null"] + } + } + } + } + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c41408a5d..aabef48b6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 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`。 +- 影响范围:`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`。 + ## 2026-06-19 图片画布生成按钮价格统一绑定模型定价配置 - 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。 @@ -2400,7 +2408,7 @@ ## 2026-06-19 编辑器游戏音效默认改用 Vidu 文生音频 - 背景:VectorEngine Apifox `创建文生音频任务` 文档明确 Vidu `/ent/v2/text2audio` 请求体使用 `model: "audio1.0"`、`prompt`、`duration` 和可选 `seed`;编辑器此前把游戏音效提交到 Suno `task: "sound"`,与当前游戏音效默认模型要求不一致。 -- 决策:`/editor/canvas` 的 `生成游戏音效` 入口继续保留,但默认且暂时唯一可用模型为 Vidu `audio1.0`,前端请求固定发送 `prompt`、`model: "audio1.0"`、`duration` 和 `priceMudPoints`,面板只显示 `Vidu` 与 `2-10` 秒时长选项,默认 `5` 秒;不再发送或展示 `sound`、`type`、`tempo`、BPM 或 Suno 文生音效入口。后端 `/api/editor/audios/sound-effects/generations` 只接受空模型或 `audio1.0`,拒绝 Suno / `chirp-*`;提交和轮询改走 Vidu `/ent/v2/text2audio` 与 `/ent/v2/tasks/{taskId}/creations`。背景音乐仍保留 Suno `/suno/submit/music`、`/suno/fetch/{taskId}` 和 wav clip 兜底逻辑。 +- 决策:`/editor/canvas` 的 `生成游戏音效` 入口继续保留,但默认且暂时唯一可用模型为 Vidu `audio1.0`,前端请求固定发送 `prompt`、`model: "audio1.0"`、`duration` 和 `priceMudPoints`,面板只显示 `Vidu` 与 `2-10` 秒时长选项,默认 `5` 秒;不再展示 `type`、`tempo`、BPM 或 Suno 文生音效入口。后端 `/api/editor/audios/sound-effects/generations` 只接受空模型或 `audio1.0`,拒绝 Suno / `chirp-*`;提交到 VectorEngine 时对内 `prompt` 同步映射为上游 body 的 `prompt` 与 `sound`,兼容 Apifox 文档和线上网关实际 `missing field sound` 校验;提交和轮询改走 Vidu `/ent/v2/text2audio` 与 `/ent/v2/tasks/{taskId}/creations`。背景音乐仍保留 Suno `/suno/submit/music`、`/suno/fetch/{taskId}` 和 wav clip 兜底逻辑。 - 影响范围:`server-rs/crates/platform-audio`、`server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs`、`server-rs/crates/shared-contracts/src/assets.rs`、`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/ImageCanvasGeneration*`。 - 验证方式:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_sound_effect`、`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_audio_requests_and_response_use_canvas_audio_shape`、`npx vitest run src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasGenerationModel.test.ts --reporter=dot`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5b639514f..cafa80ee5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -15,6 +15,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## Vidu 文生音频线上网关可能要求 sound 字段 + +- 现象:画板点击 `生成游戏音效` 后,请求返回 `Failed to deserialize the JSON body into the target type: missing field sound`。 +- 原因:VectorEngine Apifox `创建文生音频任务` 文档仍写 `/ent/v2/text2audio` 使用 `model + prompt + duration`,但线上 Vidu 网关曾按 `sound` 字段反序列化;只发送 `prompt` 会被上游拦截在 JSON 解析阶段。 +- 处理:前端和 BFF 对内继续使用用户语义更清晰的 `prompt`;`platform-audio` 转发到 VectorEngine Vidu 时同时发送 `prompt` 与 `sound`,两者值保持一致。不要把 UI 改回 Suno `task: "sound"`、`type`、`tempo` 或 BPM。 +- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio` 中音效请求体测试必须同时断言 `prompt` 与 `sound`;必要时用线上生成音效 smoke 确认不再出现 `missing field sound`。 +- 关联:`server-rs/crates/platform-audio/src/request.rs`、`server-rs/crates/platform-audio/tests/vector_engine_audio.rs`、`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 + ## Suno 任务完成不代表已经拿到 wav 下载地址 - 现象:画板生成背景音乐时,前端报 `音频生成尚未返回可下载地址(requestId:...)`;画板生成音效时,前端可能报 `获取 Suno 音效 wav 失败(requestId:...)`。上游任务可能已经完成,但 wav 下载地址还没就绪。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 41f06309a..707959d24 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -12,7 +12,7 @@ - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 -- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 +- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 6d6df13ec..553c2f3bd 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -429,6 +429,13 @@ npm run check:server-rs-ddd - Rust 结构体:`DatabaseMigrationOperator` - 源码:`server-rs/crates/spacetime-module/src/migration.rs` +### `external_api_key` + +- 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。 +- 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。 + ### `editor_project` - Rust 结构体:`EditorProject` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md new file mode 100644 index 000000000..34a4b8daf --- /dev/null +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -0,0 +1,101 @@ +# 外部 OpenAPI 与 API Key 接入方案 + +## 背景 + +外部调用方需要通过稳定 HTTP 契约使用图片画布编辑器内的美术生图能力,并能创建项目、保存画板布局。该能力必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态或生成结果放到前端临时状态中。 + +## v1 范围 + +本期新增外部 API 命名空间: + +```text +/api/external/v1 +``` + +v1 只开放以下能力: + +- `POST /api/external/v1/editor/projects`:创建图片画布项目。 +- `GET /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` 素材库。 +- `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。 + +管理 API Key 的登录态接口: + +```text +GET /api/profile/api-keys +POST /api/profile/api-keys +DELETE /api/profile/api-keys/{keyId} +``` + +前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。 + +## 鉴权 + +外部调用使用 Bearer API Key: + +```http +Authorization: Bearer tnr_sk_xxx +``` + +规则: + +- API Key 归属于 `owner_user_id`,外部接口只能访问该账号自己的项目、画布和生成素材。 +- 明文 Key 只在创建接口返回一次,后端只保存 `key_hash` 与 `key_prefix`。 +- API Key 被撤销后立即不可再用于外部接口。 +- 外部 API 鉴权不复用登录态 JWT,不检查 refresh session;它是独立开发者凭据。 +- OpenAPI JSON 公共可读,不需要鉴权。 + +## 数据模型 + +新增 SpacetimeDB private 表: + +```text +external_api_key +``` + +字段: + +- `key_id`:主键。 +- `owner_user_id`:所属账号。 +- `name`:用户可识别名称。 +- `key_prefix`:前缀片段,用于列表展示和排障。 +- `key_hash`:完整 Key 的 SHA-256 十六进制摘要,唯一。 +- `scopes_json`:作用域 JSON,v1 固定包含 `editor:project`、`editor:canvas`、`editor:image-generate`。 +- `created_at` / `last_used_at` / `revoked_at` / `updated_at`。 + +SpacetimeDB procedure: + +- `create_external_api_key_and_return` +- `list_external_api_keys_and_return` +- `revoke_external_api_key_and_return` +- `authenticate_external_api_key_and_return` + +## 生成图落库 + +外部生图接口复用编辑器内 `VectorEngine` / `gpt-image-2` 生成链路,后端拿到图片后: + +1. 通过 OSS / asset object adapter 持久化图片。 +2. 写入 `editor_asset`,让生成图进入账号级素材库。 +3. 如果请求带 `projectId`,写入 `editor_project_resource`。 +4. 返回图片读取地址、素材 ID、资源 ID、尺寸、prompt、model、provider 和 taskId。 + +如果请求未带 `projectId`,只生成并写入素材库;调用方可随后创建项目或自行保存画板布局。 + +## OpenAPI 导出 + +OpenAPI 3.1 JSON 固定落在: + +```text +docs/openapi/genarrative-external-v1.openapi.json +``` + +服务端 `GET /api/external/v1/openapi.json` 使用同一份 JSON,通过 `include_str!` 导出,避免运行时生成结果与仓库文档漂移。 + +## 验收 + +- API Key 创建只返回一次明文,列表不返回明文。 +- 撤销后的 API Key 调用外部接口返回 `401`。 +- 外部生图成功后,生成结果同时出现在画布资源和账号级素材库。 +- OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。 +- 修改 SpacetimeDB schema 后运行 `npm run spacetime:generate` 与 `npm run check:spacetime-schema`。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index d79e93e55..54bdaba63 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -12,6 +12,8 @@ - `生成图标素材` - `生成UI设计图` - `生成视频` +- `生成游戏音效` +- `生成游戏背景音乐` - `生成角色动作` - `快速编辑` @@ -49,8 +51,8 @@ ``` - 面板内不显示 `画面比例`、`大小尺寸`、`模型` 等字段标题,只显示当前选择值。 -- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框;音效面板把 type 和 BPM 合并成一个无标题左下角选项框。 -- 模型选项框和生成按钮位于右下角。 +- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框;音效面板把 Vidu duration 合并成一个无标题左下角选项框。 +- 模型选项框和生成按钮位于右下角,模型胶囊紧贴生成按钮;音效固定显示 `Vidu`,背景音乐固定显示 `Suno`,两者均沿用模型按钮样式但不可点击。 - 点击后以页面级浮层向上弹出独立选项面板;父级面板隐藏或销毁时,选项面板同步销毁。 - 点击选项只更新字段和选中样式,不收起选项面板;再次点击当前参数按钮可收起。 - 选项面板打开后,点击父级生成面板内任意非选项框、非触发按钮区域必须收起;点击选项面板自身仍保持打开,便于连续修改。 @@ -58,7 +60,7 @@ - 模型子面板每行固定一个模型,不用方框包裹模型名;模型名不换行,前置对应模型类型图标,选中项在模型名后用对号标记。 - 比例选项卡片内展示对应比例的线框。 - 父级面板、底部选项框、弹出子面板字号保持一致。 -- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p`、`单次·120BPM`。 +- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p`、`16:9 · 1K`。 - 底部参数热区与生成按钮等高,默认不显示阴影;悬停显示轻量阴影;箭头默认向下,展开后旋转向上。 - 底边栏中会在上方弹出二级选项的入口不再依赖点击展开。鼠标悬停到入口即可打开二级面板,鼠标离开入口和二级面板后自动收起;当前范围包括 `生成规范` 和 `生成音乐`。 - 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。 @@ -115,12 +117,9 @@ ## 第一版计费配置 ```text -生成图片:12 泥点 -生成规范:5 泥点 -生成角色形象:12 泥点 -生成图标素材:12 泥点 -生成UI设计图:12 泥点 -生成视频:480p 每秒 10 泥点,720p 每秒 20 泥点 +生成图片 / 生成角色形象 / 生成图标素材 / 生成UI设计图 / 宣发素材 / 快速编辑 / 重绘:nanobanana2 为 12 泥点,gpt-image-2 为 20 泥点 +生成规范:固定 gpt-image-2,5 泥点 +生成视频:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20;seedance2.0 为 480p 每秒 12 / 720p 每秒 24;kling3.0 为 480p 每秒 15 / 720p 每秒 30;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40 角色动画:480p 每秒 10 泥点,720p 每秒 20 泥点 生成音效:10 泥点 生成背景音乐:5 泥点 @@ -128,9 +127,9 @@ 当前必须显式覆盖的正式模型定价配置: -- 图片类:`gemini-3.1-flash-image-preview`(UI 显示与历史别名统一为 `nanobanana2`)、`gpt-image-2`。 +- 图片类:`gemini-3.1-flash-image-preview`(UI 显示与历史别名统一为 `nanobanana2`)为 12 泥点;`gpt-image-2` 为 20 泥点。 - 规范:`gpt-image-2`。 -- 视频:`seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`;兼容旧布局回放的 `veo3.1`、`veo3.1-fast` 也要保留定价配置,但前端模型菜单不展示。 +- 视频:`seedance2.0-fast` 为 10 / 20 泥点每秒;`seedance2.0` 为 12 / 24 泥点每秒;`kling3.0` 为 15 / 30 泥点每秒;`kling3.0-omni` 为 20 / 40 泥点每秒。兼容旧布局回放的 `veo3.1`、`veo3.1-fast` 也要保留 10 / 20 泥点每秒定价配置,但前端模型菜单不展示。 - 角色动画:`seedance2.0-fast`。 - 音效:`audio1.0`。 - 背景音乐:`chirp-v5`。 @@ -185,7 +184,8 @@ - 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。 - 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 - 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后固定用 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成 spritesheet,再复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。 -- 生成游戏音效面板底部不显示 `type` / `tempo` 字段标题,只有一个音效参数按钮;type 只支持 `one-shot` / `loop`,BPM 为 `1-300` 的数字或 `null`,选择后按钮显示 `单次·120BPM` 这类组合值。 +- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。 +- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。 - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 - 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。 - Seedance 2.0 参考视频 / 音频上传不产生 `data:video/*` / 大体积音频 JSON;提交请求优先携带 `objectKey`,后端 Ark body 含 `generate_audio:false` 且不含未确认的 `mode` 字段。 diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index ff315b39f..5aa0221af 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md @@ -13,23 +13,23 @@ - `生成游戏音效` - `生成游戏背景音乐` 3. 选择某一项后创建独立 `generation-dialog` 画布生成对象,并通过现有 placement 模型避让已有图层和占位。 -4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 生成按钮区,不写规则说明类文案。 +4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 模型 / 生成按钮区,不写规则说明类文案。音效参数按钮靠左下角,固定模型胶囊紧贴生成按钮;背景音乐同样在右下角显示固定模型胶囊并紧贴生成按钮。 5. 生成中隐藏设置面板,只保留画布中的音频生成占位;失败后恢复面板并展示短错误。 ## 面板字段 ### 生成游戏音效 -- `prompt`:用户输入的音效提示词,直接作为 Vidu 文生音频请求的 `prompt` 字段。 -- `model`:固定默认 `audio1.0`,UI 显示为 `Vidu`;暂不展示 Suno 文生音效模型入口。 +- `prompt`:用户输入的音效提示词。前端与 BFF 对内仍使用 `prompt`,提交到 VectorEngine Vidu 时同时写入 `prompt` 与 `sound` 同值,兼容线上网关对 `sound` 字段的反序列化要求。 +- `model`:固定默认 `audio1.0`,UI 以禁用态模型胶囊显示为 `Vidu`,位置紧贴生成按钮;暂不展示 Suno 文生音效模型入口。 - `duration`:Vidu 音频时长,UI 以一个无标题参数按钮展示,选项范围 `2-10` 秒,默认 `5` 秒。 -- 提交到 VectorEngine 时映射为 Vidu 文生音频字段:`model: "audio1.0"`、`prompt`、`duration`、可选 `seed`。当前编辑器音效固定使用 Vidu `audio1.0`,不再走 Suno `task: "sound"` / `metadata_params` 文生音效契约。 +- 提交到 VectorEngine 时映射为 Vidu 文生音频字段:`model: "audio1.0"`、`prompt`、`sound: prompt`、`duration`、可选 `seed`。当前编辑器音效固定使用 Vidu `audio1.0`,不再走 Suno `task: "sound"` / `metadata_params` 文生音效契约。 ### 生成游戏背景音乐 - `gpt_description_prompt`:用户输入的背景音乐提示词。 - `make_instrumental`:固定传 `true`,不在 UI 中展示为可改字段。 -- 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv`、`gpt_description_prompt`、`make_instrumental: true`。`mv` 后端固定使用默认 Suno 模型,不在 UI 中展示。 +- 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv`、`gpt_description_prompt`、`make_instrumental: true`。`mv` 后端固定使用默认 Suno 模型,UI 以禁用态模型胶囊显示 `Suno`。 - `gpt_description_prompt` 按 Apifox 契约限制 200 字,超出时由 BFF 返回参数错误。 ## 画布数据 @@ -42,9 +42,10 @@ - 新增素材类型: - `assetKind="sound-effect"` - `assetKind="background-music"` -- 音频结果以小型音频卡加入画布,卡片底部使用 `