@vosjs/studio-core 0.19.1 → 0.20.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 CHANGED
@@ -1353,6 +1353,18 @@ interface HtmlOverlayClip extends OverlayClipBase {
1353
1353
  * component designed at 452 px lands at 452 px in a 1080p frame.
1354
1354
  */
1355
1355
  width?: number;
1356
+ /**
1357
+ * A LIVE layer: the picture is a function of clip-local time. CSS
1358
+ * `@keyframes` inside it are scrubbed to t (paused, delayed by -t), and
1359
+ * `{{t}}` / `{{data.<name>}}` placeholders in the markup and CSS fill per
1360
+ * frame, so a counter, a progress bar or a typed line come out on the
1361
+ * timeline's clock in the preview and in every export chunk alike. One
1362
+ * rasterize per frame while on screen. Absent = a still, byte-identical
1363
+ * to before the field existed.
1364
+ */
1365
+ live?: boolean;
1366
+ /** Values for a live layer's `{{data.<name>}}` placeholders. */
1367
+ data?: Record<string, string | number | boolean>;
1356
1368
  /** Opacity 0..1. Absent = 1. */
1357
1369
  opacity?: number;
1358
1370
  }
@@ -3090,7 +3102,7 @@ declare function studioEntry(data: Record<string, unknown>): StudioEntry;
3090
3102
  * import, so the composer is carried twice and `htmlOverlay.test.ts` pins
3091
3103
  * the two byte-for-byte. Change them together.
3092
3104
  */
3093
- declare const HTML_LAYER_SVG_CODE = "(src, faces) => {\n const bleed = Math.max(0, Math.round(src.bleed || 0))\n const boxW = src.box.width + bleed * 2\n const boxH = src.box.height + bleed * 2\n let fontCss = ''\n 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}\"\n 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}'\n return '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"' + boxW + '\" height=\"' + boxH + '\" viewBox=\"0 0 ' + boxW + ' ' + boxH + '\">' +\n '<foreignObject x=\"0\" y=\"0\" width=\"' + boxW + '\" height=\"' + boxH + '\">' +\n '<div xmlns=\"http://www.w3.org/1999/xhtml\" class=\"vos-html-layer\">' +\n '<style>' + fontCss + wrapper + (src.css || '') + '</style>' + src.html +\n '</div></foreignObject></svg>'\n}";
3105
+ declare const HTML_LAYER_SVG_CODE = "(src, faces, assets) => {\n const bleed = Math.max(0, Math.round(src.bleed || 0))\n const boxW = src.box.width + bleed * 2\n const boxH = src.box.height + bleed * 2\n let fontCss = ''\n 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}\"\n const t = src.t || 0\n const fmtT = (n) => String(Math.round(n * 1000) / 1000)\n const esc = (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;')\n const fill = (text) => {\n let out = text\n for (const a of assets || []) out = out.split(a.url).join(a.dataUri)\n if (src.live) {\n out = out.replace(/\\{\\{\\s*t\\s*\\}\\}/g, fmtT(t))\n 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) })\n }\n return out\n }\n 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}' : ''\n 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}'\n return '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"' + boxW + '\" height=\"' + boxH + '\" viewBox=\"0 0 ' + boxW + ' ' + boxH + '\">' +\n '<foreignObject x=\"0\" y=\"0\" width=\"' + boxW + '\" height=\"' + boxH + '\">' +\n '<div xmlns=\"http://www.w3.org/1999/xhtml\" class=\"vos-html-layer\">' +\n '<style>' + fontCss + wrapper + fill(src.css || '') + scrub + '</style>' + fill(src.html) +\n '</div></foreignObject></svg>'\n}";
3094
3106
 
3095
3107
  /** The design frame an HTML layer's `box` is measured against (1080p). */
3096
3108
  declare const DESIGN_FRAME_WIDTH = 1920;
@@ -3113,6 +3125,14 @@ interface HtmlLayerSource {
3113
3125
  height: number;
3114
3126
  };
3115
3127
  bleed?: number;
3128
+ /**
3129
+ * A LIVE layer (§3.13): the picture is a function of clip-local time.
3130
+ * `t` is the moment being drawn, `data` the clip's own values for the
3131
+ * `{{data.<name>}}` placeholders. Absent = a still.
3132
+ */
3133
+ live?: boolean;
3134
+ t?: number;
3135
+ data?: Record<string, string | number | boolean>;
3116
3136
  }
