From 9b63424740e4e201f71418d18f3490c91a7284a1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Timeless=E5=B0=8F=E5=B8=85?= <1018008088@qq.com>
Date: Mon, 12 Oct 2026 18:06:20 +0800
Subject: [PATCH] =?UTF-8?q?chore:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E4=BB=93?=
=?UTF-8?q?=E5=BA=93=EF=BC=8C=E6=8F=90=E4=BA=A4=20vibevideo=20=E6=BA=90?=
=?UTF-8?q?=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- src/: HyperFrames 封装 CLI(hv)、组件、品牌样式、音频(TTS/混音)工具
- projects/: 各项目 project.yaml 与文案
- interactive/: 井字棋演示页
- .gitignore: 忽略 node_modules、构建产物与 HyperFrames 运行期缓存
---
.gitignore | 44 +
README.md | 261 +++
interactive/README.md | 55 +
interactive/tictactoe/index.html | 456 +++++
package-lock.json | 1971 +++++++++++++++++++++
package.json | 21 +
projects/cad3d-demo/project.yaml | 11 +
projects/demo全组件/project.yaml | 72 +
projects/ep001/project.yaml | 56 +
projects/stress/project.yaml | 8 +
projects/井字棋科普/BRIEF.md | 43 +
projects/井字棋科普/project.yaml | 92 +
projects/井字棋科普/script.md | 21 +
projects/配音演示/project.yaml | 28 +
src/audio/cli.py | 286 +++
src/audio/mix.py | 166 ++
src/audio/requirements.txt | 6 +
src/audio/sfx/__init__.py | 0
src/audio/sfx/synth.py | 159 ++
src/audio/tts/__init__.py | 23 +
src/audio/tts/base.py | 288 +++
src/audio/verify_sync.py | 134 ++
src/brand/base.css | 95 +
src/brand/tokens.css | 75 +
src/cli/hv.mjs | 997 +++++++++++
src/cli/probe_studio.mjs | 71 +
src/cli/shot.mjs | 76 +
src/cli/vendor/csg-entry.js | 9 +
src/cli/vendor/hljs-entry.js | 52 +
src/components/cad3d/cad3d.html | 365 ++++
src/components/codedemo/codedemo.html | 299 ++++
src/components/cursor/cursor.html | 138 ++
src/components/intro/intro.html | 98 +
src/components/lowerthird/lowerthird.html | 90 +
src/components/outro/outro.html | 97 +
src/components/stress/stress.html | 181 ++
src/components/terminal/terminal.html | 220 +++
src/components/textblock/textblock.html | 110 ++
src/components/tictactoe/tictactoe.html | 571 ++++++
src/templates/代码讲解.yaml | 57 +
src/templates/科普图文.yaml | 46 +
src/vendor/runtime.js | 156 ++
42 files changed, 8004 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 interactive/README.md
create mode 100644 interactive/tictactoe/index.html
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 projects/cad3d-demo/project.yaml
create mode 100644 projects/demo全组件/project.yaml
create mode 100644 projects/ep001/project.yaml
create mode 100644 projects/stress/project.yaml
create mode 100644 projects/井字棋科普/BRIEF.md
create mode 100644 projects/井字棋科普/project.yaml
create mode 100644 projects/井字棋科普/script.md
create mode 100644 projects/配音演示/project.yaml
create mode 100644 src/audio/cli.py
create mode 100644 src/audio/mix.py
create mode 100644 src/audio/requirements.txt
create mode 100644 src/audio/sfx/__init__.py
create mode 100644 src/audio/sfx/synth.py
create mode 100644 src/audio/tts/__init__.py
create mode 100644 src/audio/tts/base.py
create mode 100644 src/audio/verify_sync.py
create mode 100644 src/brand/base.css
create mode 100644 src/brand/tokens.css
create mode 100755 src/cli/hv.mjs
create mode 100644 src/cli/probe_studio.mjs
create mode 100644 src/cli/shot.mjs
create mode 100644 src/cli/vendor/csg-entry.js
create mode 100644 src/cli/vendor/hljs-entry.js
create mode 100644 src/components/cad3d/cad3d.html
create mode 100644 src/components/codedemo/codedemo.html
create mode 100644 src/components/cursor/cursor.html
create mode 100644 src/components/intro/intro.html
create mode 100644 src/components/lowerthird/lowerthird.html
create mode 100644 src/components/outro/outro.html
create mode 100644 src/components/stress/stress.html
create mode 100644 src/components/terminal/terminal.html
create mode 100644 src/components/textblock/textblock.html
create mode 100644 src/components/tictactoe/tictactoe.html
create mode 100644 src/templates/代码讲解.yaml
create mode 100644 src/templates/科普图文.yaml
create mode 100644 src/vendor/runtime.js
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ffe10d9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,44 @@
+# 依赖与构建产物
+node_modules/
+src/vendor/three.js
+src/vendor/csg.js
+src/vendor/hljs.js
+src/vendor/gsap.min.js
+
+# 项目内自动生成(hv 生成 时会重建,不要提交)
+projects/*/index.html
+projects/*/assets/brand/
+projects/*/assets/vendor/
+projects/*/assets/canvas.css
+projects/*/compositions/components/
+projects/*/hyperframes.json
+projects/*/package.json
+projects/*/meta.json
+
+# HyperFrames 运行期缓存(preview 是几 MB 的自包含 HTML,必须忽略)
+projects/*/.thumbnails/
+projects/*/.hyperframes/
+
+# 成片与中间产物
+projects/*/out/
+projects/*/snapshots/
+projects/*/assets/audio/
+cache/
+out/
+
+# 编辑器与系统
+.DS_Store
+*.swp
+.idea/
+.vscode/
+
+# 运行日志与本地密钥
+*.log
+.env
+.env.*
+!.env.example
+
+# Python
+__pycache__/
+*.pyc
+.venv/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0f7a5b8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,261 @@
+# vibevideo
+
+用 HTML 写视频。一个视频 = 一个 `project.yaml`;组件、模板、素材全站共享。
+
+渲染引擎是 [HyperFrames](https://github.com/heygen-com/hyperframes)(Apache-2.0,HeyGen 开源),
+**但它只占整个系统约 5% 的代码**,全部关在 `src/cli/hv.mjs` 里,随时可换。
+
+---
+
+## 30 秒上手
+
+```bash
+node src/cli/hv.mjs 体检 # 环境体检
+node src/cli/hv.mjs 新建 ep001 # 建项目
+$EDITOR projects/ep001/project.yaml # 写内容
+node src/cli/hv.mjs 生成 ep001 # 生成 index.html
+node src/cli/hv.mjs 看帧 ep001 # 抽几帧看效果(快)
+node src/cli/hv.mjs 渲染 ep001 # 出片
+```
+
+所有子命令都有英文别名(`hv new` / `hv build` / `hv render` …),中文只是习惯问题。
+
+---
+
+## 一个项目长什么样
+
+```
+projects/ep001/
+├── project.yaml ← 你唯一要写的文件
+├── BRIEF.md ← 选题、受众、一句话信息
+├── script.md ← 逐场景台词
+├── assets/project/ ← 私有素材(录屏、截图)
+└── out/ ← 成片
+```
+
+`project.yaml`:
+
+```yaml
+title: 三个 Cursor 实用技巧
+aspect: 横屏 # 横屏 1920x1080 / 竖屏 1080x1920 / 方形 1080x1080 / 超宽
+fps: 60 # 24 / 30 / 60
+lang: zh-CN
+
+scenes:
+ - component: intro
+ duration: 3.5
+ props:
+ kicker: 技巧合集
+ title: 三个 Cursor 实用技巧
+ subtitle: 让你写代码少一半时间
+
+ - component: codedemo
+ duration: 8
+ props:
+ filename: fib.py
+ language: python
+ highlight: "3,4,5" # 高亮这几行
+ dimOthers: "true" # 压暗其他行
+ code: |
+ def fib(n):
+ if n < 2:
+ return n
+```
+
+跑 `hv 生成` 后,它会编译成时间轴、挂载组件、同步资源。
+
+---
+
+## 命令
+
+| 命令 | 作用 |
+|---|---|
+| `hv 新建 <名> [--template 模板] [--aspect 画幅]` | 建项目 |
+| `hv 生成 <名>` | `project.yaml` → `index.html` |
+| `hv 检查 <名>` | 校验参数/时长 + 运行时/对比度/布局检查 |
+| `hv 渲染 <名> [--quality 草稿\|正式\|交付]` | 出片到 `out/` |
+| `hv 预览 <名>` | 浏览器实时预览 |
+| `hv 看帧 <名> [--时间 1,3,5]` | 抽帧,比整条渲染快得多 |
+| `hv 说明 <组件>` | 看某组件有哪些参数 |
+| `hv 列表 [项目\|组件\|模板]` | 列出已有内容 |
+| `hv 体检` | 依赖/字体/组件检查 |
+
+---
+
+## 组件库
+
+| 组件 | 说明 |
+|---|---|
+| `intro` | 片头:标题、副标题入场 |
+| `outro` | 片尾:引导关注 |
+| `codedemo` | 代码演示:逐行打字、语法高亮、重点行高亮 |
+| `terminal` | 终端:命令逐字输入、输出分类着色 |
+| `lowerthird` | 下方字幕条 |
+| `textblock` | 文字块,支持 `**加粗**` 和 `[[强调色]]` |
+| `cursor` | 鼠标指针:移动、点击涟漪 |
+| `cad3d` | **三维钩子建模全过程**:草图→拉伸→切除→圆角 |
+| `stress` | 自由度压测:9 项超纲特性的渲染探针 |
+
+`hv 说明 codedemo` 看参数。组件的参数表直接写在组件自己的
+`data-composition-variables` 里,带中文标签,`hv 检查` 会校验你传错的名字。
+
+---
+
+## 分辨率与画幅
+
+改 `project.yaml` 的 `aspect` 即可,**组件代码一行不用动**。
+
+```yaml
+aspect: 竖屏 # 1080x1920
+# 或自定义
+width: 2560
+height: 1080
+```
+
+原理:所有尺寸都写成 `calc(N * var(--u))`,`--u` = 短边 / 1080,
+所以 1080p 横竖都是 `1px`,4K 自动放大一倍。
+
+> ⚠️ 组件里写 `width: var(--stage-w)` 是**非法的**(缺单位,浏览器会整条丢弃)。
+> 无单位数字用在 `width/height` 上必须补 `* 1px`。这个坑踩过一次。
+
+---
+
+## 写新组件
+
+`src/components/<名字>/<名字>.html`,只有一个硬性要求:
+
+```js
+window.__timelines["你的组件名"] = gsap.timeline({ paused: true });
+```
+
+**就这一条。** 满足它,组件就能在任何渲染器里跑。
+
+### 两条铁律
+
+1. **画面必须是时间 t 的纯函数。**
+ 给定第 37 帧,直接画出第 37 帧长什么样,不依赖前面播过什么。
+ 这是缓存、并行、秒级拖动预览的基础。
+ - ✅ `gsap` 时间轴 + `onUpdate` 里重画
+ - ✅ 3D 每帧手动清空重画
+ - ❌ `requestAnimationFrame` 累积状态
+ - ❌ CSS `@keyframes`(真实时间跑,抓到的是任意中间态)
+
+2. **不准 import 渲染引擎的私有 API。**
+ 组件只允许依赖:浏览器 + GSAP + 上面那条注册约定。
+ 违反这条,将来换渲染引擎就得重写。
+
+---
+
+## 当前能力边界(实测结论)
+
+**能做**:WebGL / Three.js、真实布尔运算(CSG)、mix-blend-mode、clip-path、
+mask-image、3D 变换、SVG 描边动画、canvas 逐帧重绘、字体排版、音频混流。
+
+**约束只有一条**:确定性渲染(见上面铁律 1)。
+这不是某个框架的限制——Remotion 也一样,只是它把这个约束藏在你写 React 组件里,
+你察觉不到。
+
+**本机的性能实测**(12 核 i5,1080p):
+- 10s @ 30fps → 11.8s(5 个并行 worker,约等于实时)
+- 16s @ 30fps → 22s
+- 60fps 帧数翻倍,耗时也翻倍
+
+---
+
+## 目录
+
+```
+├── src/
+│ ├── cli/hv.mjs ★ 适配层:CLI + 时间轴编译(约 600 行)
+│ ├── cli/shot.mjs 抽帧
+│ ├── components/ 组件库
+│ ├── brand/ 设计规范:色板、字体、基准单位
+│ ├── templates/ 视频结构套路
+│ ├── audio/ Python:TTS 接口 + 程序化音效
+│ └── vendor/ gsap / three / highlight.js
+├── projects/ 视频项目
+├── tools/ 构建 vendor 的脚本
+└── node_modules/
+```
+
+---
+
+## 音频
+
+配音、点击音、背景音都归这条链路管。**核心设计:场景时长 = 配音时长**,
+所以你不用手工对轴——写完台词,跑一条命令,时间轴自己就对了。
+
+### 用法
+
+```bash
+python3 src/audio/cli.py 检查 # 音频侧体检
+python3 src/audio/cli.py 配音 <项目> # 合成配音 + 对齐场景时长
+python3 src/audio/cli.py 音效 <项目> # 合成音效
+python3 src/audio/cli.py 混音 <项目> --bgm bgm.mp3 # 混成一条轨
+node src/cli/hv.mjs 渲染 <项目> # 音轨自动挂上
+python3 src/audio/verify_sync.py <项目> # 验证音画真的对齐
+```
+
+在 `project.yaml` 的场景里写 `narration` 即可:
+
+```yaml
+- component: codedemo
+ duration: 8
+ narration: "第二步拉伸,把这条轮廓沿 Z 轴扫成实体。" # 时长会被这句覆盖
+ sfx: "pop@1.2" # 第 1.2 秒一个音效
+```
+
+跑完 `配音` 后,时长会变成 `朗读时长 + 0.35s 留白`,并回写 `project.yaml`。
+
+### 换 TTS
+
+Provider 是抽象的,换服务只写一个类:
+
+```python
+from tts import TTSProvider, AudioClip, register
+
+class MyTTS(TTSProvider):
+ name = "我的服务"
+ def synthesize(self, text, voice="", out_dir=None) -> AudioClip:
+ path = call_your_api(text, voice)
+ return AudioClip(path=path, duration=real_duration,
+ text=text, voice=voice, provider=self.name)
+
+register("mine", MyTTS)
+```
+
+然后 `配音 <项目> --provider mine`。**画面侧一行都不用动。**
+
+已内置:
+
+| Provider | 说明 |
+|---|---|
+| `dummy` | 不发声,但产出**精确时长的合法 wav**。链路先跑通,不是 mock |
+| `kokoro` | 本地离线 Kokoro-82M,不联网、结果可复现 |
+
+> ⚠️ `duration` 必须真实,不能估算——整条时间轴的对齐都依赖它。
+> DummyTTS 的价值就在于:现在就能验证音画链路,不用等真实 TTS。
+
+### 音效是算出来的,不是找来的
+
+`src/audio/sfx/synth.py` 用 numpy 程序化合成:点击、提示音、转场、落位、气泡。
+体积为零、参数随便调、能跟着画面的强调色走。
+
+### 验证对齐
+
+`verify_sync.py` 会在每个场景起点铺一个可闻的标记音,渲染成片后逐点测能量峰。
+长度对但偏移了照样是错的,所以必须这样测。
+
+---
+
+## 为什么用 HyperFrames
+
+| | HyperFrames | 自己造 / Remotion |
+|---|---|---|
+| 成熟度 | Apache-2.0,HeyGen 在用 | 从零三个月起步 |
+| 许可证 | 免费 | Remotion 按人头 $25/月,含 agent 写码 |
+| 并行渲染 | 5 worker 自带 | 自己实现 |
+| 对比度/布局检查 | `check` 自带 | 自己实现 |
+
+**但真正的理由是隔离**:引擎的全部知识只存在于 `src/cli/hv.mjs`。
+你的组件不知道引擎的存在。哪天要换,改一个文件,组件全部原样保留。
\ No newline at end of file
diff --git a/interactive/README.md b/interactive/README.md
new file mode 100644
index 0000000..38a6581
--- /dev/null
+++ b/interactive/README.md
@@ -0,0 +1,55 @@
+# interactive —— 交互网页
+
+和 `projects/`(视频)并列的另一类产物:**网页,不是视频**。
+
+这里不经过 `hv` 时间轴、不经过渲染引擎、不依赖 ffmpeg。
+代码里没有 `data-start` / `data-duration` / `window.__timelines`。
+
+## 能复用什么
+
+| 框架里的东西 | 网页里怎么用 |
+|---|---|
+| `src/brand/tokens.css` | 色板、字体回退链、`--u` 基准单位,**直接相对路径引入,零拷贝** |
+| `src/brand/base.css` | reset、排版原子(`.panel` / `.display` / `.headline`)、`--u` 缩放写法 |
+| `src/vendor/three.js` | 离线可用,不依赖 CDN(交互 3D 时用) |
+
+**只需要覆盖两处「视频专用」的设置:**
+
+1. `--u`:视频里对应固定的 1920x1080 舞台,网页里改成
+ ```css
+ :root { --u: calc(100vmin / 1080); } /* 同一个「短边 / 1080」比例 */
+ ```
+ 所有 `calc(N * var(--u))` 的写法原样继续有效。
+2. `base.css` 把 `body` 写死成固定像素 + `overflow:hidden`(视频不能有滚动条),
+ 网页要反过来覆盖掉。
+
+## 代码结构
+
+和视频组件是同一个套路,**两边可以互相搬**:
+
+```
+状态 唯一真值,一个对象
+ ↓
+规则(纯函数) old -> new,不碰 DOM、不改传进来的对象
+ ↓
+渲染(纯函数) state -> DOM ← 视频里对应 render(t)
+ ↓
+驱动 状态从哪来 ← 视频里是 tl.time(),网页里是点击
+```
+
+确定性铁律(画面必须是时间的纯函数)**只约束视频渲染**。
+网页不导视频,状态、事件、`requestAnimationFrame` 都可以正常用。
+
+## 跑起来
+
+任何静态服务器都行,**根目录必须是仓库根**(因为页面用 `../../src/brand/` 相对引用):
+
+```bash
+cd /hermes/AshuaiXsinfo
+python3 -m http.server 8000
+# 打开 http://localhost:8000/interactive/tictactoe/
+```
+
+## 目录
+
+- `tictactoe/` —— 井字棋,用来验证可行性。单文件,无构建,无依赖
diff --git a/interactive/tictactoe/index.html b/interactive/tictactoe/index.html
new file mode 100644
index 0000000..387ac07
--- /dev/null
+++ b/interactive/tictactoe/index.html
@@ -0,0 +1,456 @@
+
+
+
+
+
+
+ 井字棋 · 交互网页验证
+
+
+
+
+
+
+
+
+
+
+
交互网页验证
+
井字棋
+
+ 点格子落子。赢家三个格子会高亮。1–9
+ 也可以键盘落子。
+
+
+
+
+
+
+
+
+
+
+ 轮到 X
+
+
+
—
+
+
+
+
+
+
+
+
+
+ 这个页面在验证什么
+ ① 色板、字体、--u 基准单位全部来自框架的
+ src/brand/,一个文件都没复制,只覆盖了两处「视频专用」的设置。
+ ② 渲染函数是纯函数 render(state):给状态出画面,不依赖之前发生过什么。
+ 视频组件里是 render(t),同一个套路。
+ ③ 没有时间轴,没有渲染引擎,不依赖 ffmpeg —— 就是一个普通网页。
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f871d2a
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1971 @@
+{
+ "name": "ashuaixsinfo",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ashuaixsinfo",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "gsap": "3.14.2",
+ "highlight.js": "11.11.1",
+ "hyperframes": "0.8.148",
+ "three": "0.170.0",
+ "three-bvh-csg": "0.0.17",
+ "yaml": "2.8.1"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@hono/node-server": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-2.1.4.tgz",
+ "integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "hono": "^4 || ^5.0.0-0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
+ "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
+ "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
+ "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
+ "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
+ "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
+ "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
+ "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
+ "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
+ "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
+ "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
+ "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
+ "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
+ "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
+ "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
+ "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
+ "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
+ "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
+ "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
+ "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
+ "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
+ "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
+ "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
+ "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
+ "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@puppeteer/browsers": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.2.4.tgz",
+ "integrity": "sha512-okJ6yQMc0SBexOKxMi966omwAV1uAPwgF/YMZdb04gZQPXVjWHl9bmXjZYtIcsN1+PN3Bz4iFfGK3g+c6lZJ0Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "modern-tar": "^0.8.5",
+ "yargs": "^18.0.0"
+ },
+ "bin": {
+ "browsers": "lib/main-cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "peerDependencies": {
+ "proxy-agent": ">=8.0.1",
+ "yauzl": "^2.10.0 || ^3.4.0"
+ },
+ "peerDependenciesMeta": {
+ "proxy-agent": {
+ "optional": true
+ },
+ "yauzl": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.23",
+ "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz",
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/adm-zip": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmmirror.com/adm-zip/-/adm-zip-0.6.1.tgz",
+ "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.4.0.tgz",
+ "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/brotli": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmmirror.com/brotli/-/brotli-1.3.3.tgz",
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.1.2"
+ }
+ },
+ "node_modules/bundle-name": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.1.tgz",
+ "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==",
+ "license": "MIT",
+ "dependencies": {
+ "run-applescript": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/chromium-bidi": {
+ "version": "157.0.8090-0",
+ "resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz",
+ "integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "zod": "^4.6.5"
+ },
+ "engines": {
+ "node": ">=20.19.0 <22.0.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "devtools-protocol": "*"
+ }
+ },
+ "node_modules/citty": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmmirror.com/citty/-/citty-0.2.2.tgz",
+ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
+ "license": "MIT"
+ },
+ "node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/compare-versions": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmmirror.com/compare-versions/-/compare-versions-6.1.1.tgz",
+ "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==",
+ "license": "MIT"
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/default-browser": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmmirror.com/default-browser/-/default-browser-5.5.1.tgz",
+ "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==",
+ "license": "MIT",
+ "dependencies": {
+ "bundle-name": "^4.1.0",
+ "default-browser-id": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/default-browser-id/-/default-browser-id-5.0.1.tgz",
+ "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+ "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devtools-protocol": {
+ "version": "0.0.1696802",
+ "resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1696802.tgz",
+ "integrity": "sha512-FO5/SENfvc2K8wRnWQflKcwuoTH4dBkXCWTQ9ebIWtuX8JM5+ZvCotaEmkfh21pO7TvIKGTM3hG4xFiq9WTwmg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/dfa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/dfa/-/dfa-1.2.0.tgz",
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fontkit": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/fontkit/-/fontkit-2.0.4.tgz",
+ "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@swc/helpers": "^0.5.12",
+ "brotli": "^1.3.2",
+ "clone": "^2.1.2",
+ "dfa": "^1.2.0",
+ "fast-deep-equal": "^3.1.3",
+ "restructure": "^3.0.0",
+ "tiny-inflate": "^1.0.3",
+ "unicode-properties": "^1.4.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
+ "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/giget": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmmirror.com/giget/-/giget-3.3.1.tgz",
+ "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==",
+ "license": "MIT",
+ "bin": {
+ "giget": "dist/cli.mjs"
+ }
+ },
+ "node_modules/gsap": {
+ "version": "3.14.2",
+ "resolved": "https://registry.npmmirror.com/gsap/-/gsap-3.14.2.tgz",
+ "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
+ "license": "Standard 'no charge' license: https://gsap.com/standard-license."
+ },
+ "node_modules/highlight.js": {
+ "version": "11.11.1",
+ "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.13.13",
+ "resolved": "https://registry.npmmirror.com/hono/-/hono-4.13.13.tgz",
+ "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/hyperframes": {
+ "version": "0.8.148",
+ "resolved": "https://registry.npmmirror.com/hyperframes/-/hyperframes-0.8.148.tgz",
+ "integrity": "sha512-DOWTSE1HeF+s6qKdz7dVNqgIymVcfA9NYeWxOwFWL4qm4I4/2GQM2h0hC2iZocmsPUblWr4KfJoZzb1/dLl7gQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@hono/node-server": "^2.0.5",
+ "@puppeteer/browsers": "^3.2.2",
+ "adm-zip": "^0.6.0",
+ "citty": "^0.2.1",
+ "compare-versions": "^6.1.1",
+ "css-tree": "^3.2.1",
+ "debug": "^4.4.0",
+ "esbuild": "^0.25.12",
+ "fontkit": "^2.0.4",
+ "giget": "^3.2.0",
+ "hono": "^4.0.0",
+ "ignore": "^5.3.2",
+ "open": "^10.0.0",
+ "postcss": "^8.5.8",
+ "prettier": "^3.8.1",
+ "puppeteer-core": "^25.10.0",
+ "sharp": "^0.35.0",
+ "yauzl": "^3.4.0"
+ },
+ "bin": {
+ "hyperframes": "bin/hyperframes.mjs",
+ "hyperframes-localize-fonts": "bin/hyperframes-localize-fonts.mjs"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-3.1.1.tgz",
+ "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "license": "CC0-1.0"
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "license": "MIT"
+ },
+ "node_modules/modern-tar": {
+ "version": "0.8.5",
+ "resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.8.5.tgz",
+ "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "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/open": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmmirror.com/open/-/open-10.2.0.tgz",
+ "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
+ "license": "MIT",
+ "dependencies": {
+ "default-browser": "^5.2.1",
+ "define-lazy-prop": "^3.0.0",
+ "is-inside-container": "^1.0.0",
+ "wsl-utils": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmmirror.com/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "license": "MIT"
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.29",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.29.tgz",
+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
+ "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.19",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.10",
+ "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.10.tgz",
+ "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==",
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/puppeteer-core": {
+ "version": "25.13.0",
+ "resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.13.0.tgz",
+ "integrity": "sha512-cyBS0OEx3e9NS53B5F6+UazXtR7f9Tw0cR1/FbPW59/hV6JPZbkIiMnxK2543IkFp3rsGlLn1iPDLF77TJsJsA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "3.2.4",
+ "chromium-bidi": "157.0.8090-0",
+ "devtools-protocol": "0.0.1696802",
+ "typed-query-selector": "^2.12.3",
+ "webdriver-bidi-protocol": "1.0.1",
+ "ws": "^8.21.3"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/restructure": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/restructure/-/restructure-3.0.2.tgz",
+ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
+ "license": "MIT"
+ },
+ "node_modules/run-applescript": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz",
+ "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.35.5.tgz",
+ "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.5",
+ "@img/sharp-darwin-x64": "0.35.5",
+ "@img/sharp-freebsd-wasm32": "0.35.5",
+ "@img/sharp-libvips-darwin-arm64": "1.3.4",
+ "@img/sharp-libvips-darwin-x64": "1.3.4",
+ "@img/sharp-libvips-linux-arm": "1.3.4",
+ "@img/sharp-libvips-linux-arm64": "1.3.4",
+ "@img/sharp-libvips-linux-ppc64": "1.3.4",
+ "@img/sharp-libvips-linux-riscv64": "1.3.4",
+ "@img/sharp-libvips-linux-s390x": "1.3.4",
+ "@img/sharp-libvips-linux-x64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4",
+ "@img/sharp-linux-arm": "0.35.5",
+ "@img/sharp-linux-arm64": "0.35.5",
+ "@img/sharp-linux-ppc64": "0.35.5",
+ "@img/sharp-linux-riscv64": "0.35.5",
+ "@img/sharp-linux-s390x": "0.35.5",
+ "@img/sharp-linux-x64": "0.35.5",
+ "@img/sharp-linuxmusl-arm64": "0.35.5",
+ "@img/sharp-linuxmusl-x64": "0.35.5",
+ "@img/sharp-webcontainers-wasm32": "0.35.5",
+ "@img/sharp-win32-arm64": "0.35.5",
+ "@img/sharp-win32-ia32": "0.35.5",
+ "@img/sharp-win32-x64": "0.35.5"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.3.0.tgz",
+ "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmmirror.com/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
+ "license": "MIT"
+ },
+ "node_modules/three-bvh-csg": {
+ "version": "0.0.17",
+ "resolved": "https://registry.npmmirror.com/three-bvh-csg/-/three-bvh-csg-0.0.17.tgz",
+ "integrity": "sha512-iEkHDF8GRfGM6593Cuw8SnF1vfENCp46gIAtRzuL4nGXGWPcR1sbTBwM9ptDONb5twqlZp5WkAKya5aBKe2qcA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.151.0",
+ "three-mesh-bvh": ">=0.6.6"
+ }
+ },
+ "node_modules/three-mesh-bvh": {
+ "version": "0.9.16",
+ "resolved": "https://registry.npmmirror.com/three-mesh-bvh/-/three-mesh-bvh-0.9.16.tgz",
+ "integrity": "sha512-2RK+5MJOaiHJhopURn2CUkOHiF3tFloD0uZBfhebV0CKB7mgVLpWUWaDpOyhNJRDkKKe7x4rRfTHZ32GwpXdkw==",
+ "license": "MIT",
+ "peer": true,
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "license": "MIT"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typed-query-selector": {
+ "version": "2.12.3",
+ "resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
+ "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
+ "license": "MIT"
+ },
+ "node_modules/unicode-properties": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmmirror.com/unicode-properties/-/unicode-properties-1.4.1.tgz",
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
+ "node_modules/webdriver-bidi-protocol": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz",
+ "integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmmirror.com/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wsl-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz",
+ "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-wsl": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.1.tgz",
+ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmmirror.com/yargs/-/yargs-18.2.0.tgz",
+ "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^8.2.1",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yauzl": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-3.4.0.tgz",
+ "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.6.6",
+ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.6.6.tgz",
+ "integrity": "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..cb1c968
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "ashuaixsinfo",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "commonjs",
+ "dependencies": {
+ "gsap": "3.14.2",
+ "highlight.js": "11.11.1",
+ "hyperframes": "0.8.148",
+ "three": "0.170.0",
+ "three-bvh-csg": "0.0.17",
+ "yaml": "2.8.1"
+ }
+}
diff --git a/projects/cad3d-demo/project.yaml b/projects/cad3d-demo/project.yaml
new file mode 100644
index 0000000..de3ea0b
--- /dev/null
+++ b/projects/cad3d-demo/project.yaml
@@ -0,0 +1,11 @@
+title: 三维钩子建模全过程
+aspect: 横屏
+fps: 30
+lang: zh-CN
+
+scenes:
+ - component: cad3d
+ duration: 16
+ props:
+ spin: "true"
+ accent: "#d97757"
diff --git a/projects/demo全组件/project.yaml b/projects/demo全组件/project.yaml
new file mode 100644
index 0000000..bc1cc44
--- /dev/null
+++ b/projects/demo全组件/project.yaml
@@ -0,0 +1,72 @@
+title: 一个钩子的诞生
+aspect: 横屏
+fps: 60
+lang: zh-CN
+
+# 演示项目:把全部组件串成一条完整时间线
+# 改任何一处都只影响对应场景,用 `hv 看帧 demo全组件` 快速验证
+
+scenes:
+ # ---------- 开场 ----------
+ - component: intro
+ duration: 3.5
+ props:
+ kicker: 建模演示
+ title: 一个钩子的诞生
+ subtitle: 草图 · 拉伸 · 切除 · 圆角
+
+ # ---------- 第一部分:写代码 ----------
+ - component: codedemo
+ duration: 8
+ props:
+ filename: hook.py
+ language: python
+ dimOthers: "true"
+ highlight: "3,4"
+ code: |
+ def make_hook(thickness=0.4):
+ """生成一个钩子的截面轮廓"""
+ profile = bezier_profile()
+ solid = extrude(profile, depth=0.6)
+ throat = subtract(solid, sphere(r=0.92))
+ return fillet(throat, r=0.11)
+
+ - component: terminal
+ duration: 6
+ props:
+ title: python
+ prompt: ">>> "
+ script: |
+ [
+ { "text": "from cad import make_hook", "kind": "cmd" },
+ { "text": "hook = make_hook(thickness=0.4)", "kind": "cmd" },
+ { "text": "四个布尔操作全部成功", "kind": "ok" },
+ { "text": "耗时 0.42s", "kind": "dim" }
+ ]
+
+ - component: lowerthird
+ duration: 3
+ props:
+ text: 关键:轮廓必须一次性闭合
+ sub: 自交的草图会让拉伸直接失败
+
+ # ---------- 第二部分:三维建模 ----------
+ - component: cad3d
+ duration: 16
+ props:
+ spin: "true"
+ accent: "#d97757"
+
+ # ---------- 收尾 ----------
+ - component: textblock
+ duration: 4
+ props:
+ heading: 四个操作,[[一气呵成]]
+ body: 草图定形状,拉伸给厚度,切除挖钩槽,圆角去毛刺。
+
+ - component: outro
+ duration: 4
+ props:
+ cta: 觉得有用就点个关注
+ handle: "@你的频道"
+ foot: 每周三更新一期
\ No newline at end of file
diff --git a/projects/ep001/project.yaml b/projects/ep001/project.yaml
new file mode 100644
index 0000000..0199e6c
--- /dev/null
+++ b/projects/ep001/project.yaml
@@ -0,0 +1,56 @@
+title: 三个 Cursor 实用技巧
+aspect: 横屏
+fps: 60
+lang: zh-CN
+
+scenes:
+ - component: intro
+ duration: 3.5
+ props:
+ kicker: 技巧合集
+ title: 三个 Cursor 实用技巧
+ subtitle: 让你写代码少一半时间
+
+ - component: codedemo
+ duration: 8
+ props:
+ filename: fib.py
+ language: python
+ dimOthers: "true"
+ highlight: "3,4,5"
+ code: |
+ def fib(n):
+ """斐波那契数列"""
+ if n < 2:
+ return n
+ return fib(n - 1) + fib(n - 2)
+
+ for i in range(10):
+ print(f"fib({i}) = {fib(i)}")
+
+ - component: terminal
+ duration: 6
+ props:
+ title: zsh
+ prompt: "$ "
+ script: |
+ [
+ { "text": "python fib.py", "kind": "cmd" },
+ { "text": "fib(0) = 0", "kind": "out" },
+ { "text": "fib(1) = 1", "kind": "out" },
+ { "text": "fib(8) = 21", "kind": "ok" },
+ { "text": "9 行代码,递归一次跑通。", "kind": "dim" }
+ ]
+
+ - component: lowerthird
+ duration: 3
+ props:
+ text: 技巧一:先描述意图,再让它动手
+ sub: 上下文质量决定了产出质量
+
+ - component: outro
+ duration: 4
+ props:
+ cta: 觉得有用就点个关注
+ handle: "@你的频道"
+ foot: 每周三更新一期
\ No newline at end of file
diff --git a/projects/stress/project.yaml b/projects/stress/project.yaml
new file mode 100644
index 0000000..009c811
--- /dev/null
+++ b/projects/stress/project.yaml
@@ -0,0 +1,8 @@
+title: 自由度压测
+aspect: 横屏
+fps: 30
+lang: zh-CN
+
+scenes:
+ - component: stress
+ duration: 6
\ No newline at end of file
diff --git a/projects/井字棋科普/BRIEF.md b/projects/井字棋科普/BRIEF.md
new file mode 100644
index 0000000..e1e9178
--- /dev/null
+++ b/projects/井字棋科普/BRIEF.md
@@ -0,0 +1,43 @@
+# 选题简报 · 井字棋科普
+
+> 这份文件是策划面。填完之后再动手改 `project.yaml`。
+
+## 一句话
+
+(这条视频要让观众记住的一句话。写不出来说明选题还没想清楚。)
+
+## 给谁看
+
+- 受众:
+- 他们现在知道什么:
+- 他们看完应该能做什么:
+
+## 钩子
+
+(前 3 秒说什么。用反常识、具体数字、或者直接抛结果。别铺垫。)
+
+## 结构
+
+| 段 | 时长 | 讲什么 | 用哪个组件 |
+|---|---|---|---|
+| 开场 | 3.5s | | intro |
+| 主体 | | | codedemo / terminal / cad3d |
+| 收尾 | 4s | | outro |
+
+## 素材清单
+
+- [ ] 录屏:
+- [ ] 截图:
+- [ ] 代码片段:
+- [ ] 背景音乐:
+
+## 完成标准
+
+- [ ] `hv 检查 井字棋科普` 通过
+- [ ] `hv 看帧 井字棋科普` 抽查几个关键时间点,没有排版问题
+- [ ] 全片时长与预期一致
+- [ ] 音画对齐(若有配音)
+
+## 已知取舍
+
+(哪里打算偷懒、哪里有风险,写下来免得忘了)
diff --git a/projects/井字棋科普/project.yaml b/projects/井字棋科普/project.yaml
new file mode 100644
index 0000000..7895335
--- /dev/null
+++ b/projects/井字棋科普/project.yaml
@@ -0,0 +1,92 @@
+title: 井字棋怎么玩
+aspect: 横屏
+fps: 30
+lang: zh-CN
+
+scenes:
+ # 1. 片头
+ - component: intro
+ duration: 3.5
+ narration: 五步就能看出谁会玩。井字棋怎么玩,今天一次讲清楚。
+ props:
+ kicker: 游戏科普
+ title: 井字棋怎么玩
+ subtitle: 5 手就分出胜负
+
+ # 2. 规则
+ - component: tictactoe
+ duration: 5.5
+ narration: 棋盘是三乘三的九个格子。两人轮流落子,先把三个棋子连成一条线的人获胜。
+ props:
+ moves: "0"
+ eyebrow: 规则
+ caption: 棋盘三乘三,双方轮流落子,三子连线即胜
+ tip: 三条规则
+
+ # 3. X 走中心
+ - component: tictactoe
+ duration: 5.5
+ narration: X 先手。第一手走中心最稳,因为中心这一格和四条线都相连。
+ props:
+ moves: "5"
+ eyebrow: 第 1 手
+ caption: X 走中心——这一格和四条线都相连
+ tip: 占中心
+
+ # 4. O 应对
+ - component: tictactoe
+ duration: 5.5
+ narration: O 落在第二格。棋盘上一共只有八条线,从这一步起,每一步都要盯着它们。
+ props:
+ moves: "5,2"
+ eyebrow: 第 2 手
+ caption: O 应在第二格,八条线一条都不能放松
+
+ # 5. X 形成威胁
+ - component: tictactoe
+ duration: 6.5
+ narration: X 走第九格。现在第一格、第五格、第九格三缺一,X 再补上第一格就赢了。
+ props:
+ moves: "5,2,9"
+ eyebrow: 第 3 手
+ caption: 斜线三缺一,X 只差第一格
+ tip: 形成威胁
+ point: 1
+
+ # 6. O 漏防
+ - component: tictactoe
+ duration: 6.5
+ narration: 可是 O 走了第四格,把第一格空在那儿。对成线的威胁视而不见,是新手最常见的失误。
+ props:
+ moves: "5,2,9,4"
+ eyebrow: 第 4 手
+ caption: O 走了第四格——它漏掉了第一格
+ tip: 漏防了
+ point: 1
+
+ # 7. X 获胜
+ - component: tictactoe
+ duration: 5
+ narration: X 补上第一格,三个棋子连成一条斜线,棋局结束。
+ props:
+ moves: "5,2,9,4,1"
+ eyebrow: 第 5 手
+ caption: X 连成斜线,取胜
+ tip: 三子连线
+
+ # 8. 总结
+ - component: textblock
+ duration: 7
+ narration: 记住三句:先占中心,始终盯着有没有成线的机会,看到威胁立刻去挡。
+ props:
+ heading: 记住三句
+ body: 先占中心 · 盯住成线 · 见威胁就挡
+
+ # 9. 片尾
+ - component: outro
+ duration: 4
+ narration: 下一局,轮到你先手。
+ props:
+ cta: 下次轮到你先手
+ handle: "@你的频道"
+ foot: ""
diff --git a/projects/井字棋科普/script.md b/projects/井字棋科普/script.md
new file mode 100644
index 0000000..0cfb213
--- /dev/null
+++ b/projects/井字棋科普/script.md
@@ -0,0 +1,21 @@
+# 台词稿
+
+> 一行一个场景,顺序与 `project.yaml` 的 `scenes` 一致。
+> 填完后跑 `python3 src/audio/cli.py 配音 <项目名>`,
+> 场景时长会自动对齐到每句的朗读时长。
+
+## 场景 1 · intro
+
+- 画面:
+- 台词:
+
+## 场景 2 · codedemo
+
+- 画面:
+- 台词:
+- 重点行号:
+
+## 场景 3
+
+- 画面:
+- 台词:
diff --git a/projects/配音演示/project.yaml b/projects/配音演示/project.yaml
new file mode 100644
index 0000000..c01c703
--- /dev/null
+++ b/projects/配音演示/project.yaml
@@ -0,0 +1,28 @@
+title: 配音对齐演示
+aspect: 横屏
+fps: 30
+lang: zh-CN
+scenes:
+- component: intro
+ duration: 2.812
+ narration: 一个钩子是怎么被造出来的。
+ sfx: whoosh@0.1
+ audio: projects/配音演示/assets/audio/tts_一个钩子是怎么被造出来的_2462.wav
+- component: textblock
+ duration: 4.952
+ narration: 第一步画草图,用一条闭合的贝塞尔曲线把轮廓定下来。
+ audio: projects/配音演示/assets/audio/tts_第一步画草图-用一条闭合_4602.wav
+- component: codedemo
+ duration: 5.724
+ narration: 第二步拉伸,把这条轮廓沿着 Z 轴扫成一个实体,厚度零点六。
+ sfx: pop@1.2
+ audio: projects/配音演示/assets/audio/tts_第二步拉伸-把这条轮廓沿_5374.wav
+- component: cad3d
+ duration: 8.421
+ narration: 第三步切除,用一个球做布尔差集,把钩子内侧的承载面挖出来。最后一步圆角,把生硬的棱磨圆。
+ audio: projects/配音演示/assets/audio/tts_第三步切除-用一个球做布_8071.wav
+- component: outro
+ duration: 2.225
+ narration: 四个操作,一气呵成。
+ sfx: whoosh@0.2
+ audio: projects/配音演示/assets/audio/tts_四个操作-一气呵成_1875.wav
diff --git a/src/audio/cli.py b/src/audio/cli.py
new file mode 100644
index 0000000..cb9a933
--- /dev/null
+++ b/src/audio/cli.py
@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+"""音频命令行:hv 的音频侧入口。
+
+三件事
+------
+1. ``配音`` 读 project.yaml 里各场景的 ``narration``,逐条合成,
+ 然后**把场景时长改成配音的真实时长**,回写 project.yaml。
+ 这是整套框架省掉手工对轴的关键。
+2. ``音效`` 把场景上的 ``sfx`` 标记变成具体的时间点与文件。
+3. ``混音`` 按时间轴把配音/音效/BGM 合成一条轨。
+
+用法
+----
+ python3 src/audio/cli.py 配音 ep001 --provider dummy
+ python3 src/audio/cli.py 混音 ep001
+ python3 src/audio/cli.py 检查
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "src/audio"))
+
+import yaml # noqa: E402
+
+from tts import base as tts # noqa: E402
+from sfx import synth # noqa: E402
+import mix as mixer # noqa: E402
+
+
+# ---------------------------------------------------------------------------
+# 终端着色
+# ---------------------------------------------------------------------------
+
+class C:
+ def __getattr__(self, n):
+ codes = {"b": "1", "g": "32", "y": "33", "r": "31", "c": "36", "dim": "2"}
+ return lambda s: f"\x1b[{codes.get(n, '0')}m{s}\x1b[0m"
+
+
+c = C()
+
+
+def die(msg: str):
+ print(c.r(f"✗ {msg}"), file=sys.stderr)
+ sys.exit(1)
+
+
+def ok(msg: str):
+ print(c.g(f"✓ {msg}"))
+
+
+def info(msg: str):
+ print(c.dim(f" {msg}"))
+
+
+# ---------------------------------------------------------------------------
+
+def load(project: str):
+ path = ROOT / "projects" / project / "project.yaml"
+ if not path.exists():
+ die(f"项目不存在:{project}")
+ return path, yaml.safe_load(path.read_text(encoding="utf-8"))
+
+
+def save(path: Path, doc: dict):
+ path.write_text(
+ yaml.safe_dump(doc, allow_unicode=True, sort_keys=False, width=100),
+ encoding="utf-8",
+ )
+
+
+# ---------------------------------------------------------------------------
+
+
+def cmd_voice(args) -> int:
+ """合成配音,并把场景时长对齐到配音真实时长。"""
+ path, doc = load(args.project)
+ provider = tts.get(args.provider)
+
+ if not provider.available():
+ die(f"Provider「{provider.name}」在当前环境不可用,换一个或先装依赖")
+
+ scenes = doc.get("scenes") or []
+ with_text = [(i, s) for i, s in enumerate(scenes) if s.get("narration")]
+ if not with_text:
+ info("这个项目没有场景写 narration,跳过")
+ return 0
+
+ out_dir = ROOT / "projects" / args.project / "assets" / "audio"
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ ok(f"Provider:{provider.name}")
+
+ changed = 0
+ for i, scene in with_text:
+ text = str(scene["narration"]).strip()
+ clip = provider.synthesize(text, voice=args.voice, out_dir=out_dir)
+ scene["audio"] = str(clip.path.relative_to(ROOT))
+
+ old = scene.get("duration")
+ # +0.35s 留白:配音结束后画面还要停一下,不然切得很硬
+ new = round(clip.duration + args.tail, 3)
+ if old != new:
+ scene["duration"] = new
+ changed += 1
+ info(
+ f"场景 #{i + 1}「{scene.get('component', '?')}」"
+ f" {old if old != new else new}s → {new}s"
+ f"(配音 {clip.duration:.2f}s)"
+ )
+
+ save(path, doc)
+ ok(f"{args.project}:{len(with_text)} 条配音,{changed} 个场景时长已对齐")
+ info("时长已回写 project.yaml,接着跑 hv 生成 && hv 渲染")
+ return 0
+
+
+def cmd_sfx(args) -> int:
+ """按场景上的 sfx 标记生成音效。"""
+ path, doc = load(args.project)
+ out_dir = ROOT / "projects" / args.project / "assets" / "audio" / "sfx"
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ events: list[str] = []
+ for i, scene in enumerate(doc.get("scenes") or []):
+ marks = scene.get("sfx") or []
+ if isinstance(marks, str):
+ marks = [m.strip() for m in marks.split(",") if m.strip()]
+ for m in marks:
+ # 支持 "whoosh@2.5" 形式:音效名@相对场景起点的秒数
+ name, _, off = m.partition("@")
+ name = name.strip()
+ try:
+ s = synth.synthesize(name, out_dir)
+ except KeyError as e:
+ die(str(e))
+ at = round(scene.get("_start", 0) + (float(off) if off else 0.0), 3)
+ events.append(f"{s.path.relative_to(ROOT)}|{at}|0.8")
+ info(f"场景 #{i + 1} +{off or 0}s → {name}")
+
+ plan_file = ROOT / "projects" / args.project / "assets" / "audio" / "sfx.txt"
+ plan_file.write_text("\n".join(events), encoding="utf-8")
+ ok(f"{len(events)} 个音效已合成,位置写入 {plan_file.relative_to(ROOT)}")
+ return 0
+
+
+def cmd_mix(args) -> int:
+ """按时间轴混音。"""
+ path, doc = load(args.project)
+ proj_dir = ROOT / "projects" / args.project
+
+ # 先把场景起点算出来
+ cursor = 0.0
+ for scene in doc.get("scenes") or []:
+ scene["_start"] = cursor
+ cursor += float(scene.get("duration") or 0)
+ total = cursor
+
+ plan = mixer.AudioPlan(bgm_gain=args.bgm_gain, duck=not args.no_duck)
+
+ n = 0
+ for scene in doc.get("scenes") or []:
+ rel = scene.get("audio")
+ if not rel:
+ continue
+ p = ROOT / rel if not Path(rel).is_absolute() else Path(rel)
+ if p.exists():
+ plan.narration.append((p, scene["_start"]))
+ n += 1
+ else:
+ info(f"跳过缺失的配音:{rel}")
+
+ bgm = None
+ if args.bgm:
+ cand = proj_dir / "assets" / "audio" / args.bgm
+ bgm = cand if cand.exists() else Path(args.bgm)
+ if not Path(bgm).exists():
+ die(f"找不到背景音乐:{args.bgm}")
+
+ sfx_file = proj_dir / "assets" / "audio" / "sfx.txt"
+ if sfx_file.exists():
+ for line in sfx_file.read_text(encoding="utf-8").splitlines():
+ if not line.strip():
+ continue
+ path_s, at, gain = line.split("|")
+ p = ROOT / path_s if not Path(path_s).is_absolute() else Path(path_s)
+ if p.exists():
+ plan.sfx.append((p, float(at), float(gain)))
+
+ if not plan.narration and bgm is None:
+ info("没有配音也没有背景音乐,跳过混音")
+ return 0
+
+ out = proj_dir / "assets" / "audio" / "mix.wav"
+ mixer.mix(plan, out, total_duration=total)
+
+ ok(f"混音完成:{out.relative_to(ROOT)}")
+ info(f"配音 {len(plan.narration)} 条 · 音效 {len(plan.sfx)} 个 · 时长 {total:.2f}s")
+ return 0
+
+
+def cmd_check(args) -> int:
+ """体检音频侧。"""
+ print(c.b("\n音频体检\n"))
+ for name, okk in tts.available_providers().items():
+ mark = c.g("✓") if okk else c.y("!")
+ print(f" {mark} TTS 提供方 {name:<10} {c.dim('可用' if okk else '不可用')}")
+
+ try:
+ import numpy # noqa: F401
+ print(f" {c.g('✓')} numpy {c.dim('音效合成可用')}")
+ except ImportError:
+ print(f" {c.y('!')} numpy {c.dim('缺失,音效合成不可用')}")
+
+ import shutil
+ if shutil.which("ffmpeg") and shutil.which("ffprobe"):
+ print(f" {c.g('✓')} ffmpeg {c.dim('混音可用')}")
+ else:
+ print(f" {c.y('!')} ffmpeg {c.dim('缺失,混音不可用')}")
+
+ print()
+ return 0
+
+
+# ---------------------------------------------------------------------------
+
+HELP = """
+音频工具
+
+ 配音 <项目> [--provider dummy|kokoro] [--voice X] [--tail 0.35]
+ 合成所有场景的 narration,并把场景时长对齐到配音真实时长。
+
+ 音效 <项目>
+ 按场景上的 sfx 标记合成音效(如 "whoosh@2.5")。
+
+ 混音 <项目> [--bgm 曲名.wav] [--bgm-gain 0.18] [--no-duck]
+ 配音 + 音效 + BGM 混成一条轨,带人声闪避。
+
+ 检查
+ 音频侧环境体检。
+"""
+
+
+def main(argv=None) -> int:
+ argv = argv or sys.argv[1:]
+ if not argv or argv[0] in ("-h", "--help", "帮助"):
+ print(HELP)
+ return 0
+
+ cmd, rest = argv[0], argv[1:]
+
+ ap = argparse.ArgumentParser(prog=cmd, add_help=False)
+ if cmd == "配音":
+ ap.add_argument("project")
+ ap.add_argument("--provider", default="dummy")
+ ap.add_argument("--voice", default="")
+ ap.add_argument("--tail", type=float, default=0.35)
+ args = ap.parse_args(rest)
+ return cmd_voice(args)
+
+ if cmd == "音效":
+ ap.add_argument("project")
+ return cmd_sfx(ap.parse_args(rest))
+
+ if cmd == "混音":
+ ap.add_argument("project")
+ ap.add_argument("--bgm", default="")
+ ap.add_argument("--bgm-gain", type=float, default=0.18)
+ ap.add_argument("--no-duck", action="store_true")
+ return cmd_mix(ap.parse_args(rest))
+
+ if cmd == "检查":
+ return cmd_check(ap.parse_args(rest))
+
+ print(HELP)
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
\ No newline at end of file
diff --git a/src/audio/mix.py b/src/audio/mix.py
new file mode 100644
index 0000000..d0a420a
--- /dev/null
+++ b/src/audio/mix.py
@@ -0,0 +1,166 @@
+"""混音:把配音、音效、BGM 合成一条轨。
+
+设计要点
+--------
+- 用 ffmpeg 的 ``adelay`` / ``amix`` 做精确放置,不依赖手工裁剪。
+- BGM 走 ducking(人声出现时自动压低),不需要手写音量包络。
+- 全程返回真实时长,与视频时间轴对齐。
+"""
+
+from __future__ import annotations
+
+import json
+import shutil
+import subprocess
+from dataclasses import dataclass, field
+from pathlib import Path
+
+
+@dataclass
+class AudioPlan:
+ """一条完整的音频计划。
+
+ Attributes
+ ----------
+ narration:
+ 配音轨:(文件, 起点秒) 列表。
+ sfx:
+ 音效轨:(文件, 起点秒, 音量) 列表。
+ bgm:
+ 背景音乐文件,可空。
+ bgm_gain:
+ 背景音乐基础音量(0-1)。
+ duck:
+ 是否开启人声闪避。
+ """
+
+ narration: list[tuple[Path, float]] = field(default_factory=list)
+ sfx: list[tuple[Path, float, float]] = field(default_factory=list)
+ bgm: Path | None = None
+ bgm_gain: float = 0.18
+ duck: bool = True
+
+ def to_json(self) -> str:
+ return json.dumps(
+ {
+ "narration": [[str(p), round(t, 3)] for p, t in self.narration],
+ "sfx": [[str(p), round(t, 3), round(g, 3)] for p, t, g in self.sfx],
+ "bgm": str(self.bgm) if self.bgm else None,
+ "bgm_gain": self.bgm_gain,
+ "duck": self.duck,
+ },
+ ensure_ascii=False,
+ indent=2,
+ )
+
+
+def probe_duration(path: Path) -> float:
+ """读音频真实时长。读不出来直接抛错,不猜。"""
+ if not shutil.which("ffprobe"):
+ raise RuntimeError("需要 ffmpeg/ffprobe,请先安装")
+ out = subprocess.run(
+ [
+ "ffprobe", "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1",
+ str(path),
+ ],
+ capture_output=True, text=True, check=True,
+ ).stdout.strip()
+ return float(out)
+
+
+def mix(plan: AudioPlan, out: Path, total_duration: float | None = None) -> Path:
+ """按计划混音,输出 wav。
+
+ Parameters
+ ----------
+ plan:
+ 音频计划。
+ out:
+ 输出文件。
+ total_duration:
+ 总时长;给定时输出会被裁到这个长度,保证音画等长。
+ """
+ out = Path(out)
+ out.parent.mkdir(parents=True, exist_ok=True)
+
+ inputs: list[str] = []
+ filters: list[str] = []
+ mix_labels: list[str] = []
+
+ # ---- 配音 ----
+ for i, (path, start) in enumerate(plan.narration):
+ inputs += ["-i", str(path)]
+ delay = max(0, int(round(start * 1000)))
+ filters.append(f"[{i}:a]aresample=44100,adelay={delay}|{delay}[v{i}]")
+ mix_labels.append(f"[v{i}]")
+
+ # ---- 音效 ----
+ for i, (path, start, gain) in enumerate(plan.sfx):
+ idx = len(plan.narration) + i
+ inputs += ["-i", str(path)]
+ delay = max(0, int(round(start * 1000)))
+ vol = max(0.0, min(4.0, float(gain)))
+ filters.append(f"[{idx}:a]aresample=44100,volume={vol},adelay={delay}|{delay}[s{i}]")
+ mix_labels.append(f"[s{i}]")
+
+ voice_labels = [lbl for lbl in mix_labels]
+
+ # ---- 背景音乐 ----
+ bgm_index = None
+ if plan.bgm and Path(plan.bgm).exists():
+ bgm_index = len(inputs) // 2
+ inputs += ["-i", str(plan.bgm)]
+ filters.append(
+ f"[{bgm_index}:a]aresample=44100,volume={plan.bgm_gain},"
+ f"aloop=loop=-1:size=2147483647[bgmraw]"
+ )
+ if plan.duck and voice_labels:
+ # 人声出现时把 BGM 压低
+ merged = "".join(voice_labels)
+ filters.append(
+ f"{merged}amix=inputs={len(voice_labels)}:normalize=0[mixv]"
+ )
+ filters.append(
+ f"[bgmraw][mixv]sidechaincompress=threshold=0.04:ratio=8:attack=20:release=350[bgm]"
+ )
+ else:
+ if voice_labels:
+ merged = "".join(voice_labels)
+ filters.append(
+ f"{merged}amix=inputs={len(voice_labels)}:normalize=0[mixv]"
+ )
+ filters.append("[bgmraw]anull[bgm]")
+
+ # ---- 汇总 ----
+ final_labels: list[str] = []
+ if voice_labels:
+ filters.append(f"{''.join(voice_labels)}amix=inputs={len(voice_labels)}:normalize=0[vout]")
+ final_labels.append("[vout]")
+ if plan.bgm and Path(plan.bgm).exists():
+ final_labels.append("[bgm]")
+
+ if not final_labels:
+ raise ValueError("音频计划是空的:既没有配音也没有背景音乐")
+
+ # 注意别用 tail[:-1] 去抠右方括号:它会把 [out] 的 '[' 一起削掉,
+ # 生成 "[out,atrim=..." 这种非法标签,ffmpeg 报
+ # "Cannot find a matching stream for unlabeled input pad"。踩过。
+ stages = [f"{''.join(final_labels)}amix=inputs={len(final_labels)}:normalize=0:duration=longest"]
+ if total_duration:
+ # apad 补静音到指定长度:不补的话末场景的留白(默认 0.35s)会让
+ # 音频比视频短,ffmpeg 的 -shortest 会据此截短画面。
+ stages.append(f"apad=whole_dur={total_duration:.3f}")
+ stages.append(f"atrim=0:{total_duration:.3f}")
+ stages.append("asetpts=N/SR/TB")
+ filters.append(",".join(stages) + "[out]")
+
+ cmd = ["ffmpeg", "-y", *inputs, "-filter_complex", ";".join(filters), "-map", "[out]",
+ "-ac", "2", "-ar", "44100", str(out)]
+
+ res = subprocess.run(cmd, capture_output=True, text=True)
+ if res.returncode != 0:
+ raise RuntimeError(f"混音失败:\n{res.stderr[-2000:]}")
+
+ return out
\ No newline at end of file
diff --git a/src/audio/requirements.txt b/src/audio/requirements.txt
new file mode 100644
index 0000000..ef9284a
--- /dev/null
+++ b/src/audio/requirements.txt
@@ -0,0 +1,6 @@
+# 音频侧依赖(用 uv 装:uv pip install -r src/audio/requirements.txt)
+numpy>=2.0 # 音效合成(必需)
+PyYAML>=6.0 # 读 project.yaml(必需)
+# 本地 TTS(可选,装完 hv 音频 配音 --provider kokoro 就能用)
+# kokoro-onnx>=0.4
+# soundfile>=0.12
diff --git a/src/audio/sfx/__init__.py b/src/audio/sfx/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/audio/sfx/synth.py b/src/audio/sfx/synth.py
new file mode 100644
index 0000000..8beaa4c
--- /dev/null
+++ b/src/audio/sfx/synth.py
@@ -0,0 +1,159 @@
+"""程序化音效合成。
+
+这些音效**不该去找素材库**,而应该算出来:一次点击就是一个带包络的
+短脉冲,whoosh 就是一段扫频噪声。好处是体积为零、参数可无限调、
+配色跟着画面的强调色走。
+
+全部用 numpy 实现,输出 wav。不依赖 soundfile(soundfile 只在 TTS 里用)。
+"""
+
+from __future__ import annotations
+
+import math
+import wave
+from dataclasses import dataclass
+from pathlib import Path
+
+import numpy as np
+
+SR = 44100
+
+
+@dataclass
+class Sfx:
+ """一段合成好的音效。"""
+
+ path: Path
+ duration: float
+
+
+def _env_ad(n: int, attack: float, decay: float, sr: int = SR) -> np.ndarray:
+ """包络:快起慢落,模拟敲击类音色的自然衰减。"""
+ a = max(1, int(attack * sr))
+ d = max(1, int(decay * sr))
+ env = np.zeros(n, dtype=np.float32)
+ env[: min(a, n)] = np.linspace(0, 1, min(a, n), dtype=np.float32)
+ rest = n - min(a, n)
+ if rest > 0:
+ env[min(a, n):] = np.exp(-np.linspace(0, decay * 4, rest, dtype=np.float32))
+ return env
+
+
+def _noise(n: int, seed: int = 0) -> np.ndarray:
+ return np.random.default_rng(seed).uniform(-1, 1, n).astype(np.float32)
+
+
+def _sweep(
+ n: int, f0: float, f1: float, sr: int = SR, curve: float = 1.0
+) -> np.ndarray:
+ """从 f0 扫到 f1 的正弦。curve<1 前段变化快,>1 后段变化快。"""
+ t = np.linspace(0, 1, n, dtype=np.float32) ** curve
+ freq = f0 + (f1 - f0) * t
+ phase = 2 * np.pi * np.cumsum(freq) / sr
+ return np.sin(phase).astype(np.float32)
+
+
+def click(path: Path, sr: int = SR) -> Sfx:
+ """鼠标点击:极短的高频脉冲 + 一丝噪声。"""
+ n = int(0.035 * sr)
+ t = np.arange(n, dtype=np.float32) / sr
+ body = np.sin(2 * np.pi * 2400 * t) * _env_ad(n, 0.0008, 0.006, sr)
+ tick = _noise(n, 7) * _env_ad(n, 0.0005, 0.004, sr) * 0.35
+ sig = (body * 0.7 + tick).astype(np.float32)
+ return _write(path, sig, sr)
+
+
+def blip(path: Path, freq: float = 880.0, dur: float = 0.08, sr: int = SR) -> Sfx:
+ """界面提示音:干净的短音。"""
+ n = int(dur * sr)
+ sig = np.sin(2 * np.pi * freq * np.arange(n, dtype=np.float32) / sr)
+ sig *= _env_ad(n, 0.004, 0.05, sr)
+ sig = (sig * 0.5).astype(np.float32)
+ return _write(path, sig, sr)
+
+
+def whoosh(path: Path, dur: float = 0.5, sr: int = SR) -> Sfx:
+ """转场:带通噪声 + 轻微扫频,模拟空气掠过。"""
+ n = int(dur * sr)
+ nz = _noise(n, 3)
+ # 简易带通:原信号减去低频成分
+ k = 24
+ kernel = np.ones(k, dtype=np.float32) / k
+ low = np.convolve(nz, kernel, mode="same")
+ band = nz - low
+ # clip(0,1) 很关键:float32 的 sin(pi) 会是 -8.7e-8 这种微小负值,
+ # 负数开 1.6 次方直接得 NaN,整段音频会被污染成静音+爆音。
+ env = np.sin(np.linspace(0, math.pi, n, dtype=np.float32))
+ env = np.clip(env, 0.0, 1.0) ** 1.6
+ sig = (band * env * 0.85).astype(np.float32)
+ return _write(path, sig, sr)
+
+
+def thud(path: Path, sr: int = SR) -> Sfx:
+ """落位音:低频下扫 + 噪声,给"东西放定了"一个重量感。"""
+ n = int(0.28 * sr)
+ sweep = _sweep(n, 180, 48, sr, curve=0.55)
+ env = np.exp(-np.linspace(0, 5.5, n, dtype=np.float32))
+ nz = _noise(n, 11) * env * 0.25
+ sig = ((sweep * 0.65 + nz) * env).astype(np.float32)
+ return _write(path, sig, sr)
+
+
+def pop(path: Path, sr: int = SR) -> Sfx:
+ """气泡音:短促的上扫,像内容逐个冒出来。"""
+ n = int(0.12 * sr)
+ sig = _sweep(n, 320, 980, sr, curve=0.6)
+ sig *= _env_ad(n, 0.006, 0.035, sr)
+ sig = (sig * 0.45).astype(np.float32)
+ return _write(path, sig, sr)
+
+
+# ---------------------------------------------------------------------------
+# 目录
+# ---------------------------------------------------------------------------
+
+REGISTRY = {
+ "click": click,
+ "blip": blip,
+ "whoosh": whoosh,
+ "thud": thud,
+ "pop": pop,
+}
+
+
+def synthesize(name: str, out_dir: Path, **kw) -> Sfx:
+ """按名字合成一个音效到 ``out_dir``。"""
+ if name not in REGISTRY:
+ raise KeyError(f"未知音效:{name}。可用:{', '.join(REGISTRY)}")
+ out_dir = Path(out_dir)
+ out_dir.mkdir(parents=True, exist_ok=True)
+ return REGISTRY[name](out_dir / f"{name}.wav", **kw)
+
+
+def synthesize_all(out_dir: Path) -> list[Sfx]:
+ """一次性生成全部音效(用于素材库初始化)。"""
+ out_dir = Path(out_dir)
+ return [synthesize(n, out_dir) for n in REGISTRY]
+
+
+# ---------------------------------------------------------------------------
+
+
+def _write(path: Path, sig: np.ndarray, sr: int = SR) -> Sfx:
+ """把浮点信号写成 16bit wav,并做峰值归一化。"""
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ peak = float(np.max(np.abs(sig))) or 1.0
+ sig = sig / peak * 0.85
+ # 兜底:任何 NaN/Inf 都会在 cast 时变成垃圾整数,整轨报废
+ sig = np.nan_to_num(sig, nan=0.0, posinf=1.0, neginf=-1.0)
+ pcm = (np.clip(sig, -1.0, 1.0) * 32767).astype(np.int16)
+
+ with wave.open(str(path), "wb") as w:
+ w.setnchannels(1)
+ w.setsampwidth(2)
+ w.setframerate(sr)
+ w.writeframes(pcm.tobytes())
+
+ return Sfx(path=path, duration=len(sig) / float(sr))
\ No newline at end of file
diff --git a/src/audio/tts/__init__.py b/src/audio/tts/__init__.py
new file mode 100644
index 0000000..4dacee7
--- /dev/null
+++ b/src/audio/tts/__init__.py
@@ -0,0 +1,23 @@
+"""TTS 提供方集合。"""
+
+from .base import (
+ AudioClip,
+ DummyTTS,
+ KokoroTTS,
+ TTSProvider,
+ available_providers,
+ estimate_duration,
+ get,
+ register,
+)
+
+__all__ = [
+ "AudioClip",
+ "TTSProvider",
+ "DummyTTS",
+ "KokoroTTS",
+ "get",
+ "register",
+ "available_providers",
+ "estimate_duration",
+]
\ No newline at end of file
diff --git a/src/audio/tts/base.py b/src/audio/tts/base.py
new file mode 100644
index 0000000..2e55b9b
--- /dev/null
+++ b/src/audio/tts/base.py
@@ -0,0 +1,288 @@
+"""语音合成 Provider 抽象层。
+
+设计意图
+--------
+框架不绑定任何一家 TTS。你要换服务时,只需要写一个继承
+:class:`TTSProvider` 的类,然后改一行配置,**画面侧一行都不用动**。
+
+契约
+----
+实现类必须满足:
+
+1. :meth:`synthesize` 返回一个 :class:`AudioClip`,其中 ``duration``
+ 必须是**真实音频的精确时长**(秒,浮点)。
+2. 时长必须真实,因为整条视频的音画对齐完全依赖它:
+ 场景时长 = 配音时长,这是这套框架省掉手工对轴的原因。
+
+内置实现
+--------
+- :class:`DummyTTS` —— 不发声,但返回**精确计算长度的静音**。
+ 用途是让整条音频链路(时间轴/对齐/混音/烧字幕)在还没有真实
+ TTS 时也能真实跑通,不是 mock:它产出的 wav 是合法的、可被
+ ffprobe 读到、时长分毫不差。
+- :class:`KokoroTTS` —— 本地离线 Kokoro-82M,无需联网,可复现。
+ 装了 ``kokoro-onnx`` 就自动可用。
+"""
+
+from __future__ import annotations
+
+import abc
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Sequence
+
+
+@dataclass
+class AudioClip:
+ """一段生成好的音频。
+
+ Attributes
+ ----------
+ path:
+ 音频文件路径。
+ duration:
+ 精确时长(秒)。**这是整个音频链路的地基**,不允许估算。
+ text:
+ 对应的原文,供字幕/对齐使用。
+ voice:
+ 实际使用的音色。
+ provider:
+ 产出它的 Provider 名字。
+ """
+
+ path: Path
+ duration: float
+ text: str = ""
+ voice: str = ""
+ provider: str = ""
+
+ def __post_init__(self) -> None:
+ self.path = Path(self.path)
+ if self.duration <= 0:
+ raise ValueError(f"时长必须为正数,收到 {self.duration}")
+ if not self.path.exists():
+ raise FileNotFoundError(f"音频文件不存在:{self.path}")
+
+
+class TTSProvider(abc.ABC):
+ """语音合成 Provider 接口。"""
+
+ #: 展示名,进命令行输出用
+ name: str = "未命名"
+
+ @abc.abstractmethod
+ def synthesize(self, text: str, voice: str = "", out_dir: Path | None = None) -> AudioClip:
+ """把一段文字合成为音频。
+
+ Parameters
+ ----------
+ text:
+ 要朗读的原文。
+ voice:
+ 音色标识,各 Provider 自行解释;空串表示用默认音色。
+ out_dir:
+ 输出目录。实现需自行建目录。
+
+ Returns
+ -------
+ AudioClip
+ ``duration`` 必须是真实音频的精确时长。
+ """
+
+ def list_voices(self) -> Sequence[str]:
+ """可用音色列表。默认只有一个。"""
+ return ("default",)
+
+ def available(self) -> bool:
+ """当前环境是否可用。用于让 CLI 优雅降级。"""
+ return True
+
+
+# ---------------------------------------------------------------------------
+# 语速估算:中英文混排,按字符加权
+# ---------------------------------------------------------------------------
+
+_CJK = re.compile(r"[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]")
+
+
+def estimate_duration(text: str, cps: float = 5.5) -> float:
+ """估算朗读一段文字需要多久(秒)。
+
+ 中文按字计、英文按词计,两者语速不同,分别加权后再加停顿。
+ **仅供没有真实音频时使用**(例如预排时间轴);
+ 一旦有真实 TTS,就用真实时长,不要用这个。
+
+ Parameters
+ ----------
+ text:
+ 原文。
+ cps:
+ 中文语速,默认 5.5 字/秒(约 330 字/分,播报腔)。
+ """
+ text = (text or "").strip()
+ if not text:
+ return 0.0
+
+ cjk = len(_CJK.findall(text))
+ # 英文按空白切词,大致一个词 2.6 个音节,取 0.45 秒/词
+ latin = len([w for w in re.split(r"\s+", text) if w and not _CJK.search(w)])
+ dur = cjk / cps + latin * 0.45
+
+ # 句末停顿
+ for mark in "。!?!?…":
+ dur += text.count(mark) * 0.28
+ for mark in ",、;:,;:—":
+ dur += text.count(mark) * 0.14
+
+ return max(0.6, round(dur, 3))
+
+
+# ---------------------------------------------------------------------------
+# 内置实现
+# ---------------------------------------------------------------------------
+
+
+class DummyTTS(TTSProvider):
+ """占位实现:生成等长静音。
+
+ 这不是 mock —— 它写出的是合法 wav,ffprobe 读得到,时长精确到毫秒。
+ 目的是让"场景时长 = 配音时长"这条链路在接入真实 TTS 之前就能真实
+ 跑通:时间轴、混音、音画对齐、字幕全部可以先验证一遍。
+
+ 换成真实 TTS 时,只要新的 Provider 也返回精确时长,其余代码无需改动。
+ """
+
+ name = "静音占位"
+
+ #: 静音采样率,和人声常用值一致,将来替换不用重采样
+ SAMPLE_RATE = 24000
+
+ def __init__(self, cps: float = 5.5):
+ self.cps = cps
+
+ def synthesize(self, text: str, voice: str = "", out_dir: Path | None = None) -> AudioClip:
+ import wave
+
+ out_dir = Path(out_dir or ".")
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ duration = estimate_duration(text, self.cps)
+ n = int(duration * self.SAMPLE_RATE)
+ out = out_dir / f"tts_{_slug(text)}_{int(duration * 1000)}.wav"
+
+ with wave.open(str(out), "wb") as w:
+ w.setnchannels(1)
+ w.setsampwidth(2)
+ w.setframerate(self.SAMPLE_RATE)
+ w.writeframes(b"\x00\x00" * n)
+
+ return AudioClip(
+ path=out,
+ duration=duration,
+ text=text,
+ voice=voice or "silence",
+ provider=self.name,
+ )
+
+ def available(self) -> bool:
+ return True
+
+
+class KokoroTTS(TTSProvider):
+ """本地离线 Kokoro-82M。
+
+ 纯本地推理,不联网、结果可复现,适合长期批量产出。
+ 需要 ``pip install kokoro-onnx soundfile``。
+ """
+
+ name = "Kokoro-82M(本地)"
+
+ def __init__(self, model_dir: str | Path | None = None):
+ self.model_dir = Path(model_dir) if model_dir else None
+ self._loaded = False
+ self._kokoro = None
+
+ def available(self) -> bool:
+ try:
+ import kokoro_onnx # noqa: F401
+ import soundfile # noqa: F401
+
+ return True
+ except Exception:
+ return False
+
+ def _load(self) -> None:
+ if self._loaded:
+ return
+ from kokoro_onnx import Kokoro
+
+ if self.model_dir is None:
+ import os
+
+ self.model_dir = Path(
+ os.environ.get("KOKORO_MODEL_DIR", Path.home() / ".cache/kokoro")
+ )
+ self._kokoro = Kokoro(str(self.model_dir / "kokoro-v1.0.onnx"), str(self.model_dir / "voices-v1.0.bin"))
+ self._loaded = True
+
+ def list_voices(self) -> Sequence[str]:
+ return ("af_heart", "af_bella", "af_nicole", "am_michael", "zf_xiaobei")
+
+ def synthesize(self, text: str, voice: str = "", out_dir: Path | None = None) -> AudioClip:
+ import soundfile as sf
+
+ self._load()
+ out_dir = Path(out_dir or ".")
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ voice = voice or "af_heart"
+ samples, sample_rate = self._kokoro.create(text, voice=voice, speed=1.0)
+ out = out_dir / f"tts_{_slug(text)}.wav"
+ sf.write(str(out), samples, sample_rate)
+
+ # 用真实采样数算时长,不信任浮点返回
+ duration = len(samples) / float(sample_rate)
+ return AudioClip(
+ path=out, duration=duration, text=text, voice=voice, provider=self.name
+ )
+
+
+# ---------------------------------------------------------------------------
+# 注册表
+# ---------------------------------------------------------------------------
+
+_REGISTRY: dict[str, type[TTSProvider]] = {
+ "dummy": DummyTTS,
+ "kokoro": KokoroTTS,
+}
+
+
+def register(name: str, cls: type[TTSProvider]) -> None:
+ """注册自定义 Provider,之后 ``get(name)`` 就能拿到。"""
+ _REGISTRY[name] = cls
+
+
+def get(name: str) -> TTSProvider:
+ """按名字取 Provider 实例。"""
+ key = (name or "dummy").lower()
+ if key not in _REGISTRY:
+ raise KeyError(f"未知的 TTS 提供方:{name}。可用:{', '.join(_REGISTRY)}")
+ return _REGISTRY[key]()
+
+
+def available_providers() -> dict[str, bool]:
+ """列出所有 Provider 及其当前可用性。"""
+ out = {}
+ for name, cls in _REGISTRY.items():
+ try:
+ out[name] = cls().available()
+ except Exception:
+ out[name] = False
+ return out
+
+
+def _slug(text: str, n: int = 12) -> str:
+ """把中文标题转成安全的文件名。"""
+ s = re.sub(r"[^\w\u4e00-\u9fff]+", "-", (text or "").strip())[:n]
+ return s.strip("-") or "clip"
\ No newline at end of file
diff --git a/src/audio/verify_sync.py b/src/audio/verify_sync.py
new file mode 100644
index 0000000..6140919
--- /dev/null
+++ b/src/audio/verify_sync.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+"""音画对齐的端到端验证。
+
+做法:让每个场景起点都有一次可闻的「滴」声,
+渲染成片后逐点测能量峰值,验证峰值确实落在场景起点上。
+
+这是对「场景时长 = 配音时长」这条链路的真实检验,
+不是看文件长度对不对——长度对但偏移了照样是错的。
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+import wave
+from pathlib import Path
+
+import numpy as np
+import yaml
+
+ROOT = Path(__file__).resolve().parents[2]
+SR = 44100
+
+
+def build_markers(project: str) -> tuple[Path, list[float]]:
+ """按场景起点铺一串滴声,返回 wav 与场景起点列表。"""
+ pdir = ROOT / "projects" / project
+ doc = yaml.safe_load((pdir / "project.yaml").read_text(encoding="utf-8"))
+
+ starts, cur = [], 0.0
+ for s in doc.get("scenes") or []:
+ starts.append(cur)
+ cur += float(s.get("duration") or 0)
+ total = cur
+
+ n = int(total * SR) + SR // 2
+ a = np.zeros(n, dtype=np.float32)
+ m = int(0.05 * SR)
+ tt = np.arange(m) / SR
+ # 每段用不同频率,事后能分辨是哪一段
+ for i, st in enumerate(starts):
+ f = 330 * (i + 1)
+ x = np.sin(2 * np.pi * f * tt) * np.exp(-np.linspace(0, 7, m)) * 0.6
+ s0 = int(st * SR)
+ if s0 + m <= n:
+ a[s0:s0 + m] = x.astype(np.float32)
+
+ out = pdir / "assets" / "audio" / "markers.wav"
+ with wave.open(str(out), "wb") as w:
+ w.setnchannels(1)
+ w.setsampwidth(2)
+ w.setframerate(SR)
+ w.writeframes((np.clip(a, -1, 1) * 32767).astype(np.int16).tobytes())
+ return out, starts
+
+
+def probe_energy(video: Path, t: float, win: float = 0.12) -> float:
+ """取 t 附近一小段的峰值音量(dB,-91 表示静音)。
+
+ 注意:这里不能用 ``-v error``。volumedetect 把结果打在 info 级别,
+ 一旦压到 error,输出会被整条过滤掉,函数永远返回 -91,
+ 于是「全部静音」的假象会一直出现。踩过这个坑。
+ """
+ r = subprocess.run(
+ ["ffmpeg", "-ss", f"{t:.3f}", "-t", str(win),
+ "-i", str(video), "-af", "volumedetect", "-f", "null", "/dev/null"],
+ capture_output=True, text=True,
+ )
+ for line in (r.stderr or "").splitlines():
+ if "max_volume" in line:
+ for tok in line.split():
+ try:
+ return float(tok)
+ except ValueError:
+ pass
+ return -91.0
+
+
+def main() -> int:
+ project = sys.argv[1] if len(sys.argv) > 1 else "配音演示"
+ video = ROOT / "projects" / project / "out" / f"{project}.mp4"
+ if not video.exists():
+ print(f"✗ 找不到成片:{video}")
+ return 1
+
+ wav, starts = build_markers(project)
+ print(f"✓ 已在每个场景起点铺标记音:{wav.relative_to(ROOT)}")
+ print(f" 场景起点:{[round(s, 2) for s in starts]}\n")
+
+ # 备份原音轨,换成标记音
+ import shutil
+ mix = ROOT / "projects" / project / "assets" / "audio" / "mix.wav"
+ backup = mix.with_suffix(".orig.wav")
+ if mix.exists():
+ shutil.copy2(mix, backup)
+
+ # 直接把标记音放到 mix.wav。渲染前 hv 会重新生成 index.html 并挂载音轨,
+ # 所以这里换文件即可,不需要改 index.html。
+ shutil.copy2(wav, mix)
+
+ r = subprocess.run(
+ ["node", str(ROOT / "src/cli/hv.mjs"), "渲染", project, "--quality", "草稿"],
+ capture_output=True, text=True, cwd=ROOT,
+ )
+ if r.returncode != 0:
+ if backup.exists():
+ shutil.copy2(backup, mix)
+ print("✗ 渲染失败")
+ print((r.stderr or r.stdout)[-1500:])
+ return 1
+
+ if backup.exists():
+ shutil.copy2(backup, mix) # 还原原音轨
+
+ print("对齐检测(场景起点处应有明显能量峰):\n")
+ ok = True
+ for i, st in enumerate(starts):
+ at = probe_energy(video, st + 0.02)
+ off = probe_energy(video, max(0.0, st + 0.45))
+ flag = "✓" if at > -40 else "✗"
+ if at <= -40:
+ ok = False
+ print(f" {flag} 场景 #{i + 1} @ {st:6.2f}s 起点峰值 {at:7.1f} dB 错开 0.45s 处 {off:7.1f} dB")
+
+ print()
+ if ok:
+ print("✓ 音画对齐验证通过:每个场景起点都能测到标记音能量峰")
+ return 0
+ print("✗ 对齐验证失败")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
\ No newline at end of file
diff --git a/src/brand/base.css b/src/brand/base.css
new file mode 100644
index 0000000..4d83e40
--- /dev/null
+++ b/src/brand/base.css
@@ -0,0 +1,95 @@
+/* ==========================================================================
+ vibevideo — base layer
+ reset + 跨组件共用的排版/容器原子
+ ========================================================================== */
+
+*,
+*::before,
+*::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+/* 注意:--stage-w/--stage-h 是无单位数字,用在 width/height 上必须补 * 1px,
+ 否则 `width: 1920` 是非法值会被整条丢弃(布局会悄悄塌掉)。 */
+html,
+body {
+ width: calc(var(--stage-w) * 1px);
+ height: calc(var(--stage-h) * 1px);
+ overflow: hidden;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+/* 组件舞台:占满画布并建立定位上下文 */
+.stage {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+/* --- 排版原子 --------------------------------------------------------- */
+
+/* 主标题:给足字重和字距控制,中英混排也不会松散 */
+.display {
+ font-weight: 700;
+ line-height: 1.08;
+ letter-spacing: -0.02em;
+}
+
+.headline {
+ font-weight: 700;
+ line-height: 1.15;
+ letter-spacing: -0.015em;
+}
+
+.subtitle {
+ font-weight: 500;
+ line-height: 1.4;
+ color: var(--text-dim);
+}
+
+.mono {
+ font-family: var(--font-mono);
+ font-variant-ligatures: none;
+}
+
+/* --- 容器原子 --------------------------------------------------------- */
+
+/* 玻璃面板:窗口/卡片类组件的通用外壳 */
+.panel {
+ background: var(--surface);
+ border: calc(1 * var(--u)) solid var(--border);
+ border-radius: calc(16 * var(--u));
+ overflow: hidden;
+ box-shadow: 0 calc(24 * var(--u)) calc(64 * var(--u)) rgba(0, 0, 0, 0.45);
+}
+
+/* 强调描边:用于需要聚焦的容器 */
+.panel-accent {
+ border-color: var(--accent-dim);
+ box-shadow: 0 0 0 calc(1 * var(--u)) var(--accent-dim),
+ 0 calc(24 * var(--u)) calc(64 * var(--u)) rgba(0, 0, 0, 0.5);
+}
+
+/* 安全区:所有内容默认在这个范围内,避开不同平台的 UI 遮挡 */
+.safe {
+ position: absolute;
+ inset: var(--pad-y) var(--pad-x);
+}
+
+/* 底部字幕条(lower-third)的挂载位 */
+.lower-third-slot {
+ position: absolute;
+ left: var(--pad-x);
+ bottom: calc(96 * var(--u));
+ right: var(--pad-x);
+ display: flex;
+ align-items: flex-end;
+ gap: calc(20 * var(--u));
+}
\ No newline at end of file
diff --git a/src/brand/tokens.css b/src/brand/tokens.css
new file mode 100644
index 0000000..2194016
--- /dev/null
+++ b/src/brand/tokens.css
@@ -0,0 +1,75 @@
+/* ==========================================================================
+ vibevideo — brand tokens
+ --------------------------------------------------------------------------
+ 每个组件必须引用这个文件。所有尺寸写成 calc(N * var(--u)) 的形式,
+ 不要写死 px —— 这是横屏/竖屏/任意分辨率都能共用同一个组件的前提。
+ ========================================================================== */
+
+:root {
+ /* --- 画布尺寸(由 hv build 写进 assets/canvas.css,随 aspect 变) ---
+ --stage-w / --stage-h: 舞台宽高,无单位数字。
+ 用到 width/height 时记得补 * 1px,否则是非法值会被浏览器丢掉。
+ --u: 基准单位 = 短边 / 1080,所以 1080p 横竖都是 1px,缩放安全 */
+ --stage-w: 1920;
+ --stage-h: 1080;
+ --u: calc(min(var(--stage-w), var(--stage-h)) / 1080 * 1px);
+
+ /* 面板宽度:横屏取宽边的 88%,竖屏取宽边的 88%,但不超过高的 1.35 倍,
+ 这样代码在两种画幅下都不会挤成一条。 */
+ --panel-w: calc(min(var(--stage-w) * 0.88, var(--stage-h) * 1.35) * 1px);
+
+ /* --- 色板:暗色技术风 --- */
+ --bg: #0a0a0c;
+ --bg-elevated: #131317;
+ --surface: #16161b;
+ --surface-2: #1c1c22;
+ --border: #26262e;
+ --border-strong: #34343f;
+
+ --text: #ededf0;
+ --text-dim: #a1a1ad;
+ --text-faint: #7d7d8a;
+ --text-ghost: #6a6a78;
+
+ --accent: #d97757; /* 强调色,暖陶土 */
+ --accent-dim: #8f4c37;
+ --accent-glow: rgba(217, 119, 87, 0.18);
+
+ /* 语义色 */
+ --ok: #7fb069;
+ --warn: #e5a54b;
+ --err: #e06c6c;
+ --info: #6ba3d6;
+
+ /* --- 代码高亮 --- */
+ --syn-plain: #d4d4dc;
+ --syn-comment: #5d5d6b;
+ --syn-keyword: #c98fdb;
+ --syn-string: #9ece6a;
+ --syn-number: #ff9e64;
+ --syn-func: #7aa2f7;
+ --syn-type: #7dcfff;
+ --syn-punct: #8b8b99;
+ --syn-var: #e0af68;
+
+ /* --- 字体:优先系统已装,回退链保证可移植 --- */
+ --font-sans: "Inter", "Noto Sans CJK SC", "PingFang SC", "Microsoft YaHei",
+ system-ui, sans-serif;
+ --font-mono: "JetBrainsMono Nerd Font Mono", "JetBrains Mono", "DejaVu Sans Mono",
+ Menlo, Consolas, monospace;
+
+ /* --- 动效基准时长(秒)--- */
+ --d-fast: 0.18;
+ --d-base: 0.32;
+ --d-slow: 0.55;
+}
+
+/* 竖屏时留更多安全边距 */
+:root[data-aspect="portrait"] {
+ --pad-x: calc(96 * var(--u));
+ --pad-y: calc(160 * var(--u));
+}
+:root:not([data-aspect="portrait"]) {
+ --pad-x: calc(120 * var(--u));
+ --pad-y: calc(96 * var(--u));
+}
\ No newline at end of file
diff --git a/src/cli/hv.mjs b/src/cli/hv.mjs
new file mode 100755
index 0000000..7ffdffe
--- /dev/null
+++ b/src/cli/hv.mjs
@@ -0,0 +1,997 @@
+#!/usr/bin/env node
+/* ==========================================================================
+ hv — vibevideo 框架命令行
+ --------------------------------------------------------------------------
+ 项目目录里只有 project.yaml 是你要写的,其余全部由 hv 生成:
+ project.yaml ──hv build──▶ index.html + assets/canvas.css ──▶ 成片
+ ========================================================================== */
+
+import fs from "node:fs";
+import path from "node:path";
+import { execFileSync, execSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import YAML from "yaml";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
+const PROJECTS = path.join(ROOT, "projects");
+const COMPONENTS = path.join(ROOT, "src/components");
+const TEMPLATES = path.join(ROOT, "src/templates");
+const BRAND = path.join(ROOT, "src/brand");
+const VENDOR = path.join(ROOT, "src/vendor");
+
+const ASPECTS = {
+ landscape: [1920, 1080],
+ portrait: [1080, 1920],
+ square: [1080, 1080],
+ ultrawide: [2560, 1080],
+};
+
+/* 中文别名:让 project.yaml 里可以直接写 aspect: 竖屏 */
+const ASPECT_ALIAS = {
+ 横屏: "landscape",
+ 竖屏: "portrait",
+ 方形: "square",
+ 方图: "square",
+ 超宽: "ultrawide",
+ landscape: "landscape",
+ portrait: "portrait",
+ square: "square",
+ ultrawide: "ultrawide",
+};
+
+const QUALITY_ALIAS = {
+ 草稿: "draft",
+ 正式: "looks",
+ 交付: "delivery",
+ draft: "draft",
+ looks: "looks",
+ delivery: "delivery",
+ standard: "standard",
+ high: "high",
+};
+
+/** 组件中文名与说明。键是目录名,缺省回退到目录名本身。 */
+const COMPONENT_CN = {
+ codedemo: ["代码演示", "代码窗口逐行输入,可指定重点行高亮"],
+ intro: ["片头", "标题与副标题入场"],
+ outro: ["片尾", "结尾与引导关注"],
+ lowerthird: ["下方字幕条", "左下角标题条,可带副标题"],
+ textblock: ["文字块", "纯文字标题段落,逐行浮现"],
+ terminal: ["终端", "命令行模拟,命令逐字输入、输出逐行出现"],
+ cursor: ["鼠标指针", "指针移动、点击与悬停"],
+ cad3d: ["三维建模", "钩子建模全过程:草图→拉伸→切除→圆角,真 3D 布尔运算"],
+ stress: ["自由度压测", "9 项超纲特性的渲染探针,用来验证引擎不限制创意"],
+};
+
+const cn = (id) => COMPONENT_CN[id]?.[0] || id;
+
+/* ---------- 小工具 ------------------------------------------------------ */
+
+const C = {
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
+ b: (s) => `\x1b[1m${s}\x1b[0m`,
+ g: (s) => `\x1b[32m${s}\x1b[0m`,
+ y: (s) => `\x1b[33m${s}\x1b[0m`,
+ r: (s) => `\x1b[31m${s}\x1b[0m`,
+ c: (s) => `\x1b[36m${s}\x1b[0m`,
+};
+
+const die = (msg) => {
+ console.error(C.r(`✗ ${msg}`));
+ process.exit(1);
+};
+const ok = (msg) => console.log(C.g(`✓ ${msg}`));
+const info = (msg) => console.log(C.dim(` ${msg}`));
+
+/** HTML 属性值转义:统一用单引号包裹,内部单引号转实体 */
+const attr = (s) => String(s).replace(/&/g, "&").replace(/'/g, "'").replace(/ & 需要转义才能安全进 DOM */
+const jsonAttr = (obj) =>
+ JSON.stringify(obj)
+ .replace(/&/g, "\\u0026")
+ .replace(//g, "\\u003e");
+
+/** 读组件自己声明的参数表(data-composition-variables)。
+ 真值放在组件里,这里只负责读出来做校验和帮助文本,避免两处维护。 */
+function readSchema(compId) {
+ const f = path.join(COMPONENTS, compId, `${compId}.html`);
+ if (!fs.existsSync(f)) return null;
+ const m = fs.readFileSync(f, "utf8").match(/data-composition-variables='(\[[^]*?\])'/);
+ if (!m) return [];
+ try {
+ return JSON.parse(m[1]);
+ } catch {
+ return [];
+ }
+}
+
+/** 组件自然时长(组件根节点上的 data-duration) */
+function readDuration(compId) {
+ const f = path.join(COMPONENTS, compId, `${compId}.html`);
+ if (!fs.existsSync(f)) return null;
+ const m = fs.readFileSync(f, "utf8").match(/data-composition-id="[^"]*"[\s\S]{0,200}?data-duration="([\d.]+)"/);
+ return m ? Number(m[1]) : null;
+}
+
+function listComponents() {
+ if (!fs.existsSync(COMPONENTS)) return [];
+ return fs
+ .readdirSync(COMPONENTS, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name)
+ .filter((n) => fs.existsSync(path.join(COMPONENTS, n, `${n}.html`)))
+ .sort();
+}
+
+function listTemplates() {
+ if (!fs.existsSync(TEMPLATES)) return [];
+ return fs
+ .readdirSync(TEMPLATES)
+ .filter((f) => f.endsWith(".yaml"))
+ .map((f) => f.replace(/\.yaml$/, ""))
+ .sort();
+}
+
+function listProjects() {
+ if (!fs.existsSync(PROJECTS)) return [];
+ return fs
+ .readdirSync(PROJECTS, { withFileTypes: true })
+ .filter((d) => d.isDirectory() && fs.existsSync(path.join(PROJECTS, d.name, "project.yaml")))
+ .map((d) => d.name)
+ .sort();
+}
+
+const projectDir = (name) => {
+ const d = path.join(PROJECTS, name);
+ if (!fs.existsSync(d)) die(`项目不存在:${name} (hv new ${name} 先建一个)`);
+ return d;
+};
+
+/** HyperFrames 的可执行文件,避免每次走 npx 解析 */
+function hfBin() {
+ const p = path.join(ROOT, "node_modules/.bin/hyperframes");
+ return fs.existsSync(p) ? p : "npx";
+}
+
+function runHf(args, cwd, quiet = false) {
+ try {
+ const out = execFileSync(hfBin(), args, {
+ cwd,
+ encoding: "utf8",
+ stdio: quiet ? ["ignore", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
+ maxBuffer: 64 * 1024 * 1024,
+ });
+ return { code: 0, out };
+ } catch (e) {
+ const stdout = e.stdout ? String(e.stdout) : "";
+ const stderr = e.stderr ? String(e.stderr) : "";
+ if (!quiet) process.stdout.write(stdout);
+ process.stderr.write(stderr);
+ return { code: e.status ?? 1, out: stdout };
+ }
+}
+
+/* ---------- 项目读取 ---------------------------------------------------- */
+
+function loadProject(name) {
+ const dir = projectDir(name);
+ const file = path.join(dir, "project.yaml");
+ let doc;
+ try {
+ doc = YAML.parse(fs.readFileSync(file, "utf8"));
+ } catch (e) {
+ die(`project.yaml 解析失败:${e.message}`);
+ }
+ if (!doc || typeof doc !== "object") die("project.yaml 是空的");
+ if (!Array.isArray(doc.scenes)) die("project.yaml 缺少 scenes 列表");
+
+ const raw = doc.aspect || "横屏";
+ const aspect = ASPECT_ALIAS[raw];
+ let w, h;
+ if (aspect && ASPECTS[aspect]) {
+ w = ASPECTS[aspect][0];
+ h = ASPECTS[aspect][1];
+ if (doc.width) w = doc.width;
+ if (doc.height) h = doc.height;
+ } else if (doc.width && doc.height) {
+ w = doc.width;
+ h = doc.height;
+ } else {
+ die(
+ `画幅 "${raw}" 无法识别。可选:${Object.keys(ASPECT_ALIAS).slice(0, 4).join(" / ")};` +
+ `或者直接写 width 和 height。`
+ );
+ }
+
+ const fps = Number(doc.fps) || 60;
+ if (![24, 30, 60].includes(fps)) {
+ console.log(C.y(` ! 帧率 ${fps} 不是 24/30/60,仍按 ${fps} 渲染`));
+ }
+
+ return { dir, file, doc, width: w, height: h, fps, aspect: aspect || "landscape" };
+}
+
+/** 算每个场景的 start / end,同时收集问题 */
+function resolveTimeline(doc) {
+ const available = listComponents();
+ const problems = [];
+ let cursor = 0;
+ const scenes = doc.scenes.map((s, i) => {
+ const id = `s${i}-${s.component || "?"}`;
+ if (!s.component) problems.push(`场景 #${i + 1} 缺少 component 字段`);
+ const known = available.includes(s.component);
+ if (!known) {
+ problems.push(
+ `场景 #${i + 1} 引用了不存在的组件 "${s.component}"` +
+ (available.length ? `。可用组件:${available.map(cn).join("、")}` : "。组件库还是空的")
+ );
+ }
+ const dur = Number(s.duration);
+ if (!Number.isFinite(dur) || dur <= 0) {
+ problems.push(`场景 #${i + 1}「${cn(s.component)}」的 duration 无效:${s.duration}`);
+ }
+ // 参数名校验:写错了在这里就报出来,不用等渲染时才发现画面不对
+ if (known) {
+ const schema = readSchema(s.component);
+ if (schema && schema.length && s.props) {
+ const allowed = new Set(schema.map((v) => v.id));
+ for (const k of Object.keys(s.props)) {
+ if (!allowed.has(k)) {
+ problems.push(
+ `场景 #${i + 1}「${cn(s.component)}」传了不存在的参数 "${k}"。` +
+ `可用参数:${schema.map((v) => `${v.id}(${v.label})`).join("、")}`
+ );
+ }
+ }
+ }
+ }
+ const start = Number.isFinite(Number(s.start)) ? Number(s.start) : cursor;
+ const out = { ...s, id, start, end: start + (Number.isFinite(dur) ? dur : 0), known, index: i };
+ cursor = out.end;
+ return out;
+ });
+ return { scenes, problems, total: cursor };
+}
+
+/* ---------- 生成 -------------------------------------------------------- */
+
+function writeCanvasCss(dir, width, height, aspect) {
+ const assets = path.join(dir, "assets");
+ fs.mkdirSync(assets, { recursive: true });
+ const css = `/* 自动生成,请勿手改 —— 改 project.yaml 后重新跑 hv build */
+:root {
+ --stage-w: ${width};
+ --stage-h: ${height};
+}
+`;
+ writeFileAtomic(path.join(assets, "canvas.css"), css);
+}
+
+/** 保留键前缀。hv build 注入的真实时长/起点用它,
+ 组件通过公开的 getVariables() 读取,不碰引擎私有 API。 */
+const HV_KEY = "__hv";
+
+/** 场景时长可能由配音回写产生(小数),与 time 的 seek 精度一致取三位 */
+const round3 = (n) => Number(Number(n).toFixed(3));
+
+function generateIndex(p) {
+ const { dir, doc, width, height, aspect } = p;
+ const { scenes, total, problems } = resolveTimeline(doc);
+
+ const mounts = scenes
+ .map((s) => {
+ /* 真实挂载时长/起点一并交给组件。
+ 组件自己去 DOM 里找宿主是不可靠的:引擎打包时会把
+ data-composition-src 改写成 data-composition-file,而 data-duration
+ 在 clip 上、不在祖先上——两条路都取不到,hv.duration() 恒为 null,
+ 组件只能退回写死的默认时长,配音驱动的场景就会被截断。
+ 这里走 data-variable-values,它和 data-duration 是同源的同一组数。 */
+ const props = jsonAttr({
+ ...(s.props || {}),
+ [`${HV_KEY}Duration`]: round3(s.end - s.start),
+ [`${HV_KEY}Start`]: round3(s.start),
+ });
+ return `
+ `;
+ })
+ .join("\n");
+
+ const html = `
+
+
+
+
+
+ ${attr(doc.title || p.name || "video")}
+
+
+
+
+
+
+
+
+
+
+${mounts}
+
+
+
+
+`;
+ writeFileAtomic(path.join(dir, "index.html"), html);
+ writeCanvasCss(dir, width, height, aspect);
+ return { scenes, total, problems };
+}
+
+/** 原子写:先写临时文件再改名。
+ 避免「生成」刚写完、「检查」读到写了一半的 index.html,
+ 那种竞态会表现为检查结果时对时错。踩过。 */
+function writeFileAtomic(file, content) {
+ const tmp = `${file}.tmp`;
+ fs.writeFileSync(tmp, content);
+ fs.renameSync(tmp, file);
+}
+
+/** 组件/资源同步到项目目录。
+ 这里必须用「复制」而不是软链:HyperFrames 的 bundler 不跟随软链目录,
+ 会把软链出来的子组合当成空文件(实测报 "the file is empty")。
+ 单一份真值仍在 src/,每次 build/render 重新同步即可。 */
+function syncInto(srcDir, destDir) {
+ if (!fs.existsSync(srcDir)) return;
+ fs.mkdirSync(destDir, { recursive: true });
+ for (const name of fs.readdirSync(srcDir)) {
+ const s = path.join(srcDir, name);
+ const d = path.join(destDir, name);
+ const st = fs.lstatSync(s);
+ if (st.isSymbolicLink()) {
+ fs.rmSync(d, { force: true, recursive: true });
+ copyReal(fs.realpathSync(s), d);
+ } else if (st.isDirectory()) {
+ syncInto(s, d);
+ } else {
+ // 只在内容变化时复制,省掉大量无谓 IO
+ const need =
+ !fs.existsSync(d) || fs.statSync(d).size !== st.size || fs.statSync(s).mtimeMs > fs.statSync(d).mtimeMs;
+ if (need) fs.copyFileSync(s, d);
+ }
+ }
+}
+
+function copyReal(src, dest) {
+ const st = fs.lstatSync(src);
+ if (st.isDirectory()) {
+ fs.mkdirSync(dest, { recursive: true });
+ for (const n of fs.readdirSync(src)) syncInto(path.join(src, n), path.join(dest, n));
+ } else {
+ fs.copyFileSync(src, dest);
+ }
+}
+
+/** 把整条混音轨挂到时间轴根部。
+ 根节点带 data-has-audio 时,整条片子的声音就是这一轨;
+ 场景各自的 audio 字段仍可用于分轨调试。 */
+function writeAudioTrack(p, mixPath) {
+ const index = path.join(p.dir, "index.html");
+ let html = fs.readFileSync(index, "utf8");
+ const rel = "./assets/audio/mix.wav";
+
+ // 移除旧的音轨节点,保证重复渲染不会叠加
+ html = html.replace(/\s*
+
+ ", ` ${node}\n \n `);
+ writeFileAtomic(index, html);
+}
+
+const totalOf = (p) => resolveTimeline(p.doc).total;
+
+function linkAssets(dir) {
+ const compDir = path.join(dir, "compositions/components");
+ const assets = path.join(dir, "assets");
+ fs.mkdirSync(compDir, { recursive: true });
+ fs.mkdirSync(assets, { recursive: true });
+
+ // 清掉旧版留下的软链
+ for (const n of fs.readdirSync(compDir)) {
+ const d = path.join(compDir, n);
+ if (fs.lstatSync(d).isSymbolicLink()) fs.unlinkSync(d);
+ }
+ for (const n of ["brand", "vendor"]) {
+ const d = path.join(assets, n);
+ if (fs.lstatSync(d, { throwIfNoEntry: false })?.isSymbolicLink()) fs.rmSync(d, { recursive: true, force: true });
+ }
+
+ syncInto(COMPONENTS, compDir);
+ syncInto(BRAND, path.join(assets, "brand"));
+ syncInto(VENDOR, path.join(assets, "vendor"));
+}
+
+function writeProjectFiles(dir, p) {
+ fs.writeFileSync(
+ path.join(dir, "hyperframes.json"),
+ JSON.stringify(
+ {
+ $schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
+ registry: "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
+ paths: { blocks: "compositions", components: "compositions/components", assets: "assets" },
+ media: { autoProxy: true },
+ },
+ null,
+ 2
+ ) + "\n"
+ );
+ fs.writeFileSync(
+ path.join(dir, "package.json"),
+ JSON.stringify({ name: p.name, private: true, type: "module", scripts: {} }, null, 2) + "\n"
+ );
+ fs.writeFileSync(
+ path.join(dir, "meta.json"),
+ JSON.stringify({ id: p.name, name: doc_name(p), createdAt: new Date().toISOString() }, null, 2) + "\n"
+ );
+ for (const d of ["assets/project", "assets/audio", "out", "compositions/components"]) {
+ fs.mkdirSync(path.join(dir, d), { recursive: true });
+ }
+
+ // 策划面文件:只写一次,已存在不覆盖,避免把用户写的文案冲掉
+ const scaffold = {
+ "BRIEF.md": BRIEF_TPL.replace(/\{\{NAME\}\}/g, p.name),
+ "script.md": SCRIPT_TPL,
+ };
+ for (const [file, text] of Object.entries(scaffold)) {
+ const target = path.join(dir, file);
+ if (!fs.existsSync(target)) fs.writeFileSync(target, text);
+ }
+}
+
+const BRIEF_TPL = `# 选题简报 · {{NAME}}
+
+> 这份文件是策划面。填完之后再动手改 \`project.yaml\`。
+
+## 一句话
+
+(这条视频要让观众记住的一句话。写不出来说明选题还没想清楚。)
+
+## 给谁看
+
+- 受众:
+- 他们现在知道什么:
+- 他们看完应该能做什么:
+
+## 钩子
+
+(前 3 秒说什么。用反常识、具体数字、或者直接抛结果。别铺垫。)
+
+## 结构
+
+| 段 | 时长 | 讲什么 | 用哪个组件 |
+|---|---|---|---|
+| 开场 | 3.5s | | intro |
+| 主体 | | | codedemo / terminal / cad3d |
+| 收尾 | 4s | | outro |
+
+## 素材清单
+
+- [ ] 录屏:
+- [ ] 截图:
+- [ ] 代码片段:
+- [ ] 背景音乐:
+
+## 完成标准
+
+- [ ] \`hv 检查 {{NAME}}\` 通过
+- [ ] \`hv 看帧 {{NAME}}\` 抽查几个关键时间点,没有排版问题
+- [ ] 全片时长与预期一致
+- [ ] 音画对齐(若有配音)
+
+## 已知取舍
+
+(哪里打算偷懒、哪里有风险,写下来免得忘了)
+`;
+
+const SCRIPT_TPL = `# 台词稿
+
+> 一行一个场景,顺序与 \`project.yaml\` 的 \`scenes\` 一致。
+> 填完后跑 \`python3 src/audio/cli.py 配音 <项目名>\`,
+> 场景时长会自动对齐到每句的朗读时长。
+
+## 场景 1 · intro
+
+- 画面:
+- 台词:
+
+## 场景 2 · codedemo
+
+- 画面:
+- 台词:
+- 重点行号:
+
+## 场景 3
+
+- 画面:
+- 台词:
+`;
+const doc_name = (p) => p.doc.title || p.name;
+
+/* ---------- 命令 -------------------------------------------------------- */
+
+function cmdNew(args) {
+ const name = args._[0];
+ if (!name) die("用法:hv new <项目名> [--template code-explainer] [--aspect portrait]");
+ const template = argOf(args, "--template");
+ const aspect = argOf(args, "--aspect") || "landscape";
+ const fps = argOf(args, "--fps") || "60";
+ const dir = path.join(PROJECTS, name);
+ if (fs.existsSync(dir)) die(`项目已存在:projects/${name}`);
+
+ let tplText;
+ if (template) {
+ const t = path.join(TEMPLATES, `${template}.yaml`);
+ if (!fs.existsSync(t)) {
+ const have = listTemplates();
+ die(`模板不存在:${template}${have.length ? `(可用:${have.join(", ")})` : "(模板目录是空的)"}`);
+ }
+ tplText = fs.readFileSync(t, "utf8");
+ } else {
+ tplText = `title: ${name}
+aspect: ${aspect}
+fps: ${fps}
+
+scenes: []
+`;
+ }
+
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, "project.yaml"), tplText);
+
+ const p = { dir, name, doc: YAML.parse(tplText), width: 0, height: 0 };
+ const aKey = ASPECT_ALIAS[p.doc.aspect || aspect] || "landscape";
+ const a = ASPECTS[aKey];
+ p.width = p.doc.width || a[0];
+ p.height = p.doc.height || a[1];
+ p.aspect = p.doc.aspect || aspect;
+ p.fps = p.doc.fps || Number(fps);
+
+ writeProjectFiles(dir, p);
+ linkAssets(dir);
+ generateIndex(p);
+
+ ok(`已创建项目 projects/${name}`);
+ info(`画幅 ${p.width}x${p.height} · ${p.fps} 帧/秒`);
+ info(`素材放 projects/${name}/assets/project/`);
+ info(`接下来编辑 project.yaml,然后: hv build ${name} && hv preview ${name}`);
+}
+
+function cmdBuild(args) {
+ const name = args._[0];
+ if (!name) die("用法:hv build <项目名>");
+ const p = loadProject(name);
+ linkAssets(p.dir);
+ const { scenes, problems, total } = generateIndex(p);
+
+ if (problems.length) {
+ problems.forEach((x) => console.log(C.y(` ! ${x}`)));
+ }
+ ok(
+ `${name}:${scenes.length} 个场景 · 时长 ${total.toFixed(2)}s · ${p.width}x${p.height}@${p.fps}`
+ );
+ if (problems.length) process.exitCode = 1;
+}
+
+/* 组件是否会跟随挂载时长。
+ 用了运行时(hv.duration())的组件,长度不一致是正常的——
+ 配音驱动时间轴时时长本来就会被改,这不该报错。
+ 没用的组件才需要提醒:挂多长都得按组件默认值写,否则动画会被截断。 */
+const FLEXIBLE = /hv\.duration\(\)/;
+
+/* 压测组件故意制造遮挡、裁切等极端排版,用来验证引擎不限制创意。
+ 这些告警是预期内的,不该拖垮整个项目的检查。
+ 豁免方式:组件根节点上加 data-allow="text_occluded clipped_text"。 */
+const ALLOWED_RE = /\bdata-allow="([^"]+)"/;
+
+function allowedFindings(compId) {
+ const f = path.join(COMPONENTS, compId, `${compId}.html`);
+ if (!fs.existsSync(f)) return new Set();
+ const m = fs.readFileSync(f, "utf8").match(ALLOWED_RE);
+ return new Set(m ? m[1].split(/\s+/) : []);
+}
+
+function isDurationFlexible(compId) {
+ const f = path.join(COMPONENTS, compId, `${compId}.html`);
+ if (!fs.existsSync(f)) return true; // 未知组件已经报过了
+ return FLEXIBLE.test(fs.readFileSync(f, "utf8"));
+}
+
+function cmdCheck(args) {
+ const name = args._[0];
+ if (!name) die("用法:hv check <项目名>");
+ const p = loadProject(name);
+ // 和渲染一样先同步 + 生成,否则检查的是上一次的旧副本,
+ // 会出现「明明改了却还报旧问题」或「改了却漏检」。踩过。
+ linkAssets(p.dir);
+ const { scenes, problems, total } = resolveTimeline(p.doc);
+ generateIndex(p);
+
+ // 1) 组件存在性 + 参数合法性 + 时长一致性
+ for (const s of scenes) {
+ if (!s.known) continue;
+ if (isDurationFlexible(s.component)) continue;
+ const natural = readDuration(s.component);
+ if (natural != null) {
+ const used = s.end - s.start;
+ if (Math.abs(natural - used) > 0.01) {
+ problems.push(
+ `场景 #${s.index + 1}「${cn(s.component)}」挂载了 ${used}s,但组件自身是 ${natural}s` +
+ `(该组件时长写死,不一致会让动画被截断或多停)`
+ );
+ }
+ }
+ }
+
+ if (problems.length) {
+ problems.forEach((x) => console.log(C.r(` ✗ ${x}`)));
+ die(`${name} 未通过检查`);
+ }
+
+ // 2) 交给渲染引擎自己的 check(运行时报错/布局/对比度)
+ const res = runHf(["check"], p.dir);
+ if (res.code === 0) {
+ ok(`${name} 检查通过 · 时长 ${total.toFixed(2)}s`);
+ return;
+ }
+
+ // 引擎报了问题,但可能都是组件自己声明过的预期项。
+ // 逐行解析:类别写在行首(如 text_occluded / clipped_text),
+ // 对比度告警没有类别前缀,单独按 contrast 归类。
+ const allow = new Set();
+ scenes.forEach((s) => s.known && allowedFindings(s.component).forEach((f) => allow.add(f)));
+
+ const out = runHf(["check"], p.dir, true).out || "";
+ // 已知类别集合:只认这些,其余一律当 unknown 报出来(宁可多报,不可漏报)
+ const KNOWN = new Set([
+ "text_occluded", "clipped_text", "contrast", "overflow",
+ "text_overlap", "offscreen", "tiny_text", "layout_shift",
+ ]);
+
+ const seen = new Set();
+ for (const line of out.split("\n")) {
+ if (!/^\s*✗/.test(line)) continue;
+ let tag = "unknown";
+ for (const w of line.split(/[\s\[\]>:(),]+/)) {
+ if (KNOWN.has(w)) {
+ tag = w;
+ break;
+ }
+ }
+ // 对比度告警不带类别名,只靠 "...:1.32:1 (need 4.5:1" 这个形状识别
+ if (tag === "unknown" && /\(need\s[\d.]+:1\b/.test(line)) tag = "contrast";
+ seen.add(tag);
+ }
+ const unexpected = [...seen].filter((c) => !allow.has(c));
+
+ if (seen.size > 0 && unexpected.length === 0) {
+ ok(`${name} 检查通过 · 时长 ${total.toFixed(2)}s`);
+ info(`已忽略组件声明的预期项:${[...seen].join("、")}`);
+ } else {
+ process.stdout.write(out);
+ die(`${name} 未通过检查${unexpected.length ? `(${unexpected.join("、")})` : ""}`);
+ }
+}
+
+function cmdRender(args) {
+ const name = args._[0];
+ if (!name) die("用法:hv render <项目名> [--quality 交付] [--fps 60]");
+ const p = loadProject(name);
+ // 渲染前重新同步组件并生成 index.html,保证产物与源码一致
+ linkAssets(p.dir);
+ const { problems, total } = generateIndex(p);
+ if (problems.length) problems.forEach((x) => console.log(C.y(` ! ${x}`)));
+
+ const qRaw = argOf(args, "--quality") || "looks";
+ const quality = QUALITY_ALIAS[qRaw];
+ if (!quality) die(`渲染档位 "${qRaw}" 无法识别,可用:草稿(draft) / 正式(looks) / 交付(delivery)`);
+ const fps = argOf(args, "--fps") || String(p.fps);
+ const out = path.join(p.dir, "out", `${name}.mp4`);
+ const started = Date.now();
+
+ // 有混音就挂上去。渲染引擎只认场景里声明的音频,
+ // 所以这里把混音轨写进 index.html 的根节点。
+ const mix = path.join(p.dir, "assets", "audio", "mix.wav");
+ const hasAudio = fs.existsSync(mix);
+ if (hasAudio) writeAudioTrack(p, mix);
+
+ const res = runHf(["render", "--fps", String(fps), "--quality", quality, "--output", out], p.dir);
+ if (res.code !== 0) die("渲染失败");
+ if (hasAudio) info("已挂载音轨:assets/audio/mix.wav");
+
+ const stat = fs.statSync(out);
+ ok(
+ `${name} 渲染完成 · ${(stat.size / 1024 / 1024).toFixed(1)}MB · 时长 ${total.toFixed(
+ 1
+ )}s · 耗时 ${((Date.now() - started) / 1000).toFixed(0)}s`
+ );
+ info(out.replace(ROOT + "/", ""));
+}
+
+function cmdPreview(args) {
+ const name = args._[0];
+ if (!name) die("用法:hv preview <项目名>");
+ const p = loadProject(name);
+ linkAssets(p.dir);
+ generateIndex(p);
+ info("正在启动预览,按 Ctrl+C 结束");
+ runHf(["preview"], p.dir);
+}
+
+function cmdList(args) {
+ const raw = args._[0];
+ const what = { 项目: "projects", 组件: "components", 模板: "templates" }[raw] || raw;
+
+ if (!what || what === "projects") {
+ const l = listProjects();
+ console.log(C.b("\n项目"));
+ if (!l.length) info("(还没有项目,用 hv new 建一个)");
+ else
+ l.forEach((x) => {
+ try {
+ const p = loadProject(x);
+ const { total } = resolveTimeline(p.doc);
+ console.log(` ${C.c(x)} ${C.dim(`${p.width}x${p.height}@${p.fps} · ${total.toFixed(1)}s`)}`);
+ } catch {
+ console.log(` ${C.c(x)}`);
+ }
+ });
+ }
+
+ if (!what || what === "components") {
+ const l = listComponents();
+ console.log(C.b("\n组件"));
+ if (!l.length) info("(组件库是空的)");
+ else
+ l.forEach((x) => {
+ const f = path.join(COMPONENTS, x, `${x}.html`);
+ const html = fs.existsSync(f) ? fs.readFileSync(f, "utf8") : "";
+ const d = html.match(/data-duration="([\d.]+)"/)?.[1];
+ const desc = COMPONENT_CN[x]?.[1] || "";
+ console.log(
+ ` ${C.c(x.padEnd(12))} ${(cn(x) + " ").padEnd(10)} ${C.dim(
+ (d ? `默认 ${d}s · ` : "") + desc
+ )}`
+ );
+ });
+ }
+
+ if (!what || what === "templates") {
+ const l = listTemplates();
+ console.log(C.b("\n模板"));
+ if (!l.length) info("(模板库是空的)");
+ else l.forEach((x) => console.log(` ${C.c(x)}`));
+ }
+ console.log();
+}
+
+function cmdDoc(args) {
+ const which = args._[0];
+ if (!which) return cmdList({ _: ["组件"] });
+
+ const all = listComponents();
+ const target = all.includes(which) ? which : all.find((c) => cn(c) === which);
+ if (!target) die(`没有名为「${which}」的组件。可用:${all.map(cn).join("、")}`);
+
+ const schema = readSchema(target) || [];
+ const dur = readDuration(target);
+ const desc = COMPONENT_CN[target]?.[1] || "";
+
+ console.log(`\n${C.b(cn(target))} ${C.dim(target)}\n`);
+ if (desc) console.log(` ${desc}\n`);
+ console.log(` ${C.dim("默认时长")} ${dur ?? "—"} 秒\n`);
+
+ if (schema.length) {
+ console.log(C.b(" 参数"));
+ for (const v of schema) {
+ const def = v.default === "" || v.default == null ? C.dim("(不填则不显示)") : String(v.default);
+ console.log(` ${C.c((v.id + " ").padEnd(16))}${C.dim((v.label + " ").padEnd(24))}默认 ${def}`);
+ }
+ console.log();
+ }
+ console.log(C.dim(` 在 project.yaml 里这样用:\n`));
+ console.log(C.dim(` - component: ${target}`));
+ console.log(C.dim(` duration: ${dur ?? 3}`));
+ if (schema.length) {
+ const first = schema[0];
+ console.log(C.dim(` props:`));
+ console.log(C.dim(` ${first.id}: ${first.id === "code" || first.id === "script" ? "|-\n ..." : JSON.stringify(first.default ?? "")}`));
+ }
+ console.log();
+}
+
+function cmdDoctor() {
+ console.log(C.b("\n环境体检\n"));
+ const checks = [];
+ const add = (n, pass, detail) => checks.push({ n, pass, detail });
+
+ add("Node 运行环境", true, process.version);
+ try {
+ const v = execSync("ffmpeg -version", { encoding: "utf8" }).split("\n")[0].slice(0, 44);
+ add("ffmpeg 视频编码", true, v);
+ } catch {
+ add("ffmpeg 视频编码", false, "未安装,装了才能渲染");
+ }
+ const chrome = path.join(process.env.HOME || "", ".cache/hyperframes/chrome");
+ add(
+ "Chrome 渲染内核",
+ fs.existsSync(chrome),
+ fs.existsSync(chrome) ? "已就绪" : "缺失,执行 npx hyperframes browser ensure"
+ );
+
+ for (const [label, family, fb, why] of [
+ ["中文字体", "Noto Sans CJK SC", "系统默认无衬线", "中文会显示成方框"],
+ ["代码字体", "JetBrainsMono Nerd Font Mono", "DejaVu Sans Mono", "代码排版会变味"],
+ ]) {
+ let okf = false;
+ try {
+ okf = execSync(`fc-match -f "%{family}" "${family}"`, { encoding: "utf8" }).includes(
+ family.split(" ")[0]
+ );
+ } catch {}
+ add(label, okf, okf ? `${family} 已安装` : `缺失:${why}(会回退到 ${fb})`);
+ }
+
+ add("组件库", listComponents().length > 0, `${listComponents().length} 个组件`);
+ add("模板库", listTemplates().length > 0, `${listTemplates().length} 个模板`);
+
+ for (const c of checks) {
+ console.log(` ${c.pass ? C.g("✓") : C.y("!")} ${c.n.padEnd(20)} ${C.dim(c.detail)}`);
+ }
+ const bad = checks.filter((c) => !c.pass).length;
+ console.log();
+ if (bad) console.log(C.y(`${bad} 项需要注意,不阻塞使用\n`));
+ else console.log(C.g("全部就绪\n"));
+}
+
+/* ---------- 参数 -------------------------------------------------------- */
+
+function argOf(args, name) {
+ const i = args._.indexOf(name);
+ return i >= 0 ? args._[i + 1] : undefined;
+}
+function parseArgv(argv) {
+ const _ = [];
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i].startsWith("--")) {
+ _.push(argv[i]);
+ if (argv[i + 1] && !argv[i + 1].startsWith("--")) _.push(argv[++i]);
+ } else _.push(argv[i]);
+ }
+ return { _ };
+}
+
+/* ---------- 入口 -------------------------------------------------------- */
+
+const HELP = `
+${C.b("hv")} — 视频生成框架
+
+${C.b("项目")}
+ hv new <名字> [--template 模板名] [--aspect 画幅] [--fps 帧率]
+ 新建项目。--template 套用现成的结构套路。
+ hv build <名字> 把 project.yaml 生成成 index.html(改完配置跑这个)
+ hv check <名字> 校验组件与时长,再跑渲染引擎的运行时检查
+ hv render <名字> [--quality 档位] [--fps 帧率]
+ 渲染成片,成品在 projects/<名字>/out/ 下
+ hv preview <名字> 本地预览,改动即时生效
+
+${C.b("画幅 aspect")}
+ 横屏 landscape 1920x1080
+ 竖屏 portrait 1080x1920
+ 方形 square 1080x1080
+ 超宽 ultrawide 2560x1080
+ 也可在 project.yaml 里直接写 width / height 自定义
+
+${C.b("渲染档位 quality")}
+ draft 草稿,快速迭代用
+ looks 正式画质,默认
+ delivery 交付画质,帧率更高、体积更大
+
+${C.b("资源")}
+ hv list [项目|组件|模板] 列出已有内容
+ hv shot <名字> [--时间 1,3,5] 抽几帧看效果,比整条渲染快得多
+ hv 诊断 <名字> 在真实浏览器里抓预览的控制台错误
+ hv doc <组件名> 看某个组件有哪些参数、怎么写
+ hv doctor 环境体检
+
+${C.b("典型流程")}
+ hv new ep001 --template 代码讲解
+ ${C.dim("# 编辑 projects/ep001/project.yaml")}
+ hv build ep001
+ hv preview ep001 ${C.dim("看一眼")}
+ hv render ep001
+
+${C.dim("帧率支持 24 / 30 / 60,默认 60")}
+${C.dim("所有子命令都有中文别名:hv 新建 / 生成 / 检查 / 渲染 / 预览 / 列表 / 看帧 / 说明 / 体检")}
+`;
+
+const [, , rawCmd, ...rest] = process.argv;
+const args = parseArgv(rest);
+
+/* 中文子命令别名 */
+const CMD_ALIAS = {
+ 新建: "new",
+ new: "new",
+ 生成: "build",
+ build: "build",
+ 检查: "check",
+ check: "check",
+ 渲染: "render",
+ render: "render",
+ 预览: "preview",
+ preview: "preview",
+ 列表: "list",
+ list: "list",
+ 看帧: "shot",
+ shot: "shot",
+ 诊断: "probe",
+ probe: "probe",
+ 说明: "doc",
+ doc: "doc",
+ 体检: "doctor",
+ doctor: "doctor",
+};
+
+const cmd = CMD_ALIAS[rawCmd];
+
+try {
+ switch (cmd) {
+ case "new": cmdNew(args); break;
+ case "build": cmdBuild(args); break;
+ case "check": cmdCheck(args); break;
+ case "render": cmdRender(args); break;
+ case "preview": cmdPreview(args); break;
+ case "list": cmdList(args); break;
+ case "doc": cmdDoc(args); break;
+ case "doctor": cmdDoctor(); break;
+ case "shot": {
+ const { shotCLI } = await import("./shot.mjs");
+ await shotCLI(args._);
+ break;
+ }
+ case "probe": {
+ const { probeCLI } = await import("./probe_studio.mjs");
+ await probeCLI(args._);
+ break;
+ }
+ default:
+ if (rawCmd) console.log(C.y(`未知命令:${rawCmd}\n`));
+ console.log(HELP);
+ process.exit(rawCmd ? 1 : 0);
+ }
+} catch (e) {
+ die(e.stack || e.message);
+}
\ No newline at end of file
diff --git a/src/cli/probe_studio.mjs b/src/cli/probe_studio.mjs
new file mode 100644
index 0000000..b261d51
--- /dev/null
+++ b/src/cli/probe_studio.mjs
@@ -0,0 +1,71 @@
+#!/usr/bin/env node
+/* 在真实浏览器里打开 Studio,收集控制台错误。
+ 用法:node src/cli/probe_studio.mjs <项目名> */
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import puppeteer from "puppeteer-core";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
+
+function findChrome() {
+ const base = path.join(process.env.HOME || "", ".cache/hyperframes/chrome/chrome-headless-shell");
+ for (const v of fs.readdirSync(base)) {
+ const p = path.join(base, v, "chrome-headless-shell-linux64", "chrome-headless-shell");
+ if (fs.existsSync(p)) return p;
+ }
+ throw new Error("找不到 Chrome");
+}
+
+async function run(project, url) {
+const browser = await puppeteer.launch({
+ executablePath: findChrome(),
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
+ headless: true,
+});
+const page = await browser.newPage();
+await page.setViewport({ width: 1600, height: 1000 });
+
+const errors = [];
+const failed = [];
+page.on("console", (m) => {
+ if (m.type() === "error") errors.push(m.text().slice(0, 220));
+});
+page.on("pageerror", (e) => errors.push("PAGEERROR: " + String(e.message).slice(0, 220)));
+page.on("requestfailed", (r) => failed.push(r.url().slice(-90)));
+page.on("response", (r) => {
+ if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(-90)}`);
+});
+
+await page.goto(url, { waitUntil: "networkidle2", timeout: 60000 });
+await new Promise((r) => setTimeout(r, 9000));
+
+// 看看有没有 iframe 承载组合
+const frames = page.frames().map((f) => f.url().slice(0, 120));
+
+console.log("=== 页面结构 ===");
+console.log("frames:", JSON.stringify(frames, null, 1));
+
+console.log("\n=== 控制台错误 ===");
+if (errors.length) [...new Set(errors)].forEach((e) => console.log(" ✗ " + e));
+else console.log(" (无)");
+
+console.log("\n=== 失败请求 ===");
+if (failed.length) [...new Set(failed)].slice(0, 12).forEach((f) => console.log(" ✗ " + f));
+else console.log(" (无)");
+
+await browser.close();
+}
+
+export async function probeCLI(argv) {
+ const project = argv[0] || "demo全组件";
+ const url = argv[1] || "http://localhost:3002";
+ console.log(`正在诊断 ${project} 的预览:${url}\n`);
+ await run(project, url);
+}
+// 单独运行
+if (import.meta.url === `file://${process.argv[1]}`) {
+ const argv = process.argv.slice(2);
+ console.log(`正在诊断 ${argv[0] || "demo全组件"} 的预览\n`);
+ await run(argv[0] || "demo全组件", argv[1] || "http://localhost:3002");
+}
diff --git a/src/cli/shot.mjs b/src/cli/shot.mjs
new file mode 100644
index 0000000..aa81d0b
--- /dev/null
+++ b/src/cli/shot.mjs
@@ -0,0 +1,76 @@
+#!/usr/bin/env node
+/* ==========================================================================
+ 预览帧:hv shot
+ --------------------------------------------------------------------------
+ 改完组件先抽几帧看看,比直接渲染整条快一个数量级。
+ 底层用的是渲染引擎自带的 snapshot,它按时间点精确抽帧,
+ 还会顺便输出一张 contact sheet 拼图方便一次看完。
+
+ 用法:
+ hv 看帧 <项目名> [--时间 1,3,5] [--数 5] [--缩放 .editor] [--输出 目录]
+ ========================================================================== */
+
+import path from "node:path";
+import fs from "node:fs";
+import { fileURLToPath } from "node:url";
+import { execFileSync } from "node:child_process";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
+
+export async function shotCLI(argv) {
+ const arg = (k) => {
+ const i = argv.indexOf(k);
+ return i >= 0 ? argv[i + 1] : undefined;
+ };
+ const name = argv[0];
+ if (!name) {
+ console.log("用法: hv 看帧 <项目名> [--时间 1,3,5] [--数 5] [--缩放 .editor] [--输出 目录]");
+ process.exit(1);
+ }
+
+ const dir = path.join(ROOT, "projects", name);
+ if (!fs.existsSync(dir)) {
+ console.error(`✗ 项目不存在:${name}`);
+ process.exit(1);
+ }
+ if (!fs.existsSync(path.join(dir, "index.html"))) {
+ console.error("✗ 还没有 index.html,先执行 hv build");
+ process.exit(1);
+ }
+
+ const at = arg("--时间") || arg("--at");
+ const count = arg("--数") || arg("--frames") || "5";
+ const zoom = arg("--缩放") || arg("--zoom");
+ const outDir = arg("--输出") || arg("--output") || path.join(dir, "snapshots");
+
+ const bin = path.join(ROOT, "node_modules/.bin/hyperframes");
+ const argvHf = ["snapshot", dir, "--output", outDir];
+ if (at) argvHf.push("--at", at, "--no-end");
+ else argvHf.push("--frames", count);
+ if (zoom) argvHf.push("--zoom", zoom);
+
+ console.log(`正在抽帧:${name} ${at ? `时间点 ${at}s` : `${count} 帧`}`);
+ try {
+ const out = execFileSync(fs.existsSync(bin) ? bin : "npx", argvHf, {
+ encoding: "utf8",
+ maxBuffer: 32 * 1024 * 1024,
+ });
+ process.stdout.write(out);
+ } catch (e) {
+ if (e.stdout) process.stdout.write(e.stdout);
+ if (e.stderr) process.stderr.write(e.stderr);
+ process.exit(1);
+ }
+ console.log(`\n看图目录:${outDir.replace(ROOT + "/", "")}`);
+ console.log(`${C("contact-sheet.jpg")} 是所有帧的拼图,一张看完整条时间轴。\n`);
+}
+const C = (s) => `\x1b[36m${s}\x1b[0m`;
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+ try {
+ await shotCLI(process.argv.slice(2));
+ } catch (e) {
+ console.error("✗ " + e.message);
+ process.exit(1);
+ }
+}
\ No newline at end of file
diff --git a/src/cli/vendor/csg-entry.js b/src/cli/vendor/csg-entry.js
new file mode 100644
index 0000000..dd4f64f
--- /dev/null
+++ b/src/cli/vendor/csg-entry.js
@@ -0,0 +1,9 @@
+import { Brush, Evaluator, SUBTRACTION } from "three-bvh-csg";
+import { BufferGeometry, Mesh } from "three";
+
+window.Brush = Brush;
+window.Evaluator = Evaluator;
+window.SUBTRACTION = SUBTRACTION;
+window.__Brush = Brush;
+window.__Evaluator = Evaluator;
+window.__SUBTRACTION = SUBTRACTION;
\ No newline at end of file
diff --git a/src/cli/vendor/hljs-entry.js b/src/cli/vendor/hljs-entry.js
new file mode 100644
index 0000000..c8aa24d
--- /dev/null
+++ b/src/cli/vendor/hljs-entry.js
@@ -0,0 +1,52 @@
+// 打包入口:highlight.js core + 精选语言 → 全局 hljs
+// 用 esbuild 打成 IIFE,避免运行时再去 CDN 拉,保证渲染可复现。
+import hljs from "highlight.js/lib/core";
+
+import javascript from "highlight.js/lib/languages/javascript";
+import typescript from "highlight.js/lib/languages/typescript";
+import python from "highlight.js/lib/languages/python";
+import bash from "highlight.js/lib/languages/bash";
+import shell from "highlight.js/lib/languages/shell";
+import json from "highlight.js/lib/languages/json";
+import xml from "highlight.js/lib/languages/xml";
+import css from "highlight.js/lib/languages/css";
+import scss from "highlight.js/lib/languages/scss";
+import sql from "highlight.js/lib/languages/sql";
+import go from "highlight.js/lib/languages/go";
+import rust from "highlight.js/lib/languages/rust";
+import java from "highlight.js/lib/languages/java";
+import cpp from "highlight.js/lib/languages/cpp";
+import c from "highlight.js/lib/languages/c";
+import yaml from "highlight.js/lib/languages/yaml";
+import markdown from "highlight.js/lib/languages/markdown";
+import diff from "highlight.js/lib/languages/diff";
+import dockerfile from "highlight.js/lib/languages/dockerfile";
+import ini from "highlight.js/lib/languages/ini";
+
+hljs.registerLanguage("javascript", javascript);
+hljs.registerLanguage("typescript", typescript);
+hljs.registerLanguage("python", python);
+hljs.registerLanguage("bash", bash);
+hljs.registerLanguage("shell", shell);
+hljs.registerLanguage("json", json);
+hljs.registerLanguage("xml", xml);
+hljs.registerLanguage("html", xml);
+hljs.registerLanguage("css", css);
+hljs.registerLanguage("scss", scss);
+hljs.registerLanguage("sql", sql);
+hljs.registerLanguage("go", go);
+hljs.registerLanguage("rust", rust);
+hljs.registerLanguage("java", java);
+hljs.registerLanguage("cpp", cpp);
+hljs.registerLanguage("c", c);
+hljs.registerLanguage("yaml", yaml);
+hljs.registerLanguage("markdown", markdown);
+hljs.registerLanguage("diff", diff);
+hljs.registerLanguage("dockerfile", dockerfile);
+hljs.registerLanguage("ini", ini);
+
+// 挂到 window 上:esbuild 打成 IIFE 后 module 命名空间不等于 hljs 本身,
+// 所以必须显式赋给全局,否则组件里 window.hljs 是 undefined。
+window.hljs = hljs;
+
+export default hljs;
\ No newline at end of file
diff --git a/src/components/cad3d/cad3d.html b/src/components/cad3d/cad3d.html
new file mode 100644
index 0000000..6397d33
--- /dev/null
+++ b/src/components/cad3d/cad3d.html
@@ -0,0 +1,365 @@
+
+
+
+
+
+
三维钩子建模
+
+
+
+
+
+
+