@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 +24 -0
- package/README.md +79 -1
- package/cli.js +137 -46
- package/index.css +12 -1
- package/index.js +185 -4
- package/index.min.css +1 -1
- package/index.min.js +1 -1
- package/package.json +1 -1
- package/vector.js +817 -0
- package/vector.min.js +1 -0
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
|
-
|
|
202
|
-
|
|
203
|
-
fps
|
|
204
|
-
sub =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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-
|
|
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)}}
|