commit 9b63424740e4e201f71418d18f3490c91a7284a1 Author: Timeless小帅 <1018008088@qq.com> Date: Mon Oct 12 18:06:20 2026 +0800 chore: 初始化仓库,提交 vibevideo 源码 - src/: HyperFrames 封装 CLI(hv)、组件、品牌样式、音频(TTS/混音)工具 - projects/: 各项目 project.yaml 与文案 - interactive/: 井字棋演示页 - .gitignore: 忽略 node_modules、构建产物与 HyperFrames 运行期缓存 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*]*mix\.wav[^>]*>\s*/g, "\n"); + html = html.replace(/\s*data-has-audio="true"(?=\s*data-start="0")/g, ""); + + // 注意:audio 元素必须有 id,否则渲染器发现不到,成片会是静音的。 + // 这个坑很隐蔽——文件里有音轨节点、ffprobe 也显示音轨,但全是 -91dB。 + const node = ``; + html = html.replace("\n ", ` ${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 @@ + + + + + + 三维钩子建模 + + + + + + +
+ +
+
草图
+
拉伸
+
切除
+
圆角
+
+
+
(解说文字由脚本生成)
+
+
+ + +
+ + \ No newline at end of file diff --git a/src/components/codedemo/codedemo.html b/src/components/codedemo/codedemo.html new file mode 100644 index 0000000..32d0ef5 --- /dev/null +++ b/src/components/codedemo/codedemo.html @@ -0,0 +1,299 @@ + + + + + codedemo + + + + + + +
+
+
+
+ +
+ main.py +
+
+
+ + +
+ + \ No newline at end of file diff --git a/src/components/cursor/cursor.html b/src/components/cursor/cursor.html new file mode 100644 index 0000000..edb5e83 --- /dev/null +++ b/src/components/cursor/cursor.html @@ -0,0 +1,138 @@ + + + + + 鼠标指针 + + + + + +
+
+
+ + + +
+ + +
+ + \ No newline at end of file diff --git a/src/components/intro/intro.html b/src/components/intro/intro.html new file mode 100644 index 0000000..05291bc --- /dev/null +++ b/src/components/intro/intro.html @@ -0,0 +1,98 @@ + + + + + 片头 + + + + + +
+
+

+
+

