diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..17967c7
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+config.toml
+.env
+.env.*
+.git
+.gitignore
+**/node_modules
+**/dist
+**/target
+coverage
+*.log
diff --git a/Dockerfile b/Dockerfile
index 2f6ec06..b1f86b3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,10 @@
+FROM node:22-bookworm-slim AS web-build
+WORKDIR /app/apps/overlay
+COPY apps/overlay/package.json apps/overlay/package-lock.json ./
+RUN npm ci
+COPY apps/overlay ./
+RUN npm run build
+
FROM rust:1.97-bookworm AS rust-build
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev libasound2-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -17,5 +24,6 @@ WORKDIR /app
# account metadata and short-lived connection tokens. Keep application lifecycle
# logs while suppressing dependency INFO output by default.
COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server
+COPY --from=web-build /app/apps/overlay/dist /app/web
EXPOSE 9719
CMD ["/app/lxc-stream-server", "--config", "/app/config.toml"]
diff --git a/README.md b/README.md
index cc712d2..6f9cae5 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# 洛星瓷直播转盘
+# 洛星瓷直播弹幕姬
-运行前复制带注释的 `config.toml.example` 为 `config.toml`,填入 CookieCloud、业务数据库、歌单只读数据库和访问密钥,然后执行:
+运行前复制带注释的 `config.toml.example` 为 `config.toml`,填入 CookieCloud、设置数据库和访问密钥,然后执行:
```sh
cp config.toml.example config.toml
@@ -9,10 +9,23 @@ docker compose up --build -d
应用配置遵循 `blivedm_rs` 的 TOML/`--config` 形式;Compose 会以只读方式将 `config.toml` 挂载到容器并传入 `--config /app/config.toml`。配置段及字段说明直接写在 [config.toml.example](config.toml.example) 的注释中。
-应用容器使用 host 网络并监听 `9719`。它通过宿主机 `127.0.0.1:5432` 直连 PostgreSQL:转盘业务使用独立的 `wheel` 数据库,歌单读取使用现有 `lxc_songlist` 数据库。应用会自动创建转盘业务表;`[connection].room_id`、CookieCloud Key/UUID 与密码均为必填配置。
-
-将 `[reply].enabled` 设为 `true` 可启用聊天回复;发送前会从 CookieCloud 读取最新的 Bilibili Cookie,并从其中取得 `bili_jct` CSRF 值。若 Cookie 未同步 `bili_jct`,转盘逻辑仍会正常运行,只会停用聊天回复。
+应用容器使用 host 网络并监听 `9719`。它通过 `[database].url` 连接 PostgreSQL,仅用于持久化 OBS 弹幕姬设置。应用会自动创建所需表;`[connection].room_id`、CookieCloud Key/UUID 与密码均为必填配置。
服务端已迁移为 Rust/Axum,并使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 发布的 `blivedm` crate 建立 Bilibili 认证弹幕连接;不再使用 Node 服务端或 `@laplace.live/ws`。为确保 UID、礼物价格和上游事件 ID 不会被库的简化消息结构丢弃,项目在 `vendor/blivedm` 固定了一个仅保留原始 JSON 的小补丁。应用从 CookieCloud 获取 Bilibili Cookie(必须包含 `SESSDATA`),用于获取认证 UID、直播间网关与 WebSocket 鉴权包。
-当前版本只提供 Rust 后端 API 与 WebSocket,不再托管任何前端静态文件或 SPA 路由。未匹配的 HTTP 路径将返回 `404`;新的前端可独立部署并连接 `/ws` 及 `/api/*`。
+## OBS 弹幕姬
+
+镜像会在构建阶段编译 `apps/overlay`,最终由 Rust/Axum **同域托管**:
+
+- `/obs?token=你的_OBS_访问令牌`:透明背景的 OBS 浏览器源。它没有固定画布,按浏览器源的实际宽高自动在窄侧栏、常规卡片和低高度模式之间切换。
+- `/control`:管理员控制台。使用 `admin.password` 登录后可改标题、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效、每卡粒子数量与速度、低性能模式和礼物高亮阈值;保存会实时广播给已打开的 OBS 源。
+
+控制台的“复制 OBS 地址”会生成含只读令牌的完整地址。该令牌仅能订阅 `/ws`,不能访问管理或写入接口。常用 OBS 浏览器源尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始;可按实际版面自由拖拽缩放,不会出现 1920×1080 的固定画布留白。
+
+每张消息卡片都会从六套花纹组合中稳定选取一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景;连续的新卡会主动避开相同款式,礼物连击更新则保持原样式不跳动。透明消息墙本身不铺设全局装饰背景。粒子数量与速度可在控制台调整;窄尺寸会自动减少粒子,低性能模式会关闭动态粒子。花边 SVG 已本地打包,来源和公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。
+
+后端会在启动时、之后每十分钟调用 Bilibili 的无需 Cookie 礼物面板接口缓存礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失目录条目会安全降级为直播事件自带的名称与价格。
+
+直播弹幕中的普通混排表情和整条大表情会作为安全的文字/图片分段通过 WebSocket 推送。消息自带的图片地址优先;服务还会使用 CookieCloud 的 `SESSDATA` 刷新直播间表情目录,在消息只提供表情唯一标识时补齐图片。图片加载失败时前端会退回原始表情文字。
+
+刷新间隔和请求超时可通过 `[gifts]` 调整。`[overlay]` 与 `[overlay.events]` 是某个直播间第一次运行时的展示默认值;在 `/control` 保存后,数据库中的设置优先,因此更新 TOML 不会覆盖管理员已经调好的 OBS 样式。若希望重新采用 TOML 默认值,可删除该房间在 `overlay_settings` 表中的记录后重启。
diff --git a/apps/overlay/index.html b/apps/overlay/index.html
new file mode 100644
index 0000000..71a27a5
--- /dev/null
+++ b/apps/overlay/index.html
@@ -0,0 +1 @@
+
洛星瓷弹幕猪
diff --git a/apps/overlay/package-lock.json b/apps/overlay/package-lock.json
new file mode 100644
index 0000000..56c49b6
--- /dev/null
+++ b/apps/overlay/package-lock.json
@@ -0,0 +1,1823 @@
+{
+ "name": "lxc-stream-overlay",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "lxc-stream-overlay",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "19.1.1",
+ "react-dom": "19.1.1"
+ },
+ "devDependencies": {
+ "@types/react": "19.1.10",
+ "@types/react-dom": "19.1.7",
+ "@vitejs/plugin-react": "5.0.2",
+ "typescript": "5.9.2",
+ "vite": "7.3.6"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.34",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz",
+ "integrity": "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.1.10",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
+ "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.1.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
+ "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.0.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz",
+ "integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.3",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.34",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.43",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
+ "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.6",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
+ "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.42",
+ "caniuse-lite": "^1.0.30001803",
+ "electron-to-chromium": "^1.5.389",
+ "node-releases": "^2.0.51",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001805",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz",
+ "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.391",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.391.tgz",
+ "integrity": "sha512-YmCu4856jkgKT1Nh6fwRdeVrM6Ydf/fBnq51tpmSfX+jOcUMTxh31yH6hjKScRenhB2oDSvA9oooxcpjogPeig==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.51",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.19",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
+ "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.1.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
+ "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.1.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
+ "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.26.0"
+ },
+ "peerDependencies": {
+ "react": "^19.1.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
+ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/apps/overlay/package.json b/apps/overlay/package.json
new file mode 100644
index 0000000..f742dcd
--- /dev/null
+++ b/apps/overlay/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "lxc-stream-overlay",
+ "private": true,
+ "version": "0.1.0",
+ "scripts": { "build": "tsc -b && vite build", "dev": "vite" },
+ "dependencies": { "react": "19.1.1", "react-dom": "19.1.1" },
+ "devDependencies": { "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", "typescript": "5.9.2", "vite": "7.3.6" }
+}
diff --git a/apps/overlay/public/assets/NOTICE.md b/apps/overlay/public/assets/NOTICE.md
new file mode 100644
index 0000000..df83647
--- /dev/null
+++ b/apps/overlay/public/assets/NOTICE.md
@@ -0,0 +1,29 @@
+# Decorative SVG assets
+
+`floral-divider.svg` is a locally bundled copy of “Floral Divider”.
+
+- Source: https://openclipart.org/detail/311692/floral-divider
+- Author: GDJ
+- License: CC0 1.0 / public domain
+- Retrieved: 2026-07-15
+
+`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by
+Paul Bürck”.
+
+- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg
+- Original artist: Paul Bürck, 1899
+- SVG adaptation: Cbaile19
+- License: original public domain; SVG adaptation CC0 1.0
+- Retrieved: 2026-07-15
+
+`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by
+Paul Bürck”.
+
+- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg
+- Original artist: Paul Bürck, 1899
+- SVG adaptation: Cbaile19
+- License: original public domain; SVG adaptation CC0 1.0
+- Retrieved: 2026-07-15
+
+The overlay uses these SVGs as recolorable CSS masks inside message cards and
+bundles them locally, so OBS never needs to load the source websites.
diff --git a/apps/overlay/public/assets/floral-cluster.svg b/apps/overlay/public/assets/floral-cluster.svg
new file mode 100644
index 0000000..21c2e33
--- /dev/null
+++ b/apps/overlay/public/assets/floral-cluster.svg
@@ -0,0 +1,8 @@
+
diff --git a/apps/overlay/public/assets/floral-divider.svg b/apps/overlay/public/assets/floral-divider.svg
new file mode 100644
index 0000000..0ad8f0d
--- /dev/null
+++ b/apps/overlay/public/assets/floral-divider.svg
@@ -0,0 +1,68 @@
+
diff --git a/apps/overlay/public/assets/floral-vine.svg b/apps/overlay/public/assets/floral-vine.svg
new file mode 100644
index 0000000..2d5e01f
--- /dev/null
+++ b/apps/overlay/public/assets/floral-vine.svg
@@ -0,0 +1,8 @@
+
diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css
new file mode 100644
index 0000000..907ac44
--- /dev/null
+++ b/apps/overlay/src/control.css
@@ -0,0 +1,535 @@
+.overlay {
+ align-items: flex-start;
+ background: transparent;
+}
+
+html,
+body,
+#root,
+.copy {
+ font-family: "Noto Serif SC", "Microsoft YaHei", "Noto Color Emoji", "Segoe UI Emoji", "Apple Color Emoji", serif;
+}
+
+.wall {
+ justify-content: flex-start;
+}
+
+.card {
+ isolation: isolate;
+}
+
+.card-decor {
+ --decor-primary-image: url("/assets/floral-divider.svg");
+ --decor-primary-position: center 44%;
+ --decor-primary-size: 92% auto;
+ --decor-primary-transform: none;
+ --decor-primary-opacity: .11;
+ --decor-primary-color: linear-gradient(90deg, #e7d0f4 4%, #75e6cc 35%, #d5fff1 50%, #6adcc6 68%, #f4bfd4 96%);
+ --decor-secondary-image: url("/assets/floral-cluster.svg");
+ --decor-secondary-position: right -10px bottom -22px;
+ --decor-secondary-size: 34% auto;
+ --decor-secondary-transform: none;
+ --decor-secondary-opacity: .065;
+ --decor-secondary-color: linear-gradient(135deg, #e9c7f1, #79ead3 62%, #fff0bc);
+ --decor-surface: radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, .09), transparent 58%), linear-gradient(90deg, rgba(230, 203, 241, .04), transparent 18% 82%, rgba(244, 190, 214, .04));
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ overflow: hidden;
+ border-radius: inherit;
+ pointer-events: none;
+ user-select: none;
+}
+
+.card-decor::before,
+.card-decor::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ background: var(--decor-primary-color);
+ -webkit-mask-image: var(--decor-primary-image);
+ mask-image: var(--decor-primary-image);
+ -webkit-mask-position: var(--decor-primary-position);
+ mask-position: var(--decor-primary-position);
+ -webkit-mask-size: var(--decor-primary-size);
+ mask-size: var(--decor-primary-size);
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ opacity: var(--decor-primary-opacity);
+ transform: var(--decor-primary-transform);
+ transform-origin: center;
+ filter: drop-shadow(0 0 6px rgba(105, 255, 224, .28));
+}
+
+.card-decor::after {
+ background: var(--decor-secondary-color);
+ -webkit-mask-image: var(--decor-secondary-image);
+ mask-image: var(--decor-secondary-image);
+ -webkit-mask-position: var(--decor-secondary-position);
+ mask-position: var(--decor-secondary-position);
+ -webkit-mask-size: var(--decor-secondary-size);
+ mask-size: var(--decor-secondary-size);
+ opacity: var(--decor-secondary-opacity);
+ transform: var(--decor-secondary-transform);
+}
+
+.card-decor-surface {
+ position: absolute;
+ inset: 2px;
+ z-index: 0;
+ border: 1px solid rgba(157, 255, 232, .14);
+ border-radius: inherit;
+ background: var(--decor-surface);
+ box-shadow:
+ inset 0 1px rgba(225, 255, 247, .1),
+ inset 0 -1px rgba(82, 224, 198, .08);
+}
+
+.decor-v1 {
+ --decor-primary-position: center 68%;
+ --decor-primary-size: 86% auto;
+ --decor-primary-transform: rotate(180deg);
+ --decor-primary-color: linear-gradient(90deg, #ffeab1, #79e2cc 45%, #d7fff3 70%, #e8c6f2);
+ --decor-secondary-position: right -12px bottom -20px;
+ --decor-secondary-size: 31% auto;
+ --decor-secondary-transform: rotate(180deg);
+ --decor-secondary-opacity: .075;
+ --decor-secondary-color: linear-gradient(145deg, #ffe9aa, #83e9d2 62%, #efd0f5);
+ --decor-surface: radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, .08), transparent 56%), linear-gradient(90deg, rgba(112, 232, 208, .045), transparent 34% 76%, rgba(236, 200, 243, .04));
+}
+
+.decor-v2 {
+ --decor-primary-image: url("/assets/floral-vine.svg");
+ --decor-primary-position: left -12px bottom -13px;
+ --decor-primary-size: 73% auto;
+ --decor-primary-opacity: .105;
+ --decor-primary-color: linear-gradient(110deg, #83ead5, #d8fff3 54%, #cbb9e9);
+ --decor-secondary-image: url("/assets/floral-divider.svg");
+ --decor-secondary-position: right -22px top -18px;
+ --decor-secondary-size: 62% auto;
+ --decor-secondary-opacity: .055;
+ --decor-secondary-color: linear-gradient(90deg, #f5c9dc, #8be9d7 68%, #fff1bd);
+ --decor-surface: radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, .09), transparent 54%), linear-gradient(105deg, rgba(217, 198, 241, .045), transparent 58%);
+}
+
+.decor-v3 {
+ --decor-primary-image: url("/assets/floral-vine.svg");
+ --decor-primary-position: left -15px bottom -15px;
+ --decor-primary-size: 77% auto;
+ --decor-primary-transform: scaleX(-1);
+ --decor-primary-opacity: .115;
+ --decor-primary-color: linear-gradient(100deg, #f2bfd7, #8aead8 48%, #d6fff0);
+ --decor-secondary-position: right -12px bottom -20px;
+ --decor-secondary-size: 32% auto;
+ --decor-secondary-opacity: .07;
+ --decor-secondary-color: linear-gradient(145deg, #d9c1ed, #6fe0c9 58%, #ffe8ad);
+ --decor-surface: radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, .075), transparent 54%), linear-gradient(270deg, rgba(103, 228, 204, .05), transparent 64%);
+}
+
+.decor-v4 {
+ --decor-primary-image: url("/assets/floral-cluster.svg");
+ --decor-primary-position: left -18px bottom -28px;
+ --decor-primary-size: 45% auto;
+ --decor-primary-transform: rotate(-5deg);
+ --decor-primary-opacity: .09;
+ --decor-primary-color: linear-gradient(135deg, #9cecd8, #fff0bd 57%, #edc5e4);
+ --decor-secondary-position: left -15px bottom -25px;
+ --decor-secondary-size: 37% auto;
+ --decor-secondary-transform: rotate(180deg);
+ --decor-secondary-opacity: .065;
+ --decor-secondary-color: linear-gradient(145deg, #dec4ee, #72dfc7 66%, #fff0b6);
+ --decor-surface: radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, .085), transparent 47%), radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, .06), transparent 44%);
+}
+
+.decor-v5 {
+ --decor-primary-image: url("/assets/floral-vine.svg");
+ --decor-primary-position: center top -17px;
+ --decor-primary-size: 90% auto;
+ --decor-primary-transform: scaleY(-1);
+ --decor-primary-opacity: .095;
+ --decor-primary-color: linear-gradient(90deg, #d9c4ed, #82e5d2 44%, #f8e7b5 78%, #efc2d8);
+ --decor-secondary-image: url("/assets/floral-divider.svg");
+ --decor-secondary-position: left 18% top -15px;
+ --decor-secondary-size: 55% auto;
+ --decor-secondary-opacity: .06;
+ --decor-secondary-color: linear-gradient(90deg, #fff0bc, #86e7d5 64%, #e4c6f0);
+ --decor-surface: radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, .065), transparent 56%), linear-gradient(90deg, rgba(232, 199, 239, .04), transparent 52%, rgba(255, 229, 168, .035));
+}
+
+.card::before {
+ z-index: 1;
+ pointer-events: none;
+}
+
+.card > .gift-art,
+.card > .copy,
+.card > .particles {
+ position: relative;
+ z-index: 3;
+}
+
+.card-particle-layer {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ opacity: .5;
+ transition: opacity .3s ease;
+}
+
+.decor-v1 .card-particle-layer,
+.decor-v3 .card-particle-layer {
+ transform: scaleX(-1);
+}
+
+.decor-v2 .card-particle-layer {
+ transform: scaleY(-1);
+}
+
+.decor-v5 .card-particle-layer {
+ transform: rotate(180deg);
+}
+
+.card.expanded .card-particle-layer {
+ opacity: 1;
+}
+
+.card-particle {
+ --particle-size: 8px;
+ position: absolute;
+ display: block;
+ width: var(--particle-size);
+ height: var(--particle-size);
+ opacity: 0;
+ will-change: transform, opacity;
+ animation: card-sparkle var(--particle-duration, 4000ms) ease-in-out infinite;
+}
+
+.card-particle.star {
+ background: linear-gradient(135deg, #fff7ca, #b7fff0 58%, #f5d4ff);
+ clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
+ filter: drop-shadow(0 0 4px rgba(190, 255, 240, .9));
+}
+
+.card-particle.floret {
+ border-radius: 50%;
+ background:
+ radial-gradient(circle at 50% 17%, #ffd7e5 0 19%, transparent 22%),
+ radial-gradient(circle at 82% 50%, #ffd7e5 0 19%, transparent 22%),
+ radial-gradient(circle at 50% 83%, #ffd7e5 0 19%, transparent 22%),
+ radial-gradient(circle at 18% 50%, #ffd7e5 0 19%, transparent 22%),
+ radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%);
+ filter: drop-shadow(0 0 4px rgba(255, 202, 226, .72));
+}
+
+.card-particle:nth-child(1) { left: 2%; top: 14%; animation-delay: -.35s; }
+.card-particle:nth-child(2) { right: 3%; top: 16%; --particle-size: 10px; animation-delay: -1.15s; }
+.card-particle:nth-child(3) { left: 4%; bottom: 13%; --particle-size: 7px; animation-delay: -2.4s; }
+.card-particle:nth-child(4) { right: 2%; bottom: 15%; --particle-size: 8px; animation-delay: -.75s; }
+.card-particle:nth-child(5) { left: 22%; top: 5%; --particle-size: 9px; animation-delay: -3.15s; }
+.card-particle:nth-child(6) { right: 24%; top: 8%; --particle-size: 6px; animation-delay: -1.65s; }
+.card-particle:nth-child(7) { left: 47%; bottom: 4%; --particle-size: 8px; animation-delay: -2.75s; }
+.card-particle:nth-child(8) { right: 43%; top: 4%; --particle-size: 7px; animation-delay: -.15s; }
+.card-particle:nth-child(9) { left: 34%; top: 46%; --particle-size: 6px; animation-delay: -1.95s; }
+.card-particle:nth-child(10) { right: 32%; bottom: 30%; --particle-size: 9px; animation-delay: -3.55s; }
+.card-particle:nth-child(11) { left: 12%; top: 48%; --particle-size: 7px; animation-delay: -.95s; }
+.card-particle:nth-child(12) { right: 13%; top: 52%; --particle-size: 6px; animation-delay: -2.2s; }
+
+@keyframes card-sparkle {
+ 0%, 100% { opacity: .04; transform: translate3d(0, 4px, 0) rotate(0) scale(.45); }
+ 38% { opacity: .82; transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08); }
+ 68% { opacity: .22; transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(.7); }
+}
+
+.narrow .card-particle-layer > :nth-child(n+9),
+.short .card-particle-layer > :nth-child(n+7) {
+ display: none;
+}
+
+.low-motion .card-particle-layer {
+ display: none;
+}
+
+.wall header {
+ max-width: 100%;
+ padding: 9px 14px;
+ font-size: var(--font-title, 25px);
+}
+
+.wall header span {
+ min-width: 0;
+ overflow: visible;
+ overflow-wrap: anywhere;
+ text-overflow: clip;
+ white-space: normal;
+}
+
+.wall:not(.awake) header,
+.wall header span,
+.copy,
+.copy b,
+.copy span,
+.copy em {
+ opacity: 1;
+}
+
+.copy {
+ font-size: var(--font-body, 25px);
+}
+
+.copy span {
+ color: #e7fff9;
+}
+
+.danmaku-content {
+ line-height: 1.35;
+}
+
+.card.danmaku .copy .danmaku-content .danmaku-text {
+ display: inline;
+ white-space: inherit;
+}
+
+.danmaku-emoticon {
+ display: inline-block;
+ width: auto;
+ height: 1.4em;
+ max-width: 6em;
+ margin-inline: .08em;
+ object-fit: contain;
+ vertical-align: -.32em;
+ opacity: 1;
+ filter: none;
+}
+
+.danmaku-emoticon.standalone {
+ display: block;
+ width: auto;
+ height: auto;
+ max-width: 100%;
+ max-height: 4.8em;
+ margin: .12em 0;
+ vertical-align: top;
+}
+
+.card.danmaku.compact .danmaku-emoticon.standalone {
+ display: inline-block;
+ max-width: 5em;
+ max-height: 2.2em;
+ margin: 0 .1em;
+ vertical-align: -.55em;
+}
+
+.copy b,
+.copy span {
+ max-width: none;
+ overflow: visible;
+ overflow-wrap: anywhere;
+ text-overflow: clip;
+ white-space: normal;
+}
+
+.card.danmaku {
+ isolation: isolate;
+ transform-origin: center center;
+ transition: min-height .24s ease, padding .24s ease, border-radius .24s ease, background .24s ease;
+}
+
+.card.danmaku.expanded {
+ min-height: 92px;
+ padding: 14px;
+ border-color: rgba(133, 255, 232, .58);
+ background:
+ linear-gradient(90deg, rgba(91, 224, 199, .13), transparent 14% 86%, rgba(91, 224, 199, .13)),
+ linear-gradient(115deg, rgba(4, 43, 59, .94), rgba(9, 82, 81, .78));
+ box-shadow:
+ inset 12px 0 18px -15px rgba(142, 255, 230, .95),
+ inset -12px 0 18px -15px rgba(142, 255, 230, .95),
+ 0 10px 28px rgba(0, 15, 25, .32);
+ animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
+}
+
+.card.danmaku.expanded::after {
+ content: "";
+ position: absolute;
+ inset-block: 4px;
+ inset-inline: -1px;
+ z-index: 2;
+ pointer-events: none;
+ border-inline: 4px solid rgba(123, 238, 214, .72);
+ border-radius: 11px;
+ background:
+ linear-gradient(90deg,
+ rgba(201, 255, 240, .32) 0,
+ rgba(63, 180, 165, .26) 5px,
+ transparent 5px,
+ transparent calc(100% - 5px),
+ rgba(63, 180, 165, .26) calc(100% - 5px),
+ rgba(201, 255, 240, .32) 100%);
+ box-shadow:
+ inset 6px 0 7px -7px rgba(216, 255, 246, .72),
+ inset -6px 0 7px -7px rgba(216, 255, 246, .72),
+ -2px 0 0 rgba(7, 46, 53, .72),
+ 2px 0 0 rgba(7, 46, 53, .72);
+ animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
+}
+
+.card.danmaku.expanded .copy {
+ z-index: 3;
+ width: 100%;
+ padding-inline: 6px;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+ font-size: var(--font-expanded, 36px);
+}
+
+.card.danmaku.expanded .copy b {
+ color: #f0fffb;
+ font-size: .78em;
+ letter-spacing: .06em;
+}
+
+.card.danmaku.expanded .copy span {
+ display: block;
+ overflow: visible;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ -webkit-line-clamp: unset;
+}
+
+.card.danmaku.compact {
+ min-height: 38px;
+ padding: 5px 10px;
+ border-radius: 10px;
+ background: linear-gradient(115deg, rgba(4, 34, 49, .72), rgba(8, 69, 72, .48));
+}
+
+.card.danmaku.compact .copy {
+ width: 100%;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 8px;
+ font-size: var(--font-compact, 21px);
+}
+
+.card.danmaku.compact .copy b {
+ max-width: none;
+ flex: 0 0 auto;
+ color: #aeece1;
+}
+
+.card.danmaku.compact .copy span {
+ min-width: 0;
+ flex: 1 1 auto;
+ overflow: visible;
+ overflow-wrap: anywhere;
+ text-overflow: clip;
+ white-space: normal;
+}
+
+.short .cards .card:nth-child(n+4) {
+ display: flex;
+}
+
+@keyframes scroll-unfurl {
+ from {
+ clip-path: inset(0 49.5% round 18px);
+ }
+ to {
+ clip-path: inset(0 round 14px);
+ }
+}
+
+@keyframes scroll-rails-open {
+ from {
+ transform: scaleX(.025);
+ }
+ to {
+ transform: scaleX(1);
+ }
+}
+
+@keyframes arrive {
+ from { transform: translateX(38px) scale(.96); }
+ to { transform: none; }
+}
+
+.overlay.narrow { padding: 6px; }
+
+.narrow .card.danmaku.expanded {
+ min-height: 82px;
+ padding: 12px;
+}
+
+.gift-art {
+ width: 96px;
+ height: 96px;
+}
+
+.narrow .gift-art {
+ width: 72px;
+ height: 72px;
+}
+
+.narrow .gift.featured .gift-art {
+ width: 100px;
+ height: 100px;
+}
+
+.buttons,
+.preset-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+.preset-buttons button {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.preset-buttons small {
+ font-weight: normal;
+ opacity: .72;
+}
+
+.preset-buttons .active {
+ box-shadow: 0 0 0 2px rgba(137, 255, 226, .28);
+}
+
+.control .obs-address {
+ margin-top: 16px;
+}
+
+.control .obs-address input {
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 12px;
+}
+
+.control .success {
+ color: #77f5c9;
+}
+
+.preview-viewport {
+ max-width: 100%;
+ max-height: 75vh;
+ overflow: auto;
+ margin-top: 14px;
+ border: 1px solid rgba(89, 183, 173, .25);
+ background: #04131b;
+}
+
+.preview-frame {
+ max-width: none;
+ height: auto;
+ resize: both;
+ overflow: hidden;
+}
+
+@media (max-width: 720px) {
+ .preview-viewport { max-height: 560px; }
+}
diff --git a/apps/overlay/src/main.tsx b/apps/overlay/src/main.tsx
new file mode 100644
index 0000000..90ee944
--- /dev/null
+++ b/apps/overlay/src/main.tsx
@@ -0,0 +1,68 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { createRoot } from 'react-dom/client'
+import './style.css'
+import './control.css'
+
+type Settings = { title:string; fontScale:number; showDanmaku:boolean; showEnter:boolean; showGift:boolean; showSuperchat:boolean; showGuard:boolean; showLike:boolean; showShare:boolean; maxVisible:number; collapseAfterSeconds:number; unfoldDurationMs:number; motionIntensity:number; particleCount:number; particleSpeed:number; lowPerformanceMode:boolean; highValueThreshold:number; featuredValueThreshold:number }
+type Envelope = { id:string; type:string; payload:any }
+type Item = Envelope & { key:string; received:number; decorVariant:number }
+type DanmakuSegment = { type:'text'; text:string } | { type:'emoticon'; text:string; unique?:string; url:string; width?:number; height?:number; isDynamic?:boolean; standalone?:boolean }
+const defaults: Settings = { title:'洛星瓷专用弹幕猪!', fontScale:140, showDanmaku:true, showEnter:true, showGift:true, showSuperchat:true, showGuard:true, showLike:false, showShare:false, maxVisible:5, collapseAfterSeconds:12, unfoldDurationMs:1000, motionIntensity:70, particleCount:8, particleSpeed:100, lowPerformanceMode:false, highValueThreshold:10000, featuredValueThreshold:100000 }
+const cardParticles=['star','floret','star','star','floret','star','floret','star','star','floret','star','floret'] as const
+const decorVariantCount=6
+
+function stableHash(value:string) { let hash=2166136261; for(let index=0;index>>0 }
+function chooseDecorVariant(seed:string,previous?:number) { const hash=stableHash(seed); const base=hash%decorVariantCount; if(previous===undefined||base!==previous)return base; return (base+1+((hash>>>8)%(decorVariantCount-1)))%decorVariantCount }
+function CardDecor({count,variant}:{count:number;variant:number}) { const visible=Math.min(cardParticles.length,Math.max(0,Math.round(count||0))); const normalized=((variant%decorVariantCount)+decorVariantCount)%decorVariantCount; return {cardParticles.slice(0,visible).map((kind,index)=>)}
}
+
+function wsUrl() { const p = new URLSearchParams(location.search); const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${location.host}/ws?token=${encodeURIComponent(p.get('token') || '')}` }
+function enabled(type:string, s:Settings) { return (type==='live.danmaku'&&s.showDanmaku)||(type==='live.enter'&&s.showEnter)||(type.startsWith('live.gift')&&s.showGift)||(type==='live.superchat'&&s.showSuperchat)||(type==='live.guard.buy'&&s.showGuard)||(type==='live.like'&&s.showLike)||(type==='live.share'&&s.showShare) }
+function useEvents(disabled=false) {
+ const [settings,setSettings]=useState(defaults); const [items,setItems]=useState- ([]); const [connected,setConnected]=useState(false); const settingsRef=useRef(settings)
+ useEffect(()=>{settingsRef.current=settings},[settings])
+ useEffect(()=>{ if(disabled)return; let dead=false; let socket:WebSocket|undefined; let timer=0
+ const open=()=>{ socket=new WebSocket(wsUrl()); socket.onopen=()=>setConnected(true); socket.onclose=()=>{setConnected(false); if(!dead) timer=window.setTimeout(open,1500)}; socket.onmessage=e=>{ try { const x:Envelope=JSON.parse(e.data); if(x.type==='overlay.settings.snapshot'||x.type==='overlay.settings.updated'){setSettings(x.payload.settings);return} setItems(old=>{ const current=settingsRef.current; if(!enabled(x.type,current))return old; const combo=x.type==='live.gift.combo'&&x.payload.comboId; const key=combo?`combo:${combo}`:x.id; const existing=old.find(v=>v.key===key); const decorVariant=existing?.decorVariant??chooseDecorVariant(`${x.type}:${key}`,old[0]?.decorVariant); const next=[{...x,key,received:Date.now(),decorVariant},...old.filter(v=>v.key!==key)].slice(0,current.maxVisible); return next }) }catch{} } }
+ open(); return()=>{dead=true;window.clearTimeout(timer);socket?.close()}
+ },[disabled])
+ useEffect(()=>{setItems(items=>items.slice(0,settings.maxVisible))},[settings.maxVisible])
+ return {settings,items,connected,setItems}
+}
+function giftTier(item:Item,s:Settings){const price=item.payload?.gift?.totalPrice||0;return price>=s.featuredValueThreshold?'featured':price>=s.highValueThreshold?'high':'normal'}
+const eventRenderers:Recordstring>={
+ 'live.enter':()=> '踏入了云台','live.superchat':p=>p.message,
+ 'live.guard.buy':p=>`开通 ${p.guardName||'舰长'}`,'live.like':()=> '点亮了一颗星','live.share':()=> '分享了直播间'
+}
+function DanmakuEmoticon({segment}:{segment:Extract}) { const [failed,setFailed]=useState(false); if(failed)return <>{segment.text}>; return
setFailed(true)}/> }
+function DanmakuBody({payload}:{payload:any}) { const segments=Array.isArray(payload.segments)?payload.segments as DanmakuSegment[]:undefined; if(!segments?.length)return <>{payload.text||''}>; return <>{segments.map((segment,index)=>segment.type==='emoticon'&&segment.url?:{segment.text})}> }
+function Card({item,settings,expanded}:{item:Item;settings:Settings;expanded:boolean}) { const p=item.payload||{}; const v=p.viewer||{}; const gift=p.gift; const isDanmaku=item.type==='live.danmaku'; const tier=gift?giftTier(item,settings):''; const body=gift?`献上 ${gift.name} ×${p.quantity||1}`:isDanmaku?:eventRenderers[item.type]?.(p)||'送来了一份互动';
+ return
+
+ {gift&&{gift.animationUrl||gift.imageUrl?

{const image=e.currentTarget;if(gift.imageUrl&&!image.src.endsWith(gift.imageUrl))image.src=gift.imageUrl;else image.style.display='none'}}/>:
✦}
}
+ {v.name||'直播间观众'}{body}{gift?.priceCny>0&&¥ {Number(gift.priceCny).toFixed(2)}}
{tier==='featured'&&✦ ✧ ✦
}
+ }
+function Overlay({preview=false,previewSettings}:{preview?:boolean;previewSettings?:Settings}) { const root=useRef(null); const events=useEvents(preview); const {items,connected,setItems}=events; const settings=previewSettings||events.settings; const [shape,setShape]=useState('standard'); const [expandedKey,setExpandedKey]=useState(); const fontFactor=settings.fontScale/100
+ useEffect(()=>{if(!root.current)return;const ob=new ResizeObserver(([entry])=>{const {width,height}=entry.contentRect;setShape(width<380?'narrow':height<420?'short':'standard')});ob.observe(root.current);return()=>ob.disconnect()},[])
+ useEffect(()=>{if(preview&&!items.length)setItems([{id:'text-preview',key:'text-preview',received:Date.now(),decorVariant:0,type:'live.danmaku',payload:{viewer:{name:'青玉观众'},text:'今天也要闪闪发光!'}},{id:'gift-preview',key:'gift-preview',received:Date.now()-1000,decorVariant:3,type:'live.gift',payload:{viewer:{name:'星光旅人'},quantity:1,gift:{name:'甜蜜告白',totalPrice:12000,priceCny:12,imageUrl:'',animationUrl:''}}}])},[preview,items.length,setItems])
+ useEffect(()=>{const newest=items[0];if(!newest){setExpandedKey(undefined);return}setExpandedKey(newest.key);const densityFactor=shape==='short'?.6:1;const timer=window.setTimeout(()=>setExpandedKey(key=>key===newest.key?undefined:key),settings.collapseAfterSeconds*1000*densityFactor);return()=>window.clearTimeout(timer)},[items[0]?.key,items[0]?.received,settings.collapseAfterSeconds,shape])
+ return }
+function api(url:string, init?:RequestInit){return fetch(url,{credentials:'same-origin',headers:{'content-type':'application/json',...(init?.headers||{})},...init})}
+async function copyToClipboard(text:string){
+ if(window.isSecureContext&&navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(text);return true}catch{}}
+ const input=document.createElement('textarea');input.value=text;input.readOnly=true;input.style.position='fixed';input.style.left='-9999px';input.style.opacity='0';document.body.appendChild(input);input.focus();input.select()
+ let copied=false;try{copied=document.execCommand('copy')}finally{input.remove()}
+ return copied
+}
+const previewPresets=[{label:'窄侧栏',width:360,height:600},{label:'竖屏',width:440,height:760},{label:'高清竖栏',width:600,height:1080},{label:'横向条',width:720,height:320}]
+function Control(){
+ const [password,setPassword]=useState(''); const [settings,setSettings]=useState(); const [error,setError]=useState(''); const [message,setMessage]=useState(''); const [obsAddress,setObsAddress]=useState(''); const [previewSize,setPreviewSize]=useState(previewPresets[1])
+ const load=useCallback(async()=>{const r=await api('/api/admin/overlay-settings');if(!r.ok)throw new Error(r.status===401?'请输入管理员密码':'无法读取设置');setSettings(await r.json())},[])
+ useEffect(()=>{load().catch(()=>{})},[load])
+ const login=async(e:React.FormEvent)=>{e.preventDefault();const r=await api('/api/auth/login',{method:'POST',body:JSON.stringify({password})});if(!r.ok){setError('密码不正确');return}setError('');await load()}
+ const save=async()=>{if(!settings)return;const r=await api('/api/admin/overlay-settings',{method:'PUT',body:JSON.stringify(settings)});if(!r.ok)setError('保存失败');else setSettings((await r.json()).settings)}
+ const copy=async()=>{setMessage('');const r=await api('/api/admin/obs-url');if(!r.ok){setError('无法获取 OBS 地址,请重新登录');return}const {path}=await r.json();const address=new URL(path,location.origin).toString();setObsAddress(address);if(await copyToClipboard(address)){setError('');setMessage('OBS 地址已复制到剪贴板')}else{setError('浏览器阻止了自动复制,请在下方地址框中手动复制')}}
+ if(!settings)return 弹幕猪控制台
+ const edit=(key:keyof Settings,value:any)=>setSettings({...settings,[key]:value})
+ const labels={showDanmaku:'弹幕',showEnter:'进房',showGift:'礼物',showSuperchat:'醒目留言',showGuard:'舰长',showLike:'点赞',showShare:'分享',lowPerformanceMode:'低性能模式'}
+ return 青玉弹幕姬
改动会立即同步到所有 OBS 浏览器源。
{obsAddress&&}{message&&{message}
}{error&&{error}
}自适应预览
选择常用尺寸后仍可拖拽预览框右下角;OBS 中也可使用任意宽高。
{previewPresets.map(size=>)}
+}
+const isControl=location.pathname.startsWith('/control');createRoot(document.getElementById('root')!).render(isControl?:);
diff --git a/apps/overlay/src/style.css b/apps/overlay/src/style.css
new file mode 100644
index 0000000..d3ea376
--- /dev/null
+++ b/apps/overlay/src/style.css
@@ -0,0 +1 @@
+*{box-sizing:border-box}html,body,#root{margin:0;width:100%;height:100%;font-family:"Noto Serif SC","Microsoft YaHei",serif}body{background:transparent;color:#dcfffa}.overlay{width:100%;height:100%;padding:clamp(8px,2vw,22px);display:flex;align-items:center;justify-content:flex-end;overflow:hidden;background:radial-gradient(ellipse at 100% 50%,rgba(21,94,96,.2),transparent 62%)}.wall{width:min(100%,480px);display:flex;flex-direction:column;gap:9px;filter:drop-shadow(0 10px 28px rgba(0,11,19,.36))}.wall header{align-self:flex-end;display:flex;gap:8px;align-items:center;padding:8px 14px;border:1px solid rgba(144,255,240,.33);border-radius:999px;background:linear-gradient(110deg,rgba(13,47,63,.72),rgba(29,115,107,.44));backdrop-filter:blur(12px);letter-spacing:.08em;font-size:clamp(12px,2.5vw,17px);transition:.5s}.wall:not(.awake) header{opacity:.72}.wall header i{width:7px;height:7px;border-radius:50%;background:#55736f}.wall header i.online{background:#74ffd9;box-shadow:0 0 10px #4bffc7}.cards{display:flex;flex-direction:column;gap:8px}.card{position:relative;min-height:58px;padding:11px 14px;border:1px solid rgba(101,226,211,.28);border-radius:14px;overflow:hidden;display:flex;align-items:center;gap:10px;background:linear-gradient(115deg,rgba(4,34,49,.82),rgba(8,69,72,.61));backdrop-filter:blur(15px);animation:arrive calc(.35s + .35s*var(--motion)) cubic-bezier(.19,.9,.3,1) both}.card:before{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 25%,rgba(155,255,232,.14),transparent 65%);transform:translateX(-120%);animation:sheen calc(2.4s - 1.3s*var(--motion)) ease-in-out .25s both}.copy{position:relative;min-width:0;display:flex;flex-direction:column;gap:3px;font-size:clamp(12px,2.7vw,17px)}.copy b{color:#e6fff9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy span{color:#a9dbd4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy em{font-style:normal;color:#ffdc89;font-size:.84em}.gift-art{z-index:1;width:48px;height:48px;flex:none;border-radius:12px;background:radial-gradient(circle,#2e9f9e,transparent 66%);display:grid;place-items:center}.gift-art img{width:100%;height:100%;object-fit:contain}.gift.high{min-height:72px;border-color:rgba(102,255,229,.65);background:linear-gradient(105deg,rgba(5,59,78,.92),rgba(14,119,103,.7))}.gift.featured{min-height:128px;border-color:#b6ffdc;background:radial-gradient(circle at 20% 50%,rgba(69,236,190,.4),transparent 35%),linear-gradient(118deg,rgba(7,63,84,.97),rgba(12,105,89,.85));animation:featured calc(2.8s - 1.5s*var(--motion)) ease-in-out infinite}.gift.featured .gift-art{width:92px;height:92px}.particles{position:absolute;right:9px;top:6px;color:#ceffe4;letter-spacing:9px;animation:float 2.2s ease-in-out infinite}.narrow .overlay{padding:6px}.narrow .wall{gap:5px}.narrow .card{min-height:45px;padding:7px 9px;border-radius:10px}.narrow .gift-art{width:34px;height:34px}.narrow .gift.featured{min-height:90px;align-items:flex-start}.narrow .gift.featured .gift-art{width:58px;height:58px}.short .cards .card:nth-child(n+4){display:none}.low-motion *{animation:none!important}.control{min-height:100%;padding:clamp(20px,5vw,56px);display:grid;grid-template-columns:minmax(300px,520px) minmax(280px,1fr);gap:36px;background:linear-gradient(135deg,#061923,#092c32 55%,#06151f);color:#d9fff6}.control h1{margin:0;color:#bffff0}.control h2{margin:0}.control p{color:#88bdb5}.control label{display:block;margin:14px 0;color:#bcebe3}.control input:not([type=checkbox]):not([type=range]){width:100%;padding:10px;margin-top:5px;border:1px solid #357e79;border-radius:8px;background:#071d28;color:#e6fff9}.control input[type=range]{margin-left:10px;accent-color:#5ae7c6}.control output{margin-left:8px}.control fieldset{border:1px solid #28645f;border-radius:10px;display:flex;flex-wrap:wrap;gap:3px 12px}.control fieldset label{margin:8px 0}.buttons{display:flex;gap:10px;margin-top:20px}.control button{padding:10px 14px;border:0;border-radius:8px;background:#55dcb9;color:#06211e;font-weight:bold;cursor:pointer}.control button.secondary{background:#173e4b;color:#d4fff7}.preview-frame{height:600px;resize:both;overflow:auto;border:1px dashed #4dafa4;background:linear-gradient(135deg,rgba(71,190,172,.12),transparent)}.login{display:grid;place-content:center;grid-template-columns:360px;background:#071923}.login form{display:flex;flex-direction:column;gap:12px}@keyframes arrive{from{opacity:0;transform:translateX(38px) scale(.96)}to{opacity:1;transform:none}}@keyframes sheen{to{transform:translateX(135%)}}@keyframes featured{50%{filter:brightness(1.18);transform:scale(1.015)}}@keyframes float{50%{transform:translateY(-8px);opacity:.5}}@media(max-width:720px){.control{grid-template-columns:1fr}.preview-frame{height:480px}}
diff --git a/apps/overlay/tsconfig.json b/apps/overlay/tsconfig.json
new file mode 100644
index 0000000..0b4fc59
--- /dev/null
+++ b/apps/overlay/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2022"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" },
+ "include": ["src"]
+}
diff --git a/apps/overlay/vite.config.ts b/apps/overlay/vite.config.ts
new file mode 100644
index 0000000..ecc7ca4
--- /dev/null
+++ b/apps/overlay/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+export default defineConfig({ plugins: [react()] })
diff --git a/apps/server-rust/Cargo.lock b/apps/server-rust/Cargo.lock
index ad4ba5c..0f3a7e2 100644
--- a/apps/server-rust/Cargo.lock
+++ b/apps/server-rust/Cargo.lock
@@ -1307,6 +1307,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1841,6 +1847,7 @@ dependencies = [
"tokio",
"tokio-postgres",
"toml",
+ "tower-http",
"tracing",
"tracing-subscriber",
"uuid",
@@ -1898,6 +1905,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -3660,10 +3677,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.13.0",
"bytes",
+ "futures-core",
"futures-util",
"http 1.4.2",
"http-body 1.1.0",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
"pin-project-lite",
+ "tokio",
+ "tokio-util",
"tower",
"tower-layer",
"tower-service",
@@ -3815,6 +3841,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-bidi"
version = "0.3.18"
diff --git a/apps/server-rust/Cargo.toml b/apps/server-rust/Cargo.toml
index f1e48b9..515ee23 100644
--- a/apps/server-rust/Cargo.toml
+++ b/apps/server-rust/Cargo.toml
@@ -22,6 +22,7 @@ sha2 = "0.10"
tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-serde_json-1"] }
toml = "0.8"
+tower-http = { version = "0.6", features = ["fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v4", "serde"] }
diff --git a/apps/server-rust/migrations/001_initial.sql b/apps/server-rust/migrations/001_initial.sql
index 3c72302..dbe6c9e 100644
--- a/apps/server-rust/migrations/001_initial.sql
+++ b/apps/server-rust/migrations/001_initial.sql
@@ -1,19 +1,3 @@
-CREATE TABLE IF NOT EXISTS viewer_accounts (
- scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, display_name TEXT NOT NULL,
- points INTEGER NOT NULL DEFAULT 0 CHECK(points >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- PRIMARY KEY(scope, room_id, uid)
-);
-CREATE TABLE IF NOT EXISTS point_ledger (
- id UUID PRIMARY KEY, scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, delta INTEGER NOT NULL,
- reason TEXT NOT NULL, source_event_id TEXT UNIQUE, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-CREATE TABLE IF NOT EXISTS wheel_spins (
- id UUID PRIMARY KEY, scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, category TEXT NOT NULL,
- song_id INTEGER NOT NULL, song_title TEXT NOT NULL, fallback BOOLEAN NOT NULL, cost INTEGER NOT NULL, balance_after INTEGER NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-CREATE INDEX IF NOT EXISTS viewer_accounts_name_idx ON viewer_accounts(scope, room_id, display_name);
-CREATE INDEX IF NOT EXISTS point_ledger_recent_idx ON point_ledger(scope, room_id, created_at DESC);
CREATE TABLE IF NOT EXISTS live_session_outbox (
id BIGSERIAL PRIMARY KEY,
streamer_uid BIGINT NOT NULL,
diff --git a/apps/server-rust/migrations/002_overlay_settings.sql b/apps/server-rust/migrations/002_overlay_settings.sql
new file mode 100644
index 0000000..de46cc1
--- /dev/null
+++ b/apps/server-rust/migrations/002_overlay_settings.sql
@@ -0,0 +1,5 @@
+CREATE TABLE IF NOT EXISTS overlay_settings (
+ room_id TEXT PRIMARY KEY,
+ settings JSONB NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/apps/server-rust/src/main.rs b/apps/server-rust/src/main.rs
index de6aa69..3128798 100644
--- a/apps/server-rust/src/main.rs
+++ b/apps/server-rust/src/main.rs
@@ -1,140 +1,1285 @@
-use std::{env, fs, path::PathBuf, sync::{Arc, atomic::{AtomicBool, Ordering}}};
+use std::{env, fs, path::PathBuf, sync::Arc};
-use axum::{extract::{ws::{Message, WebSocket, WebSocketUpgrade}, Query, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router};
-use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
+use axum::{
+ Json, Router,
+ extract::{
+ Query, State,
+ ws::{Message, WebSocket, WebSocketUpgrade},
+ },
+ http::{HeaderMap, HeaderValue, StatusCode},
+ response::{IntoResponse, Response},
+ routing::{any, get, post},
+};
+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use blivedm::client::{models::BiliMessage, websocket::BiliLiveClient};
use futures_channel::mpsc;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
-use serde_json::{json, Value};
+use serde_json::{Value, json};
use sha2::Sha256;
-use tokio::{sync::{broadcast, Mutex}, task};
+use tokio::{
+ sync::{Mutex, RwLock, broadcast},
+ task,
+};
use tokio_postgres::{Client, NoTls};
+use tower_http::services::{ServeDir, ServeFile};
use tracing::{error, info, warn};
use uuid::Uuid;
type HmacSha256 = Hmac;
-const WHEEL_COST: i32 = 150;
+
+mod overlay;
+use overlay::{EmoticonCatalog, EmoticonMeta, GiftCatalog, OverlaySettings, normalize_image_url};
#[derive(Clone)]
-struct Config { port: u16, room_id: String, database_url: String, songlist_database_url: String, cookiecloud_host: String, cookiecloud_key: String, cookiecloud_password: String, admin_password: String, session_secret: String, obs_access_token: String, reply_enabled: bool, log_filter: String }
+struct Config {
+ port: u16,
+ room_id: String,
+ database_url: String,
+ cookiecloud_host: String,
+ cookiecloud_key: String,
+ cookiecloud_password: String,
+ admin_password: String,
+ session_secret: String,
+ obs_access_token: String,
+ log_filter: String,
+ gift_refresh_seconds: u64,
+ gift_request_timeout_seconds: u64,
+ emoticon_refresh_seconds: u64,
+ emoticon_request_timeout_seconds: u64,
+ overlay_defaults: OverlaySettings,
+}
#[derive(Deserialize)]
-struct FileConfig { connection: ConnectionConfig, #[serde(default)] server: ServerConfig, database: DatabaseConfig, songlist: SonglistConfig, cookiecloud: CookieCloudConfig, admin: AdminConfig, obs: ObsConfig, #[serde(default)] reply: ReplyConfig, #[serde(default)] logging: LoggingConfig }
-#[derive(Deserialize)] struct ConnectionConfig { room_id: String }
-#[derive(Deserialize, Default)] struct ServerConfig { port: Option }
-#[derive(Deserialize)] struct DatabaseConfig { url: String }
-#[derive(Deserialize)] struct SonglistConfig { database_url: String }
-#[derive(Deserialize)] struct CookieCloudConfig { host: String, key: String, password: String }
-#[derive(Deserialize)] struct AdminConfig { password: String, session_secret: String }
-#[derive(Deserialize)] struct ObsConfig { access_token: String }
-#[derive(Deserialize, Default)] struct ReplyConfig { enabled: Option }
-#[derive(Deserialize, Default)] struct LoggingConfig { filter: Option }
+struct FileConfig {
+ connection: ConnectionConfig,
+ #[serde(default)]
+ server: ServerConfig,
+ database: DatabaseConfig,
+ cookiecloud: CookieCloudConfig,
+ admin: AdminConfig,
+ obs: ObsConfig,
+ #[serde(default)]
+ gifts: GiftsConfig,
+ #[serde(default)]
+ emoticons: EmoticonsConfig,
+ #[serde(default)]
+ overlay: OverlayFileConfig,
+ #[serde(default)]
+ logging: LoggingConfig,
+}
+#[derive(Deserialize)]
+struct ConnectionConfig {
+ room_id: String,
+}
+#[derive(Deserialize, Default)]
+struct ServerConfig {
+ port: Option,
+}
+#[derive(Deserialize)]
+struct DatabaseConfig {
+ url: String,
+}
+#[derive(Deserialize)]
+struct CookieCloudConfig {
+ host: String,
+ key: String,
+ password: String,
+}
+#[derive(Deserialize)]
+struct AdminConfig {
+ password: String,
+ session_secret: String,
+}
+#[derive(Deserialize)]
+struct ObsConfig {
+ access_token: String,
+}
+#[derive(Deserialize, Default)]
+struct GiftsConfig {
+ refresh_interval_seconds: Option,
+ request_timeout_seconds: Option,
+}
+#[derive(Deserialize, Default)]
+struct EmoticonsConfig {
+ refresh_interval_seconds: Option,
+ request_timeout_seconds: Option,
+}
+#[derive(Deserialize, Default)]
+struct OverlayFileConfig {
+ title: Option,
+ font_scale: Option,
+ max_visible: Option,
+ collapse_after_seconds: Option,
+ unfold_duration_ms: Option,
+ motion_intensity: Option,
+ particle_count: Option,
+ particle_speed: Option,
+ low_performance_mode: Option,
+ high_value_threshold: Option,
+ featured_value_threshold: Option,
+ #[serde(default)]
+ events: OverlayEventsConfig,
+}
+#[derive(Deserialize, Default)]
+struct OverlayEventsConfig {
+ danmaku: Option,
+ enter: Option,
+ gift: Option,
+ superchat: Option,
+ guard: Option,
+ like: Option,
+ share: Option,
+}
+#[derive(Deserialize, Default)]
+struct LoggingConfig {
+ filter: Option,
+}
impl Config {
fn config_path() -> Result {
let mut args = env::args_os().skip(1);
let mut path = PathBuf::from("config.toml");
while let Some(arg) = args.next() {
- if arg == "--config" { path = PathBuf::from(args.next().ok_or("--config requires a TOML path")?); }
- else { return Err(format!("Unknown argument: {:?}; use --config ", arg)); }
+ if arg == "--config" {
+ path = PathBuf::from(args.next().ok_or("--config requires a TOML path")?);
+ } else {
+ return Err(format!("Unknown argument: {:?}; use --config ", arg));
+ }
}
Ok(path)
}
fn load() -> Result {
let path = Self::config_path()?;
- let source = fs::read_to_string(&path).map_err(|e| format!("Cannot read configuration {}: {e}", path.display()))?;
- let file: FileConfig = toml::from_str(&source).map_err(|e| format!("Invalid TOML in {}: {e}", path.display()))?;
- Ok(Self { port: file.server.port.unwrap_or(9719), room_id: file.connection.room_id, database_url: file.database.url, songlist_database_url: file.songlist.database_url, cookiecloud_host: file.cookiecloud.host, cookiecloud_key: file.cookiecloud.key, cookiecloud_password: file.cookiecloud.password, admin_password: file.admin.password, session_secret: file.admin.session_secret, obs_access_token: file.obs.access_token, reply_enabled: file.reply.enabled.unwrap_or(false), log_filter: file.logging.filter.unwrap_or_else(|| "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into()) })
+ let source = fs::read_to_string(&path)
+ .map_err(|e| format!("Cannot read configuration {}: {e}", path.display()))?;
+ let file: FileConfig = toml::from_str(&source)
+ .map_err(|e| format!("Invalid TOML in {}: {e}", path.display()))?;
+ let overlay_default = OverlaySettings::default();
+ let overlay_defaults = OverlaySettings {
+ title: file.overlay.title.unwrap_or(overlay_default.title),
+ font_scale: file
+ .overlay
+ .font_scale
+ .unwrap_or(overlay_default.font_scale),
+ show_danmaku: file
+ .overlay
+ .events
+ .danmaku
+ .unwrap_or(overlay_default.show_danmaku),
+ show_enter: file
+ .overlay
+ .events
+ .enter
+ .unwrap_or(overlay_default.show_enter),
+ show_gift: file
+ .overlay
+ .events
+ .gift
+ .unwrap_or(overlay_default.show_gift),
+ show_superchat: file
+ .overlay
+ .events
+ .superchat
+ .unwrap_or(overlay_default.show_superchat),
+ show_guard: file
+ .overlay
+ .events
+ .guard
+ .unwrap_or(overlay_default.show_guard),
+ show_like: file
+ .overlay
+ .events
+ .like
+ .unwrap_or(overlay_default.show_like),
+ show_share: file
+ .overlay
+ .events
+ .share
+ .unwrap_or(overlay_default.show_share),
+ max_visible: file
+ .overlay
+ .max_visible
+ .unwrap_or(overlay_default.max_visible),
+ collapse_after_seconds: file
+ .overlay
+ .collapse_after_seconds
+ .unwrap_or(overlay_default.collapse_after_seconds),
+ unfold_duration_ms: file
+ .overlay
+ .unfold_duration_ms
+ .unwrap_or(overlay_default.unfold_duration_ms),
+ motion_intensity: file
+ .overlay
+ .motion_intensity
+ .unwrap_or(overlay_default.motion_intensity),
+ particle_count: file
+ .overlay
+ .particle_count
+ .unwrap_or(overlay_default.particle_count),
+ particle_speed: file
+ .overlay
+ .particle_speed
+ .unwrap_or(overlay_default.particle_speed),
+ low_performance_mode: file
+ .overlay
+ .low_performance_mode
+ .unwrap_or(overlay_default.low_performance_mode),
+ high_value_threshold: file
+ .overlay
+ .high_value_threshold
+ .unwrap_or(overlay_default.high_value_threshold),
+ featured_value_threshold: file
+ .overlay
+ .featured_value_threshold
+ .unwrap_or(overlay_default.featured_value_threshold),
+ }
+ .sanitize();
+ Ok(Self {
+ port: file.server.port.unwrap_or(9719),
+ room_id: file.connection.room_id,
+ database_url: file.database.url,
+ cookiecloud_host: file.cookiecloud.host,
+ cookiecloud_key: file.cookiecloud.key,
+ cookiecloud_password: file.cookiecloud.password,
+ admin_password: file.admin.password,
+ session_secret: file.admin.session_secret,
+ obs_access_token: file.obs.access_token,
+ gift_refresh_seconds: file
+ .gifts
+ .refresh_interval_seconds
+ .unwrap_or(600)
+ .clamp(60, 86_400),
+ gift_request_timeout_seconds: file
+ .gifts
+ .request_timeout_seconds
+ .unwrap_or(10)
+ .clamp(2, 120),
+ emoticon_refresh_seconds: file
+ .emoticons
+ .refresh_interval_seconds
+ .unwrap_or(600)
+ .clamp(60, 86_400),
+ emoticon_request_timeout_seconds: file
+ .emoticons
+ .request_timeout_seconds
+ .unwrap_or(10)
+ .clamp(2, 120),
+ overlay_defaults,
+ log_filter: file.logging.filter.unwrap_or_else(|| {
+ "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into()
+ }),
+ })
}
}
#[derive(Clone)]
-struct AppState { config: Config, events: broadcast::Sender, reply_enabled: Arc, source: Arc> }
+struct AppState {
+ config: Config,
+ events: broadcast::Sender,
+ source: Arc>,
+ overlay_settings: Arc>,
+ gift_catalog: GiftCatalog,
+ emoticon_catalog: EmoticonCatalog,
+}
#[derive(Clone, Default)]
-struct SourceStatus { connected: bool, cookie_cloud: bool, detail: String }
+struct SourceStatus {
+ connected: bool,
+ cookie_cloud: bool,
+ detail: String,
+}
#[derive(Serialize, Clone)]
-struct Viewer { uid: String, name: String }
-#[derive(Clone)]
-enum Incoming { Enter { viewer: Viewer }, Danmaku { viewer: Viewer, text: String }, Gift { viewer: Viewer, name: String, battery: i32, quantity: i32, event_id: String }, Event { kind: String, payload: Value } }
+struct Viewer {
+ uid: String,
+ name: String,
+}
+#[derive(Clone, Debug)]
+struct DanmakuEmoticonHint {
+ text: String,
+ unique: Option,
+ url: Option,
+ width: Option,
+ height: Option,
+ is_dynamic: bool,
+ bulge_display: bool,
+ standalone: bool,
+}
-#[derive(Deserialize)] struct Login { password: String }
-#[derive(Deserialize)] struct ToggleReply { enabled: bool }
-#[derive(Deserialize)] struct ViewerQuery { search: Option }
-#[derive(Deserialize)] struct WsQuery { token: Option }
-#[derive(Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")]
-enum TestEvent { Enter { uid: String, name: String }, Danmaku { uid: String, name: String, text: String }, Gift { uid: String, name: String, #[serde(rename = "giftName")] gift_name: String, battery: i32, quantity: i32 } }
+#[derive(Clone, Debug, Serialize)]
+#[serde(
+ tag = "type",
+ rename_all = "lowercase",
+ rename_all_fields = "camelCase"
+)]
+enum DanmakuSegment {
+ Text {
+ text: String,
+ },
+ Emoticon {
+ text: String,
+ unique: Option,
+ url: String,
+ width: Option,
+ height: Option,
+ is_dynamic: bool,
+ standalone: bool,
+ },
+}
+
+#[derive(Clone, Debug)]
+struct ResolvedDanmakuEmoticon {
+ text: String,
+ unique: Option,
+ url: String,
+ width: Option,
+ height: Option,
+ is_dynamic: bool,
+ standalone: bool,
+}
+
+#[derive(Clone)]
+enum Incoming {
+ Enter {
+ viewer: Viewer,
+ },
+ Danmaku {
+ viewer: Viewer,
+ text: String,
+ emoticons: Vec,
+ },
+ Gift {
+ viewer: Viewer,
+ name: String,
+ gift_id: Option,
+ battery: i32,
+ quantity: i32,
+ event_id: String,
+ },
+ Combo {
+ viewer: Viewer,
+ name: String,
+ gift_id: Option,
+ battery: i32,
+ quantity: i32,
+ combo_id: String,
+ },
+ Event {
+ kind: String,
+ payload: Value,
+ },
+}
+
+#[derive(Deserialize)]
+struct Login {
+ password: String,
+}
+#[derive(Deserialize)]
+struct WsQuery {
+ token: Option,
+}
+#[derive(Deserialize)]
+#[serde(tag = "kind", rename_all = "lowercase")]
+enum TestEvent {
+ Enter {
+ uid: String,
+ name: String,
+ },
+ Danmaku {
+ uid: String,
+ name: String,
+ text: String,
+ },
+ Gift {
+ uid: String,
+ name: String,
+ #[serde(rename = "giftName")]
+ gift_name: String,
+ #[serde(rename = "giftId")]
+ gift_id: Option,
+ battery: i32,
+ quantity: i32,
+ },
+}
#[tokio::main]
async fn main() {
let config = Config::load().unwrap_or_else(|error| panic!("Configuration error: {error}"));
let log_filter = tracing_subscriber::EnvFilter::new(config.log_filter.clone());
- tracing_subscriber::fmt().with_env_filter(log_filter).json().init();
+ tracing_subscriber::fmt()
+ .with_env_filter(log_filter)
+ .json()
+ .init();
let (events, _) = broadcast::channel(256);
- let state = AppState { reply_enabled: Arc::new(AtomicBool::new(config.reply_enabled)), source: Arc::new(Mutex::new(SourceStatus { detail: "Starting blivedm_rs listener".into(), ..Default::default() })), config, events };
- migrate(&state.config.database_url).await.expect("database migration failed");
+ let state = AppState {
+ source: Arc::new(Mutex::new(SourceStatus {
+ detail: "Starting blivedm_rs listener".into(),
+ ..Default::default()
+ })),
+ overlay_settings: Arc::new(RwLock::new(config.overlay_defaults.clone())),
+ gift_catalog: GiftCatalog::default(),
+ emoticon_catalog: EmoticonCatalog::default(),
+ config,
+ events,
+ };
+ migrate(&state.config.database_url)
+ .await
+ .expect("database migration failed");
+ let settings = overlay::load_settings(
+ &state.config.database_url,
+ &state.config.room_id,
+ state.config.overlay_defaults.clone(),
+ )
+ .await
+ .expect("overlay settings load failed");
+ *state.overlay_settings.write().await = settings;
+ if let Err(error) = state
+ .gift_catalog
+ .refresh(
+ &state.config.room_id,
+ state.config.gift_request_timeout_seconds,
+ )
+ .await
+ {
+ warn!(%error, "initial gift catalog refresh failed; retrying in the background");
+ }
+ state.gift_catalog.clone().spawn_refresh(
+ state.config.room_id.clone(),
+ state.config.gift_refresh_seconds,
+ state.config.gift_request_timeout_seconds,
+ );
+ if let Err(error) = refresh_emoticon_catalog(&state).await {
+ warn!(%error, "initial emoticon catalog refresh failed; message metadata will remain available");
+ }
+ spawn_emoticon_catalog_refresh(state.clone());
spawn_live_listener(state.clone());
let app = Router::new()
- .route("/health", get(health)).route("/ws", get(ws))
- .route("/api/auth/login", post(login)).route("/api/auth/logout", post(logout))
- .route("/api/admin/status", get(status)).route("/api/admin/viewers", get(viewers)).route("/api/admin/ledger", get(ledger))
- .route("/api/admin/reconnect", post(reconnect)).route("/api/admin/reply", post(toggle_reply))
- .route("/api/test/event", post(test_event)).with_state(state.clone());
+ .route("/health", get(health))
+ .route("/ws", get(ws))
+ .route("/obs", get(overlay_page))
+ .route("/control", get(overlay_page))
+ .route("/api/auth/login", post(login))
+ .route("/api/auth/logout", post(logout))
+ .route("/api/admin/status", get(status))
+ .route("/api/admin/reconnect", post(reconnect))
+ .route(
+ "/api/admin/overlay-settings",
+ get(get_overlay_settings).put(put_overlay_settings),
+ )
+ .route("/api/admin/obs-url", get(obs_url))
+ .route("/api/test/event", post(test_event))
+ .route("/api/{*path}", any(api_not_found))
+ .fallback_service(
+ ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html")),
+ )
+ .with_state(state.clone());
let addr = format!("0.0.0.0:{}", state.config.port);
- let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind failed");
- info!(%addr, "Rust backend listening"); axum::serve(listener, app).await.expect("server failed");
+ let listener = tokio::net::TcpListener::bind(&addr)
+ .await
+ .expect("bind failed");
+ info!(%addr, "Rust backend listening");
+ axum::serve(listener, app).await.expect("server failed");
}
-async fn migrate(url: &str) -> Result<(), String> { let client = connect(url).await?; client.batch_execute(include_str!("../migrations/001_initial.sql")).await.map_err(|e| e.to_string()) }
-async fn connect(url: &str) -> Result { let (client, connection) = tokio_postgres::connect(url, NoTls).await.map_err(|e| e.to_string())?; tokio::spawn(async move { if let Err(e) = connection.await { warn!(error = %e, "postgres connection ended"); } }); Ok(client) }
-fn session(config: &Config) -> String { let mut mac = HmacSha256::new_from_slice(config.session_secret.as_bytes()).expect("hmac key"); mac.update(b"admin"); URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) }
-fn cookie(headers: &HeaderMap, name: &str) -> Option { headers.get("cookie")?.to_str().ok()?.split(';').find_map(|part| part.trim().split_once('=').filter(|(k, _)| *k == name).map(|(_, v)| v.to_owned())) }
-fn admin(state: &AppState, headers: &HeaderMap) -> bool { cookie(headers, "lxc_session").is_some_and(|v| v == session(&state.config)) }
-fn unauthorized() -> Response { (StatusCode::UNAUTHORIZED, Json(json!({"error":"Administrator authentication required"}))).into_response() }
-fn event(state: &AppState, kind: &str, payload: Value) { let message = json!({"version":1,"id":Uuid::new_v4(),"occurredAt":chrono_now(),"roomId":state.config.room_id,"type":kind,"payload":payload}); let _ = state.events.send(message.to_string()); }
-fn chrono_now() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) }
+async fn migrate(url: &str) -> Result<(), String> {
+ let client = connect(url).await?;
+ client
+ .batch_execute(include_str!("../migrations/001_initial.sql"))
+ .await
+ .map_err(|e| e.to_string())?;
+ client
+ .batch_execute(include_str!("../migrations/002_overlay_settings.sql"))
+ .await
+ .map_err(|e| e.to_string())
+}
+async fn connect(url: &str) -> Result {
+ let (client, connection) = tokio_postgres::connect(url, NoTls)
+ .await
+ .map_err(|e| e.to_string())?;
+ tokio::spawn(async move {
+ if let Err(e) = connection.await {
+ warn!(error = %e, "postgres connection ended");
+ }
+ });
+ Ok(client)
+}
+fn session(config: &Config) -> String {
+ let mut mac = HmacSha256::new_from_slice(config.session_secret.as_bytes()).expect("hmac key");
+ mac.update(b"admin");
+ URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
+}
+fn cookie(headers: &HeaderMap, name: &str) -> Option {
+ headers
+ .get("cookie")?
+ .to_str()
+ .ok()?
+ .split(';')
+ .find_map(|part| {
+ part.trim()
+ .split_once('=')
+ .filter(|(k, _)| *k == name)
+ .map(|(_, v)| v.to_owned())
+ })
+}
+fn admin(state: &AppState, headers: &HeaderMap) -> bool {
+ cookie(headers, "lxc_session").is_some_and(|v| v == session(&state.config))
+}
+fn unauthorized() -> Response {
+ (
+ StatusCode::UNAUTHORIZED,
+ Json(json!({"error":"Administrator authentication required"})),
+ )
+ .into_response()
+}
+fn envelope(state: &AppState, kind: &str, payload: Value) -> String {
+ json!({"version":1,"id":Uuid::new_v4(),"occurredAt":chrono_now(),"roomId":state.config.room_id,"type":kind,"payload":payload}).to_string()
+}
+fn event(state: &AppState, kind: &str, payload: Value) {
+ let _ = state.events.send(envelope(state, kind, payload));
+}
+fn chrono_now() -> String {
+ chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
+}
-async fn health(State(state): State) -> Json { Json(json!({"ok":true,"roomId":state.config.room_id})) }
-async fn login(State(state): State, Json(body): Json) -> Response { if body.password != state.config.admin_password { return (StatusCode::UNAUTHORIZED, Json(json!({"error":"Invalid password"}))).into_response(); } let mut response = Json(json!({"ok":true})).into_response(); response.headers_mut().insert("set-cookie", HeaderValue::from_str(&format!("lxc_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200", session(&state.config))).unwrap()); response }
-async fn logout() -> Response { let mut r = Json(json!({"ok":true})).into_response(); r.headers_mut().insert("set-cookie", HeaderValue::from_static("lxc_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")); r }
-async fn status(State(state): State, headers: HeaderMap) -> Response { if !admin(&state, &headers) { return unauthorized(); } let source = state.source.lock().await.clone(); Json(json!({"roomId":state.config.room_id,"source":{"connected":source.connected,"cookieCloud":source.cookie_cloud,"detail":source.detail},"reply":{"enabled":state.reply_enabled.load(Ordering::Relaxed),"available":true,"detail":"CookieCloud-backed reply ready"},"websocketClients":state.events.receiver_count(),"cookieCloudHost":state.config.cookiecloud_host})).into_response() }
-async fn viewers(State(state): State, headers: HeaderMap, Query(query): Query) -> Response { if !admin(&state, &headers) { return unauthorized(); } let db = match connect(&state.config.database_url).await { Ok(db) => db, Err(e) => return error_response(e) }; let search = format!("%{}%", query.search.unwrap_or_default()); match db.query("SELECT uid,display_name,points FROM viewer_accounts WHERE scope='live' AND room_id=$1 AND (uid ILIKE $2 OR display_name ILIKE $2) ORDER BY updated_at DESC LIMIT 100", &[&state.config.room_id, &search]).await { Ok(rows) => Json(rows.into_iter().map(|r| json!({"uid":r.get::<_, String>(0),"displayName":r.get::<_, String>(1),"points":r.get::<_, i32>(2)})).collect::>()).into_response(), Err(e) => error_response(e.to_string()) } }
-async fn ledger(State(state): State, headers: HeaderMap) -> Response { if !admin(&state, &headers) { return unauthorized(); } let db = match connect(&state.config.database_url).await { Ok(db) => db, Err(e) => return error_response(e) }; match db.query("SELECT l.id::text,l.uid,a.display_name,l.delta,l.reason,l.created_at::text FROM point_ledger l JOIN viewer_accounts a ON(a.scope=l.scope AND a.room_id=l.room_id AND a.uid=l.uid) WHERE l.scope='live' AND l.room_id=$1 ORDER BY l.created_at DESC LIMIT 100", &[&state.config.room_id]).await { Ok(rows) => Json(rows.into_iter().map(|r| json!({"id":r.get::<_, String>(0),"uid":r.get::<_, String>(1),"displayName":r.get::<_, String>(2),"delta":r.get::<_, i32>(3),"reason":r.get::<_, String>(4),"createdAt":r.get::<_, String>(5)})).collect::>()).into_response(), Err(e) => error_response(e.to_string()) } }
-async fn toggle_reply(State(state): State, headers: HeaderMap, Json(body): Json) -> Response { if !admin(&state, &headers) { return unauthorized(); } state.reply_enabled.store(body.enabled, Ordering::Relaxed); Json(json!({"enabled":body.enabled,"available":true,"detail":"CookieCloud-backed reply ready"})).into_response() }
-async fn reconnect(State(state): State, headers: HeaderMap) -> Response { if !admin(&state, &headers) { return unauthorized(); } spawn_live_listener(state.clone()); Json(json!({"ok":true})).into_response() }
-async fn test_event(State(state): State, headers: HeaderMap, Json(body): Json) -> Response { if !admin(&state, &headers) { return unauthorized(); } let message = match body { TestEvent::Enter{uid,name} => Incoming::Enter{viewer:Viewer{uid,name}}, TestEvent::Danmaku{uid,name,text} => Incoming::Danmaku{viewer:Viewer{uid,name},text}, TestEvent::Gift{uid,name,gift_name,battery,quantity} => Incoming::Gift{viewer:Viewer{uid,name},name:gift_name,battery,quantity,event_id:format!("test-{}",Uuid::new_v4())} }; if let Err(e) = process(&state, message, "test").await { return error_response(e); } Json(json!({"ok":true})).into_response() }
-async fn ws(State(state): State, headers: HeaderMap, Query(query): Query, upgrade: WebSocketUpgrade) -> Response { let allowed = admin(&state, &headers) || query.token.as_deref() == Some(&state.config.obs_access_token); if !allowed { return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); } upgrade.on_upgrade(move |socket| ws_loop(socket, state.events.subscribe())) }
-async fn ws_loop(mut socket: WebSocket, mut rx: broadcast::Receiver) { while let Ok(text) = rx.recv().await { if socket.send(Message::Text(text.into())).await.is_err() { break; } } }
-fn error_response(error: String) -> Response { error!(%error, "request failed"); (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"Internal server error"}))).into_response() }
+async fn health(State(state): State) -> Json {
+ Json(
+ json!({"ok":true,"roomId":state.config.room_id,"giftCatalogSize":state.gift_catalog.len().await,"emoticonCatalogSize":state.emoticon_catalog.len().await}),
+ )
+}
+async fn overlay_page() -> Response {
+ match tokio::fs::read("/app/web/index.html").await {
+ Ok(contents) => ([("content-type", "text/html; charset=utf-8")], contents).into_response(),
+ Err(error) => {
+ error!(%error, "overlay entry point is missing");
+ StatusCode::NOT_FOUND.into_response()
+ }
+ }
+}
+async fn api_not_found() -> Response {
+ (
+ StatusCode::NOT_FOUND,
+ Json(json!({"error":"API endpoint not found"})),
+ )
+ .into_response()
+}
+async fn login(State(state): State, Json(body): Json) -> Response {
+ if body.password != state.config.admin_password {
+ return (
+ StatusCode::UNAUTHORIZED,
+ Json(json!({"error":"Invalid password"})),
+ )
+ .into_response();
+ }
+ let mut response = Json(json!({"ok":true})).into_response();
+ response.headers_mut().insert(
+ "set-cookie",
+ HeaderValue::from_str(&format!(
+ "lxc_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200",
+ session(&state.config)
+ ))
+ .unwrap(),
+ );
+ response
+}
+async fn logout() -> Response {
+ let mut r = Json(json!({"ok":true})).into_response();
+ r.headers_mut().insert(
+ "set-cookie",
+ HeaderValue::from_static("lxc_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
+ );
+ r
+}
+async fn status(State(state): State, headers: HeaderMap) -> Response {
+ if !admin(&state, &headers) {
+ return unauthorized();
+ }
+ let source = state.source.lock().await.clone();
+ Json(json!({"roomId":state.config.room_id,"source":{"connected":source.connected,"cookieCloud":source.cookie_cloud,"detail":source.detail},"websocketClients":state.events.receiver_count(),"cookieCloudHost":state.config.cookiecloud_host})).into_response()
+}
+async fn reconnect(State(state): State, headers: HeaderMap) -> Response {
+ if !admin(&state, &headers) {
+ return unauthorized();
+ }
+ spawn_live_listener(state.clone());
+ Json(json!({"ok":true})).into_response()
+}
+async fn get_overlay_settings(State(state): State, headers: HeaderMap) -> Response {
+ if !admin(&state, &headers) {
+ return unauthorized();
+ }
+ Json(state.overlay_settings.read().await.clone()).into_response()
+}
+async fn put_overlay_settings(
+ State(state): State,
+ headers: HeaderMap,
+ Json(settings): Json,
+) -> Response {
+ if !admin(&state, &headers) {
+ return unauthorized();
+ }
+ let settings = settings.sanitize();
+ if let Err(error) =
+ overlay::save_settings(&state.config.database_url, &state.config.room_id, &settings).await
+ {
+ return error_response(error);
+ }
+ *state.overlay_settings.write().await = settings.clone();
+ event(
+ &state,
+ "overlay.settings.updated",
+ json!({"settings":settings}),
+ );
+ Json(json!({"ok":true,"settings":settings})).into_response()
+}
+async fn obs_url(State(state): State, headers: HeaderMap) -> Response {
+ if !admin(&state, &headers) {
+ return unauthorized();
+ }
+ Json(json!({"path":format!("/obs?token={}", state.config.obs_access_token)})).into_response()
+}
+async fn test_event(
+ State(state): State,
+ headers: HeaderMap,
+ Json(body): Json,
+) -> Response {
+ if !admin(&state, &headers) {
+ return unauthorized();
+ }
+ let message = match body {
+ TestEvent::Enter { uid, name } => Incoming::Enter {
+ viewer: Viewer { uid, name },
+ },
+ TestEvent::Danmaku { uid, name, text } => Incoming::Danmaku {
+ viewer: Viewer { uid, name },
+ text,
+ emoticons: Vec::new(),
+ },
+ TestEvent::Gift {
+ uid,
+ name,
+ gift_name,
+ gift_id,
+ battery,
+ quantity,
+ } => Incoming::Gift {
+ viewer: Viewer { uid, name },
+ name: gift_name,
+ gift_id,
+ battery,
+ quantity,
+ event_id: format!("test-{}", Uuid::new_v4()),
+ },
+ };
+ if let Err(e) = process(&state, message).await {
+ return error_response(e);
+ }
+ Json(json!({"ok":true})).into_response()
+}
+async fn ws(
+ State(state): State,
+ headers: HeaderMap,
+ Query(query): Query,
+ upgrade: WebSocketUpgrade,
+) -> Response {
+ let allowed =
+ admin(&state, &headers) || query.token.as_deref() == Some(&state.config.obs_access_token);
+ if !allowed {
+ return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
+ }
+ upgrade.on_upgrade(move |socket| ws_loop(socket, state))
+}
+async fn ws_loop(mut socket: WebSocket, state: AppState) {
+ let snapshot = state.overlay_settings.read().await.clone();
+ if socket
+ .send(Message::Text(
+ envelope(
+ &state,
+ "overlay.settings.snapshot",
+ json!({"settings":snapshot}),
+ )
+ .into(),
+ ))
+ .await
+ .is_err()
+ {
+ return;
+ }
+ let mut rx = state.events.subscribe();
+ while let Ok(text) = rx.recv().await {
+ if socket.send(Message::Text(text.into())).await.is_err() {
+ break;
+ }
+ }
+}
+fn error_response(error: String) -> Response {
+ error!(%error, "request failed");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error":"Internal server error"})),
+ )
+ .into_response()
+}
+
+fn spawn_live_listener(state: AppState) {
+ task::spawn_blocking(move || {
+ let runtime = tokio::runtime::Handle::current();
+ let cookie = match runtime.block_on(cookiecloud_cookie(&state.config)) {
+ Ok(v) => v,
+ Err(e) => {
+ runtime.block_on(set_source(&state, false, false, e));
+ return;
+ }
+ };
+ runtime.block_on(set_source(
+ &state,
+ false,
+ true,
+ "Connecting with blivedm_rs".into(),
+ ));
+ let (sender, mut receiver) = mpsc::channel(256);
+ let mut client =
+ match BiliLiveClient::new_auto(Some(&cookie), &state.config.room_id, sender) {
+ Ok(client) => client,
+ Err(e) => {
+ runtime.block_on(set_source(&state, false, true, e));
+ return;
+ }
+ };
+ client.send_auth();
+ runtime.block_on(set_source(
+ &state,
+ true,
+ true,
+ "Connected with authenticated blivedm_rs listener".into(),
+ ));
+ loop {
+ if let Err(e) = client.receive() {
+ runtime.block_on(set_source(&state, false, true, e));
+ }
+ while let Ok(Some(raw)) = receiver.try_next() {
+ if let Some(message) = normalize(raw) {
+ let state = state.clone();
+ runtime.block_on(async move {
+ if let Err(e) = process(&state, message).await {
+ error!(%e, "live event processing failed");
+ }
+ });
+ }
+ }
+ }
+ });
+}
+async fn set_source(state: &AppState, connected: bool, cookie_cloud: bool, detail: String) {
+ *state.source.lock().await = SourceStatus {
+ connected,
+ cookie_cloud,
+ detail,
+ };
+}
+async fn cookiecloud_cookie(config: &Config) -> Result {
+ let host = config.cookiecloud_host.trim_end_matches('/');
+ let response = reqwest::Client::new()
+ .post(format!("{host}/get/{}", config.cookiecloud_key))
+ .form(&[("password", config.cookiecloud_password.as_str())])
+ .send()
+ .await
+ .map_err(|e| e.to_string())?;
+ if !response.status().is_success() {
+ return Err(format!("CookieCloud HTTP {}", response.status()));
+ }
+ let value: Value = response.json().await.map_err(|e| e.to_string())?;
+ let mut cookies = Vec::new();
+ if let Some(domains) = value.get("cookie_data").and_then(Value::as_object) {
+ for (domain, stored) in domains {
+ if !domain.contains("bilibili.com") {
+ continue;
+ }
+ let entries: Vec<&Value> = if let Some(array) = stored.as_array() {
+ array.iter().collect()
+ } else {
+ stored
+ .as_object()
+ .map(|values| values.values().collect())
+ .unwrap_or_default()
+ };
+ for c in entries {
+ if let (Some(name), Some(value)) = (
+ c.get("name").and_then(Value::as_str),
+ c.get("value").and_then(Value::as_str),
+ ) {
+ cookies.push(format!("{name}={value}"));
+ }
+ }
+ }
+ }
+ if cookies.iter().any(|c| c.starts_with("SESSDATA=")) {
+ Ok(cookies.join("; "))
+ } else {
+ Err("CookieCloud has no Bilibili SESSDATA cookie".into())
+ }
+}
+
+async fn refresh_emoticon_catalog(state: &AppState) -> Result {
+ let cookie = cookiecloud_cookie(&state.config).await?;
+ state
+ .emoticon_catalog
+ .refresh(
+ &state.config.room_id,
+ &cookie,
+ state.config.emoticon_request_timeout_seconds,
+ )
+ .await
+}
+
+fn spawn_emoticon_catalog_refresh(state: AppState) {
+ tokio::spawn(async move {
+ loop {
+ tokio::time::sleep(std::time::Duration::from_secs(
+ state.config.emoticon_refresh_seconds,
+ ))
+ .await;
+ match refresh_emoticon_catalog(&state).await {
+ Ok(count) => info!(count, "emoticon catalog refreshed"),
+ Err(error) => {
+ warn!(%error, "emoticon catalog refresh failed; retaining the last successful cache")
+ }
+ }
+ }
+ });
+}
+
+fn json_object(value: &Value) -> Option {
+ match value {
+ Value::Object(_) => Some(value.clone()),
+ Value::String(value) => serde_json::from_str::(value)
+ .ok()
+ .filter(Value::is_object),
+ _ => None,
+ }
+}
+
+fn value_as_u32(value: Option<&Value>) -> Option {
+ value.and_then(|value| {
+ value
+ .as_u64()
+ .and_then(|value| u32::try_from(value).ok())
+ .or_else(|| value.as_str()?.parse().ok())
+ })
+}
+
+fn value_is_truthy(value: Option<&Value>) -> bool {
+ value.is_some_and(|value| {
+ value.as_bool().unwrap_or(false)
+ || value.as_i64().is_some_and(|value| value != 0)
+ || value.as_str().is_some_and(|value| value == "1")
+ })
+}
+
+fn emoticon_hint(value: &Value, text: &str, standalone: bool) -> Option {
+ let value = json_object(value)?;
+ let unique = value
+ .get("emoticon_unique")
+ .and_then(Value::as_str)
+ .filter(|value| !value.is_empty())
+ .map(ToOwned::to_owned);
+ let url = value
+ .get("url")
+ .and_then(Value::as_str)
+ .and_then(normalize_image_url);
+ if unique.is_none() && url.is_none() {
+ return None;
+ }
+ Some(DanmakuEmoticonHint {
+ text: text.to_owned(),
+ unique,
+ url,
+ width: value_as_u32(value.get("width")),
+ height: value_as_u32(value.get("height")),
+ is_dynamic: value_is_truthy(value.get("is_dynamic")),
+ bulge_display: value_is_truthy(value.get("bulge_display")),
+ standalone,
+ })
+}
+
+fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec {
+ let Some(header) = info.first().and_then(Value::as_array) else {
+ return Vec::new();
+ };
+ let mut hints = Vec::new();
+
+ // Large and room-specific emoticons normally carry their image in info[0][13].
+ // Scan the other fields too because Bilibili has shifted this slot before.
+ if let Some(direct) = header
+ .get(13)
+ .and_then(|value| emoticon_hint(value, text, true))
+ .or_else(|| {
+ header.iter().find_map(|value| {
+ let object = json_object(value)?;
+ object.get("url")?;
+ emoticon_hint(&object, text, true)
+ })
+ })
+ {
+ hints.push(direct);
+ }
+
+ // Inline metadata is nested in a JSON string at info[0][15].extra.
+ let extra = header.iter().find_map(|value| {
+ let object = json_object(value)?;
+ json_object(object.get("extra")?)
+ });
+ if let Some(extra) = extra {
+ if let Some(emoticons) = extra.get("emots").and_then(json_object) {
+ if let Some(emoticons) = emoticons.as_object() {
+ for (token, metadata) in emoticons {
+ if let Some(hint) = emoticon_hint(metadata, token, false) {
+ hints.push(hint);
+ }
+ }
+ }
+ }
+ if let Some(unique) = extra
+ .get("emoticon_unique")
+ .and_then(Value::as_str)
+ .filter(|value| !value.is_empty())
+ {
+ let already_present = hints
+ .iter()
+ .any(|hint| hint.unique.as_deref() == Some(unique));
+ if !already_present {
+ hints.push(DanmakuEmoticonHint {
+ text: text.to_owned(),
+ unique: Some(unique.to_owned()),
+ url: None,
+ width: None,
+ height: None,
+ is_dynamic: false,
+ bulge_display: value_is_truthy(extra.get("bulge_display")),
+ standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1),
+ });
+ }
+ }
+ }
+ hints
+}
-fn spawn_live_listener(state: AppState) { task::spawn_blocking(move || { let runtime = tokio::runtime::Handle::current(); let cookie = match runtime.block_on(cookiecloud_cookie(&state.config)) { Ok(v) => v, Err(e) => { runtime.block_on(set_source(&state, false, false, e)); return; } }; runtime.block_on(set_source(&state, false, true, "Connecting with blivedm_rs".into())); let (sender, mut receiver) = mpsc::channel(256); let mut client = match BiliLiveClient::new_auto(Some(&cookie), &state.config.room_id, sender) { Ok(client) => client, Err(e) => { runtime.block_on(set_source(&state, false, true, e)); return; } }; client.send_auth(); runtime.block_on(set_source(&state, true, true, "Connected with authenticated blivedm_rs listener".into())); loop { if let Err(e) = client.receive() { runtime.block_on(set_source(&state, false, true, e)); } while let Ok(Some(raw)) = receiver.try_next() { if let Some(message) = normalize(raw) { let state = state.clone(); runtime.block_on(async move { if let Err(e) = process(&state, message, "live").await { error!(%e, "live event processing failed"); } }); } } } }); }
-async fn set_source(state: &AppState, connected: bool, cookie_cloud: bool, detail: String) { *state.source.lock().await = SourceStatus { connected, cookie_cloud, detail }; }
-async fn cookiecloud_cookie(config: &Config) -> Result { let host = config.cookiecloud_host.trim_end_matches('/'); let response = reqwest::Client::new().post(format!("{host}/get/{}", config.cookiecloud_key)).form(&[("password", config.cookiecloud_password.as_str())]).send().await.map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("CookieCloud HTTP {}", response.status())); } let value: Value = response.json().await.map_err(|e| e.to_string())?; let mut cookies = Vec::new(); if let Some(domains) = value.get("cookie_data").and_then(Value::as_object) { for (domain, stored) in domains { if !domain.contains("bilibili.com") { continue; } let entries: Vec<&Value> = if let Some(array) = stored.as_array() { array.iter().collect() } else { stored.as_object().map(|values| values.values().collect()).unwrap_or_default() }; for c in entries { if let (Some(name), Some(value)) = (c.get("name").and_then(Value::as_str), c.get("value").and_then(Value::as_str)) { cookies.push(format!("{name}={value}")); } } } } if cookies.iter().any(|c| c.starts_with("SESSDATA=")) { Ok(cookies.join("; ")) } else { Err("CookieCloud has no Bilibili SESSDATA cookie".into()) } }
fn normalize(message: BiliMessage) -> Option {
- let raw = match message { BiliMessage::Raw(v) => v, _ => return None };
+ let raw = match message {
+ BiliMessage::Raw(v) => v,
+ _ => return None,
+ };
let cmd = raw.get("cmd")?.as_str()?.split(':').next()?.to_owned();
let data = raw.get("data").unwrap_or(&raw);
- let viewer = |uid: &Value, name: &Value| Some(Viewer { uid: uid.as_i64().map(|id| id.to_string()).or_else(|| uid.as_str().map(str::to_owned))?, name: name.as_str()?.to_string() });
- let data_viewer = |value: &Value| viewer(value.get("uid")?, value.get("uname").or_else(|| value.pointer("/sender_uinfo/base/name")).or_else(|| value.pointer("/user_info/uname"))?);
- let event = |kind: &str, payload: Value| Some(Incoming::Event { kind: kind.into(), payload });
+ let viewer = |uid: &Value, name: &Value| {
+ Some(Viewer {
+ uid: uid
+ .as_i64()
+ .map(|id| id.to_string())
+ .or_else(|| uid.as_str().map(str::to_owned))?,
+ name: name.as_str()?.to_string(),
+ })
+ };
+ let data_viewer = |value: &Value| {
+ viewer(
+ value.get("uid")?,
+ value
+ .get("uname")
+ .or_else(|| value.pointer("/sender_uinfo/base/name"))
+ .or_else(|| value.pointer("/user_info/uname"))?,
+ )
+ };
+ let event = |kind: &str, payload: Value| {
+ Some(Incoming::Event {
+ kind: kind.into(),
+ payload,
+ })
+ };
match cmd.as_str() {
- "DANMU_MSG" => { let info = raw.get("info")?.as_array()?; Some(Incoming::Danmaku { viewer: viewer(info.get(2)?.get(0)?, info.get(2)?.get(1)?)?, text: info.get(1)?.as_str()?.to_string() }) }
- "SEND_GIFT" => Some(Incoming::Gift { viewer: data_viewer(data)?, name: data.get("giftName").or_else(|| data.get("gift_name"))?.as_str()?.to_string(), battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32, quantity: data.get("num").and_then(Value::as_i64).unwrap_or(1) as i32, event_id: data.get("tid").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| format!("{}-{}", data.get("uid").unwrap_or(&Value::Null), data.get("timestamp").unwrap_or(&Value::Null))) }),
- "COMBO_SEND" => { let viewer = data_viewer(data)?; event("live.gift.combo", json!({"viewer":viewer,"giftName":data.get("gift_name").or_else(||data.get("giftName")).and_then(Value::as_str).unwrap_or("礼物"),"battery":data.get("price").and_then(Value::as_i64).unwrap_or(0),"quantity":data.get("combo_num").or_else(||data.get("total_num")).and_then(Value::as_i64).unwrap_or(1),"comboId":data.get("combo_id").and_then(Value::as_str).unwrap_or("")})) }
- "INTERACT_WORD" => Some(Incoming::Enter { viewer: data_viewer(data)? }),
- "GUARD_BUY" => { let viewer = data_viewer(data)?; event("live.guard.buy", json!({"viewer":viewer,"guardName":data.get("gift_name").or_else(||data.get("giftName")).and_then(Value::as_str).unwrap_or("舰长"),"quantity":data.get("num").and_then(Value::as_i64).unwrap_or(1),"price":data.get("price").and_then(Value::as_i64).unwrap_or(0)})) }
- "SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => { let viewer = data_viewer(data)?; event("live.superchat", json!({"viewer":viewer,"message":data.get("message").and_then(Value::as_str).unwrap_or(""),"price":data.get("price").and_then(Value::as_i64).unwrap_or(0),"sourceEventId":data.get("id").map(Value::to_string).unwrap_or_else(||Uuid::new_v4().to_string())})) }
- "LIKE_INFO_V3_CLICK" => { let viewer = data_viewer(data)?; event("live.like", json!({"viewer":viewer})) }
- "SHARE" => { let viewer = data_viewer(data)?; event("live.share", json!({"viewer":viewer})) }
+ "DANMU_MSG" => {
+ let info = raw.get("info")?.as_array()?;
+ let text = info.get(1)?.as_str()?.to_string();
+ Some(Incoming::Danmaku {
+ viewer: viewer(info.get(2)?.get(0)?, info.get(2)?.get(1)?)?,
+ emoticons: parse_danmaku_emoticons(info, &text),
+ text,
+ })
+ }
+ "SEND_GIFT" => Some(Incoming::Gift {
+ viewer: data_viewer(data)?,
+ name: data
+ .get("giftName")
+ .or_else(|| data.get("gift_name"))?
+ .as_str()?
+ .to_string(),
+ gift_id: data
+ .get("giftId")
+ .or_else(|| data.get("gift_id"))
+ .and_then(Value::as_i64),
+ battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32,
+ quantity: data.get("num").and_then(Value::as_i64).unwrap_or(1) as i32,
+ event_id: data
+ .get("tid")
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+ .unwrap_or_else(|| {
+ format!(
+ "{}-{}",
+ data.get("uid").unwrap_or(&Value::Null),
+ data.get("timestamp").unwrap_or(&Value::Null)
+ )
+ }),
+ }),
+ "COMBO_SEND" => Some(Incoming::Combo {
+ viewer: data_viewer(data)?,
+ name: data
+ .get("gift_name")
+ .or_else(|| data.get("giftName"))
+ .and_then(Value::as_str)
+ .unwrap_or("礼物")
+ .to_owned(),
+ gift_id: data
+ .get("gift_id")
+ .or_else(|| data.get("giftId"))
+ .and_then(Value::as_i64),
+ battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32,
+ quantity: data
+ .get("combo_num")
+ .or_else(|| data.get("total_num"))
+ .and_then(Value::as_i64)
+ .unwrap_or(1) as i32,
+ combo_id: data
+ .get("combo_id")
+ .map(Value::to_string)
+ .unwrap_or_default(),
+ }),
+ "INTERACT_WORD" => Some(Incoming::Enter {
+ viewer: data_viewer(data)?,
+ }),
+ "GUARD_BUY" => {
+ let viewer = data_viewer(data)?;
+ event(
+ "live.guard.buy",
+ json!({"viewer":viewer,"guardName":data.get("gift_name").or_else(||data.get("giftName")).and_then(Value::as_str).unwrap_or("舰长"),"quantity":data.get("num").and_then(Value::as_i64).unwrap_or(1),"price":data.get("price").and_then(Value::as_i64).unwrap_or(0)}),
+ )
+ }
+ "SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => {
+ let viewer = data_viewer(data)?;
+ event(
+ "live.superchat",
+ json!({"viewer":viewer,"message":data.get("message").and_then(Value::as_str).unwrap_or(""),"price":data.get("price").and_then(Value::as_i64).unwrap_or(0),"sourceEventId":data.get("id").map(Value::to_string).unwrap_or_else(||Uuid::new_v4().to_string())}),
+ )
+ }
+ "LIKE_INFO_V3_CLICK" => {
+ let viewer = data_viewer(data)?;
+ event("live.like", json!({"viewer":viewer}))
+ }
+ "SHARE" => {
+ let viewer = data_viewer(data)?;
+ event("live.share", json!({"viewer":viewer}))
+ }
_ => event("live.unknown", json!({"cmd":cmd,"raw":raw})),
}
}
-async fn process(state: &AppState, message: Incoming, scope: &str) -> Result<(), String> { if let Incoming::Event { kind, payload } = &message { event(state, kind, payload.clone()); return Ok(()); } let viewer = match &message { Incoming::Enter{viewer}|Incoming::Danmaku{viewer,..}|Incoming::Gift{viewer,..} => viewer.clone(), Incoming::Event{..} => unreachable!() }; let db = connect(&state.config.database_url).await?; db.execute("INSERT INTO viewer_accounts(scope,room_id,uid,display_name) VALUES($1,$2,$3,$4) ON CONFLICT(scope,room_id,uid) DO UPDATE SET display_name=EXCLUDED.display_name,updated_at=now()", &[&scope,&state.config.room_id,&viewer.uid,&viewer.name]).await.map_err(|e|e.to_string())?; match message { Incoming::Enter{..} => event(state,"live.enter",json!({"viewer":viewer})), Incoming::Danmaku{ text,.. } => { event(state,"live.danmaku",json!({"viewer":viewer,"text":text})); command(state, &db, scope, viewer, text).await? }, Incoming::Gift{name,battery,quantity,event_id,..} => { let amount = battery.max(0).saturating_mul(quantity.max(1)); let id = Uuid::new_v4().to_string(); let inserted = db.query_opt("INSERT INTO point_ledger(id,scope,room_id,uid,delta,reason,source_event_id,metadata) VALUES($1::text::uuid,$2,$3,$4,$5,'gift',$6,$7) ON CONFLICT(source_event_id) DO NOTHING RETURNING id", &[&id,&scope,&state.config.room_id,&viewer.uid,&amount,&event_id,&json!({"giftName":name,"battery":battery,"quantity":quantity})]).await.map_err(|e|e.to_string())?; event(state,"live.gift",json!({"viewer":viewer,"giftName":name,"battery":battery,"quantity":quantity,"sourceEventId":event_id})); if inserted.is_some() { let row = db.query_one("UPDATE viewer_accounts SET points=points+$1,updated_at=now() WHERE scope=$2 AND room_id=$3 AND uid=$4 RETURNING points", &[&amount,&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?; let points:i32=row.get(0); event(state,"viewer.points.updated",json!({"viewer":viewer,"delta":amount,"balance":points,"reason":"gift"})); } }, Incoming::Event{..} => unreachable!() } Ok(()) }
-async fn command(state: &AppState, db: &Client, scope: &str, viewer: Viewer, text: String) -> Result<(), String> { let normalized = text.split_whitespace().collect::>().join(" "); if normalized == "转盘查询" { let points = db.query_opt("SELECT points FROM viewer_accounts WHERE scope=$1 AND room_id=$2 AND uid=$3", &[&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?.map(|r|r.get::<_,i32>(0)).unwrap_or(0); event(state,"viewer.points.updated",json!({"viewer":viewer,"delta":0,"balance":points,"reason":"gift"})); reply(state, format!("{} 当前转盘点数:{}", viewer.name, points)).await; return Ok(()); } let Some(category) = normalized.strip_prefix("转盘 ").filter(|v| !v.is_empty()) else { if normalized == "转盘" { event(state,"wheel.invalid-command",json!({"viewer":viewer,"message":"用法:转盘 [类别]"})); reply(state, format!("{}:用法:转盘 [类别]", viewer.name)).await; } return Ok(()); }; let song = random_song(&state.config.songlist_database_url, category).await?; let Some((song_id,title,tags,fallback)) = song else { event(state,"wheel.invalid-command",json!({"viewer":viewer,"message":"歌单暂时没有可抽取的歌曲"})); return Ok(()); }; let row = db.query_opt("UPDATE viewer_accounts SET points=points-$1,updated_at=now() WHERE scope=$2 AND room_id=$3 AND uid=$4 AND points >= $1 RETURNING points", &[&WHEEL_COST,&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?; if let Some(row) = row { let balance:i32=row.get(0); let id = Uuid::new_v4().to_string(); db.execute("INSERT INTO point_ledger(id,scope,room_id,uid,delta,reason,metadata) VALUES($1::text::uuid,$2,$3,$4,$5,'wheel',$6)",&[&id,&scope,&state.config.room_id,&viewer.uid,&-WHEEL_COST,&json!({"category":category,"songId":song_id})]).await.map_err(|e|e.to_string())?; event(state,"viewer.points.updated",json!({"viewer":viewer,"delta":-WHEEL_COST,"balance":balance,"reason":"wheel"})); event(state,"wheel.result",json!({"viewer":viewer,"category":category,"fallback":fallback,"song":{"id":song_id,"title":title,"tags":tags},"cost":150,"balance":balance})); reply(state, format!("{} 抽中了《{}》", viewer.name, title)).await; } else { let points = db.query_opt("SELECT points FROM viewer_accounts WHERE scope=$1 AND room_id=$2 AND uid=$3", &[&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?.map(|r|r.get::<_,i32>(0)).unwrap_or(0); event(state,"wheel.insufficient-balance",json!({"viewer":viewer,"balance":points,"cost":150})); reply(state, format!("{} 点数不足(需要 150,当前 {})", viewer.name, points)).await; } Ok(()) }
-async fn reply(state: &AppState, text: String) { if !state.reply_enabled.load(Ordering::Relaxed) { return; } let cookie = match cookiecloud_cookie(&state.config).await { Ok(cookie) => cookie, Err(e) => { warn!(%e, "reply cookie unavailable"); return; } }; let Some(csrf) = cookie.split(';').find_map(|v| v.trim().strip_prefix("bili_jct=").map(str::to_owned)) else { warn!("reply cookie has no bili_jct"); return; }; let response = reqwest::Client::new().post("https://api.live.bilibili.com/msg/send").header("cookie", cookie).header("referer", format!("https://live.bilibili.com/{}", state.config.room_id)).form(&[("roomid", state.config.room_id.as_str()), ("msg", text.as_str()), ("csrf", csrf.as_str()), ("csrf_token", csrf.as_str())]).send().await; if let Err(e) = response { warn!(%e, "Bilibili reply failed"); } }
-async fn random_song(url: &str, category: &str) -> Result