@plotdb/lotion 0.2.0 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## v0.3.0
2
+
3
+ - features:
4
+ - `player.encode(opt)` / `lotion.encode(opt)`: render to mp4 in the browser ( snapdom capture, WebCodecs encode via
5
+ mediabunny ), both libraries loaded on demand by dynamic `import()`; urls configurable in `lotion.libs`
6
+ - demo: export button
7
+ - `dist/vector.js` ( experimental ): `lotion.vector(el)` / `player.vector(t)` convert the current frame to a vector svg
8
+ with satori. layout is taken from the browser and each element becomes an absolutely positioned node; web fonts
9
+ are collected from `@font-face` by the characters in use, woff2 decompressed, subset with HarfBuzz and variable
10
+ fonts pinned to the used weight. root-level `mix-blend-mode` is composed as svg layers; inline svg is embedded
11
+ with its computed styles, referenced external defs and fonts.
12
+ - `lotion.lib(name)`: load an on-demand dependency listed in `lotion.libs`
13
+ - demo: `/vector/` compares the player with its converted svg side by side; the block demo loads `vector.min.js`
14
+
15
+
16
+ ## v0.2.1
17
+
18
+ - features:
19
+ - cli: `video --workers N` renders with N browsers in parallel, splitting frames into contiguous ranges and joining
20
+ the encoded parts without re-encoding. each part first renders the 3 preceding frames and discards them, since a
21
+ freshly opened page that jumps straight to a frame rasterizes its first frames slightly differently; with this the
22
+ output matches a single worker frame for frame. about 2.7x faster with 4 workers on an 8-core ( 4 performance ) mac.
23
+
24
+
1
25
  ## v0.2.0
2
26
 
3
27
  - breaking:
package/README.md CHANGED
@@ -55,7 +55,8 @@ player options:
55
55
  - `loading`: what to show before `start()`. default `true` ( a spinner ); a string is used as custom html;
56
56
  `false` shows nothing.
57
57
 
58
- player methods: `start()`, `seek(t)`, `play(go = true)`, `pause()`, `toggle()`, `fullscreen()`.
58
+ player methods: `start()`, `seek(t)`, `play(go = true)`, `pause()`, `toggle()`, `fullscreen()`, `encode(opt)`
59
+ ( see Online Export ).
59
60
  `player.ready` is a promise resolved by `start()`.
60
61
 
61
62
  Until `start()` is called, the stage is hidden behind the loading screen, the control bar is disabled, and
@@ -83,6 +84,73 @@ helpers:
83
84
  - `svg(tag, attrs, parent)`: create an svg element.
84
85
 
85
86
 
