chore: 初始化仓库,提交 vibevideo 源码

- src/: HyperFrames 封装 CLI(hv)、组件、品牌样式、音频(TTS/混音)工具
- projects/: 各项目 project.yaml 与文案
- interactive/: 井字棋演示页
- .gitignore: 忽略 node_modules、构建产物与 HyperFrames 运行期缓存
This commit is contained in:
2026-10-12 18:06:20 +08:00
commit 9b63424740
42 changed files with 8004 additions and 0 deletions
+456
View File
@@ -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>