pixi-effects 0.28.1 → 0.28.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +6 -6
  3. package/ai/chat-template.html +3 -3
  4. package/ai/tools/view.mjs +102 -14
  5. package/dist/Composition-H7UNR5F5.js +7 -0
  6. package/dist/{Composition-HFEVIYHN.js.map → Composition-H7UNR5F5.js.map} +1 -1
  7. package/dist/Composition-K6B3APM5.cjs +16 -0
  8. package/dist/{Composition-NI74UJT3.cjs.map → Composition-K6B3APM5.cjs.map} +1 -1
  9. package/dist/{chunk-WEJAQAFX.js → chunk-5MZWXVTT.js} +3 -3
  10. package/dist/{chunk-WEJAQAFX.js.map → chunk-5MZWXVTT.js.map} +1 -1
  11. package/dist/{chunk-AK54FIWM.js → chunk-7VN2KI3U.js} +6 -3
  12. package/dist/chunk-7VN2KI3U.js.map +1 -0
  13. package/dist/{chunk-SBOVUZZY.cjs → chunk-KBH33IQX.cjs} +61 -61
  14. package/dist/{chunk-SBOVUZZY.cjs.map → chunk-KBH33IQX.cjs.map} +1 -1
  15. package/dist/{chunk-MZCDBRTY.cjs → chunk-MJW7KILM.cjs} +6 -3
  16. package/dist/chunk-MJW7KILM.cjs.map +1 -0
  17. package/dist/index.cjs +35 -33
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.js +7 -5
  20. package/dist/index.js.map +1 -1
  21. package/dist/loader.css +1 -1
  22. package/dist/three.cjs +5 -5
  23. package/dist/three.js +1 -1
  24. package/llms-full.txt +5 -4
  25. package/package.json +1 -1
  26. package/skills/pixi-effects/SKILL.md +2 -2
  27. package/skills/pixi-effects/reference/direction.md +2 -2
  28. package/skills/pixi-effects/reference/pitfalls.md +1 -0
  29. package/skills/pixi-effects/template.html +6 -6
  30. package/dist/Composition-HFEVIYHN.js +0 -7
  31. package/dist/Composition-NI74UJT3.cjs +0 -16
  32. package/dist/chunk-AK54FIWM.js.map +0 -1
  33. package/dist/chunk-MZCDBRTY.cjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.28.2
4
+
5
+ **Added**
6
+
7
+ - **`pixi-effects view` tells you the work changed, and never switches by itself.** It watches the page's folder (not `node_modules`, `out`, `reviews`, `check-out`, hidden files); when something changes it shows "Updated hh:mm:ss — reload (R)" and nothing else. Reload (the button or R) loads the page again and returns to the frame you were on; the switch "auto" (off by default, remembered) reloads only while the movie is paused. The skill now says to start the viewer right after copying the template (before the brief is written) and to give the human the address in the first message, so the first address goes out within two minutes and they watch the film grow.
8
+
9
+ **Fixed**
10
+
11
+ - **`pixi-effects view` centres the page, and the timeline legend no longer gets cut off.** A page that keeps its picture at the left edge (the template does) sat against the left of a wide viewer; the iframe is now as wide as the page's own content and centred. The legend under the timeline (text, shape, image, …) is wrapped onto more lines when the chart is narrow instead of running past the right edge (`timelineSvg()` and the `timeline.html` that `check` writes).
12
+ - **A text layer whose `style` has an undefined value is no longer blanked.** `style: { fontSize: undefined }` (a variable that was never set) erased the default size and the text was not drawn; the check said only "has no size". The undefined keys are now skipped (Pixi keeps its default) and one warning names them: `style.fontSize is undefined … omit the key instead`.
13
+
14
+ **Docs**
15
+
16
+ - Pitfall 103: load a web font before `measureText` / `splitText` / `animateText` (measured: a Latin headline is 21 % wider in the fallback font). `direction.md` asks the reviewer to write the three defects into `reviews/notes.md`, to measure the smallest text, and to look at both ends of every cut. Both came from a fresh agent making a company film from the published skill alone.
17
+
3
18
  ## 0.28.1
