@vosjs/studio-core 0.34.0 → 0.35.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
@@ -2701,7 +2701,10 @@ function camBubbleRect(cam, W, H = 1080) {
2701
2701
  radius: cam.shape === "rounded" ? (cam.radius ?? 18) * s : size / 2
2702
2702
  };
2703
2703
  }
2704
- function focusBounds(level, layout, camera = "card") {
2704
+ function focusBounds(level, layout, camera = "card", screen) {
2705
+ if (camera === "stage" && screen && level > 1.001) {
2706
+ return { minX: 0, maxX: 1, minY: 0, maxY: 1 };
2707
+ }
2705
2708
  if (level <= 1.001) {
2706
2709
  return { minX: 0.5, maxX: 0.5, minY: 0.5, maxY: 0.5 };
2707
2710
  }
@@ -2764,8 +2767,8 @@ function stageAxisBounds(anchorOff, anchorSize, coverOff, coverSize, viewport, l
2764
2767
  }
2765
2768
  return { min: clamp013(lo), max: clamp013(hi) };
2766
2769
  }
2767
- function clampFocus(cx, cy, level, layout, camera = "card") {
2768
- const b = focusBounds(level, layout, camera);
2770
+ function clampFocus(cx, cy, level, layout, camera = "card", screen) {
2771
+ const b = focusBounds(level, layout, camera, screen);
2769
2772
  return {
2770
2773
  cx: Math.min(b.maxX, Math.max(b.minX, cx)),
2771
2774
  cy: Math.min(b.maxY, Math.max(b.minY, cy))
@@ -2774,6 +2777,9 @@ function clampFocus(cx, cy, level, layout, camera = "card") {
2774
2777
  function clamp013(v) {
2775
2778
  return Math.max(0, Math.min(1, v));
2776
2779
  }
2780
+ function screenPoint(layout, camera, screen) {
2781
+ return camera === "stage" && screen ? { ox: screen.x * layout.W, oy: screen.y * layout.H } : { ox: layout.W / 2, oy: layout.H / 2 };
2782
+ }
2777
2783
  function cameraModel(frame) {
2778
2784
  return frame?.camera === "stage" ? "stage" : "card";
2779
2785
  }
@@ -2783,34 +2789,36 @@ function cameraCentring(level) {
2783
2789
  const u = clamp013((level - 1) / CAMERA_CENTRE_RAMP);
2784
2790
  return u * u * (3 - 2 * u);
2785
2791
  }
2786
- function zoomView(level, cx, cy, layout, camera = "card", centring) {
2792
+ function zoomView(level, cx, cy, layout, camera = "card", centring, screen) {
2787
2793
  const L = Math.max(1, level);
2788
2794
  const fx = layout.dx + cx * layout.dw;
2789
2795
  const fy = layout.dy + cy * layout.dh;
2790
- const ox = layout.W / 2;
2791
- const oy = layout.H / 2;
2796
+ const { ox, oy } = screenPoint(layout, camera, screen);
2792
2797
  const t = camera === "stage" ? clamp013(centring ?? 1) : 0;
2793
2798
  const wcx = fx + (ox - fx) / L * (1 - t);
2794
2799
  const wcy = fy + (oy - fy) / L * (1 - t);
2795
2800
  return { level: L, fx, fy, wcx, wcy, ox, oy };
2796
2801
  }
2797
- function zoomViewport(level, cx, cy, layout, camera = "card", centring) {
2798
- const v = zoomView(level, cx, cy, layout, camera, centring);
2802
+ function zoomViewport(level, cx, cy, layout, camera = "card", centring, screen) {
2803
+ const v = zoomView(level, cx, cy, layout, camera, centring, screen);
2799
2804
  const size = 1 / v.level;
2805
+ const mx = v.wcx + (layout.W / 2 - v.ox) / v.level;
2806
+ const my = v.wcy + (layout.H / 2 - v.oy) / v.level;
2800
2807
  return {
2801
- x: (v.wcx - layout.W / (2 * v.level)) / layout.W,
2802
- y: (v.wcy - layout.H / (2 * v.level)) / layout.H,
2808
+ x: (mx - layout.W / (2 * v.level)) / layout.W,
2809
+ y: (my - layout.H / (2 * v.level)) / layout.H,
2803
2810
  w: size,
2804
2811
  h: size
2805
2812
  };
2806
2813
  }
2807
- function focusForViewportCentre(cX, cY, level, layout, camera = "card", centring) {
2814
+ function focusForViewportCentre(cX, cY, level, layout, camera = "card", centring, screen) {
2808
2815
  const L = Math.max(1, level);
2809
2816
  const t = camera === "stage" ? clamp013(centring ?? 1) : 0;
2810
2817
  const a = 1 - (1 - t) / L;
2811
2818
  if (a <= 1e-6) return { cx: 0.5, cy: 0.5 };
2812
- const fx = (cX * layout.W - layout.W / 2 * (1 - t) / L) / a;
2813
- const fy = (cY * layout.H - layout.H / 2 * (1 - t) / L) / a;
2819
+ const { ox, oy } = screenPoint(layout, camera, screen);
2820
+ const fx = (cX * layout.W - ox * (1 - t) / L - (layout.W / 2 - ox) / L) / a;
2821
+ const fy = (cY * layout.H - oy * (1 - t) / L - (layout.H / 2 - oy) / L) / a;
2814
2822
  return { cx: (fx - layout.dx) / layout.dw, cy: (fy - layout.dy) / layout.dh };
2815
2823
  }
2816
2824
  function levelForFocusFraction(frac) {
@@ -3337,14 +3345,17 @@ function overlayFaceFor(clip) {
3337
3345
  function overlayFontFaces(doc) {
3338
3346
  const faces = [...OVERLAY_FONT_FACES];
3339
3347
  const seen = new Set(faces.map((f) => `${f.family}|${f.weight}`));
3340
- for (const o of doc.overlays ?? []) {
3341
- if (o.kind !== "text") continue;
3342
- const face = overlayFaceFor(o);
3343
- if (!face) continue;
3348
+ const add = (face) => {
3349
+ if (!face) return;
3344
3350
  const key = `${face.family}|${face.weight}`;
3345
- if (seen.has(key)) continue;
3351
+ if (seen.has(key)) return;
3346
3352
  seen.add(key);
3347
3353
  faces.push(face);
3354
+ };
3355
+ for (const o of doc.overlays ?? []) {
3356
+ if (o.kind !== "text") continue;
3357
+ add(overlayFaceFor(o));
3358
+ add(resolveEmphasis(o)?.face);
3348
3359
  }
3349
3360
  return faces;
3350
3361
  }
@@ -3352,6 +3363,102 @@ function overlayLines(text) {
3352
3363
  const lines = text.split("\n");
3353
3364
  return lines.length ? lines : [""];
3354
3365
  }
3366
+ var EM_OPEN = "";
3367
+ var EM_CLOSE = "";
3368
+ function parseEmphasis(text) {
3369
+ let any = false;
3370
+ const lines = text.split("\n").map((line) => {
3371
+ const chars = [];
3372
+ const bold = [];
3373
+ const marker = [];
3374
+ for (let i = 0; i < line.length; i++) {
3375
+ if (line[i] === "\\" && line[i + 1] === "*") {
3376
+ chars.push("*");
3377
+ marker.push(false);
3378
+ i++;
3379
+ } else {
3380
+ chars.push(line[i]);
3381
+ marker.push(line[i] === "*");
3382
+ }
3383
+ }
3384
+ const keep = chars.map(() => true);
3385
+ for (let i = 0; i < chars.length; i++) {
3386
+ if (!marker[i]) continue;
3387
+ let j = i + 1;
3388
+ while (j < chars.length && !marker[j]) j++;
3389
+ if (j >= chars.length) break;
3390
+ const inner = chars.slice(i + 1, j).join("");
3391
+ if (!inner.trim()) {
3392
+ i = j - 1;
3393
+ continue;
3394
+ }
3395
+ keep[i] = false;
3396
+ keep[j] = false;
3397
+ for (let k = i + 1; k < j; k++) bold[k] = true;
3398
+ any = true;
3399
+ i = j;
3400
+ }
3401
+ const kept = [];
3402
+ for (let i = 0; i < chars.length; i++) {
3403
+ if (keep[i])
3404
+ kept.push({ c: chars[i], b: !!bold[i] && !/\s/.test(chars[i]) });
3405
+ }
3406
+ let out = "";
3407
+ for (let i = 0; i < kept.length; i++) {
3408
+ const { c, b } = kept[i];
3409
+ if (b && !(i > 0 && kept[i - 1].b)) out += EM_OPEN;
3410
+ out += c;
3411
+ if (b && !(i + 1 < kept.length && kept[i + 1].b)) out += EM_CLOSE;
3412
+ }
3413
+ return out;
3414
+ });
3415
+ return any ? lines.join("\n") : null;
3416
+ }
3417
+ function overlayDisplayText(clip) {
3418
+ if (!clip.emphasis) return clip.text;
3419
+ return parseEmphasis(clip.text) ?? clip.text;
3420
+ }
3421
+ function stripEmphasis(text) {
3422
+ return text.split(EM_OPEN).join("").split(EM_CLOSE).join("");
3423
+ }
3424
+ var EMPHASIS_WEIGHT = 700;
3425
+ function resolveEmphasis(clip) {
3426
+ if (!clip.emphasis || parseEmphasis(clip.text) === null) return null;
3427
+ const style = resolveOverlayStyle(clip);
3428
+ const entry = findFontFamily2(
3429
+ clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
3430
+ );
3431
+ const wanted = clip.emphasis?.weight ?? Math.max(EMPHASIS_WEIGHT, style.weight);
3432
+ const weight = entry ? nearestFontWeight2(entry, wanted) : wanted;
3433
+ const inBase = OVERLAY_FONT_FACES.some(
3434
+ (f) => entry && f.family === entry.family && f.weight === weight
3435
+ );
3436
+ return {
3437
+ weight,
3438
+ ...clip.emphasis?.color ? { color: clip.emphasis.color } : {},
3439
+ ...entry && !inBase ? {
3440
+ face: {
3441
+ family: entry.family,
3442
+ weight,
3443
+ url: fontFaceUrl2(entry.slug, weight)
3444
+ }
3445
+ } : {}
3446
+ };
3447
+ }
3448
+ function measureEmphasized(text, measure, font, emFont, bold = false) {
3449
+ let w = 0;
3450
+ let run = "";
3451
+ let b = bold;
3452
+ for (const c of text) {
3453
+ if (c === EM_OPEN || c === EM_CLOSE) {
3454
+ if (run) w += measure(run, b ? emFont : font);
3455
+ run = "";
3456
+ b = c === EM_OPEN;
3457
+ } else run += c;
3458
+ }
3459
+ if (run) w += measure(run, b ? emFont : font);
3460
+ return w;
3461
+ }
3355
3462
  function overlayTokens(line) {
3356
3463
  return line.match(/\S+\s*/g) ?? [line];
3357
3464
  }
@@ -3402,9 +3509,10 @@ function resolveOverlayFx(clip, clipDuration) {
3402
3509
  const spec = overlayFxSpec(clip) ?? clip.fx ?? null;
3403
3510
  if (!spec) return null;
3404
3511
  const unit = spec.unit ?? "block";
3405
- const units = overlaySegments(clip.text, unit);
3512
+ const text = overlayDisplayText(clip);
3513
+ const units = overlaySegments(text, unit);
3406
3514
  const n = unit === "block" ? 1 : units.reduce((sum, line) => sum + line.length, 0);
3407
- const upperN = unit === "line" && clip.maxWidth ? overlayLines(clip.text).reduce(
3515
+ const upperN = unit === "line" && clip.maxWidth ? overlayLines(text).reduce(
3408
3516
  (sum, line) => sum + overlayTokens(line).length,
3409
3517
  0
3410
3518
  ) : n;
@@ -3451,13 +3559,13 @@ function overlayRect(clip, measure, frameW, frameH = 1080, mediaAspect) {
3451
3559
  const style = resolveOverlayStyle(clip);
3452
3560
  const font = overlayFontString(style, scale, 1);
3453
3561
  const ls = style.letterSpacing * scale;
3454
- const lines = clip.maxWidth ? wrapOverlayLines(
3455
- overlayLines(clip.text),
3456
- (t) => measure(t, font, ls),
3457
- clip.maxWidth * frameW
3458
- ) : overlayLines(clip.text);
3562
+ const em = resolveEmphasis(clip);
3563
+ const emFont = em ? overlayFontString({ ...style, weight: em.weight }, scale, 1) : "";
3564
+ const width = (t) => em ? measureEmphasized(t, (run, f) => measure(run, f, ls), font, emFont) : measure(t, font, ls);
3565
+ const text = overlayDisplayText(clip);
3566
+ const lines = clip.maxWidth ? wrapOverlayLines(overlayLines(text), width, clip.maxWidth * frameW) : overlayLines(text);
3459
3567
  let w = 0;
3460
- for (const line of lines) w = Math.max(w, measure(line, font, ls));
3568
+ for (const line of lines) w = Math.max(w, width(line));
3461
3569
  const lineH = style.size * scale * style.lineHeight;
3462
3570
  const box = resolveOverlayBox(clip);
3463
3571
  const padX = box ? box.padX * scale : 0;
@@ -3963,14 +4071,16 @@ function pinRectOnScreen(rect, t, layout, camera, zoomTrack) {
3963
4071
  let cx = 0.5;
3964
4072
  let cy = 0.5;
3965
4073
  let centring;
4074
+ let screen;
3966
4075
  if (zoomTrack && zoomTrack.keyframes.length) {
3967
4076
  const z = sample(zoomTrack, t, lerpArray);
3968
4077
  level = z[0];
3969
4078
  cx = z[1];
3970
4079
  cy = z[2];
3971
4080
  if (z.length > 3) centring = z[3];
4081
+ if (z.length > 5) screen = { x: z[4], y: z[5] };
3972
4082
  }
3973
- const v = zoomView(level, cx, cy, layout, camera, centring);
4083
+ const v = zoomView(level, cx, cy, layout, camera, centring, screen);
3974
4084
  const map = (nx, ny) => {
3975
4085
  const px = layout.dx + nx * layout.dw;
3976
4086
  const py = layout.dy + ny * layout.dh;
@@ -5012,6 +5122,19 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5012
5122
  } catch (e) {}
5013
5123
  }
5014
5124
  }
5125
+ // The emphasis weight's face, the same way (a live edit that adds the
5126
+ // first *marked* word paints without a LOAD).
5127
+ if (ol.em && ol.em.face) {
5128
+ var oeKey = ol.em.face.f + '|' + ol.em.face.w
5129
+ if (!ofSet[oeKey]) {
5130
+ ofSet[oeKey] = 1
5131
+ try {
5132
+ var oeFace = new FontFace(ol.em.face.f, 'url(' + ol.em.face.u + ')', { weight: String(ol.em.face.w) })
5133
+ document.fonts.add(oeFace)
5134
+ oeFace.load().catch(function () {})
5135
+ } catch (e) {}
5136
+ }
5137
+ }
5015
5138
  }
5016
5139
  ovC.save()
5017
5140
  ovC.globalAlpha = olA
@@ -5027,6 +5150,69 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5027
5150
  ovC.textAlign = 'center'
5028
5151
  ovC.textBaseline = 'middle'
5029
5152
  ovC.letterSpacing = ((ol.ls || 0) * olMS * s) + 'px'
5153
+ // Emphasis (ol.em, baked only when the text marks a word): the lines
5154
+ // carry \\u0001/\\u0002 around every emphasized word, and the normal and
5155
+ // emphasis fonts alternate at them, run by run. MIRRORS measureEmphasized
5156
+ // in overlayText.ts \u2014 change together. A clip without ol.em never reaches
5157
+ // these helpers: it keeps its plain measureText/fillText calls.
5158
+ var olEm = ol.em || null
5159
+ var olFN = ovC.font
5160
+ var olFB = olEm ? (ol.sty ? ol.sty + ' ' : '') + olEm.w + ' ' + olPx + 'px ' + ol.stack : ''
5161
+ var olMeasure = function (t, b0) {
5162
+ if (!olEm) return ovC.measureText(t).width
5163
+ var mw = 0, mrun = '', mb = !!b0
5164
+ for (var mi = 0; mi < t.length; mi++) {
5165
+ var mc = t.charAt(mi)
5166
+ if (mc === '\\u0001' || mc === '\\u0002') {
5167
+ if (mrun) { ovC.font = mb ? olFB : olFN; mw += ovC.measureText(mrun).width }
5168
+ mrun = ''
5169
+ mb = mc === '\\u0001'
5170
+ } else mrun += mc
5171
+ }
5172
+ if (mrun) { ovC.font = mb ? olFB : olFN; mw += ovC.measureText(mrun).width }
5173
+ ovC.font = olFN
5174
+ return mw
5175
+ }
5176
+ // The emphasis state after a run of text (a char unit starts in it).
5177
+ var olStateAfter = function (t, b0) {
5178
+ var sb = !!b0
5179
+ for (var si = 0; si < t.length; si++) {
5180
+ var sc = t.charAt(si)
5181
+ if (sc === '\\u0001') sb = true
5182
+ else if (sc === '\\u0002') sb = false
5183
+ }
5184
+ return sb
5185
+ }
5186
+ // Draw t LEFT-aligned from x, run by run: the font switches at the
5187
+ // marks, an emphasized run takes ol.em.c when set, stroke under fill.
5188
+ var olDraw = function (t, x, y, b0) {
5189
+ var drun = '', db = !!b0, dx = x
5190
+ var dflush = function () {
5191
+ if (!drun) return
5192
+ ovC.font = db ? olFB : olFN
5193
+ var dfs = ovC.fillStyle
5194
+ if (db && olEm.c) ovC.fillStyle = olEm.c
5195
+ if (ol.stroke) {
5196
+ ovC.strokeStyle = ol.stroke.c
5197
+ ovC.lineWidth = ol.stroke.w * olMS * s
5198
+ ovC.lineJoin = 'round'
5199
+ ovC.strokeText(drun, dx, y)
5200
+ }
5201
+ ovC.fillText(drun, dx, y)
5202
+ dx += ovC.measureText(drun).width
5203
+ ovC.fillStyle = dfs
5204
+ drun = ''
5205
+ }
5206
+ for (var di = 0; di < t.length; di++) {
5207
+ var dc = t.charAt(di)
5208
+ if (dc === '\\u0001' || dc === '\\u0002') {
5209
+ dflush()
5210
+ db = dc === '\\u0001'
5211
+ } else drun += dc
5212
+ }
5213
+ dflush()
5214
+ ovC.font = olFN
5215
+ }
5030
5216
  var olLines = ol.lines || ['']
5031
5217
  // maxWidth wrap (ol.mw = frame-width fraction): greedy over word tokens
5032
5218
  // (/\\S+\\s*/ \u2014 the SAME tokenization fx uses, trailing spaces kept, so
@@ -5038,7 +5224,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5038
5224
  var olWrapped = []
5039
5225
  for (var olwl = 0; olwl < olLines.length; olwl++) {
5040
5226
  var olWLine = olLines[olwl]
5041
- if (!olWLine || ovC.measureText(olWLine).width <= olWMax) {
5227
+ if (!olWLine || olMeasure(olWLine) <= olWMax) {
5042
5228
  olWrapped.push(olWLine)
5043
5229
  continue
5044
5230
  }
@@ -5046,7 +5232,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5046
5232
  var olCur = ''
5047
5233
  for (var olti = 0; olti < olToks.length; olti++) {
5048
5234
  if (!olCur) { olCur = olToks[olti]; continue }
5049
- if (ovC.measureText(olCur + olToks[olti]).width <= olWMax) {
5235
+ if (olMeasure(olCur + olToks[olti]) <= olWMax) {
5050
5236
  olCur += olToks[olti]
5051
5237
  } else {
5052
5238
  olWrapped.push(olCur)
@@ -5096,10 +5282,10 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5096
5282
  olFx = { k: olFx.k, u: olFx.u, d: olFx.d, st: olFx.st, dur: olFx.dur, tt: olFx.tt, units: olRe, n: olReN }
5097
5283
  }
5098
5284
  var olLWs = null, olMaxW = 0
5099
- if (ol.box || ol.align || olFx) {
5285
+ if (ol.box || ol.align || olFx || olEm) {
5100
5286
  olLWs = []
5101
5287
  for (var olwi = 0; olwi < olLines.length; olwi++) {
5102
- var olw = ovC.measureText(olLines[olwi]).width
5288
+ var olw = olMeasure(olLines[olwi])
5103
5289
  olLWs.push(olw)
5104
5290
  if (olw > olMaxW) olMaxW = olw
5105
5291
  }
@@ -5135,6 +5321,12 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5135
5321
  : (olMaxW - olLWs[ok]) / 2
5136
5322
  }
5137
5323
  var olLY = olY0 + ok * olLH
5324
+ if (olEm) {
5325
+ // Runs draw LEFT-aligned from the centred line's left edge.
5326
+ ovC.textAlign = 'left'
5327
+ olDraw(olLines[ok], olXof - olLWs[ok] / 2, olLY, false)
5328
+ continue
5329
+ }
5138
5330
  if (ol.stroke) {
5139
5331
  ovC.strokeStyle = ol.stroke.c
5140
5332
  ovC.lineWidth = ol.stroke.w * olMS * s
@@ -5171,7 +5363,10 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5171
5363
  : olIdx
5172
5364
  var olT2 = olT - olOrd * olFx.st
5173
5365
  var olNext = olPref + olUs[ou]
5174
- var olNW = ovC.measureText(olNext).width
5366
+ var olNW = olMeasure(olNext)
5367
+ // The emphasis state this unit starts in (a char unit inside a
5368
+ // marked word carries no mark of its own).
5369
+ var olUB = olEm ? olStateAfter(olPref) : false
5175
5370
  var olUW = olNW - olPW
5176
5371
  var olUX = olXb + olPW
5177
5372
  var olUA = 1, olUu = 1
@@ -5200,6 +5395,11 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5200
5395
  ovC.filter = 'blur(' + ((1 - olUu) * olPx * 0.12).toFixed(2) + 'px)'
5201
5396
  }
5202
5397
  }
5398
+ if (olEm) {
5399
+ olDraw(olUnit, olUX, olLY2, olUB)
5400
+ ovC.restore()
5401
+ continue
5402
+ }
5203
5403
  if (ol.stroke) {
5204
5404
  ovC.strokeStyle = ol.stroke.c
5205
5405
  ovC.lineWidth = ol.stroke.w * olMS * s
@@ -5848,15 +6048,22 @@ function zoomTrackFromDoc(zoom, segments, style = ZOOM_STYLES[DEFAULT_ZOOM_STYLE
5848
6048
  return ext ? [{ z, tIn: ext.start, tOut: ext.end }] : [];
5849
6049
  });
5850
6050
  const { keyframes, push: push2 } = trackEmitter();
6051
+ const placedAt = (z) => z.screen && z.focusMode !== "auto" ? z.screen : null;
6052
+ const placed = mapped.some(({ z }) => placedAt(z) !== null);
6053
+ const sc = (z) => {
6054
+ if (!placed) return [];
6055
+ const s = placedAt(z);
6056
+ return s ? [s.x, s.y] : [0.5, 0.5];
6057
+ };
5851
6058
  let chained = false;
5852
6059
  for (let i = 0; i < mapped.length; i++) {
5853
6060
  const { z, tIn, tOut } = mapped[i];
5854
- const entry = [clampZoomLevel(z.level), z.cx, z.cy, 1];
6061
+ const entry = [clampZoomLevel(z.level), z.cx, z.cy, 1, ...sc(z)];
5855
6062
  let cur = entry;
5856
6063
  const m = transitionMult(z.transition);
5857
6064
  if (!chained) {
5858
6065
  const startWanted = tIn - (style.rampIn - style.rampInOverlap) * m;
5859
- const start = push2(startWanted, [1, z.cx, z.cy, 0], "none");
6066
+ const start = push2(startWanted, [1, z.cx, z.cy, 0, ...sc(z)], "none");
5860
6067
  push2(
5861
6068
  rampLanding(start, startWanted, style.rampIn * m, tOut),
5862
6069
  entry,
@@ -5868,7 +6075,7 @@ function zoomTrackFromDoc(zoom, segments, style = ZOOM_STYLES[DEFAULT_ZOOM_STYLE
5868
6075
  const eOut = sourceToTimeline(segments, e.t);
5869
6076
  if (eOut === null || eOut <= tIn || eOut >= tOut) continue;
5870
6077
  if (eOut - tIn < RAMP_FLOOR || tOut - eOut < RAMP_FLOOR) continue;
5871
- const next2 = [cur[0], e.cx, e.cy, 1];
6078
+ const next2 = [cur[0], e.cx, e.cy, 1, ...cur.slice(4)];
5872
6079
  if (e.path) {
5873
6080
  if (!inPath) {
5874
6081
  push2(Math.max(tIn, eOut - style.followRecenter), cur, "none");
@@ -5888,7 +6095,13 @@ function zoomTrackFromDoc(zoom, segments, style = ZOOM_STYLES[DEFAULT_ZOOM_STYLE
5888
6095
  const landed = keyframes.at(-1)?.t;
5889
6096
  if (drift > 0 && z.focusMode !== "auto" && !(z.followEvents ?? []).length && landed !== void 0 && tOut > landed) {
5890
6097
  const grow = Math.min(HOLD_DRIFT_MAX, drift * (tOut - landed));
5891
- cur = [clampZoomLevel(cur[0] * (1 + grow)), cur[1], cur[2], cur[3]];
6098
+ cur = [
6099
+ clampZoomLevel(cur[0] * (1 + grow)),
6100
+ cur[1],
6101
+ cur[2],
6102
+ cur[3],
6103
+ ...cur.slice(4)
6104
+ ];
5892
6105
  push2(tOut, cur, "linear");
5893
6106
  } else {
5894
6107
  push2(tOut, cur, "none");
@@ -5896,7 +6109,13 @@ function zoomTrackFromDoc(zoom, segments, style = ZOOM_STYLES[DEFAULT_ZOOM_STYLE
5896
6109
  const next = mapped.at(i + 1);
5897
6110
  if (next && next.tIn - tOut <= style.chainGap) {
5898
6111
  const mNext = transitionMult(next.z.transition);
5899
- const nextValue = [clampZoomLevel(next.z.level), next.z.cx, next.z.cy, 1];
6112
+ const nextValue = [
6113
+ clampZoomLevel(next.z.level),
6114
+ next.z.cx,
6115
+ next.z.cy,
6116
+ 1,
6117
+ ...sc(next.z)
6118
+ ];
5900
6119
  push2(
5901
6120
  Math.max(
5902
6121
  tOut + RAMP_FLOOR,
@@ -5912,7 +6131,7 @@ function zoomTrackFromDoc(zoom, segments, style = ZOOM_STYLES[DEFAULT_ZOOM_STYLE
5912
6131
  } else {
5913
6132
  push2(
5914
6133
  tOut + style.rampOut * m,
5915
- [1, cur[1], cur[2], 0],
6134
+ [1, cur[1], cur[2], 0, ...cur.slice(4)],
5916
6135
  spanEase(z.ease, style.ease)
5917
6136
  );
5918
6137
  chained = false;
@@ -7140,7 +7359,7 @@ var ON_FRAME = `(ctx, content, dt) => {
7140
7359
  // current zoom \u2014 a standard keyframe track in OUTPUT time (hold + arrival pairs
7141
7360
  // expanded by the lowering), sampled with the shared deterministic interpolator.
7142
7361
  // Sampled here, before the rect math, so a cover crop can follow it.
7143
- var lvl = 1, zx = 0.5, zy = 0.5, zc = -1
7362
+ var lvl = 1, zx = 0.5, zy = 0.5, zc = -1, zsx = 0.5, zsy = 0.5
7144
7363
  var zt = d.zoomTrack
7145
7364
  if (zt && zt.keyframes && zt.keyframes.length) {
7146
7365
  var z = TL.sample(zt, tpT, TL.lerpArray)
@@ -7149,6 +7368,9 @@ var ON_FRAME = `(ctx, content, dt) => {
7149
7368
  // with the level); a three-component track (a document lowered before
7150
7369
  // the component existed) falls back to the level band below.
7151
7370
  if (z.length > 3) zc = z[3]
7371
+ // A span placed on screen carries where its target lands (frame
7372
+ // fractions); a track without them lands it at the centre.
7373
+ if (z.length > 5) { zsx = z[4]; zsy = z[5] }
7152
7374
  }
7153
7375
  var barH = bar.kind && bar.kind !== 'none' ? (bar.height || 44) * s2 : 0
7154
7376
  var availW = Math.max(1, W - ipL - ipR), availH = Math.max(1, H - ipT - ipB - barH)
@@ -7209,6 +7431,10 @@ var ON_FRAME = `(ctx, content, dt) => {
7209
7431
  var camStage = cfr.camera === 'stage'
7210
7432
  var fx = dx + zx * dw, fy = dy + zy * dh
7211
7433
  var wcx = fx, wcy = fy
7434
+ // The point the stage camera lands its focus on: the span's place on
7435
+ // screen, else the centre (0.5\xB7W is W / 2 exactly). MIRRORS zoomView's
7436
+ // screenPoint \u2014 change together.
7437
+ var cox = camStage ? zsx * W : W / 2, coy = camStage ? zsy * H : H / 2
7212
7438
  if (lvl > 1.001 && !d.zoomSuppressed) {
7213
7439
  if (camStage) {
7214
7440
  var ctt = zc
@@ -7216,9 +7442,9 @@ var ON_FRAME = `(ctx, content, dt) => {
7216
7442
  var ctu = Math.max(0, Math.min(1, (lvl - 1) / ${CAMERA_CENTRE_RAMP}))
7217
7443
  ctt = ctu * ctu * (3 - 2 * ctu)
7218
7444
  }
7219
- wcx = fx + ((W / 2 - fx) / lvl) * (1 - ctt)
7220
- wcy = fy + ((H / 2 - fy) / lvl) * (1 - ctt)
7221
- c.translate(W / 2, H / 2); c.scale(lvl, lvl); c.translate(-wcx, -wcy)
7445
+ wcx = fx + ((cox - fx) / lvl) * (1 - ctt)
7446
+ wcy = fy + ((coy - fy) / lvl) * (1 - ctt)
7447
+ c.translate(cox, coy); c.scale(lvl, lvl); c.translate(-wcx, -wcy)
7222
7448
  } else {
7223
7449
  c.translate(fx, fy); c.scale(lvl, lvl); c.translate(-fx, -fy)
7224
7450
  }
@@ -7618,7 +7844,7 @@ var ON_FRAME = `(ctx, content, dt) => {
7618
7844
  if (trOut) {
7619
7845
  var tqX = ax, tqY = ay
7620
7846
  if (lvl > 1.001 && !d.zoomSuppressed) {
7621
- if (camStage) { tqX = W / 2 + (ax - wcx) * lvl; tqY = H / 2 + (ay - wcy) * lvl }
7847
+ if (camStage) { tqX = cox + (ax - wcx) * lvl; tqY = coy + (ay - wcy) * lvl }
7622
7848
  else { tqX = fx + (ax - fx) * lvl; tqY = fy + (ay - fy) * lvl }
7623
7849
  }
7624
7850
  if (tpG) trQa = [tqX, tqY]
@@ -7740,8 +7966,8 @@ var ON_FRAME = `(ctx, content, dt) => {
7740
7966
  }
7741
7967
  if (lvl > 1.001 && !d.zoomSuppressed) {
7742
7968
  if (camStage) {
7743
- pqAx = W / 2 + (pqAx - wcx) * lvl; pqAy = H / 2 + (pqAy - wcy) * lvl
7744
- pqBx = W / 2 + (pqBx - wcx) * lvl; pqBy = H / 2 + (pqBy - wcy) * lvl
7969
+ pqAx = cox + (pqAx - wcx) * lvl; pqAy = coy + (pqAy - wcy) * lvl
7970
+ pqBx = cox + (pqBx - wcx) * lvl; pqBy = coy + (pqBy - wcy) * lvl
7745
7971
  } else {
7746
7972
  pqAx = fx + (pqAx - fx) * lvl; pqAy = fy + (pqAy - fy) * lvl
7747
7973
  pqBx = fx + (pqBx - fx) * lvl; pqBy = fy + (pqBy - fy) * lvl
@@ -8050,10 +8276,27 @@ function studioLayerData(layers, duration, pins) {
8050
8276
  }
8051
8277
  const st = resolveOverlayStyle(o);
8052
8278
  const bx = resolveOverlayBox(o);
8279
+ const em = resolveEmphasis(o);
8053
8280
  return {
8054
8281
  ...base,
8055
8282
  text: o.text,
8056
- lines: overlayLines(o.text),
8283
+ // The DISPLAYED lines: emphasis marks inline, else the source.
8284
+ lines: overlayLines(overlayDisplayText(o)),
8285
+ // Emphasis set (weight, colour, face to lazy-load), only when
8286
+ // the text marks a word: an unmarked clip's data is unchanged.
8287
+ ...em ? {
8288
+ em: {
8289
+ w: em.weight,
8290
+ ...em.color ? { c: em.color } : {},
8291
+ ...em.face ? {
8292
+ face: {
8293
+ f: em.face.family,
8294
+ w: em.face.weight,
8295
+ u: em.face.url
8296
+ }
8297
+ } : {}
8298
+ }
8299
+ } : {},
8057
8300
  fs: st.size,
8058
8301
  weight: st.weight,
8059
8302
  stack: st.stack,
@@ -8297,7 +8540,15 @@ function loweredZoomSpans(doc, rated, transitions, layout, camera, zoomStyle) {
8297
8540
  }
8298
8541
  return {
8299
8542
  ...z,
8300
- ...clampFocus(z.cx, z.cy, clampZoomLevel(z.level), layout, camera)
8543
+ // A span placed on screen is the author's composition: no cover band.
8544
+ ...clampFocus(
8545
+ z.cx,
8546
+ z.cy,
8547
+ clampZoomLevel(z.level),
8548
+ layout,
8549
+ camera,
8550
+ z.focusMode !== "auto" ? z.screen : void 0
8551
+ )
8301
8552
  };
8302
8553
  });
8303
8554
  }
@@ -8850,8 +9101,9 @@ function sceneChanges(bins, opts = {}) {
8850
9101
  // src/digest/framing.ts
8851
9102
  function zoomWindow(span, layout, camera = "card") {
8852
9103
  const L = Math.max(1, span.level);
8853
- const { cx, cy } = clampFocus(span.cx, span.cy, L, layout, camera);
8854
- const v = zoomViewport(L, cx, cy, layout, camera);
9104
+ const screen = span.focusMode !== "auto" ? span.screen : void 0;
9105
+ const { cx, cy } = clampFocus(span.cx, span.cy, L, layout, camera, screen);
9106
+ const v = zoomViewport(L, cx, cy, layout, camera, void 0, screen);
8855
9107
  const px0 = v.x * layout.W;
8856
9108
  const px1 = (v.x + v.w) * layout.W;
8857
9109
  const py0 = v.y * layout.H;
@@ -11577,6 +11829,8 @@ export {
11577
11829
  DRAG_FIT_LEVEL,
11578
11830
  DRAG_MIN_S,
11579
11831
  DRAG_MIN_TRAVEL_FRAC,
11832
+ EM_CLOSE,
11833
+ EM_OPEN,
11580
11834
  END_CARD_FROM,
11581
11835
  END_CARD_RECEDE,
11582
11836
  END_CARD_SECONDS,
@@ -11808,6 +12062,7 @@ export {
11808
12062
  lowerProgramDoc,
11809
12063
  lowerStudioDoc,
11810
12064
  lowerToComposition,
12065
+ measureEmphasized,
11811
12066
  mediaAtOutput,
11812
12067
  mediaDuration,
11813
12068
  mediaFrame,
@@ -11831,6 +12086,7 @@ export {
11831
12086
  outputEnd,
11832
12087
  outputRangeToSource,
11833
12088
  overlapFraction,
12089
+ overlayDisplayText,
11834
12090
  overlayFaceFor,
11835
12091
  overlayFontFaces,
11836
12092
  overlayFontString,
@@ -11845,6 +12101,7 @@ export {
11845
12101
  ownFreezes,
11846
12102
  pageDisplayUrl,
11847
12103
  parseCssColour,
12104
+ parseEmphasis,
11848
12105
  parsePoseId,
11849
12106
  pickStyle,
11850
12107
  pinBox,
@@ -11872,6 +12129,7 @@ export {
11872
12129
  relativeLuminance,
11873
12130
  removeSourceRange,
11874
12131
  removeSpeedInRange,
12132
+ resolveEmphasis,
11875
12133
  resolveExportSize,
11876
12134
  resolveOverlayBox,
11877
12135
  resolveOverlayStyle,
@@ -11892,6 +12150,7 @@ export {
11892
12150
  smoothCursor,
11893
12151
  spanOutputExtent,
11894
12152
  speedLane,
12153
+ stripEmphasis,
11895
12154
  studioAudioPlan,
11896
12155
  studioEntry,
11897
12156
  studioLayerData,