diff --git a/README.md b/README.md index 9161dc3..7ef625f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # 多用户直播组件服务 这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬、`song_request` 点歌姬、 -`gift_effect` 全屏礼物特效与 `gift_menu` +`gift_effect` 全屏礼物特效、`guard_effect` 大航海特效与 `gift_menu` 礼物菜单,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。 直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili) @@ -21,6 +21,7 @@ adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。cr - [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md) - [`song_request` 点歌姬](docs/components/song-request.md) - [`gift_effect` 全屏礼物特效](docs/components/gift-effect.md) +- [`guard_effect` 大航海特效](docs/components/guard-effect.md) - [`gift_menu` 礼物菜单](docs/components/gift-menu.md) - [WebSocket 实时协议](docs/protocol.md) - [租户、Secret 与部署安全](docs/security.md) @@ -72,7 +73,7 @@ Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--c `[database].url` 指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL RLS 约束,HTTP -API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列、评分和完整历史同样保存在租户隔离表中。账户语言偏好也保存在强制 RLS 的 +API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列和完整历史同样保存在租户隔离表中。账户语言偏好也保存在强制 RLS 的 `account_preferences` 表中。 `security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP @@ -211,12 +212,16 @@ fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通 `/api/v1/components//stream` 完成认证。令牌只带 `events:subscribe` 权限,不能调用管理或写入接口;轮换后旧令牌立即失效。 -每个账户会自动拥有不可删除的点歌姬、全屏礼物特效和礼物菜单组件。观众发送 `点歌 歌名` 加入队列,发送 -`打分 1-5` -为当前歌曲评分;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态和评分持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。 +每个账户会自动拥有不可删除的点歌姬、全屏礼物特效、大航海特效和礼物菜单组件。观众发送 `点歌歌名` 或 +`点歌 歌名` +加入队列;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态与历史持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。 -礼物特效组件在透明全屏浏览器源中展示从左向右飞行的礼物流星,并按礼物原始价值选择数量、尺寸和速度;舰长、提督和总督事件会临时覆盖一层不透明星河庆祝画面。所有档位参数、拖尾、星数、持续时间和低性能模式均可在控制台调整。该组件只订阅一次性 -`live.gift` 与 `live.guard.buy`,不会把连击更新重复播放为新礼物。 +礼物特效组件在透明全屏浏览器源中展示从左向右飞行的礼物流星,并按礼物原始价值选择数量、尺寸和速度。该组件只订阅一次性 +`live.gift`,不会接收大航海事件,也不会把连击更新重复播放为新礼物。密集礼物在每个 OBS 礼物组件中按有界 FIFO 逐个播放,当前动画结束前不会叠加下一笔特效。 + +大航海特效是独立的 `guard_effect` 组件,只订阅 +`live.guard.buy`。舰长、提督和总督分别播放独立视频,或按主题展示月下星光庆祝;它拥有自己的实例、设置、OBS +token 和 WebSocket 通道。连续上舰事件同样按有界 FIFO 逐个完整播放,不会覆盖正在播放的视频。 礼物菜单读取账户当前直播间的礼物目录与图标,可把指定礼物、舰长/提督/总督或指定礼物单价映射为自定义直播内容。OBS 以横排行无限循环展示,实际投喂命中时自动定位、暂停并播放渐变星花高亮。 diff --git a/apps/overlay/README.md b/apps/overlay/README.md index be3e3bc..0179571 100644 --- a/apps/overlay/README.md +++ b/apps/overlay/README.md @@ -27,8 +27,10 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域 | `src/stream.ts` | 通用组件 WebSocket 鉴权、重连和 renderer 分流 | | `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 | | `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 | -| `src/giftEffect.tsx` | 礼物流星、大航海全屏庆祝与视口自适应渲染 | +| `src/giftEffect.tsx` | 礼物流星与视口自适应渲染 | | `src/giftThemes.ts` | 可扩展礼物特效主题注册表与 CSS 变量 | +| `src/guardEffect.tsx` | 独立大航海视频、感谢卷轴与月夜庆祝渲染 | +| `src/guardThemes.ts` | 可扩展大航海特效主题注册表与 CSS 变量 | | `src/giftMenu.tsx` | 礼物菜单无限循环、触发定位与高亮 reducer | | `src/giftMenuThemes.ts` | 可扩展礼物菜单主题注册表 | | `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker | @@ -60,9 +62,10 @@ metadata 的唯一文案来源。Vite 在构建时解析并注入它,Rust 则 ## 字体资源 Noto Serif SC、ZCOOL XiaoWei 与 LXGW -WenKai 由锁定的 Fontsource 依赖提供;漓雨手书则以固定版本的 WOFF2 文件保存在 -`public/fonts/`。浏览器从 Rust 静态服务同域加载这些字体,不依赖 OBS 设备的系统字体。每套字体的 OFL-1.1 许可证均输出到 -`/fonts/licenses/`。 +WenKai 由锁定的 Fontsource 依赖提供;漓雨手书与鸿雷行书简体则以固定 WOFF2 文件保存在 +`public/fonts/`。浏览器从 Rust 静态服务同域加载这些字体,不依赖 OBS 设备的系统字体。前三套 Fontsource 字体与漓雨手书的 OFL-1.1 许可证输出到 +`/fonts/licenses/`;鸿雷行书的随附说明不构成开放授权,公开或商业部署前必须确认 Web 嵌入与再分发权利,具体摘要见 +`public/fonts/NOTICE.md`。 ## 格式化与构建 @@ -83,4 +86,5 @@ YAML 和项目文档。 - [弹幕姬组件](../../docs/components/danmaku-overlay.md) - [点歌姬组件](../../docs/components/song-request.md) - [全屏礼物特效](../../docs/components/gift-effect.md) +- [大航海特效](../../docs/components/guard-effect.md) - [礼物菜单组件](../../docs/components/gift-menu.md) diff --git a/apps/overlay/public/assets/NOTICE.md b/apps/overlay/public/assets/NOTICE.md index 180d761..9093b7f 100644 --- a/apps/overlay/public/assets/NOTICE.md +++ b/apps/overlay/public/assets/NOTICE.md @@ -28,3 +28,10 @@ so OBS never needs to load the source websites. `moonlit-edge.svg` is an original repository asset. It is a transparent, monochrome mask shared by the Moonlit Water danmaku, song-request, and gift-menu components. + +# Membership voyage videos + +`captain.webm`, `admiral.webm`, and `general.webm` are project-owner-supplied 1280×720 VP9/Opus +videos used by the independent Jade Starfall Guard, Admiral, and Governor component respectively. +Each video runs for approximately 7.988 seconds. Their redistribution rights remain the +responsibility of the deployment owner. diff --git a/apps/overlay/public/assets/admiral.webm b/apps/overlay/public/assets/admiral.webm new file mode 100644 index 0000000..1caa148 Binary files /dev/null and b/apps/overlay/public/assets/admiral.webm differ diff --git a/apps/overlay/public/assets/captain.webm b/apps/overlay/public/assets/captain.webm new file mode 100644 index 0000000..be1fd38 Binary files /dev/null and b/apps/overlay/public/assets/captain.webm differ diff --git a/apps/overlay/public/assets/general.webm b/apps/overlay/public/assets/general.webm new file mode 100644 index 0000000..a765470 Binary files /dev/null and b/apps/overlay/public/assets/general.webm differ diff --git a/apps/overlay/public/fonts/NOTICE.md b/apps/overlay/public/fonts/NOTICE.md index 558e1f4..109ff3f 100644 --- a/apps/overlay/public/fonts/NOTICE.md +++ b/apps/overlay/public/fonts/NOTICE.md @@ -17,3 +17,17 @@ The original font credits Yuji Kataoka and the Yuji Project Authors for the base and the LXGW ZhenKai Project Authors for punctuation and symbols, and Yuchen Tian's [zi2zi-JiT](https://github.com/kaonashi-tyc/zi2zi-JiT) for generated Chinese glyphs. This notice preserves the zi2zi-JiT attribution required by the upstream font documentation. + +## HongLei XingShu Jian + +`honglei-xingshu-jian.woff2` is a full-font WOFF2 conversion of the project owner's +`鸿雷行书简体.otf`; it is used only by the independent Jade Starfall membership component. + +- Embedded family: `hongleixingshu` / `鸿雷行书简体` +- Original OTF SHA-256: `5081c025bc350339885297550b33d6742cd6467c7db5b340c537d0eb1e357234` +- Bundled WOFF2 SHA-256: `6b925c5825bbbc936e37fa1a8db9a81db99a6f09e59e0b2feea2fb6917726b6d` +- Conversion: FontTools 4.63.0 WOFF2 compression with Brotli 1.2.0; no glyph subsetting +- Coverage retained: 7,012 glyphs +- License: the supplied download notice does not grant an open-source or redistribution license. + Confirm that the deployment owner holds Web embedding and redistribution rights before public or + commercial deployment. diff --git a/apps/overlay/public/fonts/honglei-xingshu-jian.woff2 b/apps/overlay/public/fonts/honglei-xingshu-jian.woff2 new file mode 100644 index 0000000..7ece931 Binary files /dev/null and b/apps/overlay/public/fonts/honglei-xingshu-jian.woff2 differ diff --git a/apps/overlay/scripts/check-font-assets.mjs b/apps/overlay/scripts/check-font-assets.mjs index 6be8d38..e553cd7 100644 --- a/apps/overlay/scripts/check-font-assets.mjs +++ b/apps/overlay/scripts/check-font-assets.mjs @@ -20,6 +20,7 @@ async function filesBelow(directory, prefix = '') { const files = await filesBelow(dist) const expectedFonts = ['noto-serif-sc', 'zcool-xiaowei', 'lxgw-wenkai'] const liyuFont = 'fonts/liyu-shoushu-v0.107.woff2' +const hongleiFont = 'fonts/honglei-xingshu-jian.woff2' for (const font of expectedFonts) { if (!files.some(file => file.startsWith(`assets/${font}-`) && file.endsWith('.woff2'))) { @@ -40,6 +41,26 @@ const liyuDigest = createHash('sha256') if (liyuDigest !== 'b17f8c4fc6612a539d7a46e0eb5f57fdc732cff1d17804563b00a3f0a7eb9ab3') { throw new Error(`Unexpected liyu-shoushu WOFF2 digest: ${liyuDigest}`) } +if (!files.includes(hongleiFont)) throw new Error('Missing bundled WOFF2 asset for HongLei XingShu') +const hongleiDigest = createHash('sha256') + .update(await readFile(new URL(hongleiFont, dist))) + .digest('hex') +if (hongleiDigest !== '6b925c5825bbbc936e37fa1a8db9a81db99a6f09e59e0b2feea2fb6917726b6d') { + throw new Error(`Unexpected HongLei XingShu WOFF2 digest: ${hongleiDigest}`) +} + +const guardVideos = new Map([ + ['assets/captain.webm', 'e9d2b416a582c4606cec1d613027e941bfc422f3ad12de24e88980908496d759'], + ['assets/admiral.webm', '7c504469f638f321768edecb3b5ba25d6826737349f3479c89fb1776d35a8672'], + ['assets/general.webm', '869b54a5104e4dfbcc97a6338e8805444e0f0034c6d8075530b2242a2c3436e1'], +]) +for (const [video, expectedDigest] of guardVideos) { + if (!files.includes(video)) throw new Error(`Missing bundled membership video: ${video}`) + const digest = createHash('sha256') + .update(await readFile(new URL(video, dist))) + .digest('hex') + if (digest !== expectedDigest) throw new Error(`Unexpected membership video digest: ${video}`) +} const legacyWoff = files.find(file => file.endsWith('.woff')) if (legacyWoff) throw new Error(`Unexpected legacy WOFF asset: ${legacyWoff}`) @@ -50,7 +71,13 @@ const css = ( ) ).join('\n') -for (const family of ['Noto Serif SC', 'ZCOOL XiaoWei', 'LXGW WenKai', 'Liyu Shoushu']) { +for (const family of [ + 'Noto Serif SC', + 'ZCOOL XiaoWei', + 'LXGW WenKai', + 'Liyu Shoushu', + 'HongLei XingShu', +]) { const declarations = [ `font-family:${family}`, `font-family:${JSON.stringify(family)}`, @@ -64,4 +91,4 @@ if (!css.includes('font-display:block') || css.includes('font-display:swap')) { throw new Error('Bundled fonts must block local fallback while loading') } -console.log('Verified same-origin WOFF2 component fonts and licenses.') +console.log('Verified same-origin component fonts, licenses, and membership videos.') diff --git a/apps/overlay/src/api.ts b/apps/overlay/src/api.ts index 67864fe..55ef246 100644 --- a/apps/overlay/src/api.ts +++ b/apps/overlay/src/api.ts @@ -11,6 +11,7 @@ import type { AuthUser, ComponentSummary, CookieCloudSource, + GuardEffectSettings, GiftEffectSettings, GiftCatalogItem, GiftMenuItem, @@ -27,8 +28,10 @@ import { normalizeThemeId } from './themes' import { normalizeFontFamilyId } from './typography' import { normalizeGiftEffectThemeId } from './giftThemes' import { normalizeGiftMenuThemeId } from './giftMenuThemes' +import { normalizeGuardEffectThemeId } from './guardThemes' import { defaultGiftEffectSettings, + defaultGuardEffectSettings, defaultGiftMenuSettings, defaultOverlaySettings, defaultSongRequestSettings, @@ -261,6 +264,17 @@ export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings } } +export function normalizeGuardEffectSettings(value: unknown): GuardEffectSettings { + const root = object(value) + const settings = object(root.settings ?? value) + return { + ...defaultGuardEffectSettings, + ...(settings as Partial), + themeId: normalizeGuardEffectThemeId(settings.themeId), + fontFamily: normalizeFontFamilyId(settings.fontFamily), + } +} + function normalizeGiftMenuItem(value: unknown): GiftMenuItem | undefined { const item = object(value) const trigger = object(item.trigger) @@ -366,11 +380,6 @@ export function normalizeSongRequestItem(value: unknown): SongRequestItem | unde typeof item.startedAt === 'string' || item.startedAt === null ? item.startedAt : undefined, finishedAt: typeof item.finishedAt === 'string' || item.finishedAt === null ? item.finishedAt : undefined, - averageScore: - typeof item.averageScore === 'number' || item.averageScore === null - ? item.averageScore - : undefined, - ratingCount: Number(item.ratingCount ?? 0), } } @@ -390,7 +399,6 @@ export function normalizeSongRequestPage(value: unknown): SongRequestPage { queuedCount: Number(summary.queuedCount ?? 0), completedCount: Number(summary.completedCount ?? 0), cancelledCount: Number(summary.cancelledCount ?? 0), - ratingCount: Number(summary.ratingCount ?? 0), }, } } diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css index 61ca903..cb653c4 100644 --- a/apps/overlay/src/control.css +++ b/apps/overlay/src/control.css @@ -110,7 +110,7 @@ .song-stat-grid { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } diff --git a/apps/overlay/src/control.tsx b/apps/overlay/src/control.tsx index b379988..4645b70 100644 --- a/apps/overlay/src/control.tsx +++ b/apps/overlay/src/control.tsx @@ -17,6 +17,7 @@ import { normalizeComponents, normalizeGiftCatalog, normalizeGiftEffectSettings, + normalizeGuardEffectSettings, normalizeGiftMenuSettings, normalizeInvitations, normalizeEnrollment, @@ -29,9 +30,12 @@ import { import { TotpQr } from './auth' import { Overlay } from './overlay' import { GiftEffectOverlay } from './giftEffect' +import { GuardEffectOverlay } from './guardEffect' import { GiftMenuOverlay } from './giftMenu' import type { GiftEffectPreviewMode } from './giftEffect' +import type { GuardEffectPreviewMode } from './guardEffect' import { getGiftEffectTheme, giftEffectThemes } from './giftThemes' +import { getGuardEffectTheme, guardEffectThemes } from './guardThemes' import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemes } from './giftMenuThemes' import { PwaControls, usePwaUpdateBlocker } from './pwa' import { SongRequestOverlay } from './songOverlay' @@ -41,6 +45,7 @@ import { fontFamilies } from './typography' import type { FontFamilyId } from './typography' import { defaultGiftEffectSettings, + defaultGuardEffectSettings, defaultGiftMenuSettings, defaultOverlaySettings, defaultSongRequestSettings, @@ -51,6 +56,7 @@ import type { ComponentSummary, CookieCloudSource, GiftEffectSettings, + GuardEffectSettings, GiftCatalogItem, GiftMenuItem, GiftMenuSettings, @@ -201,11 +207,21 @@ function isGiftEffectKind(kind: string): boolean { return kind === 'gift_effect' } +function isGuardEffectKind(kind: string): boolean { + return kind === 'guard_effect' +} + function isGiftMenuKind(kind: string): boolean { return kind === 'gift_menu' } -const componentKinds = ['danmaku_overlay', 'song_request', 'gift_effect', 'gift_menu'] as const +const componentKinds = [ + 'danmaku_overlay', + 'song_request', + 'gift_effect', + 'guard_effect', + 'gift_menu', +] as const function componentKindLabel(kind: string): string { return isDanmakuKind(kind) @@ -214,9 +230,11 @@ function componentKindLabel(kind: string): string { ? translate('components.song_type') : isGiftEffectKind(kind) ? translate('components.gift_type') - : isGiftMenuKind(kind) - ? translate('components.gift_menu_type') - : kind + : isGuardEffectKind(kind) + ? translate('components.guard_type') + : isGiftMenuKind(kind) + ? translate('components.gift_menu_type') + : kind } function componentKindMark(kind: string): string { @@ -226,9 +244,11 @@ function componentKindMark(kind: string): string { ? translate('components.song_mark') : isGiftEffectKind(kind) ? translate('components.gift_mark') - : isGiftMenuKind(kind) - ? translate('components.gift_menu_mark') - : translate('components.generic_mark') + : isGuardEffectKind(kind) + ? translate('components.guard_mark') + : isGiftMenuKind(kind) + ? translate('components.gift_menu_mark') + : translate('components.generic_mark') } type Flash = { kind: 'success' | 'error'; text: string } | undefined @@ -434,6 +454,7 @@ function SettingsEditor({ type="range" min="2" max="120" + disabled={!settings.expandNewDanmaku} value={settings.collapseAfterSeconds} onChange={event => edit('collapseAfterSeconds', +event.target.value)} /> @@ -448,6 +469,7 @@ function SettingsEditor({ min="200" max="5000" step="100" + disabled={!settings.expandNewDanmaku} value={settings.unfoldDurationMs} onChange={event => edit('unfoldDurationMs', +event.target.value)} /> @@ -491,6 +513,18 @@ function SettingsEditor({ +
+ {translate('settings.new_danmaku_animation')} + +
+
{translate('settings.show_events')} {eventToggles.map(([key, labelKey]) => ( @@ -878,42 +912,15 @@ function GiftEffectSettingsEditor({ /> - -
@@ -949,7 +956,7 @@ function GiftEffectPreview({ settings }: { settings: GiftEffectSettings }) { className="preview-panel" >
- {(['normal', 'high', 'featured', 'guard'] as const).map(candidate => ( + {(['normal', 'high', 'featured'] as const).map(candidate => ( +
+ + ) +} + +function GuardEffectPreview({ settings }: { settings: GuardEffectSettings }) { + const [mode, setMode] = useState('captain') + const [nonce, setNonce] = useState(0) + const trigger = (next: GuardEffectPreviewMode) => { + setMode(next) + setNonce(current => current + 1) + } + return ( + +
+ {(['captain', 'admiral', 'governor'] as const).map(candidate => ( + + ))} +
+
+ +
+
+ ) +} + function GiftMenuSettingsEditor({ componentId, settings, @@ -1906,14 +2078,12 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) { function TestEvents({ componentId, - giftOnly = false, + eventKinds, }: { componentId: string - giftOnly?: boolean + eventKinds: Array<'danmaku' | 'enter' | 'gift' | 'guard'> }) { - const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift' | 'guard'>( - giftOnly ? 'gift' : 'danmaku', - ) + const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift' | 'guard'>(eventKinds[0]) const [uid, setUid] = useState('test-viewer') const [name, setName] = useState(() => translate('test.default_viewer')) const [text, setText] = useState(() => translate('test.default_text')) @@ -1966,10 +2136,18 @@ function TestEvents({