87
+ ## Online Export
88
+
89
+ `player.encode(opt)` renders the animation to mp4 inside the browser, with no server: it seeks frame by frame, captures
90
+ the stage with [snapdom](https://github.com/zumerlab/snapdom) and encodes with WebCodecs through
91
+ [mediabunny](https://github.com/Vanilagy/mediabunny). Returns a promise of a `Blob` ( `video/mp4` ).
92
+
93
+ p.encode({fps: 30, progress: (v) -> console.log v})
94
+ .then (blob) -> a.href = URL.createObjectURL(blob)
95
+
96
+ options:
97
+
98
+ - `width`, `height`: output size, default the player's design size; rounded to even numbers.
99
+ - `fps`: default `30`. `from`, `to`: render only a range, in seconds.
100
+ - `sub`, `shutter`: motion blur, as in the cli.
101
+ - `bitrate`: in bps. default mediabunny's `QUALITY_HIGH` for the size.
102
+ - `background`: default the stage's background color; black when transparent, since mp4 has no alpha.
103
+ - `progress(v)`: `0..1`. `signal`: an `AbortSignal`; aborting rejects with an `AbortError`.
104
+
105
+ While encoding, the player is paused and locked, the stage is shown unscaled ( snapdom pads a scaled element by a few
106
+ pixels, which shrinks the frame slightly ) behind a cover showing the progress, and afterwards it returns to the time
107
+ it was at.
108
+
109
+ snapdom and mediabunny are loaded by dynamic `import()` only when encoding, from jsdelivr by default. Point
110
+ `lotion.libs.snapdom` / `lotion.libs.mediabunny` to self-hosted ES module builds
111
+ ( `@zumer/snapdom/dist/snapdom.mjs`, `mediabunny/dist/bundles/mediabunny.min.mjs` ), or pass loaded modules as
112
+ `opt.snapdom` / `opt.mediabunny`. `lotion.encode({el, seek, duration, ...})` does the same for any element, such as a
113
+ block; `el` should not be scaled by a transform.
114
+
115
+ Frames match the cli's closely ( about 49 dB PSNR on the demo, the remaining difference being compression ), since
116
+ both are rasterized by the browser; what snapdom cannot capture ( cross-origin images or fonts without CORS, iframes )
117
+ differs. Requires `VideoEncoder` ( Chrome / Edge, Safari 16.4+, Firefox 130+ ). Speed is bound by snapdom cloning the
118
+ DOM, not by resolution: the 17 seconds demo at 1080p30 takes about 46 seconds in Chrome on a development mac. For long or
119
+ final renders, the cli remains faster ( `--workers` ) and exact.
120
+
121
+
122
+ ## Vector ( experimental )
123
+
124
+ `dist/vector.js` converts the current frame into a vector svg with [satori](https://github.com/vercel/satori):
125
+
126
+ <script src="index.min.js"></script>
127
+ <script src="vector.min.js"></script>
128
+
129
+ lotion.vector(el).then ({svg, warnings}) -> ...
130
+ player.vector(t).then ({svg, warnings}) -> ... # the frame at t, without moving the player
131
+
132
+ `vector.js` extends the `lotion` object loaded before it, so load it after `index.js`. In a block, declare it as a
133
+ dependency after lotion ( `{name: 'lotion', path: 'vector.min.js'}` ); rescope gives it the same `lotion`.
134
+
135
+ - layout is read from the browser ( `offsetLeft` / `offsetWidth` ... ) and every element becomes an absolutely
136
+ positioned satori node with its computed style; text becomes paths.
137
+ - fonts are collected from the page's `@font-face` rules by the families, weights and characters in use, following
138
+ `font-family` fallbacks and `unicode-range`. woff2 is decompressed ( woff2-encoder ), then each font is subset with
139
+ HarfBuzz, which also pins variable fonts ( as served by Google Fonts ) to the weight in use; satori would otherwise
140
+ draw only their default instance. Pass `opt.fonts` to provide fonts yourself.
141
+ - `mix-blend-mode` on children of the root is kept by converting the children in layers and stacking them with
142
+ svg `mix-blend-mode` ( honored by browsers, not by every svg viewer ).
143
+ - inline `<svg>` is embedded as an svg image with its computed styles, the external definitions it references and
144
+ the fonts its text needs. `<img>` and `<canvas>` are embedded as raster images.
145
+ - satori, woff2-encoder and the HarfBuzz subsetter ( wasm ) are loaded on demand; urls are in `lotion.libs`.
146
+ - what may differ is listed in `warnings`: blend on nested elements, text mixed with elements, characters without a
147
+ web font ( system fonts are not available ), mask or clip-path under a transform other than a translation
148
+ ( satori does not move masks with transforms ).
149
+
150
+ On the demo and the sumi-ink trial of lotitor, frames rasterized from the svg match screenshots at 29 - 39 dB PSNR;
151
+ the remaining difference is mostly text antialiasing and baselines in fixed line-heights. A frame takes 50 - 300 ms.
152
+
153
+
86
154
  ## Render protocol
87
155
 
88
156
  With `?render` in the url, a page should cover the viewport with its stage and expose:
@@ -137,6 +205,8 @@ options:
137
205
  - `--width 1920 --height 1080`: viewport size.
138
206
  - `--fps 60`, `--sub 1`, `--shutter 0.5`: video settings. with `--sub` greater than 1, each frame averages
139
207
  `sub` sub-samples spread over `shutter` of the frame interval ( motion blur, via ffmpeg `tmix` ).
208
+ - `--workers 1`: render a video with N browsers in parallel. frames are split into contiguous ranges, each encoded on
209
+ its own, then joined without re-encoding. since every frame depends on `t` only, the result matches a single worker.
140
210
  - `--from`, `--to`: render only a range, in seconds.
141
211
  - `--crf 16`: x264 quality.
142
212
  - `--cols 3`, `--tile 640`: contact sheet layout.
@@ -173,6 +243,14 @@ the packed block demo is generated from the running dev server:
173
243
  node dist/cli.js bundle http://localhost:<port> lotion-demo web/static/block/lotion-demo.bundle.html
174
244
 
175
245
 
246
+ ## Credits
247
+
248
+ Releases up to v0.1.0 included `prompt/ui-loop.md`, derived from a prompt template posted by zero
249
+ ( [@twoclipping](https://x.com/twoclipping) ) on X on 2026-09-25: https://x.com/twoclipping/status/2103273003555402193 .
250
+ The post says "im open sourcing the prompt template for these motion designs" but attaches no explicit license, so that
251
+ file was never covered by lotion's MIT license; rights remain with the author. It is no longer part of lotion.
252
+
253
+
176
254
  ## License
177
255
 
178
256
  MIT
package/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- var fs, path, http, child_process, usage, parse, types, serve, playwright, open, shot, frames, sheet, video, cues, bundle, moved, argv, ref$, pos, opt, cmd, src, out, rest, times, p, slice$ = [].slice;
2
+ var fs, path, http, child_process, usage, parse, types, serve, playwright, open, shot, frames, sheet, WARM, encode, video, cues, bundle, moved, argv, ref$, pos, opt, cmd, src, out, rest, times, p, slice$ = [].slice;
3
3
  fs = require('fs');
4
4
  path = require('path');
5
5
  http = require('http');
@@ -198,60 +198,151 @@ sheet = function(src, out, times, opt){
198
198
  });
199
199
  });
200
200
  };
201
- video = function(src, out, opt){
202
- var fps, sub, shutter;
203
- fps = +(opt.fps || 60);
204
- sub = +(opt.sub || 1);
205
- shutter = +(opt.shutter || 0.5);
206
- return open(src, opt).then(function(arg$){
207
- var page, duration, close, from, to, n, d, vf, ff, t0, write, step;
208
- page = arg$.page, duration = arg$.duration, close = arg$.close;
209
- from = +(opt.from || 0);
210
- to = +(opt.to || duration);
211
- n = Math.round((to - from) * fps);
212
- d = shutter / fps / sub;
213
- vf = sub > 1
214
- ? ['-vf', "tmix=frames=" + sub + ",select='eq(mod(n\\," + sub + ")\\," + (sub - 1) + ")',setpts=N/" + fps + "/TB"]
215
- : [];
216
- ff = child_process.spawn('ffmpeg', ['-y', '-v', 'error', '-f', 'image2pipe', '-framerate', fps * sub + "", '-i', '-'].concat(vf, ['-r', fps + "", '-c:v', 'libx264', '-preset', 'slow', '-crf', (opt.crf || 16) + "", '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out]), {
217
- stdio: ['pipe', 'inherit', 'inherit']
201
+ WARM = 3;
202
+ encode = function(page, i0, i1, out, o, tick){
203
+ var fps, sub, shutter, from, d, vf, ff, write, step, warm;
204
+ fps = o.fps, sub = o.sub, shutter = o.shutter, from = o.from;
205
+ d = shutter / fps / sub;
206
+ vf = sub > 1
207
+ ? ['-vf', "tmix=frames=" + sub + ",select='eq(mod(n\\," + sub + ")\\," + (sub - 1) + ")',setpts=N/" + fps + "/TB"]
208
+ : [];
209
+ ff = child_process.spawn('ffmpeg', ['-y', '-v', 'error', '-f', 'image2pipe', '-framerate', fps * sub + "", '-i', '-'].concat(vf, ['-r', fps + "", '-c:v', 'libx264', '-preset', 'slow', '-crf', o.crf + "", '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out]), {
210
+ stdio: ['pipe', 'inherit', 'inherit']
211
+ });
212
+ write = function(buf){
213
+ return new Promise(function(res){
214
+ if (ff.stdin.write(buf)) {
215
+ return res();
216
+ } else {
217
+ return ff.stdin.once('drain', res);
218
+ }
218
219
  });
219
- t0 = Date.now();
220
- write = function(buf){
221
- return new Promise(function(res){
222
- if (ff.stdin.write(buf)) {
223
- return res();
220
+ };
221
+ step = function(i, j){
222
+ var t;
223
+ if (i >= i1) {
224
+ return Promise.resolve();
225
+ }
226
+ if (j >= sub) {
227
+ tick();
228
+ return step(i + 1, 0);
229
+ }
230
+ t = Math.max(0, from + i / fps + (sub > 1 ? (j - (sub - 1) / 2) * d : 0));
231
+ return shot(page, t).then(write).then(function(){
232
+ return step(i, j + 1);
233
+ });
234
+ };
235
+ warm = (function(){
236
+ var i$, to$, results$ = [];
237
+ for (i$ = Math.max(0, i0 - WARM), to$ = i0; i$ < to$; ++i$) {
238
+ results$.push(i$);
239
+ }
240
+ return results$;
241
+ }()).reduce(function(p, i){
242
+ return p.then(function(){
243
+ return shot(page, Math.max(0, from + i / fps));
244
+ });
245
+ }, Promise.resolve());
246
+ return warm.then(function(){
247
+ return step(i0, 0);
248
+ }).then(function(){
249
+ return new Promise(function(res, rej){
250
+ ff.on('close', function(c){
251
+ if (c) {
252
+ return rej(new Error("ffmpeg exited with " + c));
224
253
  } else {
225
- return ff.stdin.once('drain', res);
254
+ return res();
226
255
  }
227
256
  });
257
+ return ff.stdin.end();
258
+ });
259
+ });
260
+ };
261
+ video = function(src, out, opt){
262
+ var o, workers, t0;
263
+ o = {
264
+ fps: +(opt.fps || 60),
265
+ sub: +(opt.sub || 1),
266
+ shutter: +(opt.shutter || 0.5),
267
+ crf: opt.crf || 16
268
+ };
269
+ workers = Math.max(1, Math.floor(+(opt.workers || 1)));
270
+ t0 = Date.now();
271
+ return open(src, opt).then(function(first){
272
+ var n, done, tick, finish, k, tmp, parts;
273
+ o.from = +(opt.from || 0);
274
+ n = Math.round((+(opt.to || first.duration) - o.from) * o.fps);
275
+ done = 0;
276
+ tick = function(){
277
+ if (++done % 300 === 0) {
278
+ return console.log("frame " + done + "/" + n + " " + ((Date.now() - t0) / 1000).toFixed(0) + "s");
279
+ }
280
+ };
281
+ console.log(("frame 0/" + n + " 0s") + (workers > 1 ? " ( " + workers + " workers )" : ''));
282
+ finish = function(){
283
+ return console.log(out + " ( " + n + " frames, " + ((Date.now() - t0) / 1000).toFixed(0) + "s )");
228
284
  };
229
- step = function(i, j){
230
- var t;
231
- if (i >= n) {
232
- return Promise.resolve();
285
+ if (workers === 1) {
286
+ return encode(first.page, 0, n, out, o, tick).then(function(){
287
+ return first.close();
288
+ }).then(finish);
289
+ }
290
+ k = Math.min(workers, n);
291
+ tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'lotion-'));
292
+ parts = (function(){
293
+ var i$, to$, results$ = [];
294
+ for (i$ = 0, to$ = k; i$ < to$; ++i$) {
295
+ results$.push(i$);
233
296
  }
234
- if (j >= sub) {
235
- if (i % 300 === 0) {
236
- console.log("frame " + i + "/" + n + " " + ((Date.now() - t0) / 1000).toFixed(0) + "s");
237
- }
238
- return step(i + 1, 0);
297
+ return results$;
298
+ }()).map(function(w){
299
+ return {
300
+ i0: Math.floor(n * w / k),
301
+ i1: Math.floor(n * (w + 1) / k),
302
+ out: path.join(tmp, "part-" + w + ".mp4")
303
+ };
304
+ });
305
+ return Promise.all([Promise.resolve(first)].concat((function(){
306
+ var i$, to$, results$ = [];
307
+ for (i$ = 1, to$ = k; i$ < to$; ++i$) {
308
+ results$.push(i$);
239
309
  }
240
- t = Math.max(0, from + i / fps + (sub > 1 ? (j - (sub - 1) / 2) * d : 0));
241
- return shot(page, t).then(write).then(function(){
242
- return step(i, j + 1);
243
- });
244
- };
245
- return step(0, 0).then(function(){
246
- return new Promise(function(res){
247
- ff.on('close', res);
248
- return ff.stdin.end();
310
+ return results$;
311
+ }()).map(function(){
312
+ return open(src, opt);
313
+ }))).then(function(pages){
314
+ return Promise.all(parts.map(function(p, w){
315
+ return encode(pages[w].page, p.i0, p.i1, p.out, o, tick);
316
+ }))['finally'](function(){
317
+ return Promise.all(pages.map(function(p){
318
+ return p.close();
319
+ }));
249
320
  });
250
321
  }).then(function(){
251
- return close();
252
- }).then(function(){
253
- return console.log(out + " ( " + n + " frames, " + ((Date.now() - t0) / 1000).toFixed(0) + "s )");
254
- });
322
+ var list;
323
+ list = path.join(tmp, 'list.txt');
324
+ fs.writeFileSync(list, parts.map(function(p){
325
+ return "file '" + p.out + "'";
326
+ }).join('\n'));
327
+ return new Promise(function(res, rej){
328
+ var ff;
329
+ ff = child_process.spawn('ffmpeg', ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', '-movflags', '+faststart', out], {
330
+ stdio: 'inherit'
331
+ });
332
+ return ff.on('close', function(c){
333
+ if (c) {
334
+ return rej(new Error("ffmpeg concat exited with " + c));
335
+ } else {
336
+ return res();
337
+ }
338
+ });
339
+ });
340
+ })['finally'](function(){
341
+ return fs.rmSync(tmp, {
342
+ recursive: true,
343
+ force: true
344
+ });
345
+ }).then(finish);
255
346
  });
256
347
  };
257
348
  cues = function(src, out, opt){
package/index.css CHANGED
@@ -124,15 +124,26 @@
124
124
  opacity: 0.5;
125
125
  pointer-events: none;
126
126
  }
127
- .lotion .lotion-loading-screen {
127
+ .lotion.lotion-encoding .lotion-bar {
128
+ opacity: 0.5;
129
+ pointer-events: none;
130
+ }
131
+ .lotion .lotion-loading-screen,
132
+ .lotion .lotion-encoding-screen {
128
133
  position: absolute;
129
134
  inset: 0;
130
135
  display: flex;
136
+ flex-direction: column;
137
+ gap: 12px;
131
138
  align-items: center;
132
139
  justify-content: center;
133
140
  background: var(--lotion-bg);
134
141
  color: var(--lotion-mute);
135
142
  }
143
+ .lotion .lotion-progress {
144
+ font-size: 14px;
145
+ font-variant-numeric: tabular-nums;
146
+ }
136
147
  .lotion .lotion-spinner {
137
148
  width: 36px;
138
149
  height: 36px;
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  (function(){
2
- var PI, sin, cos, exp, sqrt, round, clamp, lerp, ss, spring, presets, track, vis, bump, typing, hex, mixc, rgb, mk, put, txt, NS, svg, player, ref$, lotion;
2
+ var PI, sin, cos, exp, sqrt, round, clamp, lerp, ss, spring, presets, track, vis, bump, typing, hex, mixc, rgb, mk, put, txt, NS, svg, player, ref$, libs, libCache, lib, encode, lotion;
3
3
  PI = Math.PI, sin = Math.sin, cos = Math.cos, exp = Math.exp, sqrt = Math.sqrt, round = Math.round;
4
4
  clamp = function(v, a, b){
5
5
  a == null && (a = 0);
@@ -338,6 +338,9 @@
338
338
  });
339
339
  }, ref$.fit = function(){
340
340
  var w, h, s;
341
+ if (this.encoding) {
342
+ return;
343
+ }
341
344
  w = this.viewport.clientWidth;
342
345
  h = this.viewport.clientHeight;
343
346
  if (!w || !h) {
@@ -349,7 +352,7 @@
349
352
  return Math.floor(t / 60) + ":" + ("0" + Math.floor(t % 60)).slice(-2);
350
353
  }, ref$.seek = function(t){
351
354
  var p, ch;
352
- if (!this.started) {
355
+ if (!this.started || this.encoding) {
353
356
  return;
354
357
  }
355
358
  this.t = t = clamp(t, 0, this.duration);
@@ -382,7 +385,7 @@
382
385
  }, ref$.play = function(go){
383
386
  var this$ = this;
384
387
  go == null && (go = true);
385
- if (!this.started) {
388
+ if (!this.started || this.encoding) {
386
389
  return;
387
390
  }
388
391
  if (go && this.t >= this.duration) {
@@ -409,7 +412,177 @@
409
412
  (this.root.requestFullscreen || this.root.webkitRequestFullscreen).call(this.root);
410
413
  }
411
414
  return this.root.focus();
415
+ }, ref$.encode = function(opt){
416
+ var t0, tf, cover, bar, done, progress, ref$, this$ = this;
417
+ opt == null && (opt = {});
418
+ if (!this.started) {
419
+ return Promise.reject(new Error('[lotion] encode() before start()'));
420
+ }
421
+ if (this.encoding) {
422
+ return Promise.reject(new Error('[lotion] already encoding'));
423
+ }
424
+ this.pause();
425
+ t0 = this.t;
426
+ this.encoding = true;
427
+ this.root.classList.add('lotion-encoding');
428
+ tf = this.stage.style.transform;
429
+ this.stage.style.transform = 'none';
430
+ cover = mk('lotion-encoding-screen', '<div class="lotion-spinner"></div><div class="lotion-progress"></div>', this.viewport);
431
+ bar = cover.querySelector('.lotion-progress');
432
+ done = function(){
433
+ cover.remove();
434
+ this$.stage.style.transform = tf;
435
+ this$.encoding = false;
436
+ this$.root.classList.remove('lotion-encoding');
437
+ this$.fit();
438
+ return this$.seek(t0);
439
+ };
440
+ progress = function(v){
441
+ bar.textContent = Math.round(v * 100) + "%";
442
+ if (opt.progress) {
443
+ return opt.progress(v);
444
+ }
445
+ };
446
+ return encode((ref$ = import$({
447
+ el: this.stage,
448
+ seek: this.opt.seek,
449
+ duration: this.duration,
450
+ width: this.width,
451
+ height: this.height
452
+ }, opt), ref$.progress = progress, ref$)).then(function(b){
453
+ done();
454
+ return b;
455
+ })['catch'](function(e){
456
+ done();
457
+ throw e;
458
+ });
412
459
  }, ref$);
460
+ libs = {
461
+ snapdom: 'https://cdn.jsdelivr.net/npm/@zumer/snapdom@3.3.0/dist/snapdom.mjs',
462
+ mediabunny: 'https://cdn.jsdelivr.net/npm/mediabunny@1.61.3/dist/bundles/mediabunny.min.mjs'
463
+ };
464
+ libCache = {};
465
+ lib = function(name){
466
+ var ref$;
467
+ return (ref$ = libCache[name]) != null
468
+ ? ref$
469
+ : libCache[name] = import(libs[name]);
470
+ };
471
+ encode = function(opt){
472
+ var even, w, h, fps, sub, d, ref$, from, to, n, bg, abort, output, that;
473
+ opt == null && (opt = {});
474
+ if (typeof VideoEncoder === 'undefined') {
475
+ return Promise.reject(new Error('[lotion] WebCodecs ( VideoEncoder ) is not supported in this browser'));
476
+ }
477
+ even = function(v){
478
+ return 2 * Math.round(v / 2);
479
+ };
480
+ w = even(opt.width || 1920);
481
+ h = even(opt.height || 1080);
482
+ fps = opt.fps || 30;
483
+ sub = Math.max(1, Math.round(opt.sub || 1));
484
+ d = ((ref$ = opt.shutter) != null ? ref$ : 0.5) / fps / sub;
485
+ from = opt.from || 0;
486
+ to = (ref$ = opt.to) != null
487
+ ? ref$
488
+ : opt.duration;
489
+ n = Math.max(1, Math.round((to - from) * fps));
490
+ bg = opt.background || getComputedStyle(opt.el).backgroundColor;
491
+ if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') {
492
+ bg = '#000';
493
+ }
494
+ abort = function(){
495
+ if (opt.signal && opt.signal.aborted) {
496
+ throw new DOMException('export aborted', 'AbortError');
497
+ }
498
+ };
499
+ output = null;
500
+ return Promise.all([
501
+ (that = opt.snapdom)
502
+ ? Promise.resolve(that)
503
+ : lib('snapdom'), (that = opt.mediabunny)
504
+ ? Promise.resolve(that)
505
+ : lib('mediabunny')
506
+ ]).then(function(arg$){
507
+ var sd, mb, snap, canvas, ctx;
508
+ sd = arg$[0], mb = arg$[1];
509
+ snap = sd.snapdom || sd;
510
+ canvas = document.createElement('canvas');
511
+ canvas.width = w;
512
+ canvas.height = h;
513
+ ctx = canvas.getContext('2d');
514
+ return mb.getFirstEncodableVideoCodec(['avc', 'vp9', 'av1'], {
515
+ width: w,
516
+ height: h
517
+ }).then(function(codec){
518
+ var src;
519
+ if (!codec) {
520
+ throw new Error("[lotion] no encodable video codec for " + w + "x" + h);
521
+ }
522
+ output = new mb.Output({
523
+ format: new mb.Mp4OutputFormat({
524
+ fastStart: 'in-memory'
525
+ }),
526
+ target: new mb.BufferTarget()
527
+ });
528
+ src = new mb.CanvasSource(canvas, {
529
+ codec: codec,
530
+ bitrate: opt.bitrate || mb.QUALITY_HIGH
531
+ });
532
+ output.addVideoTrack(src, {
533
+ frameRate: fps
534
+ });
535
+ return output.start().then(function(){
536
+ return src;
537
+ });
538
+ }).then(function(src){
539
+ var pending, step;
540
+ pending = Promise.resolve();
541
+ step = function(i, j){
542
+ var t;
543
+ abort();
544
+ if (i >= n) {
545
+ return pending;
546
+ }
547
+ if (j >= sub) {
548
+ return pending.then(function(){
549
+ abort();
550
+ pending = src.add(i / fps, 1 / fps);
551
+ if (opt.progress) {
552
+ opt.progress((i + 1) / n);
553
+ }
554
+ return step(i + 1, 0);
555
+ });
556
+ }
557
+ t = clamp(from + i / fps + (sub > 1 ? (j - (sub - 1) / 2) * d : 0), 0, opt.duration);
558
+ opt.seek(t);
559
+ return snap.toCanvas(opt.el, {
560
+ width: w,
561
+ height: h,
562
+ dpr: 1,
563
+ backgroundColor: bg
564
+ }).then(function(c){
565
+ ctx.globalAlpha = 1 / (j + 1);
566
+ ctx.drawImage(c, 0, 0, w, h);
567
+ return step(i, j + 1);
568
+ });
569
+ };
570
+ return step(0, 0);
571
+ });
572
+ }).then(function(){
573
+ return output.finalize();
574
+ }).then(function(){
575
+ return new Blob([output.target.buffer], {
576
+ type: 'video/mp4'
577
+ });
578
+ })['catch'](function(e){
579
+ var ref$;
580
+ if (output && ((ref$ = output.state) === 'started' || ref$ === 'pending')) {
581
+ output.cancel();
582
+ }
583
+ throw e;
584
+ });
585
+ };
413
586
  lotion = {
414
587
  clamp: clamp,
415
588
  lerp: lerp,
@@ -427,11 +600,19 @@
427
600
  put: put,
428
601
  txt: txt,
429
602
  svg: svg,
430
- player: player
603
+ player: player,
604
+ encode: encode,
605
+ libs: libs,
606
+ lib: lib
431
607
  };
432
608
  if (typeof module != 'undefined' && module !== null) {
433
609
  module.exports = lotion;
434
610
  } else if (typeof window != 'undefined' && window !== null) {
435
611
  window.lotion = lotion;
436
612
  }
613
+ function import$(obj, src){
614
+ var own = {}.hasOwnProperty;
615
+ for (var key in src) if (own.call(src, key)) obj[key] = src[key];
616
+ return obj;
617
+ }
437
618
  }).call(this);
package/index.min.css CHANGED
@@ -1 +1 @@
1
- .lotion{--lotion-bg:#eceae5;--lotion-paper:#fbfaf7;--lotion-ink:#18181a;--lotion-mute:#8a877f;--lotion-line:#d9d6cf;position:relative;border:1px solid var(--lotion-line);border-radius:12px;overflow:hidden;outline:none}.lotion .lotion-viewport{position:relative;width:100%;overflow:hidden;background:var(--lotion-bg)}.lotion .lotion-stage{position:absolute;left:0;top:0;transform-origin:0 0;background:var(--lotion-bg);overflow:hidden;line-height:normal}.lotion .lotion-bar{display:flex;align-items:center;gap:18px;height:64px;padding:0 24px;background:var(--lotion-paper);border-top:1px solid var(--lotion-line);user-select:none}.lotion .lotion-btn{flex:none;width:40px;height:40px;border-radius:50%;background:var(--lotion-ink);color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer}.lotion .lotion-play:before{content:"";margin-left:3px;border-style:solid;border-width:7px 0 7px 12px;border-color:transparent transparent transparent #fff}.lotion.lotion-playing .lotion-play:before{margin-left:0;width:4px;height:14px;border-width:0 4px;border-color:#fff}.lotion .lotion-fs{background:transparent;color:var(--lotion-ink);border:1px solid var(--lotion-line)}.lotion .lotion-time{font:15px/1 sans-serif;color:var(--lotion-mute);font-variant-numeric:tabular-nums;white-space:nowrap}.lotion .lotion-track{position:relative;flex:1;height:40px;cursor:pointer}.lotion .lotion-rail,.lotion .lotion-fill{position:absolute;left:0;top:18px;height:4px;border-radius:2px}.lotion .lotion-rail{right:0;background:var(--lotion-line)}.lotion .lotion-fill{background:var(--lotion-ink)}.lotion .lotion-knob{position:absolute;top:13px;width:14px;height:14px;margin-left:-7px;border-radius:50%;background:var(--lotion-ink)}.lotion .lotion-chapters{position:absolute;inset:0;pointer-events:none}.lotion .lotion-ch{position:absolute;top:14px;width:2px;height:12px;margin-left:-1px;border-radius:1px;background:var(--lotion-mute);opacity:.5}.lotion.lotion-loading .lotion-stage{visibility:hidden}.lotion.lotion-loading .lotion-bar{opacity:.5;pointer-events:none}.lotion .lotion-loading-screen{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:var(--lotion-bg);color:var(--lotion-mute)}.lotion .lotion-spinner{width:36px;height:36px;border-radius:50%;border:3px solid var(--lotion-line);border-top-color:var(--lotion-ink);animation:lotion-spin .8s linear infinite}.lotion:fullscreen{display:flex;flex-direction:column;border:0;border-radius:0}.lotion:fullscreen .lotion-viewport{flex:1;aspect-ratio:auto !important}.lotion:fullscreen .lotion-bar{flex:none}.lotion.lotion-render{position:fixed;inset:0;z-index:99999;border:0;border-radius:0}.lotion.lotion-render .lotion-viewport{width:100vw;height:100vh;aspect-ratio:auto !important}.lotion.lotion-render .lotion-bar{display:none}@-moz-keyframes lotion-spin{to{transform:rotate(360deg)}}@-webkit-keyframes lotion-spin{to{transform:rotate(360deg)}}@-o-keyframes lotion-spin{to{transform:rotate(360deg)}}@keyframes lotion-spin{to{transform:rotate(360deg)}}
1
+ .lotion{--lotion-bg:#eceae5;--lotion-paper:#fbfaf7;--lotion-ink:#18181a;--lotion-mute:#8a877f;--lotion-line:#d9d6cf;position:relative;border:1px solid var(--lotion-line);border-radius:12px;overflow:hidden;outline:none}.lotion .lotion-viewport{position:relative;width:100%;overflow:hidden;background:var(--lotion-bg)}.lotion .lotion-stage{position:absolute;left:0;top:0;transform-origin:0 0;background:var(--lotion-bg);overflow:hidden;line-height:normal}.lotion .lotion-bar{display:flex;align-items:center;gap:18px;height:64px;padding:0 24px;background:var(--lotion-paper);border-top:1px solid var(--lotion-line);user-select:none}.lotion .lotion-btn{flex:none;width:40px;height:40px;border-radius:50%;background:var(--lotion-ink);color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer}.lotion .lotion-play:before{content:"";margin-left:3px;border-style:solid;border-width:7px 0 7px 12px;border-color:transparent transparent transparent #fff}.lotion.lotion-playing .lotion-play:before{margin-left:0;width:4px;height:14px;border-width:0 4px;border-color:#fff}.lotion .lotion-fs{background:transparent;color:var(--lotion-ink);border:1px solid var(--lotion-line)}.lotion .lotion-time{font:15px/1 sans-serif;color:var(--lotion-mute);font-variant-numeric:tabular-nums;white-space:nowrap}.lotion .lotion-track{position:relative;flex:1;height:40px;cursor:pointer}.lotion .lotion-rail,.lotion .lotion-fill{position:absolute;left:0;top:18px;height:4px;border-radius:2px}.lotion .lotion-rail{right:0;background:var(--lotion-line)}.lotion .lotion-fill{background:var(--lotion-ink)}.lotion .lotion-knob{position:absolute;top:13px;width:14px;height:14px;margin-left:-7px;border-radius:50%;background:var(--lotion-ink)}.lotion .lotion-chapters{position:absolute;inset:0;pointer-events:none}.lotion .lotion-ch{position:absolute;top:14px;width:2px;height:12px;margin-left:-1px;border-radius:1px;background:var(--lotion-mute);opacity:.5}.lotion.lotion-loading .lotion-stage{visibility:hidden}.lotion.lotion-loading .lotion-bar{opacity:.5;pointer-events:none}.lotion.lotion-encoding .lotion-bar{opacity:.5;pointer-events:none}.lotion .lotion-loading-screen,.lotion .lotion-encoding-screen{position:absolute;inset:0;display:flex;flex-direction:column;gap:12px;align-items:center;justify-content:center;background:var(--lotion-bg);color:var(--lotion-mute)}.lotion .lotion-progress{font-size:14px;font-variant-numeric:tabular-nums}.lotion .lotion-spinner{width:36px;height:36px;border-radius:50%;border:3px solid var(--lotion-line);border-top-color:var(--lotion-ink);animation:lotion-spin .8s linear infinite}.lotion:fullscreen{display:flex;flex-direction:column;border:0;border-radius:0}.lotion:fullscreen .lotion-viewport{flex:1;aspect-ratio:auto !important}.lotion:fullscreen .lotion-bar{flex:none}.lotion.lotion-render{position:fixed;inset:0;z-index:99999;border:0;border-radius:0}.lotion.lotion-render .lotion-viewport{width:100vw;height:100vh;aspect-ratio:auto !important}.lotion.lotion-render .lotion-bar{display:none}@-moz-keyframes lotion-spin{to{transform:rotate(360deg)}}@-webkit-keyframes lotion-spin{to{transform:rotate(360deg)}}@-o-keyframes lotion-spin{to{transform:rotate(360deg)}}@keyframes lotion-spin{to{transform:rotate(360deg)}}