@plotdb/lotion 0.3.0 → 0.5.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,49 @@
1
+ ## v0.5.0
2
+
3
+ - player: captions. `captions: [[t0, t1, text, meta?], ...]` or `{lang: [...]}`, drawn outside the stage in a
4
+ bottom safe area, styled by css variables; readers turn them on / off with the `CC` button, the `c` key or
5
+ `showCaptions(on)`; `captionsBurned: true` starts them off for scenes that already show the narration.
6
+ Not drawn in `?render` unless `burnCaptions` ( or `?render&captions` ).
7
+ - player: events `on` / `off` ( `time`, `chapter`, `caption`, `captions` ) and state `chapter`, `caption`,
8
+ `captionsOn`, `captionLang`
9
+ - chapters may carry a third element, `meta` ( such as `{description, thumb}` ), passed through in `player.chapter`
10
+ - `lotion.vtt.parse` / `stringify`: WebVTT to and from the caption format
11
+ - cli: `lotion video` writes captions as `.vtt` next to the mp4 ( shifted by `--from` ); `lotion captions` exports
12
+ them alone. render protocol: `window.CAPTIONS`
13
+
14
+
15
+ ## v0.4.2
16
+
17
+ - animate, size: `clip-path: inset()` ( reveals ) becomes one svg clipPath whose rect moves by smil, instead of one
18
+ appearance per inset ( lotitor's sumi-ink trial: html variants 68 to 35 )
19
+
20
+
21
+ ## v0.4.1
22
+
23
+ - bug fix:
24
+ - vector / animate: box-shadow and text-shadow are reordered to "offsets blur spread color", since computed style
25
+ puts the color first and satori then drew a broken path, so shadows were missing
26
+
27
+
28
+ ## v0.4.0
29
+
30
+ - features:
31
+ - `lotion.animate(opt)` / `player.animate(opt)` ( experimental, in `vector.js` ): convert a whole animation into one
32
+ animated svg. each element becomes a `<g>` nested as in the dom; its motion ( position and transform as a matrix,
33
+ opacity, blur ) is sampled per frame and written as css keyframes, simplified to the points linear interpolation
34
+ needs; each distinct appearance is drawn once by satori and switched by visibility. inline svg is walked
35
+ natively: groups animate, leaves keep their markup with numeric attributes as smil `<animate>`, definitions are
36
+ written once, and text uses fonts embedded once. `mix-blend-mode` now works at any depth.
37
+ - demo: the `/vector/` page converts the whole animation and plays the animated svg
38
+ - animate, size: numeric tracks are written once per element instead of once per appearance ( positions of svg
39
+ leaves join the group matrix, inheritable values such as `stroke-dashoffset` animate on the group );
40
+ css gradient masks moved by `mask-position` / `mask-size` ( wipes ) become one svg mask with a moving rect, so
41
+ the content is drawn once; paths drawn progressively ( each `d` a prefix of a longer one ) become the full path
42
+ revealed by a dash animation. the code-only sumi-ink trial drops from 3.2 MB to 1.5 MB.
43
+ - tweaks:
44
+ - vector: inline svg keeps a content-based placeholder `src` until fonts are ready
45
+
46
+
1
47
  ## v0.3.0
2
48
 
3
49
  - features:
package/README.md CHANGED
@@ -48,7 +48,14 @@ player options:
48
48
  - `width`, `height`: design size of the stage. default `1920` x `1080`. The stage scales to fit the container.
49
49
  - `duration`: total length in seconds.
50
50
  - `seek(t)`: render the frame at time `t`. must depend on `t` only.
51
- - `chapters`: optional `[[t, name], ...]`, shown as ticks on the timeline and used by `←` / `→`.
51
+ - `chapters`: optional `[[t, name, meta?], ...]`, shown as ticks on the timeline and used by `←` / `→`. `meta` is
52
+ an optional object ( such as `{description, thumb}` ) the player passes through in `player.chapter`.
53
+ - `captions`: optional `[[t0, t1, text, meta?], ...]` ( `meta` such as `{speaker}` ), or `{zh: [...], en: [...]}`
54
+ for several languages. See Captions.
55
+ - `captionsBurned`: `true` when the scene already shows the narration as text: captions start off. default
56
+ `false` ( captions start on ). Readers can always turn them on or off.
57
+ - `captionLang`: initial caption language. default the first one.
58
+ - `burnCaptions`: draw captions in `?render` mode ( frame export ). default `false`; `?render&captions` also works.
52
59
  - `start`: initial time. `?t=<sec>` in the url takes precedence.
53
60
  - `autoplay`: default `false`.
54
61
  - `cues`: optional function returning sound cues `[{t, ...}]`, exported by `lotion cues`.
@@ -56,16 +63,45 @@ player options:
56
63
  `false` shows nothing.
57
64
 
58
65
  player methods: `start()`, `seek(t)`, `play(go = true)`, `pause()`, `toggle()`, `fullscreen()`, `encode(opt)`
59
- ( see Online Export ).
66
+ ( see Online Export ), `showCaptions(on = true)`, `setCaptionLang(lang)`, `on(name, cb)`, `off(name, cb)`.
60
67
  `player.ready` is a promise resolved by `start()`.
61
68
 
69
+ player state: `t`, `playing`, `chapter` ( `{index, t, name, meta}` or `null` ), `caption`
70
+ ( `{t0, t1, text, meta, lang}` or `null` ), `captionsOn`, `captionLang`, `captionLangs`.
71
+
72
+ events ( `player.on(name, cb)` ):
73
+
74
+ - `time`: every seek, with `t`.
75
+ - `chapter`: the current chapter changed, with `player.chapter`.
76
+ - `caption`: the shown caption changed ( including to `null` ), with `player.caption`. Fired whether captions
77
+ are on or off, so a page can read them anyway ( search, transcripts ).
78
+ - `captions`: captions turned on / off or switched language, with `{on, lang}`.
79
+
62
80
  Until `start()` is called, the stage is hidden behind the loading screen, the control bar is disabled, and
63
81
  `seek` / `play` do nothing. Scenes often need asynchronous preparation before the first frame is right
64
82
  ( web fonts, measuring the layout to place things ), and a half-built stage should not be seen or scrubbed.
65
83
  So build the scene, finish whatever it waits for, then call `start()`:
66
84
 
67
85
  Promise.all([document.fonts.ready, prepare!]).then -> p.start!
68
- Keys ( when the player is focused ): `space` play / pause, `←` / `→` previous / next chapter, `f` fullscreen.
86
+ Keys ( when the player is focused ): `space` play / pause, `←` / `→` previous / next chapter, `f` fullscreen,
87
+ `c` captions on / off.
88
+
89
+ ### Captions
90
+
91
+ Captions are drawn by the player, outside the stage: they keep their size when the stage scales, sit in a safe
92
+ area at the bottom, and are not part of `encode()` frames. Style them with css variables on `.lotion`:
93
+ `--lotion-caption-size`, `--lotion-caption-bg`, `--lotion-caption-color`, `--lotion-caption-font`.
94
+ The control bar shows a `CC` button when there are captions.
95
+
96
+ `lotion.vtt.parse(text)` reads WebVTT into the caption format ( a `<v name>` voice becomes `{speaker}` ),
97
+ `lotion.vtt.stringify(captions)` writes it back. `lotion video` writes a `.vtt` next to the mp4, and
98
+ `lotion captions` exports it alone.
99
+
100
+ Guidelines for splitting ( common subtitle practice ): Traditional Chinese up to about 16 characters a line, at most
101
+ 2 lines, no faster than about 9 characters a second; English up to about 42 characters a line, 17 - 20 a second.
102
+ Keep each caption on screen 0.83 - 7 s, with at least 2 frames between captions. Break at punctuation or pauses,
103
+ not inside a phrase; merge captions that are too short. With word timings from a speech generator, cut at sentence
104
+ ends and at pauses longer than about 0.3 s, then split or merge by those limits.
69
105
 
70
106
  helpers:
71
107
 
@@ -147,6 +183,33 @@ dependency after lotion ( `{name: 'lotion', path: 'vector.min.js'}` ); rescope g
147
183
  web font ( system fonts are not available ), mask or clip-path under a transform other than a translation
148
184
  ( satori does not move masks with transforms ).
149
185
 
186
+ ### Animated svg
187
+
188
+ `lotion.animate({el, seek, duration, fps})` ( or `player.animate({fps})` ) converts the whole animation into one svg
189
+ that plays by itself, resolving to `{svg, warnings, stats}`:
190
+
191
+ - every element becomes a `<g>`, nested as in the dom. Its motion, the position and transform combined into a
192
+ matrix, opacity and a `blur()` filter, is sampled at `fps` and written as css keyframes, keeping only the samples
193
+ linear interpolation needs.
194
+ - everything else is its appearance. Each distinct appearance is drawn once ( by satori ) and shown by visibility
195
+ keyframes, so an element that only moves costs one drawing, while one whose content changes ( typing, a growing
196
+ width ) costs one per state. Elements with a css mask, clip-path or other filters, `<img>` and `<canvas>`, are
197
+ drawn with their children as one appearance.
198
+ - inline svg is kept as svg: groups animate like elements, leaves keep their markup with numeric attributes
199
+ ( coordinates, `stroke-dashoffset`, opacities ... ) written as smil `<animate>`, definitions are written once,
200
+ and text uses fonts embedded once. nested `<svg>` becomes a group, since smil inside a nested svg does not follow
201
+ the outer svg when paused or seeked.
202
+ - tracks are written once per element: positions of svg leaves join the group matrix, and inheritable values
203
+ ( `stroke-dashoffset`, `stroke-width`, fill / stroke opacity ) animate on the group.
204
+ - a css mask of one `linear-gradient` ( horizontal or vertical, `no-repeat` ) becomes an svg mask whose rect follows
205
+ `mask-position` / `mask-size`, so a wipe draws its content once. Other masks and clip-paths are drawn per state.
206
+ - a path whose `d` grows ( each one a prefix of a longer one, stroked without fill, dashes or markers ) becomes the
207
+ longest path revealed by a dash animation.
208
+ - options: `fps` ( default 30 ), `from`, `to`, `loop` ( default true ), `progress`, `signal`.
209
+
210
+ 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 )
211
+ 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.
212
+
150
213
  On the demo and the sumi-ink trial of lotitor, frames rasterized from the svg match screenshots at 29 - 39 dB PSNR;
