@vosjs/studio-core 0.41.2 → 0.42.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/dist/index.js CHANGED
@@ -623,7 +623,7 @@ function projectFromArtifact(artifact, videoUrl, opts = {}) {
623
623
  const coverage = surface === "monitor" || artifact.meta.windowMovedDuringTake || unfocused ? 0 : norm.coverage;
624
624
  if (artifact.cursor.length > 0 && coverage < CAPTURE_COVERAGE_MIN) {
625
625
  console.warn(
626
- "[voila/studio] dropping cursor track \u2014",
626
+ "[studio] dropping cursor track \u2014",
627
627
  surface === "monitor" ? "entire-screen takes have no cursor effects (untrackable outside browser pages)" : artifact.meta.windowMovedDuringTake ? "window moved/resized during the take" : unfocused ? `browser window focused only ${Math.round((artifact.meta.windowFocusedFrac ?? 0) * 100)}% of the take \u2014 a different window was likely shared` : `coverage ${norm.coverage.toFixed(2)} (wrong surface shared, or unusable capture geometry)`
628
628
  );
629
629
  }
@@ -3403,6 +3403,13 @@ var TYPING_CLICK_ABSORB = 1.5;
3403
3403
  var TYPING_REFOCUS_FRAC = 0.08;
3404
3404
  function groupTrack(track, opts) {
3405
3405
  const { width, height, clusterGap, typingGap, typingZoom, targetFill } = opts;
3406
+ const stepOf = (t) => opts.steps?.find(
3407
+ (st) => st.do !== "wait" && !st.skipped && st.tStart <= t && t <= st.tEnd
3408
+ ) ?? null;
3409
+ const sameStep = (a, b) => {
3410
+ const sa = stepOf(a.t);
3411
+ return sa !== null && sa === stepOf(b.t);
3412
+ };
3406
3413
  const minLevel = opts.minLevel ?? resolveZoomStyle().minLevel;
3407
3414
  const clicks = dedupePresses(
3408
3415
  track.filter((e) => e.type === "down")
@@ -3436,7 +3443,7 @@ function groupTrack(track, opts) {
3436
3443
  for (const c of list) {
3437
3444
  const last = clusters.at(-1);
3438
3445
  const prev = last?.at(-1);
3439
- const joins = last && prev && c.t - prev.t <= clusterGap && (!spatial || unionFitLevel(unionRect([...last, c]), width, height) >= minLevel);
3446
+ const joins = last && prev && (c.t - prev.t <= clusterGap || sameStep(prev, c)) && (!spatial || unionFitLevel(unionRect([...last, c]), width, height) >= minLevel);
3440
3447
  if (joins) last.push(c);
3441
3448
  else clusters.push([c]);
3442
3449
  }
@@ -3515,7 +3522,8 @@ function planAutoZoom(track, options) {
3515
3522
  typingGap,
3516
3523
  typingZoom,
3517
3524
  targetFill,
3518
- minLevel
3525
+ minLevel,
3526
+ steps: options.steps
3519
3527
  });
3520
3528
  const { sessions } = grouped;
3521
3529
  const drags = dragsFromTrack(track, width, height);
@@ -3630,10 +3638,11 @@ function planAutoZoom(track, options) {
3630
3638
  const spans = within([...zSpans, ...kSpans, ...dragSpans]).sort(
3631
3639
  (a, b) => a.in - b.in
3632
3640
  );
3641
+ const firstStep = options.steps?.find((st) => !st.skipped)?.tStart;
3633
3642
  const dwells = dwellSpans(track, width, height, maxLevel, [
3634
3643
  ...spans,
3635
3644
  ...dragReserved
3636
- ]);
3645
+ ]).filter((d) => firstStep === void 0 || d.out > firstStep);
3637
3646
  return within([...spans, ...dwells]).sort((a, b) => a.in - b.in);
3638
3647
  }
3639
3648
  function typingSessions(track, width, height, typingGap) {
@@ -4834,7 +4843,7 @@ var STUDIO_SETUP = `async (ctx) => {
4834
4843
  // The MediaError rides along: code 4 is an unreadable/unsupported source
4835
4844
  // (a dead blob URL, a 404), code 3 a decode failure, code 2 a network
4836
4845
  // stall. A bare "failed to load" gave the fleet log nothing to act on.
4837
- v.onerror = () => rej(new Error('[voila] video failed to load' + (v.error ? ' (' + v.error.code + (v.error.message ? ': ' + v.error.message : '') + ')' : '')))
4846
+ v.onerror = () => rej(new Error('[studio] video failed to load' + (v.error ? ' (' + v.error.code + (v.error.message ? ': ' + v.error.message : '') + ')' : '')))
4838
4847
  v.load()
4839
4848
  })
4840
4849
  cache.set(src, v)
@@ -4847,7 +4856,7 @@ var STUDIO_SETUP = `async (ctx) => {
4847
4856
  const img = new Image()
4848
4857
  img.crossOrigin = 'anonymous'
4849
4858
  img.onload = () => { cache.set(src, img); res(img) }
4850
- img.onerror = () => rej(new Error('[voila] image failed to load'))
4859
+ img.onerror = () => rej(new Error('[studio] image failed to load'))
4851
4860
  img.src = src
4852
4861
  })
4853
4862
  }
