diff --git a/CHANGELOG.md b/CHANGELOG.md
index d2fd05f..dfd2a5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,27 @@ All notable changes to BeatDesign are documented in this file.
## Unreleased
+### Added
+
+- Project settings from project cards and workspace headers: names, short descriptions, explicit image covers, automatic image/video-poster fallback, and the revision-checked `bdesign_project_update` MCP tool (30 tools total). Community contribution: #28.
+
+### Fixed
+
+- Create a project directly after choosing Canvas or Studio, preserving entry parameters and retrying in the chosen workspace without duplicate clicks (#22).
+- Align Canvas previews and Take selection with the latest usable successful generation, while preserving explicit selections and pinned timeline Assets (#26).
+- Preserve timeline clip gains in MCP audio exports and limit overlapping peaks without makeup gain (#24).
+- Commit project settings and idempotency receipts atomically; retain edits and show errors when header renames conflict.
+- Persist undo/redo back to an intentionally empty Canvas. Extract Windows test portability, Studio empty-state translations, and narrow-screen hero layout improvements from #18 without its branding, Provider, or hosted deployment profile.
+
+### Security
+
+- Isolate BeatAPI adapter construction, readiness checks and input validation with TanStack server-only functions so browser model discovery never imports local configuration or SQLite.
+- Update TanStack Start/Router, the MCP SDK, Vite, Seroval and source-map-js to patched versions; production dependency audit reports no known vulnerabilities.
+
+### Documentation
+
+- Highlight current source updates in English, Chinese and Japanese READMEs, while distinguishing them from the v0.2.4 release and package. Update the MCP catalog and current product status.
+
## [0.2.4] - 2026-09-23
### Added
diff --git a/README.ja.md b/README.ja.md
index 88d704c..5f87b16 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -16,7 +16,7 @@
- ウェブサイト ·
+ ウェブサイト ·
クイックスタート ·
MCP ·
製品ステータス
@@ -30,6 +30,20 @@
BeatDesignは、AI画像・動画制作のための独立したオープンソース、ローカルファーストのワークスペースです。Higgsfieldのような一貫した制作フローを、自分のマシンで実行、確認、拡張したい人に向けて作られています。
+## 最近のソース更新 · 2026年10月
+
+以下は`main`の更新です。最新の正式Releaseとnpmパッケージは引き続き **v0.2.4** です。利用するにはソースを更新してください。既存のパッケージへ自動配信されるものではありません。
+
+- **Project設定とカバー:** 名前と説明の編集、画像カバーのアップロード・選択、自動カバーへのリセットに対応。手動カバーは後の読み込みや生成で置き換わりません。Projectカードまたはワークスペース上部の設定アイコンから開きます。
+- **生成バージョンの一致:** CanvasのプレビューとTake選択が最新の成功結果を示します。手動選択とTimelineの固定Assetは保持されます。
+- **Projectへ直接移動:** CanvasまたはStudioを選ぶとProjectを作成して直接開きます。再試行と連打防止にも対応します。
+- **音量を保持した書き出し:** MCPのTimeline書き出しで順番に並ぶClipの音量を保持し、重なる音声にはピーク保護を適用します。
+- **30個のMCPツール:** AgentもUIと同じバージョン確認付きコマンドでProject設定を更新できます。空のCanvasへの取り消し・やり直しの保存、Windowsテスト互換性、狭い画面のレイアウトも改善しました。
+
+末尾フレームからの続き生成、CanvasからEditorへの移行、画像オーバーレイ、スタイル付きSRT字幕、ブラウザーとMCPによるMP4書き出しも利用できます。ブラウザーの書き出しにはシステムFFmpegは不要です。MCPのフレーム抽出には`ffmpeg`、Timeline書き出しにはさらに`ffprobe`が必要です。
+
+[変更履歴](./CHANGELOG.md) · [Project設定](./docs/PROJECT_SETTINGS.md) · [現状とロードマップ](./docs/PRODUCT_PLAN_AND_STATUS.md)
+
## 2つの制作モード、1つのProject
### 無限Canvas
@@ -88,7 +102,7 @@ pnpm dev
## AgentからBeatDesignを使う
-BeatDesignはSkill、Project、Asset、Canvas、生成、Editor操作をカバーする29個のローカルMCPツールを提供し、内蔵Skillの検出と正式なTimelineからのMP4書き出しにも対応します。Agentによる変更は同じProjectサービスを通り、ブラウザーのワークスペースに表示されます。
+BeatDesignはSkill、Project、Asset、Canvas、生成、Editor操作をカバーする30個のローカルMCPツールを提供し、内蔵Skillの検出と正式なTimelineからのMP4書き出しにも対応します。Agentによる変更は同じProjectサービスを通り、ブラウザーのワークスペースに表示されます。
MCP Hostへ接続した後は、次のように依頼できます。
diff --git a/README.md b/README.md
index 384317b..3a73004 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
- Website ·
+ Website ·
Quick start ·
MCP ·
Product status
@@ -30,6 +30,20 @@
BeatDesign is an independent, open-source, local-first workspace for AI image and video creation. It is built for people who want the connected creative flow of tools such as Higgsfield, with a workspace they can run, inspect, and extend on their own machine.
+## Latest source updates — October 2026
+
+Available on `main`; the latest tagged release and npm package remain **v0.2.4**. Pull the source to use these updates; existing packaged installations do not receive them automatically.
+
+- **Project settings:** edit names and descriptions, upload or select a cover, and reset to an automatic image or local video poster. A manual cover stays selected after later imports and generations. Open the settings icon on a project card or workspace header.
+- **Consistent generation versions:** Canvas previews and Take selection agree on the latest successful output; manual selections and concrete timeline Assets are preserved.
+- **Faster project entry:** choosing Canvas or Studio creates one project and opens it directly, with retry protection.
+- **Reliable audio export:** MCP timeline rendering preserves clip gain across sequential shots, with peak protection for overlapping audio.
+- **30 MCP tools:** Agents can update project settings through the same revision-checked command path as the UI. Canvas undo/redo can save an intentionally empty canvas; Windows test portability and narrow-screen layout are improved.
+
+Already available: tail-frame continuation, Canvas-to-Editor handoff, image overlays, styled SRT captions, browser-native MP4 export, and MCP timeline rendering. Browser export needs no system FFmpeg; MCP frame extraction needs `ffmpeg`, and MCP timeline rendering also needs `ffprobe`.
+
+[Change details](./CHANGELOG.md) · [Project settings](./docs/PROJECT_SETTINGS.md) · [Current scope and roadmap](./docs/PRODUCT_PLAN_AND_STATUS.md)
+
## Two creative modes, one Project
### Infinite Canvas
@@ -88,7 +102,7 @@ Add your own [BeatAPI API key](https://beatapi.io/dashboard/apikeys) only when y
## Use BeatDesign with an Agent
-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.
+BeatDesign exposes 30 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 e694716..3488500 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -16,7 +16,7 @@
- 网站 ·
+ 网站 ·
快速开始 ·
MCP ·
产品状态
@@ -30,6 +30,20 @@
BeatDesign 是一个独立、开源、本地优先的 AI 图片与视频创作工作台。它适合喜欢 Higgsfield 这类连贯创作流程,同时希望在自己的电脑上运行、检查和扩展工作空间的人。
+## 最近更新 · 2026 年 10 月
+
+以下更新位于 `main` 源码;最新正式 Release 和 npm 包仍为 **v0.2.4**。拉取源码后即可使用,现有安装包不会自动获得这些更新。
+
+- **项目设置与稳定封面:** 修改项目名称和简介,上传或选择封面,或恢复自动封面。手动封面不会被后续导入、生成的素材替换。入口在项目卡片和工作区顶栏的设置图标。
+- **生成版本保持一致:** 画布预览和 Take 选择默认指向最新成功结果,同时保留手动选择和时间线锁定的具体素材。
+- **创建项目一步直达:** 选择画布或创作台后直接进入新项目,支持失败重试和重复点击保护。
+- **修复导出音量:** MCP 时间线导出保留顺序片段的音量,叠加音频增加峰值保护。
+- **30 个 MCP 工具:** Agent 可通过与 UI 相同、带版本校验的命令修改项目设置。同时改进画布撤销/重做到空白状态的保存、Windows 测试兼容性和窄屏布局。
+
+此前已支持尾帧续写、画布到剪辑的连续流程、图片叠层、带样式的 SRT 字幕、浏览器 MP4 导出和 MCP 时间线导出。浏览器导出不依赖系统 FFmpeg;MCP 抽帧需要 `ffmpeg`,MCP 时间线导出还需要 `ffprobe`。
+
+[更新详情](./CHANGELOG.md) · [项目设置说明](./docs/PROJECT_SETTINGS.md) · [当前能力与后续规划](./docs/PRODUCT_PLAN_AND_STATUS.md)
+
## 两种核心创作模式,一个 Project
### 无限 Canvas
@@ -88,7 +102,7 @@ pnpm dev
## 让 Agent 操作 BeatDesign
-BeatDesign 提供 29 个本地 MCP 工具,覆盖 Skill、Project、Asset、Canvas、生成和 Editor 操作,包括发现内置 Skill 目录以及从权威时间线导出 MP4。Agent 修改会经过同一套项目服务,并显示在浏览器工作空间中。
+BeatDesign 提供 30 个本地 MCP 工具,覆盖 Skill、Project、Asset、Canvas、生成和 Editor 操作,包括发现内置 Skill 目录以及从权威时间线导出 MP4。Agent 修改会经过同一套项目服务,并显示在浏览器工作空间中。
连接 MCP Host 后,可以直接提出这样的要求。
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 04d6fb0..a6ef648 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -32,7 +32,7 @@ The browser calls local `/api` routes. Server routes validate input and resolve
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.
+Provider and optional R2/S3 credentials are encrypted in the local `config` table. Browser components never receive raw credentials. Shared model metadata may read Provider bindings, but Provider adapter construction, configuration checks and input validation are wrapped in TanStack `createServerOnlyFn`; the browser dependency graph must not import local configuration or SQLite.
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 be10fbc..7658fbf 100644
--- a/docs/MCP.md
+++ b/docs/MCP.md
@@ -104,12 +104,14 @@ installation shapes.
## Tool groups
-There are **29** tools:
+There are **30** 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.
+- Project (6): list, get, update settings, create, target the current MCP
+ session, and open a workspace review surface. `bdesign_project_update` requires
+ the latest `settingsRevision` from `bdesign_project_get`; settings changes and
+ idempotency receipts commit atomically.
- Asset (4): list, get by project membership, import a local file, and extract a
video frame.
- Canvas (5): get, browser view/focus, search, incremental apply, and
diff --git a/docs/PRODUCT_PLAN_AND_STATUS.md b/docs/PRODUCT_PLAN_AND_STATUS.md
index 367f27c..63e2ff2 100644
--- a/docs/PRODUCT_PLAN_AND_STATUS.md
+++ b/docs/PRODUCT_PLAN_AND_STATUS.md
@@ -2,10 +2,17 @@
- 文档用途:给新加入的开发者、Codex、Claude Code 和其他 AI Agent 提供统一产品上下文
- 当前基线:BeatDesign v0.2 Phase 1 本地实现
-- 最后核对:2026-09-23
+- 最后核对:2026-10-22
- 事实边界:只有代码、测试和本文“已完成”栏目共同证明的能力才算完成;规划项不等于已发布
- 发布状态:GitHub 最新正式 Release 为 [v0.2.4](https://github.com/BeatAPI/BeatDesign/releases/tag/v0.2.4);npm 最新版本为 `@beatapi/beatdesign-workbuddy@0.2.4`(2026-09-23 核验)。WorkBuddy 开放平台中,原有连接器 `oc_321d021032343aea` 的 v0.2.3 已发布,v0.2.4 已提交更新并显示“审核中”;新版通过审核和公开市场可检索性均未核验。
+### 2026-10 主分支更新(尚未发布新版本)
+
+- 整合社区 PR #22、#24、#26、#28:直接创建项目、MCP 导出音量修复、最新成功生成版本统一选择、项目设置与手动/自动封面。
+- `bdesign_project_update` 将 MCP 目录扩展为 30 个工具;项目设置更新与幂等回执在同一 SQLite 事务提交,回执失败时回滚设置,保留旧项目封面。
+- 从 PR #18 提取 Windows 测试兼容、Studio 首屏翻译、空画布撤销/重做保存及窄屏布局修复;第三方品牌、Provider 和托管部署配置未纳入上游。
+- README 分开标注源码更新与 v0.2.4 安装包。官方 Skill 目录仍为空,Issue #29 的持久分组、素材文件夹和复用模板仍待实现。
+
### v0.2.4 已发布改进
- 中文或日文浏览器首次打开无语言前缀的本地工作台链接时,自动进入对应语言;显式语言链接和已保存的语言偏好优先。中文首屏与项目创建入口使用“创作台、画布、素材库”等常用名称。
@@ -94,9 +101,18 @@ Codex / Claude Code / Other Agent
## 5. 已完成并有代码支撑的能力
+### 项目设置(2026-10-18)
+
+- Projects 卡片和工作空间顶部提供项目设置,可修改名称、140 字简介、手动封面或恢复自动封面;展示只读 ID、创建日期和本地存储信息,项目链接保持稳定。
+- 手动封面不会被后续上传或生成覆盖;自动封面按首张导入/生成图片、首个本地视频缩略图、占位图回退。旧项目已有封面在升级时保留为手动选择。
+- UI 和 `bdesign_project_update` MCP 使用相同 `project.update` 命令、项目素材归属检查和独立设置版本;旧版本写入返回冲突。详见 `docs/PROJECT_SETTINGS.md`。
+
+
### Project / Local-first
- 项目创建、打开、重命名和本地 SQLite 持久化。
+- 项目列表中选择 Studio 或 Canvas 后直接创建并进入对应视图,创建期间防止重复点击,失败重试保留原选择。
+- 直接访问 `/studio` 或 `/canvas` 时只提供对应视图的创建按钮,访问本身不创建项目;创建后保留语言、模型和提示词入口参数。
- 界面支持英语、中文和日语,并可在主页、项目列表及工作区顶栏切换;英文使用无前缀路径,中文和日语分别使用 `/zh` 与 `/ja`。
- Canvas Snapshot 自动保存、页面隐藏时刷新和版本冲突保护。
- 本地上传图片、视频、音频后立即复制到项目目录。
@@ -156,7 +172,7 @@ Codex / Claude Code / Other Agent
- `AssetFirstGenerationRequest` v2 不保存通用首帧或尾帧角色;大多数模型仍由明确的 `@ImageN` Prompt 指令表达首尾帧语义。MiniMax H3 Max / Max Turbo 按其公开契约使用专用双槽位,并将附件顺序映射为首帧、尾帧。Adapter 负责翻译 Provider 字段和校验模型硬限制。
- UI 命令入口不再接受客户端 `origin`;服务端固定写入 `ui`,MCP 入口在内核边界固定写入 `mcp`。
- Provider Contract 已将逻辑模型目录与 BeatAPI effectId、上传路径和上游模型名拆开;当前同步 GPT Image 2.5 Flare/Sunburst、Wan 3.0/Prime、HappyHorse 1.0/1.1、MiniMax H3 Max/Max Turbo 的请求契约和 SVG 品牌标志。BeatAPI 是默认实现,Fork 可在源码扩展点注册其他 Provider。
-- 本地 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。
+- 本地 stdio MCP Server 提供 30 个工具(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 本地已实现
@@ -171,7 +187,7 @@ Codex / Claude Code / Other Agent
- Canvas -> Timeline Node -> Editor 连续工作流。
- Editor 自动保存接入命令入口,并补齐冲突三方合并、重复操作保护和稳定播放头时间。
- 图片 Clip、时间线拖拽调整持续时间与图片/视频统一视觉轨。
-- 本地 MCP Server 提供 29 个 Skill、Project、Asset、Canvas、Generation、Editor 工具;支持内置 Skill 目录发现、会话项目绑定、Canvas/Editor 可视化交接、从绝对路径导入本地素材、抽取尾帧续写、导入和精调 SRT 字幕、放置/替换/调整图片 Overlay,以及权威时间线 MP4 导出。
+- 本地 MCP Server 提供 30 个 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。
@@ -206,7 +222,7 @@ Codex / Claude Code / Other Agent
当前可以称为“已支持本地 MCP 基础版”,但不能称为完整 Agent 编辑环境:像素级 Snapshot、独立后台媒体 Worker、实时 UI 事件和永久审计仍未实现。本地文件导入桥和 MCP 权威时间线 MP4 导出已完成;UI 当前使用 2 秒 revision 轮询和聚焦检查,而不是实时事件推送。
-Canvas 的拖拽、缩放、视口和完整布局仍使用 revision-checked Snapshot 自动保存;Canvas/Timeline 业务 operation 已有 Command 合同,Timeline Node 回写也已接入 `/commands`。后续做 MCP parity 时,应继续把可语义化的 Canvas UI 动作迁移为 `canvas.apply`,不能让外部 Agent 调用完整 Snapshot 覆盖。
+Canvas UI 已通过 `canvas.apply` 持久化卡片、布局、视口和工作流增量变化;Snapshot 端点仅为兼容例外。Editor 可表达的变化使用 `editor.apply`,其他 UI 编辑保留带 revision 校验的替换路径;外部 Agent 始终只能使用增量命令。
### 桌面端
diff --git a/docs/PROJECT_SETTINGS.md b/docs/PROJECT_SETTINGS.md
new file mode 100644
index 0000000..cb1abf4
--- /dev/null
+++ b/docs/PROJECT_SETTINGS.md
@@ -0,0 +1,62 @@
+# Project settings
+
+Project settings are available from the gear on each Projects card and the
+workspace header in Studio, Canvas, Editor and Assets. They edit the same local
+project: name (1–240 characters), description (up to 140 characters), and cover.
+Project ID, creation date and local storage information are read-only. Project
+URLs keep their stable IDs. This does not introduce accounts, sharing or publishing.
+Image tiles in the project Asset library also offer **Set as cover**.
+
+## Covers
+
+- A manual cover must be an image Asset belonging to this project. Later imports,
+ generations and re-linking assets never replace it.
+- Upload cover uses the existing local project Asset import endpoint. Uploads stay
+ in the Asset library even when the settings dialog is cancelled.
+- Automatic cover selects the earliest uploaded/generated image by Asset creation
+ timestamp, then stable Asset ID. Reference-only images are excluded. If there is
+ no eligible image, it uses the first local video poster. No usable media or a
+ failed poster shows a placeholder; the original video is never loaded by a card.
+- Missing/deleted manual cover Assets fall back automatically. Removing the cover
+ choice never deletes an Asset. Project cards and the settings preview use 16:9.
+- Existing covers are preserved as manual during additive SQLite startup upgrades.
+ The schema default also preserves legacy covers when applying `db:push` first;
+ new projects explicitly start in automatic mode. Upgrade tests use isolated data.
+
+Video fallback uses the optional server `ffmpeg` decoder from `PATH` or
+`BEATDESIGN_FFMPEG`. A membership-checked endpoint caches bounded 512px JPEG
+posters under the data directory, with at most two concurrent decodes. Missing
+decoders or failed decoding return a placeholder; they do not prevent ordinary
+browser playback or require downloading the original video in Projects.
+
+## Shared UI/MCP command
+
+`GET /api/app/projects/:id` returns `{ settings }`, including effective cover,
+automatic fallback, and `settingsRevision`.
+
+`PATCH /api/app/projects/:id` accepts:
+
+```json
+{
+ "expectedRevision": 1,
+ "commandId": "unique-command-id",
+ "patch": {
+ "name": "My film",
+ "description": "A short film",
+ "cover": { "mode": "manual", "assetId": "project-image-id" }
+ }
+}
+```
+
+Use `cover: { "mode": "auto" }` to reset the choice. The route, generic command
+endpoint and `bdesign_project_update` MCP tool use the same `project.update`
+Command Kernel path. `bdesign_project_get` returns `settings`. Unknown fields,
+external URLs, non-image covers and foreign-project Assets are rejected. Settings
+have a separate revision from Canvas/Editor. A stale update returns
+`REVISION_CONFLICT` (HTTP 409); the dialog retains edits and asks the user to
+reopen before saving. It never automatically overwrites a newer change.
+
+The command commits the revision-checked settings update and its idempotency
+receipt in one SQLite transaction. Receipt failures roll back the settings too. Reuse both command ID and
+idempotency key for retries. List/header queries refresh every five seconds while
+visible; opening settings always loads the current record.
diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md
index a391516..befa502 100644
--- a/docs/PROVIDERS.md
+++ b/docs/PROVIDERS.md
@@ -33,7 +33,7 @@ Video Analysis is exposed as a stable BeatAPI workflow with Standard and Deep de
An API with a different request or polling contract needs its own adapter. Forks can register one in `src/config/generation-providers.ts`, bind only the logical models they support, and change `ACTIVE_GENERATION_PROVIDER_ID` in the same file. BeatAPI remains the upstream default; an unknown configured id fails explicitly so it cannot accidentally submit a task to another provider. This repository does not ship placeholder KIE, Vidu, Evolink, Gemini, Fal, Replicate, or payment-provider integrations.
-A custom provider definition owns adapter construction, readiness checks, parameter validation, model bindings, upstream model names, and upload paths. Provider credentials must stay server-side. Switching providers does not change Canvas nodes, Editor clips, Asset IDs, or MCP requests; each submitted task also records its provider/model identity so polling does not silently follow a later default-provider change.
+A custom provider definition owns adapter construction, readiness checks, parameter validation, model bindings, upstream model names, and upload paths. Keep the binding metadata browser-safe and wrap server implementations in TanStack `createServerOnlyFn`, as the BeatAPI provider does, so model discovery cannot import the database or credentials into the browser. Provider credentials must stay server-side. Switching providers does not change Canvas nodes, Editor clips, Asset IDs, or MCP requests; each submitted task also records its provider/model identity so polling does not silently follow a later default-provider change.
## Storage
diff --git a/integrations/codex/beatdesign/README.md b/integrations/codex/beatdesign/README.md
index 0dbd0d0..cdd8f41 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 29 tools)
+Agent: MCP stdio → pnpm mcp (Agent calls 30 tools)
Both processes share the same local SQLite + project files.
```
diff --git a/messages/en.json b/messages/en.json
index 3ca8c74..835da92 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -330,8 +330,8 @@
"status": {
"queued": "Queued",
"generating": "Generating",
- "ready": "Success",
- "success": "Success",
+ "ready": "Success",
+ "success": "Success",
"failed": "Failed"
},
"fromCanvas": "From canvas",
@@ -413,6 +413,18 @@
},
"textNote": {
"template": "Text note\n\nCapture composition ideas, shot notes, or production reminders here."
+ },
+ "startHere": {
+ "ideasLabel": "Starting ideas",
+ "title": "Create Here",
+ "description": "Imagine the scene. Shape the mood. Bring it to life.",
+ "selectIdea": "Select {title}",
+ "ideas": {
+ "cinematicLandscape": "Cinematic landscape",
+ "editorialCharacter": "Editorial character",
+ "materialStudy": "Material study",
+ "surrealFashionFilm": "Surreal fashion film"
+ }
}
},
"editor": {
@@ -745,7 +757,9 @@
"retry": "Try again",
"backToHistory": "Back to history",
"startTitle": "Start a new project",
- "startDescription": "Choose Studio or Canvas to create this project.",
+ "startDescription": "Create your project here. You can switch between Studio and Canvas at any time.",
+ "createStudio": "Create in Studio",
+ "createCanvas": "Create in Canvas",
"startStudio": "Studio",
"startCanvas": "Canvas",
"defaultName": "Untitled project"
@@ -753,5 +767,37 @@
},
"Common": {
"loading": "Loading"
+ },
+ "ProjectSettings": {
+ "title": "Project settings",
+ "subtitle": "Manage the details and cover shown in your project list.",
+ "details": "Details",
+ "name": "Project name",
+ "description": "Description",
+ "descriptionHint": "A short summary shown on project cards.",
+ "descriptionPlaceholder": "Describe your project",
+ "cover": "Cover image",
+ "coverHint": "Choose a project image or upload a cover. 16:9 works best. Uploads stay in the asset library.",
+ "automatic": "Automatic · first image, then a video thumbnail",
+ "manual": "Custom cover · stays fixed when new assets arrive",
+ "upload": "Upload cover",
+ "reset": "Use automatic cover",
+ "choose": "Choose from project images",
+ "info": "Project info",
+ "id": "Project ID",
+ "created": "Created date",
+ "storage": "Storage",
+ "local": "Local workspace",
+ "cancel": "Cancel",
+ "save": "Save changes",
+ "saving": "Saving…",
+ "loading": "Loading settings…",
+ "loadError": "Unable to load settings.",
+ "saveError": "Unable to save settings.",
+ "imageOnly": "Choose a PNG, JPEG, WebP or GIF image.",
+ "closeReload": "Close and reopen to reload",
+ "setAsCover": "Set as cover",
+ "coverSaved": "Project cover updated",
+ "conflict": "Project settings changed elsewhere. Close and reopen settings before saving."
}
}
diff --git a/messages/ja.json b/messages/ja.json
index 6a05122..f805638 100644
--- a/messages/ja.json
+++ b/messages/ja.json
@@ -330,8 +330,8 @@
"status": {
"queued": "キュー待ち",
"generating": "生成中",
- "ready": "成功",
- "success": "成功",
+ "ready": "成功",
+ "success": "成功",
"failed": "失敗"
},
"fromCanvas": "Canvasから",
@@ -413,6 +413,18 @@
},
"textNote": {
"template": "テキストメモ\n\n構図のアイデア、ショットメモ、制作上の注意点をここに記録します。"
+ },
+ "startHere": {
+ "ideasLabel": "制作アイデア",
+ "title": "ここから制作",
+ "description": "シーンを思い描き、ムードを整え、アイデアを形にしましょう。",
+ "selectIdea": "{title}を選択",
+ "ideas": {
+ "cinematicLandscape": "映画のような風景",
+ "editorialCharacter": "エディトリアルポートレート",
+ "materialStudy": "素材のスタディ",
+ "surrealFashionFilm": "シュールなファッション映像"
+ }
}
},
"editor": {
@@ -745,7 +757,9 @@
"retry": "もう一度試す",
"backToHistory": "履歴に戻る",
"startTitle": "新しいプロジェクトを始める",
- "startDescription": "StudioまたはCanvasを選択してプロジェクトを作成します。",
+ "startDescription": "ここでプロジェクトを作成します。StudioとCanvasはいつでも切り替えられます。",
+ "createStudio": "Studioで作成",
+ "createCanvas": "Canvasで作成",
"startStudio": "Studio",
"startCanvas": "Canvas",
"defaultName": "無題のプロジェクト"
@@ -753,5 +767,37 @@
},
"Common": {
"loading": "読み込み中"
+ },
+ "ProjectSettings": {
+ "title": "プロジェクト設定",
+ "subtitle": "一覧に表示する情報とカバーを管理します。",
+ "details": "基本情報",
+ "name": "プロジェクト名",
+ "description": "説明",
+ "descriptionHint": "プロジェクトカードに表示する短い説明。",
+ "descriptionPlaceholder": "プロジェクトの説明",
+ "cover": "カバー画像",
+ "coverHint": "画像を選択するかアップロードします。16:9を推奨。画像は素材に保存されます。",
+ "automatic": "自動 · 最初の画像、次に動画サムネイル",
+ "manual": "カスタム · 新しい素材で上書きされません",
+ "upload": "カバーをアップロード",
+ "reset": "自動カバーに戻す",
+ "choose": "プロジェクトの画像から選択",
+ "info": "プロジェクト情報",
+ "id": "プロジェクト ID",
+ "created": "作成日",
+ "storage": "保存先",
+ "local": "ローカルワークスペース",
+ "cancel": "キャンセル",
+ "save": "変更を保存",
+ "saving": "保存中…",
+ "loading": "設定を読み込み中…",
+ "loadError": "設定を読み込めません。",
+ "saveError": "設定を保存できません。",
+ "imageOnly": "PNG、JPEG、WebP、GIFを選択してください。",
+ "closeReload": "閉じて再度開くと更新されます",
+ "setAsCover": "カバーに設定",
+ "coverSaved": "カバーを更新しました",
+ "conflict": "設定が別の場所で更新されました。閉じて再度開いてから保存してください。"
}
}
diff --git a/messages/zh.json b/messages/zh.json
index 1ae6210..6f7b2e5 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -413,6 +413,18 @@
},
"textNote": {
"template": "文本备注\n\n在这里记录构图想法、镜头说明或制作备注。"
+ },
+ "startHere": {
+ "ideasLabel": "创作灵感",
+ "title": "从这里开始创作",
+ "description": "想象画面,塑造氛围,让灵感成为作品。",
+ "selectIdea": "选择{title}",
+ "ideas": {
+ "cinematicLandscape": "电影感风景",
+ "editorialCharacter": "杂志人像",
+ "materialStudy": "材质探索",
+ "surrealFashionFilm": "超现实时尚短片"
+ }
}
},
"editor": {
@@ -745,7 +757,9 @@
"retry": "重试",
"backToHistory": "返回历史",
"startTitle": "开始新项目",
- "startDescription": "选择创作台或画布来创建这个项目。",
+ "startDescription": "创建项目后即可开始,创作台和画布可随时切换。",
+ "createStudio": "创建并进入创作台",
+ "createCanvas": "创建并进入画布",
"startStudio": "创作台",
"startCanvas": "画布",
"defaultName": "未命名项目"
@@ -753,5 +767,37 @@
},
"Common": {
"loading": "加载中"
+ },
+ "ProjectSettings": {
+ "title": "项目设置",
+ "subtitle": "管理项目列表中的基本信息和封面。",
+ "details": "基本信息",
+ "name": "项目名称",
+ "description": "简介",
+ "descriptionHint": "显示在项目卡片上的简短说明。",
+ "descriptionPlaceholder": "描述这个项目",
+ "cover": "封面图片",
+ "coverHint": "选择项目图片或上传封面,推荐 16:9。上传的图片会保留在素材库。",
+ "automatic": "自动选择 · 优先首张图片,其次视频缩略图",
+ "manual": "自定义封面 · 后续新增素材不会覆盖",
+ "upload": "上传封面",
+ "reset": "恢复自动封面",
+ "choose": "从项目图片中选择",
+ "info": "项目信息",
+ "id": "项目 ID",
+ "created": "创建日期",
+ "storage": "存储位置",
+ "local": "本地工作空间",
+ "cancel": "取消",
+ "save": "保存更改",
+ "saving": "保存中…",
+ "loading": "正在加载设置…",
+ "loadError": "无法加载设置。",
+ "saveError": "无法保存设置。",
+ "imageOnly": "请选择 PNG、JPEG、WebP 或 GIF 图片。",
+ "closeReload": "关闭后重新打开以刷新",
+ "setAsCover": "设为项目封面",
+ "coverSaved": "项目封面已更新",
+ "conflict": "项目设置已在其他位置更新。请关闭后重新打开,再保存更改。"
}
}
diff --git a/package.json b/package.json
index 14d108e..8fc4d5c 100644
--- a/package.json
+++ b/package.json
@@ -52,10 +52,10 @@
"@fontsource-variable/space-grotesk": "^5.3.0",
"@libsql/client": "^0.14.0",
"@mdx-js/react": "^3.1.1",
- "@modelcontextprotocol/server": "^2.0.0",
+ "@modelcontextprotocol/server": "^2.3.0",
"@tanstack/react-query": "^5.101.0",
- "@tanstack/react-router": "1.170.11",
- "@tanstack/react-start": "1.168.19",
+ "@tanstack/react-router": "1.170.41",
+ "@tanstack/react-start": "1.168.60",
"@types/mdx": "^2.0.13",
"@xyflow/react": "12.11.2",
"class-variance-authority": "^0.7.1",
@@ -94,7 +94,7 @@
"tailwindcss": "^4.1.0",
"tsx": "^4.21.0",
"typescript": "^5.9.0",
- "vite": "^8.0.16"
+ "vite": "^8.1.6"
},
"pnpm": {
"overrides": {
@@ -108,7 +108,9 @@
"postcss": "8.5.25",
"qs": "6.15.3",
"uuid": "13.0.1",
- "ws": "8.21.2"
+ "ws": "8.21.2",
+ "seroval": "1.7.0",
+ "source-map-js": "1.2.2"
}
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b052982..63a014b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -16,6 +16,8 @@ overrides:
qs: 6.15.3
uuid: 13.0.1
ws: 8.21.2
+ seroval: 1.7.0
+ source-map-js: 1.2.2
importers:
@@ -46,17 +48,17 @@ importers:
specifier: ^3.1.1
version: 3.1.1(@types/react@19.2.14)(react@19.2.5)
'@modelcontextprotocol/server':
- specifier: ^2.0.0
- version: 2.0.0
+ specifier: ^2.3.0
+ version: 2.3.1
'@tanstack/react-query':
specifier: ^5.101.0
version: 5.101.0(react@19.2.5)
'@tanstack/react-router':
- specifier: 1.170.11
- version: 1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ specifier: 1.170.41
+ version: 1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tanstack/react-start':
- specifier: 1.168.19
- version: 1.168.19(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
+ specifier: 1.168.60
+ version: 1.168.60(crossws@0.4.5(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
'@types/mdx':
specifier: ^2.0.13
version: 2.0.13
@@ -126,7 +128,7 @@ importers:
version: 1.63.0
'@tailwindcss/vite':
specifier: ^4.3.0
- version: 4.3.0(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
+ version: 4.3.0(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
'@tanstack/react-query-devtools':
specifier: ^5.101.0
version: 5.101.0(@tanstack/react-query@5.101.0(react@19.2.5))(react@19.2.5)
@@ -141,7 +143,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^6.0.2
- version: 6.0.2(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
+ version: 6.0.2(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
drizzle-kit:
specifier: ^0.31.4
version: 0.31.10
@@ -150,7 +152,7 @@ importers:
version: 0.28.1
nitro:
specifier: 3.0.260603-beta
- version: 3.0.260603-beta(@libsql/client@0.14.0)(chokidar@5.0.0)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.14.0)(kysely@0.28.17))(jiti@2.7.0)(rollup@4.62.4)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
+ version: 3.0.260603-beta(@libsql/client@0.14.0)(chokidar@5.0.0)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.14.0)(kysely@0.28.17))(jiti@2.7.0)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
rollup:
specifier: 4.62.4
version: 4.62.4
@@ -167,8 +169,8 @@ importers:
specifier: ^5.9.0
version: 5.9.3
vite:
- specifier: ^8.0.16
- version: 8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
+ specifier: ^8.1.6
+ version: 8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
packages:
@@ -279,14 +281,14 @@ packages:
resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==}
engines: {node: '>=18.0.0'}
- '@babel/code-frame@7.27.1':
- resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
- engines: {node: '>=6.9.0'}
-
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/compat-data@7.29.0':
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
engines: {node: '>=6.9.0'}
@@ -425,6 +427,10 @@ packages:
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
'@babel/traverse@7.29.0':
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
engines: {node: '>=6.9.0'}
@@ -778,8 +784,8 @@ packages:
peerDependencies:
rollup: '>=2'
- '@modelcontextprotocol/core@2.0.0':
- resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==}
+ '@modelcontextprotocol/core@2.3.1':
+ resolution: {integrity: sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==}
engines: {node: '>=20'}
'@modelcontextprotocol/sdk@1.29.0':
@@ -792,8 +798,8 @@ packages:
'@cfworker/json-schema':
optional: true
- '@modelcontextprotocol/server@2.0.0':
- resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==}
+ '@modelcontextprotocol/server@2.3.1':
+ resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==}
engines: {node: '>=20'}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
@@ -858,41 +864,80 @@ packages:
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
+ '@oxc-project/types@0.155.0':
+ resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
+
'@playwright/test@1.63.0':
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
engines: {node: '>=20'}
hasBin: true
+ '@rolldown/binding-android-arm-eabi@1.2.15':
+ resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [android]
+
'@rolldown/binding-android-arm64@1.0.3':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
+ '@rolldown/binding-android-arm64@1.2.15':
+ resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
'@rolldown/binding-darwin-arm64@1.0.3':
resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
+ '@rolldown/binding-darwin-arm64@1.2.15':
+ resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
'@rolldown/binding-darwin-x64@1.0.3':
resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
+ '@rolldown/binding-darwin-x64@1.2.15':
+ resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
'@rolldown/binding-freebsd-x64@1.0.3':
resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
+ '@rolldown/binding-freebsd-x64@1.2.15':
+ resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
'@rolldown/binding-linux-arm-gnueabihf@1.0.3':
resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
+ '@rolldown/binding-linux-arm-gnueabihf@1.2.15':
+ resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
'@rolldown/binding-linux-arm64-gnu@1.0.3':
resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -900,6 +945,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@rolldown/binding-linux-arm64-gnu@1.2.15':
+ resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
'@rolldown/binding-linux-arm64-musl@1.0.3':
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -907,6 +959,13 @@ packages:
os: [linux]
libc: [musl]
+ '@rolldown/binding-linux-arm64-musl@1.2.15':
+ resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -914,6 +973,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@rolldown/binding-linux-ppc64-gnu@1.2.15':
+ resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
'@rolldown/binding-linux-s390x-gnu@1.0.3':
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -921,6 +987,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@rolldown/binding-linux-s390x-gnu@1.2.15':
+ resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
'@rolldown/binding-linux-x64-gnu@1.0.3':
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -928,6 +1001,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@rolldown/binding-linux-x64-gnu@1.2.15':
+ resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
'@rolldown/binding-linux-x64-musl@1.0.3':
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -935,12 +1015,25 @@ packages:
os: [linux]
libc: [musl]
+ '@rolldown/binding-linux-x64-musl@1.2.15':
+ resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
'@rolldown/binding-openharmony-arm64@1.0.3':
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
+ '@rolldown/binding-openharmony-arm64@1.2.15':
+ resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
'@rolldown/binding-wasm32-wasi@1.0.3':
resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -952,12 +1045,24 @@ packages:
cpu: [arm64]
os: [win32]
+ '@rolldown/binding-win32-arm64-msvc@1.2.15':
+ resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
'@rolldown/binding-win32-x64-msvc@1.0.3':
resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
+ '@rolldown/binding-win32-x64-msvc@1.2.15':
+ resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
@@ -1252,8 +1357,8 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
- '@tanstack/history@1.162.0':
- resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
+ '@tanstack/history@1.162.4':
+ resolution: {integrity: sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==}
engines: {node: '>=20.19'}
'@tanstack/query-core@5.101.0':
@@ -1273,26 +1378,26 @@ packages:
peerDependencies:
react: ^18 || ^19
- '@tanstack/react-router@1.170.11':
- resolution: {integrity: sha512-gP2vzdyaI8Ow/Uz/MRPfK2wN09YwRI0Y/oF74Wuy9R3KmjbfJv2tLrkM+Onu1xWklSn3ugZarMPJXRE0kzrJTA==}
+ '@tanstack/react-router@1.170.41':
+ resolution: {integrity: sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w==}
engines: {node: '>=20.19'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
- '@tanstack/react-start-client@1.168.8':
- resolution: {integrity: sha512-CW2P0riDN+IQCuXx33R1H0ONEW3NespMfb2t6qSesOyuoPjnh4earDKaZsWYEVvewzx8465BOhOmh+nxEJRjJg==}
+ '@tanstack/react-start-client@1.168.39':
+ resolution: {integrity: sha512-Uby0uo37JlQgUKHU0snDwH9nYiEeAjm9+7pUOFgycU/UszEILC2SO2DSCoNnCMuHKj9z+4URKzBYIP2exT9+wg==}
engines: {node: '>=22.12.0'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
- '@tanstack/react-start-rsc@0.1.18':
- resolution: {integrity: sha512-pfekO3dvSLacSUW2kUJGnhfdNTo6rgQE6QjQzPaDsjUaNXT4zVWgbaqM0R6kXhwkGA69L1ZbBqtIXBwTQCrJzg==}
+ '@tanstack/react-start-rsc@0.1.59':
+ resolution: {integrity: sha512-M2Lqxkk5C1zQncQs5paqX+/OLdOht51pg6D84YfLMrNsjhU/nzb6wd92MIja0mi5i23+L+fxLLjQYR2DObjBlw==}
engines: {node: '>=22.12.0'}
peerDependencies:
'@rspack/core': '>=2.0.0-0'
- '@vitejs/plugin-rsc': '>=0.5.20'
+ '@vitejs/plugin-rsc': '>=0.5.30'
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
react-server-dom-rspack: '>=0.0.2'
@@ -1304,15 +1409,15 @@ packages:
react-server-dom-rspack:
optional: true
- '@tanstack/react-start-server@1.167.14':
- resolution: {integrity: sha512-Cma1M0ofxPxpmax1aQp6NM38N62MCgfEmto6RqfptZHd5UOlMp1dFf5zsnEukJq6vDVxg4lQyUgE2+qJuo2PmA==}
+ '@tanstack/react-start-server@1.167.46':
+ resolution: {integrity: sha512-K7z8xBOEdp8u5CZCxWrbS0tFtk5XZGMdhssLeZlRZKlLDbX7BKLdMtJ7th5sR6QVt9MTUAg3LcoqvJMPSFF+lg==}
engines: {node: '>=22.12.0'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
- '@tanstack/react-start@1.168.19':
- resolution: {integrity: sha512-UGguzD22ZdxZmz/Rcw2My/L40il/S51adm1zARclr7zkhoQfV7WlgBxjskPi5ngiOYAPlI7847Ptz8we5TSM3Q==}
+ '@tanstack/react-start@1.168.60':
+ resolution: {integrity: sha512-WMOM0Xlh2lPbVlbjYM6uOxOpiaWRPvODm/dwkLsGKq5tjyHOHZmKckJgwlrZl7OQUqFlOZHYj8bKUTgoeg8cUQ==}
engines: {node: '>=22.12.0'}
peerDependencies:
'@rsbuild/core': ^2.0.0
@@ -1328,26 +1433,26 @@ packages:
vite:
optional: true
- '@tanstack/react-store@0.9.3':
- resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
+ '@tanstack/react-store@0.11.2':
+ resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- '@tanstack/router-core@1.171.9':
- resolution: {integrity: sha512-QM5ZwLT9c5ZcTJW0QQZRRIBC4qjImUyUCXCVyuYVOF9xr76XLsJSX4F2dOxr9VptAv+W+TkWNOYdX8VaO9kdgA==}
+ '@tanstack/router-core@1.171.34':
+ resolution: {integrity: sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw==}
engines: {node: '>=20.19'}
- '@tanstack/router-generator@1.167.13':
- resolution: {integrity: sha512-DldbCjA8S/CXQBuoyQqr76xqZe9k+H1ymV+ugj2IBHFi4yRzx4z4f2nSsPYlLdpXD2Cf/MEjLncaG7ceY5H5ig==}
+ '@tanstack/router-generator@1.167.40':
+ resolution: {integrity: sha512-WkEueqEvsBBudzO0fx1nWfOmRNW8OAvBoVABTGls9bAWzjdC+KDGHvjCNrvn88ofs1F6gqL8aWnzKCbxW3dCyQ==}
engines: {node: '>=20.19'}
- '@tanstack/router-plugin@1.168.14':
- resolution: {integrity: sha512-z+3vYJ7ouNnMzBIC1hsNWsxaQFu9Gf0WSdE3jBHWa326ipnONqDD5KeCqWGczq0HMdZY4UsDjyfvjucxXhrb0A==}
+ '@tanstack/router-plugin@1.168.42':
+ resolution: {integrity: sha512-vAR5WXJh/ErseEWQftDhsJcr2c++ucRQ6FbqN3ygMO44+ZxxRfW3sIpZmddHROVSmBk8jc6MrwjTPNjVhd62Vw==}
engines: {node: '>=20.19'}
peerDependencies:
'@rsbuild/core': '>=1.0.2 || ^2.0.0'
- '@tanstack/react-router': ^1.170.11
+ '@tanstack/react-router': ^1.170.41
vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0'
vite-plugin-solid: ^2.11.10 || ^3.0.0-0
webpack: '>=5.92.0'
@@ -1363,20 +1468,20 @@ packages:
webpack:
optional: true
- '@tanstack/router-utils@1.162.1':
- resolution: {integrity: sha512-62layyTGmclHDQS/eidwKRfN1hhCKwViG7iEBcVmL0MXgcAB3OOucWCEcDDGd9Cu11H6b4QQ5oOo47MWIqwz0A==}
+ '@tanstack/router-utils@1.162.3':
+ resolution: {integrity: sha512-Icb0xGuG1+54IV0WMLRcc3ErTx2HeJWyPG661FkQ8UT6guoBq1FJjFRqlG/JS0xi4mFFkBhvwO7tbLa32f3yxA==}
engines: {node: '>=20.19'}
- '@tanstack/start-client-core@1.170.7':
- resolution: {integrity: sha512-LKNHeK3n8DZ2ub1KpidWCISvJNq7wGuErrd2oSyoUDHSo90ldl7JJcG4OpbDS7GQjqIZ79M47eklajwgKXBxrQ==}
+ '@tanstack/start-client-core@1.170.34':
+ resolution: {integrity: sha512-LfwZSbau2MLLZ/uPAoO+NC9fK3J66c0wLhnNQqIa+Goqw7bXPjkHwlsjY7evlUz1AnyhhQ1Qab95v+VmP0TZ9A==}
engines: {node: '>=22.12.0'}
'@tanstack/start-fn-stubs@1.162.0':
resolution: {integrity: sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==}
engines: {node: '>=22.12.0'}
- '@tanstack/start-plugin-core@1.171.11':
- resolution: {integrity: sha512-f6z9W8lYveloSLFocMGfUrS4UL2sc0qrJiB0cuhs885W/bRE1iG0Vm9cNhM/khWxrLmWNeN5eelcnfB77QjLJg==}
+ '@tanstack/start-plugin-core@1.171.49':
+ resolution: {integrity: sha512-HPOb7LnMc71k38cd5uvMybPPUU/Q/56KvSqQzSWz7cps1ZDFXaxUM6EFaFHLCdgTWqGexNGDdRE+sUVPVBM4Xg==}
engines: {node: '>=22.12.0'}
peerDependencies:
'@rsbuild/core': ^2.0.0
@@ -1387,16 +1492,16 @@ packages:
vite:
optional: true
- '@tanstack/start-server-core@1.169.9':
- resolution: {integrity: sha512-i2OXl+svinZI+7tE2FTQSc9vLIMp0/3nQAI47zg7cZ/0btmC2g2wVrEUa7pF4bmS2TrKEfmOancbURWfB2YrkA==}
+ '@tanstack/start-server-core@1.169.39':
+ resolution: {integrity: sha512-pqqkxhXY46nuFSYt6TsMGyABiplUzHFHndKAlRRGC0fWDrJCH1koWxVMoxySa51ZETV79f/z73b5UZ1jfIkXwQ==}
engines: {node: '>=22.12.0'}
- '@tanstack/start-storage-context@1.167.11':
- resolution: {integrity: sha512-19wywJH3jiamctg4BxXme0G9iH+P5qHSILxBbyksTK727shDEZPb6V/NzO2dz4cKFAoB6TdBcKBj/guADClOfQ==}
+ '@tanstack/start-storage-context@1.167.36':
+ resolution: {integrity: sha512-x+xCXK2GtFzY6NbXqAg8YpU6cwAjDfMeYSNzxfGzntpjbwBRHHrXeC/kYUnBBQCV7wK4kJ8vBBQGP0OyYTasUg==}
engines: {node: '>=22.12.0'}
- '@tanstack/store@0.9.3':
- resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
+ '@tanstack/store@0.11.2':
+ resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==}
'@tanstack/virtual-file-routes@1.162.0':
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
@@ -2113,6 +2218,9 @@ packages:
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
+ exsolve@1.1.3:
+ resolution: {integrity: sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==}
+
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -2490,30 +2598,60 @@ packages:
cpu: [arm64]
os: [android]
+ lightningcss-android-arm64@1.33.0:
+ resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
+ lightningcss-darwin-arm64@1.33.0:
+ resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
+ lightningcss-darwin-x64@1.33.0:
+ resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
+ lightningcss-freebsd-x64@1.33.0:
+ resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
@@ -2521,6 +2659,13 @@ packages:
os: [linux]
libc: [glibc]
+ lightningcss-linux-arm64-gnu@1.33.0:
+ resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
@@ -2528,6 +2673,13 @@ packages:
os: [linux]
libc: [musl]
+ lightningcss-linux-arm64-musl@1.33.0:
+ resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
@@ -2535,6 +2687,13 @@ packages:
os: [linux]
libc: [glibc]
+ lightningcss-linux-x64-gnu@1.33.0:
+ resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
@@ -2542,22 +2701,45 @@ packages:
os: [linux]
libc: [musl]
+ lightningcss-linux-x64-musl@1.33.0:
+ resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
+ lightningcss-win32-arm64-msvc@1.33.0:
+ resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
+ lightningcss-win32-x64-msvc@1.33.0:
+ resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
+ lightningcss@1.33.0:
+ resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
+ engines: {node: '>= 12.0.0'}
+
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -2914,6 +3096,10 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
+ engines: {node: '>=12'}
+
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@@ -2940,8 +3126,8 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
- prettier@3.8.2:
- resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==}
+ prettier@3.9.10:
+ resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==}
engines: {node: '>=14'}
hasBin: true
@@ -3054,6 +3240,11 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
+ rolldown@1.2.15:
+ resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
rollup@4.62.4:
resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -3094,15 +3285,15 @@ packages:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
- seroval-plugins@1.5.4:
- resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==}
+ seroval-plugins@1.6.12:
+ resolution: {integrity: sha512-+reiwp/lYxiyctSrdvX7OQ1YLrcbWYgBiZrhSFL+V43b0/DGRLNVSDyZINbQe+tWplPOI2fw2dPrdv6hcc09kQ==}
engines: {node: '>=10'}
peerDependencies:
- seroval: ^1.0
+ seroval: 1.7.0
- seroval@1.5.4:
- resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==}
- engines: {node: '>=10'}
+ seroval@1.7.0:
+ resolution: {integrity: sha512-FU+LUkmDzWNsJ6oubZFN3dGPKny53DRhPJG6nftEaXDL8MIn19+kEE1Gvc+63Go+qPOwVUlKIdKhsu2HN6tksw==}
+ engines: {node: '>=20'}
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
@@ -3162,8 +3353,8 @@ packages:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
- source-map-js@1.2.1:
- resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ source-map-js@1.2.2:
+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
source-map-support@0.5.21:
@@ -3190,6 +3381,11 @@ packages:
engines: {node: '>=20.16.0'}
hasBin: true
+ srvx@0.11.22:
+ resolution: {integrity: sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==}
+ engines: {node: '>=20.16.0'}
+ hasBin: true
+
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -3351,9 +3547,41 @@ packages:
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
engines: {node: '>=18.12.0'}
- unplugin@3.0.0:
- resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
+ unplugin@3.4.0:
+ resolution: {integrity: sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==}
engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@farmfe/core': '*'
+ '@rsbuild/core': '*'
+ '@rspack/core': '*'
+ bun-types-no-globals: '*'
+ esbuild: 0.28.1
+ rolldown: '*'
+ rollup: '*'
+ unloader: '*'
+ vite: '*'
+ webpack: '*'
+ peerDependenciesMeta:
+ '@farmfe/core':
+ optional: true
+ '@rsbuild/core':
+ optional: true
+ '@rspack/core':
+ optional: true
+ bun-types-no-globals:
+ optional: true
+ esbuild:
+ optional: true
+ rolldown:
+ optional: true
+ rollup:
+ optional: true
+ unloader:
+ optional: true
+ vite:
+ optional: true
+ webpack:
+ optional: true
unstorage@2.0.0-alpha.7:
resolution: {integrity: sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog==}
@@ -3464,13 +3692,13 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
- vite@8.0.16:
- resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
+ vite@8.3.5:
+ resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
- '@vitejs/devtools': ^0.1.18
+ '@vitejs/devtools': ^0.7.1 || ^0.8.0
esbuild: 0.28.1
jiti: '>=1.21.0'
less: ^4.0.0
@@ -3584,6 +3812,9 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+ zod@4.6.7:
+ resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
+
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
@@ -3843,15 +4074,15 @@ snapshots:
'@aws/lambda-invoke-store@0.2.4': {}
- '@babel/code-frame@7.27.1':
+ '@babel/code-frame@7.29.0':
dependencies:
- '@babel/helper-validator-identifier': 7.29.7
+ '@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0
picocolors: 1.1.1
- '@babel/code-frame@7.29.0':
+ '@babel/code-frame@7.29.7':
dependencies:
- '@babel/helper-validator-identifier': 7.28.5
+ '@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
@@ -4036,6 +4267,12 @@ snapshots:
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+
'@babel/traverse@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
@@ -4395,7 +4632,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@modelcontextprotocol/core@2.0.0':
+ '@modelcontextprotocol/core@2.3.1':
dependencies:
zod: 4.4.3
@@ -4421,9 +4658,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@modelcontextprotocol/server@2.0.0':
+ '@modelcontextprotocol/server@2.3.1':
dependencies:
- '@modelcontextprotocol/core': 2.0.0
+ '@modelcontextprotocol/core': 2.3.1
zod: 4.4.3
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
@@ -4479,46 +4716,87 @@ snapshots:
'@oxc-project/types@0.133.0': {}
+ '@oxc-project/types@0.155.0': {}
+
'@playwright/test@1.63.0':
dependencies:
playwright: 1.63.0
+ '@rolldown/binding-android-arm-eabi@1.2.15':
+ optional: true
+
'@rolldown/binding-android-arm64@1.0.3':
optional: true
+ '@rolldown/binding-android-arm64@1.2.15':
+ optional: true
+
'@rolldown/binding-darwin-arm64@1.0.3':
optional: true
+ '@rolldown/binding-darwin-arm64@1.2.15':
+ optional: true
+
'@rolldown/binding-darwin-x64@1.0.3':
optional: true
+ '@rolldown/binding-darwin-x64@1.2.15':
+ optional: true
+
'@rolldown/binding-freebsd-x64@1.0.3':
optional: true
+ '@rolldown/binding-freebsd-x64@1.2.15':
+ optional: true
+
'@rolldown/binding-linux-arm-gnueabihf@1.0.3':
optional: true
+ '@rolldown/binding-linux-arm-gnueabihf@1.2.15':
+ optional: true
+
'@rolldown/binding-linux-arm64-gnu@1.0.3':
optional: true
+ '@rolldown/binding-linux-arm64-gnu@1.2.15':
+ optional: true
+
'@rolldown/binding-linux-arm64-musl@1.0.3':
optional: true
+ '@rolldown/binding-linux-arm64-musl@1.2.15':
+ optional: true
+
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
optional: true
+ '@rolldown/binding-linux-ppc64-gnu@1.2.15':
+ optional: true
+
'@rolldown/binding-linux-s390x-gnu@1.0.3':
optional: true
+ '@rolldown/binding-linux-s390x-gnu@1.2.15':
+ optional: true
+
'@rolldown/binding-linux-x64-gnu@1.0.3':
optional: true
+ '@rolldown/binding-linux-x64-gnu@1.2.15':
+ optional: true
+
'@rolldown/binding-linux-x64-musl@1.0.3':
optional: true
+ '@rolldown/binding-linux-x64-musl@1.2.15':
+ optional: true
+
'@rolldown/binding-openharmony-arm64@1.0.3':
optional: true
+ '@rolldown/binding-openharmony-arm64@1.2.15':
+ optional: true
+
'@rolldown/binding-wasm32-wasi@1.0.3':
dependencies:
'@emnapi/core': 1.10.0
@@ -4529,9 +4807,15 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc@1.0.3':
optional: true
+ '@rolldown/binding-win32-arm64-msvc@1.2.15':
+ optional: true
+
'@rolldown/binding-win32-x64-msvc@1.0.3':
optional: true
+ '@rolldown/binding-win32-x64-msvc@1.2.15':
+ optional: true
+
'@rolldown/pluginutils@1.0.1': {}
'@rollup/pluginutils@5.4.0(rollup@4.62.4)':
@@ -4680,7 +4964,7 @@ snapshots:
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
- source-map-js: 1.2.1
+ source-map-js: 1.2.2
tailwindcss: 4.3.0
'@tailwindcss/oxide-android-arm64@4.3.0':
@@ -4734,14 +5018,14 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.0
'@tailwindcss/oxide-win32-x64-msvc': 4.3.0
- '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
+ '@tailwindcss/vite@4.3.0(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
'@tailwindcss/node': 4.3.0
'@tailwindcss/oxide': 4.3.0
tailwindcss: 4.3.0
- vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
+ vite: 8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
- '@tanstack/history@1.162.0': {}
+ '@tanstack/history@1.162.4': {}
'@tanstack/query-core@5.101.0': {}
@@ -4758,131 +5042,141 @@ snapshots:
'@tanstack/query-core': 5.101.0
react: 19.2.5
- '@tanstack/react-router@1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ '@tanstack/react-router@1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@tanstack/history': 1.162.0
- '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@tanstack/router-core': 1.171.9
+ '@tanstack/history': 1.162.4
+ '@tanstack/react-store': 0.11.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/router-core': 1.171.34
isbot: 5.1.40
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
- '@tanstack/react-start-client@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ '@tanstack/react-start-client@1.168.39(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@tanstack/react-router': 1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@tanstack/router-core': 1.171.9
- '@tanstack/start-client-core': 1.170.7
+ '@tanstack/react-router': 1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/router-core': 1.171.34
+ '@tanstack/start-client-core': 1.170.34
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
- '@tanstack/react-start-rsc@0.1.18(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
+ '@tanstack/react-start-rsc@0.1.59(crossws@0.4.5(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
- '@tanstack/react-router': 1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@tanstack/react-start-server': 1.167.14(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@tanstack/router-core': 1.171.9
- '@tanstack/router-utils': 1.162.1
- '@tanstack/start-client-core': 1.170.7
+ '@tanstack/react-router': 1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/router-core': 1.171.34
+ '@tanstack/router-utils': 1.162.3
+ '@tanstack/start-client-core': 1.170.34
'@tanstack/start-fn-stubs': 1.162.0
- '@tanstack/start-plugin-core': 1.171.11(@tanstack/react-router@1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.16))(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
- '@tanstack/start-server-core': 1.169.9(crossws@0.4.5(srvx@0.11.16))
- '@tanstack/start-storage-context': 1.167.11
+ '@tanstack/start-plugin-core': 1.171.49(@tanstack/react-router@1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.22))(esbuild@0.28.1)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
+ '@tanstack/start-storage-context': 1.167.36
pathe: 2.0.3
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
transitivePeerDependencies:
+ - '@farmfe/core'
- '@rsbuild/core'
+ - bun-types-no-globals
- crossws
+ - esbuild
+ - rolldown
+ - rollup
- supports-color
+ - unloader
- vite
- vite-plugin-solid
- webpack
- '@tanstack/react-start-server@1.167.14(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ '@tanstack/react-start-server@1.167.46(crossws@0.4.5(srvx@0.11.22))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@tanstack/history': 1.162.0
- '@tanstack/react-router': 1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@tanstack/router-core': 1.171.9
- '@tanstack/start-client-core': 1.170.7
- '@tanstack/start-server-core': 1.169.9(crossws@0.4.5(srvx@0.11.16))
+ '@tanstack/react-router': 1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/router-core': 1.171.34
+ '@tanstack/start-server-core': 1.169.39(crossws@0.4.5(srvx@0.11.22))
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
transitivePeerDependencies:
- crossws
- '@tanstack/react-start@1.168.19(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
+ '@tanstack/react-start@1.168.60(crossws@0.4.5(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
- '@tanstack/react-router': 1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@tanstack/react-start-client': 1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@tanstack/react-start-rsc': 0.1.18(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
- '@tanstack/react-start-server': 1.167.14(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- '@tanstack/router-utils': 1.162.1
- '@tanstack/start-client-core': 1.170.7
- '@tanstack/start-plugin-core': 1.171.11(@tanstack/react-router@1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.16))(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
- '@tanstack/start-server-core': 1.169.9(crossws@0.4.5(srvx@0.11.16))
+ '@tanstack/react-router': 1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/react-start-client': 1.168.39(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/react-start-rsc': 0.1.59(crossws@0.4.5(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
+ '@tanstack/react-start-server': 1.167.46(crossws@0.4.5(srvx@0.11.22))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tanstack/router-utils': 1.162.3
+ '@tanstack/start-client-core': 1.170.34
+ '@tanstack/start-plugin-core': 1.171.49(@tanstack/react-router@1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.22))(esbuild@0.28.1)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
+ '@tanstack/start-server-core': 1.169.39(crossws@0.4.5(srvx@0.11.22))
pathe: 2.0.3
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
- vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
+ vite: 8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
transitivePeerDependencies:
+ - '@farmfe/core'
- '@rspack/core'
+ - bun-types-no-globals
- crossws
+ - esbuild
- react-server-dom-rspack
+ - rolldown
+ - rollup
- supports-color
+ - unloader
- vite-plugin-solid
- webpack
- '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ '@tanstack/react-store@0.11.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
- '@tanstack/store': 0.9.3
+ '@tanstack/store': 0.11.2
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
use-sync-external-store: 1.6.0(react@19.2.5)
- '@tanstack/router-core@1.171.9':
+ '@tanstack/router-core@1.171.34':
dependencies:
- '@tanstack/history': 1.162.0
+ '@tanstack/history': 1.162.4
cookie-es: 3.1.1
- seroval: 1.5.4
- seroval-plugins: 1.5.4(seroval@1.5.4)
+ seroval: 1.7.0
+ seroval-plugins: 1.6.12(seroval@1.7.0)
- '@tanstack/router-generator@1.167.13':
+ '@tanstack/router-generator@1.167.40':
dependencies:
'@babel/types': 7.29.8
- '@tanstack/router-core': 1.171.9
- '@tanstack/router-utils': 1.162.1
+ '@tanstack/router-core': 1.171.34
+ '@tanstack/router-utils': 1.162.3
'@tanstack/virtual-file-routes': 1.162.0
jiti: 2.7.0
magic-string: 0.30.21
- prettier: 3.8.2
- zod: 4.4.3
+ prettier: 3.9.10
+ zod: 4.6.7
transitivePeerDependencies:
- supports-color
- '@tanstack/router-plugin@1.168.14(@tanstack/react-router@1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
+ '@tanstack/router-plugin@1.168.42(@tanstack/react-router@1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(esbuild@0.28.1)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.29.6
- '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.6)
- '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.6)
- '@babel/template': 7.28.6
- '@babel/traverse': 7.29.0
+ '@babel/template': 7.29.7
'@babel/types': 7.29.8
- '@tanstack/router-core': 1.171.9
- '@tanstack/router-generator': 1.167.13
- '@tanstack/router-utils': 1.162.1
- '@tanstack/virtual-file-routes': 1.162.0
+ '@tanstack/router-core': 1.171.34
+ '@tanstack/router-generator': 1.167.40
+ '@tanstack/router-utils': 1.162.3
chokidar: 5.0.0
- unplugin: 3.0.0
- zod: 4.4.3
+ unplugin: 3.4.0(esbuild@0.28.1)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
+ zod: 4.6.7
optionalDependencies:
- '@tanstack/react-router': 1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
+ '@tanstack/react-router': 1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ vite: 8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
transitivePeerDependencies:
+ - '@farmfe/core'
+ - '@rspack/core'
+ - bun-types-no-globals
+ - esbuild
+ - rolldown
+ - rollup
- supports-color
+ - unloader
- '@tanstack/router-utils@1.162.1':
+ '@tanstack/router-utils@1.162.3':
dependencies:
- '@babel/core': 7.29.6
'@babel/generator': 7.29.8
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
@@ -4894,65 +5188,71 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/start-client-core@1.170.7':
+ '@tanstack/start-client-core@1.170.34':
dependencies:
- '@tanstack/router-core': 1.171.9
+ '@tanstack/router-core': 1.171.34
'@tanstack/start-fn-stubs': 1.162.0
- '@tanstack/start-storage-context': 1.167.11
- seroval: 1.5.4
+ '@tanstack/start-storage-context': 1.167.36
+ seroval: 1.7.0
'@tanstack/start-fn-stubs@1.162.0': {}
- '@tanstack/start-plugin-core@1.171.11(@tanstack/react-router@1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.16))(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
+ '@tanstack/start-plugin-core@1.171.49(@tanstack/react-router@1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.22))(esbuild@0.28.1)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
- '@babel/code-frame': 7.27.1
+ '@babel/code-frame': 7.29.7
'@babel/core': 7.29.6
'@babel/types': 7.29.8
- '@rolldown/pluginutils': 1.0.1
- '@tanstack/router-core': 1.171.9
- '@tanstack/router-generator': 1.167.13
- '@tanstack/router-plugin': 1.168.14(@tanstack/react-router@1.170.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
- '@tanstack/router-utils': 1.162.1
- '@tanstack/start-client-core': 1.170.7
- '@tanstack/start-server-core': 1.169.9(crossws@0.4.5(srvx@0.11.16))
- exsolve: 1.0.8
- lightningcss: 1.32.0
+ '@jridgewell/remapping': 2.3.5
+ '@tanstack/router-core': 1.171.34
+ '@tanstack/router-generator': 1.167.40
+ '@tanstack/router-plugin': 1.168.42(@tanstack/react-router@1.170.41(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(esbuild@0.28.1)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
+ '@tanstack/router-utils': 1.162.3
+ '@tanstack/start-server-core': 1.169.39(crossws@0.4.5(srvx@0.11.22))
+ exsolve: 1.1.3
+ lightningcss: 1.33.0
pathe: 2.0.3
- picomatch: 4.0.4
- seroval: 1.5.4
+ picomatch: 4.0.7
+ seroval: 1.7.0
source-map: 0.7.6
- srvx: 0.11.16
+ srvx: 0.11.22
tinyglobby: 0.2.17
ufo: 1.6.4
- vitefu: 1.1.3(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
+ vitefu: 1.1.3(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))
xmlbuilder2: 4.0.3
- zod: 4.4.3
+ zod: 4.6.7
optionalDependencies:
- vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
+ vite: 8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
transitivePeerDependencies:
+ - '@farmfe/core'
+ - '@rspack/core'
- '@tanstack/react-router'
+ - bun-types-no-globals
- crossws
+ - esbuild
+ - rolldown
+ - rollup
- supports-color
+ - unloader
- vite-plugin-solid
- webpack
- '@tanstack/start-server-core@1.169.9(crossws@0.4.5(srvx@0.11.16))':
+ '@tanstack/start-server-core@1.169.39(crossws@0.4.5(srvx@0.11.22))':
dependencies:
- '@tanstack/history': 1.162.0
- '@tanstack/router-core': 1.171.9
- '@tanstack/start-client-core': 1.170.7
- '@tanstack/start-storage-context': 1.167.11
+ '@tanstack/history': 1.162.4
+ '@tanstack/router-core': 1.171.34
+ '@tanstack/start-client-core': 1.170.34
+ '@tanstack/start-storage-context': 1.167.36
fetchdts: 0.1.7
- h3-v2: h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.16))
- seroval: 1.5.4
+ h3-v2: h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.22))
+ seroval: 1.7.0
transitivePeerDependencies:
- crossws
- '@tanstack/start-storage-context@1.167.11':
+ '@tanstack/start-storage-context@1.167.36':
dependencies:
- '@tanstack/router-core': 1.171.9
+ '@tanstack/router-core': 1.171.34
- '@tanstack/store@0.9.3': {}
+ '@tanstack/store@0.11.2': {}
'@tanstack/virtual-file-routes@1.162.0': {}
@@ -5040,10 +5340,10 @@ snapshots:
'@ungap/structured-clone@1.3.1': {}
- '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
+ '@vitejs/plugin-react@6.0.2(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
+ vite: 8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
'@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
@@ -5264,6 +5564,11 @@ snapshots:
optionalDependencies:
srvx: 0.11.16
+ crossws@0.4.5(srvx@0.11.22):
+ optionalDependencies:
+ srvx: 0.11.22
+ optional: true
+
cssesc@3.0.0: {}
csstype@3.2.3: {}
@@ -5568,6 +5873,8 @@ snapshots:
exsolve@1.0.8: {}
+ exsolve@1.1.3: {}
+
extend@3.0.2: {}
fast-deep-equal@3.1.3: {}
@@ -5602,6 +5909,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
+ fdir@6.5.0(picomatch@4.0.7):
+ optionalDependencies:
+ picomatch: 4.0.7
+
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
@@ -5692,19 +6003,19 @@ snapshots:
graceful-fs@4.2.11: {}
- h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.16)):
+ h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.22)):
dependencies:
rou3: 0.8.1
- srvx: 0.11.16
+ srvx: 0.11.22
optionalDependencies:
- crossws: 0.4.5(srvx@0.11.16)
+ crossws: 0.4.5(srvx@0.11.22)
- h3@2.0.1-rc.22(crossws@0.4.5(srvx@0.11.16)):
+ h3@2.0.1-rc.22(crossws@0.4.5(srvx@0.11.22)):
dependencies:
rou3: 0.8.1
srvx: 0.11.16
optionalDependencies:
- crossws: 0.4.5(srvx@0.11.16)
+ crossws: 0.4.5(srvx@0.11.22)
has-symbols@1.1.0: {}
@@ -5920,36 +6231,69 @@ snapshots:
lightningcss-android-arm64@1.32.0:
optional: true
+ lightningcss-android-arm64@1.33.0:
+ optional: true
+
lightningcss-darwin-arm64@1.32.0:
optional: true
+ lightningcss-darwin-arm64@1.33.0:
+ optional: true
+
lightningcss-darwin-x64@1.32.0:
optional: true
+ lightningcss-darwin-x64@1.33.0:
+ optional: true
+
lightningcss-freebsd-x64@1.32.0:
optional: true
+ lightningcss-freebsd-x64@1.33.0:
+ optional: true
+
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ optional: true
+
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
+ lightningcss-linux-arm64-gnu@1.33.0:
+ optional: true
+
lightningcss-linux-arm64-musl@1.32.0:
optional: true
+ lightningcss-linux-arm64-musl@1.33.0:
+ optional: true
+
lightningcss-linux-x64-gnu@1.32.0:
optional: true
+ lightningcss-linux-x64-gnu@1.33.0:
+ optional: true
+
lightningcss-linux-x64-musl@1.32.0:
optional: true
+ lightningcss-linux-x64-musl@1.33.0:
+ optional: true
+
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
+ lightningcss-win32-arm64-msvc@1.33.0:
+ optional: true
+
lightningcss-win32-x64-msvc@1.32.0:
optional: true
+ lightningcss-win32-x64-msvc@1.33.0:
+ optional: true
+
lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
@@ -5966,6 +6310,22 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
+ lightningcss@1.33.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.33.0
+ lightningcss-darwin-arm64: 1.33.0
+ lightningcss-darwin-x64: 1.33.0
+ lightningcss-freebsd-x64: 1.33.0
+ lightningcss-linux-arm-gnueabihf: 1.33.0
+ lightningcss-linux-arm64-gnu: 1.33.0
+ lightningcss-linux-arm64-musl: 1.33.0
+ lightningcss-linux-x64-gnu: 1.33.0
+ lightningcss-linux-x64-musl: 1.33.0
+ lightningcss-win32-arm64-msvc: 1.33.0
+ lightningcss-win32-x64-msvc: 1.33.0
+
lines-and-columns@1.2.4: {}
log-symbols@6.0.0:
@@ -6343,13 +6703,13 @@ snapshots:
nf3@0.3.17: {}
- nitro@3.0.260603-beta(@libsql/client@0.14.0)(chokidar@5.0.0)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.14.0)(kysely@0.28.17))(jiti@2.7.0)(rollup@4.62.4)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)):
+ nitro@3.0.260603-beta(@libsql/client@0.14.0)(chokidar@5.0.0)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.14.0)(kysely@0.28.17))(jiti@2.7.0)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)):
dependencies:
consola: 3.4.2
crossws: 0.4.5(srvx@0.11.16)
db0: 0.3.4(@libsql/client@0.14.0)(drizzle-orm@0.45.2(@libsql/client@0.14.0)(kysely@0.28.17))
env-runner: 0.1.9
- h3: 2.0.1-rc.22(crossws@0.4.5(srvx@0.11.16))
+ h3: 2.0.1-rc.22(crossws@0.4.5(srvx@0.11.22))
hookable: 6.1.1
nf3: 0.3.17
ocache: 0.1.4
@@ -6363,7 +6723,7 @@ snapshots:
dotenv: 17.4.1
jiti: 2.7.0
rollup: 4.62.4
- vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
+ vite: 8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -6510,6 +6870,8 @@ snapshots:
picomatch@4.0.4: {}
+ picomatch@4.0.7: {}
+
pkce-challenge@5.0.1: {}
playwright-core@1.63.0: {}
@@ -6527,11 +6889,11 @@ snapshots:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
- source-map-js: 1.2.1
+ source-map-js: 1.2.2
powershell-utils@0.1.0: {}
- prettier@3.8.2: {}
+ prettier@3.9.10: {}
pretty-ms@9.3.0:
dependencies:
@@ -6693,6 +7055,27 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.3
'@rolldown/binding-win32-x64-msvc': 1.0.3
+ rolldown@1.2.15:
+ dependencies:
+ '@oxc-project/types': 0.155.0
+ '@rolldown/pluginutils': 1.0.1
+ optionalDependencies:
+ '@rolldown/binding-android-arm-eabi': 1.2.15
+ '@rolldown/binding-android-arm64': 1.2.15
+ '@rolldown/binding-darwin-arm64': 1.2.15
+ '@rolldown/binding-darwin-x64': 1.2.15
+ '@rolldown/binding-freebsd-x64': 1.2.15
+ '@rolldown/binding-linux-arm-gnueabihf': 1.2.15
+ '@rolldown/binding-linux-arm64-gnu': 1.2.15
+ '@rolldown/binding-linux-arm64-musl': 1.2.15
+ '@rolldown/binding-linux-ppc64-gnu': 1.2.15
+ '@rolldown/binding-linux-s390x-gnu': 1.2.15
+ '@rolldown/binding-linux-x64-gnu': 1.2.15
+ '@rolldown/binding-linux-x64-musl': 1.2.15
+ '@rolldown/binding-openharmony-arm64': 1.2.15
+ '@rolldown/binding-win32-arm64-msvc': 1.2.15
+ '@rolldown/binding-win32-x64-msvc': 1.2.15
+
rollup@4.62.4:
dependencies:
'@types/estree': 1.0.9
@@ -6769,11 +7152,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
- seroval-plugins@1.5.4(seroval@1.5.4):
+ seroval-plugins@1.6.12(seroval@1.7.0):
dependencies:
- seroval: 1.5.4
+ seroval: 1.7.0
- seroval@1.5.4: {}
+ seroval@1.7.0: {}
serve-static@2.2.1:
dependencies:
@@ -6875,7 +7258,7 @@ snapshots:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
- source-map-js@1.2.1: {}
+ source-map-js@1.2.2: {}
source-map-support@0.5.21:
dependencies:
@@ -6895,6 +7278,8 @@ snapshots:
srvx@0.11.16: {}
+ srvx@0.11.22: {}
+
statuses@2.0.2: {}
stdin-discarder@0.2.2: {}
@@ -6956,8 +7341,8 @@ snapshots:
tinyglobby@0.2.17:
dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
to-regex-range@5.0.1:
dependencies:
@@ -7059,11 +7444,16 @@ snapshots:
picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
- unplugin@3.0.0:
+ unplugin@3.4.0(esbuild@0.28.1)(rolldown@1.2.15)(rollup@4.62.4)(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)):
dependencies:
'@jridgewell/remapping': 2.3.5
- picomatch: 4.0.4
+ picomatch: 4.0.7
webpack-virtual-modules: 0.6.2
+ optionalDependencies:
+ esbuild: 0.28.1
+ rolldown: 1.2.15
+ rollup: 4.62.4
+ vite: 8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@libsql/client@0.14.0)(drizzle-orm@0.45.2(@libsql/client@0.14.0)(kysely@0.28.17)))(ofetch@2.0.0-alpha.3):
optionalDependencies:
@@ -7101,12 +7491,12 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0):
+ vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0):
dependencies:
- lightningcss: 1.32.0
- picomatch: 4.0.4
+ lightningcss: 1.33.0
+ picomatch: 4.0.7
postcss: 8.5.25
- rolldown: 1.0.3
+ rolldown: 1.2.15
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 22.19.17
@@ -7115,9 +7505,9 @@ snapshots:
jiti: 2.7.0
tsx: 4.21.0
- vitefu@1.1.3(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)):
+ vitefu@1.1.3(vite@8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)):
optionalDependencies:
- vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
+ vite: 8.3.5(@types/node@22.19.17)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)
web-streams-polyfill@3.3.3: {}
@@ -7167,6 +7557,8 @@ snapshots:
zod@4.4.3: {}
+ zod@4.6.7: {}
+
zustand@4.5.7(@types/react@19.2.14)(react@19.2.5):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.5)
diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs
index 215a24a..6f9c69f 100644
--- a/scripts/run-tests.mjs
+++ b/scripts/run-tests.mjs
@@ -4,6 +4,9 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const configuredConcurrency = process.env.BEATDESIGN_TEST_CONCURRENCY?.trim();
+const testConcurrency = configuredConcurrency ||
+ (process.platform === 'win32' ? '1' : undefined);
const files = [
...globSync('src/**/*.test.ts', { cwd: root }),
...globSync('src/**/*.test.tsx', { cwd: root }),
@@ -18,7 +21,13 @@ if (files.length === 0) {
const result = spawnSync(
process.execPath,
- ['--import', 'tsx', '--test', ...files],
+ [
+ '--import',
+ 'tsx',
+ '--test',
+ ...(testConcurrency ? [`--test-concurrency=${testConcurrency}`] : []),
+ ...files,
+ ],
{
cwd: root,
stdio: 'inherit',
diff --git a/src/components/app/create-project-route-page.test.ts b/src/components/app/create-project-route-page.test.ts
index 94bf924..060e54c 100644
--- a/src/components/app/create-project-route-page.test.ts
+++ b/src/components/app/create-project-route-page.test.ts
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { CreateProjectRoutePage } from './create-project-route-page';
const source = readFileSync(
new URL('./create-project-route-page.tsx', import.meta.url),
@@ -9,10 +12,41 @@ const source = readFileSync(
test('visiting Studio or Canvas does not create a project automatically', () => {
assert.doesNotMatch(source, /useEffect/);
- assert.match(source, /createProject\('studio'\)/);
- assert.match(source, /createProject\('canvas'\)/);
+ assert.match(
+ source,
+ /onClick=\{\(\) => void createProject\(workspaceMode\)\}/
+ );
assert.match(source, /appConfig\.app_logo/);
- assert.match(source, /'\/api\/app\/projects'/);
+});
+
+test('direct entry only offers the workspace selected by its route', () => {
+ for (const workspaceMode of ['studio', 'canvas'] as const) {
+ const html = renderToStaticMarkup(
+ createElement(CreateProjectRoutePage, {
+ data: {
+ locale: 'en',
+ target: null,
+ model: null,
+ prompt: null,
+ projectName: 'Entry test',
+ workspaceMode,
+ },
+ })
+ );
+ assert.equal((html.match(/]*disabled=""/);
+ assert.ok(
+ html.includes(
+ workspaceMode === 'canvas' ? 'Create in Canvas' : 'Create in Studio'
+ )
+ );
+ assert.ok(
+ !html.includes(
+ workspaceMode === 'canvas' ? 'Create in Studio' : 'Create in Canvas'
+ )
+ );
+ assert.ok(!html.includes('Choose Studio or Canvas'));
+ }
});
test('the first-run screen covers the full viewport', () => {
diff --git a/src/components/app/create-project-route-page.tsx b/src/components/app/create-project-route-page.tsx
index a03c53d..28bd36c 100644
--- a/src/components/app/create-project-route-page.tsx
+++ b/src/components/app/create-project-route-page.tsx
@@ -1,20 +1,12 @@
-import { useState } from 'react';
import { AlertCircleIcon, Loader2Icon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { appConfig } from '@/config';
import { normalizeLocale } from '@/config/locale';
import type { WorkspaceMode } from '@/config/workspace-modes';
-import { buildPostCreateProjectDetailPath } from '@/core/projects/project-entry';
-import { apiJsonPost } from '@/lib/api-client';
+import { useCreateProject } from '@/core/workspace-hooks/use-create-project';
import { m } from '@/paraglide/messages.js';
-type CreateProjectResponse = {
- id?: string;
- name?: string;
- error?: string;
-};
-
export type CreateProjectRouteData = {
locale: string;
target: string | null;
@@ -42,11 +34,11 @@ function getCopy(locale: string) {
{},
{ locale: messageLocale }
),
- startStudio: m['BeatAPI.project.startStudio'](
+ startStudio: m['BeatAPI.project.createStudio'](
{},
{ locale: messageLocale }
),
- startCanvas: m['BeatAPI.project.startCanvas'](
+ startCanvas: m['BeatAPI.project.createCanvas'](
{},
{ locale: messageLocale }
),
@@ -58,43 +50,17 @@ export function CreateProjectRoutePage({
}: {
data: CreateProjectRouteData;
}) {
- const { locale, target, model, prompt, projectName } = data;
+ const { locale, target, model, prompt, projectName, workspaceMode } = data;
const copy = getCopy(locale);
- const [creating, setCreating] = useState(false);
- const [error, setError] = useState(null);
-
- async function createProject(workspaceMode: WorkspaceMode) {
- if (creating) return;
- setCreating(true);
- setError(null);
-
- try {
- const payload = await apiJsonPost(
- '/api/app/projects',
- { name: projectName, workspaceMode }
- );
-
- if (!payload?.id) {
- throw new Error(payload?.error || copy.errorTitle);
- }
-
- window.location.replace(
- buildPostCreateProjectDetailPath({
- locale,
- projectId: payload.id,
- target: target ?? undefined,
- model: model ?? undefined,
- prompt: prompt ?? undefined,
- mode: workspaceMode,
- })
- );
- } catch (createError) {
- setError(
- createError instanceof Error ? createError.message : copy.errorTitle
- );
- setCreating(false);
- }
- }
+ const { ready, creating, error, createProject, retryProject } = useCreateProject({
+ locale,
+ projectName,
+ errorMessage: copy.errorTitle,
+ replace: true,
+ target: target ?? undefined,
+ model: model ?? undefined,
+ prompt: prompt ?? undefined,
+ });
return (
@@ -128,28 +94,21 @@ export function CreateProjectRoutePage({
void createProject('studio')}
+ onClick={() => void retryProject()}
>
{copy.retry}
) : (
- <>
- void createProject('studio')}
- >
- {copy.startStudio}
-
- void createProject('canvas')}
- >
- {copy.startCanvas}
-
- >
+ void createProject(workspaceMode)}
+ disabled={!ready}
+ >
+ {workspaceMode === 'studio'
+ ? copy.startStudio
+ : copy.startCanvas}
+
)}
) : null}
diff --git a/src/components/app/product-page-shell.tsx b/src/components/app/product-page-shell.tsx
index 48f1e11..76ca9d7 100644
--- a/src/components/app/product-page-shell.tsx
+++ b/src/components/app/product-page-shell.tsx
@@ -3,11 +3,15 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Images, LayoutPanelTop, Scissors, Workflow } from 'lucide-react';
import { WorkspaceApiConfigDialog } from '@/components/app/workspace-api-config-dialog';
+import { ProjectSettingsDialog } from './project-settings-dialog';
+import { fetchProjectSettings, saveProjectSettings } from '@/core/workspace-lib/app/project-settings-client';
+import { useQuery } from '@tanstack/react-query';
+import { toast } from 'sonner';
import { LanguageSwitcher } from '@/components/app/language-switcher';
import { GitHubIcon } from '@/components/icons/github';
import { Link } from '@/core/i18n/navigation';
import { useTranslations } from '@/core/workspace-lib/shims/next-intl';
-import { apiJsonPatch, apiJsonPost } from '@/lib/api-client';
+import { ApiError, apiJsonPost } from '@/lib/api-client';
import { appConfig } from '@/config';
import { ACTIVE_GENERATION_PROVIDER_ID } from '@/config/generation-providers';
import type { WorkspaceMode } from '@/config/workspace-modes';
@@ -24,6 +28,7 @@ export function ProductPageShell({
workspaceMode?: WorkspaceMode;
}) {
const t = useTranslations('AppShell');
+ const settingsCopy = useTranslations('ProjectSettings');
const untitledWorkspaceName = t('header.untitledCanvas');
const getDisplayWorkspaceName = useCallback(
(name: string | null | undefined) => {
@@ -40,6 +45,16 @@ export function ProductPageShell({
const [draftWorkspaceName, setDraftWorkspaceName] =
useState(defaultWorkspaceName);
const lastPersistedWorkspaceNameRef = useRef(defaultWorkspaceName);
+ const projectSettings = useQuery({ queryKey: ['project-settings', projectId], queryFn: () => fetchProjectSettings(projectId!), enabled: Boolean(projectId), refetchInterval: 5000, refetchIntervalInBackground: false });
+
+ useEffect(() => {
+ const name = projectSettings.data?.name;
+ if (!name) return;
+ // React may defer the updater until after this effect mutates the ref.
+ const previousName = lastPersistedWorkspaceNameRef.current;
+ setDraftWorkspaceName((draft) => draft === previousName ? name : draft);
+ lastPersistedWorkspaceNameRef.current = name;
+ }, [projectSettings.data?.name]);
useEffect(() => {
const nextName = getDisplayWorkspaceName(workspaceName);
@@ -72,11 +87,16 @@ export function ProductPageShell({
}
try {
- await apiJsonPatch(`/api/app/projects/${projectId}`, { name: nextName });
+ const current = projectSettings.data ?? await fetchProjectSettings(projectId);
+ await saveProjectSettings(projectId, current.settingsRevision, { name: nextName });
+ void projectSettings.refetch();
lastPersistedWorkspaceNameRef.current = nextName;
} catch (error) {
- console.error('rename project failed:', error);
- setDraftWorkspaceName(lastPersistedWorkspaceNameRef.current);
+ toast.error(error instanceof ApiError && error.code === 409
+ ? settingsCopy('conflict')
+ : error instanceof Error ? error.message : settingsCopy('saveError'));
+ // Preserve the user's edit and refresh the revision for an explicit retry.
+ void projectSettings.refetch();
}
};
@@ -180,6 +200,11 @@ export function ProductPageShell({
) : null}
+ {projectId && {
+ setDraftWorkspaceName(settings.name);
+ lastPersistedWorkspaceNameRef.current = settings.name;
+ void projectSettings.refetch();
+ }} />}
diff --git a/src/components/app/project-assets-dialog.tsx b/src/components/app/project-assets-dialog.tsx
index d1b3bfc..84cb0b3 100644
--- a/src/components/app/project-assets-dialog.tsx
+++ b/src/components/app/project-assets-dialog.tsx
@@ -1,4 +1,6 @@
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { fetchProjectSettings, saveProjectSettings } from '@/core/workspace-lib/app/project-settings-client';
+import { invalidateWorkspaceAfterProjectMutation } from '@/core/workspace-lib/app/workspace-query-invalidation';
import {
AudioLines,
ExternalLink,
@@ -63,6 +65,21 @@ function AssetTile({
openLabel: string;
}) {
const [isAddingToTimeline, setIsAddingToTimeline] = useState(false);
+ const [isSettingCover, setIsSettingCover] = useState(false);
+ const settingsCopy = useTranslations('ProjectSettings');
+ const queryClient = useQueryClient();
+ const setAsCover = async () => {
+ if (isSettingCover) return;
+ setIsSettingCover(true);
+ try {
+ const settings = await fetchProjectSettings(projectId);
+ await saveProjectSettings(projectId, settings.settingsRevision, { cover: { mode: 'manual', assetId: asset.id } });
+ await invalidateWorkspaceAfterProjectMutation(queryClient);
+ await queryClient.invalidateQueries({ queryKey: ['project-settings', projectId] });
+ toast.success(settingsCopy('coverSaved'));
+ } catch (error) { toast.error(error instanceof Error ? error.message : settingsCopy('saveError')); }
+ finally { setIsSettingCover(false); }
+ };
const transfer: ProjectAssetTransfer = {
...asset,
projectId,
@@ -147,6 +164,7 @@ function AssetTile({
{canAddToCanvas || mediaType === 'image' || mediaType === 'video' || mediaType === 'audio' ? (
+ {mediaType === 'image' &&
void setAsCover()} className="inline-flex h-7 items-center rounded-full border border-white/15 bg-white/12 px-2.5 text-[10px] font-semibold text-white disabled:opacity-40">{settingsCopy('setAsCover')} }
{canAddToCanvas ? (
setFailed(false), [url]);
+ return url && !failed ? (
+ setFailed(true)}
+ />
+ ) : (
+
+ );
+}
diff --git a/src/components/app/project-settings-dialog.tsx b/src/components/app/project-settings-dialog.tsx
new file mode 100644
index 0000000..909376b
--- /dev/null
+++ b/src/components/app/project-settings-dialog.tsx
@@ -0,0 +1,365 @@
+import { useEffect, useRef, useState } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { Settings2, Upload } from 'lucide-react';
+import { ProjectCoverPreview } from './project-cover-preview';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import {
+ useTranslations,
+ useLocale,
+} from '@/core/workspace-lib/shims/next-intl';
+import {
+ fetchProjectSettings,
+ saveProjectSettings,
+} from '@/core/workspace-lib/app/project-settings-client';
+import { uploadLocalProjectAsset } from '@/core/workspace-lib/app/local-project-asset-client';
+import {
+ fetchRecentAssets,
+ type RecentAsset,
+} from '@/core/workspace-lib/app/workspace-client-api';
+import {
+ invalidateWorkspaceAfterProjectMutation,
+ invalidateWorkspaceAfterAssetMutation,
+} from '@/core/workspace-lib/app/workspace-query-invalidation';
+import type { ProjectSettings } from '@/core/projects/project-settings-contract';
+import { ApiError } from '@/lib/api-client';
+
+export function ProjectSettingsDialog({
+ projectId,
+ onSaved,
+}: {
+ projectId: string;
+ onSaved?: (settings: ProjectSettings) => void;
+}) {
+ const t = useTranslations('ProjectSettings');
+ const locale = useLocale();
+ const queryClient = useQueryClient();
+ const [open, setOpen] = useState(false);
+ const [interactive, setInteractive] = useState(false);
+ useEffect(() => setInteractive(true), []);
+ const [settings, setSettings] = useState(null);
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [coverId, setCoverId] = useState(null);
+ const [coverUrl, setCoverUrl] = useState(null);
+ const [autoCover, setAutoCover] = useState(true);
+ const [images, setImages] = useState([]);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const [loaded, setLoaded] = useState(false);
+ const fileInput = useRef(null);
+ const generation = useRef(0);
+
+ useEffect(() => {
+ if (!open) return;
+ const request = ++generation.current;
+ setLoaded(false);
+ setSettings(null);
+ setError('');
+ void Promise.all([
+ fetchProjectSettings(projectId),
+ fetchRecentAssets(projectId),
+ ])
+ .then(([value, assets]) => {
+ if (request !== generation.current) return;
+ setSettings(value);
+ setName(value.name);
+ setDescription(value.description);
+ setCoverId(value.coverAssetId);
+ setCoverUrl(value.coverImageUrl);
+ setAutoCover(value.coverMode === 'auto');
+ setImages(assets.images);
+ setLoaded(true);
+ })
+ .catch((err) => {
+ if (request === generation.current)
+ setError(err instanceof Error ? err.message : t('loadError'));
+ });
+ return () => {
+ generation.current++;
+ };
+ }, [open, projectId, locale]);
+
+ async function upload(file?: File) {
+ if (!file || busy) return;
+ if (
+ !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(
+ file.type,
+ )
+ ) {
+ setError(t('imageOnly'));
+ return;
+ }
+ setBusy(true);
+ setError('');
+ try {
+ const asset = await uploadLocalProjectAsset({ projectId, file });
+ setCoverId(asset.id);
+ setCoverUrl(asset.publicUrl);
+ setAutoCover(false);
+ setImages((old) => [
+ {
+ ...asset,
+ width: asset.width ?? null,
+ height: asset.height ?? null,
+ createdAt: new Date().toISOString(),
+ },
+ ...old,
+ ]);
+ await invalidateWorkspaceAfterAssetMutation(queryClient);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : t('saveError'));
+ } finally {
+ setBusy(false);
+ if (fileInput.current) fileInput.current.value = '';
+ }
+ }
+
+ async function save() {
+ if (!settings || busy || !name.trim()) return;
+ setBusy(true);
+ setError('');
+ try {
+ const next = await saveProjectSettings(
+ projectId,
+ settings.settingsRevision,
+ {
+ name,
+ description,
+ cover: autoCover
+ ? { mode: 'auto' }
+ : { mode: 'manual', assetId: coverId! },
+ },
+ );
+ await invalidateWorkspaceAfterProjectMutation(queryClient);
+ onSaved?.(next);
+ setOpen(false);
+ } catch (err) {
+ setError(
+ err instanceof ApiError && err.code === 409
+ ? t('conflict')
+ : err instanceof Error
+ ? err.message
+ : t('saveError'),
+ );
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const control =
+ 'w-full rounded-lg border border-white/10 bg-[#202126] px-3 py-2 text-sm text-white outline-none focus-visible:ring-2 focus-visible:ring-[#ff6b1a]';
+ const button =
+ 'rounded-lg border border-white/10 px-3 py-2 text-xs text-white/80 hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-[#ff6b1a] disabled:opacity-40';
+ return (
+ {
+ if (!busy) setOpen(value);
+ }}
+ >
+
+
+
+
+
+ {t('title')}
+
+ {t('subtitle')}
+
+
+ {error && (
+
+ {error}{' '}
+ {
+ setOpen(false);
+ }}
+ >
+ {t('closeReload')}
+
+
+ )}
+ {!loaded ? (
+ {t('loading')}
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/beatcanvas/beatcanvas-shell.tsx b/src/components/beatcanvas/beatcanvas-shell.tsx
index e165e12..92b7ad2 100644
--- a/src/components/beatcanvas/beatcanvas-shell.tsx
+++ b/src/components/beatcanvas/beatcanvas-shell.tsx
@@ -276,6 +276,16 @@ export function BeatCanvasShell({
const [canvasDocumentRevision, setCanvasDocumentRevision] = useState(0);
const [allowEmptyProjectSnapshot, setAllowEmptyProjectSnapshot] =
useState(false);
+ const handleUndoCanvas = useCallback(() => {
+ const restoredDocument = undoCanvas();
+ if (!restoredDocument) return;
+ setAllowEmptyProjectSnapshot(restoredDocument.cards.length === 0);
+ }, [undoCanvas]);
+ const handleRedoCanvas = useCallback(() => {
+ const restoredDocument = redoCanvas();
+ if (!restoredDocument) return;
+ setAllowEmptyProjectSnapshot(restoredDocument.cards.length === 0);
+ }, [redoCanvas]);
const [previewMedia, setPreviewMedia] = useState(
null
);
@@ -752,8 +762,8 @@ export function BeatCanvasShell({
promptCharacterLimit: generationValidationConstraints.maxPromptChars,
canUndoCanvas,
canRedoCanvas,
- onUndoCanvas: undoCanvas,
- onRedoCanvas: redoCanvas,
+ onUndoCanvas: handleUndoCanvas,
+ onRedoCanvas: handleRedoCanvas,
onCreateImageDraft: () => handleCreatePromptDraft('image'),
onUploadImage: () =>
openUploadPicker({ intent: 'image', mode: 'global' }),
@@ -819,8 +829,8 @@ export function BeatCanvasShell({
selectedShapeIds,
selectedCanvasCardIds,
setActiveComposerCardId,
- undoCanvas,
- redoCanvas,
+ handleUndoCanvas,
+ handleRedoCanvas,
videoModels,
]
);
@@ -1488,17 +1498,17 @@ export function BeatCanvasShell({
) {
if (key === 'z' && event.shiftKey) {
event.preventDefault();
- redoCanvas();
+ handleRedoCanvas();
return;
}
if (key === 'z') {
event.preventDefault();
- undoCanvas();
+ handleUndoCanvas();
return;
}
if (key === 'y') {
event.preventDefault();
- redoCanvas();
+ handleRedoCanvas();
return;
}
if (key === 'c') {
@@ -1567,8 +1577,8 @@ export function BeatCanvasShell({
handleCreatePromptDraft,
openUploadPicker,
pasteCanvasCards,
- redoCanvas,
- undoCanvas,
+ handleRedoCanvas,
+ handleUndoCanvas,
]);
// Register card connector "+" button handler
diff --git a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts
index 39184a4..677d94e 100644
--- a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts
+++ b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts
@@ -52,6 +52,33 @@ const makeDraft = (
sourceGenerationId: null,
});
+for (const type of ['image', 'video'] as const) {
+ test(`${type} preview and selected thumbnail agree before and after restoring manual selection`, () => {
+ const draft = makeDraft(type, '16:9');
+ const outputs: CanvasOutputCard[] = [0, 1, 2].map((index) => ({
+ ...draft,
+ id: `take-${index}`,
+ kind: 'output',
+ url: index === 2 ? null : `https://example.com/${index}.${type === 'image' ? 'png' : 'mp4'}`,
+ status: index === 2 ? 'processing' : 'succeeded',
+ sourceConfigCardId: draft.id,
+ generationRunId: `run-${index}`,
+ generationSnapshot: {
+ ...draft,
+ capturedAt: `2026-10-18T00:0${index}:00.000Z`,
+ },
+ }));
+ for (const pinnedOutputId of [null, outputs[0].id, 'missing']) {
+ const restored = JSON.parse(JSON.stringify({ ...draft, pinnedOutputId }));
+ const presentation = buildGenerationCardPresentation({ card: restored, outputs });
+ const selected = presentation.takes.find((take) => take.isPinned);
+ assert.equal(selected?.id, pinnedOutputId === outputs[0].id ? outputs[0].id : outputs[1].id);
+ assert.equal(presentation.latestOutputUrl, selected?.url);
+ assert.equal(presentation.status, 'processing');
+ }
+ });
+}
+
test('draft frame size follows portrait and landscape ratios', () => {
assert.deepEqual(resolveDraftShapeSize(null, makeDraft('video', '9:16')), {
w: 236,
diff --git a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts
index 92807ab..8a043d0 100644
--- a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts
+++ b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts
@@ -20,7 +20,10 @@ import type {
CanvasDraftCard,
CanvasOutputCard,
} from '@/core/beatcanvas/canvas-types';
-import { buildGenerationTakes } from '@/core/beatcanvas/generation-history';
+import {
+ buildGenerationTakes,
+ resolvePinnedGenerationOutputId,
+} from '@/core/beatcanvas/generation-history';
import {
isCanvasDraftCard,
isCanvasOutputCard,
@@ -186,21 +189,13 @@ export const buildGenerationCardPresentation = ({
) || left.id.localeCompare(right.id)
);
const latestOutput = orderedOutputs[orderedOutputs.length - 1] ?? null;
- const pinnedOutput = card.pinnedOutputId
- ? orderedOutputs.find(
- (output) =>
- output.id === card.pinnedOutputId &&
- output.status === 'succeeded' &&
- Boolean(output.url || output.resultText)
- ) ?? null
- : null;
- const latestSucceededOutput = [...orderedOutputs]
- .reverse()
- .find(
- (output) =>
- output.status === 'succeeded' &&
- Boolean(output.url || output.resultText)
- );
+ const selectedOutputId = resolvePinnedGenerationOutputId({
+ outputs: orderedOutputs,
+ pinnedOutputId: card.pinnedOutputId,
+ });
+ const selectedOutput = orderedOutputs.find(
+ (output) => output.id === selectedOutputId && output.status === 'succeeded'
+ );
const isAnalysis = card.generationMode === 'analysis';
const analysisReportCount = isAnalysis
? orderedOutputs.filter(
@@ -213,14 +208,10 @@ export const buildGenerationCardPresentation = ({
isAnalysis,
latestOutputUrl: isAnalysis
? null
- : pinnedOutput?.url ?? latestSucceededOutput?.url ?? card.url ?? null,
- latestOutputText:
- isAnalysis
- ? null
- : pinnedOutput?.resultText ??
- latestSucceededOutput?.resultText ??
- card.resultText ??
- null,
+ : (selectedOutput?.url ?? card.url ?? null),
+ latestOutputText: isAnalysis
+ ? null
+ : (selectedOutput?.resultText ?? card.resultText ?? null),
analysisReportCount,
takes: buildGenerationTakes({
outputs: orderedOutputs,
@@ -1224,6 +1215,7 @@ export function useBeatCanvasReactFlowAdapter({
setCanvasCard(completedOutputCard);
setCanvasCard({
...draftCard,
+ pinnedOutputId: outputCardId,
url: isAnalysisOutput ? null : (url ?? null),
resultText: isAnalysisOutput ? null : resultText,
});
@@ -1576,7 +1568,7 @@ export function useBeatCanvasReactFlowAdapter({
const undoCanvas = useCallback(() => {
const previous = undoStackRef.current.at(-1);
- if (!previous) return;
+ if (!previous) return null;
undoStackRef.current = undoStackRef.current.slice(0, -1);
redoStackRef.current = [
@@ -1585,11 +1577,12 @@ export function useBeatCanvasReactFlowAdapter({
];
applyHistoryDocument(previous);
syncHistoryState();
+ return previous;
}, [applyHistoryDocument, captureCanvasDocument, syncHistoryState]);
const redoCanvas = useCallback(() => {
const next = redoStackRef.current.at(-1);
- if (!next) return;
+ if (!next) return null;
redoStackRef.current = redoStackRef.current.slice(0, -1);
undoStackRef.current = [
@@ -1598,6 +1591,7 @@ export function useBeatCanvasReactFlowAdapter({
];
applyHistoryDocument(next);
syncHistoryState();
+ return next;
}, [applyHistoryDocument, captureCanvasDocument, syncHistoryState]);
useEffect(() => {
diff --git a/src/components/beatcanvas/use-project-snapshot-lifecycle.ts b/src/components/beatcanvas/use-project-snapshot-lifecycle.ts
index 39b160d..4ebd138 100644
--- a/src/components/beatcanvas/use-project-snapshot-lifecycle.ts
+++ b/src/components/beatcanvas/use-project-snapshot-lifecycle.ts
@@ -316,7 +316,9 @@ export function useProjectSnapshotLifecycle({
);
useEffect(() => {
- if (!initialPrompt?.trim()) return;
+ // Consume the one-time prompt only after it has become a real draft. Updating
+ // history earlier can re-run the route loader before Canvas is ready.
+ if (!initialPrompt?.trim() || !isHydratedFromQuery) return;
const searchParams = new URLSearchParams(window.location.search);
if (!searchParams.has('prompt')) return;
@@ -329,7 +331,7 @@ export function useProjectSnapshotLifecycle({
search: window.location.search,
})
);
- }, [initialPrompt, projectPath]);
+ }, [initialPrompt, isHydratedFromQuery, projectPath]);
useEffect(() => {
if (!isCanvasReady || isHydratedFromProject) return;
@@ -350,11 +352,12 @@ export function useProjectSnapshotLifecycle({
if (!isCanvasReady || !isHydratedFromProject || isHydratedFromQuery) return;
if (initialPrompt?.trim()) {
- createDraftCard({
+ const created = createDraftCard({
taskType: initialTaskType,
prompt: initialPrompt.trim(),
referenceCardIds: [],
});
+ if (!created) return;
}
setIsHydratedFromQuery(true);
diff --git a/src/components/home/workspace-page.test.ts b/src/components/home/workspace-page.test.ts
index 0993616..579820f 100644
--- a/src/components/home/workspace-page.test.ts
+++ b/src/components/home/workspace-page.test.ts
@@ -12,7 +12,8 @@ test('projects page stays simple and resumes each project in its last workspace'
assert.doesNotMatch(source, /copy\.allProjects/);
assert.match(source, /mode: project\.lastWorkspaceMode/);
assert.match(source, /copy\.createTitle/);
- assert.match(source, /href="\/studio"/);
- assert.match(source, /href="\/canvas"/);
+ assert.match(source, /onClick=\{\(\) => void createProject\('studio'\)\}/);
+ assert.match(source, /onClick=\{\(\) => void createProject\('canvas'\)\}/);
+ assert.doesNotMatch(source, /href="\/(studio|canvas)"/);
assert.match(source, /sm:whitespace-nowrap/);
});
diff --git a/src/components/home/workspace-page.tsx b/src/components/home/workspace-page.tsx
index b30ff4a..e1cfe45 100644
--- a/src/components/home/workspace-page.tsx
+++ b/src/components/home/workspace-page.tsx
@@ -4,11 +4,14 @@ import {
Plus,
Trash2,
Workflow,
+ Loader2,
} from 'lucide-react';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { BeatApiProductShell } from '@/components/marketing/beatapi-product-shell';
import { normalizeLocale } from '@/config/locale';
+import { ProjectSettingsDialog } from '@/components/app/project-settings-dialog';
+import { ProjectCoverPreview } from '@/components/app/project-cover-preview';
import { ConfirmDialog } from '@/components/confirm-dialog';
import {
Dialog,
@@ -20,6 +23,7 @@ import {
import { Link } from '@/core/i18n/navigation';
import { buildLocalizedProjectDetailPath } from '@/core/projects/project-entry';
import { useWorkspaceProjects } from '@/core/workspace-hooks/use-workspace-projects';
+import { useCreateProject } from '@/core/workspace-hooks/use-create-project';
import type { WorkspaceProjectCardItem } from '@/core/workspace-lib/app/workspace-client-api';
import { m } from '@/paraglide/messages.js';
@@ -32,6 +36,16 @@ function getHistoryCopy(locale: string) {
newProject: m['product.history.newProject']({}, { locale: messageLocale }),
newStudio: m['product.history.newStudio']({}, { locale: messageLocale }),
newCanvas: m['product.history.newCanvas']({}, { locale: messageLocale }),
+ creating: m['BeatAPI.project.newTitle']({}, { locale: messageLocale }),
+ createError: m['BeatAPI.project.newErrorTitle'](
+ {},
+ { locale: messageLocale }
+ ),
+ retry: m['BeatAPI.project.retry']({}, { locale: messageLocale }),
+ defaultName: m['BeatAPI.project.defaultName'](
+ {},
+ { locale: messageLocale }
+ ),
createTitle: m['product.history.createTitle'](
{},
{ locale: messageLocale }
@@ -68,10 +82,7 @@ function getHistoryCopy(locale: string) {
{},
{ locale: messageLocale }
),
- startFirst: m['product.history.startFirst'](
- {},
- { locale: messageLocale }
- ),
+ startFirst: m['product.history.startFirst']({}, { locale: messageLocale }),
emptyDescription: m['product.history.emptyDescription'](
{},
{ locale: messageLocale }
@@ -97,13 +108,28 @@ function CreateProjectDialog({
open,
onOpenChange,
copy,
+ locale,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
copy: HistoryCopy;
+ locale: string;
}) {
+ const { creating, error, createProject, retryProject, clearError } =
+ useCreateProject({
+ locale,
+ projectName: copy.defaultName,
+ errorMessage: copy.createError,
+ });
return (
-
+ {
+ if (creating) return;
+ clearError();
+ onOpenChange(nextOpen);
+ }}
+ >
@@ -115,36 +141,62 @@ function CreateProjectDialog({
-
void createProject('studio')}
+ className="group relative min-h-[190px] overflow-hidden rounded-[20px] border border-[#ff7a33]/35 bg-[radial-gradient(circle_at_82%_8%,rgba(255,122,51,0.22),transparent_42%),#171719] p-5 text-left transition hover:-translate-y-0.5 hover:border-[#ff7a33]/65 hover:bg-[#1b1b1e] disabled:cursor-wait disabled:opacity-60"
>
-
+
{copy.newStudio}
-
-
+
+
{copy.studioDescription}
-
-
+
+
-
void createProject('canvas')}
+ className="group relative min-h-[190px] overflow-hidden rounded-[20px] border border-white/[0.10] bg-[radial-gradient(circle_at_82%_8%,rgba(255,255,255,0.09),transparent_42%),#171719] p-5 text-left transition hover:-translate-y-0.5 hover:border-white/[0.22] hover:bg-[#1b1b1e] disabled:cursor-wait disabled:opacity-60"
>
-
+
{copy.newCanvas}
-
-
+
+
{copy.canvasDescription}
-
-
+
+
+ {creating ? (
+
+
+ {copy.creating}
+
+ ) : error ? (
+
+
+ {error}
+
+
void retryProject()}
+ className="mt-3 rounded-[8px] border border-white/20 px-4 py-2 text-sm hover:bg-white/10"
+ >
+ {copy.retry}
+
+
+ ) : null}
);
@@ -159,6 +211,8 @@ export function WorkspacePage({
}) {
const copy = getHistoryCopy(locale);
const [createOpen, setCreateOpen] = useState(false);
+ const [interactive, setInteractive] = useState(false);
+ useEffect(() => setInteractive(true), []);
const [deleteTarget, setDeleteTarget] =
useState(null);
const { projects: liveProjects, deleteProjects } = useWorkspaceProjects({
@@ -196,6 +250,7 @@ export function WorkspacePage({
setCreateOpen(true)}
+ disabled={!interactive}
className="inline-flex h-11 shrink-0 items-center justify-center gap-2 self-start rounded-[var(--beat-radius-sm)] bg-[var(--beat-accent)] px-5 text-[13px] font-semibold text-[var(--beat-accent-ink)] shadow-[0_8px_28px_rgba(255,122,51,0.22)] transition hover:bg-[#ff8a4d] lg:self-auto"
>
@@ -219,25 +274,18 @@ export function WorkspacePage({
aria-label={copy.openProject(name)}
className="block"
>
-
- {project.coverImageUrl ? (
-
- ) : (
-
-
-
- )}
+
{name}
+ {project.description &&
{project.description}
}
+
+
void removeProject(project)}
@@ -264,6 +312,7 @@ export function WorkspacePage({
setCreateOpen(true)}
+ disabled={!interactive}
className="mt-6 inline-flex h-10 items-center gap-2 rounded-[var(--beat-radius-sm)] bg-[var(--beat-accent)] px-4 text-[12px] font-semibold text-[var(--beat-accent-ink)] shadow-[0_8px_24px_rgba(255,122,51,0.24)] transition hover:bg-[#ff8a4d]"
>
@@ -278,6 +327,7 @@ export function WorkspacePage({
open={createOpen}
onOpenChange={setCreateOpen}
copy={copy}
+ locale={locale}
/>
\s*Create Here\s*);
assert.match(studioSource, /fetchProjectGenerations/);
assert.match(studioSource, /VIDEO_ANALYSIS_MODEL_ID/);
assert.match(composerSource, /value: 'analysis'/);
diff --git a/src/components/studio/studio-start-here.tsx b/src/components/studio/studio-start-here.tsx
index bdfdd65..d0077a3 100644
--- a/src/components/studio/studio-start-here.tsx
+++ b/src/components/studio/studio-start-here.tsx
@@ -1,11 +1,12 @@
import { useState } from 'react';
+import { useTranslations } from '@/core/workspace-lib/shims/next-intl';
import { cn } from '@/lib/utils';
type StudioStartHereIdea = {
className: string;
src: string;
- title: string;
+ titleKey: string;
};
const STUDIO_START_HERE_IDEAS: StudioStartHereIdea[] = [
@@ -13,35 +14,36 @@ const STUDIO_START_HERE_IDEAS: StudioStartHereIdea[] = [
className:
'left-0 top-[18px] size-[90px] rotate-[-8deg] rounded-[14px] sm:left-[22px] sm:top-[14px] sm:size-[132px]',
src: '/app/create-thumb-forest.webp',
- title: 'Cinematic landscape',
+ titleKey: 'ideas.cinematicLandscape',
},
{
className:
'left-[82px] top-[2px] size-[102px] rotate-[3deg] rounded-[17px] sm:left-[134px] sm:top-0 sm:size-[132px]',
src: '/app/create-thumb-character.webp',
- title: 'Editorial character',
+ titleKey: 'ideas.editorialCharacter',
},
{
className:
'left-[172px] top-[10px] size-[96px] rounded-full sm:left-[250px] sm:top-[4px] sm:size-[126px]',
src: '/app/create-thumb-texture.webp',
- title: 'Material study',
+ titleKey: 'ideas.materialStudy',
},
{
className:
'right-0 top-[16px] size-[98px] rotate-[6deg] rounded-[14px] sm:right-[6px] sm:top-[12px] sm:size-[128px]',
src: '/app/create-thumb-mask.webp',
- title: 'Surreal fashion film',
+ titleKey: 'ideas.surrealFashionFilm',
},
];
export function StudioStartHere() {
+ const t = useTranslations('AppShell.studio.startHere');
const [selectedSrc, setSelectedSrc] = useState(null);
return (
@@ -58,13 +60,14 @@ export function StudioStartHere() {
{STUDIO_START_HERE_IDEAS.map((idea) => {
const isSelected = selectedSrc === idea.src;
const hasSelection = selectedSrc !== null;
+ const title = t(idea.titleKey);
return (
setSelectedSrc(idea.src)}
className={cn(
@@ -88,10 +91,10 @@ export function StudioStartHere() {
})}
- Create Here
+ {t('title')}
- Imagine the scene. Shape the mood. Bring it to life.
+ {t('description')}
);
diff --git a/src/config/data-root.test.ts b/src/config/data-root.test.ts
index 5dda215..f2417a1 100644
--- a/src/config/data-root.test.ts
+++ b/src/config/data-root.test.ts
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
+import { resolve } from 'node:path';
import test from 'node:test';
import {
@@ -7,23 +8,25 @@ import {
} from './data-root';
test('uses the repository data directory by default', () => {
+ const repositoryRoot = resolve('fixtures/beatdesign-repository');
assert.equal(
- getBeatDesignDataRoot('/tmp/beatdesign-repository', undefined),
- '/tmp/beatdesign-repository/data'
+ getBeatDesignDataRoot(repositoryRoot, undefined),
+ resolve(repositoryRoot, 'data')
);
assert.equal(
- getBeatDesignDatabaseUrl('/tmp/beatdesign-repository', undefined),
+ getBeatDesignDatabaseUrl(repositoryRoot, undefined),
'file:data/local.db'
);
});
test('uses an explicit application-data directory for packaged runtimes', () => {
+ const configuredRoot = resolve('fixtures/beatdesign-user-data');
assert.equal(
- getBeatDesignDataRoot('/tmp/ignored', '/tmp/beatdesign-user-data'),
- '/tmp/beatdesign-user-data'
+ getBeatDesignDataRoot(resolve('fixtures/ignored'), configuredRoot),
+ configuredRoot
);
assert.equal(
- getBeatDesignDatabaseUrl('/tmp/ignored', '/tmp/beatdesign-user-data'),
- 'file:/tmp/beatdesign-user-data/local.db'
+ getBeatDesignDatabaseUrl(resolve('fixtures/ignored'), configuredRoot),
+ `file:${resolve(configuredRoot, 'local.db')}`
);
});
diff --git a/src/config/db/schema.sqlite.ts b/src/config/db/schema.sqlite.ts
index 3b2aebd..9c98b68 100644
--- a/src/config/db/schema.sqlite.ts
+++ b/src/config/db/schema.sqlite.ts
@@ -26,6 +26,10 @@ export const project = sqliteTable(
{
id: text('id').primaryKey(),
name: text('name').notNull(),
+ description: text('description').notNull().default(''),
+ // Legacy rows with covers also remain manual when upgraded via db:push.
+ coverMode: text('cover_mode').notNull().default('manual'),
+ settingsRevision: integer('settings_revision').notNull().default(1),
coverAssetId: text('cover_asset_id'),
status: text('status').notNull().default('active'),
currentStateVersion: integer('current_state_version').notNull().default(1),
diff --git a/src/config/paraglide.test.ts b/src/config/paraglide.test.ts
index f94641b..765d27a 100644
--- a/src/config/paraglide.test.ts
+++ b/src/config/paraglide.test.ts
@@ -14,7 +14,11 @@ test('the shared Paraglide config can prepare server imports before Vite starts'
const outdir = join(outputRoot, 'paraglide');
try {
- await symlink(resolve('node_modules'), join(outputRoot, 'node_modules'));
+ await symlink(
+ resolve('node_modules'),
+ join(outputRoot, 'node_modules'),
+ process.platform === 'win32' ? 'junction' : 'dir'
+ );
await compile({ ...paraglideCompilerOptions, outdir });
await Promise.all([
diff --git a/src/core/beatcanvas/generation-history.test.ts b/src/core/beatcanvas/generation-history.test.ts
index 028242a..db12546 100644
--- a/src/core/beatcanvas/generation-history.test.ts
+++ b/src/core/beatcanvas/generation-history.test.ts
@@ -50,6 +50,42 @@ const makeOutput = (
},
});
+for (const type of ['image', 'video'] as const) {
+ test(`${type} defaults to the newest usable take regardless of input order`, () => {
+ const older = { ...makeOutput('old', '2026-10-18T00:00:00.000Z'), type };
+ const newer = { ...makeOutput('new', '2026-10-18T00:01:00.000Z'), type };
+ const pending = { ...makeOutput('pending', '2026-10-18T00:02:00.000Z', 'processing'), type };
+ const empty = { ...makeOutput('empty', '2026-10-18T00:03:00.000Z'), type, url: null };
+ const failed = { ...makeOutput('failed', '2026-10-18T00:04:00.000Z', 'failed'), type };
+ for (const outputs of [[older, pending, newer, empty, failed], [failed, empty, newer, pending, older]]) {
+ const originalOrder = outputs.map((output) => output.id);
+ for (const pinnedOutputId of [null, 'missing', failed.id, empty.id, pending.id]) {
+ assert.equal(resolvePinnedGenerationOutputId({ outputs, pinnedOutputId }), newer.id);
+ const takes = buildGenerationTakes({ outputs, pinnedOutputId });
+ assert.equal(takes.find((take) => take.isPinned)?.id, newer.id);
+ assert.equal(takes.find((take) => take.id === newer.id)?.takeNumber, 2);
+ }
+ assert.equal(resolvePinnedGenerationOutputId({ outputs, pinnedOutputId: older.id }), older.id);
+ assert.deepEqual(outputs.map((output) => output.id), originalOrder);
+ }
+ const draft = { ...older, id: 'draft-1', kind: 'generation' as const, pinnedOutputId: null };
+ const cards = Object.fromEntries([draft, older, newer, pending, failed].map((card) => [card.id, card]));
+ assert.equal(resolveConcreteCanvasMediaCard({ cards, card: draft })?.id, newer.id);
+ });
+}
+
+test('fallback handles empty histories, only failed outputs and equal timestamps deterministically', () => {
+ const time = '2026-10-18T00:00:00.000Z';
+ assert.equal(resolvePinnedGenerationOutputId({ outputs: [] }), null);
+ assert.equal(resolvePinnedGenerationOutputId({ outputs: [makeOutput('failed', time, 'failed')] }), null);
+ for (const status of ['succeeded', 'pending'] as const) {
+ const a = makeOutput('a', time, status);
+ const b = makeOutput('b', time, status);
+ assert.equal(resolvePinnedGenerationOutputId({ outputs: [a, b] }), 'b');
+ assert.equal(resolvePinnedGenerationOutputId({ outputs: [b, a] }), 'b');
+ }
+});
+
test('resolves a generation node to its pinned concrete output asset', () => {
const older = makeOutput('output-old', '2026-08-16T00:00:00.000Z');
const newer = makeOutput('output-new', '2026-08-16T00:02:00.000Z');
diff --git a/src/core/beatcanvas/generation-history.ts b/src/core/beatcanvas/generation-history.ts
index 6cc7fbd..cbdca54 100644
--- a/src/core/beatcanvas/generation-history.ts
+++ b/src/core/beatcanvas/generation-history.ts
@@ -40,21 +40,26 @@ export const resolvePinnedGenerationOutputId = ({
outputs: CanvasOutputCard[];
pinnedOutputId?: string | null;
}) => {
+ const newestFirst = [...outputs]
+ .filter((output) => output.status !== 'failed')
+ .sort(
+ (left, right) =>
+ right.generationSnapshot.capturedAt.localeCompare(
+ left.generationSnapshot.capturedAt
+ ) || right.id.localeCompare(left.id)
+ );
+ const hasResult = (output: CanvasOutputCard) =>
+ output.status === 'succeeded' && Boolean(output.url || output.resultText);
if (
pinnedOutputId &&
- outputs.some((output) => output.id === pinnedOutputId)
+ newestFirst.some((output) => output.id === pinnedOutputId && hasResult(output))
) {
return pinnedOutputId;
}
return (
- outputs.find(
- (output) =>
- output.status === 'succeeded' &&
- Boolean(output.url || output.resultText)
- )
- ?.id ??
- outputs[0]?.id ??
+ newestFirst.find(hasResult)?.id ??
+ newestFirst[0]?.id ??
null
);
};
diff --git a/src/core/commands/asset-boundary.ts b/src/core/commands/asset-boundary.ts
index 1e51345..3a0a83a 100644
--- a/src/core/commands/asset-boundary.ts
+++ b/src/core/commands/asset-boundary.ts
@@ -39,7 +39,7 @@ export async function normalizeCommandAssetReferences({
projectId: string;
command: BeatDesignCommand;
}): Promise
{
- if (command.type === 'editor.validate') return command;
+ if (command.type === 'editor.validate' || command.type === 'project.update') return command;
if (command.type === 'canvas.apply') {
return {
diff --git a/src/core/commands/executor.ts b/src/core/commands/executor.ts
index 05532d7..0e99950 100644
--- a/src/core/commands/executor.ts
+++ b/src/core/commands/executor.ts
@@ -4,6 +4,7 @@ import {
type TimelineDocument,
} from '@/core/editor/timeline-document';
import type { ProjectSnapshotDocument } from '@/core/projects/project-snapshot';
+import type { ProjectSettings, ProjectSettingsPatch } from '@/core/projects/project-settings-contract';
import {
applyCanvasOperations,
@@ -23,6 +24,7 @@ import {
} from './contracts';
export type BeatDesignCommand =
+ | { type: 'project.update'; patch: ProjectSettingsPatch }
| { type: 'canvas.apply'; operations: CanvasOperation[] }
| { type: 'editor.apply'; operations: EditorOperation[] }
| { type: 'editor.replace_document'; document: TimelineDocument }
@@ -34,6 +36,7 @@ export type BeatDesignCommandDocuments = {
};
export type BeatDesignCommandData = BeatDesignCommandDocuments & {
+ projectSettings?: ProjectSettings;
diagnostics?: ReturnType;
};
@@ -47,6 +50,9 @@ export function executeBeatDesignCommand({
const { commandId, projectId, origin, command } = envelope;
try {
+ if (command.type === 'project.update') {
+ throw new BeatDesignCommandError('INVALID_COMMAND', 'Project settings commands require the persistence boundary.');
+ }
if (command.type === 'canvas.apply') {
if (!documents.canvas) {
throw new BeatDesignCommandError(
diff --git a/src/core/commands/persist.ts b/src/core/commands/persist.ts
index d569a5a..190a62a 100644
--- a/src/core/commands/persist.ts
+++ b/src/core/commands/persist.ts
@@ -2,6 +2,8 @@ import {
loadProjectTimeline,
saveProjectTimeline,
} from '@/core/editor/timeline-state';
+import { updateProjectSettings } from '@/core/projects/project-settings';
+import { getDb } from '@/core/workspace-lib/db-adapter';
import {
loadProjectWithLatestSnapshot,
saveProjectSnapshot,
@@ -13,6 +15,7 @@ import {
BeatDesignCommandError,
createCommandFailure,
createCommandId,
+ createCommandSuccess,
type BeatDesignCommandEnvelope,
type BeatDesignCommandOrigin,
type BeatDesignCommandResult,
@@ -28,6 +31,15 @@ import {
storeCommandReceipt,
} from './receipts';
+// Keep a settings transaction and its receipt together before another settings
+// command starts on libsql's shared connection. SQLite CAS still guards processes.
+let pendingSettingsCommand: Promise = Promise.resolve();
+function enqueueSettingsCommand(run: () => Promise): Promise {
+ const pending = pendingSettingsCommand.catch(() => undefined).then(run);
+ pendingSettingsCommand = pending;
+ return pending;
+}
+
const inFlightCommands = new Map<
string,
{
@@ -40,6 +52,7 @@ const inFlightCommands = new Map<
const isVersionConflict = (error: unknown) =>
error instanceof Error &&
(error.name === 'TimelineVersionConflict' ||
+ error.name === 'ProjectSettingsVersionConflict' ||
error.name === 'ProjectSnapshotVersionConflict');
const getConflictRevision = (error: unknown) => {
@@ -222,7 +235,23 @@ async function persistBeatDesignCommandOnce({
};
let result: BeatDesignCommandResult;
- if (normalizedCommand.type === 'canvas.apply') {
+ if (normalizedCommand.type === 'project.update') {
+ const db = await getDb();
+ // Settings and retry receipts must commit together, including across Web
+ // and MCP processes. A failed receipt must never leave an unacknowledged edit.
+ return await db.transaction(async (tx: any) => {
+ const cached = await loadCommandReceiptRecord({ projectId, idempotencyKey }, tx);
+ if (cached) {
+ if (cached.commandId !== commandId || cached.commandType !== normalizedCommand.type) {
+ throw new BeatDesignCommandError('INVALID_COMMAND', 'Idempotency key is already bound to a different command.');
+ }
+ return cached.result;
+ }
+ const settings = await updateProjectSettings({ projectId, patch: normalizedCommand.patch, expectedRevision, database: tx });
+ const saved = createCommandSuccess({ commandId, projectId, origin, changedIds: [projectId], revision: settings.settingsRevision, data: { projectSettings: settings } });
+ return storeCommandReceipt({ projectId, idempotencyKey, commandId, origin, commandType: normalizedCommand.type, result: saved }, tx);
+ });
+ } else if (normalizedCommand.type === 'canvas.apply') {
const state = await loadProjectWithLatestSnapshot({ projectId });
if (!state) {
return createCommandFailure({
@@ -319,7 +348,9 @@ async function persistBeatDesignCommandOnce({
code: 'REVISION_CONFLICT',
revision: getConflictRevision(error),
message:
- command.type === 'canvas.apply'
+ command.type === 'project.update'
+ ? 'Project settings changed. Reload before saving.'
+ : command.type === 'canvas.apply'
? 'Project snapshot version conflict'
: 'Timeline version conflict',
});
@@ -412,14 +443,15 @@ export function persistBeatDesignCommand({
return existing.promise;
}
- const promise = persistBeatDesignCommandOnce({
+ const run = () => persistBeatDesignCommandOnce({
projectId,
origin,
commandId,
expectedRevision,
idempotencyKey: stableIdempotencyKey,
command,
- }).finally(() => {
+ });
+ const promise = (command.type === 'project.update' ? enqueueSettingsCommand(run) : run()).finally(() => {
if (inFlightCommands.get(lockKey)?.promise === promise) {
inFlightCommands.delete(lockKey);
}
diff --git a/src/core/commands/receipts.ts b/src/core/commands/receipts.ts
index cf44cc9..3cdac7a 100644
--- a/src/core/commands/receipts.ts
+++ b/src/core/commands/receipts.ts
@@ -17,13 +17,13 @@ const MAX_RECEIPTS_PER_PROJECT = 2_000;
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
const lastCleanupByProject = new Map();
-async function cleanupCommandReceipts(projectId: string) {
+async function cleanupCommandReceipts(projectId: string, database?: any) {
const now = Date.now();
const lastCleanup = lastCleanupByProject.get(projectId) ?? 0;
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
lastCleanupByProject.set(projectId, now);
- const db = await getDb();
+ const db = database ?? await getDb();
const overflow = await db
.select({ id: projectCommandReceipt.id })
.from(projectCommandReceipt)
@@ -90,8 +90,8 @@ export async function loadCommandReceiptRecord({
}: {
projectId: string;
idempotencyKey: string;
-}) {
- const db = await getDb();
+}, database?: any) {
+ const db = database ?? await getDb();
const rows = await db
.select({
commandId: projectCommandReceipt.commandId,
@@ -130,8 +130,8 @@ export async function storeCommandReceipt({
origin: string;
commandType: string;
result: StoredCommandResult;
-}) {
- const db = await getDb();
+}, database?: any) {
+ const db = database ?? await getDb();
await db
.insert(projectCommandReceipt)
.values({
@@ -145,8 +145,8 @@ export async function storeCommandReceipt({
createdAt: new Date(),
})
.onConflictDoNothing();
- await cleanupCommandReceipts(projectId);
- const stored = await loadCommandReceiptRecord({ projectId, idempotencyKey });
+ await cleanupCommandReceipts(projectId, db);
+ const stored = await loadCommandReceiptRecord({ projectId, idempotencyKey }, db);
if (
stored &&
(stored.commandId !== commandId || stored.commandType !== commandType)
diff --git a/src/core/commands/schema.ts b/src/core/commands/schema.ts
index aed5125..996a206 100644
--- a/src/core/commands/schema.ts
+++ b/src/core/commands/schema.ts
@@ -4,6 +4,7 @@ import type { CanvasCard } from '@/core/beatcanvas/canvas-types';
import { normalizeTimelineDocument } from '@/core/editor/timeline-document';
import { repairCanvasCardInput } from './canvas-card-repair';
+import { projectSettingsPatchSchema } from '@/core/projects/project-settings-contract';
import type { BeatDesignCommand } from './executor';
const commandIdSchema = z.string().trim().min(1).max(200);
@@ -443,6 +444,7 @@ export const editorOperationSchema = z.discriminatedUnion('type', [
export const beatDesignCommandSchema: z.ZodType =
z.discriminatedUnion('type', [
+ z.object({ type: z.literal('project.update'), patch: projectSettingsPatchSchema }).strict(),
z
.object({
type: z.literal('canvas.apply'),
diff --git a/src/core/db/sqlite.test.ts b/src/core/db/sqlite.test.ts
index 732036a..9dfcd5a 100644
--- a/src/core/db/sqlite.test.ts
+++ b/src/core/db/sqlite.test.ts
@@ -20,6 +20,11 @@ test('local SQLite uses WAL and a busy timeout on each connection', async () =>
assert.equal((await clients[1].execute('SELECT id FROM test')).rows[0].id, 1);
} finally {
clients.forEach((client) => client.close());
- await rm(directory, { recursive: true, force: true });
+ await rm(directory, {
+ recursive: true,
+ force: true,
+ maxRetries: 20,
+ retryDelay: 100,
+ });
}
});
diff --git a/src/core/db/sqlite.ts b/src/core/db/sqlite.ts
index 5025f71..0ac3792 100644
--- a/src/core/db/sqlite.ts
+++ b/src/core/db/sqlite.ts
@@ -1,14 +1,72 @@
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
+import { setTimeout as delay } from 'node:timers/promises';
import type { DbConfig } from './types';
// SQLite/libsql singleton
let sqliteDbInstance: Promise> | null = null;
+let sqliteClientInstance: ReturnType | null = null;
-export async function configureLocalSqlite(client: ReturnType) {
- await client.execute('PRAGMA busy_timeout = 5000');
- await client.execute('PRAGMA journal_mode = WAL');
+export async function configureLocalSqlite(
+ client: ReturnType,
+) {
+ // Keep startup lock waits short: synchronous libsql busy waits would otherwise
+ // block another Web/MCP connection from finishing its migration in this process.
+ await client.execute('PRAGMA busy_timeout = 50');
+ try {
+ for (let attempt = 0; attempt < 6; attempt++) {
+ try {
+ await client.execute('PRAGMA journal_mode = WAL');
+ const columns = await client.execute('PRAGMA table_info(project)');
+ const names = new Set(columns.rows.map((row) => String(row.name)));
+ if (
+ !names.size ||
+ ['description', 'settings_revision', 'cover_mode'].every((name) =>
+ names.has(name),
+ )
+ )
+ break;
+ const tx = await client.transaction('write');
+ try {
+ // Another process may have upgraded while this connection was waiting.
+ const latest = await tx.execute('PRAGMA table_info(project)');
+ const existing = new Set(latest.rows.map((row) => String(row.name)));
+ if (!existing.has('description'))
+ await tx.execute(
+ "ALTER TABLE project ADD COLUMN description TEXT NOT NULL DEFAULT ''",
+ );
+ if (!existing.has('settings_revision'))
+ await tx.execute(
+ 'ALTER TABLE project ADD COLUMN settings_revision INTEGER NOT NULL DEFAULT 1',
+ );
+ if (!existing.has('cover_mode')) {
+ await tx.execute(
+ "ALTER TABLE project ADD COLUMN cover_mode TEXT NOT NULL DEFAULT 'manual'",
+ );
+ await tx.execute(
+ "UPDATE project SET cover_mode = 'auto' WHERE cover_asset_id IS NULL",
+ );
+ }
+ await tx.commit();
+ } catch (error) {
+ await tx.rollback();
+ throw error;
+ }
+ break;
+ } catch (error) {
+ if (
+ (error as { code?: string }).code !== 'SQLITE_BUSY' ||
+ attempt === 5
+ )
+ throw error;
+ await delay(50 * (attempt + 1));
+ }
+ }
+ } finally {
+ // libsql can return a different pooled connection after a transaction.
+ await client.execute('PRAGMA busy_timeout = 5000');
+ }
}
export async function createSqliteDb(config: DbConfig) {
@@ -19,10 +77,24 @@ export async function createSqliteDb(config: DbConfig) {
if (sqliteDbInstance) return sqliteDbInstance;
const client = createClient({ url: databaseUrl });
- sqliteDbInstance = configureLocalSqlite(client).then(() => drizzle({ client })).catch((error) => {
- client.close();
- sqliteDbInstance = null;
- throw error;
- });
+ sqliteClientInstance = client;
+ sqliteDbInstance = configureLocalSqlite(client)
+ .then(() => drizzle({ client }))
+ .catch((error) => {
+ client.close();
+ sqliteDbInstance = null;
+ sqliteClientInstance = null;
+ throw error;
+ });
return sqliteDbInstance;
}
+
+/** Release the local connection before test cleanup or runtime shutdown. */
+export async function closeSqliteDb() {
+ const pendingDb = sqliteDbInstance;
+ const client = sqliteClientInstance;
+ sqliteDbInstance = null;
+ sqliteClientInstance = null;
+ await pendingDb?.catch(() => undefined);
+ client?.close();
+}
diff --git a/src/core/editor/render-project-timeline.ts b/src/core/editor/render-project-timeline.ts
index b3524fc..930c326 100644
--- a/src/core/editor/render-project-timeline.ts
+++ b/src/core/editor/render-project-timeline.ts
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
+import { buildTimelineAudioMixFilter } from './timeline-audio-filter';
import { getResolvedCaptionStyle } from './captions';
import {
@@ -363,8 +364,7 @@ async function buildFfmpegArgs({
}
if (audioLabels.length > 0) {
filters.push(
- `${audioLabels.join('')}amix=inputs=${audioLabels.length}:duration=longest:dropout_transition=0,` +
- `atrim=duration=${time(document.duration)},apad=whole_dur=${time(document.duration)}[aout]`
+ `${audioLabels.join('')}${buildTimelineAudioMixFilter(audioLabels.length, document.duration)}[aout]`
);
}
diff --git a/src/core/editor/timeline-audio-filter.test.ts b/src/core/editor/timeline-audio-filter.test.ts
new file mode 100644
index 0000000..08565be
--- /dev/null
+++ b/src/core/editor/timeline-audio-filter.test.ts
@@ -0,0 +1,36 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { test } from 'node:test';
+import { buildTimelineAudioMixFilter } from './timeline-audio-filter';
+
+const ffmpeg = process.env.BEATDESIGN_FFMPEG?.trim() || 'ffmpeg';
+const available = spawnSync(ffmpeg,['-version'],{stdio:'ignore'}).status === 0;
+function render(count:number, overlap=false) {
+ const args=['-hide_banner','-loglevel','error'];
+ for(let i=0;i`[${i}:a]volume=${overlap?10:1},adelay=${overlap?0:i*1000}:all=1[a${i}]`);
+ filters.push(Array.from({length:count},(_,i)=>`[a${i}]`).join('')+buildTimelineAudioMixFilter(count,overlap?1:count)+'[out]');
+ args.push('-filter_complex',filters.join(';'),'-map','[out]','-ac','1','-c:a','pcm_f32le','-f','f32le','pipe:1');
+ const result=spawnSync(ffmpeg,args,{maxBuffer:4*1024*1024});
+ assert.equal(result.status,0,result.stderr?.toString());
+ const samples=new Float32Array(result.stdout.buffer.slice(result.stdout.byteOffset,result.stdout.byteOffset+result.stdout.byteLength));
+ return samples;
+}
+function rms(samples:Float32Array,start:number,end:number) {
+ let sum=0;for(let i=start;i{
+ const one=render(1),three=render(3),expected=rms(one,4800,43200);
+ assert.ok(expected>0.08);
+ for(let i=0;i<3;i++)assert.ok(Math.abs(rms(three,48000*i+4800,48000*i+43200)-expected)<0.0001);
+ assert.equal(three.length,3*48000);
+});
+
+test('overlapping loud clips are limited without automatic makeup or timeline delay',{skip:!available},()=>{
+ const samples=render(2,true);
+ let peak=0;for(const sample of samples)peak=Math.max(peak,Math.abs(sample));
+ assert.ok(peak<=0.951 && peak>0.90,`Peak was ${peak}`);
+ assert.equal(samples.length,48000);
+});
diff --git a/src/core/editor/timeline-audio-filter.ts b/src/core/editor/timeline-audio-filter.ts
new file mode 100644
index 0000000..f866e08
--- /dev/null
+++ b/src/core/editor/timeline-audio-filter.ts
@@ -0,0 +1,10 @@
+/** Preserve clip gains independently of silent/delayed timeline inputs. */
+export function buildTimelineAudioMixFilter(inputCount: number, duration: number) {
+ if (!Number.isSafeInteger(inputCount) || inputCount < 1 || !Number.isFinite(duration) || duration <= 0)
+ throw new Error('Audio mixing requires inputs and a positive timeline duration.');
+ const seconds = duration.toFixed(3);
+ return `amix=inputs=${inputCount}:duration=longest:dropout_transition=0:normalize=0,` +
+ // Disable the limiter's automatic makeup gain; compensate its lookahead.
+ 'alimiter=limit=0.95:level=0:latency=1,' +
+ `atrim=duration=${seconds},apad=whole_dur=${seconds}`;
+}
diff --git a/src/core/generation-providers/beatapi-provider.ts b/src/core/generation-providers/beatapi-provider.ts
index 87e77c8..564fa8c 100644
--- a/src/core/generation-providers/beatapi-provider.ts
+++ b/src/core/generation-providers/beatapi-provider.ts
@@ -1,3 +1,4 @@
+import { createServerOnlyFn } from '@tanstack/react-start';
import {
BeatApiAdapter,
validateBeatApiTaskInput,
@@ -50,17 +51,17 @@ export const beatApiGenerationProvider: GenerationProviderDefinition = {
imageBucketName,
})
),
- createAdapter: (effect) => new BeatApiAdapter(effect),
- assertConfigured: async () => {
+ createAdapter: createServerOnlyFn((effect) => new BeatApiAdapter(effect)),
+ assertConfigured: createServerOnlyFn(async () => {
if (!(await getConfig('BEATAPI_API_KEY'))) {
throw new Error('Connect a BeatAPI API key before generating.');
}
- },
- validateInput: (effect, input) => {
+ }),
+ validateInput: createServerOnlyFn((effect, input) => {
validateBeatApiTaskInput({
effectType: effect.type,
model: effect.model,
input,
});
- },
+ }),
};
diff --git a/src/core/generation-providers/submit.integration.test.ts b/src/core/generation-providers/submit.integration.test.ts
index bd75372..633a9b1 100644
--- a/src/core/generation-providers/submit.integration.test.ts
+++ b/src/core/generation-providers/submit.integration.test.ts
@@ -2,13 +2,26 @@ import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
-import { join } from 'node:path';
+import { join, resolve } from 'node:path';
import { after, test } from 'node:test';
const directory = await mkdtemp(join(tmpdir(), 'beatdesign-generation-test-'));
process.env.BEATDESIGN_DATA_DIR = directory;
-execFileSync('pnpm', ['exec', 'drizzle-kit', 'push', '--force'], { env: process.env, stdio: 'pipe' });
-after(async () => { await rm(directory, { recursive: true, force: true }); });
+execFileSync(
+ process.execPath,
+ [resolve('node_modules/drizzle-kit/bin.cjs'), 'push', '--force'],
+ { env: process.env, stdio: 'pipe' }
+);
+const { closeSqliteDb } = await import('@/core/db/sqlite');
+after(async () => {
+ await closeSqliteDb();
+ await rm(directory, {
+ recursive: true,
+ force: true,
+ maxRetries: 20,
+ retryDelay: 100,
+ });
+});
const { createProject } = await import('@/core/projects/projects');
const { createAssetFirstGenerationIntent, preflightAssetFirstGeneration, submitAssetFirstGeneration } = await import('./submit');
diff --git a/src/core/media/video-thumbnail-cache.test.ts b/src/core/media/video-thumbnail-cache.test.ts
new file mode 100644
index 0000000..0382063
--- /dev/null
+++ b/src/core/media/video-thumbnail-cache.test.ts
@@ -0,0 +1,73 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import test from 'node:test';
+import { getCachedVideoThumbnail } from './video-thumbnail-cache';
+
+const jpeg = Uint8Array.from([0xff, 0xd8, 1, 2, 0xff, 0xd9]);
+
+test('poster cache persists once, coalesces requests, and invalidates changed sources', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'beatdesign-posters-'));
+ try {
+ const filePath = join(root, 'source.mp4');
+ await writeFile(filePath, 'source');
+ let calls = 0;
+ const render = async () => { calls++; return jpeg; };
+ const options = { filePath, assetId: 'video-a', cacheRoot: join(root, 'cache'), render };
+ const first = await Promise.all(Array.from({ length: 5 }, () => getCachedVideoThumbnail(options)));
+ assert.equal(calls, 1);
+ assert.equal(new Set(first.map((result) => result.key)).size, 1);
+ const reused = await getCachedVideoThumbnail({ ...options, render: async () => { throw new Error('must use disk cache'); } });
+ assert.equal(reused.cached, true);
+ assert.deepEqual(reused.bytes, jpeg);
+ await writeFile(filePath, 'changed source content');
+ const changed = await getCachedVideoThumbnail(options);
+ assert.notEqual(changed.key, first[0].key);
+ assert.equal(calls, 2);
+ assert.ok((await readdir(options.cacheRoot)).every((file) => file.endsWith('.jpg')));
+ } finally { await rm(root, { recursive: true, force: true }); }
+});
+
+test('failed thumbnail jobs do not poison the cache or leave in-flight work behind', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'beatdesign-posters-fail-'));
+ try {
+ const filePath = join(root, 'source.mp4');
+ await writeFile(filePath, 'source');
+ const options = { filePath, assetId: 'video-a', cacheRoot: join(root, 'cache') };
+ await assert.rejects(getCachedVideoThumbnail({ ...options, render: async () => { throw new Error('decoder unavailable'); } }), /decoder unavailable/);
+ const retry = await getCachedVideoThumbnail({ ...options, render: async () => jpeg });
+ assert.deepEqual(retry.bytes, jpeg);
+ } finally { await rm(root, { recursive: true, force: true }); }
+});
+
+test('poster decoding is limited to two jobs even for different videos', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'beatdesign-posters-queue-'));
+ try {
+ const filePath = join(root, 'source.mp4');
+ await writeFile(filePath, 'source');
+ let active = 0;
+ let peak = 0;
+ const render = async () => {
+ peak = Math.max(peak, ++active);
+ await new Promise((done) => setTimeout(done, 20));
+ active--;
+ return jpeg;
+ };
+ await Promise.all(Array.from({ length: 5 }, (_, index) => getCachedVideoThumbnail({ filePath, assetId: `video-${index}`, cacheRoot: root, render })));
+ assert.equal(peak, 2);
+ assert.equal(active, 0);
+ } finally { await rm(root, { recursive: true, force: true }); }
+});
+
+const hasFfmpeg = spawnSync(process.env.BEATDESIGN_FFMPEG?.trim() || 'ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0;
+test('real video decoder produces a small cached JPEG without ffprobe', { skip: !hasFfmpeg }, async () => {
+ const root = await mkdtemp(join(tmpdir(), 'beatdesign-posters-real-'));
+ try {
+ const poster = await getCachedVideoThumbnail({ filePath: resolve('tests/fixtures/tiny-black.mp4'), assetId: 'real-video', cacheRoot: root });
+ assert.deepEqual(Array.from(poster.bytes.slice(0, 2)), [0xff, 0xd8]);
+ assert.ok(poster.bytes.length < 100_000);
+ assert.equal((await getCachedVideoThumbnail({ filePath: resolve('tests/fixtures/tiny-black.mp4'), assetId: 'real-video', cacheRoot: root })).cached, true);
+ } finally { await rm(root, { recursive: true, force: true }); }
+});
diff --git a/src/core/media/video-thumbnail-cache.ts b/src/core/media/video-thumbnail-cache.ts
new file mode 100644
index 0000000..8798376
--- /dev/null
+++ b/src/core/media/video-thumbnail-cache.ts
@@ -0,0 +1,93 @@
+import { spawn } from 'node:child_process';
+import { createHash, randomUUID } from 'node:crypto';
+import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { getBeatDesignDataRoot } from '@/config/data-root';
+
+const MAX_CONCURRENT_DECODES = 2;
+const inFlight = new Map>();
+const waiting: Array<() => void> = [];
+let activeDecodes = 0;
+
+async function withDecoderSlot(work: () => Promise) {
+ if (activeDecodes >= MAX_CONCURRENT_DECODES) {
+ await new Promise((resolve) => waiting.push(resolve));
+ } else activeDecodes++;
+ try { return await work(); }
+ finally {
+ const next = waiting.shift();
+ if (next) next();
+ else activeDecodes--;
+ }
+}
+
+/** Optional server decoder: lack of ffmpeg never blocks browser playback. */
+export function renderVideoThumbnail(filePath: string, timeSeconds: number) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.env.BEATDESIGN_FFMPEG?.trim() || 'ffmpeg', [
+ '-hide_banner', '-loglevel', 'error', '-nostdin', '-threads', '1',
+ '-protocol_whitelist', 'file,pipe', '-ss', String(timeSeconds), '-i', filePath,
+ '-map', '0:v:0', '-frames:v', '1', '-vf',
+ 'scale=512:512:force_original_aspect_ratio=decrease',
+ '-q:v', '4', '-f', 'image2pipe', '-c:v', 'mjpeg', 'pipe:1',
+ ], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
+ const chunks: Buffer[] = [];
+ let length = 0;
+ let stderr = '';
+ const timer = setTimeout(() => {
+ child.kill();
+ reject(new Error('Video thumbnail decode timed out'));
+ }, 15_000);
+ child.stdout.on('data', (chunk: Buffer) => {
+ length += chunk.length;
+ if (length > 2 * 1024 * 1024) {
+ child.kill();
+ reject(new Error('Video thumbnail is too large'));
+ } else chunks.push(chunk);
+ });
+ child.stderr.on('data', (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-2000); });
+ child.once('error', (error) => { clearTimeout(timer); reject(error); });
+ child.once('close', (code) => {
+ clearTimeout(timer);
+ const bytes = Buffer.concat(chunks);
+ if (code === 0 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes.at(-2) === 0xff && bytes.at(-1) === 0xd9) resolve(bytes);
+ else reject(new Error(stderr || 'Video thumbnail decode failed'));
+ });
+ });
+}
+
+/** A disposable derived-file cache; never changes a Project or its original Asset. */
+export async function getCachedVideoThumbnail({
+ filePath, assetId, durationMs, cacheRoot = join(getBeatDesignDataRoot(), 'cache', 'video-posters'),
+ render = renderVideoThumbnail,
+}: {
+ filePath: string;
+ assetId: string;
+ durationMs?: number | null;
+ cacheRoot?: string;
+ render?: typeof renderVideoThumbnail;
+}) {
+ const source = await stat(filePath);
+ if (!source.isFile()) throw new Error('Video file is missing');
+ const key = createHash('sha256').update(JSON.stringify(['poster-v1', assetId, filePath, source.size, source.mtimeMs, durationMs ?? null])).digest('hex');
+ const target = join(cacheRoot, `${key}.jpg`);
+ try { return { bytes: new Uint8Array(await readFile(target)), key, cached: true }; }
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
+ let pending = inFlight.get(target);
+ if (!pending) {
+ pending = withDecoderSlot(async () => {
+ const seconds = durationMs && durationMs > 0 ? Math.min(1, Math.max(0, durationMs / 1000 - 0.05)) : 0;
+ const bytes = await render(filePath, seconds);
+ await mkdir(cacheRoot, { recursive: true });
+ const temporary = `${target}.${randomUUID()}.tmp`;
+ try {
+ await writeFile(temporary, bytes, { flag: 'wx' });
+ await rename(temporary, target);
+ } finally { await unlink(temporary).catch(() => undefined); }
+ return bytes;
+ });
+ inFlight.set(target, pending);
+ }
+ try { return { bytes: await pending, key, cached: false }; }
+ finally { if (inFlight.get(target) === pending) inFlight.delete(target); }
+}
diff --git a/src/core/projects/local-project-assets.test.ts b/src/core/projects/local-project-assets.test.ts
index 756040f..35ff683 100644
--- a/src/core/projects/local-project-assets.test.ts
+++ b/src/core/projects/local-project-assets.test.ts
@@ -63,7 +63,11 @@ test('rejects symlinked project asset directories', async () => {
const root = await mkdtemp(join(tmpdir(), 'beatapi-project-assets-root-'));
const outside = await mkdtemp(join(tmpdir(), 'beatapi-project-assets-outside-'));
try {
- await symlink(outside, join(root, 'project-1'));
+ await symlink(
+ outside,
+ join(root, 'project-1'),
+ process.platform === 'win32' ? 'junction' : 'dir'
+ );
assert.throws(
() =>
resolveLocalProjectAssetPath({
diff --git a/src/core/projects/project-entry.ts b/src/core/projects/project-entry.ts
index cc48fd9..d38f6dc 100644
--- a/src/core/projects/project-entry.ts
+++ b/src/core/projects/project-entry.ts
@@ -150,6 +150,7 @@ export function serializeProjectCenterCard(project: {
lastOpenedAt?: Date | null;
updatedAt: Date;
coverImageUrl?: string | null;
+ description?: string;
}) {
const activityAt = getProjectActivityDate(project);
@@ -160,5 +161,6 @@ export function serializeProjectCenterCard(project: {
activityAt: activityAt.toISOString(),
activityLabel: formatProjectActivityDate(activityAt),
coverImageUrl: project.coverImageUrl ?? null,
+ description: project.description ?? '',
};
}
diff --git a/src/core/projects/project-settings-contract.ts b/src/core/projects/project-settings-contract.ts
new file mode 100644
index 0000000..38ab657
--- /dev/null
+++ b/src/core/projects/project-settings-contract.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+
+export const projectSettingsPatchSchema = z
+ .object({
+ name: z.string().trim().min(1).max(240).optional(),
+ description: z.string().trim().max(140).optional(),
+ cover: z
+ .discriminatedUnion('mode', [
+ z.object({ mode: z.literal('auto') }).strict(),
+ z
+ .object({
+ mode: z.literal('manual'),
+ assetId: z.string().trim().min(1).max(200),
+ })
+ .strict(),
+ ])
+ .optional(),
+ })
+ .strict()
+ .refine(
+ (value) => Object.keys(value).length > 0,
+ 'Include at least one setting.',
+ );
+
+export type ProjectSettingsPatch = z.infer;
+export type ProjectSettings = {
+ id: string;
+ name: string;
+ description: string;
+ coverMode: 'auto' | 'manual';
+ coverAssetId: string | null;
+ coverImageUrl: string | null;
+ automaticCoverImageUrl: string | null;
+ settingsRevision: number;
+ createdAt: string;
+};
diff --git a/src/core/projects/project-settings.test.ts b/src/core/projects/project-settings.test.ts
new file mode 100644
index 0000000..a8b9b1c
--- /dev/null
+++ b/src/core/projects/project-settings.test.ts
@@ -0,0 +1,208 @@
+import assert from 'node:assert/strict';
+import { mkdtemp } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { execFileSync } from 'node:child_process';
+import test from 'node:test';
+import { createClient } from '@libsql/client';
+import { configureLocalSqlite } from '@/core/db/sqlite';
+import { projectSettingsPatchSchema } from './project-settings-contract';
+
+test('settings validation rejects empty updates, blank names, long descriptions and arbitrary URLs', () => {
+ for (const patch of [
+ {},
+ { name: ' ' },
+ { description: 'a'.repeat(141) },
+ { cover: { mode: 'manual', url: 'https://example.com' } },
+ { slug: 'new' },
+ ]) {
+ assert.equal(projectSettingsPatchSchema.safeParse(patch).success, false);
+ }
+ assert.deepEqual(
+ projectSettingsPatchSchema.parse({ description: ' Film ' }),
+ { description: 'Film' },
+ );
+});
+
+test('additive SQLite upgrade preserves legacy covers and is repeatable', async () => {
+ const directory = await mkdtemp(
+ join(tmpdir(), 'beatdesign-settings-migration-'),
+ );
+ const client = createClient({ url: `file:${directory}/migration.db` });
+ try {
+ await client.execute(
+ 'CREATE TABLE project (id TEXT PRIMARY KEY, cover_asset_id TEXT)',
+ );
+ await client.execute(
+ "INSERT INTO project VALUES ('legacy', 'poster'), ('empty', NULL)",
+ );
+ const second = createClient({ url: `file:${directory}/migration.db` });
+ try {
+ await Promise.all([
+ configureLocalSqlite(client),
+ configureLocalSqlite(second),
+ ]);
+ } finally {
+ second.close();
+ }
+ await configureLocalSqlite(client);
+ const rows = (await client.execute('SELECT * FROM project ORDER BY id'))
+ .rows;
+ assert.equal(rows[0].cover_mode, 'auto');
+ assert.equal(rows[1].cover_mode, 'manual');
+ assert.equal(rows[1].cover_asset_id, 'poster');
+ assert.equal(rows[1].settings_revision, 1);
+ assert.equal(rows[1].description, '');
+ } finally {
+ client.close();
+ }
+});
+
+test('project settings persist through the command kernel, lock covers and reject stale or foreign writes', async () => {
+ const directory = await mkdtemp(
+ join(tmpdir(), 'beatdesign-project-settings-'),
+ );
+ process.env.BEATDESIGN_DATA_DIR = directory;
+ execFileSync(
+ process.execPath,
+ ['node_modules/drizzle-kit/bin.cjs', 'push', '--force'],
+ { env: process.env, stdio: 'pipe' },
+ );
+ const { createProject, loadProjects, deleteProjects } = await import(
+ './projects'
+ );
+ const { getProjectSettings } = await import('./project-settings');
+ const { persistBeatDesignCommand } = await import('@/core/commands/persist');
+ const { recordUserAsset, linkProjectAsset, deleteUserAssetById } =
+ await import('@/core/workspace-lib/assets/user-assets');
+ const first = await createProject({ name: 'Original' });
+ const other = await createProject({ name: 'Other' });
+ async function asset(
+ id: string,
+ projectId = first.id,
+ type: 'image' | 'video' = 'image',
+ ) {
+ await recordUserAsset({
+ id,
+ type,
+ source: 'upload',
+ bucket: 'local-project-assets',
+ objectKey: id,
+ publicUrl: `/media/${id}`,
+ filename: `${id}.png`,
+ });
+ await linkProjectAsset({ projectId, assetId: id, role: 'upload' });
+ return id;
+ }
+ const update = (patch: any, revision: number, commandId: string) =>
+ persistBeatDesignCommand({
+ projectId: first.id,
+ origin: 'mcp',
+ commandId,
+ expectedRevision: revision,
+ command: { type: 'project.update', patch },
+ });
+ await asset('a-first');
+ await asset('b-poster');
+ assert.equal((await getProjectSettings(first.id))?.coverAssetId, 'a-first');
+ const saved = await update(
+ {
+ name: 'Film',
+ description: 'A short film',
+ cover: { mode: 'manual', assetId: 'b-poster' },
+ },
+ 1,
+ 'save-settings',
+ );
+ assert.equal(saved.ok, true);
+ assert.equal(saved.revision, 2);
+ assert.deepEqual(
+ await update(
+ {
+ name: 'Film',
+ description: 'A short film',
+ cover: { mode: 'manual', assetId: 'b-poster' },
+ },
+ 1,
+ 'save-settings',
+ ),
+ saved,
+ );
+ await asset('c-later');
+ await linkProjectAsset({
+ projectId: first.id,
+ assetId: 'c-later',
+ role: 'generated',
+ });
+ assert.equal((await getProjectSettings(first.id))?.coverAssetId, 'b-poster');
+ const stale = await update({ name: 'Lost update' }, 1, 'stale-save');
+ assert.equal(stale.ok, false);
+ if (!stale.ok) assert.equal(stale.code, 'REVISION_CONFLICT');
+ await asset('foreign', other.id);
+ await asset('video', first.id, 'video');
+ for (const id of ['foreign', 'video', 'missing']) {
+ const invalid = await update(
+ { cover: { mode: 'manual', assetId: id } },
+ 2,
+ `invalid-${id}`,
+ );
+ assert.equal(invalid.ok, false);
+ if (!invalid.ok) assert.equal(invalid.code, 'INVALID_COMMAND');
+ }
+ assert.equal((await getProjectSettings(first.id))?.settingsRevision, 2);
+ assert.equal(
+ (await update({ cover: { mode: 'auto' } }, 2, 'auto-cover')).ok,
+ true,
+ );
+ assert.equal((await getProjectSettings(first.id))?.coverAssetId, 'a-first');
+ assert.equal(
+ (
+ await update(
+ { cover: { mode: 'manual', assetId: 'b-poster' } },
+ 3,
+ 'manual-again',
+ )
+ ).ok,
+ true,
+ );
+ await deleteUserAssetById('b-poster');
+ const fallback = await getProjectSettings(first.id);
+ assert.equal(fallback?.coverMode, 'auto');
+ assert.equal(fallback?.coverAssetId, 'a-first');
+ const listed = (await loadProjects({})).find(
+ (item: any) => item.id === first.id,
+ );
+ assert.equal(listed?.coverImageUrl, '/media/a-first');
+ assert.equal(listed?.description, 'A short film');
+ assert.equal(listed?.name, 'Film');
+ const concurrent = await Promise.all([
+ update({ description: 'Concurrent A' }, 4, 'concurrent-a'),
+ update({ description: 'Concurrent B' }, 4, 'concurrent-b'),
+ ]);
+ assert.equal(concurrent.filter((result) => result.ok).length, 1);
+ const rejected = concurrent.find((result) => !result.ok);
+ assert.equal(
+ rejected && !rejected.ok ? rejected.code : null,
+ 'REVISION_CONFLICT',
+ );
+ const { getDb } = await import('@/core/workspace-lib/db-adapter');
+ const { sql } = await import('drizzle-orm');
+ const db = await getDb();
+ const beforeFailure = await getProjectSettings(first.id);
+ await db.run(sql.raw("CREATE TRIGGER fail_settings_receipt BEFORE INSERT ON project_command_receipt WHEN NEW.command_id = 'receipt-failure' BEGIN SELECT RAISE(ABORT, 'receipt unavailable'); END"));
+ try {
+ const failed = await update({ name: 'Must roll back' }, beforeFailure!.settingsRevision, 'receipt-failure');
+ assert.equal(failed.ok, false);
+ assert.deepEqual(await getProjectSettings(first.id), beforeFailure);
+ } finally {
+ await db.run(sql.raw('DROP TRIGGER fail_settings_receipt'));
+ }
+ const retried = await update({ name: 'Recovered save' }, beforeFailure!.settingsRevision, 'receipt-failure');
+ assert.equal(retried.ok, true);
+ assert.deepEqual(await update({ name: 'Recovered save' }, beforeFailure!.settingsRevision, 'receipt-failure'), retried);
+ await deleteProjects({ projectIds: [first.id] });
+ assert.equal(await getProjectSettings(first.id), null);
+ const deleted = await update({ name: 'Restore' }, 4, 'deleted-save');
+ assert.equal(deleted.ok, false);
+ if (!deleted.ok) assert.equal(deleted.code, 'NOT_FOUND');
+});
diff --git a/src/core/projects/project-settings.ts b/src/core/projects/project-settings.ts
new file mode 100644
index 0000000..7c76b5e
--- /dev/null
+++ b/src/core/projects/project-settings.ts
@@ -0,0 +1,204 @@
+import { and, asc, eq, inArray, or, sql } from 'drizzle-orm';
+import { project, projectAssetMembership, userAsset } from '@/config/db/schema';
+import { getDb } from '@/core/workspace-lib/db-adapter';
+import { BeatDesignCommandError } from '@/core/commands/contracts';
+import {
+ projectSettingsPatchSchema,
+ type ProjectSettings,
+} from './project-settings-contract';
+import { LOCAL_PROJECT_ASSET_PROVIDER } from './local-project-assets';
+
+export async function resolveProjectCover(
+ db: any,
+ row: { id: string; coverMode: string; coverAssetId: string | null },
+) {
+ if (row.coverMode === 'manual' && row.coverAssetId) {
+ const [asset] = await db
+ .select({ id: userAsset.id, publicUrl: userAsset.publicUrl })
+ .from(userAsset)
+ .innerJoin(
+ projectAssetMembership,
+ eq(projectAssetMembership.assetId, userAsset.id),
+ )
+ .where(
+ and(
+ eq(projectAssetMembership.projectId, row.id),
+ eq(userAsset.id, row.coverAssetId),
+ eq(userAsset.type, 'image'),
+ ),
+ )
+ .limit(1);
+ if (asset) return { ...asset, mode: 'manual' as const };
+ }
+ // Images first, then videos. Stable order never changes on re-link or generation completion.
+ const [asset] = await db
+ .select({
+ id: userAsset.id,
+ publicUrl: userAsset.publicUrl,
+ type: userAsset.type,
+ storageProvider: userAsset.storageProvider,
+ })
+ .from(userAsset)
+ .innerJoin(
+ projectAssetMembership,
+ eq(projectAssetMembership.assetId, userAsset.id),
+ )
+ .where(
+ and(
+ eq(projectAssetMembership.projectId, row.id),
+ inArray(projectAssetMembership.category, [
+ 'upload',
+ 'generated',
+ 'cover',
+ ]),
+ or(
+ eq(userAsset.type, 'image'),
+ and(
+ eq(userAsset.type, 'video'),
+ eq(userAsset.storageProvider, LOCAL_PROJECT_ASSET_PROVIDER),
+ ),
+ ),
+ ),
+ )
+ .orderBy(
+ sql`case when ${userAsset.type} = 'image' then 0 else 1 end`,
+ asc(userAsset.createdAt),
+ asc(userAsset.id),
+ )
+ .limit(1);
+ if (!asset) return null;
+ return {
+ id: asset.id,
+ mode: 'auto' as const,
+ publicUrl:
+ asset.type === 'image'
+ ? asset.publicUrl
+ : asset.storageProvider === LOCAL_PROJECT_ASSET_PROVIDER
+ ? `/api/app/projects/${encodeURIComponent(row.id)}/assets/${encodeURIComponent(asset.id)}/thumbnail`
+ : null,
+ };
+}
+
+export async function getProjectSettings(
+ projectId: string,
+ database?: any,
+): Promise {
+ const db = database ?? (await getDb());
+ const [row] = await db
+ .select()
+ .from(project)
+ .where(and(eq(project.id, projectId), eq(project.status, 'active')))
+ .limit(1);
+ if (!row) return null;
+ const automaticCover = await resolveProjectCover(db, {
+ ...row,
+ coverMode: 'auto',
+ });
+ const cover =
+ row.coverMode === 'manual'
+ ? await resolveProjectCover(db, row)
+ : automaticCover;
+ return {
+ id: row.id,
+ name: row.name,
+ description: row.description,
+ coverMode: cover?.mode ?? 'auto',
+ coverAssetId: cover?.id ?? null,
+ coverImageUrl: cover?.publicUrl ?? null,
+ automaticCoverImageUrl: automaticCover?.publicUrl ?? null,
+ settingsRevision: row.settingsRevision,
+ createdAt: row.createdAt.toISOString(),
+ };
+}
+
+export async function updateProjectSettings({
+ projectId,
+ patch,
+ expectedRevision,
+ database,
+}: {
+ projectId: string;
+ patch: unknown;
+ expectedRevision?: number | null;
+ database?: any;
+}): Promise {
+ const parsed = projectSettingsPatchSchema.safeParse(patch);
+ if (!parsed.success)
+ throw new BeatDesignCommandError(
+ 'INVALID_COMMAND',
+ parsed.error.issues[0]?.message ?? 'Invalid settings.',
+ );
+ if (!Number.isInteger(expectedRevision) || (expectedRevision ?? 0) < 1)
+ throw new BeatDesignCommandError(
+ 'INVALID_COMMAND',
+ 'Project settings require an expectedRevision from the latest settings.',
+ );
+ const db = database ?? await getDb();
+ const update = async (tx: any) => {
+ const current = await getProjectSettings(projectId, tx);
+ if (!current)
+ throw new BeatDesignCommandError('NOT_FOUND', 'Project not found');
+ if (current.settingsRevision !== expectedRevision) {
+ const error = new Error(
+ 'Project settings changed. Reload before saving.',
+ );
+ error.name = 'ProjectSettingsVersionConflict';
+ Object.assign(error, { currentVersion: current.settingsRevision });
+ throw error;
+ }
+ const next = parsed.data;
+ if (next.cover?.mode === 'manual') {
+ const [asset] = await tx
+ .select({ type: userAsset.type })
+ .from(userAsset)
+ .innerJoin(
+ projectAssetMembership,
+ eq(projectAssetMembership.assetId, userAsset.id),
+ )
+ .where(
+ and(
+ eq(projectAssetMembership.projectId, projectId),
+ eq(userAsset.id, next.cover.assetId),
+ ),
+ )
+ .limit(1);
+ if (!asset || asset.type !== 'image')
+ throw new BeatDesignCommandError(
+ 'INVALID_COMMAND',
+ 'Cover must be an image belonging to this project.',
+ );
+ }
+ const changed = await tx
+ .update(project)
+ .set({
+ ...(next.name !== undefined ? { name: next.name } : {}),
+ ...(next.description !== undefined
+ ? { description: next.description }
+ : {}),
+ ...(next.cover
+ ? {
+ coverMode: next.cover.mode,
+ coverAssetId:
+ next.cover.mode === 'manual' ? next.cover.assetId : null,
+ }
+ : {}),
+ settingsRevision: current.settingsRevision + 1,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(project.id, projectId),
+ eq(project.settingsRevision, expectedRevision as number),
+ eq(project.status, 'active'),
+ ),
+ )
+ .returning({ id: project.id });
+ if (!changed.length)
+ throw new BeatDesignCommandError(
+ 'REVISION_CONFLICT',
+ 'Project settings changed. Reload before saving.',
+ );
+ return (await getProjectSettings(projectId, tx))!;
+ };
+ return database ? update(database) : db.transaction(update);
+}
diff --git a/src/core/projects/project-video-thumbnail.ts b/src/core/projects/project-video-thumbnail.ts
new file mode 100644
index 0000000..a802d19
--- /dev/null
+++ b/src/core/projects/project-video-thumbnail.ts
@@ -0,0 +1,27 @@
+import { getCachedVideoThumbnail } from '@/core/media/video-thumbnail-cache';
+import { getProjectAssetById } from '@/core/workspace-lib/assets/user-assets';
+import { LOCAL_PROJECT_ASSET_BUCKET, LOCAL_PROJECT_ASSET_PROVIDER, resolveLocalProjectAssetPath } from './local-project-assets';
+
+export async function getProjectVideoThumbnailResponse(request: Request, params: { projectId: string; assetId: string }) {
+ // Recheck project membership even when an image is already cached on disk.
+ const asset = await getProjectAssetById(params);
+ if (!asset || asset.type !== 'video' || asset.bucket !== LOCAL_PROJECT_ASSET_BUCKET || asset.storageProvider !== LOCAL_PROJECT_ASSET_PROVIDER) {
+ return new Response(null, { status: 404, headers: { 'cache-control': 'no-store' } });
+ }
+ try {
+ const thumbnail = await getCachedVideoThumbnail({
+ filePath: resolveLocalProjectAssetPath({ objectKey: asset.objectKey }),
+ assetId: asset.id, durationMs: asset.durationMs,
+ });
+ const headers = new Headers({
+ 'content-type': 'image/jpeg', 'cache-control': 'private, max-age=86400',
+ 'etag': `"${thumbnail.key}"`, 'x-content-type-options': 'nosniff',
+ });
+ if (request.headers.get('if-none-match') === headers.get('etag')) return new Response(null, { status: 304, headers });
+ headers.set('content-length', String(thumbnail.bytes.byteLength));
+ return new Response(thumbnail.bytes as BodyInit, { headers });
+ } catch {
+ // Show the poster placeholder; never redirect to or return the original video.
+ return new Response(null, { status: 503, headers: { 'cache-control': 'no-store', 'retry-after': '60' } });
+ }
+}
diff --git a/src/core/projects/projects.ts b/src/core/projects/projects.ts
index 40132f2..3556419 100644
--- a/src/core/projects/projects.ts
+++ b/src/core/projects/projects.ts
@@ -1,7 +1,8 @@
import { randomUUID } from 'crypto';
+import { resolveProjectCover } from './project-settings';
import { getDb } from '@/core/workspace-lib/db-adapter';
-import { project, projectCanvasState, userAsset } from '@/config/db/schema';
+import { project, projectCanvasState } from '@/config/db/schema';
import {
type ProjectSnapshotDocument,
createEmptyProjectSnapshot,
@@ -9,7 +10,7 @@ import {
isDestructiveEmptyProjectSnapshot,
normalizeProjectSnapshotDocument,
} from '@/core/projects/project-snapshot';
-import { and, desc, eq, getTableColumns } from 'drizzle-orm';
+import { and, desc, eq } from 'drizzle-orm';
import {
defaultWorkspaceMode,
resolveWorkspaceMode,
@@ -41,6 +42,7 @@ export const createProject = async ({
id,
name: trimProjectName(name),
coverAssetId: null,
+ coverMode: 'auto',
status: 'active',
currentStateVersion: 1,
lastWorkspaceMode: resolveWorkspaceMode(workspaceMode),
@@ -70,12 +72,8 @@ export const loadProjects = async ({
}) => {
const db = await getDb();
const projects = await db
- .select({
- ...getTableColumns(project),
- coverImageUrl: userAsset.publicUrl,
- })
+ .select()
.from(project)
- .leftJoin(userAsset, eq(project.coverAssetId, userAsset.id))
.where(eq(project.status, 'active'))
.orderBy(
desc(project.lastOpenedAt),
@@ -84,10 +82,10 @@ export const loadProjects = async ({
)
.limit(limit);
- return projects.map((entry: any) => ({
+ return Promise.all(projects.map(async (entry: any) => ({
...entry,
- coverImageUrl: entry.coverImageUrl?.trim() || null,
- }));
+ coverImageUrl: (await resolveProjectCover(db, entry))?.publicUrl ?? null,
+ })));
};
export const getProject = async ({
diff --git a/src/core/workspace-hooks/use-create-project.ts b/src/core/workspace-hooks/use-create-project.ts
new file mode 100644
index 0000000..77a9dab
--- /dev/null
+++ b/src/core/workspace-hooks/use-create-project.ts
@@ -0,0 +1,68 @@
+import { useEffect, useRef, useState } from 'react';
+
+import type { WorkspaceMode } from '@/config/workspace-modes';
+import {
+ buildPostCreateProjectDetailPath,
+ type ProjectEntryIntent,
+} from '@/core/projects/project-entry';
+import { createWorkspaceProject } from '@/core/workspace-lib/app/workspace-client-api';
+
+export function useCreateProject({
+ locale,
+ projectName,
+ errorMessage,
+ replace = false,
+ ...intent
+}: ProjectEntryIntent & {
+ locale: string;
+ projectName: string;
+ errorMessage: string;
+ replace?: boolean;
+}) {
+ const inFlight = useRef(false);
+ const lastMode = useRef(null);
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState(null);
+ const [ready, setReady] = useState(false);
+ useEffect(() => setReady(true), []);
+
+ async function createProject(workspaceMode: WorkspaceMode) {
+ // State updates may not render before a second click arrives.
+ if (!ready || inFlight.current) return;
+ inFlight.current = true;
+ lastMode.current = workspaceMode;
+ setCreating(true);
+ setError(null);
+ try {
+ const payload = await createWorkspaceProject({
+ name: projectName,
+ workspaceMode,
+ });
+ if (!payload?.id) throw new Error(payload?.error || errorMessage);
+ const path = buildPostCreateProjectDetailPath({
+ locale,
+ projectId: payload.id,
+ ...intent,
+ mode: workspaceMode,
+ });
+ if (replace) window.location.replace(path);
+ else window.location.assign(path);
+ } catch (createError) {
+ setError(
+ createError instanceof Error ? createError.message : errorMessage
+ );
+ inFlight.current = false;
+ setCreating(false);
+ }
+ }
+
+ return {
+ ready,
+ creating,
+ error,
+ createProject,
+ clearError: () => setError(null),
+ retryProject: () =>
+ lastMode.current ? createProject(lastMode.current) : Promise.resolve(),
+ };
+}
diff --git a/src/core/workspace-hooks/use-workspace-projects.ts b/src/core/workspace-hooks/use-workspace-projects.ts
index 2228bb4..ab9b363 100644
--- a/src/core/workspace-hooks/use-workspace-projects.ts
+++ b/src/core/workspace-hooks/use-workspace-projects.ts
@@ -30,6 +30,8 @@ export function workspaceProjectsQueryOptions({
enabled,
initialData: initialProjects,
staleTime: 30 * 1000,
+ refetchInterval: 5000,
+ refetchIntervalInBackground: false,
});
}
diff --git a/src/core/workspace-lib/app/project-settings-client.ts b/src/core/workspace-lib/app/project-settings-client.ts
new file mode 100644
index 0000000..1047077
--- /dev/null
+++ b/src/core/workspace-lib/app/project-settings-client.ts
@@ -0,0 +1,30 @@
+import { apiJsonGet, apiJsonPatch } from '@/lib/api-client';
+import type { BeatDesignCommandResult } from '@/core/commands/contracts';
+import type {
+ ProjectSettings,
+ ProjectSettingsPatch,
+} from '@/core/projects/project-settings-contract';
+
+export async function fetchProjectSettings(projectId: string) {
+ return (
+ await apiJsonGet<{ settings: ProjectSettings }>(
+ `/api/app/projects/${encodeURIComponent(projectId)}`,
+ )
+ ).settings;
+}
+
+export async function saveProjectSettings(
+ projectId: string,
+ expectedRevision: number,
+ patch: ProjectSettingsPatch,
+) {
+ const result = await apiJsonPatch<
+ BeatDesignCommandResult<{ projectSettings: ProjectSettings }>
+ >(`/api/app/projects/${encodeURIComponent(projectId)}`, {
+ expectedRevision,
+ patch,
+ commandId: crypto.randomUUID(),
+ });
+ if (!result.ok) throw new Error(result.message);
+ return result.data.projectSettings;
+}
diff --git a/src/core/workspace-lib/app/workspace-client-api.ts b/src/core/workspace-lib/app/workspace-client-api.ts
index c4bb41c..8516ebd 100644
--- a/src/core/workspace-lib/app/workspace-client-api.ts
+++ b/src/core/workspace-lib/app/workspace-client-api.ts
@@ -1,6 +1,6 @@
import type { WorkspaceMode } from '@/config/workspace-modes';
import type { ProjectGenerationItem } from '@/core/effects/project-generations';
-import { apiJsonDelete, apiJsonGet } from '@/lib/api-client';
+import { apiJsonDelete, apiJsonGet, apiJsonPost } from '@/lib/api-client';
export type WorkspaceProjectCardItem = {
id: string;
@@ -9,6 +9,7 @@ export type WorkspaceProjectCardItem = {
activityAt: string;
activityLabel: string;
coverImageUrl?: string | null;
+ description?: string;
};
export type RecentAsset = {
@@ -51,6 +52,16 @@ export async function deleteWorkspaceProjects(projectIds: string[]) {
return apiJsonDelete<{ success: true }>('/api/app/projects', { projectIds });
}
+export async function createWorkspaceProject(input: {
+ name: string;
+ workspaceMode: WorkspaceMode;
+}) {
+ return apiJsonPost<{ id?: string; name?: string; error?: string }>(
+ '/api/app/projects',
+ input
+ );
+}
+
export async function fetchProjectGenerations(projectId: string) {
return apiJsonGet<{ items: ProjectGenerationItem[] }>(
`/api/app/projects/${encodeURIComponent(projectId)}/generations`
diff --git a/src/core/workspace-lib/assets/user-assets.ts b/src/core/workspace-lib/assets/user-assets.ts
index 5243033..e99c5de 100644
--- a/src/core/workspace-lib/assets/user-assets.ts
+++ b/src/core/workspace-lib/assets/user-assets.ts
@@ -15,36 +15,6 @@ export type AssetSource = 'upload' | 'provider' | 'derived';
export type AssetRole = 'input' | 'output' | 'thumbnail' | 'reference';
export type ProjectAssetRole = 'upload' | 'reference' | 'generated' | 'cover';
-function shouldSyncProjectCoverAsset({
- role,
- assetType,
-}: {
- role: ProjectAssetRole;
- assetType: AssetType;
-}) {
- return (
- assetType === 'image' &&
- (role === 'cover' || role === 'generated' || role === 'upload')
- );
-}
-
-async function syncProjectCoverAsset({
- projectId,
- assetId,
-}: {
- projectId: string;
- assetId: string;
-}) {
- const db = await getDb();
- await db
- .update(project)
- .set({
- coverAssetId: assetId,
- updatedAt: new Date(),
- })
- .where(eq(project.id, projectId));
-}
-
export const recordUserAsset = async ({
id: requestedId,
type,
@@ -379,14 +349,6 @@ export const linkProjectAsset = async ({
.limit(1);
if (existing[0]?.id) {
- if (
- shouldSyncProjectCoverAsset({
- role,
- assetType: targetAsset.type as AssetType,
- })
- ) {
- await syncProjectCoverAsset({ projectId, assetId });
- }
return existing[0].id;
}
@@ -405,14 +367,5 @@ export const linkProjectAsset = async ({
createdAt: new Date(),
});
- if (
- shouldSyncProjectCoverAsset({
- role,
- assetType: targetAsset.type as AssetType,
- })
- ) {
- await syncProjectCoverAsset({ projectId, assetId });
- }
-
return id;
};
diff --git a/src/mcp/cwd-independence.test.ts b/src/mcp/cwd-independence.test.ts
index 8f2f0cf..7cbb596 100644
--- a/src/mcp/cwd-independence.test.ts
+++ b/src/mcp/cwd-independence.test.ts
@@ -49,8 +49,12 @@ test('MCP server starts and answers when spawned from a foreign working director
const env = { ...process.env };
delete env.TSX_TSCONFIG_PATH;
const child = spawn(
- resolve(repositoryRoot, 'node_modules/.bin/tsx'),
- [resolve(repositoryRoot, 'scripts/mcp-server.ts')],
+ process.execPath,
+ [
+ '--import',
+ import.meta.resolve('tsx'),
+ resolve(repositoryRoot, 'scripts/mcp-server.ts'),
+ ],
{
cwd: foreignRoot,
env,
@@ -104,8 +108,13 @@ test('MCP server starts and answers when spawned from a foreign working director
});
child.kill('SIGTERM');
const exitResult = await exited;
- assert.equal(exitResult.signal, null);
- assert.equal(exitResult.code, 143);
+ if (process.platform === 'win32') {
+ assert.equal(exitResult.signal, 'SIGTERM');
+ assert.equal(exitResult.code, null);
+ } else {
+ assert.equal(exitResult.signal, null);
+ assert.equal(exitResult.code, 143);
+ }
} finally {
if (!child.killed) child.kill();
rmSync(foreignRoot, { recursive: true, force: true });
diff --git a/src/mcp/server.runtime.test.ts b/src/mcp/server.runtime.test.ts
index f9dc3b1..9a851a0 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 29 named tools', () => {
+test('MCP tool catalog is exactly 30 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, 29);
+ assert.equal(BEATDESIGN_MCP_TOOL_NAMES.length, 30);
assert.deepEqual(registered, [...BEATDESIGN_MCP_TOOL_NAMES]);
});
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index 0178e69..4b62a2b 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -22,6 +22,9 @@ import { extractProjectVideoFrame } from '@/core/projects/extract-project-video-
import { importLocalProjectAsset } from '@/core/projects/import-local-asset';
import { parseLocalProjectAssetUrl } from '@/core/projects/local-project-asset-url';
import { createCommandId } from '@/core/commands/contracts';
+import { getProjectSettings } from '@/core/projects/project-settings';
+import { projectSettingsPatchSchema } from '@/core/projects/project-settings-contract';
+import { persistBeatDesignCommand } from '@/core/commands/persist';
import { listCommandReceipts } from '@/core/commands/receipts';
import {
findCaptionAtTime,
@@ -551,6 +554,7 @@ export function createBeatDesignMcpServer() {
if (!canvas) throw new Error('Project not found.');
return {
project: canvas.project,
+ settings: await getProjectSettings(scopedProjectId),
canvas: {
...canvas,
snapshot: {
@@ -564,6 +568,19 @@ export function createBeatDesignMcpServer() {
})
);
+ server.registerTool(
+ 'bdesign_project_update',
+ {
+ description: 'Update project name, short description or cover through the shared revision-checked command kernel. Get settingsRevision from project_get; manual coverAssetId must belong to the project. Auto cover uses the first image, then a local video poster.',
+ inputSchema: z.object({ projectId: idSchema.optional(), expectedRevision: z.number().int().min(1), patch: projectSettingsPatchSchema, commandId: idSchema.optional(), idempotencyKey: idSchema.optional() }),
+ annotations: { destructiveHint: false, idempotentHint: true },
+ },
+ withToolErrors(async ({ projectId, expectedRevision, patch, commandId, idempotencyKey }) => {
+ const project = await resolveScopedProject(projectId);
+ return persistBeatDesignCommand({ projectId: project.id, origin: 'mcp', expectedRevision, commandId, idempotencyKey, command: { type: 'project.update', patch } });
+ })
+ );
+
server.registerTool(
'bdesign_project_create',
{
diff --git a/src/mcp/stdio-probe.test.ts b/src/mcp/stdio-probe.test.ts
index b68d8c4..8aba4af 100644
--- a/src/mcp/stdio-probe.test.ts
+++ b/src/mcp/stdio-probe.test.ts
@@ -35,8 +35,12 @@ const readJsonLine = async (
test('MCP stdio handshake lists the catalogued tools and can list projects', async () => {
const child = spawn(
- resolve('node_modules/.bin/tsx'),
- ['scripts/mcp-server.ts'],
+ process.execPath,
+ [
+ '--import',
+ import.meta.resolve('tsx'),
+ resolve('scripts/mcp-server.ts'),
+ ],
{
cwd: resolve('.'),
env: { ...process.env, NODE_ENV: 'development' },
diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts
index 02f6952..4d7ab98 100644
--- a/src/mcp/tools.ts
+++ b/src/mcp/tools.ts
@@ -3,6 +3,7 @@ export const BEATDESIGN_MCP_TOOL_NAMES = [
'bdesign_skill_get',
'bdesign_project_list',
'bdesign_project_get',
+ 'bdesign_project_update',
'bdesign_project_create',
'bdesign_project_target',
'bdesign_project_open',
diff --git a/src/mcp/workbuddy-package.ts b/src/mcp/workbuddy-package.ts
index d7ca5b5..cc8b287 100644
--- a/src/mcp/workbuddy-package.ts
+++ b/src/mcp/workbuddy-package.ts
@@ -143,10 +143,11 @@ export const validateWorkBuddyConnector = async (
resolve(connectorDirectory, 'skills/beatdesign-workspace/SKILL.md'),
'utf8'
);
- assert.match(skill, /^---\n[\s\S]*?\n---\n/);
- assert.match(skill, /\nname: beatdesign-workspace\n/);
+ const normalizedSkill = skill.replace(/\r\n?/g, '\n');
+ assert.match(normalizedSkill, /^---\n[\s\S]*?\n---\n/);
+ assert.match(normalizedSkill, /\nname: beatdesign-workspace\n/);
assert.doesNotMatch(
- skill,
+ normalizedSkill,
/\[(?:TODO|PLACEHOLDER)(?::|\])/i,
'WorkBuddy Skill must not contain unfinished placeholders'
);
diff --git a/src/routes/api/app/projects/$projectId.ts b/src/routes/api/app/projects/$projectId.ts
index 34edc0a..950abf3 100644
--- a/src/routes/api/app/projects/$projectId.ts
+++ b/src/routes/api/app/projects/$projectId.ts
@@ -6,8 +6,11 @@ import {
import {
getProject,
markProjectOpened,
- renameProject,
} from '@/core/projects/projects';
+import { getProjectSettings } from '@/core/projects/project-settings';
+import { persistBeatDesignCommand } from '@/core/commands/persist';
+import { projectSettingsPatchSchema } from '@/core/projects/project-settings-contract';
+import { z } from 'zod';
import { validateTrustedWorkspaceJsonMutation } from '@/lib/trusted-local-request';
import {
MAX_WORKSPACE_JSON_REQUEST_BYTES,
@@ -15,11 +18,6 @@ import {
RequestBodyTooLargeError,
} from '@/lib/request-body-limit';
-type UpdateProjectRequest = {
- name?: string;
- workspaceMode?: string;
-};
-
type OpenProjectRequest = {
workspaceMode?: string;
};
@@ -89,37 +87,23 @@ async function PATCH({
return Response.json({ error: 'Project not found' }, { status: 404 });
}
- let payload: UpdateProjectRequest | null = null;
+ let payload: unknown;
try {
- payload = await readRequestJsonWithLimit(
- request,
- MAX_WORKSPACE_JSON_REQUEST_BYTES
- );
+ payload = await readRequestJsonWithLimit(request, MAX_WORKSPACE_JSON_REQUEST_BYTES);
} catch (error) {
- if (error instanceof RequestBodyTooLargeError) {
- return Response.json({ error: 'Request body is too large' }, { status: 413 });
- }
- return Response.json({ error: 'Invalid JSON' }, { status: 400 });
- }
-
- const nextName = payload?.name?.trim();
- if (!nextName) {
- return Response.json(
- { error: 'Project name is required' },
- { status: 400 }
- );
+ return Response.json({ error: error instanceof RequestBodyTooLargeError ? 'Request body is too large' : 'Invalid JSON' }, { status: error instanceof RequestBodyTooLargeError ? 413 : 400 });
}
+ const parsed = z.object({ patch: projectSettingsPatchSchema, expectedRevision: z.number().int().min(1), commandId: z.string().min(1).max(200).optional() }).strict().safeParse(payload);
+ if (!parsed.success) return Response.json({ error: parsed.error.issues[0]?.message ?? 'Invalid settings' }, { status: 400 });
+ const result = await persistBeatDesignCommand({ projectId, origin: 'ui', expectedRevision: parsed.data.expectedRevision, commandId: parsed.data.commandId, command: { type: 'project.update', patch: parsed.data.patch } });
+ return Response.json(result, { status: result.ok ? 200 : result.code === 'REVISION_CONFLICT' ? 409 : result.code === 'NOT_FOUND' ? 404 : 400 });
+}
- const name = await renameProject({
- projectId,
- name: nextName,
- });
-
- return Response.json({ id: projectId, name });
+async function GET({ params }: { params: { projectId: string } }) {
+ const settings = await getProjectSettings(params.projectId);
+ return settings ? Response.json({ settings }) : Response.json({ error: 'Project not found' }, { status: 404 });
}
export const Route = createFileRoute('/api/app/projects/$projectId')({
- server: {
- handlers: { POST, PATCH },
- },
+ server: { handlers: { GET, POST, PATCH } },
});
diff --git a/src/routes/api/app/projects/$projectId/assets/$assetId/thumbnail.ts b/src/routes/api/app/projects/$projectId/assets/$assetId/thumbnail.ts
new file mode 100644
index 0000000..8cd3882
--- /dev/null
+++ b/src/routes/api/app/projects/$projectId/assets/$assetId/thumbnail.ts
@@ -0,0 +1,6 @@
+import { createFileRoute } from '@tanstack/react-router';
+import { getProjectVideoThumbnailResponse } from '@/core/projects/project-video-thumbnail';
+
+export const Route = createFileRoute('/api/app/projects/$projectId/assets/$assetId/thumbnail')({
+ server: { handlers: { GET: ({ request, params }) => getProjectVideoThumbnailResponse(request, params) } },
+});
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 0a33e6e..bfdaab9 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -110,7 +110,7 @@ button[aria-disabled="true"] {
z-index: -1;
top: -5rem;
left: 50%;
- width: min(64rem, 100vw);
+ width: min(64rem, calc(100% + 2.5rem));
height: 34rem;
content: "";
pointer-events: none;
@@ -124,6 +124,12 @@ button[aria-disabled="true"] {
opacity: 0.72;
}
+@media (min-width: 640px) {
+ .beat-product-hero::before {
+ width: min(64rem, calc(100% + 3rem));
+ }
+}
+
@media (max-width: 640px) {
.beat-product-hero::before {
top: -2rem;
diff --git a/tests/e2e/project-creation.spec.ts b/tests/e2e/project-creation.spec.ts
new file mode 100644
index 0000000..709dcb8
--- /dev/null
+++ b/tests/e2e/project-creation.spec.ts
@@ -0,0 +1,104 @@
+import { expect, test } from '@playwright/test';
+
+test.use({ locale: 'en-US' });
+
+for (const mode of ['canvas', 'studio'] as const) {
+ test(`choosing ${mode} creates one project and opens it without a second chooser`, async ({
+ page,
+ }) => {
+ const creations: string[] = [];
+ page.on('request', (request) => {
+ if (
+ request.method() === 'POST' &&
+ new URL(request.url()).pathname === '/api/app/projects'
+ ) {
+ creations.push(request.postData() ?? '');
+ }
+ });
+ await page.goto('/projects');
+ await page
+ .getByRole('button', { name: 'New project', exact: true })
+ .first()
+ .click();
+ const choice = mode === 'canvas' ? 'Start in Canvas' : 'Start in Studio';
+ await page.getByRole('button', { name: new RegExp(`^${choice}`) }).click();
+ await expect(page).toHaveURL(new RegExp(`/${mode}/[^/?]+$`));
+ await expect(
+ page.getByRole('heading', { name: 'Start a new project', exact: true })
+ ).toHaveCount(0);
+ expect(creations).toHaveLength(1);
+ expect(JSON.parse(creations[0]).workspaceMode).toBe(mode);
+ });
+}
+
+test('failed Canvas creation retries in Canvas', async ({ page }) => {
+ let attempts = 0;
+ await page.route('**/api/app/projects', async (route) => {
+ if (route.request().method() !== 'POST') return route.continue();
+ expect(route.request().postDataJSON().workspaceMode).toBe('canvas');
+ attempts += 1;
+ if (attempts === 1) {
+ return route.fulfill({
+ status: 503,
+ contentType: 'application/json',
+ body: JSON.stringify({ error: 'Creation temporarily unavailable' }),
+ });
+ }
+ return route.continue();
+ });
+ await page.goto('/projects');
+ await page
+ .getByRole('button', { name: 'New project', exact: true })
+ .first()
+ .click();
+ await page.getByRole('button', { name: /^Start in Canvas/ }).click();
+ await expect(page.getByRole('alert')).toHaveText(
+ 'Creation temporarily unavailable'
+ );
+ await page.getByRole('button', { name: 'Try again', exact: true }).click();
+ await expect(page).toHaveURL(/\/canvas\/[^/?]+$/);
+ expect(attempts).toBe(2);
+});
+
+test('direct Canvas entry requires one explicit create action and retains entry parameters', async ({
+ page,
+}) => {
+ let creations = 0;
+ let openedProjectUrl: URL | undefined;
+ page.on('request', (request) => {
+ const url = new URL(request.url());
+ if (request.isNavigationRequest() && /^\/canvas\/[^/]+$/.test(url.pathname)) openedProjectUrl = url;
+ if (
+ request.method() === 'POST' &&
+ new URL(request.url()).pathname === '/api/app/projects'
+ )
+ creations += 1;
+ });
+ await page.goto(
+ '/canvas?target=image&model=nano-banana-pro&prompt=Entry+test'
+ );
+ const create = page.getByRole('button', {
+ name: 'Create in Canvas',
+ exact: true,
+ });
+ await expect(create).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: 'Create in Studio', exact: true })
+ ).toHaveCount(0);
+ expect(creations).toBe(0);
+ await create.click();
+ await expect(page).toHaveURL(/\/canvas\/[^/?]+/);
+ // Entry prompts are deliberately removed after hydration; inspect the actual
+ // navigation and the saved draft, rather than racing that cleanup effect.
+ expect(openedProjectUrl).toBeDefined();
+ const url = openedProjectUrl!;
+ expect(url.searchParams.get('target')).toBe('image');
+ expect(url.searchParams.get('model')).toBe('nano-banana-pro');
+ expect(url.searchParams.get('prompt')).toBe('Entry test');
+ const id = url.pathname.split('/').at(-1)!;
+ await expect.poll(async () => {
+ const snapshot = await (await page.request.get(`/api/app/projects/${id}/snapshot`)).json();
+ return snapshot.document.cards.some((card: { prompt?: string }) => card.prompt === 'Entry test');
+ }).toBe(true);
+ expect(creations).toBe(1);
+});
diff --git a/tests/e2e/project-settings.spec.ts b/tests/e2e/project-settings.spec.ts
new file mode 100644
index 0000000..cc0385f
--- /dev/null
+++ b/tests/e2e/project-settings.spec.ts
@@ -0,0 +1,127 @@
+import { expect, test, type APIRequestContext } from '@playwright/test';
+import { readFile } from 'node:fs/promises';
+
+test.use({ locale: 'en-US' });
+const headers = { 'x-beatapi-workspace-request': '1' };
+
+async function createProject(request: APIRequestContext, name: string) {
+ const response = await request.post('/api/app/projects', { headers, data: { name } });
+ expect(response.ok()).toBe(true);
+ return (await response.json()).id as string;
+}
+
+async function settings(request: APIRequestContext, id: string) {
+ return (await (await request.get(`/api/app/projects/${id}`)).json()).settings;
+}
+
+async function updateViaMcp(request: APIRequestContext, id: string, revision: number, name: string) {
+ const response = await request.post('http://127.0.0.1:3043/mcp', {
+ headers: { authorization: 'Bearer e2e-local-only', accept: 'application/json, text/event-stream' },
+ data: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: {
+ name: 'bdesign_project_update', arguments: { projectId: id, expectedRevision: revision, patch: { name } },
+ } },
+ });
+ expect(response.ok()).toBe(true);
+ const text = await response.text();
+ const data = JSON.parse(text.startsWith('{') ? text : text.split('\n').find((line) => line.startsWith('data: '))!.slice(6));
+ expect(data.error).toBeUndefined();
+ expect(data.result.isError).not.toBe(true);
+ expect((await settings(request, id)).name).toBe(name);
+}
+
+test('project settings upload a persistent cover and share revisions with MCP', async ({ page, request }) => {
+ const serverModuleRequests: string[] = [];
+ page.on('request', (req) => {
+ if (/\/src\/(core\/db\/|modules\/config\/|core\/adapters\/beatapi-adapter)/.test(req.url())) serverModuleRequests.push(req.url());
+ });
+ const id = await createProject(request, 'Settings QA');
+ await page.goto(`/canvas/${id}`);
+ await page.getByRole('button', { name: 'Project settings', exact: true }).click();
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByLabel('Project name', { exact: true })).toHaveValue('Settings QA');
+ await dialog.getByLabel('Project name', { exact: true }).fill('Cover QA');
+ await dialog.getByLabel('Description', { exact: false }).fill('A local project cover');
+ await dialog.locator('input[type=file]').setInputFiles('public/logo.png');
+ await expect(dialog.getByText('Custom cover', { exact: false })).toBeVisible();
+ await dialog.getByRole('button', { name: 'Save changes', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ const saved = await settings(request, id);
+ expect(saved.coverMode).toBe('manual');
+ expect(saved.description).toBe('A local project cover');
+ const imported = await request.post(`/api/app/projects/${id}/assets`, {
+ headers, multipart: { file: { name: 'later.png', mimeType: 'image/png', buffer: await readFile('public/logo.png') } },
+ });
+ expect(imported.ok()).toBe(true);
+ expect((await settings(request, id)).coverAssetId).toBe(saved.coverAssetId);
+ await updateViaMcp(request, id, saved.settingsRevision, 'Agent renamed project');
+ await expect(page.getByRole('textbox', { name: 'Workspace name', exact: true })).toHaveValue('Agent renamed project');
+ await page.getByRole('button', { name: 'Project settings', exact: true }).click();
+ await expect(dialog.getByLabel('Project name', { exact: true })).toHaveValue('Agent renamed project');
+ await dialog.getByRole('button', { name: 'Use automatic cover', exact: true }).click();
+ await dialog.getByRole('button', { name: 'Save changes', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ expect((await settings(request, id)).coverMode).toBe('auto');
+ await page.goto('/projects');
+ const card = page.locator('article').filter({ hasText: 'Agent renamed project' });
+ await expect(card).toContainText('A local project cover');
+ await expect(card.locator('img')).toBeVisible();
+ await page.setViewportSize({ width: 390, height: 844 });
+ await card.getByRole('button', { name: 'Project settings', exact: true }).click();
+ await expect(dialog.getByLabel('Project name', { exact: true })).toBeVisible();
+ await expect(page.locator('html')).toHaveJSProperty('scrollWidth', 390);
+ await page.screenshot({ path: 'test-results/project-settings-mobile.png' });
+ expect(serverModuleRequests).toEqual([]);
+});
+
+test('stale settings saves keep local edits and leave the MCP change intact', async ({ page, request }) => {
+ const id = await createProject(request, 'Conflict QA');
+ await page.goto(`/canvas/${id}`);
+ await page.getByRole('button', { name: 'Project settings', exact: true }).click();
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByLabel('Project name', { exact: true })).toHaveValue('Conflict QA');
+ const before = await settings(request, id);
+ await dialog.getByLabel('Description', { exact: false }).fill('Keep this draft');
+ await updateViaMcp(request, id, before.settingsRevision, 'Concurrent agent edit');
+ await dialog.getByRole('button', { name: 'Save changes', exact: true }).click();
+ await expect(dialog.getByRole('alert')).toContainText('Project settings changed elsewhere');
+ await expect(dialog.getByLabel('Description', { exact: false })).toHaveValue('Keep this draft');
+ expect((await settings(request, id)).name).toBe('Concurrent agent edit');
+ await page.screenshot({ path: 'test-results/project-settings-conflict.png' });
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
+ const title = page.getByRole('textbox', { name: 'Workspace name', exact: true });
+ await expect(title).toHaveValue('Concurrent agent edit');
+ await title.fill('Unsubmitted local title');
+ const current = await settings(request, id);
+ const refreshed = page.waitForResponse(async (response) => {
+ if (response.request().method() !== 'GET' || !response.url().endsWith(`/api/app/projects/${id}`)) return false;
+ return (await response.json()).settings.name === 'Another agent edit';
+ });
+ await updateViaMcp(request, id, current.settingsRevision, 'Another agent edit');
+ await refreshed;
+ await expect(title).toHaveValue('Unsubmitted local title');
+});
+
+test('home does not overflow narrow screens', async ({ page }) => {
+ for (const width of [390, 648]) {
+ await page.setViewportSize({ width, height: 844 });
+ await page.goto('/');
+ await expect(page.locator('html')).toHaveJSProperty('scrollWidth', width);
+ }
+});
+
+test('undo and redo persist an explicitly empty Canvas across reload', async ({ page, request }) => {
+ const id = await createProject(request, 'Empty undo QA');
+ await page.goto(`/canvas/${id}`);
+ await expect(page.getByRole('button', { name: 'Project settings', exact: true })).toBeEnabled();
+ await page.getByRole('button', { name: 'Generation node', exact: true }).click();
+ const cards = async () => (await (await request.get(`/api/app/projects/${id}/snapshot`)).json()).document.cards;
+ await expect.poll(async () => (await cards()).length).toBe(1);
+ await page.getByRole('button', { name: 'Undo', exact: true }).click();
+ await expect.poll(async () => (await cards()).length).toBe(0);
+ await page.getByRole('button', { name: 'Redo', exact: true }).click();
+ await expect.poll(async () => (await cards()).length).toBe(1);
+ await page.getByRole('button', { name: 'Undo', exact: true }).click();
+ await expect.poll(async () => (await cards()).length).toBe(0);
+ await page.reload();
+ await expect(page.locator('.react-flow__node')).toHaveCount(0);
+});
diff --git a/tests/e2e/workspace.spec.ts b/tests/e2e/workspace.spec.ts
index 648ddc9..fdd828e 100644
--- a/tests/e2e/workspace.spec.ts
+++ b/tests/e2e/workspace.spec.ts
@@ -22,6 +22,71 @@ async function project(request: APIRequestContext, name: string) {
return (await response.json()).id as string;
}
+for (const type of ['image', 'video'] as const) {
+ test(`Canvas ${type} defaults to latest take and persists manual history selection`, async ({ page, request }) => {
+ const projectId = await project(request, `Latest ${type} take QA`);
+ const fixture = await readFile(type === 'image' ? 'public/logo.png' : 'tests/fixtures/tiny-black.mp4');
+ const urls: string[] = [];
+ const assetIds: string[] = [];
+ for (const index of [1, 2]) {
+ const uploaded = await request.post(`/api/app/projects/${projectId}/assets`, {
+ headers,
+ multipart: { file: {
+ name: `take-${index}.${type === 'image' ? 'png' : 'mp4'}`,
+ mimeType: type === 'image' ? 'image/png' : 'video/mp4',
+ buffer: Buffer.concat([fixture, Buffer.from(`take-${index}`)]),
+ } },
+ });
+ expect(uploaded.ok()).toBeTruthy();
+ const { asset } = await uploaded.json();
+ urls.push(asset.publicUrl);
+ assetIds.push(asset.id);
+ }
+ const draft = {
+ id: 'qa-latest-draft', kind: 'generation', type, name: 'Latest take QA', url: null,
+ prompt: 'Local version selection test', referenceCardIds: [], workflowTemplateId: null,
+ status: 'idle', error: null, modelId: type === 'image' ? 'gpt-image-2.5-flare' : 'seedance-2.0',
+ aspectRatio: '16:9', outputQuality: '720p', duration: '8s', mode: 'quality',
+ variant: 'standard', quality: 'standard', sourceGenerationId: null,
+ };
+ const outputs = [0, 1, 2].map((index) => ({
+ ...draft, id: `qa-take-${index}`, kind: 'output',
+ assetId: assetIds[index] ?? null,
+ status: index === 2 ? 'processing' : 'succeeded', url: urls[index] ?? null,
+ sourceConfigCardId: draft.id, generationRunId: `qa-run-${index}`,
+ generationSnapshot: { ...draft, capturedAt: `2026-10-18T00:0${index}:00.000Z` },
+ }));
+ const applied = await mcp(request, 'bdesign_canvas_apply', { projectId, operations: [draft, ...outputs].map((card) => ({
+ type: 'upsert_card', card, frame: { x: 200, y: 160, w: 420, h: 236 },
+ })) });
+ expect(applied.structuredContent.result.ok, JSON.stringify(applied)).toBe(true);
+ const snapshot = await (await request.get(`/api/app/projects/${projectId}/snapshot`)).json();
+ expect(snapshot.document.cards).toHaveLength(4);
+ await page.goto(`/canvas/${projectId}`);
+ const node = page.locator(`[data-card-id="${draft.id}"]`);
+ await expect(node).toBeVisible();
+ await expect(node.getByRole('button', { name: 'Take 2', exact: true })).toHaveAttribute('aria-pressed', 'true');
+ await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'false');
+ if (type === 'image') {
+ await expect(node.locator('img').first()).toHaveAttribute('src', urls[1]);
+ } else {
+ await node.getByRole('button', { name: /^View result:/ }).click();
+ await expect(page.locator('video').first()).toHaveAttribute('src', urls[1]);
+ await page.getByRole('dialog').getByRole('button').click();
+ }
+ await node.getByRole('button', { name: 'Take 1', exact: true }).click();
+ await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'true');
+ await page.getByRole('dialog').getByRole('button').click();
+ await expect.poll(async () => {
+ const state = await (await request.get(`/api/app/projects/${projectId}/snapshot`)).json();
+ return state.document.cards.find((card: { id: string }) => card.id === draft.id)?.pinnedOutputId;
+ }).toBe(outputs[0].id);
+ await page.reload();
+ await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'true');
+ await page.screenshot({ path: `test-results/latest-${type}-take.png` });
+ });
+}
+
test('Canvas UI commands and MCP edits share durable state; unchanged polling is lightweight', async ({ page, request }) => {
const id = await project(request, 'Canvas concurrency QA');
const initial = { id: 'qa-draft', kind: 'generation', type: 'image', name: 'QA draft', prompt: 'Original prompt', modelId: 'gpt-image-2.5-flare' };
@@ -49,8 +114,10 @@ test('Canvas UI commands and MCP edits share durable state; unchanged polling is
await expect.poll(() => writes.some((body) => JSON.parse(body).command.type === 'canvas.apply')).toBe(true);
await expect.poll(async () => (await (await request.get(`/api/app/projects/${id}/snapshot`)).json()).document.cards[0].prompt).toBe('Agent revised prompt');
await expect.poll(async () => (await (await request.get(`/api/app/projects/${id}/snapshot`)).json()).document.frames['qa-draft'].x).not.toBe(200);
+ // Dragging can leave the pointer inside the card without a fresh pointerenter.
+ // Re-enter it to exercise the actual hover composer interaction.
+ await page.mouse.move(10, 80);
await node.hover();
- await node.dblclick();
await expect(page.locator('textarea').first()).toHaveValue('Agent revised prompt');
reads.length = 0;
await page.waitForTimeout(4500);
diff --git a/tests/fixtures/tiny-black.mp4 b/tests/fixtures/tiny-black.mp4
new file mode 100644
index 0000000..a20c060
Binary files /dev/null and b/tests/fixtures/tiny-black.mp4 differ