mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
## WS-B — variables / brand, object-valued (end-to-end)
Part of the AI Studio (Pacific) SDK integration. **Base of the SDK-hotspot stack** (`main → ws-b → ws-c → ws-d → ws-3c → ws-3f`).
### Problem
The variable system was split-brained: SDK `setVariableValue` wrote a `--{id}` CSS custom prop, while the runtime `getVariables()` read a separate JSON model (`data-composition-variables` / `__hfVariables`). The two never connected, and there was no `--brand-*` convention. Variables were scalar-only.
### What this does
- **B1 — one source of truth.** `setVariableValue` now drives the runtime variable model (`data-composition-variables` / `__hfVariables`), with CSS compatibility emitted as explicit `stylePath`-based patches alongside the model patch. A brand kit is a variables JSON; a batch of `setVariableValue` re-skins in one frame.
- **B2 — object-valued variables.** The `CompositionVariable` union extends from scalar-only to typed objects: `font` (`{name, source}`) and `image` (`{url, …}`), end-to-end (core union → SDK op → runtime merge). Colors stay scalar (per §7 LOCKED decision).
### Implementation notes
CSS compatibility was moved out of `apply-patches.ts` (where it was incorrectly writing CSS props as a side-effect of model patches, breaking inverse/undo) and into explicit patches emitted in `mutate.ts`. Forward emits `[modelPatch, cssPatch]` for scalars; inverse correctly generates `patchRemove` for the CSS prop when there was no prior CSS prop. Font/image variables never become CSS props.
### Files (12 changed, +441 −32)
- `packages/core`: `core.types.ts`, `lint/rules/composition.ts`, `parsers/htmlParser.ts` (+test), `runtime/validateVariables.ts`
- `packages/sdk`: `engine/mutate.ts` (+test), `engine/apply-patches.ts`, `engine/patches.ts`, `index.ts`, `types.ts`
### Gates
- `bun run build` ✅
- `bun test` SDK 304/0 ✅ · `validateVariables.test.ts` 13/0 ✅
- `bunx oxlint` 0/0 ✅ · `bunx oxfmt --check` ✅
- `fallow audit --gate new-only` ✅ (complexity inherited only)
> The +8 new `htmlParser.test.ts` font/image tests fail under the pre-existing `DOMParser is not defined` happy-dom limitation (main already carries 425 such failures) — not a logic bug; the pure runtime logic is covered by `validateVariables.test.ts`.
### Deferred
Brand-kit picker UI and `batch(setVariableValue × N)` wiring are Pacific-side; per-composition variable scoping beyond `__hfVariablesByComp`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
240 lines
9.4 KiB
TypeScript
240 lines
9.4 KiB
TypeScript
/**
|
|
* RFC 6902 patch path grammar (F2) and override-set key mapping (F2 item 7).
|
|
*
|
|
* Path grammar:
|
|
* /elements/{hfId}/inlineStyles/{camelCaseProp}
|
|
* /elements/{hfId}/text
|
|
* /elements/{hfId}/attributes/{name}
|
|
* /elements/{hfId}/timing/{start|end|duration|trackIndex} ← end = computed absolute data-end
|
|
* /elements/{hfId}/hold/{start|end|fill}
|
|
* /elements/{hfId} ← whole subtree (removeElement)
|
|
* /variables/{variableId}
|
|
* /metadata/{width|height|duration}
|
|
* /script/gsap ← GSAP inline script textContent
|
|
* /style/css ← <style> element textContent
|
|
*
|
|
* Override-set key mapping:
|
|
* /elements/hf-x/inlineStyles/fontSize → "hf-x.style.fontSize"
|
|
* /elements/hf-x/text → "hf-x.text"
|
|
* /elements/hf-x/attributes/src → "hf-x.attr.src"
|
|
* /elements/hf-x/timing/start → "hf-x.timing.start"
|
|
* /elements/hf-x/hold/start → "hf-x.hold.start"
|
|
* /elements/hf-x → "hf-x" (null = removal marker)
|
|
* /variables/brand-color-primary → "var.brand-color-primary"
|
|
* /metadata/width → "meta.width"
|
|
* /script/gsap → "script.gsap"
|
|
* /style/css → "style.css"
|
|
*/
|
|
|
|
import type { JsonPatchOp, PatchEvent } from "../types.js";
|
|
|
|
// ─── Path builders ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* RFC 6902 JSON Pointer escaping for an hf-id (bare or scoped).
|
|
* Scoped ids contain "/" which must be encoded as "~1" in a path segment.
|
|
* "~" must be encoded as "~0" first (order matters per RFC 6902 §3).
|
|
*/
|
|
function escapeIdForPath(id: string): string {
|
|
return id.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
}
|
|
|
|
/** Decode a path segment that may contain RFC 6902-escaped characters back to an hf-id. */
|
|
function decodePathSegment(segment: string): string {
|
|
// RFC 6902 §3: unescape ~1 → /, then ~0 → ~ (reverse order)
|
|
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
}
|
|
|
|
export function stylePath(id: string, prop: string): string {
|
|
return `/elements/${escapeIdForPath(id)}/inlineStyles/${prop}`;
|
|
}
|
|
|
|
export function textPath(id: string): string {
|
|
return `/elements/${escapeIdForPath(id)}/text`;
|
|
}
|
|
|
|
export function attrPath(id: string, name: string): string {
|
|
// RFC 6902 JSON Pointer: ~ → ~0, / → ~1
|
|
const escapedName = name.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`;
|
|
}
|
|
|
|
export function timingPath(id: string, field: "start" | "end" | "duration" | "trackIndex"): string {
|
|
return `/elements/${escapeIdForPath(id)}/timing/${field}`;
|
|
}
|
|
|
|
export function holdPath(id: string, field: "start" | "end" | "fill"): string {
|
|
return `/elements/${escapeIdForPath(id)}/hold/${field}`;
|
|
}
|
|
|
|
export function elementPath(id: string): string {
|
|
return `/elements/${escapeIdForPath(id)}`;
|
|
}
|
|
|
|
export function variablePath(id: string): string {
|
|
return `/variables/${id}`;
|
|
}
|
|
|
|
export function metaPath(field: "width" | "height" | "duration"): string {
|
|
return `/metadata/${field}`;
|
|
}
|
|
|
|
export function gsapScriptPath(): string {
|
|
return "/script/gsap";
|
|
}
|
|
|
|
export function styleSheetPath(): string {
|
|
return "/style/css";
|
|
}
|
|
|
|
// ─── Override-set key mapping ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Maps an RFC 6902 patch path to its override-set key.
|
|
* Returns null for paths that don't correspond to override-set entries.
|
|
*/
|
|
export function pathToKey(path: string): string | null {
|
|
// /elements/{id}/inlineStyles/{prop} → "{id}.style.{prop}"
|
|
// id segment may contain ~1 (RFC 6902-escaped "/") for scoped ids
|
|
const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
|
|
if (styleMatch) return `${decodePathSegment(styleMatch[1]!)}.style.${styleMatch[2]}`;
|
|
|
|
// /elements/{id}/text → "{id}.text"
|
|
const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path);
|
|
if (textMatch) return `${decodePathSegment(textMatch[1]!)}.text`;
|
|
|
|
// /elements/{id}/attributes/{name} → "{id}.attr.{name}"
|
|
const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
|
|
if (attrMatch) return `${decodePathSegment(attrMatch[1]!)}.attr.${attrMatch[2]}`;
|
|
|
|
// /elements/{id}/timing/{field} → "{id}.timing.{field}"
|
|
// Note: field "end" maps to the computed data-end attribute value.
|
|
const timingMatch = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path);
|
|
if (timingMatch) return `${decodePathSegment(timingMatch[1]!)}.timing.${timingMatch[2]}`;
|
|
|
|
// /elements/{id}/hold/{field} → "{id}.hold.{field}"
|
|
const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path);
|
|
if (holdMatch) return `${decodePathSegment(holdMatch[1]!)}.hold.${holdMatch[2]}`;
|
|
|
|
// /elements/{id} (whole element) → "{id}"
|
|
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
|
|
if (elemMatch) return decodePathSegment(elemMatch[1]!);
|
|
|
|
// /variables/{id} → "var.{id}"
|
|
const varMatch = /^\/variables\/(.+)$/.exec(path);
|
|
if (varMatch) return `var.${varMatch[1]}`;
|
|
|
|
// /metadata/{field} → "meta.{field}"
|
|
const metaMatch = /^\/metadata\/(.+)$/.exec(path);
|
|
if (metaMatch) return `meta.${metaMatch[1]}`;
|
|
|
|
// /script/gsap → "script.gsap"
|
|
if (path === "/script/gsap") return "script.gsap";
|
|
|
|
// /style/css → "style.css"
|
|
if (path === "/style/css") return "style.css";
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Inverse of pathToKey — maps an override-set key back to its RFC 6902 path.
|
|
* Used to replay a stored override-set onto a fresh base document (T3 init).
|
|
*/
|
|
export function keyToPath(key: string): string | null {
|
|
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
|
|
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
|
|
|
|
const text = /^([^.]+)\.text$/.exec(key);
|
|
if (text?.[1]) return textPath(text[1]);
|
|
|
|
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
|
|
// The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim).
|
|
// The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape
|
|
// the already-encoded attr segment. Reconstruct manually.
|
|
if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`;
|
|
|
|
const timing = /^([^.]+)\.timing\.(start|end|duration|trackIndex)$/.exec(key);
|
|
if (timing?.[1])
|
|
return timingPath(timing[1], timing[2] as "start" | "end" | "duration" | "trackIndex");
|
|
|
|
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
|
|
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
|
|
|
|
const variable = /^var\.(.+)$/.exec(key);
|
|
if (variable?.[1]) return variablePath(variable[1]);
|
|
|
|
const meta = /^meta\.(width|height|duration)$/.exec(key);
|
|
if (meta) return metaPath(meta[1] as "width" | "height" | "duration");
|
|
|
|
if (key === "script.gsap") return gsapScriptPath();
|
|
if (key === "style.css") return styleSheetPath();
|
|
|
|
// Bare element id — removal marker key.
|
|
if (!key.includes(".")) return elementPath(key);
|
|
|
|
return null;
|
|
}
|
|
|
|
// ─── Patch event builder ──────────────────────────────────────────────────────
|
|
|
|
export function buildPatchEvent(
|
|
forward: readonly JsonPatchOp[],
|
|
inverse: readonly JsonPatchOp[],
|
|
origin: unknown,
|
|
opTypes: readonly string[],
|
|
): PatchEvent {
|
|
return { formatVersion: 1, patches: forward, inversePatches: inverse, origin, opTypes };
|
|
}
|
|
|
|
// ─── Replace/add/remove helpers ───────────────────────────────────────────────
|
|
|
|
function patchReplace(path: string, value: unknown): JsonPatchOp {
|
|
return { op: "replace", path, value };
|
|
}
|
|
|
|
export function patchAdd(path: string, value: unknown): JsonPatchOp {
|
|
return { op: "add", path, value };
|
|
}
|
|
|
|
export function patchRemove(path: string): JsonPatchOp {
|
|
return { op: "remove", path };
|
|
}
|
|
|
|
/** Emit forward (replace or add) + inverse (replace or remove) for a scalar change. */
|
|
export function scalarChange(
|
|
path: string,
|
|
oldValue: string | number | boolean | null | undefined,
|
|
newValue: string | number | boolean,
|
|
): { forward: JsonPatchOp; inverse: JsonPatchOp } {
|
|
const forward = oldValue == null ? patchAdd(path, newValue) : patchReplace(path, newValue);
|
|
const inverse = oldValue == null ? patchRemove(path) : patchReplace(path, oldValue ?? null);
|
|
return { forward, inverse };
|
|
}
|
|
|
|
/**
|
|
* Emit forward (replace or add) + inverse (replace or remove) for any JSON-serializable value.
|
|
* Use instead of scalarChange when the value may be an object (e.g. font/image variable).
|
|
* The old value is captured whole — no sub-key diffing.
|
|
*/
|
|
export function valueChange(
|
|
path: string,
|
|
oldValue: unknown,
|
|
newValue: unknown,
|
|
): { forward: JsonPatchOp; inverse: JsonPatchOp } {
|
|
const forward = oldValue == null ? patchAdd(path, newValue) : patchReplace(path, newValue);
|
|
const inverse = oldValue == null ? patchRemove(path) : patchReplace(path, oldValue);
|
|
return { forward, inverse };
|
|
}
|
|
|
|
/** Emit forward remove + inverse add for a deletion. */
|
|
export function scalarDelete(
|
|
path: string,
|
|
oldValue: string | number | boolean,
|
|
): { forward: JsonPatchOp; inverse: JsonPatchOp } {
|
|
return {
|
|
forward: patchRemove(path),
|
|
inverse: patchAdd(path, oldValue),
|
|
};
|
|
}
|