@vosjs/studio-core 0.37.0 → 0.39.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,2127 @@ 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
- }
2104
-
2105
- // src/planner/smoothing.ts
2106
- var SNAP_BEFORE = 0.12;
2107
- var SNAP_AFTER = 0.18;
2108
- var SNAP_GAIN = 0.5;
2109
- function sampleAt(points, t) {
2110
- if (points.length === 0) return { x: 0, y: 0 };
2111
- if (t <= points[0].t) return { x: points[0].x, y: points[0].y };
2112
- const last = points[points.length - 1];
2113
- if (t >= last.t) return { x: last.x, y: last.y };
2114
- for (let i = 1; i < points.length; i++) {
2115
- if (points[i].t >= t) {
2116
- const a = points[i - 1];
2117
- const b = points[i];
2118
- const f = (t - a.t) / (b.t - a.t || 1);
2119
- return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
2120
- }
2121
- }
2122
- return { x: last.x, y: last.y };
2123
- }
2124
- function smoothCursor(track, options = {}) {
2125
- const factor = clamp2(options.factor ?? 0.15, 0.01, 1);
2126
- const fps = options.fps ?? 60;
2127
- const raw = track.filter((e) => e.type === "move" || e.type === "down" || e.type === "up").map((e) => ({ t: e.t / 1e3, x: e.x, y: e.y }));
2128
- if (raw.length === 0) return [];
2129
- const clicks = options.clickSnap ? track.filter((e) => e.type === "down").map((e) => ({ t: e.t / 1e3, x: e.x, y: e.y })) : [];
2130
- const start = raw[0].t;
2131
- const end = raw[raw.length - 1].t;
2132
- const step = 1 / fps;
2133
- const out = [];
2134
- let cx = raw[0].x;
2135
- let cy = raw[0].y;
2136
- let ci = 0;
2137
- for (let t = start; t <= end + 1e-9; t += step) {
2138
- const target = sampleAt(raw, t);
2139
- cx += (target.x - cx) * factor;
2140
- cy += (target.y - cy) * factor;
2141
- if (clicks.length) {
2142
- while (ci < clicks.length - 1 && t > clicks[ci].t + SNAP_AFTER) ci++;
2143
- const ck = clicks[ci];
2144
- const u = clamp2(
2145
- (t - (ck.t - SNAP_BEFORE)) / SNAP_BEFORE,
2146
- 0,
2147
- t <= ck.t + SNAP_AFTER ? 1 : 0
2148
- );
2149
- const w = u * u * (3 - 2 * u) * SNAP_GAIN;
2150
- if (w > 0) {
2151
- cx += (ck.x - cx) * w;
2152
- cy += (ck.y - cy) * w;
2153
- }
2154
- }
2155
- out.push({ t, x: cx, y: cy });
2156
- }
2157
- return out;
2158
- }
2159
- function clamp2(v, lo, hi) {
2160
- return Math.max(lo, Math.min(hi, v));
2161
- }
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"));
2177
- const out = [];
2178
- for (const d of downs) {
2179
- const up = track.find((e) => e.type === "up" && e.t > d.t);
2180
- if (!up) continue;
2181
- const t0 = d.t / 1e3;
2182
- const t1 = up.t / 1e3;
2183
- if (t1 - t0 < DRAG_MIN_S) continue;
2184
- let travel = 0;
2185
- for (const e of track) {
2186
- if (e.t < d.t) continue;
2187
- if (e.t > up.t) break;
2188
- if (e.type !== "move" && e.type !== "up") continue;
2189
- const dist = Math.hypot(e.x - d.x, e.y - d.y) / width;
2190
- if (dist > travel) travel = dist;
2191
- }
2192
- if (travel < DRAG_MIN_TRAVEL_FRAC) continue;
2193
- out.push({
2194
- t0,
2195
- t1,
2196
- nx: clamp012(d.x / width),
2197
- ny: clamp012(d.y / height),
2198
- travel
2199
- });
2200
- }
2201
- return out;
2202
- }
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;
2247
- };
2248
- return {
2249
- sessions,
2250
- clusters: chain(targets, true),
2251
- surfaces: chain(surfaces, false)
2252
- };
2253
- }
2254
- function dedupePresses(downs) {
2255
- const kept = [];
2256
- for (const e of downs) {
2257
- const prev = kept.at(-1);
2258
- if (prev && e.t - prev.t <= PRESS_ECHO_MS && Math.abs(e.x - prev.x) <= PRESS_ECHO_PX && Math.abs(e.y - prev.y) <= PRESS_ECHO_PX) {
2259
- continue;
2260
- }
2261
- kept.push(e);
2262
- }
2263
- return kept;
2264
- }
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);
2276
- }
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);
2283
- }
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);
2287
- }
2288
- function isSurfacePress(c, width, height, targetFill) {
2289
- const fit = fitLevel(c.rect, width, height, targetFill);
2290
- return fit !== null && fit < DRAG_FIT_LEVEL;
2291
- }
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
- });
2363
- }
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;
2368
- }
2369
- for (const z of clickSpans) if (z.out - z.in < 0.3) z.dead = true;
2370
- const typingFloor = Math.min(Math.max(minLevel, typingMinLevel), maxLevel);
2371
- let typing = sessions.map((s) => {
2372
- const f = focusFor(
2373
- s.events,
2374
- width,
2375
- height,
2376
- targetFill,
2377
- typingFloor,
2378
- maxLevel
2379
- );
2380
- return {
2381
- in: Math.max(0, s.start - lead),
2382
- out: s.last + typingHold,
2383
- cx: f.cx,
2384
- cy: f.cy,
2385
- level: f.level
2386
- };
2387
- });
2388
- for (const t of typing) {
2389
- for (const z of clickSpans) {
2390
- if (z.dead || t.dead) continue;
2391
- if (t.in >= z.out || t.out <= z.in) continue;
2392
- if (Math.hypot(t.cx - z.cx, t.cy - z.cy) <= TYPING_REFOCUS_FRAC) {
2393
- t.in = Math.min(t.in, z.in);
2394
- t.out = Math.max(t.out, z.out);
2395
- z.dead = true;
2396
- } else if (z.in <= t.in && z.out >= t.out) {
2397
- t.dead = true;
2398
- } else if (z.in > t.in) {
2399
- t.out = z.in;
2400
- } else {
2401
- t.in = z.out;
2402
- }
2403
- }
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;
1820
+ // src/overlayText.ts
1821
+ import {
1822
+ findFontFamily as findFontFamily2,
1823
+ fontFaceUrl as fontFaceUrl2,
1824
+ fontStack,
1825
+ nearestFontWeight as nearestFontWeight2
1826
+ } from "@vosjs/shared";
1827
+
1828
+ // src/htmlLayer.ts
1829
+ import { findFontFamily, fontFaceUrl, nearestFontWeight } from "@vosjs/shared";
1830
+ var DESIGN_FRAME_WIDTH = 1920;
1831
+ var HTML_LAYER_WARN_BYTES = 32e3;
1832
+ var HTML_LAYER_MAX_BYTES = 128e3;
1833
+ var HTML_LAYER_LIVE_HZ = 60;
1834
+ var HTML_LAYER_ASSET_HOST = "https://assets.vos.so/";
1835
+ function splitTopLevel(value) {
1836
+ const out = [];
1837
+ let depth = 0;
1838
+ let cur = "";
1839
+ for (const ch of value) {
1840
+ if (ch === "(") depth++;
1841
+ else if (ch === ")") depth--;
1842
+ if (ch === "," && depth === 0) {
1843
+ out.push(cur);
1844
+ cur = "";
1845
+ } else cur += ch;
2408
1846
  }
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);
1847
+ if (cur.trim()) out.push(cur);
1848
+ return out;
2441
1849
  }
2442
- function typingSessions(track, width, height, typingGap) {
2443
- const pings = track.filter((e) => e.type === "key").map((e) => ({ t: e.t / 1e3, rect: e.rect, x: e.x, y: e.y }));
2444
- const all = [];
2445
- let cur = null;
2446
- for (const p of pings) {
2447
- const [nx, ny] = fieldCenter(p, width, height);
2448
- if (cur && p.t - cur.last <= typingGap && Math.hypot(nx - cur.cx, ny - cur.cy) <= TYPING_REFOCUS_FRAC) {
2449
- cur.events.push(p);
2450
- cur.last = p.t;
2451
- } else {
2452
- cur = { events: [p], first: p.t, last: p.t, start: p.t, cx: nx, cy: ny };
2453
- all.push(cur);
1850
+ var unquote = (s) => s.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim();
1851
+ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
1852
+ "serif",
1853
+ "sans-serif",
1854
+ "monospace",
1855
+ "cursive",
1856
+ "fantasy",
1857
+ "system-ui",
1858
+ "ui-sans-serif",
1859
+ "ui-serif",
1860
+ "ui-monospace",
1861
+ "ui-rounded",
1862
+ "emoji",
1863
+ "math",
1864
+ "fangsong",
1865
+ "inherit",
1866
+ "initial",
1867
+ "unset"
1868
+ ]);
1869
+ function cssFamilies(css) {
1870
+ if (!css) return [];
1871
+ const out = [];
1872
+ const seen = /* @__PURE__ */ new Set();
1873
+ for (const decl of css.match(/font-family\s*:[^;}]+/gi) ?? []) {
1874
+ for (const item of splitTopLevel(decl.replace(/^[^:]*:/, ""))) {
1875
+ const name = unquote(item.replace(/!important/i, ""));
1876
+ if (!name) continue;
1877
+ const lower = name.toLowerCase();
1878
+ if (GENERIC_FAMILIES.has(lower) || lower.startsWith("-apple-")) continue;
1879
+ if (seen.has(lower)) continue;
1880
+ seen.add(lower);
1881
+ out.push(name);
2454
1882
  }
2455
1883
  }
2456
- return all.filter(
2457
- (s) => s.events.length >= TYPING_MIN_PINGS && s.last - s.first >= TYPING_MIN_DUR
2458
- );
1884
+ return out;
2459
1885
  }
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)];
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);
2464
1895
  }
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
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"
2484
1915
  });
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);
2516
1916
  }
2517
- return accepted.sort((a, b) => a.in - b.in).map((z, i) => ({ ...z, id: `d${i}` }));
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;
2518
1925
  }
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;
1926
+ function htmlLayerUnhostedFamilies(clip) {
1927
+ const explicit = new Set(
1928
+ (clip.fonts ?? []).map((f) => f.family.toLowerCase())
1929
+ );
1930
+ return cssFamilies(clip.css).filter(
1931
+ (f) => !findFontFamily(f) && !explicit.has(f.toLowerCase())
1932
+ );
1933
+ }
1934
+ function htmlLayerAssetUrls(clip) {
1935
+ const out = [];
1936
+ const seen = /* @__PURE__ */ new Set();
1937
+ const add = (u) => {
1938
+ if (!seen.has(u)) {
1939
+ seen.add(u);
1940
+ out.push(u);
2533
1941
  }
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) };
1942
+ };
1943
+ for (const m of clip.html.matchAll(
1944
+ /\bsrc\s*=\s*["']((?:https?:)?\/\/[^"']+)["']/gi
1945
+ ))
1946
+ add(m[1]);
1947
+ for (const text of [clip.html, clip.css ?? ""])
1948
+ for (const m of text.matchAll(
1949
+ /url\(\s*["']?((?:https?:)?\/\/[^"')\s]+)["']?\s*\)/gi
1950
+ ))
1951
+ add(m[1]);
1952
+ return out;
2541
1953
  }
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;
1954
+ function htmlLayerAssets(clip) {
1955
+ const inlinable = [];
1956
+ const foreign = [];
1957
+ for (const u of htmlLayerAssetUrls(clip))
1958
+ (u.replace(/^\/\//, "https://").startsWith(HTML_LAYER_ASSET_HOST) ? inlinable : foreign).push(u);
1959
+ return { inlinable, foreign };
1960
+ }
1961
+ function htmlLayerBleedFor(css) {
1962
+ if (!css) return 0;
1963
+ let worst = 0;
1964
+ const consider = (layer) => {
1965
+ if (/\binset\b/i.test(layer)) return;
1966
+ const lengths = layer.replace(/[a-z-]+\([^)]*\)/gi, " ").replace(/#[0-9a-f]{3,8}/gi, " ").match(/-?\d*\.?\d+/g);
1967
+ if (!lengths) return;
1968
+ const [x = 0, y = 0, blur = 0, spread = 0] = lengths.map(Number);
1969
+ worst = Math.max(
1970
+ worst,
1971
+ Math.abs(x) + blur + spread,
1972
+ Math.abs(y) + blur + spread
1973
+ );
1974
+ };
1975
+ for (const decl of css.match(/box-shadow\s*:[^;}]+/gi) ?? [])
1976
+ for (const layer of splitTopLevel(decl.replace(/^[^:]*:/, "")))
1977
+ consider(layer);
1978
+ for (const fn of css.match(/drop-shadow\(([^)]*)\)/gi) ?? [])
1979
+ consider(fn.replace(/^drop-shadow\(/i, "").replace(/\)$/, ""));
1980
+ return worst ? Math.ceil(worst * 1.15) : 0;
1981
+ }
1982
+ function htmlLayerBleed(clip) {
1983
+ return Math.max(0, Math.round(clip.bleed ?? htmlLayerBleedFor(clip.css)));
1984
+ }
1985
+ function htmlLayerPictureBox(clip) {
1986
+ const bleed = htmlLayerBleed(clip);
1987
+ return {
1988
+ width: clip.box.width + bleed * 2,
1989
+ height: clip.box.height + bleed * 2,
1990
+ bleed
1991
+ };
1992
+ }
1993
+ function htmlLayerWidth(clip) {
1994
+ if (clip.width != null) return clip.width;
1995
+ return Math.min(1, htmlLayerPictureBox(clip).width / DESIGN_FRAME_WIDTH);
1996
+ }
1997
+ var ENTITIES = {
1998
+ amp: "&",
1999
+ lt: "<",
2000
+ gt: ">",
2001
+ quot: '"',
2002
+ apos: "'",
2003
+ nbsp: " "
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
+ }
2562
2063
  }
