Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ renders/
# user reference material (large videos, third-party content — never publish)
refs/

# confidential design doc (keep locally, never commit)
docs/research/2026-06-20-core-ir-design.md

# build artifact copies in example projects (regenerate: stereoframe update)
examples/*/assets/stereoframe.js

Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ import {
FINISH_ATTRS,
STAGE_PRESETS,
GEN_PROVIDERS,
IR_DRIVER_KINDS,
IR_BEHAVIOR_KINDS,
IR_TIMELINE_KINDS,
IR_CHANNELS,
IR_VERB_CHANNEL,
} from "stereoframe-runtime/vocab";
import { addBlock, listBlocks } from "./blocks";
import {
Expand Down Expand Up @@ -828,6 +833,13 @@ async function main(): Promise<void> {
stagePresets: STAGE_PRESETS,
genProviders: GEN_PROVIDERS,
},
ir: {
driverKinds: IR_DRIVER_KINDS,
behaviorKinds: IR_BEHAVIOR_KINDS,
timelineKinds: IR_TIMELINE_KINDS,
channels: IR_CHANNELS,
verbChannel: IR_VERB_CHANNEL,
},
};
console.log(JSON.stringify(spec, null, 2));
return;
Expand Down
118 changes: 117 additions & 1 deletion packages/cli/src/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@
* free; `lintHtml` is pure (filesystem access injected) so every rule is
* unit-testable against fixture strings.
*/
import { ASSET_ATTRS, EASE_NAMES, ELEMENT_NAMES, VERB_NAMES } from "stereoframe-runtime/vocab";
import {
ASSET_ATTRS,
EASE_NAMES,
ELEMENT_NAMES,
IR_VERB_CHANNEL,
VERB_DEFAULT_DURATION,
VERB_NAMES,
VERB_REF_ATTR,
} from "stereoframe-runtime/vocab";

export interface Finding {
rule: string;
Expand Down Expand Up @@ -251,5 +259,113 @@ export function lintHtml(html: string, opts: LintOptions): Finding[] {
}
}

// ── IR structural/temporal checks (the core="ir" model; render-free) ──
// Built from the shared IR verb tables, scoped per shot, so they hold for any
// composition. These catch authoring mistakes a render would only reveal late.
for (const block of sceneBlocks) {
const sceneAttrs = block.match(/<sf-scene\b([^>]*)>/i)?.[1] ?? "";
const durAttr = readAttr(sceneAttrs, "duration");
const sceneDur = durAttr != null ? Number(durAttr) : null;
const channelStarts = new Map<string, number[]>(); // "target.channel" → starts

for (const el of sfTags(block).filter((t) => t.name === "sf-animate")) {
const verb = (readAttr(el.attrs, "verb") ?? "").toLowerCase();
const target = readAttr(el.attrs, "target");
const start = Number(readAttr(el.attrs, "start") ?? 0) || 0;
const durationAttr = readAttr(el.attrs, "duration");

// ir_dangling_ref — around/toward/subject pointing at a missing #id.
const refAttr = VERB_REF_ATTR[verb];
if (refAttr) {
const ref = readAttr(el.attrs, refAttr);
if (ref?.startsWith("#") && !new RegExp(`\\bid\\s*=\\s*"${ref.slice(1)}"`).test(html)) {
findings.push({
rule: "ir_dangling_ref",
severity: "warning",
message: `sf-animate verb="${verb}" ${refAttr}="${ref}" references no element id — it resolves to the origin.`,
fixHint: `Point ${refAttr} at an existing #id, or use explicit "x y z" coordinates.`,
});
}
}

// ir_zero_duration — a windowed verb given a non-positive duration snaps.
const windowed = verb in VERB_DEFAULT_DURATION;
if (windowed && durationAttr != null && Number(durationAttr) <= 0) {
findings.push({
rule: "ir_zero_duration",
severity: "warning",
message: `sf-animate verb="${verb}" duration="${durationAttr}" is not positive — it snaps instantly.`,
fixHint: "Give the windowed verb a positive duration (seconds).",
});
}

// ir_unreachable — a verb that starts at/after the shot ends never runs.
if (sceneDur != null && sceneDur > 0 && start >= sceneDur) {
findings.push({
rule: "ir_unreachable",
severity: "warning",
message: `sf-animate verb="${verb}" start="${start}" is at/after the scene duration (${sceneDur}s) — it never runs.`,
fixHint: "Lower start, or extend the sf-scene duration.",
});
}

const channel = IR_VERB_CHANNEL[verb];
if (channel && target) {
const key = `${target}.${channel}`;
const list = channelStarts.get(key);
if (list) list.push(start);
else channelStarts.set(key, [start]);
}
}

// ir_channel_conflict — two drivers starting together on one channel are
// order-ambiguous (the later-declared one silently wins each frame).
for (const [key, starts] of channelStarts) {
const seen = new Set<number>();
const clash = starts.some((s) => (seen.has(s) ? true : (seen.add(s), false)));
if (clash) {
findings.push({
rule: "ir_channel_conflict",
severity: "warning",
message: `two sf-animate drivers start at the same time on ${key} — their order is ambiguous.`,
fixHint: "Stagger the starts, or target different channels/nodes.",
});
}
}
}

