@vosjs/studio-core 0.37.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,1977 +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;
1829
- }
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;
1834
- }
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
1874
- }
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
2027
- }
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 }
2033
- };
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;
2041
- }
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
- }
2065
- ];
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);
2075
- }
2076
- if (version < 5) doc = migrateZoomStyle(doc);
2077
- return { ...doc, docSchemaVersion: DOC_SCHEMA_VERSION };
2078
- }
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
2088
- };
2089
- }
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;
2100
- }
2101
- function anchorSourceDuration(doc) {
2102
- return isRecordingDoc(doc) ? doc.source.meta.durationMs / 1e3 : programDuration(doc);
2103
- }
1820
+ // src/overlayText.ts
1821
+ import {
1822
+ findFontFamily as findFontFamily2,
1823
+ fontFaceUrl as fontFaceUrl2,
1824
+ fontStack,
1825
+ nearestFontWeight as nearestFontWeight2
1826
+ } from "@vosjs/shared";
2104
1827
 
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 };
2120
- }
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;
2121
1846
  }
2122
- return { x: last.x, y: last.y };
1847
+ if (cur.trim()) out.push(cur);
1848
+ return out;
2123
1849
  }
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;
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 [];
2133
1871
  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;
2153
- }
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);
2154
1882
  }
2155
- out.push({ t, x: cx, y: cy });
2156
1883
  }
2157
1884
  return out;
2158
1885
  }
2159
- function clamp2(v, lo, hi) {
2160
- return Math.max(lo, Math.min(hi, v));
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);
1892
+ }
1893
+ if (/<(b|strong|h[1-6])[\s>]/i.test(html)) out.add(700);
1894
+ return [...out].sort((a, b) => a - b);
2161
1895
  }
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"));
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) {
2177
1935
  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;
1936
+ const seen = /* @__PURE__ */ new Set();
1937
+ const add = (u) => {
1938
+ if (!seen.has(u)) {
1939
+ seen.add(u);
1940
+ out.push(u);
2191
1941
  }
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
- }
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]);
2201
1952
  return out;
2202
1953
  }
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
- }
2229
- }
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);
2236
- }
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]);
2245
- }
2246
- return clusters;
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
+ );
2247
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);
2248
1987
  return {
2249
- sessions,
2250
- clusters: chain(targets, true),
2251
- surfaces: chain(surfaces, false)
1988
+ width: clip.box.width + bleed * 2,
1989
+ height: clip.box.height + bleed * 2,
1990
+ bleed
2252
1991
  };
2253
1992
  }
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;
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: " "
2004
+ };
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();
2013
+ }
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"
2031
+ ];
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
+ }
2064
+ }
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;
2143
+ }
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
+ );
2260
2176
  }
2261
- kept.push(e);
2262
- }
2263
- return kept;
2177
+ return out;
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>`;
2264
2182
  }
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);
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;
2276
2188
  }
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);
2189
+ return h.toString(36);
2283
2190
  }
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);
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)}`;
2287
2196
  }
2288
- function isSurfacePress(c, width, height, targetFill) {
2289
- const fit = fitLevel(c.rect, width, height, targetFill);
2290
- return fit !== null && fit < DRAG_FIT_LEVEL;
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 } : {} } : {}
2212
+ }
2213
+ };
2291
2214
  }
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
- });
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;
2363
2224
  }
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;
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]);
2368
2233
  }
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;
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 });
2260
+ }
2261
+ at += r.t.length;
2262
+ if (at >= b) break;
2263
+ }
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
+ }
2400
2321
  } else {
2401
- t.in = z.out;
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
+ }
2402
2328
  }
2329
+ line.units = units;
2403
2330
  }
2404
- }
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;
2408
- }
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
2435
- );
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);
2441
- }
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;
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);
2451
2340
  } else {
2452
- cur = { events: [p], first: p.t, last: p.t, start: p.t, cx: nx, cy: ny };
2453
- all.push(cur);
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);
2454
2354
  }
2355
+ base += len + 1;
2455
2356
  }
2456
- return all.filter(
2457
- (s) => s.events.length >= TYPING_MIN_PINGS && s.last - s.first >= TYPING_MIN_DUR
2458
- );
2459
- }
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)];
2357
+ if (!lines.length) lines.push({ w: 0, frags: [], a: 0, n: 0 });
2358
+ return { lines, width };
2464
2359
  }
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);
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 };
2516
2371
  }
2517
- return accepted.sort((a, b) => a.in - b.in).map((z, i) => ({ ...z, id: `d${i}` }));
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 };
2518
2377
  }
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;
2533
- }
2534
- }
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) };
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;
2393
+ return out;
2541
2394
  }
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;
2562
- }
2395
+ function runsOf(text) {
2396
+ return typeof text === "string" ? text ? [{ text }] : [] : text;
2397
+ }
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;
2412
+ const out = [];
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;
2422
+ return out;
2423
+ }
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;
2563
2432
  }
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 };
2433
+ return plain.length;
2572
2434
  }
2573
- function clamp3(v, lo, hi) {
2574
- return Math.max(lo, Math.min(hi, v));
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;
2444
+ }
2445
+ return out;
2575
2446
  }
2576
- function clamp012(v) {
2577
- return clamp3(v, 0, 1);
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
+ ]);
2578
2470
  }
2579
- function round2(v) {
2580
- return Math.round(v * 1e3) / 1e3;
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] });
2486
+ }
2487
+ return next;
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
+ ]);
2494
+ }
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);
2499
+ }
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;
2510
+ }
2511
+ return out;
2512
+ }
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
+ };
2581
2531
  }