2563
2064
  }
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 };
2572
- }
2573
- function clamp3(v, lo, hi) {
2574
- return Math.max(lo, Math.min(hi, v));
2575
- }
2576
- function clamp012(v) {
2577
- return clamp3(v, 0, 1);
2578
- }
2579
- function round2(v) {
2580
- return Math.round(v * 1e3) / 1e3;
2581
- }
2582
-
2583
- // src/lower/lowerToComposition.ts
2584
- import {
2585
- EASINGS,
2586
- lerpArray as lerpArray2,
2587
- sample as sample2,
2588
- segmentRate as segmentRate2,
2589
- sortKeyframes as sortKeyframes2,
2590
- sourceToTimeline,
2591
- splitBySpeed as splitBySpeed3,
2592
- totalDuration as totalDuration3
2593
- } from "@vosjs/timeline";
2594
- import { timelineRuntimeCode as timelineRuntimeCode2 } from "@vosjs/timeline/bundle";
2595
-
2596
- // src/layout.ts
2597
- function computeCardLayout(frame, video, W, H, zoomFocus) {
2598
- const s = H / 1080;
2599
- const pad = (frame.padding || 0) * s;
2600
- const ins = frame.inset ?? {};
2601
- const padL = ins.left == null ? pad : ins.left * W;
2602
- const padR = ins.right == null ? pad : ins.right * W;
2603
- const padT = ins.top == null ? pad : ins.top * H;
2604
- const padB = ins.bottom == null ? pad : ins.bottom * H;
2605
- const bar = frame.browserBar;
2606
- const vw = video.width || 16;
2607
- const vh = video.height || 9;
2608
- const fitCover = frame.fit === "cover";
2609
- const cf = fitCover ? 1 : Math.min(1, W / H / (vw / vh));
2610
- const barH = bar.kind !== "none" ? (bar.height || 44) * s * cf : 0;
2611
- const availW = Math.max(1, W - padL - padR);
2612
- const availH = Math.max(1, H - padT - padB - barH);
2613
- if (fitCover) {
2614
- const sc2 = Math.max(availW / vw, availH / vh);
2615
- const dw2 = vw * sc2;
2616
- const dh2 = vh * sc2;
2617
- const follow = frame.focusFollow === "camera" && zoomFocus ? zoomFocus : null;
2618
- const fcx = clamp013(follow ? follow.cx : frame.focus?.cx ?? 0.5);
2619
- const fcy = clamp013(follow ? follow.cy : frame.focus?.cy ?? 0.5);
2620
- const vTop = padT + barH;
2621
- const dx2 = Math.min(
2622
- padL,
2623
- Math.max(padL + availW - dw2, padL + availW / 2 - fcx * dw2)
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}.`
2624
2071
  );
2625
- const dy2 = Math.min(
2626
- vTop,
2627
- Math.max(vTop + availH - dh2, vTop + availH / 2 - fcy * dh2)
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."
2628
2077
  );
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
- };
2641
- }
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
- 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
2658
- };
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;
2659
2143
  }
2660
- function docCardLayout(doc, media) {
2661
- const meta = doc.source.meta;
2662
- const H = 1080;
2663
- const W = Math.max(
2664
- 2,
2665
- Math.round(H * aspectRatioValue(doc.frame.aspectRatio, meta))
2666
- );
2667
- const src = mediaSource(doc, media) ?? doc.source;
2668
- const sm = src.meta;
2669
- return computeCardLayout(
2670
- mediaFrame(doc, media),
2671
- {
2672
- width: sm.captureWidth ?? sm.width,
2673
- height: sm.captureHeight ?? sm.height
2674
- },
2675
- W,
2676
- H
2677
- );
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." };
2678
2150
  }
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;
2151
+ function fontFaceCss(faces) {
2152
+ let out = "";
2153
+ for (const f of faces)
2154
+ out += `@font-face{font-family:'${f.family}';src:url(${f.dataUri}) format('woff2');font-weight:${f.weight};font-style:${f.style};font-display:block}`;
2155
+ return out;
2156
+ }
2157
+ var fmtT = (t) => String(Math.round(t * 1e3) / 1e3);
2158
+ var escapeXml = (v) => String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2159
+ function htmlLayerSvg(src, faces = [], assets = []) {
2160
+ const bleed = Math.max(0, Math.round(src.bleed ?? 0));
2161
+ const boxW = src.box.width + bleed * 2;
2162
+ const boxH = src.box.height + bleed * 2;
2163
+ const t = src.t ?? 0;
2164
+ const fill = (text) => {
2165
+ let out = text;
2166
+ for (const a of assets) out = out.split(a.url).join(a.dataUri);
2167
+ if (src.live) {
2168
+ out = out.replace(/\{\{\s*t\s*\}\}/g, fmtT(t));
2169
+ out = out.replace(
2170
+ /\{\{\s*data\.([A-Za-z0-9_.-]+)\s*\}\}/g,
2171
+ (_m, name) => {
2172
+ const v = src.data ? src.data[name] : void 0;
2173
+ return v == null ? "" : escapeXml(v);
2174
+ }
2175
+ );
2176
+ }
2177
+ return out;
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>`;
2182
+ }
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;
2688
2188
  }
2689
- return best;
2189
+ return h.toString(36);
2690
2190
  }
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;
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)}`;
2196
+ }
2197
+ function htmlLayerPayload(clip) {
2198
+ const faces = htmlLayerFaces(clip);
2199
+ const bleed = htmlLayerBleed(clip);
2200
+ const assets = htmlLayerAssets(clip).inlinable;
2697
2201
  return {
2698
- x,
2699
- y,
2700
- size,
2701
- radius: cam.shape === "rounded" ? (cam.radius ?? 18) * s : size / 2
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
+ }
2702
2213
  };
2703
2214
  }
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 };
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;
2710
2224
  }
2711
- if (camera === "stage") {
2712
- const x2 = stageAxisBounds(
2713
- layout.dx,
2714
- layout.dw,
2715
- layout.cardX,
2716
- layout.cardW,
2717
- layout.W,
2718
- level
2719
- );
2720
- const y2 = stageAxisBounds(
2721
- layout.dy,
2722
- layout.dh,
2723
- layout.cardY,
2724
- layout.cardH,
2725
- layout.H,
2726
- level
2727
- );
2728
- return { minX: x2.min, maxX: x2.max, minY: y2.min, maxY: y2.max };
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]);
2729
2233
  }
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 };
2234
+ return out.length ? out : [[0, line.length]];
2747
2235
  }
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;
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
+ }
2321
+ } else {
2322
+ let at = 0;
2323
+ const gs = graphemesOf(text);
2324
+ for (let i = 0; i < gs.length; i++) {
2325
+ units.push(unitOf(frags, at, at + gs[i].length));
2326
+ at += gs[i].length;
2327
+ }
2328
+ }
2329
+ line.units = units;
2330
+ }
2331
+ if (x > width) width = x;
2332
+ lines.push(line);
2333
+ };
2334
+ for (let li = 0; li < input.lines.length; li++) {
2335
+ const runs = input.lines[li];
2336
+ let len = 0;
2337
+ for (let i = 0; i < runs.length; i++) len += runs[i].t.length;
2338
+ if (!max || !len || widthOf(slice(runs, 0, len)) <= max) {
2339
+ push2(runs, 0, len);
2340
+ } else {
2341
+ const text = runs.map((r) => r.t).join("");
2342
+ const toks = tokenRanges(text);
2343
+ let a = 0;
2344
+ let b = toks[0][1];
2345
+ for (let i = 1; i < toks.length; i++) {
2346
+ if (widthOf(slice(runs, a, toks[i][1])) <= max) b = toks[i][1];
2347
+ else {
2348
+ push2(runs, a, b);
2349
+ a = toks[i][0];
2350
+ b = toks[i][1];
2351
+ }
2352
+ }
2353
+ push2(runs, a, b);
2354
+ }
2355
+ base += len + 1;
2756
2356
  }
2757
- return { min: clamp013(lo), max: clamp013(hi) };
2357
+ if (!lines.length) lines.push({ w: 0, frags: [], a: 0, n: 0 });
2358
+ return { lines, width };
2758
2359
  }
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;
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 };
2767
2371
  }
2768
- return { min: clamp013(lo), max: clamp013(hi) };
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 };
2769
2377
  }
2770
- function clampFocus(cx, cy, level, layout, camera = "card", screen) {
2771
- const b = focusBounds(level, layout, camera, screen);
2772
- return {
2773
- cx: Math.min(b.maxX, Math.max(b.minX, cx)),
2774
- cy: Math.min(b.maxY, Math.max(b.minY, cy))
2775
- };
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;
2776
2394
  }
2777
- function clamp013(v) {
2778
- return Math.max(0, Math.min(1, v));
2395
+ function runsOf(text) {
2396
+ return typeof text === "string" ? text ? [{ text }] : [] : text;
2779
2397
  }
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 };
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;
2782
2407
  }
2783
- function cameraModel(frame) {
2784
- return frame?.camera === "stage" ? "stage" : "card";
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;
2785
2423
  }
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);
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;
2432
+ }
2433
+ return plain.length;
2791
2434
  }
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 };
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;
2801
2446
  }
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;
2807
- 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
2812
- };
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
+ ]);
2813
2470
  }
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 };
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
+ ]);
2823
2494
  }
2824
- function levelForFocusFraction(frac) {
2825
- if (!(frac > 0)) return clampZoomLevel(Number.POSITIVE_INFINITY);
2826
- return clampZoomLevel(Math.max(1.1, 1 / frac));
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);
2827
2499
  }
2828
-
2829
- // src/stage.ts
2830
- var CARD_FOV = 30;
2831
- var OVERLAY_Z = -2;
2832
- var CARD_Z = -4;
2833
- var BACKGROUND_Z = -6;
2834
- var CARD_OVERSCAN_STEP = 0.05;
2835
- var CARD_OVERSCAN_MAX = 4;
2836
- var CAMERA_NEAR = 0.1;
2837
- var CAMERA_FAR = 100;
2838
- function planeSizeAtDepth(distance, fovDeg, aspect) {
2839
- const height = 2 * Math.abs(distance) * Math.tan(fovDeg * Math.PI / 180 / 2);
2840
- return { width: height * aspect, height };
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;
2841
2512
  }
2842
- function cardPointToScreen(u, v) {
2843
- return { sx: u, sy: v };
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
+ };
2844
2531
  }
2845
2532
 
2846
2533
  // src/overlayText.ts
2847
- import {
2848
- findFontFamily as findFontFamily2,
2849
- fontFaceUrl as fontFaceUrl2,
2850
- fontStack,
2851
- nearestFontWeight as nearestFontWeight2
2852
- } from "@vosjs/shared";
2853
-
2854
- // src/htmlLayer.ts
2855
- import { findFontFamily, fontFaceUrl, nearestFontWeight } from "@vosjs/shared";
2856
- var DESIGN_FRAME_WIDTH = 1920;
2857
- var HTML_LAYER_WARN_BYTES = 32e3;
2858
- var HTML_LAYER_MAX_BYTES = 128e3;
2859
- var HTML_LAYER_LIVE_HZ = 60;
2860
- var HTML_LAYER_ASSET_HOST = "https://assets.vos.so/";
2861
- function splitTopLevel(value) {
2862
- const out = [];
2863
- let depth = 0;
2864
- let cur = "";
2865
- for (const ch of value) {
2866
- if (ch === "(") depth++;
2867
- else if (ch === ")") depth--;
2868
- if (ch === "," && depth === 0) {
2869
- out.push(cur);
2870
- cur = "";
2871
- } else cur += ch;
2872
- }
2873
- if (cur.trim()) out.push(cur);
2874
- return out;
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
+ };
2875
2547
  }
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);
2908
- }
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
2909
2574
  }
2910
- return out;
2911
- }
2912
- function cssWeights(css, html = "") {
2913
- const out = /* @__PURE__ */ new Set([400]);
2914
- for (const decl of css?.match(/font-weight\s*:[^;}]+/gi) ?? []) {
2915
- const value = decl.replace(/^[^:]*:/, "").toLowerCase();
2916
- for (const n of value.match(/\b[1-9]00\b/g) ?? []) out.add(Number(n));
2917
- if (/\bbold(er)?\b/.test(value)) out.add(700);
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)
2918
2585
  }
2919
- if (/<(b|strong|h[1-6])[\s>]/i.test(html)) out.add(700);
2920
- return [...out].sort((a, b) => a - b);
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;
2600
+ }
2601
+ return {
2602
+ ...base,
2603
+ stack,
2604
+ weight,
2605
+ ...clip.size !== void 0 ? {
2606
+ size: Math.min(
2607
+ OVERLAY_SIZE_MAX,
2608
+ Math.max(OVERLAY_SIZE_MIN, clip.size)
2609
+ )
2610
+ } : {},
2611
+ ...clip.color ? { color: clip.color } : {},
2612
+ ...clip.shadow !== void 0 ? { shadow: Math.min(1, Math.max(0, clip.shadow)) } : {},
2613
+ fontStyle: clip.italic ? "italic" : "normal",
2614
+ align: clip.align ?? "center",
2615
+ letterSpacing: clip.letterSpacing ?? 0,
2616
+ lineHeight: clip.lineHeight ?? OVERLAY_LINE_HEIGHT,
2617
+ stroke: clip.stroke ?? null
2618
+ };
2619
+ }
2620
+ function overlayFaceFor(clip) {
2621
+ const entry = findFontFamily2(
2622
+ clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
2623
+ );
2624
+ if (!entry) return null;
2625
+ const weight = resolveOverlayStyle(clip).weight;
2626
+ const inBase = OVERLAY_FONT_FACES.some(
2627
+ (f) => f.family === entry.family && f.weight === weight
2628
+ );
2629
+ if (inBase) return null;
2630
+ return {
2631
+ family: entry.family,
2632
+ weight,
2633
+ url: fontFaceUrl2(entry.slug, weight)
2634
+ };
2921
2635
  }
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}`;
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}`;
2927
2642
  if (seen.has(key)) return;
2928
2643
  seen.add(key);
2929
- faces.push(f);
2644
+ faces.push(face);
2930
2645
  };
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
- });
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
+ }
2942
2652
  }
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
2653
  return faces;
2951
2654
  }
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())
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)
2958
2676
  );
2959
2677
  }
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);
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);
2683
+ }
2684
+ return (h >>> 0).toString(36);
2685
+ }
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;
2967
2699
  }
2700
+ return at;
2968
2701
  };
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
2702
+ const l = [[]];
2703
+ for (const run of runs) {
2704
+ const f = fontOf(
2705
+ run.weight !== void 0 ? snapRunWeight(clip, run.weight) : style.weight,
2706
+ run.italic ?? italic
2707
+ );
2708
+ const set = {
2709
+ ...f ? { f } : {},
2710
+ ...run.color ? { c: run.color } : {},
2711
+ ...run.underline ? { u: 1 } : {},
2712
+ ...run.strike ? { s: 1 } : {},
2713
+ ...run.highlight ? { h: run.highlight } : {}
2714
+ };
2715
+ run.text.split("\n").forEach((part, i) => {
2716
+ if (i > 0) l.push([]);
2717
+ if (part) l[l.length - 1].push({ t: part, ...set });
2718
+ });
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
2999
2727
  );
2728
+ if (!inBase) {
2729
+ faces.push({
2730
+ f: entry.family,
2731
+ w: font.w,
2732
+ u: fontFaceUrl2(entry.slug, font.w)
2733
+ });
2734
+ }
2735
+ }
2736
+ return {
2737
+ l,
2738
+ fs,
2739
+ ...faces.length ? { faces } : {},
2740
+ h: hashOf(JSON.stringify([l, fs]))
3000
2741
  };
3001
- for (const decl of css.match(/box-shadow\s*:[^;}]+/gi) ?? [])
3002
- for (const layer of splitTopLevel(decl.replace(/^[^:]*:/, "")))
3003
- consider(layer);
3004
- for (const fn of css.match(/drop-shadow\(([^)]*)\)/gi) ?? [])
3005
- consider(fn.replace(/^drop-shadow\(/i, "").replace(/\)$/, ""));
3006
- return worst ? Math.ceil(worst * 1.15) : 0;
3007
- }
3008
- function htmlLayerBleed(clip) {
3009
- return Math.max(0, Math.round(clip.bleed ?? htmlLayerBleedFor(clip.css)));
3010
2742
  }
3011
- function htmlLayerPictureBox(clip) {
3012
- const bleed = htmlLayerBleed(clip);
2743
+ function textRunsOf(clip) {
2744
+ const styled = styledTextOf(clip);
2745
+ if (styled) return styled;
2746
+ const style = resolveOverlayStyle(clip);
3013
2747
  return {
3014
- width: clip.box.width + bleed * 2,
3015
- height: clip.box.height + bleed * 2,
3016
- bleed
2748
+ l: overlayLines(overlayPlainText(clip)).map((t) => t ? [{ t }] : []),
2749
+ fs: [
2750
+ {
2751
+ w: style.weight,
2752
+ ...style.fontStyle === "italic" ? { i: 1 } : {}
2753
+ }
2754
+ ]
3017
2755
  };
3018
2756
  }
3019
- function htmlLayerWidth(clip) {
3020
- if (clip.width != null) return clip.width;
3021
- return Math.min(1, htmlLayerPictureBox(clip).width / DESIGN_FRAME_WIDTH);
2757
+ var FX_DIR = { forward: 0, reverse: 1, center: 2 };
2758
+ function resolveOverlayFx(clip, clipDuration) {
2759
+ const spec = overlayFxSpec(clip) ?? clip.fx ?? null;
2760
+ if (!spec) return null;
2761
+ const unit = spec.unit ?? "block";
2762
+ const lines = overlayLines(overlayPlainText(clip));
2763
+ const count = (per) => lines.reduce((sum, line) => sum + per(line), 0);
2764
+ const words = () => count((line) => tokenRanges(line).length);
2765
+ const n = unit === "block" ? 1 : unit === "line" ? lines.length : unit === "word" ? words() : count((line) => graphemesOf(line).length);
2766
+ const upperN = unit === "line" && clip.maxWidth ? words() : n;
2767
+ const typewriter = spec.fx === "typewriter";
2768
+ const dur = typewriter ? 1e-3 : Math.min(2, Math.max(0.05, spec.duration ?? OVERLAY_TRANSITION_DUR));
2769
+ const defaultStagger = typewriter ? 0.05 : unit === "block" ? 0 : 0.06;
2770
+ let st = Math.min(2, Math.max(0, spec.stagger ?? defaultStagger));
2771
+ if (upperN > 1) {
2772
+ const maxTotal = Math.max(dur, clipDuration * 0.9);
2773
+ st = Math.min(st, Math.max(0, (maxTotal - dur) / (upperN - 1)));
2774
+ }
2775
+ const round14 = (v) => Math.round(v * 1e4) / 1e4;
2776
+ return {
2777
+ k: spec.fx,
2778
+ u: unit,
2779
+ d: FX_DIR[spec.direction ?? "forward"] ?? 0,
2780
+ st: round14(st),
2781
+ dur: round14(dur),
2782
+ tt: round14(st * (upperN - 1) + dur)
2783
+ };
3022
2784
  }
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();
2785
+ function overlayFontString(style, scale, s) {
2786
+ const italic = style.fontStyle === "italic" ? "italic " : "";
2787
+ return `${italic}${style.weight} ${style.size * scale * s}px ${style.stack}`;
3039
2788
  }
3040
- function htmlLayerLabel(clip) {
3041
- const words = htmlLayerText(clip.html);
3042
- return words ? words.slice(0, 24) : "HTML";
2789
+ function overlayRect(clip, measure, frameW, frameH = 1080, mediaAspect) {
2790
+ const scale = clip.transform.scale || 1;
2791
+ const base = {
2792
+ cx: clip.transform.x * frameW,
2793
+ cy: clip.transform.y * frameH,
2794
+ rotation: clip.transform.rotation || 0
2795
+ };
2796
+ if (clip.kind === "html") {
2797
+ const pb = htmlLayerPictureBox(clip);
2798
+ const w2 = htmlLayerWidth(clip) * frameW * scale;
2799
+ return { ...base, w: w2, h: w2 * (pb.height / pb.width) };
2800
+ }
2801
+ if (clip.kind !== "text") {
2802
+ const w2 = (clip.width ?? OVERLAY_MEDIA_DEFAULT_WIDTH) * frameW * scale;
2803
+ return { ...base, w: w2, h: w2 / (mediaAspect || 16 / 9) };
2804
+ }
2805
+ const style = resolveOverlayStyle(clip);
2806
+ const ls = style.letterSpacing * scale;
2807
+ const runs = textRunsOf(clip);
2808
+ const fonts = runs.fs.map(
2809
+ (f) => overlayFontString(
2810
+ { ...style, weight: f.w, fontStyle: f.i ? "italic" : "normal" },
2811
+ scale,
2812
+ 1
2813
+ )
2814
+ );
2815
+ const laid = layoutText(
2816
+ { lines: runs.l, maxWidth: clip.maxWidth ? clip.maxWidth * frameW : 0 },
2817
+ (t, f) => measure(t, fonts[f], ls)
2818
+ );
2819
+ const w = laid.width;
2820
+ const lineCount = laid.lines.length;
2821
+ const lineH = style.size * scale * style.lineHeight;
2822
+ const box = resolveOverlayBox(clip);
2823
+ const padX = box ? box.padX * scale : 0;
2824
+ const padY = box ? box.padY * scale : 0;
2825
+ return {
2826
+ ...base,
2827
+ w: Math.max(w, style.size * scale * 0.6) + padX * 2,
2828
+ // empty text still selectable
2829
+ h: lineCount * lineH + padY * 2
2830
+ };
3043
2831
  }
3044
- var HTML_LAYER_PLACEHOLDER = /\{\{\s*(t|data\.[A-Za-z0-9_.-]+)\s*\}\}/;
3045
- var VOID_TAGS = [
3046
- "br",
3047
- "img",
3048
- "hr",
3049
- "input",
3050
- "source",
3051
- "col",
3052
- "area",
3053
- "base",
3054
- "wbr",
3055
- "meta",
3056
- "link"
3057
- ];
3058
- var HTML_LAYER_FOREIGN_FIELDS = [
3059
- "key",
3060
- "radius",
3061
- "shadow",
3062
- "border",
3063
- "loop",
3064
- "frame",
3065
- "fx",
3066
- "text",
3067
- "preset"
3068
- ];
3069
- function htmlLayerProblems(clip) {
3070
- const html = clip.html ?? "";
3071
- const css = clip.css;
3072
- const raw = clip;
3073
- const problems = [];
3074
- const push2 = (code, level, message) => problems.push({ code, level, message });
3075
- if (!html.trim()) push2("empty", "problem", "The layer has no markup.");
3076
- for (const tag of VOID_TAGS) {
3077
- const open = new RegExp(`<${tag}(\\s[^>]*?)?>`, "gi");
3078
- let m;
3079
- let said = false;
3080
- while (m = open.exec(html)) {
3081
- if (!m[0].trimEnd().endsWith("/>") && !said) {
3082
- said = true;
3083
- push2(
3084
- "unclosed-void",
3085
- "problem",
3086
- `<${tag}> must close itself as <${tag} /> inside a foreignObject.`
3087
- );
3088
- }
2832
+ function overlayHit(rect, px, py, padPx = 8) {
2833
+ let dx = px - rect.cx;
2834
+ let dy = py - rect.cy;
2835
+ if (rect.rotation) {
2836
+ const a = -rect.rotation * Math.PI / 180;
2837
+ const rx = dx * Math.cos(a) - dy * Math.sin(a);
2838
+ const ry = dx * Math.sin(a) + dy * Math.cos(a);
2839
+ dx = rx;
2840
+ dy = ry;
2841
+ }
2842
+ return Math.abs(dx) <= rect.w / 2 + padPx && Math.abs(dy) <= rect.h / 2 + padPx;
2843
+ }
2844
+
2845
+ // src/richText/migrateEmphasis.ts
2846
+ function parseLine(line) {
2847
+ const chars = [];
2848
+ const marker = [];
2849
+ for (let i = 0; i < line.length; i++) {
2850
+ if (line[i] === "\\" && line[i + 1] === "*") {
2851
+ chars.push("*");
2852
+ marker.push(false);
2853
+ i++;
2854
+ } else {
2855
+ chars.push(line[i]);
2856
+ marker.push(line[i] === "*");
2857
+ }
2858
+ }
2859
+ const keep = chars.map(() => true);
2860
+ const bold = [];
2861
+ for (let i = 0; i < chars.length; i++) {
2862
+ if (!marker[i]) continue;
2863
+ let j = i + 1;
2864
+ while (j < chars.length && !marker[j]) j++;
2865
+ if (j >= chars.length) break;
2866
+ if (!chars.slice(i + 1, j).join("").trim()) {
2867
+ i = j - 1;
2868
+ continue;
3089
2869
  }
2870
+ keep[i] = false;
2871
+ keep[j] = false;
2872
+ for (let k = i + 1; k < j; k++) bold[k] = true;
2873
+ i = j;
3090
2874
  }
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.`
2875
+ const out = [];
2876
+ for (let i = 0; i < chars.length; i++) {
2877
+ if (keep[i]) out.push({ c: chars[i], b: !!bold[i] && !/\s/.test(chars[i]) });
2878
+ }
2879
+ return out;
2880
+ }
2881
+ function marksEmphasis(text) {
2882
+ return text.split("\n").some((line) => parseLine(line).some((x) => x.b));
2883
+ }
2884
+ function migrateTextClip(clip) {
2885
+ const legacy = clip;
2886
+ if (!("emphasis" in legacy)) return clip;
2887
+ const { emphasis, ...rest } = legacy;
2888
+ const next = rest;
2889
+ if (!emphasis || typeof legacy.text !== "string") return next;
2890
+ const weight = emphasis.weight !== void 0 ? snapRunWeight(next, emphasis.weight) : boldWeightFor(next);
2891
+ const runs = [];
2892
+ legacy.text.split("\n").forEach((line, li) => {
2893
+ if (li > 0) runs.push({ text: "\n" });
2894
+ for (const { c, b } of parseLine(line)) {
2895
+ runs.push(
2896
+ b ? {
2897
+ text: c,
2898
+ weight,
2899
+ ...emphasis.color ? { color: emphasis.color } : {}
2900
+ } : { text: c }
3123
2901
  );
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;
2902
+ }
2903
+ });
2904
+ return { ...next, text: normalizeRuns(runs) };
3169
2905
  }
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." };
2906
+ function migrateText(doc) {
2907
+ const overlays = Array.isArray(doc.overlays) ? doc.overlays : [];
2908
+ const legacy = overlays.some((o) => o.kind === "text" && "emphasis" in o);
2909
+ if (!legacy) return doc;
2910
+ return {
2911
+ ...doc,
2912
+ overlays: overlays.map((o) => o.kind === "text" ? migrateTextClip(o) : o)
2913
+ };
3176
2914
  }