4
19
 
5
20
  **Changed**
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.28.1`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
3
+ > **Status**: experimental — current release `0.28.2`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
4
4
 
5
5
  **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 52 portfolio pieces written as plain data by AI models · **[Examples →](https://yjmtmtk.github.io/pixi-effects/examples/)** · 15 numbered examples, a music lab + an in-browser playground.
6
6
 
@@ -49,8 +49,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
49
49
  "imports": {
50
50
  "pixi.js": "https://esm.sh/pixi.js@8.22.0?bundle-deps",
51
51
  "mediabunny": "https://esm.sh/mediabunny",
52
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.1/dist/index.js",
53
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.1/dist/Controller.js"
52
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.2/dist/index.js",
53
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.2/dist/Controller.js"
54
54
  }
55
55
  }
56
56
  </script>
@@ -61,7 +61,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
61
61
  </script>
62
62
  ```
63
63
 
64
- Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.28.1/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
64
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.28.2/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
65
65
 
66
66
  > **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
67
67
  >
@@ -77,7 +77,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
77
77
  "imports": {
78
78
  "...": "(everything from the importmap above)",
79
79
  "three": "https://esm.sh/three@0.178.0",
80
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.1/dist/three.js"
80
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.2/dist/three.js"
81
81
  }
82
82
  }
83
83
  </script>
@@ -174,7 +174,7 @@ This library is designed to be written by AI: a video is plain data, and every m
174
174
  - [`skills/pixi-effects/SKILL.md`](./skills/pixi-effects/SKILL.md) — a [skill](https://docs.claude.com/en/docs/claude-code/skills) (workflow, rules, verification loop). Install it with `npx skills add yjmtmtk/pixi-effects` (or copy the `skills/pixi-effects/` folder to `~/.claude/skills/pixi-effects/` or your project's `.claude/skills/`) and your agent loads it by itself when you ask for a video.
175
175
  - [`skills/pixi-effects/reference/cheatsheet.md`](./skills/pixi-effects/reference/cheatsheet.md), [`recipes.md`](./skills/pixi-effects/reference/recipes.md) (tested), [`pitfalls.md`](./skills/pixi-effects/reference/pitfalls.md), and a starter [`skills/pixi-effects/template.html`](./skills/pixi-effects/template.html).
176
176
 
177
- - **Look at a page with its timeline, and scrub one with the other:** `npx pixi-effects view my-video.html` (or `node ai/tools/view.mjs …`) opens your browser on a viewer: the page on top, the timeline of every layer under it with a playhead that follows the movie. Click or drag the timeline to seek, click a layer's name to jump to where it starts, Space plays, ← / → step a frame, + / − (or Ctrl/⌘ + wheel) zoom, the names stay in place. For the human who wants to see what the AI made.
177
+ - **Look at a page with its timeline, and scrub one with the other:** `npx pixi-effects view my-video.html` (it also watches the folder: when the work changes it shows "Updated hh:mm:ss — reload (R)" and never switches by itself; reload comes back to the same frame) (or `node ai/tools/view.mjs …`) opens your browser on a viewer: the page on top, the timeline of every layer under it with a playhead that follows the movie. Click or drag the timeline to seek, click a layer's name to jump to where it starts, Space plays, ← / → step a frame, + / − (or Ctrl/⌘ + wheel) zoom, the names stay in place. For the human who wants to see what the AI made.
178
178
  - **Render a page to a video file, headless:** `npx pixi-effects render my-video.html -o my-video.mp4` (or `node ai/tools/render.mjs …`; Node ≥ 22 and Chrome installed, no dependencies). The container follows the extension (`mp4 webm mov mkv`); `--quality very-low…very-high`, `--query lang=ja` (added to the page URL), `--fail-on-warn` (exit 1 when the page logged a warning), `--quiet`. It waits for `window.__ready`, runs `movie.render()` and streams the file to disk; exit 0 = written, 1 = the page or the render failed (no file). For scripts, CI and batches; `pixi-effects-check` below is the review.
179
179
  - **A talk as a PDF:** `npx pixi-effects render my-talk.html -o my-talk.pdf` (one page per page of a `deck()` / `stops` movie; `--all-stops` makes a page of every step). The review tool writes `stops.png` for such a page, one picture per stop.
180
180
  - **One-command review for the AI that wrote the page:** `npx pixi-effects check my-video.html` (or `node ai/tools/check.mjs my-video.html`; Node ≥ 22 and Chrome installed, no dependencies). It opens the page in its own headless Chrome and reports warnings, layout (`movie.inspect` over the whole timeline), the soundtrack (`movie.inspectAudio`) and a real export decoded again, and writes a contact sheet PNG and a `timeline.html` (every layer as a bar on a time axis) to look at. Exit code 0 / 1.
@@ -59,9 +59,9 @@
59
59
  "imports": {
60
60
  "pixi.js": "https://cdn.jsdelivr.net/npm/pixi.js@8.22.0/dist/pixi.min.mjs",
61
61
  "mediabunny": "https://cdn.jsdelivr.net/npm/mediabunny@1.61.3/+esm",
62
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.1/dist/index.js",
63
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.1/dist/Controller.js",
64
- "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.1/dist/Presenter.js"
62
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.2/dist/index.js",
63
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.2/dist/Controller.js",
64
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.2/dist/Presenter.js"
65
65
  }
66
66
  }
