@plotdb/lotion 0.1.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,40 @@
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
+
25
+ ## v0.2.0
26
+
27
+ - breaking:
28
+ - remove audio generators ( `audio/`, `lotion bgm | sfx | mix | beats` ); the commands now only print a notice.
29
+ they moved to the authoring toolkit `@plotdb/lotitor` ( not public yet )
30
+ - move `prompt/explainer.md` and `prompt/ui-loop.md` to `@plotdb/lotitor`
31
+ - features:
32
+ - `prompt/lotion.md`: how to write a lotion animation, independent of form and style
33
+ ( split from `prompt/explainer.md` )
34
+ - tweaks:
35
+ - cli: when `playwright` is missing, print how to install it instead of a node stack trace
36
+
37
+
1
38
  ## v0.1.0
2
39
 
3
40
  - features:
package/README.md CHANGED
@@ -8,8 +8,11 @@ play, scrub, embed, or render them frame by frame into video.
8
8
  - block player ( `dist/block/player.js` ): package an animation as a `@plotdb/block` and embed it anywhere.
9
9
  - cli ( `lotion` ): render any page following the render protocol into png frames, contact sheets or mp4,
10
10
  and bundle blocks.
11
- - audio tools ( `audio/` ): beat detection, ui sound effects, a simple bassline bgm synthesizer and a cue mixer.
12
- - prompts ( `prompt/` ): workflow notes for producing motion designs and explainer animations with an AI agent.
11
+ - prompt ( `prompt/lotion.md` ): how to write a lotion animation correctly, for people and AI agents.
12
+
13
+ lotion is self-contained for writing, playing and rendering animations. Authoring aids ( guides for specific forms,
14
+ a style library, asset and audio generation ) are kept in a separate toolkit, `@plotdb/lotitor`, which is not public
15
+ yet; nothing in lotion depends on it.
13
16
 
14
17
 
15
18
  ## Install
@@ -52,7 +55,8 @@ player options:
52
55
  - `loading`: what to show before `start()`. default `true` ( a spinner ); a string is used as custom html;
53
56
  `false` shows nothing.
54
57
 
55
- 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 ).
56
60
  `player.ready` is a promise resolved by `start()`.
57
61
 
58
62
  Until `start()` is called, the stage is hidden behind the loading screen, the control bar is disabled, and
@@ -80,6 +84,73 @@ helpers:
80
84
  - `svg(tag, attrs, parent)`: create an svg element.
81
85
 
82
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
+
83
154
  ## Render protocol
84
155
 
85
156
  With `?render` in the url, a page should cover the viewport with its stage and expose:
@@ -125,7 +196,7 @@ targets under the root ( or use `:scope` ). A block sample is in `web/src/pug/bl
125
196
  lotion video <src> <out.mp4> render to mp4
126
197
  lotion cues <src> <out.json> dump window.cues()
127
198
  lotion bundle <base-url> <block> <out> pack a block and its dependencies into one file
128
- lotion bgm | sfx | mix | beats ... audio tools, see below
199
+ lotion bgm | sfx | mix | beats removed, see Audio
129
200
 
130
201
  `<src>` is an http(s) url, or a local html file / directory served by a built-in static server.
131
202
 
@@ -134,6 +205,8 @@ options:
134
205
  - `--width 1920 --height 1080`: viewport size.
135
206
  - `--fps 60`, `--sub 1`, `--shutter 0.5`: video settings. with `--sub` greater than 1, each frame averages
136
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.
137
210
  - `--from`, `--to`: render only a range, in seconds.
138
211
  - `--crf 16`: x264 quality.
139
212
  - `--cols 3`, `--tile 640`: contact sheet layout.
@@ -143,36 +216,21 @@ options:
143
216
 
144
217
  ## Audio
145
218
 
146
- Each command forwards its arguments to the python script of the same name in `audio/`; pass `-h` for all options.
147
- All times are aligned so that `t = 0` matches the start of the animation.
148
-
149
- - `lotion bgm out.wav --bpm 120 --bars 16 --key A --scale minor --prog 1,6,3,7 --bass drive --drums four --pad`:
150
- synthesize a track around a repeating bassline. bass presets: `pulse`, `octave`, `house`, `drive`, `walk`,
151
- or 16 custom steps ( semitones from the chord root, `.` rest, `-` hold ). drum presets: `four`, `half`, `broken`,
152
- `none`. `--loop` makes it loop seamlessly; `--grid grid.json` writes its beat grid.
153
- - `lotion beats music.mp3 --start 64.11 --dur 30 --out grid.json`: estimate bpm and the beat grid
154
- ( `{bpm, offset, beats, downbeats}` ). with an off-beat bassline and syncopated kicks the grid may be half a beat
155
- off; correct it with `--shift 0.5`.
156
- - `lotion sfx sfx/`: synthesize ui sound effects ( click, tick, scrub, pop, toggle, success, notify, type, whoosh )
157
- and measure each transient peak into `sfx/peaks.json`.
158
- - `lotion mix cues.json out.wav --dur 30 --music bgm.wav --sfx sfx/ [--loop | --fade 2]`: mix music with sound
159
- effects placed at cue times ( `[{t, sfx, gain}]`, e.g. from `lotion cues` ), aligning each effect's measured
160
- peak rather than its file start.
161
-
162
- A typical flow:
163
-
164
- lotion bgm bgm.wav --bpm 120 --bars 16 --grid grid.json # time the animation on this grid
165
- lotion sfx sfx/
219
+ The audio generators ( `bgm`, `sfx`, `mix`, `beats` ) are no longer part of lotion; `lotion bgm ...` and the like
220
+ only print a notice. Sound stays aligned through cues: the page's `cues()` lists `[{t, ...}]`, `lotion cues` exports
221
+ it, and any audio tool can place sounds at those times. Then add the track to the rendered video:
222
+
166
223
  lotion cues page.html cues.json
167
- lotion mix cues.json audio.wav --dur 32 --music bgm.wav --sfx sfx/ --fade 2
168
224
  lotion video page.html silent.mp4
169
225
  ffmpeg -i silent.mp4 -i audio.wav -c:v copy -c:a aac -shortest out.mp4
170
226
 
171
227
 
172
228
  ## Prompts
173
229
 
174
- - `prompt/ui-loop.md`: a phased workflow for short, looping, single-object UI motion with music.
175
- - `prompt/explainer.md`: practices for longer explainer animations, embedding them into pages, and common pitfalls.
230
+ - `prompt/lotion.md`: writing `seek(t)`, embedding into pages, block packaging, and common pitfalls.
231
+
232
+ Guides for specific forms ( explainers, UI loops ) and for choosing a style moved to the authoring toolkit
233
+ `@plotdb/lotitor` ( not public yet ). `prompt/lotion.md` covers everything needed to write a lotion animation.
176
234
 
177
235
 
178
236
  ## Development
@@ -185,6 +243,14 @@ the packed block demo is generated from the running dev server:
185
243
  node dist/cli.js bundle http://localhost:<port> lotion-demo web/static/block/lotion-demo.bundle.html
186
244
 
187
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
+
188
254
  ## License
189
255
 
190
256
  MIT
package/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- var fs, path, http, child_process, usage, parse, types, serve, open, shot, frames, sheet, video, cues, bundle, audioDir, audio, 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');
6
6
  child_process = require('child_process');
7
- usage = 'usage:\n lotion <frames|sheet|video|cues> <src> <out> [t...] [options]\n lotion bundle <base-url> <block-name> <out> [options]\n lotion <bgm|sfx|mix|beats> ... ( -h for help )\nsee README for options.';
7
+ usage = 'usage:\n lotion <frames|sheet|video|cues> <src> <out> [t...] [options]\n lotion bundle <base-url> <block-name> <out> [options]\n audio generators ( bgm | sfx | mix | beats ) were removed, see README > Audio\nsee README for options.';
8
8
  parse = function(argv){
9
9
  var ref$, pos, opt, i, a;
10
10
  ref$ = [[], {}], pos = ref$[0], opt = ref$[1];
@@ -75,9 +75,22 @@ serve = function(src){
75
75
  });
76
76
  });
77
77
  };
78
+ playwright = function(){
79
+ var e;
80
+ try {
81
+ return require('playwright');
82
+ } catch (e$) {
83
+ e = e$;
84
+ if (e.code !== 'MODULE_NOT_FOUND' || !/playwright/.test(e.message)) {
85
+ throw e;
86
+ }
87
+ console.error('lotion: this command needs playwright, which is not installed. install it with:\n\n npm i -D playwright\n npx playwright install chromium');
88
+ return process.exit(1);
89
+ }
90
+ };
78
91
  open = function(src, opt){
79
92
  var chromium, p;
80
- chromium = require('playwright').chromium;
93
+ chromium = playwright().chromium;
81
94
  p = /^https?:/.test(src)
82
95
  ? Promise.resolve({
83
96
  url: src,
@@ -185,60 +198,151 @@ sheet = function(src, out, times, opt){
185
198
  });
186
199
  });
187
200
  };
188
- video = function(src, out, opt){
189
- var fps, sub, shutter;
190
- fps = +(opt.fps || 60);
191
- sub = +(opt.sub || 1);
192
- shutter = +(opt.shutter || 0.5);
193
- return open(src, opt).then(function(arg$){
194
- var page, duration, close, from, to, n, d, vf, ff, t0, write, step;
195
- page = arg$.page, duration = arg$.duration, close = arg$.close;
196
- from = +(opt.from || 0);
197
- to = +(opt.to || duration);
198
- n = Math.round((to - from) * fps);
199
- d = shutter / fps / sub;
200
- vf = sub > 1
201
- ? ['-vf', "tmix=frames=" + sub + ",select='eq(mod(n\\," + sub + ")\\," + (sub - 1) + ")',setpts=N/" + fps + "/TB"]
202
- : [];
203
- 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]), {
204
- 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
+ }
205
219
  });
206
- t0 = Date.now();
207
- write = function(buf){
208
- return new Promise(function(res){
209
- if (ff.stdin.write(buf)) {
210
- 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));
211
253
  } else {
212
- return ff.stdin.once('drain', res);
254
+ return res();
213
255
  }
214
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
+ }
215
280
  };
216
- step = function(i, j){
217
- var t;
218
- if (i >= n) {
219
- return Promise.resolve();
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 )");
284
+ };
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$);
220
296
  }
221
- if (j >= sub) {
222
- if (i % 300 === 0) {
223
- console.log("frame " + i + "/" + n + " " + ((Date.now() - t0) / 1000).toFixed(0) + "s");
224
- }
225
- 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$);
226
309
  }
227
- t = Math.max(0, from + i / fps + (sub > 1 ? (j - (sub - 1) / 2) * d : 0));
228
- return shot(page, t).then(write).then(function(){
229
- return step(i, j + 1);
230
- });
231
- };
232
- return step(0, 0).then(function(){
233
- return new Promise(function(res){
234
- ff.on('close', res);
235
- 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
+ }));
236
320
  });
237
321
  }).then(function(){
238
- return close();
239
- }).then(function(){
240
- return console.log(out + " ( " + n + " frames, " + ((Date.now() - t0) / 1000).toFixed(0) + "s )");
241
- });
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);
242
346
  });
243
347
  };
244
348
  cues = function(src, out, opt){
@@ -260,7 +364,7 @@ cues = function(src, out, opt){
260
364
  };
261
365
  bundle = function(base, name, out, opt){
262
366
  var chromium, m;
263
- chromium = require('playwright').chromium;
367
+ chromium = playwright().chromium;
264
368
  base = base.replace(/\/$/, '');
265
369
  m = function(f){
266
370
  return require.resolve(f);
@@ -322,37 +426,13 @@ bundle = function(base, name, out, opt){
322
426
  });
323
427
  });
324
428
  };
325
- audioDir = function(){
326
- return [path.join(__dirname, 'audio'), path.join(__dirname, '..', 'audio')].find(function(d){
327
- return fs.existsSync(d);
328
- });
329
- };
330
- audio = function(cmd, args){
331
- return new Promise(function(res, rej){
332
- var d, f, py;
333
- if (!(d = audioDir())) {
334
- return rej(new Error("audio scripts not found"));
335
- }
336
- f = path.join(d, cmd + ".py");
337
- py = child_process.spawn(process.env.PYTHON || 'python3', [f].concat(args), {
338
- stdio: 'inherit'
339
- });
340
- py.on('error', rej);
341
- return py.on('close', function(c){
342
- if (c) {
343
- return rej(new Error(cmd + ".py exited with " + c));
344
- } else {
345
- return res();
346
- }
347
- });
348
- });
429
+ moved = function(cmd){
430
+ console.error("lotion " + cmd + ": audio generators are no longer part of lotion ( moved to @plotdb/lotitor, not public yet ).\nto align any audio with the animation, export cue times with `lotion cues <src> cues.json`. see README > Audio.");
431
+ return process.exit(1);
349
432
  };
350
433
  argv = process.argv.slice(2);
351
434
  if ((ref$ = argv[0]) === 'bgm' || ref$ === 'sfx' || ref$ === 'mix' || ref$ === 'beats') {
352
- audio(argv[0], argv.slice(1))['catch'](function(e){
353
- console.error(e.message);
354
- return process.exit(1);
355
- });
435
+ moved(argv[0]);
356
436
  } else {
357
437
  ref$ = parse(argv), pos = ref$.pos, opt = ref$.opt;
358
438
  cmd = pos[0], src = pos[1], out = pos[2], rest = slice$.call(pos, 3);
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;