+ + +
+ + \ No newline at end of file diff --git a/src/components/lowerthird/lowerthird.html b/src/components/lowerthird/lowerthird.html new file mode 100644 index 0000000..07136ab --- /dev/null +++ b/src/components/lowerthird/lowerthird.html @@ -0,0 +1,90 @@ + + + + + 下方字幕条 + + + + + +
+
+
+
+
+
+
+
+ + +
+ + \ No newline at end of file diff --git a/src/components/outro/outro.html b/src/components/outro/outro.html new file mode 100644 index 0000000..7be38e6 --- /dev/null +++ b/src/components/outro/outro.html @@ -0,0 +1,97 @@ + + + + + 片尾 + + + + + +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/src/components/stress/stress.html b/src/components/stress/stress.html new file mode 100644 index 0000000..983cd60 --- /dev/null +++ b/src/components/stress/stress.html @@ -0,0 +1,181 @@ + + + + + + 自由度压测 + + + + + +
+
模糊锐化
1. filter: blur()
+
裁切展开
2. clip-path
+
+
+ 3. blend(故意遮挡) +
+
4. 3D 变换
+
5. mask-image
+
6. canvas 2D
+
7. CSS keyframes
+
8. WebGL
+
+ + 9. SVG 描边 +
+
+ + + + \ No newline at end of file diff --git a/src/components/terminal/terminal.html b/src/components/terminal/terminal.html new file mode 100644 index 0000000..ea4202f --- /dev/null +++ b/src/components/terminal/terminal.html @@ -0,0 +1,220 @@ + + + + + 终端 + + + + + +
+
+
+
+ +
+ 终端 +
+
+
+ + +
+ + \ No newline at end of file diff --git a/src/components/textblock/textblock.html b/src/components/textblock/textblock.html new file mode 100644 index 0000000..6421483 --- /dev/null +++ b/src/components/textblock/textblock.html @@ -0,0 +1,110 @@ + + + + + 文字块 + + + + + +
+
+
+ + +
+ + \ No newline at end of file diff --git a/src/components/tictactoe/tictactoe.html b/src/components/tictactoe/tictactoe.html new file mode 100644 index 0000000..68ba59c --- /dev/null +++ b/src/components/tictactoe/tictactoe.html @@ -0,0 +1,571 @@ + + + + + + 井字棋 + + + + + + +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+ + + + diff --git a/src/templates/代码讲解.yaml b/src/templates/代码讲解.yaml new file mode 100644 index 0000000..6f2042c --- /dev/null +++ b/src/templates/代码讲解.yaml @@ -0,0 +1,57 @@ +# 模板:代码讲解 +# 用法:hv new ep002 --template 代码讲解 +# 这是一套"钩子 → 演示 → 结论 → 转化"的空骨架,填内容就能出片。 + +title: 待填写 +aspect: 横屏 +fps: 60 +lang: zh-CN + +scenes: + # 1. 片头:3.5 秒 + - component: intro + duration: 3.5 + props: + kicker: "" + title: 待填写的标题 + subtitle: 待填写的副标题 + + # 2. 代码演示:可重复多次,每次讲一个点 + - component: codedemo + duration: 8 + props: + filename: main.py + language: python + dimOthers: "true" + highlight: "1,2" + code: | + # 把这里换成要讲的代码 + def hello(): + print("你好") + + # 3. 终端:跑一下,让人看到结果 + - component: terminal + duration: 6 + props: + title: 终端 + prompt: "$ " + script: | + [ + { "text": "python main.py", "kind": "cmd" }, + { "text": "你好", "kind": "ok" } + ] + + # 4. 字幕条:强调一个结论 + - component: lowerthird + duration: 3 + props: + text: 待填写的结论 + sub: "" + + # 5. 片尾:4 秒 + - component: outro + duration: 4 + props: + cta: 觉得有用就点个关注 + handle: "@你的频道" + foot: "" diff --git a/src/templates/科普图文.yaml b/src/templates/科普图文.yaml new file mode 100644 index 0000000..a52165b --- /dev/null +++ b/src/templates/科普图文.yaml @@ -0,0 +1,46 @@ +# 模板:科普图文 +# 用法:hv new ep003 --template 科普图文 +# 全文字 + 大字报,没有代码,适合讲概念、抛观点。 + +title: 待填写 +aspect: 横屏 +fps: 60 +lang: zh-CN + +scenes: + # 1. 用一句反常识的话做冷开场 + - component: textblock + duration: 4 + props: + heading: 90% 的人把 [[顺序]]搞反了 + body: 先做加法再做乘法,比反过来快一个数量级。 + + # 2. 展开讲 + - component: textblock + duration: 5 + props: + heading: 真正的差别在**这一步** + body: 把两件事的先后顺序交换,复杂度从 O(n²) 变成 O(n)。 + + # 3. 字幕条收结论 + - component: lowerthird + duration: 3 + props: + text: 结论:顺序比努力重要 + sub: "" + + # 4. 片头 + - component: intro + duration: 3.5 + props: + kicker: 科普 + title: 待填写的标题 + subtitle: "" + + # 5. 片尾 + - component: outro + duration: 4 + props: + cta: 点个关注,少走弯路 + handle: "@你的频道" + foot: "" diff --git a/src/vendor/runtime.js b/src/vendor/runtime.js new file mode 100644 index 0000000..1f55ffc --- /dev/null +++ b/src/vendor/runtime.js @@ -0,0 +1,156 @@ +/* ========================================================================== + vibevideo 运行时:组件公用的小工具 + -------------------------------------------------------------------------- + 只依赖浏览器,不依赖任何渲染引擎的私有 API。 + 通过