@vosjs/studio-core 0.15.1 → 0.17.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/dist/index.js CHANGED
@@ -98,6 +98,8 @@ function clipLength(clip) {
98
98
  return clip.loop ? Math.max(span, clip.loopLen ?? span) : span;
99
99
  }
100
100
  var MOTION_EASE = "power2.inOut";
101
+ var LAYER_CARD_SHADOW = 0.32;
102
+ var MEDIA_KEY_PREFIX = "media:";
101
103
  var OVERLAY_SCALE_MAX = 8;
102
104
  var OVERLAY_MEDIA_DEFAULT_WIDTH = 0.35;
103
105
  var OVERLAY_MEDIA_DEFAULT_RADIUS = 12;
@@ -582,6 +584,15 @@ function cardFields(frame) {
582
584
  }
583
585
  return out;
584
586
  }
587
+ var mediaRef = (id) => MEDIA_KEY_PREFIX + id;
588
+ function mediaRefId(key) {
589
+ if (typeof key !== "string" || !key.startsWith(MEDIA_KEY_PREFIX)) return null;
590
+ return key.slice(MEDIA_KEY_PREFIX.length);
591
+ }
592
+ function layerMedia(doc, key) {
593
+ const id = mediaRefId(key);
594
+ return id === null ? null : mediaSource(doc, id);
595
+ }
585
596
  function nextMediaId(doc) {
586
597
  const taken = new Set((doc.media ?? []).map((m) => m.id));
587
598
  let n = 1;
@@ -590,6 +601,50 @@ function nextMediaId(doc) {
590
601
  }
591
602
 
592
603
  // src/lower/motion.ts
604
+ function ownFreezes(doc) {
605
+ return docFreezes(doc).filter((f) => !f.from);
606
+ }
607
+ var STILL_DEFAULT = 0.5;
608
+ var HERO_BEAT = 0.25;
609
+ var OPENING_WINDOW = 3;
610
+ var OPENING_ENTER_SECONDS = 0.6;
611
+ var r3 = (v) => Math.round(v * 1e3) / 1e3;
612
+ function docStillTime(doc) {
613
+ const segs = docSegmentsOf(doc);
614
+ const rated = withFreezes(
615
+ splitBySpeed(segs, doc.speed ?? []),
616
+ docFreezes(doc)
617
+ );
618
+ const footage = totalDuration(rated);
619
+ const end = outputEnd(doc, footage);
620
+ const clampOut = (v) => Math.max(0, Math.min(end, v));
621
+ if (typeof doc.still === "number" && Number.isFinite(doc.still))
622
+ return r3(clampOut(doc.still));
623
+ if (!doc.endCard) {
624
+ const own = ownFreezes(doc);
625
+ if (own.length) {
626
+ const marks = freezeOutputExtents(
627
+ splitBySpeed(segs, doc.speed ?? []),
628
+ own
629
+ );
630
+ let rest = null;
631
+ for (const m of marks.values())
632
+ if (rest === null || m.t > rest) rest = m.t;
633
+ if (rest !== null && rest >= 0) return r3(clampOut(rest));
634
+ }
635
+ }
636
+ if (!(footage > 0)) return r3(clampOut(STILL_DEFAULT));
637
+ let hero = entranceSeconds(cardEnter(doc.frame));
638
+ for (const clip of doc.overlays ?? []) {
639
+ if (clip.start > OPENING_WINDOW) continue;
640
+ const step = enterOf(clip.anim);
641
+ const seconds = !step || step.kind === "none" ? 0 : Math.max(0, step.seconds ?? OPENING_ENTER_SECONDS);
642
+ hero = Math.max(hero, clip.start + seconds);
643
+ }
644
+ hero = Math.max(STILL_DEFAULT, hero + HERO_BEAT);
645
+ hero = Math.min(hero, Math.max(0, footage - 1 / 30));
646
+ return r3(clampOut(hero));
647
+ }
593
648
  function docRestTime(doc) {
594
649
  if (doc.endCard) return null;
595
650
  const freezes = docFreezes(doc);
@@ -2654,6 +2709,8 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2654
2709
  // over its whole life, so it must repaint every visible frame \u2014 the
2655
2710
  // signature alone would freeze it between edits (the olVisSig trap).
2656
2711
  if (ol0.track) olAnim = true
2712
+ // A layer CARD is painted on its own plane every frame it is visible.
2713
+ if (ol0.card) olAnim = true
2657
2714
  // Video overlays advance every frame; images redraw until decoded.
2658
2715
  if (ol0.kind === 'video') olAnim = true
2659
2716
  else if (ol0.kind === 'image') {
@@ -2663,6 +2720,76 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2663
2720
  }
2664
2721
  var ovSig = W + 'x' + H + '|' + olVisSig
2665
2722
  var ovDirty = ov.sig !== ovSig || ov.active || olAnim
2723
+ // The layer cards visible this frame, with their pose, for the card
2724
+ // planes below (the overlay loop collects them instead of painting).
2725
+ var lcList = []
2726
+ // Acquire a media overlay's element through the shared cache and sync it
2727
+ // to CLIP-LOCAL time (pure f(t); muted always \u2014 soundtracks belong to
2728
+ // doc.audio). Null until it has a frame to draw. One seam for the flat
2729
+ // picture and the layer card.
2730
+ function olAcquire(ol, olT) {
2731
+ var olEl = ns.videoCache ? ns.videoCache.get(ol.key) : null
2732
+ if (!olEl && ns.videoCache) {
2733
+ if (ol.kind === 'image') {
2734
+ olEl = new Image()
2735
+ olEl.crossOrigin = 'anonymous'
2736
+ olEl.src = ol.key
2737
+ } else {
2738
+ olEl = document.createElement('video')
2739
+ olEl.crossOrigin = 'anonymous'
2740
+ olEl.muted = true
2741
+ olEl.playsInline = true
2742
+ olEl.preload = 'auto'
2743
+ olEl.src = ol.key
2744
+ olEl.load()
2745
+ }
2746
+ ns.videoCache.set(ol.key, olEl)
2747
+ }
2748
+ if (!olEl) return null
2749
+ var olIsImg = ol.kind === 'image'
2750
+ if (!olIsImg && olEl.play) {
2751
+ var olDur = olEl.duration || 0
2752
+ var olMT = olT
2753
+ if (ol.loop && olDur > 0) olMT = olT % olDur
2754
+ else if (olDur > 0) olMT = Math.min(olT, olDur - 0.001)
2755
+ try {
2756
+ if (playing) {
2757
+ if (olEl.playbackRate !== 1) olEl.playbackRate = 1
2758
+ var olDrift = Math.abs(olEl.currentTime - olMT)
2759
+ if (olDur > 0 && !olEl.seeking && olDrift > 0.3 && (!ol.loop || olDur - olDrift > 0.3)) olEl.currentTime = olMT
2760
+ if (!ol.loop && olDur > 0 && olT >= olDur) { if (!olEl.paused) olEl.pause() }
2761
+ else if (olEl.paused) { var olP = olEl.play(); if (olP && olP.catch) olP.catch(function () {}) }
2762
+ } else {
2763
+ if (!olEl.paused) olEl.pause()
2764
+ var olTarget = Math.min(olMT, olEl.duration || olMT)
2765
+ // Coalesce scrub seeks (the backgroundMedia pattern): re-assigning
2766
+ // currentTime aborts the in-flight seek, so a per-frame scrub keeps
2767
+ // a remote source seeking forever. Defer until 'seeked' lands.
2768
+ if (olEl.readyState >= 1 && !olEl.seeking && Math.abs(olEl.currentTime - olTarget) > 0.02) {
2769
+ if (ns.pendingDecodes) {
2770
+ var olDp = new Promise(function (resolve) {
2771
+ var olDone = function () { olEl.removeEventListener('seeked', olDone); resolve() }
2772
+ olEl.addEventListener('seeked', olDone)
2773
+ setTimeout(olDone, 250)
2774
+ })
2775
+ ns.pendingDecodes.add(olDp)
2776
+ olDp.finally(function () { ns.pendingDecodes.delete(olDp) })
2777
+ }
2778
+ olEl.currentTime = olTarget
2779
+ }
2780
+ }
2781
+ } catch (e) {}
2782
+ }
2783
+ // Video readiness is STICKY through seeks (the cam-bubble pattern):
2784
+ // readyState dips below HAVE_CURRENT_DATA while a scrub seek is in
2785
+ // flight, and this layer repaints every frame a video clip is visible \u2014
2786
+ // gating each frame on it would blink the clip out for the whole drag.
2787
+ // After the first decoded frame, keep drawing: Chrome paints the
2788
+ // element's retained frame mid-seek.
2789
+ if (!olIsImg && olEl.readyState >= 2) olEl.__vosHasFrame = true
2790
+ var olReady = olIsImg ? !!(olEl.complete && olEl.naturalWidth) : !!(olEl.readyState >= 2 || olEl.__vosHasFrame)
2791
+ return olReady ? olEl : null
2792
+ }
2666
2793
  if (ovDirty) {
2667
2794
  ovC.clearRect(0, 0, W, H)
2668
2795
  // --- text overlays (compositor v2): OUTPUT-anchored clips, styles resolved
@@ -2720,71 +2847,19 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2720
2847
  if (olPose) olA *= Math.max(0, Math.min(1, olPose[4]))
2721
2848
  if (olA <= 0.004) continue
2722
2849
  if (ol.kind === 'image' || ol.kind === 'video') {
2850
+ // A layer CARD is painted by the card painter on its own plane, below:
2851
+ // collect it with its pose and skip the flat picture.
2852
+ if (ol.card) {
2853
+ lcList.push({ ol: ol, t: olT, a: olA * (ol.opacity == null ? 1 : ol.opacity), x: olMX, y: olMY, s: olMS, r: olMR, yof: olYof })
2854
+ continue
2855
+ }
2723
2856
  // Media overlay: lazy-acquire through the shared cache (SET_DATA-added
2724
2857
  // clips load without a LOAD \u2014 the backgroundMedia pattern), sync video
2725
2858
  // to CLIP-LOCAL time (pure f(t)), draw a rounded media card centered on
2726
2859
  // the fraction anchor. Muted always \u2014 soundtracks belong to doc.audio.
2727
- var olEl = ns.videoCache ? ns.videoCache.get(ol.key) : null
2728
- if (!olEl && ns.videoCache) {
2729
- if (ol.kind === 'image') {
2730
- olEl = new Image()
2731
- olEl.crossOrigin = 'anonymous'
2732
- olEl.src = ol.key
2733
- } else {
2734
- olEl = document.createElement('video')
2735
- olEl.crossOrigin = 'anonymous'
2736
- olEl.muted = true
2737
- olEl.playsInline = true
2738
- olEl.preload = 'auto'
2739
- olEl.src = ol.key
2740
- olEl.load()
2741
- }
2742
- ns.videoCache.set(ol.key, olEl)
2743
- }
2860
+ var olEl = olAcquire(ol, olT)
2744
2861
  if (!olEl) continue
2745
2862
  var olIsImg = ol.kind === 'image'
2746
- if (!olIsImg && olEl.play) {
2747
- var olDur = olEl.duration || 0
2748
- var olMT = olT
2749
- if (ol.loop && olDur > 0) olMT = olT % olDur
2750
- else if (olDur > 0) olMT = Math.min(olT, olDur - 0.001)
2751
- try {
2752
- if (playing) {
2753
- if (olEl.playbackRate !== 1) olEl.playbackRate = 1
2754
- var olDrift = Math.abs(olEl.currentTime - olMT)
2755
- if (olDur > 0 && !olEl.seeking && olDrift > 0.3 && (!ol.loop || olDur - olDrift > 0.3)) olEl.currentTime = olMT
2756
- if (!ol.loop && olDur > 0 && olT >= olDur) { if (!olEl.paused) olEl.pause() }
2757
- else if (olEl.paused) { var olP = olEl.play(); if (olP && olP.catch) olP.catch(function () {}) }
2758
- } else {
2759
- if (!olEl.paused) olEl.pause()
2760
- var olTarget = Math.min(olMT, olEl.duration || olMT)
2761
- // Coalesce scrub seeks (the backgroundMedia pattern): re-assigning
2762
- // currentTime aborts the in-flight seek, so a per-frame scrub keeps
2763
- // a remote source seeking forever. Defer until 'seeked' lands.
2764
- if (olEl.readyState >= 1 && !olEl.seeking && Math.abs(olEl.currentTime - olTarget) > 0.02) {
2765
- if (ns.pendingDecodes) {
2766
- var olDp = new Promise(function (resolve) {
2767
- var olDone = function () { olEl.removeEventListener('seeked', olDone); resolve() }
2768
- olEl.addEventListener('seeked', olDone)
2769
- setTimeout(olDone, 250)
2770
- })
2771
- ns.pendingDecodes.add(olDp)
2772
- olDp.finally(function () { ns.pendingDecodes.delete(olDp) })
2773
- }
2774
- olEl.currentTime = olTarget
2775
- }
2776
- }
2777
- } catch (e) {}
2778
- }
2779
- // Video readiness is STICKY through seeks (the cam-bubble pattern):
2780
- // readyState dips below HAVE_CURRENT_DATA while a scrub seek is in
2781
- // flight, and this layer repaints every frame a video clip is visible \u2014
2782
- // gating each frame on it would blink the clip out for the whole drag.
2783
- // After the first decoded frame, keep drawing: Chrome paints the
2784
- // element's retained frame mid-seek.
2785
- if (!olIsImg && olEl.readyState >= 2) olEl.__vosHasFrame = true
2786
- var olReady = olIsImg ? !!(olEl.complete && olEl.naturalWidth) : !!(olEl.readyState >= 2 || olEl.__vosHasFrame)
2787
- if (!olReady) continue
2788
2863
  var olNW = (olIsImg ? olEl.naturalWidth : olEl.videoWidth) || 16
2789
2864
  var olNH = (olIsImg ? olEl.naturalHeight : olEl.videoHeight) || 9
2790
2865
  var olDW = ol.w * W * olMS
@@ -3296,6 +3371,194 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
3296
3371
  }
3297
3372
  obM.scale.setScalar(obPS * obRefH * (obE.norm || 1))
3298
3373
  }
3374
+ // --- layer cards (many cards): a media overlay that names a frame is
3375
+ // drawn by the card painter on its OWN plane in the prop group
3376
+ // (renderOrder 1.25: above the primary card, below the props): the
3377
+ // layered shadow (the primary's table and strengths), the media clipped
3378
+ // to its corners, the browser bar after it, the border, and for a
3379
+ // document media its click rings and cursor dot inside the card, at
3380
+ // clip-local time. Chrome scales WITH the card (a smaller window has
3381
+ // smaller chrome), the way the primary's scales with cf. The plane is
3382
+ // sized to the canvas at the card depth, placed at the clip's anchor,
3383
+ // leaned by the card's own rx/ry (the tilt convention) and turned by the
3384
+ // clip's rotation. Locals lc*.
3385
+ var lcC = obC.cards || (obC.cards = new Map())
3386
+ var lcSeen = {}
3387
+ var lcK = obRefH / H
3388
+ for (var lci = 0; lci < lcList.length; lci++) {
3389
+ var lcI = lcList[lci], lcO = lcI.ol, lcCard = lcO.card
3390
+ var lcEl = olAcquire(lcO, lcI.t)
3391
+ if (!lcEl) continue
3392
+ lcSeen[lcO.id] = true
3393
+ var lcE = lcC.get(lcO.id)
3394
+ if (!lcE) {
3395
+ var lcCv = document.createElement('canvas')
3396
+ var lcTx = new THREE3.CanvasTexture(lcCv)
3397
+ if (THREE3.SRGBColorSpace) lcTx.colorSpace = THREE3.SRGBColorSpace
3398
+ if (THREE3.LinearFilter) { lcTx.minFilter = THREE3.LinearFilter; lcTx.magFilter = THREE3.LinearFilter }
3399
+ lcTx.generateMipmaps = false
3400
+ var lcM = new THREE3.Mesh(new THREE3.PlaneGeometry(1, 1), new THREE3.MeshBasicMaterial({ map: lcTx, transparent: true, depthTest: false, depthWrite: false }))
3401
+ lcM.renderOrder = 1.25
3402
+ lcM.frustumCulled = false
3403
+ obC.group.add(lcM)
3404
+ lcE = { mesh: lcM, canvas: lcCv, c2d: lcCv.getContext('2d'), texture: lcTx }
3405
+ lcC.set(lcO.id, lcE)
3406
+ }
3407
+ var lcIsImg = lcO.kind === 'image'
3408
+ var lcCrop = lcO.crop || null
3409
+ var lcNW = lcCrop ? lcCrop.w : ((lcIsImg ? lcEl.naturalWidth : lcEl.videoWidth) || 16)
3410
+ var lcNH = lcCrop ? lcCrop.h : ((lcIsImg ? lcEl.naturalHeight : lcEl.videoHeight) || 9)
3411
+ var lcW = Math.max(2, lcO.w * W * lcI.s)
3412
+ var lcS = s * (lcW / W)
3413
+ var lcBar = lcCard.bar || {}
3414
+ var lcBarH = lcBar.kind && lcBar.kind !== 'none' ? (lcBar.height || 44) * lcS : 0
3415
+ var lcMH = lcW * (lcNH / lcNW)
3416
+ var lcH = lcMH + lcBarH
3417
+ var lcRad = Math.min((lcO.radius || 0) * lcS, lcH / 2)
3418
+ var lcMg = Math.ceil(170 * lcS)
3419
+ var lcCW = Math.ceil(lcW + 2 * lcMg), lcCH = Math.ceil(lcH + 2 * lcMg)
3420
+ var lcCv2 = lcE.canvas, lcX = lcE.c2d
3421
+ if (lcCv2.width !== lcCW || lcCv2.height !== lcCH) { lcCv2.width = lcCW; lcCv2.height = lcCH }
3422
+ lcX.clearRect(0, 0, lcCW, lcCH)
3423
+ var lcX0 = lcMg, lcY0 = lcMg
3424
+ var lcSh = lcCard.shadow == null ? ${LAYER_CARD_SHADOW} : lcCard.shadow
3425
+ var lcRgb = '0,0,0'
3426
+ var lcHex = lcCard.shadowColor
3427
+ if (typeof lcHex === 'string' && /^#[0-9a-fA-F]{6}$/.test(lcHex)) lcRgb = parseInt(lcHex.slice(1, 3), 16) + ',' + parseInt(lcHex.slice(3, 5), 16) + ',' + parseInt(lcHex.slice(5, 7), 16)
3428
+ // the shadows are cast by a body drawn off the canvas and brought back
3429
+ // by shadowOffsetX (the primary's seam rule)
3430
+ var lcD = lcCW * 2 + lcCH * 2
3431
+ if (lcSh > 0) {
3432
+ var lcL = [[2, 1, 0.1], [12, 4, 0.1], [40, 16, 0.14], [120, 44, 0.3]]
3433
+ for (var lcj = 0; lcj < lcL.length; lcj++) {
3434
+ lcX.save()
3435
+ lcX.shadowColor = 'rgba(' + lcRgb + ',' + +(lcSh * lcL[lcj][2]).toFixed(3) + ')'
3436
+ lcX.shadowBlur = lcL[lcj][0] * lcS; lcX.shadowOffsetX = lcD; lcX.shadowOffsetY = lcL[lcj][1] * lcS
3437
+ lcX.fillStyle = '#000'
3438
+ rr(lcX0 - lcD, lcY0, lcW, lcH, lcRad, lcX); lcX.fill()
3439
+ lcX.restore()
3440
+ }
3441
+ }
3442
+ if (lcCard.shadowContact > 0) {
3443
+ lcX.save()
3444
+ lcX.shadowColor = 'rgba(' + lcRgb + ',' + lcCard.shadowContact + ')'
3445
+ lcX.shadowBlur = 10 * lcS; lcX.shadowOffsetX = lcD; lcX.shadowOffsetY = 3 * lcS
3446
+ lcX.fillStyle = '#000'
3447
+ rr(lcX0 - lcD, lcY0, lcW, lcH, lcRad, lcX); lcX.fill()
3448
+ lcX.restore()
3449
+ }
3450
+ lcX.save()
3451
+ rr(lcX0, lcY0, lcW, lcH, lcRad, lcX); lcX.clip()
3452
+ var lcMY = lcY0 + lcBarH
3453
+ try {
3454
+ if (lcCrop) lcX.drawImage(lcEl, lcCrop.x, lcCrop.y, lcCrop.w, lcCrop.h, lcX0, lcMY, lcW, lcMH)
3455
+ else lcX.drawImage(lcEl, lcX0, lcMY, lcW, lcMH)
3456
+ } catch (e) {}
3457
+ if (lcBarH > 0) {
3458
+ var lcDark = lcBar.kind.indexOf('dark') >= 0, lcMin = lcBar.kind === 'minimal'
3459
+ var lcThm = (lcMin && lcBar.theme) || null
3460
+ lcX.fillStyle = lcMin ? (lcThm ? lcThm.bar : '#141417') : lcDark ? '#2a2a2e' : '#e9e9eb'
3461
+ lcX.fillRect(lcX0, lcY0, lcW, lcBarH)
3462
+ lcX.fillStyle = lcDark || (lcMin && !(lcThm && lcThm.light)) ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'
3463
+ lcX.fillRect(lcX0, lcY0 + lcBarH - lcS, lcW, lcS)
3464
+ var lcMid = lcY0 + lcBarH / 2
3465
+ if (lcBar.showControls !== false && !lcMin) {
3466
+ if (lcBar.kind.indexOf('mac') === 0) {
3467
+ var lcLights = ['#ff5f57', '#febc2e', '#28c840']
3468
+ for (var lcl = 0; lcl < 3; lcl++) { lcX.fillStyle = lcLights[lcl]; lcX.beginPath(); lcX.arc(lcX0 + (20 + lcl * 20) * lcS, lcMid, 6 * lcS, 0, Math.PI * 2); lcX.fill() }
3469
+ } else {
3470
+ lcX.strokeStyle = lcDark ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.6)'
3471
+ lcX.lineWidth = 1.5 * lcS
3472
+ var lcG = 4.5 * lcS, lcGx = lcX0 + lcW - 22 * lcS
3473
+ lcX.beginPath(); lcX.moveTo(lcGx - lcG, lcMid - lcG); lcX.lineTo(lcGx + lcG, lcMid + lcG); lcX.moveTo(lcGx + lcG, lcMid - lcG); lcX.lineTo(lcGx - lcG, lcMid + lcG); lcX.stroke()
3474
+ lcX.strokeRect(lcGx - 28 * lcS - lcG, lcMid - lcG, lcG * 2, lcG * 2)
3475
+ lcX.beginPath(); lcX.moveTo(lcGx - 56 * lcS - lcG, lcMid); lcX.lineTo(lcGx - 56 * lcS + lcG, lcMid); lcX.stroke()
3476
+ }
3477
+ }
3478
+ if (lcBar.showUrl !== false && lcBar.url) {
3479
+ var lcPW = Math.min(lcW * 0.5, Math.max(200 * lcS, lcW * 0.34)), lcPH = lcBarH - 16 * lcS
3480
+ var lcPx = lcX0 + (lcW - lcPW) / 2, lcPy = lcY0 + 8 * lcS
3481
+ lcX.fillStyle = lcMin ? (lcThm ? lcThm.pill : '#26262b') : lcDark ? '#1d1d20' : '#ffffff'
3482
+ rr(lcPx, lcPy, lcPW, lcPH, lcPH / 2, lcX); lcX.fill()
3483
+ lcX.fillStyle = lcMin ? (lcThm ? lcThm.text : '#9a9aa1') : lcDark ? '#a1a1a6' : '#5f5f64'
3484
+ lcX.font = 13 * lcS + 'px -apple-system, system-ui, sans-serif'
3485
+ lcX.textAlign = 'center'; lcX.textBaseline = 'middle'
3486
+ var lcLabel = String(lcBar.url), lcMaxW = lcPW - 28 * lcS
3487
+ if (lcX.measureText(lcLabel).width > lcMaxW) {
3488
+ while (lcLabel.length > 1 && lcX.measureText(lcLabel + '\u2026').width > lcMaxW) lcLabel = lcLabel.slice(0, -1)
3489
+ lcLabel += '\u2026'
3490
+ }
3491
+ lcX.fillText(lcLabel, lcPx + lcPW / 2, lcPy + lcPH / 2)
3492
+ lcX.textAlign = 'start'; lcX.textBaseline = 'alphabetic'
3493
+ }
3494
+ }
3495
+ // the media's facts: click rings and the cursor dot, video-anchored,
3496
+ // at clip-local time
3497
+ var lcCu = lcO.cur
3498
+ if (lcCu && lcCu.pts && lcCu.pts.length) {
3499
+ var lcSp = lcCu.space || { w: lcNW, h: lcNH }
3500
+ var lcSize = (lcCu.size || 24) * lcS * 0.5
3501
+ var lcT = lcI.t
3502
+ var lcCks = lcCu.clicks || []
3503
+ for (var lcc = 0; lcc < lcCks.length; lcc++) {
3504
+ var lcCk = lcCks[lcc]
3505
+ var lcAge = lcT - lcCk.t
3506
+ if (lcAge < -0.08 || lcAge > 0.6) continue
3507
+ var lcU = Math.max(0, Math.min(1, (lcAge + 0.08) / 0.68))
3508
+ var lcFade = 1 - Math.pow(1 - lcU, 3)
3509
+ lcX.save()
3510
+ lcX.strokeStyle = 'rgba(255,255,255,' + (0.9 * (1 - lcU)) + ')'
3511
+ lcX.lineWidth = Math.max(1, 3 * lcS * (1 - lcU * 0.6))
3512
+ lcX.beginPath(); lcX.arc(lcX0 + (lcCk.x / (lcSp.w || lcNW)) * lcW, lcMY + (lcCk.y / (lcSp.h || lcNH)) * lcMH, lcSize * 0.8 + lcFade * 44 * lcS, 0, Math.PI * 2); lcX.stroke()
3513
+ lcX.restore()
3514
+ }
3515
+ var lcPts = lcCu.pts, lcPX = lcPts[0].x, lcPY = lcPts[0].y
3516
+ for (var lcp = 0; lcp < lcPts.length; lcp++) { if (lcPts[lcp].t <= lcT) { lcPX = lcPts[lcp].x; lcPY = lcPts[lcp].y } }
3517
+ lcX.save()
3518
+ lcX.fillStyle = 'rgba(255,255,255,0.95)'; lcX.strokeStyle = 'rgba(0,0,0,0.4)'; lcX.lineWidth = 2 * lcS
3519
+ lcX.beginPath(); lcX.arc(lcX0 + (lcPX / (lcSp.w || lcNW)) * lcW, lcMY + (lcPY / (lcSp.h || lcNH)) * lcMH, lcSize, 0, Math.PI * 2); lcX.fill(); lcX.stroke()
3520
+ lcX.restore()
3521
+ }
3522
+ lcX.restore()
3523
+ if (lcO.border && lcO.border.width > 0) {
3524
+ var lcBW = lcO.border.width * lcS
3525
+ lcX.save(); lcX.strokeStyle = lcO.border.color || '#fff'; lcX.lineWidth = lcBW
3526
+ rr(lcX0 - lcBW / 2, lcY0 - lcBW / 2, lcW + lcBW, lcH + lcBW, lcRad > 0 ? lcRad + lcBW / 2 : 0, lcX); lcX.stroke()
3527
+ lcX.restore()
3528
+ }
3529
+ lcE.texture.needsUpdate = true
3530
+ var lcM2 = lcE.mesh
3531
+ lcM2.visible = true
3532
+ if (lcM2.material) lcM2.material.opacity = lcI.a
3533
+ var lcLean = lcCard.lean || [0, 0]
3534
+ var lcRx = (lcLean[0] || 0) * Math.PI / 180, lcRy = (lcLean[1] || 0) * Math.PI / 180, lcRz = -(lcI.r || 0) * Math.PI / 180
3535
+ var lcPlW = lcCW * lcK, lcPlH = lcCH * lcK
3536
+ var lcWX = (lcI.x - 0.5) * obRefH * obAspect
3537
+ var lcWY = -(lcI.y - 0.5) * obRefH - lcI.yof * lcK
3538
+ if (obOrtho) {
3539
+ lcM2.position.set((obOCX + (lcI.x - 0.5) * obOW) / obAX, obOCY - (lcI.y - 0.5) * obOH, -obOD)
3540
+ obB.e.set(lcRx, lcRy, lcRz); lcM2.quaternion.setFromEuler(obB.e)
3541
+ } else if (obB) {
3542
+ lcM2.position.copy(obCam.position).addScaledVector(obB.f, Math.abs(${CARD_Z}) - 0.01).addScaledVector(obB.r, lcWX).addScaledVector(obB.u, lcWY)
3543
+ obB.e.set(lcRx, lcRy, lcRz); lcM2.quaternion.copy(obCam.quaternion).multiply(obB.q.setFromEuler(obB.e))
3544
+ } else {
3545
+ lcM2.position.set(lcWX, lcWY, ${CARD_Z} + 0.01)
3546
+ lcM2.rotation.set(lcRx, lcRy, lcRz)
3547
+ }
3548
+ lcM2.scale.set(lcPlW, lcPlH, 1)
3549
+ }
3550
+ // A card off screen rests; one gone from the data is disposed.
3551
+ lcC.forEach(function (lcE2, lcId) {
3552
+ if (lcSeen[lcId]) return
3553
+ var lcStill = false
3554
+ for (var lcq = 0; lcq < ols.length; lcq++) if (ols[lcq].id === lcId && ols[lcq].card) lcStill = true
3555
+ if (lcStill) { lcE2.mesh.visible = false; return }
3556
+ obC.group.remove(lcE2.mesh)
3557
+ if (lcE2.mesh.geometry && lcE2.mesh.geometry.dispose) lcE2.mesh.geometry.dispose()
3558
+ if (lcE2.mesh.material && lcE2.mesh.material.dispose) lcE2.mesh.material.dispose()
3559
+ if (lcE2.texture && lcE2.texture.dispose) lcE2.texture.dispose()
3560
+ lcC.delete(lcId)
3561
+ })
3299
3562
  // Dispose props no longer in the data (live removal).
3300
3563
  obC.pool.forEach(function (obE2, obId) {
3301
3564
  if (!obSeen[obId]) {
@@ -5074,6 +5337,7 @@ var ON_FRAME = `(ctx, content, dt) => {
5074
5337
  }
5075
5338
  }`;
5076
5339
  function studioLayerData(layers, duration) {
5340
+ const rec = "source" in layers ? layers : null;
5077
5341
  return {
5078
5342
  // Music/SFX clips with their gain envelopes baked (shared truth for the
5079
5343
  // preview scheduler and the export's offline mix). `duckEnv` (the mic-derived
@@ -5161,9 +5425,60 @@ function studioLayerData(layers, duration) {
5161
5425
  })()
5162
5426
  };
5163
5427
  if (o.kind !== "text") {
5428
+ const lm = rec ? layerMedia(rec, o.key) : null;
5429
+ const lmImage = lm ? lm.sourceKind === "image" : o.kind === "image";
5430
+ const lmCard = o.frame ? {
5431
+ bar: {
5432
+ kind: o.frame.browserBar?.kind ?? "none",
5433
+ url: o.frame.browserBar?.url ?? (lm ? pageDisplayUrl(lm.meta.pageUrl) : ""),
5434
+ showUrl: o.frame.browserBar?.showUrl ?? true,
5435
+ showControls: o.frame.browserBar?.showControls ?? true,
5436
+ height: o.frame.browserBar?.height ?? 44,
5437
+ ...o.frame.browserBar?.theme ? { theme: o.frame.browserBar.theme } : {}
5438
+ },
5439
+ lean: [
5440
+ round6(o.frame.lean?.rx ?? 0),
5441
+ round6(o.frame.lean?.ry ?? 0)
5442
+ ],
5443
+ shadow: o.frame.shadow ?? LAYER_CARD_SHADOW,
5444
+ shadowContact: o.frame.shadowContact ?? 0,
5445
+ ...o.frame.shadowColor ? { shadowColor: o.frame.shadowColor } : {}
5446
+ } : null;
5447
+ const lmFacts = lm && o.frame && o.frame.cursor !== false && lm.cursor.length ? (() => {
5448
+ const lfx = rec.cursor.clickFx;
5449
+ const sm = smoothCursor(lm.cursor, {
5450
+ factor: rec.cursor.smoothing,
5451
+ clickSnap: lfx.style !== "none" || lfx.press
5452
+ });
5453
+ const clicks = [];
5454
+ for (const ev of lm.cursor) {
5455
+ if (ev.type === "down")
5456
+ clicks.push({
5457
+ t: round6(ev.t / 1e3),
5458
+ x: round6(ev.x),
5459
+ y: round6(ev.y)
5460
+ });
5461
+ }
5462
+ return {
5463
+ cur: {
5464
+ pts: sm.map((p) => ({
5465
+ t: round6(p.t),
5466
+ x: round6(p.x),
5467
+ y: round6(p.y)
5468
+ })),
5469
+ space: { w: lm.meta.width, h: lm.meta.height },
5470
+ size: rec.cursor.size,
5471
+ clicks
5472
+ }
5473
+ };
5474
+ })() : {};
5164
5475
  return {
5165
5476
  ...base,
5166
- key: o.key,
5477
+ ...lm ? { kind: lmImage ? "image" : "video" } : {},
5478
+ key: lm ? lm.videoKey : o.key,
5479
+ ...lm && lm.crop ? { crop: lm.crop } : {},
5480
+ ...lmCard ? { card: lmCard } : {},
5481
+ ...lmFacts,
5167
5482
  w: round6(o.width ?? OVERLAY_MEDIA_DEFAULT_WIDTH),
5168
5483
  radius: o.radius ?? OVERLAY_MEDIA_DEFAULT_RADIUS,
5169
5484
  opacity: o.opacity ?? 1,
@@ -6043,15 +6358,15 @@ function copyLayout(from, to, opts = {}) {
6043
6358
  const freeze = trailingFreeze(from);
6044
6359
  const last = doc.segments.at(-1);
6045
6360
  if (freeze && last) {
6046
- const own2 = (doc.freeze ?? []).filter(
6047
- (f) => Math.abs(f.at - last.out) > 1e-9 || !sameMedia(f.media, last.media)
6048
- );
6361
+ const atEnd = (f) => Math.abs(f.at - last.out) <= 1e-9 && sameMedia(f.media, last.media);
6362
+ const own2 = (doc.freeze ?? []).filter((f) => !atEnd(f));
6363
+ const kept = (doc.freeze ?? []).find((f) => atEnd(f) && !f.from);
6049
6364
  const taken = new Set(own2.map((f) => f.id));
6050
6365
  let n = 0;
6051
6366
  while (taken.has(`f${n}`)) n++;
6052
6367
  doc.freeze = [
6053
6368
  ...own2,
6054
- {
6369
+ kept ? { ...kept, seconds: Math.max(kept.seconds, freeze.seconds) } : {
6055
6370
  id: `f${n}`,
6056
6371
  at: last.out,
6057
6372
  seconds: freeze.seconds,
@@ -6100,15 +6415,15 @@ function applyTemplate(template, take, opts) {
6100
6415
  const tail = trailingFreeze(template);
6101
6416
  const lastSeg = doc.segments.at(-1);
6102
6417
  if (tail && opts.at === "end" && lastSeg) {
6103
- const own2 = (doc.freeze ?? []).filter(
6104
- (f) => Math.abs(f.at - lastSeg.out) > 1e-9 || !sameMedia(f.media, lastSeg.media)
6105
- );
6418
+ const atEnd = (f) => Math.abs(f.at - lastSeg.out) <= 1e-9 && sameMedia(f.media, lastSeg.media);
6419
+ const kept = (doc.freeze ?? []).find((f) => atEnd(f) && !f.from);
6420
+ const own2 = (doc.freeze ?? []).filter((f) => !atEnd(f));
6106
6421
  const taken = new Set(own2.map((f) => f.id));
6107
6422
  let n = 0;
6108
6423
  while (taken.has(`f${n}`)) n++;
6109
6424
  doc.freeze = [
6110
6425
  ...own2,
6111
- {
6426
+ kept ? { ...kept, seconds: Math.max(kept.seconds, tail.seconds) } : {
6112
6427
  id: `f${n}`,
6113
6428
  at: lastSeg.out,
6114
6429
  seconds: tail.seconds,
@@ -8478,11 +8793,13 @@ export {
8478
8793
  FREEZE_SECONDS_MIN,
8479
8794
  HOUSE_GRADIENT,
8480
8795
  IDLE_KINDS,
8796
+ LAYER_CARD_SHADOW,
8481
8797
  LAYOUT_FRAME_FIELDS,
8482
8798
  LOOK_KINDS,
8483
8799
  MEDIA_ENTER_KINDS,
8484
8800
  MEDIA_EXIT_KINDS,
8485
8801
  MEDIA_FRAME_KEYS,
8802
+ MEDIA_KEY_PREFIX,
8486
8803
  MINIMAL_BAR_THEMES,
8487
8804
  MOTION_DELTA,
8488
8805
  MOTION_EASE,
@@ -8509,6 +8826,7 @@ export {
8509
8826
  SPEED_RATE_MIN,
8510
8827
  SPEED_SPAN_MIN,
8511
8828
  STAGE_CLIP_PREFIX,
8829
+ STILL_DEFAULT,
8512
8830
  STUDIO_ENTRY_ID,
8513
8831
  STYLE_FIELDS,
8514
8832
  TEXT3D_DEPTH_DEFAULT,
@@ -8591,6 +8909,7 @@ export {
8591
8909
  docMediaList,
8592
8910
  docOutputDuration,
8593
8911
  docRestTime,
8912
+ docStillTime,
8594
8913
  docToCropSpace,
8595
8914
  docToFullSpace,
8596
8915
  dropTemplate,
@@ -8627,6 +8946,7 @@ export {
8627
8946
  isStageClip,
8628
8947
  isUnitStep,
8629
8948
  lastLayerEnd,
8949
+ layerMedia,
8630
8950
  layoutOf,
8631
8951
  levelForFocusFraction,
8632
8952
  lookFromBrand,
@@ -8638,6 +8958,8 @@ export {
8638
8958
  mediaDuration,
8639
8959
  mediaFrame,
8640
8960
  mediaKey,
8961
+ mediaRef,
8962
+ mediaRefId,
8641
8963
  mediaSource,
8642
8964
  micLane,
8643
8965
  migrateHostedDoc,
@@ -8664,6 +8986,7 @@ export {
8664
8986
  overlayMotionPoseAt,
8665
8987
  overlayRect,
8666
8988
  overlaysLane,
8989
+ ownFreezes,
8667
8990
  pageDisplayUrl,
8668
8991
  parsePoseId,
8669
8992
  pickStyle,