3177
- function fontFaceCss(faces) {
3178
- let out = "";
3179
- for (const f of faces)
3180
- out += `@font-face{font-family:'${f.family}';src:url(${f.dataUri}) format('woff2');font-weight:${f.weight};font-style:${f.style};font-display:block}`;
3181
- return out;
2915
+
2916
+ // src/zoomStyle.ts
2917
+ var RETIRED_ZOOM_STYLES = {
2918
+ keynote: { style: "glide", tilt: "medium" },
2919
+ drift: { style: "cinema", tilt: "subtle" }
2920
+ };
2921
+ function liveZoomStyleName(name) {
2922
+ if (!name) return null;
2923
+ if (name in ZOOM_STYLES) return name;
2924
+ return RETIRED_ZOOM_STYLES[name]?.style ?? null;
3182
2925
  }
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
- );
2926
+ var HOLD_DRIFT_MAX = 0.15;
2927
+ var PUMP_REST = 1;
2928
+ function pumpFreeChainGap(p) {
2929
+ return p.rampOut + (p.rampIn - p.rampInOverlap) + PUMP_REST;
2930
+ }
2931
+ var glide = {
2932
+ autoZoom: true,
2933
+ clusterGap: 3,
2934
+ minClusterClicks: 1,
2935
+ targetFill: 0.42,
2936
+ minLevel: 1.3,
2937
+ maxLevel: 1.8,
2938
+ dragLevel: 1.5,
2939
+ lead: 0.35,
2940
+ hold: 1.2,
2941
+ followByDefault: true,
2942
+ typingZoom: true,
2943
+ typingGap: 2.5,
2944
+ typingHold: 1.1,
2945
+ typingMinLevel: 1.4,
2946
+ rampIn: 0.6,
2947
+ rampInOverlap: 0.2,
2948
+ rampOut: 0.5,
2949
+ chainGap: 3,
2950
+ pan: 0.55,
2951
+ // The reference's corner trace at 60 fps: a quarter of the move by a
2952
+ // fifth of the time, two thirds by half, nine tenths by three
2953
+ // quarters, over ~0.6 s. Fitted against those three points,
2954
+ // (0.36, 0, 0.4, 1) is the closest cubic (15 / 64 / 93 %; half the
2955
+ // move at ~245 ms and 90 % at ~425 ms at 0.6 s): a gentle start and a
2956
+ // long settle. CSS ease (0.25, 0.1, 0.25, 1) reached the midpoint
2957
+ // 70 ms earlier and read a touch fast; (0.16, 1, 0.3, 1) put half the
2958
+ // move in the first sixty milliseconds and read as a twitch.
2959
+ ease: "css-bezier(0.36, 0, 0.4, 1)",
2960
+ panEase: "css-bezier(0.36, 0, 0.4, 1)",
2961
+ followSafeRatio: 0.45,
2962
+ followRecenter: 0.55,
2963
+ followLookahead: 0.4,
2964
+ tilt: {
2965
+ intensity: "off",
2966
+ rampIn: 0.6,
2967
+ rampOut: 0.5,
2968
+ chainGap: 3,
2969
+ pan: 0.55
2970
+ }
2971
+ };
2972
+ var ZOOM_STYLES = {
2973
+ glide,
2974
+ // Screen Studio "Focused" / Recordly — a block per click, framed and
2975
+ // released, no travel. The spring-settled family ease (near-linear onset,
2976
+ // long settle) at the default's tempo: 0.8 s in, 0.65 s out, and a beat
2977
+ // of 1.5 s after the click so the block is read before it goes. At its
2978
+ // 2026-07 timing (1.4 s in, 1.0 s out around a 1.35 s span) a lone click
2979
+ // spent 74% of its beat moving and every dwell or sped-up click was
2980
+ // shorter than the ramp, which the lowering compressed into a cut.
2981
+ focus: {
2982
+ autoZoom: true,
2983
+ clusterGap: 1.5,
2984
+ minClusterClicks: 1,
2985
+ targetFill: 0.5,
2986
+ minLevel: 1.4,
2987
+ maxLevel: 2,
2988
+ dragLevel: 1.6,
2989
+ lead: 0.35,
2990
+ hold: 1.5,
2991
+ followByDefault: false,
2992
+ typingZoom: true,
2993
+ typingGap: 2,
2994
+ typingHold: 1.3,
2995
+ typingMinLevel: 1.5,
2996
+ rampIn: 0.8,
2997
+ rampInOverlap: 0.25,
2998
+ rampOut: 0.65,
2999
+ chainGap: 2.4,
3000
+ pan: 0.7,
3001
+ ease: "css-bezier(0.28, 0.03, 0.09, 1)",
3002
+ panEase: "css-bezier(0.26, 0.08, 0.2, 1)",
3003
+ followSafeRatio: 0.5,
3004
+ followRecenter: 0.55,
3005
+ followLookahead: 0,
3006
+ tilt: {
3007
+ intensity: "off",
3008
+ rampIn: 0.8,
3009
+ rampOut: 0.65,
3010
+ chainGap: 2.4,
3011
+ pan: 0.7
3012
+ }
3013
+ },
3014
+ // Screen Studio "Smooth" — the long-HOLD style: 1.2 s in, 1.0 s out,
3015
+ // two-second holds, chains across 3.5 s so a slow clip travels rather
3016
+ // than pumps, and the default's level range, because a watched clip
3017
+ // wants the frame, not the magnifier. Its ramps still exceed a dwell or
3018
+ // a sped-up click; the lowering fits them to the room.
3019
+ cinema: {
3020
+ autoZoom: true,
3021
+ clusterGap: 2.4,
3022
+ minClusterClicks: 1,
3023
+ targetFill: 0.42,
3024
+ minLevel: 1.3,
3025
+ maxLevel: 1.8,
3026
+ dragLevel: 1.5,
3027
+ lead: 0.6,
3028
+ hold: 2,
3029
+ followByDefault: true,
3030
+ typingZoom: true,
3031
+ typingGap: 3,
3032
+ typingHold: 1.8,
3033
+ typingMinLevel: 1.4,
3034
+ rampIn: 1.2,
3035
+ rampInOverlap: 0.4,
3036
+ rampOut: 1,
3037
+ chainGap: 3.5,
3038
+ pan: 1.1,
3039
+ ease: "css-bezier(0.33, 0, 0.15, 1)",
3040
+ panEase: "css-bezier(0.33, 0, 0.22, 1)",
3041
+ followSafeRatio: 0.6,
3042
+ followRecenter: 0.9,
3043
+ followLookahead: 0.5,
3044
+ tilt: {
3045
+ intensity: "off",
3046
+ rampIn: 1.2,
3047
+ rampOut: 1,
3048
+ chainGap: 3.5,
3049
+ pan: 1.1
3202
3050
  }
3203
- return out;
3204
- };
3205
- const scrub = src.live ? `.vos-html-layer{--vos-t:${fmtT(t)}}.vos-html-layer,.vos-html-layer *{animation-play-state:paused!important;animation-delay:calc(var(--vos-t) * -1s)!important}` : "";
3206
- const wrapper = `.vos-html-layer{width:${boxW}px;height:${boxH}px;box-sizing:border-box;margin:0;padding:${bleed}px;font-family:-apple-system,system-ui,sans-serif;-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision}`;
3207
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${boxW}" height="${boxH}" viewBox="0 0 ${boxW} ${boxH}"><foreignObject x="0" y="0" width="${boxW}" height="${boxH}"><div xmlns="http://www.w3.org/1999/xhtml" class="vos-html-layer"><style>${fontFaceCss(faces)}${wrapper}${fill(src.css ?? "")}${scrub}</style>` + fill(src.html) + `</div></foreignObject></svg>`;
3051
+ },
3052
+ // The studio's original pace with the instant-velocity onset defect fixed:
3053
+ // 0.4 s cycles on the onset-capped ease, the 2.5× ceiling of the 2026-07
3054
+ // comparison's defect two brought to 2.2, a chain gap past its pump band.
3055
+ snappy: {
3056
+ autoZoom: true,
3057
+ clusterGap: 1.2,
3058
+ minClusterClicks: 1,
3059
+ targetFill: 0.5,
3060
+ minLevel: 1.4,
3061
+ maxLevel: 2.2,
3062
+ dragLevel: 1.6,
3063
+ lead: 0.25,
3064
+ hold: 1.2,
3065
+ followByDefault: false,
3066
+ typingZoom: true,
3067
+ typingGap: 1.6,
3068
+ typingHold: 0.9,
3069
+ typingMinLevel: 1.5,
3070
+ rampIn: 0.4,
3071
+ rampInOverlap: 0.15,
3072
+ rampOut: 0.4,
3073
+ chainGap: 2,
3074
+ pan: 0.5,
3075
+ ease: "css-bezier(0.3, 0.55, 0.2, 1)",
3076
+ panEase: "css-bezier(0.25, 0.1, 0.25, 1)",
3077
+ followSafeRatio: 0.5,
3078
+ followRecenter: 0.45,
3079
+ followLookahead: 0,
3080
+ tilt: {
3081
+ intensity: "off",
3082
+ rampIn: 0.4,
3083
+ rampOut: 0.4,
3084
+ chainGap: 2,
3085
+ pan: 0.5
3086
+ }
3087
+ },
3088
+ // Screen Studio's "instant zoom" — a cut-in (eight frames at 60 fps),
3089
+ // tutorial tempo. Untouched in motion (the one style that never pumped
3090
+ // and never left a ramp unsettled); the ceiling comes down with the
3091
+ // others and the hold gains the beat a cut-in needs to be read.
3092
+ cut: {
3093
+ autoZoom: true,
3094
+ clusterGap: 1.2,
3095
+ minClusterClicks: 1,
3096
+ targetFill: 0.5,
3097
+ minLevel: 1.4,
3098
+ maxLevel: 2,
3099
+ dragLevel: 1.6,
3100
+ lead: 0.2,
3101
+ hold: 1.2,
3102
+ followByDefault: false,
3103
+ typingZoom: true,
3104
+ typingGap: 1.2,
3105
+ typingHold: 0.6,
3106
+ typingMinLevel: 1.5,
3107
+ rampIn: 0.14,
3108
+ rampInOverlap: 0.07,
3109
+ rampOut: 0.14,
3110
+ chainGap: 1.3,
3111
+ pan: 0.35,
3112
+ ease: "css-bezier(0.2, 0, 0.4, 1)",
3113
+ panEase: "css-bezier(0.2, 0, 0.4, 1)",
3114
+ followSafeRatio: 0.5,
3115
+ followRecenter: 0.35,
3116
+ followLookahead: 0,
3117
+ tilt: {
3118
+ intensity: "off",
3119
+ rampIn: 0.3,
3120
+ rampOut: 0.3,
3121
+ chainGap: 1.3,
3122
+ pan: 0.35
3123
+ }
3124
+ },
3125
+ // Auto-zoom off (every competitor ships this switch). Camera params still
3126
+ // apply to MANUAL spans — they move the way the default camera moves
3127
+ // TODAY (spelled as a spread, never a copy that fossilises).
3128
+ none: { ...glide, autoZoom: false }
3129
+ };
3130
+ var DEFAULT_ZOOM_STYLE = "glide";
3131
+ function resolveZoomStyle(name, overrides) {
3132
+ const live = liveZoomStyleName(name) ?? DEFAULT_ZOOM_STYLE;
3133
+ const params = { ...ZOOM_STYLES[live], ...overrides };
3134
+ const floor = pumpFreeChainGap(params);
3135
+ if (params.chainGap < floor) params.chainGap = floor;
3136
+ return params;
3208
3137
  }
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;
3138
+ var ZOOM_STYLE_OPTIONS = [
3139
+ {
3140
+ name: "glide",
3141
+ label: "Glide",
3142
+ hint: "One steady zoom that travels between clicks"
3143
+ },
3144
+ {
3145
+ name: "focus",
3146
+ label: "Focus",
3147
+ hint: "A zoom per click, framed and released"
3148
+ },
3149
+ {
3150
+ name: "cinema",
3151
+ label: "Cinema",
3152
+ hint: "Slow moves and long holds, for content that is watched"
3153
+ },
3154
+ { name: "snappy", label: "Snappy", hint: "Quick, energetic zoom cycles" },
3155
+ { name: "cut", label: "Cut", hint: "Instant zooms, no glide" },
3156
+ {
3157
+ name: "none",
3158
+ label: "None",
3159
+ hint: "No automatic zooms, add your own on the timeline"
3214
3160
  }
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)}`;
3161
+ ];
3162
+
3163
+ // src/docVersion.ts
3164
+ var DOC_SCHEMA_VERSION = 6;
3165
+ function migrateHostedDoc(raw) {
3166
+ const version = typeof raw.docSchemaVersion === "number" ? raw.docSchemaVersion : 0;
3167
+ const read = migrateText(raw);
3168
+ if (version >= DOC_SCHEMA_VERSION) return read;
3169
+ let doc = read;
3170
+ if (version < 4 && doc.source && typeof doc.source === "object") {
3171
+ doc = migrateMotion(doc);
3172
+ }
3173
+ if (version < 5) doc = migrateZoomStyle(doc);
3174
+ return { ...doc, docSchemaVersion: DOC_SCHEMA_VERSION };
3222
3175
  }
3223
- function htmlLayerPayload(clip) {
3224
- const faces = htmlLayerFaces(clip);
3225
- const bleed = htmlLayerBleed(clip);
3226
- const assets = htmlLayerAssets(clip).inlinable;
3176
+ function migrateZoomStyle(doc) {
3177
+ const name = doc.zoomStyle;
3178
+ if (typeof name !== "string") return doc;
3179
+ const retired = RETIRED_ZOOM_STYLES[name];
3180
+ if (!retired) return doc;
3227
3181
  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
- }
3182
+ ...doc,
3183
+ zoomStyle: retired.style,
3184
+ tiltStyle: doc.tiltStyle ?? retired.tilt
3239
3185
  };
3240
3186
  }
3241
3187
 
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;
3188
+ // src/doc/studioDoc.ts
3189
+ var anchorKindOf = (doc) => "source" in doc ? "recording" : "program";
3190
+ var isRecordingDoc = (doc) => "source" in doc;
3191
+ var isProgramDoc = (doc) => !("source" in doc);
3192
+ function programDuration(doc) {
3193
+ const own = doc.program.duration;
3194
+ if (typeof own === "number" && own > 0) return own;
3195
+ const cfg = doc.program.config.duration;
3196
+ return typeof cfg === "number" && cfg > 0 ? cfg : 0;
3197
+ }
3198
+ function anchorSourceDuration(doc) {
3199
+ return isRecordingDoc(doc) ? doc.source.meta.durationMs / 1e3 : programDuration(doc);
3200
+ }
3201
+
3202
+ // src/planner/smoothing.ts
3203
+ var SNAP_BEFORE = 0.12;
3204
+ var SNAP_AFTER = 0.18;
3205
+ var SNAP_GAIN = 0.5;
3206
+ function sampleAt(points, t) {
3207
+ if (points.length === 0) return { x: 0, y: 0 };
3208
+ if (t <= points[0].t) return { x: points[0].x, y: points[0].y };
3209
+ const last = points[points.length - 1];
3210
+ if (t >= last.t) return { x: last.x, y: last.y };
3211
+ for (let i = 1; i < points.length; i++) {
3212
+ if (points[i].t >= t) {
3213
+ const a = points[i - 1];
3214
+ const b = points[i];
3215
+ const f = (t - a.t) / (b.t - a.t || 1);
3216
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
3217
+ }
3250
3218
  }
3251
- return Array.from(text);
3219
+ return { x: last.x, y: last.y };
3252
3220
  }
3253
- function tokenRanges(line) {
3221
+ function smoothCursor(track, options = {}) {
3222
+ const factor = clamp2(options.factor ?? 0.15, 0.01, 1);
3223
+ const fps = options.fps ?? 60;
3224
+ 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 }));
3225
+ if (raw.length === 0) return [];
3226
+ const clicks = options.clickSnap ? track.filter((e) => e.type === "down").map((e) => ({ t: e.t / 1e3, x: e.x, y: e.y })) : [];
3227
+ const start = raw[0].t;
3228
+ const end = raw[raw.length - 1].t;
3229
+ const step = 1 / fps;
3230
+ const out = [];
3231
+ let cx = raw[0].x;
3232
+ let cy = raw[0].y;
3233
+ let ci = 0;
3234
+ for (let t = start; t <= end + 1e-9; t += step) {
3235
+ const target = sampleAt(raw, t);
3236
+ cx += (target.x - cx) * factor;
3237
+ cy += (target.y - cy) * factor;
3238
+ if (clicks.length) {
3239
+ while (ci < clicks.length - 1 && t > clicks[ci].t + SNAP_AFTER) ci++;
3240
+ const ck = clicks[ci];
3241
+ const u = clamp2(
3242
+ (t - (ck.t - SNAP_BEFORE)) / SNAP_BEFORE,
3243
+ 0,
3244
+ t <= ck.t + SNAP_AFTER ? 1 : 0
3245
+ );
3246
+ const w = u * u * (3 - 2 * u) * SNAP_GAIN;
3247
+ if (w > 0) {
3248
+ cx += (ck.x - cx) * w;
3249
+ cy += (ck.y - cy) * w;
3250
+ }
3251
+ }
3252
+ out.push({ t, x: cx, y: cy });
3253
+ }
3254
+ return out;
3255
+ }
3256
+ function clamp2(v, lo, hi) {
3257
+ return Math.max(lo, Math.min(hi, v));
3258
+ }
3259
+
3260
+ // src/planner/autoZoom.ts
3261
+ var DWELL_MOVE_FRAC = 0.02;
3262
+ var DWELL_MIN = 0.45;
3263
+ var DWELL_MAX = 2.6;
3264
+ var DWELL_SPACING = 1.8;
3265
+ var CLUSTER_FILL = 0.85;
3266
+ var DRAG_FIT_LEVEL = 1.15;
3267
+ var PRESS_ECHO_MS = 4;
3268
+ var PRESS_ECHO_PX = 2;
3269
+ var DRAG_MIN_TRAVEL_FRAC = 0.015;
3270
+ var DRAG_MIN_S = 0.2;
3271
+ function dragsFromTrack(track, width, height) {
3272
+ if (!(width > 0) || !(height > 0)) return [];
3273
+ const downs = dedupePresses(track.filter((e) => e.type === "down"));
3254
3274
  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]);
3275
+ for (const d of downs) {
3276
+ const up = track.find((e) => e.type === "up" && e.t > d.t);
3277
+ if (!up) continue;
3278
+ const t0 = d.t / 1e3;
3279
+ const t1 = up.t / 1e3;
3280
+ if (t1 - t0 < DRAG_MIN_S) continue;
3281
+ let travel = 0;
3282
+ for (const e of track) {
3283
+ if (e.t < d.t) continue;
3284
+ if (e.t > up.t) break;
3285
+ if (e.type !== "move" && e.type !== "up") continue;
3286
+ const dist = Math.hypot(e.x - d.x, e.y - d.y) / width;
3287
+ if (dist > travel) travel = dist;
3288
+ }
3289
+ if (travel < DRAG_MIN_TRAVEL_FRAC) continue;
3290
+ out.push({
3291
+ t0,
3292
+ t1,
3293
+ nx: clamp012(d.x / width),
3294
+ ny: clamp012(d.y / height),
3295
+ travel
3296
+ });
3259
3297
  }
3260
- return out.length ? out : [[0, line.length]];
3298
+ return out;
3261
3299
  }
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;
3300
+ var TYPING_MIN_PINGS = 2;
3301
+ var TYPING_MIN_DUR = 0.5;
3302
+ var TYPING_CLICK_ABSORB = 1.5;
3303
+ var TYPING_REFOCUS_FRAC = 0.08;
3304
+ function groupTrack(track, opts) {
3305
+ const { width, height, clusterGap, typingGap, typingZoom, targetFill } = opts;
3306
+ const minLevel = opts.minLevel ?? resolveZoomStyle().minLevel;
3307
+ const clicks = dedupePresses(
3308
+ track.filter((e) => e.type === "down")
3309
+ ).map((e) => ({ t: e.t / 1e3, rect: e.rect, x: e.x, y: e.y }));
3310
+ const sessions = typingZoom ? typingSessions(track, width, height, typingGap) : [];
3311
+ const absorbed = /* @__PURE__ */ new Set();
3312
+ for (const s of sessions) {
3313
+ let best = null;
3314
+ for (const c of clicks) {
3315
+ if (absorbed.has(c)) continue;
3316
+ if (c.t >= s.first || s.first - c.t > TYPING_CLICK_ABSORB) continue;
3317
+ const [nx, ny] = fieldCenter(c, width, height);
3318
+ if (Math.hypot(nx - s.cx, ny - s.cy) > TYPING_REFOCUS_FRAC) continue;
3319
+ if (!best || c.t > best.t) best = c;
3271
3320
  }
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;
3321
+ if (best) {
3322
+ absorbed.add(best);
3323
+ s.start = best.t;
3324
+ s.events = [best, ...s.events];
3289
3325
  }
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
- });
3326
+ }
3327
+ const targets = [];
3328
+ const surfaces = [];
3329
+ for (const c of clicks) {
3330
+ if (absorbed.has(c)) continue;
3331
+ if (isSurfacePress(c, width, height, targetFill)) surfaces.push(c);
3332
+ else targets.push(c);
3333
+ }
3334
+ const chain = (list, spatial) => {
3335
+ const clusters = [];
3336
+ for (const c of list) {
3337
+ const last = clusters.at(-1);
3338
+ const prev = last?.at(-1);
3339
+ const joins = last && prev && c.t - prev.t <= clusterGap && (!spatial || unionFitLevel(unionRect([...last, c]), width, height) >= minLevel);
3340
+ if (joins) last.push(c);
3341
+ else clusters.push([c]);
3313
3342
  }
3314
- return out;
3343
+ return clusters;
3315
3344
  };
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 };
3345
+ return {
3346
+ sessions,
3347
+ clusters: chain(targets, true),
3348
+ surfaces: chain(surfaces, false)
3321
3349
  };
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;
3350
+ }
3351
+ function dedupePresses(downs) {
3352
+ const kept = [];
3353
+ for (const e of downs) {
3354
+ const prev = kept.at(-1);
3355
+ 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) {
3356
+ continue;
3336
3357
  }
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
- }
3358
+ kept.push(e);
3359
+ }
3360
+ return kept;
3361
+ }
3362
+ function unionRect(clicks) {
3363
+ let x0 = Infinity;
3364
+ let y0 = Infinity;
3365
+ let x1 = -Infinity;
3366
+ let y1 = -Infinity;
3367
+ for (const c of clicks) {
3368
+ const r = c.rect ?? { x: c.x, y: c.y, w: 0, h: 0 };
3369
+ x0 = Math.min(x0, r.x);
3370
+ y0 = Math.min(y0, r.y);
3371
+ x1 = Math.max(x1, r.x + r.w);
3372
+ y1 = Math.max(y1, r.y + r.h);
3373
+ }
3374
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
3375
+ }
3376
+ function unionFitLevel(rect, width, height) {
3377
+ const fx = rect.w > 0 ? width * CLUSTER_FILL / rect.w : Infinity;
3378
+ const fy = rect.h > 0 ? height * CLUSTER_FILL / rect.h : Infinity;
3379
+ return Math.min(fx, fy);
3380
+ }
3381
+ function fitLevel(rect, width, height, targetFill) {
3382
+ if (!rect || rect.w <= 0 || rect.h <= 0) return null;
3383
+ return Math.min(width * targetFill / rect.w, height * targetFill / rect.h);
3384
+ }
3385
+ function isSurfacePress(c, width, height, targetFill) {
3386
+ const fit = fitLevel(c.rect, width, height, targetFill);
3387
+ return fit !== null && fit < DRAG_FIT_LEVEL;
3388
+ }
3389
+ function planAutoZoom(track, options) {
3390
+ const style = resolveZoomStyle(options.style, options.params);
3391
+ if (!style.autoZoom) return [];
3392
+ const {
3393
+ width,
3394
+ height,
3395
+ targetFill = style.targetFill,
3396
+ minLevel = style.minLevel,
3397
+ maxLevel = style.maxLevel,
3398
+ clusterGap = style.clusterGap,
3399
+ lead = style.lead,
3400
+ hold = style.hold,
3401
+ minClusterClicks = style.minClusterClicks,
3402
+ dragLevel = style.dragLevel,
3403
+ followByDefault = style.followByDefault,
3404
+ typingZoom = style.typingZoom,
3405
+ typingGap = style.typingGap,
3406
+ typingHold = style.typingHold,
3407
+ typingMinLevel = style.typingMinLevel,
3408
+ duration
3409
+ } = options;
3410
+ 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);
3411
+ const grouped = groupTrack(track, {
3412
+ width,
3413
+ height,
3414
+ clusterGap,
3415
+ typingGap,
3416
+ typingZoom,
3417
+ targetFill,
3418
+ minLevel
3419
+ });
3420
+ const { sessions } = grouped;
3421
+ const drags = dragsFromTrack(track, width, height);
3422
+ const dragPress = new Set(drags.map((d) => round2(d.t0)));
3423
+ const withoutDrags = (list) => list.map((cl) => cl.filter((c) => !dragPress.has(round2(c.t)))).filter((cl) => cl.length > 0);
3424
+ const clusters = withoutDrags(grouped.clusters);
3425
+ const surfaces = withoutDrags(grouped.surfaces);
3426
+ const dragSpans = drags.map((d, i) => ({
3427
+ id: `g${i}`,
3428
+ in: round2(Math.max(0, d.t0 - lead)),
3429
+ out: round2(d.t1 + hold),
3430
+ level: clampZoomLevel(Math.min(Math.max(dragLevel, 1.1), maxLevel)),
3431
+ cx: round2(d.nx),
3432
+ cy: round2(d.ny),
3433
+ focusMode: "auto",
3434
+ source: "auto"
3435
+ }));
3436
+ const eligible = clusters.filter((c) => c.length >= minClusterClicks);
3437
+ const clickSpans = [];
3438
+ const dragReserved = surfaces.map((cluster, i) => {
3439
+ const f = clusterFocus(cluster, width, height);
3440
+ return {
3441
+ id: `drag${i}`,
3442
+ in: Math.max(0, cluster[0].t - lead),
3443
+ out: cluster[cluster.length - 1].t + hold,
3444
+ level: 1,
3445
+ cx: f.cx,
3446
+ cy: f.cy
3447
+ };
3448
+ });
3449
+ for (const cluster of eligible) {
3450
+ const first = cluster[0];
3451
+ const last = cluster[cluster.length - 1];
3452
+ const f = focusFor(cluster, width, height, targetFill, minLevel, maxLevel);
3453
+ clickSpans.push({
3454
+ in: Math.max(0, first.t - lead),
3455
+ out: last.t + hold,
3456
+ cx: f.cx,
3457
+ cy: f.cy,
3458
+ level: f.level
3459
+ });
3460
+ }
3461
+ clickSpans.sort((a, b) => a.in - b.in);
3462
+ for (let i = 0; i + 1 < clickSpans.length; i++) {
3463
+ if (clickSpans[i].out > clickSpans[i + 1].in)
3464
+ clickSpans[i].out = clickSpans[i + 1].in;
3465
+ }
3466
+ for (const z of clickSpans) if (z.out - z.in < 0.3) z.dead = true;
3467
+ const typingFloor = Math.min(Math.max(minLevel, typingMinLevel), maxLevel);
3468
+ let typing = sessions.map((s) => {
3469
+ const f = focusFor(
3470
+ s.events,
3471
+ width,
3472
+ height,
3473
+ targetFill,
3474
+ typingFloor,
3475
+ maxLevel
3476
+ );
3477
+ return {
3478
+ in: Math.max(0, s.start - lead),
3479
+ out: s.last + typingHold,
3480
+ cx: f.cx,
3481
+ cy: f.cy,
3482
+ level: f.level
3483
+ };
3484
+ });
3485
+ for (const t of typing) {
3486
+ for (const z of clickSpans) {
3487
+ if (z.dead || t.dead) continue;
3488
+ if (t.in >= z.out || t.out <= z.in) continue;
3489
+ if (Math.hypot(t.cx - z.cx, t.cy - z.cy) <= TYPING_REFOCUS_FRAC) {
3490
+ t.in = Math.min(t.in, z.in);
3491
+ t.out = Math.max(t.out, z.out);
3492
+ z.dead = true;
3493
+ } else if (z.in <= t.in && z.out >= t.out) {
3494
+ t.dead = true;
3495
+ } else if (z.in > t.in) {
3496
+ t.out = z.in;
3347
3497
  } 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
- }
3498
+ t.in = z.out;
3378
3499
  }
3379
- push2(runs, a, b);
3380
3500
  }
3381
- base += len + 1;
3382
3501
  }
3383
- if (!lines.length) lines.push({ w: 0, frags: [], a: 0, n: 0 });
3384
- return { lines, width };
3502
+ typing = typing.filter((t) => !t.dead).sort((a, b) => a.in - b.in);
3503
+ for (let i = 0; i + 1 < typing.length; i++) {
3504
+ if (typing[i].out > typing[i + 1].in) typing[i].out = typing[i + 1].in;
3505
+ }
3506
+ typing = typing.filter((t) => t.out - t.in >= 0.3);
3507
+ const zSpans = clickSpans.filter((s) => !s.dead).sort((a, b) => a.in - b.in).map((s, i) => ({
3508
+ id: `z${i}`,
3509
+ in: round2(s.in),
3510
+ out: round2(s.out),
3511
+ level: clampZoomLevel(s.level),
3512
+ cx: round2(s.cx),
3513
+ cy: round2(s.cy),
3514
+ // Follow styles ride the cursor through the span (entry + dead-zone
3515
+ // recenters baked at lowering); typing/dwell spans stay fixed-focus.
3516
+ ...followByDefault ? { focusMode: "auto" } : {},
3517
+ source: "auto"
3518
+ }));
3519
+ const kSpans = typing.map((s, i) => ({
3520
+ id: `k${i}`,
3521
+ in: round2(s.in),
3522
+ out: round2(s.out),
3523
+ level: clampZoomLevel(s.level),
3524
+ cx: round2(s.cx),
3525
+ cy: round2(s.cy),
3526
+ // Never focusMode:'auto': the dot is parked (and fading) while typing —
3527
+ // the FIELD is the anchor, and a follow would be a no-op at best.
3528
+ source: "auto"
3529
+ }));
3530
+ const spans = within([...zSpans, ...kSpans, ...dragSpans]).sort(
3531
+ (a, b) => a.in - b.in
3532
+ );
3533
+ const dwells = dwellSpans(track, width, height, maxLevel, [
3534
+ ...spans,
3535
+ ...dragReserved
3536
+ ]);
3537
+ return within([...spans, ...dwells]).sort((a, b) => a.in - b.in);
3385
3538
  }
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 };
3539
+ function typingSessions(track, width, height, typingGap) {
3540
+ const pings = track.filter((e) => e.type === "key").map((e) => ({ t: e.t / 1e3, rect: e.rect, x: e.x, y: e.y }));
3541
+ const all = [];
3542
+ let cur = null;
3543
+ for (const p of pings) {
3544
+ const [nx, ny] = fieldCenter(p, width, height);
3545
+ if (cur && p.t - cur.last <= typingGap && Math.hypot(nx - cur.cx, ny - cur.cy) <= TYPING_REFOCUS_FRAC) {
3546
+ cur.events.push(p);
3547
+ cur.last = p.t;
3548
+ } else {
3549
+ cur = { events: [p], first: p.t, last: p.t, start: p.t, cx: nx, cy: ny };
3550
+ all.push(cur);
3551
+ }
3397
3552
  }
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
3553
+ return all.filter(
3554
+ (s) => s.events.length >= TYPING_MIN_PINGS && s.last - s.first >= TYPING_MIN_DUR
3401
3555
  );
3402
- return { x: x0, y: top, w: x1 - x0, h: t };
3403
3556
  }
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
- };
3557
+ function fieldCenter(c, width, height) {
3558
+ const px = c.rect ? c.rect.x + c.rect.w / 2 : c.x;
3559
+ const py = c.rect ? c.rect.y + c.rect.h / 2 : c.y;
3560
+ return [clamp012(px / width), clamp012(py / height)];
3419
3561
  }
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
3562
+ function dwellSpans(track, width, height, maxLevel, reserved) {
3563
+ const moves = track.filter((e) => e.type === "move").map((e) => ({ t: e.t / 1e3, nx: e.x / width, ny: e.y / height }));
3564
+ if (moves.length < 2) return [];
3565
+ const trackEnd = track[track.length - 1].t / 1e3;
3566
+ const candidates = [];
3567
+ let start = 0;
3568
+ for (let i = 1; i <= moves.length; i++) {
3569
+ const breaks = i === moves.length || Math.hypot(moves[i].nx - moves[i - 1].nx, moves[i].ny - moves[i - 1].ny) > DWELL_MOVE_FRAC;
3570
+ if (!breaks) continue;
3571
+ const endT = i < moves.length ? moves[i].t : trackEnd;
3572
+ const dur = endT - moves[start].t;
3573
+ const opening = start === 0;
3574
+ if (!opening && dur >= DWELL_MIN && dur <= DWELL_MAX) {
3575
+ const run = moves.slice(start, i);
3576
+ candidates.push({
3577
+ center: (moves[start].t + endT) / 2,
3578
+ cx: run.reduce((s, p) => s + p.nx, 0) / run.length,
3579
+ cy: run.reduce((s, p) => s + p.ny, 0) / run.length,
3580
+ strength: dur
3581
+ });
3582
+ }
3583
+ start = i;
3446
3584
  }
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)
3585
+ const sorted = [...candidates].sort((a, b) => b.strength - a.strength);
3586
+ const sourceDuration = trackEnd;
3587
+ const len = Math.max(1, sourceDuration * 0.05);
3588
+ const taken = [...reserved];
3589
+ const accepted = [];
3590
+ const centers = [];
3591
+ for (const c of sorted) {
3592
+ if (centers.some((t) => Math.abs(t - c.center) < DWELL_SPACING)) continue;
3593
+ const spanIn = Math.max(
3594
+ 0,
3595
+ Math.min(c.center - len / 2, sourceDuration - len)
3596
+ );
3597
+ const spanOut = Math.min(sourceDuration, spanIn + len);
3598
+ if (spanOut - spanIn < 0.3) continue;
3599
+ if (taken.some((z) => spanIn < z.out && spanOut > z.in)) continue;
3600
+ const span = {
3601
+ id: `d${accepted.length}`,
3602
+ in: round2(spanIn),
3603
+ out: round2(spanOut),
3604
+ // No element rect on moves → point zoom at the planner ceiling.
3605
+ level: clampZoomLevel(maxLevel),
3606
+ cx: round2(clamp012(c.cx)),
3607
+ cy: round2(clamp012(c.cy)),
3608
+ source: "auto"
3609
+ };
3610
+ accepted.push(span);
3611
+ taken.push(span);
3612
+ centers.push(c.center);
3613
+ }
3614
+ return accepted.sort((a, b) => a.in - b.in).map((z, i) => ({ ...z, id: `d${i}` }));
3615
+ }
3616
+ function focusFor(cluster, width, height, targetFill, minLevel, maxLevel) {
3617
+ let sx = 0;
3618
+ let sy = 0;
3619
+ let maxW = 0;
3620
+ let maxH = 0;
3621
+ for (const c of cluster) {
3622
+ if (c.rect) {
3623
+ sx += c.rect.x + c.rect.w / 2;
3624
+ sy += c.rect.y + c.rect.h / 2;
3625
+ maxW = Math.max(maxW, c.rect.w);
3626
+ maxH = Math.max(maxH, c.rect.h);
3627
+ } else {
3628
+ sx += c.x;
3629
+ sy += c.y;
3630
+ }
3457
3631
  }
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}`;
3632
+ const n = cluster.length;
3633
+ const cx = clamp012(sx / n / width);
3634
+ const cy = clamp012(sy / n / height);
3635
+ const own = fitLevel({ x: 0, y: 0, w: maxW, h: maxH }, width, height, targetFill) ?? maxLevel;
3636
+ const together = unionFitLevel(unionRect(cluster), width, height);
3637
+ return { cx, cy, level: clamp3(Math.min(own, together), minLevel, maxLevel) };
3638
+ }
3639
+ function clusterFocus(events, width, height) {
3640
+ let sx = 0;
3641
+ let sy = 0;
3642
+ let x0 = Infinity;
3643
+ let y0 = Infinity;
3644
+ let x1 = -Infinity;
3645
+ let y1 = -Infinity;
3646
+ let rects = 0;
3647
+ for (const c of events) {
3648
+ if (c.rect) {
3649
+ sx += c.rect.x + c.rect.w / 2;
3650
+ sy += c.rect.y + c.rect.h / 2;
3651
+ x0 = Math.min(x0, c.rect.x);
3652
+ y0 = Math.min(y0, c.rect.y);
3653
+ x1 = Math.max(x1, c.rect.x + c.rect.w);
3654
+ y1 = Math.max(y1, c.rect.y + c.rect.h);
3655
+ rects++;
3656
+ } else {
3657
+ sx += c.x;
3658
+ sy += c.y;
3659
+ }
3468
3660
  }
3469
- if (clip.weight !== void 0 || clip.family) {
3470
- const wanted = clip.weight ?? base.weight;
3471
- weight = familyEntry ? nearestFontWeight2(familyEntry, wanted) : wanted;
3661
+ const n = Math.max(1, events.length);
3662
+ const rect = rects > 0 ? {
3663
+ x: clamp012(x0 / width),
3664
+ y: clamp012(y0 / height),
3665
+ w: clamp012((x1 - x0) / width),
3666
+ h: clamp012((y1 - y0) / height)
3667
+ } : null;
3668
+ return { cx: clamp012(sx / n / width), cy: clamp012(sy / n / height), rect };
3669
+ }
3670
+ function clamp3(v, lo, hi) {
3671
+ return Math.max(lo, Math.min(hi, v));
3672
+ }
3673
+ function clamp012(v) {
3674
+ return clamp3(v, 0, 1);
3675
+ }
3676
+ function round2(v) {
3677
+ return Math.round(v * 1e3) / 1e3;
3678
+ }
3679
+
3680
+ // src/lower/lowerToComposition.ts
3681
+ import {
3682
+ EASINGS,
3683
+ lerpArray as lerpArray2,
3684
+ sample as sample2,
3685
+ segmentRate as segmentRate2,
3686
+ sortKeyframes as sortKeyframes2,
3687
+ sourceToTimeline,
3688
+ splitBySpeed as splitBySpeed3,
3689
+ totalDuration as totalDuration3
3690
+ } from "@vosjs/timeline";
3691
+ import { timelineRuntimeCode as timelineRuntimeCode2 } from "@vosjs/timeline/bundle";
3692
+
3693
+ // src/layout.ts
3694
+ function computeCardLayout(frame, video, W, H, zoomFocus) {
3695
+ const s = H / 1080;
3696
+ const pad = (frame.padding || 0) * s;
3697
+ const ins = frame.inset ?? {};
3698
+ const padL = ins.left == null ? pad : ins.left * W;
3699
+ const padR = ins.right == null ? pad : ins.right * W;
3700
+ const padT = ins.top == null ? pad : ins.top * H;
3701
+ const padB = ins.bottom == null ? pad : ins.bottom * H;
3702
+ const bar = frame.browserBar;
3703
+ const vw = video.width || 16;
3704
+ const vh = video.height || 9;
3705
+ const fitCover = frame.fit === "cover";
3706
+ const cf = fitCover ? 1 : Math.min(1, W / H / (vw / vh));
3707
+ const barH = bar.kind !== "none" ? (bar.height || 44) * s * cf : 0;
3708
+ const availW = Math.max(1, W - padL - padR);
3709
+ const availH = Math.max(1, H - padT - padB - barH);
3710
+ if (fitCover) {
3711
+ const sc2 = Math.max(availW / vw, availH / vh);
3712
+ const dw2 = vw * sc2;
3713
+ const dh2 = vh * sc2;
3714
+ const follow = frame.focusFollow === "camera" && zoomFocus ? zoomFocus : null;
3715
+ const fcx = clamp013(follow ? follow.cx : frame.focus?.cx ?? 0.5);
3716
+ const fcy = clamp013(follow ? follow.cy : frame.focus?.cy ?? 0.5);
3717
+ const vTop = padT + barH;
3718
+ const dx2 = Math.min(
3719
+ padL,
3720
+ Math.max(padL + availW - dw2, padL + availW / 2 - fcx * dw2)
3721
+ );
3722
+ const dy2 = Math.min(
3723
+ vTop,
3724
+ Math.max(vTop + availH - dh2, vTop + availH / 2 - fcy * dh2)
3725
+ );
3726
+ return {
3727
+ W,
3728
+ H,
3729
+ dx: dx2,
3730
+ dy: dy2,
3731
+ dw: dw2,
3732
+ dh: dh2,
3733
+ cardX: padL,
3734
+ cardY: padT,
3735
+ cardW: availW,
3736
+ cardH: availH + barH
3737
+ };
3472
3738
  }
3739
+ const sc = Math.min(availW / vw, availH / vh);
3740
+ const dw = vw * sc;
3741
+ const dh = vh * sc;
3742
+ const dx = padL + (availW - dw) / 2;
3743
+ const dy = padT + barH + (availH - dh) / 2;
3473
3744
  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
3745
+ W,
3746
+ H,
3747
+ dx,
3748
+ dy,
3749
+ dw,
3750
+ dh,
3751
+ cardX: dx,
3752
+ cardY: dy - barH,
3753
+ cardW: dw,
3754
+ cardH: dh + barH
3490
3755
  };
3491
3756
  }
