# wity-scene — Full Documentation Version: scene-core v1.0.0 · scene-headless v1.0.0 · scene-player v1.0.0 · scene-to-video v1.0.0 · scene-compose v1.0.0 · scene-to-pdf v1.0.0 · scene-to-pptx v1.0.0 --- ## QUICK REFERENCE — read this first ### Core model A wity-scene document is a **pure function of time**: ``` f(scene: WityScene, t: number) → ComputedFrame ``` - `scene` is parsed from XML (immutable) - `t` is a time in seconds, clamped to `[0, scene.dur]` - `ComputedFrame` is the complete render state — pixel positions, resolved props, effective opacity No interpolation state. No playback timers. Call `evaluate(scene, t)` at any time, in any order. ### Install ```bash npm install @wity/scene-core # Node.js only (optional XML peer): npm install @xmldom/xmldom ``` ### Import ```js import { parse, evaluate, serialize, validate, resolveUnit } from '@wity/scene-core'; // or: import { parse, evaluate } from '@wity/scene'; ``` ### Minimal example ```js const scene = parse(` Opening Night `); const frame = evaluate(scene, 1.0); // frame.elements → [{id, tag, x, y, opacity, visible, props, content}, ...] ``` --- ## SCHEMA v1.0 ### Document structure ``` WityScene ├── WsCharacter[] (ws-cast — semantic metadata entities, non-rendered) └── WsLayer[] (z-ordered planes) └── WsElement[] (ws-text | ws-rect | ws-image | ws-video | ws-audio) ``` ### Root element ```xml ``` | Attribute | Type | Required | |-----------|--------|----------| | version | string | yes — must be "1.0" | | width | number | yes — canvas width px | | height | number | yes — canvas height px | | dur | number | yes — total duration seconds | ### Layers ```xml ``` | Attribute | Default | Description | |-----------|---------|-------------| | id | — | unique layer id | | z | 0 | layer stacking order | | opacity | 1 | layer-level opacity, multiplied with element opacity | ### Common element attributes (visual elements) | XML attribute | Object field | Default | Description | |------------------|----------------|------------|-------------| | id | id | auto | unique element id | | x | x | 0 | horizontal position (unit value) | | y | y | 0 | vertical position (unit value) | | anchor | anchor | top-left | origin point for x/y | | begin | begin | 0 | start time seconds | | dur | dur | Infinity | duration seconds | | z | z | 0 | z-index within layer | | opacity | opacity | 1 | element opacity (0–1) | | animate-in | animateIn | none | entrance animation | | animate-out | animateOut | none | exit animation | | animate-dur | animateDur | 0.4 | anim duration seconds | | animate-easing | animateEasing | (none) | custom cubic bezier for animate-in/out: `"x1,y1,x2,y2"` | | name | name | (none) | optional human-readable display name | #### anchor values `top-left` · `top` · `top-right` · `left` · `center` · `right` · `bottom-left` · `bottom` · `bottom-right` #### animation values `none` · `fade` · `fade-up` · `fade-down` · `slide-left` · `slide-right` Slide animations travel 40px. Default easing: easeOutCubic (entrance) / easeInCubic (exit). `animate-easing="x1,y1,x2,y2"`: override with a custom cubic bezier — same format as CSS `cubic-bezier()`. Applied to both in and out when set. #### unit values | Input | Meaning | |----------|---------| | `"50%"` | 50% of canvas width (for x/width) or height (for y/height) | | `"120px"`| 120px | | `120` | 120px | ### `` (inside ``, ``, ``) Optional child elements that animate position and/or opacity over element-relative time. ```xml Hello ``` | XML attribute | Object field | Required | Description | |---------------|-------------|----------|-------------| | t | t | yes | Element-relative time in seconds (from element's own `begin`) | | x | x | no | Horizontal position at this keyframe (unit value) | | y | y | no | Vertical position at this keyframe (unit value) | | opacity | opacity | no | Opacity 0–1 at this keyframe | | easing | easing | no | Cubic bezier for segment FROM this keyframe to the next: `"x1,y1,x2,y2"` | - Only properties specified in at least one keyframe are animated; unspecified use the element's static value. - `t` is element-relative — moving an element's `begin` does not require rewriting keyframe timestamps. - Easing is defined on the departing keyframe (CSS convention). - Composes with `animate-in`/`animate-out` — presets still apply at element boundaries. - Not supported on `` or ``. ### `` ```xml Opening Night ``` | XML attribute | Default | |----------------|--------------| | font-size | 3% (of min(width, height)) | | font-family | sans-serif | | font-weight | normal | | color | #ffffff | | text-align | center | | line-height | 1.4 | | max-width | (none) | | letter-spacing | 0 | Text content is the element's text node. ### `` ```xml ``` | XML attribute | Default | |---------------|-------------| | width | 100% | | height | 100% | | fill | transparent | | stroke | (none) | | stroke-width | 1 | | rx | 0 | ### `` ```xml ``` | XML attribute | Default | |---------------|----------| | src | required | | width | 100% | | height | 100% | | fit | cover — values: cover · contain · fill · none | ### `` A video clip element — extends all common element attributes. Positioned and temporally placed within a layer. ```xml Welcome back everyone ``` | XML attribute | Object field | Default | Description | |---------------|-------------|---------|-------------| | src | src | required | Video file URL | | width | width | 100% | Display width | | height | height | 100% | Display height | | fit | fit | cover | Object-fit: cover · contain · fill · none | | volume | volume | 1 | Playback volume 0–1 | | trim-in | trimIn | 0 | Start offset within source file (seconds) | | trim-out | trimOut | (none) | End offset within source file; omit = play to end | | muted | muted | false | Mute audio track | Both `` and `` may contain optional `` children (see below). ### `` A temporal audio element — lives inside a ws-layer but has no visual output and no spatial attributes. ```xml Intro melody ``` | XML attribute | Object field | Default | Description | |---------------|-------------|----------|-------------| | id | id | auto | Unique identifier | | begin | begin | 0 | Start time seconds | | dur | dur | Infinity | Duration seconds | | src | src | required | Audio file URL | | volume | volume | 1 | Volume 0–1 | | loop | loop | false | Loop the audio | | trim-in | trimIn | 0 | Start offset within source file (seconds) | | trim-out | trimOut | (none) | End offset within source file | | name | name | (none) | Optional human-readable display name | ### `` (inside `` or ``) Optional timed speech/subtitle cue. Non-rendered metadata — consumed by analysis services, AI agents, and accessibility tools. Timestamps are relative to the **source media file** (aligned with trim-in/trim-out), not the scene timeline. ```xml Welcome to the show Thanks for having me ``` | XML attribute | Object field | Required | Description | |---------------|-------------|----------|-------------| | begin | begin | yes | Start time within source media (seconds) | | end | end | yes | End time within source media (seconds) | | speaker | speaker | no | ws-character id (links to scene.cast) | Text content is the cue's text node. In the parsed object, cues are accessed as `element.cues: WsCue[]` (optional — absent when no cues are present). ### `` and `` The optional `` section is a direct child of `` (before layers). It contains `` metadata entities — **not rendered**, consumed by authoring tools, AI agents, players, and compilers. ```xml ``` | XML attribute | Object field | Required | Description | |---------------|-------------|----------|-------------| | id | id | yes | Unique character id | | name | name | yes | Display name | | role | role | no | Role in scene (e.g. "Host", "Narrator") | | description | description | no | Free-form notes or personality | | avatar-url | avatarUrl | no | Reference image URL | Accessed as `scene.cast: WsCharacter[]` after parsing. ### Full example — product video scene ```xml New Collection Spring 2026 ``` --- ## API REFERENCE ### parse(xml) ```js parse(xml: string): WityScene ``` Parse a wity-scene XML string into a WityScene object. Throws if XML is malformed or root element is not ``. Browser: native DOMParser. Node.js: @xmldom/xmldom (optional peer dep). ID counter resets per call — auto-assigned ids are stable within a single parse. ### evaluate(scene, t) ```js evaluate(scene: WityScene, t: number): ComputedFrame ``` Evaluate a scene at time t (clamped to [0, scene.dur]). Pure function — no mutation, no side effects. Elements in ComputedFrame are sorted by effective z ascending (painters algorithm). Effective z = layer.z × 1000 + element.z. Animation offsets are baked into x and y — renderer receives final pixel position. `ws-audio` elements appear in `frame.elements` with correct `visible` flag but no pixel output — renderers should check `el.tag === 'ws-audio'` and handle accordingly. `ws-character` entities are NOT in `frame.elements` — access via `scene.cast`. ### serialize(scene) ```js serialize(scene: WityScene): string ``` Serialize a WityScene to canonical XML. Round-trips cleanly with parse(). Omits attributes equal to their defaults. `` section appears before layers when `scene.cast` is non-empty. ### validate(scene) ```js validate(scene: WityScene): { valid: boolean, errors: string[], warnings: string[] } ``` Structural validation without throwing. Checks root types, cast array, layer types, element types, unit value forms, opacity/volume range [0,1], enum membership, id uniqueness across the scene. ### resolveUnit(value, containerSize) ```js resolveUnit(value: string | number, containerSize: number): number ``` Resolve a unit value to pixels. ```js resolveUnit('50%', 1920) // 960 resolveUnit('120px', 0) // 120 resolveUnit(80, 0) // 80 ``` --- ## TYPES ### WityScene ```js { version: string, width: number, height: number, dur: number, layers: WsLayer[], cast: WsCharacter[] } ``` ### WsLayer ```js { id: string, z: number, opacity: number, elements: WsElement[] } ``` ### WsElement = WsText | WsRect | WsImage | WsVideo | WsAudio Visual elements share WsElementBase: `{ id, x, y, anchor, begin, dur, z, opacity, animateIn, animateOut, animateDur, animateEasing?, name? }` ```js WsKeyframe: { t, x?, y?, opacity?, easing? } WsText: WsElementBase & { tag: 'ws-text', content, fontSize, fontFamily, fontWeight, color, textAlign, lineHeight, maxWidth, letterSpacing, keyframes? } WsRect: WsElementBase & { tag: 'ws-rect', width, height, fill, stroke, strokeWidth, rx, keyframes? } WsImage: WsElementBase & { tag: 'ws-image', src, width, height, fit, keyframes? } WsVideo: WsElementBase & { tag: 'ws-video', src, width, height, fit, volume, trimIn, trimOut, muted, cues? } WsAudio: { tag: 'ws-audio', id, begin, dur, src, volume, loop, trimIn, trimOut, name?, cues? } ``` ### WsCue (inside WsVideo or WsAudio) Non-rendered metadata — timed speech/subtitle cues. Timestamps relative to source media file. ```js { begin: number, end: number, text: string, speaker?: string } ``` ### WsCharacter ```js { id: string, name: string, role?: string, description?: string, avatarUrl?: string } ``` ### ComputedFrame ```js { t: number, width: number, height: number, elements: ComputedElement[] } ``` ### ComputedElement ```js { id: string, tag: 'ws-text' | 'ws-rect' | 'ws-image' | 'ws-video' | 'ws-audio', x: number, // px, anchor-adjusted + animation offset y: number, opacity: number, // element × layer × animation z: number, // layer.z * 1000 + element.z visible: boolean, // false outside [begin, begin+dur] props: { // tag-specific, all units resolved to px // ws-text: fontSize, fontFamily, fontWeight, color, textAlign, lineHeight, maxWidth, letterSpacing // ws-rect: width, height, fill, stroke, strokeWidth, rx // ws-image: src, width, height, fit // ws-video: src, width, height, fit, volume, trimIn, trimOut, muted // ws-audio: src, volume, loop, trimIn, trimOut }, content: string | null, // text content for ws-text; null otherwise } ``` --- ## RENDERING ### HTML/CSS ```js function render(container, frame) { container.innerHTML = ''; for (const el of frame.elements) { if (!el.visible) continue; if (el.tag === 'ws-audio') continue; // handled by audio engine, not DOM const div = document.createElement('div'); div.style.position = 'absolute'; div.style.left = `${el.x}px`; div.style.top = `${el.y}px`; div.style.opacity = String(el.opacity); div.style.zIndex = String(el.z); if (el.tag === 'ws-text') { div.style.fontSize = `${el.props.fontSize}px`; div.style.color = el.props.color; div.style.fontFamily = el.props.fontFamily; div.style.fontWeight = el.props.fontWeight; div.style.textAlign = el.props.textAlign; div.textContent = el.content ?? ''; } if (el.tag === 'ws-rect') { div.style.width = `${el.props.width}px`; div.style.height = `${el.props.height}px`; div.style.background = el.props.fill; if (el.props.rx) div.style.borderRadius = `${el.props.rx}px`; } if (el.tag === 'ws-image' || el.tag === 'ws-video') { const media = document.createElement(el.tag === 'ws-video' ? 'video' : 'img'); media.src = el.props.src; media.style.width = `${el.props.width}px`; media.style.height = `${el.props.height}px`; media.style.objectFit = el.props.fit; div.appendChild(media); } container.appendChild(div); } } ``` ### Canvas 2D ```js function renderCanvas(ctx, frame) { ctx.clearRect(0, 0, frame.width, frame.height); for (const el of frame.elements) { if (!el.visible) continue; if (el.tag === 'ws-audio') continue; ctx.globalAlpha = el.opacity; if (el.tag === 'ws-rect') { ctx.fillStyle = el.props.fill; ctx.fillRect(el.x, el.y, el.props.width, el.props.height); } if (el.tag === 'ws-text') { ctx.fillStyle = el.props.color; ctx.font = `${el.props.fontWeight} ${el.props.fontSize}px ${el.props.fontFamily}`; ctx.textAlign = el.props.textAlign; ctx.fillText(el.content ?? '', el.x, el.y); } } ctx.globalAlpha = 1; } ``` ### requestAnimationFrame loop (manual) ```js let start = null; function tick(ts) { if (!start) start = ts; const t = (ts - start) / 1000; render(container, evaluate(scene, t)); if (t < scene.dur) requestAnimationFrame(tick); } requestAnimationFrame(tick); ``` ### Node.js / FFmpeg (frame-by-frame compilation) ```js import { parse, evaluate } from '@wity/scene-core'; import { createCanvas } from 'canvas'; // node-canvas const scene = parse(xmlString); const fps = 30; const frames = Math.ceil(scene.dur * fps); for (let i = 0; i < frames; i++) { const t = i / fps; const frame = evaluate(scene, t); const canvas = createCanvas(frame.width, frame.height); renderCanvas(canvas.getContext('2d'), frame); // → write canvas to PNG → pipe to FFmpeg } ``` --- ## RENDERING A SCENE `witySceneRender` is the single entry point for all scene rendering. Send your scene XML — routing to the correct internal pipeline is automatic. There are no other services to call. Never invoke downstream Lambdas directly. Supported `outputFormat` values: `"mp4"` (default), `"pdf"`, `"pptx"`. ### Input | Field | Type | Default | Description | |-------|------|---------|-------------| | `sceneXml` | string | required | The `` XML document | | `outputFormat` | string | `"mp4"` | `"mp4"` · `"pdf"` · `"pptx"` | | `options.fps` | number | `30` | Frame rate (mp4 only) | | `options.sceneWidth` | number | `1280` | Canvas width px (mp4 only) | | `options.sceneHeight` | number | `720` | Canvas height px (mp4 only) | | `options.fontManifest` | object | `{}` | Custom fonts: `{ "FontName": "https://url/to/font.ttf" }` | | `options.ts` | number | `0` | Snapshot timestamp seconds (pdf/pptx) | | `options.timestamps` | number[] | — | Multi-page/slide timestamps (pdf/pptx); overrides `ts` | | `options.variant` | string | `"standard"` | `"standard"` (96 DPI) or `"print"` (300 DPI) — pdf only | | `options.dpi` | number | 96/300 | Override render DPI — pdf only | ```json { "sceneXml": "...", "outputFormat": "mp4", "options": { "fps": 30, "sceneWidth": 1920, "sceneHeight": 1080 } } { "sceneXml": "...", "outputFormat": "pdf", "options": { "ts": 2.0, "variant": "print" } } { "sceneXml": "...", "outputFormat": "pptx", "options": { "timestamps": [0, 5.0, 10.0] } } ``` ### Output | Field | Type | Description | |-------|------|-------------| | `url` | string | Public S3 URL of the rendered output | | `fileSize` | number | Output file size in bytes | | `pipeline` | string[] | Which internal Lambdas ran (for observability) | | `pages` | number | Number of pages — pdf only | | `slides` | number | Number of slides — pptx only | ### Routing (automatic) **mp4:** gateway inspects element tags: - Graphics only → `witySceneToVideo` only - Graphics + media → `witySceneToVideo` → `witySceneCompose` - Media only → `witySceneCompose` only (graphicsMp4Url: null) **pdf:** → `witySceneToPdf` (single call). **pptx:** → `witySceneToPptx` (single call). Extensible: adding a new `outputFormat` requires one new Lambda + one entry in `PIPELINE_REGISTRY` — no changes to existing infrastructure. --- ## SERVER-SIDE PIPELINE INTERNALS All pipelines are managed by `witySceneRender` — clients never invoke downstream Lambdas directly. ### mp4 pipeline (two-step) ``` sceneXml ├──▶ Step 1: witySceneToVideo (compile graphics) │ ws-rect + ws-text + ws-image → PNG frames → silent MP4 │ ws-video and ws-audio are NOT rendered in this step │ → returns: graphicsMp4Url (S3 URL) │ └──▶ Step 2: witySceneCompose (full composite) ws-video clips + ws-audio tracks + graphicsMp4Url → FFmpeg filter_complex → composited MP4 → S3 upload → returns: { url, fileSize } ``` Both steps receive the **same sceneXml**. Step 2 re-parses independently to extract ws-video/ws-audio. `graphicsMp4Url` is optional — null if the scene has no graphic elements. --- ### @wity/scene-to-video Graphics-only compiler. Node.js / Lambda only. ```js import { compile } from '@wity/scene-to-video'; // fontManifest: { 'FontFamily': 'https://cdn.../font.ttf' } — pass {} if no custom fonts const { videoPath, cleanup } = await compile(sceneXml, fontManifest, { fps: 30 }); // videoPath = local /tmp path of the rendered silent MP4 // Upload to S3, then: await cleanup(); ``` **What is rendered:** ws-rect (filled rect + optional stroke + rx), ws-text (font/color/animate), ws-image (src + fit mode). **What is skipped:** ws-video, ws-audio (they fall through to a warning log — no crash, no output). Lambda `witySceneToVideo` input: `{ sceneXml, fontManifest?, fps? }` Lambda response: `{ url: string, fileSize: number }` Lambda config: 3008 MB / 300 s / 4096 MB ephemeral / Node.js 20 ### @wity/scene-compose Full compositor. Node.js / Lambda only. ```js import { compose } from '@wity/scene-compose'; const { url, fileSize } = await compose(sceneXml, graphicsMp4Url, { outputBucket: 'my-bucket', // or set OUTPUT_BUCKET env fps: 30, sceneWidth: 1920, sceneHeight: 1080, }); // url = public S3 URL of the final composited MP4 ``` **Video compositing:** Each ws-video clip is downloaded, scaled/fit to its x/y/width/height, trimmed via -ss trimIn, overlaid with `enable='between(t,begin,end)'`. Fit modes: cover / contain / fill. **Audio compositing:** All ws-audio tracks + non-muted embedded video audio → aresample=48000 → adelay:all=1 (begin offset, mono+stereo safe) → volume → amix normalize=0 dropout_transition=0. **Graphics overlay:** graphicsMp4Url overlaid at 0:0 on top of all video compositing. **S3 upload:** Streamed (no full-file read into memory) — safe for large outputs. Lambda `witySceneCompose` input: `{ sceneXml, graphicsMp4Url?, fps?, sceneWidth?, sceneHeight? }` Lambda response: `{ url: string, fileSize: number }` Lambda config: 2048 MB / 300 s / 4096 MB ephemeral / Node.js 20 ### @wity/scene-to-pdf Document renderer. Node.js / Lambda only. ```js import { render } from '@wity/scene-to-pdf'; const { pdfPath, cleanup } = await render(sceneXml, fontManifest, { ts: 2.0, // snapshot time variant: 'print', // 'standard' | 'print' dpi: 300, // override render DPI }); await cleanup(); // Multi-page: const { pdfPath, cleanup } = await render(sceneXml, {}, { timestamps: [0, 3.0, 6.0] }); ``` Lambda `witySceneToPdf` input: `{ sceneXml, fontManifest?, ts?, timestamps?, variant?, dpi? }` Lambda response: `{ url: string, fileSize: number, pages: number }` Lambda config: 2048 MB / 120 s / 1024 MB ephemeral / Node.js 20 / no FFmpeg layer ### @wity/scene-to-pptx Presentation renderer. Node.js / Lambda only. Output importable in PowerPoint, Keynote, Google Slides. ```js import { render } from '@wity/scene-to-pptx'; const { pptxPath, cleanup } = await render(sceneXml, fontManifest, { timestamps: [0, 5.0, 10.0], // one slide per timestamp }); await cleanup(); ``` Lambda `witySceneToPptx` input: `{ sceneXml, fontManifest?, ts?, timestamps? }` Lambda response: `{ url: string, fileSize: number, slides: number }` Lambda config: 1024 MB / 120 s / 1024 MB ephemeral / Node.js 20 / no FFmpeg layer ### wityAudioProfile — audio loudness analysis Standalone analysis Lambda. **Not routed through `witySceneRender`** — call directly. Downloads all `ws-video` and `ws-audio` media, decodes audio via FFmpeg, measures windowed RMS loudness, applies volume multipliers, returns per-element and power-mixed dBFS profiles. ```js // Lambda input: { "sceneXml": "...", "options": { "windowMs": 100, "sampleRate": 16000 } } // Lambda response — AudioProfileResult: { "sceneDuration": 15.0, "windowMs": 100, "sampleRate": 16000, "elements": [ { "id": "v1", "tag": "ws-video", "src": "https://...", "begin": 0, "dur": 15, "volume": 0.8, "hasAudio": true, "profile": { "startTime": 0, "windowCount": 150, "windowMs": 100, "rmsLinear": [0.12, 0.15, ...], // volume-adjusted "rmsDbfs": [-18.4, -16.5, ...], // 20*log10(rmsLinear) "peakDbfs": -6.2, "avgDbfs": -18.1 } } ], "mixed": { "startTime": 0, "windowCount": 150, "windowMs": 100, "rmsLinear": [...], "rmsDbfs": [...], "peakDbfs": -5.8, "avgDbfs": -16.3 } } ``` **Processing:** ws-video and ws-audio only. ws-rect/ws-text/ws-image ignored. `muted="true"` → skipped. Infinite `dur` → probed via ffprobe, clamped to `sceneDur - begin`. Decode errors are logged; element gets `profile: null`, processing continues. **Mixed profile:** Power-additive (`Σ rms²` → `√`) across all elements, mapped to scene timeline with `begin` offsets. Useful for detecting quiet moments, loud peaks, or regions suitable for voice-over insertion. Lambda config: 1024 MB / 120 s / 2048 MB ephemeral / Node.js 20 / FFmpeg layer required --- ## HEADLESS PLAYER — @wity/scene-player `HeadlessPlayer` coordinates `SceneStore` + `TimelineState` + `evaluate()` into a single playback object. Emits `'frame'` on every RAF tick. Any renderer subscribes to `'frame'` and draws. ```js import { HeadlessPlayer } from '@wity/scene-player'; const player = new HeadlessPlayer(); player.loadXml(xml); // Wire renderer — only needs 'frame' player.on('frame', ({ frame, t }) => renderCanvas(ctx, frame)); player.on('playback:ended', () => showReplayButton()); player.play(); player.seek(2.5); // emits 'frame' immediately — renderer stays in sync player.pause(); ``` ### Mutation at runtime ```js // URL arrives from backend — next frame picks it up, no reload player.updateElement(videoElementId, { src: 'https://...' }); // Reposition a layer in time player.updateLayer(layerId, { begin: 1.5, dur: 4.0 }); // Add an overlay layer const lid = player.addLayer({ label: 'Lower Third', z: 20 }); player.addElement(lid, { tag: 'ws-text', content: 'Now Live', x: '50%', y: '85%', anchor: 'center', animateIn: 'fade', animateDur: 0.3, }); // Hot-swap entire scene (e.g. live edit from authoring layer) player.replaceXml(updatedXml); // retains play/pause state // Persist mutations const xml = player.getXml(); ``` ### HeadlessPlayer API ```js // Loading player.loadXml(xml) // parse + load from XML string player.loadScene(scene) // load pre-parsed WityScene player.replaceXml(xml) // hot-swap; retains play state // Playback player.play() player.pause() player.stop() // pause + seek(0) player.seek(t) // seek; emits 'frame' immediately // State player.currentTime // seconds player.duration // seconds player.isPlaying // boolean player.isLoaded // boolean player.progress // 0–1 // Mutation player.addLayer(data) → layerId player.updateLayer(id, patch) player.removeLayer(id) player.addElement(layerId, data) → elementId player.updateElement(id, patch) player.removeElement(id) // Serialization / advanced player.getXml() // serialize current scene → XML player.getStore() // raw SceneStore for history/snap/clipboard player.destroy() ``` ### Events | Event | Payload | |---|---| | `'frame'` | `{ frame: ComputedFrame, t }` | | `'playback:started'` | `{ t }` | | `'playback:paused'` | `{ t }` | | `'playback:stopped'` | `{}` | | `'playback:ended'` | `{ t }` | | `'time:changed'` | `{ t }` | | `'scene:loaded'` | `{ scene }` | | `'scene:mutated'` | `{ scene }` |