3117
3137
  interface HtmlLayerInlineFace {
3118
3138
  family: string;
@@ -3120,6 +3140,11 @@ interface HtmlLayerInlineFace {
3120
3140
  style: 'normal' | 'italic';
3121
3141
  dataUri: string;
3122
3142
  }
3143
+ /** An image the source names by URL, fetched and inlined by the page. */
3144
+ interface HtmlLayerInlineAsset {
3145
+ url: string;
3146
+ dataUri: string;
3147
+ }
3123
3148
  declare function cssFamilies(css: string | undefined): string[];
3124
3149
  /**
3125
3150
  * The weights the CSS and the markup ask for: `font-weight` numbers and
@@ -3180,7 +3205,7 @@ declare function htmlLayerWidth(clip: Pick<HtmlOverlayClip, 'width' | 'box' | 'c
3180
3205
  declare function htmlLayerText(html: string): string;
3181
3206
  /** The timeline item's label: the first words, else the kind. */
3182
3207
  declare function htmlLayerLabel(clip: Pick<HtmlOverlayClip, 'html'>): string;
3183
- type HtmlLayerProblemCode = 'empty' | 'unclosed-void' | 'bare-ampersand' | 'unquoted-attr' | 'too-large' | 'foreign-field' | 'shadow-clipped' | 'external-resource' | 'wall-clock' | 'unhosted-family';
3208
+ type HtmlLayerProblemCode = 'empty' | 'unclosed-void' | 'bare-ampersand' | 'unquoted-attr' | 'too-large' | 'foreign-field' | 'shadow-clipped' | 'external-resource' | 'wall-clock' | 'live-delay' | 'placeholder-still' | 'unhosted-family';
3184
3209
  interface HtmlLayerProblem {
3185
3210
  code: HtmlLayerProblemCode;
3186
3211
  /** A problem stops the layer painting; a warning is a layer that paints wrong. */
@@ -3196,7 +3221,7 @@ declare const HTML_LAYER_FOREIGN_FIELDS: readonly ["key", "radius", "shadow", "b
3196
3221
  * author is told instead, the same answer in the studio, in `vos validate`
3197
3222
  * and in a test.
3198
3223
  */
3199
- declare function htmlLayerProblems(clip: Pick<HtmlOverlayClip, 'html' | 'css' | 'bleed' | 'fonts'>): HtmlLayerProblem[];
3224
+ declare function htmlLayerProblems(clip: Pick<HtmlOverlayClip, 'html' | 'css' | 'bleed' | 'fonts' | 'live'>): HtmlLayerProblem[];
3200
3225
  /**
3201
3226
  * The real XML question, asked of a real parser: the studio hands in
3202
3227
  * `DOMParser`, a test hands in a stub. The CLI does not call this (Node
@@ -3220,9 +3245,15 @@ declare function wellFormedXml(svg: string, parseXml: (text: string) => {
3220
3245
  * resets the two properties an SVG document does not inherit sensibly;
3221
3246
  * everything else is the author's.
3222
3247
  *
3248
+ * Assets the source names by URL are swapped for their bytes. A LIVE layer
3249
+ * is composed for one moment: `{{t}}` and `{{data.<name>}}` fill, and a
3250
+ * scrub block after the author's CSS pauses every animation and delays it
3251
+ * by -t, so the browser lays the frame out at exactly that offset (the
3252
+ * standard way to scrub a CSS animation, here applied per picture).
3253
+ *
3223
3254
  * MIRRORED by `HTML_LAYER_SVG_CODE` in `lower/studioEntry.ts`. Change both.
3224
3255
  */
3225
- declare function htmlLayerSvg(src: HtmlLayerSource, faces?: readonly HtmlLayerInlineFace[]): string;
3256
+ declare function htmlLayerSvg(src: HtmlLayerSource, faces?: readonly HtmlLayerInlineFace[], assets?: readonly HtmlLayerInlineAsset[]): string;
3226
3257
  /**
3227
3258
  * The layer's CACHE KEY: content-addressed over everything the picture is
3228
3259
  * made of (markup, CSS, box, bleed, the resolved faces), so an edit to the
@@ -3231,7 +3262,7 @@ declare function htmlLayerSvg(src: HtmlLayerSource, faces?: readonly HtmlLayerIn
3231
3262
  * scaling or animating the clip does not re-key it: none of those repaint.
3232
3263
  * The key is a handle, never a URL: nothing may fetch it.
3233
3264
  */
3234
- declare function htmlLayerKey(clip: Pick<HtmlOverlayClip, 'id' | 'html' | 'css' | 'box'>, faces: readonly HtmlLayerFace[], bleed: number): string;
3265
+ declare function htmlLayerKey(clip: Pick<HtmlOverlayClip, 'id' | 'html' | 'css' | 'box' | 'live' | 'data'>, faces: readonly HtmlLayerFace[], bleed: number, assets?: readonly string[]): string;
3235
3266
  /** The shape the page is handed for a layer: the source plus resolved faces. */
3236
3267
  interface HtmlLayerPayload {
3237
3268
  markup: string;
@@ -3242,6 +3273,11 @@ interface HtmlLayerPayload {
3242
3273
  };
3243
3274
  bleed: number;
3244
3275
  faces: HtmlLayerFace[];
3276
+ /** Images the source names by URL, for the page to fetch and inline. */
3277
+ assets: string[];
3278
+ /** A live layer: the page composes the picture per frame at clip-local t. */
3279
+ live?: true;
3280
+ data?: Record<string, string | number | boolean>;
3245
3281
  }
3246
3282
  /** Everything the lowering emits for a layer, from the clip alone. */
3247
3283
  declare function htmlLayerPayload(clip: HtmlOverlayClip): {
package/dist/index.js CHANGED
@@ -1918,6 +1918,8 @@ import { findFontFamily, fontFaceUrl, nearestFontWeight } from "@vosjs/shared";
1918
1918
  var DESIGN_FRAME_WIDTH = 1920;
1919
1919
  var HTML_LAYER_WARN_BYTES = 32e3;
1920
1920
  var HTML_LAYER_MAX_BYTES = 128e3;
1921
+ var HTML_LAYER_LIVE_HZ = 60;
1922
+ var HTML_LAYER_ASSET_HOST = "https://assets.vos.so/";
1921
1923
  function splitTopLevel(value) {
1922
1924
  const out = [];
1923
1925
  let depth = 0;
@@ -2017,6 +2019,33 @@ function htmlLayerUnhostedFamilies(clip) {
2017
2019
  (f) => !findFontFamily(f) && !explicit.has(f.toLowerCase())
2018
2020
  );
2019
2021
  }
2022
+ function htmlLayerAssetUrls(clip) {
2023
+ const out = [];
2024
+ const seen = /* @__PURE__ */ new Set();
2025
+ const add = (u) => {
2026
+ if (!seen.has(u)) {
2027
+ seen.add(u);
2028
+ out.push(u);
2029
+ }
2030
+ };
2031
+ for (const m of clip.html.matchAll(
2032
+ /\bsrc\s*=\s*["']((?:https?:)?\/\/[^"']+)["']/gi
2033
+ ))
2034
+ add(m[1]);
2035
+ for (const text of [clip.html, clip.css ?? ""])
2036
+ for (const m of text.matchAll(
2037
+ /url\(\s*["']?((?:https?:)?\/\/[^"')\s]+)["']?\s*\)/gi
2038
+ ))
2039
+ add(m[1]);
2040
+ return out;
2041
+ }
2042
+ function htmlLayerAssets(clip) {
2043
+ const inlinable = [];
2044
+ const foreign = [];
2045
+ for (const u of htmlLayerAssetUrls(clip))
2046
+ (u.replace(/^\/\//, "https://").startsWith(HTML_LAYER_ASSET_HOST) ? inlinable : foreign).push(u);
2047
+ return { inlinable, foreign };
2048
+ }
2020
2049
  function htmlLayerBleedFor(css) {
2021
2050
  if (!css) return 0;
2022
2051
  let worst = 0;
@@ -2074,6 +2103,7 @@ function htmlLayerLabel(clip) {
2074
2103
  const words = htmlLayerText(clip.html);
2075
2104
  return words ? words.slice(0, 24) : "HTML";
2076
2105
  }
2106
+ var HTML_LAYER_PLACEHOLDER = /\{\{\s*(t|data\.[A-Za-z0-9_.-]+)\s*\}\}/;
2077
2107
  var VOID_TAGS = [
2078
2108
  "br",
2079
2109
  "img",
@@ -2160,18 +2190,36 @@ function htmlLayerProblems(clip) {
2160
2190
  "warning",
2161
2191
  `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
2192
  );
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)
2193
+ for (const u of htmlLayerAssets({ html, css }).foreign)
2165
2194
  push2(
2166
2195
  "external-resource",
2167
2196
  "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)."
2197
+ `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.`
2169
2198
  );
2170
- if (/\b(animation|transition)(-[a-z-]+)?\s*:/i.test(css ?? ""))
2199
+ if (/\bhref\s*=\s*["']\s*(https?:)?\/\//i.test(html))
2200
+ push2(
2201
+ "external-resource",
2202
+ "warning",
2203
+ "The source names a stylesheet or a link by URL. An SVG image fetches no href; inline the styles in the CSS."
2204
+ );
2205
+ const animated = /\b(animation|transition)(-[a-z-]+)?\s*:/i.test(css ?? "");
2206
+ if (animated && !clip.live)
2171
2207
  push2(
2172
2208
  "wall-clock",
2173
2209
  "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."
2210
+ "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."
2211
+ );
2212
+ if (clip.live && /\banimation-delay\s*:/i.test(css ?? ""))
2213
+ push2(
2214
+ "live-delay",
2215
+ "warning",
2216
+ "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."
2217
+ );
2218
+ if (!clip.live && (HTML_LAYER_PLACEHOLDER.test(html) || HTML_LAYER_PLACEHOLDER.test(css ?? "")))
2219
+ push2(
2220
+ "placeholder-still",
2221
+ "warning",
2222
+ "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
2223
  );
2176
2224
  for (const family of htmlLayerUnhostedFamilies(clip))
2177
2225
  push2(
@@ -2194,12 +2242,31 @@ function fontFaceCss(faces) {
2194
2242
  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
2243
  return out;
2196
2244
  }
2197
- function htmlLayerSvg(src, faces = []) {
2245
+ var fmtT = (t) => String(Math.round(t * 1e3) / 1e3);
2246
+ var escapeXml = (v) => String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2247
+ function htmlLayerSvg(src, faces = [], assets = []) {
2198
2248
  const bleed = Math.max(0, Math.round(src.bleed ?? 0));
2199
2249
  const boxW = src.box.width + bleed * 2;
2200
2250
  const boxH = src.box.height + bleed * 2;
2251
+ const t = src.t ?? 0;
2252
+ const fill = (text) => {
2253
+ let out = text;
2254
+ for (const a of assets) out = out.split(a.url).join(a.dataUri);
2255
+ if (src.live) {
2256
+ out = out.replace(/\{\{\s*t\s*\}\}/g, fmtT(t));
2257
+ out = out.replace(
2258
+ /\{\{\s*data\.([A-Za-z0-9_.-]+)\s*\}\}/g,
2259
+ (_m, name) => {
2260
+ const v = src.data ? src.data[name] : void 0;
2261
+ return v == null ? "" : escapeXml(v);
2262
+ }
2263
+ );
2264
+ }
2265
+ return out;
2266
+ };
2267
+ 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
2268
  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>`;
2269
+ 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
2270
  }
2204
2271
  function fnv1a(s) {
2205
2272
  let h = 2166136261;
@@ -2209,22 +2276,27 @@ function fnv1a(s) {
2209
2276
  }
2210
2277
  return h.toString(36);
2211
2278
  }
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("|");
2279
+ function htmlLayerKey(clip, faces, bleed, assets = []) {
2280
+ 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
2281
+ // moment to this key per frame. The data rides here, the moment there.
2282
+ (clip.live ? " live " + JSON.stringify(clip.data ?? null) : "");
2214
2283
  return `html:${clip.id}:${fnv1a(src)}`;
2215
2284
  }
2216
2285
  function htmlLayerPayload(clip) {
2217
2286
  const faces = htmlLayerFaces(clip);
2218
2287
  const bleed = htmlLayerBleed(clip);
2288
+ const assets = htmlLayerAssets(clip).inlinable;
2219
2289
  return {
2220
- key: htmlLayerKey(clip, faces, bleed),
2290
+ key: htmlLayerKey(clip, faces, bleed, assets),
2221
2291
  width: htmlLayerWidth(clip),
2222
2292
  html: {
2223
2293
  markup: clip.html,
2224
2294
  css: clip.css ?? "",
2225
2295
  box: { width: clip.box.width, height: clip.box.height },
2226
2296
  bleed,
2227
- faces
2297
+ faces,
2298
+ assets,
2299
+ ...clip.live ? { live: true, ...clip.data ? { data: clip.data } : {} } : {}
2228
2300
  }
2229
2301
  };
2230
2302
  }
@@ -2849,17 +2921,30 @@ function studioEntry(data) {
2849
2921
  onFrame: STUDIO_FRAME
2850
2922
  };
2851
2923
  }
2852
- var HTML_LAYER_SVG_CODE = `(src, faces) => {
2924
+ var HTML_LAYER_SVG_CODE = `(src, faces, assets) => {
2853
2925
  const bleed = Math.max(0, Math.round(src.bleed || 0))
2854
2926
  const boxW = src.box.width + bleed * 2
2855
2927
  const boxH = src.box.height + bleed * 2
2856
2928
  let fontCss = ''
2857
2929
  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}"
2930
+ const t = src.t || 0
2931
+ const fmtT = (n) => String(Math.round(n * 1000) / 1000)
2932
+ const esc = (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
2933
+ const fill = (text) => {
2934
+ let out = text
2935
+ for (const a of assets || []) out = out.split(a.url).join(a.dataUri)
2936
+ if (src.live) {
2937
+ out = out.replace(/\\{\\{\\s*t\\s*\\}\\}/g, fmtT(t))
2938
+ 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) })
2939
+ }
2940
+ return out
2941
+ }
2942
+ 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
2943
  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
2944
  return '<svg xmlns="http://www.w3.org/2000/svg" width="' + boxW + '" height="' + boxH + '" viewBox="0 0 ' + boxW + ' ' + boxH + '">' +
2860
2945
  '<foreignObject x="0" y="0" width="' + boxW + '" height="' + boxH + '">' +
2861
2946
  '<div xmlns="http://www.w3.org/1999/xhtml" class="vos-html-layer">' +
2862
- '<style>' + fontCss + wrapper + (src.css || '') + '</style>' + src.html +
2947
+ '<style>' + fontCss + wrapper + fill(src.css || '') + scrub + '</style>' + fill(src.html) +
2863
2948
  '</div></foreignObject></svg>'
2864
2949
  }`;
2865
2950
  var STUDIO_SETUP = `async (ctx) => {
@@ -2950,38 +3035,53 @@ var STUDIO_SETUP = `async (ctx) => {
2950
3035
  ns.htmlLast = ns.htmlLast || {}
2951
3036
  ns.htmlKeys = ns.htmlKeys || {}
2952
3037
  ns.htmlErrors = ns.htmlErrors || {}
3038
+ // One byte cache for faces AND the images a layer names by URL, keyed by
3039
+ // URL: fetched once per page however many layers name it, inlined as a
3040
+ // data: URI (a face is always woff2; an image carries its own type).
2953
3041
  const faceData = ns.faceData || (ns.faceData = new Map())
2954
- const faceOf = (f) => {
2955
- let p = faceData.get(f.url)
3042
+ const bytesOf = (url, mime, what) => {
3043
+ let p = faceData.get(url)
2956
3044
  if (p) return p
2957
- p = fetch(f.url).then(async (r) => {
3045
+ p = fetch(url).then(async (r) => {
2958
3046
  if (!r.ok) throw new Error('HTTP ' + r.status)
2959
3047
  const buf = new Uint8Array(await r.arrayBuffer())
2960
3048
  // Chunked: String.fromCharCode.apply throws on a large spread.
2961
3049
  let str = ''
2962
3050
  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)
3051
+ const type = mime || ((r.headers.get('content-type') || 'application/octet-stream').split(';')[0])
3052
+ return 'data:' + type + ';base64,' + btoa(str)
2964
3053
  }).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)
3054
+ // Fail open per URL, and NOT sticky: the layer paints without it now
3055
+ // and the next build asks again.
3056
+ faceData.delete(url)
3057
+ console.warn('[voila] html layer ' + what + ' failed to load', url, e)
2969
3058
  return ''
2970
3059
  })
2971
- faceData.set(f.url, p)
3060
+ faceData.set(url, p)
2972
3061
  return p
2973
3062
  }
2974
- const buildHtmlLayer = async (oc) => {
2975
- const want = () => ns.htmlWant[oc.id] === oc.key
2976
- ns.htmlWant[oc.id] = oc.key
3063
+ const faceOf = (f) => bytesOf(f.url, 'font/woff2', 'face')
3064
+ const assetOf = (url) => bytesOf(url, '', 'asset')
3065
+ // The second argument names the moment a LIVE layer is composed for: its
3066
+ // own key (the clip's key plus the moment) and t. A still builds under
3067
+ // the clip's key.
3068
+ const buildHtmlLayer = async (oc, live) => {
3069
+ const key = live ? live.key : oc.key
3070
+ const want = () => ns.htmlWant[oc.id] === key
3071
+ ns.htmlWant[oc.id] = key
2977
3072
  const h = oc.html
2978
3073
  const uris = await Promise.all((h.faces || []).map(faceOf))
3074
+ const assetUris = await Promise.all((h.assets || []).map(assetOf))
2979
3075
  if (!want()) return
2980
3076
  const faces = []
2981
3077
  for (let i = 0; i < uris.length; i++) {
2982
3078
  if (uris[i]) faces.push({ family: h.faces[i].family, weight: h.faces[i].weight, style: h.faces[i].style, dataUri: uris[i] })
2983
3079
  }
2984
- const svg = htmlSvg({ html: h.markup, css: h.css, box: h.box, bleed: h.bleed }, faces)
3080
+ const assets = []
3081
+ for (let i = 0; i < assetUris.length; i++) {
3082
+ if (assetUris[i]) assets.push({ url: h.assets[i], dataUri: assetUris[i] })
3083
+ }
3084
+ 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
3085
  const uri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)))
2986
3086
  const img = await new Promise((res, rej) => {
2987
3087
  const i = new Image()
@@ -3009,24 +3109,59 @@ var STUDIO_SETUP = `async (ctx) => {
3009
3109
  console.error('[voila] html layers are off: this browser taints a canvas that draws a foreignObject SVG', e)
3010
3110
  }
3011
3111
  }
3112
+ // One picture per clip in the cache: the previous key goes when a new
3113
+ // one lands, which for a live layer is every frame.
3012
3114
  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
3115
+ if (prev && prev !== key) cache.delete(prev)
3116
+ cache.set(key, img)
3117
+ ns.htmlKeys[oc.id] = key
3016
3118
  ns.htmlLast[oc.id] = img
3017
3119
  delete ns.htmlErrors[oc.id]
3018
3120
  }
3019
3121
  // ON_FRAME is its own function string, so the builder is handed over for a
3020
3122
  // layer that arrives by live edit after setup has run. A failure is
3021
3123
  // recorded on the clip and said at the level a render page forwards.
3022
- ns.buildHtmlLayer = (oc) => buildHtmlLayer(oc).catch((e) => {
3124
+ ns.buildHtmlLayer = (oc, live) => buildHtmlLayer(oc, live).catch((e) => {
3023
3125
  ns.htmlErrors[oc.id] = String((e && e.message) || e)
3024
3126
  console.error('[voila] html layer ' + oc.id + ' ' + ns.htmlErrors[oc.id])
3025
3127
  })
3128
+ // The capture contract for a LIVE layer, on ANY anchor. A recording's own
3129
+ // program installs the settle machinery (pendingDecodes, the wait a
3130
+ // capture awaits, a frame-prep hook that requests the frame's footage
3131
+ // before the paint); a program anchor installs none of it, and a live
3132
+ // layer on one would export a frame behind (the last landed picture),
3133
+ // since nothing awaited the build. So the entry installs what is absent
3134
+ // and registers its own hook: the moment every visible live layer needs
3135
+ // at t is asked for BEFORE the paint and put on pendingDecodes, so the
3136
+ // first paint draws the exact picture and a still at t is t. ON_FRAME
3137
+ // asks the same way (one source, inlined in both scopes) for the preview,
3138
+ // where the last landed picture stands in meanwhile.
3139
+ ns.pendingDecodes = ns.pendingDecodes || new Set()
3140
+ if (!ns.waitForVideosReady) ns.waitForVideosReady = async () => {
3141
+ if (ns.pendingDecodes.size) await Promise.all([...ns.pendingDecodes])
3142
+ }
3143
+ ns.framePrep = ns.framePrep || new Map()
3144
+ ns.framePrep.set('vosso.studio', (t) => {
3145
+ if (ns.isPaused === false) return
3146
+ for (const ol of (ctx.data.overlays || [])) {
3147
+ if (!ol.html || !ol.html.live) continue
3148
+ const olT = t - ol.start
3149
+ if (olT < 0 || olT > ol.dur) continue
3150
+ const q = Math.round(olT * ${HTML_LAYER_LIVE_HZ})
3151
+ const key = ol.key + '@' + q
3152
+ if (cache.get(key) || ns.htmlWant[ol.id] === key) continue
3153
+ const p = ns.buildHtmlLayer(ol, { key, t: q / ${HTML_LAYER_LIVE_HZ} })
3154
+ if (p && p.finally) {
3155
+ ns.pendingDecodes.add(p)
3156
+ p.finally(() => ns.pendingDecodes.delete(p))
3157
+ }
3158
+ }
3159
+ })
3026
3160
  // Media overlays (V1b): warm-load through the shared cache so the first
3027
3161
  // frame draws complete. Fail-open per clip (a bad key just doesn't draw).
3162
+ // A live layer warms at its first moment; ON_FRAME re-composes it per frame.
3028
3163
  for (const oc of (ctx.data.overlays || [])) {
3029
- if (oc.html) { await ns.buildHtmlLayer(oc); continue }
3164
+ if (oc.html) { await ns.buildHtmlLayer(oc, oc.html.live ? { key: oc.key + '@0', t: 0 } : undefined); continue }
3030
3165
  if (oc.kind !== 'image' && oc.kind !== 'video') continue
3031
3166
  try {
3032
3167
  if (oc.kind === 'image') await loadImage(oc.key)
@@ -3314,6 +3449,8 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
3314
3449
  // until its picture lands, or the edit would not appear until something
3315
3450
  // else happened to dirty the canvas.
3316
3451
  if (ol0.html && !(ns.videoCache && ns.videoCache.get(ol0.key))) olAnim = true
3452
+ // A LIVE html layer is a new picture every frame it is on screen.
3453
+ if (ol0.html && ol0.html.live) olAnim = true
3317
3454
  // Video overlays advance every frame; images redraw until decoded.
3318
3455
  if (ol0.kind === 'video') olAnim = true
3319
3456
  else if (ol0.kind === 'image') {
@@ -3351,6 +3488,28 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
3351
3488
  // per key and let a later frame pick it up; a build that failed stays
3352
3489
  // asked, and the next source edit is a new key.
3353
3490
  if (ol.html) {
3491
+ if (ol.html.live) {
3492
+ // A live layer's picture is f(t): the key carries clip-local time on
3493
+ // the ${HTML_LAYER_LIVE_HZ} Hz grid, the builder keeps ONE picture per clip
3494
+ // (the previous moment's is evicted as the next lands) and the last
3495
+ // landed picture is drawn while the next composes, so the preview
3496
+ // never blinks. A build in flight for this very moment is not asked
3497
+ // again (htmlWant holds the moment). In capture the build rides
3498
+ // pendingDecodes, so the frame settles and paints again with the
3499
+ // exact picture (the harness's two-phase settle): the export is
3500
+ // deterministic where the preview is merely prompt.
3501
+ var hlQ = Math.round(olT * ${HTML_LAYER_LIVE_HZ})
3502
+ var hlKey = ol.key + '@' + hlQ
3503
+ var hlEl = ns.videoCache ? ns.videoCache.get(hlKey) : null
3504
+ if (!hlEl && ns.buildHtmlLayer && !(ns.htmlWant && ns.htmlWant[ol.id] === hlKey)) {
3505
+ var hlP = ns.buildHtmlLayer(ol, { key: hlKey, t: hlQ / ${HTML_LAYER_LIVE_HZ} })
3506
+ if (ns.pendingDecodes && hlP && hlP.finally) {
3507
+ ns.pendingDecodes.add(hlP)
3508
+ hlP.finally(function () { ns.pendingDecodes.delete(hlP) })
3509
+ }
3510
+ }
3511
+ return hlEl || (ns.htmlLast && ns.htmlLast[ol.id]) || null
3512
+ }
3354
3513
  if (!olEl && ns.buildHtmlLayer) {
3355
3514
  var hlB = ns.htmlBuilding || (ns.htmlBuilding = {})
3356
3515
  if (!hlB[ol.key]) { hlB[ol.key] = 1; ns.buildHtmlLayer(ol) }