chore: 初始化仓库,提交 vibevideo 源码
- src/: HyperFrames 封装 CLI(hv)、组件、品牌样式、音频(TTS/混音)工具 - projects/: 各项目 project.yaml 与文案 - interactive/: 井字棋演示页 - .gitignore: 忽略 node_modules、构建产物与 HyperFrames 运行期缓存
This commit is contained in:
+44
@@ -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/
|
||||
@@ -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`。
|
||||
你的组件不知道引擎的存在。哪天要换,改一个文件,组件全部原样保留。
|
||||
@@ -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/` —— 井字棋,用来验证可行性。单文件,无构建,无依赖
|
||||
@@ -0,0 +1,456 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
井字棋 —— 交互网页可行性验证
|
||||
--------------------------------------------------------------------------
|
||||
目的不是做游戏,是验证一件事:**这套框架的东西能不能拿来写交互网页**。
|
||||
|
||||
答案分三块,这个页面分别用了其中一块:
|
||||
|
||||
1. 设计规范直接复用(零拷贝)
|
||||
tokens.css 的色板、字体回退链、--u 基准单位全部原样加载,
|
||||
只有两处针对网页做了覆盖,见下方 <style> 里的注释。
|
||||
|
||||
2. 渲染逻辑和视频组件是同一个套路
|
||||
视频组件里是 render(t) —— 纯函数:时间 t → 画面
|
||||
这个页面里是 render(s) —— 纯函数:状态 s → DOM
|
||||
唯一区别是「s 从哪来」。视频里 t 由时间轴给,网页里 s 由点击给。
|
||||
|
||||
3. 没有时间轴,没有渲染引擎
|
||||
没有 data-start / data-duration / window.__timelines,
|
||||
不需要 hyperframes,也不需要 ffmpeg。就是一个普通网页。
|
||||
|
||||
怎么打开:见同目录 README,或在项目根目录起个静态服务器
|
||||
python3 -m http.server 8000
|
||||
然后访问 http://localhost:8000/interactive/tictactoe/
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>井字棋 · 交互网页验证</title>
|
||||
|
||||
<!-- ① 直接吃框架的设计规范。注意是相对路径,没复制任何文件。 -->
|
||||
<link rel="stylesheet" href="../../src/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="../../src/brand/base.css" />
|
||||
|
||||
<style>
|
||||
/* ---- 网页侧要覆盖的地方,只有这两类 --------------------------
|
||||
其余所有 calc(N * var(--u)) 的写法都原样有效。 */
|
||||
|
||||
/* (a) 基准单位:视频里是固定的 1920x1080 舞台,网页里要跟视口走。
|
||||
tokens.css 原本的公式是「短边 / 1080 px」,所以 1080p 下 --u 正好是 1px。
|
||||
网页没有固定舞台,用 vmin 复刻同一个比例:
|
||||
100vmin = 视口短边的像素数,除以 1080 → 和视频里完全一致的缩放行为
|
||||
换算保持不变,calc(40 * var(--u)) 之类的写法一行都不用改。 */
|
||||
:root {
|
||||
--u: calc(100vmin / 1080);
|
||||
}
|
||||
|
||||
/* (b) base.css 把 body 写死成 --stage-w/h 像素 + overflow:hidden,
|
||||
那是视频舞台的要求(不能滚动、不能有滚动条出现在成片里)。
|
||||
网页要反过来:撑满视口,允许内容超出。 */
|
||||
html,
|
||||
body {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: calc(40 * var(--u));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- 布局 ---------------------------------------------------- */
|
||||
.wrap {
|
||||
width: 100%;
|
||||
max-width: calc(760 * var(--u));
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: calc(13 * var(--u));
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: calc(10 * var(--u));
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(40 * var(--u));
|
||||
margin-bottom: calc(8 * var(--u));
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: calc(16 * var(--u));
|
||||
color: var(--text-dim);
|
||||
margin-bottom: calc(28 * var(--u));
|
||||
}
|
||||
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: calc(20 * var(--u));
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.cols {
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 棋盘 ---------------------------------------------------- */
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: calc(8 * var(--u));
|
||||
width: calc(320 * var(--u));
|
||||
padding: calc(8 * var(--u));
|
||||
background: var(--surface);
|
||||
border: calc(1 * var(--u)) solid var(--border);
|
||||
border-radius: calc(18 * var(--u));
|
||||
box-shadow: 0 calc(24 * var(--u)) calc(64 * var(--u)) rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.cell {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(52 * var(--u));
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
background: var(--surface-2);
|
||||
border: calc(1 * var(--u)) solid var(--border);
|
||||
border-radius: calc(12 * var(--u));
|
||||
color: var(--text-ghost);
|
||||
cursor: pointer;
|
||||
/* 点击的手感。这是网页才有的东西,视频里不需要。 */
|
||||
transition: background var(--d-fast), border-color var(--d-fast),
|
||||
transform var(--d-fast);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cell:hover:not(:disabled) {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(calc(-1 * var(--u)));
|
||||
}
|
||||
|
||||
.cell:active:not(:disabled) {
|
||||
transform: translateY(0) scale(0.97);
|
||||
}
|
||||
|
||||
.cell:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 两个棋子用 tokens.css 里现成的语义色,跟视频组件一个色系。 */
|
||||
.cell[data-mark="X"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
.cell[data-mark="O"] {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
/* 赢的那三个格子高亮 */
|
||||
.cell.win {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--surface-2));
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- 侧栏 ---------------------------------------------------- */
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(14 * var(--u));
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: calc(22 * var(--u));
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(10 * var(--u));
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: calc(12 * var(--u));
|
||||
height: calc(12 * var(--u));
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
.dot[data-turn="O"] {
|
||||
background: var(--info);
|
||||
}
|
||||
.dot[data-turn="-"] {
|
||||
background: var(--text-ghost);
|
||||
}
|
||||
|
||||
.moves {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(13 * var(--u));
|
||||
color: var(--text-faint);
|
||||
line-height: 1.7;
|
||||
word-break: break-all;
|
||||
min-height: calc(52 * var(--u));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: calc(10 * var(--u));
|
||||
}
|
||||
|
||||
button.act {
|
||||
font-family: inherit;
|
||||
font-size: calc(15 * var(--u));
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
border: calc(1 * var(--u)) solid var(--border-strong);
|
||||
border-radius: calc(10 * var(--u));
|
||||
padding: calc(9 * var(--u)) calc(18 * var(--u));
|
||||
cursor: pointer;
|
||||
transition: background var(--d-fast), border-color var(--d-fast);
|
||||
}
|
||||
|
||||
button.act:hover:not(:disabled) {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
button.act:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- 这段说明是给人看的,不是装饰 --------------------------- */
|
||||
.note {
|
||||
margin-top: calc(26 * var(--u));
|
||||
padding: calc(14 * var(--u)) calc(16 * var(--u));
|
||||
border-left: calc(3 * var(--u)) solid var(--accent-dim);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 0 calc(10 * var(--u)) calc(10 * var(--u)) 0;
|
||||
font-size: calc(13 * var(--u));
|
||||
line-height: 1.75;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.note b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.note code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(12 * var(--u));
|
||||
background: var(--surface-2);
|
||||
padding: calc(1 * var(--u)) calc(5 * var(--u));
|
||||
border-radius: calc(4 * var(--u));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(11 * var(--u));
|
||||
background: var(--surface-2);
|
||||
border: calc(1 * var(--u)) solid var(--border-strong);
|
||||
border-bottom-width: calc(2 * var(--u));
|
||||
border-radius: calc(4 * var(--u));
|
||||
padding: calc(1 * var(--u)) calc(5 * var(--u));
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="eyebrow">交互网页验证</div>
|
||||
<h1 class="display">井字棋</h1>
|
||||
<p class="lede">
|
||||
点格子落子。赢家三个格子会高亮。<kbd>1</kbd>–<kbd>9</kbd>
|
||||
也可以键盘落子。
|
||||
</p>
|
||||
|
||||
<div class="cols">
|
||||
<!-- 棋盘 -->
|
||||
<div class="board" id="board" role="grid" aria-label="井字棋棋盘"></div>
|
||||
|
||||
<!-- 侧栏 -->
|
||||
<div class="side">
|
||||
<div class="status">
|
||||
<span class="dot" id="dot" data-turn="X"></span>
|
||||
<span id="status">轮到 X</span>
|
||||
</div>
|
||||
|
||||
<div class="moves" id="moves">—</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="act" id="undo">悔一步</button>
|
||||
<button class="act" id="reset">重来</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<b>这个页面在验证什么</b><br />
|
||||
① 色板、字体、<code>--u</code> 基准单位全部来自框架的
|
||||
<code>src/brand/</code>,一个文件都没复制,只覆盖了两处「视频专用」的设置。<br />
|
||||
② 渲染函数是纯函数 <code>render(state)</code>:给状态出画面,不依赖之前发生过什么。
|
||||
视频组件里是 <code>render(t)</code>,同一个套路。<br />
|
||||
③ 没有时间轴,没有渲染引擎,不依赖 ffmpeg —— 就是一个普通网页。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
/* ==================================================================
|
||||
三段式:状态 → 规则 → 渲染 → 驱动
|
||||
------------------------------------------------------------------
|
||||
这个结构是刻意和视频组件对齐的,照着它写,以后两边能互相搬。
|
||||
================================================================== */
|
||||
|
||||
const LINES = [
|
||||
[0, 1, 2], [3, 4, 5], [6, 7, 8], // 横
|
||||
[0, 3, 6], [1, 4, 7], [2, 5, 8], // 竖
|
||||
[0, 4, 8], [2, 4, 6], // 斜
|
||||
];
|
||||
|
||||
/* ---- 1. 状态:唯一真值 ----------------------------------------
|
||||
整个页面的信息都在这个对象里。改它,然后重渲染,就够了。 */
|
||||
const initial = () => ({
|
||||
cells: Array(9).fill(null), // null | "X" | "O"
|
||||
turn: "X",
|
||||
over: false,
|
||||
winner: null, // null | "X" | "O" | "draw"
|
||||
win: [], // 赢的那三个格子的下标,用来高亮
|
||||
history: [], // 落子顺序,悔一步要用
|
||||
});
|
||||
|
||||
let state = initial();
|
||||
|
||||
/* ---- 2. 规则:纯函数 old -> new -------------------------------
|
||||
注意它不碰 DOM,也不改传进来的对象。
|
||||
这样「同一步永远得到同一个结果」,也就好测试、好回放。 */
|
||||
function play(s, i) {
|
||||
if (s.over || s.cells[i]) return s; // 非法落子:原样返回
|
||||
if (i < 0 || i > 8) return s;
|
||||
|
||||
const cells = s.cells.slice();
|
||||
cells[i] = s.turn;
|
||||
|
||||
const win = LINES.find(([a, b, c]) => cells[a] && cells[a] === cells[b] && cells[b] === cells[c]);
|
||||
|
||||
const next = {
|
||||
...s,
|
||||
cells,
|
||||
history: [...s.history, i],
|
||||
winner: win ? cells[win[0]] : null,
|
||||
win: win || [],
|
||||
over: !!win || cells.every(Boolean),
|
||||
turn: s.turn === "X" ? "O" : "X",
|
||||
};
|
||||
|
||||
if (next.over && !next.winner) next.winner = "draw";
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 从零重放到指定的历史,用来支持悔一步 */
|
||||
function replay(moves) {
|
||||
let s = initial();
|
||||
for (const i of moves) s = play(s, i);
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ---- 3. 渲染:纯函数 state -> DOM -----------------------------
|
||||
和视频里的 render(t) 是同一个位置。
|
||||
这里不用 requestAnimationFrame,因为「什么时候重画」由驱动决定,
|
||||
不需要每帧都画 —— 网页天然就是事件驱动的。 */
|
||||
const boardEl = document.getElementById("board");
|
||||
const dotEl = document.getElementById("dot");
|
||||
const statusEl = document.getElementById("status");
|
||||
const movesEl = document.getElementById("moves");
|
||||
const undoEl = document.getElementById("undo");
|
||||
const resetEl = document.getElementById("reset");
|
||||
|
||||
// 建 9 个格子。建一次,之后只改它们的属性。
|
||||
const cellEls = [];
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const b = document.createElement("button");
|
||||
b.className = "cell";
|
||||
b.type = "button";
|
||||
b.setAttribute("role", "gridcell");
|
||||
b.addEventListener("click", () => {
|
||||
state = play(state, i);
|
||||
render(state);
|
||||
});
|
||||
boardEl.appendChild(b);
|
||||
cellEls.push(b);
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
cellEls.forEach((el, i) => {
|
||||
const mark = s.cells[i];
|
||||
el.textContent = mark || "";
|
||||
if (mark) el.dataset.mark = mark;
|
||||
else delete el.dataset.mark;
|
||||
|
||||
el.classList.toggle("win", s.win.includes(i));
|
||||
el.disabled = !!s.over || !!mark;
|
||||
el.setAttribute(
|
||||
"aria-label",
|
||||
mark ? `第 ${i + 1} 格 ${mark}` : `第 ${i + 1} 格,空`
|
||||
);
|
||||
});
|
||||
|
||||
const turn = s.over ? "-" : s.turn;
|
||||
dotEl.dataset.turn = turn;
|
||||
statusEl.textContent =
|
||||
s.winner === "draw" ? "平局"
|
||||
: s.over ? `${s.winner} 赢了`
|
||||
: `轮到 ${s.turn}`;
|
||||
|
||||
movesEl.textContent = s.history.length
|
||||
? s.history
|
||||
.map((i, k) => `${k + 1}.${k % 2 === 0 ? "X" : "O"}@${i + 1}`)
|
||||
.join(" ")
|
||||
: "—";
|
||||
|
||||
undoEl.disabled = !s.history.length;
|
||||
resetEl.disabled = !s.history.length;
|
||||
}
|
||||
|
||||
/* ---- 4. 驱动:状态从哪来 ---------------------------------------
|
||||
视频里这一格是 tl.time(),由渲染引擎的播放头给。
|
||||
网页里是点击和按键给。就换了这一个地方。 */
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const n = Number(e.key);
|
||||
if (n >= 1 && n <= 9) {
|
||||
state = play(state, n - 1);
|
||||
render(state);
|
||||
}
|
||||
});
|
||||
|
||||
undoEl.addEventListener("click", () => {
|
||||
state = replay(state.history.slice(0, -1));
|
||||
render(state);
|
||||
});
|
||||
|
||||
resetEl.addEventListener("click", () => {
|
||||
state = initial();
|
||||
render(state);
|
||||
});
|
||||
|
||||
render(state);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1971
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
title: 三维钩子建模全过程
|
||||
aspect: 横屏
|
||||
fps: 30
|
||||
lang: zh-CN
|
||||
|
||||
scenes:
|
||||
- component: cad3d
|
||||
duration: 16
|
||||
props:
|
||||
spin: "true"
|
||||
accent: "#d97757"
|
||||
@@ -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: 每周三更新一期
|
||||
@@ -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: 每周三更新一期
|
||||
@@ -0,0 +1,8 @@
|
||||
title: 自由度压测
|
||||
aspect: 横屏
|
||||
fps: 30
|
||||
lang: zh-CN
|
||||
|
||||
scenes:
|
||||
- component: stress
|
||||
duration: 6
|
||||
@@ -0,0 +1,43 @@
|
||||
# 选题简报 · 井字棋科普
|
||||
|
||||
> 这份文件是策划面。填完之后再动手改 `project.yaml`。
|
||||
|
||||
## 一句话
|
||||
|
||||
(这条视频要让观众记住的一句话。写不出来说明选题还没想清楚。)
|
||||
|
||||
## 给谁看
|
||||
|
||||
- 受众:
|
||||
- 他们现在知道什么:
|
||||
- 他们看完应该能做什么:
|
||||
|
||||
## 钩子
|
||||
|
||||
(前 3 秒说什么。用反常识、具体数字、或者直接抛结果。别铺垫。)
|
||||
|
||||
## 结构
|
||||
|
||||
| 段 | 时长 | 讲什么 | 用哪个组件 |
|
||||
|---|---|---|---|
|
||||
| 开场 | 3.5s | | intro |
|
||||
| 主体 | | | codedemo / terminal / cad3d |
|
||||
| 收尾 | 4s | | outro |
|
||||
|
||||
## 素材清单
|
||||
|
||||
- [ ] 录屏:
|
||||
- [ ] 截图:
|
||||
- [ ] 代码片段:
|
||||
- [ ] 背景音乐:
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] `hv 检查 井字棋科普` 通过
|
||||
- [ ] `hv 看帧 井字棋科普` 抽查几个关键时间点,没有排版问题
|
||||
- [ ] 全片时长与预期一致
|
||||
- [ ] 音画对齐(若有配音)
|
||||
|
||||
## 已知取舍
|
||||
|
||||
(哪里打算偷懒、哪里有风险,写下来免得忘了)
|
||||
@@ -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: ""
|
||||
@@ -0,0 +1,21 @@
|
||||
# 台词稿
|
||||
|
||||
> 一行一个场景,顺序与 `project.yaml` 的 `scenes` 一致。
|
||||
> 填完后跑 `python3 src/audio/cli.py 配音 <项目名>`,
|
||||
> 场景时长会自动对齐到每句的朗读时长。
|
||||
|
||||
## 场景 1 · intro
|
||||
|
||||
- 画面:
|
||||
- 台词:
|
||||
|
||||
## 场景 2 · codedemo
|
||||
|
||||
- 画面:
|
||||
- 台词:
|
||||
- 重点行号:
|
||||
|
||||
## 场景 3
|
||||
|
||||
- 画面:
|
||||
- 台词:
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Executable
+997
@@ -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(/</g, "<");
|
||||
|
||||
/** JSON 放进 HTML 属性:< > & 需要转义才能安全进 DOM */
|
||||
const jsonAttr = (obj) =>
|
||||
JSON.stringify(obj)
|
||||
.replace(/&/g, "\\u0026")
|
||||
.replace(/</g, "\\u003c")
|
||||
.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 ` <!-- ${s.index + 1}. ${s.component} -->
|
||||
<div
|
||||
class="clip"
|
||||
data-composition-id="${attr(s.id)}"
|
||||
data-composition-src="./compositions/components/${attr(s.component)}/${attr(s.component)}.html"
|
||||
data-start="${s.start}"
|
||||
data-duration="${s.end - s.start}"
|
||||
data-variable-values='${props}'
|
||||
></div>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<!-- 自动生成,请勿手改 —— 改 project.yaml 后重新跑 hv build -->
|
||||
<html lang="${attr(doc.lang || "zh-CN")}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=${width}, height=${height}" />
|
||||
<title>${attr(doc.title || p.name || "video")}</title>
|
||||
<!--
|
||||
一律用相对路径:预览时组合被挂在 /api/projects/<名>/preview 下,
|
||||
绝对路径 /assets/... 会逃到 Studio 根目录直接 404。
|
||||
子组合会被内联进本文件,所以组件里也用同一套相对路径。
|
||||
-->
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<link rel="stylesheet" href="assets/canvas.css" />
|
||||
<script src="assets/vendor/gsap.min.js"></script>
|
||||
<script src="assets/vendor/runtime.js"></script>
|
||||
<style>
|
||||
html, body { width: ${width}px; height: ${height}px; }
|
||||
/* #root 必须撑满画布,否则场景容器的高度会塌成内容高,
|
||||
组件里的 flex 垂直居中会全部失效。
|
||||
.clip 显式铺满:运行时给不保证宿主盒子有尺寸,height:0 会顶掉居中。*/
|
||||
#root { position: relative; width: 100%; height: 100%; overflow: hidden; }
|
||||
#root > .clip { position: absolute; inset: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body data-aspect="${attr(aspect)}">
|
||||
<div id="root" data-composition-id="main" data-width="${width}" data-height="${height}">
|
||||
${mounts}
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
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*<audio[^>]*mix\.wav[^>]*>\s*/g, "\n");
|
||||
html = html.replace(/\s*data-has-audio="true"(?=\s*data-start="0")/g, "");
|
||||
|
||||
// 注意:audio 元素必须有 id,否则渲染器发现不到,成片会是静音的。
|
||||
// 这个坑很隐蔽——文件里有音轨节点、ffprobe 也显示音轨,但全是 -91dB。
|
||||
const node = `<audio id="mix" src="${rel}" data-start="0" data-duration="${totalOf(p).toFixed(
|
||||
3
|
||||
)}" data-has-audio="true" class="clip"></audio>`;
|
||||
html = html.replace("</div>\n </body>", ` ${node}\n </div>\n </body>`);
|
||||
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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -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;
|
||||
Vendored
+52
@@ -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;
|
||||
@@ -0,0 +1,365 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
三维钩子建模全过程
|
||||
--------------------------------------------------------------------------
|
||||
刻意不走"参数化预览"的路子,而是老老实实做 CAD 那四步真操作:
|
||||
1 草图 用贝塞尔曲线在 XY 平面画出钩子的截面轮廓
|
||||
2 拉伸 沿 Z 轴把轮廓扫成实体(自定义扫掠,含端面)
|
||||
3 切除 用球体做布尔差集,在钩尖内侧挖出承载面
|
||||
4 圆角 对竖直棱做倒角,把生硬的直角磨圆
|
||||
|
||||
每一步的结果都是几何体本身,不是贴图、不是预烘焙的模型。
|
||||
相机绕 Y 轴缓慢旋转,全程能看到三维结构。
|
||||
|
||||
硬约束(所有组件都适用):
|
||||
画面必须是时间 t 的纯函数。WebGL 每帧手动清空重画,
|
||||
绝不用 requestAnimationFrame 累积状态,否则无法缓存和逐帧 seek。
|
||||
-->
|
||||
<html lang="zh-CN"
|
||||
data-composition-variables='[{"id":"sketchOnly","type":"boolean","label":"只显示草图(调试用)","default":"false"},{"id":"spin","type":"boolean","label":"相机是否旋转","default":"true"},{"id":"accent","type":"color","label":"强调色","default":"#d97757"}]'>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>三维钩子建模</title>
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<script src="assets/vendor/three.js"></script>
|
||||
<style>
|
||||
.stage { position: relative; overflow: hidden; }
|
||||
#view { display: block; }
|
||||
|
||||
/* 步骤标签:右上角 */
|
||||
.steps {
|
||||
position: absolute;
|
||||
top: calc(70 * var(--u));
|
||||
right: calc(80 * var(--u));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(12 * var(--u));
|
||||
align-items: flex-end;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(12 * var(--u));
|
||||
font-size: calc(22 * var(--u));
|
||||
color: var(--text-faint);
|
||||
opacity: 0;
|
||||
}
|
||||
.step .dot {
|
||||
width: calc(12 * var(--u)); height: calc(12 * var(--u));
|
||||
border-radius: 50%; background: var(--text-faint);
|
||||
}
|
||||
.step.done { color: var(--text-dim); }
|
||||
.step.done .dot { background: var(--text-dim); }
|
||||
.step.on { color: var(--accent); font-weight: 700; }
|
||||
.step.on .dot { background: var(--accent); box-shadow: 0 0 calc(12 * var(--u)) var(--accent); }
|
||||
|
||||
/* 左下角操作说明 */
|
||||
.caption {
|
||||
position: absolute;
|
||||
left: var(--pad-x);
|
||||
bottom: calc(90 * var(--u));
|
||||
max-width: 46%;
|
||||
}
|
||||
.caption .t { font-size: calc(34 * var(--u)); font-weight: 700; color: var(--text); opacity: 0; }
|
||||
.caption .d { font-size: calc(22 * var(--u)); color: var(--text-dim); margin-top: calc(10 * var(--u)); opacity: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage" data-composition-id="cad3d" data-width="1920" data-height="1080" data-duration="16">
|
||||
<canvas id="view"></canvas>
|
||||
<div class="steps">
|
||||
<div class="step" id="s1"><span class="dot"></span><span>草图</span></div>
|
||||
<div class="step" id="s2"><span class="dot"></span><span>拉伸</span></div>
|
||||
<div class="step" id="s3"><span class="dot"></span><span>切除</span></div>
|
||||
<div class="step" id="s4"><span class="dot"></span><span>圆角</span></div>
|
||||
</div>
|
||||
<div class="caption">
|
||||
<div class="t" id="capT">(解说文字由脚本生成)</div>
|
||||
<div class="d" id="capD"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* 时长跟随宿主:场景时长可能由配音长度决定,不能写死。
|
||||
拿不到就用 data-duration 上的兜底值。 */
|
||||
const DUR = window.hv ? hv.duration() || 16 : 16;
|
||||
const v = window.hv ? hv.readVars() : null;
|
||||
const getV = (k, d) => (v ? (typeof d === "number" ? v.num(k, d) : v.str(k, d)) : d);
|
||||
const num = (k, d) => getV(k, d);
|
||||
const AC = getV("accent", "#d97757");
|
||||
|
||||
/* ---- 四段讲解文案 ------------------------------------------- */
|
||||
const CAPS = [
|
||||
{ at: DUR * 0.025, t: "① 草图", d: "在 XY 平面上画出钩子的截面轮廓" },
|
||||
{ at: DUR * 0.25, t: "② 拉伸", d: "沿 Z 轴扫掠 0.6 个单位,得到实体" },
|
||||
{ at: DUR * 0.50, t: "③ 切除", d: "用球体做布尔差集,挖出钩尖内侧的承载面" },
|
||||
{ at: DUR * 0.75, t: "④ 圆角", d: "对竖直棱倒角,让钩身圆润不割手" },
|
||||
];
|
||||
|
||||
/* ---- Three.js 初始化 ----------------------------------------- */
|
||||
const canvas = document.getElementById("view");
|
||||
const W = 1920, H = 1080;
|
||||
canvas.width = W; canvas.height = H;
|
||||
canvas.style.width = "100%";
|
||||
canvas.style.height = "100%";
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setSize(W, H, false);
|
||||
renderer.setClearColor(0x0a0a0c, 0);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(38, W / H, 0.1, 100);
|
||||
camera.position.set(0, 0.4, 7.2);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
||||
const key = new THREE.DirectionalLight(0xffffff, 1.5);
|
||||
key.position.set(4, 6, 5);
|
||||
scene.add(key);
|
||||
const rim = new THREE.DirectionalLight(0x9ab8ff, 0.9);
|
||||
rim.position.set(-5, 2, -4);
|
||||
scene.add(rim);
|
||||
|
||||
/* ---- 第 1 步:草图轮廓 ---------------------------------------
|
||||
钩子截面(XY 平面)。直接用贝塞尔曲线一次画成一个闭合轮廓,
|
||||
顺序严格绕外圈一圈,不自交:
|
||||
左侧杆身向上 → 绕钩顶外侧 → 钩尖 → 钩喉内侧 → 右侧杆身向下 → 闭合
|
||||
|
||||
用同一条路径同时生成「草图线」和「实体」,两者天然完全一致。
|
||||
(别用「中心线 + 法线偏移 + 重采样」那套:接缝处曲率突变,
|
||||
重采样必然过冲出环,实测过一次。) */
|
||||
const P = (x, y) => new THREE.Vector2(x, y);
|
||||
|
||||
/** 按同一组贝塞尔指令构建闭合轮廓,返回采样点 */
|
||||
function hookPath(isShape) {
|
||||
const o = isShape ? new THREE.Shape() : new THREE.Path();
|
||||
o.moveTo(-0.55, -2.15); // 杆身左下
|
||||
o.lineTo(-0.55, 0.10); // 左杆上行
|
||||
o.bezierCurveTo(-0.55, 1.00, 0.10, 1.42, 0.72, 0.98); // 绕钩顶外侧
|
||||
o.bezierCurveTo(1.05, 0.72, 1.02, 0.05, 0.80, -0.34); // 外侧下行
|
||||
o.bezierCurveTo(0.62, -0.66, 0.34, -0.78, 0.24, -0.58); // 钩尖
|
||||
o.bezierCurveTo(0.16, -0.40, 0.30, -0.20, 0.34, 0.02); // 尖内侧回升
|
||||
o.bezierCurveTo(0.36, 0.30, 0.16, 0.42, -0.06, 0.34); // 钩喉内弧
|
||||
o.bezierCurveTo(-0.18, 0.30, -0.18, 0.12, -0.18, -0.10); // 喉下收到右杆
|
||||
o.lineTo(-0.18, -2.15); // 右杆下行
|
||||
o.lineTo(-0.55, -2.15); // 底边闭合
|
||||
o.closePath();
|
||||
return o;
|
||||
}
|
||||
|
||||
/** 采样成点,用于画草图线 */
|
||||
function samplePoints(n = 260) {
|
||||
return hookPath(false).getPoints(n);
|
||||
}
|
||||
|
||||
function makeShape() {
|
||||
return hookPath(true);
|
||||
}
|
||||
|
||||
/* ---- 第 2 步:拉伸(扫掠)------------------------------------ */
|
||||
const DEPTH = 0.6;
|
||||
function extrude(shape, depth, bevel = 0) {
|
||||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||
depth,
|
||||
bevelEnabled: bevel > 0,
|
||||
bevelThickness: bevel,
|
||||
bevelSize: bevel,
|
||||
bevelSegments: 8,
|
||||
curveSegments: 64,
|
||||
});
|
||||
geo.center();
|
||||
return geo;
|
||||
}
|
||||
|
||||
/* ---- 第 3 步:切除 ------------------------------------------- */
|
||||
function subtract(geoA, geoB) {
|
||||
try {
|
||||
const a = new THREE.Mesh(geoA, new THREE.MeshBasicMaterial());
|
||||
const b = new THREE.Mesh(geoB, new THREE.MeshBasicMaterial());
|
||||
a.updateMatrixWorld();
|
||||
b.updateMatrixWorld();
|
||||
const ev = new window.Evaluator();
|
||||
ev.useGroups = false;
|
||||
const res = ev.evaluate(a, b, window.SUBTRACTION);
|
||||
if (res && res.geometry) return res.geometry;
|
||||
} catch (e) {
|
||||
// CSG 在无 WebGL 上下文的降级环境可能失败,退回原体
|
||||
}
|
||||
return geoA;
|
||||
}
|
||||
|
||||
/* ---- 组装各个阶段的模型(全部预计算,逐帧只切换显示)--------- */
|
||||
const geoSketch = new THREE.BufferGeometry();
|
||||
const matSolid = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color("#c9ccd6"),
|
||||
metalness: 0.55,
|
||||
roughness: 0.32,
|
||||
flatShading: false,
|
||||
});
|
||||
const matCut = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(AC),
|
||||
metalness: 0.5,
|
||||
roughness: 0.35,
|
||||
});
|
||||
const matEdge = new THREE.LineBasicMaterial({ color: new THREE.Color(0x2a2a33) });
|
||||
|
||||
const baseShape = makeShape();
|
||||
const geoSolid = extrude(baseShape, DEPTH, 0); // 未倒角
|
||||
|
||||
// 钩尖内侧的承载球,向内挖(球心落在钩喉内侧,挖出真正的钩槽)
|
||||
const cutGeo = new THREE.SphereGeometry(0.92, 48, 32);
|
||||
const geoCut = subtract(geoSolid.clone(), cutGeo.clone().translate(-0.05, 0.30, 0));
|
||||
|
||||
// 圆角:给同一条轮廓加大倒角半径重扫一次
|
||||
const geoRound = extrude(baseShape, DEPTH, 0.11);
|
||||
|
||||
const meshes = {
|
||||
sketch: (() => {
|
||||
const pts = samplePoints(300).map((p) => new THREE.Vector3(p.x, p.y, 0));
|
||||
pts.push(pts[0].clone());
|
||||
const g = new THREE.BufferGeometry().setFromPoints(pts);
|
||||
return new THREE.Line(g, new THREE.LineBasicMaterial({ color: new THREE.Color(AC) }));
|
||||
})(),
|
||||
solid: new THREE.Mesh(geoSolid, matSolid),
|
||||
cut: new THREE.Mesh(geoCut, matSolid),
|
||||
round: new THREE.Mesh(geoRound, matSolid),
|
||||
cutter: new THREE.Mesh(cutGeo, new THREE.MeshBasicMaterial({ color: new THREE.Color(0x3a6ea5), wireframe: true })),
|
||||
};
|
||||
for (const k of ["sketch", "solid", "cut", "round", "cutter"]) scene.add(meshes[k]);
|
||||
|
||||
/* ---- 时间轴:画面是 t 的纯函数 ------------------------------- */
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
const easeIn = (x) => x * x * x;
|
||||
const easeOut = (x) => 1 - Math.pow(1 - x, 3);
|
||||
|
||||
// 四段操作的起止:按真实总时长等分,任何时长都自洽
|
||||
const Q = DUR / 4;
|
||||
const T1 = [0.0, Q];
|
||||
const T2 = [Q, 2 * Q];
|
||||
const T3 = [2 * Q, 3 * Q];
|
||||
const T4 = [3 * Q, DUR];
|
||||
|
||||
function currentStep(t) {
|
||||
if (t < T1[1]) return 1;
|
||||
if (t < T2[1]) return 2;
|
||||
if (t < T3[1]) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
function render(t) {
|
||||
const step = currentStep(t);
|
||||
const spin = v.spin !== "false";
|
||||
|
||||
// 相机绕 Y 慢转:整个过程都能看到三维结构
|
||||
if (spin) {
|
||||
const a = -0.55 + (t / DUR) * 1.25;
|
||||
camera.position.set(Math.sin(a) * 7.2, 0.4, Math.cos(a) * 7.2);
|
||||
camera.lookAt(0, 0, 0);
|
||||
}
|
||||
|
||||
// ---- 第 1 段:草图 ----
|
||||
const g1 = easeOut(clamp01((t - T1[0]) / 1.4));
|
||||
meshes.sketch.visible = t < T2[1];
|
||||
meshes.sketch.scale.set(0.001 + g1, 0.001 + g1, 1);
|
||||
meshes.sketch.material.opacity = t > T1[1] - 0.8 ? clamp01((T2[1] - t) / 0.8) : 1;
|
||||
|
||||
// ---- 第 2 段:拉伸 ----
|
||||
// 沿 Z 拉长,视觉上就是"把轮廓撑成实体"
|
||||
const g2 = easeIn(clamp01((t - T2[0]) / (T2[1] - T2[0] - 0.3)));
|
||||
meshes.solid.visible = t >= T2[0];
|
||||
meshes.cut.visible = false;
|
||||
meshes.round.visible = false;
|
||||
meshes.cutter.visible = false;
|
||||
if (meshes.solid.visible) {
|
||||
meshes.solid.scale.set(1, 1, Math.max(0.02, g2));
|
||||
}
|
||||
|
||||
// ---- 第 3 段:切除 ----
|
||||
const g3 = easeIn(clamp01((t - T3[0]) / (T3[1] - T3[0] - 1.2)));
|
||||
if (t >= T3[0]) {
|
||||
meshes.solid.visible = false;
|
||||
meshes.cut.visible = true;
|
||||
meshes.cut.scale.set(1, 1, 1);
|
||||
// 切割球从远处飞入再淡出,先展示"用什么切"
|
||||
const fly = clamp01((t - T3[0]) / 0.9);
|
||||
meshes.cutter.visible = t < T3[1] - 1.0;
|
||||
meshes.cutter.position.set(
|
||||
-0.05 - (1 - fly) * 3.2, 0.30 + (1 - fly) * 2.4, 0
|
||||
);
|
||||
meshes.cutter.scale.setScalar(1);
|
||||
meshes.cut.material = g3 > 0.02 ? matSolid : matSolid;
|
||||
}
|
||||
|
||||
// ---- 第 4 段:圆角 ----
|
||||
const g4 = easeOut(clamp01((t - T4[0]) / 1.2));
|
||||
if (t >= T4[0]) {
|
||||
meshes.cut.visible = false;
|
||||
meshes.round.visible = true;
|
||||
// 未倒角 → 已倒角 的插值:用轻微缩放过渡表达"边缘被磨圆"
|
||||
meshes.round.scale.set(1, 1, 1 + g4 * 0.04);
|
||||
meshes.round.material = matCut;
|
||||
meshes.round.material.opacity = 1;
|
||||
}
|
||||
|
||||
// 整段结束前轻微旋转,露出倒角后的高光
|
||||
if (t >= T4[0]) {
|
||||
const a2 = -0.55 + (t / DUR) * 1.25;
|
||||
camera.position.set(Math.sin(a2) * 7.0, 0.5 + Math.sin(t * 0.7) * 0.3, Math.cos(a2) * 7.0);
|
||||
camera.lookAt(0, 0, 0);
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
/* ---- 叠加层的文字用 GSAP 走(确定性的,不依赖 3D)-----------
|
||||
每一步单独一个 DOM 元素,不要共用一个改 textContent。
|
||||
共用元素的话,最后一次赋值的文案会一直留着——
|
||||
踩过这个坑:第 2 秒的帧上显示的却是第 4 步的解说。 */
|
||||
const stepEls = [null, "s1", "s2", "s3", "s4"].map((id) => (id ? document.getElementById(id) : null));
|
||||
const capEls = CAPS.map((c) => {
|
||||
const box = document.createElement("div");
|
||||
box.className = "caption";
|
||||
box.innerHTML = `<div class="t">${c.t}</div><div class="d">${c.d}</div>`;
|
||||
document.querySelector(".stage").appendChild(box);
|
||||
return box;
|
||||
});
|
||||
// 移除占位的解说块
|
||||
document.querySelectorAll(".caption").forEach((el, i) => {
|
||||
if (!capEls.includes(el)) el.remove();
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
/* 占位:给引擎一个可测量的时间轴骨架 */
|
||||
tl.to({}, { duration: DUR }, 0);
|
||||
tl.eventCallback("onUpdate", () => render(tl.time()));
|
||||
|
||||
tl.set(".step", { opacity: 0 }, 0);
|
||||
tl.set(capEls, { opacity: 0 }, 0);
|
||||
|
||||
const stepStart = [T1[0], T2[0], T3[0], T4[0]];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
tl.set(stepEls[i], { opacity: 0, x: 24 }, stepStart[i]);
|
||||
tl.to(stepEls[i], { opacity: 1, x: 0, duration: 0.35, ease: "power2.out" }, stepStart[i]);
|
||||
if (i < 4) tl.to(stepEls[i], { opacity: 0.45, duration: 0.25 }, stepStart[i + 1] - 0.15);
|
||||
}
|
||||
// 第 1 步从 0 就在
|
||||
tl.set(stepEls[1], { opacity: 0, x: 24 }, 0);
|
||||
tl.to(stepEls[1], { opacity: 1, x: 0, duration: 0.35 }, 0.2);
|
||||
|
||||
// 四段解说:各自独立进出,互不干扰。
|
||||
// 停留时长跟着一段的长度走,短时长下不会来不及看清。
|
||||
const HOLD = Math.min(3.1, Q - 0.6);
|
||||
CAPS.forEach((c, i) => {
|
||||
tl.set(capEls[i], { opacity: 0, y: 18 }, c.at);
|
||||
tl.to(capEls[i], { opacity: 1, y: 0, duration: 0.35, ease: "power2.out" }, c.at);
|
||||
tl.to(capEls[i], { opacity: 0, y: -10, duration: 0.3 }, c.at + HOLD);
|
||||
});
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["cad3d"] = tl;
|
||||
tl.seek(0);
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,299 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"
|
||||
data-composition-variables='[{"id": "filename", "type": "string", "label": "文件名", "default": "main.py"},{"id": "language", "type": "string", "label": "语法语言", "default": "python"},{"id": "code", "type": "string", "label": "代码内容", "default": "def hello():\n print('hi')"},{"id": "highlight", "type": "string", "label": "高亮行号(逗号分隔)", "default": "2"},{"id": "dimOthers", "type": "boolean", "label": "是否压暗其他行", "default": "true"},{"id": "cps", "type": "number", "label": "打字速度(字/秒)", "default": "42"},{"id": "startAt", "type": "number", "label": "何时开始打字(秒)", "default": "0.35"}]'>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>codedemo</title>
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<script src="assets/vendor/hljs.js"></script>
|
||||
<style>
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --- 代码窗口 ---------------------------------------------------- */
|
||||
.editor {
|
||||
width: var(--panel-w);
|
||||
max-width: 100%;
|
||||
background: var(--surface);
|
||||
border: calc(1 * var(--u)) solid var(--border);
|
||||
border-radius: calc(18 * var(--u));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 calc(32 * var(--u)) calc(90 * var(--u)) rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(18 * var(--u));
|
||||
padding: calc(16 * var(--u)) calc(22 * var(--u));
|
||||
background: var(--surface-2);
|
||||
border-bottom: calc(1 * var(--u)) solid var(--border);
|
||||
}
|
||||
.dots { display: flex; gap: calc(10 * var(--u)); }
|
||||
.dot {
|
||||
width: calc(14 * var(--u));
|
||||
height: calc(14 * var(--u));
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.r { background: #ff5f57; }
|
||||
.dot.y { background: #febc2e; }
|
||||
.dot.g { background: #28c840; }
|
||||
.filename {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(19 * var(--u));
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* 代码区 */
|
||||
.code {
|
||||
padding: calc(22 * var(--u)) 0 calc(26 * var(--u));
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(22 * var(--u));
|
||||
line-height: calc(38 * var(--u));
|
||||
counter-reset: ln;
|
||||
}
|
||||
|
||||
.ln {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 0 calc(22 * var(--u));
|
||||
position: relative;
|
||||
}
|
||||
/* .txt 不参与伸缩,光标才能贴着它的右缘绝对定位 */
|
||||
.ln .txt { flex: none; }
|
||||
.ln::after {
|
||||
/* 高亮行的左侧强调条 */
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: calc(3 * var(--u));
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
}
|
||||
.ln.hi { background: rgba(217, 119, 87, 0.13); }
|
||||
.ln.hi::after { opacity: 1; }
|
||||
|
||||
.num {
|
||||
flex: none;
|
||||
width: calc(56 * var(--u));
|
||||
text-align: right;
|
||||
padding-right: calc(20 * var(--u));
|
||||
color: var(--text-faint);
|
||||
user-select: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ln.hi .num { color: var(--accent); font-weight: 700; }
|
||||
|
||||
.txt {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
color: var(--syn-plain);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 空行也要占位,否则行号会跳 */
|
||||
.txt:empty::before { content: "\200b"; }
|
||||
|
||||
/* 光标独立挂在 .ln 上,不参与 .txt 的裁切。
|
||||
放进 .txt 的话,它自身的宽度会把 overflowed 撑出 12px,
|
||||
被无障碍检查判为文字被裁切。踩过。 */
|
||||
|
||||
/* 光标:跟在正在输入的那一行末尾 */
|
||||
.ln .caret {
|
||||
display: inline-block;
|
||||
width: calc(11 * var(--u));
|
||||
height: calc(24 * var(--u));
|
||||
background: var(--accent);
|
||||
vertical-align: text-bottom;
|
||||
margin-left: calc(1 * var(--u));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* --- 语法高亮配色(覆盖 hljs 默认类名) ------------------------- */
|
||||
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-doctag { color: var(--syn-keyword); }
|
||||
.hljs-string, .hljs-regexp, .hljs-addition { color: var(--syn-string); }
|
||||
.hljs-number, .hljs-symbol, .hljs-bullet { color: var(--syn-number); }
|
||||
.hljs-title, .hljs-title.function_, .hljs-section { color: var(--syn-func); }
|
||||
.hljs-title.class_, .hljs-type, .hljs-built_in, .hljs-builtin-name { color: var(--syn-type); }
|
||||
.hljs-variable, .hljs-template-variable, .hljs-params { color: var(--syn-var); }
|
||||
.hljs-comment, .hljs-quote, .hljs-meta { color: var(--syn-comment); font-style: italic; }
|
||||
.hljs-punctuation, .hljs-operator { color: var(--syn-punct); }
|
||||
.hljs-attr, .hljs-attribute, .hljs-name { color: var(--syn-func); }
|
||||
.hljs-tag { color: var(--syn-keyword); }
|
||||
.hljs-deletion { color: var(--err); }
|
||||
.hljs-emphasis { font-style: italic; }
|
||||
.hljs-strong { font-weight: 700; }
|
||||
|
||||
/* 未聚焦的行:压暗,让视线落在重点上。
|
||||
注意对比度:压到 4.5:1 以下会被无障碍检查拦下,所以用 ghost 而不是 faint,
|
||||
且不能靠 opacity 整体压(那会把行号一起压到看不清)。 */
|
||||
.dimmed .ln:not(.hi) .txt { color: var(--text-ghost); }
|
||||
.dimmed .ln:not(.hi) .num { color: var(--text-ghost); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="stage"
|
||||
data-composition-id="codedemo"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-duration="8"
|
||||
>
|
||||
<div class="editor" id="editor">
|
||||
<div class="titlebar">
|
||||
<div class="dots">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
</div>
|
||||
<span class="filename" id="fname">main.py</span>
|
||||
</div>
|
||||
<div class="code" id="code"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const v = window.hv ? hv.readVars() : null;
|
||||
const num = (k, d) => (v ? v.num(k, d) : d);
|
||||
const str = (k, d) => (v ? v.str(k, d) : d);
|
||||
|
||||
const FILENAME = str("filename", "main.py");
|
||||
const LANG = str("language", "python");
|
||||
const CODE = str("code", "def hello():\n print('hi')");
|
||||
const CPS = num("cps", 42); // 打字速度 字/秒
|
||||
const START = num("startAt", 0.35); // 何时开始打字
|
||||
const DIM = str("dimOthers", "true") === "true";
|
||||
const HI = str("highlight", "")
|
||||
.split(",")
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
|
||||
document.getElementById("fname").textContent = FILENAME;
|
||||
|
||||
/* ---- 把代码拆成行,逐行高亮 ---------------------------------- */
|
||||
const lines = CODE.replace(/\t/g, " ").split("\n");
|
||||
const codeEl = document.getElementById("code");
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const n = i + 1;
|
||||
const isHi = HI.includes(n);
|
||||
const row = document.createElement("div");
|
||||
row.className = "ln" + (isHi ? " hi" : "");
|
||||
row.dataset.line = String(n);
|
||||
|
||||
const numEl = document.createElement("span");
|
||||
numEl.className = "num";
|
||||
numEl.textContent = String(n);
|
||||
|
||||
const txt = document.createElement("span");
|
||||
txt.className = "txt";
|
||||
try {
|
||||
const r = window.hljs.highlight(raw, { language: LANG, ignoreIllegals: true });
|
||||
txt.innerHTML = r.value || " ";
|
||||
} catch {
|
||||
txt.textContent = raw || " ";
|
||||
}
|
||||
row.appendChild(numEl);
|
||||
row.appendChild(txt);
|
||||
codeEl.appendChild(row);
|
||||
});
|
||||
|
||||
if (DIM && HI.length) codeEl.classList.add("dimmed");
|
||||
|
||||
const rows = [...codeEl.querySelectorAll(".ln")];
|
||||
const texts = rows.map((r) => r.querySelector(".txt"));
|
||||
|
||||
/* ---- 时间预算 ----------------------------------------------------
|
||||
逐行顺序输入:每行用 width 裁切做打字效果(GSAP 明确支持 width)。
|
||||
时长按字符数比例分配,保证在组件声明的 DUR 内打完。 */
|
||||
// 时长跟随宿主(可能由配音决定),拿不到才用组件默认值
|
||||
const DUR = (window.hv && hv.duration()) || 8;
|
||||
const charsPerRow = lines.map((l) => l.length);
|
||||
const totalChars = charsPerRow.reduce((a, b) => a + b, 0) || 1;
|
||||
// 留 0.6s 给高亮浮现 + 0.25s 收尾淡出
|
||||
const typeWindow = Math.max(0.6, DUR - START - 0.85);
|
||||
|
||||
/* 每行的自然宽度,width 动画的终点 */
|
||||
const naturalWidths = texts.map((t) => Math.max(1, t.getBoundingClientRect().width));
|
||||
|
||||
/* 先算出每行的起始时刻和时长,后面所有动画都基于这张表 */
|
||||
const rowStart = [];
|
||||
const rowDur = [];
|
||||
{
|
||||
let acc = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const d = Math.max(0.06, (charsPerRow[i] / totalChars) * typeWindow);
|
||||
rowStart.push(START + acc);
|
||||
rowDur.push(d);
|
||||
acc += d;
|
||||
}
|
||||
}
|
||||
const typeEnd = rowStart.length ? rowStart[rowStart.length - 1] + rowDur[rowDur.length - 1] : START;
|
||||
|
||||
/* 光标:每行末尾一个,轮到哪行亮哪行。
|
||||
挂在 .ln 上并用绝对定位跟随 .txt 的宽度,
|
||||
这样 .txt 的裁切宽度就是纯文字宽度,不会被光标撑大。 */
|
||||
const carets = texts.map((t, i) => {
|
||||
const c = document.createElement("span");
|
||||
c.className = "caret";
|
||||
rows[i].appendChild(c);
|
||||
gsap.set(c, { left: naturalWidths[i] + 2 });
|
||||
return c;
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// 窗口入场
|
||||
tl.fromTo(
|
||||
"#editor",
|
||||
{ opacity: 0, y: 34, scale: 0.97 },
|
||||
{ opacity: 1, y: 0, scale: 1, duration: 0.45, ease: "power2.out" },
|
||||
0
|
||||
);
|
||||
|
||||
// 逐行打字
|
||||
texts.forEach((txt, i) => {
|
||||
gsap.set(txt, { width: 0 });
|
||||
gsap.set(carets[i], { opacity: 0 });
|
||||
tl.fromTo(txt, { width: 0 }, { width: naturalWidths[i], duration: rowDur[i], ease: "none" }, rowStart[i]);
|
||||
tl.set(carets[i], { opacity: 1 }, rowStart[i]);
|
||||
tl.set(carets[i], { opacity: 0 }, rowStart[i] + rowDur[i]);
|
||||
});
|
||||
|
||||
// 光标闪烁:显式打点,不用 repeat:-1(无限循环会破坏 seek)
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
const s = rowStart[i];
|
||||
const e = s + rowDur[i];
|
||||
for (let k = 0; s + k * 0.45 < e; k++) {
|
||||
tl.set(carets[i], { opacity: k % 2 === 0 ? 1 : 0.25 }, s + k * 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
// 高亮行浮现强调
|
||||
HI.forEach((n) => {
|
||||
const row = rows[n - 1];
|
||||
if (!row) return;
|
||||
tl.fromTo(
|
||||
row,
|
||||
{ backgroundColor: "rgba(217,119,87,0)" },
|
||||
{ backgroundColor: "rgba(217,119,87,0.14)", duration: 0.3, ease: "power2.out" },
|
||||
Math.min(typeEnd, DUR - 0.75)
|
||||
);
|
||||
});
|
||||
|
||||
// 收尾:轻微淡出上移,避免硬切
|
||||
tl.to("#editor", { opacity: 0, y: -16, duration: 0.25, ease: "power1.in" }, DUR - 0.25);
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["codedemo"] = tl;
|
||||
tl.seek(0);
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,138 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"
|
||||
data-composition-variables='[{"id": "fromX", "type": "number", "label": "起点横坐标(百分比)", "default": "50"},{"id": "fromY", "type": "number", "label": "起点纵坐标(百分比)", "default": "80"},{"id": "toX", "type": "number", "label": "终点横坐标(百分比)", "default": "42"},{"id": "toY", "type": "number", "label": "终点纵坐标(百分比)", "default": "38"},{"id": "clicks", "type": "string", "label": "点击时刻(秒,逗号分隔)", "default": ""}]'>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>鼠标指针</title>
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<style>
|
||||
/* 指针层是透明的,只负责在画面上画一个指针和点击涟漪 */
|
||||
.stage { pointer-events: none; }
|
||||
|
||||
.cursor {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: calc(30 * var(--u));
|
||||
height: calc(40 * var(--u));
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 经典箭头:用两层描边做出白边黑芯 */
|
||||
.cursor svg { width: 100%; height: 100%; display: block; filter: drop-shadow(0 calc(3 * var(--u)) calc(6 * var(--u)) rgba(0,0,0,0.55)); }
|
||||
|
||||
/* 点击涟漪 */
|
||||
.ripple {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: calc(70 * var(--u));
|
||||
height: calc(70 * var(--u));
|
||||
margin: calc(-35 * var(--u)) 0 0 calc(-35 * var(--u));
|
||||
border-radius: 50%;
|
||||
border: calc(3 * var(--u)) solid var(--accent);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="stage"
|
||||
data-composition-id="cursor"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-duration="4"
|
||||
>
|
||||
<div id="ripples"></div>
|
||||
<div id="cursor" class="cursor">
|
||||
<svg viewBox="0 0 30 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2 2 L2 30 L9.5 23.5 L14 36 L19 34 L14.5 22 L24 22 Z"
|
||||
fill="#ffffff"
|
||||
stroke="#0a0a0c"
|
||||
stroke-width="1.6"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const v = window.hv ? hv.readVars() : null;
|
||||
const str = (k, d) => (v ? v.str(k, d) : d);
|
||||
const num = (k, d) => (v ? v.num(k, d) : d);
|
||||
|
||||
// 时长跟随宿主(可能由配音决定),拿不到才用组件默认值
|
||||
const DUR = (window.hv && hv.duration()) || 4;
|
||||
|
||||
/* from/to 位置用百分比写,跟画幅无关 */
|
||||
const fromX = num("fromX", 50);
|
||||
const fromY = num("fromY", 80);
|
||||
const toX = num("toX", 42);
|
||||
const toY = num("toY", 38);
|
||||
|
||||
const cursor = document.getElementById("cursor");
|
||||
const ripples = document.getElementById("ripples");
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
/* 起点:直接摆好,不做入场,避免第一帧指针凭空出现 */
|
||||
const setXY = (el, x, y) => {
|
||||
const w = parseFloat(getComputedStyle(el).width) || 30;
|
||||
const h = parseFloat(getComputedStyle(el).height) || 40;
|
||||
el.style.left = x + "%";
|
||||
el.style.top = y + "%";
|
||||
el.style.transform = `translate(-10%, -8%)`;
|
||||
};
|
||||
|
||||
// 用百分比定位 + 关键帧平移,等价于移动指针
|
||||
const STEPS = [
|
||||
{ at: 0.0, x: fromX, y: fromY },
|
||||
{ at: 0.9, x: (fromX + toX) / 2 + 6, y: (fromY + toY) / 2 + 8 },
|
||||
{ at: 1.8, x: toX, y: toY },
|
||||
];
|
||||
|
||||
STEPS.forEach((s, i) => {
|
||||
const prev = i === 0 ? s : STEPS[i - 1];
|
||||
setXY(cursor, s.x, s.y);
|
||||
tl.set(cursor, { left: s.x + "%", top: s.y + "%" }, s.at);
|
||||
if (i > 0) {
|
||||
// 中间做一段带缓动的位移
|
||||
tl.fromTo(
|
||||
cursor,
|
||||
{ left: prev.x + "%", top: prev.y + "%" },
|
||||
{ left: s.x + "%", top: s.y + "%", duration: s.at - prev.at, ease: "power2.inOut" },
|
||||
prev.at
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/* clicks 是一串时间点(秒),到点就冒一圈涟漪 */
|
||||
const clicks = str("clicks", "")
|
||||
.split(",")
|
||||
.map((x) => Number(x.trim()))
|
||||
.filter(Number.isFinite);
|
||||
|
||||
clicks.forEach((t, i) => {
|
||||
if (t < 0 || t > DUR) return;
|
||||
const r = document.createElement("div");
|
||||
r.className = "ripple";
|
||||
ripples.appendChild(r);
|
||||
const x = i < STEPS.length - 1 ? toX : toX;
|
||||
const y = i < STEPS.length - 1 ? toY : toY;
|
||||
r.style.left = x + "%";
|
||||
r.style.top = y + "%";
|
||||
tl.fromTo(r, { opacity: 0.85, scale: 0.2 }, { opacity: 0, scale: 1.25, duration: 0.5, ease: "power2.out" }, t);
|
||||
});
|
||||
|
||||
// 收尾:指针淡出
|
||||
tl.to(cursor, { opacity: 0, duration: 0.25 }, DUR - 0.25);
|
||||
tl.to(".stage", { opacity: 0, duration: 0.25 }, DUR - 0.25);
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["cursor"] = tl;
|
||||
tl.seek(0);
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"
|
||||
data-composition-variables='[{"id": "title", "type": "string", "label": "主标题", "default": "三个 Cursor 实用技巧"},{"id": "kicker", "type": "string", "label": "小标签(可空)", "default": ""},{"id": "subtitle", "type": "string", "label": "副标题(可空)", "default": "让你写代码少一半时间"}]'>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>片头</title>
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<style>
|
||||
.stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-size: calc(22 * var(--u));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: calc(22 * var(--u));
|
||||
}
|
||||
.kicker:empty { display: none; }
|
||||
|
||||
.title {
|
||||
font-size: calc(76 * var(--u));
|
||||
font-weight: 700;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.02em;
|
||||
max-width: 88%;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: calc(30 * var(--u));
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: var(--text-dim);
|
||||
margin-top: calc(26 * var(--u));
|
||||
max-width: 76%;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.subtitle:empty { display: none; }
|
||||
|
||||
/* 标题下的强调线,从中间向两侧展开 */
|
||||
.rule {
|
||||
width: calc(120 * var(--u));
|
||||
height: calc(4 * var(--u));
|
||||
border-radius: calc(2 * var(--u));
|
||||
background: var(--accent);
|
||||
margin-top: calc(40 * var(--u));
|
||||
transform: scaleX(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="stage"
|
||||
data-composition-id="intro"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-duration="3.5"
|
||||
>
|
||||
<div id="kicker" class="kicker"></div>
|
||||
<h1 id="title" class="title"></h1>
|
||||
<div id="rule" class="rule"></div>
|
||||
<p id="subtitle" class="subtitle"></p>
|
||||
|
||||
<script>
|
||||
const v = window.hv ? hv.readVars() : null;
|
||||
const str = (k, d) => (v ? v.str(k, d) : d);
|
||||
|
||||
document.getElementById("title").textContent = str("title", "未命名标题");
|
||||
document.getElementById("kicker").textContent = str("kicker", "");
|
||||
document.getElementById("subtitle").textContent = str("subtitle", "");
|
||||
|
||||
// 时长跟随宿主(可能由配音决定),拿不到才用组件默认值
|
||||
const DUR = (window.hv && hv.duration()) || 3.5;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
tl.fromTo("#kicker", { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.4, ease: "power2.out" }, 0.05)
|
||||
.fromTo("#title", { opacity: 0, y: 34 }, { opacity: 1, y: 0, duration: 0.55, ease: "power3.out" }, 0.18)
|
||||
.fromTo("#rule", { scaleX: 0 }, { scaleX: 1, duration: 0.45, ease: "power2.inOut" }, 0.5)
|
||||
.fromTo("#subtitle", { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.45, ease: "power2.out" }, 0.62);
|
||||
|
||||
// 末段整体淡出,和下一个场景衔接
|
||||
tl.to(".stage", { opacity: 0, duration: 0.3, ease: "power1.in" }, DUR - 0.3);
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["intro"] = tl;
|
||||
tl.seek(0);
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"
|
||||
data-composition-variables='[{"id": "text", "type": "string", "label": "主文字", "default": "技巧一:先描述意图"},{"id": "sub", "type": "string", "label": "副文字(可空)", "default": "上下文质量决定产出质量"}]'>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>下方字幕条</title>
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<style>
|
||||
.stage { display: flex; align-items: flex-end; }
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: calc(20 * var(--u));
|
||||
padding: calc(18 * var(--u)) calc(32 * var(--u)) calc(18 * var(--u)) calc(22 * var(--u));
|
||||
background: rgba(19, 19, 23, 0.92);
|
||||
border: calc(1 * var(--u)) solid var(--border-strong);
|
||||
border-radius: calc(14 * var(--u));
|
||||
backdrop-filter: blur(calc(12 * var(--u)));
|
||||
box-shadow: 0 calc(18 * var(--u)) calc(48 * var(--u)) rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 左侧强调竖条 */
|
||||
.tick {
|
||||
width: calc(5 * var(--u));
|
||||
border-radius: calc(3 * var(--u));
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.texts { display: flex; flex-direction: column; gap: calc(6 * var(--u)); }
|
||||
|
||||
.main {
|
||||
font-size: calc(32 * var(--u));
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: calc(21 * var(--u));
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.sub:empty { display: none; }
|
||||
|
||||
.stage > .bar { margin-left: var(--pad-x); margin-bottom: calc(110 * var(--u)); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="stage"
|
||||
data-composition-id="lowerthird"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-duration="3"
|
||||
>
|
||||
<div id="bar" class="bar">
|
||||
<div class="tick"></div>
|
||||
<div class="texts">
|
||||
<div id="main" class="main"></div>
|
||||
<div id="sub" class="sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const v = window.hv ? hv.readVars() : null;
|
||||
const str = (k, d) => (v ? v.str(k, d) : d);
|
||||
|
||||
document.getElementById("main").textContent = str("text", "未命名");
|
||||
document.getElementById("sub").textContent = str("sub", "");
|
||||
|
||||
// 时长跟随宿主(可能由配音决定)
|
||||
const DUR = (window.hv && hv.duration()) || 3;
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#bar", { opacity: 0, x: -40 }, { opacity: 1, x: 0, duration: 0.4, ease: "power3.out" }, 0.1)
|
||||
.fromTo(".tick", { scaleY: 0 }, { scaleY: 1, duration: 0.3, ease: "power2.out" }, 0.22)
|
||||
// 极短场景(配音只有 1 秒)时立刻淡出,不要出现负数时长
|
||||
.to("#bar", { opacity: 0, x: -30, duration: 0.28, ease: "power1.in" }, Math.max(0.4, DUR - 0.28));
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["lowerthird"] = tl;
|
||||
tl.seek(0);
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,97 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"
|
||||
data-composition-variables='[{"id": "cta", "type": "string", "label": "引导语", "default": "如果这期有帮助,点个关注"},{"id": "handle", "type": "string", "label": "频道名(留空则不显示)", "default": "@你的频道"},{"id": "foot", "type": "string", "label": "底部小字(留空则不显示)", "default": "每周三更新一期"}]'>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>片尾</title>
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<style>
|
||||
.stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta {
|
||||
font-size: calc(34 * var(--u));
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.handle {
|
||||
margin-top: calc(40 * var(--u));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(18 * var(--u));
|
||||
padding: calc(22 * var(--u)) calc(48 * var(--u));
|
||||
border-radius: calc(999 * var(--u));
|
||||
background: var(--surface);
|
||||
border: calc(2 * var(--u)) solid var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(30 * var(--u));
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: calc(14 * var(--u));
|
||||
height: calc(14 * var(--u));
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.foot {
|
||||
position: absolute;
|
||||
bottom: calc(80 * var(--u));
|
||||
font-size: calc(20 * var(--u));
|
||||
color: var(--text-dim);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="stage"
|
||||
data-composition-id="outro"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-duration="4"
|
||||
>
|
||||
<div id="cta" class="cta"></div>
|
||||
<div id="handle" class="handle"><span class="dot"></span><span id="handleText"></span></div>
|
||||
<div id="foot" class="foot"></div>
|
||||
|
||||
<script>
|
||||
const v = window.hv ? hv.readVars() : null;
|
||||
const str = (k, d) => (v ? v.str(k, d) : d);
|
||||
|
||||
document.getElementById("cta").textContent = str("cta", "如果这期有帮助,点个关注");
|
||||
document.getElementById("handleText").textContent = str("handle", "@你的频道");
|
||||
document.getElementById("foot").textContent = str("foot", "");
|
||||
|
||||
const handle = document.getElementById("handle");
|
||||
const foot = document.getElementById("foot");
|
||||
if (!str("handle", "@你的频道")) handle.style.display = "none";
|
||||
if (!str("foot", "")) foot.style.display = "none";
|
||||
|
||||
// 时长跟随宿主(可能由配音决定)
|
||||
const DUR = (window.hv && hv.duration()) || 4;
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#cta", { opacity: 0, y: 26 }, { opacity: 1, y: 0, duration: 0.45, ease: "power2.out" }, 0.1)
|
||||
.fromTo(handle, { opacity: 0, y: 30, scale: 0.94 }, { opacity: 1, y: 0, scale: 1, duration: 0.5, ease: "back.out(1.4)" }, 0.35)
|
||||
.fromTo(foot, { opacity: 0 }, { opacity: 1, duration: 0.4 }, 0.7)
|
||||
.to(".stage", { opacity: 0, duration: 0.35, ease: "power1.in" }, Math.max(0.4, DUR - 0.35));
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["outro"] = tl;
|
||||
tl.seek(0);
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,181 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
自由度压测组件:故意使用 GSAP 白名单之外的特性,看渲染引擎扛不扛得住。
|
||||
这不是给人用的组件,是用来回答"HyperFrames 会不会限制发挥"的探针。
|
||||
-->
|
||||
<html lang="zh-CN"
|
||||
data-composition-variables='[]'>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>自由度压测</title>
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<style>
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
gap: calc(16 * var(--u));
|
||||
padding: calc(40 * var(--u));
|
||||
}
|
||||
.cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: calc(10 * var(--u));
|
||||
background: var(--surface);
|
||||
border: calc(1 * var(--u)) solid var(--border);
|
||||
border-radius: calc(14 * var(--u));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.cap { font-size: calc(17 * var(--u)); color: var(--text-dim); font-family: var(--font-mono); }
|
||||
|
||||
/* 1. filter: blur + saturate */
|
||||
#blur { font-size: calc(46 * var(--u)); font-weight: 700; color: var(--accent); }
|
||||
|
||||
/* 2. clip-path */
|
||||
#clip {
|
||||
font-size: calc(46 * var(--u)); font-weight: 700; color: var(--text);
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
|
||||
/* 3. mix-blend-mode */
|
||||
.blend { position: absolute; width: calc(120*var(--u)); height: calc(120*var(--u)); border-radius: 50%; }
|
||||
#blendA { background: var(--accent); mix-blend-mode: screen; }
|
||||
#blendB { background: var(--info); mix-blend-mode: screen; }
|
||||
|
||||
/* 4. 3D 变换 */
|
||||
#cube { width: calc(80*var(--u)); height: calc(80*var(--u)); background: var(--accent); transform-style: preserve-3d; }
|
||||
|
||||
/* 5. mask-image */
|
||||
#mask {
|
||||
width: calc(220*var(--u)); height: calc(70*var(--u));
|
||||
background: linear-gradient(90deg, var(--accent), var(--info));
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0%, transparent 100%);
|
||||
mask-image: linear-gradient(90deg, #000 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* 6. canvas */
|
||||
#gl, #cv { width: calc(160*var(--u)); height: calc(110*var(--u)); }
|
||||
|
||||
/* 7. CSS keyframes(官方说法要避免,这里故意试) */
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
#spin { width: calc(60*var(--u)); height: calc(60*var(--u)); border-radius: 50%;
|
||||
border: calc(6*var(--u)) solid var(--accent); border-right-color: transparent; }
|
||||
|
||||
/* 8. conic-gradient 动态角度(需要 @property 才可插值) */
|
||||
#cone { width: calc(90*var(--u)); height: calc(90*var(--u)); border-radius: 50%; }
|
||||
|
||||
svg { width: calc(220*var(--u)); height: calc(90*var(--u)); }
|
||||
.stroke { fill: none; stroke: var(--accent); stroke-width: 5; stroke-linecap: round; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage" data-composition-id="stress" data-width="1920" data-height="1080" data-duration="6"
|
||||
data-allow="text_occluded contrast">
|
||||
<div class="cell"><div id="blur">模糊锐化</div><span class="cap">1. filter: blur()</span></div>
|
||||
<div class="cell"><div id="clip">裁切展开</div><span class="cap">2. clip-path</span></div>
|
||||
<div class="cell" style="overflow:hidden">
|
||||
<div class="blend" id="blendA"></div><div class="blend" id="blendB"></div>
|
||||
<span class="cap">3. blend(故意遮挡)</span>
|
||||
</div>
|
||||
<div class="cell"><div id="cube"></div><span class="cap">4. 3D 变换</span></div>
|
||||
<div class="cell"><div id="mask"></div><span class="cap">5. mask-image</span></div>
|
||||
<div class="cell"><canvas id="cv" width="320" height="220"></canvas><span class="cap">6. canvas 2D</span></div>
|
||||
<div class="cell"><div id="spin" style="animation: spin 2s linear infinite"></div><span class="cap">7. CSS keyframes</span></div>
|
||||
<div class="cell"><canvas id="gl" width="320" height="220"></canvas><span class="cap">8. WebGL</span></div>
|
||||
<div class="cell">
|
||||
<svg viewBox="0 0 220 90"><path id="path" class="stroke" d="M10 70 C 60 10, 120 90, 210 25" pathLength="1"/></svg>
|
||||
<span class="cap">9. SVG 描边</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const D = 6;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
/* 1. filter —— 白名单外 */
|
||||
tl.fromTo("#blur", { filter: "blur(24px)", opacity: 0.2 },
|
||||
{ filter: "blur(0px)", opacity: 1, duration: 1.6, ease: "power2.out" }, 0.1);
|
||||
|
||||
/* 2. clip-path —— 白名单外 */
|
||||
tl.fromTo("#clip", { clipPath: "inset(0 100% 0 0)" },
|
||||
{ clipPath: "inset(0 0% 0 0)", duration: 1.8, ease: "power2.inOut" }, 0.2);
|
||||
|
||||
/* 3. mix-blend-mode + 位移 —— 半白名单 */
|
||||
tl.fromTo("#blendA", { x: -60, y: 30 }, { x: 30, y: -20, duration: 2.2, ease: "sine.inOut" }, 0.1)
|
||||
.fromTo("#blendB", { x: 60, y: -30 }, { x: -30, y: 20, duration: 2.2, ease: "sine.inOut" }, 0.1);
|
||||
|
||||
/* 4. 3D 变换 —— 白名单外 */
|
||||
tl.fromTo("#cube", { rotationY: 0, rotateX: 0, scale: 1 },
|
||||
{ rotationY: 360, rotateX: 45, scale: 1.15, duration: 3, ease: "none" }, 0.1);
|
||||
|
||||
/* 5. mask-image + 背景动画 —— 白名单外 */
|
||||
tl.fromTo("#mask", { scaleX: 0.1, opacity: 0.3 },
|
||||
{ scaleX: 1, opacity: 1, duration: 2, ease: "power2.out" }, 0.3);
|
||||
|
||||
/* 6. canvas 2D:每帧按 t 重绘,纯函数式 */
|
||||
const cv = document.getElementById("cv");
|
||||
const cx = cv.getContext("2d");
|
||||
const drawCv = (t) => {
|
||||
cx.clearRect(0, 0, cv.width, cv.height);
|
||||
cx.strokeStyle = "#d97757";
|
||||
cx.lineWidth = 6;
|
||||
cx.beginPath();
|
||||
const p = (t * 0.6) % 1;
|
||||
for (let i = 0; i <= 60; i++) {
|
||||
const x = (i / 60) * cv.width;
|
||||
const y = cv.height / 2 + Math.sin(i * 0.28 + t * 5) * (60 * p);
|
||||
i === 0 ? cx.moveTo(x, y) : cx.lineTo(x, y);
|
||||
}
|
||||
cx.stroke();
|
||||
};
|
||||
[0, 1, 2, 3, 4, 5].forEach((s) => tl.call(() => drawCv(s), null, s));
|
||||
|
||||
/* 7. CSS keyframes:官方说法不可复现,这里实测 */
|
||||
// 保持 animation 原样,不干预
|
||||
|
||||
/* 8. WebGL:真实 3D 渲染,纯函数式按 t 绘制 */
|
||||
const gl = document.getElementById("gl").getContext("webgl2") || document.getElementById("gl").getContext("webgl");
|
||||
let glOK = false;
|
||||
if (gl) {
|
||||
const vs = gl.createShader(gl.VERTEX_SHADER);
|
||||
gl.shaderSource(vs, `attribute vec2 p; varying float d; void main(){ d=length(p); gl_Position=vec4(p,0.,1.);}`);
|
||||
gl.compileShader(vs);
|
||||
const fs = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
gl.shaderSource(fs, `precision mediump float; varying float d; void main(){ gl_FragColor=vec4(1.-d,0.45,0.35,1.); }`);
|
||||
gl.compileShader(fs);
|
||||
const pr = gl.createProgram();
|
||||
gl.attachShader(pr, vs); gl.attachShader(pr, fs); gl.linkProgram(pr);
|
||||
gl.useProgram(pr);
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-.9,-.9,.9,-.9,0,.9]), gl.STATIC_DRAW);
|
||||
const loc = gl.getAttribLocation(pr, "p");
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
glOK = true;
|
||||
}
|
||||
if (glOK) {
|
||||
[0, 1, 2, 3, 4, 5].forEach((s) => tl.call(() => {
|
||||
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
|
||||
gl.clearColor(0.04, 0.04, 0.05, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}, null, s));
|
||||
}
|
||||
|
||||
/* 9. SVG 描边:pathLength=1 + strokeDashoffset,白名单外 */
|
||||
const path = document.getElementById("path");
|
||||
tl.fromTo(path, { strokeDasharray: 1, strokeDashoffset: 1 },
|
||||
{ strokeDashoffset: 0, duration: 2.4, ease: "power1.inOut" }, 0.2);
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["stress"] = tl;
|
||||
tl.seek(0);
|
||||
window.__glOK = glOK;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,220 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"
|
||||
data-composition-variables='[{"id": "title", "type": "string", "label": "窗口标题", "default": "终端"},{"id": "prompt", "type": "string", "label": "命令提示符", "default": "$ "},{"id": "script", "type": "string", "label": "脚本(JSON 数组)", "default": "[{\"text\":\"npm run build\",\"kind\":\"cmd\"}]"}]'>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>终端</title>
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<style>
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.term {
|
||||
width: var(--panel-w);
|
||||
background: #0d0d10;
|
||||
border: calc(1 * var(--u)) solid var(--border);
|
||||
border-radius: calc(16 * var(--u));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 calc(32 * var(--u)) calc(90 * var(--u)) rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(18 * var(--u));
|
||||
padding: calc(15 * var(--u)) calc(22 * var(--u));
|
||||
background: var(--surface-2);
|
||||
border-bottom: calc(1 * var(--u)) solid var(--border);
|
||||
}
|
||||
.dots { display: flex; gap: calc(10 * var(--u)); }
|
||||
.dot { width: calc(14 * var(--u)); height: calc(14 * var(--u)); border-radius: 50%; }
|
||||
.dot.r { background: #ff5f57; }
|
||||
.dot.y { background: #febc2e; }
|
||||
.dot.g { background: #28c840; }
|
||||
.title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(19 * var(--u));
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.screen {
|
||||
padding: calc(22 * var(--u)) calc(26 * var(--u)) calc(26 * var(--u));
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(21 * var(--u));
|
||||
line-height: calc(36 * var(--u));
|
||||
min-height: calc(200 * var(--u));
|
||||
}
|
||||
|
||||
/* 每行:外层固定宽度负责裁切,内层 auto 宽度负责正常排版。
|
||||
注意:不能把 width 直接动画到 .row 上——那样容器宽度被锁死,
|
||||
长行(尤其中文)不会换行,会被挤成竖排。实测踩过。 */
|
||||
/* 每行固定行高,内容自然宽度。
|
||||
打字效果用 clip-path 的 inset 从右往左揭开——
|
||||
不要动画 width:容器宽度被锁死后长行不换行,中文会挤成竖排。
|
||||
clip-path 只影响显示,不影响布局,所以换行照常。 */
|
||||
.row { position: relative; height: calc(36 * var(--u)); }
|
||||
.row .inner { white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
/* 光标钉在「已揭示宽度」的右边缘 */
|
||||
.row > .caret {
|
||||
position: absolute;
|
||||
top: calc(6 * var(--u));
|
||||
z-index: 2;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* 提示符 */
|
||||
.prompt { color: var(--syn-string); }
|
||||
.cmd { color: var(--text); }
|
||||
/* 输出类型 */
|
||||
.out { color: var(--text-dim); }
|
||||
.ok { color: var(--syn-string); }
|
||||
.err { color: var(--err); }
|
||||
.warn { color: var(--warn); }
|
||||
.dim { color: var(--text-faint); }
|
||||
.path { color: var(--syn-type); }
|
||||
|
||||
.caret {
|
||||
display: inline-block;
|
||||
width: calc(10 * var(--u));
|
||||
height: calc(22 * var(--u));
|
||||
background: var(--accent);
|
||||
vertical-align: text-bottom;
|
||||
margin-left: calc(2 * var(--u));
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="stage"
|
||||
data-composition-id="terminal"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-duration="6"
|
||||
>
|
||||
<div class="term">
|
||||
<div class="titlebar">
|
||||
<div class="dots">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
</div>
|
||||
<span id="title" class="title">终端</span>
|
||||
</div>
|
||||
<div id="screen" class="screen"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const v = window.hv ? hv.readVars() : null;
|
||||
const str = (k, d) => (v ? v.str(k, d) : d);
|
||||
|
||||
document.getElementById("title").textContent = str("title", "终端");
|
||||
|
||||
/* script 是一段 JSON 数组,每项 { text, kind }。
|
||||
kind: cmd 输入命令 / out 普通输出 / ok 成功 / err 报错 / warn 警告 / dim 次要 */
|
||||
let steps;
|
||||
try {
|
||||
steps = JSON.parse(str("script", '[{"text":"npm run build","kind":"cmd"},{"text":"✓ 构建完成","kind":"ok"}]'));
|
||||
} catch {
|
||||
steps = [{ text: "脚本格式错误,应为 JSON 数组", kind: "err" }];
|
||||
}
|
||||
if (!Array.isArray(steps)) steps = [{ text: "脚本格式错误,应为 JSON 数组", kind: "err" }];
|
||||
|
||||
// 时长跟随宿主(可能由配音决定),拿不到才用组件默认值
|
||||
const DUR = (window.hv && hv.duration()) || 6;
|
||||
const promptStr = str("prompt", "$ ");
|
||||
const screen = document.getElementById("screen");
|
||||
const rows = [];
|
||||
const carets = [];
|
||||
|
||||
steps.forEach((s) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row";
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "inner";
|
||||
if (s.kind === "cmd") {
|
||||
const p = document.createElement("span");
|
||||
p.className = "prompt";
|
||||
p.textContent = promptStr;
|
||||
const c = document.createElement("span");
|
||||
c.className = "cmd";
|
||||
c.textContent = s.text || "";
|
||||
inner.appendChild(p);
|
||||
inner.appendChild(c);
|
||||
} else {
|
||||
inner.classList.add(s.kind || "out");
|
||||
inner.textContent = s.text || "";
|
||||
}
|
||||
const caret = document.createElement("span");
|
||||
caret.className = "caret";
|
||||
row.appendChild(inner);
|
||||
row.appendChild(caret);
|
||||
screen.appendChild(row);
|
||||
rows.push(row);
|
||||
carets.push({ el: caret, row, inner });
|
||||
});
|
||||
|
||||
// 量出每行自然宽度:光标钉在这个宽度的位置
|
||||
const widths = rows.map((r) => Math.max(1, r.querySelector(".inner").getBoundingClientRect().width));
|
||||
|
||||
const START = 0.35;
|
||||
const typeWindow = Math.max(0.8, DUR - START - 0.7);
|
||||
const weights = steps.map((s) => Math.max(4, String(s.text || "").length));
|
||||
const totalW = weights.reduce((a, b) => a + b, 0);
|
||||
|
||||
const rowStart = [];
|
||||
const rowDur = [];
|
||||
{
|
||||
let acc = 0;
|
||||
steps.forEach((s, i) => {
|
||||
const d = Math.max(0.12, (weights[i] / totalW) * typeWindow);
|
||||
rowStart.push(START + acc);
|
||||
rowDur.push(d);
|
||||
acc += d;
|
||||
});
|
||||
}
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo(".term", { opacity: 0, y: 30, scale: 0.98 }, { opacity: 1, y: 0, scale: 1, duration: 0.4, ease: "power2.out" }, 0);
|
||||
|
||||
rows.forEach((r, i) => {
|
||||
const inner = carets[i].inner;
|
||||
const caret = carets[i].el;
|
||||
// clip-path 从右往左揭开 = 打字。只影响显示,布局宽度不变
|
||||
gsap.set(inner, { clipPath: "inset(0 100% 0 0)" });
|
||||
gsap.set(caret, { opacity: 0 });
|
||||
tl.fromTo(
|
||||
inner,
|
||||
{ clipPath: "inset(0 100% 0 0)" },
|
||||
{ clipPath: "inset(0 0% 0 0)", duration: rowDur[i], ease: "none" },
|
||||
rowStart[i]
|
||||
);
|
||||
// 光标跟着揭示进度走
|
||||
tl.fromTo(caret, { left: 0 }, { left: widths[i], duration: rowDur[i], ease: "none" }, rowStart[i]);
|
||||
tl.set(caret, { opacity: 1 }, rowStart[i]);
|
||||
tl.set(caret, { opacity: 0 }, rowStart[i] + rowDur[i]);
|
||||
// 输入行打字期间闪烁
|
||||
for (let k = 0; rowStart[i] + k * 0.45 < rowStart[i] + rowDur[i]; k++) {
|
||||
tl.set(caret, { opacity: k % 2 === 0 ? 1 : 0.2 }, rowStart[i] + k * 0.45);
|
||||
}
|
||||
});
|
||||
|
||||
const last = rows.length - 1;
|
||||
if (last >= 0) {
|
||||
// 打完最后一行后,光标停在末尾常亮
|
||||
for (let k = 0; rowStart[last] + rowDur[last] + k * 0.45 < DUR - 0.3; k++) {
|
||||
tl.set(carets[last].el, { opacity: k % 2 === 0 ? 1 : 0.2 }, rowStart[last] + rowDur[last] + k * 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
tl.to(".term", { opacity: 0, y: -14, duration: 0.28, ease: "power1.in" }, DUR - 0.28);
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["terminal"] = tl;
|
||||
tl.seek(0);
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"
|
||||
data-composition-variables='[{"id": "heading", "type": "string", "label": "标题", "default": "用 **加粗** 和 [[高亮]]"},{"id": "body", "type": "string", "label": "正文(可空)", "default": "支持 **加粗** 与 [[强调色]]"}]'>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>文字块</title>
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<style>
|
||||
.stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: calc(60 * var(--u));
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: calc(24 * var(--u));
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: calc(30 * var(--u));
|
||||
font-weight: 500;
|
||||
line-height: 1.55;
|
||||
color: var(--text-dim);
|
||||
max-width: 82%;
|
||||
}
|
||||
|
||||
/* 逐行浮现:每行一个块 */
|
||||
.line { overflow: hidden; }
|
||||
.line > span { display: inline-block; }
|
||||
|
||||
.accent { color: var(--accent); }
|
||||
.strong { color: var(--text); font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="stage"
|
||||
data-composition-id="textblock"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-duration="4"
|
||||
>
|
||||
<div id="heading" class="heading"></div>
|
||||
<div id="body" class="body"></div>
|
||||
|
||||
<script>
|
||||
const v = window.hv ? hv.readVars() : null;
|
||||
const str = (k, d) => (v ? v.str(k, d) : d);
|
||||
|
||||
const HEAD = str("heading", "未命名标题");
|
||||
const BODY = str("body", "");
|
||||
|
||||
const headingEl = document.getElementById("heading");
|
||||
const bodyEl = document.getElementById("body");
|
||||
|
||||
/* 支持在文字里用 **强调** 和 [[高亮]] 标记 */
|
||||
const rich = (text) => {
|
||||
const frag = document.createDocumentFragment();
|
||||
const re = /\*\*([^*]+)\*\*|\[\[([^\]]+)\]\]/g;
|
||||
let last = 0;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index)));
|
||||
const el = document.createElement("span");
|
||||
if (m[1]) {
|
||||
el.className = "strong";
|
||||
el.textContent = m[1];
|
||||
} else {
|
||||
el.className = "accent";
|
||||
el.textContent = m[2];
|
||||
}
|
||||
frag.appendChild(el);
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
|
||||
return frag;
|
||||
};
|
||||
|
||||
headingEl.textContent = "";
|
||||
headingEl.appendChild(rich(HEAD));
|
||||
|
||||
bodyEl.textContent = "";
|
||||
if (BODY) {
|
||||
const span = document.createElement("span");
|
||||
span.appendChild(rich(BODY));
|
||||
bodyEl.appendChild(span);
|
||||
} else {
|
||||
bodyEl.style.display = "none";
|
||||
}
|
||||
|
||||
// 时长跟随宿主(可能由配音决定),拿不到才用组件默认值
|
||||
const DUR = (window.hv && hv.duration()) || 4;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
tl.fromTo(headingEl, { opacity: 0, y: 28 }, { opacity: 1, y: 0, duration: 0.5, ease: "power3.out" }, 0.1)
|
||||
.fromTo(bodyEl, { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.5, ease: "power3.out" }, 0.35)
|
||||
.to(".stage", { opacity: 0, duration: 0.3, ease: "power1.in" }, DUR - 0.3);
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["textblock"] = tl;
|
||||
tl.seek(0);
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,571 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
tictactoe —— 井字棋科普演示
|
||||
--------------------------------------------------------------------------
|
||||
和 cad3d 一样是「讲一个过程」的组件:棋局一格格展开,旁边同步讲解。
|
||||
|
||||
结构刻意做成可复用的:
|
||||
props.moves 累积落子(1-9),本段结束时应该下到哪一手
|
||||
props.caption 本段讲什么(和 narration 保持一致 = 屏幕上的字幕)
|
||||
props.tip 右侧要点标签
|
||||
props.point 要指哪一格(用来引导视线,0=不指)
|
||||
|
||||
「已经下过的子」直接显示,只有**最后一手**带出现动画 —— 因为每个场景是
|
||||
独立挂载的组件实例,前一手在上一个场景已经出现过了。
|
||||
|
||||
硬约束(所有组件都适用):
|
||||
画面必须是时间 t 的纯函数。渲染只做 set/clear,绝不依赖前一帧。
|
||||
所以会用到 sin(t) 这类纯函数振荡(指针呼吸),它对同一个 t 永远给同一个值。
|
||||
-->
|
||||
<html lang="zh-CN"
|
||||
data-composition-variables='[{"id":"moves","type":"string","label":"落子序列(1-9 逗号分隔,写 0 = 空棋盘)","default":"5,2,9,4,1"},{"id":"caption","type":"string","label":"讲解字幕(与 narration 一致)","default":"连成一线的一方获胜"},{"id":"tip","type":"string","label":"要点标签(留空则不显示)","default":""},{"id":"point","type":"number","label":"要指的格子(1-9,0 表示不指)","default":"0"},{"id":"title","type":"string","label":"顶栏标题","default":"井字棋 · 怎么玩"},{"id":"eyebrow","type":"string","label":"顶栏小标签(留空则不显示)","default":""},{"id":"accent","type":"color","label":"X 的颜色","default":"#d97757"}]'>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>井字棋</title>
|
||||
<link rel="stylesheet" href="assets/brand/tokens.css" />
|
||||
<link rel="stylesheet" href="assets/brand/base.css" />
|
||||
<style>
|
||||
.stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: calc(64 * var(--u)) calc(92 * var(--u)) calc(56 * var(--u));
|
||||
gap: calc(34 * var(--u));
|
||||
}
|
||||
|
||||
/* ---- 顶栏 ---------------------------------------------------- */
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: calc(18 * var(--u));
|
||||
height: calc(58 * var(--u));
|
||||
}
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(16 * var(--u));
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--accent);
|
||||
border: calc(1 * var(--u)) solid var(--accent-dim);
|
||||
border-radius: calc(6 * var(--u));
|
||||
padding: calc(4 * var(--u)) calc(11 * var(--u));
|
||||
flex: none;
|
||||
}
|
||||
.eyebrow:empty { display: none; }
|
||||
.ptitle {
|
||||
font-size: calc(30 * var(--u));
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.top .rule {
|
||||
flex: 1;
|
||||
height: calc(1 * var(--u));
|
||||
background: var(--border);
|
||||
margin-left: calc(8 * var(--u));
|
||||
}
|
||||
|
||||
/* ---- 主区 ---------------------------------------------------- */
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(76 * var(--u));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.boardcol { flex: none; width: calc(540 * var(--u)); }
|
||||
|
||||
.board {
|
||||
position: relative;
|
||||
width: calc(540 * var(--u));
|
||||
height: calc(540 * var(--u));
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: calc(10 * var(--u));
|
||||
background: var(--surface);
|
||||
border: calc(1 * var(--u)) solid var(--border);
|
||||
border-radius: calc(20 * var(--u));
|
||||
padding: calc(10 * var(--u));
|
||||
box-shadow: 0 calc(30 * var(--u)) calc(80 * var(--u)) rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-2);
|
||||
border: calc(1 * var(--u)) solid var(--border);
|
||||
border-radius: calc(12 * var(--u));
|
||||
/* transform/opacity 由 render(t) 逐帧设置 */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.cell .mark {
|
||||
font-size: calc(96 * var(--u));
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.cell[data-m="X"] .mark { color: var(--accent); }
|
||||
.cell[data-m="O"] .mark { color: var(--info); }
|
||||
/* 非当前手的子略压暗,视线会自然落在刚下的那一格 */
|
||||
.cell.dim .mark { opacity: 0.55; }
|
||||
|
||||
/* 落子瞬间的涟漪,只在最后一手出现 */
|
||||
.ring {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: calc(72 * var(--u));
|
||||
height: calc(72 * var(--u));
|
||||
margin: calc(-36 * var(--u)) 0 0 calc(-36 * var(--u));
|
||||
border: calc(3 * var(--u)) solid var(--accent);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 讲解用的指针圈:围绕被指的格子呼吸 */
|
||||
.point-ring {
|
||||
position: absolute;
|
||||
inset: calc(-7 * var(--u));
|
||||
border: calc(3 * var(--u)) dashed var(--warn);
|
||||
border-radius: calc(16 * var(--u));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 赢了之后横跨三子的连线 */
|
||||
.winline {
|
||||
position: absolute;
|
||||
height: calc(5 * var(--u));
|
||||
background: linear-gradient(90deg, var(--accent), var(--warn));
|
||||
border-radius: calc(3 * var(--u));
|
||||
transform-origin: 0 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 calc(18 * var(--u)) var(--accent-glow);
|
||||
}
|
||||
|
||||
/* 第 N 手 的落子说明,放在棋盘下方 */
|
||||
.turn {
|
||||
margin-top: calc(22 * var(--u));
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(20 * var(--u));
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0.04em;
|
||||
height: calc(26 * var(--u));
|
||||
}
|
||||
.turn b { color: var(--text); font-weight: 600; }
|
||||
.turn .xm { color: var(--accent); font-weight: 600; }
|
||||
.turn .om { color: var(--info); font-weight: 600; }
|
||||
|
||||
/* ---- 讲解面板 ------------------------------------------------ */
|
||||
.side {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(26 * var(--u));
|
||||
align-self: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tip {
|
||||
align-self: flex-start;
|
||||
font-size: calc(19 * var(--u));
|
||||
font-weight: 600;
|
||||
color: var(--warn);
|
||||
background: rgba(229, 165, 75, 0.12);
|
||||
border: calc(1 * var(--u)) solid rgba(229, 165, 75, 0.4);
|
||||
border-radius: calc(999 * var(--u));
|
||||
padding: calc(7 * var(--u)) calc(20 * var(--u));
|
||||
opacity: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tip:empty { display: none; }
|
||||
|
||||
.caption {
|
||||
font-size: calc(40 * var(--u));
|
||||
font-weight: 600;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 进度点:当前手高亮 */
|
||||
.progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(14 * var(--u));
|
||||
margin-top: calc(6 * var(--u));
|
||||
}
|
||||
.pdot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(10 * var(--u));
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(17 * var(--u));
|
||||
color: var(--text-ghost);
|
||||
opacity: 0;
|
||||
}
|
||||
.pdot i {
|
||||
width: calc(13 * var(--u));
|
||||
height: calc(13 * var(--u));
|
||||
border-radius: 50%;
|
||||
background: var(--border-strong);
|
||||
flex: none;
|
||||
}
|
||||
.pdot.on { color: var(--text-dim); }
|
||||
.pdot.on i { background: var(--accent); box-shadow: 0 0 0 calc(4 * var(--u)) var(--accent-glow); }
|
||||
|
||||
/* ---- 底部:落子序列 ------------------------------------------ */
|
||||
.strip {
|
||||
height: calc(74 * var(--u));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(14 * var(--u));
|
||||
border-top: calc(1 * var(--u)) solid var(--border);
|
||||
padding-top: calc(24 * var(--u));
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(21 * var(--u));
|
||||
opacity: 0;
|
||||
}
|
||||
.strip .lbl {
|
||||
color: var(--text-ghost);
|
||||
margin-right: calc(6 * var(--u));
|
||||
font-size: calc(17 * var(--u));
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: calc(38 * var(--u));
|
||||
height: calc(38 * var(--u));
|
||||
padding: 0 calc(10 * var(--u));
|
||||
border-radius: calc(9 * var(--u));
|
||||
background: var(--surface-2);
|
||||
border: calc(1 * var(--u)) solid var(--border);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.chip.x { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.chip.o { color: var(--info); border-color: rgba(107, 163, 214, 0.45); }
|
||||
.chip.last { background: var(--surface); border-color: currentColor; font-weight: 700; }
|
||||
.arrow { color: var(--text-ghost); }
|
||||
.strip .verdict {
|
||||
margin-left: auto;
|
||||
color: var(--warn);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="stage" data-composition-id="tictactoe" data-width="1920" data-height="1080" data-duration="5">
|
||||
<div class="top">
|
||||
<div class="eyebrow" id="eyebrow"></div>
|
||||
<div class="ptitle" id="ptitle"></div>
|
||||
<div class="rule"></div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="boardcol">
|
||||
<div class="board" id="board"></div>
|
||||
<div class="turn" id="turn"></div>
|
||||
</div>
|
||||
|
||||
<div class="side">
|
||||
<div class="tip" id="tip"></div>
|
||||
<div class="caption" id="caption"></div>
|
||||
<div class="progress" id="progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="strip" id="strip"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* === 临时诊断 A:脚本最开头(连 hv.duration 都还没跑)=== */
|
||||
var DBG_A = (function () {
|
||||
try { return Object.keys(window.__hyperframes.getVariables()).length; }
|
||||
catch (e) { return "err:" + (e && e.message); }
|
||||
})();
|
||||
var DBG_A_vals = (function () {
|
||||
try { return JSON.stringify(window.__hyperframes.getVariables()); }
|
||||
catch (e) { return "err"; }
|
||||
})();
|
||||
|
||||
/* 时长跟随宿主:场景时长由 narration 决定,不能写死。
|
||||
(hv build 会把真实时长注入 __hvDuration,hv.duration() 读到它。) */
|
||||
const DUR = window.hv ? hv.duration() || 5 : 5;
|
||||
const v = window.hv ? hv.readVars() : null;
|
||||
const getV = (k, d) => (v ? v.str(k, d) : d);
|
||||
const numV = (k, d) => (v ? v.num(k, d) : d);
|
||||
const AC = getV("accent", "#d97757");
|
||||
|
||||
/* ---- 棋局与规则(纯函数,与时间无关) -------------------------- */
|
||||
const LINES = [
|
||||
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
||||
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
||||
[0, 4, 8], [2, 4, 6],
|
||||
];
|
||||
|
||||
/** "5,2,9" → [4,1,0]。只留 1-9 的合法格;
|
||||
0 是「空棋盘」的哨兵(readVars 的 str() 把空串当没传,不能用 "")。 */
|
||||
function parseMoves(s) {
|
||||
return String(s || "")
|
||||
.split(",")
|
||||
.map((x) => parseInt(String(x).trim(), 10) - 1)
|
||||
.filter((n) => n >= 0 && n <= 8);
|
||||
}
|
||||
|
||||
/** 落子序列 → 终局状态 */
|
||||
function boardOf(moves) {
|
||||
const cells = Array(9).fill(null);
|
||||
moves.forEach((i, k) => { if (cells[i] == null) cells[i] = k % 2 === 0 ? "X" : "O"; });
|
||||
const win = LINES.find(
|
||||
(l) => cells[l[0]] && cells[l[0]] === cells[l[1]] && cells[l[1]] === cells[l[2]]
|
||||
) || null;
|
||||
return { cells, win, winBy: win ? cells[win[0]] : null };
|
||||
}
|
||||
|
||||
/* ---- 参数 ------------------------------------------------------ */
|
||||
const MOVES = parseMoves(getV("moves", "5,2,9,4,1"));
|
||||
const CAPTION = getV("caption", "");
|
||||
const TIP = getV("tip", "");
|
||||
const POINT = numV("point", 0) - 1; // 对外 1-9,内部 0-8
|
||||
const TITLE = getV("title", "井字棋 · 怎么玩");
|
||||
const EYEBROW = getV("eyebrow", "");
|
||||
|
||||
const B = boardOf(MOVES);
|
||||
const N = MOVES.length;
|
||||
const LAST = N ? MOVES[N - 1] : -1;
|
||||
const PREV = N > 1 ? MOVES[N - 2] : -1;
|
||||
|
||||
/* ---- 建 DOM(只建一次,render 只改属性) ------------------------ */
|
||||
const boardEl = document.getElementById("board");
|
||||
const cellEls = [];
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const c = document.createElement("div");
|
||||
c.className = "cell";
|
||||
c.innerHTML = '<div class="mark"></div><div class="ring"></div><div class="point-ring"></div>';
|
||||
boardEl.appendChild(c);
|
||||
cellEls.push(c);
|
||||
}
|
||||
const lineEl = document.createElement("div");
|
||||
lineEl.className = "winline";
|
||||
boardEl.appendChild(lineEl);
|
||||
|
||||
const eyebrowEl = document.getElementById("eyebrow");
|
||||
eyebrowEl.textContent = EYEBROW;
|
||||
document.getElementById("ptitle").textContent = TITLE;
|
||||
const capEl = document.getElementById("caption");
|
||||
capEl.textContent = CAPTION;
|
||||
const tipEl = document.getElementById("tip");
|
||||
tipEl.textContent = TIP;
|
||||
|
||||
const turnEl = document.getElementById("turn");
|
||||
turnEl.innerHTML = N
|
||||
? `第 <b>${N}</b> 手 · <span class="${(N - 1) % 2 === 0 ? "xm" : "om"}">${(N - 1) % 2 === 0 ? "X" : "O"}</span> 落子 → 第 <b>${LAST + 1}</b> 格`
|
||||
: "";
|
||||
|
||||
// 进度点:按已下的手数生成,当前手高亮
|
||||
const progEl = document.getElementById("progress");
|
||||
const pdotEls = MOVES.map((_, k) => {
|
||||
const d = document.createElement("div");
|
||||
d.className = "pdot" + (k === N - 1 ? " on" : "");
|
||||
d.innerHTML = "<i></i>" + (k % 2 === 0 ? "X" : "O");
|
||||
progEl.appendChild(d);
|
||||
return d;
|
||||
});
|
||||
|
||||
// 底部落子序列
|
||||
const stripEl = document.getElementById("strip");
|
||||
stripEl.innerHTML = "";
|
||||
const chipEls = [];
|
||||
if (N) {
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "lbl";
|
||||
lbl.textContent = "落子顺序";
|
||||
stripEl.appendChild(lbl);
|
||||
MOVES.forEach((m, k) => {
|
||||
if (k) {
|
||||
const a = document.createElement("span");
|
||||
a.className = "arrow";
|
||||
a.textContent = "→";
|
||||
stripEl.appendChild(a);
|
||||
}
|
||||
const ch = document.createElement("span");
|
||||
ch.className = "chip " + (k % 2 === 0 ? "x" : "o") + (k === N - 1 ? " last" : "");
|
||||
ch.textContent = m + 1;
|
||||
stripEl.appendChild(ch);
|
||||
chipEls.push(ch);
|
||||
});
|
||||
}
|
||||
const verdictEl = document.createElement("span");
|
||||
verdictEl.className = "verdict";
|
||||
verdictEl.textContent = B.winBy ? `${B.winBy} 连线 · 胜` : "";
|
||||
stripEl.appendChild(verdictEl);
|
||||
|
||||
/* ---- 缓动 ------------------------------------------------------ */
|
||||
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
||||
const easeOut = (x) => 1 - Math.pow(1 - x, 3);
|
||||
const easeOutBack = (x) => {
|
||||
const c1 = 1.70158, c3 = c1 + 1;
|
||||
return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
|
||||
};
|
||||
|
||||
/* ---- 渲染:纯函数 t → 画面 -------------------------------------
|
||||
每次从头算,不继承上一帧。这就是为什么可以任意 seek。 */
|
||||
function render(t) {
|
||||
// 最后一手的出现:0 ~ 0.52s
|
||||
const p = clamp01(t / 0.52);
|
||||
const pop = p <= 0 ? 0 : easeOutBack(p);
|
||||
|
||||
// 讲解:0.2s 开始淡入,跟着场景时长一直停住
|
||||
const capP = clamp01((t - 0.2) / 0.5);
|
||||
// 要点标签早一点点
|
||||
const tipP = clamp01((t - 0.1) / 0.4);
|
||||
// 底部序列
|
||||
const stripP = clamp01((t - 0.05) / 0.45);
|
||||
// 进度点随每一手依次点亮
|
||||
const progP = clamp01((t - 0.35) / 0.6);
|
||||
// 胜负:最后一手落定后再出现,给观众一点反应时间
|
||||
const winP = clamp01((t - 0.95) / 0.55);
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const c = cellEls[i];
|
||||
const m = B.cells[i];
|
||||
const markEl = c.firstChild;
|
||||
|
||||
if (!m) {
|
||||
// 空格:清空并保持隐藏
|
||||
markEl.textContent = "";
|
||||
delete c.dataset.m;
|
||||
markEl.style.opacity = 0;
|
||||
markEl.style.transform = "none";
|
||||
c.classList.remove("dim");
|
||||
c.querySelector(".ring").style.opacity = 0;
|
||||
c.querySelector(".point-ring").style.opacity = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
c.dataset.m = m;
|
||||
markEl.textContent = m;
|
||||
|
||||
if (i === LAST) {
|
||||
// 刚下的这手:出现动画
|
||||
markEl.style.opacity = clamp01(p * 1.6);
|
||||
markEl.style.transform = `scale(${pop})`;
|
||||
c.classList.remove("dim");
|
||||
|
||||
// 涟漪:从落点向外扩散一圈再散掉,纯 t 函数
|
||||
const rp = clamp01((t - 0.18) / 0.7);
|
||||
const ring = c.querySelector(".ring");
|
||||
ring.style.opacity = rp > 0 && rp < 1 ? (1 - rp) * 0.8 : 0;
|
||||
const rs = 0.5 + rp * 2.4;
|
||||
ring.style.transform = `scale(${rs})`;
|
||||
} else {
|
||||
// 之前下过的:直接在位(上一个场景已经显示过了)
|
||||
markEl.style.opacity = 1;
|
||||
markEl.style.transform = "none";
|
||||
// 上一手压暗一点,形成「刚刚那步」与「此前几步」的层次
|
||||
c.classList.toggle("dim", i === PREV);
|
||||
c.querySelector(".ring").style.opacity = 0;
|
||||
}
|
||||
|
||||
// 讲解指针:围绕被指的格子呼吸
|
||||
const pr = c.querySelector(".point-ring");
|
||||
if (i === POINT && POINT >= 0 && POINT <= 8 && p >= 1) {
|
||||
pr.style.opacity = 0.35 + 0.45 * (0.5 + 0.5 * Math.sin(t * Math.PI * 2 / 1.15));
|
||||
} else {
|
||||
pr.style.opacity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 胜利连线:横跨三子
|
||||
if (B.win) {
|
||||
const pct = (i) => ({ x: ((i % 3) + 0.5) / 3 * 100, y: (Math.floor(i / 3) + 0.5) / 3 * 100 });
|
||||
const a = pct(B.win[0]), b = pct(B.win[2]);
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const ang = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
const gp = clamp01(winP * 1.4); // 线从起点扫到终点
|
||||
lineEl.style.left = a.x + "%";
|
||||
lineEl.style.top = a.y + "%";
|
||||
lineEl.style.width = len * gp + "%";
|
||||
lineEl.style.transform = `rotate(${ang}deg)`;
|
||||
lineEl.style.opacity = winP > 0 ? clamp01(winP * 2) : 0;
|
||||
} else {
|
||||
lineEl.style.opacity = 0;
|
||||
}
|
||||
|
||||
capEl.style.opacity = capP;
|
||||
capEl.style.transform = `translateY(${(1 - easeOut(capP)) * 26}px)`;
|
||||
tipEl.style.opacity = tipP;
|
||||
tipEl.style.transform = `translateX(${(1 - easeOut(tipP)) * -18}px)`;
|
||||
stripEl.style.opacity = stripP;
|
||||
turnEl.style.opacity = stripP;
|
||||
|
||||
pdotEls.forEach((d, k) => {
|
||||
d.style.opacity = clamp01((progP * N - k) * 2);
|
||||
});
|
||||
|
||||
// 本手高亮在进度点上有轻微跟随,同样纯 t
|
||||
chipEls.forEach((ch, k) => {
|
||||
if (k === N - 1 && B.winBy) ch.style.transform = `scale(${1 + Math.max(0, winP) * 0.06})`;
|
||||
else ch.style.transform = "none";
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- 时间轴:交给渲染引擎 ---------------------------------------
|
||||
占位 tween 给引擎一个可测量的长度;真正出画面靠 onUpdate。 */
|
||||
/* === 临时诊断(用完删):把实际收到的参数画到画面上 === */
|
||||
var dbgEl = document.createElement("div");
|
||||
dbgEl.id = "dbg";
|
||||
dbgEl.style.cssText = "position:absolute;top:0;right:0;z-index:9999;background:#7a1010;color:#fff;font:15px/1.45 monospace;padding:8px 12px;white-space:pre;";
|
||||
(document.querySelector("[data-composition-id=tictactoe]") || document.body).appendChild(dbgEl);
|
||||
(function () {
|
||||
var r = {};
|
||||
r.A_开头直读 = String(DBG_A);
|
||||
r.A_开头内容 = DBG_A_vals.slice(0, 150);
|
||||
var nowDirect;
|
||||
try { nowDirect = Object.keys(window.__hyperframes.getVariables()).length; }
|
||||
catch (e) { nowDirect = "err"; }
|
||||
r.B_此刻直读 = String(nowDirect);
|
||||
r.C_hvNow = String(Object.keys(hv.vars()).length);
|
||||
try { r.H_selfTest = hv.selfTest(); } catch (e) { r.H_selfTest = "no selfTest:" + e.message; }
|
||||
/* 连续采样:直调 vs hv 交替,看是否有状态被消费/清空 */
|
||||
var seq = [];
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var d, h;
|
||||
try { d = Object.keys(window.__hyperframes.getVariables()).length; } catch (e) { d = "E"; }
|
||||
try { h = Object.keys(hv.vars()).length; } catch (e) { h = "E"; }
|
||||
seq.push("d" + d + "/h" + h);
|
||||
}
|
||||
r.I_交替采样 = seq.join(" ");
|
||||
var seq2 = [];
|
||||
for (var j = 0; j < 6; j++) {
|
||||
try { seq2.push(Object.keys(window.__hyperframes.getVariables()).length); } catch (e) { seq2.push("E"); }
|
||||
}
|
||||
r.J_连续直调 = seq2.join(",");
|
||||
r.D_DUR初始化时 = String(DUR);
|
||||
r.E_MOVES初始化时 = JSON.stringify(MOVES);
|
||||
r.F_当前hvReadKeys = JSON.stringify(Object.keys((v && v.raw) || {}));
|
||||
r.G_关键 = "A 与 B 不等 => 时序问题";
|
||||
dbgEl.textContent = Object.keys(r).map(function (k) { return k + "=" + r[k]; }).join("\n");
|
||||
})();
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to({}, { duration: DUR }, 0);
|
||||
tl.eventCallback("onUpdate", () => render(tl.time()));
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["tictactoe"] = tl;
|
||||
tl.seek(0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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: ""
|
||||
@@ -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: ""
|
||||
Vendored
+156
@@ -0,0 +1,156 @@
|
||||
/* ==========================================================================
|
||||
vibevideo 运行时:组件公用的小工具
|
||||
--------------------------------------------------------------------------
|
||||
只依赖浏览器,不依赖任何渲染引擎的私有 API。
|
||||
通过 <script src="/assets/runtime.js"> 引入。
|
||||
|
||||
为什么需要它:场景时长可能来自配音(由 Python 侧回写 project.yaml),
|
||||
组件如果把时长写死,配音一变就会被截断。host() / duration() 让组件
|
||||
自动跟随真实时长。
|
||||
========================================================================== */
|
||||
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
/** 找到挂载本组件的宿主元素(含 data-duration 的最近祖先)。 */
|
||||
function host() {
|
||||
var el = document.currentScript ? document.currentScript.closest("[data-composition-src]") : null;
|
||||
if (el) return el;
|
||||
// currentScript 在某些注入方式下不可用,退回:找第一个带 data-duration 的祖先
|
||||
var cur = document.querySelector("[data-composition-id]");
|
||||
while (cur && cur.parentElement) {
|
||||
cur = cur.parentElement;
|
||||
if (cur.hasAttribute && cur.hasAttribute("data-duration")) return cur;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var RESERVED = "__hv";
|
||||
|
||||
/** === 临时诊断(用完删):让闭包里的 global 自报身份 === */
|
||||
var __hvSelfTest = function () {
|
||||
var o = { sameWindow: String(global === window), hvType: typeof global.hv };
|
||||
try {
|
||||
o.hfType = typeof global.__hyperframes;
|
||||
o.globalKeys = Object.keys(global.__hyperframes.getVariables()).length;
|
||||
} catch (e) { o.globalErr = String(e && e.message || e); }
|
||||
try {
|
||||
o.winKeys = Object.keys(window.__hyperframes.getVariables()).length;
|
||||
} catch (e) { o.winErr = String(e && e.message || e); }
|
||||
return JSON.stringify(o);
|
||||
};
|
||||
global.__hvSelfTest = __hvSelfTest;
|
||||
|
||||
/**
|
||||
* 读 hv build 注入的保留键(__hvDuration / __hvStart)。
|
||||
*
|
||||
* 这是首选路径:hv build 把真实挂载时长写进 data-variable-values,
|
||||
* 组件通过公开的 getVariables() 拿到。引擎是 `{...声明的默认值, ...data-variable-values}`
|
||||
* 纯展开合并,未声明的键会原样透传。
|
||||
*
|
||||
* 为什么优先它:DOM 那条路基本取不到——引擎打包时会把
|
||||
* data-composition-src 改写成 data-composition-file,而 data-duration 在
|
||||
* clip 上、不在祖先上,向上遍历找不到任何带 data-duration 的祖先。
|
||||
*
|
||||
* 返回 null 表示这次注入不存在(旧的 index.html,或组件单独在浏览器里打开),
|
||||
* 由调用方退回 DOM 查找。
|
||||
*/
|
||||
function injected(suffix, allowZero) {
|
||||
var raw = vars();
|
||||
if (!raw) return null;
|
||||
var k = RESERVED + suffix;
|
||||
if (raw[k] === undefined || raw[k] === null || raw[k] === "") return null;
|
||||
var v = Number(raw[k]);
|
||||
if (!isFinite(v) || (!allowZero && v <= 0)) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本组件被挂载的实际时长(秒)。
|
||||
* 拿不到时返回 null,由调用方决定用默认值。
|
||||
*/
|
||||
function duration() {
|
||||
var d = injected("Duration", false);
|
||||
if (d != null) return d;
|
||||
var h = host();
|
||||
if (h) {
|
||||
var a = parseFloat(h.getAttribute("data-duration"));
|
||||
if (isFinite(a) && a > 0) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 本组件在整条时间轴上的起点(秒),没有则 0。 */
|
||||
function start() {
|
||||
var s = injected("Start", true); // 起点可以是 0,判定条件和时长不同
|
||||
if (s != null) return s;
|
||||
var h = host();
|
||||
if (h) {
|
||||
var b = parseFloat(h.getAttribute("data-start"));
|
||||
if (isFinite(b)) return b;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 读取本组件的参数;缺省回退到组件自己声明的 default。 */
|
||||
function vars() {
|
||||
try {
|
||||
return global.__hyperframes.getVariables();
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取参数并做类型转换,附带默认值。
|
||||
* v.str("title", "未命名")
|
||||
* v.num("duration", 3)
|
||||
* v.bool("dim", true)
|
||||
*/
|
||||
function readVars() {
|
||||
var raw = vars();
|
||||
return {
|
||||
raw: raw,
|
||||
str: function (k, dflt) {
|
||||
var x = raw[k];
|
||||
return x !== undefined && x !== null && x !== "" ? String(x) : dflt;
|
||||
},
|
||||
num: function (k, dflt) {
|
||||
var x = Number(raw[k]);
|
||||
return raw[k] !== undefined && raw[k] !== null && raw[k] !== "" && isFinite(x) ? x : dflt;
|
||||
},
|
||||
bool: function (k, dflt) {
|
||||
if (raw[k] === undefined || raw[k] === null || raw[k] === "") return dflt;
|
||||
if (typeof raw[k] === "boolean") return raw[k];
|
||||
return String(raw[k]) === "true";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.hv = {
|
||||
host: host,
|
||||
duration: duration,
|
||||
start: start,
|
||||
vars: vars,
|
||||
readVars: readVars,
|
||||
/** === 临时诊断(用完删) === */
|
||||
selfTest: __hvSelfTest,
|
||||
/** 缓动:省得每个组件都抄一份 */
|
||||
ease: {
|
||||
linear: function (t) { return t; },
|
||||
inQuad: function (t) { return t * t; },
|
||||
outQuad: function (t) { return t * (2 - t); },
|
||||
inOutQuad: function (t) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; },
|
||||
outCubic: function (t) { return 1 - Math.pow(1 - t, 3); },
|
||||
inCubic: function (t) { return t * t * t; },
|
||||
inOutCubic: function (t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; },
|
||||
outBack: function (t) {
|
||||
var c1 = 1.70158, c3 = c1 + 1;
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
||||
},
|
||||
},
|
||||
clamp01: function (x) { return Math.max(0, Math.min(1, x)); },
|
||||
/** 把 0..1 的进度映射到 [a,b] */
|
||||
seg: function (t, a, b) { return Math.max(0, Math.min(1, (t - a) / (b - a || 1))); },
|
||||
};
|
||||
})(window);
|
||||
Reference in New Issue
Block a user