@vosjs/studio-core 0.36.0 → 0.38.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
@@ -1817,1777 +1817,2176 @@ function outputEnd(doc, footageEnd) {
1817
1817
  return Math.max(footageEnd, lastLayerEnd(doc));
1818
1818
  }
1819
1819
 
1820
- // src/zoomStyle.ts
1821
- var RETIRED_ZOOM_STYLES = {
1822
- keynote: { style: "glide", tilt: "medium" },
1823
- drift: { style: "cinema", tilt: "subtle" }
1824
- };
1825
- function liveZoomStyleName(name) {
1826
- if (!name) return null;
1827
- if (name in ZOOM_STYLES) return name;
1828
- return RETIRED_ZOOM_STYLES[name]?.style ?? null;
1820
+ // src/overlayText.ts
1821
+ import {
1822
+ findFontFamily as findFontFamily2,
1823
+ fontFaceUrl as fontFaceUrl2,
1824
+ fontStack,
1825
+ nearestFontWeight as nearestFontWeight2
1826
+ } from "@vosjs/shared";
1827
+
1828
+ // src/htmlLayer.ts
1829
+ import { findFontFamily, fontFaceUrl, nearestFontWeight } from "@vosjs/shared";
1830
+ var DESIGN_FRAME_WIDTH = 1920;
1831
+ var HTML_LAYER_WARN_BYTES = 32e3;
1832
+ var HTML_LAYER_MAX_BYTES = 128e3;
1833
+ var HTML_LAYER_LIVE_HZ = 60;
1834
+ var HTML_LAYER_ASSET_HOST = "https://assets.vos.so/";
1835
+ function splitTopLevel(value) {
1836
+ const out = [];
1837
+ let depth = 0;
1838
+ let cur = "";
1839
+ for (const ch of value) {
1840
+ if (ch === "(") depth++;
1841
+ else if (ch === ")") depth--;
1842
+ if (ch === "," && depth === 0) {
1843
+ out.push(cur);
1844
+ cur = "";
1845
+ } else cur += ch;
1846
+ }
1847
+ if (cur.trim()) out.push(cur);
1848
+ return out;
1829
1849
  }
1830
- var HOLD_DRIFT_MAX = 0.15;
1831
- var PUMP_REST = 1;
1832
- function pumpFreeChainGap(p) {
1833
- return p.rampOut + (p.rampIn - p.rampInOverlap) + PUMP_REST;
1850
+ var unquote = (s) => s.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim();
1851
+ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
1852
+ "serif",
1853
+ "sans-serif",
1854
+ "monospace",
1855
+ "cursive",
1856
+ "fantasy",
1857
+ "system-ui",
1858
+ "ui-sans-serif",
1859
+ "ui-serif",
1860
+ "ui-monospace",
1861
+ "ui-rounded",
1862
+ "emoji",
1863
+ "math",
1864
+ "fangsong",
1865
+ "inherit",
1866
+ "initial",
1867
+ "unset"
1868
+ ]);
1869
+ function cssFamilies(css) {
1870
+ if (!css) return [];
1871
+ const out = [];
1872
+ const seen = /* @__PURE__ */ new Set();
1873
+ for (const decl of css.match(/font-family\s*:[^;}]+/gi) ?? []) {
1874
+ for (const item of splitTopLevel(decl.replace(/^[^:]*:/, ""))) {
1875
+ const name = unquote(item.replace(/!important/i, ""));
1876
+ if (!name) continue;
1877
+ const lower = name.toLowerCase();
1878
+ if (GENERIC_FAMILIES.has(lower) || lower.startsWith("-apple-")) continue;
1879
+ if (seen.has(lower)) continue;
1880
+ seen.add(lower);
1881
+ out.push(name);
1882
+ }
1883
+ }
1884
+ return out;
1834
1885
  }
1835
- var glide = {
1836
- autoZoom: true,
1837
- clusterGap: 3,
1838
- minClusterClicks: 1,
1839
- targetFill: 0.42,
1840
- minLevel: 1.3,
1841
- maxLevel: 1.8,
1842
- dragLevel: 1.5,
1843
- lead: 0.35,
1844
- hold: 1.2,
1845
- followByDefault: true,
1846
- typingZoom: true,
1847
- typingGap: 2.5,
1848
- typingHold: 1.1,
1849
- typingMinLevel: 1.4,
1850
- rampIn: 0.6,
1851
- rampInOverlap: 0.2,
1852
- rampOut: 0.5,
1853
- chainGap: 3,
1854
- pan: 0.55,
1855
- // The reference's corner trace at 60 fps: a quarter of the move by a
1856
- // fifth of the time, two thirds by half, nine tenths by three
1857
- // quarters, over ~0.6 s. Fitted against those three points,
1858
- // (0.36, 0, 0.4, 1) is the closest cubic (15 / 64 / 93 %; half the
1859
- // move at ~245 ms and 90 % at ~425 ms at 0.6 s): a gentle start and a
1860
- // long settle. CSS ease (0.25, 0.1, 0.25, 1) reached the midpoint
1861
- // 70 ms earlier and read a touch fast; (0.16, 1, 0.3, 1) put half the
1862
- // move in the first sixty milliseconds and read as a twitch.
1863
- ease: "css-bezier(0.36, 0, 0.4, 1)",
1864
- panEase: "css-bezier(0.36, 0, 0.4, 1)",
1865
- followSafeRatio: 0.45,
1866
- followRecenter: 0.55,
1867
- followLookahead: 0.4,
1868
- tilt: {
1869
- intensity: "off",
1870
- rampIn: 0.6,
1871
- rampOut: 0.5,
1872
- chainGap: 3,
1873
- pan: 0.55
1886
+ function cssWeights(css, html = "") {
1887
+ const out = /* @__PURE__ */ new Set([400]);
1888
+ for (const decl of css?.match(/font-weight\s*:[^;}]+/gi) ?? []) {
1889
+ const value = decl.replace(/^[^:]*:/, "").toLowerCase();
1890
+ for (const n of value.match(/\b[1-9]00\b/g) ?? []) out.add(Number(n));
1891
+ if (/\bbold(er)?\b/.test(value)) out.add(700);
1874
1892
  }
1875
- };
1876
- var ZOOM_STYLES = {
1877
- glide,
1878
- // Screen Studio "Focused" / Recordly — a block per click, framed and
1879
- // released, no travel. The spring-settled family ease (near-linear onset,
1880
- // long settle) at the default's tempo: 0.8 s in, 0.65 s out, and a beat
1881
- // of 1.5 s after the click so the block is read before it goes. At its
1882
- // 2026-07 timing (1.4 s in, 1.0 s out around a 1.35 s span) a lone click
1883
- // spent 74% of its beat moving and every dwell or sped-up click was
1884
- // shorter than the ramp, which the lowering compressed into a cut.
1885
- focus: {
1886
- autoZoom: true,
1887
- clusterGap: 1.5,
1888
- minClusterClicks: 1,
1889
- targetFill: 0.5,
1890
- minLevel: 1.4,
1891
- maxLevel: 2,
1892
- dragLevel: 1.6,
1893
- lead: 0.35,
1894
- hold: 1.5,
1895
- followByDefault: false,
1896
- typingZoom: true,
1897
- typingGap: 2,
1898
- typingHold: 1.3,
1899
- typingMinLevel: 1.5,
1900
- rampIn: 0.8,
1901
- rampInOverlap: 0.25,
1902
- rampOut: 0.65,
1903
- chainGap: 2.4,
1904
- pan: 0.7,
1905
- ease: "css-bezier(0.28, 0.03, 0.09, 1)",
1906
- panEase: "css-bezier(0.26, 0.08, 0.2, 1)",
1907
- followSafeRatio: 0.5,
1908
- followRecenter: 0.55,
1909
- followLookahead: 0,
1910
- tilt: {
1911
- intensity: "off",
1912
- rampIn: 0.8,
1913
- rampOut: 0.65,
1914
- chainGap: 2.4,
1915
- pan: 0.7
1916
- }
1917
- },
1918
- // Screen Studio "Smooth" — the long-HOLD style: 1.2 s in, 1.0 s out,
1919
- // two-second holds, chains across 3.5 s so a slow clip travels rather
1920
- // than pumps, and the default's level range, because a watched clip
1921
- // wants the frame, not the magnifier. Its ramps still exceed a dwell or
1922
- // a sped-up click; the lowering fits them to the room.
1923
- cinema: {
1924
- autoZoom: true,
1925
- clusterGap: 2.4,
1926
- minClusterClicks: 1,
1927
- targetFill: 0.42,
1928
- minLevel: 1.3,
1929
- maxLevel: 1.8,
1930
- dragLevel: 1.5,
1931
- lead: 0.6,
1932
- hold: 2,
1933
- followByDefault: true,
1934
- typingZoom: true,
1935
- typingGap: 3,
1936
- typingHold: 1.8,
1937
- typingMinLevel: 1.4,
1938
- rampIn: 1.2,
1939
- rampInOverlap: 0.4,
1940
- rampOut: 1,
1941
- chainGap: 3.5,
1942
- pan: 1.1,
1943
- ease: "css-bezier(0.33, 0, 0.15, 1)",
1944
- panEase: "css-bezier(0.33, 0, 0.22, 1)",
1945
- followSafeRatio: 0.6,
1946
- followRecenter: 0.9,
1947
- followLookahead: 0.5,
1948
- tilt: {
1949
- intensity: "off",
1950
- rampIn: 1.2,
1951
- rampOut: 1,
1952
- chainGap: 3.5,
1953
- pan: 1.1
1954
- }
1955
- },
1956
- // The studio's original pace with the instant-velocity onset defect fixed:
1957
- // 0.4 s cycles on the onset-capped ease, the 2.5× ceiling of the 2026-07
1958
- // comparison's defect two brought to 2.2, a chain gap past its pump band.
1959
- snappy: {
1960
- autoZoom: true,
1961
- clusterGap: 1.2,
1962
- minClusterClicks: 1,
1963
- targetFill: 0.5,
1964
- minLevel: 1.4,
1965
- maxLevel: 2.2,
1966
- dragLevel: 1.6,
1967
- lead: 0.25,
1968
- hold: 1.2,
1969
- followByDefault: false,
1970
- typingZoom: true,
1971
- typingGap: 1.6,
1972
- typingHold: 0.9,
1973
- typingMinLevel: 1.5,
1974
- rampIn: 0.4,
1975
- rampInOverlap: 0.15,
1976
- rampOut: 0.4,
1977
- chainGap: 2,
1978
- pan: 0.5,
1979
- ease: "css-bezier(0.3, 0.55, 0.2, 1)",
1980
- panEase: "css-bezier(0.25, 0.1, 0.25, 1)",
1981
- followSafeRatio: 0.5,
1982
- followRecenter: 0.45,
1983
- followLookahead: 0,
1984
- tilt: {
1985
- intensity: "off",
1986
- rampIn: 0.4,
1987
- rampOut: 0.4,
1988
- chainGap: 2,
1989
- pan: 0.5
1990
- }
1991
- },
1992
- // Screen Studio's "instant zoom" — a cut-in (eight frames at 60 fps),
1993
- // tutorial tempo. Untouched in motion (the one style that never pumped
1994
- // and never left a ramp unsettled); the ceiling comes down with the
1995
- // others and the hold gains the beat a cut-in needs to be read.
1996
- cut: {
1997
- autoZoom: true,
1998
- clusterGap: 1.2,
1999
- minClusterClicks: 1,
2000
- targetFill: 0.5,
2001
- minLevel: 1.4,
2002
- maxLevel: 2,
2003
- dragLevel: 1.6,
2004
- lead: 0.2,
2005
- hold: 1.2,
2006
- followByDefault: false,
2007
- typingZoom: true,
2008
- typingGap: 1.2,
2009
- typingHold: 0.6,
2010
- typingMinLevel: 1.5,
2011
- rampIn: 0.14,
2012
- rampInOverlap: 0.07,
2013
- rampOut: 0.14,
2014
- chainGap: 1.3,
2015
- pan: 0.35,
2016
- ease: "css-bezier(0.2, 0, 0.4, 1)",
2017
- panEase: "css-bezier(0.2, 0, 0.4, 1)",
2018
- followSafeRatio: 0.5,
2019
- followRecenter: 0.35,
2020
- followLookahead: 0,
2021
- tilt: {
2022
- intensity: "off",
2023
- rampIn: 0.3,
2024
- rampOut: 0.3,
2025
- chainGap: 1.3,
2026
- pan: 0.35
1893
+ if (/<(b|strong|h[1-6])[\s>]/i.test(html)) out.add(700);
1894
+ return [...out].sort((a, b) => a - b);
1895
+ }
1896
+ function htmlLayerFaces(clip) {
1897
+ const faces = [];
1898
+ const seen = /* @__PURE__ */ new Set();
1899
+ const add = (f) => {
1900
+ const key = `${f.family.toLowerCase()}|${f.weight}|${f.style}`;
1901
+ if (seen.has(key)) return;
1902
+ seen.add(key);
1903
+ faces.push(f);
1904
+ };
1905
+ const weights = cssWeights(clip.css, clip.html);
1906
+ for (const family of cssFamilies(clip.css)) {
1907
+ const entry = findFontFamily(family);
1908
+ if (!entry) continue;
1909
+ for (const w of weights)
1910
+ add({
1911
+ family: entry.family,
1912
+ url: fontFaceUrl(entry.slug, nearestFontWeight(entry, w)),
1913
+ weight: nearestFontWeight(entry, w),
1914
+ style: "normal"
1915
+ });
1916
+ }
1917
+ for (const f of clip.fonts ?? [])
1918
+ add({
1919
+ family: f.family,
1920
+ url: f.url,
1921
+ weight: f.weight ?? 400,
1922
+ style: f.style ?? "normal"
1923
+ });
1924
+ return faces;
1925
+ }
1926
+ function htmlLayerUnhostedFamilies(clip) {
1927
+ const explicit = new Set(
1928
+ (clip.fonts ?? []).map((f) => f.family.toLowerCase())
1929
+ );
1930
+ return cssFamilies(clip.css).filter(
1931
+ (f) => !findFontFamily(f) && !explicit.has(f.toLowerCase())
1932
+ );
1933
+ }
1934
+ function htmlLayerAssetUrls(clip) {
1935
+ const out = [];
1936
+ const seen = /* @__PURE__ */ new Set();
1937
+ const add = (u) => {
1938
+ if (!seen.has(u)) {
1939
+ seen.add(u);
1940
+ out.push(u);
2027
1941
  }
2028
- },
2029
- // Auto-zoom off (every competitor ships this switch). Camera params still
2030
- // apply to MANUAL spans — they move the way the default camera moves
2031
- // TODAY (spelled as a spread, never a copy that fossilises).
2032
- none: { ...glide, autoZoom: false }
1942
+ };
1943
+ for (const m of clip.html.matchAll(
1944
+ /\bsrc\s*=\s*["']((?:https?:)?\/\/[^"']+)["']/gi
1945
+ ))
1946
+ add(m[1]);
1947
+ for (const text of [clip.html, clip.css ?? ""])
1948
+ for (const m of text.matchAll(
1949
+ /url\(\s*["']?((?:https?:)?\/\/[^"')\s]+)["']?\s*\)/gi
1950
+ ))
1951
+ add(m[1]);
1952
+ return out;
1953
+ }
1954
+ function htmlLayerAssets(clip) {
1955
+ const inlinable = [];
1956
+ const foreign = [];
1957
+ for (const u of htmlLayerAssetUrls(clip))
1958
+ (u.replace(/^\/\//, "https://").startsWith(HTML_LAYER_ASSET_HOST) ? inlinable : foreign).push(u);
1959
+ return { inlinable, foreign };
1960
+ }
1961
+ function htmlLayerBleedFor(css) {
1962
+ if (!css) return 0;
1963
+ let worst = 0;
1964
+ const consider = (layer) => {
1965
+ if (/\binset\b/i.test(layer)) return;
1966
+ const lengths = layer.replace(/[a-z-]+\([^)]*\)/gi, " ").replace(/#[0-9a-f]{3,8}/gi, " ").match(/-?\d*\.?\d+/g);
1967
+ if (!lengths) return;
1968
+ const [x = 0, y = 0, blur = 0, spread = 0] = lengths.map(Number);
1969
+ worst = Math.max(
1970
+ worst,
1971
+ Math.abs(x) + blur + spread,
1972
+ Math.abs(y) + blur + spread
1973
+ );
1974
+ };
1975
+ for (const decl of css.match(/box-shadow\s*:[^;}]+/gi) ?? [])
1976
+ for (const layer of splitTopLevel(decl.replace(/^[^:]*:/, "")))
1977
+ consider(layer);
1978
+ for (const fn of css.match(/drop-shadow\(([^)]*)\)/gi) ?? [])
1979
+ consider(fn.replace(/^drop-shadow\(/i, "").replace(/\)$/, ""));
1980
+ return worst ? Math.ceil(worst * 1.15) : 0;
1981
+ }
1982
+ function htmlLayerBleed(clip) {
1983
+ return Math.max(0, Math.round(clip.bleed ?? htmlLayerBleedFor(clip.css)));
1984
+ }
1985
+ function htmlLayerPictureBox(clip) {
1986
+ const bleed = htmlLayerBleed(clip);
1987
+ return {
1988
+ width: clip.box.width + bleed * 2,
1989
+ height: clip.box.height + bleed * 2,
1990
+ bleed
1991
+ };
1992
+ }
1993
+ function htmlLayerWidth(clip) {
1994
+ if (clip.width != null) return clip.width;
1995
+ return Math.min(1, htmlLayerPictureBox(clip).width / DESIGN_FRAME_WIDTH);
1996
+ }
1997
+ var ENTITIES = {
1998
+ amp: "&",
1999
+ lt: "<",
2000
+ gt: ">",
2001
+ quot: '"',
2002
+ apos: "'",
2003
+ nbsp: " "
2033
2004
  };
2034
- var DEFAULT_ZOOM_STYLE = "glide";
2035
- function resolveZoomStyle(name, overrides) {
2036
- const live = liveZoomStyleName(name) ?? DEFAULT_ZOOM_STYLE;
2037
- const params = { ...ZOOM_STYLES[live], ...overrides };
2038
- const floor = pumpFreeChainGap(params);
2039
- if (params.chainGap < floor) params.chainGap = floor;
2040
- return params;
2005
+ function htmlLayerText(html) {
2006
+ return html.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]*>/g, " ").replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, ref) => {
2007
+ if (ref[0] === "#")
2008
+ return String.fromCodePoint(
2009
+ ref[1] === "x" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1))
2010
+ );
2011
+ return ENTITIES[ref.toLowerCase()] ?? m;
2012
+ }).replace(/\s+/g, " ").trim();
2041
2013
  }