@@ -4861,7 +4870,7 @@ var STUDIO_SETUP = `async (ctx) => {
4861
4870
  const faces = ctx.data.overlayFonts || ${JSON.stringify(OVERLAY_FONT_FACES)}
4862
4871
  // Mark what SETUP registers, so ON_FRAME's live path (a text overlay
4863
4872
  // arriving through SET_DATA) never adds the same face twice.
4864
- const ofSet = window.__voilaFontSet || (window.__voilaFontSet = {})
4873
+ const ofSet = window.__vosFontSet || (window.__vosFontSet = {})
4865
4874
  const loads = faces.map((f) => {
4866
4875
  ofSet[f.family + '|' + f.weight] = 1
4867
4876
  const ff = new FontFace(f.family, 'url(' + f.url + ')', { weight: String(f.weight) })
@@ -4872,7 +4881,7 @@ var STUDIO_SETUP = `async (ctx) => {
4872
4881
  Promise.all(loads).catch(() => {}),
4873
4882
  new Promise((res) => setTimeout(res, 4000)),
4874
4883
  ])
4875
- } catch (e) { console.warn('[voila] overlay fonts failed to load', e) }
4884
+ } catch (e) { console.warn('[studio] overlay fonts failed to load', e) }
4876
4885
  }
4877
4886
  // An HTML layer arrives as SOURCE, and the picture is built HERE.
4878
4887
  //
@@ -4915,7 +4924,7 @@ var STUDIO_SETUP = `async (ctx) => {
4915
4924
  // Fail open per URL, and NOT sticky: the layer paints without it now
4916
4925
  // and the next build asks again.
4917
4926
  faceData.delete(url)
4918
- console.warn('[voila] html layer ' + what + ' failed to load', url, e)
4927
+ console.warn('[studio] html layer ' + what + ' failed to load', url, e)
4919
4928
  return ''
4920
4929
  })
4921
4930
  faceData.set(url, p)
@@ -4967,7 +4976,7 @@ var STUDIO_SETUP = `async (ctx) => {
4967
4976
  ns.htmlSafe = true
4968
4977
  } catch (e) {
4969
4978
  ns.htmlSafe = false
4970
- console.error('[voila] html layers are off: this browser taints a canvas that draws a foreignObject SVG', e)
4979
+ console.error('[studio] html layers are off: this browser taints a canvas that draws a foreignObject SVG', e)
4971
4980
  }
4972
4981
  }
4973
4982
  // One picture per clip in the cache: the previous key goes when a new
@@ -4984,7 +4993,7 @@ var STUDIO_SETUP = `async (ctx) => {
4984
4993
  // recorded on the clip and said at the level a render page forwards.
4985
4994
  ns.buildHtmlLayer = (oc, live) => buildHtmlLayer(oc, live).catch((e) => {
4986
4995
  ns.htmlErrors[oc.id] = String((e && e.message) || e)
4987
- console.error('[voila] html layer ' + oc.id + ' ' + ns.htmlErrors[oc.id])
4996
+ console.error('[studio] html layer ' + oc.id + ' ' + ns.htmlErrors[oc.id])
4988
4997
  })
4989
4998
  // The capture contract for a LIVE layer, on ANY anchor. A recording's own
4990
4999
  // program installs the settle machinery (pendingDecodes, the wait a
@@ -5027,7 +5036,7 @@ var STUDIO_SETUP = `async (ctx) => {
5027
5036
  try {
5028
5037
  if (oc.kind === 'image') await loadImage(oc.key)
5029
5038
  else await load(oc.key, true)
5030
- } catch (e) { console.warn('[voila] overlay media failed to load', oc.key, e) }
5039
+ } catch (e) { console.warn('[studio] overlay media failed to load', oc.key, e) }
5031
5040
  }
5032
5041
  // GLB object assets (V3b): load via the engine's GLTFLoader addon into a
5033
5042
  // shared cache, bbox-NORMALIZED (norm = 1/maxDim) so transform3d.scale means
@@ -5039,7 +5048,7 @@ var STUDIO_SETUP = `async (ctx) => {
5039
5048
  if (!oc.asset || oc.asset.kind !== 'gltf' || !oc.asset.key || objCache.has(oc.asset.key)) continue
5040
5049
  try {
5041
5050
  const GL = ctx.loaders && ctx.loaders.GLTFLoader
5042
- if (!GL) { console.warn('[voila] GLTFLoader unavailable'); continue }
5051
+ if (!GL) { console.warn('[studio] GLTFLoader unavailable'); continue }
5043
5052
  const gltf = await new Promise((res, rej) => new GL().load(oc.asset.key, res, undefined, rej))
5044
5053
  const box = new ctx.THREE.Box3().setFromObject(gltf.scene)
5045
5054
  const size = new ctx.THREE.Vector3()
@@ -5048,7 +5057,7 @@ var STUDIO_SETUP = `async (ctx) => {
5048
5057
  box.getCenter(center)
5049
5058
  const maxDim = Math.max(size.x, size.y, size.z) || 1
5050
5059
  objCache.set(oc.asset.key, { scene: gltf.scene, norm: 1 / maxDim, center: center })
5051
- } catch (e) { console.warn('[voila] glb failed to load', oc.asset.key, e) }
5060
+ } catch (e) { console.warn('[studio] glb failed to load', oc.asset.key, e) }
5052
5061
  }
5053
5062
  // 3D-text typefaces: FontLoader JSONs into a shared cache, keyed by
5054
5063
  // URL. Awaited here so frame 0 is complete on cold loads (export/chunk
@@ -5059,10 +5068,10 @@ var STUDIO_SETUP = `async (ctx) => {
5059
5068
  if (!oc.asset || oc.asset.kind !== 'text3d' || !oc.asset.url || fontCache.has(oc.asset.url)) continue
5060
5069
  try {
5061
5070
  const FL = ctx.loaders && ctx.loaders.FontLoader
5062
- if (!FL) { console.warn('[voila] FontLoader unavailable'); continue }
5071
+ if (!FL) { console.warn('[studio] FontLoader unavailable'); continue }
5063
5072
  const font = await new Promise((res, rej) => new FL().load(oc.asset.url, res, undefined, rej))
5064
5073
  fontCache.set(oc.asset.url, font)
5065
- } catch (e) { console.warn('[voila] typeface failed to load', oc.asset.url, e) }
5074
+ } catch (e) { console.warn('[studio] typeface failed to load', oc.asset.url, e) }
5066
5075
  }
5067
5076
  // Audio clips (music/SFX): start decoding into the shared cache now, but
5068
5077
  // never hold the first frame for it. Playback starts on a press, so the
@@ -5087,7 +5096,7 @@ var STUDIO_SETUP = `async (ctx) => {
5087
5096
  bufs.set(c.key, b)
5088
5097
  if (ns.audioSched) ns.audioSched.sig = ''
5089
5098
  })