2582
2532
 
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)
2624
- );
2625
- const dy2 = Math.min(
2626
- vTop,
2627
- Math.max(vTop + availH - dh2, vTop + availH / 2 - fcy * dh2)
2628
- );
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
- };
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
+ };
2547
+ }
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
2574
+ }
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)
2585
+ }
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}`;
2596
+ }
2597
+ if (clip.weight !== void 0 || clip.family) {
2598
+ const wanted = clip.weight ?? base.weight;
2599
+ weight = familyEntry ? nearestFontWeight2(familyEntry, wanted) : wanted;
2641
2600
  }
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;
2647
2601
  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
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
2658
2618
  };
2659
2619
  }
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))
2620
+ function overlayFaceFor(clip) {
2621
+ const entry = findFontFamily2(
2622
+ clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
2666
2623
  );
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
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
2677
2628
  );
2629
+ if (inBase) return null;
2630
+ return {
2631
+ family: entry.family,
2632
+ weight,
2633
+ url: fontFaceUrl2(entry.slug, weight)
2634
+ };
2678
2635
  }
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;
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 });
2651
+ }
2652
+ }
2653
+ return faces;
2654
+ }
2655
+ function overlayLines(text) {
2656
+ const lines = text.split("\n");
2657
+ return lines.length ? lines : [""];
2658
+ }
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);
2688
2683
  }
2689
- return best;
2684
+ return (h >>> 0).toString(36);
2690
2685
  }
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;
2697
- return {
2698
- x,
2699
- y,
2700
- size,
2701
- radius: cam.shape === "rounded" ? (cam.radius ?? 18) * s : size / 2
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;
2699
+ }
2700
+ return at;
2702
2701
  };
2703
- }
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
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
2719
2707
  );
2720
- const y2 = stageAxisBounds(
2721
- layout.dy,
2722
- layout.dh,
2723
- layout.cardY,
2724
- layout.cardH,
2725
- layout.H,
2726
- level
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
+ });
2719
+ }
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
2727
  );
2728
- return { minX: x2.min, maxX: x2.max, minY: y2.min, maxY: y2.max };
2728
+ if (!inBase) {
2729
+ faces.push({
2730
+ f: entry.family,
2731
+ w: font.w,
2732
+ u: fontFaceUrl2(entry.slug, font.w)
2733
+ });
2734
+ }
2729
2735
  }
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 };
2736
+ return {
2737
+ l,
2738
+ fs,
2739
+ ...faces.length ? { faces } : {},
2740
+ h: hashOf(JSON.stringify([l, fs]))
2741
+ };
2747
2742
  }
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;
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
+ }
2766
+ }
2767
+ if (current) out.push(current);
2756
2768
  }
2757
- return { min: clamp013(lo), max: clamp013(hi) };
2769
+ return out.length ? out : [""];
2758
2770
  }
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;
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);
2767
2778
  }
2768
- return { min: clamp013(lo), max: clamp013(hi) };
2779
+ return [...line];
2769
2780
  }
2770
- function clampFocus(cx, cy, level, layout, camera = "card", screen) {
2771
- const b = focusBounds(level, layout, camera, screen);
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);
2787
+ }
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;
2772
2809
  return {
2773
- cx: Math.min(b.maxX, Math.max(b.minX, cx)),
2774
- cy: Math.min(b.maxY, Math.max(b.minY, cy))
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
2775
2818
  };
2776
2819
  }
2777
- function clamp013(v) {
2778
- return Math.max(0, Math.min(1, v));
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}`;
2779
2823
  }
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 };
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
+ )
2853
+ );
2854
+ const laid = layoutText(
2855
+ { lines: styled.l, maxWidth: clip.maxWidth ? clip.maxWidth * frameW : 0 },
2856
+ (t, f) => measure(t, fonts[f], ls)
2857
+ );
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;
2869
+ }
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;
2874
+ return {
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
2879
+ };
2782
2880
  }
2783
- function cameraModel(frame) {
2784
- return frame?.camera === "stage" ? "stage" : "card";
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;
2785
2892
  }
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);
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;
2923
+ }
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;
2791
2929
  }
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 };
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) };
2801
2954
  }
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;
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;
2807
2959
  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
2960
+ ...doc,
2961
+ overlays: overlays.map((o) => o.kind === "text" ? migrateTextClip(o) : o)
2812
2962
  };
2813
2963
  }
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 };
2823
- }
2824
- function levelForFocusFraction(frac) {
2825
- if (!(frac > 0)) return clampZoomLevel(Number.POSITIVE_INFINITY);
2826
- return clampZoomLevel(Math.max(1.1, 1 / frac));
2827
- }
2828
2964
 
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 };
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;
2841
2974
  }
2842
- function cardPointToScreen(u, v) {
2843
- return { sx: u, sy: v };
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;
2844
2979
  }
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;
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
2872
3019
  }
2873
- if (cur.trim()) out.push(cur);
2874
- return out;
2875
- }
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);
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
2908
3135
  }
2909
- }
2910
- return out;
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;
2911
3186
  }
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);
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"
2918
3209
  }
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
- });
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);
2942
3221
  }
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())
2958
- );
2959
- }
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);
2967
- }
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)));
3222
+ if (version < 5) doc = migrateZoomStyle(doc);
3223
+ return { ...doc, docSchemaVersion: DOC_SCHEMA_VERSION };
3010
3224
  }
3011
- function htmlLayerPictureBox(clip) {
3012
- const bleed = htmlLayerBleed(clip);
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;
3013
3230
  return {
3014
- width: clip.box.width + bleed * 2,
3015
- height: clip.box.height + bleed * 2,
3016
- bleed
3231
+ ...doc,
3232
+ zoomStyle: retired.style,
3233
+ tiltStyle: doc.tiltStyle ?? retired.tilt
3017
3234
  };
3018
3235
  }
3019
- function htmlLayerWidth(clip) {
3020
- if (clip.width != null) return clip.width;
3021
- return Math.min(1, htmlLayerPictureBox(clip).width / DESIGN_FRAME_WIDTH);
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;
3022
3246
  }
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();
3247
+ function anchorSourceDuration(doc) {
3248
+ return isRecordingDoc(doc) ? doc.source.meta.durationMs / 1e3 : programDuration(doc);
3039
3249
  }
3040
- function htmlLayerLabel(clip) {
3041
- const words = htmlLayerText(clip.html);
3042
- return words ? words.slice(0, 24) : "HTML";
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 };
3043
3269
  }
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
- );
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;
3088
3299
  }
3089
3300
  }
3301
+ out.push({ t, x: cx, y: cy });
3090
3302
  }
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.`
3167
- );
3168
- return problems;
3303
+ return out;
3169
3304
  }
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." };
3305
+ function clamp2(v, lo, hi) {
3306
+ return Math.max(lo, Math.min(hi, v));
3176
3307
  }
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}`;
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
+ }
3181
3347
  return out;
3182
3348
  }
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
- );
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]);
3202
3391
  }