// ── IR named-state checks ──
const stateNames = new Set<string>();
for (const m of markup.matchAll(/<sf-state\b([^>]*)>/gi)) {
const name = readAttr(m[1] ?? "", "name");
if (name) stateNames.add(name);
}
// ir_state_requires_core — <sf-state> is inert unless the scene opts into core="ir".
for (const block of sceneBlocks) {
if (!/<sf-state\b/i.test(block)) continue;
const sceneAttrs = block.match(/<sf-scene\b([^>]*)>/i)?.[1] ?? "";
if (readAttr(sceneAttrs, "core") !== "ir") {
findings.push({
rule: "ir_state_requires_core",
severity: "warning",
message: '<sf-state> is ignored by the legacy path — its <sf-scene> needs core="ir".',
fixHint: 'Add core="ir" to the <sf-scene>.',
});
}
}
// ir_unknown_state — `to` targeting a state that was never declared.
for (const el of animates) {
if ((readAttr(el.attrs, "verb") ?? "").toLowerCase() !== "to") continue;
const state = readAttr(el.attrs, "state");
if (state && !stateNames.has(state)) {
findings.push({
rule: "ir_unknown_state",
severity: "warning",
message: `sf-animate verb="to" state="${state}" has no matching <sf-state name="${state}">.`,
fixHint: "Define the state, or fix the name.",
});
}
}

return findings;
}
85 changes: 85 additions & 0 deletions packages/cli/tests/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,91 @@ describe("lint", () => {
expect(r).toContain("remote_asset");
});

test("sf-group is a recognized element + grouped/animated content is clean", () => {
const html = `
<sf-scene duration="4">
<sf-camera look-at="#rig"></sf-camera>
<sf-group id="rig" position="0 0.5 0">
<sf-mesh id="a" geometry="box" position="-1.5 0 0"></sf-mesh>
<sf-mesh id="b" geometry="sphere" position="1.5 0 0"></sf-mesh>
</sf-group>
<sf-animate target="#rig" verb="turntable" rpm="20"></sf-animate>
<sf-animate target="#a" verb="turntable" rpm="60" axis="x"></sf-animate>
</sf-scene>${RUNTIME}`;
expect(rules(html)).toEqual([]);
});

test("named states: clean when core=ir + matching state; flags unknown state", () => {
const ok = `
<sf-scene core="ir" initial="closed" duration="4">
<sf-mesh id="box" geometry="box"></sf-mesh>
<sf-state name="closed"><sf-set target="#box" position="0 0 0"></sf-set></sf-state>
<sf-state name="open"><sf-set target="#box" position="0 2 0"></sf-set></sf-state>
<sf-animate verb="to" state="open" start="1" duration="1"></sf-animate>
</sf-scene>${RUNTIME}`;
expect(rules(ok)).toEqual([]);
const bad = ok.replace('state="open"', 'state="ajar"');
expect(rules(bad)).toContain("ir_unknown_state");
});