67
67
  </script>
package/ai/tools/view.mjs CHANGED
@@ -9,6 +9,10 @@
9
9
  * Click or drag on the timeline to seek the movie; click a layer's name to jump to where it starts; Space plays / pauses,
10
10
  * ← / → step a frame (Shift: a second), Home / End jump to the ends. Stop it with Ctrl-C.
11
11
  *
12
+ * It watches the page's folder (not `node_modules`, `out`, `reviews`, `check-out`, hidden files). When the work changes it shows "Updated hh:mm:ss — reload (R)"
13
+ * and NOTHING else: the picture you are looking at never switches by itself. Reload (the button, or R) loads the page again and goes back to the frame you were on.
14
+ * The switch "auto" (off by default, remembered) reloads for you, but only while the movie is paused and you are not dragging the timeline.
15
+ *
12
16
  * Needs: Node >= 18. A browser (the default one is opened; --no-open prints the address instead). The page must follow
13
17
  * skills/pixi-effects/template.html (`window.movie`, `window.__ready = true`) and use a pixi-effects that has `movie.timelineSvg()` (0.7+).
14
18
  * Options: --port N (default: a free one) · --no-open · --query "a=1" (added to the page URL) · --root DIR (default: the nearest folder above the page with dist/)
@@ -38,6 +42,26 @@ export function parseViewArgs(argv) {
38
42
  return o;
39
43
  }
40
44
 
45
+ const IGNORED = new Set(['node_modules', 'out', 'reviews', 'check-out', '.git', 'dist']);
46
+
47
+ /** A short fingerprint of the work in a folder (file count, newest edit, total size): it changes when something is edited or added. Outputs, installs and hidden files are left out. */
48
+ export function folderSignature(dir, { ignore = IGNORED, maxFiles = 4000, maxDepth = 5 } = {}) {
49
+ let count = 0, newest = 0, bytes = 0;
50
+ const walk = (d, depth) => {
51
+ let entries;
52
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
53
+ for (const e of entries) {
54
+ if (count >= maxFiles) return;
55
+ if (e.name.startsWith('.')) continue;
56
+ const full = path.join(d, e.name);
57
+ if (e.isDirectory()) { if (!ignore.has(e.name) && depth < maxDepth) walk(full, depth + 1); continue; }
58
+ try { const st = fs.statSync(full); count++; newest = Math.max(newest, st.mtimeMs); bytes += st.size; } catch { /* removed while we looked */ }
59
+ }
60
+ };
61
+ walk(dir, 0);
62
+ return count + ':' + Math.round(newest) + ':' + bytes;
63
+ }
64
+
41
65
  const escHtml = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
