@vosjs/studio-core 0.7.1 → 0.9.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/README.md CHANGED
@@ -36,7 +36,7 @@ The shared layers lower as one engine `stack` entry, `STUDIO_ENTRY_ID` (`'vosso.
36
36
  - **Seconds everywhere.** Zoom, speed, tilt and cam-motion spans and the cursor samples are anchored in source time, so they follow footage through trims. Overlays, audio clips and click effects are anchored in output time, so a title keeps its perceived length through a speed change. `ratedSegments`, `effectiveSegments`, `docOutputDuration` and `outputRangeToSource` are the remaps.
37
37
  - **Normalized coordinates.** A zoom's `cx`/`cy` and an overlay's `transform.x`/`y` are fractions of the frame in `[0, 1]`, never pixels, so a document survives an aspect-ratio switch. `focusBounds` and `clampFocus` keep a focus inside the card at a given `level`.
38
38
  - **Camera styles.** `ZOOM_STYLES` names eight camera personalities (`glide`, the default, then `focus`, `cinema`, `snappy`, `cut`, `keynote`, `drift`, `none`); a style parametrizes both the planner and the camera motion, and `doc.zoomParams` layers per-document overrides.
39
- - **The stage.** `CARD_FOV`, `CARD_Z`, `OVERLAY_Z`, `BACKGROUND_Z` and `planeSizeAtDepth` define the frustum-filling planes the program draws (background, the tiltable card, the overlay); `computeCardLayout` and `docCardLayout` are the host-side mirror of the program's card math, pinned to it by tests.
39
+ - **The stage.** `CARD_FOV`, `CARD_Z`, `OVERLAY_Z`, `BACKGROUND_Z` and `planeSizeAtDepth` define the frustum-filling planes the program draws (background, the tiltable card, the overlay); `cardVisibleExtent` and `cardOverscanFor` are how far a posed card is seen past the frame, which is what the card layer's canvas and plane grow by (so a lean over zoomed footage never shows the texture's edge); `computeCardLayout` and `docCardLayout` are the host-side mirror of the program's card math, pinned to it by tests.
40
40
  - **Backdrops.** `withBackdrop(frame, backdrop)` and `backdropMedia` put a looping video or a still behind the card, output-anchored modulo its duration. The package carries the mechanism and `BASE_FRAME_STYLE`; which loop a host opens on is the host's choice.
41
41
 
42
42
  ## Seeing a recording: the digest
package/dist/index.js CHANGED
@@ -1266,7 +1266,8 @@ var CARD_FOV = 30;
1266
1266
  var OVERLAY_Z = -2;
1267
1267
  var CARD_Z = -4;
1268
1268
  var BACKGROUND_Z = -6;
1269
- var CARD_OVERSCAN = 1.25;
1269
+ var CARD_OVERSCAN_STEP = 0.05;
1270
+ var CARD_OVERSCAN_MAX = 4;
1270
1271
  var CAMERA_NEAR = 0.1;
1271
1272
  var CAMERA_FAR = 100;
1272
1273
  function planeSizeAtDepth(distance, fovDeg, aspect) {
@@ -3299,6 +3300,54 @@ function objectMotionPoseAt(o, t, clipDur) {
3299
3300
  if (!track.keyframes.length) return null;
3300
3301
  return [...sample(track, t, lerpArray)];
3301
3302
  }
3303
+ var FRAME_STEP_SRC = `
3304
+ function stepPaused(vid, srcT) {
3305
+ if (!vid.paused) vid.pause()
3306
+ // A WebCodecs provider (capture pages) services the frame by PTS at
3307
+ // decode speed \u2014 no element seek, no 250ms settle cap. The element stays
3308
+ // paused as the dimension source and fallback.
3309
+ var wcp = vid.__voilaWc
3310
+ if (wcp) {
3311
+ var wcT = Math.min(srcT, wcp.duration || srcT)
3312
+ if (wcp.req !== wcT) {
3313
+ var wdp = wcp.seek(wcT)
3314
+ if (ns.pendingDecodes) {
3315
+ ns.pendingDecodes.add(wdp)
3316
+ wdp.finally(function () { ns.pendingDecodes.delete(wdp) })
3317
+ }
3318
+ }
3319
+ return
3320
+ }
3321
+ var target = Math.min(srcT, vid.duration || srcT)
3322
+ if (vid.readyState >= 1 && Math.abs(vid.currentTime - target) > 0.02) {
3323
+ if (ns.pendingDecodes) {
3324
+ var dp = new Promise(function (resolve) {
3325
+ var done = function () { vid.removeEventListener('seeked', done); resolve() }
3326
+ vid.addEventListener('seeked', done)
3327
+ setTimeout(done, 250) // fallback so a missed 'seeked' can't hang the export
3328
+ })
3329
+ ns.pendingDecodes.add(dp)
3330
+ dp.finally(function () { ns.pendingDecodes.delete(dp) })
3331
+ }
3332
+ vid.currentTime = target
3333
+ }
3334
+ }
3335
+ function stepBg(bgEl, bgT) {
3336
+ if (!bgEl.paused) bgEl.pause()
3337
+ var bgTarget = Math.min(bgT, bgEl.duration || bgT)
3338
+ if (bgEl.readyState >= 1 && !bgEl.seeking && Math.abs(bgEl.currentTime - bgTarget) > 0.02) {
3339
+ if (ns.pendingDecodes) {
3340
+ var bgDp = new Promise(function (resolve) {
3341
+ var bgDone = function () { bgEl.removeEventListener('seeked', bgDone); resolve() }
3342
+ bgEl.addEventListener('seeked', bgDone)
3343
+ setTimeout(bgDone, 250) // fallback so a missed 'seeked' can't hang the export
3344
+ })
3345
+ ns.pendingDecodes.add(bgDp)
3346
+ bgDp.finally(function () { ns.pendingDecodes.delete(bgDp) })
3347
+ }
3348
+ bgEl.currentTime = bgTarget
3349
+ }
3350
+ }`;
3302
3351
  var SETUP = `async (ctx) => {
3303
3352
  ;${timelineRuntimeCode2}
3304
3353
  const ns = (window.__vos__ = window.__vos__ || {})
@@ -3311,6 +3360,7 @@ var SETUP = `async (ctx) => {
3311
3360
  if (!ns.waitForVideosReady) ns.waitForVideosReady = async () => {
3312
3361
  if (ns.pendingDecodes.size) await Promise.all([...ns.pendingDecodes])
3313
3362
  }
3363
+ ${FRAME_STEP_SRC}
3314
3364
  const cache = ns.videoCache || (ns.videoCache = new Map())
3315
3365
  // Server capture pages opt into BLOB-backed elements (data.videoFetchMode,
3316
3366
  // merged in by the render queue \u2014 never stored in a doc or config): a
@@ -3504,6 +3554,36 @@ var SETUP = `async (ctx) => {
3504
3554
  else (await load(bgm.key, true)).loop = true
3505
3555
  } catch (e) { console.warn('[voila] background media failed to load', e) }
3506
3556
  }
3557
+ // Frame prep: a capture harness calls every ns.framePrep hook right after
3558
+ // seeking the timeline and before the first paint, then awaits
3559
+ // waitForVideosReady. The footage for the frame is requested HERE, so the
3560
+ // paint that follows draws the right frame the first time. Without the
3561
+ // hook the request could only be issued from inside ON_FRAME (the only
3562
+ // place that knew the source time), so every take frame painted twice:
3563
+ // once with the previous footage, then again after the decode. The steps
3564
+ // are the SAME functions ON_FRAME's syncVid runs (one source, inlined in
3565
+ // both scopes), so the paint finds its target already met and registers
3566
+ // nothing. Keyed by program so a warm LOAD re-registers instead of
3567
+ // chaining; playback never calls it; a harness without it keeps the
3568
+ // two-phase loop and stays correct.
3569
+ ns.framePrep = ns.framePrep || new Map()
3570
+ ns.framePrep.set('voila.card', (t) => {
3571
+ if (ns.isPaused === false) return
3572
+ const d = ctx.data
3573
+ const TL = globalThis.__vosTimeline
3574
+ const srcT = TL.mapTime(d.segments || [], t)
3575
+ if (video.play) stepPaused(video, srcT)
3576
+ if (cam) stepPaused(cam, srcT)
3577
+ if (mic) stepPaused(mic, srcT)
3578
+ const bg2 = d.frame && d.frame.backgroundMedia
3579
+ if (bg2 && bg2.key && bg2.kind !== 'image') {
3580
+ const bgEl2 = cache.get(bg2.key)
3581
+ if (bgEl2 && bgEl2.play) {
3582
+ const bgDur2 = bg2.duration || bgEl2.duration || 0
3583
+ stepBg(bgEl2, bgDur2 > 0 ? t % bgDur2 : 0)
3584
+ }
3585
+ }
3586
+ })
3507
3587
  return { video: video, cam: cam, mic: mic }
3508
3588
  }`;
3509
3589
  var CREATE_CONTENT = `(ctx, setupData) => {
@@ -3582,30 +3662,81 @@ var ON_FRAME = `(ctx, content, dt) => {
3582
3662
  // the GPU texture allocated at the original dims and re-uploads the new canvas
3583
3663
  // against stale dims \u2192 stretch/duplicate; dispose() forces a full realloc) and
3584
3664
  // rebuilding each frustum-filling plane at the new aspect + syncing the camera.
3585
- // Card OVERSCAN (stage.ts CARD_OVERSCAN): a frame whose inset bleeds the
3586
- // card past an edge paints it on a canvas and a plane larger than the
3587
- // frame by ck about the centre, so a tilt that turns the bled edge back
3588
- // toward the viewer shows card there, not the texture's edge. Every doc
3589
- // that keeps its card inside the frame (and the flat stub, which has no
3590
- // card layer) stays at 1: byte- and pixel-identical.
3665
+ // Card OVERSCAN: the card's pose (the tilt track, the card-pose track)
3666
+ // lets the frame see MORE plane than the frame is wide on a receding
3667
+ // side, and the card's content (zoomed footage, a bled card, the shadow)
3668
+ // runs past the frame, so a plane that ends at the frame edge shows its
3669
+ // texture's edge, and the backdrop through it, as soon as it leans. The
3670
+ // card layer's canvas and plane therefore grow about the frame's centre
3671
+ // by the extent the two tracks can reach at the LIVE aspect: each frame
3672
+ // corner's viewing ray meets the posed plane and reads back in plane
3673
+ // units; the extremes of both tracks (largest lean per axis, smallest
3674
+ // scale, largest rise, every sign) bound every interpolated frame. This
3675
+ // is the twin of stage.ts cardVisibleExtent/cardOverscanFor (pinned by
3676
+ // stage.test.ts + cardTilt.test.ts; change them together). A card that
3677
+ // never moves, and the flat stub (no card layer), stay at 1: byte- and
3678
+ // pixel-identical. The factor is quantised up in steps so a scrubbed
3679
+ // pose grows the canvas a few times, not per tick.
3591
3680
  var d = ctx.data || {}
3592
- var ckIns = (d.frame && d.frame.inset) || {}
3593
- var ck = r.card && (ckIns.left < 0 || ckIns.right < 0 || ckIns.top < 0 || ckIns.bottom < 0) ? ${CARD_OVERSCAN} : 1
3594
- var cW = Math.round(W * ck), cH = Math.round(H * ck)
3681
+ var okx = 1, oky = 1
3682
+ if (r.card) {
3683
+ var okT = d.tiltTrack, okP = d.cardPoseTrack, okKf, oki, okv
3684
+ var okRx = 0, okRy = 0, okSc = 1, okDy = 0
3685
+ okKf = okT && okT.keyframes || []
3686
+ for (oki = 0; oki < okKf.length; oki++) { okv = okKf[oki].value || []; okRx = Math.max(okRx, Math.abs(okv[0] || 0)); okRy = Math.max(okRy, Math.abs(okv[1] || 0)) }
3687
+ okKf = okP && okP.keyframes || []
3688
+ for (oki = 0; oki < okKf.length; oki++) { okv = okKf[oki].value || []; okSc = Math.min(okSc, okv[0] == null ? 1 : okv[0]); okDy = Math.max(okDy, Math.abs(okv[1] || 0)) }
3689
+ if (okRx || okRy || okSc < 1 || okDy) {
3690
+ var okD = ${Math.abs(CARD_Z)}, okMax = ${CARD_OVERSCAN_MAX}, okStep = ${CARD_OVERSCAN_STEP}
3691
+ var okHh = okD * Math.tan(${CARD_FOV} * Math.PI / 180 / 2), okHw = okHh * W / H
3692
+ // [max |x|, max |y|] of the frame corners on the posed plane, in
3693
+ // half-plane units (R = Rx \xB7 Ry, three.js Euler XYZ with rz = 0).
3694
+ var okE = function (rxd, ryd, sc, dy) {
3695
+ var rx = rxd * Math.PI / 180, ry = ryd * Math.PI / 180
3696
+ var cx = Math.cos(rx), sx = Math.sin(rx), cy = Math.cos(ry), sy = Math.sin(ry)
3697
+ var scl = Math.max(0.05, sc), py = dy * 2 * okHh
3698
+ var n0 = sy, n1 = -sx * cy, n2 = cx * cy
3699
+ var nPos = n1 * py - n2 * okD
3700
+ var ex = 0, ey = 0
3701
+ for (var kx = -1; kx <= 1; kx += 2) for (var ky = -1; ky <= 1; ky += 2) {
3702
+ var d0 = kx * okHw, d1 = ky * okHh, d2 = -okD
3703
+ var nd = n0 * d0 + n1 * d1 + n2 * d2
3704
+ var lam = nd === 0 ? -1 : nPos / nd
3705
+ if (!(lam > 0)) { ex = okMax; ey = okMax; continue }
3706
+ var q0 = lam * d0, q1 = lam * d1 - py, q2 = lam * d2 + okD
3707
+ ex = Math.max(ex, Math.abs((cy * q0 + sx * sy * q1 - cx * sy * q2) / scl / okHw))
3708
+ ey = Math.max(ey, Math.abs((cx * q1 + sx * q2) / scl / okHh))
3709
+ }
3710
+ return [Math.min(okMax, ex), Math.min(okMax, ey)]
3711
+ }
3712
+ for (var okA = -1; okA <= 1; okA += 2) for (var okB = -1; okB <= 1; okB += 2) for (var okC = -1; okC <= 1; okC += 2) {
3713
+ var okR = okE(okA * okRx, okB * okRy, okSc, okC * okDy)
3714
+ okx = Math.max(okx, okR[0]); oky = Math.max(oky, okR[1])
3715
+ }
3716
+ var okQ = function (k) { if (!(k > 1)) return 1; var q = Math.ceil(Math.round((k + okStep / 10) * 1000) / (okStep * 1000)) * okStep; return Math.min(okMax, Math.round(q * 1000) / 1000) }
3717
+ okx = okQ(okx); oky = okQ(oky)
3718
+ }
3719
+ }
3720
+ // The canvas grows by an EVEN number of pixels on each axis so the frame
3721
+ // region sits on whole pixels inside it (a half-pixel base translation
3722
+ // would resample every draw).
3723
+ var cW = W + 2 * Math.round(W * (okx - 1) / 2), cH = H + 2 * Math.round(H * (oky - 1) / 2)
3595
3724
  if (cv.width !== cW || cv.height !== cH) {
3596
3725
  var THREE = ctx.THREE
3597
3726
  var aspect = W / H
3598
3727
  var cm = ctx.camera
3599
3728
  if (cm && cm.isPerspectiveCamera) { cm.aspect = aspect; cm.updateProjectionMatrix() }
3600
- var lyr = [[bg, ${BACKGROUND_Z}, 1], [card, ${CARD_Z}, ck], [ov, ${OVERLAY_Z}, 1]]
3729
+ // Each layer's canvas size and its plane's per-axis growth; the card's
3730
+ // plane grows by exactly its canvas's ratio, so a texel stays a pixel.
3731
+ var lyr = [[bg, ${BACKGROUND_Z}, W, H], [card, ${CARD_Z}, cW, cH], [ov, ${OVERLAY_Z}, W, H]]
3601
3732
  for (var Li = 0; Li < lyr.length; Li++) {
3602
- var Ly = lyr[Li][0], Lz = lyr[Li][1], Lk = lyr[Li][2]
3603
- if (Ly.canvas) { Ly.canvas.width = Math.round(W * Lk); Ly.canvas.height = Math.round(H * Lk) }
3733
+ var Ly = lyr[Li][0], Lz = lyr[Li][1], Lw = lyr[Li][2], Lhp = lyr[Li][3]
3734
+ if (Ly.canvas) { Ly.canvas.width = Lw; Ly.canvas.height = Lhp }
3604
3735
  if (Ly.texture && Ly.texture.dispose) Ly.texture.dispose()
3605
3736
  if (Ly.mesh && THREE) {
3606
3737
  if (Ly.mesh.geometry && Ly.mesh.geometry.dispose) Ly.mesh.geometry.dispose()
3607
- var Lh = 2 * Math.abs(Lz) * Math.tan(${CARD_FOV} * Math.PI / 180 / 2) * Lk
3608
- Ly.mesh.geometry = new THREE.PlaneGeometry(Lh * aspect, Lh)
3738
+ var Lh = 2 * Math.abs(Lz) * Math.tan(${CARD_FOV} * Math.PI / 180 / 2)
3739
+ Ly.mesh.geometry = new THREE.PlaneGeometry(Lh * aspect * (Lw / W), Lh * (Lhp / H))
3609
3740
  }
3610
3741
  }
3611
3742
  }
@@ -3631,6 +3762,7 @@ var ON_FRAME = `(ctx, content, dt) => {
3631
3762
  // correction covers cut-boundary jumps), else step to the exact frame (registering a
3632
3763
  // decode promise so the deterministic export awaits it). Shared by the screen video
3633
3764
  // and the webcam so both stay frame-accurate and in sync.
3765
+ ${FRAME_STEP_SRC}
3634
3766
  function syncVid(vid) {
3635
3767
  try {
3636
3768
  if (playing) {
@@ -3676,35 +3808,11 @@ var ON_FRAME = `(ctx, content, dt) => {
3676
3808
  vid.__voilaAutoMuted = false
3677
3809
  }
3678
3810
  } else {
3679
- if (!vid.paused) vid.pause()
3680
- // A WebCodecs provider (capture pages) services the frame by
3681
- // PTS at decode speed \u2014 no element seek, no 250ms settle cap. The
3682
- // element stays paused as the dimension source and fallback.
3683
- var wcp = vid.__voilaWc
3684
- if (wcp) {
3685
- var wcT = Math.min(srcT, wcp.duration || srcT)
3686
- if (wcp.req !== wcT) {
3687
- var wdp = wcp.seek(wcT)
3688
- if (ns.pendingDecodes) {
3689
- ns.pendingDecodes.add(wdp)
3690
- wdp.finally(function () { ns.pendingDecodes.delete(wdp) })
3691
- }
3692
- }
3693
- return
3694
- }
3695
- var target = Math.min(srcT, vid.duration || srcT)
3696
- if (vid.readyState >= 1 && Math.abs(vid.currentTime - target) > 0.02) {
3697
- if (ns.pendingDecodes) {
3698
- var dp = new Promise(function (resolve) {
3699
- var done = function () { vid.removeEventListener('seeked', done); resolve() }
3700
- vid.addEventListener('seeked', done)
3701
- setTimeout(done, 250) // fallback so a missed 'seeked' can't hang the export
3702
- })
3703
- ns.pendingDecodes.add(dp)
3704
- dp.finally(function () { ns.pendingDecodes.delete(dp) })
3705
- }
3706
- vid.currentTime = target
3707
- }
3811
+ // The paused step (FRAME_STEP_SRC): the provider by PTS, else the
3812
+ // element seek. A capture harness that ran the frame-prep hook has
3813
+ // already met this target, so nothing registers and the paint is
3814
+ // the one that counts.
3815
+ stepPaused(vid, srcT)
3708
3816
  }
3709
3817
  } catch (e) {}
3710
3818
  }
@@ -3798,27 +3906,9 @@ var ON_FRAME = `(ctx, content, dt) => {
3798
3906
  if (bgDur > 0 && !bgEl.seeking && bgDrift > 0.3 && bgDur - bgDrift > 0.3) bgEl.currentTime = bgT
3799
3907
  if (bgEl.paused) { var bgP = bgEl.play(); if (bgP && bgP.catch) bgP.catch(function () {}) }
3800
3908
  } else {
3801
- if (!bgEl.paused) bgEl.pause()
3802
- var bgTarget = Math.min(bgT, bgEl.duration || bgT)
3803
- // COALESCE seeks: a scrub moves t every frame, and re-assigning
3804
- // currentTime ABORTS the in-flight seek \u2014 on a remote (assets.vos.so)
3805
- // source that keeps the element mid-seek for the whole drag, so no
3806
- // frame ever decodes and the background pops in seconds late. Issue
3807
- // a seek only when none is in flight; the frame after 'seeked' fires
3808
- // corrects toward the latest target, so seeks run serially and
3809
- // converge on the release point.
3810
- if (bgEl.readyState >= 1 && !bgEl.seeking && Math.abs(bgEl.currentTime - bgTarget) > 0.02) {
3811
- if (ns.pendingDecodes) {
3812
- var bgDp = new Promise(function (resolve) {
3813
- var bgDone = function () { bgEl.removeEventListener('seeked', bgDone); resolve() }
3814
- bgEl.addEventListener('seeked', bgDone)
3815
- setTimeout(bgDone, 250) // fallback so a missed 'seeked' can't hang the export
3816
- })
3817
- ns.pendingDecodes.add(bgDp)
3818
- bgDp.finally(function () { ns.pendingDecodes.delete(bgDp) })
3819
- }
3820
- bgEl.currentTime = bgTarget
3821
- }
3909
+ // The background step (FRAME_STEP_SRC): a coalesced seek, met
3910
+ // already when a capture harness ran the frame-prep hook.
3911
+ stepBg(bgEl, bgT)
3822
3912
  }
3823
3913
  } catch (e) {}
3824
3914
  }
@@ -3929,9 +4019,10 @@ var ON_FRAME = `(ctx, content, dt) => {
3929
4019
  // the contain-fit card shows the background layer through the plane's alpha.
3930
4020
  // Under overscan the canvas is larger than the frame and every frame-space
3931
4021
  // draw below rides a base translation to its centre.
3932
- if (ck !== 1) c.setTransform(1, 0, 0, 1, 0, 0)
4022
+ var okOn = cW !== W || cH !== H
4023
+ if (okOn) c.setTransform(1, 0, 0, 1, 0, 0)
3933
4024
  c.clearRect(0, 0, cv.width, cv.height)
3934
- if (ck !== 1) c.setTransform(1, 0, 0, 1, (cW - W) / 2, (cH - H) / 2)
4025
+ if (okOn) c.setTransform(1, 0, 0, 1, (cW - W) / 2, (cH - H) / 2)
3935
4026
  // Footage is nearly always drawn SMALLER than it was recorded (a 1920
3936
4027
  // recording in a padded 1080 card, or a 440-wide tile): the default
3937
4028
  // bilinear tap aliases and blurs a large downscale; 'high' resamples.
@@ -4442,8 +4533,10 @@ var ON_FRAME = `(ctx, content, dt) => {
4442
4533
  card.mesh.rotation.y = ry
4443
4534
  // The card's pose through an entrance or an end card: [scale, dy,
4444
4535
  // opacity]; absent = the rest pose, exactly as before the track existed.
4536
+ // dy is a fraction of the FRAME plane's height (not the geometry's,
4537
+ // which the overscan grows).
4445
4538
  var cpk = d.cardPoseTrack
4446
- var cpH = card.mesh.geometry && card.mesh.geometry.parameters ? card.mesh.geometry.parameters.height : 0
4539
+ var cpH = 2 * ${Math.abs(CARD_Z)} * Math.tan(${CARD_FOV} * Math.PI / 180 / 2)
4447
4540
  if (cpk && cpk.keyframes && cpk.keyframes.length && card.mesh.scale && card.mesh.position) {
4448
4541
  var cpv = TL.sample(cpk, t, TL.lerpArray)
4449
4542
  card.mesh.scale.x = cpv[0]; card.mesh.scale.y = cpv[0]