@plotdb/lotion 0.2.0 → 0.4.2
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 +56 -0
- package/README.md +106 -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 +2116 -0
- package/vector.min.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
+
## v0.4.2
|
|
2
|
+
|
|
3
|
+
- animate, size: `clip-path: inset()` ( reveals ) becomes one svg clipPath whose rect moves by smil, instead of one
|
|
4
|
+
appearance per inset ( lotitor's sumi-ink trial: html variants 68 to 35 )
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## v0.4.1
|
|
8
|
+
|
|
9
|
+
- bug fix:
|
|
10
|
+
- vector / animate: box-shadow and text-shadow are reordered to "offsets blur spread color", since computed style
|
|
11
|
+
puts the color first and satori then drew a broken path, so shadows were missing
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## v0.4.0
|
|
15
|
+
|
|
16
|
+
- features:
|
|
17
|
+
- `lotion.animate(opt)` / `player.animate(opt)` ( experimental, in `vector.js` ): convert a whole animation into one
|
|
18
|
+
animated svg. each element becomes a `<g>` nested as in the dom; its motion ( position and transform as a matrix,
|
|
19
|
+
opacity, blur ) is sampled per frame and written as css keyframes, simplified to the points linear interpolation
|
|
20
|
+
needs; each distinct appearance is drawn once by satori and switched by visibility. inline svg is walked
|
|
21
|
+
natively: groups animate, leaves keep their markup with numeric attributes as smil `<animate>`, definitions are
|
|
22
|
+
written once, and text uses fonts embedded once. `mix-blend-mode` now works at any depth.
|
|
23
|
+
- demo: the `/vector/` page converts the whole animation and plays the animated svg
|
|
24
|
+
- animate, size: numeric tracks are written once per element instead of once per appearance ( positions of svg
|
|
25
|
+
leaves join the group matrix, inheritable values such as `stroke-dashoffset` animate on the group );
|
|
26
|
+
css gradient masks moved by `mask-position` / `mask-size` ( wipes ) become one svg mask with a moving rect, so
|
|
27
|
+
the content is drawn once; paths drawn progressively ( each `d` a prefix of a longer one ) become the full path
|
|
28
|
+
revealed by a dash animation. the code-only sumi-ink trial drops from 3.2 MB to 1.5 MB.
|
|
29
|
+
- tweaks:
|
|
30
|
+
- vector: inline svg keeps a content-based placeholder `src` until fonts are ready
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## v0.3.0
|
|
34
|
+
|
|
35
|
+
- features:
|
|
36
|
+
- `player.encode(opt)` / `lotion.encode(opt)`: render to mp4 in the browser ( snapdom capture, WebCodecs encode via
|
|
37
|
+
mediabunny ), both libraries loaded on demand by dynamic `import()`; urls configurable in `lotion.libs`
|
|
38
|
+
- demo: export button
|
|
39
|
+
- `dist/vector.js` ( experimental ): `lotion.vector(el)` / `player.vector(t)` convert the current frame to a vector svg
|
|
40
|
+
with satori. layout is taken from the browser and each element becomes an absolutely positioned node; web fonts
|
|
41
|
+
are collected from `@font-face` by the characters in use, woff2 decompressed, subset with HarfBuzz and variable
|
|
42
|
+
fonts pinned to the used weight. root-level `mix-blend-mode` is composed as svg layers; inline svg is embedded
|
|
43
|
+
with its computed styles, referenced external defs and fonts.
|
|
44
|
+
- `lotion.lib(name)`: load an on-demand dependency listed in `lotion.libs`
|
|
45
|
+
- demo: `/vector/` compares the player with its converted svg side by side; the block demo loads `vector.min.js`
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
## v0.2.1
|
|
49
|
+
|
|
50
|
+
- features:
|
|
51
|
+
- cli: `video --workers N` renders with N browsers in parallel, splitting frames into contiguous ranges and joining
|
|
52
|
+
the encoded parts without re-encoding. each part first renders the 3 preceding frames and discards them, since a
|
|
53
|
+
freshly opened page that jumps straight to a frame rasterizes its first frames slightly differently; with this the
|
|
54
|
+
output matches a single worker frame for frame. about 2.7x faster with 4 workers on an 8-core ( 4 performance ) mac.
|
|
55
|
+
|
|
56
|
+
|
|
1
57
|
## v0.2.0
|
|
2
58
|
|
|
3
59
|
- 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,100 @@ 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
|
+
### Animated svg
|
|
151
|
+
|
|
152
|
+
`lotion.animate({el, seek, duration, fps})` ( or `player.animate({fps})` ) converts the whole animation into one svg
|
|
153
|
+
that plays by itself, resolving to `{svg, warnings, stats}`:
|
|
154
|
+
|
|
155
|
+
- every element becomes a `<g>`, nested as in the dom. Its motion, the position and transform combined into a
|
|
156
|
+
matrix, opacity and a `blur()` filter, is sampled at `fps` and written as css keyframes, keeping only the samples
|
|
157
|
+
linear interpolation needs.
|
|
158
|
+
- everything else is its appearance. Each distinct appearance is drawn once ( by satori ) and shown by visibility
|
|
159
|
+
keyframes, so an element that only moves costs one drawing, while one whose content changes ( typing, a growing
|
|
160
|
+
width ) costs one per state. Elements with a css mask, clip-path or other filters, `<img>` and `<canvas>`, are
|
|
161
|
+
drawn with their children as one appearance.
|
|
162
|
+
- inline svg is kept as svg: groups animate like elements, leaves keep their markup with numeric attributes
|
|
163
|
+
( coordinates, `stroke-dashoffset`, opacities ... ) written as smil `<animate>`, definitions are written once,
|
|
164
|
+
and text uses fonts embedded once. nested `<svg>` becomes a group, since smil inside a nested svg does not follow
|
|
165
|
+
the outer svg when paused or seeked.
|
|
166
|
+
- tracks are written once per element: positions of svg leaves join the group matrix, and inheritable values
|
|
167
|
+
( `stroke-dashoffset`, `stroke-width`, fill / stroke opacity ) animate on the group.
|
|
168
|
+
- a css mask of one `linear-gradient` ( horizontal or vertical, `no-repeat` ) becomes an svg mask whose rect follows
|
|
169
|
+
`mask-position` / `mask-size`, so a wipe draws its content once. Other masks and clip-paths are drawn per state.
|
|
170
|
+
- a path whose `d` grows ( each one a prefix of a longer one, stroked without fill, dashes or markers ) becomes the
|
|
171
|
+
longest path revealed by a dash animation.
|
|
172
|
+
- options: `fps` ( default 30 ), `from`, `to`, `loop` ( default true ), `progress`, `signal`.
|
|
173
|
+
|
|
174
|
+
The demo converts in under a second into 230 KB ( 35 KB with brotli ); the code-only sumi-ink trial ( 44 s at 10 fps )
|
|
175
|
+
in about 2 seconds into 1.5 MB ( 230 KB with brotli ). Serve it compressed, or save it as `.svgz`. Frames of the animated svg match screenshots as closely as single frames do.
|
|
176
|
+
|
|
177
|
+
On the demo and the sumi-ink trial of lotitor, frames rasterized from the svg match screenshots at 29 - 39 dB PSNR;
|
|
178
|
+
the remaining difference is mostly text antialiasing and baselines in fixed line-heights. A frame takes 50 - 300 ms.
|
|
179
|
+
|
|
180
|
+
|
|
86
181
|
## Render protocol
|
|
87
182
|
|
|
88
183
|
With `?render` in the url, a page should cover the viewport with its stage and expose:
|
|
@@ -137,6 +232,8 @@ options:
|
|
|
137
232
|
- `--width 1920 --height 1080`: viewport size.
|
|
138
233
|
- `--fps 60`, `--sub 1`, `--shutter 0.5`: video settings. with `--sub` greater than 1, each frame averages
|
|
139
234
|
`sub` sub-samples spread over `shutter` of the frame interval ( motion blur, via ffmpeg `tmix` ).
|
|
235
|
+
- `--workers 1`: render a video with N browsers in parallel. frames are split into contiguous ranges, each encoded on
|
|
236
|
+
its own, then joined without re-encoding. since every frame depends on `t` only, the result matches a single worker.
|
|
140
237
|
- `--from`, `--to`: render only a range, in seconds.
|
|
141
238
|
- `--crf 16`: x264 quality.
|
|
142
239
|
- `--cols 3`, `--tile 640`: contact sheet layout.
|
|
@@ -173,6 +270,14 @@ the packed block demo is generated from the running dev server:
|
|
|
173
270
|
node dist/cli.js bundle http://localhost:<port> lotion-demo web/static/block/lotion-demo.bundle.html
|
|
174
271
|
|
|
175
272
|
|
|
273
|
+
## Credits
|
|
274
|
+
|
|
275
|
+
Releases up to v0.1.0 included `prompt/ui-loop.md`, derived from a prompt template posted by zero
|
|
276
|
+
( [@twoclipping](https://x.com/twoclipping) ) on X on 2026-09-25: https://x.com/twoclipping/status/2103273003555402193 .
|
|
277
|
+
The post says "im open sourcing the prompt template for these motion designs" but attaches no explicit license, so that
|
|
278
|
+
file was never covered by lotion's MIT license; rights remain with the author. It is no longer part of lotion.
|
|
279
|
+
|
|
280
|
+
|
|
176
281
|
## License
|
|
177
282
|
|
|
178
283
|
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);
|