3203
- return out;
3392
+ return clusters;
3204
3393
  };
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>`;
3208
- }
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;
3214
- }
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;
3227
3394
  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
- }
3395
+ sessions,
3396
+ clusters: chain(targets, true),
3397
+ surfaces: chain(surfaces, false)
3239
3398
  };
3240
3399
  }
3241
-
3242
- // src/richText/layout.ts
3243
- 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;
3244
- function graphemesOf(text) {
3245
- if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
3246
- const seg = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3247
- const out = [];
3248
- for (const s of seg.segment(text)) out.push(s.segment);
3249
- return out;
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);
3250
3408
  }
3251
- return Array.from(text);
3409
+ return kept;
3252
3410
  }
3253
- function tokenRanges(line) {
3254
- const out = [];
3255
- const re = /\S+\s*/g;
3256
- let m;
3257
- while ((m = re.exec(line)) !== null) {
3258
- out.push([m.index, m.index + m[0].length]);
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);
3259
3422
  }
3260
- return out.length ? out : [[0, line.length]];
3423
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
3261
3424
  }
3262
- function layoutText(input, measure) {
3263
- const memo = {};
3264
- const adv = (t, f) => {
3265
- if (!t) return 0;
3266
- const key = f + "\0" + t;
3267
- let w = memo[key];
3268
- if (w === void 0) {
3269
- w = measure(t, f);
3270
- memo[key] = w;
3271
- }
3272
- return w;
3273
- };
3274
- const slice = (runs, a, b) => {
3275
- const out = [];
3276
- let at = 0;
3277
- for (let i = 0; i < runs.length; i++) {
3278
- const r = runs[i];
3279
- const from = Math.max(a, at);
3280
- const to = Math.min(b, at + r.t.length);
3281
- if (to > from) {
3282
- const t = r.t.slice(from - at, to - at);
3283
- const last = out[out.length - 1];
3284
- if (last && sameStyle(last, r)) last.t += t;
3285
- else out.push({ ...r, t });
3286
- }
3287
- at += r.t.length;
3288
- if (at >= b) break;
3289
- }
3290
- return out;
3291
- };
3292
- const widthOf = (runs) => {
3293
- let w = 0;
3294
- for (let i = 0; i < runs.length; i++) w += adv(runs[i].t, runs[i].f || 0);
3295
- return w;
3296
- };
3297
- const partsIn = (frags, a, b) => {
3298
- const out = [];
3299
- for (let i = 0; i < frags.length; i++) {
3300
- const fr = frags[i];
3301
- const from = Math.max(a, fr.o);
3302
- const to = Math.min(b, fr.o + fr.t.length);
3303
- if (to <= from) continue;
3304
- const f = fr.f || 0;
3305
- const before = adv(fr.t.slice(0, from - fr.o), f);
3306
- out.push({
3307
- ...fr,
3308
- t: fr.t.slice(from - fr.o, to - fr.o),
3309
- x: fr.x + before,
3310
- w: adv(fr.t.slice(0, to - fr.o), f) - before,
3311
- o: from
3312
- });
3313
- }
3314
- return out;
3315
- };
3316
- const unitOf = (frags, a, b) => {
3317
- const parts = partsIn(frags, a, b);
3318
- if (!parts.length) return { x: 0, w: 0, parts };
3319
- const last = parts[parts.length - 1];
3320
- return { x: parts[0].x, w: last.x + last.w - parts[0].x, parts };
3321
- };
3322
- const lines = [];
3323
- const max = input.maxWidth && input.maxWidth > 0 ? input.maxWidth : 0;
3324
- let width = 0;
3325
- let base = 0;
3326
- const push2 = (runs, a, b) => {
3327
- const cut = slice(runs, a, b);
3328
- const frags = [];
3329
- let x = 0;
3330
- let o = 0;
3331
- for (let i = 0; i < cut.length; i++) {
3332
- const w = adv(cut[i].t, cut[i].f || 0);
3333
- frags.push({ ...cut[i], x, w, o });
3334
- x += w;
3335
- o += cut[i].t.length;
3336
- }
3337
- const line = { w: x, frags, a: base + a, n: b - a };
3338
- if (input.unit) {
3339
- const text = cut.map((r) => r.t).join("");
3340
- const units = [];
3341
- if (input.unit === "line") units.push(unitOf(frags, 0, text.length));
3342
- else if (input.unit === "word") {
3343
- const toks = tokenRanges(text);
3344
- for (let i = 0; i < toks.length; i++) {
3345
- units.push(unitOf(frags, toks[i][0], toks[i][1]));
3346
- }
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);
3429
+ }
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);
3433
+ }
3434
+ function isSurfacePress(c, width, height, targetFill) {
3435
+ const fit = fitLevel(c.rect, width, height, targetFill);
3436
+ return fit !== null && fit < DRAG_FIT_LEVEL;
3437
+ }
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;
3347
3546
  } else {
3348
- let at = 0;
3349
- const gs = graphemesOf(text);
3350
- for (let i = 0; i < gs.length; i++) {
3351
- units.push(unitOf(frags, at, at + gs[i].length));
3352
- at += gs[i].length;
3353
- }
3354
- }
3355
- line.units = units;
3356
- }
3357
- if (x > width) width = x;
3358
- lines.push(line);
3359
- };
3360
- for (let li = 0; li < input.lines.length; li++) {
3361
- const runs = input.lines[li];
3362
- let len = 0;
3363
- for (let i = 0; i < runs.length; i++) len += runs[i].t.length;
3364
- if (!max || !len || widthOf(slice(runs, 0, len)) <= max) {
3365
- push2(runs, 0, len);
3366
- } else {
3367
- const text = runs.map((r) => r.t).join("");
3368
- const toks = tokenRanges(text);
3369
- let a = 0;
3370
- let b = toks[0][1];
3371
- for (let i = 1; i < toks.length; i++) {
3372
- if (widthOf(slice(runs, a, toks[i][1])) <= max) b = toks[i][1];
3373
- else {
3374
- push2(runs, a, b);
3375
- a = toks[i][0];
3376
- b = toks[i][1];
3377
- }
3547
+ t.in = z.out;
3378
3548
  }
3379
- push2(runs, a, b);
3380
3549
  }
3381
- base += len + 1;
3382
3550
  }
3383
- if (!lines.length) lines.push({ w: 0, frags: [], a: 0, n: 0 });
3384
- return { lines, width };
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;
3554
+ }
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
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);
3385
3587
  }
3386
- var UNDERLINE_OFFSET_EM = 0.42;
3387
- var DECORATION_THICKNESS_EM = 0.065;
3388
- var STRIKE_OFFSET_EM = 0.02;
3389
- var HIGHLIGHT_HALF_EM = 0.58;
3390
- function decorationRect(kind, x, w, y, px) {
3391
- const x0 = Math.round(x);
3392
- const x1 = Math.round(x + w);
3393
- if (kind === "h") {
3394
- const top2 = Math.round(y - HIGHLIGHT_HALF_EM * px);
3395
- const bottom = Math.round(y + HIGHLIGHT_HALF_EM * px);
3396
- return { x: x0, y: top2, w: x1 - x0, h: bottom - top2 };
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);
3600
+ }
3397
3601
  }
3398
- const t = Math.max(1, Math.round(DECORATION_THICKNESS_EM * px));
3399
- const top = Math.round(
3400
- y + (kind === "u" ? UNDERLINE_OFFSET_EM : STRIKE_OFFSET_EM) * px
3602
+ return all.filter(
3603
+ (s) => s.events.length >= TYPING_MIN_PINGS && s.last - s.first >= TYPING_MIN_DUR
3401
3604
  );
3402
- return { x: x0, y: top, w: x1 - x0, h: t };
3403
3605
  }
3404
-
3405
- // src/overlayText.ts
3406
- var OVERLAY_BOX_PAD_X = 0.6;
3407
- var OVERLAY_BOX_PAD_Y = 0.35;
3408
- var OVERLAY_BOX_RADIUS = 0.25;
3409
- function resolveOverlayBox(clip) {
3410
- if (!clip.box) return null;
3411
- const size = resolveOverlayStyle(clip).size;
3412
- return {
3413
- color: clip.box.color,
3414
- opacity: clip.box.opacity ?? 1,
3415
- padX: (clip.box.paddingX ?? OVERLAY_BOX_PAD_X) * size,
3416
- padY: (clip.box.paddingY ?? OVERLAY_BOX_PAD_Y) * size,
3417
- radius: (clip.box.radius ?? OVERLAY_BOX_RADIUS) * size
3418
- };
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)];
3419
3610
  }
3420
- var PRESET_FAMILY = {
3421
- title: "Lexend",
3422
- caption: "Lexend",
3423
- label: "JetBrains Mono"
3424
- };
3425
- var TEXT_PRESETS = {
3426
- title: {
3427
- stack: "Lexend, -apple-system, system-ui, sans-serif",
3428
- weight: 600,
3429
- size: 64,
3430
- color: "#fafafa",
3431
- shadow: 0.45
3432
- },
3433
- caption: {
3434
- stack: "Lexend, -apple-system, system-ui, sans-serif",
3435
- weight: 400,
3436
- size: 32,
3437
- color: "#f4f4f5",
3438
- shadow: 0.4
3439
- },
3440
- label: {
3441
- stack: "'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
3442
- weight: 400,
3443
- size: 22,
3444
- color: "#e4e4e7",
3445
- shadow: 0.35
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
+ });
3631
+ }
3632
+ start = i;
3446
3633
  }
3447
- };
3448
- var OVERLAY_SIZE_MIN = 12;
3449
- var OVERLAY_SIZE_MAX = 200;
3450
- var OVERLAY_FONT_FACES = [
3451
- { family: "Lexend", weight: 400, url: fontFaceUrl2("lexend", 400) },
3452
- { family: "Lexend", weight: 600, url: fontFaceUrl2("lexend", 600) },
3453
- {
3454
- family: "JetBrains Mono",
3455
- weight: 400,
3456
- url: fontFaceUrl2("jetbrains-mono", 400)
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)
3645
+ );
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}` }));
3664
+ }
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
+ }
3457
3680
  }
