HyperFrames sdk-playground 实战指南:用浏览器实时编辑 Composition、驱动 @hyperframes/sdk 全操作面
【免费下载链接】hyperframesWrite HTML. Render video. Built for agents.项目地址: https://gitcode.com/GitHub_Trending/hy/hyperframes
@hyperframes/sdk-playground是 HyperFrames 仓库中面向@hyperframes/sdk的浏览器交互式沙盒:你可以打开一段 Composition HTML,通过属性面板、Ops 面板与 DAW 风格时间轴走完 SDK 的全部操作面,并观察沙箱 iframe 中预览的实时刷新。阅读本文后,你将理解 playground 的启动方式、文件持久化与版本快照链路、预览 iframe 的 postMessage 桥接原理、各类编辑面板与底层 SDK op 的映射关系,以及 Timeline 与 GSAP 脚本、DOM 属性保持同步的实现机制。
什么是 sdk-playground
按 packages/sdk-playground/README.md 的定义,playground 是@hyperframes/sdkAPI 的"交互式浏览器演练场"(Interactive browser playground):打开一个合成(composition),通过完整的 SDK op 表面去编辑它,并观看预览实时更新。
它本质上是一个极简的"可视化 SDK 测试台",覆盖 HyperFrames 核心理念——"Write HTML, Render video"——中的 HTML 编辑与 op 应用环节,适合三类用户:
- 应用开发者:不需要写前端 UI,直接在一个页面里逐条试验
comp.setStyle、comp.addGsapTween、comp.selection()等 API 的真实行为; - 测试工程师:每个 ops 区块就是一次可重复的交互式冒烟测试;
- 学习 SDK 的读者:通过属性面板的输入框与日志面板的 patch 流,直观看到每一次编辑产生的底层变更。
从源码结构看,playground 是一个单页应用 + Vite 开发服务器插件,主体文件非常集中:
| 文件 | 职责 |
|---|---|
| index.html | 页面骨架:header、预览区、inspector(Properties/Ops)、patch 日志面板、时间轴、Open 对话框 |
| src/main.ts | 全部业务逻辑:SDK 会话、预览桥接、元素树、属性/操作面板、时间轴拖拽(约 1466 行) |
| src/fileAdapter.ts | 浏览器侧的PersistAdapter,通过 fetch 对接 Vite 插件的 REST 端点 |
| src/fileAdapter.test.ts | 对 fileAdapter 的 Vitest 单元测试 |
| vite.config.ts | 注入compositionPlugin,用@hyperframes/sdk/adapters/fs提供持久化 REST API |
| package.json | 依赖与脚本 |
依赖上,playground 直接使用@hyperframes/core(GSAP 脚本解析)与@hyperframes/sdk(组合会话),并在运行时把gsap/dist/gsap.min.js以?raw形式注入预览 iframe(见 main.ts)。
启动方式与首次加载流程
仓库使用 bun workspace 管理(根目录存在 bun.lock),playground 的脚本定义在 package.json 中:
# 在仓库根目录安装依赖后,启动 playground bun install bun run --cwd packages/sdk-playground dev服务默认监听http://localhost:5173,对应 npm scripts:
{ "scripts": { "dev": "vite", "build": "vite build", "typecheck": "tsc --noEmit", "test": "vitest run" } }首次加载时有一个关键的"读盘"逻辑(README 原文):
首次加载时它会从磁盘读取
packages/sdk-playground/composition.html(如果存在),否则回退到内置的演示合成。
对应到 main.ts 的init():
async function init() { wireStaticControls(); updatePreviewScale(); const { initialHtml } = await createFileAdapter(); await openEditor(initialHtml ?? DEMO_HTML, initialHtml ? "composition.html" : "demo"); }createFileAdapter()会先read("composition.html"),命中 404 时返回undefined,此时就用内置的演示合成DEMO_HTML(main.ts)。这段演示合成本身就是一份标准的 HyperFrames 最小示例,值得拆开看:
<div>async function openEditor(html: string, name = "untitled") { if (comp) { comp.dispose(); comp = null; } resetUiForOpen(name); const { adapter: persist } = await createFileAdapter(); const preview = new PlaygroundPreview(); playgroundPreview = preview; comp = await openComposition(html, { persist, preview, coalesceMs: 150 }); wireCompositionEvents(comp); // ...刷新元素树、inspector、预览 iframe 与时间轴 }可以看到openComposition(由 packages/sdk/src/session.ts 导出,入口见 packages/sdk/src/index.ts)同时接收三个关键配置:
persist:持久化适配器,让每次编辑最终落到磁盘;preview:预览适配器(PreviewAdapter),把内部选中态外接到自定义 UI;coalesceMs: 150:op 合并窗口——高频连续编辑(如拖拽)会被合并成更少的 patch,而不是一次拖拽产生几十个 patch 事件。
架构总览:Vite 插件、SDK 会话与沙箱 iframe 三方协作
要理解 playground 各功能面板,先把握它的三层架构:
- Vite dev-server 插件层(vite.config.ts):用
createFsAdapter({ root: COMP_ROOT })(来自@hyperframes/sdk/adapters/fs)创建 Node 侧持久化适配器,并在 dev server 上挂出三个 REST 端点,浏览器无法直接访问 fs 模块,全部读写都经过这些 HTTP 接口; - SDK 会话层(浏览器内):页面持有
Composition实例,所有面板操作最终都调用其 op 方法,产生 patch 事件流并定时刷新 UI 与 iframe; - 预览渲染层:
sandbox="allow-scripts"的 1280×720 iframe,先经srcdoc注入整段 HTML + 内联 GSAP,再通过window.postMessage与父页面双向通信,支持播放/暂停/seek、点击选中、拖拽换位与时长回传。
其中浏览器侧的 HTTP 客户端由 fileAdapter.ts 提供,它实现的是 SDK 定义的PersistAdapter契约(见 packages/sdk/src/adapters/types.ts)。三个 REST 端点的语义如下:
| 端点 | 方法 | 行为 |
|---|---|---|
/api/composition | GET | 读取当前 composition.html,404 视为无内容 |
/api/composition | PUT | 写入整份 HTML(Content-Type: text/html),204 成功 |
/api/composition?version=<key> | GET | 读取指定历史版本 |
/api/composition/versions | GET | 返回版本列表 JSON(key与timestamp) |
浏览器侧FileAdapter.read/write的失败处理值得留意:写失败并不会 reject 调用方,而是通过on("persist:error", ...)把错误转发给事件订阅者(fileAdapter.ts),这保证了 UI 线程不会被持久化异常打断,错误以日志形式出现在 Patch 面板。
文件持久化与版本快照
README 对持久化行为的描述可以拆成三条:
- Composition 状态会经 Vite dev-server 插件持久化到
packages/sdk-playground/composition.html; - 插件底层是
@hyperframes/sdk/adapters/fs; - 每次保存都会向
.hf-versions/composition.html/写入一份带时间戳的快照,最多保留 20 份;刷新页面后最后一次状态会被还原。
fs 适配器的版本上限实现
"上限 20"在 packages/sdk/src/adapters/fs.ts 中是显式的默认值,可通过构造参数覆盖:
export interface FsAdapterOptions { /** Root directory for composition files */ root: string; /** Max versions to keep per file. Default: 20 */ maxVersions?: number; } const DEFAULT_MAX_VERSIONS = 20;每次write内部先写主文件,再调用appendVersion(path, content)追加一份版本快照;版本目录固定为join(root, ".hf-versions", path)(fs.ts),因此实际落盘结构正是 README 所说的.hf-versions/composition.html/。listVersions会按文件名排序倒序返回,loadFrom(path, versionKey)则按{key}.html读取指定快照。fs.ts中还为写操作维护了按文件隔离的写锁与 inflight 集合,flush()会等待所有在途写入完成——这正是 SDK 的PersistQueue能安全落盘的基础。
浏览器侧如何"假装"直接读文件
fileAdapter.ts 把所有 fs 能力翻译成 fetch:
async read(_path: string): Promise<string | undefined> { const res = await fetch(API); // GET /api/composition if (res.status === 404) return undefined; // 首次启动、文件不存在 → 走内置 demo if (!res.ok) throw new Error(`read failed: ${res.status}`); return res.text(); }配套的 fileAdapter.test.ts 用 vitest stub 掉全局fetch,验证了三件事:初始内容通过公开 SDK 适配器契约加载;写失败通过persist:error上报而不 reject;版本列表被映射成 SDK 的版本结构。这套测试也可当作自定义PersistAdapter的参考范式——运行方式:
bun run --cwd packages/sdk-playground test预览 iframe:播放、点选、拖拽与消息桥
README 列出的预览能力包括:transport bar 的 Play / Pause / Seek、点击选中元素(高亮同步到树与属性面板)、拖拽换位(松手后调用comp.setStyle(id, { left, top }))。这些能力背后是 index.html 中的沙箱 iframe:
<iframe id="preview-frame" sandbox="allow-scripts" title="composition preview"></iframe>只开放allow-scripts的 sandbox 意味着预览环境无法操作父页面 DOM、也无法发普通网络请求——父页面与预览之间唯一通道就是postMessage。
srcdoc 组装与缩放
每次需要刷新预览时,buildSrcdoc(html, selId)(main.ts)把"页面缩放样式 + 高亮样式 + GSAP 源码(${gsapRaw},来自gsap/dist/gsap.min.js?raw的文本导入)+ Composition HTML + 桥接脚本"拼成一份独立的<!DOCTYPE html>,赋给iframe.srcdoc。同时页面用ResizeObserver监听外容器宽度,按1280基准计算scale,把 720p 画布等比缩放铺满预览区(updatePreviewScale)。预览更新有 350ms 的防抖(schedulePreviewUpdate,main.ts),让连续多次编辑合并为一次 iframe 重建。
桥接脚本:一个纯字符串 iframe
预览里运行的BRIDGE_SCRIPT(main.ts)是刻意做成"纯字符串、运行在沙箱 iframe 内"的脚本,它做了几件事:
- 拖拽感知的指针处理:
mousedown时向上查找带data-hf-id的祖先记录起始坐标;mousemove位移超过 3px 才判定为拖拽并实时改写el.style.left/top;mouseup时若发生过拖拽则发送{type:'hf:dragend', id, dx, dy},否则发送{type:'hf:click', id};点击空白处发送hf:deselect; - 时间上报:
tick()通过window.__timelines计算当前最大tl.time(),以hf:time消息回传父页面,播放时才用requestAnimationFrame续帧; - 命令接收:父页面发来的
hf:select(描边高亮选中元素)、hf:seek(对全部时间线seek(t, false))、hf:play/hf:pause(驱动播放并启停 tick); - 初始时长探测:加载 120ms 后取所有时间线的
totalDuration最大值,以hf:duration回传,并先把时间线 seek 到结尾——这样预览首帧显示的是 GSAP 的"到达态"而非 t=0 的 from 初值,避免标题元素一闪而过的错觉。
父页面侧在 main.ts 用一个MSG_HANDLERS表分发这些消息:hf:click→ 调preview.select([id]);hf:dragend→ 用拖拽增量叠加原inlineStyles后调comp.setStyle(id, { left, top });hf:duration→ 设置时间轴总时长并渲染轨道;hf:time→ 更新播放头与 scrubber。播放到结尾时maybeLoop做边沿触发回卷(pct < 0.99且上次>= 0.99),实现无缝循环。
选中高亮为何不重载
一个值得注意的细节:选中态的高亮在首帧渲染时是烘焙进srcdoc的 CSS([data-hf-id="..."]{outline:2px solid #3b82f6}),后续选中切换则走sendSelectionToIframe(id)发hf:select让 iframe 内的脚本直接改outline——因此点选不会触发整页 iframe 重建,保证交互流畅。
元素树与选中联动
元素树列出所有"非根"元素。实现上(main.ts)先取comp.getElements(),再过滤掉带data-hf-root属性的根节点:
const elements = comp.getElements().filter((e) => !e.attributes["data-hf-root"]);每个条目渲染为<tag>+id+ 截断文本,点击即调用setSelection(id)(main.ts),它会联动完成四件事:更新 header 的sel-display、重绘元素树选中样式、重绘 inspector 内容、向 iframe 发送即时高亮。另外 SDK 的selectionchange事件也会回调onSelectionChange,把ids[0]作为新选中项——也就是说,iframe 内点击、Ops 面板的preview.select([id])与 SDK 内部选择变化会走同一条链路,UI 永远跟着真实选择状态走。
属性面板:每项编辑对应的 SDK op
属性面板(Properties tab)对选中的元素分组展示可编辑属性,README 中的映射表就是最直接的"控件 → op"索引:
| 分区 | SDK op |
|---|---|
| Content | comp.setText(id, value) |
| Typography | comp.setStyle(id, { fontSize, fontWeight, color, fontFamily }) |
| Box | comp.setStyle(id, { top, left, width, height }) |
| Attributes | comp.element(id).setAttribute(name, value)——展示所有非内部属性 |
| Danger | comp.element(id).removeElement() |
| Animations | comp.setTiming(id, { start, duration })——按单个 GSAP tween 提供内联表单 |
各分区源码实现的位置与细节如下(全部位于 main.ts):
- Content(
appendContentSection,约 L501):仅当el.text !== null(元素有文本)时展示一个 textarea,blur 时comp.setText(selectedId, value)并记录 op 日志; - Typography(约 L525):颜色走
input[type=color]+ 文本框双控件,字号为文本输入,字重是下拉(["","300","400","500","600","700","800","900"],对应字重字面量),提交都统一走commitStyle(prop, value); - Box(约 L535):背景色、透明度、left、top 四个输入;
- Attributes(约 L571):遍历
el.attributes,过滤掉data-hf-前缀内部属性以及class、style后逐条渲染输入框,blur 时commitAttr; - Danger:一个红色 "Remove element" 按钮,直接
comp.element(id).removeElement(); - Animations(约 L600):若
el.animationIds非空则以绿色 chip 列出该元素绑定的动画 ID;另有 "+ Add tween" 表单(to/from/fromTo三选一 + property/value/duration/ease/position),提交后comp.addGsapTween(selectedId, spec),并把返回的新 tween id 存入lastTweenId供 Ops 面板的 set/remove 复用。
所有属性提交最终都会在 Patch 日志面板产生一条带颜色的[op]记录(commitStyle会写入{ "element().setStyle": { id, [prop]: value } }这样的结构化日志),因此每点一下都能看到 SDK 真实收到的操作载荷。
时间轴:DAW 风格 tween 块与 setTiming 同步
时间轴(Timeline)把每个元素的动画渲染为"轨道行 + 色块",README 描述如下:
DAW 风格的元素级 tween 块。拖动两端手柄可修剪 start/end;拖动块身可平移。所有编辑都经过
comp.setTiming(id, { start, duration }),该调用会让 GSAP 脚本与 DOM 属性始终保持同步。
轨道数据从哪来
关键点是:时间轴并不持有独立的动画状态机,而是每次渲染时现解析序列化后的 HTML。parseTimelineData()(main.ts)的流程是:
comp.serialize()拿到当前完整 HTML;- 用正则提取其中
<script>内容; - 交给
@hyperframes/core/gsap-parser-acorn的parseGsapScriptAcorn(script)得到结构化动画列表; groupAnimationsById()按 tween 的targetSelector中的data-hf-id(或回退到 selector 本身)分组为轨道。
resolveTweenTiming(约 L308)读取元素 DOM 属性上的data-start/data-end——这些属性正是setTiming写入的——并优先采用它们,找不到才回退到 GSAP 解析得到的resolvedStart/duration。这实现了 README 所说的"keep the GSAP script and DOM attributes in sync":DOM 属性是权威计时来源,GSAP 解析值只是解析兜底。
拖拽与修剪的三种手势
每个色块由左右手柄tl-handle-l/tl-handle-r与块身组成(buildTweenBlock,约 L316)。拖拽状态机在 main.ts:
| 手势 | 类型 | 效果 | 松手提交 |
|---|---|---|---|
| 拖块身 | move | start平移,duration 不变 | comp.setTiming(id, { start }) |
| 拖右手柄 | trim-end | duration 变化,start 不变 | comp.setTiming(id, { start, duration }) |
| 拖左手柄 | trim-start | 右缘固定,start 与 duration 同时变化(duration 下限 0.05s) | comp.setTiming(id, { start, duration }) |
拖拽期间只更新 DOM 上色块的left/width与dataset.start/duration,真正写回 SDK 发生在mouseup且确认发生过位移之后(finishDrag→commitDragTiming)。没有位移的按下被视为点击,会回退成setSelection(trackId)——选中元素而非误移动画。时间轴还带播放头(tl-playhead)、scrubber(0-1000 映射 0-100%)与循环播放,时长来自 iframe 回传的hf:duration。
Ops 面板:可交互的 SDK 全操作面
Ops 面板按功能分组陈列 SDK 的完整操作面,README 给出了官方映射表,源码中则对应OPS_SECTIONS数组(main.ts)里的 14 个区块构建函数。下面逐一说明其真实交互与底层 op:
| Ops 区块 | 交互 | SDK op |
|---|---|---|
| PreviewAdapter.select() | 为每个非根元素生成一个按钮 + clear 按钮 | preview.select([id])/select([]) |
| setStyle | 颜色输入 + "Headline color"、Bold、Reset weight | comp.setStyle(id, styles),重置时传fontWeight: null表示清除覆盖 |
| setText | 文本输入框 + Set | comp.setText(id, value) |
| addGsapTween | target/dur/ease/prop/val 输入,Add 后把返回 ID 显示在绿色徽标上 | comp.addGsapTween(target, spec) |
| setGsapTween / removeGsapTween | 作用于"最近一次添加的 tween" | comp.setGsapTween(lastTweenId, updates)/comp.removeGsapTween(lastTweenId) |
| addLabel / removeLabel | name + position 输入 | comp.dispatch({ type: "addLabel"|"removeLabel", ... }) |
| setClassStyle | selector/prop/value 输入 | comp.dispatch({ type: "setClassStyle", selector, styles }) |
| setAttribute / removeElement | 针对当前选中元素 | comp.element(id).setAttribute(name, value|null)/.removeElement() |
| setVariableValue | variable id + value 输入 | comp.setVariableValue(id, value) |
| find(query) | tag/text 条件输入,结果显示匹配 ID 列表 | comp.find({ tag, text, name, track }) |
| selection() proxy | 显示当前getSelection(),可对整组做样式/删除 | comp.selection().setStyle()/.removeElement() |
| listVersions / loadFrom | 列出全部版本、"Load oldest"加载最旧版本 | adapter.listVersions()/adapter.loadFrom() |
| History / inspect | Undo / Redo / 可执行性探测 / 覆盖读取 / 冲刷 | comp.undo()、redo()、can(op)、getOverrides()、flush() |
几个值得展开的工程细节:
- 加 tween 的规格结构:Ops 里 addGsapTween 拼出的
GsapTweenSpec(与属性面板的 tween 表单同构)包含method("to","from"或"fromTo")、duration、ease(默认power2.out)、properties(属性名可带字符串值,coerceNum会尽量转数字)、可选的position。这正是 SDK 层定义的类型,见 packages/sdk/src/index.ts 导出的GsapTweenSpec; - 历史探测:
can({ type: "addGsapTween", target: "hf-badge", tween: {...} })这类调用展示的是 SDK 的"先问后做"能力——不实际执行也能确认某 op 是否可应用; - 版本恢复现状:README 明确说明目前 UI 只暴露了"列出所有版本 + 加载最旧版本"两个入口(
buildVersionsSection中listVersionsInto把versionLabel(key + 本地化时间)展示到滚动区,loadOldestVersion取列表最后一项后调openEditor(html, "v{key}")重新打开),真正的"逐版本浏览/恢复"仍在规划中。
Editor 弹窗:直接编辑原始 HTML
点击顶栏 "Open…" 会打开一个覆盖层(index.html 的#open-overlay):对话框提示你粘贴"任意 HyperFrames composition HTML——即外层data-hf-root元素及其内容",textarea 预填comp.serialize()或内置 demo。确认时走confirmOpen(main.ts):
function confirmOpen() { const ta = document.getElementById("open-textarea") as HTMLTextAreaElement; const html = ta.value.trim(); if (!html) return; hideOpenOverlay(); openEditor(html, "custom").catch((err) => logEntry("persist:error", String(err))); }对应 README 的"Save 之后会通过 SDK 重新打开该合成":openEditor先comp.dispose()销毁旧会话,再以新 HTML 调用openComposition重建。这样做的结果是——你既可以把它当作"从任何来源粘贴 HTML 来试跑"的入口,也可以理解为 playground 对serialize()(HTML 导出)→openComposition()(HTML 导入)这个无损往返能力的自证。
Patch 日志:观察 SDK 事件流
右侧 Patch 日志面板是理解 SDK 事件模型的窗口。main.ts 为不同事件类型分配了颜色:
| 类型 | 颜色 | 含义 |
|---|---|---|
patch | 蓝 | 每次 op 产生的补丁,带序号(patch #N)与 patches 载荷 |
undo/redo | 琥珀 | 历史回退/重做事件 |
selectionchange | 紫 | 选中集合变化 |
persist:error | 红 | 落盘失败事件 |
op | 绿 | 面板发起的高层操作日志 |
info | 灰 | 提示性信息 |
日志上限 300 条,超出后从底部移除最旧条目。事件接线在wireCompositionEvents(main.ts):订阅patch、persist:error、selectionchange三个事件;其中每个 patch 都会触发"防抖刷新预览 + 重绘元素树 + 重绘 inspector + 重绘时间轴"四连刷新。也就是说,无论操作来自属性面板、Ops 面板、时间轴拖拽还是 iframe 拖拽,最终都收敛到同一条 SDK 事件流水线,UI 只对事件做响应式更新——这是 playground 能保持所有视图一致的根本原因。
预览与持久化的底层复用
从设计上可以总结出一个清晰的复用模式:playground 没有维护自己的"文档状态",所有权威数据都在Composition内部。三种输出都只是对同一个状态的投影:
- 预览:
comp.serialize()→iframe.srcdoc; - 持久化:
PersistAdapter订阅并落盘每次变更(底层fs适配器负责主文件 + 20 份版本快照); - 时间轴/元素树:
comp.serialize()/comp.getElements()的派生渲染。
这也解释了为什么 README 会说 reload 后状态自动恢复——刷新时createFileAdapter().read()拿到上次 PUT 到composition.html的 HTML,再走一遍openComposition即可。SDK 侧还提供了与 fs 适配器互补的浏览器端参考实现:createMemoryAdapter、createHeadlessAdapter与createIframePreviewAdapter均从 packages/sdk/src/index.ts 导出,其中iframe预览适配器与 playground 手写的PlaygroundPreview解决的是同一类问题。
尚未接线的规划项
README 末尾明确列出以下"planned / not yet wired"清单,写作时点它们仍未接入 UI,引用时不应当作已可用功能:
comp.setTrackVariable(trackId, variableId)——按轨道绑定变量;comp.addElement(spec)——从 UI 创建新元素;comp.duplicateElement(id)——带偏移的复制;- 多选(当前仅单选);
- 长合成的时间轴缩放与横向滚动;
- 版本历史浏览器——内联列出/预览/恢复历史版本(API 已实现,UI 目前只有列表 + 加载最旧版本);
comp.on('change', cb)——由 SDK 事件流驱动的实时事件日志;- 通过
@hyperframes/producer集成渲染成视频。
总结
@hyperframes/sdk-playground是一份"少而完整"的参考实现:用单页应用覆盖了 Composition 打开/编辑/序列化/持久化/预览的完整闭环,把 SDK 的 op 表面(元素、样式、文本、GSAP tween、时序、class、属性、变量、查询、选择代理、历史)逐一映射到可点按的 UI,并以"每次编辑都产生可读 patch 日志 + 实时预览刷新"的方式暴露内部状态变化。对想在 HyperFrames 之上构建图形化编辑器或自定义预览工具的人来说,它的三层架构(Vite 插件持久化 + SDK 会话 + postMessage 桥接 iframe)与面板/事件驱动的编码范式,都可以作为直接可参考的起点。
延伸阅读建议:SDK 公开类型与导出入口见 packages/sdk/src/index.ts,持久化契约见 packages/sdk/src/adapters/types.ts,fs 适配器实现见 packages/sdk/src/adapters/fs.ts,浏览器内存/无头/iframe 适配器位于 packages/sdk/src/adapters/,而openComposition的组合会话实现位于 packages/sdk/src/session.ts。
【免费下载链接】hyperframesWrite HTML. Render video. Built for agents.项目地址: https://gitcode.com/GitHub_Trending/hy/hyperframes
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考