42
66
 
43
67
  /** The viewer page: the composition in an iframe, its timeline under it (zoomable), a playhead, click / drag / keys to seek. */
@@ -53,6 +77,8 @@ export function viewerHtml({ pageUrl, title }) {
53
77
  header b { font-size: 14px; font-weight: 600; }
54
78
  #time { font-family: ui-monospace, Menlo, monospace; color: var(--dim); }
55
79
  #status { color: var(--dim); }
80
+ #update button { background: var(--head); color: #fff; border-color: var(--head); font-weight: 600; }
81
+ #autolabel { color: var(--dim); user-select: none; }
56
82
  .zoom { display: inline-flex; align-items: center; gap: 4px; margin-left: auto; }
57
83
  #zlabel { min-width: 44px; text-align: center; font-family: ui-monospace, Menlo, monospace; color: var(--dim); }
58
84
  button { font: inherit; background: transparent; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 3px 12px; cursor: pointer; }
@@ -72,7 +98,7 @@ export function viewerHtml({ pageUrl, title }) {
72
98
  #head line { stroke: var(--head); stroke-width: 2; } #head path { fill: var(--head); }
73
99
  </style></head>
74
100
  <body>
75
- <header><b>${escHtml(title)}</b><button id="play" type="button" disabled>▶</button><span id="time">0.00 / 0.00 s · frame 0</span><span id="status">loading the page…</span>
101
+ <header><b>${escHtml(title)}</b><button id="play" type="button" disabled>▶</button><span id="time">0.00 / 0.00 s · frame 0</span><span id="status">loading the page…</span><span id="update" hidden><button id="reload" type="button" title="load the page again, on the frame you are on (R)">Updated — reload (R)</button></span><label id="autolabel" title="reload by itself, but only while the movie is paused"><input type="checkbox" id="auto"> auto</label>
76
102
  <span class="zoom"><button id="zout" type="button" title="zoom out (-)" disabled>−</button><span id="zlabel">fit</span><button id="zin" type="button" title="zoom in (+, or Ctrl/⌘ + wheel)" disabled>+</button><button id="zfit" type="button" title="fit the whole movie (0)" disabled>fit</button></span></header>
77
103
  <div id="stage"><iframe id="page" src="${escHtml(pageUrl)}" allow="autoplay; fullscreen"></iframe></div>
78
104
  <div id="chart"><div id="names"></div><div id="plot"></div></div>
@@ -87,31 +113,75 @@ export function viewerHtml({ pageUrl, title }) {
87
113
  const sleep = ms => new Promise(r => setTimeout(r, ms));
88
114
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
89
115
  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
90
- let movie = null, svg = null, head = null, geom = null, zoom = 1, scrubbing = false;
116
+ let movie = null, svg = null, head = null, geom = null, zoom = 1, scrubbing = false, bound = false;
117
+ const $update = document.getElementById('update'), $reload = document.getElementById('reload'), $auto = document.getElementById('auto');
118
+ let seen = null, pending = false;
119
+ try { $auto.checked = localStorage.getItem('pe-view-auto') === '1'; } catch { /* storage may be blocked */ }
91
120
 
92
- (async () => {
121
+ /** Wait for the page inside to be a ready movie, then (re)build the timeline. \`restore\` = { frame, playing }: go back where the viewer was. */
122
+ async function attach(restore) {
123
+ movie = null; svg = null; head = null; geom = null;
93
124
  for (let i = 0; i < 600 && !movie; i++) {
94
125
  try { const w = frame.contentWindow; if (w && w.__ready === true && w.movie) movie = w.movie; } catch { /* still loading */ }
95
126
  if (!movie) await sleep(200);
96
127
  }
97
- if (!movie) { $status.textContent = 'the page did not become ready (window.__ready === true and window.movie are needed)'; return; }
128
+ if (!movie) {
129
+ let logs = '';
130
+ try { logs = (frame.contentWindow.__logs || []).slice(-3).join(' | '); } catch { /* unreadable */ }
131
+ $status.textContent = 'the page did not become ready (window.__ready === true and window.movie are needed)' + (logs ? ' — ' + logs : '');
132
+ return;
133
+ }
98
134
  if (typeof movie.timelineSvg !== 'function') { $status.textContent = 'this page loads a pixi-effects without movie.timelineSvg() (0.7+ needed)'; return; }
99
135
  layout();
100
136
  mount();
101
- })();
137
+ if (restore) { await seekFrame(restore.frame); if (restore.playing) movie.play(); }
138
+ }
139
+ attach(null);
140
+
141
+ /** Load the page again and come back to the same frame (and playing, if it was). Only ever on a request. */
142
+ async function reload() {
143
+ const restore = movie ? { frame: movie.currentFrame, playing: movie.isPlaying } : null;
144
+ pending = false; $update.hidden = true;
145
+ movie = null; svg = null; head = null; geom = null; plot.innerHTML = ''; names.innerHTML = '';
146
+ const loaded = new Promise(r => frame.addEventListener('load', r, { once: true }));
147
+ try { frame.contentWindow.location.reload(); } catch { frame.src = frame.src; }
148
+ await loaded;
149
+ await attach(restore);
150
+ }
151
+ $reload.addEventListener('click', reload);
152
+ $auto.addEventListener('change', () => { try { localStorage.setItem('pe-view-auto', $auto.checked ? '1' : '0'); } catch { /* storage may be blocked */ } });
153
+ // auto only while the movie is paused and nobody is dragging: otherwise the notice waits
154
+ const maybeAuto = () => { if (pending && $auto.checked && movie && !movie.isPlaying && !scrubbing) reload(); };
102
155
 
103
- // The page is shown whole: as tall as its picture, scaled down (never up) so the timeline stays in view under it.
156
+ // does the work still look the same? (the server's number goes up when a file in the folder changes)
157
+ async function poll() {
158
+ try {
159
+ const v = await (await fetch('/__viewer/version', { cache: 'no-store' })).json();
160
+ if (seen === null) seen = v.version;
161
+ else if (v.version !== seen) {
162
+ seen = v.version; pending = true;
163
+ const d = new Date(v.at), p2 = n => String(n).padStart(2, '0');
164
+ $reload.textContent = 'Updated ' + p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds()) + ' — reload (R)';
165
+ $update.hidden = false;
166
+ }
167
+ } catch { /* the viewer is stopped */ }
168
+ }
169
+ poll(); setInterval(poll, 800);
170
+
171
+ // The page is shown whole and in the MIDDLE: as tall as its picture, scaled down (never up) so the timeline stays in view under it. A page that
172
+ // keeps its picture at the left edge (the template does) is still centred: the iframe is as wide as the page's own content, then centred.
104
173
  function layout() {
105
174
  const stageW = stage.clientWidth || innerWidth, headH = document.querySelector('header').offsetHeight;
106
- let h = 560;
107
- const fit = k => { frame.style.width = (stageW / k) + 'px'; frame.style.transform = 'scale(' + k + ')'; };
108
- fit(1);
175
+ let h = 560, cw = stageW;
176
+ frame.style.width = stageW + 'px'; frame.style.transform = 'none'; frame.style.marginLeft = '0px';
109
177
  try {
110
178
  const c = frame.contentDocument.querySelector('canvas');
111
- if (c) h = Math.round(c.getBoundingClientRect().height + 40);
179
+ if (c) { const r = c.getBoundingClientRect(); h = Math.round(r.height + 40); cw = Math.min(stageW, Math.round(r.right + r.left)); }
112
180
  } catch { /* a page we cannot read */ }
113
181
  const k = Math.min(1, Math.max(200, innerHeight - headH - 240) / h);
114
- fit(k);
182
+ frame.style.width = cw + 'px';
183
+ frame.style.transform = 'scale(' + k + ')';
184
+ frame.style.marginLeft = Math.max(0, Math.round((stageW - cw * k) / 2)) + 'px';
115
185
  frame.style.height = h + 'px';
116
186
  stage.style.height = Math.round(h * k) + 'px';
117
187
  }
@@ -180,10 +250,12 @@ export function viewerHtml({ pageUrl, title }) {
180
250
  const data = movie.timelineData();
181
251
  names.innerHTML = '<div class="ruler" style="height:34px"></div>' + data.rows.map(r =>
182
252
  '<div class="name" data-start="' + r.start + '" style="padding-left:' + (8 + r.depth * 14) + 'px" title="' + esc(r.path + ' · ' + r.type) + '">' + esc(r.name) + '</div>').join('');
183
- names.addEventListener('click', e => { const n = e.target.closest('.name'); if (n) seekTime(parseFloat(n.dataset.start) || 0); });
184
253
  render(null);
185
254
  $status.textContent = 'click or drag the timeline · Space plays · ← → step a frame (Shift: a second) · + / − zoom (Ctrl/⌘ + wheel) · click a layer name to jump to its start';
186
255
  $play.disabled = false; $zfit.disabled = true;
256
+ if (bound) return; // the controls below are bound once; a reload only rebuilds the timeline
257
+ bound = true;
258
+ names.addEventListener('click', e => { const n = e.target.closest('.name'); if (n && movie) seekTime(parseFloat(n.dataset.start) || 0); });
187
259
 
188
260
  $play.addEventListener('click', toggle);
189
261
  $zin.addEventListener('click', () => setZoom(zoom * 1.6, playheadAnchor()));
@@ -198,6 +270,8 @@ export function viewerHtml({ pageUrl, title }) {
198
270
  }, { passive: false });
199
271
  addEventListener('keydown', e => {
200
272
  if (e.target && /input|textarea|select/i.test(e.target.tagName)) return;
273
+ if ((e.key === 'r' || e.key === 'R') && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); reload(); return; }
274
+ if (!movie) return;
201
275
  const fps = movie.frameRate, f = movie.currentFrame;
202
276
  if (e.key === ' ') { e.preventDefault(); toggle(); }
203
277
  else if (e.key === 'ArrowLeft') { e.preventDefault(); seekFrame(f - (e.shiftKey ? fps : 1)); }
@@ -210,9 +284,11 @@ export function viewerHtml({ pageUrl, title }) {
210
284
  });
211
285
  requestAnimationFrame(tick);
212
286
  }
213
- const toggle = () => { if (movie.isPlaying) movie.pause(); else movie.play(); };
287
+ const toggle = () => { if (!movie) return; if (movie.isPlaying) movie.pause(); else movie.play(); };
214
288
 
215
289
  function tick() {
290
+ if (!movie || !head || !geom) { maybeAuto(); requestAnimationFrame(tick); return; }
291
+ maybeAuto();
216
292
  const t = nowT(), x = tToX(t);
217
293
  head.setAttribute('transform', 'translate(' + x.toFixed(2) + ',0)');
218
294
  $time.textContent = t.toFixed(2) + ' / ' + geom.dur.toFixed(2) + ' s · frame ' + movie.currentFrame;
@@ -244,11 +320,23 @@ export async function startViewer(opts) {
244
320
  const pageUrl = '/' + rel + (opts.query ? '?' + opts.query : '');
245
321
  const title = path.basename(pagePath).replace(/\.[^.]+$/, '');
246
322
  const html = viewerHtml({ pageUrl, title });
323
+ // The work's version: a number that goes up when a file in the page's folder changes. The viewer only SHOWS it; it never reloads by itself.
324
+ const dir = path.dirname(pagePath);
325
+ let version = 1, changedAt = Date.now(), signature = folderSignature(dir);
326
+ const timer = setInterval(() => { const now = folderSignature(dir); if (now !== signature) { signature = now; version++; changedAt = Date.now(); } }, opts.watchEvery ?? 400);
327
+ timer.unref();
247
328
  const { server, port } = await serve(root, (req, res) => {
248
- if (req.method !== 'GET' || !req.url.startsWith(VIEWER)) return false;
329
+ if (req.method !== 'GET') return false;
330
+ const pathname = req.url.split('?')[0];
331
+ if (pathname === VIEWER + '/version') {
332
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ version, at: changedAt }));
333
+ return true;
334
+ }
335
+ if (pathname !== VIEWER) return false;
249
336
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }).end(html);
250
337
  return true;