3458
- ];
3459
- function resolveOverlayStyle(clip) {
3460
- const presetName = clip.preset in TEXT_PRESETS ? clip.preset : "title";
3461
- const base = TEXT_PRESETS[presetName];
3462
- let stack = base.stack;
3463
- let weight = base.weight;
3464
- const familyEntry = findFontFamily2(clip.family ?? PRESET_FAMILY[presetName]);
3465
- if (clip.family) {
3466
- const quoted = clip.family.includes(" ") ? `'${clip.family}'` : clip.family;
3467
- stack = familyEntry ? fontStack(familyEntry) : `${quoted}, ${base.stack}`;
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) };
3687
+ }
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;
3708
+ }
3468
3709
  }
3469
- if (clip.weight !== void 0 || clip.family) {
3470
- const wanted = clip.weight ?? base.weight;
3471
- weight = familyEntry ? nearestFontWeight2(familyEntry, wanted) : wanted;
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 };
3718
+ }
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
+ };
3472
3787
  }
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;
3473
3793
  return {
3474
- ...base,
3475
- stack,
3476
- weight,
3477
- ...clip.size !== void 0 ? {
3478
- size: Math.min(
3479
- OVERLAY_SIZE_MAX,
3480
- Math.max(OVERLAY_SIZE_MIN, clip.size)
3481
- )
3482
- } : {},
3483
- ...clip.color ? { color: clip.color } : {},
3484
- ...clip.shadow !== void 0 ? { shadow: Math.min(1, Math.max(0, clip.shadow)) } : {},
3485
- fontStyle: clip.italic ? "italic" : "normal",
3486
- align: clip.align ?? "center",
3487
- letterSpacing: clip.letterSpacing ?? 0,
3488
- lineHeight: clip.lineHeight ?? OVERLAY_LINE_HEIGHT,
3489
- stroke: clip.stroke ?? null
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
3490
3804
  };
3491
3805
  }
3492
- function overlayFaceFor(clip) {
3493
- const entry = findFontFamily2(
3494
- clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
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))
3495
3812
  );
3496
- if (!entry) return null;
3497
- const weight = resolveOverlayStyle(clip).weight;
3498
- const inBase = OVERLAY_FONT_FACES.some(
3499
- (f) => f.family === entry.family && f.weight === weight
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
3500
3823
  );
