@vosjs/studio-core 0.24.1 → 0.26.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
@@ -236,6 +236,210 @@ var MEDIA_FRAME_KEYS = [
236
236
  "focusFollow"
237
237
  ];
238
238
 
239
+ // src/callout.ts
240
+ var CALLOUT_SHAPES = ["tag", "note", "code"];
241
+ var CALLOUT_TYPE_RATIO = 1.35;
242
+ var CALLOUT_DEFAULT_BODY_PX = 14;
243
+ var HOUSE_REGISTER = {
244
+ ground: "#ffffff",
245
+ accent: "#7c3aed"
246
+ };
247
+ var DEFAULT_FACE = "'Inter',-apple-system,system-ui,sans-serif";
248
+ var DEFAULT_MONO = "'JetBrains Mono',ui-monospace,monospace";
249
+ function calloutLook(register, scale = 1) {
250
+ const ground = hexToRgb(register.ground) ?? [255, 255, 255];
251
+ const accent = hexToRgb(register.accent) ?? [124, 58, 237];
252
+ const light = relativeLuminance(ground) > 0.4;
253
+ const [h, s] = rgbToHsl(accent);
254
+ const cardGround = light ? hslToHex(h, Math.min(0.35, s * 0.6), 0.07) : hslToHex(h, Math.min(0.25, s * 0.35), 0.97);
255
+ const bodyPx = register.body ?? CALLOUT_DEFAULT_BODY_PX;
256
+ const titlePx = Math.max(20, Math.round(CALLOUT_TYPE_RATIO * bodyPx * scale));
257
+ return {
258
+ light,
259
+ cardGround,
260
+ cardInk: light ? "#fafafa" : "#101012",
261
+ cardMuted: light ? "rgba(255,255,255,0.64)" : "rgba(16,16,18,0.6)",
262
+ hairline: light ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.1)",
263
+ accent: rgbToHex(accent),
264
+ shadow: "0 24px 60px rgba(0,0,0,0.35), 0 2px 6px rgba(0,0,0,0.16)",
265
+ face: register.face ? quoteFamily(register.face) : DEFAULT_FACE,
266
+ mono: register.mono ? quoteFamily(register.mono) : DEFAULT_MONO,
267
+ kickerPx: Math.max(11, Math.round(titlePx * 0.55)),
268
+ titlePx,
269
+ bodyPx: Math.max(15, Math.round(titlePx * 0.75)),
270
+ codePx: Math.max(14, Math.round(titlePx * 0.68)),
271
+ radius: 16,
272
+ padX: 24,
273
+ padY: 20
274
+ };
275
+ }
276
+ function calloutBox(shape, look, words) {
277
+ const em = (px, chars, adv) => Math.ceil(px * adv * chars);
278
+ if (shape === "tag") {
279
+ const text = words.kicker ?? words.title ?? "";
280
+ const w = em(look.kickerPx * 1.1, text.length, 0.62) + 14 + 18 * 2;
281
+ return {
282
+ width: clamp(w, 120, 720),
283
+ height: Math.max(36, Math.round(look.kickerPx * 1.1 * 2.6))
284
+ };
285
+ }
286
+ if (shape === "code") {
287
+ const lines = (words.code ?? "").split("\n");
288
+ const longest = Math.max(1, ...lines.map((l) => l.length));
289
+ const lineH = Math.round(look.codePx * 1.7);
290
+ const w = em(look.codePx, longest, 0.62) + look.padX * 2;
291
+ const h = lines.length * lineH + look.padY * 2 + (words.kicker ? look.kickerPx * 2 : 0);
292
+ return { width: clamp(w, 360, 820), height: clamp(h, 72, 640) };
293
+ }
294
+ const title = words.title ?? "";
295
+ const body = words.body ?? "";
296
+ const titleW = em(look.titlePx, title.length, 0.56);
297
+ const width = clamp(Math.max(titleW, 320) + look.padX * 2, 360, 640);
298
+ const inner = width - look.padX * 2;
299
+ const bodyLines = body ? Math.max(1, Math.ceil(em(look.bodyPx, body.length, 0.54) / inner)) : 0;
300
+ const titleLines = Math.max(1, Math.ceil(titleW / inner));
301
+ const height = look.padY * 2 + (words.kicker ? Math.round(look.kickerPx * 1.6) : 0) + titleLines * Math.round(look.titlePx * 1.25) + (bodyLines ? 8 + bodyLines * Math.round(look.bodyPx * 1.5) : 0);
302
+ return { width, height: clamp(height, 64, 480) };
303
+ }
304
+ function calloutSource(shape, look, words, box) {
305
+ const base = `box-sizing:border-box;width:${box.width}px;height:${box.height}px;background:${look.cardGround};color:${look.cardInk};border:1px solid ${look.hairline};border-radius:${look.radius}px;box-shadow:${look.shadow};-webkit-font-smoothing:antialiased;`;
306
+ if (shape === "tag") {
307
+ const text = esc(words.kicker ?? words.title ?? "");
308
+ return {
309
+ html: `<div class="tag"><span class="dot"></span><span class="t">${text}</span></div>`,
310
+ css: `.tag{font-family:${look.face};display:flex;align-items:center;gap:10px;${base}border-radius:999px;padding:0 18px;background:${look.accent};color:#fff;border-color:rgba(255,255,255,0.18);font-size:${Math.round(look.kickerPx * 1.1)}px;font-weight:600;letter-spacing:0.02em;text-transform:uppercase;white-space:nowrap}.dot{width:8px;height:8px;border-radius:99px;background:#fff;opacity:0.9;flex:none}`
311
+ };
312
+ }
313
+ if (shape === "code") {
314
+ const lines = (words.code ?? "").split("\n").map(
315
+ (l) => /^\s*(\/\/|#|\$)/.test(l) ? `<span class="mut">${esc(l)}</span>` : esc(l)
316
+ ).join("\n");
317
+ const kicker2 = words.kicker ? `<div class="k">${esc(words.kicker)}</div>` : "";
318
+ return {
319
+ html: `<div class="code">${kicker2}<pre class="p">${lines}</pre></div>`,
320
+ css: `.code{font-family:${look.mono};${base}padding:${look.padY}px ${look.padX}px;font-size:${look.codePx}px;line-height:1.7;letter-spacing:-0.01em}.k{font-family:${look.face};font-size:${look.kickerPx}px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:${look.accent};margin:0 0 ${Math.round(look.kickerPx * 0.9)}px}.p{margin:0;white-space:pre;font:inherit}.mut{color:${look.cardMuted}}`
321
+ };
322
+ }
323
+ const kicker = words.kicker ? `<div class="k"><span class="dot"></span>${esc(words.kicker)}</div>` : "";
324
+ const body = words.body ? `<div class="b">${esc(words.body)}</div>` : "";
325
+ return {
326
+ html: `<div class="note">${kicker}<div class="t">${esc(words.title ?? "")}</div>${body}</div>`,
327
+ css: `.note{font-family:${look.face};${base}padding:${look.padY}px ${look.padX}px;display:flex;flex-direction:column;justify-content:center}.k{display:flex;align-items:center;gap:8px;font-size:${look.kickerPx}px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:${look.accent};margin-bottom:${Math.round(look.kickerPx * 0.6)}px}.dot{width:7px;height:7px;border-radius:99px;background:${look.accent};box-shadow:0 0 10px ${look.accent}}.t{font-size:${look.titlePx}px;font-weight:600;line-height:1.25;letter-spacing:-0.01em}.b{margin-top:8px;font-size:${look.bodyPx}px;line-height:1.5;color:${look.cardMuted}}`
328
+ };
329
+ }
330
+ function calloutClip(shape, register, words, opts) {
331
+ const look = calloutLook(register, opts.scale ?? 1);
332
+ const box = calloutBox(shape, look, words);
333
+ const { html, css } = calloutSource(shape, look, words, box);
334
+ return {
335
+ id: opts.id,
336
+ kind: "html",
337
+ start: opts.start,
338
+ duration: opts.duration,
339
+ html,
340
+ css,
341
+ box,
342
+ transform: {
343
+ x: opts.at?.x ?? 0.5,
344
+ y: opts.at?.y ?? 0.5,
345
+ scale: 1,
346
+ rotation: 0
347
+ },
348
+ anim: { enter: "rise", exit: "fade" },
349
+ ...opts.pin ? { pin: opts.pin } : {}
350
+ };
351
+ }
352
+ function calloutGroundOf(css) {
353
+ const m = /background(?:-color)?\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\))/.exec(css);
354
+ if (!m) return null;
355
+ const rgb = parseCssColour(m[1]);
356
+ return rgb ? rgbToHex(rgb) : null;
357
+ }
358
+ function hexToRgb(hex) {
359
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(hex.trim());
360
+ if (!m) return null;
361
+ let h = m[1];
362
+ if (h.length === 3)
363
+ h = h.split("").map((c) => c + c).join("");
364
+ return [
365
+ parseInt(h.slice(0, 2), 16),
366
+ parseInt(h.slice(2, 4), 16),
367
+ parseInt(h.slice(4, 6), 16)
368
+ ];
369
+ }
370
+ function rgbToHex([r, g, b]) {
371
+ const c = (v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, "0");
372
+ return `#${c(r)}${c(g)}${c(b)}`;
373
+ }
374
+ function parseCssColour(s) {
375
+ const hex = hexToRgb(s);
376
+ if (hex) return hex;
377
+ const m = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(s);
378
+ if (!m) return null;
379
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
380
+ }
381
+ function relativeLuminance([r, g, b]) {
382
+ const lin = (v) => {
383
+ const c = v / 255;
384
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
385
+ };
386
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
387
+ }
388
+ function rgbToHsl([r, g, b]) {
389
+ const rr = r / 255;
390
+ const gg = g / 255;
391
+ const bb = b / 255;
392
+ const max = Math.max(rr, gg, bb);
393
+ const min = Math.min(rr, gg, bb);
394
+ const l = (max + min) / 2;
395
+ if (max === min) return [0, 0, l];
396
+ const d = max - min;
397
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
398
+ let h = 0;
399
+ if (max === rr) h = (gg - bb) / d + (gg < bb ? 6 : 0);
400
+ else if (max === gg) h = (bb - rr) / d + 2;
401
+ else h = (rr - gg) / d + 4;
402
+ return [h / 6, s, l];
403
+ }
404
+ function hslToHex(h, s, l) {
405
+ const f = (n) => {
406
+ const k = (n + h * 12) % 12;
407
+ const a = s * Math.min(l, 1 - l);
408
+ return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
409
+ };
410
+ return rgbToHex([f(0) * 255, f(8) * 255, f(4) * 255]);
411
+ }
412
+ function deltaE(a, b) {
413
+ const la = rgbToLab(a);
414
+ const lb = rgbToLab(b);
415
+ return Math.hypot(la[0] - lb[0], la[1] - lb[1], la[2] - lb[2]);
416
+ }
417
+ function rgbToLab([r, g, b]) {
418
+ const lin = (v) => {
419
+ const c = v / 255;
420
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
421
+ };
422
+ const R = lin(r);
423
+ const G = lin(g);
424
+ const B = lin(b);
425
+ const x = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
426
+ const y = R * 0.2126 + G * 0.7152 + B * 0.0722;
427
+ const z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
428
+ const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
429
+ return [116 * f(y) - 16, 500 * (f(x) - f(y)), 200 * (f(y) - f(z))];
430
+ }
431
+ function quoteFamily(family) {
432
+ const first = family.split(",")[0].trim().replace(/^['"]|['"]$/g, "");
433
+ const rest = family.includes(",") ? family.slice(family.indexOf(",") + 1).trim() : /mono/i.test(first) ? "ui-monospace,monospace" : "-apple-system,system-ui,sans-serif";
434
+ return /^[a-z-]+$/i.test(first) && /^(ui-|system-ui|sans-serif|serif|monospace)/.test(first) ? `${first},${rest}` : `'${first}',${rest}`;
435
+ }
436
+ function esc(s) {
437
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
438
+ }
439
+ function clamp(v, lo, hi) {
440
+ return Math.max(lo, Math.min(hi, v));
441
+ }
442
+
239
443
  // src/capture.ts
240
444
  var CHROME_TOP_MIN = 20;
241
445
  var CHROME_TOP_MAX = 220;
@@ -1056,362 +1260,226 @@ function outputEnd(doc, footageEnd) {
1056
1260
  return Math.max(footageEnd, lastLayerEnd(doc));
1057
1261
  }
1058
1262
 
1059
- // src/docVersion.ts
1060
- var DOC_SCHEMA_VERSION = 4;
1061
- function migrateHostedDoc(raw) {
1062
- const version = typeof raw.docSchemaVersion === "number" ? raw.docSchemaVersion : 0;
1063
- if (version >= DOC_SCHEMA_VERSION) return raw;
1064
- let doc = raw;
1065
- if (version < 4 && doc.source && typeof doc.source === "object") {
1066
- doc = migrateMotion(doc);
1067
- }
1068
- return { ...doc, docSchemaVersion: DOC_SCHEMA_VERSION };
1069
- }
1070
-
1071
- // src/doc/studioDoc.ts
1072
- var anchorKindOf = (doc) => "source" in doc ? "recording" : "program";
1073
- var isRecordingDoc = (doc) => "source" in doc;
1074
- var isProgramDoc = (doc) => !("source" in doc);
1075
- function programDuration(doc) {
1076
- const own = doc.program.duration;
1077
- if (typeof own === "number" && own > 0) return own;
1078
- const cfg = doc.program.config.duration;
1079
- return typeof cfg === "number" && cfg > 0 ? cfg : 0;
1080
- }
1081
- function anchorSourceDuration(doc) {
1082
- return isRecordingDoc(doc) ? doc.source.meta.durationMs / 1e3 : programDuration(doc);
1083
- }
1084
-
1085
- // src/planner/smoothing.ts
1086
- var SNAP_BEFORE = 0.12;
1087
- var SNAP_AFTER = 0.18;
1088
- var SNAP_GAIN = 0.5;
1089
- function sampleAt(points, t) {
1090
- if (points.length === 0) return { x: 0, y: 0 };
1091
- if (t <= points[0].t) return { x: points[0].x, y: points[0].y };
1092
- const last = points[points.length - 1];
1093
- if (t >= last.t) return { x: last.x, y: last.y };
1094
- for (let i = 1; i < points.length; i++) {
1095
- if (points[i].t >= t) {
1096
- const a = points[i - 1];
1097
- const b = points[i];
1098
- const f = (t - a.t) / (b.t - a.t || 1);
1099
- return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
1100
- }
1101
- }
1102
- return { x: last.x, y: last.y };
1103
- }
1104
- function smoothCursor(track, options = {}) {
1105
- const factor = clamp(options.factor ?? 0.15, 0.01, 1);
1106
- const fps = options.fps ?? 60;
1107
- 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 }));
1108
- if (raw.length === 0) return [];
1109
- const clicks = options.clickSnap ? track.filter((e) => e.type === "down").map((e) => ({ t: e.t / 1e3, x: e.x, y: e.y })) : [];
1110
- const start = raw[0].t;
1111
- const end = raw[raw.length - 1].t;
1112
- const step = 1 / fps;
1113
- const out = [];
1114
- let cx = raw[0].x;
1115
- let cy = raw[0].y;
1116
- let ci = 0;
1117
- for (let t = start; t <= end + 1e-9; t += step) {
1118
- const target = sampleAt(raw, t);
1119
- cx += (target.x - cx) * factor;
1120
- cy += (target.y - cy) * factor;
1121
- if (clicks.length) {
1122
- while (ci < clicks.length - 1 && t > clicks[ci].t + SNAP_AFTER) ci++;
1123
- const ck = clicks[ci];
1124
- const u = clamp(
1125
- (t - (ck.t - SNAP_BEFORE)) / SNAP_BEFORE,
1126
- 0,
1127
- t <= ck.t + SNAP_AFTER ? 1 : 0
1128
- );
1129
- const w = u * u * (3 - 2 * u) * SNAP_GAIN;
1130
- if (w > 0) {
1131
- cx += (ck.x - cx) * w;
1132
- cy += (ck.y - cy) * w;
1133
- }
1134
- }
1135
- out.push({ t, x: cx, y: cy });
1136
- }
1137
- return out;
1138
- }
1139
- function clamp(v, lo, hi) {
1140
- return Math.max(lo, Math.min(hi, v));
1141
- }
1142
-
1143
1263
  // src/zoomStyle.ts