3492
- function overlayFaceFor(clip) {
3493
- const entry = findFontFamily2(
3494
- clip.family ?? PRESET_FAMILY[clip.preset in TEXT_PRESETS ? clip.preset : "title"]
3757
+ function docCardLayout(doc, media) {
3758
+ const meta = doc.source.meta;
3759
+ const H = 1080;
3760
+ const W = Math.max(
3761
+ 2,
3762
+ Math.round(H * aspectRatioValue(doc.frame.aspectRatio, meta))
3495
3763
  );
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
3764
+ const src = mediaSource(doc, media) ?? doc.source;
3765
+ const sm = src.meta;
3766
+ return computeCardLayout(
3767
+ mediaFrame(doc, media),
3768
+ {
3769
+ width: sm.captureWidth ?? sm.width,
3770
+ height: sm.captureHeight ?? sm.height
3771
+ },
3772
+ W,
3773
+ H
3500
3774
  );
3501
- if (inBase) return null;
3502
- return {
3503
- family: entry.family,
3504
- weight,
3505
- url: fontFaceUrl2(entry.slug, weight)
3506
- };
3507
3775
  }
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);
3776
+ function recommendedExportResolution(doc) {
3777
+ const meta = doc.source.meta;
3778
+ const capW = meta.captureWidth ?? meta.width;
3779
+ const video = { width: capW, height: meta.captureHeight ?? meta.height };
3780
+ let best = EXPORT_RESOLUTION_OPTIONS[0];
3781
+ for (const r of EXPORT_RESOLUTION_OPTIONS) {
3782
+ const { width, height } = resolveExportSize(doc, r);
3783
+ if (computeCardLayout(doc.frame, video, width, height).dw > capW) break;
3784
+ best = r;
3522
3785
  }
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;
3786
+ return best;
3583
3787
  }
3584
- function stripEmphasis(text) {
3585
- return text.split(EM_OPEN).join("").split(EM_CLOSE).join("");
3788
+ function camBubbleRect(cam, W, H = 1080) {
3789
+ const s = H / 1080;
3790
+ const size = Math.max(40, (cam.size || 0.25) * H);
3791
+ const mg = 24 * s;
3792
+ const x = cam.x != null ? cam.x * W - size / 2 : cam.position.includes("right") ? W - mg - size : mg;
3793
+ const y = cam.y != null ? cam.y * H - size / 2 : cam.position.includes("top") ? mg : H - mg - size;
3794
+ return {
3795
+ x,
3796
+ y,
3797
+ size,
3798
+ radius: cam.shape === "rounded" ? (cam.radius ?? 18) * s : size / 2
3799
+ };
3586
3800
  }
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"]
3801
+ function focusBounds(level, layout, camera = "card", screen) {
3802
+ if (camera === "stage" && screen && level > 1.001) {
3803
+ return { minX: 0, maxX: 1, minY: 0, maxY: 1 };
3804
+ }
3805
+ if (level <= 1.001) {
3806
+ return { minX: 0.5, maxX: 0.5, minY: 0.5, maxY: 0.5 };
3807
+ }
3808
+ if (camera === "stage") {
3809
+ const x2 = stageAxisBounds(
3810
+ layout.dx,
3811
+ layout.dw,
3812
+ layout.cardX,
3813
+ layout.cardW,
3814
+ layout.W,
3815
+ level
3816
+ );
3817
+ const y2 = stageAxisBounds(
3818
+ layout.dy,
3819
+ layout.dh,
3820
+ layout.cardY,
3821
+ layout.cardH,
3822
+ layout.H,
3823
+ level
3824
+ );
3825
+ return { minX: x2.min, maxX: x2.max, minY: y2.min, maxY: y2.max };
3826
+ }
3827
+ const x = axisBounds(
3828
+ layout.dx,
3829
+ layout.dw,
3830
+ layout.cardX,
3831
+ layout.cardW,
3832
+ layout.W,
3833
+ level
3593
3834
  );
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
3835
+ const y = axisBounds(
3836
+ layout.dy,
3837
+ layout.dh,
3838
+ layout.cardY,
3839
+ layout.cardH,
3840
+ layout.H,
3841
+ level
3598
3842
  );
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
- };
3843
+ return { minX: x.min, maxX: x.max, minY: y.min, maxY: y.max };
3610
3844
  }
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);
3845
+ function axisBounds(anchorOff, anchorSize, coverOff, coverSize, viewport, level) {
3846
+ const k = 1 - 1 / level;
3847
+ let lo = (coverOff / k - anchorOff) / anchorSize;
3848
+ let hi = ((coverOff + coverSize - viewport / level) / k - anchorOff) / anchorSize;
3849
+ if (lo > hi) {
3850
+ const mid = (lo + hi) / 2;
3851
+ lo = mid;
3852
+ hi = mid;
3616
3853
  }
3617
- return (h >>> 0).toString(36);
3854
+ return { min: clamp013(lo), max: clamp013(hi) };
3618
3855
  }
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 }];
3856
+ function stageAxisBounds(anchorOff, anchorSize, coverOff, coverSize, viewport, level) {
3857
+ const b = CAMERA_COVER_MIN / 2 * viewport;
3858
+ let lo = (coverOff + b / level - anchorOff) / anchorSize;
3859
+ let hi = (coverOff + coverSize - b / level - anchorOff) / anchorSize;
3860
+ if (lo > hi) {
3861
+ const mid = (lo + hi) / 2;
3862
+ lo = mid;
3863
+ hi = mid;
3864
+ }
3865
+ return { min: clamp013(lo), max: clamp013(hi) };
3866
+ }
3867
+ function clampFocus(cx, cy, level, layout, camera = "card", screen) {
3868
+ const b = focusBounds(level, layout, camera, screen);
3643
3869
  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]))