3501
- if (inBase) return null;
3502
- return {
3503
- family: entry.family,
3504
- weight,
3505
- url: fontFaceUrl2(entry.slug, weight)
3506
- };
3507
3824
  }
3508
- function overlayFontFaces(doc) {
3509
- const faces = [...OVERLAY_FONT_FACES];
3510
- const seen = new Set(faces.map((f) => `${f.family}|${f.weight}`));
3511
- const add = (face) => {
3512
- if (!face) return;
3513
- const key = `${face.family}|${face.weight}`;
3514
- if (seen.has(key)) return;
3515
- seen.add(key);
3516
- faces.push(face);
3517
- };
3518
- for (const o of doc.overlays ?? []) {
3519
- if (o.kind !== "text") continue;
3520
- add(overlayFaceFor(o));
3521
- add(resolveEmphasis(o)?.face);
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;
3522
3834
  }
3523
- return faces;
3524
- }
3525
- function overlayLines(text) {
3526
- const lines = text.split("\n");
3527
- return lines.length ? lines : [""];
3528
- }
3529
- var EM_OPEN = "";
3530
- var EM_CLOSE = "";
3531
- function parseEmphasis(text) {
3532
- let any = false;
3533
- const lines = text.split("\n").map((line) => {
3534
- const chars = [];
3535
- const bold = [];
3536
- const marker = [];
3537
- for (let i = 0; i < line.length; i++) {
3538
- if (line[i] === "\\" && line[i + 1] === "*") {
3539
- chars.push("*");
3540
- marker.push(false);
3541
- i++;
3542
- } else {
3543
- chars.push(line[i]);
3544
- marker.push(line[i] === "*");
3545
- }
3546
- }
3547
- const keep = chars.map(() => true);
3548
- for (let i = 0; i < chars.length; i++) {
3549
- if (!marker[i]) continue;
3550
- let j = i + 1;
3551
- while (j < chars.length && !marker[j]) j++;
3552
- if (j >= chars.length) break;
3553
- const inner = chars.slice(i + 1, j).join("");
3554
- if (!inner.trim()) {
3555
- i = j - 1;
3556
- continue;
3557
- }
3558
- keep[i] = false;
3559
- keep[j] = false;
3560
- for (let k = i + 1; k < j; k++) bold[k] = true;
3561
- any = true;
3562
- i = j;
3563
- }
3564
- const kept = [];
3565
- for (let i = 0; i < chars.length; i++) {
3566
- if (keep[i])
3567
- kept.push({ c: chars[i], b: !!bold[i] && !/\s/.test(chars[i]) });
3568
- }
3569
- let out = "";
3570
- for (let i = 0; i < kept.length; i++) {
3571
- const { c, b } = kept[i];
3572
- if (b && !(i > 0 && kept[i - 1].b)) out += EM_OPEN;
3573
- out += c;
3574
- if (b && !(i + 1 < kept.length && kept[i + 1].b)) out += EM_CLOSE;
3575
- }
3576
- return out;
3577
- });
3578
- return any ? lines.join("\n") : null;
3579
- }
3580
- function overlayDisplayText(clip) {
3581
- if (!clip.emphasis) return clip.text;
3582
- return parseEmphasis(clip.text) ?? clip.text;
3835
+ return best;
3583
3836
  }
3584
- function stripEmphasis(text) {
3585
- return text.split(EM_OPEN).join("").split(EM_CLOSE).join("");
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;
3843
+ return {
3844
+ x,
3845
+ y,
3846
+ size,
3847
+ radius: cam.shape === "rounded" ? (cam.radius ?? 18) * s : size / 2
3848
+ };
3586
3849
  }
3587
- var EMPHASIS_WEIGHT = 700;
3588
- function resolveEmphasis(clip) {
3589
- if (!clip.emphasis || parseEmphasis(clip.text) === null) return null;
3590
- const style = resolveOverlayStyle(clip);
3591
- const entry = findFontFamily2(
3592
- clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
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 };
3853
+ }
3854
+ if (level <= 1.001) {
3855
+ return { minX: 0.5, maxX: 0.5, minY: 0.5, maxY: 0.5 };
3856
+ }
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 };
3875
+ }
3876
+ const x = axisBounds(
3877
+ layout.dx,
3878
+ layout.dw,
3879
+ layout.cardX,
3880
+ layout.cardW,
3881
+ layout.W,
3882
+ level
3593
3883
  );
3594
- const wanted = clip.emphasis?.weight ?? Math.max(EMPHASIS_WEIGHT, style.weight);
3595
- const weight = entry ? nearestFontWeight2(entry, wanted) : wanted;
3596
- const inBase = OVERLAY_FONT_FACES.some(
3597
- (f) => entry && 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
3598
3891
  );
3599
- return {
3600
- weight,
3601
- ...clip.emphasis?.color ? { color: clip.emphasis.color } : {},
3602
- ...entry && !inBase ? {
3603
- face: {
3604
- family: entry.family,
3605
- weight,
3606
- url: fontFaceUrl2(entry.slug, weight)
3607
- }
3608
- } : {}
3609
- };
3892
+ return { minX: x.min, maxX: x.max, minY: y.min, maxY: y.max };
3610
3893
  }
3611
- function hashOf(text) {
3612
- let h = 2166136261;
3613
- for (let i = 0; i < text.length; i++) {
3614
- h ^= text.charCodeAt(i);
3615
- h = Math.imul(h, 16777619);
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;
3616
3902
  }
3617
- return (h >>> 0).toString(36);
3903
+ return { min: clamp013(lo), max: clamp013(hi) };
3618
3904
  }
3619
- function styledTextOf(clip) {
3620
- const em = resolveEmphasis(clip);
3621
- if (!em) return null;
3622
- const l = overlayLines(overlayDisplayText(clip)).map((line) => {
3623
- const runs = [];
3624
- let run = "";
3625
- let bold = false;
3626
- const flush = () => {
3627
- if (!run) return;
3628
- runs.push(
3629
- bold ? { t: run, f: 1, ...em.color ? { c: em.color } : {} } : { t: run }
3630
- );
3631
- run = "";
3632
- };
3633
- for (const c of line) {
3634
- if (c === EM_OPEN || c === EM_CLOSE) {
3635
- flush();
3636
- bold = c === EM_OPEN;
3637
- } else run += c;
3638
- }
3639
- flush();
3640
- return runs;
3641
- });
3642
- const fs = [{ w: resolveOverlayStyle(clip).weight }, { w: em.weight }];
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) };
3915
+ }
3916
+ function clampFocus(cx, cy, level, layout, camera = "card", screen) {
3917
+ const b = focusBounds(level, layout, camera, screen);
3643
3918
  return {
3644
- l,
3645
- fs,
3646
- ...em.face ? { faces: [{ f: em.face.family, w: em.face.weight, u: em.face.url }] } : {},
3647
- h: hashOf(JSON.stringify([l, fs]))
3919
+ cx: Math.min(b.maxX, Math.max(b.minX, cx)),
3920
+ cy: Math.min(b.maxY, Math.max(b.minY, cy))
3648
3921
  };
3649
3922
  }