5090
- .catch((e) => console.warn('[voila] audio decode failed', c.key, e))
5099
+ .catch((e) => console.warn('[studio] audio decode failed', c.key, e))
5091
5100
  .then(() => pend.delete(c.key))
5092
5101
  }
5093
5102
  }
@@ -5209,7 +5218,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5209
5218
  .then(function (res) { return res.arrayBuffer() })
5210
5219
  .then(function (ab) { return actx.decodeAudioData(ab) })
5211
5220
  .then(function (b) { bufs.set(cc.key, b); AS.sig = '' })
5212
- .catch(function (e) { console.warn('[voila] audio decode failed', cc.key, e) })
5221
+ .catch(function (e) { console.warn('[studio] audio decode failed', cc.key, e) })
5213
5222
  .then(function () { pend.delete(cc.key) })
5214
5223
  })(clips[di])
5215
5224
  }
@@ -5595,7 +5604,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5595
5604
  // Keyed per window, so SETUP's cold-load faces and repeat frames never
5596
5605
  // re-register; fail-open, frames repaint as each face lands.
5597
5606
  if (typeof FontFace !== 'undefined') {
5598
- var ofSet = window.__voilaFontSet || (window.__voilaFontSet = {})
5607
+ var ofSet = window.__vosFontSet || (window.__vosFontSet = {})
5599
5608
  var ofBase = d.overlayFonts || ${JSON.stringify(OVERLAY_FONT_FACES)}
5600
5609
  for (var ofi = 0; ofi < ofBase.length; ofi++) {
5601
5610
  var ofB = ofBase[ofi]
@@ -6759,7 +6768,7 @@ var FRAME_STEP_SRC = `
6759
6768
  // A WebCodecs provider (capture pages) services the frame by PTS at
6760
6769
  // decode speed \u2014 no element seek, no 250ms settle cap. The element stays
6761
6770
  // paused as the dimension source and fallback.
6762
- var wcp = vid.__voilaWc
6771
+ var wcp = vid.__vosWc
6763
6772
  if (wcp) {
6764
6773
  var wcT = Math.min(srcT, wcp.duration || srcT)
6765
6774
  if (wcp.req !== wcT) {
@@ -6790,7 +6799,7 @@ var FRAME_STEP_SRC = `
6790
6799
  // Capture pages: the loop's frame by PTS from its own provider, like the
6791
6800
  // footage. The loop clock only moves forward between wraps, so the walk
6792
6801
  // stays sequential and a wrap is one re-seek to the keyframe at 0.
6793
- var bwc = bgEl.__voilaWc
6802
+ var bwc = bgEl.__vosWc
6794
6803
  if (bwc) {
6795
6804
  var bwT = Math.min(bgT, bwc.duration || bgT)
6796
6805
  if (bwc.req !== bwT) {
@@ -6837,9 +6846,9 @@ var FRAME_STEP_SRC = `
6837
6846
  if (fired) return
6838
6847
  fired = true
6839
6848
  g.removeEventListener('loadeddata', done)
6840
- if (g.__voilaWant != null && g.readyState >= 1) {
6841
- var want = g.__voilaWant
6842
- g.__voilaWant = null
6849
+ if (g.__vosWant != null && g.readyState >= 1) {
6850
+ var want = g.__vosWant
6851
+ g.__vosWant = null
6843
6852
  var seeked = function () { g.removeEventListener('seeked', seeked); resolve() }
6844
6853
  g.addEventListener('seeked', seeked)
6845
6854
  setTimeout(seeked, 250)
@@ -6859,8 +6868,8 @@ var FRAME_STEP_SRC = `
6859
6868
  return g
6860
6869
  }
6861
6870
  function stepGhost(g, at) {
6862
- if (g.readyState >= 1) { g.__voilaWant = null; stepPaused(g, at); return }
6863
- g.__voilaWant = at
6871
+ if (g.readyState >= 1) { g.__vosWant = null; stepPaused(g, at); return }
6872
+ g.__vosWant = at
6864
6873
  }`;
6865
6874
  var SETUP = `async (ctx) => {
6866
6875
  ;${timelineRuntimeCode2}
@@ -6888,11 +6897,11 @@ var SETUP = `async (ctx) => {
6888
6897
  const BG_BLOB_MAX = 64 * 1024 * 1024
6889
6898
  const toBlobUrl = async (src) => {
6890
6899
  const resp = await fetch(src)
6891
- if (!resp.ok) throw new Error('[voila] blob fetch HTTP ' + resp.status)
6900
+ if (!resp.ok) throw new Error('[studio] blob fetch HTTP ' + resp.status)
6892
6901
  const len = Number(resp.headers.get('content-length') || 0)
6893
6902
  if (len > BLOB_FETCH_MAX) {
6894
6903
  try { if (resp.body) await resp.body.cancel() } catch (e) { void e }
6895
- throw new Error('[voila] blob fetch over size cap')
6904
+ throw new Error('[studio] blob fetch over size cap')
6896
6905
  }
6897
6906
  const fetched = await resp.blob()
6898
6907
  // Keep the Blob itself: the WebCodecs provider demuxes the SAME
@@ -6906,7 +6915,7 @@ var SETUP = `async (ctx) => {
6906
6915
  let url = src
6907
6916
  if (ctx.data.videoFetchMode === 'blob') {
6908
6917
  try { url = await toBlobUrl(src) }
6909
- catch (e) { console.warn('[voila] blob fetch failed, using network src', e) }
6918
+ catch (e) { console.warn('[studio] blob fetch failed, using network src', e) }
6910
6919
  }
6911
6920
  v = document.createElement('video')
6912
6921
  v.src = url
@@ -6919,7 +6928,7 @@ var SETUP = `async (ctx) => {
6919
6928
  // The MediaError rides along: code 4 is an unreadable/unsupported source
6920
6929
  // (a dead blob URL, a 404), code 3 a decode failure, code 2 a network
6921
6930
  // stall. A bare "failed to load" gave the fleet log nothing to act on.
6922
- v.onerror = () => rej(new Error('[voila] video failed to load' + (v.error ? ' (' + v.error.code + (v.error.message ? ': ' + v.error.message : '') + ')' : '')))
6931
+ v.onerror = () => rej(new Error('[studio] video failed to load' + (v.error ? ' (' + v.error.code + (v.error.message ? ': ' + v.error.message : '') + ')' : '')))
6923
6932
  v.load()
6924
6933
  })
6925
6934
  cache.set(src, v)
@@ -6934,7 +6943,7 @@ var SETUP = `async (ctx) => {
6934
6943
  const img = new Image()
6935
6944
  img.crossOrigin = 'anonymous'
6936
6945
  img.onload = () => { cache.set(src, img); res(img) }
6937
- img.onerror = () => rej(new Error('[voila] image failed to load'))
6946
+ img.onerror = () => rej(new Error('[studio] image failed to load'))
6938
6947
  img.src = src
6939
6948
  })
6940
6949
  }
@@ -7010,7 +7019,7 @@ var SETUP = `async (ctx) => {
7010
7019
  provider.req = t
7011
7020
  chain = chain
7012
7021
  .then(() => advanceTo(t))
7013
- .catch((e) => { console.warn('[voila] webcodecs frame failed', e) })
7022
+ .catch((e) => { console.warn('[studio] webcodecs frame failed', e) })
7014
7023
  return chain
7015
7024
  },
7016
7025
  // Draw the CURRENT frame into the card. Returns false (element path
@@ -7045,12 +7054,12 @@ var SETUP = `async (ctx) => {
7045
7054
  (wcProvider.width === video.videoWidth &&
7046
7055
  wcProvider.height === video.videoHeight))
7047
7056
  ) {
7048
- video.__voilaWc = wcProvider
7057
+ video.__vosWc = wcProvider
7049
7058
  } else if (wcProvider) {
7050
- console.warn('[voila] webcodecs dims mismatch \u2014 element path keeps the frame')
7059
+ console.warn('[studio] webcodecs dims mismatch \u2014 element path keeps the frame')
7051
7060
  }
7052
7061
  } catch (e) {
7053
- console.warn('[voila] webcodecs provider unavailable, seeks stay html5', e)
7062
+ console.warn('[studio] webcodecs provider unavailable, seeks stay html5', e)
7054
7063
  }
7055
7064
  }
