cube-hf
v0.1.2
Published
Rubik's Cube animation framework — write scripts, output MP4 via HyperFrames
Maintainers
Readme
cube-hf
Rubik's Cube animation framework — write a script, get an MP4.
import { Scene } from "cube-hf";
const scene = new Scene({ theme: "dark" });
scene.perform("R U R' U'", {
duration: 4,
annotation: "基本公式: R U R' U'",
});
scene.pause(1);
scene.perform("U R D L R' x' y");
scene.output("demo.mp4");结构化公式与反演
Formula 可以保留公式结构,也可以在执行前展开为普通动作。交换子使用
[A, B] = A B A' B',setup/共轭使用 A: B = A B A':
import { Formula, Scene } from "cube-hf";
const cycle = Formula.parse("R' D' U': [R' D R, U']");
console.log(cycle.expand().join(" "));
// R' D' U' R' D R U' R' D' R U U D R
new Scene()
.perform(cycle)
.pause(1)
.perform(cycle.inverse())
.output("cycle-and-inverse.mp4");也可以完全使用对象 API 组合,适合复用 setup 或交换子:
const commutator = Formula.commutator("R' D R", "U'");
const cycle = Formula.conjugate("R' D' U'", commutator);
const repeated = Formula.sequence(cycle, cycle.inverse());
scene.perform(repeated);含 setup 的公式会公开独立、不可变的 setup、unsetup 与 body 公式对象;
unsetup 是 setup 的完整逆公式,而 inverse() 始终表示整个公式的反演:
const cycle = Formula.parse("R' D' U': [R' D R, U']");
cycle.setup.toString(); // R' D' U'
cycle.unsetup.toString(); // (R' D' U')'
cycle.body.toString(); // [R' D R, U']
cycle.inverse(); // 整个 cycle 的逆公式commutator 会穿过嵌套 setup 取得广义交换子的核心 [A, B],isCommutator
表示是否能找到这样的核心结构:
const nested = Formula.parse("R: U: [F, D]");
nested.body.toString(); // U: [F, D]
nested.commutator.toString(); // [F, D]
nested.isCommutator; // true
Formula.parse("R: U").isCommutator; // false不含顶层 setup 的公式,其 setup、unsetup 和 body 均为 null。不是广义
交换子的公式,其 commutator 为 null。整体反演后的结构访问器返回与反演后
公式语义一致的 body 或核心交换子。
支持括号、嵌套结构、[A: B] 共轭以及 (A B)' 整体反演。公式解析是严格的:
非法动作、缺失操作数或未闭合括号会抛出带错误位置的 SyntaxError,不会只执行其中可识别的部分。
Formula 同样可以用于初始状态:
const scramble = Formula.parse("[R, U]");
const scene = new Scene({ initialState: scramble });Prerequisites
- Node.js >= 22
- FFmpeg (for video encoding)
- Chrome (managed by HyperFrames CLI)
Install
npm install cube-hfUsage
Scene API
import { Scene } from "cube-hf";
const scene = new Scene({ theme: "dark" });
// 公式演示:整段时长与强关联标注
scene.perform("R U R' U'", {
duration: 4,
annotation: {
text: "OLL 33",
position: "bottom",
},
});
// 单步节奏:普通动作 0.6s,双转 0.9s,内部间隔 0.2s
scene.perform("R U2", { stepDuration: 0.6 });
// 独立标注:不推进时间游标
scene.annotate("下一步观察顶层", {
start: "current",
duration: 1,
});
scene.pause(1); // 暂停
scene.camera({ x: 8, y: 6, z: 10 }, { duration: 2 }); // 镜头推移
scene.perform("U R D L R' x' y");
// 输出视频(阻塞直到完成)
scene.output("tutorial.mp4");
// 浏览器实时预览(不阻塞,带播放/拖动/focus 选择和 MP4 导出)
const url = scene.preview(); // http://127.0.0.1:3002/
// 快速生成并打开指定秒数的 PNG
const framePath = scene.frame(2.5);
// 保存单帧但不打开,适合自动化测试
scene.frame(2.5, {
filePath: "frames/check.png",
open: false,
});perform(formula, { duration }) 的 duration 表示从第一个动作开始到最后一个动作结束的整段公式演示时长。stepDuration 表示普通 90° 动作的单步时长;双转动作使用其 1.5 倍,内部间隔使用其三分之一。两种选项互斥,且公式末尾都不会额外加入间隔。未指定时长时,普通动作、双转动作和间隔的默认时长分别为 0.3、0.45 和 0.1 秒。内嵌 annotation 自动使用同一开始和结束时间。
独立 annotate(text, options?) 默认从当前时间游标开始显示 2 秒,也可以通过数值 start 和互斥的 duration / end 指定区间。start: "current" 表示当前游标,end: "scene-end" 会在生成 Scene snapshot 时解析为最终场景结束时间。
命名区间与时间引用
通过 id 命名一个有限区间后,camera、focus 和 annotation 可以使用 during 复用其完整时间,不需要手算绝对秒数:
scene.perform("R U R' U'", {
id: "commutator",
duration: 3,
annotation: "COMMUTATOR",
});
// 镜头在整个公式区间内移动到目标位置,随后保持该位置
scene.camera(
{ x: 8, y: 6, z: 10 },
{ during: "commutator" },
);
scene.focus(["UFL"], { during: "commutator" });
scene.annotate("观察这个角块", { during: "commutator" });也可以引用命名区间的开始或结束边界,并添加正负偏移:
scene.annotate("下一步", {
start: { ref: "commutator", edge: "end", offset: -0.3 },
duration: 1,
});
scene.range("commutator");
// => { start: 0, end: 3, duration: 3 }during 与 start / duration / end 互斥,duration 与 end 互斥。命名 ID 必须唯一且先定义后引用。正持续时间的 camera 运动不能重叠,相邻运动可以首尾衔接。
scene.cursor 是下一段顺序内容的起点,scene.duration 是覆盖游标及所有有限事件的可渲染总时长。scene.inspect() 返回按时间排序、深度只读的规范化时间线视图;需要调试时应优先使用它,而不是直接修改兼容属性 scene.timeline。
scene.frame(time, options?) 复用与 preview、视频输出相同的 composition 和 seek 逻辑,但只渲染指定时间点,不生成或编码完整视频。time 必须位于 0 到 Scene 总时长之间;未指定 filePath 时,返回保留在临时目录中的 PNG 绝对路径。
从打乱态开始并持续关注目标块
initialState.scramble 只设置首帧状态,不会作为动画播放。focus() 会把首帧中的目标位置解析为稳定的物理块,因此角块的三个外贴纸、棱块的两个外贴纸或中心块的单个外贴纸会在后续公式执行时持续保持高亮。
focus() 是不推进时间游标的时间轴行为。默认从当前游标持续到场景结束,也可以使用与独立 annotation 相同的 start 和互斥的 duration / end 指定区间。多个区间重叠时,后定义的 focus 优先;有限区间结束后,仍有效的较早 focus 会恢复。传入空目标可在指定区间清除 focus。
import { Scene } from "cube-hf";
const scene = new Scene({
theme: "dark",
initialState: { scramble: "R U R' F2 D" },
});
scene.focus(["URF", "UFL", "DFR"], {
mode: "dim-others", // 或 "highlight-only"
dimColor: "#777777",
dimOpacity: 0.42,
focusEmissive: 0.35,
});
scene.pause(1);
scene.perform("R U R' U'");
scene.output("tracked-cycle.mp4");在不同公式阶段切换关注块:
scene
.focus(["URF"])
.perform("R U")
.focus(["UFL"], { duration: 1.5 })
.perform("F R")
.focus([], { end: "scene-end" });需要只追踪单张物理贴纸时,显式设置 scope: "sticker"。此时 slot code 的首字母表示被关注的贴纸面,例如 URF 表示 URF 位置上的 U 贴纸:
scene.focus(["URF"], {
scope: "sticker",
mode: "highlight-only",
});演示视频中如果不需要用贴纸颜色表达关注,可以使用全息选择式高亮。backlit 不会替换目标贴纸的表面材质;与 dim-others 组合时,目标贴纸和其他贴纸使用相同的置灰与半透明外观。color 只控制叠加光色:贴纸上方会悬浮一层很薄的全息膜,和贴纸表面隔开一小段距离,包含轻微蓝白 halo、细网格、四角角标,以及 focus 进入时的确定性扫描高光:
scene.focus(["UFR"], {
scope: "sticker", // 只关注 UFR 角块的 U 贴纸
mode: "dim-others",
style: "backlit",
color: "#1687ff", // 可省略,默认同为 #1687ff
});color 控制全息膜、角标、halo 和扫描高光的统一颜色;旧写法 focusColor 仍然可用并会被规范化到同一配置。backlit 随物理贴纸运动,不使用全局 bloom、呼吸或随机闪烁效果;扫描高光由 composition time 确定,preview、frame 和 output 在同一时间点一致。未指定 style 时,focus 继续沿用贴纸原色与 focusEmissive。
裸 cubie id 只能用于默认的整块追踪,因为它没有指定应选择哪一张贴纸。
整块 scope 下 slot code 的字母顺序不影响选择结果,例如 URF、RFU 与 FUR 均表示同一个角块。单贴纸盲拧编码可以通过 encoding 原生解析;内置 "chichu" / "中国彳亍法" 使用 FACE:CUBIE -> 字母 映射,并默认按角块编码解析到贴纸 focus:
scene.focus("JAD", {
encoding: "chichu",
pieceType: "corner", // "corner" | "edge" | "center"
style: "backlit",
mode: "dim-others",
legend: { title: "Targets" },
});编码目标会自动使用内置默认配色,这些颜色会同时用于 backlit 高亮、focus 图例和浏览器导出。需要自定义时,传 colors 覆盖默认 palette;也可以继续在单个目标上写 color / focusColor 做更细粒度覆盖:
scene.focus("JAD", {
encoding: "chichu",
style: "backlit",
legend: true,
colors: ["#38d9ff", "#ffbf4d", "#7ee787"],
});也可以链式设置初始态:
new Scene()
.setInitialState({ scramble: "R U F" })
.focus(["URF"], { mode: "highlight-only" })
.perform("R U R'")
.output("demo.mp4");投影不可见面
默认视角主要显示 R、U、F。可以让 L、D、B 的贴纸沿各自外法线向远处投射发光副本,方便在不移动主镜头的情况下观察隐藏贴纸:
const scene = new Scene({
projectHiddenFaces: true, // 使用完整六面可见预设
});
scene.perform("L D B");
scene.output("hidden-faces.mp4");也可以选择部分面并调整显示参数:
new Scene()
.projectHiddenFaces(["L", "B"], {
distance: 5,
scale: 0.8,
opacity: 0.9,
cameraFov: 52,
})
.perform("L B'");projectHiddenFaces: true 使用经过不透明魔方实渲比较的完整六面可见预设:投影距离 9、透明度 0.82、投影构图 FOV 68°。B 面会额外沿负 Z 外移并向正 X 轻微错位,确保九块贴纸都不被本体遮挡。投影参与正常深度测试,不会穿透或覆盖魔方本体;该预设不改变全局魔方大小默认值。hiddenFaces: true 仍作为兼容写法。
投影保持原面在三维空间中的位置关系和朝向,并从主 cubie 材质继承颜色与透明度,因此 dim-others 置灰时对应投影也会置灰。若目标贴纸使用 style: "backlit",转到 L/D/B 投影面后,对应投影 tile 会继续叠加同款全息膜、角标与扫描高光,而不是退化成普通置灰贴纸或被改成 focusColor 色块。转动过程中,真实物理贴纸会连续投射到远处平面:投影随贴纸移动,并根据贴纸朝向平滑淡入或淡出,不会在动作完成时突然切换排列。
预设主题
| Theme | Background | Vibe |
| ------- | ---------- | -------- |
| dark | #111 | 默认,标准光照 |
| light | #f5f5f5 | 浅色背景,柔和光照 |
| neon | #0a0a0a | 霓虹色彩,额外点光源 |
How It Works
User Script → Scene API → CompositionGenerator → index.html (inline cube-engine)
→ HyperFrames CLI render → MP4所有 cube-engine 逻辑(状态计算、公式解析、Three.js 渲染)被打包为内联 IIFE 嵌入生成的 HTML,无需外部请求。
Examples
See examples/ directory for runnable sample scripts.