3870
+ cx: Math.min(b.maxX, Math.max(b.minX, cx)),
3871
+ cy: Math.min(b.maxY, Math.max(b.minY, cy))
3648
3872
  };
3649
3873
  }
3650
- function overlayTokens(line) {
3651
- return line.match(/\S+\s*/g) ?? [line];
3874
+ function clamp013(v) {
3875
+ return Math.max(0, Math.min(1, v));
3652
3876
  }
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 : [""];
3877
+ function screenPoint(layout, camera, screen) {
3878
+ return camera === "stage" && screen ? { ox: screen.x * layout.W, oy: screen.y * layout.H } : { ox: layout.W / 2, oy: layout.H / 2 };
3677
3879
  }
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];
3880
+ function cameraModel(frame) {
3881
+ return frame?.camera === "stage" ? "stage" : "card";
3882
+ }
3883
+ var CAMERA_CENTRE_RAMP = 0.3;
3884
+ var CAMERA_COVER_MIN = 0.8;
3885
+ function cameraCentring(level) {
3886
+ const u = clamp013((level - 1) / CAMERA_CENTRE_RAMP);
3887
+ return u * u * (3 - 2 * u);
3687
3888
  }
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);
3889
+ function zoomView(level, cx, cy, layout, camera = "card", centring, screen) {
3890
+ const L = Math.max(1, level);
3891
+ const fx = layout.dx + cx * layout.dw;
3892
+ const fy = layout.dy + cy * layout.dh;
3893
+ const { ox, oy } = screenPoint(layout, camera, screen);
3894
+ const t = camera === "stage" ? clamp013(centring ?? 1) : 0;
3895
+ const wcx = fx + (ox - fx) / L * (1 - t);
3896
+ const wcy = fy + (oy - fy) / L * (1 - t);
3897
+ return { level: L, fx, fy, wcx, wcy, ox, oy };
3694
3898
  }
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;
3899
+ function zoomViewport(level, cx, cy, layout, camera = "card", centring, screen) {
3900
+ const v = zoomView(level, cx, cy, layout, camera, centring, screen);
3901
+ const size = 1 / v.level;
3902
+ const mx = v.wcx + (layout.W / 2 - v.ox) / v.level;
3903
+ const my = v.wcy + (layout.H / 2 - v.oy) / v.level;
3716
3904
  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
3905
+ x: (mx - layout.W / (2 * v.level)) / layout.W,
3906
+ y: (my - layout.H / (2 * v.level)) / layout.H,
3907
+ w: size,
3908
+ h: size
3725
3909
  };