2042
- var ZOOM_STYLE_OPTIONS = [
2043
- {
2044
- name: "glide",
2045
- label: "Glide",
2046
- hint: "One steady zoom that travels between clicks"
2047
- },
2048
- {
2049
- name: "focus",
2050
- label: "Focus",
2051
- hint: "A zoom per click, framed and released"
2052
- },
2053
- {
2054
- name: "cinema",
2055
- label: "Cinema",
2056
- hint: "Slow moves and long holds, for content that is watched"
2057
- },
2058
- { name: "snappy", label: "Snappy", hint: "Quick, energetic zoom cycles" },
2059
- { name: "cut", label: "Cut", hint: "Instant zooms, no glide" },
2060
- {
2061
- name: "none",
2062
- label: "None",
2063
- hint: "No automatic zooms, add your own on the timeline"
2064
- }
2014
+ function htmlLayerLabel(clip) {
2015
+ const words = htmlLayerText(clip.html);
2016
+ return words ? words.slice(0, 24) : "HTML";
2017
+ }
2018
+ var HTML_LAYER_PLACEHOLDER = /\{\{\s*(t|data\.[A-Za-z0-9_.-]+)\s*\}\}/;
2019
+ var VOID_TAGS = [
2020
+ "br",
2021
+ "img",
2022
+ "hr",
2023
+ "input",
2024
+ "source",
2025
+ "col",
2026
+ "area",
2027
+ "base",
2028
+ "wbr",
2029
+ "meta",
2030
+ "link"
2065
2031
  ];
2066
-
2067
- // src/docVersion.ts
2068
- var DOC_SCHEMA_VERSION = 5;
2069
- function migrateHostedDoc(raw) {
2070
- const version = typeof raw.docSchemaVersion === "number" ? raw.docSchemaVersion : 0;
2071
- if (version >= DOC_SCHEMA_VERSION) return raw;
2072
- let doc = raw;
2073
- if (version < 4 && doc.source && typeof doc.source === "object") {
2074
- doc = migrateMotion(doc);
2032
+ var HTML_LAYER_FOREIGN_FIELDS = [
2033
+ "key",
2034
+ "radius",
2035
+ "shadow",
2036
+ "border",
2037
+ "loop",
2038
+ "frame",
2039
+ "fx",
2040
+ "text",
2041
+ "preset"
2042
+ ];
2043
+ function htmlLayerProblems(clip) {
2044
+ const html = clip.html ?? "";
2045
+ const css = clip.css;
2046
+ const raw = clip;
2047
+ const problems = [];
2048
+ const push2 = (code, level, message) => problems.push({ code, level, message });
2049
+ if (!html.trim()) push2("empty", "problem", "The layer has no markup.");
2050
+ for (const tag of VOID_TAGS) {
2051
+ const open = new RegExp(`<${tag}(\\s[^>]*?)?>`, "gi");
2052
+ let m;
2053
+ let said = false;
2054
+ while (m = open.exec(html)) {
2055
+ if (!m[0].trimEnd().endsWith("/>") && !said) {
2056
+ said = true;
2057
+ push2(
2058
+ "unclosed-void",
2059
+ "problem",
2060
+ `<${tag}> must close itself as <${tag} /> inside a foreignObject.`
2061
+ );
2062
+ }
2063
+ }
2075
2064
  }
2076
- if (version < 5) doc = migrateZoomStyle(doc);
2077
- return { ...doc, docSchemaVersion: DOC_SCHEMA_VERSION };
2065
+ const bare = html.match(/&(?!(#\d+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/g);
2066
+ if (bare)
2067
+ push2(
2068
+ "bare-ampersand",
2069
+ "problem",
2070
+ `A bare "&" is a parse error; write &amp;. Found ${bare.length}.`
2071
+ );
2072
+ if (/<[a-zA-Z][^>]*?\s[a-zA-Z-]+=(?!["'])[^\s>]+/.test(html))
2073
+ push2(
2074
+ "unquoted-attr",
2075
+ "problem",
2076
+ "Attribute values must be quoted inside a foreignObject."
2077
+ );
2078
+ const bytes = html.length + (css?.length ?? 0);
2079
+ if (bytes > HTML_LAYER_MAX_BYTES)
2080
+ push2(
2081
+ "too-large",
2082
+ "problem",
2083
+ `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.`
2084
+ );
2085
+ else if (bytes > HTML_LAYER_WARN_BYTES)
2086
+ push2(
2087
+ "too-large",
2088
+ "warning",
2089
+ `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.`
2090
+ );
2091
+ for (const field of HTML_LAYER_FOREIGN_FIELDS)
2092
+ if (raw[field] !== void 0)
2093
+ push2(
2094
+ "foreign-field",
2095
+ "problem",
2096
+ `An HTML layer has no "${field}": its CSS is its chrome (shadow, radius, border) and its markup its content.`
2097
+ );
2098
+ const needed = htmlLayerBleedFor(css);
2099
+ if (clip.bleed != null && needed > clip.bleed)
2100
+ push2(
2101
+ "shadow-clipped",
2102
+ "warning",
2103
+ `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.`
2104
+ );
2105
+ for (const u of htmlLayerAssets({ html, css }).foreign)
2106
+ push2(
2107
+ "external-resource",
2108
+ "warning",
2109
+ `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.`
2110
+ );
2111
+ if (/\bhref\s*=\s*["']\s*(https?:)?\/\//i.test(html))
2112
+ push2(
2113
+ "external-resource",
2114
+ "warning",
2115
+ "The source names a stylesheet or a link by URL. An SVG image fetches no href; inline the styles in the CSS."
2116
+ );
2117
+ const animated = /\b(animation|transition)(-[a-z-]+)?\s*:/i.test(css ?? "");
2118
+ if (animated && !clip.live)
2119
+ push2(
2120
+ "wall-clock",
2121
+ "warning",
2122
+ "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."
2123
+ );
2124
+ if (clip.live && /\banimation-delay\s*:/i.test(css ?? ""))
2125
+ push2(
2126
+ "live-delay",
2127
+ "warning",
2128
+ "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."
2129
+ );
2130
+ if (!clip.live && (HTML_LAYER_PLACEHOLDER.test(html) || HTML_LAYER_PLACEHOLDER.test(css ?? "")))
2131
+ push2(
2132
+ "placeholder-still",
2133
+ "warning",
2134
+ "The source carries {{t}} or {{data.\u2026}} placeholders, which fill only on a live layer (live: true); a still layer shows them as written."
2135
+ );
2136
+ for (const family of htmlLayerUnhostedFamilies(clip))
2137
+ push2(
2138
+ "unhosted-family",
2139
+ "warning",
2140
+ `The CSS names "${family}", which is not a hosted family (GET https://vos.so/api/fonts); the system stack stands in.`
2141
+ );
2142
+ return problems;
2078
2143
  }
2079
- function migrateZoomStyle(doc) {
2080
- const name = doc.zoomStyle;
2081
- if (typeof name !== "string") return doc;
2082
- const retired = RETIRED_ZOOM_STYLES[name];
2083
- if (!retired) return doc;
2084
- return {
2085
- ...doc,
2086
- zoomStyle: retired.style,
2087
- tiltStyle: doc.tiltStyle ?? retired.tilt
2144
+ function wellFormedXml(svg, parseXml) {
2145
+ const doc = parseXml(svg);
2146
+ const errors = doc.getElementsByTagName("parsererror");
2147
+ if (!errors.length) return { ok: true };
2148
+ const text = (errors[0].textContent ?? "").replace(/\s+/g, " ").trim();
2149
+ return { ok: false, error: text || "The markup is not well-formed XML." };
2150
+ }
2151
+ function fontFaceCss(faces) {
2152
+ let out = "";
2153
+ for (const f of faces)
2154
+ out += `@font-face{font-family:'${f.family}';src:url(${f.dataUri}) format('woff2');font-weight:${f.weight};font-style:${f.style};font-display:block}`;
2155
+ return out;
2156
+ }
2157
+ var fmtT = (t) => String(Math.round(t * 1e3) / 1e3);
2158
+ var escapeXml = (v) => String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2159
+ function htmlLayerSvg(src, faces = [], assets = []) {
2160
+ const bleed = Math.max(0, Math.round(src.bleed ?? 0));
2161
+ const boxW = src.box.width + bleed * 2;
2162
+ const boxH = src.box.height + bleed * 2;
2163
+ const t = src.t ?? 0;
2164
+ const fill = (text) => {
2165
+ let out = text;
2166
+ for (const a of assets) out = out.split(a.url).join(a.dataUri);
2167
+ if (src.live) {
2168
+ out = out.replace(/\{\{\s*t\s*\}\}/g, fmtT(t));
2169
+ out = out.replace(
2170
+ /\{\{\s*data\.([A-Za-z0-9_.-]+)\s*\}\}/g,
2171
+ (_m, name) => {
2172
+ const v = src.data ? src.data[name] : void 0;
2173
+ return v == null ? "" : escapeXml(v);
2174
+ }
2175
+ );
2176
+ }
2177
+ return out;
2088
2178
  };
2179
+ 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}` : "";
2180
+ 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}`;
2181
+ 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>`;
2089
2182
  }
2090
-
2091
- // src/doc/studioDoc.ts
2092
- var anchorKindOf = (doc) => "source" in doc ? "recording" : "program";
2093
- var isRecordingDoc = (doc) => "source" in doc;
2094
- var isProgramDoc = (doc) => !("source" in doc);
2095
- function programDuration(doc) {
2096
- const own = doc.program.duration;
2097
- if (typeof own === "number" && own > 0) return own;
2098
- const cfg = doc.program.config.duration;
2099
- return typeof cfg === "number" && cfg > 0 ? cfg : 0;
2183
+ function fnv1a(s) {
2184
+ let h = 2166136261;
2185
+ for (let i = 0; i < s.length; i++) {
2186
+ h ^= s.charCodeAt(i);
2187
+ h = Math.imul(h, 16777619) >>> 0;
2188
+ }
2189
+ return h.toString(36);
2100
2190
  }
2101
- function anchorSourceDuration(doc) {
2102
- return isRecordingDoc(doc) ? doc.source.meta.durationMs / 1e3 : programDuration(doc);
2191
+ function htmlLayerKey(clip, faces, bleed, assets = []) {
2192
+ 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
2193
+ // moment to this key per frame. The data rides here, the moment there.
2194
+ (clip.live ? " live " + JSON.stringify(clip.data ?? null) : "");
2195
+ return `html:${clip.id}:${fnv1a(src)}`;
2103
2196
  }
2104
-
2105
- // src/planner/smoothing.ts
2106
- var SNAP_BEFORE = 0.12;
2107
- var SNAP_AFTER = 0.18;
2108
- var SNAP_GAIN = 0.5;
2109
- function sampleAt(points, t) {
2110
- if (points.length === 0) return { x: 0, y: 0 };
2111
- if (t <= points[0].t) return { x: points[0].x, y: points[0].y };
2112
- const last = points[points.length - 1];
2113
- if (t >= last.t) return { x: last.x, y: last.y };
2114
- for (let i = 1; i < points.length; i++) {
2115
- if (points[i].t >= t) {
2116
- const a = points[i - 1];
2117
- const b = points[i];
2118
- const f = (t - a.t) / (b.t - a.t || 1);
2119
- return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
2197
+ function htmlLayerPayload(clip) {
2198
+ const faces = htmlLayerFaces(clip);
2199
+ const bleed = htmlLayerBleed(clip);
2200
+ const assets = htmlLayerAssets(clip).inlinable;
2201
+ return {
2202
+ key: htmlLayerKey(clip, faces, bleed, assets),
2203
+ width: htmlLayerWidth(clip),
2204
+ html: {
2205
+ markup: clip.html,
2206
+ css: clip.css ?? "",
2207
+ box: { width: clip.box.width, height: clip.box.height },
2208
+ bleed,
2209
+ faces,
2210
+ assets,
2211
+ ...clip.live ? { live: true, ...clip.data ? { data: clip.data } : {} } : {}
2120
2212
  }
2121
- }
2122
- return { x: last.x, y: last.y };
2213
+ };
2123
2214
  }
2124
- function smoothCursor(track, options = {}) {
2125
- const factor = clamp2(options.factor ?? 0.15, 0.01, 1);
2126
- const fps = options.fps ?? 60;
2127
- const raw = track.filter((e) => e.type === "move" || e.type === "down" || e.type === "up").map((e) => ({ t: e.t / 1e3, x: e.x, y: e.y }));
2128
- if (raw.length === 0) return [];
2129
- const clicks = options.clickSnap ? track.filter((e) => e.type === "down").map((e) => ({ t: e.t / 1e3, x: e.x, y: e.y })) : [];
2130
- const start = raw[0].t;
2131
- const end = raw[raw.length - 1].t;
2132
- const step = 1 / fps;
2133
- const out = [];
2134
- let cx = raw[0].x;
2135
- let cy = raw[0].y;
2136
- let ci = 0;
2137
- for (let t = start; t <= end + 1e-9; t += step) {
2138
- const target = sampleAt(raw, t);
2139
- cx += (target.x - cx) * factor;
2140
- cy += (target.y - cy) * factor;
2141
- if (clicks.length) {
2142
- while (ci < clicks.length - 1 && t > clicks[ci].t + SNAP_AFTER) ci++;
2143
- const ck = clicks[ci];
2144
- const u = clamp2(
2145
- (t - (ck.t - SNAP_BEFORE)) / SNAP_BEFORE,
2146
- 0,
2147
- t <= ck.t + SNAP_AFTER ? 1 : 0
2148
- );
2149
- const w = u * u * (3 - 2 * u) * SNAP_GAIN;
2150
- if (w > 0) {
2151
- cx += (ck.x - cx) * w;
2152
- cy += (ck.y - cy) * w;
2215
+
2216
+ // src/richText/layout.ts
2217
+ var sameStyle = (a, b) => (a.f || 0) === (b.f || 0) && a.c === b.c && a.u === b.u && a.s === b.s && a.h === b.h;
2218
+ function graphemesOf(text) {
2219
+ if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
2220
+ const seg = new Intl.Segmenter(void 0, { granularity: "grapheme" });
2221
+ const out = [];
2222
+ for (const s of seg.segment(text)) out.push(s.segment);
2223
+ return out;
2224
+ }
2225
+ return Array.from(text);
2226
+ }
2227
+ function tokenRanges(line) {
2228
+ const out = [];
2229
+ const re = /\S+\s*/g;
2230
+ let m;
2231
+ while ((m = re.exec(line)) !== null) {
2232
+ out.push([m.index, m.index + m[0].length]);
2233
+ }
2234
+ return out.length ? out : [[0, line.length]];
2235
+ }
2236
+ function layoutText(input, measure) {
2237
+ const memo = {};
2238
+ const adv = (t, f) => {
2239
+ if (!t) return 0;
2240
+ const key = f + "\0" + t;
2241
+ let w = memo[key];
2242
+ if (w === void 0) {
2243
+ w = measure(t, f);
2244
+ memo[key] = w;
2245
+ }
2246
+ return w;
2247
+ };
2248
+ const slice = (runs, a, b) => {
2249
+ const out = [];
2250
+ let at = 0;
2251
+ for (let i = 0; i < runs.length; i++) {
2252
+ const r = runs[i];
2253
+ const from = Math.max(a, at);
2254
+ const to = Math.min(b, at + r.t.length);
2255
+ if (to > from) {
2256
+ const t = r.t.slice(from - at, to - at);
2257
+ const last = out[out.length - 1];
2258
+ if (last && sameStyle(last, r)) last.t += t;
2259
+ else out.push({ ...r, t });
2153
2260
  }
2261
+ at += r.t.length;
2262
+ if (at >= b) break;
2154
2263
  }
2155
- out.push({ t, x: cx, y: cy });
2156
- }
2264
+ return out;
2265
+ };
2266
+ const widthOf = (runs) => {
2267
+ let w = 0;
2268
+ for (let i = 0; i < runs.length; i++) w += adv(runs[i].t, runs[i].f || 0);
2269
+ return w;
2270
+ };
2271
+ const partsIn = (frags, a, b) => {
2272
+ const out = [];
2273
+ for (let i = 0; i < frags.length; i++) {
2274
+ const fr = frags[i];
2275
+ const from = Math.max(a, fr.o);
2276
+ const to = Math.min(b, fr.o + fr.t.length);
2277
+ if (to <= from) continue;
2278
+ const f = fr.f || 0;
2279
+ const before = adv(fr.t.slice(0, from - fr.o), f);
2280
+ out.push({
2281
+ ...fr,
2282
+ t: fr.t.slice(from - fr.o, to - fr.o),
2283
+ x: fr.x + before,
2284
+ w: adv(fr.t.slice(0, to - fr.o), f) - before,
2285
+ o: from
2286
+ });
2287
+ }
2288
+ return out;
2289
+ };
2290
+ const unitOf = (frags, a, b) => {
2291
+ const parts = partsIn(frags, a, b);
2292
+ if (!parts.length) return { x: 0, w: 0, parts };
2293
+ const last = parts[parts.length - 1];
2294
+ return { x: parts[0].x, w: last.x + last.w - parts[0].x, parts };
2295
+ };
2296
+ const lines = [];
2297
+ const max = input.maxWidth && input.maxWidth > 0 ? input.maxWidth : 0;
2298
+ let width = 0;
2299
+ let base = 0;
2300
+ const push2 = (runs, a, b) => {
2301
+ const cut = slice(runs, a, b);
2302
+ const frags = [];
2303
+ let x = 0;
2304
+ let o = 0;
2305
+ for (let i = 0; i < cut.length; i++) {
2306
+ const w = adv(cut[i].t, cut[i].f || 0);
2307
+ frags.push({ ...cut[i], x, w, o });
2308
+ x += w;
2309
+ o += cut[i].t.length;
2310
+ }
2311
+ const line = { w: x, frags, a: base + a, n: b - a };
2312
+ if (input.unit) {
2313
+ const text = cut.map((r) => r.t).join("");
2314
+ const units = [];
2315
+ if (input.unit === "line") units.push(unitOf(frags, 0, text.length));
2316
+ else if (input.unit === "word") {
2317
+ const toks = tokenRanges(text);
2318
+ for (let i = 0; i < toks.length; i++) {
2319
+ units.push(unitOf(frags, toks[i][0], toks[i][1]));
2320
+ }
2321
+ } else {
2322
+ let at = 0;
2323
+ const gs = graphemesOf(text);
2324
+ for (let i = 0; i < gs.length; i++) {
2325
+ units.push(unitOf(frags, at, at + gs[i].length));
2326
+ at += gs[i].length;
2327
+ }
2328
+ }
2329
+ line.units = units;
2330
+ }
2331
+ if (x > width) width = x;
2332
+ lines.push(line);
2333
+ };
2334
+ for (let li = 0; li < input.lines.length; li++) {
2335
+ const runs = input.lines[li];
2336
+ let len = 0;
2337
+ for (let i = 0; i < runs.length; i++) len += runs[i].t.length;
2338
+ if (!max || !len || widthOf(slice(runs, 0, len)) <= max) {
2339
+ push2(runs, 0, len);
2340
+ } else {
2341
+ const text = runs.map((r) => r.t).join("");
2342
+ const toks = tokenRanges(text);
2343
+ let a = 0;
2344
+ let b = toks[0][1];
2345
+ for (let i = 1; i < toks.length; i++) {
2346
+ if (widthOf(slice(runs, a, toks[i][1])) <= max) b = toks[i][1];
2347
+ else {
2348
+ push2(runs, a, b);
2349
+ a = toks[i][0];
2350
+ b = toks[i][1];
2351
+ }
2352
+ }
2353
+ push2(runs, a, b);
2354
+ }
2355
+ base += len + 1;
2356
+ }
2357
+ if (!lines.length) lines.push({ w: 0, frags: [], a: 0, n: 0 });
2358
+ return { lines, width };
2359
+ }
2360
+ var UNDERLINE_OFFSET_EM = 0.42;
2361
+ var DECORATION_THICKNESS_EM = 0.065;
2362
+ var STRIKE_OFFSET_EM = 0.02;
2363
+ var HIGHLIGHT_HALF_EM = 0.58;
2364
+ function decorationRect(kind, x, w, y, px) {
2365
+ const x0 = Math.round(x);
2366
+ const x1 = Math.round(x + w);
2367
+ if (kind === "h") {
2368
+ const top2 = Math.round(y - HIGHLIGHT_HALF_EM * px);
2369
+ const bottom = Math.round(y + HIGHLIGHT_HALF_EM * px);
2370
+ return { x: x0, y: top2, w: x1 - x0, h: bottom - top2 };
2371
+ }
2372
+ const t = Math.max(1, Math.round(DECORATION_THICKNESS_EM * px));
2373
+ const top = Math.round(
2374
+ y + (kind === "u" ? UNDERLINE_OFFSET_EM : STRIKE_OFFSET_EM) * px
2375
+ );
2376
+ return { x: x0, y: top, w: x1 - x0, h: t };
2377
+ }
2378
+
2379
+ // src/richText/runs.ts
2380
+ var RUN_STYLE_KEYS = [
2381
+ "weight",
2382
+ "italic",
2383
+ "color",
2384
+ "underline",
2385
+ "strike",
2386
+ "highlight"
2387
+ ];
2388
+ var MIXED = "mixed";
2389
+ function plainText(text) {
2390
+ if (typeof text === "string") return text;
2391
+ let out = "";
2392
+ for (const r of text) out += r.text;
2157
2393
  return out;
2158
2394
  }
2159
- function clamp2(v, lo, hi) {
2160
- return Math.max(lo, Math.min(hi, v));
2395
+ function runsOf(text) {
2396
+ return typeof text === "string" ? text ? [{ text }] : [] : text;
2161
2397
  }
2162
-
2163
- // src/planner/autoZoom.ts
2164
- var DWELL_MOVE_FRAC = 0.02;
2165
- var DWELL_MIN = 0.45;
2166
- var DWELL_MAX = 2.6;
2167
- var DWELL_SPACING = 1.8;
2168
- var CLUSTER_FILL = 0.85;
2169
- var DRAG_FIT_LEVEL = 1.15;
2170
- var PRESS_ECHO_MS = 4;
2171
- var PRESS_ECHO_PX = 2;
2172
- var DRAG_MIN_TRAVEL_FRAC = 0.015;
2173
- var DRAG_MIN_S = 0.2;
2174
- function dragsFromTrack(track, width, height) {
2175
- if (!(width > 0) || !(height > 0)) return [];
2176
- const downs = dedupePresses(track.filter((e) => e.type === "down"));
2398
+ function styleOf(run) {
2399
+ const out = {};
2400
+ if (typeof run.weight === "number") out.weight = run.weight;
2401
+ if (typeof run.italic === "boolean") out.italic = run.italic;
2402
+ if (run.color) out.color = run.color;
2403
+ if (run.underline) out.underline = true;
2404
+ if (run.strike) out.strike = true;
2405
+ if (run.highlight) out.highlight = run.highlight;
2406
+ return out;
2407
+ }
2408
+ var sameStyle2 = (a, b) => RUN_STYLE_KEYS.every((k) => a[k] === b[k]);
2409
+ var isStyled = (s) => RUN_STYLE_KEYS.some((k) => s[k] !== void 0);
2410
+ function normalizeRuns(text) {
2411
+ if (typeof text === "string") return text;
2177
2412
  const out = [];
2178
- for (const d of downs) {
2179
- const up = track.find((e) => e.type === "up" && e.t > d.t);
2180
- if (!up) continue;
2181
- const t0 = d.t / 1e3;
2182
- const t1 = up.t / 1e3;
2183
- if (t1 - t0 < DRAG_MIN_S) continue;
2184
- let travel = 0;
2185
- for (const e of track) {
2186
- if (e.t < d.t) continue;
2187
- if (e.t > up.t) break;
2188
- if (e.type !== "move" && e.type !== "up") continue;
2189
- const dist = Math.hypot(e.x - d.x, e.y - d.y) / width;
2190
- if (dist > travel) travel = dist;
2191
- }
2192
- if (travel < DRAG_MIN_TRAVEL_FRAC) continue;
2193
- out.push({
2194
- t0,
2195
- t1,
2196
- nx: clamp012(d.x / width),
2197
- ny: clamp012(d.y / height),
2198
- travel
2199
- });
2200
- }
2413
+ for (const r of text) {
2414
+ if (typeof r.text !== "string" || !r.text) continue;
2415
+ const style = styleOf(r);
2416
+ const last = out[out.length - 1];
2417
+ if (last && sameStyle2(last, style)) last.text += r.text;
2418
+ else out.push({ text: r.text, ...style });
2419
+ }
2420
+ if (!out.length) return "";
2421
+ if (out.length === 1 && !isStyled(out[0])) return out[0].text;
2201
2422
  return out;
2202
2423
  }
2203
- var TYPING_MIN_PINGS = 2;
2204
- var TYPING_MIN_DUR = 0.5;
2205
- var TYPING_CLICK_ABSORB = 1.5;
2206
- var TYPING_REFOCUS_FRAC = 0.08;
2207
- function groupTrack(track, opts) {
2208
- const { width, height, clusterGap, typingGap, typingZoom, targetFill } = opts;
2209
- const minLevel = opts.minLevel ?? resolveZoomStyle().minLevel;
2210
- const clicks = dedupePresses(
2211
- track.filter((e) => e.type === "down")
2212
- ).map((e) => ({ t: e.t / 1e3, rect: e.rect, x: e.x, y: e.y }));
2213
- const sessions = typingZoom ? typingSessions(track, width, height, typingGap) : [];
2214
- const absorbed = /* @__PURE__ */ new Set();
2215
- for (const s of sessions) {
2216
- let best = null;
2217
- for (const c of clicks) {
2218
- if (absorbed.has(c)) continue;
2219
- if (c.t >= s.first || s.first - c.t > TYPING_CLICK_ABSORB) continue;
2220
- const [nx, ny] = fieldCenter(c, width, height);
2221
- if (Math.hypot(nx - s.cx, ny - s.cy) > TYPING_REFOCUS_FRAC) continue;
2222
- if (!best || c.t > best.t) best = c;
2223
- }
2224
- if (best) {
2225
- absorbed.add(best);
2226
- s.start = best.t;
2227
- s.events = [best, ...s.events];
2228
- }
2424
+ function snapOffset(plain, offset, dir) {
2425
+ const o = Math.max(0, Math.min(plain.length, offset));
2426
+ let at = 0;
2427
+ for (const g of graphemesOf(plain)) {
2428
+ const next = at + g.length;
2429
+ if (o === at) return at;
2430
+ if (o < next) return dir < 0 ? at : next;
2431
+ at = next;
2229
2432
  }
2230
- const targets = [];
2231
- const surfaces = [];
2232
- for (const c of clicks) {
2233
- if (absorbed.has(c)) continue;
2234
- if (isSurfacePress(c, width, height, targetFill)) surfaces.push(c);
2235
- else targets.push(c);
2433
+ return plain.length;
2434
+ }
2435
+ function sliceRuns(text, start, end) {
2436
+ const out = [];
2437
+ let at = 0;
2438
+ for (const r of runsOf(text)) {
2439
+ const from = Math.max(start, at);
2440
+ const to = Math.min(end, at + r.text.length);
2441
+ if (to > from) out.push({ ...r, text: r.text.slice(from - at, to - at) });
2442
+ at += r.text.length;
2443
+ if (at >= end) break;
2236
2444
  }
2237
- const chain = (list, spatial) => {
2238
- const clusters = [];
2239
- for (const c of list) {
2240
- const last = clusters.at(-1);
2241
- const prev = last?.at(-1);
2242
- const joins = last && prev && c.t - prev.t <= clusterGap && (!spatial || unionFitLevel(unionRect([...last, c]), width, height) >= minLevel);
2243
- if (joins) last.push(c);
2244
- else clusters.push([c]);
2445
+ return out;
2446
+ }
2447
+ function styleOfChar(text, offset) {
2448
+ let at = 0;
2449
+ for (const r of runsOf(text)) {
2450
+ if (offset < at + r.text.length) return styleOf(r);
2451
+ at += r.text.length;
2452
+ }
2453
+ return {};
2454
+ }
2455
+ function styleAt(text, offset) {
2456
+ const len = plainText(text).length;
2457
+ if (!len) return {};
2458
+ return styleOfChar(text, offset > 0 ? Math.min(offset, len) - 1 : 0);
2459
+ }
2460
+ function replaceRange(text, start, end, insert, style) {
2461
+ const len = plainText(text).length;
2462
+ const a = Math.max(0, Math.min(start, end, len));
2463
+ const b = Math.min(len, Math.max(start, end));
2464
+ const set = style ?? (b > a ? styleOfChar(text, a) : styleAt(text, a));
2465
+ return normalizeRuns([
2466
+ ...sliceRuns(text, 0, a),
2467
+ ...insert ? [{ text: insert, ...styleOf(set) }] : [],
2468
+ ...sliceRuns(text, b, len)
2469
+ ]);
2470
+ }
2471
+ function rangeOf(text, start, end) {
2472
+ const plain = plainText(text);
2473
+ const a = snapOffset(plain, Math.min(start, end), -1);
2474
+ const b = snapOffset(plain, Math.max(start, end), 1);
2475
+ return b > a ? { plain, a, b } : null;
2476
+ }
2477
+ function setStyle(text, start, end, patch) {
2478
+ const r = rangeOf(text, start, end);
2479
+ if (!r) return normalizeRuns(text);
2480
+ const restyle = (run) => {
2481
+ const next = { ...run };
2482
+ for (const k of RUN_STYLE_KEYS) {
2483
+ if (!(k in patch)) continue;
2484
+ if (patch[k] === void 0) delete next[k];
2485
+ else Object.assign(next, { [k]: patch[k] });
2245
2486
  }
2246
- return clusters;
2247
- };
2248
- return {
2249
- sessions,
2250
- clusters: chain(targets, true),
2251
- surfaces: chain(surfaces, false)
2487
+ return next;
2252
2488
  };
2489
+ return normalizeRuns([
2490
+ ...sliceRuns(text, 0, r.a),
2491
+ ...sliceRuns(text, r.a, r.b).map(restyle),
2492
+ ...sliceRuns(text, r.b, r.plain.length)
2493
+ ]);
2253
2494
  }
2254
- function dedupePresses(downs) {
2255
- const kept = [];
2256
- for (const e of downs) {
2257
- const prev = kept.at(-1);
2258
- if (prev && e.t - prev.t <= PRESS_ECHO_MS && Math.abs(e.x - prev.x) <= PRESS_ECHO_PX && Math.abs(e.y - prev.y) <= PRESS_ECHO_PX) {
2259
- continue;
2260
- }
2261
- kept.push(e);
2262
- }
2263
- return kept;
2495
+ function clearStyle(text, start, end) {
2496
+ const patch = {};
2497
+ for (const k of RUN_STYLE_KEYS) patch[k] = void 0;
2498
+ return setStyle(text, start, end, patch);
2264
2499
  }
2265
- function unionRect(clicks) {
2266
- let x0 = Infinity;
2267
- let y0 = Infinity;
2268
- let x1 = -Infinity;
2269
- let y1 = -Infinity;
2270
- for (const c of clicks) {
2271
- const r = c.rect ?? { x: c.x, y: c.y, w: 0, h: 0 };
2272
- x0 = Math.min(x0, r.x);
2273
- y0 = Math.min(y0, r.y);
2274
- x1 = Math.max(x1, r.x + r.w);
2275
- y1 = Math.max(y1, r.y + r.h);
2500
+ function commonStyle(text, start, end) {
2501
+ const r = rangeOf(text, start, end);
2502
+ const runs = r ? sliceRuns(text, r.a, r.b) : [];
2503
+ const out = {};
2504
+ if (!runs.length) return styleAt(text, start);
2505
+ for (const k of RUN_STYLE_KEYS) {
2506
+ const first = styleOf(runs[0])[k];
2507
+ const agree = runs.every((run) => styleOf(run)[k] === first);
2508
+ if (!agree) out[k] = MIXED;
2509
+ else if (first !== void 0) out[k] = first;
2276
2510
  }
2277
- return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
2278
- }
2279
- function unionFitLevel(rect, width, height) {
2280
- const fx = rect.w > 0 ? width * CLUSTER_FILL / rect.w : Infinity;
2281
- const fy = rect.h > 0 ? height * CLUSTER_FILL / rect.h : Infinity;
2282
- return Math.min(fx, fy);
2511
+ return out;
2283
2512
  }
2284
- function fitLevel(rect, width, height, targetFill) {
2285
- if (!rect || rect.w <= 0 || rect.h <= 0) return null;
2286
- return Math.min(width * targetFill / rect.w, height * targetFill / rect.h);
2513
+ function toggleStyle(text, start, end, key, value) {
2514
+ const on = commonStyle(text, start, end)[key] === value;
2515
+ return setStyle(text, start, end, { [key]: on ? void 0 : value });
2516
+ }
2517
+ function diffInput(prev, next, caretAfter) {
2518
+ const caret = Math.max(0, Math.min(next.length, caretAfter));
2519
+ const maxTail = Math.min(prev.length, next.length - caret);
2520
+ let tail = 0;
2521
+ while (tail < maxTail && prev[prev.length - 1 - tail] === next[next.length - 1 - tail])
2522
+ tail++;
2523
+ const maxHead = Math.min(prev.length - tail, next.length - tail);
2524
+ let head = 0;
2525
+ while (head < maxHead && prev[head] === next[head]) head++;
2526
+ return {
2527
+ start: head,
2528
+ end: prev.length - tail,
2529
+ insert: next.slice(head, next.length - tail)
2530
+ };
2287
2531
  }
2288
- function isSurfacePress(c, width, height, targetFill) {
2289
- const fit = fitLevel(c.rect, width, height, targetFill);
2290
- return fit !== null && fit < DRAG_FIT_LEVEL;
2532
+
2533
+ // src/overlayText.ts
2534
+ var OVERLAY_BOX_PAD_X = 0.6;
2535
+ var OVERLAY_BOX_PAD_Y = 0.35;
2536
+ var OVERLAY_BOX_RADIUS = 0.25;
2537
+ function resolveOverlayBox(clip) {
2538
+ if (!clip.box) return null;
2539
+ const size = resolveOverlayStyle(clip).size;
2540
+ return {
2541
+ color: clip.box.color,
2542
+ opacity: clip.box.opacity ?? 1,
2543
+ padX: (clip.box.paddingX ?? OVERLAY_BOX_PAD_X) * size,
2544
+ padY: (clip.box.paddingY ?? OVERLAY_BOX_PAD_Y) * size,
2545
+ radius: (clip.box.radius ?? OVERLAY_BOX_RADIUS) * size
2546
+ };
2291
2547
  }
2292
- function planAutoZoom(track, options) {
2293
- const style = resolveZoomStyle(options.style, options.params);
2294
- if (!style.autoZoom) return [];
2295
- const {
2296
- width,
2297
- height,
2298
- targetFill = style.targetFill,
2299
- minLevel = style.minLevel,
2300
- maxLevel = style.maxLevel,
2301
- clusterGap = style.clusterGap,
2302
- lead = style.lead,
2303
- hold = style.hold,
2304
- minClusterClicks = style.minClusterClicks,
2305
- dragLevel = style.dragLevel,
2306
- followByDefault = style.followByDefault,
2307
- typingZoom = style.typingZoom,
2308
- typingGap = style.typingGap,
2309
- typingHold = style.typingHold,
2310
- typingMinLevel = style.typingMinLevel,
2311
- duration
2312
- } = options;
2313
- const within = (spans2) => duration === void 0 ? spans2 : spans2.map((z) => ({ ...z, out: Math.min(z.out, round2(duration)) })).filter((z) => z.out - z.in >= 0.3);
2314
- const grouped = groupTrack(track, {
2315
- width,
2316
- height,
2317
- clusterGap,
2318
- typingGap,
2319
- typingZoom,
2320
- targetFill,
2321
- minLevel
2322
- });
2323
- const { sessions } = grouped;
2324
- const drags = dragsFromTrack(track, width, height);
2325
- const dragPress = new Set(drags.map((d) => round2(d.t0)));
2326
- const withoutDrags = (list) => list.map((cl) => cl.filter((c) => !dragPress.has(round2(c.t)))).filter((cl) => cl.length > 0);
2327
- const clusters = withoutDrags(grouped.clusters);
2328
- const surfaces = withoutDrags(grouped.surfaces);
2329
- const dragSpans = drags.map((d, i) => ({
2330
- id: `g${i}`,
2331
- in: round2(Math.max(0, d.t0 - lead)),
2332
- out: round2(d.t1 + hold),
2333
- level: clampZoomLevel(Math.min(Math.max(dragLevel, 1.1), maxLevel)),
2334
- cx: round2(d.nx),
2335
- cy: round2(d.ny),
2336
- focusMode: "auto",
2337
- source: "auto"
2338
- }));
2339
- const eligible = clusters.filter((c) => c.length >= minClusterClicks);
2340
- const clickSpans = [];
2341
- const dragReserved = surfaces.map((cluster, i) => {
2342
- const f = clusterFocus(cluster, width, height);
2343
- return {
2344
- id: `drag${i}`,
2345
- in: Math.max(0, cluster[0].t - lead),
2346
- out: cluster[cluster.length - 1].t + hold,
2347
- level: 1,
2348
- cx: f.cx,
2349
- cy: f.cy
2350
- };
2351
- });
2352
- for (const cluster of eligible) {
2353
- const first = cluster[0];
2354
- const last = cluster[cluster.length - 1];
2355
- const f = focusFor(cluster, width, height, targetFill, minLevel, maxLevel);
2356
- clickSpans.push({
2357
- in: Math.max(0, first.t - lead),
2358
- out: last.t + hold,
2359
- cx: f.cx,
2360
- cy: f.cy,
2361
- level: f.level
2362
- });
2548
+ var PRESET_FAMILY = {
2549
+ title: "Lexend",
2550
+ caption: "Lexend",
2551
+ label: "JetBrains Mono"
2552
+ };
2553
+ var TEXT_PRESETS = {
2554
+ title: {
2555
+ stack: "Lexend, -apple-system, system-ui, sans-serif",
2556
+ weight: 600,
2557
+ size: 64,
2558
+ color: "#fafafa",
2559
+ shadow: 0.45
2560
+ },
2561
+ caption: {
2562
+ stack: "Lexend, -apple-system, system-ui, sans-serif",
2563
+ weight: 400,
2564
+ size: 32,
2565
+ color: "#f4f4f5",
2566
+ shadow: 0.4
2567
+ },
2568
+ label: {
2569
+ stack: "'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
2570
+ weight: 400,
2571
+ size: 22,
2572
+ color: "#e4e4e7",
2573
+ shadow: 0.35
2363
2574
  }
2364
- clickSpans.sort((a, b) => a.in - b.in);
2365
- for (let i = 0; i + 1 < clickSpans.length; i++) {
2366
- if (clickSpans[i].out > clickSpans[i + 1].in)
2367
- clickSpans[i].out = clickSpans[i + 1].in;
2575
+ };
2576
+ var OVERLAY_SIZE_MIN = 12;
2577
+ var OVERLAY_SIZE_MAX = 200;
2578
+ var OVERLAY_FONT_FACES = [
2579
+ { family: "Lexend", weight: 400, url: fontFaceUrl2("lexend", 400) },
2580
+ { family: "Lexend", weight: 600, url: fontFaceUrl2("lexend", 600) },
2581
+ {
2582
+ family: "JetBrains Mono",
2583
+ weight: 400,
2584
+ url: fontFaceUrl2("jetbrains-mono", 400)
2368
2585
  }
2369
- for (const z of clickSpans) if (z.out - z.in < 0.3) z.dead = true;
2370
- const typingFloor = Math.min(Math.max(minLevel, typingMinLevel), maxLevel);
2371
- let typing = sessions.map((s) => {
2372
- const f = focusFor(
2373
- s.events,
2374
- width,
2375
- height,
2376
- targetFill,
2377
- typingFloor,
2378
- maxLevel
2379
- );
2380
- return {
2381
- in: Math.max(0, s.start - lead),
2382
- out: s.last + typingHold,
2383
- cx: f.cx,
2384
- cy: f.cy,
2385
- level: f.level
2386
- };
2387
- });
2388
- for (const t of typing) {
2389
- for (const z of clickSpans) {
2390
- if (z.dead || t.dead) continue;
2391
- if (t.in >= z.out || t.out <= z.in) continue;
2392
- if (Math.hypot(t.cx - z.cx, t.cy - z.cy) <= TYPING_REFOCUS_FRAC) {
2393
- t.in = Math.min(t.in, z.in);
2394
- t.out = Math.max(t.out, z.out);
2395
- z.dead = true;
2396
- } else if (z.in <= t.in && z.out >= t.out) {
2397
- t.dead = true;
2398
- } else if (z.in > t.in) {
2399
- t.out = z.in;
2400
- } else {
2401
- t.in = z.out;
2402
- }
2403
- }
2586
+ ];
2587
+ function resolveOverlayStyle(clip) {
2588
+ const presetName = clip.preset in TEXT_PRESETS ? clip.preset : "title";
2589
+ const base = TEXT_PRESETS[presetName];
2590
+ let stack = base.stack;
2591
+ let weight = base.weight;
2592
+ const familyEntry = findFontFamily2(clip.family ?? PRESET_FAMILY[presetName]);
2593
+ if (clip.family) {
2594
+ const quoted = clip.family.includes(" ") ? `'${clip.family}'` : clip.family;
2595
+ stack = familyEntry ? fontStack(familyEntry) : `${quoted}, ${base.stack}`;
2404
2596
  }
2405
- typing = typing.filter((t) => !t.dead).sort((a, b) => a.in - b.in);
2406
- for (let i = 0; i + 1 < typing.length; i++) {
2407
- if (typing[i].out > typing[i + 1].in) typing[i].out = typing[i + 1].in;
2597
+ if (clip.weight !== void 0 || clip.family) {
2598
+ const wanted = clip.weight ?? base.weight;
2599
+ weight = familyEntry ? nearestFontWeight2(familyEntry, wanted) : wanted;
2408
2600
  }
2409
- typing = typing.filter((t) => t.out - t.in >= 0.3);
2410
- const zSpans = clickSpans.filter((s) => !s.dead).sort((a, b) => a.in - b.in).map((s, i) => ({
2411
- id: `z${i}`,
2412
- in: round2(s.in),
2413
- out: round2(s.out),
2414
- level: clampZoomLevel(s.level),
2415
- cx: round2(s.cx),
2416
- cy: round2(s.cy),
2417
- // Follow styles ride the cursor through the span (entry + dead-zone
2418
- // recenters baked at lowering); typing/dwell spans stay fixed-focus.
2419
- ...followByDefault ? { focusMode: "auto" } : {},
2420
- source: "auto"
2421
- }));
2422
- const kSpans = typing.map((s, i) => ({
2423
- id: `k${i}`,
2424
- in: round2(s.in),
2425
- out: round2(s.out),
2426
- level: clampZoomLevel(s.level),
2427
- cx: round2(s.cx),
2428
- cy: round2(s.cy),
2429
- // Never focusMode:'auto': the dot is parked (and fading) while typing —
2430
- // the FIELD is the anchor, and a follow would be a no-op at best.
2431
- source: "auto"
2432
- }));
2433
- const spans = within([...zSpans, ...kSpans, ...dragSpans]).sort(
2434
- (a, b) => a.in - b.in
2601
+ return {
2602
+ ...base,
2603
+ stack,
2604
+ weight,
2605
+ ...clip.size !== void 0 ? {
2606
+ size: Math.min(
2607
+ OVERLAY_SIZE_MAX,
2608
+ Math.max(OVERLAY_SIZE_MIN, clip.size)
2609
+ )
2610
+ } : {},
2611
+ ...clip.color ? { color: clip.color } : {},
2612
+ ...clip.shadow !== void 0 ? { shadow: Math.min(1, Math.max(0, clip.shadow)) } : {},
2613
+ fontStyle: clip.italic ? "italic" : "normal",
2614
+ align: clip.align ?? "center",
2615
+ letterSpacing: clip.letterSpacing ?? 0,
2616
+ lineHeight: clip.lineHeight ?? OVERLAY_LINE_HEIGHT,
2617
+ stroke: clip.stroke ?? null
2618
+ };
2619
+ }
2620
+ function overlayFaceFor(clip) {
2621
+ const entry = findFontFamily2(
2622
+ clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
2435
2623
  );
2436
- const dwells = dwellSpans(track, width, height, maxLevel, [
2437
- ...spans,
2438
- ...dragReserved
2439
- ]);
2440
- return within([...spans, ...dwells]).sort((a, b) => a.in - b.in);
2624
+ if (!entry) return null;
2625
+ const weight = resolveOverlayStyle(clip).weight;
2626
+ const inBase = OVERLAY_FONT_FACES.some(
2627
+ (f) => f.family === entry.family && f.weight === weight
2628
+ );
2629
+ if (inBase) return null;
2630
+ return {
2631
+ family: entry.family,
2632
+ weight,
2633
+ url: fontFaceUrl2(entry.slug, weight)
2634
+ };
2441
2635
  }
2442
- function typingSessions(track, width, height, typingGap) {
2443
- const pings = track.filter((e) => e.type === "key").map((e) => ({ t: e.t / 1e3, rect: e.rect, x: e.x, y: e.y }));
2444
- const all = [];
2445
- let cur = null;
2446
- for (const p of pings) {
2447
- const [nx, ny] = fieldCenter(p, width, height);
2448
- if (cur && p.t - cur.last <= typingGap && Math.hypot(nx - cur.cx, ny - cur.cy) <= TYPING_REFOCUS_FRAC) {
2449
- cur.events.push(p);
2450
- cur.last = p.t;
2451
- } else {
2452
- cur = { events: [p], first: p.t, last: p.t, start: p.t, cx: nx, cy: ny };
2453
- all.push(cur);
2636
+ function overlayFontFaces(doc) {
2637
+ const faces = [...OVERLAY_FONT_FACES];
2638
+ const seen = new Set(faces.map((f) => `${f.family}|${f.weight}`));
2639
+ const add = (face) => {
2640
+ if (!face) return;
2641
+ const key = `${face.family}|${face.weight}`;
2642
+ if (seen.has(key)) return;
2643
+ seen.add(key);
2644
+ faces.push(face);
2645
+ };
2646
+ for (const o of doc.overlays ?? []) {
2647
+ if (o.kind !== "text") continue;
2648
+ add(overlayFaceFor(o));
2649
+ for (const f of styledTextOf(o)?.faces ?? []) {
2650
+ add({ family: f.f, weight: f.w, url: f.u });
2454
2651
  }
2455
2652
  }
2456
- return all.filter(
2457
- (s) => s.events.length >= TYPING_MIN_PINGS && s.last - s.first >= TYPING_MIN_DUR
2458
- );
2653
+ return faces;
2459
2654
  }
2460
- function fieldCenter(c, width, height) {
2461
- const px = c.rect ? c.rect.x + c.rect.w / 2 : c.x;
2462
- const py = c.rect ? c.rect.y + c.rect.h / 2 : c.y;
2463
- return [clamp012(px / width), clamp012(py / height)];
2655
+ function overlayLines(text) {
2656
+ const lines = text.split("\n");
2657
+ return lines.length ? lines : [""];
2464
2658
  }
2465
- function dwellSpans(track, width, height, maxLevel, reserved) {
2466
- const moves = track.filter((e) => e.type === "move").map((e) => ({ t: e.t / 1e3, nx: e.x / width, ny: e.y / height }));
2467
- if (moves.length < 2) return [];
2468
- const trackEnd = track[track.length - 1].t / 1e3;
2469
- const candidates = [];
2470
- let start = 0;
2471
- for (let i = 1; i <= moves.length; i++) {
2472
- const breaks = i === moves.length || Math.hypot(moves[i].nx - moves[i - 1].nx, moves[i].ny - moves[i - 1].ny) > DWELL_MOVE_FRAC;
2473
- if (!breaks) continue;
2474
- const endT = i < moves.length ? moves[i].t : trackEnd;
2475
- const dur = endT - moves[start].t;
2476
- const opening = start === 0;
2477
- if (!opening && dur >= DWELL_MIN && dur <= DWELL_MAX) {
2478
- const run = moves.slice(start, i);
2479
- candidates.push({
2480
- center: (moves[start].t + endT) / 2,
2481
- cx: run.reduce((s, p) => s + p.nx, 0) / run.length,
2482
- cy: run.reduce((s, p) => s + p.ny, 0) / run.length,
2483
- strength: dur
2484
- });
2485
- }
2486
- start = i;
2487
- }
2488
- const sorted = [...candidates].sort((a, b) => b.strength - a.strength);
2489
- const sourceDuration = trackEnd;
2490
- const len = Math.max(1, sourceDuration * 0.05);
2491
- const taken = [...reserved];
2492
- const accepted = [];
2493
- const centers = [];
2494
- for (const c of sorted) {
2495
- if (centers.some((t) => Math.abs(t - c.center) < DWELL_SPACING)) continue;
2496
- const spanIn = Math.max(
2497
- 0,
2498
- Math.min(c.center - len / 2, sourceDuration - len)
2499
- );
2500
- const spanOut = Math.min(sourceDuration, spanIn + len);
2501
- if (spanOut - spanIn < 0.3) continue;
2502
- if (taken.some((z) => spanIn < z.out && spanOut > z.in)) continue;
2503
- const span = {
2504
- id: `d${accepted.length}`,
2505
- in: round2(spanIn),
2506
- out: round2(spanOut),
2507
- // No element rect on moves → point zoom at the planner ceiling.
2508
- level: clampZoomLevel(maxLevel),
2509
- cx: round2(clamp012(c.cx)),
2510
- cy: round2(clamp012(c.cy)),
2511
- source: "auto"
2512
- };
2513
- accepted.push(span);
2514
- taken.push(span);
2515
- centers.push(c.center);
2659
+ function overlayPlainText(clip) {
2660
+ return plainText(clip.text);
2661
+ }
2662
+ function familyEntryOf(clip) {
2663
+ return findFontFamily2(
2664
+ clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
2665
+ ) ?? null;
2666
+ }
2667
+ function snapRunWeight(clip, weight) {
2668
+ const entry = familyEntryOf(clip);
2669
+ return entry ? nearestFontWeight2(entry, weight) : weight;
2670
+ }
2671
+ var BOLD_WEIGHT = 700;
2672
+ function boldWeightFor(clip) {
2673
+ return snapRunWeight(
2674
+ clip,
2675
+ Math.max(BOLD_WEIGHT, resolveOverlayStyle(clip).weight)
2676
+ );
2677
+ }
2678
+ function hashOf(text) {
2679
+ let h = 2166136261;
2680
+ for (let i = 0; i < text.length; i++) {
2681
+ h ^= text.charCodeAt(i);
2682
+ h = Math.imul(h, 16777619);
2516
2683
  }
2517
- return accepted.sort((a, b) => a.in - b.in).map((z, i) => ({ ...z, id: `d${i}` }));
2684
+ return (h >>> 0).toString(36);
2518
2685
  }
2519
- function focusFor(cluster, width, height, targetFill, minLevel, maxLevel) {
2520
- let sx = 0;
2521
- let sy = 0;
2522
- let maxW = 0;
2523
- let maxH = 0;
2524
- for (const c of cluster) {
2525
- if (c.rect) {
2526
- sx += c.rect.x + c.rect.w / 2;
2527
- sy += c.rect.y + c.rect.h / 2;
2528
- maxW = Math.max(maxW, c.rect.w);
2529
- maxH = Math.max(maxH, c.rect.h);
2530
- } else {
2531
- sx += c.x;
2532
- sy += c.y;
2686
+ function styledTextOf(clip) {
2687
+ const runs = normalizeRuns(clip.text);
2688
+ if (typeof runs === "string") return null;
2689
+ const style = resolveOverlayStyle(clip);
2690
+ const italic = style.fontStyle === "italic";
2691
+ const fs = [
2692
+ { w: style.weight, ...italic ? { i: 1 } : {} }
2693
+ ];
2694
+ const fontOf = (w, i) => {
2695
+ let at = fs.findIndex((f) => f.w === w && !!f.i === i);
2696
+ if (at < 0) {
2697
+ fs.push({ w, ...i ? { i: 1 } : {} });
2698
+ at = fs.length - 1;
2533
2699
  }
2700
+ return at;
2701
+ };
2702
+ const l = [[]];
2703
+ for (const run of runs) {
2704
+ const f = fontOf(
2705
+ run.weight !== void 0 ? snapRunWeight(clip, run.weight) : style.weight,
2706
+ run.italic ?? italic
2707
+ );
2708
+ const set = {
2709
+ ...f ? { f } : {},
2710
+ ...run.color ? { c: run.color } : {},
2711
+ ...run.underline ? { u: 1 } : {},
2712
+ ...run.strike ? { s: 1 } : {},
2713
+ ...run.highlight ? { h: run.highlight } : {}
2714
+ };
2715
+ run.text.split("\n").forEach((part, i) => {
2716
+ if (i > 0) l.push([]);
2717
+ if (part) l[l.length - 1].push({ t: part, ...set });
2718
+ });
2534
2719
  }
2535
- const n = cluster.length;
2536
- const cx = clamp012(sx / n / width);
2537
- const cy = clamp012(sy / n / height);
2538
- const own = fitLevel({ x: 0, y: 0, w: maxW, h: maxH }, width, height, targetFill) ?? maxLevel;
2539
- const together = unionFitLevel(unionRect(cluster), width, height);
2540
- return { cx, cy, level: clamp3(Math.min(own, together), minLevel, maxLevel) };
2720
+ const entry = familyEntryOf(clip);
2721
+ const faces = [];
2722
+ for (const font of fs.slice(1)) {
2723
+ if (!entry || font.w === style.weight) continue;
2724
+ if (faces.some((x) => x.w === font.w)) continue;
2725
+ const inBase = OVERLAY_FONT_FACES.some(
2726
+ (b) => b.family === entry.family && b.weight === font.w
2727
+ );
2728
+ if (!inBase) {
2729
+ faces.push({
2730
+ f: entry.family,
2731
+ w: font.w,
2732
+ u: fontFaceUrl2(entry.slug, font.w)
2733
+ });
2734
+ }
2735
+ }
2736
+ return {
2737
+ l,
2738
+ fs,
2739
+ ...faces.length ? { faces } : {},
2740
+ h: hashOf(JSON.stringify([l, fs]))
2741
+ };
2541
2742
  }
2542
- function clusterFocus(events, width, height) {
2543
- let sx = 0;
2544
- let sy = 0;
2545
- let x0 = Infinity;
2546
- let y0 = Infinity;
2547
- let x1 = -Infinity;
2548
- let y1 = -Infinity;
2549
- let rects = 0;
2550
- for (const c of events) {
2551
- if (c.rect) {
2552
- sx += c.rect.x + c.rect.w / 2;
2553
- sy += c.rect.y + c.rect.h / 2;
2554
- x0 = Math.min(x0, c.rect.x);
2555
- y0 = Math.min(y0, c.rect.y);
2556
- x1 = Math.max(x1, c.rect.x + c.rect.w);
2557
- y1 = Math.max(y1, c.rect.y + c.rect.h);
2558
- rects++;
2559
- } else {
2560
- sx += c.x;
2561
- sy += c.y;
2743
+ function overlayTokens(line) {
2744
+ return line.match(/\S+\s*/g) ?? [line];
2745
+ }
2746
+ function wrapOverlayLines(lines, measure, maxPx) {
2747
+ if (!(maxPx > 0)) return lines;
2748
+ const out = [];
2749
+ for (const line of lines) {
2750
+ if (!line || measure(line) <= maxPx) {
2751
+ out.push(line);
2752
+ continue;
2753
+ }
2754
+ let current = "";
2755
+ for (const token of overlayTokens(line)) {
2756
+ if (!current) {
2757
+ current = token;
2758
+ continue;
2759
+ }
2760
+ if (measure(current + token) <= maxPx) {
2761
+ current += token;
2762
+ } else {
2763
+ out.push(current);
2764
+ current = token;
2765
+ }
2562
2766
  }
2767
+ if (current) out.push(current);
2563
2768
  }
2564
- const n = Math.max(1, events.length);
2565
- const rect = rects > 0 ? {
2566
- x: clamp012(x0 / width),
2567
- y: clamp012(y0 / height),
2568
- w: clamp012((x1 - x0) / width),
2569
- h: clamp012((y1 - y0) / height)
2570
- } : null;
2571
- return { cx: clamp012(sx / n / width), cy: clamp012(sy / n / height), rect };
2769
+ return out.length ? out : [""];
2572
2770
  }
2573
- function clamp3(v, lo, hi) {
2574
- return Math.max(lo, Math.min(hi, v));
2771
+ function graphemesOf2(line) {
2772
+ if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
2773
+ return [
2774
+ ...new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(
2775
+ line
2776
+ )
2777
+ ].map((s) => s.segment);
2778
+ }
2779
+ return [...line];
2575
2780
  }
2576
- function clamp012(v) {
2577
- return clamp3(v, 0, 1);
2781
+ function overlaySegments(text, unit) {
2782
+ const lines = overlayLines(text);
2783
+ if (unit === "block") return [];
2784
+ if (unit === "line") return lines.map((l) => [l]);
2785
+ if (unit === "word") return lines.map(overlayTokens);
2786
+ return lines.map(graphemesOf2);
2578
2787
  }
2579
- function round2(v) {
2580
- return Math.round(v * 1e3) / 1e3;
2788
+ var FX_DIR = { forward: 0, reverse: 1, center: 2 };
2789
+ function resolveOverlayFx(clip, clipDuration) {
2790
+ const spec = overlayFxSpec(clip) ?? clip.fx ?? null;
2791
+ if (!spec) return null;
2792
+ const unit = spec.unit ?? "block";
2793
+ const text = overlayPlainText(clip);
2794
+ const units = overlaySegments(text, unit);
2795
+ const n = unit === "block" ? 1 : units.reduce((sum, line) => sum + line.length, 0);
2796
+ const upperN = unit === "line" && clip.maxWidth ? overlayLines(text).reduce(
2797
+ (sum, line) => sum + overlayTokens(line).length,
2798
+ 0
2799
+ ) : n;
2800
+ const typewriter = spec.fx === "typewriter";
2801
+ const dur = typewriter ? 1e-3 : Math.min(2, Math.max(0.05, spec.duration ?? OVERLAY_TRANSITION_DUR));
2802
+ const defaultStagger = typewriter ? 0.05 : unit === "block" ? 0 : 0.06;
2803
+ let st = Math.min(2, Math.max(0, spec.stagger ?? defaultStagger));
2804
+ if (upperN > 1) {
2805
+ const maxTotal = Math.max(dur, clipDuration * 0.9);
2806
+ st = Math.min(st, Math.max(0, (maxTotal - dur) / (upperN - 1)));
2807
+ }
2808
+ const round14 = (v) => Math.round(v * 1e4) / 1e4;
2809
+ return {
2810
+ k: spec.fx,
2811
+ u: unit,
2812
+ d: FX_DIR[spec.direction ?? "forward"] ?? 0,
2813
+ st: round14(st),
2814
+ dur: round14(dur),
2815
+ tt: round14(st * (upperN - 1) + dur),
2816
+ units,
2817
+ n
2818
+ };
2581
2819
  }
2582
-
2583
- // src/lower/lowerToComposition.ts
2584
- import {
2585
- EASINGS,
2586
- lerpArray as lerpArray2,
2587
- sample as sample2,
2588
- segmentRate as segmentRate2,
2589
- sortKeyframes as sortKeyframes2,
2590
- sourceToTimeline,
2591
- splitBySpeed as splitBySpeed3,
2592
- totalDuration as totalDuration3
2593
- } from "@vosjs/timeline";
2594
- import { timelineRuntimeCode as timelineRuntimeCode2 } from "@vosjs/timeline/bundle";
2595
-
2596
- // src/layout.ts
2597
- function computeCardLayout(frame, video, W, H, zoomFocus) {
2598
- const s = H / 1080;
2599
- const pad = (frame.padding || 0) * s;
2600
- const ins = frame.inset ?? {};
2601
- const padL = ins.left == null ? pad : ins.left * W;
2602
- const padR = ins.right == null ? pad : ins.right * W;
2603
- const padT = ins.top == null ? pad : ins.top * H;
2604
- const padB = ins.bottom == null ? pad : ins.bottom * H;
2605
- const bar = frame.browserBar;
2606
- const vw = video.width || 16;
2607
- const vh = video.height || 9;
2608
- const fitCover = frame.fit === "cover";
2609
- const cf = fitCover ? 1 : Math.min(1, W / H / (vw / vh));
2610
- const barH = bar.kind !== "none" ? (bar.height || 44) * s * cf : 0;
2611
- const availW = Math.max(1, W - padL - padR);
2612
- const availH = Math.max(1, H - padT - padB - barH);
2613
- if (fitCover) {
2614
- const sc2 = Math.max(availW / vw, availH / vh);
2615
- const dw2 = vw * sc2;
2616
- const dh2 = vh * sc2;
2617
- const follow = frame.focusFollow === "camera" && zoomFocus ? zoomFocus : null;
2618
- const fcx = clamp013(follow ? follow.cx : frame.focus?.cx ?? 0.5);
2619
- const fcy = clamp013(follow ? follow.cy : frame.focus?.cy ?? 0.5);
2620
- const vTop = padT + barH;
2621
- const dx2 = Math.min(
2622
- padL,
2623
- Math.max(padL + availW - dw2, padL + availW / 2 - fcx * dw2)
2820
+ function overlayFontString(style, scale, s) {
2821
+ const italic = style.fontStyle === "italic" ? "italic " : "";
2822
+ return `${italic}${style.weight} ${style.size * scale * s}px ${style.stack}`;
2823
+ }
2824
+ function overlayRect(clip, measure, frameW, frameH = 1080, mediaAspect) {
2825
+ const scale = clip.transform.scale || 1;
2826
+ const base = {
2827
+ cx: clip.transform.x * frameW,
2828
+ cy: clip.transform.y * frameH,
2829
+ rotation: clip.transform.rotation || 0
2830
+ };
2831
+ if (clip.kind === "html") {
2832
+ const pb = htmlLayerPictureBox(clip);
2833
+ const w2 = htmlLayerWidth(clip) * frameW * scale;
2834
+ return { ...base, w: w2, h: w2 * (pb.height / pb.width) };
2835
+ }
2836
+ if (clip.kind !== "text") {
2837
+ const w2 = (clip.width ?? OVERLAY_MEDIA_DEFAULT_WIDTH) * frameW * scale;
2838
+ return { ...base, w: w2, h: w2 / (mediaAspect || 16 / 9) };
2839
+ }
2840
+ const style = resolveOverlayStyle(clip);
2841
+ const font = overlayFontString(style, scale, 1);
2842
+ const ls = style.letterSpacing * scale;
2843
+ const styled = styledTextOf(clip);
2844
+ let w = 0;
2845
+ let lineCount = 0;
2846
+ if (styled) {
2847
+ const fonts = styled.fs.map(
2848
+ (f) => overlayFontString(
2849
+ { ...style, weight: f.w, fontStyle: f.i ? "italic" : "normal" },
2850
+ scale,
2851
+ 1
2852
+ )
2624
2853
  );
2625
- const dy2 = Math.min(
2626
- vTop,
2627
- Math.max(vTop + availH - dh2, vTop + availH / 2 - fcy * dh2)
2854
+ const laid = layoutText(
2855
+ { lines: styled.l, maxWidth: clip.maxWidth ? clip.maxWidth * frameW : 0 },
2856
+ (t, f) => measure(t, fonts[f], ls)
2628
2857
  );
2629
- return {
2630
- W,
2631
- H,
2632
- dx: dx2,
2633
- dy: dy2,
2634
- dw: dw2,
2635
- dh: dh2,
2636
- cardX: padL,
2637
- cardY: padT,
2638
- cardW: availW,
2639
- cardH: availH + barH
2640
- };
2858
+ w = laid.width;
2859
+ lineCount = laid.lines.length;
2860
+ } else {
2861
+ const width = (t) => measure(t, font, ls);
2862
+ const lines = clip.maxWidth ? wrapOverlayLines(
2863
+ overlayLines(overlayPlainText(clip)),
2864
+ width,
2865
+ clip.maxWidth * frameW
2866
+ ) : overlayLines(overlayPlainText(clip));
2867
+ for (const line of lines) w = Math.max(w, width(line));
2868
+ lineCount = lines.length;
2641
2869
  }
2642
- const sc = Math.min(availW / vw, availH / vh);
2643
- const dw = vw * sc;
2644
- const dh = vh * sc;
2645
- const dx = padL + (availW - dw) / 2;
2646
- const dy = padT + barH + (availH - dh) / 2;
2870
+ const lineH = style.size * scale * style.lineHeight;
2871
+ const box = resolveOverlayBox(clip);
2872
+ const padX = box ? box.padX * scale : 0;
2873
+ const padY = box ? box.padY * scale : 0;
2647
2874
  return {
2648
- W,
2649
- H,
2650
- dx,
2651
- dy,
2652
- dw,
2653
- dh,
2654
- cardX: dx,
2655
- cardY: dy - barH,
2656
- cardW: dw,
2657
- cardH: dh + barH
2875
+ ...base,
2876
+ w: Math.max(w, style.size * scale * 0.6) + padX * 2,
2877
+ // empty text still selectable
2878
+ h: lineCount * lineH + padY * 2
2658
2879
  };
2659
2880
  }
2660
- function docCardLayout(doc, media) {
2661
- const meta = doc.source.meta;
2662
- const H = 1080;
2663
- const W = Math.max(
2664
- 2,
2665
- Math.round(H * aspectRatioValue(doc.frame.aspectRatio, meta))
2666
- );
2667
- const src = mediaSource(doc, media) ?? doc.source;
2668
- const sm = src.meta;
2669
- return computeCardLayout(
2670
- mediaFrame(doc, media),
2671
- {
2672
- width: sm.captureWidth ?? sm.width,
2673
- height: sm.captureHeight ?? sm.height
2674
- },
2675
- W,
2676
- H
2677
- );
2881
+ function overlayHit(rect, px, py, padPx = 8) {
2882
+ let dx = px - rect.cx;
2883
+ let dy = py - rect.cy;
2884
+ if (rect.rotation) {
2885
+ const a = -rect.rotation * Math.PI / 180;
2886
+ const rx = dx * Math.cos(a) - dy * Math.sin(a);
2887
+ const ry = dx * Math.sin(a) + dy * Math.cos(a);
2888
+ dx = rx;
2889
+ dy = ry;
2890
+ }
2891
+ return Math.abs(dx) <= rect.w / 2 + padPx && Math.abs(dy) <= rect.h / 2 + padPx;
2678
2892
  }
2679
- function recommendedExportResolution(doc) {
2680
- const meta = doc.source.meta;
2681
- const capW = meta.captureWidth ?? meta.width;
2682
- const video = { width: capW, height: meta.captureHeight ?? meta.height };
2683
- let best = EXPORT_RESOLUTION_OPTIONS[0];
2684
- for (const r of EXPORT_RESOLUTION_OPTIONS) {
2685
- const { width, height } = resolveExportSize(doc, r);
2686
- if (computeCardLayout(doc.frame, video, width, height).dw > capW) break;
2687
- best = r;
2893
+
2894
+ // src/richText/migrateEmphasis.ts
2895
+ function parseLine(line) {
2896
+ const chars = [];
2897
+ const marker = [];
2898
+ for (let i = 0; i < line.length; i++) {
2899
+ if (line[i] === "\\" && line[i + 1] === "*") {
2900
+ chars.push("*");
2901
+ marker.push(false);
2902
+ i++;
2903
+ } else {
2904
+ chars.push(line[i]);
2905
+ marker.push(line[i] === "*");
2906
+ }
2907
+ }
2908
+ const keep = chars.map(() => true);
2909
+ const bold = [];
2910
+ for (let i = 0; i < chars.length; i++) {
2911
+ if (!marker[i]) continue;
2912
+ let j = i + 1;
2913
+ while (j < chars.length && !marker[j]) j++;
2914
+ if (j >= chars.length) break;
2915
+ if (!chars.slice(i + 1, j).join("").trim()) {
2916
+ i = j - 1;
2917
+ continue;
2918
+ }
2919
+ keep[i] = false;
2920
+ keep[j] = false;
2921
+ for (let k = i + 1; k < j; k++) bold[k] = true;
2922
+ i = j;
2688
2923
  }
2689
- return best;
2924
+ const out = [];
2925
+ for (let i = 0; i < chars.length; i++) {
2926
+ if (keep[i]) out.push({ c: chars[i], b: !!bold[i] && !/\s/.test(chars[i]) });
2927
+ }
2928
+ return out;
2690
2929
  }
2691
- function camBubbleRect(cam, W, H = 1080) {
2692
- const s = H / 1080;
2693
- const size = Math.max(40, (cam.size || 0.25) * H);
2694
- const mg = 24 * s;
2695
- const x = cam.x != null ? cam.x * W - size / 2 : cam.position.includes("right") ? W - mg - size : mg;
2696
- const y = cam.y != null ? cam.y * H - size / 2 : cam.position.includes("top") ? mg : H - mg - size;
2930
+ function marksEmphasis(text) {
2931
+ return text.split("\n").some((line) => parseLine(line).some((x) => x.b));
2932
+ }
2933
+ function migrateTextClip(clip) {
2934
+ const legacy = clip;
2935
+ if (!("emphasis" in legacy)) return clip;
2936
+ const { emphasis, ...rest } = legacy;
2937
+ const next = rest;
2938
+ if (!emphasis || typeof legacy.text !== "string") return next;
2939
+ const weight = emphasis.weight !== void 0 ? snapRunWeight(next, emphasis.weight) : boldWeightFor(next);
2940
+ const runs = [];
2941
+ legacy.text.split("\n").forEach((line, li) => {
2942
+ if (li > 0) runs.push({ text: "\n" });
2943
+ for (const { c, b } of parseLine(line)) {
2944
+ runs.push(
2945
+ b ? {
2946
+ text: c,
2947
+ weight,
2948
+ ...emphasis.color ? { color: emphasis.color } : {}
2949
+ } : { text: c }
2950
+ );
2951
+ }
2952
+ });
2953
+ return { ...next, text: normalizeRuns(runs) };
2954
+ }
2955
+ function migrateText(doc) {
2956
+ const overlays = Array.isArray(doc.overlays) ? doc.overlays : [];
2957
+ const legacy = overlays.some((o) => o.kind === "text" && "emphasis" in o);
2958
+ if (!legacy) return doc;
2697
2959
  return {
2698
- x,
2699
- y,
2700
- size,
2701
- radius: cam.shape === "rounded" ? (cam.radius ?? 18) * s : size / 2
2960
+ ...doc,
2961
+ overlays: overlays.map((o) => o.kind === "text" ? migrateTextClip(o) : o)
2702
2962
  };
2703
2963
  }
2704
- function focusBounds(level, layout, camera = "card", screen) {
2705
- if (camera === "stage" && screen && level > 1.001) {
2706
- return { minX: 0, maxX: 1, minY: 0, maxY: 1 };
2707
- }
2708
- if (level <= 1.001) {
2709
- return { minX: 0.5, maxX: 0.5, minY: 0.5, maxY: 0.5 };
2710
- }
2711
- if (camera === "stage") {
2712
- const x2 = stageAxisBounds(
2713
- layout.dx,
2714
- layout.dw,
2715
- layout.cardX,
2716
- layout.cardW,
2717
- layout.W,
2718
- level
2719
- );
2720
- const y2 = stageAxisBounds(
2721
- layout.dy,
2722
- layout.dh,
2723
- layout.cardY,
2724
- layout.cardH,
2725
- layout.H,
2726
- level
2727
- );
2728
- return { minX: x2.min, maxX: x2.max, minY: y2.min, maxY: y2.max };
2964
+
2965
+ // src/zoomStyle.ts
2966
+ var RETIRED_ZOOM_STYLES = {
2967
+ keynote: { style: "glide", tilt: "medium" },
2968
+ drift: { style: "cinema", tilt: "subtle" }
2969
+ };
2970
+ function liveZoomStyleName(name) {
2971
+ if (!name) return null;
2972
+ if (name in ZOOM_STYLES) return name;
2973
+ return RETIRED_ZOOM_STYLES[name]?.style ?? null;
2974
+ }
2975
+ var HOLD_DRIFT_MAX = 0.15;
2976
+ var PUMP_REST = 1;
2977
+ function pumpFreeChainGap(p) {
2978
+ return p.rampOut + (p.rampIn - p.rampInOverlap) + PUMP_REST;
2979
+ }
2980
+ var glide = {
2981
+ autoZoom: true,
2982
+ clusterGap: 3,
2983
+ minClusterClicks: 1,
2984
+ targetFill: 0.42,
2985
+ minLevel: 1.3,
2986
+ maxLevel: 1.8,
2987
+ dragLevel: 1.5,
2988
+ lead: 0.35,
2989
+ hold: 1.2,
2990
+ followByDefault: true,
2991
+ typingZoom: true,
2992
+ typingGap: 2.5,
2993
+ typingHold: 1.1,
2994
+ typingMinLevel: 1.4,
2995
+ rampIn: 0.6,
2996
+ rampInOverlap: 0.2,
2997
+ rampOut: 0.5,
2998
+ chainGap: 3,
2999
+ pan: 0.55,
3000
+ // The reference's corner trace at 60 fps: a quarter of the move by a
3001
+ // fifth of the time, two thirds by half, nine tenths by three
3002
+ // quarters, over ~0.6 s. Fitted against those three points,
3003
+ // (0.36, 0, 0.4, 1) is the closest cubic (15 / 64 / 93 %; half the
3004
+ // move at ~245 ms and 90 % at ~425 ms at 0.6 s): a gentle start and a
3005
+ // long settle. CSS ease (0.25, 0.1, 0.25, 1) reached the midpoint
3006
+ // 70 ms earlier and read a touch fast; (0.16, 1, 0.3, 1) put half the
3007
+ // move in the first sixty milliseconds and read as a twitch.
3008
+ ease: "css-bezier(0.36, 0, 0.4, 1)",
3009
+ panEase: "css-bezier(0.36, 0, 0.4, 1)",
3010
+ followSafeRatio: 0.45,
3011
+ followRecenter: 0.55,
3012
+ followLookahead: 0.4,
3013
+ tilt: {
3014
+ intensity: "off",
3015
+ rampIn: 0.6,
3016
+ rampOut: 0.5,
3017
+ chainGap: 3,
3018
+ pan: 0.55
2729
3019
  }
2730
- const x = axisBounds(
2731
- layout.dx,
2732
- layout.dw,
2733
- layout.cardX,
2734
- layout.cardW,
2735
- layout.W,
2736
- level
2737
- );
2738
- const y = axisBounds(
2739
- layout.dy,
2740
- layout.dh,
2741
- layout.cardY,
2742
- layout.cardH,
2743
- layout.H,
2744
- level
2745
- );
2746
- return { minX: x.min, maxX: x.max, minY: y.min, maxY: y.max };
3020
+ };
3021
+ var ZOOM_STYLES = {
3022
+ glide,
3023
+ // Screen Studio "Focused" / Recordly — a block per click, framed and
3024
+ // released, no travel. The spring-settled family ease (near-linear onset,
3025
+ // long settle) at the default's tempo: 0.8 s in, 0.65 s out, and a beat
3026
+ // of 1.5 s after the click so the block is read before it goes. At its
3027
+ // 2026-07 timing (1.4 s in, 1.0 s out around a 1.35 s span) a lone click
3028
+ // spent 74% of its beat moving and every dwell or sped-up click was
3029
+ // shorter than the ramp, which the lowering compressed into a cut.
3030
+ focus: {
3031
+ autoZoom: true,
3032
+ clusterGap: 1.5,
3033
+ minClusterClicks: 1,
3034
+ targetFill: 0.5,
3035
+ minLevel: 1.4,
3036
+ maxLevel: 2,
3037
+ dragLevel: 1.6,
3038
+ lead: 0.35,
3039
+ hold: 1.5,
3040
+ followByDefault: false,
3041
+ typingZoom: true,
3042
+ typingGap: 2,
3043
+ typingHold: 1.3,
3044
+ typingMinLevel: 1.5,
3045
+ rampIn: 0.8,
3046
+ rampInOverlap: 0.25,
3047
+ rampOut: 0.65,
3048
+ chainGap: 2.4,
3049
+ pan: 0.7,
3050
+ ease: "css-bezier(0.28, 0.03, 0.09, 1)",
3051
+ panEase: "css-bezier(0.26, 0.08, 0.2, 1)",
3052
+ followSafeRatio: 0.5,
3053
+ followRecenter: 0.55,
3054
+ followLookahead: 0,
3055
+ tilt: {
3056
+ intensity: "off",
3057
+ rampIn: 0.8,
3058
+ rampOut: 0.65,
3059
+ chainGap: 2.4,
3060
+ pan: 0.7
3061
+ }
3062
+ },
3063
+ // Screen Studio "Smooth" — the long-HOLD style: 1.2 s in, 1.0 s out,
3064
+ // two-second holds, chains across 3.5 s so a slow clip travels rather
3065
+ // than pumps, and the default's level range, because a watched clip
3066
+ // wants the frame, not the magnifier. Its ramps still exceed a dwell or
3067
+ // a sped-up click; the lowering fits them to the room.
3068
+ cinema: {
3069
+ autoZoom: true,
3070
+ clusterGap: 2.4,
3071
+ minClusterClicks: 1,
3072
+ targetFill: 0.42,
3073
+ minLevel: 1.3,
3074
+ maxLevel: 1.8,
3075
+ dragLevel: 1.5,
3076
+ lead: 0.6,
3077
+ hold: 2,
3078
+ followByDefault: true,
3079
+ typingZoom: true,
3080
+ typingGap: 3,
3081
+ typingHold: 1.8,
3082
+ typingMinLevel: 1.4,
3083
+ rampIn: 1.2,
3084
+ rampInOverlap: 0.4,
3085
+ rampOut: 1,
3086
+ chainGap: 3.5,
3087
+ pan: 1.1,
3088
+ ease: "css-bezier(0.33, 0, 0.15, 1)",
3089
+ panEase: "css-bezier(0.33, 0, 0.22, 1)",
3090
+ followSafeRatio: 0.6,
3091
+ followRecenter: 0.9,
3092
+ followLookahead: 0.5,
3093
+ tilt: {
3094
+ intensity: "off",
3095
+ rampIn: 1.2,
3096
+ rampOut: 1,
3097
+ chainGap: 3.5,
3098
+ pan: 1.1
3099
+ }
3100
+ },
3101
+ // The studio's original pace with the instant-velocity onset defect fixed:
3102
+ // 0.4 s cycles on the onset-capped ease, the 2.5× ceiling of the 2026-07
3103
+ // comparison's defect two brought to 2.2, a chain gap past its pump band.
3104
+ snappy: {
3105
+ autoZoom: true,
3106
+ clusterGap: 1.2,
3107
+ minClusterClicks: 1,
3108
+ targetFill: 0.5,
3109
+ minLevel: 1.4,
3110
+ maxLevel: 2.2,
3111
+ dragLevel: 1.6,
3112
+ lead: 0.25,
3113
+ hold: 1.2,
3114
+ followByDefault: false,
3115
+ typingZoom: true,
3116
+ typingGap: 1.6,
3117
+ typingHold: 0.9,
3118
+ typingMinLevel: 1.5,
3119
+ rampIn: 0.4,
3120
+ rampInOverlap: 0.15,
3121
+ rampOut: 0.4,
3122
+ chainGap: 2,
3123
+ pan: 0.5,
3124
+ ease: "css-bezier(0.3, 0.55, 0.2, 1)",
3125
+ panEase: "css-bezier(0.25, 0.1, 0.25, 1)",
3126
+ followSafeRatio: 0.5,
3127
+ followRecenter: 0.45,
3128
+ followLookahead: 0,
3129
+ tilt: {
3130
+ intensity: "off",
3131
+ rampIn: 0.4,
3132
+ rampOut: 0.4,
3133
+ chainGap: 2,
3134
+ pan: 0.5
3135
+ }
3136
+ },
3137
+ // Screen Studio's "instant zoom" — a cut-in (eight frames at 60 fps),
3138
+ // tutorial tempo. Untouched in motion (the one style that never pumped
3139
+ // and never left a ramp unsettled); the ceiling comes down with the
3140
+ // others and the hold gains the beat a cut-in needs to be read.
3141
+ cut: {
3142
+ autoZoom: true,
3143
+ clusterGap: 1.2,
3144
+ minClusterClicks: 1,
3145
+ targetFill: 0.5,
3146
+ minLevel: 1.4,
3147
+ maxLevel: 2,
3148
+ dragLevel: 1.6,
3149
+ lead: 0.2,
3150
+ hold: 1.2,
3151
+ followByDefault: false,
3152
+ typingZoom: true,
3153
+ typingGap: 1.2,
3154
+ typingHold: 0.6,
3155
+ typingMinLevel: 1.5,
3156
+ rampIn: 0.14,
3157
+ rampInOverlap: 0.07,
3158
+ rampOut: 0.14,
3159
+ chainGap: 1.3,
3160
+ pan: 0.35,
3161
+ ease: "css-bezier(0.2, 0, 0.4, 1)",
3162
+ panEase: "css-bezier(0.2, 0, 0.4, 1)",
3163
+ followSafeRatio: 0.5,
3164
+ followRecenter: 0.35,
3165
+ followLookahead: 0,
3166
+ tilt: {
3167
+ intensity: "off",
3168
+ rampIn: 0.3,
3169
+ rampOut: 0.3,
3170
+ chainGap: 1.3,
3171
+ pan: 0.35
3172
+ }
3173
+ },
3174
+ // Auto-zoom off (every competitor ships this switch). Camera params still
3175
+ // apply to MANUAL spans — they move the way the default camera moves
3176
+ // TODAY (spelled as a spread, never a copy that fossilises).
3177
+ none: { ...glide, autoZoom: false }
3178
+ };
3179
+ var DEFAULT_ZOOM_STYLE = "glide";
3180
+ function resolveZoomStyle(name, overrides) {
3181
+ const live = liveZoomStyleName(name) ?? DEFAULT_ZOOM_STYLE;
3182
+ const params = { ...ZOOM_STYLES[live], ...overrides };
3183
+ const floor = pumpFreeChainGap(params);
3184
+ if (params.chainGap < floor) params.chainGap = floor;
3185
+ return params;
2747
3186
  }
2748
- function axisBounds(anchorOff, anchorSize, coverOff, coverSize, viewport, level) {
2749
- const k = 1 - 1 / level;
2750
- let lo = (coverOff / k - anchorOff) / anchorSize;
2751
- let hi = ((coverOff + coverSize - viewport / level) / k - anchorOff) / anchorSize;
2752
- if (lo > hi) {
2753
- const mid = (lo + hi) / 2;
2754
- lo = mid;
2755
- hi = mid;
3187
+ var ZOOM_STYLE_OPTIONS = [
3188
+ {
3189
+ name: "glide",
3190
+ label: "Glide",
3191
+ hint: "One steady zoom that travels between clicks"
3192
+ },
3193
+ {
3194
+ name: "focus",
3195
+ label: "Focus",
3196
+ hint: "A zoom per click, framed and released"
3197
+ },
3198
+ {
3199
+ name: "cinema",
3200
+ label: "Cinema",
3201
+ hint: "Slow moves and long holds, for content that is watched"
3202
+ },
3203
+ { name: "snappy", label: "Snappy", hint: "Quick, energetic zoom cycles" },
3204
+ { name: "cut", label: "Cut", hint: "Instant zooms, no glide" },
3205
+ {
3206
+ name: "none",
3207
+ label: "None",
3208
+ hint: "No automatic zooms, add your own on the timeline"
2756
3209
  }
2757
- return { min: clamp013(lo), max: clamp013(hi) };
2758
- }
2759
- function stageAxisBounds(anchorOff, anchorSize, coverOff, coverSize, viewport, level) {
2760
- const b = CAMERA_COVER_MIN / 2 * viewport;
2761
- let lo = (coverOff + b / level - anchorOff) / anchorSize;
2762
- let hi = (coverOff + coverSize - b / level - anchorOff) / anchorSize;
2763
- if (lo > hi) {
2764
- const mid = (lo + hi) / 2;
2765
- lo = mid;
2766
- hi = mid;
3210
+ ];
3211
+
3212
+ // src/docVersion.ts
3213
+ var DOC_SCHEMA_VERSION = 6;
3214
+ function migrateHostedDoc(raw) {
3215
+ const version = typeof raw.docSchemaVersion === "number" ? raw.docSchemaVersion : 0;
3216
+ const read = migrateText(raw);
3217
+ if (version >= DOC_SCHEMA_VERSION) return read;
3218
+ let doc = read;
3219
+ if (version < 4 && doc.source && typeof doc.source === "object") {
3220
+ doc = migrateMotion(doc);
2767
3221
  }
2768
- return { min: clamp013(lo), max: clamp013(hi) };
3222
+ if (version < 5) doc = migrateZoomStyle(doc);
3223
+ return { ...doc, docSchemaVersion: DOC_SCHEMA_VERSION };
2769
3224
  }
2770
- function clampFocus(cx, cy, level, layout, camera = "card", screen) {
2771
- const b = focusBounds(level, layout, camera, screen);
3225
+ function migrateZoomStyle(doc) {
3226
+ const name = doc.zoomStyle;
3227
+ if (typeof name !== "string") return doc;
3228
+ const retired = RETIRED_ZOOM_STYLES[name];
3229
+ if (!retired) return doc;
2772
3230
  return {
2773
- cx: Math.min(b.maxX, Math.max(b.minX, cx)),
2774
- cy: Math.min(b.maxY, Math.max(b.minY, cy))
3231
+ ...doc,
3232
+ zoomStyle: retired.style,
3233
+ tiltStyle: doc.tiltStyle ?? retired.tilt
2775
3234
  };
2776
3235
  }
2777
- function clamp013(v) {
2778
- return Math.max(0, Math.min(1, v));
3236
+
3237
+ // src/doc/studioDoc.ts
3238
+ var anchorKindOf = (doc) => "source" in doc ? "recording" : "program";
3239
+ var isRecordingDoc = (doc) => "source" in doc;
3240
+ var isProgramDoc = (doc) => !("source" in doc);
3241
+ function programDuration(doc) {
3242
+ const own = doc.program.duration;
3243
+ if (typeof own === "number" && own > 0) return own;
3244
+ const cfg = doc.program.config.duration;
3245
+ return typeof cfg === "number" && cfg > 0 ? cfg : 0;
2779
3246
  }
2780
- function screenPoint(layout, camera, screen) {
2781
- return camera === "stage" && screen ? { ox: screen.x * layout.W, oy: screen.y * layout.H } : { ox: layout.W / 2, oy: layout.H / 2 };
3247
+ function anchorSourceDuration(doc) {
3248
+ return isRecordingDoc(doc) ? doc.source.meta.durationMs / 1e3 : programDuration(doc);
2782
3249
  }
2783
- function cameraModel(frame) {
2784
- return frame?.camera === "stage" ? "stage" : "card";
3250
+
3251
+ // src/planner/smoothing.ts
3252
+ var SNAP_BEFORE = 0.12;
3253
+ var SNAP_AFTER = 0.18;
3254
+ var SNAP_GAIN = 0.5;
3255
+ function sampleAt(points, t) {
3256
+ if (points.length === 0) return { x: 0, y: 0 };
3257
+ if (t <= points[0].t) return { x: points[0].x, y: points[0].y };
3258
+ const last = points[points.length - 1];
3259
+ if (t >= last.t) return { x: last.x, y: last.y };
3260
+ for (let i = 1; i < points.length; i++) {
3261
+ if (points[i].t >= t) {
3262
+ const a = points[i - 1];
3263
+ const b = points[i];
3264
+ const f = (t - a.t) / (b.t - a.t || 1);
3265
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
3266
+ }
3267
+ }
3268
+ return { x: last.x, y: last.y };
2785
3269
  }
2786
- var CAMERA_CENTRE_RAMP = 0.3;
2787
- var CAMERA_COVER_MIN = 0.8;
2788
- function cameraCentring(level) {
2789
- const u = clamp013((level - 1) / CAMERA_CENTRE_RAMP);
2790
- return u * u * (3 - 2 * u);
3270
+ function smoothCursor(track, options = {}) {
3271
+ const factor = clamp2(options.factor ?? 0.15, 0.01, 1);
3272
+ const fps = options.fps ?? 60;
3273
+ const raw = track.filter((e) => e.type === "move" || e.type === "down" || e.type === "up").map((e) => ({ t: e.t / 1e3, x: e.x, y: e.y }));
3274
+ if (raw.length === 0) return [];
3275
+ const clicks = options.clickSnap ? track.filter((e) => e.type === "down").map((e) => ({ t: e.t / 1e3, x: e.x, y: e.y })) : [];
3276
+ const start = raw[0].t;
3277
+ const end = raw[raw.length - 1].t;
3278
+ const step = 1 / fps;
3279
+ const out = [];
3280
+ let cx = raw[0].x;
3281
+ let cy = raw[0].y;
3282
+ let ci = 0;
3283
+ for (let t = start; t <= end + 1e-9; t += step) {
3284
+ const target = sampleAt(raw, t);
3285
+ cx += (target.x - cx) * factor;
3286
+ cy += (target.y - cy) * factor;
3287
+ if (clicks.length) {
3288
+ while (ci < clicks.length - 1 && t > clicks[ci].t + SNAP_AFTER) ci++;
3289
+ const ck = clicks[ci];
3290
+ const u = clamp2(
3291
+ (t - (ck.t - SNAP_BEFORE)) / SNAP_BEFORE,
3292
+ 0,
3293
+ t <= ck.t + SNAP_AFTER ? 1 : 0
3294
+ );
3295
+ const w = u * u * (3 - 2 * u) * SNAP_GAIN;
3296
+ if (w > 0) {
3297
+ cx += (ck.x - cx) * w;
3298
+ cy += (ck.y - cy) * w;
3299
+ }
3300
+ }
3301
+ out.push({ t, x: cx, y: cy });
3302
+ }
3303
+ return out;
2791
3304
  }
2792
- function zoomView(level, cx, cy, layout, camera = "card", centring, screen) {
2793
- const L = Math.max(1, level);
2794
- const fx = layout.dx + cx * layout.dw;
2795
- const fy = layout.dy + cy * layout.dh;
2796
- const { ox, oy } = screenPoint(layout, camera, screen);
2797
- const t = camera === "stage" ? clamp013(centring ?? 1) : 0;
2798
- const wcx = fx + (ox - fx) / L * (1 - t);
2799
- const wcy = fy + (oy - fy) / L * (1 - t);
2800
- return { level: L, fx, fy, wcx, wcy, ox, oy };
3305
+ function clamp2(v, lo, hi) {
3306
+ return Math.max(lo, Math.min(hi, v));
2801
3307
  }
2802
- function zoomViewport(level, cx, cy, layout, camera = "card", centring, screen) {
2803
- const v = zoomView(level, cx, cy, layout, camera, centring, screen);
2804
- const size = 1 / v.level;
2805
- const mx = v.wcx + (layout.W / 2 - v.ox) / v.level;
2806
- const my = v.wcy + (layout.H / 2 - v.oy) / v.level;
3308
+
3309
+ // src/planner/autoZoom.ts
3310
+ var DWELL_MOVE_FRAC = 0.02;
3311
+ var DWELL_MIN = 0.45;
3312
+ var DWELL_MAX = 2.6;
3313
+ var DWELL_SPACING = 1.8;
3314
+ var CLUSTER_FILL = 0.85;
3315
+ var DRAG_FIT_LEVEL = 1.15;
3316
+ var PRESS_ECHO_MS = 4;
3317
+ var PRESS_ECHO_PX = 2;
3318
+ var DRAG_MIN_TRAVEL_FRAC = 0.015;
3319
+ var DRAG_MIN_S = 0.2;
3320
+ function dragsFromTrack(track, width, height) {
3321
+ if (!(width > 0) || !(height > 0)) return [];
3322
+ const downs = dedupePresses(track.filter((e) => e.type === "down"));
3323
+ const out = [];
3324
+ for (const d of downs) {
3325
+ const up = track.find((e) => e.type === "up" && e.t > d.t);
3326
+ if (!up) continue;
3327
+ const t0 = d.t / 1e3;
3328
+ const t1 = up.t / 1e3;
3329
+ if (t1 - t0 < DRAG_MIN_S) continue;
3330
+ let travel = 0;
3331
+ for (const e of track) {
3332
+ if (e.t < d.t) continue;
3333
+ if (e.t > up.t) break;
3334
+ if (e.type !== "move" && e.type !== "up") continue;
3335
+ const dist = Math.hypot(e.x - d.x, e.y - d.y) / width;
3336
+ if (dist > travel) travel = dist;
3337
+ }
3338
+ if (travel < DRAG_MIN_TRAVEL_FRAC) continue;
3339
+ out.push({
3340
+ t0,
3341
+ t1,
3342
+ nx: clamp012(d.x / width),
3343
+ ny: clamp012(d.y / height),
3344
+ travel
3345
+ });
3346
+ }
3347
+ return out;
3348
+ }
3349
+ var TYPING_MIN_PINGS = 2;
3350
+ var TYPING_MIN_DUR = 0.5;
3351
+ var TYPING_CLICK_ABSORB = 1.5;
3352
+ var TYPING_REFOCUS_FRAC = 0.08;
3353
+ function groupTrack(track, opts) {
3354
+ const { width, height, clusterGap, typingGap, typingZoom, targetFill } = opts;
3355
+ const minLevel = opts.minLevel ?? resolveZoomStyle().minLevel;
3356
+ const clicks = dedupePresses(
3357
+ track.filter((e) => e.type === "down")
3358
+ ).map((e) => ({ t: e.t / 1e3, rect: e.rect, x: e.x, y: e.y }));
3359
+ const sessions = typingZoom ? typingSessions(track, width, height, typingGap) : [];
3360
+ const absorbed = /* @__PURE__ */ new Set();
3361
+ for (const s of sessions) {
3362
+ let best = null;
3363
+ for (const c of clicks) {
3364
+ if (absorbed.has(c)) continue;
3365
+ if (c.t >= s.first || s.first - c.t > TYPING_CLICK_ABSORB) continue;
3366
+ const [nx, ny] = fieldCenter(c, width, height);
3367
+ if (Math.hypot(nx - s.cx, ny - s.cy) > TYPING_REFOCUS_FRAC) continue;
3368
+ if (!best || c.t > best.t) best = c;
3369
+ }
3370
+ if (best) {
3371
+ absorbed.add(best);
3372
+ s.start = best.t;
3373
+ s.events = [best, ...s.events];
3374
+ }
3375
+ }
3376
+ const targets = [];
3377
+ const surfaces = [];
3378
+ for (const c of clicks) {
3379
+ if (absorbed.has(c)) continue;
3380
+ if (isSurfacePress(c, width, height, targetFill)) surfaces.push(c);
3381
+ else targets.push(c);
3382
+ }
3383
+ const chain = (list, spatial) => {
3384
+ const clusters = [];
3385
+ for (const c of list) {
3386
+ const last = clusters.at(-1);
3387
+ const prev = last?.at(-1);
3388
+ const joins = last && prev && c.t - prev.t <= clusterGap && (!spatial || unionFitLevel(unionRect([...last, c]), width, height) >= minLevel);
3389
+ if (joins) last.push(c);
3390
+ else clusters.push([c]);
3391
+ }
3392
+ return clusters;
3393
+ };
2807
3394
  return {
2808
- x: (mx - layout.W / (2 * v.level)) / layout.W,
2809
- y: (my - layout.H / (2 * v.level)) / layout.H,
2810
- w: size,
2811
- h: size
3395
+ sessions,
3396
+ clusters: chain(targets, true),
3397
+ surfaces: chain(surfaces, false)
2812
3398
  };
2813
3399
  }
2814
- function focusForViewportCentre(cX, cY, level, layout, camera = "card", centring, screen) {
2815
- const L = Math.max(1, level);
2816
- const t = camera === "stage" ? clamp013(centring ?? 1) : 0;
2817
- const a = 1 - (1 - t) / L;
2818
- if (a <= 1e-6) return { cx: 0.5, cy: 0.5 };
2819
- const { ox, oy } = screenPoint(layout, camera, screen);
2820
- const fx = (cX * layout.W - ox * (1 - t) / L - (layout.W / 2 - ox) / L) / a;
2821
- const fy = (cY * layout.H - oy * (1 - t) / L - (layout.H / 2 - oy) / L) / a;
2822
- return { cx: (fx - layout.dx) / layout.dw, cy: (fy - layout.dy) / layout.dh };
3400
+ function dedupePresses(downs) {
3401
+ const kept = [];
3402
+ for (const e of downs) {
3403
+ const prev = kept.at(-1);
3404
+ if (prev && e.t - prev.t <= PRESS_ECHO_MS && Math.abs(e.x - prev.x) <= PRESS_ECHO_PX && Math.abs(e.y - prev.y) <= PRESS_ECHO_PX) {
3405
+ continue;
3406
+ }
3407
+ kept.push(e);
3408
+ }
3409
+ return kept;
2823
3410
  }
2824
- function levelForFocusFraction(frac) {
2825
- if (!(frac > 0)) return clampZoomLevel(Number.POSITIVE_INFINITY);
2826
- return clampZoomLevel(Math.max(1.1, 1 / frac));
3411
+ function unionRect(clicks) {
3412
+ let x0 = Infinity;
3413
+ let y0 = Infinity;
3414
+ let x1 = -Infinity;
3415
+ let y1 = -Infinity;
3416
+ for (const c of clicks) {
3417
+ const r = c.rect ?? { x: c.x, y: c.y, w: 0, h: 0 };
3418
+ x0 = Math.min(x0, r.x);
3419
+ y0 = Math.min(y0, r.y);
3420
+ x1 = Math.max(x1, r.x + r.w);
3421
+ y1 = Math.max(y1, r.y + r.h);
3422
+ }
3423
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
2827
3424
  }
2828
-
2829
- // src/stage.ts
2830
- var CARD_FOV = 30;
2831
- var OVERLAY_Z = -2;
2832
- var CARD_Z = -4;
2833
- var BACKGROUND_Z = -6;
2834
- var CARD_OVERSCAN_STEP = 0.05;
2835
- var CARD_OVERSCAN_MAX = 4;
2836
- var CAMERA_NEAR = 0.1;
2837
- var CAMERA_FAR = 100;
2838
- function planeSizeAtDepth(distance, fovDeg, aspect) {
2839
- const height = 2 * Math.abs(distance) * Math.tan(fovDeg * Math.PI / 180 / 2);
2840
- return { width: height * aspect, height };
3425
+ function unionFitLevel(rect, width, height) {
3426
+ const fx = rect.w > 0 ? width * CLUSTER_FILL / rect.w : Infinity;
3427
+ const fy = rect.h > 0 ? height * CLUSTER_FILL / rect.h : Infinity;
3428
+ return Math.min(fx, fy);
2841
3429
  }
2842
- function cardPointToScreen(u, v) {
2843
- return { sx: u, sy: v };
3430
+ function fitLevel(rect, width, height, targetFill) {
3431
+ if (!rect || rect.w <= 0 || rect.h <= 0) return null;
3432
+ return Math.min(width * targetFill / rect.w, height * targetFill / rect.h);
2844
3433
  }
2845
-
2846
- // src/overlayText.ts
2847
- import {
2848
- findFontFamily as findFontFamily2,
2849
- fontFaceUrl as fontFaceUrl2,
2850
- fontStack,
2851
- nearestFontWeight as nearestFontWeight2
2852
- } from "@vosjs/shared";
2853
-
2854
- // src/htmlLayer.ts
2855
- import { findFontFamily, fontFaceUrl, nearestFontWeight } from "@vosjs/shared";
2856
- var DESIGN_FRAME_WIDTH = 1920;
2857
- var HTML_LAYER_WARN_BYTES = 32e3;
2858
- var HTML_LAYER_MAX_BYTES = 128e3;
2859
- var HTML_LAYER_LIVE_HZ = 60;
2860
- var HTML_LAYER_ASSET_HOST = "https://assets.vos.so/";
2861
- function splitTopLevel(value) {
2862
- const out = [];
2863
- let depth = 0;
2864
- let cur = "";
2865
- for (const ch of value) {
2866
- if (ch === "(") depth++;
2867
- else if (ch === ")") depth--;
2868
- if (ch === "," && depth === 0) {
2869
- out.push(cur);
2870
- cur = "";
2871
- } else cur += ch;
2872
- }
2873
- if (cur.trim()) out.push(cur);
2874
- return out;
3434
+ function isSurfacePress(c, width, height, targetFill) {
3435
+ const fit = fitLevel(c.rect, width, height, targetFill);
3436
+ return fit !== null && fit < DRAG_FIT_LEVEL;
2875
3437
  }
2876
- var unquote = (s) => s.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim();
2877
- var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
2878
- "serif",
2879
- "sans-serif",
2880
- "monospace",
2881
- "cursive",
2882
- "fantasy",
2883
- "system-ui",
2884
- "ui-sans-serif",
2885
- "ui-serif",
2886
- "ui-monospace",
2887
- "ui-rounded",
2888
- "emoji",
2889
- "math",
2890
- "fangsong",
2891
- "inherit",
2892
- "initial",
2893
- "unset"
2894
- ]);
2895
- function cssFamilies(css) {
2896
- if (!css) return [];
2897
- const out = [];
2898
- const seen = /* @__PURE__ */ new Set();
2899
- for (const decl of css.match(/font-family\s*:[^;}]+/gi) ?? []) {
2900
- for (const item of splitTopLevel(decl.replace(/^[^:]*:/, ""))) {
2901
- const name = unquote(item.replace(/!important/i, ""));
2902
- if (!name) continue;
2903
- const lower = name.toLowerCase();
2904
- if (GENERIC_FAMILIES.has(lower) || lower.startsWith("-apple-")) continue;
2905
- if (seen.has(lower)) continue;
2906
- seen.add(lower);
2907
- out.push(name);
3438
+ function planAutoZoom(track, options) {
3439
+ const style = resolveZoomStyle(options.style, options.params);
3440
+ if (!style.autoZoom) return [];
3441
+ const {
3442
+ width,
3443
+ height,
3444
+ targetFill = style.targetFill,
3445
+ minLevel = style.minLevel,
3446
+ maxLevel = style.maxLevel,
3447
+ clusterGap = style.clusterGap,
3448
+ lead = style.lead,
3449
+ hold = style.hold,
3450
+ minClusterClicks = style.minClusterClicks,
3451
+ dragLevel = style.dragLevel,
3452
+ followByDefault = style.followByDefault,
3453
+ typingZoom = style.typingZoom,
3454
+ typingGap = style.typingGap,
3455
+ typingHold = style.typingHold,
3456
+ typingMinLevel = style.typingMinLevel,
3457
+ duration
3458
+ } = options;
3459
+ const within = (spans2) => duration === void 0 ? spans2 : spans2.map((z) => ({ ...z, out: Math.min(z.out, round2(duration)) })).filter((z) => z.out - z.in >= 0.3);
3460
+ const grouped = groupTrack(track, {
3461
+ width,
3462
+ height,
3463
+ clusterGap,
3464
+ typingGap,
3465
+ typingZoom,
3466
+ targetFill,
3467
+ minLevel
3468
+ });
3469
+ const { sessions } = grouped;
3470
+ const drags = dragsFromTrack(track, width, height);
3471
+ const dragPress = new Set(drags.map((d) => round2(d.t0)));
3472
+ const withoutDrags = (list) => list.map((cl) => cl.filter((c) => !dragPress.has(round2(c.t)))).filter((cl) => cl.length > 0);
3473
+ const clusters = withoutDrags(grouped.clusters);
3474
+ const surfaces = withoutDrags(grouped.surfaces);
3475
+ const dragSpans = drags.map((d, i) => ({
3476
+ id: `g${i}`,
3477
+ in: round2(Math.max(0, d.t0 - lead)),
3478
+ out: round2(d.t1 + hold),
3479
+ level: clampZoomLevel(Math.min(Math.max(dragLevel, 1.1), maxLevel)),
3480
+ cx: round2(d.nx),
3481
+ cy: round2(d.ny),
3482
+ focusMode: "auto",
3483
+ source: "auto"
3484
+ }));
3485
+ const eligible = clusters.filter((c) => c.length >= minClusterClicks);
3486
+ const clickSpans = [];
3487
+ const dragReserved = surfaces.map((cluster, i) => {
3488
+ const f = clusterFocus(cluster, width, height);
3489
+ return {
3490
+ id: `drag${i}`,
3491
+ in: Math.max(0, cluster[0].t - lead),
3492
+ out: cluster[cluster.length - 1].t + hold,
3493
+ level: 1,
3494
+ cx: f.cx,
3495
+ cy: f.cy
3496
+ };
3497
+ });
3498
+ for (const cluster of eligible) {
3499
+ const first = cluster[0];
3500
+ const last = cluster[cluster.length - 1];
3501
+ const f = focusFor(cluster, width, height, targetFill, minLevel, maxLevel);
3502
+ clickSpans.push({
3503
+ in: Math.max(0, first.t - lead),
3504
+ out: last.t + hold,
3505
+ cx: f.cx,
3506
+ cy: f.cy,
3507
+ level: f.level
3508
+ });
3509
+ }
3510
+ clickSpans.sort((a, b) => a.in - b.in);
3511
+ for (let i = 0; i + 1 < clickSpans.length; i++) {
3512
+ if (clickSpans[i].out > clickSpans[i + 1].in)
3513
+ clickSpans[i].out = clickSpans[i + 1].in;
3514
+ }
3515
+ for (const z of clickSpans) if (z.out - z.in < 0.3) z.dead = true;
3516
+ const typingFloor = Math.min(Math.max(minLevel, typingMinLevel), maxLevel);
3517
+ let typing = sessions.map((s) => {
3518
+ const f = focusFor(
3519
+ s.events,
3520
+ width,
3521
+ height,
3522
+ targetFill,
3523
+ typingFloor,
3524
+ maxLevel
3525
+ );
3526
+ return {
3527
+ in: Math.max(0, s.start - lead),
3528
+ out: s.last + typingHold,
3529
+ cx: f.cx,
3530
+ cy: f.cy,
3531
+ level: f.level
3532
+ };
3533
+ });
3534
+ for (const t of typing) {
3535
+ for (const z of clickSpans) {
3536
+ if (z.dead || t.dead) continue;
3537
+ if (t.in >= z.out || t.out <= z.in) continue;
3538
+ if (Math.hypot(t.cx - z.cx, t.cy - z.cy) <= TYPING_REFOCUS_FRAC) {
3539
+ t.in = Math.min(t.in, z.in);
3540
+ t.out = Math.max(t.out, z.out);
3541
+ z.dead = true;
3542
+ } else if (z.in <= t.in && z.out >= t.out) {
3543
+ t.dead = true;
3544
+ } else if (z.in > t.in) {
3545
+ t.out = z.in;
3546
+ } else {
3547
+ t.in = z.out;
3548
+ }
2908
3549
  }
2909
3550
  }
2910
- return out;
2911
- }
2912
- function cssWeights(css, html = "") {
2913
- const out = /* @__PURE__ */ new Set([400]);
2914
- for (const decl of css?.match(/font-weight\s*:[^;}]+/gi) ?? []) {
2915
- const value = decl.replace(/^[^:]*:/, "").toLowerCase();
2916
- for (const n of value.match(/\b[1-9]00\b/g) ?? []) out.add(Number(n));
2917
- if (/\bbold(er)?\b/.test(value)) out.add(700);
2918
- }
2919
- if (/<(b|strong|h[1-6])[\s>]/i.test(html)) out.add(700);
2920
- return [...out].sort((a, b) => a - b);
2921
- }
2922
- function htmlLayerFaces(clip) {
2923
- const faces = [];
2924
- const seen = /* @__PURE__ */ new Set();
2925
- const add = (f) => {
2926
- const key = `${f.family.toLowerCase()}|${f.weight}|${f.style}`;
2927
- if (seen.has(key)) return;
2928
- seen.add(key);
2929
- faces.push(f);
2930
- };
2931
- const weights = cssWeights(clip.css, clip.html);
2932
- for (const family of cssFamilies(clip.css)) {
2933
- const entry = findFontFamily(family);
2934
- if (!entry) continue;
2935
- for (const w of weights)
2936
- add({
2937
- family: entry.family,
2938
- url: fontFaceUrl(entry.slug, nearestFontWeight(entry, w)),
2939
- weight: nearestFontWeight(entry, w),
2940
- style: "normal"
2941
- });
3551
+ typing = typing.filter((t) => !t.dead).sort((a, b) => a.in - b.in);
3552
+ for (let i = 0; i + 1 < typing.length; i++) {
3553
+ if (typing[i].out > typing[i + 1].in) typing[i].out = typing[i + 1].in;
2942
3554
  }
2943
- for (const f of clip.fonts ?? [])
2944
- add({
2945
- family: f.family,
2946
- url: f.url,
2947
- weight: f.weight ?? 400,
2948
- style: f.style ?? "normal"
2949
- });
2950
- return faces;
2951
- }
2952
- function htmlLayerUnhostedFamilies(clip) {
2953
- const explicit = new Set(
2954
- (clip.fonts ?? []).map((f) => f.family.toLowerCase())
2955
- );
2956
- return cssFamilies(clip.css).filter(
2957
- (f) => !findFontFamily(f) && !explicit.has(f.toLowerCase())
3555
+ typing = typing.filter((t) => t.out - t.in >= 0.3);
3556
+ const zSpans = clickSpans.filter((s) => !s.dead).sort((a, b) => a.in - b.in).map((s, i) => ({
3557
+ id: `z${i}`,
3558
+ in: round2(s.in),
3559
+ out: round2(s.out),
3560
+ level: clampZoomLevel(s.level),
3561
+ cx: round2(s.cx),
3562
+ cy: round2(s.cy),
3563
+ // Follow styles ride the cursor through the span (entry + dead-zone
3564
+ // recenters baked at lowering); typing/dwell spans stay fixed-focus.
3565
+ ...followByDefault ? { focusMode: "auto" } : {},
3566
+ source: "auto"
3567
+ }));
3568
+ const kSpans = typing.map((s, i) => ({
3569
+ id: `k${i}`,
3570
+ in: round2(s.in),
3571
+ out: round2(s.out),
3572
+ level: clampZoomLevel(s.level),
3573
+ cx: round2(s.cx),
3574
+ cy: round2(s.cy),
3575
+ // Never focusMode:'auto': the dot is parked (and fading) while typing —
3576
+ // the FIELD is the anchor, and a follow would be a no-op at best.
3577
+ source: "auto"
3578
+ }));
3579
+ const spans = within([...zSpans, ...kSpans, ...dragSpans]).sort(
3580
+ (a, b) => a.in - b.in
2958
3581
  );
3582
+ const dwells = dwellSpans(track, width, height, maxLevel, [
3583
+ ...spans,
3584
+ ...dragReserved
3585
+ ]);
3586
+ return within([...spans, ...dwells]).sort((a, b) => a.in - b.in);
2959
3587
  }
2960
- function htmlLayerAssetUrls(clip) {
2961
- const out = [];
2962
- const seen = /* @__PURE__ */ new Set();
2963
- const add = (u) => {
2964
- if (!seen.has(u)) {
2965
- seen.add(u);
2966
- out.push(u);
3588
+ function typingSessions(track, width, height, typingGap) {
3589
+ const pings = track.filter((e) => e.type === "key").map((e) => ({ t: e.t / 1e3, rect: e.rect, x: e.x, y: e.y }));
3590
+ const all = [];
3591
+ let cur = null;
3592
+ for (const p of pings) {
3593
+ const [nx, ny] = fieldCenter(p, width, height);
3594
+ if (cur && p.t - cur.last <= typingGap && Math.hypot(nx - cur.cx, ny - cur.cy) <= TYPING_REFOCUS_FRAC) {
3595
+ cur.events.push(p);
3596
+ cur.last = p.t;
3597
+ } else {
3598
+ cur = { events: [p], first: p.t, last: p.t, start: p.t, cx: nx, cy: ny };
3599
+ all.push(cur);
2967
3600
  }
2968
- };
2969
- for (const m of clip.html.matchAll(
2970
- /\bsrc\s*=\s*["']((?:https?:)?\/\/[^"']+)["']/gi
2971
- ))
2972
- add(m[1]);
2973
- for (const text of [clip.html, clip.css ?? ""])
2974
- for (const m of text.matchAll(
2975
- /url\(\s*["']?((?:https?:)?\/\/[^"')\s]+)["']?\s*\)/gi
2976
- ))
2977
- add(m[1]);
2978
- return out;
2979
- }
2980
- function htmlLayerAssets(clip) {
2981
- const inlinable = [];
2982
- const foreign = [];
2983
- for (const u of htmlLayerAssetUrls(clip))
2984
- (u.replace(/^\/\//, "https://").startsWith(HTML_LAYER_ASSET_HOST) ? inlinable : foreign).push(u);
2985
- return { inlinable, foreign };
2986
- }
2987
- function htmlLayerBleedFor(css) {
2988
- if (!css) return 0;
2989
- let worst = 0;
2990
- const consider = (layer) => {
2991
- if (/\binset\b/i.test(layer)) return;
2992
- const lengths = layer.replace(/[a-z-]+\([^)]*\)/gi, " ").replace(/#[0-9a-f]{3,8}/gi, " ").match(/-?\d*\.?\d+/g);
2993
- if (!lengths) return;
2994
- const [x = 0, y = 0, blur = 0, spread = 0] = lengths.map(Number);
2995
- worst = Math.max(
2996
- worst,
2997
- Math.abs(x) + blur + spread,
2998
- Math.abs(y) + blur + spread
2999
- );
3000
- };
3001
- for (const decl of css.match(/box-shadow\s*:[^;}]+/gi) ?? [])
3002
- for (const layer of splitTopLevel(decl.replace(/^[^:]*:/, "")))
3003
- consider(layer);
3004
- for (const fn of css.match(/drop-shadow\(([^)]*)\)/gi) ?? [])
3005
- consider(fn.replace(/^drop-shadow\(/i, "").replace(/\)$/, ""));
3006
- return worst ? Math.ceil(worst * 1.15) : 0;
3007
- }
3008
- function htmlLayerBleed(clip) {
3009
- return Math.max(0, Math.round(clip.bleed ?? htmlLayerBleedFor(clip.css)));
3010
- }
3011
- function htmlLayerPictureBox(clip) {
3012
- const bleed = htmlLayerBleed(clip);
3013
- return {
3014
- width: clip.box.width + bleed * 2,
3015
- height: clip.box.height + bleed * 2,
3016
- bleed
3017
- };
3018
- }
3019
- function htmlLayerWidth(clip) {
3020
- if (clip.width != null) return clip.width;
3021
- return Math.min(1, htmlLayerPictureBox(clip).width / DESIGN_FRAME_WIDTH);
3022
- }
3023
- var ENTITIES = {
3024
- amp: "&",
3025
- lt: "<",
3026
- gt: ">",
3027
- quot: '"',
3028
- apos: "'",
3029
- nbsp: " "
3030
- };
3031
- function htmlLayerText(html) {
3032
- return html.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]*>/g, " ").replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, ref) => {
3033
- if (ref[0] === "#")
3034
- return String.fromCodePoint(
3035
- ref[1] === "x" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1))
3036
- );
3037
- return ENTITIES[ref.toLowerCase()] ?? m;
3038
- }).replace(/\s+/g, " ").trim();
3601
+ }
3602
+ return all.filter(
3603
+ (s) => s.events.length >= TYPING_MIN_PINGS && s.last - s.first >= TYPING_MIN_DUR
3604
+ );
3039
3605
  }
3040
- function htmlLayerLabel(clip) {
3041
- const words = htmlLayerText(clip.html);
3042
- return words ? words.slice(0, 24) : "HTML";
3606
+ function fieldCenter(c, width, height) {
3607
+ const px = c.rect ? c.rect.x + c.rect.w / 2 : c.x;
3608
+ const py = c.rect ? c.rect.y + c.rect.h / 2 : c.y;
3609
+ return [clamp012(px / width), clamp012(py / height)];
3043
3610
  }
3044
- var HTML_LAYER_PLACEHOLDER = /\{\{\s*(t|data\.[A-Za-z0-9_.-]+)\s*\}\}/;
3045
- var VOID_TAGS = [
3046
- "br",
3047
- "img",
3048
- "hr",
3049
- "input",
3050
- "source",
3051
- "col",
3052
- "area",
3053
- "base",
3054
- "wbr",
3055
- "meta",
3056
- "link"
3057
- ];
3058
- var HTML_LAYER_FOREIGN_FIELDS = [
3059
- "key",
3060
- "radius",
3061
- "shadow",
3062
- "border",
3063
- "loop",
3064
- "frame",
3065
- "fx",
3066
- "text",
3067
- "preset"
3068
- ];
3069
- function htmlLayerProblems(clip) {
3070
- const html = clip.html ?? "";
3071
- const css = clip.css;
3072
- const raw = clip;
3073
- const problems = [];
3074
- const push2 = (code, level, message) => problems.push({ code, level, message });
3075
- if (!html.trim()) push2("empty", "problem", "The layer has no markup.");
3076
- for (const tag of VOID_TAGS) {
3077
- const open = new RegExp(`<${tag}(\\s[^>]*?)?>`, "gi");
3078
- let m;
3079
- let said = false;
3080
- while (m = open.exec(html)) {
3081
- if (!m[0].trimEnd().endsWith("/>") && !said) {
3082
- said = true;
3083
- push2(
3084
- "unclosed-void",
3085
- "problem",
3086
- `<${tag}> must close itself as <${tag} /> inside a foreignObject.`
3087
- );
3088
- }
3611
+ function dwellSpans(track, width, height, maxLevel, reserved) {
3612
+ const moves = track.filter((e) => e.type === "move").map((e) => ({ t: e.t / 1e3, nx: e.x / width, ny: e.y / height }));
3613
+ if (moves.length < 2) return [];
3614
+ const trackEnd = track[track.length - 1].t / 1e3;
3615
+ const candidates = [];
3616
+ let start = 0;
3617
+ for (let i = 1; i <= moves.length; i++) {
3618
+ const breaks = i === moves.length || Math.hypot(moves[i].nx - moves[i - 1].nx, moves[i].ny - moves[i - 1].ny) > DWELL_MOVE_FRAC;
3619
+ if (!breaks) continue;
3620
+ const endT = i < moves.length ? moves[i].t : trackEnd;
3621
+ const dur = endT - moves[start].t;
3622
+ const opening = start === 0;
3623
+ if (!opening && dur >= DWELL_MIN && dur <= DWELL_MAX) {
3624
+ const run = moves.slice(start, i);
3625
+ candidates.push({
3626
+ center: (moves[start].t + endT) / 2,
3627
+ cx: run.reduce((s, p) => s + p.nx, 0) / run.length,
3628
+ cy: run.reduce((s, p) => s + p.ny, 0) / run.length,
3629
+ strength: dur
3630
+ });
3089
3631
  }
3632
+ start = i;
3090
3633
  }
3091
- const bare = html.match(/&(?!(#\d+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/g);
3092
- if (bare)
3093
- push2(
3094
- "bare-ampersand",
3095
- "problem",
3096
- `A bare "&" is a parse error; write &amp;. Found ${bare.length}.`
3097
- );
3098
- if (/<[a-zA-Z][^>]*?\s[a-zA-Z-]+=(?!["'])[^\s>]+/.test(html))
3099
- push2(
3100
- "unquoted-attr",
3101
- "problem",
3102
- "Attribute values must be quoted inside a foreignObject."
3103
- );
3104
- const bytes = html.length + (css?.length ?? 0);
3105
- if (bytes > HTML_LAYER_MAX_BYTES)
3106
- push2(
3107
- "too-large",
3108
- "problem",
3109
- `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.`
3110
- );
3111
- else if (bytes > HTML_LAYER_WARN_BYTES)
3112
- push2(
3113
- "too-large",
3114
- "warning",
3115
- `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.`
3116
- );
3117
- for (const field of HTML_LAYER_FOREIGN_FIELDS)
3118
- if (raw[field] !== void 0)
3119
- push2(
3120
- "foreign-field",
3121
- "problem",
3122
- `An HTML layer has no "${field}": its CSS is its chrome (shadow, radius, border) and its markup its content.`
3123
- );
3124
- const needed = htmlLayerBleedFor(css);
3125
- if (clip.bleed != null && needed > clip.bleed)
3126
- push2(
3127
- "shadow-clipped",
3128
- "warning",
3129
- `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.`
3130
- );
3131
- for (const u of htmlLayerAssets({ html, css }).foreign)
3132
- push2(
3133
- "external-resource",
3134
- "warning",
3135
- `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.`
3136
- );
3137
- if (/\bhref\s*=\s*["']\s*(https?:)?\/\//i.test(html))
3138
- push2(
3139
- "external-resource",
3140
- "warning",
3141
- "The source names a stylesheet or a link by URL. An SVG image fetches no href; inline the styles in the CSS."
3142
- );
3143
- const animated = /\b(animation|transition)(-[a-z-]+)?\s*:/i.test(css ?? "");
3144
- if (animated && !clip.live)
3145
- push2(
3146
- "wall-clock",
3147
- "warning",
3148
- "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."
3149
- );
3150
- if (clip.live && /\banimation-delay\s*:/i.test(css ?? ""))
3151
- push2(
3152
- "live-delay",
3153
- "warning",
3154
- "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."
3155
- );
3156
- if (!clip.live && (HTML_LAYER_PLACEHOLDER.test(html) || HTML_LAYER_PLACEHOLDER.test(css ?? "")))
3157
- push2(
3158
- "placeholder-still",
3159
- "warning",
3160
- "The source carries {{t}} or {{data.\u2026}} placeholders, which fill only on a live layer (live: true); a still layer shows them as written."
3161
- );
3162
- for (const family of htmlLayerUnhostedFamilies(clip))
3163
- push2(
3164
- "unhosted-family",
3165
- "warning",
3166
- `The CSS names "${family}", which is not a hosted family (GET https://vos.so/api/fonts); the system stack stands in.`
3634
+ const sorted = [...candidates].sort((a, b) => b.strength - a.strength);
3635
+ const sourceDuration = trackEnd;
3636
+ const len = Math.max(1, sourceDuration * 0.05);
3637
+ const taken = [...reserved];
3638
+ const accepted = [];
3639
+ const centers = [];
3640
+ for (const c of sorted) {
3641
+ if (centers.some((t) => Math.abs(t - c.center) < DWELL_SPACING)) continue;
3642
+ const spanIn = Math.max(
3643
+ 0,
3644
+ Math.min(c.center - len / 2, sourceDuration - len)
3167
3645
  );
3168
- return problems;
3169
- }
3170
- function wellFormedXml(svg, parseXml) {
3171
- const doc = parseXml(svg);
3172
- const errors = doc.getElementsByTagName("parsererror");
3173
- if (!errors.length) return { ok: true };
3174
- const text = (errors[0].textContent ?? "").replace(/\s+/g, " ").trim();
3175
- return { ok: false, error: text || "The markup is not well-formed XML." };
3646
+ const spanOut = Math.min(sourceDuration, spanIn + len);
3647
+ if (spanOut - spanIn < 0.3) continue;
3648
+ if (taken.some((z) => spanIn < z.out && spanOut > z.in)) continue;
3649
+ const span = {
3650
+ id: `d${accepted.length}`,
3651
+ in: round2(spanIn),
3652
+ out: round2(spanOut),
3653
+ // No element rect on moves → point zoom at the planner ceiling.
3654
+ level: clampZoomLevel(maxLevel),
3655
+ cx: round2(clamp012(c.cx)),
3656
+ cy: round2(clamp012(c.cy)),
3657
+ source: "auto"
3658
+ };
3659
+ accepted.push(span);
3660
+ taken.push(span);
3661
+ centers.push(c.center);
3662
+ }
3663
+ return accepted.sort((a, b) => a.in - b.in).map((z, i) => ({ ...z, id: `d${i}` }));
3176
3664
  }
3177
- function fontFaceCss(faces) {
3178
- let out = "";
3179
- for (const f of faces)
3180
- out += `@font-face{font-family:'${f.family}';src:url(${f.dataUri}) format('woff2');font-weight:${f.weight};font-style:${f.style};font-display:block}`;
3181
- return out;
3665
+ function focusFor(cluster, width, height, targetFill, minLevel, maxLevel) {
3666
+ let sx = 0;
3667
+ let sy = 0;
3668
+ let maxW = 0;
3669
+ let maxH = 0;
3670
+ for (const c of cluster) {
3671
+ if (c.rect) {
3672
+ sx += c.rect.x + c.rect.w / 2;
3673
+ sy += c.rect.y + c.rect.h / 2;
3674
+ maxW = Math.max(maxW, c.rect.w);
3675
+ maxH = Math.max(maxH, c.rect.h);
3676
+ } else {
3677
+ sx += c.x;
3678
+ sy += c.y;
3679
+ }
3680
+ }
3681
+ const n = cluster.length;
3682
+ const cx = clamp012(sx / n / width);
3683
+ const cy = clamp012(sy / n / height);
3684
+ const own = fitLevel({ x: 0, y: 0, w: maxW, h: maxH }, width, height, targetFill) ?? maxLevel;
3685
+ const together = unionFitLevel(unionRect(cluster), width, height);
3686
+ return { cx, cy, level: clamp3(Math.min(own, together), minLevel, maxLevel) };
3182
3687
  }
3183
- var fmtT = (t) => String(Math.round(t * 1e3) / 1e3);
3184
- var escapeXml = (v) => String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3185
- function htmlLayerSvg(src, faces = [], assets = []) {
3186
- const bleed = Math.max(0, Math.round(src.bleed ?? 0));
3187
- const boxW = src.box.width + bleed * 2;
3188
- const boxH = src.box.height + bleed * 2;
3189
- const t = src.t ?? 0;
3190
- const fill = (text) => {
3191
- let out = text;
3192
- for (const a of assets) out = out.split(a.url).join(a.dataUri);
3193
- if (src.live) {
3194
- out = out.replace(/\{\{\s*t\s*\}\}/g, fmtT(t));
3195
- out = out.replace(
3196
- /\{\{\s*data\.([A-Za-z0-9_.-]+)\s*\}\}/g,
3197
- (_m, name) => {
3198
- const v = src.data ? src.data[name] : void 0;
3199
- return v == null ? "" : escapeXml(v);
3200
- }
3201
- );
3688
+ function clusterFocus(events, width, height) {
3689
+ let sx = 0;
3690
+ let sy = 0;
3691
+ let x0 = Infinity;
3692
+ let y0 = Infinity;
3693
+ let x1 = -Infinity;
3694
+ let y1 = -Infinity;
3695
+ let rects = 0;
3696
+ for (const c of events) {
3697
+ if (c.rect) {
3698
+ sx += c.rect.x + c.rect.w / 2;
3699
+ sy += c.rect.y + c.rect.h / 2;
3700
+ x0 = Math.min(x0, c.rect.x);
3701
+ y0 = Math.min(y0, c.rect.y);
3702
+ x1 = Math.max(x1, c.rect.x + c.rect.w);
3703
+ y1 = Math.max(y1, c.rect.y + c.rect.h);
3704
+ rects++;
3705
+ } else {
3706
+ sx += c.x;
3707
+ sy += c.y;
3202
3708
  }
3203
- return out;
3204
- };
3205
- 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}` : "";
3206
- 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}`;
3207
- 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>`;
3709
+ }
3710
+ const n = Math.max(1, events.length);
3711
+ const rect = rects > 0 ? {
3712
+ x: clamp012(x0 / width),
3713
+ y: clamp012(y0 / height),
3714
+ w: clamp012((x1 - x0) / width),
3715
+ h: clamp012((y1 - y0) / height)
3716
+ } : null;
3717
+ return { cx: clamp012(sx / n / width), cy: clamp012(sy / n / height), rect };
3208
3718
  }
3209
- function fnv1a(s) {
3210
- let h = 2166136261;
3211
- for (let i = 0; i < s.length; i++) {
3212
- h ^= s.charCodeAt(i);
3213
- h = Math.imul(h, 16777619) >>> 0;
3719
+ function clamp3(v, lo, hi) {
3720
+ return Math.max(lo, Math.min(hi, v));
3721
+ }
3722
+ function clamp012(v) {
3723
+ return clamp3(v, 0, 1);
3724
+ }
3725
+ function round2(v) {
3726
+ return Math.round(v * 1e3) / 1e3;
3727
+ }
3728
+
3729
+ // src/lower/lowerToComposition.ts
3730
+ import {
3731
+ EASINGS,
3732
+ lerpArray as lerpArray2,
3733
+ sample as sample2,
3734
+ segmentRate as segmentRate2,
3735
+ sortKeyframes as sortKeyframes2,
3736
+ sourceToTimeline,
3737
+ splitBySpeed as splitBySpeed3,
3738
+ totalDuration as totalDuration3
3739
+ } from "@vosjs/timeline";
3740
+ import { timelineRuntimeCode as timelineRuntimeCode2 } from "@vosjs/timeline/bundle";
3741
+
3742
+ // src/layout.ts
3743
+ function computeCardLayout(frame, video, W, H, zoomFocus) {
3744
+ const s = H / 1080;
3745
+ const pad = (frame.padding || 0) * s;
3746
+ const ins = frame.inset ?? {};
3747
+ const padL = ins.left == null ? pad : ins.left * W;
3748
+ const padR = ins.right == null ? pad : ins.right * W;
3749
+ const padT = ins.top == null ? pad : ins.top * H;
3750
+ const padB = ins.bottom == null ? pad : ins.bottom * H;
3751
+ const bar = frame.browserBar;
3752
+ const vw = video.width || 16;
3753
+ const vh = video.height || 9;
3754
+ const fitCover = frame.fit === "cover";
3755
+ const cf = fitCover ? 1 : Math.min(1, W / H / (vw / vh));
3756
+ const barH = bar.kind !== "none" ? (bar.height || 44) * s * cf : 0;
3757
+ const availW = Math.max(1, W - padL - padR);
3758
+ const availH = Math.max(1, H - padT - padB - barH);
3759
+ if (fitCover) {
3760
+ const sc2 = Math.max(availW / vw, availH / vh);
3761
+ const dw2 = vw * sc2;
3762
+ const dh2 = vh * sc2;
3763
+ const follow = frame.focusFollow === "camera" && zoomFocus ? zoomFocus : null;
3764
+ const fcx = clamp013(follow ? follow.cx : frame.focus?.cx ?? 0.5);
3765
+ const fcy = clamp013(follow ? follow.cy : frame.focus?.cy ?? 0.5);
3766
+ const vTop = padT + barH;
3767
+ const dx2 = Math.min(
3768
+ padL,
3769
+ Math.max(padL + availW - dw2, padL + availW / 2 - fcx * dw2)
3770
+ );
3771
+ const dy2 = Math.min(
3772
+ vTop,
3773
+ Math.max(vTop + availH - dh2, vTop + availH / 2 - fcy * dh2)
3774
+ );
3775
+ return {
3776
+ W,
3777
+ H,
3778
+ dx: dx2,
3779
+ dy: dy2,
3780
+ dw: dw2,
3781
+ dh: dh2,
3782
+ cardX: padL,
3783
+ cardY: padT,
3784
+ cardW: availW,
3785
+ cardH: availH + barH
3786
+ };
3214
3787
  }
3215
- return h.toString(36);
3216
- }
3217
- function htmlLayerKey(clip, faces, bleed, assets = []) {
3218
- 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
3219
- // moment to this key per frame. The data rides here, the moment there.
3220
- (clip.live ? " live " + JSON.stringify(clip.data ?? null) : "");
3221
- return `html:${clip.id}:${fnv1a(src)}`;
3222
- }
3223
- function htmlLayerPayload(clip) {
3224
- const faces = htmlLayerFaces(clip);
3225
- const bleed = htmlLayerBleed(clip);
3226
- const assets = htmlLayerAssets(clip).inlinable;
3788
+ const sc = Math.min(availW / vw, availH / vh);
3789
+ const dw = vw * sc;
3790
+ const dh = vh * sc;
3791
+ const dx = padL + (availW - dw) / 2;
3792
+ const dy = padT + barH + (availH - dh) / 2;
3227
3793
  return {
3228
- key: htmlLayerKey(clip, faces, bleed, assets),
3229
- width: htmlLayerWidth(clip),
3230
- html: {
3231
- markup: clip.html,
3232
- css: clip.css ?? "",
3233
- box: { width: clip.box.width, height: clip.box.height },
3234
- bleed,
3235
- faces,
3236
- assets,
3237
- ...clip.live ? { live: true, ...clip.data ? { data: clip.data } : {} } : {}
3238
- }
3794
+ W,
3795
+ H,
3796
+ dx,
3797
+ dy,
3798
+ dw,
3799
+ dh,
3800
+ cardX: dx,
3801
+ cardY: dy - barH,
3802
+ cardW: dw,
3803
+ cardH: dh + barH
3239
3804
  };
3240
3805
  }
3241
-
3242
- // src/overlayText.ts
3243
- var OVERLAY_BOX_PAD_X = 0.6;
3244
- var OVERLAY_BOX_PAD_Y = 0.35;
3245
- var OVERLAY_BOX_RADIUS = 0.25;
3246
- function resolveOverlayBox(clip) {
3247
- if (!clip.box) return null;
3248
- const size = resolveOverlayStyle(clip).size;
3806
+ function docCardLayout(doc, media) {
3807
+ const meta = doc.source.meta;
3808
+ const H = 1080;
3809
+ const W = Math.max(
3810
+ 2,
3811
+ Math.round(H * aspectRatioValue(doc.frame.aspectRatio, meta))
3812
+ );
3813
+ const src = mediaSource(doc, media) ?? doc.source;
3814
+ const sm = src.meta;
3815
+ return computeCardLayout(
3816
+ mediaFrame(doc, media),
3817
+ {
3818
+ width: sm.captureWidth ?? sm.width,
3819
+ height: sm.captureHeight ?? sm.height
3820
+ },
3821
+ W,
3822
+ H
3823
+ );
3824
+ }
3825
+ function recommendedExportResolution(doc) {
3826
+ const meta = doc.source.meta;
3827
+ const capW = meta.captureWidth ?? meta.width;
3828
+ const video = { width: capW, height: meta.captureHeight ?? meta.height };
3829
+ let best = EXPORT_RESOLUTION_OPTIONS[0];
3830
+ for (const r of EXPORT_RESOLUTION_OPTIONS) {
3831
+ const { width, height } = resolveExportSize(doc, r);
3832
+ if (computeCardLayout(doc.frame, video, width, height).dw > capW) break;
3833
+ best = r;
3834
+ }
3835
+ return best;
3836
+ }
3837
+ function camBubbleRect(cam, W, H = 1080) {
3838
+ const s = H / 1080;
3839
+ const size = Math.max(40, (cam.size || 0.25) * H);
3840
+ const mg = 24 * s;
3841
+ const x = cam.x != null ? cam.x * W - size / 2 : cam.position.includes("right") ? W - mg - size : mg;
3842
+ const y = cam.y != null ? cam.y * H - size / 2 : cam.position.includes("top") ? mg : H - mg - size;
3249
3843
  return {
3250
- color: clip.box.color,
3251
- opacity: clip.box.opacity ?? 1,
3252
- padX: (clip.box.paddingX ?? OVERLAY_BOX_PAD_X) * size,
3253
- padY: (clip.box.paddingY ?? OVERLAY_BOX_PAD_Y) * size,
3254
- radius: (clip.box.radius ?? OVERLAY_BOX_RADIUS) * size
3844
+ x,
3845
+ y,
3846
+ size,
3847
+ radius: cam.shape === "rounded" ? (cam.radius ?? 18) * s : size / 2
3255
3848
  };
3256
3849
  }
3257
- var PRESET_FAMILY = {
3258
- title: "Lexend",
3259
- caption: "Lexend",
3260
- label: "JetBrains Mono"
3261
- };
3262
- var TEXT_PRESETS = {
3263
- title: {
3264
- stack: "Lexend, -apple-system, system-ui, sans-serif",
3265
- weight: 600,
3266
- size: 64,
3267
- color: "#fafafa",
3268
- shadow: 0.45
3269
- },
3270
- caption: {
3271
- stack: "Lexend, -apple-system, system-ui, sans-serif",
3272
- weight: 400,
3273
- size: 32,
3274
- color: "#f4f4f5",
3275
- shadow: 0.4
3276
- },
3277
- label: {
3278
- stack: "'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
3279
- weight: 400,
3280
- size: 22,
3281
- color: "#e4e4e7",
3282
- shadow: 0.35
3283
- }
3284
- };
3285
- var OVERLAY_SIZE_MIN = 12;
3286
- var OVERLAY_SIZE_MAX = 200;
3287
- var OVERLAY_FONT_FACES = [
3288
- { family: "Lexend", weight: 400, url: fontFaceUrl2("lexend", 400) },
3289
- { family: "Lexend", weight: 600, url: fontFaceUrl2("lexend", 600) },
3290
- {
3291
- family: "JetBrains Mono",
3292
- weight: 400,
3293
- url: fontFaceUrl2("jetbrains-mono", 400)
3850
+ function focusBounds(level, layout, camera = "card", screen) {
3851
+ if (camera === "stage" && screen && level > 1.001) {
3852
+ return { minX: 0, maxX: 1, minY: 0, maxY: 1 };
3294
3853
  }
3295
- ];
3296
- function resolveOverlayStyle(clip) {
3297
- const presetName = clip.preset in TEXT_PRESETS ? clip.preset : "title";
3298
- const base = TEXT_PRESETS[presetName];
3299
- let stack = base.stack;
3300
- let weight = base.weight;
3301
- const familyEntry = findFontFamily2(clip.family ?? PRESET_FAMILY[presetName]);
3302
- if (clip.family) {
3303
- const quoted = clip.family.includes(" ") ? `'${clip.family}'` : clip.family;
3304
- stack = familyEntry ? fontStack(familyEntry) : `${quoted}, ${base.stack}`;
3854
+ if (level <= 1.001) {
3855
+ return { minX: 0.5, maxX: 0.5, minY: 0.5, maxY: 0.5 };
3305
3856
  }
3306
- if (clip.weight !== void 0 || clip.family) {
3307
- const wanted = clip.weight ?? base.weight;
3308
- weight = familyEntry ? nearestFontWeight2(familyEntry, wanted) : wanted;
3857
+ if (camera === "stage") {
3858
+ const x2 = stageAxisBounds(
3859
+ layout.dx,
3860
+ layout.dw,
3861
+ layout.cardX,
3862
+ layout.cardW,
3863
+ layout.W,
3864
+ level
3865
+ );
3866
+ const y2 = stageAxisBounds(
3867
+ layout.dy,
3868
+ layout.dh,
3869
+ layout.cardY,
3870
+ layout.cardH,
3871
+ layout.H,
3872
+ level
3873
+ );
3874
+ return { minX: x2.min, maxX: x2.max, minY: y2.min, maxY: y2.max };
3309
3875
  }
3310
- return {
3311
- ...base,
3312
- stack,
3313
- weight,
3314
- ...clip.size !== void 0 ? {
3315
- size: Math.min(
3316
- OVERLAY_SIZE_MAX,
3317
- Math.max(OVERLAY_SIZE_MIN, clip.size)
3318
- )
3319
- } : {},
3320
- ...clip.color ? { color: clip.color } : {},
3321
- ...clip.shadow !== void 0 ? { shadow: Math.min(1, Math.max(0, clip.shadow)) } : {},
3322
- fontStyle: clip.italic ? "italic" : "normal",
3323
- align: clip.align ?? "center",
3324
- letterSpacing: clip.letterSpacing ?? 0,
3325
- lineHeight: clip.lineHeight ?? OVERLAY_LINE_HEIGHT,
3326
- stroke: clip.stroke ?? null
3327
- };
3328
- }
3329
- function overlayFaceFor(clip) {
3330
- const entry = findFontFamily2(
3331
- clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
3876
+ const x = axisBounds(
3877
+ layout.dx,
3878
+ layout.dw,
3879
+ layout.cardX,
3880
+ layout.cardW,
3881
+ layout.W,
3882
+ level
3332
3883
  );
3333
- if (!entry) return null;
3334
- const weight = resolveOverlayStyle(clip).weight;
3335
- const inBase = OVERLAY_FONT_FACES.some(
3336
- (f) => f.family === entry.family && f.weight === weight
3884
+ const y = axisBounds(
3885
+ layout.dy,
3886
+ layout.dh,
3887
+ layout.cardY,
3888
+ layout.cardH,
3889
+ layout.H,
3890
+ level
3337
3891
  );
3338
- if (inBase) return null;
3339
- return {
3340
- family: entry.family,
3341
- weight,
3342
- url: fontFaceUrl2(entry.slug, weight)
3343
- };
3344
- }
3345
- function overlayFontFaces(doc) {
3346
- const faces = [...OVERLAY_FONT_FACES];
3347
- const seen = new Set(faces.map((f) => `${f.family}|${f.weight}`));
3348
- const add = (face) => {
3349
- if (!face) return;
3350
- const key = `${face.family}|${face.weight}`;
3351
- if (seen.has(key)) return;
3352
- seen.add(key);
3353
- faces.push(face);
3354
- };
3355
- for (const o of doc.overlays ?? []) {
3356
- if (o.kind !== "text") continue;
3357
- add(overlayFaceFor(o));
3358
- add(resolveEmphasis(o)?.face);
3359
- }
3360
- return faces;
3361
- }
3362
- function overlayLines(text) {
3363
- const lines = text.split("\n");
3364
- return lines.length ? lines : [""];
3365
- }
3366
- var EM_OPEN = "";
3367
- var EM_CLOSE = "";
3368
- function parseEmphasis(text) {
3369
- let any = false;
3370
- const lines = text.split("\n").map((line) => {
3371
- const chars = [];
3372
- const bold = [];
3373
- const marker = [];
3374
- for (let i = 0; i < line.length; i++) {
3375
- if (line[i] === "\\" && line[i + 1] === "*") {
3376
- chars.push("*");
3377
- marker.push(false);
3378
- i++;
3379
- } else {
3380
- chars.push(line[i]);
3381
- marker.push(line[i] === "*");
3382
- }
3383
- }
3384
- const keep = chars.map(() => true);
3385
- for (let i = 0; i < chars.length; i++) {
3386
- if (!marker[i]) continue;
3387
- let j = i + 1;
3388
- while (j < chars.length && !marker[j]) j++;
3389
- if (j >= chars.length) break;
3390
- const inner = chars.slice(i + 1, j).join("");
3391
- if (!inner.trim()) {
3392
- i = j - 1;
3393
- continue;
3394
- }
3395
- keep[i] = false;
3396
- keep[j] = false;
3397
- for (let k = i + 1; k < j; k++) bold[k] = true;
3398
- any = true;
3399
- i = j;
3400
- }
3401
- const kept = [];
3402
- for (let i = 0; i < chars.length; i++) {
3403
- if (keep[i])
3404
- kept.push({ c: chars[i], b: !!bold[i] && !/\s/.test(chars[i]) });
3405
- }
3406
- let out = "";
3407
- for (let i = 0; i < kept.length; i++) {
3408
- const { c, b } = kept[i];
3409
- if (b && !(i > 0 && kept[i - 1].b)) out += EM_OPEN;
3410
- out += c;
3411
- if (b && !(i + 1 < kept.length && kept[i + 1].b)) out += EM_CLOSE;
3412
- }
3413
- return out;
3414
- });
3415
- return any ? lines.join("\n") : null;
3892
+ return { minX: x.min, maxX: x.max, minY: y.min, maxY: y.max };
3416
3893
  }
3417
- function overlayDisplayText(clip) {
3418
- if (!clip.emphasis) return clip.text;
3419
- return parseEmphasis(clip.text) ?? clip.text;
3894
+ function axisBounds(anchorOff, anchorSize, coverOff, coverSize, viewport, level) {
3895
+ const k = 1 - 1 / level;
3896
+ let lo = (coverOff / k - anchorOff) / anchorSize;
3897
+ let hi = ((coverOff + coverSize - viewport / level) / k - anchorOff) / anchorSize;
3898
+ if (lo > hi) {
3899
+ const mid = (lo + hi) / 2;
3900
+ lo = mid;
3901
+ hi = mid;
3902
+ }
3903
+ return { min: clamp013(lo), max: clamp013(hi) };
3420
3904
  }
3421
- function stripEmphasis(text) {
3422
- return text.split(EM_OPEN).join("").split(EM_CLOSE).join("");
3905
+ function stageAxisBounds(anchorOff, anchorSize, coverOff, coverSize, viewport, level) {
3906
+ const b = CAMERA_COVER_MIN / 2 * viewport;
3907
+ let lo = (coverOff + b / level - anchorOff) / anchorSize;
3908
+ let hi = (coverOff + coverSize - b / level - anchorOff) / anchorSize;
3909
+ if (lo > hi) {
3910
+ const mid = (lo + hi) / 2;
3911
+ lo = mid;
3912
+ hi = mid;
3913
+ }
3914
+ return { min: clamp013(lo), max: clamp013(hi) };
3423
3915
  }
3424
- var EMPHASIS_WEIGHT = 700;
3425
- function resolveEmphasis(clip) {
3426
- if (!clip.emphasis || parseEmphasis(clip.text) === null) return null;
3427
- const style = resolveOverlayStyle(clip);
3428
- const entry = findFontFamily2(
3429
- clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
3430
- );
3431
- const wanted = clip.emphasis?.weight ?? Math.max(EMPHASIS_WEIGHT, style.weight);
3432
- const weight = entry ? nearestFontWeight2(entry, wanted) : wanted;
3433
- const inBase = OVERLAY_FONT_FACES.some(
3434
- (f) => entry && f.family === entry.family && f.weight === weight
3435
- );
3916
+ function clampFocus(cx, cy, level, layout, camera = "card", screen) {
3917
+ const b = focusBounds(level, layout, camera, screen);
3436
3918
  return {
3437
- weight,
3438
- ...clip.emphasis?.color ? { color: clip.emphasis.color } : {},
3439
- ...entry && !inBase ? {
3440
- face: {
3441
- family: entry.family,
3442
- weight,
3443
- url: fontFaceUrl2(entry.slug, weight)
3444
- }
3445
- } : {}
3919
+ cx: Math.min(b.maxX, Math.max(b.minX, cx)),
3920
+ cy: Math.min(b.maxY, Math.max(b.minY, cy))
3446
3921
  };
3447
3922
  }
3448
- function measureEmphasized(text, measure, font, emFont, bold = false) {
3449
- let w = 0;
3450
- let run = "";
3451
- let b = bold;
3452
- for (const c of text) {
3453
- if (c === EM_OPEN || c === EM_CLOSE) {
3454
- if (run) w += measure(run, b ? emFont : font);
3455
- run = "";
3456
- b = c === EM_OPEN;
3457
- } else run += c;
3458
- }
3459
- if (run) w += measure(run, b ? emFont : font);
3460
- return w;
3923
+ function clamp013(v) {
3924
+ return Math.max(0, Math.min(1, v));
3461
3925
  }
3462
- function overlayTokens(line) {
3463
- return line.match(/\S+\s*/g) ?? [line];
3926
+ function screenPoint(layout, camera, screen) {
3927
+ return camera === "stage" && screen ? { ox: screen.x * layout.W, oy: screen.y * layout.H } : { ox: layout.W / 2, oy: layout.H / 2 };
3464
3928
  }
3465
- function wrapOverlayLines(lines, measure, maxPx) {
3466
- if (!(maxPx > 0)) return lines;
3467
- const out = [];
3468
- for (const line of lines) {
3469
- if (!line || measure(line) <= maxPx) {
3470
- out.push(line);
3471
- continue;
3472
- }
3473
- let current = "";
3474
- for (const token of overlayTokens(line)) {
3475
- if (!current) {
3476
- current = token;
3477
- continue;
3478
- }
3479
- if (measure(current + token) <= maxPx) {
3480
- current += token;
3481
- } else {
3482
- out.push(current);
3483
- current = token;
3484
- }
3485
- }
3486
- if (current) out.push(current);
3487
- }
3488
- return out.length ? out : [""];
3929
+ function cameraModel(frame) {
3930
+ return frame?.camera === "stage" ? "stage" : "card";
3489
3931
  }
3490
- function graphemesOf(line) {
3491
- if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
3492
- return [
3493
- ...new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(
3494
- line
3495
- )
3496
- ].map((s) => s.segment);
3497
- }
3498
- return [...line];
3932
+ var CAMERA_CENTRE_RAMP = 0.3;
3933
+ var CAMERA_COVER_MIN = 0.8;
3934
+ function cameraCentring(level) {
3935
+ const u = clamp013((level - 1) / CAMERA_CENTRE_RAMP);
3936
+ return u * u * (3 - 2 * u);
3499
3937
  }
3500
- function overlaySegments(text, unit) {
3501
- const lines = overlayLines(text);
3502
- if (unit === "block") return [];
3503
- if (unit === "line") return lines.map((l) => [l]);
3504
- if (unit === "word") return lines.map(overlayTokens);
3505
- return lines.map(graphemesOf);
3938
+ function zoomView(level, cx, cy, layout, camera = "card", centring, screen) {
3939
+ const L = Math.max(1, level);
3940
+ const fx = layout.dx + cx * layout.dw;
3941
+ const fy = layout.dy + cy * layout.dh;
3942
+ const { ox, oy } = screenPoint(layout, camera, screen);
3943
+ const t = camera === "stage" ? clamp013(centring ?? 1) : 0;
3944
+ const wcx = fx + (ox - fx) / L * (1 - t);
3945
+ const wcy = fy + (oy - fy) / L * (1 - t);
3946
+ return { level: L, fx, fy, wcx, wcy, ox, oy };
3506
3947
  }
3507
- var FX_DIR = { forward: 0, reverse: 1, center: 2 };
3508
- function resolveOverlayFx(clip, clipDuration) {
3509
- const spec = overlayFxSpec(clip) ?? clip.fx ?? null;
3510
- if (!spec) return null;
3511
- const unit = spec.unit ?? "block";
3512
- const text = overlayDisplayText(clip);
3513
- const units = overlaySegments(text, unit);
3514
- const n = unit === "block" ? 1 : units.reduce((sum, line) => sum + line.length, 0);
3515
- const upperN = unit === "line" && clip.maxWidth ? overlayLines(text).reduce(
3516
- (sum, line) => sum + overlayTokens(line).length,
3517
- 0
3518
- ) : n;
3519
- const typewriter = spec.fx === "typewriter";
3520
- const dur = typewriter ? 1e-3 : Math.min(2, Math.max(0.05, spec.duration ?? OVERLAY_TRANSITION_DUR));
3521
- const defaultStagger = typewriter ? 0.05 : unit === "block" ? 0 : 0.06;
3522
- let st = Math.min(2, Math.max(0, spec.stagger ?? defaultStagger));
3523
- if (upperN > 1) {
3524
- const maxTotal = Math.max(dur, clipDuration * 0.9);
3525
- st = Math.min(st, Math.max(0, (maxTotal - dur) / (upperN - 1)));
3526
- }
3527
- const round14 = (v) => Math.round(v * 1e4) / 1e4;
3948
+ function zoomViewport(level, cx, cy, layout, camera = "card", centring, screen) {
3949
+ const v = zoomView(level, cx, cy, layout, camera, centring, screen);
3950
+ const size = 1 / v.level;
3951
+ const mx = v.wcx + (layout.W / 2 - v.ox) / v.level;
3952
+ const my = v.wcy + (layout.H / 2 - v.oy) / v.level;
3528
3953
  return {
3529
- k: spec.fx,
3530
- u: unit,
3531
- d: FX_DIR[spec.direction ?? "forward"] ?? 0,
3532
- st: round14(st),
3533
- dur: round14(dur),
3534
- tt: round14(st * (upperN - 1) + dur),
3535
- units,
3536
- n
3954
+ x: (mx - layout.W / (2 * v.level)) / layout.W,
3955
+ y: (my - layout.H / (2 * v.level)) / layout.H,
3956
+ w: size,
3957
+ h: size
3537
3958
  };
3538
3959
  }
3539
- function overlayFontString(style, scale, s) {
3540
- const italic = style.fontStyle === "italic" ? "italic " : "";
3541
- return `${italic}${style.weight} ${style.size * scale * s}px ${style.stack}`;
3960
+ function focusForViewportCentre(cX, cY, level, layout, camera = "card", centring, screen) {
3961
+ const L = Math.max(1, level);
3962
+ const t = camera === "stage" ? clamp013(centring ?? 1) : 0;
3963
+ const a = 1 - (1 - t) / L;
3964
+ if (a <= 1e-6) return { cx: 0.5, cy: 0.5 };
3965
+ const { ox, oy } = screenPoint(layout, camera, screen);
3966
+ const fx = (cX * layout.W - ox * (1 - t) / L - (layout.W / 2 - ox) / L) / a;
3967
+ const fy = (cY * layout.H - oy * (1 - t) / L - (layout.H / 2 - oy) / L) / a;
3968
+ return { cx: (fx - layout.dx) / layout.dw, cy: (fy - layout.dy) / layout.dh };
3542
3969
  }
3543
- function overlayRect(clip, measure, frameW, frameH = 1080, mediaAspect) {
3544
- const scale = clip.transform.scale || 1;
3545
- const base = {
3546
- cx: clip.transform.x * frameW,
3547
- cy: clip.transform.y * frameH,
3548
- rotation: clip.transform.rotation || 0
3549
- };
3550
- if (clip.kind === "html") {
3551
- const pb = htmlLayerPictureBox(clip);
3552
- const w2 = htmlLayerWidth(clip) * frameW * scale;
3553
- return { ...base, w: w2, h: w2 * (pb.height / pb.width) };
3554
- }
3555
- if (clip.kind !== "text") {
3556
- const w2 = (clip.width ?? OVERLAY_MEDIA_DEFAULT_WIDTH) * frameW * scale;
3557
- return { ...base, w: w2, h: w2 / (mediaAspect || 16 / 9) };
3558
- }
3559
- const style = resolveOverlayStyle(clip);
3560
- const font = overlayFontString(style, scale, 1);
3561
- const ls = style.letterSpacing * scale;
3562
- const em = resolveEmphasis(clip);
3563
- const emFont = em ? overlayFontString({ ...style, weight: em.weight }, scale, 1) : "";
3564
- const width = (t) => em ? measureEmphasized(t, (run, f) => measure(run, f, ls), font, emFont) : measure(t, font, ls);
3565
- const text = overlayDisplayText(clip);
3566
- const lines = clip.maxWidth ? wrapOverlayLines(overlayLines(text), width, clip.maxWidth * frameW) : overlayLines(text);
3567
- let w = 0;
3568
- for (const line of lines) w = Math.max(w, width(line));
3569
- const lineH = style.size * scale * style.lineHeight;
3570
- const box = resolveOverlayBox(clip);
3571
- const padX = box ? box.padX * scale : 0;
3572
- const padY = box ? box.padY * scale : 0;
3573
- return {
3574
- ...base,
3575
- w: Math.max(w, style.size * scale * 0.6) + padX * 2,
3576
- // empty text still selectable
3577
- h: lines.length * lineH + padY * 2
3578
- };
3970
+ function levelForFocusFraction(frac) {
3971
+ if (!(frac > 0)) return clampZoomLevel(Number.POSITIVE_INFINITY);
3972
+ return clampZoomLevel(Math.max(1.1, 1 / frac));
3579
3973
  }
3580
- function overlayHit(rect, px, py, padPx = 8) {
3581
- let dx = px - rect.cx;
3582
- let dy = py - rect.cy;
3583
- if (rect.rotation) {
3584
- const a = -rect.rotation * Math.PI / 180;
3585
- const rx = dx * Math.cos(a) - dy * Math.sin(a);
3586
- const ry = dx * Math.sin(a) + dy * Math.cos(a);
3587
- dx = rx;
3588
- dy = ry;
3589
- }
3590
- return Math.abs(dx) <= rect.w / 2 + padPx && Math.abs(dy) <= rect.h / 2 + padPx;
3974
+
3975
+ // src/stage.ts
3976
+ var CARD_FOV = 30;
3977
+ var OVERLAY_Z = -2;
3978
+ var CARD_Z = -4;
3979
+ var BACKGROUND_Z = -6;
3980
+ var CARD_OVERSCAN_STEP = 0.05;
3981
+ var CARD_OVERSCAN_MAX = 4;
3982
+ var CAMERA_NEAR = 0.1;
3983
+ var CAMERA_FAR = 100;
3984
+ function planeSizeAtDepth(distance, fovDeg, aspect) {
3985
+ const height = 2 * Math.abs(distance) * Math.tan(fovDeg * Math.PI / 180 / 2);
3986
+ return { width: height * aspect, height };
3987
+ }
3988
+ function cardPointToScreen(u, v) {
3989
+ return { sx: u, sy: v };
3591
3990
  }
3592
3991
 
3593
3992
  // src/text3d.ts
@@ -4317,6 +4716,11 @@ function push(keys, t, a) {
4317
4716
 
4318
4717
  // src/lower/studioEntry.ts
4319
4718
  import { timelineRuntimeCode } from "@vosjs/timeline/bundle";
4719
+
4720
+ // src/richText/layoutCode.ts
4721
+ var textLayoutCode = '"use strict";var __vosTextLayout=(()=>{var E=Object.defineProperty;var k=Object.getOwnPropertyDescriptor;var A=Object.getOwnPropertyNames;var N=Object.prototype.hasOwnProperty;var W=(n,r)=>{for(var h in r)E(n,h,{get:r[h],enumerable:!0})},j=(n,r,h,l)=>{if(r&&typeof r=="object"||typeof r=="function")for(let f of A(r))!N.call(n,f)&&f!==h&&E(n,f,{get:()=>r[f],enumerable:!(l=k(r,f))||l.enumerable});return n};var C=n=>j(E({},"__esModule",{value:!0}),n);var v={};W(v,{DECORATION_THICKNESS_EM:()=>U,HIGHLIGHT_HALF_EM:()=>I,STRIKE_OFFSET_EM:()=>H,UNDERLINE_OFFSET_EM:()=>O,decorationRect:()=>K,graphemesOf:()=>_,layoutText:()=>G,tokenRanges:()=>F});var D=(n,r)=>(n.f||0)===(r.f||0)&&n.c===r.c&&n.u===r.u&&n.s===r.s&&n.h===r.h;function _(n){if(typeof Intl<"u"&&typeof Intl.Segmenter=="function"){let r=new Intl.Segmenter(void 0,{granularity:"grapheme"}),h=[];for(let l of r.segment(n))h.push(l.segment);return h}return Array.from(n)}function F(n){let r=[],h=/\\S+\\s*/g,l;for(;(l=h.exec(n))!==null;)r.push([l.index,l.index+l[0].length]);return r.length?r:[[0,n.length]]}function G(n,r){let h={},l=(i,u)=>{if(!i)return 0;let s=u+"\\0"+i,t=h[s];return t===void 0&&(t=r(i,u),h[s]=t),t},f=(i,u,s)=>{let t=[],o=0;for(let e=0;e<i.length;e++){let c=i[e],a=Math.max(u,o),m=Math.min(s,o+c.t.length);if(m>a){let g=c.t.slice(a-o,m-o),x=t[t.length-1];x&&D(x,c)?x.t+=g:t.push({...c,t:g})}if(o+=c.t.length,o>=s)break}return t},p=i=>{let u=0;for(let s=0;s<i.length;s++)u+=l(i[s].t,i[s].f||0);return u},R=(i,u,s)=>{let t=[];for(let o=0;o<i.length;o++){let e=i[o],c=Math.max(u,e.o),a=Math.min(s,e.o+e.t.length);if(a<=c)continue;let m=e.f||0,g=l(e.t.slice(0,c-e.o),m);t.push({...e,t:e.t.slice(c-e.o,a-e.o),x:e.x+g,w:l(e.t.slice(0,a-e.o),m)-g,o:c})}return t},L=(i,u,s)=>{let t=R(i,u,s);if(!t.length)return{x:0,w:0,parts:t};let o=t[t.length-1];return{x:t[0].x,w:o.x+o.w-t[0].x,parts:t}},y=[],d=n.maxWidth&&n.maxWidth>0?n.maxWidth:0,w=0,T=0,S=(i,u,s)=>{let t=f(i,u,s),o=[],e=0,c=0;for(let m=0;m<t.length;m++){let g=l(t[m].t,t[m].f||0);o.push({...t[m],x:e,w:g,o:c}),e+=g,c+=t[m].t.length}let a={w:e,frags:o,a:T+u,n:s-u};if(n.unit){let m=t.map(x=>x.t).join(""),g=[];if(n.unit==="line")g.push(L(o,0,m.length));else if(n.unit==="word"){let x=F(m);for(let b=0;b<x.length;b++)g.push(L(o,x[b][0],x[b][1]))}else{let x=0,b=_(m);for(let M=0;M<b.length;M++)g.push(L(o,x,x+b[M].length)),x+=b[M].length}a.units=g}e>w&&(w=e),y.push(a)};for(let i=0;i<n.lines.length;i++){let u=n.lines[i],s=0;for(let t=0;t<u.length;t++)s+=u[t].t.length;if(!d||!s||p(f(u,0,s))<=d)S(u,0,s);else{let t=u.map(a=>a.t).join(""),o=F(t),e=0,c=o[0][1];for(let a=1;a<o.length;a++)p(f(u,e,o[a][1]))<=d||(S(u,e,c),e=o[a][0]),c=o[a][1];S(u,e,c)}T+=s+1}return y.length||y.push({w:0,frags:[],a:0,n:0}),{lines:y,width:w}}var O=.42,U=.065,H=.02,I=.58;function K(n,r,h,l,f){let p=Math.round(r),R=Math.round(r+h);if(n==="h"){let d=Math.round(l-I*f),w=Math.round(l+I*f);return{x:p,y:d,w:R-p,h:w-d}}let L=Math.max(1,Math.round(U*f)),y=Math.round(l+(n==="u"?O:H)*f);return{x:p,y,w:R-p,h:L}}return C(v);})();';
4722
+
4723
+ // src/lower/studioEntry.ts
4320
4724
  var STUDIO_ENTRY_ID = "vosso.studio";
4321
4725
  function studioEntry(data) {
4322
4726
  return {
@@ -4713,6 +5117,18 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
4713
5117
  var s = H / 1080 // scale design-px controls to comp px
4714
5118
  var ns = window.__vos__ || {}
4715
5119
  var playing = ns.isPaused === false
5120
+ // The styled-text layout (richText/layout.ts, generated), installed once:
5121
+ // the page lays a text layer out with the code the host picks with.
5122
+ if (!ns.textLayout) { ${textLayoutCode}; ns.textLayout = __vosTextLayout }
5123
+ var TXL = ns.textLayout
5124
+ // The font epoch counts faces landing. A face changes what text measures
5125
+ // and what it paints, so the epoch is part of every layout's key and of
5126
+ // the layer's signature: a late face repaints a held layer once.
5127
+ var ofBump = function () { ns.fontEpoch = (ns.fontEpoch || 0) + 1 }
5128
+ if (!ns.fontWatch && typeof document !== 'undefined' && document.fonts && document.fonts.addEventListener) {
5129
+ ns.fontWatch = 1
5130
+ document.fonts.addEventListener('loadingdone', ofBump)
5131
+ }
4716
5132
 
4717
5133
  // --- audio clips (music/SFX): Web Audio scheduler against the transport ---
4718
5134
  // Buffer sources are one-shot: (re)schedule everything on play / clip-data change
@@ -4840,7 +5256,19 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
4840
5256
  if (t < ol0.start || t > ol0.start + ol0.dur) continue
4841
5257
  // A page that taints on foreignObject draws no HTML layer (setup's probe).
4842
5258
  if (ol0.html && ns.htmlSafe === false) continue
4843
- 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 : '') + ';'
5259
+ 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 : '')
5260
+ // Everything else a held layer's pixels depend on. The signature is the
5261
+ // ONLY thing that repaints a hold, so a field missing here is an edit
5262
+ // that does not show until something else moves.
5263
+ if (ol0.lines) {
5264
+ olVisSig += '|' + ol0.lines.join('\\n') + '|' + ol0.weight + '|' + ol0.stack + '|' + (ol0.sty || '') + '|' + (ol0.ls || 0) + '|' + (ol0.lh || 0) + '|' + (ol0.align || '') + '|' + ol0.shadow
5265
+ + (ol0.stroke ? '|s' + ol0.stroke.c + ol0.stroke.w : '')
5266
+ + (ol0.box ? '|b' + ol0.box.c + ol0.box.o + ',' + ol0.box.px + ',' + ol0.box.py + ',' + ol0.box.r : '')
5267
+ + (ol0.rt ? '|r' + ol0.rt.h : '')
5268
+ } else {
5269
+ olVisSig += '|' + (ol0.shadow || '') + (ol0.border ? '|' + ol0.border.width + ol0.border.color : '') + (ol0.crop ? '|c' + ol0.crop.x + ',' + ol0.crop.y + ',' + ol0.crop.w + ',' + ol0.crop.h : '')
5270
+ }
5271
+ olVisSig += ';'
4844
5272
  // fx widens the entrance window to the whole staggered span (tt);
4845
5273
  // without fx it is the legacy enter transition window.
4846
5274
  var olEW = ol0.fx ? ol0.fx.tt : olTD
@@ -4878,8 +5306,20 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
4878
5306
  if (ns.htmlErrors) delete ns.htmlErrors[hlK]
4879
5307
  }
4880
5308
  }
4881
- var ovSig = W + 'x' + H + '|' + olVisSig
4882
- var ovDirty = ov.sig !== ovSig || ov.active || olAnim
5309
+ // A styled layer's cached layout leaves with its clip.
5310
+ if (ns.textLayouts) {
5311
+ for (var tlK in ns.textLayouts) {
5312
+ var tlSeen = false
5313
+ for (var tlI = 0; tlI < ols.length; tlI++) if (ols[tlI].id === tlK) { tlSeen = true; break }
5314
+ if (!tlSeen) delete ns.textLayouts[tlK]
5315
+ }
5316
+ }
5317
+ var ovSig = W + 'x' + H + '|' + (ns.fontEpoch || 0) + '|' + (ns.htmlSafe === false ? 0 : 1) + '|' + olVisSig
5318
+ // The layer repaints when what it shows changed (the signature), while
5319
+ // something on it animates, and ONCE more after an animation's last frame:
5320
+ // that frame was drawn a moment short of settled, and a picture that
5321
+ // landed between two frames is drawn by it too. A hold costs nothing.
5322
+ var ovDirty = ov.sig !== ovSig || olAnim || ov.wasAnim
4883
5323
  // The layer cards visible this frame, with their pose, for the card
4884
5324
  // planes below (the overlay loop collects them instead of painting).
4885
5325
  var lcList = []
@@ -5108,7 +5548,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5108
5548
  try {
5109
5549
  var ofBF = new FontFace(ofB.family, 'url(' + ofB.url + ')', { weight: String(ofB.weight) })
5110
5550
  document.fonts.add(ofBF)
5111
- ofBF.load().catch(function () {})
5551
+ ofBF.load().then(ofBump, function () {})
5112
5552
  } catch (e) {}
5113
5553
  }
5114
5554
  if (ol.face) {
@@ -5118,20 +5558,22 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5118
5558
  try {
5119
5559
  var ofFace = new FontFace(ol.face.f, 'url(' + ol.face.u + ')', { weight: String(ol.face.w) })
5120
5560
  document.fonts.add(ofFace)
5121
- ofFace.load().catch(function () {})
5561
+ ofFace.load().then(ofBump, function () {})
5122
5562
  } catch (e) {}
5123
5563
  }
5124
5564
  }
5125
- // The emphasis weight's face, the same way (a live edit that adds the
5126
- // first *marked* word paints without a LOAD).
5127
- if (ol.em && ol.em.face) {
5128
- var oeKey = ol.em.face.f + '|' + ol.em.face.w
5129
- if (!ofSet[oeKey]) {
5130
- ofSet[oeKey] = 1
5565
+ // A styled layer's other faces, the same way (a live edit that styles
5566
+ // its first run paints without a LOAD).
5567
+ if (ol.rt && ol.rt.faces) {
5568
+ for (var rfj = 0; rfj < ol.rt.faces.length; rfj++) {
5569
+ var rtF = ol.rt.faces[rfj]
5570
+ var rtFK = rtF.f + '|' + rtF.w
5571
+ if (ofSet[rtFK]) continue
5572
+ ofSet[rtFK] = 1
5131
5573
  try {
5132
- var oeFace = new FontFace(ol.em.face.f, 'url(' + ol.em.face.u + ')', { weight: String(ol.em.face.w) })
5133
- document.fonts.add(oeFace)
5134
- oeFace.load().catch(function () {})
5574
+ var rtFace = new FontFace(rtF.f, 'url(' + rtF.u + ')', { weight: String(rtF.w) })
5575
+ document.fonts.add(rtFace)
5576
+ rtFace.load().then(ofBump, function () {})
5135
5577
  } catch (e) {}
5136
5578
  }
5137
5579
  }
@@ -5150,68 +5592,153 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5150
5592
  ovC.textAlign = 'center'
5151
5593
  ovC.textBaseline = 'middle'
5152
5594
  ovC.letterSpacing = ((ol.ls || 0) * olMS * s) + 'px'
5153
- // Emphasis (ol.em, baked only when the text marks a word): the lines
5154
- // carry \\u0001/\\u0002 around every emphasized word, and the normal and
5155
- // emphasis fonts alternate at them, run by run. MIRRORS measureEmphasized
5156
- // in overlayText.ts \u2014 change together. A clip without ol.em never reaches
5157
- // these helpers: it keeps its plain measureText/fillText calls.
5158
- var olEm = ol.em || null
5159
- var olFN = ovC.font
5160
- var olFB = olEm ? (ol.sty ? ol.sty + ' ' : '') + olEm.w + ' ' + olPx + 'px ' + ol.stack : ''
5161
- var olMeasure = function (t, b0) {
5162
- if (!olEm) return ovC.measureText(t).width
5163
- var mw = 0, mrun = '', mb = !!b0
5164
- for (var mi = 0; mi < t.length; mi++) {
5165
- var mc = t.charAt(mi)
5166
- if (mc === '\\u0001' || mc === '\\u0002') {
5167
- if (mrun) { ovC.font = mb ? olFB : olFN; mw += ovC.measureText(mrun).width }
5168
- mrun = ''
5169
- mb = mc === '\\u0001'
5170
- } else mrun += mc
5171
- }
5172
- if (mrun) { ovC.font = mb ? olFB : olFN; mw += ovC.measureText(mrun).width }
5173
- ovC.font = olFN
5174
- return mw
5175
- }
5176
- // The emphasis state after a run of text (a char unit starts in it).
5177
- var olStateAfter = function (t, b0) {
5178
- var sb = !!b0
5179
- for (var si = 0; si < t.length; si++) {
5180
- var sc = t.charAt(si)
5181
- if (sc === '\\u0001') sb = true
5182
- else if (sc === '\\u0002') sb = false
5183
- }
5184
- return sb
5185
- }
5186
- // Draw t LEFT-aligned from x, run by run: the font switches at the
5187
- // marks, an emphasized run takes ol.em.c when set, stroke under fill.
5188
- var olDraw = function (t, x, y, b0) {
5189
- var drun = '', db = !!b0, dx = x
5190
- var dflush = function () {
5191
- if (!drun) return
5192
- ovC.font = db ? olFB : olFN
5193
- var dfs = ovC.fillStyle
5194
- if (db && olEm.c) ovC.fillStyle = olEm.c
5595
+ // A STYLED layer (ol.rt: lines of runs, each set its own way) is laid
5596
+ // out by the layout module, ONCE: the layout is kept per clip under a
5597
+ // key of everything it was measured with, so a frame that only moves,
5598
+ // fades or reveals the layer measures nothing. Fragments draw left to
5599
+ // right from each line's left edge; a per-unit entrance animates the
5600
+ // layout's units, each a few parts of fragments.
5601
+ if (ol.rt) {
5602
+ var rtFonts = [ovC.font]
5603
+ for (var rfi = 1; rfi < ol.rt.fs.length; rfi++) {
5604
+ rtFonts.push((ol.rt.fs[rfi].i ? 'italic ' : '') + ol.rt.fs[rfi].w + ' ' + olPx + 'px ' + ol.stack)
5605
+ }
5606
+ var rtFx = ol.fx && ol.fx.units.length ? ol.fx : null
5607
+ var rtMax = ol.mw ? ol.mw * W : 0
5608
+ var rtKey = ol.rt.h + '|' + rtFonts[0] + '|' + ((ol.ls || 0) * olMS * s) + '|' + rtMax + '|' + (rtFx ? rtFx.u : '') + '|' + (ns.fontEpoch || 0)
5609
+ var rtAll = ns.textLayouts || (ns.textLayouts = {})
5610
+ var rtHit = rtAll[ol.id]
5611
+ if (!rtHit || rtHit.k !== rtKey) {
5612
+ rtHit = rtAll[ol.id] = {
5613
+ k: rtKey,
5614
+ L: TXL.layoutText(
5615
+ { lines: ol.rt.l, maxWidth: rtMax, unit: rtFx ? rtFx.u : undefined },
5616
+ function (mt, mf) { ovC.font = rtFonts[mf]; return ovC.measureText(mt).width }
5617
+ )
5618
+ }
5619
+ ovC.font = rtFonts[0]
5620
+ }
5621
+ var rtL = rtHit.L
5622
+ var rtN = rtL.lines.length
5623
+ var rtLH = olPx * (ol.lh || ${OVERLAY_LINE_HEIGHT})
5624
+ var rtY0 = -((rtN - 1) * rtLH) / 2
5625
+ // A line's left edge: lines sit centred on the anchor and align
5626
+ // against the widest, as the plain layer's do.
5627
+ var rtLeft = function (lw) {
5628
+ var xo = ol.align === 'left' ? (lw - rtL.width) / 2 : ol.align === 'right' ? (rtL.width - lw) / 2 : 0
5629
+ return xo - lw / 2
5630
+ }
5631
+ if (ol.box) {
5632
+ var rbPX = ol.box.px * olMS * s
5633
+ var rbPY = ol.box.py * olMS * s
5634
+ var rbW = rtL.width + rbPX * 2
5635
+ var rbH = rtN * rtLH + rbPY * 2
5636
+ ovC.save()
5637
+ ovC.globalAlpha = olA * ol.box.o
5638
+ ovC.fillStyle = ol.box.c
5639
+ rr(-rbW / 2, -rbH / 2, rbW, rbH, Math.min(ol.box.r * olMS * s, rbH / 2), ovC)
5640
+ ovC.fill()
5641
+ ovC.restore()
5642
+ }
5643
+ // Highlights sit behind the words and take no legibility shadow. Under
5644
+ // a per-unit entrance each is drawn with its unit instead (below), so
5645
+ // it arrives with its word.
5646
+ for (var rhi = 0; !rtFx && rhi < rtN; rhi++) {
5647
+ var rhLine = rtL.lines[rhi]
5648
+ for (var rhj = 0; rhj < rhLine.frags.length; rhj++) {
5649
+ var rhF = rhLine.frags[rhj]
5650
+ if (!rhF.h) continue
5651
+ var rhR = TXL.decorationRect('h', rtLeft(rhLine.w) + rhF.x, rhF.w, rtY0 + rhi * rtLH, olPx)
5652
+ ovC.fillStyle = rhF.h
5653
+ ovC.fillRect(rhR.x, rhR.y, rhR.w, rhR.h)
5654
+ }
5655
+ }
5656
+ if (ol.shadow > 0) {
5657
+ ovC.shadowColor = 'rgba(0,0,0,' + ol.shadow + ')'
5658
+ ovC.shadowBlur = olPx * 0.25
5659
+ ovC.shadowOffsetY = olPx * 0.04
5660
+ }
5661
+ ovC.textAlign = 'left'
5662
+ // One fragment (or the part of one a unit holds): the underline under
5663
+ // the glyphs, stroke under fill, the strikethrough over them.
5664
+ var rtPart = function (p, bx, y, withH) {
5665
+ if (withH && p.h) {
5666
+ var rhU = TXL.decorationRect('h', bx + p.x, p.w, y, olPx)
5667
+ var rhS = ovC.shadowColor
5668
+ ovC.shadowColor = 'rgba(0,0,0,0)'
5669
+ ovC.fillStyle = p.h
5670
+ ovC.fillRect(rhU.x, rhU.y, rhU.w, rhU.h)
5671
+ ovC.shadowColor = rhS
5672
+ }
5673
+ ovC.font = rtFonts[p.f || 0]
5674
+ ovC.fillStyle = p.c || ol.color
5675
+ if (p.u) {
5676
+ var ru = TXL.decorationRect('u', bx + p.x, p.w, y, olPx)
5677
+ ovC.fillRect(ru.x, ru.y, ru.w, ru.h)
5678
+ }
5195
5679
  if (ol.stroke) {
5196
5680
  ovC.strokeStyle = ol.stroke.c
5197
5681
  ovC.lineWidth = ol.stroke.w * olMS * s
5198
5682
  ovC.lineJoin = 'round'
5199
- ovC.strokeText(drun, dx, y)
5683
+ ovC.strokeText(p.t, bx + p.x, y)
5684
+ }
5685
+ ovC.fillText(p.t, bx + p.x, y)
5686
+ if (p.s) {
5687
+ var rs = TXL.decorationRect('s', bx + p.x, p.w, y, olPx)
5688
+ ovC.fillRect(rs.x, rs.y, rs.w, rs.h)
5200
5689
  }
5201
- ovC.fillText(drun, dx, y)
5202
- dx += ovC.measureText(drun).width
5203
- ovC.fillStyle = dfs
5204
- drun = ''
5205
5690
  }
5206
- for (var di = 0; di < t.length; di++) {
5207
- var dc = t.charAt(di)
5208
- if (dc === '\\u0001' || dc === '\\u0002') {
5209
- dflush()
5210
- db = dc === '\\u0001'
5211
- } else drun += dc
5691
+ if (!rtFx) {
5692
+ for (var rli = 0; rli < rtN; rli++) {
5693
+ var rLine = rtL.lines[rli]
5694
+ var rBX = rtLeft(rLine.w)
5695
+ for (var rfk = 0; rfk < rLine.frags.length; rfk++) rtPart(rLine.frags[rfk], rBX, rtY0 + rli * rtLH)
5696
+ }
5697
+ } else {
5698
+ // Per-unit entrance, the plain layer's own arithmetic over the
5699
+ // layout's units: delay = order(index) x st, eased over dur.
5700
+ var rtCount = 0
5701
+ for (var rci = 0; rci < rtN; rci++) rtCount += rtL.lines[rci].units.length
5702
+ var rtIdx = 0
5703
+ for (var rui = 0; rui < rtN; rui++) {
5704
+ var rULine = rtL.lines[rui]
5705
+ var rUBX = rtLeft(rULine.w)
5706
+ var rUY = rtY0 + rui * rtLH
5707
+ for (var ruj = 0; ruj < rULine.units.length; ruj++, rtIdx++) {
5708
+ var rU = rULine.units[ruj]
5709
+ var rOrd = rtFx.d === 1 ? (rtCount - 1 - rtIdx) : rtFx.d === 2 ? Math.abs(rtIdx - (rtCount - 1) / 2) : rtIdx
5710
+ var rT2 = olT - rOrd * rtFx.st
5711
+ var rUA = 1, rUu = 1
5712
+ if (rtFx.k === 'typewriter') {
5713
+ rUA = rT2 >= 0 ? 1 : 0
5714
+ } else {
5715
+ rUu = Math.max(0, Math.min(1, rT2 / rtFx.dur))
5716
+ var rUE = 1 - Math.pow(1 - rUu, 3)
5717
+ rUA = rtFx.k === 'pop' ? Math.min(1, rUu * 2) : rUE
5718
+ }
5719
+ if (rUA <= 0.004) continue
5720
+ ovC.save()
5721
+ ovC.globalAlpha = olA * rUA
5722
+ if (rUu < 1) {
5723
+ if (rtFx.k === 'rise') {
5724
+ ovC.translate(0, (1 - (1 - Math.pow(1 - rUu, 3))) * 24 * s)
5725
+ } else if (rtFx.k === 'pop') {
5726
+ var rPS = 1 + 2.70158 * Math.pow(rUu - 1, 3) + 1.70158 * Math.pow(rUu - 1, 2)
5727
+ var rCX = rUBX + rU.x + rU.w / 2
5728
+ ovC.translate(rCX, rUY)
5729
+ ovC.scale(rPS, rPS)
5730
+ ovC.translate(-rCX, -rUY)
5731
+ } else if (rtFx.k === 'blur') {
5732
+ ovC.filter = 'blur(' + ((1 - rUu) * olPx * 0.12).toFixed(2) + 'px)'
5733
+ }
5734
+ }
5735
+ for (var rpk = 0; rpk < rU.parts.length; rpk++) rtPart(rU.parts[rpk], rUBX, rUY, true)
5736
+ ovC.restore()
5737
+ }
5738
+ }
5212
5739
  }
5213
- dflush()
5214
- ovC.font = olFN
5740
+ ovC.restore()
5741
+ continue
5215
5742
  }
5216
5743
  var olLines = ol.lines || ['']
5217
5744
  // maxWidth wrap (ol.mw = frame-width fraction): greedy over word tokens
@@ -5224,7 +5751,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5224
5751
  var olWrapped = []
5225
5752
  for (var olwl = 0; olwl < olLines.length; olwl++) {
5226
5753
  var olWLine = olLines[olwl]
5227
- if (!olWLine || olMeasure(olWLine) <= olWMax) {
5754
+ if (!olWLine || ovC.measureText(olWLine).width <= olWMax) {
5228
5755
  olWrapped.push(olWLine)
5229
5756
  continue
5230
5757
  }
@@ -5232,7 +5759,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5232
5759
  var olCur = ''
5233
5760
  for (var olti = 0; olti < olToks.length; olti++) {
5234
5761
  if (!olCur) { olCur = olToks[olti]; continue }
5235
- if (olMeasure(olCur + olToks[olti]) <= olWMax) {
5762
+ if (ovC.measureText(olCur + olToks[olti]).width <= olWMax) {
5236
5763
  olCur += olToks[olti]
5237
5764
  } else {
5238
5765
  olWrapped.push(olCur)
@@ -5282,10 +5809,10 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5282
5809
  olFx = { k: olFx.k, u: olFx.u, d: olFx.d, st: olFx.st, dur: olFx.dur, tt: olFx.tt, units: olRe, n: olReN }
5283
5810
  }
5284
5811
  var olLWs = null, olMaxW = 0
5285
- if (ol.box || ol.align || olFx || olEm) {
5812
+ if (ol.box || ol.align || olFx) {
5286
5813
  olLWs = []
5287
5814
  for (var olwi = 0; olwi < olLines.length; olwi++) {
5288
- var olw = olMeasure(olLines[olwi])
5815
+ var olw = ovC.measureText(olLines[olwi]).width
5289
5816
  olLWs.push(olw)
5290
5817
  if (olw > olMaxW) olMaxW = olw
5291
5818
  }
@@ -5321,12 +5848,6 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5321
5848
  : (olMaxW - olLWs[ok]) / 2
5322
5849
  }
5323
5850
  var olLY = olY0 + ok * olLH
5324
- if (olEm) {
5325
- // Runs draw LEFT-aligned from the centred line's left edge.
5326
- ovC.textAlign = 'left'
5327
- olDraw(olLines[ok], olXof - olLWs[ok] / 2, olLY, false)
5328
- continue
5329
- }
5330
5851
  if (ol.stroke) {
5331
5852
  ovC.strokeStyle = ol.stroke.c
5332
5853
  ovC.lineWidth = ol.stroke.w * olMS * s
@@ -5363,10 +5884,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5363
5884
  : olIdx
5364
5885
  var olT2 = olT - olOrd * olFx.st
5365
5886
  var olNext = olPref + olUs[ou]
5366
- var olNW = olMeasure(olNext)
5367
- // The emphasis state this unit starts in (a char unit inside a
5368
- // marked word carries no mark of its own).
5369
- var olUB = olEm ? olStateAfter(olPref) : false
5887
+ var olNW = ovC.measureText(olNext).width
5370
5888
  var olUW = olNW - olPW
5371
5889
  var olUX = olXb + olPW
5372
5890
  var olUA = 1, olUu = 1
@@ -5395,11 +5913,6 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5395
5913
  ovC.filter = 'blur(' + ((1 - olUu) * olPx * 0.12).toFixed(2) + 'px)'
5396
5914
  }
5397
5915
  }
5398
- if (olEm) {
5399
- olDraw(olUnit, olUX, olLY2, olUB)
5400
- ovC.restore()
5401
- continue
5402
- }
5403
5916
  if (ol.stroke) {
5404
5917
  ovC.strokeStyle = ol.stroke.c
5405
5918
  ovC.lineWidth = ol.stroke.w * olMS * s
@@ -5414,7 +5927,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5414
5927
  ovC.restore()
5415
5928
  }
5416
5929
  ov.sig = ovSig
5417
- ov.active = olVisSig !== ''
5930
+ ov.wasAnim = olAnim
5418
5931
  if (ov.texture) ov.texture.needsUpdate = true
5419
5932
  }
5420
5933
 
@@ -8191,7 +8704,8 @@ var ON_FRAME = `(ctx, content, dt) => {
8191
8704
  window.__voilaDebug = { cv: cv, bgCv: bg.canvas, ovCv: ov.canvas, W: W, H: H, glW: gl && gl.width, glH: gl && gl.height, t: t, canvases: document.querySelectorAll('canvas').length }
8192
8705
  }
8193
8706
  }`;
8194
- function studioLayerData(layers, duration, pins) {
8707
+ function studioLayerData(input, duration, pins) {
8708
+ const layers = migrateText(input);
8195
8709
  const rec = "source" in layers ? layers : null;
8196
8710
  return {
8197
8711
  // Music/SFX clips with their gain envelopes baked (shared truth for the
@@ -8367,27 +8881,16 @@ function studioLayerData(layers, duration, pins) {
8367
8881
  }
8368
8882
  const st = resolveOverlayStyle(o);
8369
8883
  const bx = resolveOverlayBox(o);
8370
- const em = resolveEmphasis(o);
8884
+ const rt = styledTextOf(o);
8371
8885
  return {
8372
8886
  ...base,
8373
- text: o.text,
8374
- // The DISPLAYED lines: emphasis marks inline, else the source.
8375
- lines: overlayLines(overlayDisplayText(o)),
8376
- // Emphasis set (weight, colour, face to lazy-load), only when
8377
- // the text marks a word: an unmarked clip's data is unchanged.
8378
- ...em ? {
8379
- em: {
8380
- w: em.weight,
8381
- ...em.color ? { c: em.color } : {},
8382
- ...em.face ? {
8383
- face: {
8384
- f: em.face.family,
8385
- w: em.face.weight,
8386
- u: em.face.url
8387
- }
8388
- } : {}
8389
- }
8390
- } : {},
8887
+ text: overlayPlainText(o),
8888
+ // The lines a person reads. A styled layer (rt) is painted from
8889
+ // its runs; these are then its words, for the signature.
8890
+ lines: overlayLines(overlayPlainText(o)),
8891
+ // The styled payload, only when the layer is set in more than
8892
+ // one style: a plain clip's data is unchanged.
8893
+ ...rt ? { rt } : {},
8391
8894
  fs: st.size,
8392
8895
  weight: st.weight,
8393
8896
  stack: st.stack,
@@ -8435,7 +8938,7 @@ function studioLayerData(layers, duration, pins) {
8435
8938
  };
8436
8939
  }
8437
8940
  function lowerToComposition(input) {
8438
- const doc = migrateMotion(input);
8941
+ const doc = migrateText(migrateMotion(input));
8439
8942
  const rated = ratedSegments(doc);
8440
8943
  const transitions = docTransitions(doc);
8441
8944
  const footageEnd = durationSec(doc, rated);
@@ -8644,7 +9147,7 @@ function loweredZoomSpans(doc, rated, transitions, layout, camera, zoomStyle) {
8644
9147
  });
8645
9148
  }
8646
9149
  function docZoomTrack(input) {
8647
- const doc = migrateMotion(input);
9150
+ const doc = migrateText(migrateMotion(input));
8648
9151
  const rated = ratedSegments(doc);
8649
9152
  const transitions = docTransitions(doc);
8650
9153
  const layout = docCardLayout(doc);
@@ -10520,6 +11023,68 @@ function applyLook(frame, look, size, video, placement) {
10520
11023
  return out;
10521
11024
  }
10522
11025
 
11026
+ // src/richText/caret.ts
11027
+ function caretStops(line, measure) {
11028
+ const stops = [{ o: line.a, x: 0 }];
11029
+ for (const fr of line.frags) {
11030
+ let at = 0;
11031
+ for (const g of graphemesOf(fr.t)) {
11032
+ at += g.length;
11033
+ stops.push({
11034
+ o: line.a + fr.o + at,
11035
+ x: fr.x + measure(fr.t.slice(0, at), fr.f || 0)
11036
+ });
11037
+ }
11038
+ }
11039
+ return stops;
11040
+ }
11041
+ function lineOfOffset(layout, offset) {
11042
+ const lines = layout.lines;
11043
+ for (let i = lines.length - 1; i >= 0; i--) {
11044
+ if (offset >= lines[i].a) return i;
11045
+ }
11046
+ return 0;
11047
+ }
11048
+ function caretAt(layout, offset, measure) {
11049
+ const li = lineOfOffset(layout, offset);
11050
+ const stops = caretStops(layout.lines[li], measure);
11051
+ let x = stops[stops.length - 1].x;
11052
+ for (const s of stops) {
11053
+ if (s.o >= offset) {
11054
+ x = s.x;
11055
+ break;
11056
+ }
11057
+ }
11058
+ return { line: li, x };
11059
+ }
11060
+ function offsetAtX(line, x, measure) {
11061
+ const stops = caretStops(line, measure);
11062
+ let best = stops[0];
11063
+ for (const s of stops) {
11064
+ if (Math.abs(s.x - x) < Math.abs(best.x - x)) best = s;
11065
+ }
11066
+ return best.o;
11067
+ }
11068
+ function rangeRects(layout, start, end, measure, breakTail = 0) {
11069
+ const out = [];
11070
+ if (end <= start) return out;
11071
+ layout.lines.forEach((line, i) => {
11072
+ const lineEnd = line.a + line.n;
11073
+ if (end <= line.a || start > lineEnd) return;
11074
+ const stops = caretStops(line, measure);
11075
+ const xOf = (o) => {
11076
+ for (const s of stops) if (s.o >= o) return s.x;
11077
+ return stops[stops.length - 1].x;
11078
+ };
11079
+ const x0 = xOf(Math.max(start, line.a));
11080
+ const x1 = xOf(Math.min(end, lineEnd));
11081
+ const next = layout.lines[i + 1];
11082
+ const tail = next && next.a > lineEnd && end > lineEnd ? breakTail : 0;
11083
+ if (x1 + tail > x0) out.push({ line: i, x: x0, w: x1 - x0 + tail });
11084
+ });
11085
+ return out;
11086
+ }
11087
+
10523
11088
  // src/timeline/lanes.ts
10524
11089
  import {
10525
11090
  mapTime as mapTime3,
@@ -11508,7 +12073,7 @@ var overlaysLane = {
11508
12073
  kind: "clip",
11509
12074
  t: round13(o.start),
11510
12075
  duration: round13(o.duration),
11511
- label: o.kind === "text" ? o.text.split("\n")[0].slice(0, 24) || "Text" : o.kind === "html" ? htmlLayerLabel(o) : o.kind === "image" ? "Image" : "Video"
12076
+ label: o.kind === "text" ? plainText(o.text).split("\n")[0].slice(0, 24) || "Text" : o.kind === "html" ? htmlLayerLabel(o) : o.kind === "image" ? "Image" : "Video"
11512
12077
  },
11513
12078
  // Pose diamonds render after the clips, so they sit on top.
11514
12079
  ...poseItems(o.id, o.start, o.duration, o.motion)
@@ -11900,6 +12465,7 @@ export {
11900
12465
  CURSOR_IDLE_FADE_OUT,
11901
12466
  CURSOR_IDLE_HOLD,
11902
12467
  DARK_GROUND,
12468
+ DECORATION_THICKNESS_EM,
11903
12469
  DEFAULT_BROWSER_BAR,
11904
12470
  DEFAULT_CAM_POSE,
11905
12471
  DEFAULT_CAM_STYLE,
@@ -11920,8 +12486,6 @@ export {
11920
12486
  DRAG_FIT_LEVEL,
11921
12487
  DRAG_MIN_S,
11922
12488
  DRAG_MIN_TRAVEL_FRAC,
11923
- EM_CLOSE,
11924
- EM_OPEN,
11925
12489
  END_CARD_FROM,
11926
12490
  END_CARD_RECEDE,
11927
12491
  END_CARD_SECONDS,
@@ -11937,6 +12501,7 @@ export {
11937
12501
  FREEZE_DEFAULT_SECONDS,
11938
12502
  FREEZE_SECONDS_MAX,
11939
12503
  FREEZE_SECONDS_MIN,
12504
+ HIGHLIGHT_HALF_EM,
11940
12505
  HOLD_DRIFT_MAX,
11941
12506
  HOUSE_GRADIENT,
11942
12507
  HOUSE_REGISTER,
@@ -11954,6 +12519,7 @@ export {
11954
12519
  MEDIA_FRAME_KEYS,
11955
12520
  MEDIA_KEY_PREFIX,
11956
12521
  MINIMAL_BAR_THEMES,
12522
+ MIXED,
11957
12523
  MOTION_DELTA,
11958
12524
  MOTION_EASE,
11959
12525
  OBJECT_DEFAULT_SCALE,
@@ -11977,6 +12543,7 @@ export {
11977
12543
  REJECT_OVERLAP,
11978
12544
  REST_TILT_ID,
11979
12545
  RETIRED_ZOOM_STYLES,
12546
+ RUN_STYLE_KEYS,
11980
12547
  SCENE_MOTION,
11981
12548
  SCENE_QUIET,
11982
12549
  SEGMENT_ENTER_KINDS,
@@ -11986,6 +12553,7 @@ export {
11986
12553
  SPEED_SPAN_MIN,
11987
12554
  STAGE_CLIP_PREFIX,
11988
12555
  STILL_DEFAULT,
12556
+ STRIKE_OFFSET_EM,
11989
12557
  STUDIO_ENTRY_ID,
11990
12558
  STYLE_FIELDS,
11991
12559
  TEXT3D_DEPTH_DEFAULT,
@@ -12010,6 +12578,7 @@ export {
12010
12578
  TRANSITION_SECONDS_MAX,
12011
12579
  TRANSITION_SECONDS_MIN,
12012
12580
  TRANSITION_SPEED_MULT,
12581
+ UNDERLINE_OFFSET_EM,
12013
12582
  WINDOW_FOCUS_MIN,
12014
12583
  ZOOM_CHAIN_GAP,
12015
12584
  ZOOM_EASE,
@@ -12032,6 +12601,7 @@ export {
12032
12601
  aspectRatioValue,
12033
12602
  audioLane,
12034
12603
  backdropMedia,
12604
+ boldWeightFor,
12035
12605
  buildDigest,
12036
12606
  calloutBox,
12037
12607
  calloutClip,
@@ -12053,6 +12623,8 @@ export {
12053
12623
  cardInset,
12054
12624
  cardPointToScreen,
12055
12625
  cardPoseTrack,
12626
+ caretAt,
12627
+ caretStops,
12056
12628
  clampCamFrac,
12057
12629
  clampCamSize,
12058
12630
  clampFocus,
@@ -12060,9 +12632,11 @@ export {
12060
12632
  clampSpeedRate,
12061
12633
  clampTiltDeg,
12062
12634
  clampZoomLevel,
12635
+ clearStyle,
12063
12636
  clipEnvelope,
12064
12637
  clipLength,
12065
12638
  clipsFrom,
12639
+ commonStyle,
12066
12640
  computeCardLayout,
12067
12641
  computeMicRms,
12068
12642
  computePeaks,
@@ -12076,10 +12650,12 @@ export {
12076
12650
  cursorFromRecords,
12077
12651
  cursorFromTrace,
12078
12652
  cursorIdleFade,
12653
+ decorationRect,
12079
12654
  deltaE,
12080
12655
  deriveViewportCrop,
12081
12656
  destinationById,
12082
12657
  destinationsForChannel,
12658
+ diffInput,
12083
12659
  docCardLayout,
12084
12660
  docFreezes,
12085
12661
  docMediaList,
@@ -12146,14 +12722,16 @@ export {
12146
12722
  lastLayerEnd,
12147
12723
  layerMedia,
12148
12724
  layoutOf,
12725
+ layoutText,
12149
12726
  levelForFocusFraction,
12727
+ lineOfOffset,
12150
12728
  liveZoomStyleName,
12151
12729
  lookFromBrand,
12152
12730
  lookKindForGround,
12153
12731
  lowerProgramDoc,
12154
12732
  lowerStudioDoc,
12155
12733
  lowerToComposition,
12156
- measureEmphasized,
12734
+ marksEmphasis,
12157
12735
  mediaAtOutput,
12158
12736
  mediaDuration,
12159
12737
  mediaFrame,
@@ -12164,20 +12742,23 @@ export {
12164
12742
  micLane,
12165
12743
  migrateHostedDoc,
12166
12744
  migrateMotion,
12745
+ migrateText,
12746
+ migrateTextClip,
12167
12747
  migrateZoomStyle,
12168
12748
  momentsFromDoc,
12169
12749
  motionTrack,
12170
12750
  musicBedClip,
12171
12751
  nextMediaId,
12172
12752
  normalizeCaptureSpace,
12753
+ normalizeRuns,
12173
12754
  objectMotionBase,
12174
12755
  objectMotionPoseAt,
12175
12756
  objectsLane,
12757
+ offsetAtX,
12176
12758
  outputDurationOf,
12177
12759
  outputEnd,
12178
12760
  outputRangeToSource,
12179
12761
  overlapFraction,
12180
- overlayDisplayText,
12181
12762
  overlayFaceFor,
12182
12763
  overlayFontFaces,
12183
12764
  overlayFontString,
@@ -12187,12 +12768,12 @@ export {
12187
12768
  overlayMotionBase,
12188
12769
  overlayMotionPoseAt,
12189
12770
  overlayPinPoseAt,
12771
+ overlayPlainText,
12190
12772
  overlayRect,
12191
12773
  overlaysLane,
12192
12774
  ownFreezes,
12193
12775
  pageDisplayUrl,
12194
12776
  parseCssColour,
12195
- parseEmphasis,
12196
12777
  parsePoseId,
12197
12778
  pickStyle,
12198
12779
  pinBox,
@@ -12202,6 +12783,7 @@ export {
12202
12783
  pinReferent,
12203
12784
  pinReferentAt,
12204
12785
  placeFreezes,
12786
+ plainText,
12205
12787
  planAutoSpeed,
12206
12788
  planAutoTilt,
12207
12789
  planAutoZoom,
@@ -12213,6 +12795,7 @@ export {
12213
12795
  programStillTime,
12214
12796
  projectFromArtifact,
12215
12797
  pumpFreeChainGap,
12798
+ rangeRects,
12216
12799
  ratedSegments,
12217
12800
  recommendedExportResolution,
12218
12801
  refillAudioBeds,
@@ -12220,7 +12803,7 @@ export {
12220
12803
  relativeLuminance,
12221
12804
  removeSourceRange,
12222
12805
  removeSpeedInRange,
12223
- resolveEmphasis,
12806
+ replaceRange,
12224
12807
  resolveExportSize,
12225
12808
  resolveOverlayBox,
12226
12809
  resolveOverlayStyle,
@@ -12230,6 +12813,7 @@ export {
12230
12813
  restLeanOf,
12231
12814
  restSpansThroughTransitions,
12232
12815
  rgbToHex,
12816
+ runsOf,
12233
12817
  sameMedia,
12234
12818
  sceneChanges,
12235
12819
  scrollRuns,
@@ -12237,16 +12821,24 @@ export {
12237
12821
  outputLen as segmentOutputLen,
12238
12822
  segmentStarts,
12239
12823
  setSpeedInRange,
12824
+ setStyle,
12825
+ sliceRuns,
12240
12826
  slideSide,
12241
12827
  smoothCursor,
12828
+ snapOffset,
12829
+ snapRunWeight,
12242
12830
  spanOutputExtent,
12243
12831
  speedLane,
12244
- stripEmphasis,
12245
12832
  studioAudioPlan,
12246
12833
  studioEntry,
12247
12834
  studioLayerData,
12835
+ styleAt,
12836
+ styleOf,
12837
+ styledTextOf,
12248
12838
  tiltLane,
12249
12839
  tiltTrackFromDoc,
12840
+ toggleStyle,
12841
+ tokenRanges,
12250
12842
  traceKindOf,
12251
12843
  transitionMult,
12252
12844
  transitionSeconds,