diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d06891..1ce7777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to BeatDesign are documented in this file. ## Unreleased +### Added + +- Added a versioned built-in Skill catalog exposed through MCP Resources and two + read-only compatibility tools, with WorkBuddy runtime packaging and probes. + +### Changed + +- Delegated account-level generation concurrency entirely to the active Provider, + removing BeatDesign's cross-Project mutex and per-Project running-task limit. +- Updated the asset-first generation request to v2: image attachments remain + generic references, while canonical `@ImageN` prompt directives express first- + and last-frame intent without separate persisted frame roles. + ## [0.2.3] - 2026-09-05 ### Added diff --git a/README.ja.md b/README.ja.md index 24aba96..8b9ddcf 100644 --- a/README.ja.md +++ b/README.ja.md @@ -82,7 +82,7 @@ pnpm dev ## AgentからBeatDesignを使う -BeatDesignはProject、Asset、Canvas、生成、Editor操作をカバーする27個のローカルMCPツールを提供し、正式なTimelineからのMP4書き出しにも対応します。Agentによる変更は同じProjectサービスを通り、ブラウザーのワークスペースに表示されます。 +BeatDesignはSkill、Project、Asset、Canvas、生成、Editor操作をカバーする29個のローカルMCPツールを提供し、内蔵Skillの検出と正式なTimelineからのMP4書き出しにも対応します。Agentによる変更は同じProjectサービスを通り、ブラウザーのワークスペースに表示されます。 MCP Hostへ接続した後は、次のように依頼できます。 diff --git a/README.md b/README.md index a578539..41a29bf 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Add your own [BeatAPI API key](https://beatapi.io/dashboard/apikeys) only when y ## Use BeatDesign with an Agent -BeatDesign exposes 27 local MCP tools for Projects, Assets, Canvas, generation, and Editor operations, including authoritative MP4 timeline rendering. Agent changes use the same project services and become visible in the browser workspace. +BeatDesign exposes 29 local MCP tools for Skills, Projects, Assets, Canvas, generation, and Editor operations, including built-in Skill discovery and authoritative MP4 timeline rendering. Agent changes use the same project services and become visible in the browser workspace. After connecting your MCP host, you can ask: diff --git a/README.zh-CN.md b/README.zh-CN.md index 67abea1..a3824e3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -82,7 +82,7 @@ pnpm dev ## 让 Agent 操作 BeatDesign -BeatDesign 提供 27 个本地 MCP 工具,覆盖 Project、Asset、Canvas、生成和 Editor 操作,包括从权威时间线导出 MP4。Agent 修改会经过同一套项目服务,并显示在浏览器工作空间中。 +BeatDesign 提供 29 个本地 MCP 工具,覆盖 Skill、Project、Asset、Canvas、生成和 Editor 操作,包括发现内置 Skill 目录以及从权威时间线导出 MP4。Agent 修改会经过同一套项目服务,并显示在浏览器工作空间中。 连接 MCP Host 后,可以直接提出这样的要求。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d50342f..12554e9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,6 +30,8 @@ The shared product model is asset-first: The browser calls local `/api` routes. Server routes validate input and resolve a logical model through the active Generation Provider contract. BeatAPI is the built-in/default provider; forks can register another source-level provider without changing Canvas, Editor, MCP, or the asset-first request contract. +BeatDesign does not implement account concurrency policy. Projects may submit generations independently, and the active Generation Provider is the sole authority for account-level admission. The local submission lock protects SQLite intent/history consistency only; it does not reserve a long-running generation slot or reject a different Project because another task is active. + Provider and optional R2/S3 credentials are encrypted in the local `config` table. Browser components never receive raw credentials. Upload storage is a separate adapter boundary. File selection remains browser-local. A successful generation precheck creates a short-lived, one-time SQLite intent that binds the project, model, exact upload count, uploaded URLs, and final generation submission. Required references are promoted only after that point and immediately before task submission; they become project assets only after the provider accepts the task. The default path uploads supported references to BeatAPI Files. Users may instead configure a public R2/S3-compatible bucket; those credentials remain local and are used only for confirmed generation inputs. diff --git a/docs/MCP.md b/docs/MCP.md index 8b09583..be10fbc 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -104,8 +104,10 @@ installation shapes. ## Tool groups -There are **27** tools: +There are **29** tools: +- Skill (2): list the bundled official Skill catalog and read one complete + versioned Skill definition. - Project (5): list, get, create, target the current MCP session, and open a workspace review surface. - Asset (4): list, get by project membership, import a local file, and extract a @@ -117,6 +119,14 @@ There are **27** tools: - Editor (8): get, incremental edit, SRT import, authoritative MP4 render, semantic snapshot, diagnostics, deep-link view, and command history. +The same bundled Skill catalog is also available through the read-only MCP +Resource `beatdesign://skills`. Individual Skill instructions use the resource +template `beatdesign://skills/{skillId}`. The two Skill tools are compatibility +fallbacks for Agent hosts that do not surface MCP Resources. Until an official +creative Skill passes prompt evaluation and a visible MCP workflow test, the +catalog intentionally returns an empty `skills` array and no Showcase entry is +published. + MCP writes use `origin=mcp` assigned inside the server. `canvas.apply` and `editor.apply` accept stable IDs, revisions, and idempotency keys. The server does not expose full Canvas or Timeline replacement. @@ -185,9 +195,11 @@ For a newly connected Canvas node, append a `place_card` operation after its listed in `sourceCardIds`, or to the right of the card's `referenceCardIds` when that field is omitted. This is an explicit initial-layout action, not a live auto-layout system: later drag positions and large manually arranged graphs stay -saved until a caller explicitly places the card again. References are passed to -generation independently of prompt text; BeatDesign does not insert synthetic -`@Image1` or `@Image2` tokens into a user's prompt. +saved until a caller explicitly places the card again. BeatDesign does not infer +first or last frames from attachment order. Image references remain generic, +while explicit workflows such as tail-frame continuation may insert canonical +prompt directives like `Use @Image1 as the first frame.`. The BeatAPI adapter +translates only those explicit `@ImageN` directives into provider frame fields. ## Current boundaries @@ -216,6 +228,10 @@ generation independently of prompt text; BeatDesign does not insert synthetic ## Skill and MCP +- `skills/official/` is the future single source of truth for portable, + versioned creative Skills bundled with BeatDesign. The loader validates the + manifest schema, directory identity, minimum BeatDesign version, and required + MCP tools; it reads instructions as data and never executes Skill scripts. - The host Skills are the workflow layer: they tell the Agent when to select a project, which MCP tools to combine, where user authorization is required, and which Canvas or Editor view must remain open for review. diff --git a/docs/PRODUCT_PLAN_AND_STATUS.md b/docs/PRODUCT_PLAN_AND_STATUS.md index e098bab..eab9b2a 100644 --- a/docs/PRODUCT_PLAN_AND_STATUS.md +++ b/docs/PRODUCT_PLAN_AND_STATUS.md @@ -31,6 +31,7 @@ BeatDesign 是一个免费、开源、本地优先的 AI 图片/视频创作工 - 远程 AI 模型能力。 - 异步任务、模型路由和服务计费。 +- 账号级并发限制,所有 BeatDesign Project 共享同一 BeatAPI 账号额度。 - 用户通过自己的 BeatAPI API Key 使用服务。 ### 不属于本仓库 @@ -93,6 +94,7 @@ Codex / Claude Code / Other Agent - 生成图片、视频和封面在展示前本地化。 - 项目 Assets 和生成历史索引。 - BeatAPI 与存储凭证保留在服务端。 +- BeatDesign 不会因另一个 Project 有运行中任务而拒绝新生成,也不设本地每 Project 并发上限;账号并发由当前 Generation Provider 返回的结果决定。 ### Studio / Canvas @@ -142,9 +144,10 @@ Codex / Claude Code / Other Agent - 幂等键会绑定首次 command;复用到另一个 command 会返回 `INVALID_COMMAND`,不会静默冒用旧结果。 - UI 内部允许 revision-checked `editor.replace_document` 支撑 undo/redo 和本地自动保存;MCP 被明确禁止整份替换,只能调用 `editor.apply`。 - Generation 的 `AssetFirstGenerationRequest` 已成为服务端权威输入:适配器媒体参数由 Asset ID 和当前 generation intent 编译,旧的客户端 URL 字段不再决定引用事实。 +- `AssetFirstGenerationRequest` v2 不保存首帧或尾帧角色;图片保持普通引用,明确的 `@ImageN` Prompt 指令是首尾帧语义的唯一来源,Adapter 仅负责翻译 Provider 所需字段和校验硬性模型限制。 - UI 命令入口不再接受客户端 `origin`;服务端固定写入 `ui`,MCP 入口在内核边界固定写入 `mcp`。 - Provider Contract 已将逻辑模型目录与 BeatAPI effectId、上传路径和上游模型名拆开;BeatAPI 是默认实现,Fork 可在源码扩展点注册其他 Provider。 -- 本地 stdio MCP Server 提供 27 个工具(Project / Asset / Canvas / Generation / Editor),模型和参数通过 capability discovery 暴露;Canvas / Editor 增量操作使用完整 JSON Schema,Agent 可直接发现操作类型和参数。MCP 生成直接调用当前 Provider,本地产品不重复实现 API Key、余额、计费或限流策略,只透传 Provider 的结果与错误。`bdesign_project_target` 绑定当前会话项目,Project/Canvas/Editor view 工具返回 Codex Browser handoff;`bdesign_asset_import` 把本地文件导入项目 Asset 库;`bdesign_asset_extract_frame` 与 `bdesign_canvas_continue_from_tail` 负责抽帧续写;Editor MCP 可导入 SRT、放置和替换任意项目图片 Overlay、调整叠层与单条字幕参数,并通过 `bdesign_editor_render` 将权威时间线导出为项目内 MP4 Asset。 +- 本地 stdio MCP Server 提供 29 个工具(Skill / Project / Asset / Canvas / Generation / Editor),模型和参数通过 capability discovery 暴露;Canvas / Editor 增量操作使用完整 JSON Schema,Agent 可直接发现操作类型和参数。MCP 生成直接调用当前 Provider,本地产品不重复实现 API Key、余额、计费或限流策略,只透传 Provider 的结果与错误。`bdesign_skill_list` / `bdesign_skill_get` 是不显示 MCP Resources 的宿主兼容入口;`bdesign_project_target` 绑定当前会话项目,Project/Canvas/Editor view 工具返回 Codex Browser handoff;`bdesign_asset_import` 把本地文件导入项目 Asset 库;`bdesign_asset_extract_frame` 与 `bdesign_canvas_continue_from_tail` 负责抽帧续写;Editor MCP 可导入 SRT、放置和替换任意项目图片 Overlay、调整叠层与单条字幕参数,并通过 `bdesign_editor_render` 将权威时间线导出为项目内 MP4 Asset。 - Codex、Claude Code 与 WorkBuddy 接入包内含 `beatdesign-workspace` Skill,负责项目选择、字幕/续写工具编排、付费生成停点和可视化复核;三者共用同一 MCP 与本地 Project 数据,其中 Claude Code 和 WorkBuddy 使用本机 HTTP MCP。 ## 6. v0.2 Phase 1 本地已实现 @@ -159,7 +162,8 @@ Codex / Claude Code / Other Agent - Canvas -> Timeline Node -> Editor 连续工作流。 - Editor 自动保存接入命令入口,并补齐冲突三方合并、重复操作保护和稳定播放头时间。 - 图片 Clip、时间线拖拽调整持续时间与图片/视频统一视觉轨。 -- 本地 MCP Server 提供 27 个 Project、Asset、Canvas、Generation、Editor 工具;支持会话项目绑定、Canvas/Editor 可视化交接、从绝对路径导入本地素材、抽取尾帧续写、导入和精调 SRT 字幕、放置/替换/调整图片 Overlay,以及权威时间线 MP4 导出。 +- 本地 MCP Server 提供 29 个 Skill、Project、Asset、Canvas、Generation、Editor 工具;支持内置 Skill 目录发现、会话项目绑定、Canvas/Editor 可视化交接、从绝对路径导入本地素材、抽取尾帧续写、导入和精调 SRT 字幕、放置/替换/调整图片 Overlay,以及权威时间线 MP4 导出。 +- 已加入只读 `beatdesign://skills` MCP Resource、单 Skill Resource Template、schema v1 清单校验和宿主兼容工具;当前官方创作 Skill 目录有意保持为空,待具体 Skill 完成提示词评测和可见工作流联调后再加入,并在那时开放 Showcase 入口。 - MCP 增量 Canvas/Editor 命令在短暂 revision 竞争时会基于最新权威文档限次自动重放;持续冲突返回最新 revision 和明确重试提示。 - Canvas 与 Editor 每 2 秒并在页面重新聚焦时检查 MCP 写入的新 revision。 @@ -186,7 +190,7 @@ Codex / Claude Code / Other Agent ### Agent / MCP 后续边界 -- MCP Resources 和更完整的 schema versioning。 +- 除内置 Skill 目录之外的更多 MCP Resources,以及 Canvas / Editor 等现有合同更完整的 schema versioning。 - Agent Activity、命令审计和实时 UI 事件桥。 - 外部市场正式审核与上架。仓库已提供 Codex 本地插件、可直接添加的 Claude Code 仓库插件市场,以及符合目录结构的 WorkBuddy MCP + Skill Connector;这些本地接入包不等于已通过第三方市场审核。 - 独立的 headless 像素预览与后台媒体 Worker;当前 MCP MP4 导出在本地 MCP Server 进程中完成。 diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 008dd8e..a60ae67 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -2,6 +2,8 @@ BeatAPI is the built-in and default generation/analysis provider. The official BeatAPI adapter keeps its upstream URL fixed to `https://api.beatapi.io`; users only provide their own BeatAPI API key. +BeatAPI owns account-level generation concurrency. BeatDesign does not impose a separate cross-Project or per-Project generation limit; it submits each confirmed request and surfaces the provider's admission error when the connected account has no remaining capacity. + Configure it in the Provider dialog in the workspace header. The key is encrypted in the local `config` table with a per-install key stored under `data/`. The adapter uses: @@ -14,6 +16,8 @@ The adapter uses: User-facing model capabilities are defined in `src/core/effects/effect-registry.ts`. Provider bindings live in `src/core/generation-providers/`; MCP and UI use the logical model id and capability schema, not BeatAPI `effectId` or raw upstream fields. +Image attachments remain generic references in the BeatDesign request contract. First-frame and last-frame intent exists only in explicit prompt directives such as `Use @Image1 as the first frame.` and `Use @Image2 as the last frame.`; attachment order alone never assigns a frame role. The BeatAPI adapter recognizes this canonical syntax and still enforces each upstream model's hard media limits. + Kling 2.6 and Kling 3.0 Motion Control are exposed as BeatAPI models. Each run requires exactly one character image and one MP4/MOV motion video uploaded through the connected BeatAPI account. The Workspace never asks users for a KIE key; BeatAPI owns the upstream provider route, billing, polling, and output persistence. Video Analysis is exposed as a stable BeatAPI workflow with Standard and Deep depth controls. The Workspace uploads one MP4/MOV input, submits the analysis task, polls `GET /v1/tasks/:id`, and stores the returned report text and usage in the local project history. Provider-specific Gemini routing remains private to BeatAPI. diff --git a/docs/RELEASE_SCOPE.md b/docs/RELEASE_SCOPE.md index 8c0bbc7..a48185a 100644 --- a/docs/RELEASE_SCOPE.md +++ b/docs/RELEASE_SCOPE.md @@ -12,7 +12,7 @@ Release verification requires a clean install, schema creation, production build - [x] Application, Codex, Claude Code, WorkBuddy, Claude marketplace, and LobeHub metadata agree on `0.2.3`. - [x] The WorkBuddy Connector archive passes deterministic structure validation. -- [x] The packaged WorkBuddy runtime installs in an empty directory, starts the local workspace, completes an MCP handshake, exposes all 27 tools, and keeps data outside the package directory. +- [x] The packaged WorkBuddy runtime installs in an empty directory, starts the local workspace, completes an MCP handshake, exposes all 29 tools plus the bundled Skill catalog Resource, and keeps data outside the package directory. - [x] Run `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, and `pnpm build` on the release candidate. - [ ] Publish `@beatapi/beatdesign-workbuddy@0.2.3` to npm and verify the public registry artifact. - [ ] Upload the Connector archive to the WorkBuddy Open Platform and submit it for review. diff --git a/docs/prd/BEATDESIGN_V0_2.md b/docs/prd/BEATDESIGN_V0_2.md index a0595ca..259ef30 100644 --- a/docs/prd/BEATDESIGN_V0_2.md +++ b/docs/prd/BEATDESIGN_V0_2.md @@ -80,7 +80,7 @@ Canvas 对 Asset、Generation 配置或 Timeline 的可视化引用。节点位 ### 3.6 两类关系 1. Canvas Edge:用户排列、连接和关注关系。 -2. Generation Lineage:真实的 `derived_from`、`first_frame`、`last_frame`、`continuation_of`、`redo_of`、`timeline_render` 等来源关系。 +2. Generation Lineage:真实的 `derived_from`、`continuation_of`、`redo_of`、`timeline_render` 等来源关系;首尾帧用途只由明确的 `@ImageN` Prompt 指令表达,不作为独立持久化状态。 本轮先在派生 Asset metadata 与 Canvas 引用中记录尾帧和时间线关系;后续升级为独立可查询的 lineage contract。 @@ -138,7 +138,7 @@ Editor Clip 必须引用具体 `assetId`,不能动态跟随 Canvas Generation - 本地 MCP Server。`已完成基础版` - Project、Canvas、Editor、Assets、Generation 工具组。`已完成基础版` - 模型 capability discovery。`已完成` -- 只读 MCP Resources。`未完成` +- 只读 MCP Resources。`已完成 Skill 目录基础版;更多资源后续补充` - 付费生成确认、幂等键、结构化结果和操作历史。 - Agent 修改后 UI 实时刷新。 - Codex 优先接入,Claude Code 和其他宿主使用同一协议。 diff --git a/integrations/codex/beatdesign/README.md b/integrations/codex/beatdesign/README.md index 7e9bc68..0dbd0d0 100644 --- a/integrations/codex/beatdesign/README.md +++ b/integrations/codex/beatdesign/README.md @@ -8,7 +8,7 @@ Canvas or Editor URL to Codex's in-app Browser for visible review. ```text Browser: pnpm dev → http://127.0.0.1:3020 (Canvas/Editor review surface) -Agent: MCP stdio → pnpm mcp (Agent calls 27 tools) +Agent: MCP stdio → pnpm mcp (Agent calls 29 tools) Both processes share the same local SQLite + project files. ``` diff --git a/scripts/build-workbuddy-runtime-package.ts b/scripts/build-workbuddy-runtime-package.ts index 81dc3e7..b445125 100644 --- a/scripts/build-workbuddy-runtime-package.ts +++ b/scripts/build-workbuddy-runtime-package.ts @@ -42,6 +42,7 @@ const runtimePackage = { 'bin', 'drizzle', 'mcp', + 'skills', 'LICENSE', 'README.md', ], @@ -87,6 +88,9 @@ await Promise.all([ resolve(outputRoot, 'drizzle', 'sqlite'), { recursive: true } ), + cp(resolve(repositoryRoot, 'skills'), resolve(outputRoot, 'skills'), { + recursive: true, + }), cp( resolve(repositoryRoot, 'integrations', 'workbuddy', 'runtime', 'launcher.mjs'), resolve(outputRoot, 'bin', 'beatdesign-workbuddy.mjs') diff --git a/scripts/probe-workbuddy-runtime-package.ts b/scripts/probe-workbuddy-runtime-package.ts index eb5852a..6dd5806 100644 --- a/scripts/probe-workbuddy-runtime-package.ts +++ b/scripts/probe-workbuddy-runtime-package.ts @@ -130,6 +130,19 @@ try { send({ jsonrpc: '2.0', id: 3, + method: 'resources/read', + params: { uri: 'beatdesign://skills' }, + }); + const skillCatalog = await readJsonLine(15_000); + const catalogText = ( + skillCatalog.result as { contents?: Array<{ text?: string }> } + ).contents?.[0]?.text; + assert.ok(catalogText); + assert.ok(Array.isArray((JSON.parse(catalogText) as { skills?: unknown[] }).skills)); + + send({ + jsonrpc: '2.0', + id: 4, method: 'tools/call', params: { name: 'bdesign_project_list', arguments: { limit: 5 } }, }); @@ -169,6 +182,17 @@ try { assert.match(conflictError, /Port 3020 is already serving another BeatDesign/); await access(resolve(dataDirectory, 'local.db')); + await access( + resolve( + probeRoot, + 'node_modules', + '@beatapi', + 'beatdesign-workbuddy', + 'skills', + 'official', + 'README.md' + ) + ); await assert.rejects( access( resolve( diff --git a/skills/official/README.md b/skills/official/README.md new file mode 100644 index 0000000..a7742ea --- /dev/null +++ b/skills/official/README.md @@ -0,0 +1,24 @@ +# Official BeatDesign Skills + +This directory is the source of truth for portable creative Skills bundled with +BeatDesign. The registry is intentionally empty until a Skill has completed +prompt evaluation and a visible BeatDesign MCP workflow test. + +Each accepted Skill lives in `skills/official//` and contains exactly +the two required source files: + +```text +/ +├── skill.json +└── SKILL.md +``` + +`skill.json` uses schema version `1` from +`src/core/skills/skill-registry.ts`. Its `id` must match the directory name. +`SKILL.md` contains model-readable workflow instructions. A bundled Skill may +reference BeatDesign MCP tools, but it must not contain executable scripts, +credentials, generated user media, or automatic authorization for paid +generation and export. + +The Showcase UI and host-specific installation packages are consumers of this +registry. They must not become separate Skill sources of truth. diff --git a/src/core/adapters/beatapi-adapter.test.ts b/src/core/adapters/beatapi-adapter.test.ts index 0ddaa65..c93ab87 100644 --- a/src/core/adapters/beatapi-adapter.test.ts +++ b/src/core/adapters/beatapi-adapter.test.ts @@ -178,7 +178,7 @@ test('passes image references to every BeatAPI image model', () => { assert.deepEqual(request.body.images, ['https://example.com/source.png']); }); -test('uses reference images by default and frames only when @Image roles are explicit', () => { +test('uses reference images by default and frames only when @Image directives are explicit', () => { const frames = [ 'https://example.com/first.png', 'https://example.com/last.png', @@ -225,14 +225,15 @@ test('uses reference images by default and frames only when @Image roles are exp assert.deepEqual(request.body.reference_images, images); }); -test('explicit Chinese @Image roles determine first and last frame order', () => { +test('canonical @Image directives determine first and last frame order', () => { const firstAttached = 'https://example.com/attached-first.png'; const secondAttached = 'https://example.com/attached-second.png'; const request = buildBeatApiTaskRequest({ effectType: 1, model: 'minimax-h3', input: { - prompt: '将 @Image2 作为首帧,@Image1 作为尾帧。', + prompt: + 'Use @Image2 as the first frame and @Image1 as the last frame.', image_urls: [firstAttached, secondAttached], }, }); @@ -241,6 +242,21 @@ test('explicit Chinese @Image roles determine first and last frame order', () => assert.equal(request.body.reference_images, undefined); }); +test('non-canonical frame wording remains a generic image reference', () => { + const image = 'https://example.com/reference.png'; + const request = buildBeatApiTaskRequest({ + effectType: 1, + model: 'minimax-h3', + input: { + prompt: '将 @Image1 作为首帧,继续镜头。', + image_urls: [image], + }, + }); + + assert.equal(request.body.images, undefined); + assert.deepEqual(request.body.reference_images, [image]); +}); + test('normalizes MiniMax H3 text defaults and Veo reference quality', () => { const h3 = buildBeatApiTaskRequest({ effectType: 1, diff --git a/src/core/adapters/beatapi-adapter.ts b/src/core/adapters/beatapi-adapter.ts index 95ed2bd..365b7ad 100644 --- a/src/core/adapters/beatapi-adapter.ts +++ b/src/core/adapters/beatapi-adapter.ts @@ -160,34 +160,22 @@ const assertAtMost = (label: string, count: number, max: number) => { } }; -const FRAME_ROLE_PATTERNS = { - first: '(?:首帧|起始帧|开始帧|first[\\s-]*frame|start(?:ing)?[\\s-]*frame)', - last: '(?:尾帧|末帧|结束帧|last[\\s-]*frame|end(?:ing)?[\\s-]*frame)', -} as const; - const resolveImageIndexForRole = ({ prompt, role, imageCount, }: { prompt: string; - role: keyof typeof FRAME_ROLE_PATTERNS; + role: 'first' | 'last'; imageCount: number; }): number | null => { - const aliases = [...prompt.matchAll(/@Image(\d+)/giu)].flatMap((match) => { + const directivePattern = new RegExp( + `@Image(\\d+)\\s+as\\s+the\\s+${role}\\s+frame\\b`, + 'giu' + ); + for (const match of prompt.matchAll(directivePattern)) { const imageIndex = Number.parseInt(match[1] ?? '', 10) - 1; - return imageIndex >= 0 && imageIndex < imageCount && match.index !== undefined - ? [{ imageIndex, start: match.index, end: match.index + match[0].length }] - : []; - }); - const rolePattern = new RegExp(FRAME_ROLE_PATTERNS[role], 'iu'); - for (const [aliasIndex, alias] of aliases.entries()) { - const nextAliasStart = aliases[aliasIndex + 1]?.start ?? prompt.length; - const assignedText = prompt.slice( - alias.end, - Math.min(nextAliasStart, alias.end + 48) - ); - if (rolePattern.test(assignedText)) return alias.imageIndex; + if (imageIndex >= 0 && imageIndex < imageCount) return imageIndex; } return null; }; diff --git a/src/core/beatcanvas/generation-controller.ts b/src/core/beatcanvas/generation-controller.ts index 065c6f2..0434d02 100644 --- a/src/core/beatcanvas/generation-controller.ts +++ b/src/core/beatcanvas/generation-controller.ts @@ -28,6 +28,7 @@ import { } from '@/core/effects/workspace-models'; import { buildAssetFirstReferencesFromCanvasCards, + GENERATION_REQUEST_VERSION, normalizeAssetFirstGenerationRequest, } from '@/core/commands/generation-contract'; import { @@ -395,14 +396,6 @@ export const buildGenerationEffectInput = async ({ input.image_urls = referencePayload.imageUrls; } - if ( - referencePayload.imageUrls.length > 1 && - hasInputSchemaField(metadata.inputSchema, 'last_frame') - ) { - input.last_frame = - referencePayload.imageUrls[referencePayload.imageUrls.length - 1]; - } - if (referencePayload.videoUrls.length > 0) { if (!hasInputSchemaField(metadata.inputSchema, 'video_urls')) { throw new Error(translate('messages.videoContinuationUnsupported')); @@ -730,7 +723,7 @@ export const runDraftGeneration = async ({ const generation = projectId && isCanvasDraftCard(submittedCard) ? normalizeAssetFirstGenerationRequest({ - version: 1, + version: GENERATION_REQUEST_VERSION, projectId, mode: isCanvasAnalysisCard(submittedCard) ? 'analysis' @@ -745,9 +738,6 @@ export const runDraftGeneration = async ({ }) ), referenceCardIds: submittedCard.referenceCardIds, - mode: isCanvasAnalysisCard(submittedCard) - ? 'analysis' - : submittedCard.type, deliveryUrlsByCardId: referenceUrlOverrides, }), parameters: Object.fromEntries( diff --git a/src/core/beatcanvas/reference-mentions.test.ts b/src/core/beatcanvas/reference-mentions.test.ts index 6a651de..4203966 100644 --- a/src/core/beatcanvas/reference-mentions.test.ts +++ b/src/core/beatcanvas/reference-mentions.test.ts @@ -4,6 +4,8 @@ import test from 'node:test'; import type { CanvasCard } from './canvas-types'; import { buildCanvasReferenceMentions, + CANONICAL_FIRST_FRAME_PROMPT_DIRECTIVE, + ensureCanvasFirstFramePromptDirective, filterCanvasReferenceMentions, findActiveCanvasReferenceMention, insertCanvasReferenceMention, @@ -58,6 +60,23 @@ test('numbers images and videos independently in API order', () => { ); }); +test('adds one canonical first-frame directive without rewriting the creative prompt', () => { + assert.equal( + ensureCanvasFirstFramePromptDirective('雪の粒子が舞い始める。'), + `${CANONICAL_FIRST_FRAME_PROMPT_DIRECTIVE}\n\n雪の粒子が舞い始める。` + ); + assert.equal( + ensureCanvasFirstFramePromptDirective( + 'Use @Image1 as the first frame while the camera moves forward.' + ), + 'Use @Image1 as the first frame while the camera moves forward.' + ); + assert.equal( + ensureCanvasFirstFramePromptDirective('将 @Image1 作为首帧,继续镜头。'), + `${CANONICAL_FIRST_FRAME_PROMPT_DIRECTIVE}\n\n将 @Image1 作为首帧,继续镜头。` + ); +}); + test('finds and replaces the active @ query at the caret', () => { const prompt = 'Keep @Im'; const activeMention = findActiveCanvasReferenceMention({ diff --git a/src/core/beatcanvas/reference-mentions.ts b/src/core/beatcanvas/reference-mentions.ts index 81861e7..4b5b1fe 100644 --- a/src/core/beatcanvas/reference-mentions.ts +++ b/src/core/beatcanvas/reference-mentions.ts @@ -19,6 +19,20 @@ export type ActiveCanvasReferenceMention = { const REFERENCE_MENTION_PATTERN = /@(?:Image|Video)\d+/g; +export const CANONICAL_FIRST_FRAME_PROMPT_DIRECTIVE = + 'Use @Image1 as the first frame.'; + +const CANONICAL_FIRST_FRAME_PROMPT_PATTERN = + /@Image1\s+as\s+the\s+first\s+frame\b/iu; + +export const ensureCanvasFirstFramePromptDirective = (prompt: string) => { + const trimmed = prompt.trim(); + if (CANONICAL_FIRST_FRAME_PROMPT_PATTERN.test(trimmed)) return trimmed; + return [CANONICAL_FIRST_FRAME_PROMPT_DIRECTIVE, trimmed] + .filter(Boolean) + .join('\n\n'); +}; + export const getCanvasReferenceAlias = ( type: CanvasCardMediaType, index: number diff --git a/src/core/commands/command-kernel.test.ts b/src/core/commands/command-kernel.test.ts index ffee0d7..34aee20 100644 --- a/src/core/commands/command-kernel.test.ts +++ b/src/core/commands/command-kernel.test.ts @@ -168,26 +168,36 @@ test('local import validates file size before reading and accepts audio extensio test('generation requests are asset-first and do not accept placement', () => { assert.throws(() => normalizeAssetFirstGenerationRequest({ - version: 1, + version: 2, projectId: 'project-1', mode: 'video', modelId: 'model-1', prompt: 'Continue the shot', - references: [{ assetId: 'asset-frame', role: 'first_frame' }], + references: [{ assetId: 'asset-frame', role: 'reference' }], parameters: {}, placement: 'canvas_node', }) ); const request = normalizeAssetFirstGenerationRequest({ - version: 1, + version: 2, projectId: 'project-1', mode: 'video', modelId: 'model-1', prompt: 'Continue the shot', - references: [{ assetId: 'asset-frame', role: 'first_frame' }], + references: [{ assetId: 'asset-frame', role: 'reference' }], }); assert.equal(request.references[0]?.assetId, 'asset-frame'); + assert.throws(() => + normalizeAssetFirstGenerationRequest({ + version: 2, + projectId: 'project-1', + mode: 'video', + modelId: 'model-1', + prompt: 'Use @Image1 as the first frame.', + references: [{ assetId: 'asset-frame', role: 'first_frame' }], + }) + ); }); test('canvas operations update cards and frames atomically', () => { diff --git a/src/core/commands/generation-compiler.ts b/src/core/commands/generation-compiler.ts index b33d002..f3aa2c7 100644 --- a/src/core/commands/generation-compiler.ts +++ b/src/core/commands/generation-compiler.ts @@ -48,8 +48,6 @@ export async function compileAssetFirstGenerationInput({ const imageUrls: string[] = []; const videoUrls: string[] = []; const audioUrls: string[] = []; - let firstFrame: string | null = null; - let lastFrame: string | null = null; for (const reference of generation.references) { const asset = await getProjectAssetById({ @@ -86,8 +84,6 @@ export async function compileAssetFirstGenerationInput({ ); } imageUrls.push(deliveryUrl); - if (reference.role === 'first_frame') firstFrame = deliveryUrl; - if (reference.role === 'last_frame') lastFrame = deliveryUrl; } const parameters = Object.fromEntries( @@ -101,8 +97,6 @@ export async function compileAssetFirstGenerationInput({ ...(imageUrls.length > 0 ? { image_urls: [...new Set(imageUrls)] } : {}), ...(videoUrls.length > 0 ? { video_urls: [...new Set(videoUrls)] } : {}), ...(audioUrls.length > 0 ? { audio_urls: [...new Set(audioUrls)] } : {}), - ...(firstFrame ? { first_frame: firstFrame } : {}), - ...(lastFrame ? { last_frame: lastFrame } : {}), ...(generation.mode === 'analysis' && videoUrls[0] ? { video_url: videoUrls[0] } : {}), diff --git a/src/core/commands/generation-contract.test.ts b/src/core/commands/generation-contract.test.ts new file mode 100644 index 0000000..6e51e98 --- /dev/null +++ b/src/core/commands/generation-contract.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { CanvasCard } from '@/core/beatcanvas/canvas-types'; + +import { + buildAssetFirstReferencesFromCanvasCards, + GENERATION_REQUEST_VERSION, + normalizeAssetFirstGenerationRequest, +} from './generation-contract'; + +const makeAssetCard = ( + id: string, + assetId: string, + type: 'image' | 'video' | 'audio' +): CanvasCard => ({ + id, + assetId, + kind: 'asset', + type, + name: id, + url: `https://example.com/${id}`, + prompt: '', + referenceCardIds: [], + workflowTemplateId: null, + status: 'succeeded', + error: null, + modelId: '', + aspectRatio: '1:1', + outputQuality: '1k', + duration: '5s', + mode: 'quality', + variant: 'standard', + quality: 'standard', + sourceGenerationId: null, +}); + +test('generation request v2 rejects the former frame-role contract', () => { + assert.equal(GENERATION_REQUEST_VERSION, 2); + assert.throws(() => + normalizeAssetFirstGenerationRequest({ + version: 1, + projectId: 'project-1', + mode: 'video', + modelId: 'seedance-2', + prompt: 'Use @Image1 as the first frame.', + references: [{ assetId: 'asset-first', role: 'first_frame' }], + }) + ); +}); + +test('Canvas image references stay generic regardless of attachment order', () => { + const cards = { + first: makeAssetCard('first', 'asset-first', 'image'), + second: makeAssetCard('second', 'asset-second', 'image'), + }; + + assert.deepEqual( + buildAssetFirstReferencesFromCanvasCards({ + cards, + referenceCardIds: ['first', 'second'], + }).map(({ assetId, role }) => ({ assetId, role })), + [ + { assetId: 'asset-first', role: 'reference' }, + { assetId: 'asset-second', role: 'reference' }, + ] + ); +}); + +test('non-image references retain only their media-level roles', () => { + const cards = { + video: makeAssetCard('video', 'asset-video', 'video'), + audio: makeAssetCard('audio', 'asset-audio', 'audio'), + }; + + assert.deepEqual( + buildAssetFirstReferencesFromCanvasCards({ + cards, + referenceCardIds: ['video', 'audio'], + }).map(({ assetId, role }) => ({ assetId, role })), + [ + { assetId: 'asset-video', role: 'source' }, + { assetId: 'asset-audio', role: 'audio_track' }, + ] + ); +}); diff --git a/src/core/commands/generation-contract.ts b/src/core/commands/generation-contract.ts index f28ed70..856dff5 100644 --- a/src/core/commands/generation-contract.ts +++ b/src/core/commands/generation-contract.ts @@ -2,7 +2,7 @@ import type { CanvasCard } from '@/core/beatcanvas/canvas-types'; import { parseLocalProjectAssetUrl } from '@/core/projects/local-project-asset-url'; import { z } from 'zod'; -export const GENERATION_REQUEST_VERSION = 1 as const; +export const GENERATION_REQUEST_VERSION = 2 as const; export const generationReferenceRoleSchema = z.enum([ 'source', @@ -10,8 +10,6 @@ export const generationReferenceRoleSchema = z.enum([ 'style', 'subject', 'pose', - 'first_frame', - 'last_frame', 'audio_track', ]); @@ -63,12 +61,10 @@ const resolveCardAssetId = (card: CanvasCard) => export function buildAssetFirstReferencesFromCanvasCards({ cards, referenceCardIds, - mode, deliveryUrlsByCardId = {}, }: { cards: Record; referenceCardIds: string[]; - mode: AssetFirstGenerationRequest['mode']; deliveryUrlsByCardId?: Record; }) { const mediaCards = referenceCardIds.flatMap((cardId) => { @@ -82,9 +78,6 @@ export function buildAssetFirstReferencesFromCanvasCards({ return [card]; }); - const imageCount = mediaCards.filter((card) => card.type === 'image').length; - let imageIndex = 0; - const references: Array<{ assetId: string; role: GenerationReferenceRole; @@ -101,15 +94,6 @@ export function buildAssetFirstReferencesFromCanvasCards({ let role: GenerationReferenceRole = 'reference'; if (card.type === 'video') role = 'source'; else if (card.type === 'audio') role = 'audio_track'; - else if (mode === 'video') { - role = - imageIndex === 0 - ? 'first_frame' - : imageIndex === imageCount - 1 && imageCount > 1 - ? 'last_frame' - : 'reference'; - imageIndex += 1; - } const deliveryUrl = deliveryUrlsByCardId[card.id]?.trim() || card.url?.trim(); references.push({ assetId, diff --git a/src/core/effects/generation-concurrency.ts b/src/core/effects/generation-concurrency.ts deleted file mode 100644 index da49468..0000000 --- a/src/core/effects/generation-concurrency.ts +++ /dev/null @@ -1,59 +0,0 @@ -export const MAX_RUNNING_GENERATIONS_PER_PROJECT = 7; - -export type GenerationConcurrencyGateResult = - | { - ok: true; - } - | { - ok: false; - code: 'ANOTHER_PROJECT_RUNNING'; - activeProjectId: string; - } - | { - ok: false; - code: 'PROJECT_CONCURRENCY_LIMIT'; - limit: number; - }; - -export const resolveGenerationConcurrencyGate = ({ - requestedProjectId, - activeProjectId, - runningCountForRequestedProject, - limit = MAX_RUNNING_GENERATIONS_PER_PROJECT, -}: { - requestedProjectId: string; - activeProjectId: string | null; - runningCountForRequestedProject: number; - limit?: number; -}): GenerationConcurrencyGateResult => { - if (activeProjectId && activeProjectId !== requestedProjectId) { - return { - ok: false, - code: 'ANOTHER_PROJECT_RUNNING', - activeProjectId, - }; - } - - if (runningCountForRequestedProject >= limit) { - return { - ok: false, - code: 'PROJECT_CONCURRENCY_LIMIT', - limit, - }; - } - - return { - ok: true, - }; -}; - -export const getGenerationConcurrencyErrorMessage = ( - result: Exclude -) => { - switch (result.code) { - case 'ANOTHER_PROJECT_RUNNING': - return 'Another project is already running for this account.'; - case 'PROJECT_CONCURRENCY_LIMIT': - return `This project already has ${result.limit} running generations.`; - } -}; diff --git a/src/core/effects/provider-controlled-concurrency.contract.test.ts b/src/core/effects/provider-controlled-concurrency.contract.test.ts new file mode 100644 index 0000000..984e99b --- /dev/null +++ b/src/core/effects/provider-controlled-concurrency.contract.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const submitGeneration = readFileSync( + new URL('./submit-generation.ts', import.meta.url), + 'utf8' +); +const precheck = readFileSync( + new URL('../../routes/api/effects/precheck.ts', import.meta.url), + 'utf8' +); + +test('BeatDesign delegates account concurrency across projects to the generation provider', () => { + for (const source of [submitGeneration, precheck]) { + assert.doesNotMatch(source, /resolveGenerationConcurrencyGate/); + assert.doesNotMatch(source, /ANOTHER_PROJECT_RUNNING|PROJECT_CONCURRENCY_LIMIT/); + } +}); diff --git a/src/core/effects/record-generation.ts b/src/core/effects/record-generation.ts index 6ce0b02..0385fe3 100644 --- a/src/core/effects/record-generation.ts +++ b/src/core/effects/record-generation.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { and, desc, eq, inArray, sql } from 'drizzle-orm'; +import { and, desc, eq, inArray } from 'drizzle-orm'; import { generationHistory } from '@/config/db/schema'; import { deriveGenerationOperationalFields } from '@/core/effects/generation-operational-fields'; @@ -66,31 +66,6 @@ export async function recordGeneration({ } } -export async function countRunningGenerationsForProject(projectId: string) { - const db = await getDb(); - const rows = await db - .select({ count: sql`count(*)` }) - .from(generationHistory) - .where( - and( - eq(generationHistory.projectId, projectId), - inArray(generationHistory.status, RUNNING_STATUSES) - ) - ); - return Number(rows[0]?.count ?? 0); -} - -export async function findActiveProject() { - const db = await getDb(); - const rows = await db - .select({ projectId: generationHistory.projectId }) - .from(generationHistory) - .where(inArray(generationHistory.status, RUNNING_STATUSES)) - .orderBy(desc(generationHistory.createdAt)) - .limit(1); - return rows[0]?.projectId ?? null; -} - export async function updateGenerationById({ id, status, diff --git a/src/core/effects/submit-generation.ts b/src/core/effects/submit-generation.ts index 2a94ee6..89ef17a 100644 --- a/src/core/effects/submit-generation.ts +++ b/src/core/effects/submit-generation.ts @@ -3,18 +3,12 @@ import { getEffectById } from '@/core/effects/effects'; import { getWorkspaceEffectRegistryEntryByEffectId } from '@/core/effects/effect-registry'; import { VIDEO_ANALYSIS_MODEL_ID } from '@/core/effects/video-analysis'; import { getGenerationModelBindingByEffectId } from '@/core/generation-providers'; -import { - getGenerationConcurrencyErrorMessage, - resolveGenerationConcurrencyGate, -} from '@/core/effects/generation-concurrency'; import { resolveGenerationSubmitTransition, resolveProviderTaskId, } from '@/core/effects/generation-orchestrator'; import { persistEffectOutputIfNeeded } from '@/core/effects/output-storage'; import { - countRunningGenerationsForProject, - findActiveProject, recordGeneration, updateGenerationById, } from '@/core/effects/record-generation'; @@ -213,35 +207,6 @@ export async function submitEffectGeneration({ | { result: SubmitEffectGenerationResult } | { generationId: string; intentId: string } >(async () => { - const [activeProjectId, runningCount] = await Promise.all([ - findActiveProject(), - countRunningGenerationsForProject(normalizedProjectId), - ]); - const gate = resolveGenerationConcurrencyGate({ - requestedProjectId: normalizedProjectId, - activeProjectId, - runningCountForRequestedProject: runningCount, - }); - if (!gate.ok) { - return { - result: { - status: 429, - body: { - error: getGenerationConcurrencyErrorMessage(gate), - code: gate.code, - activeProjectId: - gate.code === 'ANOTHER_PROJECT_RUNNING' - ? gate.activeProjectId - : undefined, - limit: - gate.code === 'PROJECT_CONCURRENCY_LIMIT' - ? gate.limit - : undefined, - }, - } satisfies SubmitEffectGenerationResult, - }; - } - const referencedUrls = getReferencedUrls(adapterInput); if ( referencedUrls.some( diff --git a/src/core/projects/continue-from-tail-frame.test.ts b/src/core/projects/continue-from-tail-frame.test.ts index bf1fbea..e41186e 100644 --- a/src/core/projects/continue-from-tail-frame.test.ts +++ b/src/core/projects/continue-from-tail-frame.test.ts @@ -128,6 +128,13 @@ test('tail continuation returns a visible review target after a recovered write' assert.equal(result.frameAsset.reused, true); assert.equal(result.review.tool, 'bdesign_canvas_view'); assert.equal(result.review.cardId, result.generationCardId); + assert.equal( + result.next.prompt, + 'Use @Image1 as the first frame.' + ); + assert.deepEqual(result.next.references, [ + { assetId: frameAsset.id, role: 'reference' }, + ]); }); test('one command id cannot be reused with different generation settings', async () => { diff --git a/src/core/projects/continue-from-tail-frame.ts b/src/core/projects/continue-from-tail-frame.ts index 1734b40..beb30c3 100644 --- a/src/core/projects/continue-from-tail-frame.ts +++ b/src/core/projects/continue-from-tail-frame.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import type { CanvasCard } from '@/core/beatcanvas/canvas-types'; +import { ensureCanvasFirstFramePromptDirective } from '@/core/beatcanvas/reference-mentions'; import { persistExternalCommandWithConflictRetry } from '@/core/commands/conflict-retry'; import { createCommandId } from '@/core/commands/contracts'; import { @@ -94,12 +95,13 @@ async function continueFromTailFrameOnce({ modelId?.trim() || (sourceCard?.type === 'video' && sourceCard.modelId) || DEFAULT_VIDEO_MODEL_ID; + const resolvedPrompt = ensureCanvasFirstFramePromptDirective(prompt); const existingContinuation = state.snapshot.cards.find( (card) => card.id === identity.generationCardId ); if ( existingContinuation && - (existingContinuation.prompt !== prompt.trim() || + (existingContinuation.prompt !== resolvedPrompt || existingContinuation.modelId !== resolvedModelId) ) { throw new Error( @@ -152,7 +154,7 @@ async function continueFromTailFrameOnce({ type: 'video', name: 'Continue shot', url: null, - prompt: prompt.trim(), + prompt: resolvedPrompt, referenceCardIds: [tailFrameCardId], workflowTemplateId: null, status: 'idle', @@ -216,8 +218,8 @@ async function continueFromTailFrameOnce({ sourceCardId: generationCardId, mode: 'video', modelId: resolvedModelId, - prompt: prompt.trim(), - references: [{ assetId: frameAsset.id, role: 'first_frame' }], + prompt: resolvedPrompt, + references: [{ assetId: frameAsset.id, role: 'reference' }], }, }; } diff --git a/src/core/skills/skill-registry.test.ts b/src/core/skills/skill-registry.test.ts new file mode 100644 index 0000000..d344c40 --- /dev/null +++ b/src/core/skills/skill-registry.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; + +import { + BEATDESIGN_SKILL_CATALOG_URI, + createBeatDesignSkillRegistry, + loadBeatDesignSkillRegistry, + type BeatDesignSkillDefinition, +} from './skill-registry'; + +const definition = (id = 'ugc-director'): BeatDesignSkillDefinition => ({ + manifest: { + schemaVersion: 1, + id, + version: '1.0.0', + title: 'UGC Director', + summary: 'Plan a reviewable UGC production workflow.', + visibility: 'showcase', + compatibility: { + minimumBeatDesignVersion: '0.2.3', + requiredMcpTools: ['bdesign_canvas_apply'], + }, + requiredCapabilities: ['image-to-video'], + inputs: [], + stages: [], + showcase: { + category: 'ugc', + tags: ['video'], + featured: true, + }, + }, + instructions: '# UGC Director\n\nPrepare the Canvas before generation.', +}); + +test('Skill registry returns descriptors without leaking full instructions', () => { + const registry = createBeatDesignSkillRegistry({ + definitions: [definition()], + beatDesignVersion: '0.2.3', + availableMcpTools: ['bdesign_canvas_apply'], + }); + + const [descriptor] = registry.list(); + assert.equal(descriptor.manifest.id, 'ugc-director'); + assert.equal(descriptor.resourceUri, `${BEATDESIGN_SKILL_CATALOG_URI}/ugc-director`); + assert.equal(descriptor.compatible, true); + assert.equal('instructions' in descriptor, false); + assert.match(registry.get('ugc-director')?.instructions ?? '', /Prepare the Canvas/); +}); + +test('Skill registry reports version and MCP compatibility failures', () => { + const registry = createBeatDesignSkillRegistry({ + definitions: [definition()], + beatDesignVersion: '0.2.2', + availableMcpTools: [], + }); + + const [descriptor] = registry.list(); + assert.equal(descriptor.compatible, false); + assert.deepEqual(descriptor.incompatibilities, [ + 'Requires BeatDesign 0.2.3 or newer.', + 'Missing MCP tools: bdesign_canvas_apply.', + ]); +}); + +test('Skill registry rejects duplicate ids', () => { + assert.throws( + () => + createBeatDesignSkillRegistry({ + definitions: [definition(), definition()], + beatDesignVersion: '0.2.3', + availableMcpTools: ['bdesign_canvas_apply'], + }), + /Duplicate BeatDesign Skill id/ + ); +}); + +test('filesystem loader validates directory names and reads fixed Skill files', async () => { + const root = await mkdtemp(join(tmpdir(), 'beatdesign-skills-')); + const skillDirectory = resolve(root, 'ugc-director'); + try { + await mkdir(skillDirectory); + await writeFile( + resolve(skillDirectory, 'skill.json'), + `${JSON.stringify(definition().manifest)}\n` + ); + await writeFile( + resolve(skillDirectory, 'SKILL.md'), + definition().instructions + ); + + const registry = await loadBeatDesignSkillRegistry({ + directory: root, + beatDesignVersion: '0.2.3', + availableMcpTools: ['bdesign_canvas_apply'], + }); + assert.equal(registry.list().length, 1); + assert.equal(registry.get('ugc-director')?.manifest.title, 'UGC Director'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('filesystem loader treats a missing official directory as an empty catalog', async () => { + const registry = await loadBeatDesignSkillRegistry({ + directory: resolve(tmpdir(), 'beatdesign-skills-directory-does-not-exist'), + beatDesignVersion: '0.2.3', + availableMcpTools: [], + }); + assert.deepEqual(registry.catalog().skills, []); +}); diff --git a/src/core/skills/skill-registry.ts b/src/core/skills/skill-registry.ts new file mode 100644 index 0000000..377cdbc --- /dev/null +++ b/src/core/skills/skill-registry.ts @@ -0,0 +1,259 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import * as z from 'zod/v4'; + +export const BEATDESIGN_SKILL_SCHEMA_VERSION = 1; +export const BEATDESIGN_SKILL_CATALOG_URI = 'beatdesign://skills'; +export const BEATDESIGN_SKILL_RESOURCE_TEMPLATE = + 'beatdesign://skills/{skillId}'; + +const MAX_MANIFEST_BYTES = 64 * 1024; +const MAX_INSTRUCTIONS_BYTES = 512 * 1024; + +export const beatDesignSkillIdSchema = z + .string() + .trim() + .min(1) + .max(80) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + +const semanticVersionSchema = z + .string() + .trim() + .regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/); + +const skillInputSchema = z + .object({ + id: beatDesignSkillIdSchema, + label: z.string().trim().min(1).max(120), + description: z.string().trim().min(1).max(500), + kind: z.enum(['text', 'image', 'video', 'audio', 'number', 'boolean']), + required: z.boolean().default(true), + }) + .strict(); + +const skillStageSchema = z + .object({ + id: beatDesignSkillIdSchema, + title: z.string().trim().min(1).max(120), + description: z.string().trim().min(1).max(500), + reviewRequired: z.boolean().default(false), + }) + .strict(); + +export const beatDesignSkillManifestSchema = z + .object({ + schemaVersion: z.literal(BEATDESIGN_SKILL_SCHEMA_VERSION), + id: beatDesignSkillIdSchema, + version: semanticVersionSchema, + title: z.string().trim().min(1).max(120), + summary: z.string().trim().min(1).max(500), + visibility: z.enum(['showcase', 'hidden']).default('showcase'), + compatibility: z + .object({ + minimumBeatDesignVersion: semanticVersionSchema, + requiredMcpTools: z + .array(z.string().regex(/^bdesign_[a-z0-9_]+$/)) + .max(100) + .default([]), + }) + .strict(), + requiredCapabilities: z + .array(beatDesignSkillIdSchema) + .max(50) + .default([]), + inputs: z.array(skillInputSchema).max(32).default([]), + stages: z.array(skillStageSchema).max(32).default([]), + showcase: z + .object({ + category: beatDesignSkillIdSchema, + tags: z.array(z.string().trim().min(1).max(40)).max(12).default([]), + featured: z.boolean().default(false), + }) + .strict() + .optional(), + }) + .strict(); + +export type BeatDesignSkillManifest = z.infer< + typeof beatDesignSkillManifestSchema +>; + +export type BeatDesignSkillDefinition = { + manifest: BeatDesignSkillManifest; + instructions: string; +}; + +export type BeatDesignSkill = BeatDesignSkillDefinition & { + resourceUri: string; + compatible: boolean; + incompatibilities: string[]; +}; + +export type BeatDesignSkillDescriptor = Omit; + +export type BeatDesignSkillCatalog = { + schemaVersion: typeof BEATDESIGN_SKILL_SCHEMA_VERSION; + beatDesignVersion: string; + skills: BeatDesignSkillDescriptor[]; +}; + +const parseVersion = (value: string) => + value + .split('-', 1)[0] + .split('.') + .map((part) => Number.parseInt(part, 10)) as [number, number, number]; + +export const isBeatDesignVersionCompatible = ( + currentVersion: string, + minimumVersion: string +) => { + const current = parseVersion(currentVersion); + const minimum = parseVersion(minimumVersion); + for (let index = 0; index < 3; index += 1) { + if (current[index] > minimum[index]) return true; + if (current[index] < minimum[index]) return false; + } + return true; +}; + +export const buildBeatDesignSkillResourceUri = (skillId: string) => + `${BEATDESIGN_SKILL_CATALOG_URI}/${encodeURIComponent(skillId)}`; + +export function createBeatDesignSkillRegistry({ + definitions, + beatDesignVersion, + availableMcpTools, +}: { + definitions: readonly BeatDesignSkillDefinition[]; + beatDesignVersion: string; + availableMcpTools: readonly string[]; +}) { + semanticVersionSchema.parse(beatDesignVersion); + const availableTools = new Set(availableMcpTools); + const skills = definitions.map((definition) => { + const manifest = beatDesignSkillManifestSchema.parse(definition.manifest); + const instructions = z + .string() + .trim() + .min(1) + .max(MAX_INSTRUCTIONS_BYTES) + .parse(definition.instructions); + const incompatibilities: string[] = []; + if ( + !isBeatDesignVersionCompatible( + beatDesignVersion, + manifest.compatibility.minimumBeatDesignVersion + ) + ) { + incompatibilities.push( + `Requires BeatDesign ${manifest.compatibility.minimumBeatDesignVersion} or newer.` + ); + } + const missingTools = manifest.compatibility.requiredMcpTools.filter( + (tool) => !availableTools.has(tool) + ); + if (missingTools.length > 0) { + incompatibilities.push(`Missing MCP tools: ${missingTools.join(', ')}.`); + } + return { + manifest, + instructions, + resourceUri: buildBeatDesignSkillResourceUri(manifest.id), + compatible: incompatibilities.length === 0, + incompatibilities, + } satisfies BeatDesignSkill; + }); + + const byId = new Map(); + for (const skill of skills) { + if (byId.has(skill.manifest.id)) { + throw new Error(`Duplicate BeatDesign Skill id: ${skill.manifest.id}`); + } + byId.set(skill.manifest.id, skill); + } + + const ordered = [...skills].sort((left, right) => + left.manifest.id.localeCompare(right.manifest.id) + ); + + return { + list: (): BeatDesignSkillDescriptor[] => + ordered.map(({ instructions: _instructions, ...descriptor }) => descriptor), + get: (skillId: string): BeatDesignSkill | null => byId.get(skillId) ?? null, + catalog: (): BeatDesignSkillCatalog => ({ + schemaVersion: BEATDESIGN_SKILL_SCHEMA_VERSION, + beatDesignVersion, + skills: ordered.map(({ instructions: _instructions, ...descriptor }) => + descriptor + ), + }), + }; +} + +const readBoundedTextFile = async (path: string, maxBytes: number) => { + const contents = await readFile(path); + if (contents.byteLength > maxBytes) { + throw new Error(`BeatDesign Skill file is too large: ${path}`); + } + return contents.toString('utf8'); +}; + +export async function loadBeatDesignSkillRegistry({ + directory, + beatDesignVersion, + availableMcpTools, +}: { + directory: string; + beatDesignVersion: string; + availableMcpTools: readonly string[]; +}) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return createBeatDesignSkillRegistry({ + definitions: [], + beatDesignVersion, + availableMcpTools, + }); + } + throw error; + } + + const definitions = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .sort((left, right) => left.name.localeCompare(right.name)) + .map(async (entry) => { + const skillDirectory = resolve(directory, entry.name); + const [manifestText, instructions] = await Promise.all([ + readBoundedTextFile( + resolve(skillDirectory, 'skill.json'), + MAX_MANIFEST_BYTES + ), + readBoundedTextFile( + resolve(skillDirectory, 'SKILL.md'), + MAX_INSTRUCTIONS_BYTES + ), + ]); + const manifest = beatDesignSkillManifestSchema.parse( + JSON.parse(manifestText) as unknown + ); + if (manifest.id !== entry.name) { + throw new Error( + `BeatDesign Skill directory ${entry.name} must match manifest id ${manifest.id}.` + ); + } + return { manifest, instructions }; + }) + ); + + return createBeatDesignSkillRegistry({ + definitions, + beatDesignVersion, + availableMcpTools, + }); +} diff --git a/src/mcp/generation-canvas.ts b/src/mcp/generation-canvas.ts index bf62f62..fc44865 100644 --- a/src/mcp/generation-canvas.ts +++ b/src/mcp/generation-canvas.ts @@ -6,6 +6,7 @@ import { } from '@/core/beatcanvas/canvas-types'; import { buildAssetFirstReferencesFromCanvasCards, + GENERATION_REQUEST_VERSION, type AssetFirstGenerationRequest, } from '@/core/commands/generation-contract'; import type { CanvasOperation } from '@/core/commands/canvas-commands'; @@ -180,7 +181,6 @@ export function prepareCanvasGenerationSubmission({ const references = buildAssetFirstReferencesFromCanvasCards({ cards: cardsById, referenceCardIds: sourceCard.referenceCardIds, - mode: reviewedMode, }); if (references.length !== sourceCard.referenceCardIds.length) { throw new Error( @@ -247,7 +247,7 @@ export function prepareCanvasGenerationSubmission({ sourceCard, outputCard, request: { - version: 1 as const, + version: GENERATION_REQUEST_VERSION, mode: reviewedMode, modelId: sourceCard.modelId, prompt: sourceCard.prompt, diff --git a/src/mcp/server.contract.test.ts b/src/mcp/server.contract.test.ts index 399e3a0..0d145b1 100644 --- a/src/mcp/server.contract.test.ts +++ b/src/mcp/server.contract.test.ts @@ -17,6 +17,14 @@ test('MCP registers Canvas, Generation, and Editor tool groups', () => { } }); +test('MCP exposes one Skill catalog through Resources and read-only fallback tools', () => { + assert.match(source, /server\.registerResource\(/); + assert.match(source, /BEATDESIGN_SKILL_CATALOG_URI/); + assert.match(source, /BEATDESIGN_SKILL_RESOURCE_TEMPLATE/); + assert.match(source, /'bdesign_skill_list'/); + assert.match(source, /'bdesign_skill_get'/); +}); + test('MCP writes use a fixed origin and never expose document replacement', () => { assert.match(source, /origin: 'mcp'/); assert.doesNotMatch(source, /editor\.replace_document/); diff --git a/src/mcp/server.runtime.test.ts b/src/mcp/server.runtime.test.ts index b541bcc..f9dc3b1 100644 --- a/src/mcp/server.runtime.test.ts +++ b/src/mcp/server.runtime.test.ts @@ -14,12 +14,12 @@ import { listGenerationModelDescriptors } from '@/core/generation-providers'; import { BEATDESIGN_MCP_TOOL_NAMES } from './tools'; -test('MCP tool catalog is exactly 27 named tools', () => { +test('MCP tool catalog is exactly 29 named tools', () => { const source = readFileSync(new URL('./server.ts', import.meta.url), 'utf8'); const registered = [...source.matchAll(/server\.registerTool\(\s*'([^']+)'/g)].map( (match) => match[1] ); - assert.equal(BEATDESIGN_MCP_TOOL_NAMES.length, 27); + assert.equal(BEATDESIGN_MCP_TOOL_NAMES.length, 29); assert.deepEqual(registered, [...BEATDESIGN_MCP_TOOL_NAMES]); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9d0abde..b724444 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,7 +1,12 @@ import { readFile, stat } from 'node:fs/promises'; import { isAbsolute, resolve as resolvePath } from 'node:path'; -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { + createMcpHandler, + McpServer, + ResourceNotFoundError, + ResourceTemplate, +} from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; @@ -44,6 +49,13 @@ import { loadProjects, loadProjectWithLatestSnapshot, } from '@/core/projects/projects'; +import { + BEATDESIGN_SKILL_CATALOG_URI, + BEATDESIGN_SKILL_RESOURCE_TEMPLATE, + beatDesignSkillIdSchema, + loadBeatDesignSkillRegistry, + type BeatDesignSkill, +} from '@/core/skills/skill-registry'; import { getProjectAssetById, listProjectAssets, @@ -58,9 +70,37 @@ import { prepareCanvasGenerationSubmission, runCanvasGenerationSubmissionOnce, } from './generation-canvas'; +import { BEATDESIGN_MCP_TOOL_NAMES } from './tools'; const VERSION = '0.2.3'; const idSchema = z.string().trim().min(1).max(200); +const builtInSkillDirectory = resolvePath(process.cwd(), 'skills', 'official'); +let builtInSkillRegistryPromise: ReturnType< + typeof loadBeatDesignSkillRegistry +> | null = null; + +const getBuiltInSkillRegistry = () => { + builtInSkillRegistryPromise ??= loadBeatDesignSkillRegistry({ + directory: builtInSkillDirectory, + beatDesignVersion: VERSION, + availableMcpTools: BEATDESIGN_MCP_TOOL_NAMES, + }); + return builtInSkillRegistryPromise; +}; + +const formatSkillResource = (skill: BeatDesignSkill) => + [ + `# ${skill.manifest.title}`, + '', + `Skill ID: ${skill.manifest.id}`, + `Skill version: ${skill.manifest.version}`, + `BeatDesign compatibility: ${skill.compatible ? 'compatible' : 'incompatible'}`, + ...(skill.incompatibilities.length > 0 + ? [`Compatibility issues: ${skill.incompatibilities.join(' ')}`] + : []), + '', + skill.instructions, + ].join('\n'); const toCommandCanvasCards = (cards: unknown[]) => cards.flatMap((card) => { @@ -81,8 +121,6 @@ const generationReferenceSchema = z.object({ 'style', 'subject', 'pose', - 'first_frame', - 'last_frame', 'audio_track', ]), deliveryUrl: z.string().trim().min(1).max(4096).optional(), @@ -280,6 +318,80 @@ export function createBeatDesignMcpServer() { const server = new McpServer({ name: 'beatdesign', version: VERSION }); let targetedProjectId: string | null = null; + server.registerResource( + 'beatdesign-skill-catalog', + BEATDESIGN_SKILL_CATALOG_URI, + { + title: 'BeatDesign Skill catalog', + description: + 'Versioned catalog of official creative Skills bundled with this BeatDesign installation.', + mimeType: 'application/json', + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify( + (await getBuiltInSkillRegistry()).catalog(), + null, + 2 + ), + }, + ], + }) + ); + + server.registerResource( + 'beatdesign-skill', + new ResourceTemplate(BEATDESIGN_SKILL_RESOURCE_TEMPLATE, { + list: async () => ({ + resources: (await getBuiltInSkillRegistry()).list().map((skill) => ({ + uri: skill.resourceUri, + name: skill.manifest.id, + title: skill.manifest.title, + description: skill.manifest.summary, + mimeType: 'text/markdown', + })), + }), + complete: { + skillId: async (value) => + (await getBuiltInSkillRegistry()) + .list() + .map((skill) => skill.manifest.id) + .filter((skillId) => skillId.startsWith(value)), + }, + }), + { + title: 'BeatDesign Skill', + description: + 'Full model-readable instructions for one bundled BeatDesign creative Skill.', + mimeType: 'text/markdown', + }, + async (uri, variables) => { + const rawSkillId = Array.isArray(variables.skillId) + ? variables.skillId[0] + : variables.skillId; + const skillId = beatDesignSkillIdSchema.parse(rawSkillId); + const skill = (await getBuiltInSkillRegistry()).get(skillId); + if (!skill) { + throw new ResourceNotFoundError( + uri.href, + `BeatDesign Skill not found: ${skillId}` + ); + } + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: formatSkillResource(skill), + }, + ], + }; + } + ); + const resolveScopedProject = async (projectId?: string | null) => { const resolvedProjectId = projectId?.trim() || targetedProjectId; if (!resolvedProjectId) { @@ -375,6 +487,32 @@ export function createBeatDesignMcpServer() { return applied; }; + server.registerTool( + 'bdesign_skill_list', + { + description: + 'List official creative Skills bundled with this BeatDesign installation, including compatibility and MCP resource URIs.', + inputSchema: z.object({}), + annotations: { readOnlyHint: true }, + }, + withToolErrors(async () => (await getBuiltInSkillRegistry()).catalog()) + ); + + server.registerTool( + 'bdesign_skill_get', + { + description: + 'Read one bundled BeatDesign Skill with its manifest, compatibility result, and full workflow instructions.', + inputSchema: z.object({ skillId: beatDesignSkillIdSchema }), + annotations: { readOnlyHint: true }, + }, + withToolErrors(async ({ skillId }) => { + const skill = (await getBuiltInSkillRegistry()).get(skillId); + if (!skill) throw new Error(`BeatDesign Skill not found: ${skillId}`); + return skill; + }) + ); + server.registerTool( 'bdesign_project_list', { @@ -699,7 +837,7 @@ export function createBeatDesignMcpServer() { 'bdesign_canvas_continue_from_tail', { description: - 'Extract the tail frame of a video, place it on Canvas, and create a continuation generation node. Then call bdesign_generation_submit with the returned first_frame reference.', + 'Extract the tail frame of a video, place it on Canvas, and create a continuation generation node. The returned prompt explicitly maps @Image1 as the first frame; submit the image as a normal reference.', inputSchema: commandMetadataSchema.extend({ projectId: idSchema.optional(), sourceCardId: idSchema.optional(), diff --git a/src/mcp/stdio-probe.test.ts b/src/mcp/stdio-probe.test.ts index b70e924..b68d8c4 100644 --- a/src/mcp/stdio-probe.test.ts +++ b/src/mcp/stdio-probe.test.ts @@ -58,6 +58,7 @@ test('MCP stdio handshake lists the catalogued tools and can list projects', asy const initialized = await readJsonLine(child); const initResult = initialized.result as { serverInfo?: { name?: string; version?: string }; + capabilities?: { resources?: object }; }; const packageVersion = ( JSON.parse(readFileSync(resolve('package.json'), 'utf8')) as { @@ -75,6 +76,7 @@ test('MCP stdio handshake lists the catalogued tools and can list projects', asy assert.equal(initResult.serverInfo?.name, 'beatdesign'); assert.equal(initResult.serverInfo?.version, packageVersion); assert.equal(pluginVersion, packageVersion); + assert.ok(initResult.capabilities?.resources); send(child, { jsonrpc: '2.0', method: 'notifications/initialized' }); send(child, { jsonrpc: '2.0', id: 2, method: 'tools/list' }); @@ -87,6 +89,70 @@ test('MCP stdio handshake lists the catalogued tools and can list projects', asy send(child, { jsonrpc: '2.0', id: 3, + method: 'resources/list', + }); + const listedResources = await readJsonLine(child); + const resources = ( + listedResources.result as { + resources?: Array<{ uri: string; mimeType?: string }>; + } + ).resources; + assert.ok( + resources?.some( + (resource) => + resource.uri === 'beatdesign://skills' && + resource.mimeType === 'application/json' + ) + ); + + send(child, { + jsonrpc: '2.0', + id: 4, + method: 'resources/templates/list', + }); + const listedTemplates = await readJsonLine(child); + const resourceTemplates = ( + listedTemplates.result as { + resourceTemplates?: Array<{ uriTemplate: string }>; + } + ).resourceTemplates; + assert.ok( + resourceTemplates?.some( + (resource) => resource.uriTemplate === 'beatdesign://skills/{skillId}' + ) + ); + + send(child, { + jsonrpc: '2.0', + id: 5, + method: 'resources/read', + params: { uri: 'beatdesign://skills' }, + }); + const readCatalog = await readJsonLine(child); + const catalogText = ( + readCatalog.result as { contents?: Array<{ text?: string }> } + ).contents?.[0]?.text; + assert.ok(catalogText); + assert.deepEqual( + (JSON.parse(catalogText) as { skills?: unknown[] }).skills, + [] + ); + + send(child, { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'bdesign_skill_list', arguments: {} }, + }); + const listedSkills = await readJsonLine(child); + const skillPayload = listedSkills.result as { + structuredContent?: { result?: { skills?: unknown[] } }; + }; + assert.deepEqual(skillPayload.structuredContent?.result?.skills, []); + + send(child, { + jsonrpc: '2.0', + id: 7, method: 'tools/call', params: { name: 'bdesign_project_list', arguments: { limit: 5 } }, }); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 5bd0c2a..02f6952 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,4 +1,6 @@ export const BEATDESIGN_MCP_TOOL_NAMES = [ + 'bdesign_skill_list', + 'bdesign_skill_get', 'bdesign_project_list', 'bdesign_project_get', 'bdesign_project_create', diff --git a/src/routes/api/effects/precheck.ts b/src/routes/api/effects/precheck.ts index cf1b3fc..d1f1bed 100644 --- a/src/routes/api/effects/precheck.ts +++ b/src/routes/api/effects/precheck.ts @@ -2,14 +2,6 @@ import { createFileRoute } from '@tanstack/react-router'; import { getEffectById } from '@/core/effects/effects'; import { getWorkspaceEffectRegistryEntryByEffectId } from '@/core/effects/effect-registry'; import { isVideoAnalysisEffectId } from '@/core/effects/video-analysis'; -import { - getGenerationConcurrencyErrorMessage, - resolveGenerationConcurrencyGate, -} from '@/core/effects/generation-concurrency'; -import { - countRunningGenerationsForProject, - findActiveProject, -} from '@/core/effects/record-generation'; import { getGenerationPromptConstraints, validateGenerationPrompt } from '@/core/effects/validation'; import { getProject } from '@/core/projects/projects'; import { getGenerationProvider } from '@/core/generation-providers'; @@ -73,21 +65,6 @@ async function POST({ request }: { request: Request }) { includeCookie: false, }); if (rateLimitResponse) return rateLimitResponse; - const [activeProjectId, runningCountForRequestedProject] = await Promise.all([ - findActiveProject(), - countRunningGenerationsForProject(projectId), - ]); - const gate = resolveGenerationConcurrencyGate({ - requestedProjectId: projectId, - activeProjectId, - runningCountForRequestedProject, - }); - if (!gate.ok) { - return Response.json( - { error: getGenerationConcurrencyErrorMessage(gate), code: gate.code }, - { status: 429 } - ); - } const input = payload.input && typeof payload.input === 'object' ? (payload.input as Record) : {};