3726
3910
  }
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}`;
3911
+ function focusForViewportCentre(cX, cY, level, layout, camera = "card", centring, screen) {
3912
+ const L = Math.max(1, level);
3913
+ const t = camera === "stage" ? clamp013(centring ?? 1) : 0;
3914
+ const a = 1 - (1 - t) / L;
3915
+ if (a <= 1e-6) return { cx: 0.5, cy: 0.5 };
3916
+ const { ox, oy } = screenPoint(layout, camera, screen);
3917
+ const fx = (cX * layout.W - ox * (1 - t) / L - (layout.W / 2 - ox) / L) / a;
3918
+ const fy = (cY * layout.H - oy * (1 - t) / L - (layout.H / 2 - oy) / L) / a;
3919
+ return { cx: (fx - layout.dx) / layout.dw, cy: (fy - layout.dy) / layout.dh };
3730
3920
  }
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
- };
3921
+ function levelForFocusFraction(frac) {
3922
+ if (!(frac > 0)) return clampZoomLevel(Number.POSITIVE_INFINITY);
3923
+ return clampZoomLevel(Math.max(1.1, 1 / frac));
3779
3924
  }
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;
3925
+
3926
+ // src/stage.ts
3927
+ var CARD_FOV = 30;
3928
+ var OVERLAY_Z = -2;
3929
+ var CARD_Z = -4;
3930
+ var BACKGROUND_Z = -6;
3931
+ var CARD_OVERSCAN_STEP = 0.05;
3932
+ var CARD_OVERSCAN_MAX = 4;
3933
+ var CAMERA_NEAR = 0.1;
3934
+ var CAMERA_FAR = 100;
3935
+ function planeSizeAtDepth(distance, fovDeg, aspect) {
3936
+ const height = 2 * Math.abs(distance) * Math.tan(fovDeg * Math.PI / 180 / 2);
3937
+ return { width: height * aspect, height };
3938
+ }
3939
+ function cardPointToScreen(u, v) {
3940
+ return { sx: u, sy: v };
3791
3941
  }
3792
3942
 
3793
3943
  // src/text3d.ts
@@ -5393,324 +5543,155 @@ var STUDIO_FRAME = `(ctx, content, dt) => {
5393
5543
  ovC.textAlign = 'center'
5394
5544
  ovC.textBaseline = 'middle'
5395
5545
  ovC.letterSpacing = ((ol.ls || 0) * olMS * s) + 'px'
5396
- // A STYLED layer (ol.rt: lines of runs, each set its own way) is laid
5397
- // out by the layout module, ONCE: the layout is kept per clip under a
5398
- // key of everything it was measured with, so a frame that only moves,
5399
- // fades or reveals the layer measures nothing. Fragments draw left to
5400
- // right from each line's left edge; a per-unit entrance animates the
5401
- // layout's units, each a few parts of fragments.
5546
+ // Every text layer is laid out by the layout module, ONCE: a styled one
5547
+ // from its runs (ol.rt: lines of runs, each set its own way), a plain
5548
+ // one from its lines, each a single run in the layer's own style. The
5549
+ // layout is kept per clip under a key of everything it was measured
5550
+ // with, so a frame that only moves, fades or reveals the layer measures
5551
+ // nothing. Fragments draw left to right from each line's left edge; a
5552
+ // per-unit entrance animates the layout's units, each a few parts of
5553
+ // fragments.
5554
+ var rtFonts = [ovC.font]
5402
5555
  if (ol.rt) {
5403
- var rtFonts = [ovC.font]
5404
5556
  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)
5406
- }
5407
- var rtFx = ol.fx && ol.fx.units.length ? ol.fx : null
5408
- var rtMax = ol.mw ? ol.mw * W : 0
5409
- var rtKey = ol.rt.h + '|' + rtFonts[0] + '|' + ((ol.ls || 0) * olMS * s) + '|' + rtMax + '|' + (rtFx ? rtFx.u : '') + '|' + (ns.fontEpoch || 0)
5410
- var rtAll = ns.textLayouts || (ns.textLayouts = {})
5411
- var rtHit = rtAll[ol.id]
5412
- if (!rtHit || rtHit.k !== rtKey) {
5413
- rtHit = rtAll[ol.id] = {
5414
- k: rtKey,
5415
- L: TXL.layoutText(
5416
- { lines: ol.rt.l, maxWidth: rtMax, unit: rtFx ? rtFx.u : undefined },
5417
- function (mt, mf) { ovC.font = rtFonts[mf]; return ovC.measureText(mt).width }
5418
- )
5419
- }
5420
- ovC.font = rtFonts[0]
5421
- }
5422
- var rtL = rtHit.L
5423
- var rtN = rtL.lines.length
5424
- var rtLH = olPx * (ol.lh || ${OVERLAY_LINE_HEIGHT})
5425
- var rtY0 = -((rtN - 1) * rtLH) / 2
5426
- // A line's left edge: lines sit centred on the anchor and align
5427
- // against the widest, as the plain layer's do.
5428
- var rtLeft = function (lw) {
5429
- var xo = ol.align === 'left' ? (lw - rtL.width) / 2 : ol.align === 'right' ? (rtL.width - lw) / 2 : 0
5430
- return xo - lw / 2
5431
- }
5432
- if (ol.box) {
5433
- var rbPX = ol.box.px * olMS * s
5434
- var rbPY = ol.box.py * olMS * s
5435
- var rbW = rtL.width + rbPX * 2
5436
- var rbH = rtN * rtLH + rbPY * 2
5437
- ovC.save()
5438
- ovC.globalAlpha = olA * ol.box.o
5439
- ovC.fillStyle = ol.box.c
5440
- rr(-rbW / 2, -rbH / 2, rbW, rbH, Math.min(ol.box.r * olMS * s, rbH / 2), ovC)
5441
- ovC.fill()
5442
- ovC.restore()
5443
- }
5444
- // Highlights sit behind the words and take no legibility shadow.
5445
- for (var rhi = 0; rhi < rtN; rhi++) {
5446
- var rhLine = rtL.lines[rhi]
5447
- for (var rhj = 0; rhj < rhLine.frags.length; rhj++) {
5448
- var rhF = rhLine.frags[rhj]
5449
- if (!rhF.h) continue
5450
- var rhR = TXL.decorationRect('h', rtLeft(rhLine.w) + rhF.x, rhF.w, rtY0 + rhi * rtLH, olPx)
5451
- ovC.fillStyle = rhF.h
5452
- ovC.fillRect(rhR.x, rhR.y, rhR.w, rhR.h)
5453
- }
5454
- }
5455
- if (ol.shadow > 0) {
5456
- ovC.shadowColor = 'rgba(0,0,0,' + ol.shadow + ')'
5457
- ovC.shadowBlur = olPx * 0.25
5458
- ovC.shadowOffsetY = olPx * 0.04
5459
- }
5460
- ovC.textAlign = 'left'
5461
- // One fragment (or the part of one a unit holds): the underline under
5462
- // the glyphs, stroke under fill, the strikethrough over them.
5463
- var rtPart = function (p, bx, y) {
5464
- ovC.font = rtFonts[p.f || 0]
5465
- ovC.fillStyle = p.c || ol.color
5466
- if (p.u) {
5467
- var ru = TXL.decorationRect('u', bx + p.x, p.w, y, olPx)
5468
- ovC.fillRect(ru.x, ru.y, ru.w, ru.h)
5469
- }
5470
- if (ol.stroke) {
5471
- ovC.strokeStyle = ol.stroke.c
5472
- ovC.lineWidth = ol.stroke.w * olMS * s
5473
- ovC.lineJoin = 'round'
5474
- ovC.strokeText(p.t, bx + p.x, y)
5475
- }
5476
- ovC.fillText(p.t, bx + p.x, y)
5477
- if (p.s) {
5478
- var rs = TXL.decorationRect('s', bx + p.x, p.w, y, olPx)
5479
- ovC.fillRect(rs.x, rs.y, rs.w, rs.h)
5480
- }
5481
- }
5482
- if (!rtFx) {
5483
- for (var rli = 0; rli < rtN; rli++) {
5484
- var rLine = rtL.lines[rli]
5485
- var rBX = rtLeft(rLine.w)
5486
- for (var rfk = 0; rfk < rLine.frags.length; rfk++) rtPart(rLine.frags[rfk], rBX, rtY0 + rli * rtLH)
5487
- }
5488
- } else {
5489
- // Per-unit entrance, the plain layer's own arithmetic over the
5490
- // layout's units: delay = order(index) x st, eased over dur.
5491
- var rtCount = 0
5492
- for (var rci = 0; rci < rtN; rci++) rtCount += rtL.lines[rci].units.length
5493
- var rtIdx = 0
5494
- for (var rui = 0; rui < rtN; rui++) {
5495
- var rULine = rtL.lines[rui]
5496
- var rUBX = rtLeft(rULine.w)
5497
- var rUY = rtY0 + rui * rtLH
5498
- for (var ruj = 0; ruj < rULine.units.length; ruj++, rtIdx++) {
5499
- var rU = rULine.units[ruj]
5500
- var rOrd = rtFx.d === 1 ? (rtCount - 1 - rtIdx) : rtFx.d === 2 ? Math.abs(rtIdx - (rtCount - 1) / 2) : rtIdx
5501
- var rT2 = olT - rOrd * rtFx.st
5502
- var rUA = 1, rUu = 1
5503
- if (rtFx.k === 'typewriter') {
5504
- rUA = rT2 >= 0 ? 1 : 0
5505
- } else {
5506
- rUu = Math.max(0, Math.min(1, rT2 / rtFx.dur))
5507
- var rUE = 1 - Math.pow(1 - rUu, 3)
5508
- rUA = rtFx.k === 'pop' ? Math.min(1, rUu * 2) : rUE
5509
- }
5510
- if (rUA <= 0.004) continue
5511
- ovC.save()
5512
- ovC.globalAlpha = olA * rUA
5513
- if (rUu < 1) {
5514
- if (rtFx.k === 'rise') {
5515
- ovC.translate(0, (1 - (1 - Math.pow(1 - rUu, 3))) * 24 * s)
5516
- } else if (rtFx.k === 'pop') {
5517
- var rPS = 1 + 2.70158 * Math.pow(rUu - 1, 3) + 1.70158 * Math.pow(rUu - 1, 2)
5518
- var rCX = rUBX + rU.x + rU.w / 2
5519
- ovC.translate(rCX, rUY)
5520
- ovC.scale(rPS, rPS)
5521
- ovC.translate(-rCX, -rUY)
5522
- } else if (rtFx.k === 'blur') {
5523
- ovC.filter = 'blur(' + ((1 - rUu) * olPx * 0.12).toFixed(2) + 'px)'
5524
- }
5525
- }
5526
- for (var rpk = 0; rpk < rU.parts.length; rpk++) rtPart(rU.parts[rpk], rUBX, rUY)
5527
- ovC.restore()
5528
- }
5529
- }
5557
+ rtFonts.push((ol.rt.fs[rfi].i ? 'italic ' : '') + ol.rt.fs[rfi].w + ' ' + olPx + 'px ' + ol.stack)
5558
+ }
5559
+ }
5560
+ var rtFx = ol.fx && ol.fx.u !== 'block' ? ol.fx : null
5561
+ var rtMax = ol.mw ? ol.mw * W : 0
5562
+ var rtKey = (ol.rt ? 'r' + ol.rt.h : 'p' + (ol.lines || ['']).join('\\n')) + '|' + rtFonts[0] + '|' + ((ol.ls || 0) * olMS * s) + '|' + rtMax + '|' + (rtFx ? rtFx.u : '') + '|' + (ns.fontEpoch || 0)
5563
+ var rtAll = ns.textLayouts || (ns.textLayouts = {})
5564
+ var rtHit = rtAll[ol.id]
5565
+ if (!rtHit || rtHit.k !== rtKey) {
5566
+ var rtIn = ol.rt ? ol.rt.l : []
5567
+ if (!ol.rt) {
5568
+ var rtPl = ol.lines || ['']
5569
+ for (var rpi = 0; rpi < rtPl.length; rpi++) rtIn.push(rtPl[rpi] ? [{ t: rtPl[rpi] }] : [])
5570
+ }
5571
+ rtHit = rtAll[ol.id] = {
5572
+ k: rtKey,
5573
+ L: TXL.layoutText(
5574
+ { lines: rtIn, maxWidth: rtMax, unit: rtFx ? rtFx.u : undefined },
5575
+ function (mt, mf) { ovC.font = rtFonts[mf]; return ovC.measureText(mt).width }
5576
+ )
5530
5577
  }
5531
- ovC.restore()
5532
- continue
5578
+ ovC.font = rtFonts[0]
5533
5579
  }
