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
+365
View File
@@ -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>