@vosjs/studio-core 0.19.1 → 0.21.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
@@ -1135,7 +1135,7 @@ var ZOOM_STYLES = {
1135
1135
  glide: {
1136
1136
  autoZoom: true,
1137
1137
  clusterGap: 3,
1138
- minClusterClicks: 2,
1138
+ minClusterClicks: 1,
1139
1139
  targetFill: 0.42,
1140
1140
  minLevel: 1.3,
1141
1141
  maxLevel: 1.8,
@@ -1308,7 +1308,7 @@ var ZOOM_STYLES = {
1308
1308
  keynote: {
1309
1309
  autoZoom: true,
1310
1310
  clusterGap: 3,
1311
- minClusterClicks: 2,
1311
+ minClusterClicks: 1,
1312
1312
  targetFill: 0.42,
1313
1313
  minLevel: 1.3,
1314
1314
  maxLevel: 1.8,
@@ -1417,13 +1417,17 @@ var DWELL_MIN = 0.45;
1417
1417
  var DWELL_MAX = 2.6;
1418
1418
  var DWELL_SPACING = 1.8;
1419
1419
  var DRAG_FIT_LEVEL = 1.15;
1420
+ var PRESS_ECHO_MS = 4;
1421
+ var PRESS_ECHO_PX = 2;
1420
1422
  var TYPING_MIN_PINGS = 2;
1421
1423
  var TYPING_MIN_DUR = 0.5;
1422
1424
  var TYPING_CLICK_ABSORB = 1.5;
1423
1425
  var TYPING_REFOCUS_FRAC = 0.08;
1424
1426
  function groupTrack(track, opts) {
1425
- const { width, height, clusterGap, typingGap, typingZoom } = opts;
1426
- const clicks = track.filter((e) => e.type === "down").map((e) => ({ t: e.t / 1e3, rect: e.rect, x: e.x, y: e.y }));
1427
+ const { width, height, clusterGap, typingGap, typingZoom, targetFill } = opts;
1428
+ const clicks = dedupePresses(
1429
+ track.filter((e) => e.type === "down")
1430
+ ).map((e) => ({ t: e.t / 1e3, rect: e.rect, x: e.x, y: e.y }));
1427
1431
  const sessions = typingZoom ? typingSessions(track, width, height, typingGap) : [];
1428
1432
  const absorbed = /* @__PURE__ */ new Set();
1429
1433
  for (const s of sessions) {
@@ -1441,15 +1445,43 @@ function groupTrack(track, opts) {
1441
1445
  s.events = [best, ...s.events];
1442
1446
  }
1443
1447
  }
1444
- const clusters = [];
1448
+ const targets = [];
1449
+ const surfaces = [];
1445
1450
  for (const c of clicks) {
1446
1451
  if (absorbed.has(c)) continue;
1447
- const last = clusters.at(-1);
1448
- const prev = last?.at(-1);
1449
- if (last && prev && c.t - prev.t <= clusterGap) last.push(c);
1450
- else clusters.push([c]);
1452
+ if (isSurfacePress(c, width, height, targetFill)) surfaces.push(c);
1453
+ else targets.push(c);
1454
+ }
1455
+ const chain = (list) => {
1456
+ const clusters = [];
1457
+ for (const c of list) {
1458
+ const last = clusters.at(-1);
1459
+ const prev = last?.at(-1);
1460
+ if (last && prev && c.t - prev.t <= clusterGap) last.push(c);
1461
+ else clusters.push([c]);
1462
+ }
1463
+ return clusters;
1464
+ };
1465
+ return { sessions, clusters: chain(targets), surfaces: chain(surfaces) };
1466
+ }
1467
+ function dedupePresses(downs) {
1468
+ const kept = [];
1469
+ for (const e of downs) {
1470
+ const prev = kept.at(-1);
1471
+ if (prev && e.t - prev.t <= PRESS_ECHO_MS && Math.abs(e.x - prev.x) <= PRESS_ECHO_PX && Math.abs(e.y - prev.y) <= PRESS_ECHO_PX) {
1472
+ continue;
1473
+ }
1474
+ kept.push(e);
1451
1475
  }
1452
- return { sessions, clusters };
1476
+ return kept;
1477
+ }
1478
+ function fitLevel(rect, width, height, targetFill) {
1479
+ if (!rect || rect.w <= 0 || rect.h <= 0) return null;
1480
+ return Math.min(width * targetFill / rect.w, height * targetFill / rect.h);
1481
+ }
1482
+ function isSurfacePress(c, width, height, targetFill) {
1483
+ const fit = fitLevel(c.rect, width, height, targetFill);
1484
+ return fit !== null && fit < DRAG_FIT_LEVEL;
1453
1485
  }
1454
1486
  function planAutoZoom(track, options) {
1455
1487
  const style = resolveZoomStyle(options.style, options.params);
@@ -1470,31 +1502,31 @@ function planAutoZoom(track, options) {
1470
1502
  typingHold = style.typingHold,
1471
1503
  typingMinLevel = style.typingMinLevel
1472
1504
  } = options;
1473
- const { sessions, clusters } = groupTrack(track, {
1505
+ const { sessions, clusters, surfaces } = groupTrack(track, {
1474
1506
  width,
1475
1507
  height,
1476
1508
  clusterGap,
1477
1509
  typingGap,
1478
- typingZoom
1510
+ typingZoom,
1511
+ targetFill
1479
1512
  });
1480
1513
  const eligible = clusters.filter((c) => c.length >= minClusterClicks);
1481
1514
  const clickSpans = [];
1482
- const dragReserved = [];
1515
+ const dragReserved = surfaces.map((cluster, i) => {
1516
+ const f = clusterFocus(cluster, width, height);
1517
+ return {
1518
+ id: `drag${i}`,
1519
+ in: Math.max(0, cluster[0].t - lead),
1520
+ out: cluster[cluster.length - 1].t + hold,
1521
+ level: 1,
1522
+ cx: f.cx,
1523
+ cy: f.cy
1524
+ };
1525
+ });
1483
1526
  for (const cluster of eligible) {
1484
1527
  const first = cluster[0];
1485
1528
  const last = cluster[cluster.length - 1];
1486
1529
  const f = focusFor(cluster, width, height, targetFill, minLevel, maxLevel);
1487
- if (f.fit !== null && f.fit < DRAG_FIT_LEVEL) {
1488
- dragReserved.push({
1489
- id: `drag${dragReserved.length}`,
1490
- in: Math.max(0, first.t - lead),
1491
- out: last.t + hold,
1492
- level: 1,
1493
- cx: f.cx,
1494
- cy: f.cy
1495
- });
1496
- continue;
1497
- }
1498
1530
  clickSpans.push({
1499
1531
  in: Math.max(0, first.t - lead),
1500
1532
  out: last.t + hold,
@@ -1668,15 +1700,8 @@ function focusFor(cluster, width, height, targetFill, minLevel, maxLevel) {
1668
1700
  const n = cluster.length;
1669
1701
  const cx = clamp012(sx / n / width);
1670
1702
  const cy = clamp012(sy / n / height);
1671
- let level = maxLevel;
1672
- let fit = null;
1673
- if (maxW > 0 && maxH > 0) {
1674
- const fitX = width * targetFill / maxW;
1675
- const fitY = height * targetFill / maxH;
1676
- level = Math.min(fitX, fitY);
1677
- fit = level;
1678
- }
1679
- return { cx, cy, level: clamp2(level, minLevel, maxLevel), fit };
1703
+ const level = fitLevel({ x: 0, y: 0, w: maxW, h: maxH }, width, height, targetFill) ?? maxLevel;
1704
+ return { cx, cy, level: clamp2(level, minLevel, maxLevel) };
1680
1705
  }
1681
1706
  function clusterFocus(events, width, height) {
1682
1707
  let sx = 0;
@@ -1918,6 +1943,8 @@ import { findFontFamily, fontFaceUrl, nearestFontWeight } from "@vosjs/shared";
1918
1943
  var DESIGN_FRAME_WIDTH = 1920;
1919
1944
  var HTML_LAYER_WARN_BYTES = 32e3;
1920
1945
  var HTML_LAYER_MAX_BYTES = 128e3;
1946
+ var HTML_LAYER_LIVE_HZ = 60;
1947
+ var HTML_LAYER_ASSET_HOST = "https://assets.vos.so/";
1921
1948
  function splitTopLevel(value) {
1922
1949
  const out = [];
1923
1950
  let depth = 0;
@@ -2017,6 +2044,33 @@ function htmlLayerUnhostedFamilies(clip) {
2017
2044
  (f) => !findFontFamily(f) && !explicit.has(f.toLowerCase())
2018
2045
  );
2019
2046
  }
2047
+ function htmlLayerAssetUrls(clip) {
2048
+ const out = [];
2049
+ const seen = /* @__PURE__ */ new Set();
2050
+ const add = (u) => {
2051
+ if (!seen.has(u)) {
2052
+ seen.add(u);
2053
+ out.push(u);
2054
+ }
2055
+ };
2056
+ for (const m of clip.html.matchAll(
2057
+ /\bsrc\s*=\s*["']((?:https?:)?\/\/[^"']+)["']/gi
2058
+ ))
2059
+ add(m[1]);
2060
+ for (const text of [clip.html, clip.css ?? ""])
2061
+ for (const m of text.matchAll(
2062
+ /url\(\s*["']?((?:https?:)?\/\/[^"')\s]+)["']?\s*\)/gi
2063
+ ))
2064
+ add(m[1]);
2065
+ return out;
2066
+ }
2067
+ function htmlLayerAssets(clip) {
2068
+ const inlinable = [];
2069
+ const foreign = [];
2070
+ for (const u of htmlLayerAssetUrls(clip))
2071
+ (u.replace(/^\/\//, "https://").startsWith(HTML_LAYER_ASSET_HOST) ? inlinable : foreign).push(u);
2072
+ return { inlinable, foreign };
2073
+ }
2020
2074
  function htmlLayerBleedFor(css) {
2021
2075
  if (!css) return 0;
2022
2076
  let worst = 0;
@@ -2074,6 +2128,7 @@ function htmlLayerLabel(clip) {
2074
2128
  const words = htmlLayerText(clip.html);
2075
2129
  return words ? words.slice(0, 24) : "HTML";
2076
2130
  }
2131
+ var HTML_LAYER_PLACEHOLDER = /\{\{\s*(t|data\.[A-Za-z0-9_.-]+)\s*\}\}/;
2077
2132
  var VOID_TAGS = [
2078
2133
  "br",
2079
2134
  "img",
@@ -2160,18 +2215,36 @@ function htmlLayerProblems(clip) {
2160
2215
  "warning",
2161
2216
  `The CSS paints up to ${needed}px outside the component and bleed is ${clip.bleed}, so the shadow is clipped square at the layer's edge. Set bleed to ${needed}, or leave it out to derive it.`
2162
2217
  );
2163
- const external = /\b(src|href)\s*=\s*["']\s*(https?:)?\/\//i.test(html) || /url\(\s*["']?\s*(https?:)?\/\//i.test(css ?? "") || /url\(\s*["']?\s*(https?:)?\/\//i.test(html);
2164
- if (external)
2218
+ for (const u of htmlLayerAssets({ html, css }).foreign)
2219
+ push2(
2220
+ "external-resource",
2221
+ "warning",
2222
+ `The source names ${u} by URL. The page inlines only ${HTML_LAYER_ASSET_HOST}\u2026 (the host it may fetch, and the one the fleet reaches), so this will paint blank. Upload it (vos asset upload) or inline it as a data: URI.`
2223
+ );
2224
+ if (/\bhref\s*=\s*["']\s*(https?:)?\/\//i.test(html))
2165
2225
  push2(
2166
2226
  "external-resource",
2167
2227
  "warning",
2168
- "The source names a resource by URL. An SVG image fetches nothing, so it will not paint; inline it as a data: URI (faces are inlined for you when the CSS names a hosted family)."
2228
+ "The source names a stylesheet or a link by URL. An SVG image fetches no href; inline the styles in the CSS."
2169
2229
  );
2170
- if (/\b(animation|transition)(-[a-z-]+)?\s*:/i.test(css ?? ""))
2230
+ const animated = /\b(animation|transition)(-[a-z-]+)?\s*:/i.test(css ?? "");
2231
+ if (animated && !clip.live)
2171
2232
  push2(
2172
2233
  "wall-clock",
2173
2234
  "warning",
2174
- "CSS animations inside a layer run on the wall clock, not the timeline: the export will not match the preview. Animate the layer with motion and anim instead."
2235
+ "CSS animations inside a layer run on the wall clock, not the timeline: the export will not match the preview. Set live: true to scrub them to the clip's time, or animate the layer with motion and anim."
2236
+ );
2237
+ if (clip.live && /\banimation-delay\s*:/i.test(css ?? ""))
2238
+ push2(
2239
+ "live-delay",
2240
+ "warning",
2241
+ "A live layer scrubs its keyframes by setting animation-delay to -t, so the authored animation-delay is overridden. Fold the delay into the keyframes instead."
2242
+ );
2243
+ if (!clip.live && (HTML_LAYER_PLACEHOLDER.test(html) || HTML_LAYER_PLACEHOLDER.test(css ?? "")))
2244
+ push2(
2245
+ "placeholder-still",
2246
+ "warning",
2247
+ "The source carries {{t}} or {{data.\u2026}} placeholders, which fill only on a live layer (live: true); a still layer shows them as written."
2175
2248
  );
2176
2249
  for (const family of htmlLayerUnhostedFamilies(clip))
2177
2250
  push2(
@@ -2194,12 +2267,31 @@ function fontFaceCss(faces) {
2194
2267
  out += `@font-face{font-family:'${f.family}';src:url(${f.dataUri}) format('woff2');font-weight:${f.weight};font-style:${f.style};font-display:block}`;
2195
2268
  return out;
2196
2269
  }
2197
- function htmlLayerSvg(src, faces = []) {
2270
+ var fmtT = (t) => String(Math.round(t * 1e3) / 1e3);
2271
+ var escapeXml = (v) => String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2272
+ function htmlLayerSvg(src, faces = [], assets = []) {
2198
2273
  const bleed = Math.max(0, Math.round(src.bleed ?? 0));
2199
2274
  const boxW = src.box.width + bleed * 2;
2200
2275
  const boxH = src.box.height + bleed * 2;
2276
+ const t = src.t ?? 0;
2277
+ const fill = (text) => {
2278
+ let out = text;
2279
+ for (const a of assets) out = out.split(a.url).join(a.dataUri);
2280
+ if (src.live) {
2281
+ out = out.replace(/\{\{\s*t\s*\}\}/g, fmtT(t));
2282
+ out = out.replace(
2283
+ /\{\{\s*data\.([A-Za-z0-9_.-]+)\s*\}\}/g,
2284
+ (_m, name) => {
2285
+ const v = src.data ? src.data[name] : void 0;
2286
+ return v == null ? "" : escapeXml(v);
2287
+ }
2288
+ );
2289
+ }
2290
+ return out;
2291
+ };
2292
+ const scrub = src.live ? `.vos-html-layer{--vos-t:${fmtT(t)}}.vos-html-layer,.vos-html-layer *{animation-play-state:paused!important;animation-delay:calc(var(--vos-t) * -1s)!important}` : "";
2201
2293
  const wrapper = `.vos-html-layer{width:${boxW}px;height:${boxH}px;box-sizing:border-box;margin:0;padding:${bleed}px;font-family:-apple-system,system-ui,sans-serif;-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision}`;
2202
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${boxW}" height="${boxH}" viewBox="0 0 ${boxW} ${boxH}"><foreignObject x="0" y="0" width="${boxW}" height="${boxH}"><div xmlns="http://www.w3.org/1999/xhtml" class="vos-html-layer"><style>${fontFaceCss(faces)}${wrapper}${src.css ?? ""}</style>` + src.html + `</div></foreignObject></svg>`;
2294
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${boxW}" height="${boxH}" viewBox="0 0 ${boxW} ${boxH}"><foreignObject x="0" y="0" width="${boxW}" height="${boxH}"><div xmlns="http://www.w3.org/1999/xhtml" class="vos-html-layer"><style>${fontFaceCss(faces)}${wrapper}${fill(src.css ?? "")}${scrub}</style>` + fill(src.html) + `</div></foreignObject></svg>`;
2203
2295
  }
2204
2296
  function fnv1a(s) {
2205
2297
  let h = 2166136261;
@@ -2209,22 +2301,27 @@ function fnv1a(s) {
2209
2301
  }
2210
2302
  return h.toString(36);
2211
2303
  }
2212
- function htmlLayerKey(clip, faces, bleed) {
2213
- const src = clip.html + "\0" + (clip.css ?? "") + "\0" + clip.box.width + "x" + clip.box.height + "\0" + bleed + "\0" + faces.map((f) => `${f.family}:${f.url}:${f.weight}:${f.style}`).join("|");
2304
+ function htmlLayerKey(clip, faces, bleed, assets = []) {
2305
+ const src = clip.html + "\0" + (clip.css ?? "") + "\0" + clip.box.width + "x" + clip.box.height + "\0" + bleed + "\0" + faces.map((f) => `${f.family}:${f.url}:${f.weight}:${f.style}`).join("|") + " " + assets.join("|") + // A live layer's picture is also a function of t: ON_FRAME appends the
2306
+ // moment to this key per frame. The data rides here, the moment there.
2307
+ (clip.live ? " live " + JSON.stringify(clip.data ?? null) : "");
2214
2308
  return `html:${clip.id}:${fnv1a(src)}`;
2215
2309
  }
2216
2310
  function htmlLayerPayload(clip) {
2217
2311
  const faces = htmlLayerFaces(clip);
2218
2312
  const bleed = htmlLayerBleed(clip);
2313
+ const assets = htmlLayerAssets(clip).inlinable;
2219
2314
  return {
2220
- key: htmlLayerKey(clip, faces, bleed),
2315
+ key: htmlLayerKey(clip, faces, bleed, assets),
2221
2316
  width: htmlLayerWidth(clip),
2222
2317
  html: {
2223
2318
  markup: clip.html,
2224
2319
  css: clip.css ?? "",
2225
2320
  box: { width: clip.box.width, height: clip.box.height },
2226
2321
  bleed,
2227
- faces
2322
+ faces,
2323
+ assets,
2324
+ ...clip.live ? { live: true, ...clip.data ? { data: clip.data } : {} } : {}
2228
2325
  }
2229
2326
  };
2230
2327
  }
@@ -2849,17 +2946,30 @@ function studioEntry(data) {
2849
2946
  onFrame: STUDIO_FRAME
2850
2947
  };
2851
2948
  }
2852
- var HTML_LAYER_SVG_CODE = `(src, faces) => {
2949
+ var HTML_LAYER_SVG_CODE = `(src, faces, assets) => {
2853
2950
  const bleed = Math.max(0, Math.round(src.bleed || 0))
2854
2951
  const boxW = src.box.width + bleed * 2
2855
2952
  const boxH = src.box.height + bleed * 2
2856
2953
  let fontCss = ''
2857
2954
  for (const f of faces || []) fontCss += "@font-face{font-family:'" + f.family + "';src:url(" + f.dataUri + ") format('woff2');font-weight:" + f.weight + ";font-style:" + f.style + ";font-display:block}"
2955
+ const t = src.t || 0
2956
+ const fmtT = (n) => String(Math.round(n * 1000) / 1000)
2957
+ const esc = (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
2958
+ const fill = (text) => {
2959
+ let out = text
2960
+ for (const a of assets || []) out = out.split(a.url).join(a.dataUri)
2961
+ if (src.live) {
2962
+ out = out.replace(/\\{\\{\\s*t\\s*\\}\\}/g, fmtT(t))
2963
+ out = out.replace(/\\{\\{\\s*data\\.([A-Za-z0-9_.-]+)\\s*\\}\\}/g, (m, name) => { const v = src.data ? src.data[name] : undefined; return v == null ? '' : esc(v) })
2964
+ }
2965
+ return out
2966
+ }
2967
+ const scrub = src.live ? '.vos-html-layer{--vos-t:' + fmtT(t) + '}.vos-html-layer,.vos-html-layer *{animation-play-state:paused!important;animation-delay:calc(var(--vos-t) * -1s)!important}' : ''
2858
2968
  const wrapper = '.vos-html-layer{width:' + boxW + 'px;height:' + boxH + 'px;box-sizing:border-box;margin:0;padding:' + bleed + 'px;font-family:-apple-system,system-ui,sans-serif;-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision}'
2859
2969
  return '<svg xmlns="http://www.w3.org/2000/svg" width="' + boxW + '" height="' + boxH + '" viewBox="0 0 ' + boxW + ' ' + boxH + '">' +
2860
2970
  '<foreignObject x="0" y="0" width="' + boxW + '" height="' + boxH + '">' +
2861
2971
  '<div xmlns="http://www.w3.org/1999/xhtml" class="vos-html-layer">' +
2862
- '<style>' + fontCss + wrapper + (src.css || '') + '</style>' + src.html +
2972
+ '<style>' + fontCss + wrapper + fill(src.css || '') + scrub + '</style>' + fill(src.html) +
2863
2973
  '</div></foreignObject></svg>'
2864
2974
  }`;
2865
2975
  var STUDIO_SETUP = `async (ctx) => {
@@ -2950,38 +3060,53 @@ var STUDIO_SETUP = `async (ctx) => {
2950
3060
  ns.htmlLast = ns.htmlLast || {}
2951
3061
  ns.htmlKeys = ns.htmlKeys || {}
2952
3062
  ns.htmlErrors = ns.htmlErrors || {}
3063
+ // One byte cache for faces AND the images a layer names by URL, keyed by
3064
+ // URL: fetched once per page however many layers name it, inlined as a
3065
+ // data: URI (a face is always woff2; an image carries its own type).
2953
3066
  const faceData = ns.faceData || (ns.faceData = new Map())
2954
- const faceOf = (f) => {
2955
- let p = faceData.get(f.url)
3067
+ const bytesOf = (url, mime, what) => {
3068
+ let p = faceData.get(url)
2956
3069
  if (p) return p
2957
- p = fetch(f.url).then(async (r) => {
3070
+ p = fetch(url).then(async (r) => {
2958
3071
  if (!r.ok) throw new Error('HTTP ' + r.status)
2959
3072
  const buf = new Uint8Array(await r.arrayBuffer())
2960
3073
  // Chunked: String.fromCharCode.apply throws on a large spread.
2961
3074
  let str = ''
2962
3075
  for (let i = 0; i < buf.length; i += 0x8000) str += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000))
2963
- return 'data:font/woff2;base64,' + btoa(str)
3076
+ const type = mime || ((r.headers.get('content-type') || 'application/octet-stream').split(';')[0])
3077
+ return 'data:' + type + ';base64,' + btoa(str)
2964
3078
  }).catch((e) => {
2965
- // Fail open per face, and NOT sticky: the layer paints in the fallback
2966
- // stack now and the next build asks again.
2967
- faceData.delete(f.url)
2968
- console.warn('[voila] html layer face failed to load', f.url, e)
3079
+ // Fail open per URL, and NOT sticky: the layer paints without it now
3080
+ // and the next build asks again.
3081
+ faceData.delete(url)
3082
+ console.warn('[voila] html layer ' + what + ' failed to load', url, e)
2969
3083
  return ''
2970
3084
  })
2971
- faceData.set(f.url, p)
3085
+ faceData.set(url, p)
2972
3086
  return p
2973
3087
  }
2974
- const buildHtmlLayer = async (oc) => {
2975
- const want = () => ns.htmlWant[oc.id] === oc.key
2976
- ns.htmlWant[oc.id] = oc.key
3088
+ const faceOf = (f) => bytesOf(f.url, 'font/woff2', 'face')
3089
+ const assetOf = (url) => bytesOf(url, '', 'asset')
3090
+ // The second argument names the moment a LIVE layer is composed for: its
3091
+ // own key (the clip's key plus the moment) and t. A still builds under
3092
+ // the clip's key.
3093
+ const buildHtmlLayer = async (oc, live) => {
3094
+ const key = live ? live.key : oc.key
3095
+ const want = () => ns.htmlWant[oc.id] === key
3096
+ ns.htmlWant[oc.id] = key
2977
3097
  const h = oc.html
2978
3098
  const uris = await Promise.all((h.faces || []).map(faceOf))
3099
+ const assetUris = await Promise.all((h.assets || []).map(assetOf))
2979
3100
  if (!want()) return
2980
3101
  const faces = []
2981
3102
  for (let i = 0; i < uris.length; i++) {
2982
3103
  if (uris[i]) faces.push({ family: h.faces[i].family, weight: h.faces[i].weight, style: h.faces[i].style, dataUri: uris[i] })
2983
3104
  }
2984
- const svg = htmlSvg({ html: h.markup, css: h.css, box: h.box, bleed: h.bleed }, faces)
3105
+ const assets = []
3106
+ for (let i = 0; i < assetUris.length; i++) {
3107
+ if (assetUris[i]) assets.push({ url: h.assets[i], dataUri: assetUris[i] })
3108
+ }
3109
+ const svg = htmlSvg({ html: h.markup, css: h.css, box: h.box, bleed: h.bleed, live: !!live, t: live ? live.t : 0, data: h.data }, faces, assets)
2985
3110
  const uri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)))
2986
3111
  const img = await new Promise((res, rej) => {
2987
3112
  const i = new Image()
@@ -3009,24 +3134,59 @@ var STUDIO_SETUP = `async (ctx) => {
3009
3134
  console.error('[voila] html layers are off: this browser taints a canvas that draws a foreignObject SVG', e)
3010
3135
  }
3011
3136
  }
3137
+ // One picture per clip in the cache: the previous key goes when a new
3138
+ // one lands, which for a live layer is every frame.
3012
3139
  const prev = ns.htmlKeys[oc.id]
3013
- if (prev && prev !== oc.key) cache.delete(prev)
3014
- cache.set(oc.key, img)
3015
- ns.htmlKeys[oc.id] = oc.key
3140
+ if (prev && prev !== key) cache.delete(prev)
3141
+ cache.set(key, img)
3142
+ ns.htmlKeys[oc.id] = key
3016
3143
  ns.htmlLast[oc.id] = img
3017
3144
  delete ns.htmlErrors[oc.id]
3018
3145
  }
3019
3146
  // ON_FRAME is its own function string, so the builder is handed over for a
3020
3147
  // layer that arrives by live edit after setup has run. A failure is
3021
3148
  // recorded on the clip and said at the level a render page forwards.
3022
- ns.buildHtmlLayer = (oc) => buildHtmlLayer(oc).catch((e) => {
3149
+ ns.buildHtmlLayer = (oc, live) => buildHtmlLayer(oc, live).catch((e) => {
3023
3150
  ns.htmlErrors[oc.id] = String((e && e.message) || e)
3024
3151
  console.error('[voila] html layer ' + oc.id + ' ' + ns.htmlErrors[oc.id])
3025
3152
  })
3153
+ // The capture contract for a LIVE layer, on ANY anchor. A recording's own
3154
+ // program installs the settle machinery (pendingDecodes, the wait a
3155
+ // capture awaits, a frame-prep hook that requests the frame's footage
3156
+ // before the paint); a program anchor installs none of it, and a live
3157
+ // layer on one would export a frame behind (the last landed picture),
3158
+ // since nothing awaited the build. So the entry installs what is absent
3159
+ // and registers its own hook: the moment every visible live layer needs
3160
+ // at t is asked for BEFORE the paint and put on pendingDecodes, so the
3161
+ // first paint draws the exact picture and a still at t is t. ON_FRAME
3162
+ // asks the same way (one source, inlined in both scopes) for the preview,
3163
+ // where the last landed picture stands in meanwhile.
3164
+ ns.pendingDecodes = ns.pendingDecodes || new Set()
3165
+ if (!ns.waitForVideosReady) ns.waitForVideosReady = async () => {
3166
+ if (ns.pendingDecodes.size) await Promise.all([...ns.pendingDecodes])
3167
+ }
3168
+ ns.framePrep = ns.framePrep || new Map()
3169
+ ns.framePrep.set('vosso.studio', (t) => {
3170
+ if (ns.isPaused === false) return
3171
+ for (const ol of (ctx.data.overlays || [])) {
3172
+ if (!ol.html || !ol.html.live) continue
3173
+ const olT = t - ol.start
3174
+ if (olT < 0 || olT > ol.dur) continue
3175
+ const q = Math.round(olT * ${HTML_LAYER_LIVE_HZ})
3176
+ const key = ol.key + '@' + q
3177
+ if (cache.get(key) || ns.htmlWant[ol.id] === key) continue
3178
+ const p = ns.buildHtmlLayer(ol, { key, t: q / ${HTML_LAYER_LIVE_HZ} })
3179
+ if (p && p.finally) {
3180
+ ns.pendingDecodes.add(p)
3181
+ p.finally(() => ns.pendingDecodes.delete(p))
3182
+ }
3183
+ }
3184
+ })
3026
3185
  // Media overlays (V1b): warm-load through the shared cache so the first
3027
3186
  // frame draws complete. Fail-open per clip (a bad key just doesn't draw).
3187
+ // A live layer warms at its first moment; ON_FRAME re-composes it per frame.
3028
3188
  for (const oc of (ctx.data.overlays || [])) {
3029
- if (oc.html) { await ns.buildHtmlLayer(oc); continue }
3189
+ if (oc.html) { await ns.buildHtmlLayer(oc, oc.html.live ? { key: oc.key + '@0', t: 0 } : undefined); continue }
3030
3190
  if (oc.kind !== 'image' && oc.kind !== 'video') continue
3031
3191
  try {
3032
3192
  if (oc.kind === 'image') await loadImage(oc.key)
@@ -3314,6 +3474,8 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
3314
3474
  // until its picture lands, or the edit would not appear until something
3315
3475
  // else happened to dirty the canvas.
3316
3476
  if (ol0.html && !(ns.videoCache && ns.videoCache.get(ol0.key))) olAnim = true
3477
+ // A LIVE html layer is a new picture every frame it is on screen.
3478
+ if (ol0.html && ol0.html.live) olAnim = true
3317
3479
  // Video overlays advance every frame; images redraw until decoded.
3318
3480
  if (ol0.kind === 'video') olAnim = true
3319
3481
  else if (ol0.kind === 'image') {
@@ -3351,6 +3513,28 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
3351
3513
  // per key and let a later frame pick it up; a build that failed stays
3352
3514
  // asked, and the next source edit is a new key.
3353
3515
  if (ol.html) {
3516
+ if (ol.html.live) {
3517
+ // A live layer's picture is f(t): the key carries clip-local time on
3518
+ // the ${HTML_LAYER_LIVE_HZ} Hz grid, the builder keeps ONE picture per clip
3519
+ // (the previous moment's is evicted as the next lands) and the last
3520
+ // landed picture is drawn while the next composes, so the preview
3521
+ // never blinks. A build in flight for this very moment is not asked
3522
+ // again (htmlWant holds the moment). In capture the build rides
3523
+ // pendingDecodes, so the frame settles and paints again with the
3524
+ // exact picture (the harness's two-phase settle): the export is
3525
+ // deterministic where the preview is merely prompt.
3526
+ var hlQ = Math.round(olT * ${HTML_LAYER_LIVE_HZ})
3527
+ var hlKey = ol.key + '@' + hlQ
3528
+ var hlEl = ns.videoCache ? ns.videoCache.get(hlKey) : null
3529
+ if (!hlEl && ns.buildHtmlLayer && !(ns.htmlWant && ns.htmlWant[ol.id] === hlKey)) {
3530
+ var hlP = ns.buildHtmlLayer(ol, { key: hlKey, t: hlQ / ${HTML_LAYER_LIVE_HZ} })
3531
+ if (ns.pendingDecodes && hlP && hlP.finally) {
3532
+ ns.pendingDecodes.add(hlP)
3533
+ hlP.finally(function () { ns.pendingDecodes.delete(hlP) })
3534
+ }
3535
+ }
3536
+ return hlEl || (ns.htmlLast && ns.htmlLast[ol.id]) || null
3537
+ }
3354
3538
  if (!olEl && ns.buildHtmlLayer) {
3355
3539
  var hlB = ns.htmlBuilding || (ns.htmlBuilding = {})
3356
3540
  if (!hlB[ol.key]) { hlB[ol.key] = 1; ns.buildHtmlLayer(ol) }
@@ -6803,14 +6987,15 @@ function momentsFromDoc(doc, plan, opts = {}) {
6803
6987
  rect: null
6804
6988
  });
6805
6989
  if (track.length) {
6806
- const { sessions, clusters } = groupTrack(track, {
6990
+ const { sessions, clusters, surfaces } = groupTrack(track, {
6807
6991
  width,
6808
6992
  height,
6809
6993
  clusterGap: style.clusterGap,
6810
6994
  typingGap: style.typingGap,
6811
- typingZoom: style.typingZoom
6995
+ typingZoom: style.typingZoom,
6996
+ targetFill: style.targetFill
6812
6997
  });
6813
- for (const c of clusters) {
6998
+ const pressed = (c, surface) => {
6814
6999
  const f = clusterFocus(c, width, height);
6815
7000
  drafts.push({
6816
7001
  kind: "click",
@@ -6821,9 +7006,12 @@ function momentsFromDoc(doc, plan, opts = {}) {
6821
7006
  at: c[0].t,
6822
7007
  focus: { cx: f.cx, cy: f.cy },
6823
7008
  rect: f.rect,
6824
- clicks: c.length
7009
+ clicks: c.length,
7010
+ ...surface ? { surface: true } : {}
6825
7011
  });
6826
- }
7012
+ };
7013
+ for (const c of clusters) pressed(c, false);
7014
+ for (const c of surfaces) pressed(c, true);
6827
7015
  for (const s of sessions) {
6828
7016
  const f = clusterFocus(s.events, width, height);
6829
7017
  drafts.push({
@@ -6943,6 +7131,7 @@ function momentsFromDoc(doc, plan, opts = {}) {
6943
7131
  h: round8(d.rect.h)
6944
7132
  } : null,
6945
7133
  ...d.clicks !== void 0 ? { clicks: d.clicks } : {},
7134
+ ...d.surface ? { surface: true } : {},
6946
7135
  ...d.pings !== void 0 ? { pings: d.pings } : {},
6947
7136
  activity: activityOf(opts.bins, d),
6948
7137
  proposed,