@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.
package/src/media.js ADDED
@@ -0,0 +1,289 @@
1
+ import { Bounds2D, Transform2D, ZObject, setWorldCanvasTransform } from './core.js';
2
+
3
+ function finitePositive(value, name) {
4
+ const n = Number(value);
5
+ if (!(n > 0) || !Number.isFinite(n)) throw new RangeError(`${name} must be positive`);
6
+ return n;
7
+ }
8
+
9
+ function mediaBounds(width, height, m) {
10
+ const hx = width * .5, hy = height * .5;
11
+ const points = [[-hx,-hy],[hx,-hy],[hx,hy],[-hx,hy]].map(([x,y]) => m.apply(x,y));
12
+ return new Bounds2D(
13
+ Math.min(...points.map(p => p[0])), Math.min(...points.map(p => p[1])),
14
+ Math.max(...points.map(p => p[0])), Math.max(...points.map(p => p[1])),
15
+ );
16
+ }
17
+
18
+ function normalizeSize(sourceWidth, sourceHeight, width, height) {
19
+ const aspect = sourceWidth > 0 && sourceHeight > 0 ? sourceWidth / sourceHeight : null;
20
+ if (width == null && height == null) {
21
+ if (aspect) return [sourceWidth / 100, sourceHeight / 100];
22
+ return [4, 3];
23
+ }
24
+ if (width == null) return [finitePositive(height, 'height') * (aspect ?? 4/3), finitePositive(height, 'height')];
25
+ if (height == null) return [finitePositive(width, 'width'), finitePositive(width, 'width') / (aspect ?? 4/3)];
26
+ return [finitePositive(width, 'width'), finitePositive(height, 'height')];
27
+ }
28
+
29
+ export class MediaObject2D extends ZObject {
30
+ constructor(url, { width = null, height = null, sourceWidth = 0, sourceHeight = 0, duration = null, crossOrigin = null, ...rest } = {}) {
31
+ super(rest);
32
+ this.url = String(url);
33
+ this.sourceWidth = Number(sourceWidth) || 0;
34
+ this.sourceHeight = Number(sourceHeight) || 0;
35
+ this.duration = duration == null ? null : Number(duration);
36
+ this.crossOrigin = crossOrigin;
37
+ [this.width, this.height] = normalizeSize(this.sourceWidth, this.sourceHeight, width, height);
38
+ this._explicitWidth = width != null;
39
+ this._explicitHeight = height != null;
40
+ this._mediaKind = 'media';
41
+ this._webRuntimeOnly = 'media';
42
+ this._element = null;
43
+ this._loadError = null;
44
+ this.ready = Promise.resolve(this);
45
+ }
46
+
47
+ _updateNaturalSize(width, height) {
48
+ this.sourceWidth = Number(width) || this.sourceWidth;
49
+ this.sourceHeight = Number(height) || this.sourceHeight;
50
+ if (!this._explicitWidth || !this._explicitHeight) {
51
+ const next = normalizeSize(this.sourceWidth, this.sourceHeight, this._explicitWidth ? this.width : null, this._explicitHeight ? this.height : null);
52
+ this.width = next[0]; this.height = next[1];
53
+ }
54
+ return this;
55
+ }
56
+
57
+ _boundsWithTransform(m) { return mediaBounds(this.width, this.height, m); }
58
+ bounds() { return this._boundsWithTransform(this.transform); }
59
+ media(options = {}) { this._bound().media(this, options); return this; }
60
+
61
+ _drawElement(renderer, parent, element) {
62
+ if (!element) return;
63
+ const ctx = renderer.ctx, m = this.world(parent);
64
+ ctx.save();
65
+ ctx.globalAlpha *= Math.max(0, Math.min(1, this.opacity));
66
+ setWorldCanvasTransform(renderer, ctx, m);
67
+ // Zanim world coordinates point upward while bitmap scanlines point down.
68
+ // Canvas drawImage() does not mirror pixels just because the destination
69
+ // height is negative, so compensate explicitly in local image space.
70
+ ctx.scale(1, -1);
71
+ ctx.drawImage(element, -this.width / 2, -this.height / 2, this.width, this.height);
72
+ ctx.restore();
73
+ }
74
+ }
75
+
76
+ class ImageLikeMedia extends MediaObject2D {
77
+ constructor(url, options = {}) {
78
+ super(url, options);
79
+ const image = new globalThis.Image();
80
+ if (this.crossOrigin != null) image.crossOrigin = this.crossOrigin;
81
+ image.decoding = 'async';
82
+ this._element = image;
83
+ this.ready = new Promise((resolve, reject) => {
84
+ image.addEventListener('load', () => { this._updateNaturalSize(image.naturalWidth, image.naturalHeight); if(this._scene?.renderer?.ctx)this._scene.render(); resolve(this); }, { once:true });
85
+ image.addEventListener('error', () => { const err = new Error(`failed to load media ${this.url}`); this._loadError = err; reject(err); }, { once:true });
86
+ });
87
+ image.src = this.url;
88
+ }
89
+ draw(renderer, parent = Transform2D.identity()) { const time=renderer.time??0;if(this._scene&&this._scene.mediaTimeAt(this,time)==null)return;if(this._element?.complete&&this._element.naturalWidth)this._drawElement(renderer,parent,this._element); }
90
+ }
91
+
92
+ export class Image extends ImageLikeMedia {
93
+ constructor(url, options = {}) { super(url, options); this._mediaKind = 'image'; }
94
+ }
95
+
96
+ export class GIF extends MediaObject2D {
97
+ constructor(url, options = {}) {
98
+ super(url, options);
99
+ this._mediaKind = 'gif';
100
+ this._frame = null;
101
+ this._frameIndex = -1;
102
+ this._requestedIndex = -1;
103
+ this._starts = [0];
104
+ this._decoder = null;
105
+ this._fallback = null;
106
+ this.ready = this._load();
107
+ }
108
+
109
+ async _load() {
110
+ if (typeof globalThis.ImageDecoder !== 'function') {
111
+ const image = new globalThis.Image();
112
+ if (this.crossOrigin != null) image.crossOrigin = this.crossOrigin;
113
+ this._fallback = image;
114
+ await new Promise((resolve, reject) => {
115
+ image.addEventListener('load', resolve, { once:true });
116
+ image.addEventListener('error', () => reject(new Error(`failed to load GIF ${this.url}`)), { once:true });
117
+ image.src = this.url;
118
+ });
119
+ this._updateNaturalSize(image.naturalWidth, image.naturalHeight);
120
+ if (this._scene?.renderer?.ctx) this._scene.render();
121
+ return this;
122
+ }
123
+ const response = await fetch(this.url);
124
+ if (!response.ok) throw new Error(`failed to load GIF ${this.url} (${response.status})`);
125
+ const data = await response.arrayBuffer();
126
+ const decoder = new ImageDecoder({ data, type:'image/gif', preferAnimation:true });
127
+ await decoder.tracks.ready;
128
+ const track = decoder.tracks.selectedTrack;
129
+ if (!track) throw new Error(`GIF has no selected track: ${this.url}`);
130
+ this._decoder = decoder;
131
+ const count = Math.max(1, Number(track.frameCount) || 1);
132
+ const starts = [];
133
+ let total = 0;
134
+ for (let index = 0; index < count; index++) {
135
+ const result = await decoder.decode({ frameIndex:index });
136
+ const frame = result.image;
137
+ if (index === 0) this._updateNaturalSize(frame.displayWidth, frame.displayHeight);
138
+ starts.push(total);
139
+ total += Math.max(1, Number(frame.duration) || 100_000) / 1_000_000;
140
+ frame.close();
141
+ }
142
+ this._starts = starts;
143
+ this.duration = total;
144
+ await this._requestFrame(0);
145
+ if (this._scene?.renderer?.ctx) this._scene.render();
146
+ return this;
147
+ }
148
+
149
+ _indexAt(sourceTime) {
150
+ if (this._starts.length <= 1) return 0;
151
+ const t = Math.max(0, Math.min(Number(sourceTime), Math.max(0, (this.duration ?? 0) - 1e-9)));
152
+ let lo=0, hi=this._starts.length;
153
+ while (lo < hi) { const mid=(lo+hi)>>1; if (this._starts[mid] <= t + 1e-12) lo=mid+1; else hi=mid; }
154
+ return Math.max(0, Math.min(this._starts.length-1, lo-1));
155
+ }
156
+
157
+ async _requestFrame(index) {
158
+ if (!this._decoder || index === this._frameIndex || index === this._requestedIndex) return;
159
+ this._requestedIndex = index;
160
+ try {
161
+ const result = await this._decoder.decode({ frameIndex:index });
162
+ if (this._requestedIndex !== index) { result.image.close(); return; }
163
+ this._frame?.close();
164
+ this._frame = result.image;
165
+ this._frameIndex = index;
166
+ if (this._scene?.renderer?.ctx) this._scene.render();
167
+ } finally {
168
+ if (this._requestedIndex === index) this._requestedIndex = -1;
169
+ }
170
+ }
171
+
172
+ draw(renderer, parent = Transform2D.identity()) {
173
+ const time = renderer.time ?? 0, sourceTime = this._scene ? this._scene.mediaTimeAt(this, time) : 0;
174
+ if (sourceTime == null) return;
175
+ if (this._fallback) { this._drawElement(renderer, parent, this._fallback); return; }
176
+ const index = this._indexAt(sourceTime);
177
+ if (index !== this._frameIndex) this._requestFrame(index).catch(() => {});
178
+ if (this._frame && this._frameIndex === index) this._drawElement(renderer, parent, this._frame);
179
+ }
180
+
181
+ destroy() {
182
+ this._frame?.close();
183
+ this._frame = null;
184
+ this._decoder?.close?.();
185
+ this._decoder = null;
186
+ if (this._fallback) this._fallback.src = '';
187
+ }
188
+ }
189
+
190
+ export class Video extends MediaObject2D {
191
+ constructor(url, { muted = true, playsInline = true, preload = 'auto', ...options } = {}) {
192
+ super(url, options);
193
+ const video = document.createElement('video');
194
+ if (this.crossOrigin != null) video.crossOrigin = this.crossOrigin;
195
+ video.preload = preload;
196
+ video.muted = !!muted;
197
+ video.playsInline = !!playsInline;
198
+ this._element = video;
199
+ this._mediaKind = 'video';
200
+ this.ready = new Promise((resolve, reject) => {
201
+ video.addEventListener('loadedmetadata', () => {
202
+ this.duration = Number.isFinite(video.duration) ? video.duration : this.duration;
203
+ this._updateNaturalSize(video.videoWidth, video.videoHeight);
204
+ resolve(this);
205
+ }, { once:true });
206
+ video.addEventListener('loadeddata', () => { if(this._scene?.renderer?.ctx)this._scene.render(); });
207
+ video.addEventListener('seeked', () => { if(this._scene?.renderer?.ctx&&!this._scene.playing)this._scene.render(); });
208
+ video.addEventListener('error', () => { const err = new Error(`failed to load video ${this.url}`); this._loadError = err; reject(err); }, { once:true });
209
+ });
210
+ video.src = this.url;
211
+ video.load();
212
+ }
213
+
214
+ _sync(time) {
215
+ const scene = this._scene;
216
+ if (!scene || !this._element || this._element.readyState < 1) return false;
217
+ const playback = scene.mediaPlaybackAt(this, time);
218
+ const desired = scene.mediaTimeAt(this, time);
219
+ if (desired == null) { this._element.pause(); return false; }
220
+ if (scene.playing && playback) {
221
+ this._element.playbackRate = Math.max(.0625, Math.min(16, playback.speed));
222
+ this._element.loop = !!playback.loop;
223
+ if (Math.abs(this._element.currentTime - desired) > .15) this._element.currentTime = desired;
224
+ this._element.play().catch(() => {});
225
+ } else {
226
+ this._element.pause();
227
+ if (Math.abs(this._element.currentTime - desired) > 1 / 120) this._element.currentTime = desired;
228
+ }
229
+ return this._element.readyState >= 2;
230
+ }
231
+
232
+ draw(renderer, parent = Transform2D.identity()) {
233
+ if (this._sync(renderer.time ?? 0)) this._drawElement(renderer, parent, this._element);
234
+ }
235
+
236
+ destroy() {
237
+ this._element?.pause();
238
+ if (this._element) this._element.removeAttribute('src');
239
+ this._element?.load();
240
+ }
241
+ }
242
+
243
+ export class Audio extends ZObject {
244
+ constructor(url, { gain = 1, duration = null, crossOrigin = null, preload = 'auto', ...rest } = {}) {
245
+ super(rest);
246
+ this.url = String(url);
247
+ this.duration = duration == null ? null : Number(duration);
248
+ this.gain = Math.max(0, Number(gain));
249
+ this.crossOrigin = crossOrigin;
250
+ this._mediaKind = 'audio';
251
+ this._webRuntimeOnly = 'audio';
252
+ const audio = document.createElement('audio');
253
+ if (crossOrigin != null) audio.crossOrigin = crossOrigin;
254
+ audio.preload = preload;
255
+ audio.volume = Math.max(0, Math.min(1, this.gain));
256
+ this._element = audio;
257
+ this.ready = new Promise((resolve, reject) => {
258
+ audio.addEventListener('loadedmetadata', () => { this.duration = Number.isFinite(audio.duration) ? audio.duration : this.duration; resolve(this); }, { once:true });
259
+ audio.addEventListener('error', () => reject(new Error(`failed to load audio ${this.url}`)), { once:true });
260
+ });
261
+ audio.src = this.url;
262
+ audio.load();
263
+ }
264
+
265
+ media(options = {}) { this._bound().media(this, options); return this; }
266
+
267
+ draw(renderer) {
268
+ const scene = this._scene;
269
+ if (!scene || this._element.readyState < 1) return;
270
+ const time = renderer.time ?? 0, playback = scene.mediaPlaybackAt(this, time), desired = scene.mediaTimeAt(this, time);
271
+ if (desired == null) { this._element.pause(); return; }
272
+ this._element.volume = Math.max(0, Math.min(1, this.gain));
273
+ if (scene.playing && playback) {
274
+ this._element.playbackRate = Math.max(.0625, Math.min(16, playback.speed));
275
+ this._element.loop = !!playback.loop;
276
+ if (Math.abs(this._element.currentTime - desired) > .15) this._element.currentTime = desired;
277
+ this._element.play().catch(() => {});
278
+ } else {
279
+ this._element.pause();
280
+ if (Math.abs(this._element.currentTime - desired) > 1 / 120) this._element.currentTime = desired;
281
+ }
282
+ }
283
+
284
+ destroy() {
285
+ this._element?.pause();
286
+ if (this._element) this._element.removeAttribute('src');
287
+ this._element?.load();
288
+ }
289
+ }
package/src/player.js ADDED
@@ -0,0 +1,99 @@
1
+ import { CachedBatch2D, assignState, cloneState } from './core.js';
2
+
3
+ export function seekScene(scene, time) {
4
+ const t0 = performance.now();
5
+ scene.time = Math.max(0, Math.min(scene.duration || time, time));
6
+ scene.renderer.time = scene.time;
7
+ scene.render();
8
+ scene.stats.seekMs = performance.now() - t0;
9
+ return scene;
10
+ }
11
+
12
+ export function renderScene(scene) {
13
+ const t0 = performance.now();
14
+ scene.renderer.resize();
15
+ scene.renderer.clear();
16
+ scene.renderer.time = scene.time;
17
+ if (scene._renderListDirty) {
18
+ scene._renderList = [...scene.objects].sort((a, b) => a.zIndex - b.zIndex);
19
+ scene._renderListDirty = false;
20
+ }
21
+
22
+ const savedObjects = [];
23
+ const savedValues = scene.values.map(value => [value, value.value]);
24
+ try {
25
+ for (const object of scene._trackedObjects.values()) {
26
+ let batchRestore = null;
27
+ savedObjects.push([object, cloneState(object), batchRestore]);
28
+ assignState(object, scene.stateAt(object, scene.time));
29
+ const batchClips = object instanceof CachedBatch2D
30
+ ? scene._batchClipsByObject.get(object.id)
31
+ : null;
32
+ if (
33
+ batchClips?.length
34
+ && scene.time >= object.birth
35
+ && scene.time < object.death
36
+ ) {
37
+ // Batch clips temporarily replace retained geometry for this sample.
38
+ // Preserve the authored references/cache directly: going through the
39
+ // public items setter would invalidate an otherwise reusable Path2D
40
+ // cache on every render, defeating CachedBatch2D entirely.
41
+ batchRestore = [object._items, object._cache];
42
+ savedObjects[savedObjects.length - 1][2] = batchRestore;
43
+ object.items = scene.batchAt(object, scene.time);
44
+ }
45
+ }
46
+ for (const value of scene.values) value.value = scene.valueAt(value, scene.time);
47
+ for (const object of scene._renderList) {
48
+ if (object.visible && scene.time >= object.birth && scene.time < object.death) {
49
+ object.draw(scene.renderer, scene.camera.transform);
50
+ }
51
+ }
52
+ } finally {
53
+ for (let i = savedObjects.length - 1; i >= 0; i--) {
54
+ const [object, state, batchRestore] = savedObjects[i];
55
+ assignState(object, state);
56
+ if (batchRestore) {
57
+ object._items = batchRestore[0];
58
+ object._cache = batchRestore[1];
59
+ }
60
+ }
61
+ for (const [value, raw] of savedValues) value.value = raw;
62
+ }
63
+
64
+ scene.stats.renderMs = performance.now() - t0;
65
+ scene.stats.frames++;
66
+ }
67
+
68
+ export function playScene(scene, { loop = false, from = 0 } = {}) {
69
+ pauseScene(scene);
70
+ scene.playing = true;
71
+ scene._start = performance.now() - from * 1000;
72
+ const tick = now => {
73
+ if (!scene.playing) return;
74
+ let time = (now - scene._start) / 1000;
75
+ if (scene.duration && time > scene.duration) {
76
+ if (loop) { scene._start = now; time = 0; }
77
+ else { scene.seek(scene.duration); pauseScene(scene); return; }
78
+ }
79
+ scene.seek(time);
80
+ scene._raf = requestAnimationFrame(tick);
81
+ };
82
+ scene._raf = requestAnimationFrame(tick);
83
+ return scene;
84
+ }
85
+
86
+ export function pauseScene(scene) {
87
+ scene.playing = false;
88
+ if (scene._raf) cancelAnimationFrame(scene._raf);
89
+ scene._raf = null;
90
+ return scene;
91
+ }
92
+
93
+ export function destroyScene(scene) {
94
+ pauseScene(scene);
95
+ for (const object of scene._trackedObjects.values()) object.destroy?.();
96
+ scene._resizeObserver?.disconnect();
97
+ scene._resizeObserver = null;
98
+ return scene;
99
+ }