vexy-stax-js 3.0.10 → 3.1.1

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
@@ -4,6 +4,28 @@
4
4
 
5
5
  All notable changes to this project are documented here.
6
6
 
7
+ ## [3.0.11] — issues 335, 336, 337
8
+
9
+ ### Added
10
+
11
+ - **`video` scene section** (335 / 336): `scene.js` `parseVideo()` + the JSON schema now accept and
12
+ validate the `video` section (`width`/`height`/`fps`/`frames`/`first_hold`/`last_hold`), mirroring
13
+ `vexy_stax.scene.Video`. Previously the strict parser rejected the key with "Unknown key 'video' in
14
+ scene", which broke the Python `playwright` engine (it mounts the scene in `<vexy-stax>`) — issue
15
+ 336. The element now accepts a scene carrying `video`.
16
+ - **Held first/last still frames in `toVideo()`** (335 §2): the video export now bookends the clip
17
+ with held stills — it renders the start endpoint and captures it `video.first_hold` times (default
18
+ 10), plays the transition, then captures the end endpoint `video.last_hold` times (still →
19
+ transition → still). Mirrors `geometry.py`'s `frame_plan` holds. Verified: the exported
20
+ `airbl-transition.mp4` first/last frames are static.
21
+
22
+ ### Fixed
23
+
24
+ - **Compact view reserved empty caption space** (337): `compactCamera` now fits ONLY the frontmost
25
+ slide plate (height `H`, aimed at the slide center `Y = lift`) instead of the slide+caption
26
+ composite, so the compact view fills the frame with no caption padding. Mirrors `geometry.py`
27
+ (issue 337). Verified: the playwright-rendered compact still fills the frame; geometry test updated.
28
+
7
29
  ## [3.0.10] — issues 331
8
30
 
9
31
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexy-stax-js",
3
- "version": "3.0.10",
3
+ "version": "3.1.1",
4
4
  "description": "Browser renderer for the vexy-stax shared scene format: 3D glass plates in two views, with morphable opacity. Ships as ESM, Web Component, and a classic-script global.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -70,6 +70,19 @@
70
70
  "background": { "type": "string", "default": "#ffffff" },
71
71
  "juicy": { "type": "boolean", "default": false, "description": "Python-only per-channel color match." },
72
72
  "captions": { "type": "boolean", "default": true, "description": "Global captions toggle (issue 332). ON: each slide plate sits on top of its on-floor caption plate (stacked layout). OFF: no caption plates; slide plates sit directly on the floor." },
73
+ "video": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "description": "Video render params (issue 335 §3): dimensions/fps/frame counts/held stills. transition still owns the animation (kind/easing/wait/duration); video owns the output framing. Defaults preserve prior behavior.",
77
+ "properties": {
78
+ "width": { "type": "integer", "minimum": 1, "description": "Video width; omitted ⇒ size.width." },
79
+ "height": { "type": "integer", "minimum": 1, "description": "Video height; omitted ⇒ size.height." },
80
+ "fps": { "type": "integer", "minimum": 1, "description": "Video frames per second; omitted ⇒ transition.fps (else 30). Overrides transition.fps when set." },
81
+ "frames": { "type": "integer", "minimum": 1, "description": "Transition frames PER LEG; omitted ⇒ round(transition.duration × fps)." },
82
+ "first_hold": { "type": "integer", "minimum": 0, "default": 10, "description": "Held copies of the FIRST frame (still intro)." },
83
+ "last_hold": { "type": "integer", "minimum": 0, "default": 10, "description": "Held copies of the LAST frame (still outro)." }
84
+ }
85
+ },
73
86
  "caption_defaults": { "$ref": "#/$defs/captionStyle" },