3650
- function overlayTokens(line) {
3651
- return line.match(/\S+\s*/g) ?? [line];
3923
+ function clamp013(v) {
3924
+ return Math.max(0, Math.min(1, v));
3652
3925
  }
3653
- function wrapOverlayLines(lines, measure, maxPx) {
3654
- if (!(maxPx > 0)) return lines;
3655
- const out = [];
3656
- for (const line of lines) {
3657
- if (!line || measure(line) <= maxPx) {
3658
- out.push(line);
3659
- continue;
3660
- }
3661
- let current = "";
3662
- for (const token of overlayTokens(line)) {
3663
- if (!current) {
3664
- current = token;
3665
- continue;
3666
- }
3667
- if (measure(current + token) <= maxPx) {
3668
- current += token;
3669
- } else {
3670
- out.push(current);
3671
- current = token;
3672
- }
3673
- }
3674
- if (current) out.push(current);
3675
- }
3676
- return out.length ? out : [""];
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 };
3677
3928
  }
3678
- function graphemesOf2(line) {
3679
- if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
3680
- return [
3681
- ...new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(
3682
- line
3683
- )
3684
- ].map((s) => s.segment);
3685
- }
3686
- return [...line];
3929
+ function cameraModel(frame) {
3930
+ return frame?.camera === "stage" ? "stage" : "card";
3687
3931
  }
3688
- function overlaySegments(text, unit) {
3689
- const lines = overlayLines(text);
3690
- if (unit === "block") return [];
3691
- if (unit === "line") return lines.map((l) => [l]);
3692
- if (unit === "word") return lines.map(overlayTokens);
3693
- return lines.map(graphemesOf2);
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);
3694
3937
  }
3695
- var FX_DIR = { forward: 0, reverse: 1, center: 2 };
3696
- function resolveOverlayFx(clip, clipDuration) {
3697
- const spec = overlayFxSpec(clip) ?? clip.fx ?? null;
3698
- if (!spec) return null;
3699
- const unit = spec.unit ?? "block";
3700
- const text = stripEmphasis(overlayDisplayText(clip));
3701
- const units = overlaySegments(text, unit);
3702
- const n = unit === "block" ? 1 : units.reduce((sum, line) => sum + line.length, 0);
3703
- const upperN = unit === "line" && clip.maxWidth ? overlayLines(text).reduce(
3704
- (sum, line) => sum + overlayTokens(line).length,
3705
- 0
3706
- ) : n;
3707
- const typewriter = spec.fx === "typewriter";
3708
- const dur = typewriter ? 1e-3 : Math.min(2, Math.max(0.05, spec.duration ?? OVERLAY_TRANSITION_DUR));
3709
- const defaultStagger = typewriter ? 0.05 : unit === "block" ? 0 : 0.06;
3710
- let st = Math.min(2, Math.max(0, spec.stagger ?? defaultStagger));
3711
- if (upperN > 1) {
3712
- const maxTotal = Math.max(dur, clipDuration * 0.9);
3713
- st = Math.min(st, Math.max(0, (maxTotal - dur) / (upperN - 1)));
3714
- }
3715
- const round14 = (v) => Math.round(v * 1e4) / 1e4;
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 };
3947
+ }
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;
3716
3953
  return {
3717
- k: spec.fx,
3718
- u: unit,
3719
- d: FX_DIR[spec.direction ?? "forward"] ?? 0,
3720
- st: round14(st),
3721
- dur: round14(dur),
3722
- tt: round14(st * (upperN - 1) + dur),
3723
- units,
3724
- 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
3725
3958
  };
3726
3959
  }
3727
- function overlayFontString(style, scale, s) {
3728
- const italic = style.fontStyle === "italic" ? "italic " : "";
3729
- 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 };
3730
3969
  }
3731
- function overlayRect(clip, measure, frameW, frameH = 1080, mediaAspect) {
3732
- const scale = clip.transform.scale || 1;
3733
- const base = {
3734
- cx: clip.transform.x * frameW,
3735
- cy: clip.transform.y * frameH,
3736
- rotation: clip.transform.rotation || 0
3737
- };
3738
- if (clip.kind === "html") {
3739
- const pb = htmlLayerPictureBox(clip);
3740
- const w2 = htmlLayerWidth(clip) * frameW * scale;
3741
- return { ...base, w: w2, h: w2 * (pb.height / pb.width) };
3742
- }
3743
- if (clip.kind !== "text") {
3744
- const w2 = (clip.width ?? OVERLAY_MEDIA_DEFAULT_WIDTH) * frameW * scale;
3745
- return { ...base, w: w2, h: w2 / (mediaAspect || 16 / 9) };
3746
- }
3747
- const style = resolveOverlayStyle(clip);
3748
- const font = overlayFontString(style, scale, 1);
3749
- const ls = style.letterSpacing * scale;
3750
- const styled = styledTextOf(clip);
3751
- let w = 0;
3752
- let lineCount = 0;
3753
- if (styled) {
3754
- const fonts = styled.fs.map(
3755
- (f) => overlayFontString({ ...style, weight: f.w }, scale, 1)
3756
- );
3757
- const laid = layoutText(
3758
- { lines: styled.l, maxWidth: clip.maxWidth ? clip.maxWidth * frameW : 0 },
3759
- (t, f) => measure(t, fonts[f], ls)
3760
- );
3761
- w = laid.width;
3762
- lineCount = laid.lines.length;
3763
- } else {
3764
- const width = (t) => measure(t, font, ls);
3765
- const lines = clip.maxWidth ? wrapOverlayLines(overlayLines(clip.text), width, clip.maxWidth * frameW) : overlayLines(clip.text);
3766
- for (const line of lines) w = Math.max(w, width(line));
3767
- lineCount = lines.length;
3768
- }
3769
- const lineH = style.size * scale * style.lineHeight;
3770
- const box = resolveOverlayBox(clip);
3771
- const padX = box ? box.padX * scale : 0;
3772
- const padY = box ? box.padY * scale : 0;
3773
- return {
3774
- ...base,
3775
- w: Math.max(w, style.size * scale * 0.6) + padX * 2,
3776
- // empty text still selectable
3777
- h: lineCount * lineH + padY * 2
3778
- };
3970
+ function levelForFocusFraction(frac) {
3971
+ if (!(frac > 0)) return clampZoomLevel(Number.POSITIVE_INFINITY);
3972
+ return clampZoomLevel(Math.max(1.1, 1 / frac));
3779
3973
  }
3780
- function overlayHit(rect, px, py, padPx = 8) {
3781
- let dx = px - rect.cx;
3782
- let dy = py - rect.cy;
3783
- if (rect.rotation) {
3784
- const a = -rect.rotation * Math.PI / 180;
3785
- const rx = dx * Math.cos(a) - dy * Math.sin(a);
3786
- const ry = dx * Math.sin(a) + dy * Math.cos(a);
3787
- dx = rx;
3788
- dy = ry;
3789
- }
3790
- 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 };
3791
3990
  }
