mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-02 19:57:13 +00:00
feat(core): data-vfx-chain defs and chain parsing (mirrors data-fx-chain)
Mirrors audioFx.ts's shape for the per-pixel VFX chain: a versioned JSON chain attribute (data-vfx-chain), a def registry with typed params (number/enum/bool/ref), and parse/serialize/normalize helpers. HF_VFX holds one def, fractal-noise, with a placeholder frag; the kernel and its consumers (packages/core/src/runtime/vfx.ts) land in Task 1.2/1.3 of the vfx-chain-runtime plan, so vfx.ts is added to .fallowrc.jsonc's entry list for now. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ec3bc93b90
commit
f930cd95f3
@@ -3,6 +3,10 @@
|
||||
"entry": [
|
||||
// IconButton, Tabs and cn are exported before their first consumer exists.
|
||||
"packages/studio/src/components/ui/index.ts",
|
||||
// HF_VFX_CHAIN_VERSION, HF_VFX, VfxChainError and enabledVfxNodes are
|
||||
// exported before packages/core/src/runtime/vfx.ts (vfx-chain-runtime
|
||||
// plan, Task 1.2) consumes them.
|
||||
"packages/core/src/vfx.ts",
|
||||
"packages/producer/src/**/*.test.ts",
|
||||
"packages/aws-lambda/src/**/*.test.ts",
|
||||
"packages/gcp-cloud-run/src/**/*.test.ts",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HF_VFX_ATTR,
|
||||
chainCapture,
|
||||
getVfxDef,
|
||||
normalizeVfxParams,
|
||||
parseVfxChain,
|
||||
serializeVfxChain,
|
||||
} from "./vfx";
|
||||
|
||||
describe("vfx chain", () => {
|
||||
it("round-trips a one-node chain with the audio-chain field names", () => {
|
||||
const json =
|
||||
'{"version":1,"nodes":[{"type":"fractal-noise","id":"n1","params":{"contrast":562}}]}';
|
||||
const chain = parseVfxChain(json);
|
||||
expect(chain.nodes[0].type).toBe("fractal-noise");
|
||||
expect(JSON.parse(serializeVfxChain(chain))).toEqual(JSON.parse(json));
|
||||
});
|
||||
it("rejects an unknown type and a wrong version loudly", () => {
|
||||
expect(() =>
|
||||
parseVfxChain('{"version":1,"nodes":[{"type":"nope","id":"n1","params":{}}]}'),
|
||||
).toThrow(/unknown effect type/);
|
||||
expect(() => parseVfxChain('{"version":2,"nodes":[]}')).toThrow(/version/);
|
||||
});
|
||||
it("normalizes params to the def's range and defaults", () => {
|
||||
const p = normalizeVfxParams("fractal-noise", { contrast: 99999 });
|
||||
expect(p.contrast).toBe(1000); // clamped to the def's max
|
||||
expect(p.complexity).toBe(6); // default filled in
|
||||
expect(getVfxDef("fractal-noise")!.capture).toBe("none");
|
||||
});
|
||||
it("a chain's capture is the strongest enabled node's", () => {
|
||||
expect(
|
||||
chainCapture(
|
||||
parseVfxChain('{"version":1,"nodes":[{"type":"fractal-noise","id":"n1","params":{}}]}'),
|
||||
),
|
||||
).toBe("none");
|
||||
});
|
||||
it("attribute name is data-vfx-chain", () => expect(HF_VFX_ATTR).toBe("data-vfx-chain"));
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* VFX chain: the one description of every per-pixel effect that can be
|
||||
* applied to a layer's own pixels through the WebGL2 runtime.
|
||||
*
|
||||
* Mirrors `audioFx.ts` (`data-fx-chain`) in shape: a versioned JSON chain
|
||||
* attribute, a def registry with typed params, and parse/serialize/normalize
|
||||
* helpers so the exporter and the runtime agree on one description of each
|
||||
* effect.
|
||||
*/
|
||||
|
||||
export const HF_VFX_ATTR = "data-vfx-chain";
|
||||
|
||||
/** Chain files are versioned; a reader must refuse a version it doesn't know. */
|
||||
export const HF_VFX_CHAIN_VERSION = 1;
|
||||
|
||||
/**
|
||||
* What a def's kernel must read besides `(x, y, t, params)`:
|
||||
* - `none` — the kernel is a generator/replacement; no captured texture.
|
||||
* - `self` — the kernel reads the host's own pixels through a `layoutsubtree`
|
||||
* capture canvas.
|
||||
* - `backdrop` — the kernel reads everything below the host (adjustment-layer
|
||||
* style effects).
|
||||
*/
|
||||
export type HfVfxCapture = "none" | "self" | "backdrop";
|
||||
|
||||
export interface HfVfxNumberParam {
|
||||
kind: "number";
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
default: number;
|
||||
/** May be driven by a `--vfx-<nodeId>-<key>` CSS var instead of `params`. */
|
||||
animatable?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface HfVfxEnumParam {
|
||||
kind: "enum";
|
||||
key: string;
|
||||
label: string;
|
||||
options: readonly { value: number; label: string }[];
|
||||
default: number;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface HfVfxBoolParam {
|
||||
kind: "bool";
|
||||
key: string;
|
||||
label: string;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
/** A second-source param (v1.1): its value is the id of another element. */
|
||||
export interface HfVfxRefParam {
|
||||
kind: "ref";
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type HfVfxParam = HfVfxNumberParam | HfVfxEnumParam | HfVfxBoolParam | HfVfxRefParam;
|
||||
|
||||
/** `string` values only occur for `ref` params (element ids). */
|
||||
export type HfVfxParamValues = Record<string, number | boolean | string>;
|
||||
|
||||
export interface HfVfxDef {
|
||||
id: string;
|
||||
label: string;
|
||||
/** What the kernel must read besides (x, y, t, params). */
|
||||
capture: HfVfxCapture;
|
||||
params: readonly HfVfxParam[];
|
||||
/** GLSL ES 3.00 fragment source; uniforms are u_size/u_t/u_fps/u_src(2)/u_<key>. */
|
||||
frag: string;
|
||||
/** AE match name this def ports, for docs — e.g. "ADBE Fractal Noise". */
|
||||
ae?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fractal-noise` params, from the fractal-noise deep dive and retro-wave's
|
||||
* observed values. Basic (type 1) is the only fractal type implemented in v1;
|
||||
* see `vfx/fractalNoise.ts` (Task 1.3) for the kernel scope decision.
|
||||
*/
|
||||
export const HF_VFX: readonly HfVfxDef[] = [
|
||||
{
|
||||
id: "fractal-noise",
|
||||
label: "Fractal Noise",
|
||||
ae: "ADBE Fractal Noise",
|
||||
capture: "none",
|
||||
frag: "void main(){}",
|
||||
params: [
|
||||
{
|
||||
kind: "enum",
|
||||
key: "fractalType",
|
||||
label: "Fractal Type",
|
||||
options: [{ value: 1, label: "Basic" }],
|
||||
default: 1,
|
||||
hint: "Only Basic is implemented in v1.",
|
||||
},
|
||||
{
|
||||
kind: "enum",
|
||||
key: "noiseType",
|
||||
label: "Noise Type",
|
||||
options: [
|
||||
{ value: 1, label: "Block" },
|
||||
{ value: 2, label: "Linear" },
|
||||
{ value: 3, label: "Soft Linear" },
|
||||
{ value: 4, label: "Spline" },
|
||||
],
|
||||
default: 3,
|
||||
},
|
||||
{ kind: "bool", key: "invert", label: "Invert", default: false },
|
||||
{
|
||||
kind: "number",
|
||||
key: "contrast",
|
||||
label: "Contrast",
|
||||
unit: "%",
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "brightness",
|
||||
label: "Brightness",
|
||||
unit: "",
|
||||
min: -1000,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "scale",
|
||||
label: "Scale",
|
||||
unit: "%",
|
||||
min: 6,
|
||||
max: 10000,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "complexity",
|
||||
label: "Complexity",
|
||||
unit: "",
|
||||
min: 1,
|
||||
max: 20,
|
||||
step: 1,
|
||||
default: 6,
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "subInfluence",
|
||||
label: "Sub Influence",
|
||||
unit: "%",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 70,
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "subScaling",
|
||||
label: "Sub Scaling",
|
||||
unit: "%",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 56,
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "evolution",
|
||||
label: "Evolution",
|
||||
unit: "revolutions",
|
||||
min: -10000,
|
||||
max: 10000,
|
||||
step: 0.01,
|
||||
default: 0,
|
||||
animatable: true,
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "randomSeed",
|
||||
label: "Random Seed",
|
||||
unit: "",
|
||||
min: 0,
|
||||
max: 99999,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "opacity",
|
||||
label: "Opacity",
|
||||
unit: "%",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
animatable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const BY_ID = new Map(HF_VFX.map((d) => [d.id, d]));
|
||||
|
||||
export function getVfxDef(id: string): HfVfxDef | undefined {
|
||||
return BY_ID.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp and fill a parameter set so it is always renderable: unknown keys are
|
||||
* dropped, missing keys take their default, numbers are clamped into their
|
||||
* declared range, an unrecognised enum value falls back to its default, and a
|
||||
* non-boolean bool value falls back to its default. Mirrors
|
||||
* `normalizeAudioFxParams`.
|
||||
*/
|
||||
export function normalizeVfxParams(
|
||||
id: string,
|
||||
values: Readonly<HfVfxParamValues> | undefined,
|
||||
): HfVfxParamValues {
|
||||
const def = BY_ID.get(id);
|
||||
if (!def) return {};
|
||||
const out: HfVfxParamValues = {};
|
||||
for (const p of def.params) {
|
||||
const raw = values?.[p.key];
|
||||
out[p.key] =
|
||||
p.kind === "enum"
|
||||
? normalizeEnumParam(p, raw)
|
||||
: p.kind === "bool"
|
||||
? normalizeBoolParam(p, raw)
|
||||
: p.kind === "ref"
|
||||
? normalizeRefParam(raw)
|
||||
: normalizeNumberParam(p, raw);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A number, or a string that actually spells one; `undefined` otherwise.
|
||||
* `Number(null)`, `Number("")`, `Number(false)` and `Number([])` are all 0
|
||||
* and all pass `Number.isFinite`, so a raw value of that shape must be
|
||||
* treated as missing rather than coerced to 0 — 0 is a legal value for most
|
||||
* of these knobs, so nothing downstream could otherwise tell. */
|
||||
function coerceFiniteNumber(raw: unknown): number | undefined {
|
||||
const n =
|
||||
typeof raw === "number"
|
||||
? raw
|
||||
: typeof raw === "string" && raw.trim() !== ""
|
||||
? Number(raw)
|
||||
: Number.NaN;
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function normalizeEnumParam(p: HfVfxEnumParam, raw: unknown): number {
|
||||
const n = coerceFiniteNumber(raw);
|
||||
return n !== undefined && p.options.some((o) => o.value === n) ? n : p.default;
|
||||
}
|
||||
|
||||
function normalizeBoolParam(p: HfVfxBoolParam, raw: unknown): boolean {
|
||||
return typeof raw === "boolean" ? raw : p.default;
|
||||
}
|
||||
|
||||
function normalizeRefParam(raw: unknown): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
function normalizeNumberParam(p: HfVfxNumberParam, raw: unknown): number {
|
||||
const n = coerceFiniteNumber(raw);
|
||||
return n !== undefined ? Math.min(p.max, Math.max(p.min, n)) : p.default;
|
||||
}
|
||||
|
||||
export interface HfVfxNode {
|
||||
type: string;
|
||||
id: string;
|
||||
enabled?: boolean;
|
||||
params: HfVfxParamValues;
|
||||
}
|
||||
|
||||
export interface HfVfxChain {
|
||||
version: 1;
|
||||
nodes: HfVfxNode[];
|
||||
}
|
||||
|
||||
export class VfxChainError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "VfxChainError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The nodes that should paint, in order. */
|
||||
export function enabledVfxNodes(chain: HfVfxChain): HfVfxNode[] {
|
||||
return chain.nodes.filter((n) => n.enabled !== false);
|
||||
}
|
||||
|
||||
const CAPTURE_RANK: Record<HfVfxCapture, number> = { none: 0, self: 1, backdrop: 2 };
|
||||
|
||||
/** A chain's capture requirement is the strongest of its enabled nodes'. */
|
||||
export function chainCapture(chain: HfVfxChain): HfVfxCapture {
|
||||
let best: HfVfxCapture = "none";
|
||||
for (const node of enabledVfxNodes(chain)) {
|
||||
const def = BY_ID.get(node.type);
|
||||
if (def && CAPTURE_RANK[def.capture] > CAPTURE_RANK[best]) best = def.capture;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a chain file. Unknown effect ids are rejected rather than skipped: a
|
||||
* chain that silently loses a node would render differently from the
|
||||
* composition the author saved, which is worse than refusing to render at
|
||||
* all. Params are kept as given (not normalized) so a chain round-trips
|
||||
* exactly; call `normalizeVfxParams` at paint time.
|
||||
*/
|
||||
export function parseVfxChain(json: string): HfVfxChain {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(json);
|
||||
} catch (err) {
|
||||
throw new VfxChainError(`Chain file is not valid JSON: ${(err as Error).message}`);
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
throw new VfxChainError("Chain file must be a JSON object.");
|
||||
}
|
||||
const obj = raw as { version?: unknown; nodes?: unknown };
|
||||
if (obj.version !== HF_VFX_CHAIN_VERSION) {
|
||||
throw new VfxChainError(`Unsupported chain version: ${String(obj.version)}`);
|
||||
}
|
||||
if (!Array.isArray(obj.nodes)) {
|
||||
throw new VfxChainError("Chain file is missing a `nodes` array.");
|
||||
}
|
||||
const nodes = obj.nodes.map(parseVfxNode);
|
||||
return { version: HF_VFX_CHAIN_VERSION, nodes };
|
||||
}
|
||||
|
||||
/** Serialise a chain for the `data-vfx-chain` attribute. */
|
||||
export function serializeVfxChain(chain: HfVfxChain): string {
|
||||
return JSON.stringify({
|
||||
version: HF_VFX_CHAIN_VERSION,
|
||||
nodes: chain.nodes.map(serializeVfxNode),
|
||||
});
|
||||
}
|
||||
|
||||
/** The shape a node is READ as: everything unknown until checked. */
|
||||
interface RawVfxNode {
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
enabled?: unknown;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null;
|
||||
}
|
||||
|
||||
function requireVfxNodeType(node: RawVfxNode, i: number): string {
|
||||
if (typeof node.type !== "string" || !BY_ID.has(node.type)) {
|
||||
throw new VfxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`);
|
||||
}
|
||||
return node.type;
|
||||
}
|
||||
|
||||
function requireVfxNodeId(node: RawVfxNode, i: number): string {
|
||||
if (typeof node.id !== "string" || node.id === "") {
|
||||
throw new VfxChainError(`Node ${i} is missing an id.`);
|
||||
}
|
||||
return node.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* One node out of a chain file, validated. Throws rather than dropping: a
|
||||
* chain that silently loses a node would render differently from the
|
||||
* composition the author saved.
|
||||
*/
|
||||
function parseVfxNode(n: unknown, i: number): HfVfxNode {
|
||||
if (!isPlainObject(n)) {
|
||||
throw new VfxChainError(`Node ${i} is not an object.`);
|
||||
}
|
||||
const node = n as RawVfxNode;
|
||||
const type = requireVfxNodeType(node, i);
|
||||
const id = requireVfxNodeId(node, i);
|
||||
const params = isPlainObject(node.params) ? (node.params as HfVfxParamValues) : {};
|
||||
return withoutUndefined({
|
||||
type,
|
||||
id,
|
||||
enabled: node.enabled === false ? false : undefined,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** One node as the `data-vfx-chain` attribute carries it. `enabled` is
|
||||
* omitted when it holds its default, so a plain chain stays plain. */
|
||||
function serializeVfxNode(node: HfVfxNode) {
|
||||
return withoutUndefined({
|
||||
type: node.type,
|
||||
id: node.id,
|
||||
enabled: node.enabled === false ? false : undefined,
|
||||
params: node.params,
|
||||
});
|
||||
}
|
||||
|
||||
// Mirrors audioFx.ts's identical helper (Task 1.1: copy the shape of the
|
||||
// audio chain, not its code — the two chain files stay independently
|
||||
// readable rather than sharing a module neither owns).
|
||||
// fallow-ignore-next-line code-duplication
|
||||
function withoutUndefined<T extends object>(obj: T): T {
|
||||
for (const key of Object.keys(obj) as Array<keyof T>) {
|
||||
if (obj[key] === undefined) delete obj[key];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
Reference in New Issue
Block a user