test("ir_state_requires_core flags <sf-state> on a non-IR scene", () => {
const html = `
<sf-scene duration="4">
<sf-mesh id="box" geometry="box"></sf-mesh>
<sf-state name="open"><sf-set target="#box" position="0 2 0"></sf-set></sf-state>
</sf-scene>${RUNTIME}`;
expect(rules(html)).toContain("ir_state_requires_core");
});

test("ir_dangling_ref flags an orbit around a missing #id; valid ref is clean", () => {
const bad = `
<sf-scene duration="6">
<sf-mesh id="hero" geometry="box"></sf-mesh>
<sf-animate target="camera" verb="orbit" around="#heor" duration="6"></sf-animate>
</sf-scene>${RUNTIME}`;
expect(rules(bad)).toContain("ir_dangling_ref");
const good = bad.replace("#heor", "#hero");
expect(rules(good)).not.toContain("ir_dangling_ref");
});

test("ir_zero_duration flags a windowed verb with non-positive duration", () => {
const html = `
<sf-scene duration="6">
<sf-mesh id="hero" geometry="box"></sf-mesh>
<sf-animate target="#hero" verb="move" to="1 0 0" duration="0"></sf-animate>
</sf-scene>${RUNTIME}`;
expect(rules(html)).toContain("ir_zero_duration");
});

test("ir_unreachable flags a verb starting at/after the scene duration", () => {
const html = `
<sf-scene duration="5">
<sf-mesh id="hero" geometry="box"></sf-mesh>
<sf-animate target="#hero" verb="move" to="1 0 0" start="6" duration="1"></sf-animate>
</sf-scene>${RUNTIME}`;
expect(rules(html)).toContain("ir_unreachable");
});

test("ir_channel_conflict flags two same-start drivers on one channel", () => {
const html = `
<sf-scene duration="6">
<sf-mesh id="hero" geometry="box"></sf-mesh>
<sf-animate target="camera" verb="orbit" around="#hero" start="0" duration="6"></sf-animate>
<sf-animate target="camera" verb="dolly" toward="#hero" start="0" duration="2"></sf-animate>
</sf-scene>${RUNTIME}`;
expect(rules(html)).toContain("ir_channel_conflict");
});

test("sequential drivers on one channel (different starts) are NOT a conflict", () => {
const html = `
<sf-scene duration="6">
<sf-mesh id="hero" geometry="box"></sf-mesh>
<sf-animate target="camera" verb="dolly" toward="#hero" start="0" duration="2"></sf-animate>
<sf-animate target="camera" verb="orbit" around="#hero" start="2" duration="4"></sf-animate>
</sf-scene>${RUNTIME}`;
expect(rules(html)).not.toContain("ir_channel_conflict");
});