1144
- var ZOOM_STYLES = {
1145
- // Cursorful strategy — one steady zoom per activity session, travel by pans.
1146
- // The camera's timing was measured against a Cursorful take of the same
1147
- // steps: a zoom-in of ~0.55 s on a heavy ease-out (most of the move in
1148
- // the first quarter second, a long settle), a zoom-out of ~0.45 s, and
1149
- // holds of four to five seconds per beat. The old 1.1 s ramp started
1150
- // 0.75 s BEFORE the click and landed 0.35 s after it, so the frame was
1151
- // moving more of the time than it was resting.
1152
- glide: {
1153
- autoZoom: true,
1154
- clusterGap: 3,
1155
- minClusterClicks: 1,
1156
- targetFill: 0.42,
1157
- minLevel: 1.3,
1158
- maxLevel: 1.8,
1159
- dragLevel: 1.5,
1160
- lead: 0.35,
1161
- hold: 1.2,
1162
- followByDefault: true,
1163
- typingZoom: true,
1164
- typingGap: 2.5,
1165
- typingHold: 1.1,
1166
- typingMinLevel: 1.4,
1264
+ var RETIRED_ZOOM_STYLES = {
1265
+ keynote: { style: "glide", tilt: "medium" },
1266
+ drift: { style: "cinema", tilt: "subtle" }
1267
+ };
1268
+ function liveZoomStyleName(name) {
1269
+ if (!name) return null;
1270
+ if (name in ZOOM_STYLES) return name;
1271
+ return RETIRED_ZOOM_STYLES[name]?.style ?? null;
1272
+ }
1273
+ var PUMP_REST = 1;
1274
+ function pumpFreeChainGap(p) {
1275
+ return p.rampOut + (p.rampIn - p.rampInOverlap) + PUMP_REST;
1276
+ }
1277
+ var glide = {
1278
+ autoZoom: true,
1279
+ clusterGap: 3,
1280
+ minClusterClicks: 1,
1281
+ targetFill: 0.42,
1282
+ minLevel: 1.3,
1283
+ maxLevel: 1.8,
1284
+ dragLevel: 1.5,
1285
+ lead: 0.35,
1286
+ hold: 1.2,
1287
+ followByDefault: true,
1288
+ typingZoom: true,
1289
+ typingGap: 2.5,
1290
+ typingHold: 1.1,
1291
+ typingMinLevel: 1.4,
1292
+ rampIn: 0.6,
1293
+ rampInOverlap: 0.2,
1294
+ rampOut: 0.5,
1295
+ chainGap: 3,
1296
+ pan: 0.55,
1297
+ // The reference's corner trace at 60 fps: a quarter of the move by a
1298
+ // fifth of the time, two thirds by half, nine tenths by three
1299
+ // quarters, over ~0.6 s. Fitted against those three points,
1300
+ // (0.36, 0, 0.4, 1) is the closest cubic (15 / 64 / 93 %; half the
1301
+ // move at ~245 ms and 90 % at ~425 ms at 0.6 s): a gentle start and a
1302
+ // long settle. CSS ease (0.25, 0.1, 0.25, 1) reached the midpoint
1303
+ // 70 ms earlier and read a touch fast; (0.16, 1, 0.3, 1) put half the
1304
+ // move in the first sixty milliseconds and read as a twitch.
1305
+ ease: "css-bezier(0.36, 0, 0.4, 1)",
1306
+ panEase: "css-bezier(0.36, 0, 0.4, 1)",
1307
+ followSafeRatio: 0.45,
1308
+ followRecenter: 0.55,
1309
+ followLookahead: 0.4,
1310
+ tilt: {
1311
+ intensity: "off",
1167
1312
  rampIn: 0.6,
1168
- rampInOverlap: 0.2,
1169
1313
  rampOut: 0.5,
1170
1314
  chainGap: 3,
1171
- pan: 0.55,
1172
- // The reference's corner trace at 60 fps: a quarter of the move by a
1173
- // fifth of the time, two thirds by half, nine tenths by three
1174
- // quarters, over ~0.6 s. Fitted against those three points,
1175
- // (0.36, 0, 0.4, 1) is the closest cubic (15 / 64 / 93 %; half the
1176
- // move at ~245 ms and 90 % at ~425 ms at 0.6 s): a gentle start and a
1177
- // long settle. CSS ease (0.25, 0.1, 0.25, 1) reached the midpoint
1178
- // 70 ms earlier and read a touch fast; (0.16, 1, 0.3, 1) put half the
1179
- // move in the first sixty milliseconds and read as a twitch.
1180
- ease: "css-bezier(0.36, 0, 0.4, 1)",
1181
- panEase: "css-bezier(0.36, 0, 0.4, 1)",
1182
- followSafeRatio: 0.45,
1183
- followRecenter: 0.55,
1184
- followLookahead: 0.4,
1185
- tilt: { intensity: "off" }
1186
- },
1187
- // Screen Studio "Focused" / Recordly — block zooms, spring-settled, readable.
1315
+ pan: 0.55
1316
+ }
1317
+ };
1318
+ var ZOOM_STYLES = {
1319
+ glide,
1320
+ // Screen Studio "Focused" / Recordly — a block per click, framed and
1321
+ // released, no travel. The spring-settled family ease (near-linear onset,
1322
+ // long settle) at the default's tempo: 0.8 s in, 0.65 s out, and a beat
1323
+ // of 1.5 s after the click so the block is read before it goes. At its
1324
+ // 2026-07 timing (1.4 s in, 1.0 s out around a 1.35 s span) a lone click
1325
+ // spent 74% of its beat moving and every dwell or sped-up click was
1326
+ // shorter than the ramp, which the lowering compressed into a cut.
1188
1327
  focus: {
1189
1328
  autoZoom: true,
1190
- clusterGap: 1.2,
1329
+ clusterGap: 1.5,
1191
1330
  minClusterClicks: 1,
1192
1331
  targetFill: 0.5,
1193
- minLevel: 1.5,
1194
- maxLevel: 2.2,
1195
- dragLevel: 1.7,
1332
+ minLevel: 1.4,
1333
+ maxLevel: 2,
1334
+ dragLevel: 1.6,
1196
1335
  lead: 0.35,
1197
- hold: 1,
1336
+ hold: 1.5,
1198
1337
  followByDefault: false,
1199
1338
  typingZoom: true,
1200
1339
  typingGap: 2,
1201
- typingHold: 1.2,
1202
- typingMinLevel: 1.6,
1203
- rampIn: 1.4,
1204
- rampInOverlap: 0.5,
1205
- rampOut: 1,
1206
- chainGap: 1.35,
1207
- pan: 1,
1340
+ typingHold: 1.3,
1341
+ typingMinLevel: 1.5,
1342
+ rampIn: 0.8,
1343
+ rampInOverlap: 0.25,
1344
+ rampOut: 0.65,
1345
+ chainGap: 2.4,
1346
+ pan: 0.7,
1208
1347
  ease: "css-bezier(0.28, 0.03, 0.09, 1)",
1209
1348
  panEase: "css-bezier(0.26, 0.08, 0.2, 1)",
1210
1349
  followSafeRatio: 0.5,
1211
- followRecenter: 0.6,
1350
+ followRecenter: 0.55,
1212
1351
  followLookahead: 0,
1213
- tilt: { intensity: "off" }
1352
+ tilt: {
1353
+ intensity: "off",
1354
+ rampIn: 0.8,
1355
+ rampOut: 0.65,
1356
+ chainGap: 2.4,
1357
+ pan: 0.7
1358
+ }
1214
1359
  },
1215
- // Screen Studio "Smooth" — slower, more fluid; creative content over reading.
1360
+ // Screen Studio "Smooth" — the long-HOLD style: 1.2 s in, 1.0 s out,
1361
+ // two-second holds, chains across 3.5 s so a slow clip travels rather
1362
+ // than pumps, and the default's level range, because a watched clip
1363
+ // wants the frame, not the magnifier. Its ramps still exceed a dwell or
1364
+ // a sped-up click; the lowering fits them to the room.
1216
1365
  cinema: {
1217
1366
  autoZoom: true,
1218
- clusterGap: 1.8,
1367
+ clusterGap: 2.4,
1219
1368
  minClusterClicks: 1,
1220
- targetFill: 0.45,
1221
- minLevel: 1.35,
1222
- maxLevel: 2,
1369
+ targetFill: 0.42,
1370
+ minLevel: 1.3,
1371
+ maxLevel: 1.8,
1223
1372
  dragLevel: 1.5,
1224
1373
  lead: 0.6,
1225
- hold: 1.4,
1374
+ hold: 2,
1226
1375
  followByDefault: true,
1227
1376
  typingZoom: true,
1228
1377
  typingGap: 3,
1229
- typingHold: 1.6,
1230
- typingMinLevel: 1.45,
1231
- rampIn: 1.8,
1232
- rampInOverlap: 0.55,
1233
- rampOut: 1.5,
1234
- chainGap: 2.2,
1235
- pan: 1.4,
1378
+ typingHold: 1.8,
1379
+ typingMinLevel: 1.4,
1380
+ rampIn: 1.2,
1381
+ rampInOverlap: 0.4,
1382
+ rampOut: 1,
1383
+ chainGap: 3.5,
1384
+ pan: 1.1,
1236
1385
  ease: "css-bezier(0.33, 0, 0.15, 1)",
1237
1386
  panEase: "css-bezier(0.33, 0, 0.22, 1)",
1238
1387
  followSafeRatio: 0.6,
1239
- followRecenter: 1,
1388
+ followRecenter: 0.9,
1240
1389
  followLookahead: 0.5,
1241
- tilt: { intensity: "off" }
1390
+ tilt: {
1391
+ intensity: "off",
1392
+ rampIn: 1.2,
1393
+ rampOut: 1,
1394
+ chainGap: 3.5,
1395
+ pan: 1.1
1396
+ }
1242
1397
  },
1243
- // The studio's original pace with the instant-velocity onset defect fixed.
1398
+ // The studio's original pace with the instant-velocity onset defect fixed:
1399
+ // 0.4 s cycles on the onset-capped ease, the 2.5× ceiling of the 2026-07
1400
+ // comparison's defect two brought to 2.2, a chain gap past its pump band.
1244
1401
  snappy: {
1245
1402
  autoZoom: true,
1246
1403
  clusterGap: 1.2,
1247
1404
  minClusterClicks: 1,
1248
1405
  targetFill: 0.5,
1249
1406
  minLevel: 1.4,
1250
- maxLevel: 2.5,
1251
- dragLevel: 1.7,
1407
+ maxLevel: 2.2,
1408
+ dragLevel: 1.6,
1252
1409
  lead: 0.25,
1253
- hold: 1,
1410
+ hold: 1.2,
1254
1411
  followByDefault: false,
1255
1412
  typingZoom: true,
1256
1413
  typingGap: 1.6,
1257
- typingHold: 0.8,
1414
+ typingHold: 0.9,
1258
1415
  typingMinLevel: 1.5,
1259
- rampIn: 0.7,
1260
- rampInOverlap: 0.3,
1261
- rampOut: 0.75,
1262
- chainGap: 1.5,
1263
- pan: 0.8,
1416
+ rampIn: 0.4,
1417
+ rampInOverlap: 0.15,
1418
+ rampOut: 0.4,
1419
+ chainGap: 2,
1420
+ pan: 0.5,
1264
1421
  ease: "css-bezier(0.3, 0.55, 0.2, 1)",
1265
1422
  panEase: "css-bezier(0.25, 0.1, 0.25, 1)",
1266
1423
  followSafeRatio: 0.5,
1267
- followRecenter: 0.5,
1424
+ followRecenter: 0.45,
1268
1425
  followLookahead: 0,
1269
- tilt: { intensity: "off" }
1426
+ tilt: {
1427
+ intensity: "off",
1428
+ rampIn: 0.4,
1429
+ rampOut: 0.4,
1430
+ chainGap: 2,
1431
+ pan: 0.5
1432
+ }
1270
1433
  },
1271
- // Screen Studio's "instant zoom" — a cut-in (2–4 frames), tutorial tempo.
1434
+ // Screen Studio's "instant zoom" — a cut-in (eight frames at 60 fps),
1435
+ // tutorial tempo. Untouched in motion (the one style that never pumped
1436
+ // and never left a ramp unsettled); the ceiling comes down with the
1437
+ // others and the hold gains the beat a cut-in needs to be read.
1272
1438
  cut: {
1273
1439
  autoZoom: true,
1274
1440
  clusterGap: 1.2,
1275
1441
  minClusterClicks: 1,
1276
1442
  targetFill: 0.5,
1277
- minLevel: 1.5,
1278
- maxLevel: 2.2,
1279
- dragLevel: 1.7,
1443
+ minLevel: 1.4,
1444
+ maxLevel: 2,
1445
+ dragLevel: 1.6,
1280
1446
  lead: 0.2,
1281
- hold: 1,
1447
+ hold: 1.2,
1282
1448
  followByDefault: false,
1283
1449
  typingZoom: true,
1284
1450
  typingGap: 1.2,
1285
1451
  typingHold: 0.6,
1286
- typingMinLevel: 1.6,
1452
+ typingMinLevel: 1.5,
1287
1453
  rampIn: 0.14,
1288
1454
  rampInOverlap: 0.07,
1289
1455
  rampOut: 0.14,
1290
- chainGap: 1.2,
1456
+ chainGap: 1.3,
1291
1457
  pan: 0.35,
1292
1458
  ease: "css-bezier(0.2, 0, 0.4, 1)",
1293
1459
  panEase: "css-bezier(0.2, 0, 0.4, 1)",
1294
1460
  followSafeRatio: 0.5,
1295
1461
  followRecenter: 0.35,
1296
1462
  followLookahead: 0,
1297
- tilt: { intensity: "off" }
1298
- },
1299
- // Auto-zoom off (every competitor ships this switch). Camera params still
1300
- // apply to MANUAL spans — they get the default (glide) motion.
1301
- none: {
1302
- autoZoom: false,
1303
- clusterGap: 3,
1304
- minClusterClicks: 2,
1305
- targetFill: 0.42,
1306
- minLevel: 1.3,
1307
- maxLevel: 1.8,
1308
- dragLevel: 1.5,
1309
- lead: 0.5,
1310
- hold: 0.6,
1311
- followByDefault: false,
1312
- typingZoom: true,
1313
- typingGap: 2.5,
1314
- typingHold: 1.1,
1315
- typingMinLevel: 1.4,
1316
- rampIn: 1.1,
1317
- rampInOverlap: 0.35,
1318
- rampOut: 1,
1319
- chainGap: 2.5,
1320
- pan: 1,
1321
- ease: "css-bezier(0.26, 0, 0.16, 1)",
1322
- panEase: "css-bezier(0.3, 0, 0.2, 1)",
1323
- followSafeRatio: 0.45,
1324
- followRecenter: 0.8,
1325
- followLookahead: 0,
1326
- tilt: { intensity: "off" }
1327
- },
1328
- // ── tilt-forward styles — the camera moves in DEPTH too.
1329
- // Research note (2026-08-03): no competitor ships a document-wide personality
1330
- // driving BOTH auto-zoom dynamics and focus-following tilt from one name —
1331
- // FocuSee's Subtle/Default/Strong 3D Motion is a separate layered effect,
1332
- // TiltIt/ScreenDrift sell per-clip templates. These two own that space.
1333
- //
1334
- // "keynote": the launch-film sentence (the Apple register: rehearsed,
1335
- // restrained, one move per beat) — glide's session-merged, modest zooms plus
1336
- // a MEDIUM lean toward each zoom's focus, tilt ramps MATCHED to the zoom
1337
- // ramps so lean and zoom read as one camera move; chained so back-to-back
1338
- // beats swing pose-to-pose. Never oscillation, ±5..18° band.
1339
- keynote: {
1340
- autoZoom: true,
1341
- clusterGap: 3,
1342
- minClusterClicks: 1,
1343
- targetFill: 0.42,
1344
- minLevel: 1.3,
1345
- maxLevel: 1.8,
1346
- dragLevel: 1.5,
1347
- lead: 0.5,
1348
- hold: 0.7,
1349
- followByDefault: true,
1350
- typingZoom: true,
1351
- typingGap: 2.5,
1352
- typingHold: 1.2,
1353
- typingMinLevel: 1.4,
1354
- rampIn: 1.2,
1355
- rampInOverlap: 0.35,
1356
- rampOut: 1.1,
1357
- chainGap: 2.5,
1358
- pan: 1.1,
1359
- ease: "css-bezier(0.26, 0, 0.16, 1)",
1360
- panEase: "css-bezier(0.3, 0, 0.2, 1)",
1361
- followSafeRatio: 0.45,
1362
- followRecenter: 0.8,
1363
- followLookahead: 0.4,
1364
- // Lean lands WITH the zoom (matched ramps read as one camera move).
1365
1463
  tilt: {
1366
- intensity: "medium",
1367
- rampIn: 1.2,
1368
- rampOut: 1.1,
1369
- chainGap: 2.5,
1370
- pan: 1.1
1464
+ intensity: "off",
1465
+ rampIn: 0.3,
1466
+ rampOut: 0.3,
1467
+ chainGap: 1.3,
1468
+ pan: 0.35
1371
1469
  }
1372
1470
  },
1373
- // "drift": calm ambient depth — cinema's slow, fluid blocks with a SUBTLE
1374
- // lean that eases in over ~1.6s and lingers (long chain gap keeps the card
1375
- // from flattening between nearby beats). For watched-not-read content:
1376
- // launch films, portfolio clips, hero loops.
1377
- drift: {
1378
- autoZoom: true,
1379
- clusterGap: 1.8,
1380
- minClusterClicks: 1,
1381
- targetFill: 0.45,
1382
- minLevel: 1.35,
1383
- maxLevel: 2,
1384
- dragLevel: 1.5,
1385
- lead: 0.6,
1386
- hold: 1.4,
1387
- followByDefault: true,
1388
- typingZoom: true,
1389
- typingGap: 3,
1390
- typingHold: 1.6,
1391
- typingMinLevel: 1.45,
1392
- rampIn: 1.8,
1393
- rampInOverlap: 0.55,
1394
- rampOut: 1.5,
1395
- chainGap: 2.2,
1396
- pan: 1.4,
1397
- ease: "css-bezier(0.33, 0, 0.15, 1)",
1398
- panEase: "css-bezier(0.33, 0, 0.22, 1)",
1399
- followSafeRatio: 0.6,
1400
- followRecenter: 1,
1401
- followLookahead: 0.5,
1402
- tilt: {
1403
- intensity: "subtle",
1404
- rampIn: 1.6,
1405
- rampOut: 1.4,
1406
- chainGap: 3,
1407
- pan: 1.4
1408
- }
1409
- }
1471
+ // Auto-zoom off (every competitor ships this switch). Camera params still
1472
+ // apply to MANUAL spans — they move the way the default camera moves
1473
+ // TODAY (spelled as a spread, never a copy that fossilises).
1474
+ none: { ...glide, autoZoom: false }
1410
1475
  };
1411
1476
  var DEFAULT_ZOOM_STYLE = "glide";
1412
1477
  function resolveZoomStyle(name, overrides) {
1413
- const params = name ? ZOOM_STYLES[name] : void 0;
1414
- return { ...params ?? ZOOM_STYLES[DEFAULT_ZOOM_STYLE], ...overrides };
1478
+ const live = liveZoomStyleName(name) ?? DEFAULT_ZOOM_STYLE;
1479
+ const params = { ...ZOOM_STYLES[live], ...overrides };
1480
+ const floor = pumpFreeChainGap(params);
1481
+ if (params.chainGap < floor) params.chainGap = floor;
1482
+ return params;
1415
1483
  }
1416
1484
  var ZOOM_STYLE_OPTIONS = [
1417
1485
  {
@@ -1419,22 +1487,16 @@ var ZOOM_STYLE_OPTIONS = [
1419
1487
  label: "Glide",
1420
1488
  hint: "One steady zoom that travels between clicks"
1421
1489
  },
1422
- {
1423
- name: "keynote",
1424
- label: "Keynote",
1425
- hint: "Gliding zooms that lean toward each focus"
1426
- },
1427
- {
1428
- name: "drift",
1429
- label: "Drift",
1430
- hint: "Slow, fluid moves with a subtle ambient lean"
1431
- },
1432
1490
  {
1433
1491
  name: "focus",
1434
1492
  label: "Focus",
1435
- hint: "A zoom per click cluster, settles fast"
1493
+ hint: "A zoom per click, framed and released"
1494
+ },
1495
+ {
1496
+ name: "cinema",
1497
+ label: "Cinema",
1498
+ hint: "Slow moves and long holds, for content that is watched"
1436
1499
  },
1437
- { name: "cinema", label: "Cinema", hint: "Slow, fluid camera moves" },
1438
1500
  { name: "snappy", label: "Snappy", hint: "Quick, energetic zoom cycles" },
1439
1501
  { name: "cut", label: "Cut", hint: "Instant zooms, no glide" },
1440
1502
  {
@@ -1444,6 +1506,102 @@ var ZOOM_STYLE_OPTIONS = [
1444
1506
  }
1445
1507
  ];
1446
1508
 
1509
+ // src/docVersion.ts
1510
+ var DOC_SCHEMA_VERSION = 5;
1511
+ function migrateHostedDoc(raw) {
1512
+ const version = typeof raw.docSchemaVersion === "number" ? raw.docSchemaVersion : 0;
1513
+ if (version >= DOC_SCHEMA_VERSION) return raw;
1514
+ let doc = raw;
1515
+ if (version < 4 && doc.source && typeof doc.source === "object") {
1516
+ doc = migrateMotion(doc);
1517
+ }
1518
+ if (version < 5) doc = migrateZoomStyle(doc);
1519
+ return { ...doc, docSchemaVersion: DOC_SCHEMA_VERSION };
1520
+ }
1521
+ function migrateZoomStyle(doc) {
1522
+ const name = doc.zoomStyle;
1523
+ if (typeof name !== "string") return doc;
1524
+ const retired = RETIRED_ZOOM_STYLES[name];
1525
+ if (!retired) return doc;
1526
+ return {
1527
+ ...doc,
1528
+ zoomStyle: retired.style,
1529
+ tiltStyle: doc.tiltStyle ?? retired.tilt
1530
+ };
1531
+ }
1532
+
1533
+ // src/doc/studioDoc.ts
1534
+ var anchorKindOf = (doc) => "source" in doc ? "recording" : "program";
1535
+ var isRecordingDoc = (doc) => "source" in doc;
1536
+ var isProgramDoc = (doc) => !("source" in doc);
1537
+ function programDuration(doc) {
1538
+ const own = doc.program.duration;
1539
+ if (typeof own === "number" && own > 0) return own;
1540
+ const cfg = doc.program.config.duration;
1541
+ return typeof cfg === "number" && cfg > 0 ? cfg : 0;
1542
+ }
1543
+ function anchorSourceDuration(doc) {
1544
+ return isRecordingDoc(doc) ? doc.source.meta.durationMs / 1e3 : programDuration(doc);
1545
+ }
1546
+
1547
+ // src/planner/smoothing.ts
1548
+ var SNAP_BEFORE = 0.12;
1549
+ var SNAP_AFTER = 0.18;
1550
+ var SNAP_GAIN = 0.5;
1551
+ function sampleAt(points, t) {
1552
+ if (points.length === 0) return { x: 0, y: 0 };
1553
+ if (t <= points[0].t) return { x: points[0].x, y: points[0].y };
1554
+ const last = points[points.length - 1];
1555
+ if (t >= last.t) return { x: last.x, y: last.y };
1556
+ for (let i = 1; i < points.length; i++) {
1557
+ if (points[i].t >= t) {
1558
+ const a = points[i - 1];
1559
+ const b = points[i];
1560
+ const f = (t - a.t) / (b.t - a.t || 1);
1561
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
1562
+ }
1563
+ }
1564
+ return { x: last.x, y: last.y };
1565
+ }
1566
+ function smoothCursor(track, options = {}) {
1567
+ const factor = clamp2(options.factor ?? 0.15, 0.01, 1);
1568
+ const fps = options.fps ?? 60;
1569
+ 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 }));
1570
+ if (raw.length === 0) return [];
1571
+ const clicks = options.clickSnap ? track.filter((e) => e.type === "down").map((e) => ({ t: e.t / 1e3, x: e.x, y: e.y })) : [];
1572
+ const start = raw[0].t;
1573
+ const end = raw[raw.length - 1].t;
1574
+ const step = 1 / fps;
1575
+ const out = [];
1576
+ let cx = raw[0].x;
1577
+ let cy = raw[0].y;
1578
+ let ci = 0;
1579
+ for (let t = start; t <= end + 1e-9; t += step) {
1580
+ const target = sampleAt(raw, t);
1581
+ cx += (target.x - cx) * factor;
1582
+ cy += (target.y - cy) * factor;
1583
+ if (clicks.length) {
1584
+ while (ci < clicks.length - 1 && t > clicks[ci].t + SNAP_AFTER) ci++;
1585
+ const ck = clicks[ci];
1586
+ const u = clamp2(
1587
+ (t - (ck.t - SNAP_BEFORE)) / SNAP_BEFORE,
1588
+ 0,
1589
+ t <= ck.t + SNAP_AFTER ? 1 : 0
1590
+ );
1591
+ const w = u * u * (3 - 2 * u) * SNAP_GAIN;
1592
+ if (w > 0) {
1593
+ cx += (ck.x - cx) * w;
1594
+ cy += (ck.y - cy) * w;
1595
+ }
1596
+ }
1597
+ out.push({ t, x: cx, y: cy });
1598
+ }
1599
+ return out;
1600
+ }
1601
+ function clamp2(v, lo, hi) {
1602
+ return Math.max(lo, Math.min(hi, v));
1603
+ }
1604
+
1447
1605
  // src/planner/autoZoom.ts