251
338
  }, opts.port);
339
+ server.on('close', () => clearInterval(timer));
252
340
  return { server, url: `http://127.0.0.1:${port}${VIEWER}` };
253
341
  }
254
342
 
@@ -0,0 +1,7 @@
1
+ export { CompositionSequence } from './chunk-5MZWXVTT.js';
2
+ import './chunk-7VN2KI3U.js';
3
+ import './chunk-TYWFKCPY.js';
4
+ import './chunk-45QKLD6M.js';
5
+ import './chunk-BXOCEZ4U.js';
6
+ //# sourceMappingURL=Composition-H7UNR5F5.js.map
7
+ //# sourceMappingURL=Composition-H7UNR5F5.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-HFEVIYHN.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-H7UNR5F5.js"}
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ var chunkKBH33IQX_cjs = require('./chunk-KBH33IQX.cjs');
4
+ require('./chunk-MJW7KILM.cjs');
5
+ require('./chunk-P4GY3Z4G.cjs');
6
+ require('./chunk-ZMAD3KJU.cjs');
7
+ require('./chunk-TWZNOUM7.cjs');
8
+
9
+
10
+
11
+ Object.defineProperty(exports, "CompositionSequence", {
12
+ enumerable: true,
13
+ get: function () { return chunkKBH33IQX_cjs.CompositionSequence; }
14
+ });
15
+ //# sourceMappingURL=Composition-K6B3APM5.cjs.map
16
+ //# sourceMappingURL=Composition-K6B3APM5.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-NI74UJT3.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-K6B3APM5.cjs"}
@@ -1,4 +1,4 @@
1
- import { MAX_CASTERS, Sequence, timeRemapOf, contentLength, remapOf, buildSequenceTree, CameraSequence, LightSequence, layerInitialZ, createTimeline, bindClock, clockTable, NullSequence, lightLevel, homeCamera, cameraBasis, packLights, projectPoint, NEAR, blurRadius, MANY_BLURRED, focusProblems, MAX_LIGHTS, projectLayer, layerToWorld, FALLOFFS, LIGHT_PROPS, LIGHT_KINDS, MIN_BLUR, MAX_BLUR, DEG, SHADOW_TAPS } from './chunk-AK54FIWM.js';
1
+ import { MAX_CASTERS, Sequence, timeRemapOf, contentLength, remapOf, buildSequenceTree, CameraSequence, LightSequence, layerInitialZ, createTimeline, bindClock, clockTable, NullSequence, lightLevel, homeCamera, cameraBasis, packLights, projectPoint, NEAR, blurRadius, MANY_BLURRED, focusProblems, MAX_LIGHTS, projectLayer, layerToWorld, FALLOFFS, LIGHT_PROPS, LIGHT_KINDS, MIN_BLUR, MAX_BLUR, DEG, SHADOW_TAPS } from './chunk-7VN2KI3U.js';
2
2
  import { maxConcurrentAdvanced, MANY_ADVANCED, applyBlendMode, isAdvancedBlend, blendFilterFor } from './chunk-TYWFKCPY.js';
3
3
  import { describeLayer, lintTiming, lintText, lintKeys, summarizeWarnings, collectPropKeys } from './chunk-45QKLD6M.js';
4
4
  import { suggestName } from './chunk-BXOCEZ4U.js';
@@ -1716,5 +1716,5 @@ function frameOf(child) {
1716
1716
  }
1717
1717
 
1718
1718
  export { CompositionSequence, maskSourceOf };
1719
- //# sourceMappingURL=chunk-WEJAQAFX.js.map
1720
- //# sourceMappingURL=chunk-WEJAQAFX.js.map
1719
+ //# sourceMappingURL=chunk-5MZWXVTT.js.map
1720
+ //# sourceMappingURL=chunk-5MZWXVTT.js.map