@vosjs/studio-core 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -98,6 +98,9 @@ function clipLength(clip) {
98
98
  return clip.loop ? Math.max(span, clip.loopLen ?? span) : span;
99
99
  }
100
100
  var MOTION_EASE = "power2.inOut";
101
+ function isKeyedOverlay(c) {
102
+ return c.kind === "image" || c.kind === "video";
103
+ }
101
104
  var LAYER_CARD_SHADOW = 0.32;
102
105
  var MEDIA_KEY_PREFIX = "media:";
103
106
  var OVERLAY_SCALE_MAX = 8;
@@ -1904,11 +1907,329 @@ function cardPointToScreen(u, v) {
1904
1907
 
1905
1908
  // src/overlayText.ts
1906
1909
  import {
1907
- findFontFamily,
1908
- fontFaceUrl,
1910
+ findFontFamily as findFontFamily2,
1911
+ fontFaceUrl as fontFaceUrl2,
1909
1912
  fontStack,
1910
- nearestFontWeight
1913
+ nearestFontWeight as nearestFontWeight2
1911
1914
  } from "@vosjs/shared";
1915
+
1916
+ // src/htmlLayer.ts
1917
+ import { findFontFamily, fontFaceUrl, nearestFontWeight } from "@vosjs/shared";
1918
+ var DESIGN_FRAME_WIDTH = 1920;
1919
+ var HTML_LAYER_WARN_BYTES = 32e3;
1920
+ var HTML_LAYER_MAX_BYTES = 128e3;
1921
+ function splitTopLevel(value) {
1922
+ const out = [];
1923
+ let depth = 0;
1924
+ let cur = "";
1925
+ for (const ch of value) {
1926
+ if (ch === "(") depth++;
1927
+ else if (ch === ")") depth--;
1928
+ if (ch === "," && depth === 0) {
1929
+ out.push(cur);
1930
+ cur = "";
1931
+ } else cur += ch;
1932
+ }
1933
+ if (cur.trim()) out.push(cur);
1934
+ return out;
1935
+ }
1936
+ var unquote = (s) => s.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim();
1937
+ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
1938
+ "serif",
1939
+ "sans-serif",
1940
+ "monospace",
1941
+ "cursive",
1942
+ "fantasy",
1943
+ "system-ui",
1944
+ "ui-sans-serif",
1945
+ "ui-serif",
1946
+ "ui-monospace",
1947
+ "ui-rounded",
1948
+ "emoji",
1949
+ "math",
1950
+ "fangsong",
1951
+ "inherit",
1952
+ "initial",
1953
+ "unset"
1954
+ ]);
1955
+ function cssFamilies(css) {
1956
+ if (!css) return [];
1957
+ const out = [];
1958
+ const seen = /* @__PURE__ */ new Set();
1959
+ for (const decl of css.match(/font-family\s*:[^;}]+/gi) ?? []) {
1960
+ for (const item of splitTopLevel(decl.replace(/^[^:]*:/, ""))) {
1961
+ const name = unquote(item.replace(/!important/i, ""));
1962
+ if (!name) continue;
1963
+ const lower = name.toLowerCase();
1964
+ if (GENERIC_FAMILIES.has(lower) || lower.startsWith("-apple-")) continue;
1965
+ if (seen.has(lower)) continue;
1966
+ seen.add(lower);
1967
+ out.push(name);
1968
+ }
1969
+ }
1970
+ return out;
1971
+ }
1972
+ function cssWeights(css, html = "") {
1973
+ const out = /* @__PURE__ */ new Set([400]);
1974
+ for (const decl of css?.match(/font-weight\s*:[^;}]+/gi) ?? []) {
1975
+ const value = decl.replace(/^[^:]*:/, "").toLowerCase();
1976
+ for (const n of value.match(/\b[1-9]00\b/g) ?? []) out.add(Number(n));
1977
+ if (/\bbold(er)?\b/.test(value)) out.add(700);
1978
+ }
1979
+ if (/<(b|strong|h[1-6])[\s>]/i.test(html)) out.add(700);
1980
+ return [...out].sort((a, b) => a - b);
1981
+ }
1982
+ function htmlLayerFaces(clip) {
1983
+ const faces = [];
1984
+ const seen = /* @__PURE__ */ new Set();
1985
+ const add = (f) => {
1986
+ const key = `${f.family.toLowerCase()}|${f.weight}|${f.style}`;
1987
+ if (seen.has(key)) return;
1988
+ seen.add(key);
1989
+ faces.push(f);
1990
+ };
1991
+ const weights = cssWeights(clip.css, clip.html);
1992
+ for (const family of cssFamilies(clip.css)) {
1993
+ const entry = findFontFamily(family);
1994
+ if (!entry) continue;
1995
+ for (const w of weights)
1996
+ add({
1997
+ family: entry.family,
1998
+ url: fontFaceUrl(entry.slug, nearestFontWeight(entry, w)),
1999
+ weight: nearestFontWeight(entry, w),
2000
+ style: "normal"
2001
+ });
2002
+ }
2003
+ for (const f of clip.fonts ?? [])
2004
+ add({
2005
+ family: f.family,
2006
+ url: f.url,
2007
+ weight: f.weight ?? 400,
2008
+ style: f.style ?? "normal"
2009
+ });
2010
+ return faces;
2011
+ }
2012
+ function htmlLayerUnhostedFamilies(clip) {
2013
+ const explicit = new Set(
2014
+ (clip.fonts ?? []).map((f) => f.family.toLowerCase())
2015
+ );
2016
+ return cssFamilies(clip.css).filter(
2017
+ (f) => !findFontFamily(f) && !explicit.has(f.toLowerCase())
2018
+ );
2019
+ }
2020
+ function htmlLayerBleedFor(css) {
2021
+ if (!css) return 0;
2022
+ let worst = 0;
2023
+ const consider = (layer) => {
2024
+ if (/\binset\b/i.test(layer)) return;
2025
+ const lengths = layer.replace(/[a-z-]+\([^)]*\)/gi, " ").replace(/#[0-9a-f]{3,8}/gi, " ").match(/-?\d*\.?\d+/g);
2026
+ if (!lengths) return;
2027
+ const [x = 0, y = 0, blur = 0, spread = 0] = lengths.map(Number);
2028
+ worst = Math.max(
2029
+ worst,
2030
+ Math.abs(x) + blur + spread,
2031
+ Math.abs(y) + blur + spread
2032
+ );
2033
+ };
2034
+ for (const decl of css.match(/box-shadow\s*:[^;}]+/gi) ?? [])
2035
+ for (const layer of splitTopLevel(decl.replace(/^[^:]*:/, "")))
2036
+ consider(layer);
2037
+ for (const fn of css.match(/drop-shadow\(([^)]*)\)/gi) ?? [])
2038
+ consider(fn.replace(/^drop-shadow\(/i, "").replace(/\)$/, ""));
2039
+ return worst ? Math.ceil(worst * 1.15) : 0;
2040
+ }
2041
+ function htmlLayerBleed(clip) {
2042
+ return Math.max(0, Math.round(clip.bleed ?? htmlLayerBleedFor(clip.css)));
2043
+ }
2044
+ function htmlLayerPictureBox(clip) {
2045
+ const bleed = htmlLayerBleed(clip);
2046
+ return {
2047
+ width: clip.box.width + bleed * 2,
2048
+ height: clip.box.height + bleed * 2,
2049
+ bleed
2050
+ };
2051
+ }
2052
+ function htmlLayerWidth(clip) {
2053
+ if (clip.width != null) return clip.width;
2054
+ return Math.min(1, htmlLayerPictureBox(clip).width / DESIGN_FRAME_WIDTH);
2055
+ }
2056
+ var ENTITIES = {
2057
+ amp: "&",
2058
+ lt: "<",
2059
+ gt: ">",
2060
+ quot: '"',
2061
+ apos: "'",
2062
+ nbsp: " "
2063
+ };
2064
+ function htmlLayerText(html) {
2065
+ return html.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]*>/g, " ").replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, ref) => {
2066
+ if (ref[0] === "#")
2067
+ return String.fromCodePoint(
2068
+ ref[1] === "x" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1))
2069
+ );
2070
+ return ENTITIES[ref.toLowerCase()] ?? m;
2071
+ }).replace(/\s+/g, " ").trim();
2072
+ }
2073
+ function htmlLayerLabel(clip) {
2074
+ const words = htmlLayerText(clip.html);
2075
+ return words ? words.slice(0, 24) : "HTML";
2076
+ }
2077
+ var VOID_TAGS = [
2078
+ "br",
2079
+ "img",
2080
+ "hr",
2081
+ "input",
2082
+ "source",
2083
+ "col",
2084
+ "area",
2085
+ "base",
2086
+ "wbr",
2087
+ "meta",
2088
+ "link"
2089
+ ];
2090
+ var HTML_LAYER_FOREIGN_FIELDS = [
2091
+ "key",
2092
+ "radius",
2093
+ "shadow",
2094
+ "border",
2095
+ "loop",
2096
+ "frame",
2097
+ "fx",
2098
+ "text",
2099
+ "preset"
2100
+ ];
2101
+ function htmlLayerProblems(clip) {
2102
+ const html = clip.html ?? "";
2103
+ const css = clip.css;
2104
+ const raw = clip;
2105
+ const problems = [];
2106
+ const push2 = (code, level, message) => problems.push({ code, level, message });
2107
+ if (!html.trim()) push2("empty", "problem", "The layer has no markup.");
2108
+ for (const tag of VOID_TAGS) {
2109
+ const open = new RegExp(`<${tag}(\\s[^>]*?)?>`, "gi");
2110
+ let m;
2111
+ let said = false;
2112
+ while (m = open.exec(html)) {
2113
+ if (!m[0].trimEnd().endsWith("/>") && !said) {
2114
+ said = true;
2115
+ push2(
2116
+ "unclosed-void",
2117
+ "problem",
2118
+ `<${tag}> must close itself as <${tag} /> inside a foreignObject.`
2119
+ );
2120
+ }
2121
+ }
2122
+ }
2123
+ const bare = html.match(/&(?!(#\d+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/g);
2124
+ if (bare)
2125
+ push2(
2126
+ "bare-ampersand",
2127
+ "problem",
2128
+ `A bare "&" is a parse error; write &amp;. Found ${bare.length}.`
2129
+ );
2130
+ if (/<[a-zA-Z][^>]*?\s[a-zA-Z-]+=(?!["'])[^\s>]+/.test(html))
2131
+ push2(
2132
+ "unquoted-attr",
2133
+ "problem",
2134
+ "Attribute values must be quoted inside a foreignObject."
2135
+ );
2136
+ const bytes = html.length + (css?.length ?? 0);
2137
+ if (bytes > HTML_LAYER_MAX_BYTES)
2138
+ push2(
2139
+ "too-large",
2140
+ "problem",
2141
+ `The layer's source is ${Math.round(bytes / 1e3)} KB; the ceiling is ${HTML_LAYER_MAX_BYTES / 1e3} KB. Inline less, or split the layer.`
2142
+ );
2143
+ else if (bytes > HTML_LAYER_WARN_BYTES)
2144
+ push2(
2145
+ "too-large",
2146
+ "warning",
2147
+ `The layer's source is ${Math.round(bytes / 1e3)} KB. A document carries it twice (the doc and the composed config); keep a layer under ${HTML_LAYER_WARN_BYTES / 1e3} KB.`
2148
+ );
2149
+ for (const field of HTML_LAYER_FOREIGN_FIELDS)
2150
+ if (raw[field] !== void 0)
2151
+ push2(
2152
+ "foreign-field",
2153
+ "problem",
2154
+ `An HTML layer has no "${field}": its CSS is its chrome (shadow, radius, border) and its markup its content.`
2155
+ );
2156
+ const needed = htmlLayerBleedFor(css);
2157
+ if (clip.bleed != null && needed > clip.bleed)
2158
+ push2(
2159
+ "shadow-clipped",
2160
+ "warning",
2161
+ `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
+ );
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)
2165
+ push2(
2166
+ "external-resource",
2167
+ "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)."
2169
+ );
2170
+ if (/\b(animation|transition)(-[a-z-]+)?\s*:/i.test(css ?? ""))
2171
+ push2(
2172
+ "wall-clock",
2173
+ "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."
2175
+ );
2176
+ for (const family of htmlLayerUnhostedFamilies(clip))
2177
+ push2(
2178
+ "unhosted-family",
2179
+ "warning",
2180
+ `The CSS names "${family}", which is not a hosted family (GET https://vos.so/api/fonts); the system stack stands in.`
2181
+ );
2182
+ return problems;
2183
+ }
2184
+ function wellFormedXml(svg, parseXml) {
2185
+ const doc = parseXml(svg);
2186
+ const errors = doc.getElementsByTagName("parsererror");
2187
+ if (!errors.length) return { ok: true };
2188
+ const text = (errors[0].textContent ?? "").replace(/\s+/g, " ").trim();
2189
+ return { ok: false, error: text || "The markup is not well-formed XML." };
2190
+ }
2191
+ function fontFaceCss(faces) {
2192
+ let out = "";
2193
+ for (const f of faces)
2194
+ 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
+ return out;
2196
+ }
2197
+ function htmlLayerSvg(src, faces = []) {
2198
+ const bleed = Math.max(0, Math.round(src.bleed ?? 0));
2199
+ const boxW = src.box.width + bleed * 2;
2200
+ const boxH = src.box.height + bleed * 2;
2201
+ 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>`;
2203
+ }
2204
+ function fnv1a(s) {
2205
+ let h = 2166136261;
2206
+ for (let i = 0; i < s.length; i++) {
2207
+ h ^= s.charCodeAt(i);
2208
+ h = Math.imul(h, 16777619) >>> 0;
2209
+ }
2210
+ return h.toString(36);
2211
+ }
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("|");
2214
+ return `html:${clip.id}:${fnv1a(src)}`;
2215
+ }
2216
+ function htmlLayerPayload(clip) {
2217
+ const faces = htmlLayerFaces(clip);
2218
+ const bleed = htmlLayerBleed(clip);
2219
+ return {
2220
+ key: htmlLayerKey(clip, faces, bleed),
2221
+ width: htmlLayerWidth(clip),
2222
+ html: {
2223
+ markup: clip.html,
2224
+ css: clip.css ?? "",
2225
+ box: { width: clip.box.width, height: clip.box.height },
2226
+ bleed,
2227
+ faces
2228
+ }
2229
+ };
2230
+ }
2231
+
2232
+ // src/overlayText.ts
1912
2233
  var OVERLAY_BOX_PAD_X = 0.6;
1913
2234
  var OVERLAY_BOX_PAD_Y = 0.35;
1914
2235
  var OVERLAY_BOX_RADIUS = 0.25;
@@ -1954,12 +2275,12 @@ var TEXT_PRESETS = {
1954
2275
  var OVERLAY_SIZE_MIN = 12;
1955
2276
  var OVERLAY_SIZE_MAX = 200;
1956
2277
  var OVERLAY_FONT_FACES = [
1957
- { family: "Lexend", weight: 400, url: fontFaceUrl("lexend", 400) },
1958
- { family: "Lexend", weight: 600, url: fontFaceUrl("lexend", 600) },
2278
+ { family: "Lexend", weight: 400, url: fontFaceUrl2("lexend", 400) },
2279
+ { family: "Lexend", weight: 600, url: fontFaceUrl2("lexend", 600) },
1959
2280
  {
1960
2281
  family: "JetBrains Mono",
1961
2282
  weight: 400,
1962
- url: fontFaceUrl("jetbrains-mono", 400)
2283
+ url: fontFaceUrl2("jetbrains-mono", 400)
1963
2284
  }
1964
2285
  ];
1965
2286
  function resolveOverlayStyle(clip) {
@@ -1967,14 +2288,14 @@ function resolveOverlayStyle(clip) {
1967
2288
  const base = TEXT_PRESETS[presetName];
1968
2289
  let stack = base.stack;
1969
2290
  let weight = base.weight;
1970
- const familyEntry = findFontFamily(clip.family ?? PRESET_FAMILY[presetName]);
2291
+ const familyEntry = findFontFamily2(clip.family ?? PRESET_FAMILY[presetName]);
1971
2292
  if (clip.family) {
1972
2293
  const quoted = clip.family.includes(" ") ? `'${clip.family}'` : clip.family;
1973
2294
  stack = familyEntry ? fontStack(familyEntry) : `${quoted}, ${base.stack}`;
1974
2295
  }
1975
2296
  if (clip.weight !== void 0 || clip.family) {
1976
2297
  const wanted = clip.weight ?? base.weight;
1977
- weight = familyEntry ? nearestFontWeight(familyEntry, wanted) : wanted;
2298
+ weight = familyEntry ? nearestFontWeight2(familyEntry, wanted) : wanted;
1978
2299
  }
1979
2300
  return {
1980
2301
  ...base,
@@ -1996,7 +2317,7 @@ function resolveOverlayStyle(clip) {
1996
2317
  };
1997
2318
  }
1998
2319
  function overlayFaceFor(clip) {
1999
- const entry = findFontFamily(
2320
+ const entry = findFontFamily2(
2000
2321
  clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
2001
2322
  );
2002
2323
  if (!entry) return null;
@@ -2008,7 +2329,7 @@ function overlayFaceFor(clip) {
2008
2329
  return {
2009
2330
  family: entry.family,
2010
2331
  weight,
2011
- url: fontFaceUrl(entry.slug, weight)
2332
+ url: fontFaceUrl2(entry.slug, weight)
2012
2333
  };
2013
2334
  }
2014
2335
  function overlayFontFaces(doc) {
@@ -2116,6 +2437,11 @@ function overlayRect(clip, measure, frameW, frameH = 1080, mediaAspect) {
2116
2437
  cy: clip.transform.y * frameH,
2117
2438
  rotation: clip.transform.rotation || 0
2118
2439
  };
2440
+ if (clip.kind === "html") {
2441
+ const pb = htmlLayerPictureBox(clip);
2442
+ const w2 = htmlLayerWidth(clip) * frameW * scale;
2443
+ return { ...base, w: w2, h: w2 * (pb.height / pb.width) };
2444
+ }
2119
2445
  if (clip.kind !== "text") {
2120
2446
  const w2 = (clip.width ?? OVERLAY_MEDIA_DEFAULT_WIDTH) * frameW * scale;
2121
2447
  return { ...base, w: w2, h: w2 / (mediaAspect || 16 / 9) };
@@ -2523,6 +2849,19 @@ function studioEntry(data) {
2523
2849
  onFrame: STUDIO_FRAME
2524
2850
  };
2525
2851
  }
2852
+ var HTML_LAYER_SVG_CODE = `(src, faces) => {
2853
+ const bleed = Math.max(0, Math.round(src.bleed || 0))
2854
+ const boxW = src.box.width + bleed * 2
2855
+ const boxH = src.box.height + bleed * 2
2856
+ let fontCss = ''
2857
+ 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}"
2858
+ 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
+ return '<svg xmlns="http://www.w3.org/2000/svg" width="' + boxW + '" height="' + boxH + '" viewBox="0 0 ' + boxW + ' ' + boxH + '">' +
2860
+ '<foreignObject x="0" y="0" width="' + boxW + '" height="' + boxH + '">' +
2861
+ '<div xmlns="http://www.w3.org/1999/xhtml" class="vos-html-layer">' +
2862
+ '<style>' + fontCss + wrapper + (src.css || '') + '</style>' + src.html +
2863
+ '</div></foreignObject></svg>'
2864
+ }`;
2526
2865
  var STUDIO_SETUP = `async (ctx) => {
2527
2866
  if (!globalThis.__vosTimeline) { ${timelineRuntimeCode} }
2528
2867
  const ns = (window.__vos__ = window.__vos__ || {})
@@ -2589,9 +2928,105 @@ var STUDIO_SETUP = `async (ctx) => {
2589
2928
  ])
2590
2929
  } catch (e) { console.warn('[voila] overlay fonts failed to load', e) }
2591
2930
  }
2931
+ // An HTML layer arrives as SOURCE, and the picture is built HERE.
2932
+ //
2933
+ // Two rules force this shape. An SVG carrying a <foreignObject> is
2934
+ // origin-clean ONLY as a data: URI (served from a URL it taints the canvas
2935
+ // even same-origin, and a tainted frame cannot be uploaded as a WebGL
2936
+ // texture, so every layer vanishes at once), and an SVG loaded through
2937
+ // <img> may fetch nothing of its own, so a @font-face naming a URL is
2938
+ // silently ignored. So the faces are fetched here by ordinary fetch,
2939
+ // inlined, and what lands in the cache is an inline, vector picture under
2940
+ // the clip's content-addressed key.
2941
+ //
2942
+ // Per-clip state on the namespace: htmlWant (the key a clip wants NOW, so a
2943
+ // build a keystroke made stale is dropped before it decodes), htmlLast (the
2944
+ // last picture built for a clip, drawn while the next rasterizes so an edit
2945
+ // never blinks the layer out), htmlKeys (the key in the cache for a clip,
2946
+ // evicted when a new one lands), htmlErrors (why a clip did not decode).
2947
+ // faceData caches a face's bytes by URL across every layer on the page.
2948
+ const htmlSvg = ${HTML_LAYER_SVG_CODE}
2949
+ ns.htmlWant = ns.htmlWant || {}
2950
+ ns.htmlLast = ns.htmlLast || {}
2951
+ ns.htmlKeys = ns.htmlKeys || {}
2952
+ ns.htmlErrors = ns.htmlErrors || {}
2953
+ const faceData = ns.faceData || (ns.faceData = new Map())
2954
+ const faceOf = (f) => {
2955
+ let p = faceData.get(f.url)
2956
+ if (p) return p
2957
+ p = fetch(f.url).then(async (r) => {
2958
+ if (!r.ok) throw new Error('HTTP ' + r.status)
2959
+ const buf = new Uint8Array(await r.arrayBuffer())
2960
+ // Chunked: String.fromCharCode.apply throws on a large spread.
2961
+ let str = ''
2962
+ 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)
2964
+ }).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)
2969
+ return ''
2970
+ })
2971
+ faceData.set(f.url, p)
2972
+ return p
2973
+ }
2974
+ const buildHtmlLayer = async (oc) => {
2975
+ const want = () => ns.htmlWant[oc.id] === oc.key
2976
+ ns.htmlWant[oc.id] = oc.key
2977
+ const h = oc.html
2978
+ const uris = await Promise.all((h.faces || []).map(faceOf))
2979
+ if (!want()) return
2980
+ const faces = []
2981
+ for (let i = 0; i < uris.length; i++) {
2982
+ if (uris[i]) faces.push({ family: h.faces[i].family, weight: h.faces[i].weight, style: h.faces[i].style, dataUri: uris[i] })
2983
+ }
2984
+ const svg = htmlSvg({ html: h.markup, css: h.css, box: h.box, bleed: h.bleed }, faces)
2985
+ const uri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)))
2986
+ const img = await new Promise((res, rej) => {
2987
+ const i = new Image()
2988
+ i.onload = () => res(i)
2989
+ // foreignObject is XML: malformed markup fails to DECODE, silently, and
2990
+ // the layer would simply never appear. Say so instead.
2991
+ i.onerror = () => rej(new Error('did not decode: is the markup well-formed XML?'))
2992
+ i.src = uri
2993
+ })
2994
+ if (!want()) return
2995
+ // A browser that taints a canvas drawing a foreignObject SVG (WebKit's
2996
+ // history) would lose EVERY layer to one SecurityError at the texture
2997
+ // upload. Probe once on a scratch canvas and, failing, drop the HTML
2998
+ // layers and nothing else.
2999
+ if (ns.htmlSafe === undefined) {
3000
+ try {
3001
+ const pc = document.createElement('canvas')
3002
+ pc.width = pc.height = 1
3003
+ const px = pc.getContext('2d')
3004
+ px.drawImage(img, 0, 0, 1, 1)
3005
+ px.getImageData(0, 0, 1, 1)
3006
+ ns.htmlSafe = true
3007
+ } catch (e) {
3008
+ ns.htmlSafe = false
3009
+ console.error('[voila] html layers are off: this browser taints a canvas that draws a foreignObject SVG', e)
3010
+ }
3011
+ }
3012
+ 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
3016
+ ns.htmlLast[oc.id] = img
3017
+ delete ns.htmlErrors[oc.id]
3018
+ }
3019
+ // ON_FRAME is its own function string, so the builder is handed over for a
3020
+ // layer that arrives by live edit after setup has run. A failure is
3021
+ // recorded on the clip and said at the level a render page forwards.
3022
+ ns.buildHtmlLayer = (oc) => buildHtmlLayer(oc).catch((e) => {
3023
+ ns.htmlErrors[oc.id] = String((e && e.message) || e)
3024
+ console.error('[voila] html layer ' + oc.id + ' ' + ns.htmlErrors[oc.id])
3025
+ })
2592
3026
  // Media overlays (V1b): warm-load through the shared cache so the first
2593
3027
  // frame draws complete. Fail-open per clip (a bad key just doesn't draw).
2594
3028
  for (const oc of (ctx.data.overlays || [])) {
3029
+ if (oc.html) { await ns.buildHtmlLayer(oc); continue }
2595
3030
  if (oc.kind !== 'image' && oc.kind !== 'video') continue
2596
3031
  try {
2597
3032
  if (oc.kind === 'image') await loadImage(oc.key)
@@ -2862,6 +3297,8 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2862
3297
  for (var oi = 0; oi < ols.length; oi++) {
2863
3298
  var ol0 = ols[oi]
2864
3299
  if (t < ol0.start || t > ol0.start + ol0.dur) continue
3300
+ // A page that taints on foreignObject draws no HTML layer (setup's probe).
3301
+ if (ol0.html && ns.htmlSafe === false) continue
2865
3302
  olVisSig += ol0.id + ':' + (ol0.text || ol0.key) + ':' + ol0.x + ',' + ol0.y + ',' + ol0.scale + ',' + ol0.rot + ',' + (ol0.fs || ol0.w) + ',' + (ol0.color || ol0.radius) + ',' + (ol0.opacity == null ? 1 : ol0.opacity) + (ol0.fx ? ',' + ol0.fx.k + ol0.fx.u + ol0.fx.d + ol0.fx.st : '') + (ol0.mw ? ',w' + ol0.mw : '') + ';'
2866
3303
  // fx widens the entrance window to the whole staggered span (tt);
2867
3304
  // without fx it is the legacy enter transition window.
@@ -2873,6 +3310,10 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2873
3310
  if (ol0.track) olAnim = true
2874
3311
  // A layer CARD is painted on its own plane every frame it is visible.
2875
3312
  if (ol0.card) olAnim = true
3313
+ // An HTML layer whose source just changed is rasterizing: keep repainting
3314
+ // until its picture lands, or the edit would not appear until something
3315
+ // else happened to dirty the canvas.
3316
+ if (ol0.html && !(ns.videoCache && ns.videoCache.get(ol0.key))) olAnim = true
2876
3317
  // Video overlays advance every frame; images redraw until decoded.
2877
3318
  if (ol0.kind === 'video') olAnim = true
2878
3319
  else if (ol0.kind === 'image') {
@@ -2880,6 +3321,20 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2880
3321
  if (!(olEl0 && olEl0.complete && olEl0.naturalWidth)) olAnim = true
2881
3322
  }
2882
3323
  }
3324
+ // A clip that left the document takes its picture with it: the cache
3325
+ // holds decoded images, and a session's edits would otherwise pile up.
3326
+ if (ns.htmlLast) {
3327
+ for (var hlK in ns.htmlLast) {
3328
+ var hlSeen = false
3329
+ for (var hlI = 0; hlI < ols.length; hlI++) if (ols[hlI].id === hlK) { hlSeen = true; break }
3330
+ if (hlSeen) continue
3331
+ if (ns.htmlKeys && ns.htmlKeys[hlK] && ns.videoCache) ns.videoCache.delete(ns.htmlKeys[hlK])
3332
+ delete ns.htmlLast[hlK]
3333
+ if (ns.htmlKeys) delete ns.htmlKeys[hlK]
3334
+ if (ns.htmlWant) delete ns.htmlWant[hlK]
3335
+ if (ns.htmlErrors) delete ns.htmlErrors[hlK]
3336
+ }
3337
+ }
2883
3338
  var ovSig = W + 'x' + H + '|' + olVisSig
2884
3339
  var ovDirty = ov.sig !== ovSig || ov.active || olAnim
2885
3340
  // The layer cards visible this frame, with their pose, for the card
@@ -2891,6 +3346,18 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2891
3346
  // picture and the layer card.
2892
3347
  function olAcquire(ol, olT) {
2893
3348
  var olEl = ns.videoCache ? ns.videoCache.get(ol.key) : null
3349
+ // An HTML layer's key is a CACHE HANDLE, not a URL: never hand it to an
3350
+ // <img>. A miss means setup has not built it (a live edit), so ask once
3351
+ // per key and let a later frame pick it up; a build that failed stays
3352
+ // asked, and the next source edit is a new key.
3353
+ if (ol.html) {
3354
+ if (!olEl && ns.buildHtmlLayer) {
3355
+ var hlB = ns.htmlBuilding || (ns.htmlBuilding = {})
3356
+ if (!hlB[ol.key]) { hlB[ol.key] = 1; ns.buildHtmlLayer(ol) }
3357
+ }
3358
+ // While the edited source rasterizes, keep the clip's previous picture.
3359
+ return olEl || (ns.htmlLast && ns.htmlLast[ol.id]) || null
3360
+ }
2894
3361
  if (!olEl && ns.videoCache) {
2895
3362
  if (ol.kind === 'image') {
2896
3363
  olEl = new Image()
@@ -2967,6 +3434,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
2967
3434
  var ol = ols[oj]
2968
3435
  var olT = t - ol.start
2969
3436
  if (olT < 0 || olT > ol.dur) continue
3437
+ if (ol.html && ns.htmlSafe === false) continue
2970
3438
  var olA = 1, olYof = 0, olScl = 1, olBlur = 0
2971
3439
  if (ol.fx && ol.fx.u === 'block') {
2972
3440
  // fx owns the entrance; block unit = the legacy presets generalized
@@ -5793,6 +6261,19 @@ function studioLayerData(layers, duration) {
5793
6261
  return track.keyframes.length ? { track } : {};
5794
6262
  })()
5795
6263
  };
6264
+ if (o.kind === "html") {
6265
+ const hl = htmlLayerPayload(o);
6266
+ return {
6267
+ ...base,
6268
+ kind: "image",
6269
+ key: hl.key,
6270
+ w: round6(hl.width),
6271
+ radius: 0,
6272
+ shadow: "none",
6273
+ opacity: round6(o.opacity ?? 1),
6274
+ html: hl.html
6275
+ };
6276
+ }
5796
6277
  if (o.kind !== "text") {
5797
6278
  const lm = rec ? layerMedia(rec, o.key) : null;
5798
6279
  const lmImage = lm ? lm.sourceKind === "image" : o.kind === "image";
@@ -6704,9 +7185,10 @@ function copyLayout(from, to, opts = {}) {
6704
7185
  next.duration = Math.max(0.3, Math.round(outDur * 1e3) / 1e3);
6705
7186
  if (next.kind === "text") {
6706
7187
  if (mine?.kind === "text") next.text = mine.text;
7188
+ } else if (next.kind === "html") {
6707
7189
  } else if (opts.keys?.[clip.id]) {
6708
7190
  next.key = opts.keys[clip.id];
6709
- } else if (mine && mine.kind !== "text") {
7191
+ } else if (mine && mine.kind !== "text" && mine.kind !== "html") {
6710
7192
  next.key = mine.key;
6711
7193
  } else {
6712
7194
  skippedMarks.push(clip.id);
@@ -6833,9 +7315,10 @@ function applyTemplate(template, take, opts) {
6833
7315
  const word = opts.words?.[clip.id];
6834
7316
  if (word !== void 0) next.text = word;
6835
7317
  else if (mine?.kind === "text") next.text = mine.text;
7318
+ } else if (next.kind === "html") {
6836
7319
  } else if (opts.keys?.[clip.id]) {
6837
7320
  next.key = opts.keys[clip.id];
6838
- } else if (mine && mine.kind !== "text") {
7321
+ } else if (mine && mine.kind !== "text" && mine.kind !== "html") {
6839
7322
  next.key = mine.key;
6840
7323
  } else if (!/^(https?:|\/\/)/.test(next.key)) {
6841
7324
  skipped.push(clip.id);
@@ -7581,6 +8064,60 @@ function destinationsForChannel(channel) {
7581
8064
  return DESTINATIONS.filter((d) => d.channel === channel);
7582
8065
  }
7583
8066
 
8067
+ // src/htmlStarters.ts
8068
+ var WORDS = "font-family:'Inter',-apple-system,system-ui,sans-serif;color:#fafafa;-webkit-font-smoothing:antialiased;";
8069
+ var MONO = "font-family:'JetBrains Mono',ui-monospace,monospace;";
8070
+ var CARD = "background:#0b0b0d;border:1px solid rgba(255,255,255,.08);border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.45);";
8071
+ var HTML_STARTERS = [
8072
+ {
8073
+ id: "callout",
8074
+ name: "Callout",
8075
+ box: { width: 420, height: 132 },
8076
+ html: '<div class="callout"><div class="head"><span class="dot"></span><span class="title">Title</span></div><div class="body">One line that says what this is.</div></div>',
8077
+ css: `.callout{${WORDS}${CARD}box-sizing:border-box;width:420px;height:132px;padding:22px 24px}.head{display:flex;align-items:center;gap:10px}.dot{width:8px;height:8px;border-radius:99px;background:#4ade80;box-shadow:0 0 10px rgba(74,222,128,.9)}.title{font-size:20px;font-weight:600;letter-spacing:-0.01em}.body{margin-top:12px;font-size:15px;line-height:1.5;color:#8b8b94}`
8078
+ },
8079
+ {
8080
+ id: "code",
8081
+ name: "Code",
8082
+ box: { width: 460, height: 168 },
8083
+ html: '<div class="code"><span class="mut">// button.tsx</span>\n<span class="kw">export function</span> <span class="fn">Button</span>({ variant }) {\n <span class="kw">return</span> <span class="tag">&lt;button</span> className={<span class="fn">cn</span>(variant)} <span class="tag">/&gt;</span>\n}</div>',
8084
+ css: `.code{${MONO}${CARD}color:#e8e8ec;box-sizing:border-box;width:460px;height:168px;padding:22px 24px;font-size:14px;line-height:1.8;white-space:pre;letter-spacing:-0.01em}.kw{color:#c4b5fd}.fn{color:#7dd3fc}.tag{color:#f0abfc}.mut{color:#5b5b66}`
8085
+ },
8086
+ {
8087
+ id: "terminal",
8088
+ name: "Terminal",
8089
+ box: { width: 460, height: 152 },
8090
+ html: '<div class="term"><span class="prompt">$</span> <span class="cmd">npx vos record https://your.app</span>\n<span class="mut">recording 1920x1080 at 30 fps</span>\n<span class="mut">saved take/</span></div>',
8091
+ css: `.term{${MONO}${CARD}background:#08080a;color:#e8e8ec;box-sizing:border-box;width:460px;height:152px;padding:22px 24px;font-size:14px;line-height:1.8;white-space:pre}.prompt{color:#4ade80}.mut{color:#5b5b66}`
8092
+ },
8093
+ {
8094
+ id: "keys",
8095
+ name: "Keys",
8096
+ box: { width: 220, height: 64 },
8097
+ html: '<div class="keys"><kbd>&#8984;</kbd><span class="plus">+</span><kbd>K</kbd></div>',
8098
+ css: `.keys{${WORDS}display:flex;align-items:center;justify-content:center;gap:10px;width:220px;height:64px}kbd{display:inline-flex;align-items:center;justify-content:center;min-width:48px;height:48px;padding:0 14px;border-radius:10px;background:#1a1a1f;border:1px solid rgba(255,255,255,.14);border-bottom-width:3px;box-shadow:0 10px 24px rgba(0,0,0,.4);font-size:22px;font-weight:600}.plus{color:#8b8b94;font-size:18px}`
8099
+ },
8100
+ {
8101
+ id: "badge",
8102
+ name: "Badge",
8103
+ box: { width: 200, height: 52 },
8104
+ html: '<div class="badge"><span class="dot"></span>New in 2.0</div>',
8105
+ css: `.badge{${WORDS}display:inline-flex;align-items:center;gap:10px;height:44px;padding:0 20px;border-radius:99px;background:#1a1a1f;border:1px solid rgba(255,255,255,.12);box-shadow:0 12px 30px rgba(0,0,0,.4);font-size:16px;font-weight:600;letter-spacing:-0.01em}.dot{width:8px;height:8px;border-radius:99px;background:#3b82f6;box-shadow:0 0 10px rgba(59,130,246,.9)}`
8106
+ }
8107
+ ];
8108
+ function htmlStarterClip(starter, id, start, duration) {
8109
+ return {
8110
+ id,
8111
+ kind: "html",
8112
+ html: starter.html,
8113
+ css: starter.css,
8114
+ box: { ...starter.box },
8115
+ start,
8116
+ duration,
8117
+ transform: { x: 0.5, y: 0.5, scale: 1, rotation: 0 }
8118
+ };
8119
+ }
8120
+
7584
8121
  // src/lower/audioPlan.ts
7585
8122
  var AUDIO_PLAN_STEP = 1 / 240;
7586
8123
  function envelopeAt(env, t, def) {
@@ -8738,7 +9275,7 @@ var overlaysLane = {
8738
9275
  kind: "clip",
8739
9276
  t: round11(o.start),
8740
9277
  duration: round11(o.duration),
8741
- label: o.kind === "text" ? o.text.split("\n")[0].slice(0, 24) || "Text" : o.kind === "image" ? "Image" : "Video"
9278
+ label: o.kind === "text" ? o.text.split("\n")[0].slice(0, 24) || "Text" : o.kind === "html" ? htmlLayerLabel(o) : o.kind === "image" ? "Image" : "Video"
8742
9279
  },
8743
9280
  // Pose diamonds render after the clips, so they sit on top.
8744
9281
  ...poseItems(o.id, o.start, o.duration, o.motion)
@@ -9134,6 +9671,7 @@ export {
9134
9671
  DEFAULT_TILT_POSE,
9135
9672
  DEFAULT_ZOOM_LEVEL,
9136
9673
  DEFAULT_ZOOM_STYLE,
9674
+ DESIGN_FRAME_WIDTH,
9137
9675
  DESTINATIONS,
9138
9676
  DIGEST_CROP_MAX,
9139
9677
  DIGEST_FULL_MAX,
@@ -9155,6 +9693,11 @@ export {
9155
9693
  FREEZE_SECONDS_MAX,
9156
9694
  FREEZE_SECONDS_MIN,
9157
9695
  HOUSE_GRADIENT,
9696
+ HTML_LAYER_FOREIGN_FIELDS,
9697
+ HTML_LAYER_MAX_BYTES,
9698
+ HTML_LAYER_SVG_CODE,
9699
+ HTML_LAYER_WARN_BYTES,
9700
+ HTML_STARTERS,
9158
9701
  IDLE_KINDS,
9159
9702
  LAYER_CARD_SHADOW,
9160
9703
  LAYOUT_FRAME_FIELDS,
@@ -9268,6 +9811,8 @@ export {
9268
9811
  copyLayout,
9269
9812
  copyStyle,
9270
9813
  cropBox,
9814
+ cssFamilies,
9815
+ cssWeights,
9271
9816
  cursorIdleFade,
9272
9817
  deriveViewportCrop,
9273
9818
  destinationById,
@@ -9304,8 +9849,22 @@ export {
9304
9849
  freezeOutputExtents,
9305
9850
  hexToRgbTriplet,
9306
9851
  houseLook,
9852
+ htmlLayerBleed,
9853
+ htmlLayerBleedFor,
9854
+ htmlLayerFaces,
9855
+ htmlLayerKey,
9856
+ htmlLayerLabel,
9857
+ htmlLayerPayload,
9858
+ htmlLayerPictureBox,
9859
+ htmlLayerProblems,
9860
+ htmlLayerSvg,
9861
+ htmlLayerText,
9862
+ htmlLayerUnhostedFamilies,
9863
+ htmlLayerWidth,
9864
+ htmlStarterClip,
9307
9865
  idleGaps,
9308
9866
  idleKey,
9867
+ isKeyedOverlay,
9309
9868
  isLookKind,
9310
9869
  isMusicBed,
9311
9870
  isPlayback,
@@ -9401,6 +9960,7 @@ export {
9401
9960
  transitionSeconds,
9402
9961
  videoLane,
9403
9962
  voiceKey,
9963
+ wellFormedXml,
9404
9964
  withBackdrop,
9405
9965
  withFreezes,
9406
9966
  withHolds,