@vosjs/studio-core 0.19.0 → 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 +41 -5
- package/dist/index.js +194 -35
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"')\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
|
|
2164
|
-
|
|
2193
|
+
for (const u of htmlLayerAssets({ html, css }).foreign)
|
|
2194
|
+
push2(
|
|
2195
|
+
"external-resource",
|
|
2196
|
+
"warning",
|
|
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.`
|
|
2198
|
+
);
|
|
2199
|
+
if (/\bhref\s*=\s*["']\s*(https?:)?\/\//i.test(html))
|
|
2165
2200
|
push2(
|
|
2166
2201
|
"external-resource",
|
|
2167
2202
|
"warning",
|
|
2168
|
-
"The source names a
|
|
2203
|
+
"The source names a stylesheet or a link by URL. An SVG image fetches no href; inline the styles in the CSS."
|
|
2169
2204
|
);
|
|
2170
|
-
|
|
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.
|
|
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
|
-
|
|
2245
|
+
var fmtT = (t) => String(Math.round(t * 1e3) / 1e3);
|
|
2246
|
+
var escapeXml = (v) => String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
|
2955
|
-
let p = faceData.get(
|
|
3042
|
+
const bytesOf = (url, mime, what) => {
|
|
3043
|
+
let p = faceData.get(url)
|
|
2956
3044
|
if (p) return p
|
|
2957
|
-
p = fetch(
|
|
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
|
-
|
|
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
|
|
2966
|
-
//
|
|
2967
|
-
faceData.delete(
|
|
2968
|
-
console.warn('[voila] html layer
|
|
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(
|
|
3060
|
+
faceData.set(url, p)
|
|
2972
3061
|
return p
|
|
2973
3062
|
}
|
|
2974
|
-
const
|
|
2975
|
-
|
|
2976
|
-
|
|
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
|
|
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 !==
|
|
3014
|
-
cache.set(
|
|
3015
|
-
ns.htmlKeys[oc.id] =
|
|
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) }
|
|
@@ -7249,7 +7408,7 @@ function copyLayout(from, to, opts = {}) {
|
|
|
7249
7408
|
return { doc, notes };
|
|
7250
7409
|
}
|
|
7251
7410
|
function trailingFreeze(doc) {
|
|
7252
|
-
const last = doc.segments
|
|
7411
|
+
const last = doc.segments?.at(-1);
|
|
7253
7412
|
if (!last) return null;
|
|
7254
7413
|
return docFreezes(doc).find((f) => Math.abs(f.at - last.out) < 1e-9) ?? null;
|
|
7255
7414
|
}
|
|
@@ -7343,16 +7502,16 @@ function applyTemplate(template, take, opts) {
|
|
|
7343
7502
|
}
|
|
7344
7503
|
if (objects.length || doc.objects) doc.objects = objects;
|
|
7345
7504
|
const audio = doc.audio.filter(
|
|
7346
|
-
(a) => !template.audio.some((t) => t.id === a.id)
|
|
7505
|
+
(a) => !(template.audio ?? []).some((t) => t.id === a.id)
|
|
7347
7506
|
);
|
|
7348
|
-
for (const clip of template.audio) {
|
|
7507
|
+
for (const clip of template.audio ?? []) {
|
|
7349
7508
|
const next = structuredClone(clip);
|
|
7350
7509
|
next.start = Math.max(0, round34(clip.start + shift));
|
|
7351
7510
|
next.from = from;
|
|
7352
7511
|
audio.push(next);
|
|
7353
7512
|
}
|
|
7354
7513
|
doc.audio = audio;
|
|
7355
|
-
if (template.frame
|
|
7514
|
+
if (template.frame?.anim) {
|
|
7356
7515
|
doc.frame = {
|
|
7357
7516
|
...doc.frame,
|
|
7358
7517
|
anim: { ...doc.frame.anim ?? {}, ...template.frame.anim }
|