74
87
  "caption_fade": {
75
88
  "type": "object",
package/src/geometry.js CHANGED
@@ -330,13 +330,14 @@ export function expandedCamera(scene, viewportAspect) {
330
330
  export function compactCamera(scene, viewportAspect) {
331
331
  const cam = scene.camera;
332
332
  const depth = stackDepth(scene, "compact");
333
- // Issue 332: the frontmost COMPOSITE the head-on camera frames is the slide plate plus
334
- // (captions on) its on-floor caption plate stacked below it: full width W, height H + lift
335
- // (lift == one caption-plate height), vertically centered at Y = lift/2. Aim at that
336
- // composite center so neither the slide nor the caption row crops. Mirrors geometry.py.
333
+ // Issue 337: the compact view frames ONLY the frontmost SLIDE plate not the composite with
334
+ // its caption row. Captions are invisible in compact (they fade in only as the deck expands),
335
+ // so reserving the caption-plate height just padded the frame. The slide is lifted by `lift`
336
+ // (issue 332: it sits on top of the on-floor caption plate), so its center is at Y = lift and
337
+ // it spans height H. Aim at the slide center and fit H so the slide fills the frame tight.
338
+ // Mirrors geometry.py.
337
339
  const lift = slideLift(scene);
338
- const compositeH = scene.size.height + lift;
339
- const target = [0.0, lift / 2.0, -depth / 2.0];
340
+ const target = [0.0, lift, -depth / 2.0];
340
341
 
341
342
  let isPercent = false;
342
343
  let pctVal = 90.0;
@@ -351,15 +352,16 @@ export function compactCamera(scene, viewportAspect) {
351
352
 
352
353
  let distance;
353
354
  if (isPercent) {
354
- // Dual-axis crop-free fit (SPEC.md §3, issue 302 §1): fit the frontmost COMPOSITE
355
- // (width W, height H + lift) so the limiting axis touches P% and the other axis only
356
- // ever has extra padding (never a crop). distance = max(d_w, d_h). Mirrors geometry.py.
355
+ // Dual-axis crop-free fit (SPEC.md §3, issue 302 §1, issue 337): fit the frontmost SLIDE
356
+ // plate (width W, height H NOT the caption composite) so the limiting axis touches P% and
357
+ // the other axis only ever has extra padding (never a crop). distance = max(d_w, d_h).
358
+ // Mirrors geometry.py.
357
359
  const hfov = (cam.fov * Math.PI) / 180.0;
358
360
  const aspect = viewportAspect || scene.size.width / scene.size.height;
359
361
  const vfov = 2.0 * Math.atan(Math.tan(hfov / 2.0) / aspect);
360
362
  const frac = pctVal / 100.0;
361
363
  const dW = scene.size.width / (2.0 * Math.tan(hfov / 2.0) * frac);
362
- const dH = compositeH / (2.0 * Math.tan(vfov / 2.0) * frac);
364
+ const dH = scene.size.height / (2.0 * Math.tan(vfov / 2.0) * frac);
363
365
  const distToZ0 = Math.max(dW, dH);
364
366
  distance = distToZ0 + depth / 2.0;
365
367
  } else {
package/src/index.js CHANGED
@@ -205,13 +205,28 @@ export class VexyStax {
205
205
  await this._ready;
206
206
  const kind = opts.kind ?? this.scene.transition?.kind;
207
207
  if (!kind) throw new Error("VexyStax.toVideo: no kind given and scene.transition is null");
208
- const fps = this.scene.transition?.fps ?? 30;
208
+ const fps = this.scene.video?.fps ?? this.scene.transition?.fps ?? 30;
209
209
  const canvas = this.stage.renderer.domElement;
210
+ // Issue 335 §2: bookend the clip with HELD STILLS — render the start frame and capture it
211
+ // `first_hold` times, then the transition, then capture the end frame `last_hold` times
212
+ // (still → transition → still). Defaults 10/10 from scene.video. Mirrors geometry.py's
213
+ // frame_plan holds (the Python engines get holds via frame_plan; here toVideo drives a
214
+ // real-time capture, so we hold by capturing the boundary frames repeatedly).
215
+ const firstHold = this.scene.video?.first_hold ?? 10;
216
+ const lastHold = this.scene.video?.last_hold ?? 10;
217
+ const { startMorph, endMorph } = transitionEndpoints(kind);
218
+ const aspect = this.stage.camera.aspect;
210
219
 
211
220
  return recordVideo({
212
221
  canvas,
213
222
  fps,
214
223
  run: async (onFrame) => {
224
+ // Held still intro: snap to the start endpoint and capture it `firstHold` times.
225
+ const startState = frameStateAt(this.scene, startMorph, aspect);
226
+ this.stage.applyFrameState(startState, startMorph);
227
+ this.stage.render();
228
+ for (let i = 0; i < firstHold; i++) onFrame(startState);
229
+
215
230
  const controller = playTransition(this.scene, (state) => {
216
231
  const t = this._morphFromGaps(state.gaps);
217
232
  this.stage.applyFrameState(state, t);
@@ -219,6 +234,12 @@ export class VexyStax {
219
234
  onFrame(state);
220
235
  }, { kind });
221
236
  await controller.promise;
237
+
238
+ // Held still outro: snap to the end endpoint and capture it `lastHold` times.
239
+ const endState = frameStateAt(this.scene, endMorph, aspect);
240
+ this.stage.applyFrameState(endState, endMorph);
241
+ this.stage.render();
242
+ for (let i = 0; i < lastHold; i++) onFrame(endState);
222
243
  },
223
244
  });
224
245
  }