5534
- var olLines = ol.lines || ['']
5535
- // maxWidth wrap (ol.mw = frame-width fraction): greedy over word tokens
5536
- // (/\\S+\\s*/ \u2014 the SAME tokenization fx uses, trailing spaces kept, so
5537
- // unit sequences stay byte-identical) at measured widths. MIRRORS
5538
- // wrapOverlayLines in overlayText.ts \u2014 change together. A token wider
5539
- // than the budget gets its own line; explicit \\n lines wrap independently.
5540
- if (ol.mw) {
5541
- var olWMax = ol.mw * W
5542
- var olWrapped = []
5543
- for (var olwl = 0; olwl < olLines.length; olwl++) {
5544
- var olWLine = olLines[olwl]
5545
- if (!olWLine || ovC.measureText(olWLine).width <= olWMax) {
5546
- olWrapped.push(olWLine)
5547
- continue
5548
- }
5549
- var olToks = olWLine.match(/\\S+\\s*/g) || [olWLine]
5550
- var olCur = ''
5551
- for (var olti = 0; olti < olToks.length; olti++) {
5552
- if (!olCur) { olCur = olToks[olti]; continue }
5553
- if (ovC.measureText(olCur + olToks[olti]).width <= olWMax) {
5554
- olCur += olToks[olti]
5555
- } else {
5556
- olWrapped.push(olCur)
5557
- olCur = olToks[olti]
5558
- }
5559
- }
5560
- if (olCur) olWrapped.push(olCur)
5561
- }
5562
- olLines = olWrapped.length ? olWrapped : ['']
5563
- }
5564
- var olLH = olPx * (ol.lh || ${OVERLAY_LINE_HEIGHT})
5565
- var olY0 = -((olLines.length - 1) * olLH) / 2
5566
- // Per-line widths: needed by the pill, by left/right alignment (lines
5567
- // draw centered; alignment is an x offset against the widest line), and
5568
- // by per-unit fx (units place by prefix advance from the line's left edge).
5569
- var olFx = ol.fx && ol.fx.units.length ? ol.fx : null
5570
- // With wrap active, baked per-line unit arrays regroup onto the WRAPPED
5571
- // lines. Wrapping never reorders: word/char units consume in flat order
5572
- // by string length (wrapped lines are token concatenations); 'line'
5573
- // units become one per wrapped line. Flat delay order is unchanged.
5574
- if (olFx && ol.mw) {
5575
- var olFlat = []
5576
- for (var olfi = 0; olfi < olFx.units.length; olfi++) {
5577
- for (var olfj = 0; olfj < olFx.units[olfi].length; olfj++) {
5578
- olFlat.push(olFx.units[olfi][olfj])
5579
- }
5580
- }
5581
- var olRe = []
5582
- if (olFx.u === 'line') {
5583
- for (var olri = 0; olri < olLines.length; olri++) olRe.push([olLines[olri]])
5584
- } else {
5585
- var olFk = 0
5586
- for (var olri2 = 0; olri2 < olLines.length; olri2++) {
5587
- var olNeed = olLines[olri2].length
5588
- var olArr = []
5589
- var olGot = 0
5590
- while (olFk < olFlat.length && olGot < olNeed) {
5591
- olArr.push(olFlat[olFk])
5592
- olGot += olFlat[olFk].length
5593
- olFk++
5594
- }
5595
- olRe.push(olArr)
5596
- }
5597
- }
5598
- var olReN = 0
5599
- for (var olrn = 0; olrn < olRe.length; olrn++) olReN += olRe[olrn].length
5600
- olFx = { k: olFx.k, u: olFx.u, d: olFx.d, st: olFx.st, dur: olFx.dur, tt: olFx.tt, units: olRe, n: olReN }
5601
- }
5602
- var olLWs = null, olMaxW = 0
5603
- if (ol.box || ol.align || olFx) {
5604
- olLWs = []
5605
- for (var olwi = 0; olwi < olLines.length; olwi++) {
5606
- var olw = ovC.measureText(olLines[olwi]).width
5607
- olLWs.push(olw)
5608
- if (olw > olMaxW) olMaxW = olw
5609
- }
5580
+ var rtL = rtHit.L
5581
+ var rtN = rtL.lines.length
5582
+ var rtLH = olPx * (ol.lh || ${OVERLAY_LINE_HEIGHT})
5583
+ var rtY0 = -((rtN - 1) * rtLH) / 2
5584
+ // A line's left edge: lines sit centred on the anchor and align
5585
+ // against the widest.
5586
+ var rtLeft = function (lw) {
5587
+ var xo = ol.align === 'left' ? (lw - rtL.width) / 2 : ol.align === 'right' ? (rtL.width - lw) / 2 : 0
5588
+ return xo - lw / 2
5610
5589
  }
5611
- // Background pill (ol.box, baked design px at fs): drawn BEFORE the text
5612
- // and before the legibility shadow config, so the pill never inherits the
5613
- // text shadow. Geometry mirrors overlayRect's inflation \u2014 change together.
5614
5590
  if (ol.box) {
5615
- var obPX = ol.box.px * olMS * s
5616
- var obPY = ol.box.py * olMS * s
5617
- var obFullW = olMaxW + obPX * 2
5618
- var obFullH = olLines.length * olLH + obPY * 2
5619
- var obR = Math.min(ol.box.r * olMS * s, obFullH / 2)
5591
+ var rbPX = ol.box.px * olMS * s
5592
+ var rbPY = ol.box.py * olMS * s
5593
+ var rbW = rtL.width + rbPX * 2
5594
+ var rbH = rtN * rtLH + rbPY * 2
5620
5595
  ovC.save()
5621
5596
  ovC.globalAlpha = olA * ol.box.o
5622
5597
  ovC.fillStyle = ol.box.c
5623
- rr(-obFullW / 2, -obFullH / 2, obFullW, obFullH, obR, ovC)
5598
+ rr(-rbW / 2, -rbH / 2, rbW, rbH, Math.min(ol.box.r * olMS * s, rbH / 2), ovC)
5624
5599
  ovC.fill()
5625
5600
  ovC.restore()
5626
5601
  }
5602
+ // Highlights sit behind the words and take no legibility shadow. Under
5603
+ // a per-unit entrance each is drawn with its unit instead (below), so
5604
+ // it arrives with its word.
5605
+ for (var rhi = 0; !rtFx && rhi < rtN; rhi++) {
5606
+ var rhLine = rtL.lines[rhi]
5607
+ for (var rhj = 0; rhj < rhLine.frags.length; rhj++) {
5608
+ var rhF = rhLine.frags[rhj]
5609
+ if (!rhF.h) continue
5610
+ var rhR = TXL.decorationRect('h', rtLeft(rhLine.w) + rhF.x, rhF.w, rtY0 + rhi * rtLH, olPx)
5611
+ ovC.fillStyle = rhF.h
5612
+ ovC.fillRect(rhR.x, rhR.y, rhR.w, rhR.h)
5613
+ }
5614
+ }
5627
5615
  if (ol.shadow > 0) {
5628
5616
  ovC.shadowColor = 'rgba(0,0,0,' + ol.shadow + ')'
5629
5617
  ovC.shadowBlur = olPx * 0.25
5630
5618
  ovC.shadowOffsetY = olPx * 0.04
5631
5619
  }
5632
- ovC.fillStyle = ol.color
5633
- if (!olFx) {
5634
- for (var ok = 0; ok < olLines.length; ok++) {
5635
- var olXof = 0
5636
- if (ol.align && olLWs) {
5637
- olXof = ol.align === 'left'
5638
- ? (olLWs[ok] - olMaxW) / 2
5639
- : (olMaxW - olLWs[ok]) / 2
5640
- }
5641
- var olLY = olY0 + ok * olLH
5642
- if (ol.stroke) {
5643
- ovC.strokeStyle = ol.stroke.c
5644
- ovC.lineWidth = ol.stroke.w * olMS * s
5645
- ovC.lineJoin = 'round'
5646
- ovC.strokeText(olLines[ok], olXof, olLY)
5647
- }
5648
- ovC.fillText(olLines[ok], olXof, olLY)
5620
+ ovC.textAlign = 'left'
5621
+ // One fragment (or the part of one a unit holds): the underline under
5622
+ // the glyphs, stroke under fill, the strikethrough over them.
5623
+ var rtPart = function (p, bx, y, withH) {
5624
+ if (withH && p.h) {
5625
+ var rhU = TXL.decorationRect('h', bx + p.x, p.w, y, olPx)
5626
+ var rhS = ovC.shadowColor
5627
+ ovC.shadowColor = 'rgba(0,0,0,0)'
5628
+ ovC.fillStyle = p.h
5629
+ ovC.fillRect(rhU.x, rhU.y, rhU.w, rhU.h)
5630
+ ovC.shadowColor = rhS
5631
+ }
5632
+ ovC.font = rtFonts[p.f || 0]
5633
+ ovC.fillStyle = p.c || ol.color
5634
+ if (p.u) {
5635
+ var ru = TXL.decorationRect('u', bx + p.x, p.w, y, olPx)
5636
+ ovC.fillRect(ru.x, ru.y, ru.w, ru.h)
5637
+ }
5638
+ if (ol.stroke) {
5639
+ ovC.strokeStyle = ol.stroke.c
5640
+ ovC.lineWidth = ol.stroke.w * olMS * s
5641
+ ovC.lineJoin = 'round'
5642
+ ovC.strokeText(p.t, bx + p.x, y)
5643
+ }
5644
+ ovC.fillText(p.t, bx + p.x, y)
5645
+ if (p.s) {
5646
+ var rs = TXL.decorationRect('s', bx + p.x, p.w, y, olPx)
5647
+ ovC.fillRect(rs.x, rs.y, rs.w, rs.h)
5648
+ }
5649
+ }
5650
+ if (!rtFx) {
5651
+ for (var rli = 0; rli < rtN; rli++) {
5652
+ var rLine = rtL.lines[rli]
5653
+ var rBX = rtLeft(rLine.w)
5654
+ for (var rfk = 0; rfk < rLine.frags.length; rfk++) rtPart(rLine.frags[rfk], rBX, rtY0 + rli * rtLH)
5649
5655
  }
5650
5656
  } else {
5651
- // Per-unit entrance: units draw LEFT-aligned at prefix advances
5652
- // measured from the full line (exact bar cross-unit kerning), so the
5653
- // settled frame matches the non-fx layout. Per-unit progress is pure
5654
- // f(t): delay = order(index)\xB7st, eased over dur; typewriter is a step
5655
- // reveal. Stroke-under-fill per unit; pill/shadow config above apply.
5656
- ovC.textAlign = 'left'
5657
- var olIdx = 0
5658
- for (var ok2 = 0; ok2 < olFx.units.length; ok2++) {
5659
- var olUs = olFx.units[ok2]
5660
- var olLW2 = olLWs ? olLWs[ok2] : 0
5661
- var olLY2 = olY0 + ok2 * olLH
5662
- var olXof2 = 0
5663
- if (ol.align) {
5664
- olXof2 = ol.align === 'left'
5665
- ? (olLW2 - olMaxW) / 2
5666
- : (olMaxW - olLW2) / 2
5667
- }
5668
- var olXb = olXof2 - olLW2 / 2
5669
- var olPref = '', olPW = 0
5670
- for (var ou = 0; ou < olUs.length; ou++, olIdx++) {
5671
- var olOrd = olFx.d === 1
5672
- ? (olFx.n - 1 - olIdx)
5673
- : olFx.d === 2
5674
- ? Math.abs(olIdx - (olFx.n - 1) / 2)
5675
- : olIdx
5676
- var olT2 = olT - olOrd * olFx.st
5677
- var olNext = olPref + olUs[ou]
5678
- var olNW = ovC.measureText(olNext).width
5679
- var olUW = olNW - olPW
5680
- var olUX = olXb + olPW
5681
- var olUA = 1, olUu = 1
5682
- if (olFx.k === 'typewriter') {
5683
- olUA = olT2 >= 0 ? 1 : 0
5657
+ // Per-unit entrance, the plain layer's own arithmetic over the
5658
+ // layout's units: delay = order(index) x st, eased over dur.
5659
+ var rtCount = 0
5660
+ for (var rci = 0; rci < rtN; rci++) rtCount += rtL.lines[rci].units.length
5661
+ var rtIdx = 0
5662
+ for (var rui = 0; rui < rtN; rui++) {
5663
+ var rULine = rtL.lines[rui]
5664
+ var rUBX = rtLeft(rULine.w)
5665
+ var rUY = rtY0 + rui * rtLH
5666
+ for (var ruj = 0; ruj < rULine.units.length; ruj++, rtIdx++) {
5667
+ var rU = rULine.units[ruj]
5668
+ var rOrd = rtFx.d === 1 ? (rtCount - 1 - rtIdx) : rtFx.d === 2 ? Math.abs(rtIdx - (rtCount - 1) / 2) : rtIdx
5669
+ var rT2 = olT - rOrd * rtFx.st
5670
+ var rUA = 1, rUu = 1
5671
+ if (rtFx.k === 'typewriter') {
5672
+ rUA = rT2 >= 0 ? 1 : 0
5684
5673
  } else {
5685
- olUu = Math.max(0, Math.min(1, olT2 / olFx.dur))
5686
- var olUE = 1 - Math.pow(1 - olUu, 3)
5687
- olUA = olFx.k === 'pop' ? Math.min(1, olUu * 2) : olUE
5674
+ rUu = Math.max(0, Math.min(1, rT2 / rtFx.dur))
5675
+ var rUE = 1 - Math.pow(1 - rUu, 3)
5676
+ rUA = rtFx.k === 'pop' ? Math.min(1, rUu * 2) : rUE
5688
5677
  }
5689
- var olUnit = olUs[ou]
5690
- olPref = olNext
5691
- olPW = olNW
5692
- if (olUA <= 0.004) continue
5678
+ if (rUA <= 0.004) continue
5693
5679
  ovC.save()
5694
- ovC.globalAlpha = olA * olUA
5695
- if (olUu < 1) {
5696
- if (olFx.k === 'rise') {
5697
- ovC.translate(0, (1 - (1 - Math.pow(1 - olUu, 3))) * 24 * s)
5698
- } else if (olFx.k === 'pop') {
5699
- var olPS = 1 + 2.70158 * Math.pow(olUu - 1, 3) + 1.70158 * Math.pow(olUu - 1, 2)
5700
- ovC.translate(olUX + olUW / 2, olLY2)
5701
- ovC.scale(olPS, olPS)
5702
- ovC.translate(-(olUX + olUW / 2), -olLY2)
5703
- } else if (olFx.k === 'blur') {
5704
- ovC.filter = 'blur(' + ((1 - olUu) * olPx * 0.12).toFixed(2) + 'px)'
5680
+ ovC.globalAlpha = olA * rUA
5681
+ if (rUu < 1) {
5682
+ if (rtFx.k === 'rise') {
5683
+ ovC.translate(0, (1 - (1 - Math.pow(1 - rUu, 3))) * 24 * s)
5684
+ } else if (rtFx.k === 'pop') {
5685
+ var rPS = 1 + 2.70158 * Math.pow(rUu - 1, 3) + 1.70158 * Math.pow(rUu - 1, 2)
5686
+ var rCX = rUBX + rU.x + rU.w / 2
5687
+ ovC.translate(rCX, rUY)
5688
+ ovC.scale(rPS, rPS)
5689
+ ovC.translate(-rCX, -rUY)
5690
+ } else if (rtFx.k === 'blur') {
5691
+ ovC.filter = 'blur(' + ((1 - rUu) * olPx * 0.12).toFixed(2) + 'px)'
5705
5692
  }
5706
5693
  }
5707
- if (ol.stroke) {
5708
- ovC.strokeStyle = ol.stroke.c
5709
- ovC.lineWidth = ol.stroke.w * olMS * s
5710
- ovC.lineJoin = 'round'
5711
- ovC.strokeText(olUnit, olUX, olLY2)
5712
- }
5713
- ovC.fillText(olUnit, olUX, olLY2)
5694
+ for (var rpk = 0; rpk < rU.parts.length; rpk++) rtPart(rU.parts[rpk], rUBX, rUY, true)
5714
5695
  ovC.restore()
5715
5696
  }
5716
5697
  }
@@ -8495,7 +8476,8 @@ var ON_FRAME = `(ctx, content, dt) => {
8495
8476
  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
8477
  }
8497
8478
  }`;
8498
- function studioLayerData(layers, duration, pins) {
8479
+ function studioLayerData(input, duration, pins) {
8480
+ const layers = migrateText(input);
8499
8481
  const rec = "source" in layers ? layers : null;
8500
8482
  return {
8501
8483
  // Music/SFX clips with their gain envelopes baked (shared truth for the
@@ -8674,12 +8656,13 @@ function studioLayerData(layers, duration, pins) {
8674
8656
  const rt = styledTextOf(o);
8675
8657
  return {
8676
8658
  ...base,
8677
- text: o.text,
8678
- // The lines a person reads. A styled layer (rt) is painted from
8659
+ text: overlayPlainText(o),
8660
+ // The lines a person reads: what a plain layer is laid out
8661
+ // from, one run a line. A styled layer (rt) is laid out from
8679
8662
  // its runs; these are then its words, for the signature.
8680
- lines: overlayLines(stripEmphasis(overlayDisplayText(o))),
8663
+ lines: overlayLines(overlayPlainText(o)),
8681
8664
  // The styled payload, only when the layer is set in more than
8682
- // one style: a plain clip's data is unchanged.
8665
+ // one style.
8683
8666
  ...rt ? { rt } : {},
8684
8667
  fs: st.size,
8685
8668
  weight: st.weight,
@@ -8712,9 +8695,10 @@ function studioLayerData(layers, duration, pins) {
8712
8695
  r: round8(bx.radius)
8713
8696
  }
8714
8697
  } : {},
8715
- // Entrance animation: segmentation + timing normalized
8716
- // HERE (deterministic doc-derived data) — ON_FRAME interprets
8717
- // per-unit progress as pure f(t). Absent = data byte parity.
8698
+ // Entrance animation: timing normalized HERE (deterministic
8699
+ // doc-derived data) — ON_FRAME cuts the units from the layout
8700
+ // and interprets per-unit progress as pure f(t). Absent = data
8701
+ // byte parity.
8718
8702
  ...(() => {
8719
8703
  const olFx = resolveOverlayFx(
8720
8704
  o,
@@ -8728,7 +8712,7 @@ function studioLayerData(layers, duration, pins) {
8728
8712
  };
8729
8713
  }
8730
8714
  function lowerToComposition(input) {
8731
- const doc = migrateMotion(input);
8715
+ const doc = migrateText(migrateMotion(input));
8732
8716
  const rated = ratedSegments(doc);
8733
8717
  const transitions = docTransitions(doc);
8734
8718
  const footageEnd = durationSec(doc, rated);
@@ -8937,7 +8921,7 @@ function loweredZoomSpans(doc, rated, transitions, layout, camera, zoomStyle) {
8937
8921
  });
8938
8922
  }
8939
8923
  function docZoomTrack(input) {
8940
- const doc = migrateMotion(input);
8924
+ const doc = migrateText(migrateMotion(input));
8941
8925
  const rated = ratedSegments(doc);
8942
8926
  const transitions = docTransitions(doc);
8943
8927
  const layout = docCardLayout(doc);
@@ -11863,7 +11847,7 @@ var overlaysLane = {
11863
11847
  kind: "clip",
11864
11848
  t: round13(o.start),
11865
11849
  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"
11850
+ 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
11851
  },
11868
11852
  // Pose diamonds render after the clips, so they sit on top.
11869
11853
  ...poseItems(o.id, o.start, o.duration, o.motion)
@@ -12276,8 +12260,6 @@ export {
12276
12260
  DRAG_FIT_LEVEL,
12277
12261
  DRAG_MIN_S,
12278
12262
  DRAG_MIN_TRAVEL_FRAC,
12279
- EM_CLOSE,
12280
- EM_OPEN,
12281
12263
  END_CARD_FROM,
12282
12264
  END_CARD_RECEDE,
12283
12265
  END_CARD_SECONDS,
@@ -12311,6 +12293,7 @@ export {
12311
12293
  MEDIA_FRAME_KEYS,
12312
12294
  MEDIA_KEY_PREFIX,
12313
12295
  MINIMAL_BAR_THEMES,
12296
+ MIXED,
12314
12297
  MOTION_DELTA,
12315
12298
  MOTION_EASE,
12316
12299
  OBJECT_DEFAULT_SCALE,
@@ -12334,6 +12317,7 @@ export {
12334
12317
  REJECT_OVERLAP,
12335
12318
  REST_TILT_ID,
12336
12319
  RETIRED_ZOOM_STYLES,
12320
+ RUN_STYLE_KEYS,
12337
12321
  SCENE_MOTION,
12338
12322
  SCENE_QUIET,
12339
12323
  SEGMENT_ENTER_KINDS,
@@ -12391,6 +12375,7 @@ export {
12391
12375
  aspectRatioValue,
12392
12376
  audioLane,
12393
12377
  backdropMedia,
12378
+ boldWeightFor,
12394
12379
  buildDigest,
12395
12380
  calloutBox,
12396
12381
  calloutClip,
@@ -12421,9 +12406,11 @@ export {
12421
12406
  clampSpeedRate,
12422
12407
  clampTiltDeg,
12423
12408
  clampZoomLevel,
12409
+ clearStyle,
12424
12410
  clipEnvelope,
12425
12411
  clipLength,
12426
12412
  clipsFrom,
12413
+ commonStyle,
12427
12414
  computeCardLayout,
12428
12415
  computeMicRms,
12429
12416
  computePeaks,
@@ -12442,6 +12429,7 @@ export {
12442
12429
  deriveViewportCrop,
12443
12430
  destinationById,
12444
12431
  destinationsForChannel,
12432
+ diffInput,
12445
12433
  docCardLayout,
12446
12434
  docFreezes,
12447
12435
  docMediaList,
@@ -12517,6 +12505,7 @@ export {
12517
12505
  lowerProgramDoc,
12518
12506
  lowerStudioDoc,
12519
12507
  lowerToComposition,
12508
+ marksEmphasis,
12520
12509
  mediaAtOutput,
12521
12510
  mediaDuration,
12522
12511
  mediaFrame,
@@ -12527,12 +12516,15 @@ export {
12527
12516
  micLane,
12528
12517
  migrateHostedDoc,
12529
12518
  migrateMotion,
12519
+ migrateText,
12520
+ migrateTextClip,
12530
12521
  migrateZoomStyle,
12531
12522
  momentsFromDoc,
12532
12523
  motionTrack,
12533
12524
  musicBedClip,
12534
12525
  nextMediaId,
12535
12526
  normalizeCaptureSpace,
12527
+ normalizeRuns,
12536
12528
  objectMotionBase,
12537
12529
  objectMotionPoseAt,
12538
12530
  objectsLane,
@@ -12541,7 +12533,6 @@ export {
12541
12533
  outputEnd,
12542
12534
  outputRangeToSource,
12543
12535
  overlapFraction,
12544
- overlayDisplayText,
12545
12536
  overlayFaceFor,
12546
12537
  overlayFontFaces,
12547
12538
  overlayFontString,
@@ -12551,12 +12542,12 @@ export {
12551
12542
  overlayMotionBase,
12552
12543
  overlayMotionPoseAt,
12553
12544
  overlayPinPoseAt,
12545
+ overlayPlainText,
12554
12546
  overlayRect,
12555
12547
  overlaysLane,
12556
12548
  ownFreezes,
12557
12549
  pageDisplayUrl,
12558
12550
  parseCssColour,
12559
- parseEmphasis,
12560
12551
  parsePoseId,
12561
12552
  pickStyle,
12562
12553
  pinBox,
@@ -12566,6 +12557,7 @@ export {
12566
12557
  pinReferent,
12567
12558
  pinReferentAt,
12568
12559
  placeFreezes,
12560
+ plainText,
12569
12561
  planAutoSpeed,
12570
12562
  planAutoTilt,
12571
12563
  planAutoZoom,
@@ -12585,7 +12577,7 @@ export {
12585
12577
  relativeLuminance,
12586
12578
  removeSourceRange,
12587
12579
  removeSpeedInRange,
12588
- resolveEmphasis,
12580
+ replaceRange,
12589
12581
  resolveExportSize,
12590
12582
  resolveOverlayBox,
12591
12583
  resolveOverlayStyle,
@@ -12595,6 +12587,7 @@ export {
12595
12587
  restLeanOf,
12596
12588
  restSpansThroughTransitions,
12597
12589
  rgbToHex,
12590
+ runsOf,
12598
12591
  sameMedia,
12599
12592
  sceneChanges,
12600
12593
  scrollRuns,
@@ -12602,17 +12595,24 @@ export {
12602
12595
  outputLen as segmentOutputLen,
12603
12596
  segmentStarts,
12604
12597
  setSpeedInRange,
12598
+ setStyle,
12599
+ sliceRuns,
12605
12600
  slideSide,
12606
12601
  smoothCursor,
12602
+ snapOffset,
12603
+ snapRunWeight,
12607
12604
  spanOutputExtent,
12608
12605
  speedLane,
12609
- stripEmphasis,
12610
12606
  studioAudioPlan,
12611
12607
  studioEntry,
12612
12608
  studioLayerData,
12609
+ styleAt,
12610
+ styleOf,
12613
12611
  styledTextOf,
12612
+ textRunsOf,
12614
12613
  tiltLane,
12615
12614
  tiltTrackFromDoc,
12615
+ toggleStyle,
12616
12616
  tokenRanges,
12617
12617
  traceKindOf,
12618
12618
  transitionMult,