@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.d.ts +66 -11
- package/dist/index.js +258 -69
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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:
|
|
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:
|
|
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 =
|
|
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
|
|
1448
|
+
const targets = [];
|
|
1449
|
+
const surfaces = [];
|
|
1445
1450
|
for (const c of clicks) {
|
|
1446
1451
|
if (absorbed.has(c)) continue;
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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
|
|
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
|
-
|
|
1672
|
-
|
|
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
|
|
2164
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2270
|
+
var fmtT = (t) => String(Math.round(t * 1e3) / 1e3);
|
|
2271
|
+
var escapeXml = (v) => String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
|
2955
|
-
let p = faceData.get(
|
|
3067
|
+
const bytesOf = (url, mime, what) => {
|
|
3068
|
+
let p = faceData.get(url)
|
|
2956
3069
|
if (p) return p
|
|
2957
|
-
p = fetch(
|
|
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
|
-
|
|
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
|
|
2966
|
-
//
|
|
2967
|
-
faceData.delete(
|
|
2968
|
-
console.warn('[voila] html layer
|
|
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(
|
|
3085
|
+
faceData.set(url, p)
|
|
2972
3086
|
return p
|
|
2973
3087
|
}
|
|
2974
|
-
const
|
|
2975
|
-
|
|
2976
|
-
|
|
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
|
|
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 !==
|
|
3014
|
-
cache.set(
|
|
3015
|
-
ns.htmlKeys[oc.id] =
|
|
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
|
-
|
|
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,
|