@zanim/web 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ import { Transform2D, lerpColorValue, lerpNumber, lerpStyleState } from './core.js';
2
+
3
+ export function evaluateValue(scene, value, time) {
4
+ let out = value.initial;
5
+ for (const clip of scene._valueClipsByValue.get(value.id) ?? []) {
6
+ if (time < clip.start) break;
7
+ if (time >= clip.end) { out = clip.after; continue; }
8
+ const alpha = clip.end === clip.start ? 1 : (time - clip.start) / (clip.end - clip.start);
9
+ out = lerpNumber(clip.before, clip.after, clip.easing(alpha));
10
+ break;
11
+ }
12
+ return out;
13
+ }
14
+
15
+ function lerpBatchItem(a, b, t) {
16
+ return a.map((value, i) => {
17
+ if (typeof value === 'number' && typeof b[i] === 'number') return lerpNumber(value, b[i], t);
18
+ if (typeof value === 'string' || typeof b[i] === 'string') return lerpColorValue(typeof value === 'string' ? value : null, typeof b[i] === 'string' ? b[i] : null, t);
19
+ return t < 1 ? value : b[i];
20
+ });
21
+ }
22
+
23
+ export function evaluateBatch(scene, object, time) {
24
+ let out = (scene._batchInitial.get(object.id) ?? object.items).map(item => [...item]);
25
+ for (const clip of scene._batchClipsByObject.get(object.id) ?? []) {
26
+ if (time < clip.start) break;
27
+ if (time >= clip.end) { out = clip.after.map(item => [...item]); continue; }
28
+ const alpha = clip.end === clip.start ? 1 : (time - clip.start) / (clip.end - clip.start);
29
+ const t = clip.easing(alpha);
30
+ out = clip.before.map((item, i) => lerpBatchItem(item, clip.after[i], t));
31
+ break;
32
+ }
33
+ return out;
34
+ }
35
+
36
+ export function evaluateObjectState(scene, object, time) {
37
+ let state = { ...(scene.initial.get(object.id) ?? {}) };
38
+ for (const clip of scene._clipsByObject.get(object.id) ?? []) {
39
+ if (time < clip.start) break;
40
+ if (clip.kind === 'transformFunction') {
41
+ if (time >= clip.end) state.transform = clip.after;
42
+ else {
43
+ const alpha = clip.end === clip.start ? 1 : (time - clip.start) / (clip.end - clip.start);
44
+ state.transform = clip.provider(clip.easing(alpha));
45
+ }
46
+ continue;
47
+ }
48
+ for (const [key, change] of Object.entries(clip.changes)) {
49
+ if (time >= clip.end) state[key] = change.after;
50
+ else {
51
+ const alpha = clip.end === clip.start ? 1 : (time - clip.start) / (clip.end - clip.start);
52
+ const t = clip.easing(alpha);
53
+ if (key === 'transform') state.transform = Transform2D.lerp(change.before, change.after, t);
54
+ else if (key === 'style') state.style = lerpStyleState(change.before, change.after, t);
55
+ else state[key] = lerpNumber(change.before, change.after, t);
56
+ }
57
+ }
58
+ }
59
+ return state;
60
+ }
61
+
62
+ export function parentWorldAt(scene, object, time) {
63
+ const chain = [];
64
+ let parent = object._parent;
65
+ while (parent) { chain.push(parent); parent = parent._parent; }
66
+ let out = Transform2D.identity();
67
+ for (let i = chain.length - 1; i >= 0; i--) out = out.mul(evaluateObjectState(scene, chain[i], time).transform);
68
+ return out;
69
+ }
70
+
71
+ export function worldTransformAt(scene, object, time) {
72
+ return parentWorldAt(scene, object, time).mul(evaluateObjectState(scene, object, time).transform);
73
+ }
package/src/ir.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { CanvasRenderer, Scene, ScalarValue, Transform2D, ZanimWasm } from './zanim.js';
2
+
3
+ export const SCENE_IR_FORMAT:'zanim.scene';
4
+ export const SCENE_IR_VERSION:1;
5
+ export class SceneIRUnsupported extends Error {}
6
+
7
+ export type RGBA = [number,number,number,number];
8
+ export type IRTransform = [number,number,number,number,number,number];
9
+ export interface SceneIR {
10
+ format:'zanim.scene';
11
+ version:1;
12
+ canvas:{width:number;height:number;unit_size:number};
13
+ fps:number;
14
+ duration:number;
15
+ objects:Array<Record<string,unknown>>;
16
+ values:Array<Record<string,unknown>>;
17
+ resources:Array<Record<string,unknown>>;
18
+ clips:Array<Record<string,unknown>>;
19
+ meta?:Record<string,unknown>;
20
+ }
21
+ export function validateSceneIR(value:unknown):SceneIR;
22
+ export function sceneFromIR(ir:SceneIR,renderer:CanvasRenderer,options?:{proceduralQuality?:{resolution?:number;minWidth?:number;maxWidth?:number;maxHeight?:number}}):Scene;
23
+ export function createSceneFromIR(canvas:HTMLCanvasElement|string,ir:SceneIR,options?:{wasmURL?:string|URL;wasm?:ZanimWasm|null;renderer?:{unitSize?:number;background?:string};observeResize?:boolean;proceduralQuality?:{resolution?:number;minWidth?:number;maxWidth?:number;maxHeight?:number}}):Promise<Scene>;
24
+ export function sceneToIR(scene:Scene,options?:{sampleTransformFunctions?:boolean;sampleDynamicProviders?:boolean;sampleFps?:number}):SceneIR;
25
+ export function stringifySceneIR(ir:SceneIR,space?:number):string;
26
+ export function parseSceneIR(text:string):SceneIR;
package/src/ir.js ADDED
@@ -0,0 +1,250 @@
1
+ // Portable Zanim Scene IR v1 bridge.
2
+ // This module is deliberately separate from the realtime runtime: authoring can
3
+ // stay tiny, while apps that need cross-language serialization opt into IR.
4
+
5
+ import {
6
+ Scene, CanvasRenderer, ZanimWasm, DEFAULT_WASM_URL,
7
+ Transform2D, Easing, ScalarValue,
8
+ ZObject, Group, Line, Polyline, Polygon, Rectangle, Square, Circle, RegularPolygon,
9
+ Text, CustomObject2D, VectorObject2D,
10
+ LineSet, CircleSet, RectSet, DynamicPolyline, DynamicLineSet, DynamicCircleSet, DynamicRectSet,
11
+ InfiniteLine, InfiniteGrid, FractalField, MandelbrotSet, JuliaSet, ComplexMappedGrid, FourierEpicycles, FunctionPlot, ScalarExpr,
12
+ } from './zanim.js';
13
+ import { Audio as WebAudio, GIF as WebGIF, Image as WebImage, Video as WebVideo } from './media.js';
14
+
15
+ export const SCENE_IR_FORMAT='zanim.scene';
16
+ export const SCENE_IR_VERSION=1;
17
+
18
+ export class SceneIRUnsupported extends Error {}
19
+
20
+ const clamp01=x=>Math.max(0,Math.min(1,x));
21
+ const T=v=>new Transform2D(...v.map(Number));
22
+ const tIR=t=>[t.xx,t.xy,t.yx,t.yy,t.tx,t.ty];
23
+ const pIR=p=>[Number(p[0]),Number(p[1])];
24
+
25
+ function colorArray(value){
26
+ if(value==null)return null;
27
+ if(Array.isArray(value)){if(value.length!==4)throw new TypeError('IR color requires RGBA');return value.map(Number);}
28
+ if(typeof value!=='string')throw new TypeError('portable Web colors must be CSS strings');
29
+ let v=value.trim();
30
+ if(v.startsWith('#')){let h=v.slice(1);if(h.length===3||h.length===4)h=[...h].map(c=>c+c).join('');if(h.length===6)h+='ff';if(h.length!==8)throw new TypeError(`unsupported portable color: ${value}`);return [0,2,4,6].map(i=>parseInt(h.slice(i,i+2),16));}
31
+ const m=v.match(/^rgba?\(([^)]+)\)$/i);if(m){const q=m[1].split(',').map(x=>x.trim()),a=q.length>3?Math.round(Number(q[3])*255):255;return [Number(q[0]),Number(q[1]),Number(q[2]),a];}
32
+ throw new TypeError(`unsupported portable color: ${value}`);
33
+ }
34
+ function colorCSS(value){if(value==null)return null;const [r,g,b,a]=value;return `#${[r,g,b,a].map(v=>Math.max(0,Math.min(255,Math.round(v))).toString(16).padStart(2,'0')).join('')}`;}
35
+ function strokeIR(color,width){return color==null?null:{color:colorArray(color),width:Number(width)};}
36
+ function styleIRFromObject(o){
37
+ const fill='fill' in o?o.fill:null,stroke='stroke' in o?o.stroke:null,width='width' in o?o.width:null;
38
+ return {fill:colorArray(fill),stroke:stroke==null?null:{color:colorArray(stroke),width:Number(width??.035)}};
39
+ }
40
+ function styleWeb(value){const width=value?.stroke?.width??null;return {fill:colorCSS(value?.fill??null),stroke:value?.stroke?colorCSS(value.stroke.color):null,strokeWidth:width,width,worldStroke:true};}
41
+ function easingName(fn){if(fn===Easing.LINEAR)return 'linear';if(fn===Easing.SMOOTHSTEP)return 'smoothstep';if(fn===Easing.SMOOTH)return 'smooth';throw new SceneIRUnsupported('custom easing functions are not portable in Scene IR v1');}
42
+ function easingFn(name){if(name==='linear')return Easing.LINEAR;if(name==='smoothstep')return Easing.SMOOTHSTEP;if(name==='smooth')return Easing.SMOOTH;throw new SceneIRUnsupported(`unsupported IR easing: ${name}`);}
43
+ function se2Transform(value){const theta=Number(value.theta),[x,y]=value.translation,c=Math.cos(theta),s=Math.sin(theta);return new Transform2D(c,-s,s,c,Number(x),Number(y));}
44
+ function interpolateSE2(a,b,t){const d=((Number(b.theta)-Number(a.theta)+Math.PI)%(2*Math.PI)+2*Math.PI)%(2*Math.PI)-Math.PI,theta=Number(a.theta)+d*t,x=Number(a.translation[0])+(Number(b.translation[0])-Number(a.translation[0]))*t,y=Number(a.translation[1])+(Number(b.translation[1])-Number(a.translation[1]))*t,c=Math.cos(theta),s=Math.sin(theta);return new Transform2D(c,-s,s,c,x,y);}
45
+
46
+ function documentFromIR(raw){return {width:raw.width,height:raw.height,group_count:raw.group_count,paths:raw.paths.map(path=>({group:path.group??0,fill:colorCSS(path.fill),stroke:path.stroke?{color:colorCSS(path.stroke.color),width:Number(path.stroke.width)}:null,contours:path.contours.map(c=>({closed:!!c.closed,segments:c.segments.map(seg=>seg.map(p=>pIR(p)))}))}))};}
47
+ function documentToIR(doc){return {width:doc.width,height:doc.height,group_count:doc.group_count??1,paths:doc.paths.map(path=>({group:path.group??0,fill:colorArray(path.fill),stroke:path.stroke?{color:colorArray(path.stroke.color),width:Number(path.stroke.width)}:null,contours:path.contours.map(c=>({closed:!!c.closed,segments:c.segments.map(seg=>seg.map(p=>pIR(p)))}))}))};}
48
+
49
+ function batchItemsFromIR(raw){
50
+ if(raw.kind==='lines')return raw.starts.map((p,i)=>[p[0],p[1],raw.ends[i][0],raw.ends[i][1],colorCSS(raw.colors[i]),Number(raw.widths[i])]);
51
+ if(raw.kind==='circles')return raw.centers.map((p,i)=>[p[0],p[1],Number(raw.radii[i]),colorCSS(raw.fills[i]),raw.stroke_colors?colorCSS(raw.stroke_colors[i]):null,raw.stroke_widths?Number(raw.stroke_widths[i]):1]);
52
+ if(raw.kind==='rects')return raw.centers.map((p,i)=>[p[0],p[1],raw.sizes[i][0],raw.sizes[i][1],colorCSS(raw.fills[i]),raw.stroke_colors?colorCSS(raw.stroke_colors[i]):null,raw.stroke_widths?Number(raw.stroke_widths[i]):1]);
53
+ throw new SceneIRUnsupported(`unknown batch kind ${raw.kind}`);
54
+ }
55
+ function batchIRFromItems(object,items){
56
+ if(object instanceof LineSet)return {kind:'lines',starts:items.map(x=>[x[0],x[1]]),ends:items.map(x=>[x[2],x[3]]),colors:items.map(x=>colorArray(x[4]??object.stroke)),widths:items.map(x=>Number(x[5]??object.width))};
57
+ if(object instanceof CircleSet)return {kind:'circles',centers:items.map(x=>[x[0],x[1]]),radii:items.map(x=>Number(x[2])),fills:items.map(x=>colorArray(x[3]??object.fill)),stroke_colors:items.some(x=>(x[4]??object.stroke)!=null)?items.map(x=>colorArray(x[4]??object.stroke)):null,stroke_widths:items.some(x=>(x[4]??object.stroke)!=null)?items.map(x=>Number(x[5]??object.width)):null};
58
+ if(object instanceof RectSet)return {kind:'rects',centers:items.map(x=>[x[0],x[1]]),sizes:items.map(x=>[Number(x[2]),Number(x[3])]),fills:items.map(x=>colorArray(x[4]??object.fill)),stroke_colors:items.some(x=>(x[5]??object.stroke)!=null)?items.map(x=>colorArray(x[5]??object.stroke)):null,stroke_widths:items.some(x=>(x[5]??object.stroke)!=null)?items.map(x=>Number(x[6]??object.width)):null};
59
+ throw new SceneIRUnsupported(`unsupported Web batch ${object.constructor.name}`);
60
+ }
61
+ function batchObjectFromIR(raw,state,common){
62
+ const items=batchItemsFromIR(raw);
63
+ if(raw.kind==='lines')return new LineSet(items,{worldStroke:true,...common});
64
+ if(raw.kind==='circles')return new CircleSet(items,{worldStroke:true,...common});
65
+ return new RectSet(items,{worldStroke:true,...common});
66
+ }
67
+
68
+ function geometryObjectFromIR(g,state){
69
+ const st=styleWeb(state.style),common={transform:T(state.transform),opacity:Number(state.opacity),zIndex:Number(state.z_index),fill:st.fill,stroke:st.stroke};
70
+ if(st.strokeWidth!=null)common.strokeWidth=Number(st.strokeWidth);
71
+ const trim=Number(state.trim??1);let o;
72
+ if(g.kind==='line')o=new Line(g.start,g.end,common);
73
+ else if(g.kind==='polyline')o=new Polyline(g.points,{...common,trim});
74
+ else if(g.kind==='polygon')o=new Polygon(g.points,{...common,trim});
75
+ else if(g.kind==='rectangle')o=new Rectangle(Number(g.width),Number(g.height),{...common,trim});
76
+ else if(g.kind==='square')o=new Square(Number(g.side),{...common,trim});
77
+ else if(g.kind==='circle')o=new Circle(Number(g.radius),{...common,trim});
78
+ else if(g.kind==='regular_polygon')o=new RegularPolygon(Number(g.sides),Number(g.radius),{...common,phase:Number(g.phase),trim});
79
+ else throw new SceneIRUnsupported(`Web IR player does not yet implement geometry ${g.kind}`);
80
+ return o;
81
+ }
82
+ function snapshotObjectFromIR(raw){return geometryObjectFromIR(raw.geometry,raw);}
83
+ function geometryIRFromObject(o){
84
+ if(o instanceof Square)return {kind:'square',side:o.rectWidth};
85
+ if(o instanceof Rectangle)return {kind:'rectangle',width:o.rectWidth,height:o.rectHeight};
86
+ if(o instanceof RegularPolygon)return {kind:'polygon',points:o.points.map(pIR)};
87
+ if(o instanceof Polygon)return {kind:'polygon',points:o.points.map(pIR)};
88
+ if(o instanceof Polyline)return {kind:'polyline',points:o.points.map(pIR)};
89
+ if(o instanceof Line)return {kind:'line',start:pIR(o.start),end:pIR(o.end)};
90
+ if(o instanceof Circle)return {kind:'circle',radius:o.radius};
91
+ throw new SceneIRUnsupported(`unsupported Web geometry ${o.constructor.name}`);
92
+ }
93
+ function objectSnapshotToIR(o,state){return {geometry:geometryIRFromObject(o),transform:tIR(state.transform),style:styleIRFromObjectState(o,state.style),opacity:Number(state.opacity),z_index:Number(o.zIndex),trim:Number(state.reveal??('reveal' in o?o.reveal:1)??1)};}
94
+ function styleIRFromObjectState(o,state){const s=state??null;if(s&&('fill' in s||'stroke' in s))return {fill:colorArray(s.fill??null),stroke:s.stroke==null?null:{color:colorArray(s.stroke),width:Number(s.width??.035)}};return styleIRFromObject(o);}
95
+
96
+ function commonState(record){const s=record.state;return {transform:T(s.transform),opacity:Number(s.opacity??1),zIndex:Number(s.z_index??0)};}
97
+
98
+ function sampledIndex(state,time){
99
+ const offsets=state.sample_offsets??[],local=Number(time)-Number(state.sample_start??0);
100
+ if(!offsets.length)return 0;let lo=0,hi=offsets.length;while(lo<hi){const mid=(lo+hi)>>1;if(Number(offsets[mid])<=local+1e-12)lo=mid+1;else hi=mid;}return Math.max(0,Math.min(offsets.length-1,lo-1));
101
+ }
102
+ function sampledValue(state,time){return state.samples[sampledIndex(state,time)];}
103
+
104
+ class SampledGeometryIRObject extends ZObject {
105
+ constructor(state){
106
+ const style=styleWeb(state.style);super({transform:T(state.transform),opacity:Number(state.opacity??1),zIndex:Number(state.z_index??0)});
107
+ this.track=state;this.fill=style.fill;this.stroke=style.stroke;this.width=style.strokeWidth??.035;this.worldStroke=true;this.reveal=Number(state.trim??1);
108
+ }
109
+ draw(r,parent=Transform2D.identity()){
110
+ const geometry=sampledValue(this.track,r.time),style={fill:colorArray(this.fill),stroke:this.stroke==null?null:{color:colorArray(this.stroke),width:Number(this.width)}};
111
+ const child=geometryObjectFromIR(geometry,{geometry,transform:[1,0,0,1,0,0],style,opacity:1,z_index:0,trim:this.reveal});
112
+ child.draw(r,parent.mul(this.transform));
113
+ }
114
+ }
115
+ class SampledVectorIRObject extends VectorObject2D {
116
+ constructor(state,resources){
117
+ const docs=state.samples.map(id=>{const resource=resources.get(Number(id));if(!resource||resource.kind!=='vector_document')throw new SceneIRUnsupported('sampled_vector2d references missing vector_document resource');return documentFromIR(resource.data);});
118
+ super(docs[0],{transform:T(state.transform),reveal:Number(state.reveal??1),opacity:Number(state.opacity??1),zIndex:Number(state.z_index??0)});this.track=state;this.documents=docs;this._sampleIndex=-1;
119
+ }
120
+ draw(r,parent=Transform2D.identity()){const index=sampledIndex(this.track,r.time);if(index!==this._sampleIndex){this._sampleIndex=index;this.document=this.documents[index];this.invalidate();}super.draw(r,parent);}
121
+ }
122
+ function sampledBatchObjectFromIR(state,common){
123
+ const first=state.samples[0],provider=time=>batchItemsFromIR(sampledValue(state,time));
124
+ if(first.kind==='lines')return new DynamicLineSet(provider,{worldStroke:true,...common});
125
+ if(first.kind==='circles')return new DynamicCircleSet(provider,{worldStroke:true,...common});
126
+ if(first.kind==='rects')return new DynamicRectSet(provider,{worldStroke:true,...common});
127
+ throw new SceneIRUnsupported(`unknown sampled batch kind ${first.kind}`);
128
+ }
129
+
130
+ export function sceneFromIR(ir,renderer,{proceduralQuality={resolution:.22,minWidth:96,maxWidth:720,maxHeight:405}}={}){
131
+ validateSceneIR(ir);
132
+ const scene=new Scene(renderer,{fps:Number(ir.fps)}),records=new Map(ir.objects.map(x=>[Number(x.id),x])),resources=new Map((ir.resources??[]).map(x=>[Number(x.id),x])),values=new Map(),objects=new Map([[0,scene.camera]]);
133
+ for(const v of ir.values??[])values.set(Number(v.id),new ScalarValue(Number(v.initial)));
134
+ // Build all objects before hierarchy/value references.
135
+ for(const record of [...records.values()].sort((a,b)=>a.id-b.id)){
136
+ const id=Number(record.id),kind=record.kind,s=record.state;if(kind==='camera2d')continue;const common=commonState(record);let o;
137
+ if(kind==='group')o=new Group([],common);
138
+ else if(kind==='object2d')o=geometryObjectFromIR(s.geometry,s);
139
+ else if(kind==='sampled_object2d')o=new SampledGeometryIRObject(s);
140
+ else if(kind==='function_plot'){const st=styleWeb(s.style),a=s.axes;o=new FunctionPlot(ScalarExpr.fromData(s.expression),{...common,xRange:s.x_range.map(Number),axesXRange:a.x_range.map(Number),axesYRange:a.y_range.map(Number),width:Number(a.width),height:Number(a.height),center:a.center.map(Number),samples:Number(s.samples),stroke:st.stroke,strokeWidth:Number(st.strokeWidth??.035),trim:Number(s.trim??1)});}
141
+ else if(kind==='batch2d')o=batchObjectFromIR(s.batch,s,common);
142
+ else if(kind==='sampled_batch2d')o=sampledBatchObjectFromIR(s,common);
143
+ else if(kind==='vector2d'){const resource=resources.get(Number(s.resource));if(!resource||resource.kind!=='vector_document')throw new SceneIRUnsupported('vector2d references missing vector_document resource');o=new VectorObject2D(documentFromIR(resource.data),{...common,reveal:Number(s.reveal??1)});}
144
+ else if(kind==='media2d'){const resource=resources.get(Number(s.resource));if(!resource||resource.kind!=='external_media')throw new SceneIRUnsupported('media2d references missing external_media resource');const d=resource.data,opts={...common,width:Number(s.width),height:Number(s.height),sourceWidth:Number(d.source_width??0),sourceHeight:Number(d.source_height??0),duration:d.duration==null?null:Number(d.duration)};if(d.media_kind==='image')o=new WebImage(d.url,opts);else if(d.media_kind==='gif')o=new WebGIF(d.url,opts);else if(d.media_kind==='video')o=new WebVideo(d.url,opts);else throw new SceneIRUnsupported(`unknown external media kind ${d.media_kind}`);}
145
+ else if(kind==='audio'){const resource=resources.get(Number(s.resource));if(!resource||resource.kind!=='external_media'||resource.data.media_kind!=='audio')throw new SceneIRUnsupported('audio references missing external_media resource');const d=resource.data;o=new WebAudio(d.url,{...common,gain:Number(s.gain??1),duration:d.duration==null?null:Number(d.duration)});}
146
+ else if(kind==='sampled_vector2d')o=new SampledVectorIRObject(s,resources);
147
+ else if(kind==='fourier_epicycles'){const circle=styleWeb(s.circle_style),arrow=styleWeb(s.arrow_style),trace=styleWeb(s.trace_style),tip=styleWeb(s.tip_style);o=new FourierEpicycles(s.terms,{...common,startTime:Number(s.start_time),drawDuration:Number(s.draw_duration),circleSamples:Number(s.circle_samples),traceSamples:Number(s.trace_samples),visualIndices:s.visual_indices.map(Number),circleColor:circle.stroke,circleWidth:Number(circle.strokeWidth??.012),arrowColor:arrow.fill,traceColor:trace.stroke,traceWidth:Number(trace.strokeWidth??.045),tipColor:tip.fill,tipRadius:Number(s.tip_radius),tipSides:Number(s.tip_sides)});}
148
+ else if(kind==='infinite_line')o=new InfiniteLine(s.point,s.direction,{...common,stroke:colorCSS(s.color),strokeWidth:Number(s.stroke_width)});
149
+ else if(kind==='infinite_grid'){
150
+ if(Math.abs(Number(s.origin?.[0]??0))>1e-12||Math.abs(Number(s.origin?.[1]??0))>1e-12||Math.abs(Number(s.step[0])-Number(s.step[1]))>1e-12)throw new SceneIRUnsupported('Web InfiniteGrid IR v1 currently requires origin=0 and equal x/y step');
151
+ o=new InfiniteGrid({step:Number(s.step[0]),stroke:colorCSS(s.color),strokeWidth:Number(s.stroke_width),...common});
152
+ }else if(kind==='fractal'){
153
+ const opts={...common,...proceduralQuality,viewport:'transform',maxIter:Number(s.max_iter),juliaC:s.julia_c,colorShift:Number(s.color_shift),colorScale:Number(s.color_scale),insideColor:colorCSS(s.inside_color),paletteColor:colorCSS(s.palette_color)};
154
+ o=Number(s.fractal_kind)===1?new MandelbrotSet(opts):new JuliaSet(s.julia_c,opts);
155
+ }else if(kind==='complex_grid'){
156
+ const map={1:'square',2:'exp',3:'reciprocal',4:'mobius'}[Number(s.map_kind)],progress=s.progress;
157
+ o=new ComplexMappedGrid(map,{...common,...proceduralQuality,viewport:'canvas',step:s.step,mapParams:s.map_params,progress:typeof progress==='object'?0:Number(progress)});
158
+ }else throw new SceneIRUnsupported(`Web IR player does not support object ${kind}`);
159
+ objects.set(id,o);
160
+ }
161
+ // Resolve scalar references and hierarchy.
162
+ for(const record of records.values()){
163
+ if(record.kind==='complex_grid'&&typeof record.state.progress==='object'){const o=objects.get(Number(record.id)),ref=Number(record.state.progress.value_ref);if(!values.has(ref))throw new Error(`missing ScalarValue ${ref}`);o.progress=values.get(ref);}
164
+ const parent=record.parent;if(parent!=null&&record.kind!=='camera2d'){const p=objects.get(Number(parent)),o=objects.get(Number(record.id));if(!(p instanceof Group))throw new Error('IR parent must be Group');p.children.push(o);o._parent=p;}
165
+ }
166
+ const cameraRecord=[...records.values()].find(r=>r.kind==='camera2d');if(cameraRecord){const s=cameraRecord.state;scene.camera.transform=T(s.transform);scene.camera.opacity=Number(s.opacity??1);scene.camera.zIndex=Number(s.z_index??0);scene.initial.set(scene.camera.id,{transform:scene.camera.transform,opacity:scene.camera.opacity,reveal:null,style:null});}
167
+ // Register roots without changing authored time.
168
+ for(const record of [...records.values()].sort((a,b)=>a.id-b.id)){if(record.kind==='camera2d'||record.parent!=null)continue;const o=objects.get(Number(record.id));scene.objects.push(o);scene._track(o,Number(record.birth??0));}
169
+ for(const [id,v] of values){scene.addValue(v);v._irId=id;}
170
+ for(const record of records.values()){if(record.kind==='camera2d')continue;const o=objects.get(Number(record.id));o.birth=Number(record.birth??0);o.death=record.death==null?Infinity:Number(record.death);o._irId=Number(record.id);}
171
+ scene.camera._irId=0;
172
+ const valueObject=id=>{const v=values.get(Number(id));if(!v)throw new Error(`unknown IR value ${id}`);return v;},object=id=>{const o=Number(id)===0?scene.camera:objects.get(Number(id));if(!o)throw new Error(`unknown IR object ${id}`);return o;};
173
+ const clips=[...(ir.clips??[])].map((x,i)=>({...x,_order:i})).sort((a,b)=>Number(a.start)-Number(b.start)||a._order-b._order);
174
+ for(const raw of clips){const start=Number(raw.start),duration=Number(raw.duration),e=easingFn(raw.easing??'smoothstep');
175
+ // Scene authoring uses relative `at`; IR stores absolute clip starts.
176
+ // Move the authoring cursor to the IR start and schedule at offset zero.
177
+ scene.at(start);
178
+ if(raw.kind==='transform')scene.animate(object(raw.target),{transform:T(raw.after),duration,easing:e});
179
+ else if(raw.kind==='se2_transform'){
180
+ const target=object(raw.target),before=raw.before,after=raw.after;
181
+ scene.transformFunction(target,a=>interpolateSE2(before,after,a),{duration,easing:e});
182
+ scene.clips.at(-1)._irSE2={before,after};
183
+ }
184
+ else if(raw.kind==='sampled_transform'){
185
+ const samples=raw.samples.map(T),offsets=(raw.sample_offsets??raw.samples.map((_,i)=>duration*i/Math.max(1,raw.samples.length-1))).map(Number),provider=a=>{if(samples.length===1)return samples[0];const local=clamp01(a)*duration;let i=0;while(i+1<offsets.length&&offsets[i+1]<=local+1e-12)i++;if(Math.abs(local-offsets[i])<=1e-10||i===samples.length-1)return samples[i];const j=i+1,width=offsets[j]-offsets[i],u=width<=1e-15?0:(local-offsets[i])/width;return Transform2D.lerp(samples[i],samples[j],clamp01(u));};scene.transformFunction(object(raw.target),provider,{duration,easing:Easing.LINEAR});const clip=scene.clips.at(-1);clip._irSampled={sample_rate:raw.sample_rate,sample_offsets:raw.sample_offsets,samples:raw.samples};
186
+ }else if(raw.kind==='opacity')scene.animate(object(raw.target),{opacity:Number(raw.after),duration,easing:e});
187
+ else if(raw.kind==='style')scene.animate(object(raw.target),{style:styleWeb(raw.after),duration,easing:e});
188
+ else if(raw.kind==='trim'||raw.kind==='reveal')scene.animate(object(raw.target),{reveal:Number(raw.after),duration,easing:e});
189
+ else if(raw.kind==='batch')scene.batch(object(raw.target),{to:batchItemsFromIR(raw.after),duration,easing:e});
190
+ else if(raw.kind==='value')scene.animateValue(valueObject(raw.target),{to:Number(raw.after),duration,easing:e});
191
+ else if(raw.kind==='media_playback')scene.media(object(raw.target),{duration,sourceStart:Number(raw.source_start??0),speed:Number(raw.speed??1),loop:!!raw.loop,sourceDuration:raw.source_duration==null?object(raw.target).duration:Number(raw.source_duration)});
192
+ else if(raw.kind==='interpolation')scene.interpolate(snapshotObjectFromIR(raw.source),snapshotObjectFromIR(raw.target),{duration,easing:e});
193
+ else throw new SceneIRUnsupported(`Web IR player does not support clip ${raw.kind}`);
194
+ }
195
+ scene.cursor=Number(ir.duration??0);scene.duration=Number(ir.duration??0);scene.seek(0);return scene;
196
+ }
197
+
198
+ export async function createSceneFromIR(canvas,ir,{wasmURL=DEFAULT_WASM_URL,wasm=null,renderer={},observeResize=true,...options}={}){
199
+ const target=typeof canvas==='string'?document.querySelector(canvas):canvas;if(!target||typeof target.getContext!=='function')throw new TypeError('createSceneFromIR requires a canvas element or selector');
200
+ const engine=wasm??await ZanimWasm.load(wasmURL),scene=sceneFromIR(ir,new CanvasRenderer(target,engine,{unitSize:Number(ir.canvas?.unit_size??renderer.unitSize??90),...renderer}),options);
201
+ if(observeResize&&typeof ResizeObserver!=='undefined'){scene._resizeObserver=new ResizeObserver(()=>scene.render());scene._resizeObserver.observe(target);}scene.render();return scene;
202
+ }
203
+
204
+ function portableScalar(value,idMap){if(value instanceof ScalarValue)return {value_ref:idMap.get(value)};if(typeof value==='number')return value;if(typeof value==='function')throw new SceneIRUnsupported('runtime scalar callback is not portable in Scene IR v1');return Number(value);}
205
+ function initialOf(scene,o){return scene.initial.get(o.id)??{transform:o.transform,opacity:o.opacity,reveal:'reveal' in o?o.reveal:null,style:null};}
206
+ function frameSampleTimes(start,end,rate){start=Number(start);end=Math.max(start,Number(end));const times=[start];if(end<=start+1e-15)return times;const first=Math.ceil(start*rate-1e-12),last=Math.floor(end*rate+1e-12);for(let frame=first;frame<=last;frame++){const time=frame/rate;if(time>start+1e-12&&time<end-1e-12)times.push(time);}if(end-times.at(-1)>1e-12)times.push(end);else times[times.length-1]=end;return times;}
207
+ function sampledState(times,samples,rate){const start=times[0];return{sample_rate:rate,sample_start:start,sample_offsets:times.map(time=>time-start),samples};}
208
+
209
+ export function sceneToIR(scene,{sampleTransformFunctions=false,sampleDynamicProviders=false,sampleFps=scene?.fps??60}={}){
210
+ if(!(scene instanceof Scene))throw new TypeError('sceneToIR requires Scene');
211
+ const idMap=new Map([[scene.camera,0]]),portableObjects=[...scene._trackedObjects.values()].filter(o=>o!==scene.camera&&!o._transientInterpolation),values=[...scene.values];let next=1;for(const o of portableObjects)idMap.set(o,next++);for(const v of values)idMap.set(v,next++);
212
+ const resources=[],resourceIds=new Map();const vectorResource=doc=>{if(resourceIds.has(doc))return resourceIds.get(doc);const id=resources.length+1;resourceIds.set(doc,id);resources.push({id,kind:'vector_document',data:documentToIR(doc)});return id;};
213
+ const objects=[{id:0,parent:null,birth:0,death:null,kind:'camera2d',state:{transform:tIR(initialOf(scene,scene.camera).transform),opacity:initialOf(scene,scene.camera).opacity,z_index:scene.camera.zIndex}}];
214
+ let sampledDynamicObjects=0;
215
+ for(const o of portableObjects){const isDynamicGeometry=o instanceof DynamicPolyline,isDynamicBatch=o instanceof DynamicLineSet||o instanceof DynamicCircleSet||o instanceof DynamicRectSet,hasProvider='provider' in o&&typeof o.provider==='function';if(o._webRuntimeOnly||o instanceof CustomObject2D||o instanceof Text||(hasProvider&&!isDynamicGeometry&&!isDynamicBatch))throw new SceneIRUnsupported(`${o.constructor.name} contains browser/runtime code and is not portable in Scene IR v1`);if((isDynamicGeometry||isDynamicBatch)&&!sampleDynamicProviders)throw new SceneIRUnsupported(`${o.constructor.name} contains browser/runtime code; pass {sampleDynamicProviders:true} to bake it`);const init=initialOf(scene,o),base={id:idMap.get(o),parent:o._parent?idMap.get(o._parent):null,birth:o.birth,death:Number.isFinite(o.death)?o.death:null};let record;
216
+ if(o instanceof Group)record={...base,kind:'group',state:{transform:tIR(init.transform),opacity:init.opacity,z_index:o.zIndex}};
217
+ else if(o instanceof VectorObject2D)record={...base,kind:'vector2d',state:{resource:vectorResource(o.document),transform:tIR(init.transform),reveal:Number(init.reveal??1),opacity:init.opacity,z_index:o.zIndex}};
218
+ else if(isDynamicGeometry){const rate=Math.max(1,Math.round(Number(sampleFps))),end=Math.min(Number.isFinite(o.death)?o.death:scene.duration,scene.duration),times=frameSampleTimes(o.birth,Math.max(o.birth,end),rate),samples=times.map(time=>({kind:'polyline',points:o.provider(time,o).map(pIR)}));record={...base,kind:'sampled_object2d',state:{...sampledState(times,samples,rate),transform:tIR(init.transform),style:styleIRFromObject(o),opacity:init.opacity,z_index:o.zIndex,trim:Number(init.reveal??o.reveal??1)}};sampledDynamicObjects++;}
219
+ else if(isDynamicBatch){const rate=Math.max(1,Math.round(Number(sampleFps))),end=Math.min(Number.isFinite(o.death)?o.death:scene.duration,scene.duration),times=frameSampleTimes(o.birth,Math.max(o.birth,end),rate),samples=times.map(time=>batchIRFromItems(o,o.provider(time,o)));record={...base,kind:'sampled_batch2d',state:{...sampledState(times,samples,rate),transform:tIR(init.transform),opacity:init.opacity,z_index:o.zIndex}};sampledDynamicObjects++;}
220
+ else if(o instanceof LineSet||o instanceof CircleSet||o instanceof RectSet)record={...base,kind:'batch2d',state:{batch:batchIRFromItems(o,scene._batchInitial.get(o.id)??o.items),transform:tIR(init.transform),opacity:init.opacity,z_index:o.zIndex}};
221
+ else if(o instanceof FunctionPlot)record={...base,kind:'function_plot',state:{expression:o.expression.toData(),axes:{x_range:[...o.axesXRange],y_range:[...o.axesYRange],width:o.plotWidth,height:o.plotHeight,center:[...o.plotCenter]},x_range:[...o.xRange],samples:o.samples,transform:tIR(init.transform),style:styleIRFromObject(o),opacity:init.opacity,z_index:o.zIndex,trim:Number(init.reveal??o.reveal??1)}};
222
+ else if(o instanceof FourierEpicycles)record={...base,kind:'fourier_epicycles',state:{terms:o.terms.map(term=>[term.frequency,term.re,term.im]),start_time:o.startTime,draw_duration:o.drawDuration,circle_samples:o.circleSamples,trace_samples:o.traceSamples,visual_indices:[...o.visualIndices],circle_style:{fill:null,stroke:{color:colorArray(o.circleColor),width:o.circleWidth}},arrow_style:{fill:colorArray(o.arrowColor),stroke:null},trace_style:{fill:null,stroke:{color:colorArray(o.traceColor),width:o.traceWidth}},tip_style:{fill:colorArray(o.tipColor),stroke:null},tip_radius:o.tipRadius,tip_sides:o.tipSides,transform:tIR(init.transform),opacity:init.opacity,z_index:o.zIndex}};
223
+ else if(o instanceof InfiniteLine)record={...base,kind:'infinite_line',state:{point:pIR(o.point),direction:pIR(o.direction),transform:tIR(init.transform),color:colorArray(o.stroke),stroke_width:Number(o.width),opacity:init.opacity,z_index:o.zIndex}};
224
+ else if(o instanceof InfiniteGrid)record={...base,kind:'infinite_grid',state:{origin:[0,0],step:[Number(o.step),Number(o.step)],transform:tIR(init.transform),color:colorArray(o.stroke),stroke_width:Number(o.width),opacity:init.opacity,z_index:o.zIndex}};
225
+ else if(o instanceof FractalField){if([o.maxIter,o.colorShift,o.colorScale,...o.juliaC,...o.viewportCenter,o.zoom].some(x=>typeof x==='function'||x instanceof ScalarValue))throw new SceneIRUnsupported('dynamic FractalField parameter callbacks are not portable; animate its transform instead');record={...base,kind:'fractal',state:{fractal_kind:o.kind,max_iter:Number(o.maxIter),escape_radius:2,julia_c:o.juliaC.map(Number),inside_color:colorArray(o.insideColor),palette_color:colorArray(o.paletteColor),color_shift:Number(o.colorShift),color_scale:Number(o.colorScale),transform:tIR(init.transform),opacity:init.opacity,z_index:o.zIndex}};}
226
+ else if(o instanceof ComplexMappedGrid)record={...base,kind:'complex_grid',state:{map_kind:o.mapKind,origin:[0,0],step:o.step.map(Number),progress:portableScalar(o.progress,idMap),map_params:[...o.mapParams],x_color:[255,151,92,210],y_color:[95,218,255,210],stroke_width:Number(o.strokePx)/90,transform:tIR(init.transform),opacity:init.opacity,z_index:o.zIndex}};
227
+ else if(o instanceof ZObject)record={...base,kind:'object2d',state:objectSnapshotToIR(o,init)};
228
+ else throw new SceneIRUnsupported(`unsupported Web object ${o.constructor.name}`);objects.push(record);
229
+ }
230
+ const valueRecords=values.map(v=>({id:idMap.get(v),parent:null,birth:0,death:null,kind:'scalar',initial:v.initial})),clips=[];
231
+ for(const clip of scene.clips){if(clip.kind==='batch'){clips.push({kind:'batch',target:idMap.get(clip.object),start:clip.start,duration:clip.end-clip.start,easing:easingName(clip.easing),before:batchIRFromItems(clip.object,clip.before),after:batchIRFromItems(clip.object,clip.after)});continue;}if(clip.kind==='transformFunction'){
232
+ if(clip._irSE2){clips.push({kind:'se2_transform',target:idMap.get(clip.object),start:clip.start,duration:clip.end-clip.start,easing:easingName(clip.easing),before:clip._irSE2.before,after:clip._irSE2.after});continue;}
233
+ if(clip._irSampled){clips.push({kind:'sampled_transform',target:idMap.get(clip.object),start:clip.start,duration:clip.end-clip.start,sample_rate:clip._irSampled.sample_rate,sample_offsets:clip._irSampled.sample_offsets,samples:clip._irSampled.samples});continue;}
234
+ if(!sampleTransformFunctions)throw new SceneIRUnsupported('transformFunction callback is not portable; pass {sampleTransformFunctions:true} to bake it');
235
+ const rate=Math.max(1,Math.round(Number(sampleFps))),duration=clip.end-clip.start,times=[clip.start];
236
+ const first=Math.ceil(clip.start*rate-1e-12),last=Math.floor(clip.end*rate+1e-12);
237
+ for(let frame=first;frame<=last;frame++){const time=frame/rate;if(time>clip.start+1e-12&&time<clip.end-1e-12)times.push(time);}
238
+ if(clip.end-times.at(-1)>1e-12)times.push(clip.end);else times[times.length-1]=clip.end;
239
+ const samples=times.map(time=>{const alpha=duration<=0?1:(time-clip.start)/duration,value=clip.provider(clip.easing(clamp01(alpha)));if(!(value instanceof Transform2D))throw new SceneIRUnsupported('transformFunction provider must return Transform2D');return tIR(value);});
240
+ clips.push({kind:'sampled_transform',target:idMap.get(clip.object),start:clip.start,duration,sample_rate:rate,sample_offsets:times.map(time=>time-clip.start),samples});continue;
241
+ }if(clip.kind!=='state')continue;for(const [key,change] of Object.entries(clip.changes)){const common={target:idMap.get(clip.object),start:clip.start,duration:clip.end-clip.start,easing:easingName(clip.easing)};if(key==='transform')clips.push({kind:'transform',...common,before:tIR(change.before),after:tIR(change.after)});else if(key==='opacity')clips.push({kind:'opacity',...common,before:change.before,after:change.after});else if(key==='reveal')clips.push({kind:'trim',...common,before:change.before,after:change.after});else if(key==='style')clips.push({kind:'style',...common,before:styleIRFromObjectState(clip.object,change.before),after:styleIRFromObjectState(clip.object,change.after)});}}
242
+ for(const clip of scene.valueClips)clips.push({kind:'value',target:idMap.get(clip.value),start:clip.start,duration:clip.end-clip.start,easing:easingName(clip.easing),before:clip.before,after:clip.after});
243
+ for(const x of scene.interpolations){clips.push({kind:'interpolation',start:x.start,duration:x.end-x.start,easing:easingName(x.easing),source:objectSnapshotToIR(x.source,x.sourceState),target:objectSnapshotToIR(x.target,x.targetState)});}
244
+ clips.sort((a,b)=>a.start-b.start);
245
+ return {format:SCENE_IR_FORMAT,version:SCENE_IR_VERSION,canvas:{width:scene.renderer?.canvas?.width??1280,height:scene.renderer?.canvas?.height??720,unit_size:scene.renderer?.baseUnitSize??90},fps:scene.fps,duration:scene.duration,objects,values:valueRecords,resources,clips,meta:{portable:true,sampled_dynamic_objects:sampledDynamicObjects}};
246
+ }
247
+
248
+ export function validateSceneIR(ir){if(!ir||ir.format!==SCENE_IR_FORMAT||Number(ir.version)!==SCENE_IR_VERSION)throw new TypeError(`unsupported Zanim Scene IR ${ir?.format??'<missing>'} v${ir?.version??'?'}`);if(!ir.canvas||!Array.isArray(ir.objects)||!Array.isArray(ir.resources)||!Array.isArray(ir.clips))throw new TypeError('invalid Zanim Scene IR structure');return ir;}
249
+ export const stringifySceneIR=(ir,space=0)=>JSON.stringify(validateSceneIR(ir),null,space);
250
+ export const parseSceneIR=text=>validateSceneIR(JSON.parse(text));