151
214
  the remaining difference is mostly text antialiasing and baselines in fixed line-heights. A frame takes 50 - 300 ms.
152
215
 
@@ -158,6 +221,8 @@ With `?render` in the url, a page should cover the viewport with its stage and e
158
221
  - `window.seek(t)`: render the frame at `t`.
159
222
  - `window.DURATION`: total length in seconds.
160
223
  - `window.cues()`: optional, list of sound cues.
224
+ - `window.CAPTIONS`: optional, captions as `{lang: [[t0, t1, text, meta?], ...]}` ( `lotion video` writes them as
225
+ `.vtt` ).
161
226
 
162
227
  `lotion.player` does this automatically, and only when `start()` is called: the cli waits for `window.seek` to
163
228
  exist before rendering, so it never captures a scene that is still being prepared. Any other page can follow
@@ -193,8 +258,9 @@ targets under the root ( or use `:scope` ). A block sample is in `web/src/pug/bl
193
258
 
194
259
  lotion frames <src> <outdir> <t...> one png per time
195
260
  lotion sheet <src> <out.png> <t...> the same, tiled into one contact sheet
196
- lotion video <src> <out.mp4> render to mp4
261
+ lotion video <src> <out.mp4> render to mp4, plus <out>.vtt when the page has captions
197
262
  lotion cues <src> <out.json> dump window.cues()
263
+ lotion captions <src> <out.vtt> export captions as WebVTT ( out.<lang>.vtt for several languages )
198
264
  lotion bundle <base-url> <block> <out> pack a block and its dependencies into one file
199
265
  lotion bgm | sfx | mix | beats removed, see Audio
200
266
 
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, playwright, open, shot, frames, sheet, WARM, encode, 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, writeVtt, 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 audio generators ( bgm | sfx | mix | beats ) were removed, see README > Audio\nsee README for options.';
7
+ usage = 'usage:\n lotion <frames|sheet|video|cues|captions> <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];
@@ -269,7 +269,7 @@ video = function(src, out, opt){
269
269
  workers = Math.max(1, Math.floor(+(opt.workers || 1)));
270
270
  t0 = Date.now();
271
271
  return open(src, opt).then(function(first){
272
- var n, done, tick, finish, k, tmp, parts;
272
+ var n, done, tick, finish, vtt, k, tmp, parts;
273
273
  o.from = +(opt.from || 0);
274
274
  n = Math.round((+(opt.to || first.duration) - o.from) * o.fps);
275
275
  done = 0;
@@ -282,8 +282,11 @@ video = function(src, out, opt){
282
282
  finish = function(){
283
283
  return console.log(out + " ( " + n + " frames, " + ((Date.now() - t0) / 1000).toFixed(0) + "s )");
284
284
  };
285
+ vtt = writeVtt(first.page, out, o.from, +(opt.to || first.duration));
285
286
  if (workers === 1) {
286
- return encode(first.page, 0, n, out, o, tick).then(function(){
287
+ return vtt.then(function(){
288
+ return encode(first.page, 0, n, out, o, tick);
289
+ }).then(function(){
287
290
  return first.close();
288
291
  }).then(finish);
289
292
  }
@@ -302,15 +305,17 @@ video = function(src, out, opt){
302
305
  out: path.join(tmp, "part-" + w + ".mp4")
303
306
  };
304
307
  });
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$);
309
- }
310
- return results$;
311
- }()).map(function(){
312
- return open(src, opt);
313
- }))).then(function(pages){
308
+ return vtt.then(function(){
309
+ return Promise.all([Promise.resolve(first)].concat((function(){
310
+ var i$, to$, results$ = [];
311
+ for (i$ = 1, to$ = k; i$ < to$; ++i$) {
312
+ results$.push(i$);
313
+ }
314
+ return results$;
315
+ }()).map(function(){
316
+ return open(src, opt);
317
+ })));
318
+ }).then(function(pages){
314
319
  return Promise.all(parts.map(function(p, w){
315
320
  return encode(pages[w].page, p.i0, p.i1, p.out, o, tick);
316
321
  }))['finally'](function(){
@@ -345,6 +350,36 @@ video = function(src, out, opt){
345
350
  }).then(finish);
346
351
  });
347
352
  };
353
+ writeVtt = function(page, out, t0, t1){
354
+ var vtt;
355
+ vtt = require('./index').vtt;
356
+ return page.evaluate(function(){
357
+ return window.CAPTIONS || null;
358
+ }).then(function(tracks){
359
+ var langs, base, i$, len$, lang, list, f, results$ = [];
360
+ if (!tracks) {
361
+ return;
362
+ }
363
+ langs = Object.keys(tracks);
364
+ base = out.replace(/\.(vtt|mp4|webm|mov)$/i, '');
365
+ for (i$ = 0, len$ = langs.length; i$ < len$; ++i$) {
366
+ lang = langs[i$];
367
+ list = tracks[lang].filter(fn$).map(fn1$);
368
+ f = langs.length > 1 && lang
369
+ ? base + "." + lang + ".vtt"
370
+ : base + ".vtt";
371
+ fs.writeFileSync(f, vtt.stringify(list));
372
+ results$.push(console.log(f));
373
+ }
374
+ return results$;
375
+ function fn$(c){
376
+ return c[1] > t0 && c[0] < t1;
377
+ }
378
+ function fn1$(c){
379
+ return [Math.max(0, c[0] - t0), Math.min(t1, c[1]) - t0].concat(c.slice(2));
380
+ }
381
+ });
382
+ };
348
383
  cues = function(src, out, opt){
349
384
  return open(src, opt).then(function(arg$){
350
385
  var page, close;
@@ -449,6 +484,14 @@ if ((ref$ = argv[0]) === 'bgm' || ref$ === 'sfx' || ref$ === 'mix' || ref$ === '
449
484
  return video(src, out, opt);
450
485
  case 'cues':
451
486
  return cues(src, out, opt);
487
+ case 'captions':
488
+ return open(src, opt).then(function(arg$){
489
+ var page, close;
490
+ page = arg$.page, close = arg$.close;
491
+ return writeVtt(page, out, 0, Infinity).then(function(){
492
+ return close();
493
+ });
494
+ });
452
495
  case 'bundle':
453
496
  if (rest[0]) {
454
497
  return bundle(src, out, rest[0], opt);
package/index.css CHANGED
@@ -66,6 +66,52 @@
66
66
  color: var(--lotion-ink);
67
67
  border: 1px solid var(--lotion-line);
68
68
  }
69
+ .lotion .lotion-cc {
70
+ width: auto;
71
+ height: 28px;
72
+ padding: 0 8px;
73
+ border-radius: 6px;
74
+ font: 600 13px/1 sans-serif;
75
+ letter-spacing: 0.04em;
76
+ background: transparent;
77
+ color: var(--lotion-ink);
78
+ border: 1px solid var(--lotion-line);
79
+ }
80
+ .lotion.lotion-captions-on .lotion-cc {
81
+ background: var(--lotion-ink);
82
+ border-color: var(--lotion-ink);
83
+ color: #fff;
84
+ }
85
+ .lotion .lotion-viewport {
86
+ container-type: inline-size;
87
+ }
88
+ .lotion .lotion-captions {
89
+ --lotion-caption-size: clamp(14px, 2.6cqw, 44px);
90
+ position: absolute;
91
+ left: 10%;
92
+ right: 10%;
93
+ bottom: 6%;
94
+ display: none;
95
+ flex-direction: column;
96
+ align-items: center;
97
+ gap: 0.2em;
98
+ pointer-events: none;
99
+ text-align: center;
100
+ font-size: var(--lotion-caption-size);
101
+ line-height: 1.35;
102
+ }
103
+ .lotion.lotion-captions-on .lotion-captions {
104
+ display: flex;
105
+ }
106
+ .lotion .lotion-caption {
107
+ max-width: 100%;
108
+ padding: 0.12em 0.5em;
109
+ border-radius: 0.2em;
110
+ background: var(--lotion-caption-bg, rgba(0,0,0,0.72));
111
+ color: var(--lotion-caption-color, #fff);
112
+ font-family: var(--lotion-caption-font, sans-serif);
113
+ white-space: pre-wrap;
114
+ }
69
115
  .lotion .lotion-time {
70
116
  font: 15px/1 sans-serif;
71
117
  color: var(--lotion-mute);
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$, libs, libCache, lib, encode, 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, vttTime, vttParseTime, vtt, 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);
@@ -179,7 +179,7 @@
179
179
  return e;
180
180
  };
181
181
  player = function(opt){
182
- var this$ = this;
182
+ var c, tracks, k, v, ref$, this$ = this;
183
183
  opt == null && (opt = {});
184
184
  this.opt = opt;
185
185
  this.root = typeof opt.root === 'string'
@@ -190,6 +190,9 @@
190
190
  this.duration = opt.duration || 0;
191
191
  this.chapters = opt.chapters || [];
192
192
  this.t = 0;
193
+ this.chapter = null;
194
+ this.caption = null;
195
+ this.handlers = {};
193
196
  this.playing = false;
194
197
  this.last = null;
195
198
  this.started = false;
@@ -197,8 +200,29 @@
197
200
  return this$.readyRes = res;
198
201
  });
199
202
  this.renderMode = /[?&]render\b/.test(location.search);
203
+ c = opt.captions;
204
+ tracks = !c
205
+ ? {}
206
+ : Array.isArray(c) ? {
207
+ '': c
208
+ } : c;
209
+ this.captionTracks = {};
210
+ for (k in tracks) {
211
+ v = tracks[k];
212
+ this.captionTracks[k] = v.slice().sort(fn$);
213
+ }
214
+ this.captionLangs = Object.keys(this.captionTracks);
215
+ this.captionLang = in$(opt.captionLang, this.captionLangs)
216
+ ? opt.captionLang
217
+ : (ref$ = this.captionLangs[0]) != null ? ref$ : null;
218
+ this.captionsOn = this.renderMode
219
+ ? !!(opt.burnCaptions || /[?&]captions\b/.test(location.search))
220
+ : !opt.captionsBurned;
200
221
  this.init();
201
222
  return this;
223
+ function fn$(a, b){
224
+ return a[0] - b[0];
225
+ }
202
226
  };
203
227
  player.prototype = (ref$ = Object.create(Object.prototype), ref$.constructor = player, ref$.init = function(){
204
228
  var r, ref$, html, q, this$ = this;
@@ -218,7 +242,7 @@
218
242
  html = typeof this.opt.loading === 'string' ? this.opt.loading : '<div class="lotion-spinner"></div>';
219
243
  this.loading = mk('lotion-loading-screen', html, this.viewport);
220
244
  }
221
- this.bar = mk('lotion-bar', '<div class="lotion-btn lotion-play"></div>\n<div class="lotion-track"><div class="lotion-rail"></div><div class="lotion-fill"></div>\n<div class="lotion-chapters"></div><div class="lotion-knob"></div></div>\n<div class="lotion-time"></div>\n<div class="lotion-btn lotion-fs" title="全螢幕"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"\nstroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4"/></svg></div>', r);
245
+ this.bar = mk('lotion-bar', '<div class="lotion-btn lotion-play"></div>\n<div class="lotion-track"><div class="lotion-rail"></div><div class="lotion-fill"></div>\n<div class="lotion-chapters"></div><div class="lotion-knob"></div></div>\n<div class="lotion-time"></div>\n<div class="lotion-btn lotion-cc" title="字幕 ( c )">CC</div>\n<div class="lotion-btn lotion-fs" title="全螢幕"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"\nstroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4"/></svg></div>', r);
222
246
  q = function(n){
223
247
  return this$.bar.querySelector(".lotion-" + n);
224
248
  };
@@ -229,15 +253,21 @@
229
253
  time: q('time'),
230
254
  track: q('track'),
231
255
  chapters: q('chapters'),
232
- fs: q('fs')
256
+ fs: q('fs'),
257
+ cc: q('cc')
233
258
  };
234
259
  this.chapters.map(function(arg$){
235
- var t, name, e;
236
- t = arg$[0], name = arg$[1];
260
+ var t, name, meta, e;
261
+ t = arg$[0], name = arg$[1], meta = arg$[2];
237
262
  e = mk('lotion-ch', '', this$.el.chapters);
238
- e.title = name;
263
+ e.title = name + (meta && meta.description ? "\n" + meta.description : '');
239
264
  return e.style.left = 100 * t / this$.duration + "%";
240
265
  });
266
+ this.el.captions = mk('lotion-captions', '', this.viewport);
267
+ if (!this.captionLangs.length) {
268
+ this.el.cc.remove();
269
+ }
270
+ this.root.classList.toggle('lotion-captions-on', this.captionsOn);
241
271
  this.bind();
242
272
  if (this.renderMode) {
243
273
  r.classList.add('lotion-render');
@@ -267,6 +297,9 @@
267
297
  if (this.opt.cues) {
268
298
  window.cues = this.opt.cues;
269
299
  }
300
+ if (this.captionLangs.length) {
301
+ window.CAPTIONS = this.captionTracks;
302
+ }
270
303
  }
271
304
  this.readyRes(this);
272
305
  m = /[?&]t=([\d.]+)/.exec(location.search);
@@ -291,6 +324,10 @@
291
324
  e.stopPropagation();
292
325
  return this$.fullscreen();
293
326
  });
327
+ this.el.cc.addEventListener('click', function(e){
328
+ e.stopPropagation();
329
+ return this$.showCaptions(!this$.captionsOn);
330
+ });
294
331
  this.viewport.addEventListener('click', function(){
295
332
  this$.root.focus();
296
333
  return this$.toggle();
@@ -324,6 +361,8 @@
324
361
  return this$.toggle();
325
362
  } else if (e.key === 'f') {
326
363
  return this$.fullscreen();
364
+ } else if (e.key === 'c' && this$.captionLangs.length) {
365
+ return this$.showCaptions(!this$.captionsOn);
327
366
  } else if (e.key === 'ArrowRight') {
328
367
  return this$.seek((ref$ = starts.find(function(s){
329
368
  return s > this$.t + 0.05;
@@ -351,7 +390,7 @@
351
390
  }, ref$.fmt = function(t){
352
391
  return Math.floor(t / 60) + ":" + ("0" + Math.floor(t % 60)).slice(-2);
353
392
  }, ref$.seek = function(t){
354
- var p, ch;
393
+ var p, ci, i$, ref$, len$, i, c, ch;
355
394
  if (!this.started || this.encoding) {
356
395
  return;
357
396
  }
@@ -360,11 +399,109 @@
360
399
  p = 100 * t / this.duration + "%";
361
400
  this.el.fill.style.width = p;
362
401
  this.el.knob.style.left = p;
363
- ch = this.chapters.filter(function(c){
364
- return c[0] <= t;
365
- }).pop();
402
+ ci = -1;
403
+ for (i$ = 0, len$ = (ref$ = this.chapters).length; i$ < len$; ++i$) {
404
+ i = i$;
405
+ c = ref$[i$];
406
+ if (c[0] <= t) {
407
+ ci = i;
408
+ }
409
+ }
410
+ ch = this.chapters[ci];
366
411
  this.el.time.textContent = (ch ? ch[1] + " · " : '') + (this.fmt(t) + " / " + this.fmt(this.duration));
367
- return this.root.classList.toggle('lotion-playing', this.playing);
412
+ this.root.classList.toggle('lotion-playing', this.playing);
413
+ if ((ch
414
+ ? ci
415
+ : -1) !== (this.chapter
416
+ ? this.chapter.index
417
+ : -1)) {
418
+ this.chapter = ch ? {
419
+ index: ci,
420
+ t: ch[0],
421
+ name: ch[1],
422
+ meta: ch[2] || null
423
+ } : null;
424
+ this.fire('chapter', this.chapter);
425
+ }
426
+ this.updateCaption();
427
+ return this.fire('time', t);
428
+ }, ref$.updateCaption = function(){
429
+ var t, list, act, cap, key, i$, ref$, len$, line;
430
+ t = this.t;
431
+ list = (this.captionLang != null && this.captionTracks[this.captionLang]) || [];
432
+ act = list.filter(function(c){
433
+ return c[0] <= t && t < c[1];
434
+ });
435
+ cap = !act.length
436
+ ? null
437
+ : {
438
+ t0: act[0][0],
439
+ t1: act[act.length - 1][1],
440
+ text: act.map(function(c){
441
+ return c[2];
442
+ }).join('\n'),
443
+ meta: act[0][3] || null,
444
+ lang: this.captionLang
445
+ };
446
+ key = function(c){
447
+ if (c) {
448
+ return c.lang + "|" + c.t0 + "|" + c.text;
449
+ } else {
450
+ return '';
451
+ }
452
+ };
453
+ if (key(cap) === key(this.caption)) {
454
+ return;
455
+ }
456
+ this.caption = cap;
457
+ this.el.captions.innerHTML = '';
458
+ if (cap) {
459
+ for (i$ = 0, len$ = (ref$ = cap.text.split('\n')).length; i$ < len$; ++i$) {
460
+ line = ref$[i$];
461
+ mk('lotion-caption', '', this.el.captions).textContent = line;
462
+ }
463
+ }
464
+ return this.fire('caption', cap);
465
+ }, ref$.showCaptions = function(v){
466
+ v == null && (v = true);
467
+ this.captionsOn = !!v;
468
+ this.root.classList.toggle('lotion-captions-on', this.captionsOn);
469
+ this.fire('captions', {
470
+ on: this.captionsOn,
471
+ lang: this.captionLang
472
+ });
473
+ return this;
474
+ }, ref$.setCaptionLang = function(lang){
475
+ if (!in$(lang, this.captionLangs)) {
476
+ return this;
477
+ }
478
+ this.captionLang = lang;
479
+ this.updateCaption();
480
+ this.fire('captions', {
481
+ on: this.captionsOn,
482
+ lang: lang
483
+ });
484
+ return this;
485
+ }, ref$['on'] = function(name, cb){
486
+ var ref$, ref1$;
487
+ ((ref1$ = (ref$ = this.handlers)[name]) != null
488
+ ? ref1$
489
+ : ref$[name] = []).push(cb);
490
+ return this;
491
+ }, ref$['off'] = function(name, cb){
492
+ if (this.handlers[name]) {
493
+ this.handlers[name] = this.handlers[name].filter(function(h){
494
+ return h !== cb;
495
+ });
496
+ }
497
+ return this;
498
+ }, ref$.fire = function(name, v){
499
+ var i$, ref$, len$, h, results$ = [];
500
+ for (i$ = 0, len$ = (ref$ = (this.handlers[name] || []).slice()).length; i$ < len$; ++i$) {
501
+ h = ref$[i$];
502
+ results$.push(h(v));
503
+ }
504
+ return results$;
368
505
  }, ref$.tick = function(now){
369
506
  var this$ = this;
370
507
  if (!this.playing) {
@@ -583,7 +720,79 @@
583
720
  throw e;
584
721
  });
585
722
  };
723
+ vttTime = function(t){
724
+ var ms, h, m, s, pad;
725
+ ms = Math.round(t * 1000);
726
+ h = Math.floor(ms / 3600000);
727
+ m = Math.floor(ms / 60000) % 60;
728
+ s = Math.floor(ms / 1000) % 60;
729
+ pad = function(v, n){
730
+ n == null && (n = 2);
731
+ return (v + "").padStart(n, '0');
732
+ };
733
+ return pad(h) + ":" + pad(m) + ":" + pad(s) + "." + pad(ms % 1000, 3);
734
+ };
735
+ vttParseTime = function(s){
736
+ var p;
737
+ p = s.trim().split(':');
738
+ return p.reduce(function(a, v){
739
+ return a * 60 + parseFloat(v);
740
+ }, 0);
741
+ };
742
+ vtt = {
743
+ parse: function(text){
744
+ var ret, blocks, i$, len$, b, lines, i, ref$, a, z, body, meta, m, cue;
745
+ ret = [];
746
+ blocks = text.replace(/\r\n?/g, '\n').split(/\n{2,}/);
747
+ for (i$ = 0, len$ = blocks.length; i$ < len$; ++i$) {
748
+ b = blocks[i$];
749
+ lines = b.split('\n').filter(fn$);
750
+ i = lines.findIndex(fn1$);
751
+ if (i < 0) {
752
+ continue;
753
+ }
754
+ ref$ = lines[i].split('-->'), a = ref$[0], z = ref$[1];
755
+ body = lines.slice(i + 1).join('\n');
756
+ meta = null;
757
+ if (m = /^<v(?:\.[^\s>]*)?\s+([^>]+)>/.exec(body)) {
758
+ meta = {
759
+ speaker: m[1].trim()
760
+ };
761
+ }
762
+ body = body.replace(/<\/?[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
763
+ cue = [vttParseTime(a), vttParseTime(z.trim().split(/\s+/)[0]), body];
764
+ if (meta) {
765
+ cue.push(meta);
766
+ }
767
+ ret.push(cue);
768
+ }
769
+ return ret;
770
+ function fn$(l){
771
+ return l.trim();
772
+ }
773
+ function fn1$(l){
774
+ return /-->/.test(l);
775
+ }
776
+ },
777
+ stringify: function(captions){
778
+ var esc, cues;
779
+ esc = function(s){
780
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
781
+ };
782
+ cues = captions.map(function(arg$){
783
+ var t0, t1, text, meta, body;
784
+ t0 = arg$[0], t1 = arg$[1], text = arg$[2], meta = arg$[3];
785
+ body = esc(text);
786
+ if (meta && meta.speaker) {
787
+ body = "<v " + esc(meta.speaker) + ">" + body;
788
+ }
789
+ return vttTime(t0) + " --> " + vttTime(t1) + "\n" + body;
790
+ });
791
+ return "WEBVTT\n\n" + cues.join('\n\n') + '\n';
792
+ }
793
+ };
586
794
  lotion = {
795
+ vtt: vtt,
587
796
  clamp: clamp,
588
797
  lerp: lerp,
589
798
  ss: ss,
@@ -610,6 +819,11 @@
610
819
  } else if (typeof window != 'undefined' && window !== null) {
611
820
  window.lotion = lotion;
612
821
  }
822
+ function in$(x, xs){
823
+ var i = -1, l = xs.length >>> 0;
824
+ while (++i < l) if (x === xs[i]) return true;
825
+ return false;
826
+ }
613
827
  function import$(obj, src){
614
828
  var own = {}.hasOwnProperty;
615
829
  for (var key in src) if (own.call(src, key)) obj[key] = src[key];