7056
7065
  // The webcam is a separate recording (video-only) drawn as an editable bubble overlay.
@@ -7069,14 +7078,14 @@ var SETUP = `async (ctx) => {
7069
7078
  if (!m.isImage && ctx.data.videoDecodeMode === 'webcodecs') {
7070
7079
  try {
7071
7080
  const wp = await makeWcProvider(m.src)
7072
- if (wp && (!el.videoWidth || (wp.width === el.videoWidth && wp.height === el.videoHeight))) el.__voilaWc = wp
7081
+ if (wp && (!el.videoWidth || (wp.width === el.videoWidth && wp.height === el.videoHeight))) el.__vosWc = wp
7073
7082
  } catch (e) {
7074
- console.warn('[voila] webcodecs provider unavailable for a media, seeks stay html5', e)
7083
+ console.warn('[studio] webcodecs provider unavailable for a media, seeks stay html5', e)
7075
7084
  }
7076
7085
  }
7077
7086
  mediaEls[m.id] = el
7078
7087
  } catch (e) {
7079
- console.warn('[voila] a media failed to load', m.id, e)
7088
+ console.warn('[studio] a media failed to load', m.id, e)
7080
7089
  }
7081
7090
  }
7082
7091
  // Background media (frame.backgroundMedia): warm-load so the first paint is
@@ -7096,7 +7105,7 @@ var SETUP = `async (ctx) => {
7096
7105
  // into a Blob first when it is small enough (one request instead
7097
7106
  // of ranged reads against a CDN). FAIL-OPEN to the element at
7098
7107
  // every step, like the footage.
7099
- if (ctx.data.videoDecodeMode === 'webcodecs' && !bgV.__voilaWc) {
7108
+ if (ctx.data.videoDecodeMode === 'webcodecs' && !bgV.__vosWc) {
7100
7109
  try {
7101
7110
  if (!(ns.videoBlobs && ns.videoBlobs.get(bgm.key)) && bgm.key.indexOf('blob:') !== 0 && bgm.key.indexOf('data:') !== 0) {
7102
7111
  try {
@@ -7110,22 +7119,22 @@ var SETUP = `async (ctx) => {
7110
7119
  } catch (e) { void e }
7111
7120
  }
7112
7121
  const bgWc = await makeWcProvider(bgm.key)
7113
- if (bgWc && (!bgV.videoWidth || (bgWc.width === bgV.videoWidth && bgWc.height === bgV.videoHeight))) bgV.__voilaWc = bgWc
7122
+ if (bgWc && (!bgV.videoWidth || (bgWc.width === bgV.videoWidth && bgWc.height === bgV.videoHeight))) bgV.__vosWc = bgWc
7114
7123
  } catch (e) {
7115
- console.warn('[voila] webcodecs provider unavailable for the background, seeks stay html5', e)
7124
+ console.warn('[studio] webcodecs provider unavailable for the background, seeks stay html5', e)
7116
7125
  }
7117
7126
  }
7118
7127
  }
7119
- } catch (e) { console.warn('[voila] background media failed to load', e) }
7128
+ } catch (e) { console.warn('[studio] background media failed to load', e) }
7120
7129
  }
7121
7130
  // Which path decodes what, for a host's telemetry: 'provider' is the
7122
7131
  // sequential WebCodecs walk, 'element' an HTMLVideoElement seek per frame.
7123
- ns.voilaDecode = {
7124
- recording: ctx.data.isImage ? 'none' : video.__voilaWc ? 'provider' : 'element',
7132
+ ns.studioDecode = {
7133
+ recording: ctx.data.isImage ? 'none' : video.__vosWc ? 'provider' : 'element',
7125
7134
  backdrop: (function () {
7126
7135
  if (!bgm || !bgm.key || bgm.kind === 'image') return 'none'
7127
7136
  const bgHit = cache.get(bgm.key)
7128
- return bgHit && bgHit.__voilaWc ? 'provider' : 'element'
7137
+ return bgHit && bgHit.__vosWc ? 'provider' : 'element'
7129
7138
  })(),
7130
7139
  }
7131
7140
  // Frame prep: a capture harness calls every ns.framePrep hook right after
@@ -7141,7 +7150,7 @@ var SETUP = `async (ctx) => {
7141
7150
  // chaining; playback never calls it; a harness without it keeps the
7142
7151
  // two-phase loop and stays correct.
7143
7152
  ns.framePrep = ns.framePrep || new Map()
7144
- ns.framePrep.set('voila.card', (t) => {
7153
+ ns.framePrep.set('studio.card', (t) => {
7145
7154
  if (ns.isPaused === false) return
7146
7155
  const d = ctx.data
7147
7156
  const TL = globalThis.__vosTimeline
@@ -7429,17 +7438,17 @@ var ON_FRAME = `(ctx, content, dt) => {
7429
7438
  // allow="autoplay" so the top frame's activation counts here).
7430
7439
  if (!vid.muted && err && err.name === 'NotAllowedError') {
7431
7440
  vid.muted = true
7432
- vid.__voilaAutoMuted = true
7441
+ vid.__vosAutoMuted = true
7433
7442
  var p2 = vid.play()
7434
7443
  if (p2 && p2.catch) p2.catch(function () {})
7435
7444
  }
7436
7445
  })
7437
- } else if (vid.__voilaAutoMuted && d.audioUnlocked) {
7446
+ } else if (vid.__vosAutoMuted && d.audioUnlocked) {
7438
7447
  // First user gesture happened \u2014 lift the policy fallback. If the
7439
7448
  // browser still objects it pauses the element, and the paused branch
7440
7449
  // above self-heals (re-mute + resume) on the next frame.
7441
7450
  vid.muted = false
7442
- vid.__voilaAutoMuted = false
7451
+ vid.__vosAutoMuted = false
7443
7452
  }
7444
7453
  } else {
7445
7454
  // The paused step (FRAME_STEP_SRC): the provider by PTS, else the
@@ -7759,7 +7768,7 @@ var ON_FRAME = `(ctx, content, dt) => {
7759
7768
  // The loop's provider draws its current sample (paused capture
7760
7769
  // pages); false \u21D2 the element (playback, or before the first ask).
7761
7770
  try {
7762
- var bgWcp = !bgIsImg && !playing ? bgEl.__voilaWc : null
7771
+ var bgWcp = !bgIsImg && !playing ? bgEl.__vosWc : null
7763
7772
  if (!(bgWcp && bgWcp.draw(bgC, null, (W - bgDw) / 2 + bgOx, (H - bgDh) / 2 + bgOy, bgDw, bgDh))) {
7764
7773
  bgC.drawImage(bgEl, (W - bgDw) / 2 + bgOx, (H - bgDh) / 2 + bgOy, bgDw, bgDh)
7765
7774
  }
@@ -8021,7 +8030,7 @@ var ON_FRAME = `(ctx, content, dt) => {
8021
8030
  // The provider draws the current sample directly (crop-capable,
8022
8031
  // dimension-checked at attach); false \u21D2 element path (pre-first-seek,
8023
8032
  // or the provider never attached).
8024
- var wcp3 = video.__voilaWc
8033
+ var wcp3 = video.__vosWc
8025
8034
  if (!(wcp3 && wcp3.draw(c, crp, ceX, ceY, ceW, ceH))) {
8026
8035
  if (crp) c.drawImage(video, crp.x, crp.y, crp.w, crp.h, ceX, ceY, ceW, ceH)
8027
8036
  else c.drawImage(video, ceX, ceY, ceW, ceH)
@@ -8580,10 +8589,10 @@ var ON_FRAME = `(ctx, content, dt) => {
8580
8589
  // gated inside their blocks (dirty-tracking above).
8581
8590
  if (card.texture) card.texture.needsUpdate = true
8582
8591
 
8583
- // verification hook (no-op unless the harness sets window.__VOILA_DEBUG__).
8592
+ // verification hook (no-op unless the harness sets window.__STUDIO_DEBUG__).
8584
8593
  // cv = the CARD 2D canvas; bgCv/ovCv are the background/overlay layers (v2).
8585
- if (typeof window !== 'undefined' && window.__VOILA_DEBUG__) {
8586
- window.__voilaDebug = { cv: cv, bgCv: bg.canvas, ovCv: ov.canvas, W: W, H: H, glW: gl && gl.width, glH: gl && gl.height, t: t, canvases: document.querySelectorAll('canvas').length }
8594
+ if (typeof window !== 'undefined' && window.__STUDIO_DEBUG__) {
8595
+ window.__studioDebug = { cv: cv, bgCv: bg.canvas, ovCv: ov.canvas, W: W, H: H, glW: gl && gl.width, glH: gl && gl.height, t: t, canvases: document.querySelectorAll('canvas').length }
8587
8596
  }
8588
8597
  }`;
8589
8598
  function studioLayerData(input, duration, pins) {
@@ -9238,13 +9247,15 @@ var DEFAULT_SPEED_PARAMS = {
9238
9247
  typingMin: 3,
9239
9248
  typingRate: 3,
9240
9249
  scrollMin: 2.5,
9241
- scrollRate: 2
9250
+ scrollRate: 2,
9251
+ stillRate: 2
9242
9252
  };
9243
9253
  var TYPING_GAP = 1.5;
9244
9254
  var SCROLL_GAP = 0.8;
9245
9255
  var IDLE_PAD = 0.6;
9246
9256
  var MIN_SPAN = 1;
9247
9257
  var PLAYBACK_ACTIVITY = 0.1;
9258
+ var STILL_ACTIVITY = 0.02;
9248
9259
  function planAutoSpeed(track, opts) {
9249
9260
  const p = { ...DEFAULT_SPEED_PARAMS, ...opts.params };
9250
9261
  const durS = opts.durationMs / 1e3;
@@ -9263,6 +9274,9 @@ function planAutoSpeed(track, opts) {
9263
9274
  p.scrollMin,
9264
9275
  (start, last) => cands.push({ in: start, out: last, rate: p.scrollRate })
9265
9276
  );
9277
+ for (const [a, b] of stillSteps(opts.steps, opts.activity)) {
9278
+ if (b - a >= MIN_SPAN) cands.push({ in: a, out: b, rate: p.stillRate });
9279
+ }
9266
9280
  for (const [a, b] of idleGaps(evs, durS, p.idleMin)) {
9267
9281
  if (isPlayback(opts.activity, a, b)) continue;
9268
9282
  const start = a + IDLE_PAD;
@@ -9316,6 +9330,29 @@ function idleGaps(track, durationS, idleMin = DEFAULT_SPEED_PARAMS.idleMin) {
9316
9330
  if (durationS - prev >= idleMin) gaps.push([prev, durationS]);
9317
9331
  return gaps;
9318
9332
  }
9333
+ function stillSteps(steps, activity) {
9334
+ if (!steps?.length || !activity?.length) return [];
9335
+ const live = steps.filter((st) => !st.skipped);
9336
+ const doing = live.map((st) => st.do !== "wait");
9337
+ const first = doing.indexOf(true);
9338
+ const last = doing.lastIndexOf(true);
9339
+ const out = [];
9340
+ for (const [i, st] of live.entries()) {
9341
+ if (st.do !== "wait" && st.do !== "hover") continue;
9342
+ if (st.do === "wait" && (first < 0 || i < first || i > last)) continue;
9343
+ if (meanActivity(activity, st.tStart, st.tEnd) >= STILL_ACTIVITY) continue;
9344
+ out.push([st.tStart, st.tEnd]);
9345
+ }
9346
+ return out;
9347
+ }
9348
+ function meanActivity(activity, a, b) {
9349
+ const lo = Math.max(0, Math.floor(a));
9350
+ const hi = Math.min(activity.length, Math.ceil(b));
9351
+ if (hi <= lo) return 0;
9352
+ let sum = 0;
9353
+ for (let i = lo; i < hi; i++) sum += activity[i];
9354
+ return sum / (hi - lo);
9355
+ }
9319
9356
  function isPlayback(activity, a, b) {
9320
9357
  if (!activity?.length) return false;
9321
9358
  const lo = Math.max(0, Math.floor(a));
@@ -9354,12 +9391,14 @@ function planForDigest(doc, activity) {
9354
9391
  width: meta.width,
9355
9392
  height: meta.height,
9356
9393
  style: doc.zoomStyle,
9357
- params: doc.zoomParams
9394
+ params: doc.zoomParams,
9395
+ steps: meta.steps
9358
9396
  });
9359
9397
  const speed = planAutoSpeed(cursor, {
9360
9398
  durationMs: meta.durationMs,
9361
9399
  params: doc.speedParams,
9362
- activity
9400
+ activity,
9401
+ steps: meta.steps
9363
9402
  });
9364
9403
  const style = resolveZoomStyle(doc.zoomStyle, doc.zoomParams);
9365
9404
  const intensity = doc.tiltStyle ?? style.tilt.intensity;
@@ -9399,7 +9438,8 @@ function momentsFromDoc(doc, plan, opts = {}) {
9399
9438
  typingGap: style.typingGap,
9400
9439
  typingZoom: style.typingZoom,
9401
9440
  targetFill: style.targetFill,
9402
- minLevel: style.minLevel
9441
+ minLevel: style.minLevel,
9442
+ steps: meta.steps
9403
9443
  });
9404
9444
  const pressed = (c, surface) => {
9405
9445
  const f = clusterFocus(c, width, height);
@@ -9495,15 +9535,30 @@ function momentsFromDoc(doc, plan, opts = {}) {
9495
9535
  rect: null
9496
9536
  });
9497
9537
  }
9538
+ for (const c of opts.changes ?? []) {
9539
+ if (!(c.at >= 0 && c.at < dur)) continue;
9540
+ const peak = Math.min(dur, Math.max(c.at, c.peakAt));
9541
+ drafts.push({
9542
+ kind: "changed",
9543
+ in: c.at,
9544
+ out: peak,
9545
+ at: peak,
9546
+ focus: { cx: c.rect.x + c.rect.w / 2, cy: c.rect.y + c.rect.h / 2 },
9547
+ rect: c.rect,
9548
+ seedAt: c.at,
9549
+ fraction: c.fraction
9550
+ });
9551
+ }
9498
9552
  const order = {
9499
9553
  head: 0,
9500
9554
  click: 1,
9501
9555
  typing: 2,
9502
- scroll: 3,
9503
- dwell: 4,
9504
- scene: 5,
9505
- idle: 6,
9506
- tail: 7
9556
+ changed: 3,
9557
+ scroll: 4,
9558
+ dwell: 5,
9559
+ scene: 6,
9560
+ idle: 7,
9561
+ tail: 8
9507
9562
  };
9508
9563
  drafts.sort((a, b) => a.in - b.in || order[a.kind] - order[b.kind]);
9509
9564
  const rated = ratedSegments(doc);
@@ -9512,8 +9567,17 @@ function momentsFromDoc(doc, plan, opts = {}) {
9512
9567
  return spanOutputExtent(rated, lo, Math.max(b, lo + 1e-3));
9513
9568
  };
9514
9569
  const covers = (s, d) => d.in === d.out ? s.in <= d.in && d.in < s.out : s.in < d.out && s.out > d.in;
9570
+ const ids = drafts.map((_, i) => `m${String(i + 1).padStart(2, "0")}`);
9571
+ const seedOf = (d) => {
9572
+ if (d.kind !== "changed" || d.seedAt === void 0) return void 0;
9573
+ const j = drafts.findIndex(
9574
+ (x) => x.kind === "click" && x.at === d.seedAt || x.kind === "typing" && x.out === d.seedAt
9575
+ );
9576
+ return j >= 0 ? ids[j] : void 0;
9577
+ };
9515
9578
  return drafts.map((d, i) => {
9516
9579
  const output = outputOf(d.in, d.out);
9580
+ const seed = seedOf(d);
9517
9581
  const outAt = d.at === null ? null : outputOf(d.at, d.at);
9518
9582
  const proposed = {};
9519
9583
  const z = plan.zoom.find((s) => covers(s, d));
@@ -9523,7 +9587,7 @@ function momentsFromDoc(doc, plan, opts = {}) {
9523
9587
  const tl = plan.tilt.find((s) => covers(s, d));
9524
9588
  if (tl) proposed.tilt = tl.id;
9525
9589
  return {
9526
- id: `m${String(i + 1).padStart(2, "0")}`,
9590
+ id: ids[i],
9527
9591
  kind: d.kind,
9528
9592
  source: { in: round10(d.in), out: round10(d.out) },
9529
9593
  output: output ? { in: round10(output.start), out: round10(output.end) } : null,
@@ -9539,9 +9603,10 @@ function momentsFromDoc(doc, plan, opts = {}) {
9539
9603
  ...d.clicks !== void 0 ? { clicks: d.clicks } : {},
9540
9604
  ...d.surface ? { surface: true } : {},
9541
9605
  ...d.pings !== void 0 ? { pings: d.pings } : {},
9542
- activity: activityOf(opts.bins, d),
9606
+ activity: d.kind === "changed" && d.fraction !== void 0 ? round10(d.fraction) : activityOf(opts.bins, d),
9543
9607
  proposed,
9544
- said: saidOver(opts.transcript, d)
9608
+ said: saidOver(opts.transcript, d),
9609
+ ...seed ? { seed } : {}
9545
9610
  };
9546
9611
  });
9547
9612
  }
@@ -9568,6 +9633,105 @@ function round10(v) {
9568
9633
  return Math.round(v * 1e3) / 1e3;
9569
9634
  }
9570
9635
 
9636
+ // src/digest/changes.ts
9637
+ var CHANGE_GRID = { w: 128, h: 72 };
9638
+ var BLOCK_FILL = 0.5;
9639
+ var CHANGE_FLOOR = 0.02;
9640
+ var CHANGE_OFFSETS = [0.3, 0.8, 1.5, 2.5];
9641
+ var CHANGE_LEAD = 0.5;
9642
+ function changedRegion(before, after, delta, minBlocks = 1, ambient = null, block = Math.max(1, Math.round(before.w / 16))) {
9643
+ const { w, h } = before;
9644
+ if (after.w !== w || after.h !== h || w <= 0 || h <= 0) return null;
9645
+ const cols = Math.ceil(w / block);
9646
+ const rows = Math.ceil(h / block);
9647
+ const moved = new Uint16Array(cols * rows);
9648
+ const cells = new Uint16Array(cols * rows);
9649
+ for (let y = 0; y < h; y++) {
9650
+ const by = Math.floor(y / block);
9651
+ for (let x = 0; x < w; x++) {
9652
+ const i = y * w + x;
9653
+ const b = by * cols + Math.floor(x / block);
9654
+ cells[b]++;
9655
+ const drift = ambient ? ambient.rate[i] * ambient.dt : 0;
9656
+ if (Math.abs(after.luma[i] - before.luma[i]) - drift > delta) moved[b]++;
9657
+ }
9658
+ }
9659
+ const filled = new Uint8Array(cols * rows);
9660
+ for (let b = 0; b < filled.length; b++)
9661
+ if (moved[b] >= cells[b] * BLOCK_FILL) filled[b] = 1;
9662
+ const seen = new Uint8Array(cols * rows);
9663
+ let best = null;
9664
+ for (let start = 0; start < filled.length; start++) {
9665
+ if (!filled[start] || seen[start]) continue;
9666
+ const stack = [start];
9667
+ seen[start] = 1;
9668
+ let size = 0;
9669
+ let minX = cols;
9670
+ let minY = rows;
9671
+ let maxX = -1;
9672
+ let maxY = -1;
9673
+ while (stack.length) {
9674
+ const b = stack.pop();
9675
+ size++;
9676
+ const bx = b % cols;
9677
+ const by = Math.floor(b / cols);
9678
+ if (bx < minX) minX = bx;
9679
+ if (bx > maxX) maxX = bx;
9680
+ if (by < minY) minY = by;
9681
+ if (by > maxY) maxY = by;
9682
+ for (const [dx, dy] of [
9683
+ [1, 0],
9684
+ [-1, 0],
9685
+ [0, 1],
9686
+ [0, -1]
9687
+ ]) {
9688
+ const nx = bx + dx;
9689
+ const ny = by + dy;
9690
+ if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
9691
+ const n = ny * cols + nx;
9692
+ if (filled[n] && !seen[n]) {
9693
+ seen[n] = 1;
9694
+ stack.push(n);
9695
+ }
9696
+ }
9697
+ }
9698
+ if (!best || size > best.size) best = { size, minX, minY, maxX, maxY };
9699
+ }
9700
+ if (!best || best.size < minBlocks) return null;
9701
+ return {
9702
+ fraction: best.size / (cols * rows),
9703
+ rect: {
9704
+ x: best.minX * block / w,
9705
+ y: best.minY * block / h,
9706
+ w: Math.min(w, (best.maxX + 1) * block) / w - best.minX * block / w,
9707
+ h: Math.min(h, (best.maxY + 1) * block) / h - best.minY * block / h
9708
+ }
9709
+ };
9710
+ }
9711
+ function ambientDrift(earlier, before, lead) {
9712
+ const { w, h } = before;
9713
+ if (earlier.w !== w || earlier.h !== h || !(lead > 0)) return null;
9714
+ const rate = new Array(w * h);
9715
+ for (let i = 0; i < rate.length; i++)
9716
+ rate[i] = Math.abs(before.luma[i] - earlier.luma[i]) / lead;
9717
+ return rate;
9718
+ }
9719
+ function pickChange(seed, samples, floor = CHANGE_FLOOR) {
9720
+ let best = null;
9721
+ for (const s of samples) {
9722
+ if (!s.region || s.region.fraction < floor) continue;
9723
+ if (!best || s.region.fraction > (best.region?.fraction ?? 0)) best = s;
9724
+ }
9725
+ if (!best?.region) return null;
9726
+ return {
9727
+ seed: seed.id,
9728
+ at: seed.at,
9729
+ peakAt: seed.at + best.dt,
9730
+ fraction: best.region.fraction,
9731
+ rect: best.region.rect
9732
+ };
9733
+ }
9734
+
9571
9735
  // src/digest/scenes.ts
9572
9736
  var SCENE_MOTION = 0.25;
9573
9737
  var SCENE_QUIET = 0.1;
@@ -10099,6 +10263,7 @@ function buildDigest(input) {
10099
10263
  activity: "fraction of pixels that changed, per SOURCE second"
10100
10264
  },
10101
10265
  moments: withFiles,
10266
+ steps: digestSteps(doc),
10102
10267
  activity: input.bins,
10103
10268
  plan: input.plan,
10104
10269
  doc: {
@@ -10122,6 +10287,26 @@ function buildDigest(input) {
10122
10287
  }
10123
10288
  };
10124
10289
  }
10290
+ function digestSteps(doc) {
10291
+ const steps = doc.source.meta.steps ?? [];
10292
+ if (!steps.length) return [];
10293
+ const rated = ratedSegments(doc);
10294
+ return steps.map((s) => {
10295
+ const out = spanOutputExtent(rated, s.tStart, s.tEnd);
10296
+ return {
10297
+ step: s.step,
10298
+ id: s.id ?? null,
10299
+ do: s.do,
10300
+ selector: s.selector ?? null,
10301
+ source: { in: round11(s.tStart), out: round11(s.tEnd) },
10302
+ output: out ? { in: round11(out.start), out: round11(out.end) } : null,
10303
+ skipped: s.skipped === true,
10304
+ navigated: s.navigated === true,
10305
+ url: s.url ?? null,
10306
+ text: s.text ?? null
10307
+ };
10308
+ });
10309
+ }
10125
10310
  function outputDurationOf(doc) {
10126
10311
  return docOutputDuration(doc);
10127
10312
  }
@@ -11956,6 +12141,10 @@ export {
11956
12141
  CARD_FADE_SECONDS,
11957
12142
  CARD_FOV,
11958
12143
  CARD_Z,
12144
+ CHANGE_FLOOR,
12145
+ CHANGE_GRID,
12146
+ CHANGE_LEAD,
12147
+ CHANGE_OFFSETS,
11959
12148
  CHANNEL_SPECS_HASH,
11960
12149
  CHANNEL_SPECS_VERIFIED,
11961
12150
  CLICK_FX_INTENSITY,
@@ -12105,6 +12294,7 @@ export {
12105
12294
  ZOOM_SPAN_MIN,
12106
12295
  ZOOM_STYLES,
12107
12296
  ZOOM_STYLE_OPTIONS,
12297
+ ambientDrift,
12108
12298
  anchorKindOf,
12109
12299
  anchorSourceDuration,
12110
12300
  animStep,
@@ -12137,6 +12327,7 @@ export {
12137
12327
  cardPoseTrack,
12138
12328
  caretAt,
12139
12329
  caretStops,
12330
+ changedRegion,
12140
12331
  clampCamFrac,
12141
12332
  clampCamSize,
12142
12333
  clampFocus,
@@ -12289,6 +12480,7 @@ export {
12289
12480
  pageDisplayUrl,
12290
12481
  parseCssColour,
12291
12482
  parsePoseId,
12483
+ pickChange,
12292
12484
  pickStyle,
12293
12485
  pinBox,
12294
12486
  pinCandidates,