@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 +37 -0
- package/README.md +93 -27
- package/cli.js +157 -77
- 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/prompt/{explainer.md → lotion.md} +7 -29
- package/vector.js +817 -0
- package/vector.min.js +1 -0
- package/audio/beats.py +0 -93
- package/audio/bgm.py +0 -181
- package/audio/common.py +0 -30
- package/audio/mix.py +0 -51
- package/audio/sfx.py +0 -93
- package/prompt/ui-loop.md +0 -497
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
|
-
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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/
|
|
175
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
fps
|
|
191
|
-
sub =
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 =
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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-
|
|
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;
|