3792
3991
 
3793
3992
  // src/text3d.ts
@@ -5402,7 +5601,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5402
5601
  if (ol.rt) {
5403
5602
  var rtFonts = [ovC.font]
5404
5603
  for (var rfi = 1; rfi < ol.rt.fs.length; rfi++) {
5405
- rtFonts.push((ol.sty ? ol.sty + ' ' : '') + ol.rt.fs[rfi].w + ' ' + olPx + 'px ' + ol.stack)
5604
+ rtFonts.push((ol.rt.fs[rfi].i ? 'italic ' : '') + ol.rt.fs[rfi].w + ' ' + olPx + 'px ' + ol.stack)
5406
5605
  }
5407
5606
  var rtFx = ol.fx && ol.fx.units.length ? ol.fx : null
5408
5607
  var rtMax = ol.mw ? ol.mw * W : 0
@@ -5441,8 +5640,10 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5441
5640
  ovC.fill()
5442
5641
  ovC.restore()
5443
5642
  }
5444
- // Highlights sit behind the words and take no legibility shadow.
5445
- for (var rhi = 0; rhi < rtN; rhi++) {
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++) {
5446
5647
  var rhLine = rtL.lines[rhi]
5447
5648
  for (var rhj = 0; rhj < rhLine.frags.length; rhj++) {
5448
5649
  var rhF = rhLine.frags[rhj]
@@ -5460,7 +5661,15 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5460
5661
  ovC.textAlign = 'left'
5461
5662
  // One fragment (or the part of one a unit holds): the underline under
5462
5663
  // the glyphs, stroke under fill, the strikethrough over them.
5463
- var rtPart = function (p, bx, y) {
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
+ }
5464
5673
  ovC.font = rtFonts[p.f || 0]
5465
5674
  ovC.fillStyle = p.c || ol.color
5466
5675
  if (p.u) {
@@ -5523,7 +5732,7 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5523
5732
  ovC.filter = 'blur(' + ((1 - rUu) * olPx * 0.12).toFixed(2) + 'px)'
5524
5733
  }
5525
5734
  }
5526
- for (var rpk = 0; rpk < rU.parts.length; rpk++) rtPart(rU.parts[rpk], rUBX, rUY)
5735
+ for (var rpk = 0; rpk < rU.parts.length; rpk++) rtPart(rU.parts[rpk], rUBX, rUY, true)
5527
5736
  ovC.restore()
5528
5737
  }
5529
5738
  }
@@ -8495,7 +8704,8 @@ var ON_FRAME = `(ctx, content, dt) => {
8495
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 }
8496
8705
  }
8497
8706
  }`;