test("time_impurity catches wall-clock and RNG in inline scripts", () => {
const html = `${OK}<script type="stereoframe">sf.onSeek(() => Math.random());</script>`;
expect(rules(html)).toContain("time_impurity");
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/tests/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ import {
GEOMETRY_KINDS,
MATERIAL_KINDS,
STAGE_PRESETS,
IR_DRIVER_KINDS,
IR_BEHAVIOR_KINDS,
IR_BEHAVIOR_VERBS,
IR_VERB_CHANNEL,
} from "stereoframe-runtime/vocab";
import { PRESETS } from "../src/stage";

const sceneSrc = readFileSync(new URL("../../runtime/src/scene.ts", import.meta.url), "utf8");
const cliSrc = readFileSync(new URL("../src/cli.ts", import.meta.url), "utf8");
const irTypesSrc = readFileSync(new URL("../../runtime/src/ir/types.ts", import.meta.url), "utf8");

describe("schema vocab consistency", () => {
test("VERB_PARAMS has an entry for every verb (and no extras)", () => {
Expand Down Expand Up @@ -52,6 +57,23 @@ describe("schema ↔ source drift guards", () => {
for (const n of names) expect(cliSrc).toContain(`stereoframe ${n}`);
});

test("IR driver/behavior kinds match the unions in ir/types.ts", () => {
for (const k of IR_DRIVER_KINDS) expect(irTypesSrc).toContain(`kind: "${k}"`);
for (const k of IR_BEHAVIOR_KINDS) expect(irTypesSrc).toContain(`kind: "${k}"`);
});

test("IR verb tables reference only real verbs", () => {
const verbs = new Set<string>(VERB_NAMES);
for (const v of Object.keys(IR_VERB_CHANNEL)) expect(verbs.has(v)).toBe(true);
for (const v of IR_BEHAVIOR_VERBS) expect(verbs.has(v)).toBe(true);
});

test("schema command emits the ir block", () => {
for (const key of ["driverKinds", "behaviorKinds", "timelineKinds", "channels", "verbChannel"]) {
expect(cliSrc).toContain(`${key}:`);
}
});

test("gen schema advertises implemented generation flags", () => {
for (const flag of [
"input",
Expand Down
21 changes: 20 additions & 1 deletion packages/runtime/src/animate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,29 @@ function makeOpacitySetter(obj: Object3D): (factor: number) => void {
};
}

export function compileAnimations(compiled: CompiledScene): void {
/**
* @param opts.skip verbs already handled elsewhere (the IR core owns
* turntable/orbit/etc); legacy writers are built only for the rest. This is the
* `core="ir"` hybrid: the IR drives the modeled verbs, legacy handles the
* long-tail (path/explode/variant/…) byte-identically until they migrate.
*/
export function compileAnimations(
compiled: CompiledScene,
opts: { skip?: Set<string>; skip3d?: Set<string> } = {},
): void {
const skip = opts.skip;
const skip3d = opts.skip3d;
const variantEls: Array<{ el: Element; timing: verbs.VerbTiming }> = [];
for (const el of Array.from(compiled.host.querySelectorAll("sf-animate"))) {
const verb = (el.getAttribute("verb") ?? "").toLowerCase();
if (skip?.has(verb)) continue;
// skip3d: the IR handled this verb only for 3D targets (camera or a #id that
// resolves to a scene object); DOM-target instances still fall through here.
if (skip3d?.has(verb)) {
const t = el.getAttribute("target");
const is3d = t === "camera" || (!!t && t.startsWith("#") && compiled.objectsById.has(t.slice(1)));
if (is3d) continue;
}

// Window verbs get a sensible default duration; continuous verbs
// (turntable, float, follow) stay duration-less.
Expand Down
8 changes: 6 additions & 2 deletions packages/runtime/src/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
import * as THREE from "three";
import { compileAnimations } from "./animate";
import { compileCallouts } from "./callout";
import { installIR } from "./ir/backend";
import { parseSeconds } from "./parse";
import { compileScene, sceneDuration } from "./scene";
import { installHfGate, installSeekListener, isStandalone, startPreviewLoop } from "./seek";

const STYLES = `
sf-scene { display: block; line-height: 0; }
sf-scene > canvas { display: block; }
sf-camera, sf-model, sf-mesh, sf-light, sf-env, sf-animate, sf-particles, sf-sky, sf-ocean, sf-swarm, sf-metaball, sf-scatter, sf-baked, sf-shader, sf-callout { display: none; }
sf-camera, sf-group, sf-state, sf-set, sf-model, sf-mesh, sf-light, sf-env, sf-animate, sf-particles, sf-sky, sf-ocean, sf-swarm, sf-metaball, sf-scatter, sf-baked, sf-shader, sf-callout { display: none; }
`;

function injectStyles(): void {
Expand Down Expand Up @@ -59,7 +60,10 @@ export async function boot(): Promise<void> {

await Promise.all(scenes.map((s) => s.ready));
for (const s of scenes) {
compileAnimations(s);
// Opt-in IR core (core="ir" on <sf-scene>): lower to the IR and drive seeks
// via evaluate(t) instead of the legacy verb writers. Same seek protocol.
if (s.host.getAttribute("core") === "ir") installIR(s);
else compileAnimations(s);
compileCallouts(s);
}
runEscapeHatchScripts(scenes);
Expand Down
Loading
Loading