mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(sdk): file-backed fs adapter + setTiming GSAP sync; sdk-playground workspace (#1458)
* feat(sdk): file-backed fs adapter + setTiming GSAP-script sync; add sdk-playground * fix(sdk): address PR #1423 review — oxfmt, PersistVersionEntry contract, race, comments - bunx oxfmt packages/sdk-playground/index.html (unblocks CI) - PersistVersionEntry.content is now optional; HTTP adapter omits it for lazy-load - fs adapter: monotonic key (Date.now-NNNN) + per-path write serialization via promise chain - mutate.ts: fix wrong comment on GSAP sync reason; add caveat to "pre-parse once" note Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(core): oxfmt gsapSerialize.ts — unblocks Preflight across stack Pre-existing format issue on the base; fixing here to unblock CI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * chore: update bun.lock for sdk-playground workspace Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Miguel Ángel
parent
3bfb25efcf
commit
577a689860
@@ -1,44 +1,126 @@
|
||||
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
export interface FsAdapterOptions {
|
||||
/** Root directory for composition files */
|
||||
root: string;
|
||||
/** Max versions to keep per file. Default: 20 */
|
||||
maxVersions?: number;
|
||||
}
|
||||
|
||||
// Phase 4 — fs adapter stub. Full implementation in SDK Phase 4 (adapters stage).
|
||||
// Uses Node.js fs/promises; not browser-safe (must be conditionally imported by consumers).
|
||||
const DEFAULT_MAX_VERSIONS = 20;
|
||||
|
||||
let _versionCounter = 0;
|
||||
|
||||
class FsAdapter implements PersistAdapter {
|
||||
private readonly root: string;
|
||||
private readonly maxVersions: number;
|
||||
private errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
|
||||
private _writeLocks = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(opts: FsAdapterOptions) {
|
||||
this.root = opts.root;
|
||||
this.maxVersions = opts.maxVersions ?? DEFAULT_MAX_VERSIONS;
|
||||
}
|
||||
|
||||
async read(_path: string): Promise<string | undefined> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
async read(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(this.abs(path), "utf8");
|
||||
} catch (err: unknown) {
|
||||
if (isNotFound(err)) return undefined;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async write(_path: string, _content: string): Promise<void> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
async write(path: string, content: string): Promise<void> {
|
||||
try {
|
||||
const abs = this.abs(path);
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
await writeFile(abs, content, "utf8");
|
||||
await this.appendVersion(path, content);
|
||||
} catch (err) {
|
||||
for (const h of this.errorHandlers) h({ error: { message: String(err), cause: err } });
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
async flush(): Promise<void> {}
|
||||
|
||||
async listVersions(path: string): Promise<PersistVersionEntry[]> {
|
||||
const dir = this.versionsDir(path);
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
const sorted = entries
|
||||
.filter((f) => f.endsWith(".html"))
|
||||
.sort()
|
||||
.reverse();
|
||||
return Promise.all(
|
||||
sorted.map(async (f) => {
|
||||
const key = f.replace(/\.html$/, "");
|
||||
return {
|
||||
key,
|
||||
content: await readFile(join(dir, f), "utf8"),
|
||||
timestamp: Number(key.split("-")[0]),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async listVersions(_path: string): Promise<PersistVersionEntry[]> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
async loadFrom(path: string, versionKey: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(join(this.versionsDir(path), `${versionKey}.html`), "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async loadFrom(_path: string, _versionKey: string): Promise<string | undefined> {
|
||||
throw new Error("FsAdapter: Phase 4 — not yet implemented");
|
||||
on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void {
|
||||
if (event !== "persist:error") return () => {};
|
||||
this.errorHandlers.push(handler);
|
||||
return () => {
|
||||
const i = this.errorHandlers.indexOf(handler);
|
||||
if (i !== -1) this.errorHandlers.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
on(_event: "persist:error", _handler: (e: PersistErrorEvent) => void): () => void {
|
||||
return () => {};
|
||||
private abs(path: string): string {
|
||||
return join(this.root, path);
|
||||
}
|
||||
|
||||
private versionsDir(path: string): string {
|
||||
return join(this.root, ".hf-versions", path);
|
||||
}
|
||||
|
||||
private async appendVersion(path: string, content: string): Promise<void> {
|
||||
const prior = this._writeLocks.get(path) ?? Promise.resolve();
|
||||
const next = prior.then(() => this._doAppendVersion(path, content));
|
||||
this._writeLocks.set(
|
||||
path,
|
||||
next.catch(() => {}),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
private async _doAppendVersion(path: string, content: string): Promise<void> {
|
||||
const dir = this.versionsDir(path);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const key = `${Date.now()}-${String(++_versionCounter).padStart(4, "0")}`;
|
||||
await writeFile(join(dir, `${key}.html`), content, "utf8");
|
||||
// prune oldest beyond maxVersions
|
||||
const all = (await readdir(dir)).filter((f) => f.endsWith(".html")).sort();
|
||||
const excess = all.length - this.maxVersions;
|
||||
if (excess > 0) {
|
||||
await Promise.all(all.slice(0, excess).map((f) => unlink(join(dir, f)).catch(() => {})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFound(err: unknown): boolean {
|
||||
return (err as NodeJS.ErrnoException)?.code === "ENOENT";
|
||||
}
|
||||
|
||||
export function createFsAdapter(opts: FsAdapterOptions): PersistAdapter {
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { PersistErrorEvent } from "../types.js";
|
||||
export interface PersistVersionEntry {
|
||||
/** Opaque key identifying this version (adapter-defined format) */
|
||||
key: string;
|
||||
content: string;
|
||||
/** Full HTML content — may be omitted by adapters that load content lazily via loadFrom() */
|
||||
content?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,13 @@ function handleSetTiming(
|
||||
timing: { start?: number; duration?: number; trackIndex?: number },
|
||||
): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
|
||||
// Parse GSAP script once; updateAnimationInScript re-parses internally per call but
|
||||
// we avoid re-fetching the script element on every iteration.
|
||||
const origScript = getGsapScript(parsed.document);
|
||||
const parsedGsap = origScript ? parseGsapScriptAcornForWrite(origScript) : null;
|
||||
let currentScript = origScript;
|
||||
|
||||
for (const id of ids) {
|
||||
const el = findById(parsed.document, id);
|
||||
if (!el) continue;
|
||||
@@ -332,7 +339,32 @@ function handleSetTiming(
|
||||
result.inverse.push(p.inverse);
|
||||
el.setAttribute("data-track-index", String(newTrack));
|
||||
}
|
||||
|
||||
// Sync GSAP tween positions: the GSAP script is the source of truth at play time —
|
||||
// the timeline rebuilds from it on every seek. Without this, DOM attribute edits
|
||||
// have zero playback effect; the script's position/duration silently overrides them.
|
||||
if (parsedGsap && currentScript) {
|
||||
for (const { id: animId, animation } of parsedGsap.located) {
|
||||
const sel = animation.targetSelector;
|
||||
if (sel !== `[data-hf-id="${id}"]` && sel !== `[data-hf-id='${id}']` && sel !== `#${id}`)
|
||||
continue;
|
||||
const updates: Partial<GsapAnimation> = {};
|
||||
if (timing.start !== undefined && newStart !== null) updates.position = newStart;
|
||||
if (timing.duration !== undefined && newDuration !== null) updates.duration = newDuration;
|
||||
if (Object.keys(updates).length === 0) continue;
|
||||
currentScript = updateAnimationInScript(currentScript, animId, updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush accumulated GSAP script changes as a single patch pair.
|
||||
if (origScript && currentScript && currentScript !== origScript) {
|
||||
setGsapScript(parsed.document, currentScript);
|
||||
const gsapResult = gsapScriptChange(origScript, currentScript);
|
||||
result.forward.push(...gsapResult.forward);
|
||||
result.inverse.push(...gsapResult.inverse);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user