8498
- function studioLayerData(layers, duration, pins) {
8707
+ function studioLayerData(input, duration, pins) {
8708
+ const layers = migrateText(input);
8499
8709
  const rec = "source" in layers ? layers : null;
8500
8710
  return {
8501
8711
  // Music/SFX clips with their gain envelopes baked (shared truth for the
@@ -8674,10 +8884,10 @@ function studioLayerData(layers, duration, pins) {
8674
8884
  const rt = styledTextOf(o);
8675
8885
  return {
8676
8886
  ...base,
8677
- text: o.text,
8887
+ text: overlayPlainText(o),
8678
8888
  // The lines a person reads. A styled layer (rt) is painted from
8679
8889
  // its runs; these are then its words, for the signature.
8680
- lines: overlayLines(stripEmphasis(overlayDisplayText(o))),
8890
+ lines: overlayLines(overlayPlainText(o)),
8681
8891
  // The styled payload, only when the layer is set in more than
8682
8892
  // one style: a plain clip's data is unchanged.
8683
8893
  ...rt ? { rt } : {},
@@ -8728,7 +8938,7 @@ function studioLayerData(layers, duration, pins) {
8728
8938
  };
8729
8939
  }
8730
8940
  function lowerToComposition(input) {
8731
- const doc = migrateMotion(input);
8941
+ const doc = migrateText(migrateMotion(input));
8732
8942
  const rated = ratedSegments(doc);
8733
8943
  const transitions = docTransitions(doc);
8734
8944
  const footageEnd = durationSec(doc, rated);
@@ -8937,7 +9147,7 @@ function loweredZoomSpans(doc, rated, transitions, layout, camera, zoomStyle) {
8937
9147
  });
8938
9148
  }
8939
9149
  function docZoomTrack(input) {
8940
- const doc = migrateMotion(input);
9150
+ const doc = migrateText(migrateMotion(input));
8941
9151
  const rated = ratedSegments(doc);
8942
9152
  const transitions = docTransitions(doc);
8943
9153
  const layout = docCardLayout(doc);
@@ -11863,7 +12073,7 @@ var overlaysLane = {
11863
12073
  kind: "clip",
11864
12074
  t: round13(o.start),
11865
12075
  duration: round13(o.duration),
11866
- 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"
11867
12077
  },
11868
12078
  // Pose diamonds render after the clips, so they sit on top.
11869
12079
  ...poseItems(o.id, o.start, o.duration, o.motion)
@@ -12276,8 +12486,6 @@ export {
12276
12486
  DRAG_FIT_LEVEL,
12277
12487
  DRAG_MIN_S,
12278
12488
  DRAG_MIN_TRAVEL_FRAC,
12279
- EM_CLOSE,
12280
- EM_OPEN,
12281
12489
  END_CARD_FROM,
12282
12490
  END_CARD_RECEDE,
12283
12491
  END_CARD_SECONDS,
@@ -12311,6 +12519,7 @@ export {
12311
12519
  MEDIA_FRAME_KEYS,
12312
12520
  MEDIA_KEY_PREFIX,
12313
12521
  MINIMAL_BAR_THEMES,
12522
+ MIXED,
12314
12523
  MOTION_DELTA,
12315
12524
  MOTION_EASE,
12316
12525
  OBJECT_DEFAULT_SCALE,
@@ -12334,6 +12543,7 @@ export {
12334
12543
  REJECT_OVERLAP,
12335
12544
  REST_TILT_ID,
12336
12545
  RETIRED_ZOOM_STYLES,
12546
+ RUN_STYLE_KEYS,
12337
12547
  SCENE_MOTION,
12338
12548
  SCENE_QUIET,
12339
12549
  SEGMENT_ENTER_KINDS,
@@ -12391,6 +12601,7 @@ export {
12391
12601
  aspectRatioValue,
12392
12602
  audioLane,
12393
12603
  backdropMedia,
12604
+ boldWeightFor,
12394
12605
  buildDigest,
12395
12606
  calloutBox,
12396
12607
  calloutClip,
@@ -12421,9 +12632,11 @@ export {
12421
12632
  clampSpeedRate,
12422
12633
  clampTiltDeg,
12423
12634
  clampZoomLevel,
12635
+ clearStyle,
12424
12636
  clipEnvelope,
12425
12637
  clipLength,
12426
12638
  clipsFrom,
12639
+ commonStyle,
12427
12640
  computeCardLayout,
12428
12641
  computeMicRms,
12429
12642
  computePeaks,
@@ -12442,6 +12655,7 @@ export {
12442
12655
  deriveViewportCrop,
12443
12656
  destinationById,
12444
12657
  destinationsForChannel,
12658
+ diffInput,
12445
12659
  docCardLayout,
12446
12660
  docFreezes,
12447
12661
  docMediaList,
@@ -12517,6 +12731,7 @@ export {
12517
12731
  lowerProgramDoc,
12518
12732
  lowerStudioDoc,
12519
12733
  lowerToComposition,
12734
+ marksEmphasis,
12520
12735
  mediaAtOutput,
12521
12736
  mediaDuration,
12522
12737
  mediaFrame,
@@ -12527,12 +12742,15 @@ export {
12527
12742
  micLane,
12528
12743
  migrateHostedDoc,
12529
12744
  migrateMotion,
12745
+ migrateText,
12746
+ migrateTextClip,
12530
12747
  migrateZoomStyle,
12531
12748
  momentsFromDoc,
12532
12749
  motionTrack,
12533
12750
  musicBedClip,
12534
12751
  nextMediaId,
12535
12752
  normalizeCaptureSpace,
12753
+ normalizeRuns,
12536
12754
  objectMotionBase,
12537
12755
  objectMotionPoseAt,
12538
12756
  objectsLane,
@@ -12541,7 +12759,6 @@ export {
12541
12759
  outputEnd,
12542
12760
  outputRangeToSource,
12543
12761
  overlapFraction,
12544
- overlayDisplayText,
12545
12762
  overlayFaceFor,
12546
12763
  overlayFontFaces,
12547
12764
  overlayFontString,
@@ -12551,12 +12768,12 @@ export {
12551
12768
  overlayMotionBase,
12552
12769
  overlayMotionPoseAt,
12553
12770
  overlayPinPoseAt,
12771
+ overlayPlainText,
12554
12772
  overlayRect,
12555
12773
  overlaysLane,
12556
12774
  ownFreezes,
12557
12775
  pageDisplayUrl,
12558
12776
  parseCssColour,
12559
- parseEmphasis,
12560
12777
  parsePoseId,
12561
12778
  pickStyle,
12562
12779
  pinBox,
@@ -12566,6 +12783,7 @@ export {
12566
12783
  pinReferent,
12567
12784
  pinReferentAt,
12568
12785
  placeFreezes,
12786
+ plainText,
12569
12787
  planAutoSpeed,
12570
12788
  planAutoTilt,
12571
12789
  planAutoZoom,
@@ -12585,7 +12803,7 @@ export {
12585
12803
  relativeLuminance,
12586
12804
  removeSourceRange,
12587
12805
  removeSpeedInRange,
12588
- resolveEmphasis,
12806
+ replaceRange,
12589
12807
  resolveExportSize,
12590
12808
  resolveOverlayBox,
12591
12809
  resolveOverlayStyle,
@@ -12595,6 +12813,7 @@ export {
12595
12813
  restLeanOf,
12596
12814
  restSpansThroughTransitions,
12597
12815
  rgbToHex,
12816
+ runsOf,
12598
12817
  sameMedia,
12599
12818
  sceneChanges,
12600
12819
  scrollRuns,
@@ -12602,17 +12821,23 @@ export {
12602
12821
  outputLen as segmentOutputLen,
12603
12822
  segmentStarts,
12604
12823
  setSpeedInRange,
12824
+ setStyle,
12825
+ sliceRuns,
12605
12826
  slideSide,
12606
12827
  smoothCursor,
12828
+ snapOffset,
12829
+ snapRunWeight,
12607
12830
  spanOutputExtent,
12608
12831
  speedLane,
12609
- stripEmphasis,
12610
12832
  studioAudioPlan,
12611
12833
  studioEntry,
12612
12834
  studioLayerData,
12835
+ styleAt,
12836
+ styleOf,
12613
12837
  styledTextOf,
12614
12838
  tiltLane,
12615
12839
  tiltTrackFromDoc,
12840
+ toggleStyle,
12616
12841
  tokenRanges,
12617
12842
  traceKindOf,
12618
12843
  transitionMult,