| name | frontend-runtime-guardrails |
| description | Use when adding, refactoring, or reviewing frontend runtime behavior in this repository, especially visualizers, lyrics animation, Framer Motion usage, useMotionValueEvent, requestAnimationFrame, ResizeObserver, high CPU reports, jank, per-character or per-frame animation, React state update frequency, and performance-sensitive UI paths. |
Frontend Runtime Guardrails
Purpose
这个 skill 用于在前端运行时敏感路径中保持低 CPU、低重渲染和稳定时序,尤其是歌词 visualizer、全屏播放、预览面板和动画密集组件。
核心目标:
- 区分“离散 UI 状态”和“逐帧运动数据”
- 复用现有 visualizer 的运行时模式
- 保持歌词时序、排版和动画之间的边界清晰
- 在代码进入 React render path 前检查性能风险
First Pass
开始写或审查前,先快速定位这次改动属于哪类路径:
- 普通 UI 状态:可以用
useState,但仍要避免无意义重复更新。
- 歌词时序状态:优先使用离散状态,例如
waiting | active | passed。
- 播放中连续变化的运动值:优先使用 Framer Motion variants、MotionValue、CSS transition/keyframes、
useRef、canvas 或 DOM 同步。
- 布局测量和缓存:优先使用
useMemo、useRef、ResizeObserver,并做缓存 key、尺寸变化和清理保护。
- 歌词解析、分词、时序映射:优先从
types.ts、utils/lyrics/parserCore.ts 和已有 lyrics utilities 理解数据真源。
Hard Rules
React State
高频路径里不要无条件 setState:
useMotionValueEvent(currentTime, 'change', ...)
requestAnimationFrame callback
ResizeObserver callback
- audio analyser / playback polling callback
- pointer move / scroll / resize 高频事件
绝对禁止用高频 useState、store setter 或等价 React 更新去追踪“当前精确时间”。例如不要把 currentTime.get()、audio.currentTime、RAF 时间戳或 MotionValue 的每次变化写入 state 来驱动歌词、进度、逐字强度或动画相位。连续时间必须优先参考现有方案:MotionValue / useTransform / CSS 或 Framer Motion 动画 / canvas draw loop / useRef 保存瞬时值;只有当前行、播放状态、可见段落这类离散变化可以进入 React state。
如果必须在高频回调里更新 React state,必须满足至少一条:
- 只更新离散状态,并先比较新旧值,例如
current === next ? current : next
- 限流到明确的低频节奏
- 只在可见、active、mounted 且确实变化时更新
- 更新的是少量稳定标量,而不是按字、按词、按像素展开的数组或对象
不要把每帧变化的 scale、opacity、transform、坐标、逐字符强度数组放进 useState。这些更适合由 MotionValue、CSS、Framer Motion variants、canvas draw loop,或 useRef 驱动的非 React 渲染层处理。
Visualizer Patterns
优先参考现有模式:
classic / partita:每个词只持有 waiting | active | passed 这种粗粒度状态,状态变化时才 setState;入口都经过 src/components/visualizer/registry.tsx。
cappella:字符数量和时间戳可进入 state,但 setter 需要做相等保护,只在计数真正变化时更新。
cadenza / fume:重动画和高频渲染使用 refs、缓存、canvas、DOM overlay、RAF draw loop,而不是逐帧 React rerender。
claddagh:用 buildLineGraphemeTimeline 保留逐字时间,使用 pretext 做字符间距测量,并在 useLayoutEffect 中直接写入有限数量 ring line 的 DOM 样式;中心线的音频响应由独立 RAF 驱动,必须清理订阅和 RAF。
diorama:React Three Fiber / canvas 场景负责粒子、相机和光栅化文字;不要把每帧场景数据提升到 React state。
pendolo:PendoloClockworkCanvas、timeline 和 bounded text layout 负责连续动画;React 只保留 tuning 与离散歌词切换。
sonnet:VisualizerSonnet 负责 React 外壳,createSonnetPixiRuntime 与 sonnet* helpers 负责 Pixi director;销毁 Pixi runtime、纹理池和 RAF。
VisPlayground:预览时间可以推进 MotionValue,但派生到 React state 时必须只更新当前行等低频状态。
共享运行时边界是 VisualizerRenderer.tsx -> runtime.ts / registry.tsx / VisualizerShell.tsx;它同时被 App、ThemePark、VisPlayground 和 OBS source 复用。新增模式应沿 entry.tsx registry 接入,不要在各宿主复制渲染分支。
所有 visualizer 的歌词和字幕字重都必须通过 src/utils/fontStacks.ts 的 resolveThemeFontWeight(theme, modeFallback) 获取。模式设计字重只能作为 fallback,不能用 font-bold、font-medium、固定内联 fontWeight 或字体规格中的硬编码数字覆盖用户设置。DOM、Canvas、pretext 和光栅化路径必须使用同一个最终字重。
新增 visualizer 时,先决定:
- 这段动画是入场/离场动画,还是播放期间持续动画?
- 它需要 React 参与渲染吗,还是可以由 CSS/Motion/canvas 自己完成?
- 当前实现的每秒 React 更新次数大概是多少?
- 长歌词、重复歌词、CJK grapheme、移动端和 Electron 下是否仍然可接受?
Strict Constraint: Protect Project Comments
- Keep all comments prefixed with
@note exactly as they are. These comments mark critical annotations and must not be translated, shortened, modified, or removed.
- If refactoring significantly changes the code structure, preserve these comments as close as possible to the code they are logically associated with.
Lyrics Timing
歌词显示文本、布局单元和 Line.words 时序是三层不同数据。不要在 visualizer 里临时猜测它们之间的关系。
Visualizer Lyrics Contract
写 visualizer 时,把输入视为已经整理好的统一歌词对象,而不是原始歌词文本:
- renderer 接收的是
LyricData / Line / Word,不直接处理 .lrc、.vtt、yrc、qrc。
- 不要把格式识别、翻译对齐、纯音乐判断、来源 adapter 逻辑塞回 visualizer。
Line 是渲染最常用的单位;Word 是最小时间单位;LyricData 是整首歌。
currentLineIndex 可能是 -1,lines[currentLineIndex] 可能不存在。
translation 已经按行对齐,可能为空;底部字幕和下一句提示交给共享 overlay 处理。
处理 fullText 和 words 时要记住:
fullText 更接近整句展示文本,适合做整句布局。
words 是歌词流水线产出的时间片段,粒度依来源和内容变化。
fullText 不保证等于 words.map(word => word.text).join('');空格、标点和 display text 可能不完全对齐。
- 标点很多时候会作为独立
Word 出现,需要 renderer 决定合并、跳过或特殊动画。
- 拉丁文本可能以整词进入
Word,例如英文或法文单词;字母级动画应在 renderer 内部基于 word.text 再拆分。
- 英文 contraction 或 sticky punctuation 可能需要用
buildPostLyricLayoutUnits / buildDisplayWordsFromLayoutUnits,不要临时手写拼接规则。
处理 renderHints 时要记住:
renderHints 是 visualizer 运行时契约的一部分,尤其影响短句、极短句、逐字 reveal、尾迹和退场。
line.endTime 是安全边界:到达这里前,歌词主体必须已经展示完成;需要退场的模式也应默认能在这里完成或进入可截断状态。
renderHints.renderEndTime 只是给尾迹、残影、退场特效的额外延长可能性,不是稳定可用时间。
- 大多数播放场景会在
line.endTime 或下一句开始时直接切到下一句;不要把核心 reveal、排版进入、关键信息展示依赖到 renderEndTime 之后。
- 需要清理尾迹或判断“最晚还可保留多久”时,才使用
getLineRenderEndTime(line)。
处理 segment/layout unit 到 words 的映射时,优先保留或计算明确范围:
- 从分割函数返回 segment 的
startOffset / endOffset
- 按前序 segment 累积 cursor
- 用原始
Line.words 的字符范围做映射
- 对重复词、CJK、标点、空格和长句添加单测
任何歌词分割都必须保证:
segments.map(s => s.text).join('') === fullText
- 空格、标点、CJK grapheme 不被意外丢失
- 重复片段不会复用错误时间
- parser 的词级 timing 仍然是动画时序真源
Layout And Measurement
测量、分词、排版和缓存应保持稳定:
- 用
useMemo 缓存纯计算布局,依赖项必须能解释布局变化原因。
- 用
useRef 保存缓存 Map、RAF id、DOM node、上一次 frame 状态。
ResizeObserver 里只在尺寸真实变化时 setState。
- 长文本测量和
Intl.Segmenter 结果不要在 render 中反复重算,除非文本很短且路径低频。
- cache key 必须包含影响布局的主题、字体、尺寸、tuning 和歌词内容。
- 字体相关 cache key、memo 依赖和测量规格必须包含
resolveThemeFontWeight 得到的最终字重;测量与实际 DOM / Canvas / 光栅化渲染不得使用不同字重。
- RAF / timeout / observer 必须在 effect cleanup 中释放。
Module Boundaries
遵守仓库职责边界;如果需要 README 背景,按 readme-reference 只读取相关片段:
- visualizer 共享逻辑放
components/visualizer/* 的 runtime、registry、shell 或相邻 util。
- 模式实现放
components/visualizer/<mode>/;当前模式包括 classic、cadenza、partita、fume、cappella、tilt、claddagh、monet、diorama、pendolo、sonnet。
- 背景实现放
components/visualizer/backgrounds/*,当前 entry 为 common、latent、monet、nomand、sora、url。
- 歌词解析真源看
utils/lyrics/parserCore.ts。
- 纯分词、layout、时序映射优先放
utils/lyrics/*,并用单测覆盖。
- 共享类型先看
types.ts。
- 不要把新 visualizer 的设置、runtime、layout、测试辅助全部堆进单个超大组件;必要时配合
file-modularization skill。
Review Checklist
审查或实现性能敏感前端改动时,至少检查这些问题:
- 是否存在高频回调里的无条件
setState?
- 是否把当前精确播放时间、RAF 时间戳或 MotionValue 的连续值写进 React state / store?
- 是否每帧创建新数组、新对象并进入 React state?
- 是否每个字符、每个词、每个 segment 都各自订阅同一个 MotionValue?
claddagh 是否仍只渲染中心行附近的有限 ring line,字符测量是否命中有界缓存?
- 不可见或 inactive 元素是否仍在持续更新?
- 动画能否改成 CSS/Motion/canvas 驱动,而不是 React rerender?
- 重复文本、CJK、多空格和标点是否有测试?
- 单测通过是否只是放宽断言,而不是验证了目标行为?
- 变更是否需要 UI 截图测试或手动性能观察,而不只是 unit test?
- 新增缓存是否有失效条件,旧 DOM/canvas 节点是否会被清理?
- 歌词和字幕是否统一使用
resolveThemeFontWeight,且最终字重已进入测量规格、memo 依赖和布局缓存键?
- 是否存在会覆盖用户字重设置的
font-bold、font-medium、固定 fontWeight 或固定 Canvas / pretext 字体规格?
- 新增 visualizer 是否遵守 registry、runtime、shell、subtitle overlay 的既有结构?
Validation
按风险选择最小验证:
- 纯歌词分割、时序映射、layout util:跑相关 Vitest 单测。
- visualizer 交互、样式、截图回归:按
testing-strategy 判断是否跑 Playwright UI。
- CPU、jank、逐帧动画:优先解释更新路径,并建议用浏览器 Performance 或 Electron 实测确认。
- 不要为了 visualizer 小改默认跑完整 build;先读
testing-strategy。
Red Flags
看到这些实现时要停下来重新设计:
useMotionValueEvent、RAF 或 observer 里每次回调都创建新引用并写入 state
- 用
useState、store 或 reducer 每帧保存当前精确时间,再依赖 React rerender 推进动画
- 每个字符一个 React state 或每帧重建字符强度数组
- inactive segment 也持续写 state
- 用字符串搜索推断歌词时序范围,却没有处理重复文本和 offset
- render 阶段反复执行重型分词、测量、排序
- 测试断言比需求更宽,无法约束时序、分割或性能目标
- 新功能让已很大的 visualizer 文件继续膨胀,却没有拆 layout/util/hook
- cache key 漏掉主题、字体、窗口尺寸或 tuning,导致旧布局复用
- 歌词或字幕硬编码字重,或测量与实际渲染使用不同字重