# Reuse Project Utilities

> Use when implementing, refactoring, or reviewing code in this repository and the task may duplicate existing helpers, libraries, visualizer utilities, lyrics utilities, font/color helpers, list virtualization, icon usage, i18n, playback adapters, or service patterns. Trigger before writing new utility functions or installing/hand-rolling functionality already present in the project.

- Skill: `chthollyphile/reuse-project-utilities` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add chthollyphile/reuse-project-utilities`
- Raw SKILL.md: https://api.skillmd.com/api/skills/chthollyphile/reuse-project-utilities/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: chthollyphile (https://skillmd.com/u/chthollyphile)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/chthollyphile/reuse-project-utilities

---


# Reuse Project Utilities

## Purpose

这个 skill 用于防止“造一个颜色不同的轮子”。写代码前先查已有 helper、组件模式和项目已安装库，优先复用它们。

核心规则：

- 新增工具函数前，先用 `rg` 搜相同领域的现有函数。
- 外部库已经覆盖的能力，不要手写低配版。
- 复用项目里已经承载语义的 helper，而不是复制局部实现。
- 如果已有 helper 不够，优先扩展原 helper 并补测试，而不是另起相似名字。

## Fast Lookup

按任务类型先查这些入口：

- 歌词解析、时序、render end：`src/utils/lyrics/*`
- visualizer 运行时、背景、颜色：`src/components/visualizer/*`
- 设置 UI、导入导出、命令面板：`src/components/modal/settings/*`、`src/stores/useSettingsModalStore.ts`（弹窗 UI 状态）、按领域拆分的 `src/stores/use*SettingsStore.ts`（设置值）、`src/components/command-palette/*`
- 同步配置、主题同步和本地导出：`src/services/sync/*`、`src/components/modal/settings/StorageSettingsSection.tsx`
- 字体栈和自定义字体：`src/utils/fontStacks.ts`、`src/services/customLyricsFont.ts`
- 播放队列、播放适配：`src/services/playbackAdapters.ts`、`src/utils/appPlaybackHelpers.ts`
- 播放身份与去重：`src/utils/appPlaybackGuards.ts`、`src/utils/appPlaybackHelpers.ts`
- 在线歌曲统一入口：`src/services/onlineMusic/omni.ts`、`src/types/onlineMusic.ts`
- 本地库索引与命名：`src/utils/localLibraryIndex.ts`、`localLibraryNames.ts`、`localLibraryResolver.ts`
- Stage / OBS / PlayerCap 数据转换：`src/utils/appStageHelpers.ts`、`src/utils/stageClientDemo.ts`、`src/utils/stagePlayerSnapshot.ts`、`src/utils/obs*.ts`、`src/utils/playerCap*.ts`
- 网易云 / Navidrome / 本地音乐 API：`src/services/netease.ts`、`navidromeService.ts`、`localMusicService.ts`
- 主题、封面、取色、缓存：`src/hooks/themeControllerState.ts`、`src/utils/colorExtractor.ts`、`src/services/themeCache.ts`、`src/services/coverCache.ts`
- UI 图标、动画、弹窗、选择器：`lucide-react`、`framer-motion`、`components/shared/*`

推荐搜索：

```bash
rg -n "目标词|函数名|相邻概念" src test
rg -n "prepareWithSegments|layoutWithLines|useVisualizerRuntime|getLineRenderEndTime|buildLineGraphemeTimeline|resolveThemeFontStack|colorWithAlpha|mixColors" src
```

## Common Utilities

### Text Layout And Measurement

已安装 `@chenglou/pretext`，用于文字准备、测量和排版。

优先用：

```ts
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

const prepared = prepareWithSegments(text, fontSpec);
const layout = layoutWithLines(prepared, maxWidth, lineHeightPx);
const width = layout.lines[0]?.width ?? fallbackWidth;
```

适用场景：

- visualizer 文本宽度测量
- 歌词自动换行
- 气泡、块状文本、canvas overlay 的排版
- 需要和 CJK / grapheme 排版更接近真实浏览器表现的场景

不要手写 `text.length * fontSize` 作为主测量逻辑；只能作为 fallback。

#### 测量必须和真实渲染结构一致

`prepareWithSegments(fullText, fontSpec)` 把整行当成一个可任意断开的字符串。只有当 DOM 里这行文本
也是一个普通的 inline 文本流时，这个假设才成立。

如果渲染时把每个词/token 包成了 `display: inline-block`（逐字扫光、per-word 上色、chip、mention 都会这么做），
那它就是**原子行内盒，内部不允许断行**，浏览器只能在 token 之间断。中日文尤其明显：词间没有空格，
pretext 会在任意字之间断，浏览器只能在 token 边界断，实测行数可以差 1~2 行。行数算少了，
预留高度就不够，多出来的行会从 padding 里漏出去压到下一个区块（Monet 歌词压到翻译行就是这么来的）。

这种情况用 rich-inline helper，把原子 token 标成 `break: 'never'`：

```ts
import { measureRichInlineStats, prepareRichInline, type RichInlineItem } from '@chenglou/pretext/rich-inline';

const items: RichInlineItem[] = tokens.map(token => ({
    text: token.text,
    font: fontSpec,
    break: token.timed ? 'never' : 'normal',   // 'never' == inline-block 原子盒
}));
const { lineCount } = measureRichInlineStats(prepareRichInline(items), maxWidthPx);
```

实测（Chromium，对照真实 DOM 行数）：token 都放得进列宽时，`break: 'never'` 154 组用例**零误差**，
而按整串测量错 14 组。参考实现见 `measureLyricLineCount`（`src/components/visualizer/monet/monetLyricsModel.ts`）。

其它容易踩的对齐点：

- **`whiteSpace`**：`prepare()` / `prepareWithSegments()` 默认按 `white-space: normal` 处理。DOM 上写了
  `whitespace-pre-wrap` 就必须传 `{ whiteSpace: 'pre-wrap' }`，否则连续空格被折叠，行数算少。
- **`letterSpacing` / `wordBreak`**：CSS 上有 `letter-spacing`、`word-break: keep-all` 时，同样要作为 options 传进去。
- **零宽字符不是断行控制手段**：U+2060 WORD JOINER、U+FEFF 都不会阻止 pretext 断行（宽度为 0，断点照旧），
  别指望靠插入零宽字符来表达"不可断"。要原子性就用 rich-inline 的 `break: 'never'`。
- **超宽 token 仍不精确**：token 自然宽度超过列宽时，Blink 对 `inline-block` 的 shrink-to-fit 行为
  （`overflow-wrap: break-word` 不参与 min-content 计算，结果是溢出而不是内部换行）rich-inline 没有建模，
  实测仍有偏差。这种极端情形不要依赖测量值兜底，改用内容自撑高度。
- **`system-ui` 在 macOS 上不可靠**：pretext README 明确说明 `layout()` 精度对 `system-ui` 不保证，字体栈里
  优先落到具名字体。
- **单测要 mock 子路径**：`vi.mock('@chenglou/pretext')` 不会覆盖 `@chenglou/pretext/rich-inline`，
  两个都要 mock，否则 node 环境下会抛 `Text measurement requires OffscreenCanvas or a DOM canvas context.`。

### Visualizer Runtime

visualizer 当前行、上一行、下一行、预热窗口已有共享入口：

- `useVisualizerRuntime`
- `prepareActiveAndUpcoming`
- `shouldPreheatLine`
- `getUpcomingLine`
- `getUpcomingLines`

位置：`src/components/visualizer/runtime.ts`

新增 visualizer 时优先使用它们，不要重新写“当前行/下一行/最近完成行”扫描逻辑。

### Lyrics Timing Hints

歌词行显示结束时间和过短行处理已有统一 helper：

- `getLineRenderHints`
- `getLineRenderEndTime`
- `getLineTransitionTiming`
- `ensureLyricLinesRenderHints`

位置：`src/utils/lyrics/renderHints.ts`

需要决定 line exit、subtitle 保留、短行 fast/instant reveal 时，先用这些 helper。不要直接假设 `line.endTime` 就是视觉渲染结束时间。

写 visualizer 时同时遵守 `frontend-runtime-guardrails` 里的歌词输入契约：renderer 消费统一 `LyricData`，不要重新处理原始歌词格式或翻译对齐。

### Lyrics Layout Units

CJK 语义分组、sticky 标点、英文 contraction 已有布局工具：

- `buildPostLyricLayoutUnits`
- `buildDisplayWordsFromLayoutUnits`
- `createSingleWordLayoutUnits`

位置：`src/utils/lyrics/cjkSemanticLayout.ts`

新增按词/按块 visualizer 时，优先使用 layout units。不要在组件里临时拼接标点、撇号、CJK 字符。

### Word Segmentation

歌词按词分词只有一个入口：`src/utils/lyrics/wordSegmentation.ts`

- `segmentLyricWords(line)` —— 有用户保存的精细分词就用它，否则 `Intl.Segmenter`
- `segmentTextWords(text)` —— 没有 `Line` 时的无覆盖版本
- `segmentsFromBoundaries(boundaries)`、`isValidWordSegmentation(text, boundaries)`

**不要再写 `new Intl.Segmenter(..., { granularity: 'word' })`。** 这里原本有三份各自为政的实现
（`cjkSemanticLayout`、`sonnetSemantic`、`temperaProgram`，后两份逐字重复），用户的精细分词
（命令 `lyric-segmentation`）就没法一次覆盖到所有模式。`granularity: 'grapheme'` 是另一回事，
走 `graphemeTiming.ts`，与这里无关。

新增按词排版的 visualizer 时，在它的 `entry.tsx` 上标 `usesWordSegmentation: true`，
面板快捷按钮和命令的可用性都从注册表读这个字段。

### Grapheme Timing

逐字或逐 grapheme 动画优先复用 `src/utils/lyrics/graphemeTiming.ts`：

- `buildLineGraphemeTimeline`
- `buildWordGraphemeTimings`

它们负责把 `Line.words` 的原始 timing 映射到可渲染字符；不要在 renderer 里用字符串搜索重新猜重复词、空格或标点的时间范围。

### Font Stacks

主题字体和自定义字体已有 resolver：

- `resolveThemeFontStack`
- `resolveThemeTranslationFontStack`
- `getBuiltinThemeFontStack`
- `resolveThemeFontWeight`
- `normalizeFontWeight`

位置：`src/utils/fontStacks.ts`

需要 CSS `fontFamily`、canvas `font`、pretext `fontSpec` 时，先用 resolver。不要手写一份新的 fallback font stack。

歌词和字幕的最终字重必须使用 `resolveThemeFontWeight(theme, modeFallback)`。模式自身的 300、400、500、700 等设计值只能作为 `modeFallback`：`theme.fontWeight` 有值时必须由用户值覆盖，没有值时才保留模式原设计。当用户在设置面板或快捷设置中清空/重置自定义字体栈时，系统会自动清空保存的自定义字重。DOM、Canvas、pretext 和光栅化文本必须复用同一个解析结果；测量规格、memo 依赖和布局缓存键也必须包含该最终字重。

不要在歌词或字幕渲染路径中使用 `font-bold`、`font-medium`、固定 `style.fontWeight`，也不要把固定字重直接写进 Canvas / pretext 字体规格。这些写法会绕开视觉设置，并造成测量与渲染不一致。

### Visualizer Colors

visualizer 颜色混合和 alpha 已有 helper：

- `colorWithAlpha(color, alpha)`
- `mixColors(from, to, amount, alpha?)`

位置：`src/components/visualizer/colorMix.ts`

需要 `rgba(...)`、主题色混合、canvas gradient、shadow color 时，先用这些 helper。不要重复写 hex/rgb parser。

组件颜色必须来自当前 `Theme` / `DualTheme` 流程。新增 UI 或 visualizer 颜色时，不要把只适合单一明暗背景的固定 hex / rgba 当成主色长期写死；应从 `theme.backgroundColor`、`theme.primaryColor`、`theme.accentColor`、`theme.secondaryColor` 或由 `buildBuiltinDualTheme` / `useThemeController` 选出的当前明暗主题动态派生，并确保 light / dark 两套颜色都有可读对比。临时 alpha、shadow 和 gradient 也应基于主题色再用 `colorWithAlpha` / `mixColors` 生成。

### Icons

项目使用 `lucide-react`。按钮、tab、控制项、状态动作优先从 lucide 导入图标。

不要手写 SVG，除非现有 icon 库没有合适图标或需要项目专属图形。

### Animation

项目使用 `framer-motion`：

- 普通 enter/exit：`motion`、`AnimatePresence`
- 播放进度和时间值：`MotionValue`、`useMotionValueEvent`
- 派生动画值：`useTransform`、`useSpring`

新增动画先看相邻组件模式。涉及高频运行时，再结合 `frontend-runtime-guardrails`。

### i18n

UI 文案使用 `react-i18next`：

```ts
const { t } = useTranslation();
```

任何新增到 UI 上的用户可见文本都必须准备 i18n key，并同步写入 `src/i18n/locales/en.ts` 和 `src/i18n/locales/zh-CN.ts`。不要把按钮、标题、提示、空态、设置项、tooltip、toast 或模式文案只写成硬编码字符串；短 fallback 只能作为现有 registry / 兼容模式的兜底，不能替代字典项。

### Settings And Commands

新增设置时先套用 `settings-feature-integration`：

- 视觉相关设置必须进入 `AppearanceSettingsSubview.tsx` 的导入导出链路。
- 功能性设置或可执行动作必须注册到 `src/components/command-palette/commandRegistry.ts`。
- 设置状态优先复用已有 store：弹窗 UI 状态在 `src/stores/useSettingsModalStore.ts`，设置值在对应领域的 `use*SettingsStore`（`useAudioSettingsStore`、`useLyricSettingsStore`、`useThemeSettingsStore`、`useTypographySettingsStore` 等）。不要在组件里另起一套 localStorage 读写。

### Long Lists

项目已使用 `react-window` 的 `List` 和 `useListRef`。

需要渲染队列、字体列表、大量歌曲列表时，先查现有 `react-window` 用法。不要用普通 `.map()` 渲染大量可滚动项目。

## Service And Data Patterns

不要绕过现有 service：

- 网易云 API：`src/services/netease.ts`
- Navidrome / Subsonic：`src/services/navidromeService.ts`
- 本地音乐：`src/services/localMusicService.ts`
- 在线歌曲搜索、播放、歌词、歌单、账户和 provider 路由：`src/services/onlineMusic/omni.ts`
- 在线播放编排：`src/services/onlinePlayback.ts`（内部在线数据仍须通过 Omni）
- 播放结构统一：`src/services/playbackAdapters.ts`
- IndexedDB：`src/services/db.ts`
- 队列预取：`src/services/prefetchService.ts`
- 同步 HTTP / diff / 本地 registry：`src/services/sync/syncClient.ts`、`syncRepository.ts`、`themeSyncRegistry.ts`、`syncCoordinator.ts`

新增数据流时，先判断是 service、hook、util 还是 component：

- 请求、缓存、接口适配放 service。
- React 生命周期和用户动作编排放 hook 或 app-level builder。
- 纯计算、映射、格式化放 util。
- 展示和交互结构放 component。
- 普通在线歌曲调用必须走 `onlineMusic/omni.ts`；不要在 component/hook/store 中直接调用 provider adapter 或 transport。
- app-level props、导航和动作组装优先查 `src/components/app/*/build*.ts` / `create*.ts`，不要在 `App.tsx` 重新复制一套映射。

## Extension Rule

如果找到已有 helper 但不完全够用：

- 优先扩展已有 helper 的参数或返回值。
- 保持现有调用兼容。
- 给扩展后的 helper 补单测。
- 避免创建同领域的第二套 `formatXxx`、`parseXxx`、`measureXxx`、`mixXxx`。

只有在职责确实不同、现有 helper 会变得混乱时，才新建工具。

## Review Checklist

审查代码时检查：

- 是否手写了已由 `@chenglou/pretext`、`fontStacks`、`colorMix`、lyrics utils 覆盖的逻辑？
- 是否复制了 visualizer runtime 的当前行/下一行扫描？
- 是否直接使用 `line.endTime`，但应该使用 `getLineRenderEndTime`？
- 是否手写 SVG，而 lucide 已有图标？
- 是否新建了 service 请求逻辑，但已有 service 已经封装同类 API？
- 是否绕过 `onlineMusic/omni.ts`，或丢失 `sourceRef/providerId` 造成跨 provider 误去重？
- 是否复制了本地库索引、播放身份、Stage/OBS/PlayerCap 的现有转换 helper？
- 是否绕过 `src/services/sync/*`，直接对同步服务发 fetch，或直接读写主题同步 registry / IndexedDB？
- 是否对大量列表使用普通 `.map()` 而不是虚拟列表？
- 是否新增硬编码文案却没有更新 i18n 字典？
- 是否新增设置却没有接入视觉配置导入导出或 command palette？
- 是否新增固定颜色却没有从 `Theme` / `DualTheme` 动态派生并检查明暗两套表现？
- 是否硬编码歌词或字幕字重，而没有使用 `resolveThemeFontWeight` 和模式 fallback？
- DOM、Canvas、pretext、光栅化的最终字重以及布局缓存键是否一致？
- pretext 测量的文本结构是否和实际渲染结构一致？渲染成 `inline-block` token 时要用 rich-inline 的 `break: 'never'`，DOM 上是 `whitespace-pre-wrap` 时要传 `{ whiteSpace: 'pre-wrap' }`。
- 是否创建了相似 helper，却没有搜索已有实现或测试？

## Validation

- 纯工具、歌词、颜色、字体：优先补或跑相关 Vitest 单测。
- visualizer UI：按 `testing-strategy` 判断是否需要 UI 截图测试。
- service 改动：优先看现有 service 单测或相邻 mock 模式；不要直接打真实外部服务。