1448
1606
  var DWELL_MOVE_FRAC = 0.02;
1449
1607
  var DWELL_MIN = 0.45;
@@ -1720,7 +1878,8 @@ function dwellSpans(track, width, height, maxLevel, reserved) {
1720
1878
  if (!breaks) continue;
1721
1879
  const endT = i < moves.length ? moves[i].t : trackEnd;
1722
1880
  const dur = endT - moves[start].t;
1723
- if (dur >= DWELL_MIN && dur <= DWELL_MAX) {
1881
+ const opening = start === 0;
1882
+ if (!opening && dur >= DWELL_MIN && dur <= DWELL_MAX) {
1724
1883
  const run = moves.slice(start, i);
1725
1884
  candidates.push({
1726
1885
  center: (moves[start].t + endT) / 2,
@@ -1782,7 +1941,7 @@ function focusFor(cluster, width, height, targetFill, minLevel, maxLevel) {
1782
1941
  const cx = clamp012(sx / n / width);
1783
1942
  const cy = clamp012(sy / n / height);
1784
1943
  const level = fitLevel({ x: 0, y: 0, w: maxW, h: maxH }, width, height, targetFill) ?? maxLevel;
1785
- return { cx, cy, level: clamp2(level, minLevel, maxLevel) };
1944
+ return { cx, cy, level: clamp3(level, minLevel, maxLevel) };
1786
1945
  }
1787
1946
  function clusterFocus(events, width, height) {
1788
1947
  let sx = 0;
@@ -1815,11 +1974,11 @@ function clusterFocus(events, width, height) {
1815
1974
  } : null;
1816
1975
  return { cx: clamp012(sx / n / width), cy: clamp012(sy / n / height), rect };
1817
1976
  }
1818
- function clamp2(v, lo, hi) {
1977
+ function clamp3(v, lo, hi) {
1819
1978
  return Math.max(lo, Math.min(hi, v));
1820
1979
  }
1821
1980
  function clamp012(v) {
1822
- return clamp2(v, 0, 1);
1981
+ return clamp3(v, 0, 1);
1823
1982
  }
1824
1983
  function round(v) {
1825
1984
  return Math.round(v * 1e3) / 1e3;
@@ -5065,6 +5224,13 @@ function spanOutputExtent(segments, sIn, sOut, media) {
5065
5224
  function spanEase(ease, fallback) {
5066
5225
  return ease && ease in EASINGS ? ease : fallback;
5067
5226
  }
5227
+ var RAMP_FLOOR = 0.14;
5228
+ function rampLanding(start, startWanted, duration, spanEnd) {
5229
+ if (Math.abs(start - startWanted) < 1e-9) return start + duration;
5230
+ const landWanted = startWanted + duration;
5231
+ const landFull = Math.min(start + duration, spanEnd);
5232
+ return Math.max(start + RAMP_FLOOR, landWanted, landFull);
5233
+ }
5068
5234
  function trackEmitter() {
5069
5235
  const keyframes = [];
5070
5236
  const push2 = (t, value, ease) => {
@@ -5093,17 +5259,19 @@ function zoomTrackFromDoc(zoom, segments, style = ZOOM_STYLES[DEFAULT_ZOOM_STYLE
5093
5259
  let cur = entry;
5094
5260
  const m = transitionMult(z.transition);
5095
5261
  if (!chained) {
5096
- const start = push2(
5097
- tIn - (style.rampIn - style.rampInOverlap) * m,
5098
- [1, z.cx, z.cy, 0],
5099
- "none"
5262
+ const startWanted = tIn - (style.rampIn - style.rampInOverlap) * m;
5263
+ const start = push2(startWanted, [1, z.cx, z.cy, 0], "none");
5264
+ push2(
5265
+ rampLanding(start, startWanted, style.rampIn * m, tOut),
5266
+ entry,
5267
+ spanEase(z.ease, style.ease)
5100
5268
  );
5101
- push2(start + style.rampIn * m, entry, spanEase(z.ease, style.ease));
5102
5269
  }
5103
5270
  let inPath = false;
5104
5271
  for (const e of z.followEvents ?? []) {
5105
5272
  const eOut = sourceToTimeline(segments, e.t);
5106
5273
  if (eOut === null || eOut <= tIn || eOut >= tOut) continue;
5274
+ if (eOut - tIn < RAMP_FLOOR || tOut - eOut < RAMP_FLOOR) continue;
5107
5275
  const next2 = [cur[0], e.cx, e.cy, 1];
5108
5276
  if (e.path) {
5109
5277
  if (!inPath) {
@@ -5126,9 +5294,12 @@ function zoomTrackFromDoc(zoom, segments, style = ZOOM_STYLES[DEFAULT_ZOOM_STYLE
5126
5294
  const mNext = transitionMult(next.z.transition);
5127
5295
  const nextValue = [clampZoomLevel(next.z.level), next.z.cx, next.z.cy, 1];
5128
5296
  push2(
5129
- Math.min(
5130
- tOut + style.pan * mNext,
5131
- next.tIn + style.rampInOverlap * mNext
5297
+ Math.max(
5298
+ tOut + RAMP_FLOOR,
5299
+ Math.min(
5300
+ tOut + style.pan * mNext,
5301
+ next.tIn + style.rampInOverlap * mNext
5302
+ )
5132
5303
  ),
5133
5304
  nextValue,
5134
5305
  panEase
@@ -5166,15 +5337,23 @@ function tiltTrackFromDoc(tilt, segments, motion = {}) {
5166
5337
  const pose = [clampTiltDeg(z.rx), clampTiltDeg(z.ry)];
5167
5338
  const m = transitionMult(z.transition);
5168
5339
  if (!chained) {
5169
- const start = push2(tIn - rampInDur * m, rest, "none");
5170
- push2(start + rampInDur * m, pose, spanEase(z.ease, TILT_EASE));
5340
+ const startWanted = tIn - rampInDur * m;
5341
+ const start = push2(startWanted, rest, "none");
5342
+ push2(
5343
+ rampLanding(start, startWanted, rampInDur * m, tOut),
5344
+ pose,
5345
+ spanEase(z.ease, TILT_EASE)
5346
+ );
5171
5347
  }
5172
5348
  push2(tOut, pose, "none");
5173
5349
  const next = mapped.at(i + 1);
5174
5350
  if (next && next.tIn - tOut <= chainGap) {
5175
5351
  const nextPose = [clampTiltDeg(next.z.rx), clampTiltDeg(next.z.ry)];
5176
5352
  push2(
5177
- Math.min(tOut + panDur * transitionMult(next.z.transition), next.tIn),
5353
+ Math.max(
5354
+ tOut + RAMP_FLOOR,
5355
+ Math.min(tOut + panDur * transitionMult(next.z.transition), next.tIn)
5356
+ ),
5178
5357
  nextPose,
5179
5358
  panEase
5180
5359
  );
@@ -7532,6 +7711,29 @@ function resolvePins(doc, layout, camera, zoomTrack) {
7532
7711
  }
7533
7712
  return out;
7534
7713
  }
7714
+ function pinCandidates(doc, clip) {
7715
+ const rated = ratedSegments(doc);
7716
+ const out = [];
7717
+ const a0 = clip.start;
7718
+ const a1 = clip.start + clip.duration;
7719
+ for (const s of doc.source.meta.steps ?? []) {
7720
+ if (s.skipped) continue;
7721
+ const ref = pinReferent(doc, { step: s.id ?? s.step });
7722
+ if (!ref) continue;
7723
+ const ext = spanOutputExtent(rated, s.tStart, s.tEnd);
7724
+ if (!ext) continue;
7725
+ const overlap = Math.min(a1, ext.end) - Math.max(a0, ext.start);
7726
+ if (overlap <= 0) continue;
7727
+ out.push({
7728
+ step: s.id ?? s.step,
7729
+ do: s.do,
7730
+ ...s.selector ? { selector: s.selector } : {},
7731
+ output: { start: round7(ext.start), end: round7(ext.end) },
7732
+ overlap: round7(overlap)
7733
+ });
7734
+ }
7735
+ return out.sort((x, y) => y.overlap - x.overlap);
7736
+ }
7535
7737
  function overlayPinPoseAt(doc, o, t) {
7536
7738
  if (!o.pin) return null;
7537
7739
  const layout = docCardLayout(doc);
@@ -9079,24 +9281,31 @@ function destinationsForChannel(channel) {
9079
9281
  }
9080
9282
 
9081
9283
  // src/htmlStarters.ts
9284
+ function grammarStarter(id, name, words) {
9285
+ const clip = calloutClip(id, HOUSE_REGISTER, words, {
9286
+ id,
9287
+ start: 0,
9288
+ duration: 1,
9289
+ scale: 1.4
9290
+ });
9291
+ return { id, name, html: clip.html, css: clip.css ?? "", box: clip.box };
9292
+ }
9082
9293
  var WORDS = "font-family:'Inter',-apple-system,system-ui,sans-serif;color:#fafafa;-webkit-font-smoothing:antialiased;";
9083
9294
  var MONO = "font-family:'JetBrains Mono',ui-monospace,monospace;";
9084
9295
  var CARD = "background:#0b0b0d;border:1px solid rgba(255,255,255,.08);border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.45);";
9085
9296
  var HTML_STARTERS = [
9086
- {
9087
- id: "callout",
9088
- name: "Callout",
9089
- box: { width: 420, height: 132 },
9090
- html: '<div class="callout"><div class="head"><span class="dot"></span><span class="title">Title</span></div><div class="body">One line that says what this is.</div></div>',
9091
- css: `.callout{${WORDS}${CARD}box-sizing:border-box;width:420px;height:132px;padding:22px 24px}.head{display:flex;align-items:center;gap:10px}.dot{width:8px;height:8px;border-radius:99px;background:#4ade80;box-shadow:0 0 10px rgba(74,222,128,.9)}.title{font-size:20px;font-weight:600;letter-spacing:-0.01em}.body{margin-top:12px;font-size:15px;line-height:1.5;color:#8b8b94}`
9092
- },
9093
- {
9094
- id: "code",
9095
- name: "Code",
9096
- box: { width: 460, height: 168 },
9097
- html: '<div class="code"><span class="mut">// button.tsx</span>\n<span class="kw">export function</span> <span class="fn">Button</span>({ variant }) {\n <span class="kw">return</span> <span class="tag">&lt;button</span> className={<span class="fn">cn</span>(variant)} <span class="tag">/&gt;</span>\n}</div>',
9098
- css: `.code{${MONO}${CARD}color:#e8e8ec;box-sizing:border-box;width:460px;height:168px;padding:22px 24px;font-size:14px;line-height:1.8;white-space:pre;letter-spacing:-0.01em}.kw{color:#c4b5fd}.fn{color:#7dd3fc}.tag{color:#f0abfc}.mut{color:#5b5b66}`
9099
- },
9297
+ // The callout grammar (three shapes a viewer learns once), in the house
9298
+ // register: a dark card in the house hue over a light app.
9299
+ grammarStarter("note", "Note", {
9300
+ kicker: "Presets",
9301
+ title: "One of 43, picked here.",
9302
+ body: "One line that says what this is about."
9303
+ }),
9304
+ grammarStarter("tag", "Tag", { kicker: "New in 2.0" }),
9305
+ grammarStarter("code", "Code", {
9306
+ kicker: "index.css",
9307
+ code: ":root {\n --primary: oklch(0.63 0.06 4.6);\n --radius: 2.25rem;\n}"
9308
+ }),
9100
9309
  {
9101
9310
  id: "terminal",
9102
9311
  name: "Terminal",
@@ -10637,6 +10846,9 @@ export {
10637
10846
  AUDIO_PLAN_STEP,
10638
10847
  BACKGROUND_Z,
10639
10848
  BASE_FRAME_STYLE,
10849
+ CALLOUT_DEFAULT_BODY_PX,
10850
+ CALLOUT_SHAPES,
10851
+ CALLOUT_TYPE_RATIO,
10640
10852
  CAMERA_CENTRE_RAMP,
10641
10853
  CAMERA_COVER_MIN,
10642
10854
  CAMERA_FAR,
@@ -10711,6 +10923,7 @@ export {
10711
10923
  FREEZE_SECONDS_MAX,
10712
10924
  FREEZE_SECONDS_MIN,
10713
10925
  HOUSE_GRADIENT,
10926
+ HOUSE_REGISTER,
10714
10927
  HTML_LAYER_FOREIGN_FIELDS,
10715
10928
  HTML_LAYER_MAX_BYTES,
10716
10929
  HTML_LAYER_SVG_CODE,
@@ -10744,8 +10957,10 @@ export {
10744
10957
  PLAYBACK_ACTIVITY,
10745
10958
  PRIMARY_MEDIA,
10746
10959
  PROGRAM_RETIME,
10960
+ PUMP_REST,
10747
10961
  REJECT_OVERLAP,
10748
10962
  REST_TILT_ID,
10963
+ RETIRED_ZOOM_STYLES,
10749
10964
  SCENE_MOTION,
10750
10965
  SCENE_QUIET,
10751
10966
  SEGMENT_ENTER_KINDS,
@@ -10802,6 +11017,11 @@ export {
10802
11017
  audioLane,
10803
11018
  backdropMedia,
10804
11019
  buildDigest,
11020
+ calloutBox,
11021
+ calloutClip,
11022
+ calloutGroundOf,
11023
+ calloutLook,
11024
+ calloutSource,
10805
11025
  camBubbleRect,
10806
11026
  camBubbleRectAt,
10807
11027
  camLane,
@@ -10836,6 +11056,7 @@ export {
10836
11056
  cssFamilies,
10837
11057
  cssWeights,
10838
11058
  cursorIdleFade,
11059
+ deltaE,
10839
11060
  deriveViewportCrop,
10840
11061
  destinationById,
10841
11062
  destinationsForChannel,
@@ -10873,6 +11094,7 @@ export {
10873
11094
  frameGeometry,
10874
11095
  freezeLane,
10875
11096
  freezeOutputExtents,
11097
+ hexToRgb,
10876
11098
  hexToRgbTriplet,
10877
11099
  houseLook,
10878
11100
  htmlLayerBleed,
@@ -10903,6 +11125,7 @@ export {
10903
11125
  layerMedia,
10904
11126
  layoutOf,
10905
11127
  levelForFocusFraction,
11128
+ liveZoomStyleName,
10906
11129
  lookFromBrand,
10907
11130
  lookKindForGround,
10908
11131
  lowerProgramDoc,
@@ -10918,6 +11141,7 @@ export {
10918
11141
  micLane,
10919
11142
  migrateHostedDoc,
10920
11143
  migrateMotion,
11144
+ migrateZoomStyle,
10921
11145
  momentsFromDoc,
10922
11146
  motionTrack,
10923
11147
  musicBedClip,
@@ -10943,9 +11167,11 @@ export {
10943
11167
  overlaysLane,
10944
11168
  ownFreezes,
10945
11169
  pageDisplayUrl,
11170
+ parseCssColour,
10946
11171
  parsePoseId,
10947
11172
  pickStyle,
10948
11173
  pinBox,
11174
+ pinCandidates,
10949
11175
  pinPlacement,
10950
11176
  pinRectOnScreen,
10951
11177
  pinReferent,
@@ -10959,10 +11185,12 @@ export {
10959
11185
  platformBarKind,
10960
11186
  programDuration,
10961
11187
  projectFromArtifact,
11188
+ pumpFreeChainGap,
10962
11189
  ratedSegments,
10963
11190
  recommendedExportResolution,
10964
11191
  refillAudioBeds,
10965
11192
  rejectSpan,
11193
+ relativeLuminance,
10966
11194
  removeSourceRange,
10967
11195
  removeSpeedInRange,
10968
11196
  resolveExportSize,
@@ -10973,6 +11201,7 @@ export {
10973
11201
  resolveZoomStyle,
10974
11202
  restLeanOf,
10975
11203
  restSpansThroughTransitions,
11204
+ rgbToHex,
10976
11205
  sameMedia,
10977
11206
  sceneChanges,
10978
11207
  scrollRuns,