@vosjs/studio-core 0.1.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.
@@ -0,0 +1,2973 @@
1
+ import { Segment, KeyframeTrack } from '@vosjs/timeline';
2
+ export { Segment } from '@vosjs/timeline';
3
+ import { TimelineEdit } from '@vosjs/shared/timelineEdits';
4
+ import { LaneAdapter } from '@vosjs/editor';
5
+
6
+ /**
7
+ * Zoom/pan camera styles — named strategy
8
+ * presets covering the whole auto-zoom pipeline: how the planner turns clicks
9
+ * into spans (cluster vs session merging, level clamps, follow default) AND
10
+ * how the lowering animates the camera (ramp durations, eases, connected-pan
11
+ * gap, dead-zone follow tuning). One name → one coherent feel.
12
+ *
13
+ * The presets are grounded in a measured comparison of the four shipping
14
+ * strategies (frame-by-frame optical-flow tracking of real exports + source
15
+ * audits of Recordly/OpenScreen + Cursorful bundle/behavior research):
16
+ *
17
+ * - Cursorful ("glide"): ONE modest zoom (~1.5×) per activity session —
18
+ * 2+ clicks within a rolling window keep the zoom alive — and the camera
19
+ * TRAVELS by panning between focus points while zoomed. Measured zoom ramp
20
+ * fits css-bezier(0.26, 0, 0.16, 1): near-zero initial velocity, soft
21
+ * landing, zero overshoot. Pan durations scale with distance.
22
+ * - Screen Studio "Focused" / Recordly ("focus"): a zoom block per click
23
+ * cluster at ~1.8×, spring-settled ramps (~1.5 s in / ~1 s out), direct
24
+ * pans across gaps ≤ ~1.35 s, dead-zone cursor follow. The measured
25
+ * OpenScreen/Recordly ramp (their bezier filtered through the spring) fits
26
+ * css-bezier(0.28, 0.03, 0.09, 1) — that curve IS the family feel.
27
+ * - Screen Studio "Smooth" ("cinema"): the same block model, slower and
28
+ * more fluid — for content that is watched, not read.
29
+ * - "snappy": the studio's original fast cycles, with the defect fixed — the raw
30
+ * css-bezier(0.16, 1, 0.3, 1) arrival had an initial velocity 6.25× the
31
+ * ramp average (the "jump cut" complaint); competitors always filter that
32
+ * curve through a spring. This preset keeps the pace but caps the onset.
33
+ * - "cut": Screen Studio's "instant zoom" option — hard cut-in, no glide.
34
+ *
35
+ * Style changes are live SET_DATA (the zoom track is data); regenerating
36
+ * auto spans on a style switch re-plans with the style's planner params while
37
+ * preserving user-touched ('manual') spans, per the wand contract.
38
+ */
39
+
40
+ type ZoomStyleName = 'glide' | 'focus' | 'cinema' | 'snappy' | 'cut' | 'none' | 'keynote' | 'drift';
41
+ /**
42
+ * The tilt half of a camera style: the Dynamic-tilt
43
+ * intensity the style ships with, plus optional overrides on the tilt track's
44
+ * motion constants. A style pick stamps `doc.tiltStyle` with `intensity` and
45
+ * re-plans auto tilt spans alongside the auto zooms — one name, one coherent
46
+ * camera sentence (zoom AND lean).
47
+ */
48
+ interface TiltPersonality {
49
+ /** Dynamic-tilt intensity this style ships with ('off' = flat card). */
50
+ intensity: TiltStyleName;
51
+ /** tilt ramp overrides (seconds); absent = the TILT_RAMP_* constants. */
52
+ rampIn?: number;
53
+ rampOut?: number;
54
+ /** output-time gap ≤ this → swing pose-to-pose (absent = TILT_CHAIN_GAP). */
55
+ chainGap?: number;
56
+ /** connected-swing duration (absent = TILT_PAN). */
57
+ pan?: number;
58
+ }
59
+ interface ZoomStyleParams {
60
+ /** false = the planner emits nothing (the 'none' style — manual zooms only). */
61
+ autoZoom: boolean;
62
+ /** clicks within this many seconds merge into one span (session merge). */
63
+ clusterGap: number;
64
+ /** minimum clicks for a cluster to earn a zoom (Cursorful's ≥2 rule). */
65
+ minClusterClicks: number;
66
+ /** zoom so the clicked element fills ~this fraction of the frame. */
67
+ targetFill: number;
68
+ minLevel: number;
69
+ maxLevel: number;
70
+ /** span lead-in before the first click / hold after the last (seconds). */
71
+ lead: number;
72
+ hold: number;
73
+ /** planner emits spans with focusMode 'auto' (cursor-follow camera). */
74
+ followByDefault: boolean;
75
+ /** false = typing sessions plan no spans (clicks/dwells still do). */
76
+ typingZoom: boolean;
77
+ /** max silence between pings before the typing session ends (seconds). */
78
+ typingGap: number;
79
+ /** hold after the last keystroke — the read-what-you-typed beat (seconds). */
80
+ typingHold: number;
81
+ /**
82
+ * Level FLOOR for typing spans, above the style's minLevel: a wide field
83
+ * (URL bar, dialog search input) fit-clamps to this instead — typing is the
84
+ * moment being narrated, so it reads a notch punchier than a wide click.
85
+ */
86
+ typingMinLevel: number;
87
+ /** zoom-in ramp duration; arrival lands rampInOverlap into the span. */
88
+ rampIn: number;
89
+ rampInOverlap: number;
90
+ rampOut: number;
91
+ /** output-time gap ≤ this → pan straight to the next span (no zoom-out). */
92
+ chainGap: number;
93
+ /** connected-pan duration. */
94
+ pan: number;
95
+ /** arrival ease (zoom-in AND zoom-out). */
96
+ ease: string;
97
+ /** connected-pan + follow-recenter ease. */
98
+ panEase: string;
99
+ /** recenter when the cursor exits this central fraction of the crop. */
100
+ followSafeRatio: number;
101
+ /** seconds the camera takes to glide to a recentered focus. */
102
+ followRecenter: number;
103
+ /**
104
+ * Recenter targets the cursor this many seconds AHEAD of the exit moment
105
+ * (Cursorful's look-ahead: the camera leads the pointer instead of chasing
106
+ * a stale position). Sampled from the real track — still deterministic.
107
+ */
108
+ followLookahead: number;
109
+ /** The style's tilt personality — see TiltPersonality. */
110
+ tilt: TiltPersonality;
111
+ }
112
+ declare const ZOOM_STYLES: Record<ZoomStyleName, ZoomStyleParams>;
113
+ /** The default camera style for new projects (the Cursorful-family strategy). */
114
+ declare const DEFAULT_ZOOM_STYLE: ZoomStyleName;
115
+ /**
116
+ * Resolve a style name (+ optional per-doc overrides, `doc.zoomParams`) into a
117
+ * full parameter bundle. Overrides are the "Custom" seam: agents/doc.json can
118
+ * tune individual params on top of a named preset; the studio shows Custom
119
+ * while any override is present. Unknown names from hand-edited doc.json fall
120
+ * back to the default style.
121
+ */
122
+ declare function resolveZoomStyle(name?: ZoomStyleName, overrides?: Partial<ZoomStyleParams>): ZoomStyleParams;
123
+ /** Picker order + copy for the studio's Camera style control. */
124
+ declare const ZOOM_STYLE_OPTIONS: {
125
+ name: ZoomStyleName;
126
+ label: string;
127
+ hint: string;
128
+ }[];
129
+
130
+ interface SpeedParams {
131
+ /** Seconds of no input at all before a stretch counts as idle. */
132
+ idleMin: number;
133
+ /** Rate applied to idle stretches. */
134
+ idleRate: number;
135
+ /** Seconds a typing session must last to earn a span. */
136
+ typingMin: number;
137
+ /** Rate applied to typing passages. */
138
+ typingRate: number;
139
+ /** Seconds a scroll run must last to earn a span. */
140
+ scrollMin: number;
141
+ /** Rate applied to scroll runs. */
142
+ scrollRate: number;
143
+ }
144
+ /** Conservative defaults: only stretches nobody wants to watch in real time. */
145
+ declare const DEFAULT_SPEED_PARAMS: SpeedParams;
146
+ /**
147
+ * An idle gap whose measured frame activity (the digest's per-second
148
+ * changed-pixel fraction) averages above this is the video PLAYING — the
149
+ * recording's own playback, a render in progress — not idle. Speeding it up
150
+ * compresses the payoff. Five real takes (2026-08-25) each had one; the
151
+ * cursor track alone cannot tell, so this needs the activity witness, and
152
+ * without one (the studio's ingest) the gap still plans as idle.
153
+ */
154
+ declare const PLAYBACK_ACTIVITY = 0.1;
155
+ declare function planAutoSpeed(track: readonly CursorEvent[], opts: {
156
+ durationMs: number;
157
+ params?: Partial<SpeedParams>;
158
+ /** Per-SOURCE-second motion bins (0..1) when a digest measured them. */
159
+ activity?: readonly number[] | null;
160
+ }): SpeedSpan[];
161
+ /**
162
+ * Scroll runs as [start, last] seconds — the same grouping the speed planner
163
+ * proposes 2× over, exported for the take digest.
164
+ */
165
+ declare function scrollRuns(track: readonly CursorEvent[], minLen?: number): [number, number][];
166
+ /**
167
+ * Idle gaps as [start, end] seconds: no event of ANY kind for ≥ idleMin,
168
+ * head and tail included — the digest's `idle` moments and the speed
169
+ * planner's 4× candidates come from this one derivation.
170
+ */
171
+ declare function idleGaps(track: readonly CursorEvent[], durationS: number, idleMin?: number): [number, number][];
172
+ /** Mean activity over [a, b) source seconds exceeds PLAYBACK_ACTIVITY. */
173
+ declare function isPlayback(activity: readonly number[] | null | undefined, a: number, b: number): boolean;
174
+
175
+ /** A single input event captured in the page, relative to the recording's t0. */
176
+ interface CursorEvent {
177
+ /** ms since t0 (recording start). */
178
+ t: number;
179
+ /** viewport CSS px. */
180
+ x: number;
181
+ y: number;
182
+ /**
183
+ * Screen-coordinate CSS px (MouseEvent.screenX/Y) — the mapping anchor for
184
+ * window/monitor captures, where the viewport is only part of the frame.
185
+ * Carrying both spaces per event also gives an exact per-event viewport→screen
186
+ * offset (sx−x, sy−y) for transforming element rects. Absent on old tracks.
187
+ */
188
+ sx?: number;
189
+ sy?: number;
190
+ /**
191
+ * `key` is a typing-ACTIVITY ping (throttled): when and where typing is
192
+ * happening, never what is typed — the payload must not carry key identity
193
+ * or input contents at any layer. Position/rect follow the `focus`
194
+ * convention: the focused editable element's center + bounds.
195
+ */
196
+ type: 'move' | 'down' | 'up' | 'scroll' | 'focus' | 'key';
197
+ /** pointer button for down/up (0=left). */
198
+ button?: 0 | 1 | 2;
199
+ /** target element bounds at event time — enables element-aware auto-zoom. */
200
+ rect?: Rect;
201
+ }
202
+ type CursorTrack = CursorEvent[];
203
+ interface Rect {
204
+ x: number;
205
+ y: number;
206
+ w: number;
207
+ h: number;
208
+ }
209
+ /** Recording metadata needed to map captured pixels ↔ cursor coords ↔ time. */
210
+ interface RecordingMeta {
211
+ /** device pixel ratio at capture. */
212
+ dpr: number;
213
+ /** page/browser zoom at capture. */
214
+ zoom: number;
215
+ /** wall-clock origin (Date.now at first frame). */
216
+ t0: number;
217
+ durationMs: number;
218
+ /** captured pixel dimensions. */
219
+ width: number;
220
+ height: number;
221
+ fps: number;
222
+ /**
223
+ * The recording's OWN file carries an audio track — unmute preview, mux into
224
+ * export. After the AT split that track is SYSTEM/tab audio only (it rides
225
+ * the same stream as the video, sample-aligned by construction); on takes
226
+ * recorded before the split it is the legacy record-time mic+system mix.
227
+ */
228
+ hasAudio?: boolean;
229
+ /**
230
+ * A separately-recorded microphone sidecar exists. The mic never
231
+ * enters the video file — it records through its own audio-only
232
+ * MediaRecorder so the studio can gain/mute/duck it independently.
233
+ */
234
+ hasMic?: boolean;
235
+ /**
236
+ * Recorder start skew: wall-clock ms between the main recorder's start and
237
+ * the mic/cam sidecar recorders' starts (positive = sidecar started later).
238
+ * Lets the consume path trim/pad a sidecar head instead of assuming t0
239
+ * equality. Absent on takes without the matching sidecar.
240
+ */
241
+ micT0DeltaMs?: number;
242
+ camT0DeltaMs?: number;
243
+ /**
244
+ * Encoded frame dimensions in device px, from the capture track's settings.
245
+ * `width`/`height` are the CSS-px viewport (the CursorEvent coordinate space);
246
+ * these are the actual video pixels — same aspect when capture is constrained
247
+ * to the tab size, but keep both spaces so mapping never assumes it.
248
+ */
249
+ captureWidth?: number;
250
+ captureHeight?: number;
251
+ /**
252
+ * The tab viewport changed size mid-take. Capture resolution is fixed for the
253
+ * whole take, so Chrome letterboxes the resized content — surface a notice.
254
+ */
255
+ resizedDuringTake?: boolean;
256
+ /**
257
+ * Page URL/title at record start (seeds the browser-bar mock's address pill).
258
+ * Query/hash are stripped at capture for privacy.
259
+ */
260
+ pageUrl?: string;
261
+ pageTitle?: string;
262
+ /**
263
+ * What the frame contains. Absent = 'tab' (back-compat). Non-tab surfaces use
264
+ * CursorEvent.sx/sy + the geometry rects below to map cursor → capture px
265
+ * (see normalizeCaptureSpace); tab-only studio features (browser-bar mock,
266
+ * letterbox notice) are gated off for them.
267
+ */
268
+ captureSurface?: 'tab' | 'window' | 'monitor';
269
+ /**
270
+ * Target-tab browser-window bounds at record start, screen-coord CSS px
271
+ * (window.screenX/Y + outerWidth/Height). Anchor for 'window' captures.
272
+ */
273
+ windowRect?: Rect;
274
+ /**
275
+ * FULL bounds of the display hosting the target window at record start,
276
+ * screen-coord CSS px (chrome.system.display bounds — the true origin,
277
+ * including the macOS menu bar; page availLeft/Top only as a fallback).
278
+ * Anchor for 'monitor' captures; wrong-display shares surface as low
279
+ * coverage and fall back to no auto-zoom.
280
+ */
281
+ screenRect?: Rect;
282
+ /**
283
+ * The target window moved or resized during a 'window' take — the single
284
+ * windowRect anchor can't map the whole track, so the studio drops the
285
+ * cursor rather than rendering it at stale positions.
286
+ */
287
+ windowMovedDuringTake?: boolean;
288
+ /**
289
+ * Viewport CSS-px size (innerWidth/Height) of the recorded tab at record
290
+ * start. On 'window' takes this + windowRect + the cursor events' screen
291
+ * coords derive the viewport crop that removes the real browser chrome from
292
+ * the footage (deriveViewportCrop) so the synthetic browser bar applies.
293
+ */
294
+ viewport?: {
295
+ w: number;
296
+ h: number;
297
+ };
298
+ /**
299
+ * The tab viewport changed size mid-take on a display take (resize, devtools
300
+ * dock, zoom) — the static viewport crop can't map the whole take, so crop
301
+ * derivation fails closed.
302
+ */
303
+ viewportChangedDuringTake?: boolean;
304
+ /**
305
+ * Fraction of a 'window' take during which the target tab's browser window
306
+ * was the FOCUSED window (chrome.windows.onFocusChanged, pause-gated).
307
+ * The wrong-window tell that geometry can't provide: cursor events come from
308
+ * the recorded tab and its window geometry is self-consistent, so sharing a
309
+ * DIFFERENT window (Finder, another app — even one with identical bounds)
310
+ * still maps events "in frame". But driving that other window means focusing
311
+ * it — a low fraction ⇒ the footage isn't the browser window, so cursor
312
+ * effects and the viewport crop must fail closed (WINDOW_FOCUS_MIN).
313
+ */
314
+ windowFocusedFrac?: number;
315
+ /** Recorder OS (chrome.runtime.getPlatformInfo) — seeds the browser-bar style. */
316
+ platform?: 'mac' | 'windows' | 'linux';
317
+ /**
318
+ * Which recorder produced the artifact. CLI takes synthesize the cursor
319
+ * track from automation (exact coords, fresh rects, coverage 1 by
320
+ * construction) and encode WebM; absent means the extension.
321
+ */
322
+ producer?: 'extension' | 'cli';
323
+ /**
324
+ * The step timeline: when each actions.json step ran, in SOURCE
325
+ * seconds. This is what makes a cut re-anchorable across re-records — a
326
+ * span anchored to a step re-times to wherever that step landed in the
327
+ * new recording (`vos plan --reuse`). CLI takes only; a human recording
328
+ * has no script and carries none.
329
+ */
330
+ steps?: StepSpan[];
331
+ }
332
+ /**
333
+ * A span's tie to an actions.json step: metadata for `vos plan
334
+ * --reuse`, which re-times the span onto a NEW recording of the same script
335
+ * by resolving the step in the new `meta.steps`. NEVER read by lowering —
336
+ * seconds stay the wire truth (`in`/`out` are always authoritative), so a
337
+ * human recording with no steps renders identically with or without one.
338
+ */
339
+ interface StepAnchor {
340
+ /** The step: its `id` from actions.json when it has one, else its index. */
341
+ step: string | number;
342
+ /** Which edge of the step the span's `in` is measured from. Default 'start'. */
343
+ at?: 'start' | 'end';
344
+ /** Seconds from that edge to the span's `in` (negative = before it). */
345
+ offset?: number;
346
+ }
347
+ /** One executed actions.json step's extent in the recording. */
348
+ interface StepSpan {
349
+ /** index into actions.steps at record time. */
350
+ step: number;
351
+ /** the step's own id from actions.json, when it names one — an id lets a
352
+ * step move or be reordered without breaking anchors (absent = the index
353
+ * is the identity). */
354
+ id?: string;
355
+ do: string;
356
+ selector?: string;
357
+ /** SOURCE seconds the gesture occupied, [tStart, tEnd]. */
358
+ tStart: number;
359
+ tEnd: number;
360
+ /** the selector never became visible — the gesture did not run. */
361
+ skipped?: boolean;
362
+ }
363
+ /** Everything the capture extension hands off to the studio. */
364
+ interface RecordingArtifact {
365
+ /** OPFS key / object URL for the recorded video. */
366
+ videoKey: string;
367
+ cursor: CursorTrack;
368
+ /** object URL for the separately-recorded mic sidecar (the mic/system split). */
369
+ audioKey?: string;
370
+ /** object URL for a separately-recorded webcam track (drawn as an editable bubble). */
371
+ camKey?: string;
372
+ meta: RecordingMeta;
373
+ }
374
+ /**
375
+ * Per-span transition speed — how fast the camera/bubble/card moves
376
+ * into and out of a span's state, as NAMED steps (the category convention:
377
+ * Screen Studio's speed words, Descript's one knob — never a curve editor).
378
+ * Multipliers on the lane's own ramp constants, so 'smooth' (absent) is
379
+ * byte-identical to the pre-feature motion and each lane keeps its feel.
380
+ * 'instant' is a hard cut: the ramp collapses to the track emitter's 1ms
381
+ * collision nudge.
382
+ */
383
+ type TransitionSpeed = 'instant' | 'fast' | 'smooth' | 'slow';
384
+ declare const TRANSITION_SPEED_MULT: Record<TransitionSpeed, number>;
385
+ /** A span's ramp multiplier (absent = 'smooth' = 1, the exact legacy motion). */
386
+ declare function transitionMult(t: TransitionSpeed | undefined): number;
387
+ /**
388
+ * A speed-change region over a SOURCE-time span (seconds). Footage-anchored
389
+ * like zoom keyframes — it follows its content through trims/splits, and a
390
+ * span whose footage is fully cut away simply has no effect (and comes back
391
+ * if the trim is undone). Non-overlapping (the lane clamps). The lowering
392
+ * intersects spans with `segments` via @vosjs/timeline `splitBySpeed` into
393
+ * rated segments; playback, export, and lane display all evaluate those.
394
+ */
395
+ interface SpeedSpan {
396
+ /** Stable identity for selection/editing in the timeline UI. */
397
+ id: string;
398
+ in: number;
399
+ out: number;
400
+ /** Re-record tie to an actions.json step; `in`/`out` stay the truth. */
401
+ anchor?: StepAnchor;
402
+ /** Playback rate (> 0): 2 = twice as fast, 0.5 = half speed. */
403
+ rate: number;
404
+ /**
405
+ * The auto-zoom wand contract: 'auto' = planner suggestion
406
+ * (planAutoSpeed — typing/scroll/idle), replaced by a re-plan; 'manual' =
407
+ * user/agent work, always preserved. Absent = manual (spans predating the contract).
408
+ */
409
+ source?: 'auto' | 'manual';
410
+ }
411
+ /**
412
+ * Speed-rate bounds. 16 is also Chromium's HTMLMediaElement.playbackRate
413
+ * ceiling, so preview (native playback) and export (offline resample) can
414
+ * honor the same range.
415
+ */
416
+ declare const SPEED_RATE_MIN = 0.1;
417
+ declare const SPEED_RATE_MAX = 16;
418
+ /**
419
+ * Minimum speed-span length in OUTPUT seconds (the lane converts through the
420
+ * span's own rate: a 2× span may not shrink below 0.5s of source). A source
421
+ * floor shrank with the rate — 0.1s of source at 5× was 20ms of screen, a
422
+ * sliver nobody could grab again.
423
+ */
424
+ declare const SPEED_SPAN_MIN = 0.25;
425
+ /** Clamp + quantize a speed rate for storage (2 decimals, like "1.75×"). */
426
+ declare function clampSpeedRate(rate: number): number;
427
+ /**
428
+ * A zoom region over a SOURCE-time span (seconds) — one adjustable clip on the
429
+ * zoom lane. Footage-anchored like SpeedSpan/CamStyle.window: it follows its
430
+ * content through trims/splits (a span whose footage is fully cut away renders
431
+ * nothing, and comes back if the trim is undone; a partially-cut span keeps its
432
+ * kept extent). Non-overlapping (the lane clamps). The camera ramps in around
433
+ * `in`, holds `[level, cx, cy]` until `out`, then ramps back to 1× — or pans
434
+ * straight to the next span when the gap is short (see `zoomTrackFromDoc`).
435
+ */
436
+ interface ZoomSpan {
437
+ /** Stable identity for selection/editing (`z{n}` planner, `u{n}` user). */
438
+ id: string;
439
+ in: number;
440
+ out: number;
441
+ /** Re-record tie to an actions.json step; `in`/`out` stay the truth. */
442
+ anchor?: StepAnchor;
443
+ /** zoom level (1 = no zoom), ZOOM_LEVEL_MIN..ZOOM_LEVEL_MAX, 2 decimals. */
444
+ level: number;
445
+ /** focus point in normalized [0..1] video-frame coords. */
446
+ cx: number;
447
+ cy: number;
448
+ /** arrival ease (@vosjs/timeline EASINGS name). Absent = the default ramp ease. */
449
+ ease?: string;
450
+ /**
451
+ * Transition speed for THIS span's ramps (in, out, and the pan arriving
452
+ * here from a chained neighbor). Absent = 'smooth', the camera style's
453
+ * stock motion; 'instant' is a hard cut.
454
+ */
455
+ transition?: TransitionSpeed;
456
+ /**
457
+ * 'auto' = the camera follows the cursor through the span (dead-zone
458
+ * recenter, baked deterministically at lowering — see followFocusEvents);
459
+ * absent/'manual' = the fixed cx/cy focus.
460
+ */
461
+ focusMode?: 'manual' | 'auto';
462
+ /**
463
+ * 'auto' = planner suggestion — regenerate replaces these freely, never
464
+ * 'manual' ones. Any edit gesture promotes the span to 'manual' (OpenScreen's
465
+ * contract: suggestions are disposable, user work is sacred).
466
+ */
467
+ source?: 'auto' | 'manual';
468
+ }
469
+ /** Zoom-level preset chips (OpenScreen-style picker). */
470
+ declare const ZOOM_LEVELS: readonly [1.25, 1.5, 1.8, 2.2, 3.5, 5];
471
+ declare const ZOOM_LEVEL_MIN = 1;
472
+ declare const ZOOM_LEVEL_MAX = 5;
473
+ /** Default level for new/user-created zooms. */
474
+ declare const DEFAULT_ZOOM_LEVEL = 1.8;
475
+ /**
476
+ * Minimum zoom-span length in OUTPUT seconds (the lane clamps resizes,
477
+ * converting through the rate in force so the floor is what the eye sees).
478
+ */
479
+ declare const ZOOM_SPAN_MIN = 0.3;
480
+ /** Clamp + quantize a zoom level for storage (2 decimals, like "1.8×"). */
481
+ declare function clampZoomLevel(level: number): number;
482
+ /**
483
+ * A tilt region over a SOURCE-time span (seconds) — one adjustable clip on the
484
+ * tilt lane. Footage-anchored
485
+ * like ZoomSpan/SpeedSpan: it follows its content through trims/splits and
486
+ * speed changes (a span whose footage is fully cut away renders nothing, and
487
+ * comes back if the trim is undone). Non-overlapping (the lane clamps). While
488
+ * active the card leans to this pose; between spans it returns to the RESTING
489
+ * FLAT rest pose (there is no static card tilt) — expanded at lowering into an
490
+ * OUTPUT-time [rx, ry] degree keyframe track (see tiltTrackFromDoc).
491
+ */
492
+ interface TiltSpan {
493
+ /** Stable identity for selection/editing (`t{n}` planner, `u{n}` user). */
494
+ id: string;
495
+ in: number;
496
+ out: number;
497
+ /** Re-record tie to an actions.json step; `in`/`out` stay the truth. */
498
+ anchor?: StepAnchor;
499
+ /**
500
+ * Pose in DEGREES (the CardTilt convention): rx leans the card back/forward,
501
+ * ry swings it left/right. Gentle values read best (±5..18°).
502
+ */
503
+ rx: number;
504
+ ry: number;
505
+ /** arrival ease (@vosjs/timeline EASINGS name). Absent = the house tilt ease. */
506
+ ease?: string;
507
+ /**
508
+ * Transition speed for this span's ramps. Absent = 'smooth' (the stock
509
+ * tilt motion); 'instant' snaps the card to the pose.
510
+ */
511
+ transition?: TransitionSpeed;
512
+ /**
513
+ * 'auto' = Dynamic-tilt wand suggestion — regenerate replaces these freely,
514
+ * never 'manual' ones. Any edit gesture promotes the span to 'manual' (the
515
+ * auto-zoom wand contract).
516
+ */
517
+ source?: 'auto' | 'manual';
518
+ }
519
+ /** Hard tilt bound in degrees (schema/lint); UI sliders stay within ±20. */
520
+ declare const TILT_DEG_MAX = 45;
521
+ /** UI slider bound — matches the Card panel's static (rest) tilt sliders. */
522
+ declare const TILT_UI_DEG_MAX = 20;
523
+ /**
524
+ * Minimum tilt-span length in OUTPUT seconds (the lane clamps resizes). Bigger
525
+ * than ZOOM_SPAN_MIN because tilt ramps are longer — a pose that can't settle
526
+ * isn't a pose.
527
+ */
528
+ declare const TILT_SPAN_MIN = 0.8;
529
+ /** Default pose for user-created spans: a medium three-quarter "showcase" lean. */
530
+ declare const DEFAULT_TILT_POSE: {
531
+ rx: number;
532
+ ry: number;
533
+ };
534
+ /** Clamp + quantize a tilt angle for storage (1 decimal, degrees). */
535
+ declare function clampTiltDeg(deg: number): number;
536
+ /**
537
+ * Dynamic-tilt wand intensity ladder (the category convention — FocuSee ships
538
+ * Subtle/Default/Strong): the max degrees planAutoTilt will lean per axis.
539
+ */
540
+ type TiltStyleName = 'off' | 'subtle' | 'medium' | 'strong';
541
+ declare const TILT_INTENSITY_MAX: Record<Exclude<TiltStyleName, 'off'>, number>;
542
+ interface CursorStyle {
543
+ /**
544
+ * Draw the cursor dot. Off still keeps the track: auto-zoom cursor-follow
545
+ * and click effects are independent of whether the dot is painted. Absent
546
+ * reads as visible (pre-toggle docs).
547
+ */
548
+ visible: boolean;
549
+ /** 0..1 smoothing strength (lerp factor; higher = smoother/laggier). */
550
+ smoothing: number;
551
+ /** rendered cursor size in px. */
552
+ size: number;
553
+ style: 'default' | 'dot' | 'ring';
554
+ hideWhenIdle: boolean;
555
+ clickFx: ClickFxStyle;
556
+ }
557
+ /**
558
+ * Click-effect styling. Clicks are extracted
559
+ * at lowering (OUTPUT-anchored — see extractClicks) and drawn by ON_FRAME as a
560
+ * pure function of t; every field here is a live SET_DATA edit.
561
+ */
562
+ interface ClickFxStyle {
563
+ /** ring drawn at the click point ('highlight' glows the clicked element's rect). */
564
+ style: 'none' | 'ripple' | 'pulse' | 'highlight';
565
+ /** cursor press dip on real down→up spans — independent of ring style. */
566
+ press: boolean;
567
+ intensity: 'subtle' | 'medium' | 'strong';
568
+ /** resolved hex for rings/glow; 'auto' = neutral white-over-dark-rim. */
569
+ color: string | 'auto';
570
+ }
571
+ /**
572
+ * Named intensity levels → resolved multipliers (size/alpha `k`, duration
573
+ * `dur`), baked into ctx.data at lowering so ON_FRAME needs no registry —
574
+ * the same pattern as MINIMAL_BAR_THEMES resolving to concrete colors.
575
+ */
576
+ declare const CLICK_FX_INTENSITY: Record<ClickFxStyle['intensity'], {
577
+ k: number;
578
+ dur: number;
579
+ }>;
580
+ /** Webcam bubble overlay — an editable layer composited over the frame. */
581
+ interface CamStyle {
582
+ visible: boolean;
583
+ /**
584
+ * Free placement: the bubble CENTER as frame fractions (the overlay
585
+ * and zoom cx/cy convention, so positions survive aspect switches). When
586
+ * present they WIN over `position`; clearing them snaps back to the corner.
587
+ */
588
+ x?: number;
589
+ y?: number;
590
+ /** corner the bubble is anchored to when x/y are absent. */
591
+ position: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
592
+ /** bubble diameter as a fraction of frame height (0..1). */
593
+ size: number;
594
+ shape: 'circle' | 'rounded';
595
+ /**
596
+ * Corner radius in design px for the 'rounded' shape (absent = 18, the
597
+ * house look; a circle ignores it). Scales with the canvas like every
598
+ * frame-owned control.
599
+ */
600
+ radius?: number;
601
+ /**
602
+ * Ring stroke over the bubble edge. Absent = the house ring (3px white at
603
+ * 0.9 alpha — the pre-existing paint); `width: 0` = no ring.
604
+ */
605
+ border?: {
606
+ width: number;
607
+ color: string;
608
+ };
609
+ /** Bubble shadow. Absent = 'soft', the pre-existing paint. */
610
+ shadow?: 'none' | 'soft' | 'strong';
611
+ /** mirror horizontally (selfie view). */
612
+ mirror: boolean;
613
+ /**
614
+ * Show the bubble only during this SOURCE-time span (trimmed on the cam
615
+ * timeline lane; anchored to footage like zoom keyframes). Absent = always.
616
+ */
617
+ window?: Segment;
618
+ }
619
+ /**
620
+ * A cam pose region over a SOURCE-time span (seconds) — one adjustable clip on
621
+ * the cam-move lane (MO track: animated cam layouts, the Screen Studio
622
+ * signature). Footage-anchored like ZoomSpan/TiltSpan: it follows its content
623
+ * through trims/splits and speed changes (a span whose footage is fully cut
624
+ * away renders nothing, and comes back if the trim is undone). Non-overlapping
625
+ * (the lane clamps). While active the bubble holds this pose; outside spans it
626
+ * rests at the doc's cam style (doc.cam IS the rest pose); spans close together
627
+ * in output time morph pose-to-pose without returning to rest. Absent pose
628
+ * fields inherit the rest pose, so a span may move without resizing. Expanded
629
+ * at lowering into an OUTPUT-time [x, y, size] fraction keyframe track
630
+ * (camTrackFromDoc) — pure f(t), no springs.
631
+ */
632
+ interface CamPoseSpan {
633
+ /** Stable identity for selection/editing (`m{n}` user-created). */
634
+ id: string;
635
+ in: number;
636
+ out: number;
637
+ /**
638
+ * Bubble CENTER as frame fractions [0..1] (the cam.x/y and zoom cx/cy
639
+ * convention — aspect-stable). Absent = the rest pose's center.
640
+ */
641
+ x?: number;
642
+ y?: number;
643
+ /** Bubble diameter as a fraction of frame height. Absent = the rest size. */
644
+ size?: number;
645
+ /** arrival ease (@vosjs/timeline EASINGS name). Absent = the house cam ease. */
646
+ ease?: string;
647
+ /**
648
+ * Transition speed for this span's morphs. Absent = 'smooth' (~0.65s);
649
+ * 'instant' jump-cuts the bubble to its pose — the Screen Studio layout-cut.
650
+ */
651
+ transition?: TransitionSpeed;
652
+ /**
653
+ * Reserved for a future auto planner (the wand contract: 'auto' spans are
654
+ * disposable suggestions). Every studio gesture writes 'manual'.
655
+ */
656
+ source?: 'auto' | 'manual';
657
+ }
658
+ /**
659
+ * Minimum cam-move span length in OUTPUT seconds (the lane clamps resizes).
660
+ * Between zoom's 0.3 and tilt's 0.8: a bubble move settles faster than a card
661
+ * pose but still needs its ~0.65s ramp to read as a move, not a jump.
662
+ */
663
+ declare const CAM_SPAN_MIN = 0.5;
664
+ /** Bubble-size band for pose spans (fractions of frame height; UI + lint). */
665
+ declare const CAM_SIZE_MIN = 0.08;
666
+ declare const CAM_SIZE_MAX = 0.6;
667
+ /** Clamp + quantize a pose size for storage (3 decimals, frame fraction). */
668
+ declare function clampCamSize(size: number): number;
669
+ /** Clamp + quantize a pose center coordinate for storage (3 decimals, [0..1]). */
670
+ declare function clampCamFrac(v: number): number;
671
+ /**
672
+ * Default pose for user-created cam-move spans: front-and-center, large —
673
+ * the "talk to camera" moment that is the feature's reason to exist (the
674
+ * DEFAULT_TILT_POSE philosophy: a new span shows a visible, editable move,
675
+ * never a no-op).
676
+ */
677
+ declare const DEFAULT_CAM_POSE: {
678
+ x: number;
679
+ y: number;
680
+ size: number;
681
+ };
682
+ /**
683
+ * Mock browser chrome drawn as a strip above the video inside the frame card.
684
+ * Drawn by the compositor (never captured) — the editor-frame pattern every
685
+ * shot/recorder tool uses. All values travel in `ctx.data.frame` (live T2 edits).
686
+ */
687
+ interface BrowserBarStyle {
688
+ kind: 'none' | 'mac-light' | 'mac-dark' | 'windows-light' | 'windows-dark' | 'minimal';
689
+ /** address-pill text (editable; seeded from the recorded page's URL). */
690
+ url: string;
691
+ showUrl: boolean;
692
+ /** traffic lights (mac) / window buttons (windows). */
693
+ showControls: boolean;
694
+ /** bar height in design px (1080-based, same space as padding/radius). */
695
+ height: number;
696
+ /**
697
+ * Minimal-bar color theme — RESOLVED colors (not a palette id) so ON_FRAME
698
+ * needs no registry lookup (interpreter rule: everything in ctx.data is
699
+ * self-contained). Absent = the built-in graphite look. Pick from
700
+ * MINIMAL_BAR_THEMES in the inspector.
701
+ */
702
+ theme?: MinimalBarTheme;
703
+ }
704
+ /** Resolved minimal-bar colors. `light` flips the hairline to dark-on-light. */
705
+ interface MinimalBarTheme {
706
+ id: string;
707
+ bar: string;
708
+ pill: string;
709
+ text: string;
710
+ light?: boolean;
711
+ }
712
+ /**
713
+ * Curated minimal-bar palette (Cursorful-style 12 swatches: 6 dark, 6 light).
714
+ * The first entry matches the built-in default (theme absent).
715
+ */
716
+ declare const MINIMAL_BAR_THEMES: MinimalBarTheme[];
717
+ /**
718
+ * A music/SFX clip. OUTPUT-anchored (`start` is final-cut seconds): music and
719
+ * effects are authored against the cut that remains — unlike zoom keyframes /
720
+ * cam window, they do NOT follow footage through trims. The mic track stays
721
+ * source-anchored via the export's segment splice.
722
+ */
723
+ interface AudioClip {
724
+ id: string;
725
+ /** blob URL (or asset URL) of the audio file. */
726
+ key: string;
727
+ /** display name (file name or library track title). */
728
+ name: string;
729
+ /** placement on the OUTPUT timeline, seconds. */
730
+ start: number;
731
+ /** kept span within the source file, seconds (trim). */
732
+ in: number;
733
+ out: number;
734
+ /** full source-file length, seconds — the trim ceiling (set on add). */
735
+ duration: number;
736
+ /** linear gain 0..1. */
737
+ gain: number;
738
+ /** fade durations, seconds. */
739
+ fadeIn: number;
740
+ fadeOut: number;
741
+ /** loop the [in,out) span to fill `loopLen` output seconds. */
742
+ loop?: boolean;
743
+ /** placed output length when looping (≥ span; defaults to the span). */
744
+ loopLen?: number;
745
+ /** duck this clip under the mic while speech is detected. */
746
+ duck?: boolean;
747
+ }
748
+ /** Effective placed length of a clip on the output timeline, seconds. */
749
+ declare function clipLength(clip: Pick<AudioClip, 'in' | 'out' | 'loop' | 'loopLen'>): number;
750
+ /**
751
+ * Media layer drawn over the CSS `background` and under the card
752
+ * The flagship option is a vos rendered
753
+ * to a seamless loop (`vosId` provenance kept for re-bakes + a future live
754
+ * tier). Video time is OUTPUT-anchored modulo the loop (`bgT = t % duration`)
755
+ * — trims/speed never retime ambience. Fail-open: while the media loads (or if
756
+ * it can't), the CSS background underneath still paints — never a black frame.
757
+ */
758
+ interface BackgroundMedia {
759
+ kind: 'video' | 'image';
760
+ /**
761
+ * Source URL — blob URL (session), /api/assets/{id}/file (saved vos),
762
+ * https://assets.vos.so/... (pre-baked official), or take-dir relative path
763
+ * (CLI). Rides the same resolution plumbing as source.videoKey.
764
+ */
765
+ key: string;
766
+ /** Loop length in seconds (video; the bake duration). */
767
+ duration?: number;
768
+ /** Provenance: the vos this media was rendered from. */
769
+ vosId?: string;
770
+ versionId?: string;
771
+ /** Poster/thumbnail URL (picker display + reduced-motion; not drawn by the layer). */
772
+ poster?: string;
773
+ /** Black scrim over the media, 0..1 — the one legibility dial. */
774
+ dim: number;
775
+ /** Blur radius in design px — softens the media behind the card. */
776
+ blur?: number;
777
+ }
778
+ interface FrameStyle {
779
+ /** CSS background (gradient/color): always painted — the media underlay/fallback. */
780
+ background: string;
781
+ /** Optional media layer (vos loop / image) drawn over the CSS background. */
782
+ backgroundMedia?: BackgroundMedia | null;
783
+ padding: number;
784
+ radius: number;
785
+ /** shadow strength 0..1. */
786
+ shadow: number;
787
+ /** stroke around the card, 0..1 alpha (0 = off). The switch AND the opacity. */
788
+ border: number;
789
+ /**
790
+ * Stroke width in design px (scales with the canvas, like radius), drawn
791
+ * OUTWARD from the card's edge (a CSS outline) so it never covers footage.
792
+ * Absent = FRAME_BORDER_WIDTH_DEFAULT, the hairline every take shipped with.
793
+ */
794
+ borderWidth?: number;
795
+ /**
796
+ * Stroke colour, any CSS colour string. Absent = FRAME_BORDER_COLOR_DEFAULT.
797
+ * `border` is the alpha it is drawn at, so an opaque colour is correct here.
798
+ */
799
+ borderColor?: string;
800
+ /**
801
+ * How footage meets an off-ratio frame. 'contain' (default) fits the
802
+ * whole card inside the padded area, letterboxing onto the background;
803
+ * 'cover' makes the padded area the card and cover-fills it with footage,
804
+ * cropped around `focus` — what a 440x280 store tile or a 2.5:1 marquee
805
+ * demands ("fill the region"). Absent = contain; every existing doc is
806
+ * byte-identical.
807
+ */
808
+ fit?: 'contain' | 'cover';
809
+ /**
810
+ * Cover-crop anchor, normalized video-frame fractions (the zoom cx/cy
811
+ * convention): which point of the footage stays visible when `fit:'cover'`
812
+ * crops. Absent = center. Ignored under contain.
813
+ */
814
+ focus?: {
815
+ cx: number;
816
+ cy: number;
817
+ };
818
+ aspectRatio: string;
819
+ browserBar: BrowserBarStyle;
820
+ /**
821
+ * Background parallax 0..1: the background media counter-pans subtly
822
+ * as the zoom camera moves (depth cue). 0/absent = static.
823
+ */
824
+ parallax?: number;
825
+ }
826
+ /**
827
+ * Overlay clips (compositor v2: "elements-shaped data"). Screen-
828
+ * space clips drawn on the OVERLAY layer (above the card, never tilts, outside
829
+ * the zoom transform). **OUTPUT-anchored** — trims/speed never retime a title.
830
+ * The first slice ships `kind: 'text'`; image/video kinds are the next slice and extend this
831
+ * union without changing the anchoring or transform model.
832
+ */
833
+ type OverlayKind = 'text' | 'image' | 'video';
834
+ /** Named house text styles — resolved to concrete font/size/color at lowering. */
835
+ type TextOverlayPreset = 'title' | 'caption' | 'label';
836
+ /** Enter/exit transition presets — pure f(t), evaluated in ON_FRAME. */
837
+ type OverlayTransition = 'none' | 'fade' | 'rise';
838
+ /** Text animation vocabulary — entrance presets evaluated per unit. */
839
+ type TextFxKind = 'fade' | 'rise' | 'pop' | 'blur' | 'typewriter';
840
+ type TextFxUnit = 'block' | 'line' | 'word' | 'char';
841
+ type TextFxDirection = 'forward' | 'reverse' | 'center';
842
+ /**
843
+ * Text entrance animation. When present it OWNS the entrance — the
844
+ * clip's `enter` string is ignored (a spec with `unit: 'block'` is the
845
+ * superset of the legacy presets); `exit` stays clip-level. Segmentation is
846
+ * baked at lowering (deterministic doc-derived data), per-unit progress is
847
+ * evaluated in ON_FRAME — pure f(t), so scrub/seek/chunk cold-seeks agree.
848
+ */
849
+ interface TextFxSpec {
850
+ fx: TextFxKind;
851
+ /** What animates as one thing (default 'block' — the whole text). */
852
+ unit?: TextFxUnit;
853
+ /** Unit start order (default 'forward'; 'center' ripples outward). */
854
+ direction?: TextFxDirection;
855
+ /**
856
+ * Seconds between unit starts. Defaults: typewriter 0.05, other kinds
857
+ * 0.06 when unit ≠ block, else 0. Clamped at lowering so the whole
858
+ * entrance fits the clip.
859
+ */
860
+ stagger?: number;
861
+ /** Per-unit seconds (default OVERLAY_TRANSITION_DUR); typewriter ignores it. */
862
+ duration?: number;
863
+ }
864
+ /**
865
+ * A pose keyframe on an overlay/object clip (element motion).
866
+ * `at` is CLIP-LOCAL OUTPUT seconds (0 = the clip's start), so poses ride
867
+ * along when the clip moves. Values interpolate across the gap between poses
868
+ * (ease-into per pose, the KeyframeTrack convention); a hold is two identical
869
+ * poses. The clip's base transform is the value before the first pose, and
870
+ * absent fields inherit it — a pose may move without resizing. Baked at
871
+ * lowering into a clip-local keyframe track, sampled in ON_FRAME as pure
872
+ * f(t): scrub, export and chunked server renders agree by construction.
873
+ */
874
+ interface MotionPose {
875
+ /** Clip-local OUTPUT seconds. */
876
+ at: number;
877
+ /** Anchor center as frame fractions (the transform.x/y convention). */
878
+ x?: number;
879
+ y?: number;
880
+ /** Scale multiplier (the transform.scale convention). */
881
+ scale?: number;
882
+ /** Degrees (the transform.rotation convention). */
883
+ rotation?: number;
884
+ /** Opacity MULTIPLIER 0..1 on the clip's own alpha (default 1). */
885
+ opacity?: number;
886
+ /** Arrival ease (@vosjs/timeline EASINGS name). Absent = the house motion ease. */
887
+ ease?: string;
888
+ }
889
+ /** Default pose-to-pose ease: a symmetric in-out (continuous motion between
890
+ * poses, not a settle — the CapCut/keyframe convention). */
891
+ declare const MOTION_EASE = "power2.inOut";
892
+ interface OverlayTransform {
893
+ /**
894
+ * Anchor CENTER as FRACTIONS of the output frame [0..1] (the zoom cx/cy
895
+ * convention): 0.5/0.5 = frame center at ANY aspect ratio — positions
896
+ * survive aspect switches (design px did not: the space's width changes
897
+ * with the aspect, pushing clips off-frame).
898
+ */
899
+ x: number;
900
+ y: number;
901
+ /** Uniform scale multiplier on the preset size. */
902
+ scale: number;
903
+ /** Rotation in degrees (screen-space, about the anchor). */
904
+ rotation: number;
905
+ }
906
+ interface OverlayClipBase {
907
+ /** Stable identity for selection/editing in the timeline UI. */
908
+ id: string;
909
+ /** OUTPUT-time span (seconds). */
910
+ start: number;
911
+ duration: number;
912
+ transform: OverlayTransform;
913
+ /** Absent = 'rise' for enter, 'fade' for exit (the house default motion). */
914
+ enter?: OverlayTransition;
915
+ exit?: OverlayTransition;
916
+ /**
917
+ * Pose keyframes (see MotionPose): the clip's transform animated
918
+ * over clip-local time, rendered as diamonds on the clip. Optional: absent
919
+ * lowers byte-identically (no track in data).
920
+ */
921
+ motion?: MotionPose[];
922
+ }
923
+ interface TextOverlayClip extends OverlayClipBase {
924
+ kind: 'text';
925
+ /** Text content; '\n' breaks lines. */
926
+ text: string;
927
+ preset: TextOverlayPreset;
928
+ /** Font size override in design px (preset default when absent). */
929
+ size?: number;
930
+ /** CSS color override (preset default when absent). */
931
+ color?: string;
932
+ /**
933
+ * Font family override — a catalog family name (GET /api/fonts). Unknown
934
+ * names fail open: used verbatim with the preset stack as fallback.
935
+ */
936
+ family?: string;
937
+ /** Weight override — snapped to the nearest weight the catalog hosts. */
938
+ weight?: number;
939
+ /** Synthesized oblique (no italic files are hosted). */
940
+ italic?: boolean;
941
+ /** Multi-line alignment within the block (default center). */
942
+ align?: 'left' | 'center' | 'right';
943
+ /** Letter spacing in design px at the resolved size (default 0). */
944
+ letterSpacing?: number;
945
+ /** Line height multiplier (default OVERLAY_LINE_HEIGHT). */
946
+ lineHeight?: number;
947
+ /** Text outline, drawn under the fill. */
948
+ stroke?: TextOverlayStroke;
949
+ /** Background pill behind the text block (absent = none). */
950
+ box?: TextOverlayBox;
951
+ /** Entrance animation. Absent = the legacy `enter` transition. */
952
+ fx?: TextFxSpec;
953
+ /**
954
+ * Wrap width as a FRACTION of the frame width [0.1..1] (the transform.x
955
+ * convention — aspect-stable). Absent = no wrapping (lines break only on
956
+ * explicit \n). Wrapping is greedy over word tokens at measured widths; a
957
+ * single token wider than the budget gets its own line (no intra-word
958
+ * breaks). Tokens keep their trailing whitespace, so fx unit sequences
959
+ * are IDENTICAL wrapped or not — entrances regroup, never recount.
960
+ */
961
+ maxWidth?: number;
962
+ }
963
+ interface TextOverlayStroke {
964
+ /** CSS stroke color. */
965
+ color: string;
966
+ /** Stroke width in design px at the resolved size. */
967
+ width: number;
968
+ }
969
+ /**
970
+ * Text background pill. Paddings and radius are EMs of the resolved font
971
+ * size, so the pill scales with the text through size overrides, transform
972
+ * scale and output resolution alike.
973
+ */
974
+ interface TextOverlayBox {
975
+ /** CSS color of the pill. */
976
+ color: string;
977
+ /** Extra opacity multiplier on top of the clip's fade alpha (default 1). */
978
+ opacity?: number;
979
+ /** Horizontal padding in EMs (default 0.6). */
980
+ paddingX?: number;
981
+ /** Vertical padding in EMs (default 0.35). */
982
+ paddingY?: number;
983
+ /** Corner radius in EMs (default 0.25); clamped to half the pill height. */
984
+ radius?: number;
985
+ }
986
+ /**
987
+ * Image/video overlay (V1b) — a media card on the overlay layer. `key` rides
988
+ * the same resolution plumbing as source.videoKey / backgroundMedia.key
989
+ * (blob URL in-session, /api/assets URL saved, take-dir path in CLI takes).
990
+ * Sized by `width` (fraction of the FRAME width, aspect from the media) ×
991
+ * transform.scale. Video time is clip-local (t − start), muted (soundtracks
992
+ * belong to doc.audio), looping optional.
993
+ */
994
+ interface MediaOverlayClip extends OverlayClipBase {
995
+ kind: 'image' | 'video';
996
+ key: string;
997
+ /**
998
+ * The card shadow. Absent = 'soft' — the baked look every
999
+ * doc predating the field renders, so absence lowers byte-identically. 'strong' is the
1000
+ * hero float, 'none' the flat cutout.
1001
+ */
1002
+ shadow?: 'none' | 'soft' | 'strong';
1003
+ /**
1004
+ * An outline stroke drawn over the clipped media edge.
1005
+ * Absent = none. `width` in design px (scales with the canvas like
1006
+ * radius); any CSS color.
1007
+ */
1008
+ border?: {
1009
+ width: number;
1010
+ color: string;
1011
+ };
1012
+ /** Base width as a fraction of the frame width [0..1]. Absent = 0.35. */
1013
+ width?: number;
1014
+ /** Corner radius in design px. Absent = 12 (the house card radius). */
1015
+ radius?: number;
1016
+ /** Opacity 0..1. Absent = 1. */
1017
+ opacity?: number;
1018
+ /** Video only: loop while the clip is active. Absent = hold the last frame. */
1019
+ loop?: boolean;
1020
+ }
1021
+ type OverlayClip = TextOverlayClip | MediaOverlayClip;
1022
+ /**
1023
+ * Ceiling on `transform.scale` for text overlays, shared by the canvas box
1024
+ * and the panel so the two can never disagree about where growth stops (a
1025
+ * 64px title at 8× is a 512px hero word — past that it is a poster, not a
1026
+ * caption). The floor is 0.1 in both places.
1027
+ */
1028
+ declare const OVERLAY_SCALE_MAX = 8;
1029
+ declare const OVERLAY_MEDIA_DEFAULT_WIDTH = 0.35;
1030
+ declare const OVERLAY_MEDIA_DEFAULT_RADIUS = 12;
1031
+ /**
1032
+ * World-space object clips (compositor v2). These shapes are
1033
+ * DRAFTED AS THE FUTURE ENGINE SPEC: field names, asset-ref shape, and transform
1034
+ * convention carry to `objects?: ObjectConfig[]` upstream unchanged — today they
1035
+ * run interpreter-side (ON_FRAME reconciles meshes from ctx.data; live
1036
+ * SET_DATA add/remove), and a later engine release swaps the construction site into the engine.
1037
+ *
1038
+ * Conventions (agent-facing units match the rest of the doc):
1039
+ * - position x/y = FRACTIONS of the frame [0..1] (the overlay/zoom
1040
+ * convention), z = world units TOWARD the camera from the card plane
1041
+ * (0 = on the card's depth; 0.5 floats clearly in front).
1042
+ * - scale = fraction of the FRAME HEIGHT the object's unit size occupies.
1043
+ * - span (OUTPUT seconds) gates visibility with soft edge fades; absent =
1044
+ * the whole timeline.
1045
+ */
1046
+ type ObjectPrimitiveShape = 'cube' | 'sphere' | 'torus' | 'knot';
1047
+ /**
1048
+ * 3D-text material presets — fleet-audited: everything single-sided,
1049
+ * no `dispersion`, transmission only single-sided (the documented
1050
+ * SwiftShader constraints). Resolved to plain material params at lowering.
1051
+ */
1052
+ type Text3dMaterial = 'standard' | 'metal' | 'glass' | 'neon';
1053
+ type ObjectAsset =
1054
+ /** Curated primitive props — fleet-safe, no asset fetch. */
1055
+ {
1056
+ kind: 'primitive';
1057
+ shape: ObjectPrimitiveShape;
1058
+ color?: string;
1059
+ }
1060
+ /** GLB by key — accepted in the schema for forward compat with the engine spec; loads in a later slice. */
1061
+ | {
1062
+ kind: 'gltf';
1063
+ key: string;
1064
+ }
1065
+ /**
1066
+ * Extruded 3D text from a hosted typeface JSON. `typeface` is a
1067
+ * catalog slug or family name (GET the list from the typeface catalog;
1068
+ * unknown names fall back to the house face). `depth` is the extrusion as
1069
+ * a fraction of the glyph height (default 0.25); `bevel` defaults on.
1070
+ */
1071
+ | {
1072
+ kind: 'text3d';
1073
+ text: string;
1074
+ typeface?: string;
1075
+ material?: Text3dMaterial;
1076
+ color?: string;
1077
+ depth?: number;
1078
+ bevel?: boolean;
1079
+ };
1080
+ /** Curated motion presets — pure f(t), deterministic. */
1081
+ type ObjectAnimation = 'spin' | 'float';
1082
+ /**
1083
+ * A pose keyframe on a 3D object clip (the MotionPose model over
1084
+ * transform3d). `at` is CLIP-LOCAL OUTPUT seconds from the clip's span start
1085
+ * (0 when the clip has no span). Absent fields inherit the base transform3d;
1086
+ * `spin`/`float` presets compose ADDITIVELY on top of the sampled pose.
1087
+ */
1088
+ interface MotionPose3D {
1089
+ at: number;
1090
+ /** Frame fractions (the transform3d.x/y convention). */
1091
+ x?: number;
1092
+ y?: number;
1093
+ /** World units toward the camera from the card plane. */
1094
+ z?: number;
1095
+ /** Euler degrees. */
1096
+ rx?: number;
1097
+ ry?: number;
1098
+ rz?: number;
1099
+ /** Fraction of the frame height. */
1100
+ scale?: number;
1101
+ /** Arrival ease (@vosjs/timeline EASINGS name). Absent = the house motion ease. */
1102
+ ease?: string;
1103
+ }
1104
+ interface ObjectClip {
1105
+ id: string;
1106
+ asset: ObjectAsset;
1107
+ /** OUTPUT-time visibility span; absent = always. */
1108
+ span?: {
1109
+ start: number;
1110
+ duration: number;
1111
+ };
1112
+ transform3d: {
1113
+ x: number;
1114
+ y: number;
1115
+ /** World units toward the camera from the card plane. */
1116
+ z: number;
1117
+ /** Euler degrees. */
1118
+ rx: number;
1119
+ ry: number;
1120
+ rz: number;
1121
+ /** Fraction of the frame height. */
1122
+ scale: number;
1123
+ };
1124
+ animation?: ObjectAnimation | null;
1125
+ /** Pose keyframes (see MotionPose3D). Absent lowers byte-identically. */
1126
+ motion?: MotionPose3D[];
1127
+ }
1128
+ declare const OBJECT_DEFAULT_SCALE = 0.18;
1129
+ /** Enter/exit transition length in seconds (pure f(t) in ON_FRAME). */
1130
+ declare const OVERLAY_TRANSITION_DUR = 0.35;
1131
+ /** Line height multiplier for multi-line text overlays. */
1132
+ declare const OVERLAY_LINE_HEIGHT = 1.25;
1133
+ declare const OVERLAY_MIN_DURATION = 0.2;
1134
+ /**
1135
+ * The editable project state. An app-level convention that *lowers to* a vos
1136
+ * Composition (it is NOT vos core). Fully serializable.
1137
+ */
1138
+ interface ProjectDoc {
1139
+ source: {
1140
+ videoKey: string;
1141
+ cursor: CursorTrack;
1142
+ meta: RecordingMeta;
1143
+ /** object URL for a separately-recorded webcam track, if the take had a camera. */
1144
+ camKey?: string;
1145
+ /**
1146
+ * object URL for the separately-recorded microphone sidecar (AT split).
1147
+ * When present the recording's own audio track (hasAudio) is SYSTEM/tab
1148
+ * audio and micGain governs this sidecar; absent on legacy takes, where
1149
+ * the recording's track is the old record-time mix.
1150
+ */
1151
+ micKey?: string;
1152
+ /**
1153
+ * Decode strategy for the recording (vos VideoElement.frameSource):
1154
+ * 'webcodecs' (frame-accurate, MP4), 'html5' (robust, any format), 'auto'.
1155
+ * Defaults to 'auto' in lowering. Dev uploads use 'html5'; the B2 recorder
1156
+ * (known-good MP4) uses 'webcodecs'.
1157
+ */
1158
+ frameSource?: 'auto' | 'webcodecs' | 'html5';
1159
+ /**
1160
+ * 'image' = videoKey points at a still (screenshot) shown for the doc's
1161
+ * whole duration — the full editing stack (frame, browser bar, zoom, export)
1162
+ * applies unchanged. Defaults to 'video'.
1163
+ */
1164
+ sourceKind?: 'video' | 'image';
1165
+ /**
1166
+ * drawImage source rect (capture px) for window takes: the viewport's rect
1167
+ * inside the captured frame, derived once at doc build (normalizeCaptureSpace)
1168
+ * — crops the real browser chrome out of the footage so the synthetic
1169
+ * browser bar applies. When set, cursor track + meta dims are already in
1170
+ * crop space. Absent = draw the full frame.
1171
+ */
1172
+ crop?: Rect;
1173
+ /**
1174
+ * The derived viewport crop + the full capture dims it was cut from — kept
1175
+ * even while the "Original" frame mode shows the uncropped window, so the
1176
+ * crop can be re-applied losslessly (docToCropSpace/docToFullSpace remap
1177
+ * cursor/zoom/meta between the two spaces; the footage itself always holds
1178
+ * the full frame). Present ⟺ crop derivation succeeded at ingest.
1179
+ */
1180
+ chromeCrop?: {
1181
+ rect: Rect;
1182
+ frameW: number;
1183
+ frameH: number;
1184
+ };
1185
+ };
1186
+ /**
1187
+ * Kept SOURCE-time spans (@vosjs/timeline `Segment`s); the output timeline is
1188
+ * their concatenation — trim/split/cut are all segment edits. Canonical form
1189
+ * is one full-source segment; an empty list is tolerated and means "untrimmed".
1190
+ */
1191
+ segments: Segment[];
1192
+ /**
1193
+ * Speed-change spans (SOURCE time, footage-anchored — see SpeedSpan).
1194
+ * Optional for backward compatibility with persisted docs; absent = all 1×.
1195
+ */
1196
+ speed?: SpeedSpan[];
1197
+ /** Zoom regions (SOURCE time, footage-anchored, non-overlapping — see ZoomSpan). */
1198
+ zoom: ZoomSpan[];
1199
+ /**
1200
+ * Camera style — one named strategy preset driving BOTH the auto-zoom
1201
+ * planner and the camera motion (ramps/eases/pans/follow; see zoomStyle.ts).
1202
+ * Absent = DEFAULT_ZOOM_STYLE.
1203
+ */
1204
+ zoomStyle?: ZoomStyleName;
1205
+ /**
1206
+ * Per-doc overrides on top of the named style — the "Custom" seam for
1207
+ * agents/doc.json (the studio shows Custom while any override is present;
1208
+ * picking a named style clears them). Span edits do NOT set this: the style
1209
+ * describes camera dynamics, spans are content.
1210
+ */
1211
+ zoomParams?: Partial<ZoomStyleParams>;
1212
+ /**
1213
+ * Per-doc overrides for the auto-speed planner: idle/typing/scroll
1214
+ * thresholds and rates. Absent = DEFAULT_SPEED_PARAMS.
1215
+ */
1216
+ speedParams?: Partial<SpeedParams>;
1217
+ /**
1218
+ * Tilt regions (SOURCE time, footage-anchored, non-overlapping — see
1219
+ * TiltSpan). Optional: absent lowers byte-identically (no tiltTrack in data).
1220
+ */
1221
+ tilt?: TiltSpan[];
1222
+ /**
1223
+ * Dynamic-tilt wand intensity (planAutoTilt — the auto-zoom wand contract:
1224
+ * regenerate replaces only `source:'auto'` spans). 'off'/absent = the wand
1225
+ * is off; manual tilt spans work either way.
1226
+ */
1227
+ tiltStyle?: TiltStyleName;
1228
+ /** music/SFX clips on the output timeline (see AudioClip anchoring note). */
1229
+ audio: AudioClip[];
1230
+ /**
1231
+ * Master gain for the VOICE, 0..1. Absent = 1. With a mic sidecar
1232
+ * (source.micKey) this governs the sidecar; on legacy takes it governs the
1233
+ * recording's own (mixed) track.
1234
+ */
1235
+ micGain?: number;
1236
+ /**
1237
+ * Master gain for the recording's own SYSTEM/tab audio track, 0..1. Absent
1238
+ * = 1. Only meaningful on split takes (source.micKey present) — legacy
1239
+ * takes have one track and one fader (micGain).
1240
+ */
1241
+ systemGain?: number;
1242
+ cursor: CursorStyle;
1243
+ cam: CamStyle;
1244
+ /**
1245
+ * Cam pose regions (SOURCE time, footage-anchored, non-overlapping — see
1246
+ * CamPoseSpan). The bubble morphs to a span's pose and back to the rest
1247
+ * pose (doc.cam). Optional: absent lowers byte-identically (no camTrack
1248
+ * in data). Only renders when the take has a cam track (source.camKey).
1249
+ */
1250
+ camMotion?: CamPoseSpan[];
1251
+ frame: FrameStyle;
1252
+ /**
1253
+ * Card presentation (tilt / entrance) — compositor v2. Optional: absent
1254
+ * lowers byte-identically to a pre-v2 doc and renders pixel-identically.
1255
+ */
1256
+ /**
1257
+ * Screen-space overlay clips (text; later image/video) — compositor v2.
1258
+ * OUTPUT-anchored spans on the overlay layer. Optional: absent lowers
1259
+ * byte-identically to a doc predating overlays.
1260
+ */
1261
+ overlays?: OverlayClip[];
1262
+ /**
1263
+ * World-space object clips (interpreter-side; the drafted engine spec).
1264
+ * Optional: absent lowers byte-identically.
1265
+ */
1266
+ objects?: ObjectClip[];
1267
+ export: {
1268
+ resolution: ExportResolution;
1269
+ fps: 30 | 60;
1270
+ format: 'mp4';
1271
+ };
1272
+ }
1273
+ /** Export quality presets — each names the SHORT edge of the output (see resolveExportSize). */
1274
+ type ExportResolution = '720p' | '1080p' | '2k' | '4k';
1275
+ declare const EXPORT_SHORT_EDGE: Record<ExportResolution, number>;
1276
+ /** Presets in ascending quality order (picker order; recommendedExportResolution walks it). */
1277
+ declare const EXPORT_RESOLUTION_OPTIONS: ExportResolution[];
1278
+ /**
1279
+ * Default ON (ripple + press, medium, neutral): click emphasis is the point of
1280
+ * the product, and the wrong-window/coverage gates already drop the cursor
1281
+ * track (and with it every click) on takes where effects would misfire.
1282
+ */
1283
+ declare const DEFAULT_CLICK_FX: ClickFxStyle;
1284
+ declare const DEFAULT_CURSOR_STYLE: CursorStyle;
1285
+ declare const DEFAULT_CAM_STYLE: CamStyle;
1286
+ declare const DEFAULT_BROWSER_BAR: BrowserBarStyle;
1287
+ /**
1288
+ * The frame a NEW take opens on. Once `BACKDROP_DEFAULT_ON`
1289
+ * flips (backdrop.ts), the default is the house loop on its own ground;
1290
+ * until then the brand gradient. Every ingest path spreads this, so the
1291
+ * flip reaches the extension handoff, the in-page recorder, a dropped file
1292
+ * and `vos record` at once; a doc that already carries a frame keeps it.
1293
+ */
1294
+ declare const DEFAULT_FRAME_STYLE: FrameStyle;
1295
+ /** Border alpha applied when the Frame-border toggle turns on. */
1296
+ declare const FRAME_BORDER_DEFAULT = 0.35;
1297
+ /**
1298
+ * The border a doc that names no width/colour is drawn with: the hairline
1299
+ * white stroke that was hard-coded in ON_FRAME before the two knobs existed,
1300
+ * so every take made before them renders byte-identically after.
1301
+ */
1302
+ declare const FRAME_BORDER_WIDTH_DEFAULT = 1.5;
1303
+ declare const FRAME_BORDER_COLOR_DEFAULT = "#ffffff";
1304
+ /**
1305
+ * The realistic browser-bar kind matching the recorder's OS — seeds Default
1306
+ * mode so the synthetic chrome looks native to where the take was recorded
1307
+ * (light variants: browsers default light). Windows/Linux get the windows
1308
+ * chrome; when the platform is unknown — a direct upload with no browser
1309
+ * information — we default to macOS.
1310
+ */
1311
+ declare function platformBarKind(platform: RecordingMeta['platform']): BrowserBarStyle['kind'];
1312
+ /**
1313
+ * Address-pill display text for a recorded page URL: hostname (www. stripped)
1314
+ * plus a non-root path. Empty for non-http(s) or unparsable URLs.
1315
+ */
1316
+ declare function pageDisplayUrl(pageUrl: string | undefined): string;
1317
+ /** Output aspect-ratio presets (id used as FrameStyle.aspectRatio). Ordered for the picker. */
1318
+ interface AspectRatioOption {
1319
+ id: string;
1320
+ label: string;
1321
+ }
1322
+ declare const ASPECT_RATIOS: AspectRatioOption[];
1323
+ /** Numeric width/height ratio for an aspect-ratio id; 'native' resolves from the source meta. */
1324
+ declare function aspectRatioValue(id: string, meta: {
1325
+ width: number;
1326
+ height: number;
1327
+ }): number;
1328
+ /**
1329
+ * Resolve the export pixel dimensions from the chosen aspect ratio + quality. The quality
1330
+ * (`export.resolution`) is the SHORT edge (720/1080/1440/2160), so 16:9 @ 1080p→1920×1080,
1331
+ * 9:16 @ 4k→2160×3840, 1:1 @ 1080p→1080×1080. Dimensions are rounded to even numbers
1332
+ * (H.264 requires it). Unknown values (hand-edited doc.json) fall back to 1080p.
1333
+ */
1334
+ declare function resolveExportSize(doc: Pick<ProjectDoc, 'frame' | 'source' | 'export'>, resolution?: ExportResolution): {
1335
+ width: number;
1336
+ height: number;
1337
+ };
1338
+ /**
1339
+ * The quality-preset → pixels math, free of any document. Every product's
1340
+ * export UI resolves its dimensions through this one function (the shared
1341
+ * ExportDialog included), so a preset name means the same thing everywhere:
1342
+ * before it existed the web app carried three private resolution tables that
1343
+ * disagreed about what "2K" was.
1344
+ */
1345
+ declare function exportSizeFor(ratio: number, resolution: ExportResolution): {
1346
+ width: number;
1347
+ height: number;
1348
+ };
1349
+
1350
+ /**
1351
+ * Capture-space normalization — the single seam that makes window/monitor
1352
+ * recordings look like tab recordings to everything downstream.
1353
+ *
1354
+ * Tab captures record the viewport, so CursorEvent.x/y (viewport CSS px) IS the
1355
+ * cursor space and meta.width/height describes it. For window/monitor captures
1356
+ * the viewport is only part of the frame, so events are mapped into capture
1357
+ * pixels here — once, at doc-build time — using each event's screen coords
1358
+ * (sx/sy) and the geometry sampled at record start. After normalization the
1359
+ * planner, lowering, and composition consume the doc unchanged.
1360
+ *
1361
+ * Window takes additionally get a VIEWPORT CROP when the geometry is clean
1362
+ * (deriveViewportCrop): the real browser
1363
+ * chrome is cut out of the footage at the drawImage seam, the cursor/meta are
1364
+ * rewritten into crop space, and the synthetic browser bar becomes available
1365
+ * exactly as on tab takes. Fail-closed: a wrong crop (chrome sliver, cut page
1366
+ * edge) reads far worse than no crop, so any geometry doubt → no crop.
1367
+ */
1368
+
1369
+ interface CaptureNormalization {
1370
+ cursor: CursorTrack;
1371
+ meta: RecordingMeta;
1372
+ /**
1373
+ * Fraction of mapped events that landed inside the captured frame (1 for tab
1374
+ * captures). Low coverage means the user shared a different window/display
1375
+ * than the one hosting the recorded tab — the app should skip auto-zoom and
1376
+ * cursor overlay rather than render them at wrong positions.
1377
+ */
1378
+ coverage: number;
1379
+ /**
1380
+ * Window takes with clean geometry: the viewport's rect inside the capture
1381
+ * frame (capture px) — the drawImage source crop that removes the real
1382
+ * browser chrome. When present, the returned cursor/meta are already in
1383
+ * crop space. Absent = render the full frame.
1384
+ */
1385
+ crop?: Rect;
1386
+ }
1387
+ /**
1388
+ * Derive the viewport crop for a window take: where the page viewport sits
1389
+ * inside the captured window frame, in capture px.
1390
+ *
1391
+ * Two independent estimators cross-check each other:
1392
+ * 1. event-derived (primary): each event's own viewport→screen offset
1393
+ * (sx − x, sy − y) — exact wherever the chrome actually is, but needs events;
1394
+ * 2. window-derived: windowRect vs meta.viewport under the chrome-on-top
1395
+ * assumption (side insets split evenly) — no events needed, but wrong for
1396
+ * docked devtools / exotic decorations.
1397
+ *
1398
+ * Only offsets agreeing with (2) are kept — this simultaneously validates the
1399
+ * chrome-on-top assumption AND rejects cross-origin-iframe events, whose
1400
+ * offsets are the IFRAME's origin, not the top viewport's. Returns null
1401
+ * (no crop) on any doubt — see the fail-closed matrix in the analysis doc.
1402
+ */
1403
+ declare function deriveViewportCrop(cursor: CursorTrack, meta: RecordingMeta): Rect | null;
1404
+ /**
1405
+ * Map a cursor track into the capture's pixel space. Identity for tab captures
1406
+ * (or when geometry is missing). For window/monitor captures the returned meta
1407
+ * has width/height set to the capture pixel dimensions (the new cursor space)
1408
+ * and dpr/zoom reset to 1 — cursor space and video pixels now coincide. When a
1409
+ * window take yields a viewport crop, cursor space is the CROPPED frame and
1410
+ * meta dims are the crop dims (downstream layout/planner/zoom need no crop
1411
+ * awareness — only drawImage reads the rect).
1412
+ */
1413
+ declare function normalizeCaptureSpace(cursor: CursorTrack, meta: RecordingMeta): CaptureNormalization;
1414
+ /** Coverage below this → treat the cursor track as unusable (skip auto-zoom + overlay). */
1415
+ declare const CAPTURE_COVERAGE_MIN = 0.5;
1416
+ /**
1417
+ * Window takes whose browser window was focused for less than this fraction of
1418
+ * the take are treated as wrong-window shares: cursor track dropped, no
1419
+ * viewport crop (see RecordingMeta.windowFocusedFrac).
1420
+ */
1421
+ declare const WINDOW_FOCUS_MIN = 0.5;
1422
+ /**
1423
+ * Crop-space ↔ full-space doc remaps — the "Original" frame mode (show the
1424
+ * user's real browser chrome on a cropped window take). The footage always
1425
+ * holds the full frame, so the toggle is a LOSSLESS coordinate remap of the
1426
+ * doc (cursor events/rects, zoom focus points, meta dims) driven by the
1427
+ * chromeCrop record kept from ingest — one undoable patch-store edit, program
1428
+ * string untouched (everything involved lowers into ctx.data). Time-based
1429
+ * state (segments, speed, audio, cam window) is space-independent.
1430
+ *
1431
+ * Both helpers MUTATE a draft doc (call inside the store's edit()) and are
1432
+ * no-ops when the doc is already in the requested space or has no chromeCrop.
1433
+ */
1434
+ /** Remap a crop-space doc to full-capture space (show the original chrome). */
1435
+ declare function docToFullSpace(d: ProjectDoc): void;
1436
+ /** Remap a full-space doc back into crop space (hide the chrome again). */
1437
+ declare function docToCropSpace(d: ProjectDoc): void;
1438
+
1439
+ /** Build a ProjectDoc from a RecordingArtifact handed off by a recorder. */
1440
+ declare function projectFromArtifact(artifact: RecordingArtifact, videoUrl: string): {
1441
+ doc: ProjectDoc;
1442
+ videoUrl: string;
1443
+ };
1444
+
1445
+ /**
1446
+ * Hosted-doc schema versioning: the scoped reversal of "ProjectDocs
1447
+ * are never persisted" is hosted versions only, and every persisted doc is
1448
+ * stamped `docSchemaVersion` from day one so the migration obligation the
1449
+ * old rule avoided stays bounded to one seam — migrate-on-read, here.
1450
+ *
1451
+ * Local studio sessions still never persist docs; CLI take dirs carry
1452
+ * doc.json under `schema/doc.schema.json` (which tolerates the stamp via
1453
+ * additionalProperties). Both hydration paths (studio handback, `vos
1454
+ * pull`) run through migrateHostedDoc before trusting a hosted doc.
1455
+ */
1456
+ /**
1457
+ * 2 = the document FAMILY era: a doc is a recording document (`source`)
1458
+ * or a program document (`program.config`). A v1 doc IS a recording document,
1459
+ * field for field, so 1 → 2 is a stamp; 0 → 1 was a stamp too.
1460
+ */
1461
+ declare const DOC_SCHEMA_VERSION = 2;
1462
+ /**
1463
+ * Upgrade a hosted doc.json payload to the current schema version.
1464
+ * Unstamped docs are v0 — the pre-stamp era. Every step so far is a stamp
1465
+ * (a post-v1 doc field is optional by doctrine, and v2 only widened the
1466
+ * family), so migration is structural identity. A real shape change chains
1467
+ * its step here.
1468
+ */
1469
+ declare function migrateHostedDoc(raw: Record<string, unknown>): Record<string, unknown>;
1470
+
1471
+ /**
1472
+ * The studio document family.
1473
+ *
1474
+ * A document is an ANCHOR plus the layers every anchor shares. `ProjectDoc`
1475
+ * is the recording-anchored member, field for field what it always was (its
1476
+ * wire, `doc.json`, does not move). `ProgramAnchorDoc` is the program-anchored
1477
+ * member: its anchor IS the user's config — the execution IR, untouched
1478
+ * — plus the tween-timing overlay that used to live only in a
1479
+ * hook. The shared layers are optional on it until the shared modules activate them.
1480
+ *
1481
+ * Discriminated on `source`: every recording doc carries one, no program doc
1482
+ * may.
1483
+ */
1484
+ /** One entry of the tween-timing overlay — `@vosjs/tween`'s `TweenEdit`, structurally. */
1485
+ interface ProgramTweenEdit {
1486
+ index: number;
1487
+ startTime?: number;
1488
+ duration?: number;
1489
+ ease?: string;
1490
+ to?: Record<string, number>;
1491
+ from?: Record<string, number>;
1492
+ }
1493
+ interface ProgramAnchorDoc {
1494
+ program: {
1495
+ /** THE user's config, as authored (functions as strings). Never composed here. */
1496
+ config: Record<string, unknown>;
1497
+ /** Retimes over the config's recorded tweens, by spec index. */
1498
+ tweenEdits?: Record<number, ProgramTweenEdit>;
1499
+ /** The anchor's own length when the config's is a placeholder. */
1500
+ duration?: number;
1501
+ };
1502
+ overlays?: OverlayClip[];
1503
+ objects?: ObjectClip[];
1504
+ /** Required, like the recording's: the audio module and its lane read it without a guard. Minted `[]`. */
1505
+ audio: AudioClip[];
1506
+ /** Retime spans over the ANCHOR's clock: the recording's type, `in`/`out` in program seconds. */
1507
+ speed?: SpeedSpan[];
1508
+ export?: ProjectDoc['export'];
1509
+ }
1510
+ type StudioDoc = ProjectDoc | ProgramAnchorDoc;
1511
+ type AnchorKind = 'recording' | 'program';
1512
+ declare const anchorKindOf: (doc: StudioDoc) => AnchorKind;
1513
+ declare const isRecordingDoc: (doc: StudioDoc) => doc is ProjectDoc;
1514
+ declare const isProgramDoc: (doc: StudioDoc) => doc is ProgramAnchorDoc;
1515
+ /** A program anchor's own length in seconds: `program.duration`, else the config's. */
1516
+ declare function programDuration(doc: ProgramAnchorDoc): number;
1517
+ /**
1518
+ * The anchor's SOURCE length: the footage's for a recording, the
1519
+ * program's own for a program. Speed spans, segments and every source-time
1520
+ * floor measure against it.
1521
+ */
1522
+ declare function anchorSourceDuration(doc: StudioDoc): number;
1523
+
1524
+ /**
1525
+ * Cursor smoothing.
1526
+ *
1527
+ * Raw pointer samples are jittery and irregularly spaced. We resample to a fixed
1528
+ * cadence and apply an exponential lerp — counterintuitively, linear smoothing
1529
+ * beats ease-in-out for cursors (easing stutters between samples). Pure and
1530
+ * deterministic: same input → same output.
1531
+ */
1532
+
1533
+ interface SmoothPoint {
1534
+ /** seconds. */
1535
+ t: number;
1536
+ x: number;
1537
+ y: number;
1538
+ }
1539
+ interface SmoothOptions {
1540
+ /** lerp factor per step, 0..1 (higher = smoother/laggier). Default 0.15. */
1541
+ factor?: number;
1542
+ /** resample cadence in fps. Default 60. */
1543
+ fps?: number;
1544
+ /**
1545
+ * Pull the smoothed path onto each click's true position around the click
1546
+ * instant: click effects anchor at the
1547
+ * click point, so the (laggy) smoothed cursor must arrive on time or the
1548
+ * ring blooms away from the dot. The pull feeds back into the lerp state,
1549
+ * so the path continues from the click point afterwards. Deterministic.
1550
+ */
1551
+ clickSnap?: boolean;
1552
+ }
1553
+ /**
1554
+ * Produce a smoothed, fixed-cadence cursor path (in seconds) from a raw track.
1555
+ * Only `move`/`down`/`up` events carry positions; others are ignored here —
1556
+ * `scroll` re-emits a stale point, and `focus`/`key` synthesize element
1557
+ * centers the cursor never visited (letting those in would teleport the dot).
1558
+ */
1559
+ declare function smoothCursor(track: CursorTrack, options?: SmoothOptions): SmoothPoint[];
1560
+
1561
+ /**
1562
+ * A click cluster whose element FITS the frame at less than this level is
1563
+ * not a target: it is a drag (aiming, scrubbing, moving a thing across the
1564
+ * canvas) or a frame-sized surface, and a zoom on it says nothing. Five real
1565
+ * takes (2026-08-25) each carried 1-4 such clusters, planned at the floor
1566
+ * level for 10-25s; every one was dropped by hand. Now they plan nothing.
1567
+ */
1568
+ declare const DRAG_FIT_LEVEL = 1.15;
1569
+ interface PlanOptions {
1570
+ /** captured frame size (for normalizing rects → [0..1] focus points). */
1571
+ width: number;
1572
+ height: number;
1573
+ /**
1574
+ * Camera style whose planner params seed every default below (zoomStyle.ts).
1575
+ * Explicit options still win. Absent = DEFAULT_ZOOM_STYLE.
1576
+ */
1577
+ style?: ZoomStyleName;
1578
+ /** per-doc overrides on top of the style (doc.zoomParams — the Custom seam). */
1579
+ params?: Partial<ZoomStyleParams>;
1580
+ /** target: zoom so the element fills ~this fraction of the frame. */
1581
+ targetFill?: number;
1582
+ /** clamp zoom level. */
1583
+ minLevel?: number;
1584
+ maxLevel?: number;
1585
+ /** clicks within this many seconds merge into one zoom (session merge). */
1586
+ clusterGap?: number;
1587
+ /** span lead-in before the first click + hold after the last. */
1588
+ lead?: number;
1589
+ hold?: number;
1590
+ /** minimum clicks for a cluster to earn a zoom (Cursorful's ≥2 rule). */
1591
+ minClusterClicks?: number;
1592
+ /** emit spans with focusMode 'auto' (cursor-follow camera). */
1593
+ followByDefault?: boolean;
1594
+ /** typing sessions plan spans. */
1595
+ typingZoom?: boolean;
1596
+ /** max silence between `key` pings before the typing session ends. */
1597
+ typingGap?: number;
1598
+ /** hold after the last keystroke (the read-what-you-typed beat). */
1599
+ typingHold?: number;
1600
+ /** level floor for typing spans (a wide field still reads punchier). */
1601
+ typingMinLevel?: number;
1602
+ }
1603
+ declare function planAutoZoom(track: CursorTrack, options: PlanOptions): ZoomSpan[];
1604
+
1605
+ /** Zoom spans shorter than this (OUTPUT seconds) get no tilt — a pose that
1606
+ * can't settle before the zoom leaves reads as wobble, not emphasis. */
1607
+ declare const TILT_AUTO_MIN = 1.2;
1608
+ /** Focus offsets under this (|cx−0.5| fraction) don't tilt that axis — a
1609
+ * centered zoom keeps the pure push-in feel; tilt is for off-center focus. */
1610
+ declare const TILT_AUTO_DEAD_ZONE = 0.12;
1611
+ interface PlanTiltOptions {
1612
+ /** Intensity ladder — max degrees per axis (TILT_INTENSITY_MAX). */
1613
+ intensity: Exclude<TiltStyleName, 'off'>;
1614
+ }
1615
+ /**
1616
+ * One tilt span per qualifying zoom span, SAME source extents (the tracks
1617
+ * ramp/chain together), pose aimed at the zoom's focus. Spans whose footage
1618
+ * is cut away, whose output run is too short, or whose focus is centered
1619
+ * (both axes inside the dead zone) emit nothing.
1620
+ */
1621
+ declare function planAutoTilt(zoom: readonly ZoomSpan[], segments: Segment[], options: PlanTiltOptions): TiltSpan[];
1622
+
1623
+ type MomentKind = 'head' | 'tail' | 'click' | 'typing' | 'scroll' | 'dwell' | 'idle' | 'scene';
1624
+ /** A rect in normalized [0..1] frame fractions (the zoom cx/cy convention). */
1625
+ interface NormRect {
1626
+ x: number;
1627
+ y: number;
1628
+ w: number;
1629
+ h: number;
1630
+ }
1631
+ interface Moment {
1632
+ id: string;
1633
+ kind: MomentKind;
1634
+ /** Footage seconds. Instants (scene/head/tail) have in === out. */
1635
+ source: {
1636
+ in: number;
1637
+ out: number;
1638
+ };
1639
+ /** The same window in OUTPUT seconds, null when trimmed away. */
1640
+ output: {
1641
+ in: number;
1642
+ out: number;
1643
+ } | null;
1644
+ /** The source instant the digest frames (null = no frame, e.g. idle). */
1645
+ at: number | null;
1646
+ /** `at` mapped to output time (null when cut or frameless). */
1647
+ outputAt: number | null;
1648
+ /** Normalized focus — copy into a ZoomSpan's cx/cy. */
1649
+ focus: {
1650
+ cx: number;
1651
+ cy: number;
1652
+ } | null;
1653
+ /** Normalized target bounds (element rect union), when the events had one. */
1654
+ rect: NormRect | null;
1655
+ clicks?: number;
1656
+ pings?: number;
1657
+ /** Motion in the window, 0..1 (fraction of changed pixels), null without frames. */
1658
+ activity: number | null;
1659
+ /** Planner spans (from `plan`) that cover this moment, by id. */
1660
+ proposed: {
1661
+ zoom?: string;
1662
+ speed?: string;
1663
+ tilt?: string;
1664
+ };
1665
+ /** Transcript text over the window, when a transcript was merged. */
1666
+ said: string | null;
1667
+ }
1668
+ interface DigestPlan {
1669
+ zoom: ZoomSpan[];
1670
+ speed: SpeedSpan[];
1671
+ tilt: TiltSpan[];
1672
+ }
1673
+ interface TranscriptSegment {
1674
+ /** SOURCE seconds (the recording's own clock). */
1675
+ start: number;
1676
+ end: number;
1677
+ text: string;
1678
+ }
1679
+ interface MomentsOptions {
1680
+ /** Per-SOURCE-second motion bins (0..1) from the frame-diff pass. */
1681
+ bins?: readonly number[] | null;
1682
+ /** Source seconds of visual scene changes (see scenes.ts). */
1683
+ scenes?: readonly number[];
1684
+ transcript?: readonly TranscriptSegment[] | null;
1685
+ /** Idle threshold (seconds); defaults to the speed planner's. */
1686
+ idleMin?: number;
1687
+ }
1688
+ /**
1689
+ * The three planners, run fresh under the doc's own style — the proposals.
1690
+ * With activity bins (a digest's decode pass), the speed planner can tell
1691
+ * playback from idle.
1692
+ */
1693
+ declare function planForDigest(doc: ProjectDoc, activity?: readonly number[] | null): DigestPlan;
1694
+ declare function momentsFromDoc(doc: ProjectDoc, plan: DigestPlan, opts?: MomentsOptions): Moment[];
1695
+
1696
+ /**
1697
+ * Scene changes from the digest's motion bins: a SOURCE second whose
1698
+ * changed-pixel fraction jumps past `motion` after a second at or below
1699
+ * `quiet` is a scene — a navigation, a dialog, a page swap. A luma diff, not
1700
+ * a scene detector: a video playing inside the page is a permanent change,
1701
+ * and the moment kind says `scene`, never "navigation" — the agent looks at
1702
+ * the frame to say what it was.
1703
+ */
1704
+ interface SceneOptions {
1705
+ /** Changed-pixel fraction that reads as a change. */
1706
+ motion?: number;
1707
+ /** The bin before must be at or below this. */
1708
+ quiet?: number;
1709
+ }
1710
+ /**
1711
+ * Calibrated on a dark-theme CLI take (launch-d2, 2026-08-25): a page swap
1712
+ * moved 0.33–0.36 of a 64×36 luma thumb, a click's own ripple ≤0.03 — so a
1713
+ * quarter of the pixels is a scene and a tenth is still quiet.
1714
+ */
1715
+ declare const SCENE_MOTION = 0.25;
1716
+ declare const SCENE_QUIET = 0.1;
1717
+ declare function sceneChanges(bins: readonly number[], opts?: SceneOptions): number[];
1718
+
1719
+ interface CardLayout {
1720
+ /** canvas size the layout was computed for (comp px). */
1721
+ W: number;
1722
+ H: number;
1723
+ /** video destination rect, comp px. Contain: the fitted video. Cover
1724
+ * the cover-scaled video, positioned by frame.focus — it can
1725
+ * overflow the card, which crops it. */
1726
+ dx: number;
1727
+ dy: number;
1728
+ dw: number;
1729
+ dh: number;
1730
+ /** card rect = browser-bar strip + footage area — what the zoom must keep
1731
+ * covering. Contain: cardX/cardW equal dx/dw. Cover: the padded area
1732
+ * itself, which the video rect overflows. */
1733
+ cardX: number;
1734
+ cardY: number;
1735
+ cardW: number;
1736
+ cardH: number;
1737
+ }
1738
+ /**
1739
+ * Mirror of ON_FRAME's destination-rect math (see the "video destination rect"
1740
+ * block in lowerToComposition's ON_FRAME string). `video` is the source's
1741
+ * pixel size — only its aspect matters (contain-fit scales it).
1742
+ */
1743
+ declare function computeCardLayout(frame: FrameStyle, video: {
1744
+ width: number;
1745
+ height: number;
1746
+ }, W: number, H: number): CardLayout;
1747
+ /**
1748
+ * The doc's card layout at design size (H = 1080, W from the output aspect).
1749
+ * All layout terms scale linearly with the canvas at a fixed aspect, so the
1750
+ * normalized focus bounds computed from this layout hold at ANY render size.
1751
+ *
1752
+ * Viewport-cropped window takes need no special casing here: normalization
1753
+ * rewrote meta.captureWidth/Height to the CROP dims, which is exactly what
1754
+ * ON_FRAME uses as source dims when `d.crop` is set (crp.w/crp.h) — the
1755
+ * golden contract holds through the crop.
1756
+ */
1757
+ declare function docCardLayout(doc: Pick<ProjectDoc, 'frame' | 'source'>): CardLayout;
1758
+ /**
1759
+ * Largest export preset whose footage card still gets ≥1 captured px per output
1760
+ * px. The composited chrome (background, bar, cursor, effects) is vector-drawn
1761
+ * and real at any size; the footage layer is bounded by capture pixels, so
1762
+ * presets above this one upscale the footage (the picker labels them, never
1763
+ * hides them). Judged on the width of
1764
+ * the contain-fitted card at each preset's output size vs meta.captureWidth
1765
+ * (crop-space when a viewport crop applies — ingest rewrote meta to crop dims).
1766
+ * Floors at the smallest preset for tiny captures.
1767
+ */
1768
+ declare function recommendedExportResolution(doc: Pick<ProjectDoc, 'frame' | 'source' | 'export'>): ExportResolution;
1769
+ interface CamBubbleRect {
1770
+ /** top-left corner of the bubble's square, design px (docCardLayout space). */
1771
+ x: number;
1772
+ y: number;
1773
+ /** the square's side — the bubble diameter. */
1774
+ size: number;
1775
+ /** corner radius (size/2 for circles, the fixed rounded radius otherwise). */
1776
+ radius: number;
1777
+ }
1778
+ /**
1779
+ * Host-side mirror of ON_FRAME's webcam-bubble geometry (the "webcam bubble"
1780
+ * block in lowerToComposition) — the picking oracle for the on-canvas cam
1781
+ * layer. Same design space as docCardLayout (H = 1080), and like ON_FRAME the
1782
+ * bubble is FRAME-owned chrome: everything scales by s = H/1080, never the
1783
+ * card-chrome cf. The bubble ignores card tilt/zoom (it paints on the
1784
+ * screen-space overlay plane), so this rect is valid under any camera pose.
1785
+ * camDraw.test.ts pins this to the painted geometry — change them TOGETHER.
1786
+ */
1787
+ declare function camBubbleRect(cam: CamStyle, W: number, H?: number): CamBubbleRect;
1788
+ interface FocusBounds {
1789
+ minX: number;
1790
+ maxX: number;
1791
+ minY: number;
1792
+ maxY: number;
1793
+ }
1794
+ /**
1795
+ * Focus bounds (normalized video coords) so the zoomed CARD covers the whole
1796
+ * canvas — the crop never reveals background past a card edge at full zoom.
1797
+ *
1798
+ * Derivation: ON_FRAME's transform maps content point p → f + (p − f)·L around
1799
+ * the focus anchor f (fx = dx + zx·dw), so the visible canvas [0, V] shows
1800
+ * content [f − f/L, f + (V − f)/L]. Requiring that window ⊆ the cover range
1801
+ * [o, o + c] and solving for the anchor (k = 1 − 1/L):
1802
+ *
1803
+ * f ≥ o / k and f ≤ (o + c − V/L) / k
1804
+ *
1805
+ * When the zoomed card is too small to cover the canvas (low level + padding),
1806
+ * the bounds cross — collapse to their midpoint (the least-uncovered focus;
1807
+ * 0.5 for a centered card, matching OpenScreen's margin collapse).
1808
+ */
1809
+ declare function focusBounds(level: number, layout: CardLayout): FocusBounds;
1810
+ /** Clamp a focus point into the bounds for its zoom level. */
1811
+ declare function clampFocus(cx: number, cy: number, level: number, layout: CardLayout): {
1812
+ cx: number;
1813
+ cy: number;
1814
+ };
1815
+ /**
1816
+ * The zoom level a focus rect of this canvas-fraction size
1817
+ * means — the aiming rect's size is purely 1/level, so a corner drag IS a
1818
+ * level drag, and this is the inverse. Floored a hair above the identity so
1819
+ * a drag can never reach level ≈ 1 and dismiss the aiming rect mid-gesture;
1820
+ * clamped and quantized like every stored level (clampZoomLevel).
1821
+ */
1822
+ declare function levelForFocusFraction(frac: number): number;
1823
+
1824
+ interface ZoomWindow {
1825
+ x0: number;
1826
+ x1: number;
1827
+ y0: number;
1828
+ y1: number;
1829
+ }
1830
+ /** The visible window in normalized video coords at `level` around the focus. */
1831
+ declare function zoomWindow(span: {
1832
+ level: number;
1833
+ cx: number;
1834
+ cy: number;
1835
+ }, layout: CardLayout): ZoomWindow;
1836
+ /**
1837
+ * True when the rect (normalized) sits inside the zoom's visible window, with
1838
+ * `tol` of slack per edge (frame fractions). A level ≤ 1 zoom shows the whole
1839
+ * frame and covers everything.
1840
+ */
1841
+ declare function zoomCoversRect(span: {
1842
+ level: number;
1843
+ cx: number;
1844
+ cy: number;
1845
+ }, rect: NormRect, layout: CardLayout, tol?: number): boolean;
1846
+
1847
+ /**
1848
+ * Style as DATA: the fields of a signed-off take's doc that ARE its
1849
+ * style — camera, speed, tilt personality, frame, cursor, cam bubble, export.
1850
+ * `copyStyle` carries them onto another take deterministically, so a series
1851
+ * shares them by construction and the recipe (CUT.md) only has to say what a
1852
+ * number cannot. Never the spans, overlays or audio: those are the cut.
1853
+ */
1854
+
1855
+ declare const STYLE_FIELDS: readonly ["zoomStyle", "zoomParams", "speedParams", "tiltStyle", "frame", "cursor", "cam", "export"];
1856
+ type StyleField = (typeof STYLE_FIELDS)[number];
1857
+ /** The style fields present on a doc, deep-cloned. */
1858
+ declare function pickStyle(doc: ProjectDoc): Partial<Pick<ProjectDoc, StyleField>>;
1859
+ /**
1860
+ * A new doc: `to` with `from`'s style fields. A field absent on `from` is
1861
+ * removed from the result (the seed's absence is a choice — the default).
1862
+ */
1863
+ declare function copyStyle(from: ProjectDoc, to: ProjectDoc): ProjectDoc;
1864
+
1865
+ /**
1866
+ * The digest's crop geometry, pure and shared by the CLI's page and
1867
+ * the fleet's page: cursor/meta coords are CSS px of the viewport (or the
1868
+ * crop space when `source.crop` is set); the frame is capture px. A window
1869
+ * take's crop applies to the FRAME, never to the already-cropped cursor
1870
+ * coords. Both hosts compute boxes here so the two never drift by a dpr.
1871
+ */
1872
+
1873
+ interface PxRect {
1874
+ x: number;
1875
+ y: number;
1876
+ w: number;
1877
+ h: number;
1878
+ }
1879
+ /** A crop box is at least this fraction of the frame width (floor 320px). */
1880
+ declare const CROP_MIN_FRAC = 0.25;
1881
+ declare const CROP_MIN_PX = 320;
1882
+ declare const CROP_MAX_PX = 1024;
1883
+ declare const CROP_PAD = 0.25;
1884
+ /** Default long edges (px) of the emitted images — the agent's token budget. */
1885
+ declare const DIGEST_FULL_MAX = 960;
1886
+ declare const DIGEST_CROP_MAX = 640;
1887
+ /** Changed-pixel threshold (luma, 0..255) for the motion bins. */
1888
+ declare const MOTION_DELTA = 24;
1889
+ interface FrameGeometry {
1890
+ /** The region of the frame the doc renders (the viewport crop, or all). */
1891
+ region: PxRect;
1892
+ /** Cursor px → frame px. */
1893
+ scale: number;
1894
+ }
1895
+ /** The frame's pixel size when no decode has told us: capture px, else CSS×dpr. */
1896
+ declare function expectedFrameSize(doc: ProjectDoc): {
1897
+ width: number;
1898
+ height: number;
1899
+ };
1900
+ declare function frameGeometry(doc: ProjectDoc, frameW: number, frameH: number): FrameGeometry;
1901
+ /** The crop box (frame px) around a moment's rect or focus point, or null. */
1902
+ declare function cropBox(m: Pick<Moment, 'rect' | 'focus'>, geo: FrameGeometry, meta: {
1903
+ width: number;
1904
+ height: number;
1905
+ }): PxRect | null;
1906
+
1907
+ declare const DIGEST_VERSION = 1;
1908
+ interface DigestImageRef {
1909
+ full: string | null;
1910
+ crop: string | null;
1911
+ /** The crop's source box in FRAME px (what `crop` shows). */
1912
+ box: PxRect | null;
1913
+ fullSize?: {
1914
+ width: number;
1915
+ height: number;
1916
+ } | null;
1917
+ cropSize?: {
1918
+ width: number;
1919
+ height: number;
1920
+ } | null;
1921
+ }
1922
+ interface DigestTakeFacts {
1923
+ sourceDuration: number;
1924
+ outputDuration: number;
1925
+ width: number;
1926
+ height: number;
1927
+ captureWidth: number | null;
1928
+ captureHeight: number | null;
1929
+ frameWidth: number | null;
1930
+ frameHeight: number | null;
1931
+ surface: string;
1932
+ producer: string;
1933
+ pageUrl: string | null;
1934
+ pageTitle: string | null;
1935
+ hasMic: boolean;
1936
+ hasSystemAudio: boolean;
1937
+ hasCursor: boolean;
1938
+ windowFocusedFrac: number | null;
1939
+ }
1940
+ interface Digest {
1941
+ digestVersion: number;
1942
+ take: DigestTakeFacts;
1943
+ units: {
1944
+ source: 'seconds of footage';
1945
+ output: 'seconds of the rendered video (trims and speed applied)';
1946
+ focus: 'fractions of the video frame [0..1], the zoom cx/cy convention';
1947
+ activity: 'fraction of pixels that changed, per SOURCE second';
1948
+ };
1949
+ moments: (Moment & {
1950
+ full: string | null;
1951
+ crop: string | null;
1952
+ box: PxRect | null;
1953
+ })[];
1954
+ activity: number[] | null;
1955
+ plan: DigestPlan;
1956
+ doc: {
1957
+ manual: {
1958
+ zoom: number;
1959
+ speed: number;
1960
+ tilt: number;
1961
+ overlays: number;
1962
+ };
1963
+ zoomStyle: string | null;
1964
+ tiltStyle: string | null;
1965
+ };
1966
+ style: {
1967
+ from: string;
1968
+ fields: Record<string, unknown>;
1969
+ } | null;
1970
+ transcript: TranscriptSegment[] | null;
1971
+ images: {
1972
+ full: number;
1973
+ crop: number;
1974
+ sheet: string | null;
1975
+ tokensEstimateClaude: number;
1976
+ };
1977
+ }
1978
+ interface BuildDigestInput {
1979
+ doc: ProjectDoc;
1980
+ plan: DigestPlan;
1981
+ moments: Moment[];
1982
+ outputDuration: number;
1983
+ bins: number[] | null;
1984
+ frame: {
1985
+ width: number;
1986
+ height: number;
1987
+ } | null;
1988
+ images: Map<string, DigestImageRef>;
1989
+ sheet: string | null;
1990
+ style?: {
1991
+ from: string;
1992
+ doc: ProjectDoc;
1993
+ } | null;
1994
+ transcript?: readonly TranscriptSegment[] | null;
1995
+ }
1996
+ declare function buildDigest(input: BuildDigestInput): Digest;
1997
+ /** OUTPUT seconds of a doc: its kept footage through the rate map. */
1998
+ declare function outputDurationOf(doc: ProjectDoc): number;
1999
+
2000
+ /** Map an OUTPUT-time range to SOURCE seconds through the doc's rate map. */
2001
+ declare function outputRangeToSource(doc: StudioDoc, t0: number, t1: number): {
2002
+ srcIn: number;
2003
+ srcOut: number;
2004
+ };
2005
+ /**
2006
+ * Re-rate a SOURCE range: spans overlapping it are trimmed (a span split in
2007
+ * two mints a fresh id for the second half), then a manual span at `rate`
2008
+ * covers the range. `rate: null` just clears — the 1× chip.
2009
+ */
2010
+ declare function setSpeedInRange(spans: readonly SpeedSpan[], srcIn: number, srcOut: number, rate: number | null): SpeedSpan[];
2011
+ /** The Remove chip: drop every span the range touches, whole. */
2012
+ declare function removeSpeedInRange(spans: readonly SpeedSpan[], srcIn: number, srcOut: number): SpeedSpan[];
2013
+ /**
2014
+ * The Cut chip: subtract a SOURCE range from the kept segments. Segment
2015
+ * order (and any reorder) is preserved; a remainder below MIN_SEGMENT is
2016
+ * dropped with its parent. Never empties the take: cutting everything
2017
+ * returns the original list unchanged.
2018
+ */
2019
+ declare function removeSourceRange(segments: readonly Segment[], srcIn: number, srcOut: number): Segment[];
2020
+ /**
2021
+ * The Zoom chip: a manual zoom span covering as much of the range as the
2022
+ * lane's non-overlap rule allows — clipped against existing spans, starting
2023
+ * at the first free moment inside the range. Null when the free room is
2024
+ * below the zoom floor (the chip greys out).
2025
+ */
2026
+ declare function zoomSpanForRange(zoom: readonly ZoomSpan[], srcIn: number, srcOut: number): ZoomSpan | null;
2027
+
2028
+ /** Legacy defaults (= the default style's values); prefer FollowOptions. */
2029
+ declare const FOLLOW_SAFE_RATIO: number;
2030
+ declare const FOLLOW_RECENTER: number;
2031
+ interface FollowOptions {
2032
+ /** recenter when the cursor exits this central fraction of the crop. */
2033
+ safeRatio?: number;
2034
+ /** seconds the camera takes to glide to a recentered focus. */
2035
+ recenter?: number;
2036
+ /** target the cursor this many seconds ahead of the exit moment. */
2037
+ lookahead?: number;
2038
+ }
2039
+ interface FollowEvent {
2040
+ /** SOURCE seconds — the moment the recenter starts. */
2041
+ t: number;
2042
+ cx: number;
2043
+ cy: number;
2044
+ }
2045
+ declare function followFocusEvents(span: ZoomSpan, cursor: CursorTrack, space: {
2046
+ w: number;
2047
+ h: number;
2048
+ }, layout: CardLayout, options?: FollowOptions): {
2049
+ entry: {
2050
+ cx: number;
2051
+ cy: number;
2052
+ } | null;
2053
+ events: FollowEvent[];
2054
+ };
2055
+
2056
+ interface LoweredComposition {
2057
+ /** The composed config: the anchor's program plus the studio stack entry. */
2058
+ config: Record<string, unknown>;
2059
+ /** The MAIN program's ctx.data. */
2060
+ data: Record<string, unknown>;
2061
+ /** Each stack entry's own ctx.data, by entry id (`deps.stack` / `SET_DATA { target }`). */
2062
+ stack: Record<string, Record<string, unknown>>;
2063
+ /**
2064
+ * A program anchor's tween-timing overlay: delivered to the player
2065
+ * LIVE (`SET_TWEEN_EDITS`, bridge protocol 8) so a retime never changes
2066
+ * the program string. Absent on a recording, and on a stored (baked)
2067
+ * program config.
2068
+ */
2069
+ tweenEdits?: readonly TimelineEdit[];
2070
+ /** Output duration in seconds (drives the carrier + SET_DURATION). */
2071
+ duration: number;
2072
+ }
2073
+ /**
2074
+ * Zoom transition shape — deterministic pure keyframes evaluated by TL.sample
2075
+ * (no stateful springs, seek stays a pure function of t). The zoom-in ramp
2076
+ * starts BEFORE the span and lands rampInOverlap into it (the camera arrives
2077
+ * just after the moment it frames); the zoom-out starts at the span's end.
2078
+ * All timing/ease constants come from the doc's zoom STYLE (zoomStyle.ts —
2079
+ * named strategy presets grounded in the measured competitor comparison).
2080
+ * Eases are `css-bezier(…)` curves
2081
+ * (@vosjs/timeline >=0.4.0 — parsed identically by host + runtime bundle).
2082
+ *
2083
+ * Legacy constant names = the DEFAULT style's values, kept for scripts/tests
2084
+ * that reason about "the default ramp" symbolically.
2085
+ */
2086
+ declare const ZOOM_RAMP_IN: number;
2087
+ declare const ZOOM_RAMP_IN_OVERLAP: number;
2088
+ declare const ZOOM_RAMP_OUT: number;
2089
+ /** Output-time gap ≤ this → pan straight to the next span (no zoom-out). */
2090
+ declare const ZOOM_CHAIN_GAP: number;
2091
+ /** Connected-zoom pan duration (compressed into short gaps). */
2092
+ declare const ZOOM_PAN: number;
2093
+ declare const ZOOM_EASE: string;
2094
+ declare const ZOOM_PAN_EASE: string;
2095
+ /**
2096
+ * Tilt transition shape (tilt spans). Fixed constants, deliberately NOT the
2097
+ * zoom style's (switching
2098
+ * "Camera style" must not silently change tilt feel; a tiltParams override
2099
+ * layer can arrive later if real use demands it). The eases are the same
2100
+ * measured css-bezier family the default zoom style uses. Unlike zoom, the
2101
+ * pose is SETTLED at span.in (the ramp starts TILT_RAMP_IN before, with no
2102
+ * overlap-into-span): tilt frames a moment, it doesn't chase content.
2103
+ */
2104
+ declare const TILT_RAMP_IN = 0.9;
2105
+ declare const TILT_RAMP_OUT = 0.8;
2106
+ /** Output-time gap ≤ this → swing straight to the next pose (no flatten between). */
2107
+ declare const TILT_CHAIN_GAP = 1.35;
2108
+ /** Connected-tilt swing duration (compressed into short gaps). */
2109
+ declare const TILT_PAN = 0.9;
2110
+ declare const TILT_EASE: string;
2111
+ declare const TILT_PAN_EASE: string;
2112
+ /**
2113
+ * Cam-move transition shape (animated cam layouts). Own constants,
2114
+ * deliberately NOT the zoom style's or tilt's (switching another
2115
+ * subsystem's personality must never silently change the bubble's feel). The
2116
+ * premium band for an in-video layout morph is 0.6–1.0s (Descript's Smart
2117
+ * Transition defaults to 0.8s); the bubble is small chrome, so it sits at the
2118
+ * fast end. Like tilt, the pose is SETTLED at span.in (the ramp starts before,
2119
+ * no overlap-into-span): a cam move frames what follows.
2120
+ */
2121
+ declare const CAM_RAMP_IN = 0.65;
2122
+ declare const CAM_RAMP_OUT = 0.65;
2123
+ /** Output-time gap ≤ this → morph straight to the next pose (no return to rest). */
2124
+ declare const CAM_CHAIN_GAP = 1.2;
2125
+ /** Connected-move morph duration (compressed into short gaps). */
2126
+ declare const CAM_PAN = 0.7;
2127
+ declare const CAM_EASE: string;
2128
+ declare const CAM_PAN_EASE: string;
2129
+ /**
2130
+ * The doc's kept spans with speed spans applied — the OUTPUT-time truth every
2131
+ * downstream consumer evaluates (mapTime in ON_FRAME, duration, zoom remap,
2132
+ * the export's audio splice). An empty segment list means "untrimmed", so
2133
+ * speed spans still apply over one synthesized full-source segment.
2134
+ */
2135
+ declare function ratedSegments(doc: StudioDoc): Segment[];
2136
+ /**
2137
+ * Map a SOURCE-time span onto the output timeline through the RATED segment
2138
+ * list: the output extent of its KEPT footage (a partially-cut span snaps its
2139
+ * edges into kept footage; a fully-cut span returns null — it follows its
2140
+ * footage, like every source-anchored feature). Rate-aware: output positions
2141
+ * accumulate each piece's (out − in) / rate.
2142
+ */
2143
+ declare function spanOutputExtent(segments: Segment[], sIn: number, sOut: number): {
2144
+ start: number;
2145
+ end: number;
2146
+ } | null;
2147
+ /**
2148
+ * Expand the doc's source-anchored zoom spans into a standard @vosjs/timeline
2149
+ * keyframe track in OUTPUT time (values are [level, cx, cy] vectors):
2150
+ *
2151
+ * rest ──ramp-in──▶ [level,cx,cy] ──hold──▶ span end ──ramp-out──▶ rest
2152
+ *
2153
+ * with one twist: when the output gap to the NEXT span is ≤ ZOOM_CHAIN_GAP,
2154
+ * the camera never returns to rest — it pans straight to the next span's
2155
+ * state over ZOOM_PAN and holds it through the gap (OpenScreen's connected
2156
+ * zooms: the camera glides from focus to focus). Transitions run in output
2157
+ * time, so they never straddle a cut; keyframe times are strictly increasing
2158
+ * (dense spans compress rather than reorder).
2159
+ */
2160
+ /** A span enriched by the lowering with baked cursor-follow recenters. */
2161
+ interface LoweredZoomSpan extends ZoomSpan {
2162
+ followEvents?: FollowEvent[];
2163
+ }
2164
+ declare function zoomTrackFromDoc(zoom: LoweredZoomSpan[], segments: Segment[], style?: ZoomStyleParams): KeyframeTrack<number[]>;
2165
+ /**
2166
+ * Expand the doc's source-anchored tilt spans into an OUTPUT-time keyframe
2167
+ * track of [rx, ry] DEGREES (ON_FRAME converts to radians at the mesh):
2168
+ *
2169
+ * flat ──ramp-in──▶ [rx,ry] ──hold──▶ span end ──ramp-out──▶ flat
2170
+ *
2171
+ * Rest is FLAT: there is no static card pose to return to (decided
2172
+ * 2026-08-03 — a lean is a moment on the timeline), which makes this the
2173
+ * exact analog of zoom's level-1 rest. Deliberate
2174
+ * differences from zoomTrackFromDoc: the pose is SETTLED at
2175
+ * span.in (ramp starts TILT_RAMP_IN before, no overlap-into-span), there are
2176
+ * no follow events, and ramps are fixed constants rather than the zoom
2177
+ * style's. Spans ≤ TILT_CHAIN_GAP apart in output time swing pose-to-pose
2178
+ * without flattening between (the connected-zoom rule). Transitions run in
2179
+ * output time so they never straddle a cut; keyframe times are strictly
2180
+ * increasing (dense spans compress rather than reorder).
2181
+ */
2182
+ declare function tiltTrackFromDoc(tilt: TiltSpan[], segments: Segment[], motion?: {
2183
+ rampIn?: number;
2184
+ rampOut?: number;
2185
+ chainGap?: number;
2186
+ pan?: number;
2187
+ }): KeyframeTrack<number[]>;
2188
+ /**
2189
+ * The bubble's rest pose as [x, y, size] frame fractions, resolved through the
2190
+ * SAME oracle the picking layer uses (camBubbleRect) so the corner math can
2191
+ * never fork a third way (draw / pick / lowering). Fractions are resolution-
2192
+ * stable at a fixed aspect: the margin (24·s) and diameter (size·H) both
2193
+ * scale with s = H/1080, so the fraction depends only on the aspect ratio.
2194
+ */
2195
+ declare function camRestPose(cam: CamStyle, W: number, H?: number): number[];
2196
+ /**
2197
+ * Expand the doc's source-anchored cam pose spans into an OUTPUT-time keyframe
2198
+ * track of [x, y, size] frame fractions (the third consumer of the
2199
+ * span→track seam):
2200
+ *
2201
+ * rest ──ramp-in──▶ [x,y,size] ──hold──▶ span end ──ramp-out──▶ rest
2202
+ *
2203
+ * Rest is the doc's cam style resolved to fractions (camRestPose) — doc.cam IS
2204
+ * the rest pose, exactly as tilt's rest is flat. The pose is SETTLED at
2205
+ * span.in (ramp starts CAM_RAMP_IN before): a cam move frames what follows.
2206
+ * Spans ≤ CAM_CHAIN_GAP apart in output time morph pose-to-pose without
2207
+ * returning to rest (the connected-zoom rule). Absent pose fields inherit the
2208
+ * rest pose. Transitions run in output time so they never straddle a cut.
2209
+ */
2210
+ declare function camTrackFromDoc(cam: CamStyle, spans: CamPoseSpan[], segments: Segment[], W: number, H?: number): KeyframeTrack<number[]>;
2211
+ /**
2212
+ * Pose fractions → the bubble square, mirroring ON_FRAME's pose branch (the
2213
+ * 40px floor and the rounded radius ride s, exactly like the static path).
2214
+ */
2215
+ declare function camRectFromPose(pose: readonly number[], cam: CamStyle, W: number, H?: number): CamBubbleRect;
2216
+ /**
2217
+ * The bubble rect at OUTPUT time t — the time-aware picking oracle.
2218
+ * With no motion spans it is exactly camBubbleRect at the doc's design layout;
2219
+ * with spans it samples the SAME track the lowering ships, so picking can
2220
+ * never drift from the paint (camDraw.test.ts pins both paths). Design space
2221
+ * is docCardLayout's (H = 1080, W from the output aspect).
2222
+ */
2223
+ declare function camBubbleRectAt(doc: ProjectDoc, t: number): CamBubbleRect;
2224
+ /** A resolved pose keyframe: clip-local time + full value vector. */
2225
+ interface MotionKey {
2226
+ at: number;
2227
+ value: number[];
2228
+ ease?: string;
2229
+ }
2230
+ /**
2231
+ * Bake resolved pose keyframes into a CLIP-LOCAL keyframe track.
2232
+ * The base vector holds until the first pose (a leading keyframe at 0 pins
2233
+ * it), values interpolate across each gap (ease-into per pose), and the last
2234
+ * pose holds to the clip's end (sample clamps). Same emitter, same
2235
+ * interpolator, same purity as the zoom/tilt/cam tracks.
2236
+ */
2237
+ declare function motionTrack(base: readonly number[], keys: MotionKey[], dur: number): KeyframeTrack<number[]>;
2238
+ /** An overlay clip's base vector: [x, y, scale, rotation, opacityMul]. */
2239
+ declare function overlayMotionBase(o: OverlayClip): number[];
2240
+ /**
2241
+ * Effective [x, y, scale, rotation, opacityMul] of an overlay clip at
2242
+ * CLIP-LOCAL time t — the host-side mirror of ON_FRAME's sampling (the
2243
+ * picking layer substitutes it into the clip's transform so hit rects track
2244
+ * the animated element). Null = the clip has no motion.
2245
+ */
2246
+ declare function overlayMotionPoseAt(o: OverlayClip, t: number): number[] | null;
2247
+ /** An object clip's base vector: [x, y, z, rx, ry, rz, scale]. */
2248
+ declare function objectMotionBase(o: ObjectClip): number[];
2249
+ /**
2250
+ * Effective [x, y, z, rx, ry, rz, scale] of an object clip at CLIP-LOCAL
2251
+ * time t (from the span start; 0 when span-less over `clipDur`). Null = the
2252
+ * clip has no motion. The 3D mirror of overlayMotionPoseAt.
2253
+ */
2254
+ declare function objectMotionPoseAt(o: ObjectClip, t: number, clipDur: number): number[] | null;
2255
+ /**
2256
+ * The shared layers as the studio entry's data: overlay clips (presets resolved
2257
+ * to plain values HERE, ON_FRAME reads no registry), 3D props (numbers resolved
2258
+ * HERE), the extra font faces SETUP awaits. Every key is omitted when its layer
2259
+ * is absent — data byte parity for docs that never touched it. Both anchors
2260
+ * call this with their own output duration.
2261
+ */
2262
+ declare function studioLayerData(layers: {
2263
+ overlays?: OverlayClip[];
2264
+ objects?: ObjectClip[];
2265
+ audio?: AudioClip[];
2266
+ }, duration: number): Record<string, unknown>;
2267
+ declare function lowerToComposition(doc: ProjectDoc): LoweredComposition;
2268
+
2269
+ /**
2270
+ * ONE lowering for the document family: the anchor's program
2271
+ * plus the studio stack entry, on every anchor.
2272
+ *
2273
+ * - A recording lowers to its card program (`lowerToComposition`), which
2274
+ * already carries the entry.
2275
+ * - A program lowers to the user's config, COMPLETE and untouched (the
2276
+ * execution IR, D1) with its tween-timing overlay baked into
2277
+ * `createTimeline`, plus the same entry carrying the shared layers. Params
2278
+ * and Looks ride the config as authored. `retime` arrives with speed spans
2279
+ * absent, the engine's identity is the identity.
2280
+ *
2281
+ * The composed config is what the platform stores for a layered program and
2282
+ * what the fleet compiles; the player runs it MINUS every data object (the
2283
+ * structural hash), with `data` and `stack` delivered live.
2284
+ */
2285
+ declare function lowerStudioDoc(doc: StudioDoc, opts?: LowerProgramOptions): LoweredComposition;
2286
+ interface LowerProgramOptions {
2287
+ /**
2288
+ * Bake the tween overlay into `createTimeline` (the STORED composed
2289
+ * config: the fleet, the watch page and `vos render` have no bridge to
2290
+ * hand an overlay to). Off by default: the player runs the user's
2291
+ * timeline and retimes it live, so the program string is constant
2292
+ * across every timing edit.
2293
+ */
2294
+ bake?: boolean;
2295
+ }
2296
+
2297
+ /**
2298
+ * `config.retime` for a program with speed spans: output time →
2299
+ * program time through the RATED segments on `data.retime` (the same map
2300
+ * `mapTime` performs; inlined so the config stays self-contained, tested
2301
+ * against it). Reads data live, so a rate edit is SET_DATA, never a LOAD.
2302
+ */
2303
+ declare const PROGRAM_RETIME = "(t, data) => {\n var s = data && data.retime\n if (!s || !s.length) return t\n var acc = 0\n for (var i = 0; i < s.length; i++) {\n var r = s[i].rate && s[i].rate > 0 ? s[i].rate : 1\n var d = (s[i].out - s[i].in) / r\n if (t < acc + d) return s[i].in + (t - acc) * r\n acc += d\n }\n return s[s.length - 1].out\n}";
2304
+ /**
2305
+ * With speed spans the OUTPUT length is not the program's own, but the
2306
+ * engine hands ONE \`duration\` to both the clock and \`createTimeline\`. The
2307
+ * composed config's \`duration\` is the output length (the clock, the fleet's
2308
+ * render length); this wrapper hands the user's function the program's own
2309
+ * length from \`data.programDuration\` — data, so a rate edit stays live.
2310
+ */
2311
+ declare function wrapProgramLength(source: string): string;
2312
+ declare function lowerProgramDoc(doc: ProgramAnchorDoc, opts?: LowerProgramOptions): LoweredComposition;
2313
+
2314
+ /**
2315
+ * A destination is where a release's media goes — the output twin of the
2316
+ * doors registry's "a door is what you bring". One row per channel asset,
2317
+ * derived from the verified channel specs; `vos deliver` loops these and
2318
+ * the kit manifest records them.
2319
+ */
2320
+ interface Destination {
2321
+ /** `${channel}-${asset}` — the id `vos deliver --to` and kit.json use. */
2322
+ id: string;
2323
+ channel: string;
2324
+ asset: string;
2325
+ label: string;
2326
+ kind: 'video' | 'still' | 'still-set';
2327
+ /** Reduced aspect ratio, the exportSizeFor convention. */
2328
+ ratio: string;
2329
+ px: {
2330
+ w: number;
2331
+ h: number;
2332
+ };
2333
+ /** still-set only: how many the channel takes. */
2334
+ count?: {
2335
+ min: number;
2336
+ max: number;
2337
+ };
2338
+ /**
2339
+ * Image genre: 'screenshot' = real UI from the take (store policy demands
2340
+ * real UX); 'card' = a COMPOSED cover — rendered from the maker's poster
2341
+ * program when `vos deliver --poster` has one.
2342
+ */
2343
+ genre?: 'screenshot' | 'card';
2344
+ minSeconds?: number;
2345
+ maxSeconds?: number;
2346
+ maxBytes?: number;
2347
+ /** The format the kit renders. */
2348
+ format: 'mp4' | 'png';
2349
+ /** What the channel accepts, in the spec's own words. */
2350
+ accepts: string;
2351
+ /** How footage meets an off-ratio frame (a still fills, a video letterboxes). */
2352
+ fit: 'contain' | 'cover';
2353
+ notes: string;
2354
+ }
2355
+ declare const CHANNEL_SPECS_VERIFIED = "2026-08-04";
2356
+ declare const CHANNEL_SPECS_HASH = "7a3ad4301f96507ec471a35fa37f367dcaf4bd405b008f551d0ac4cd395d61f7";
2357
+ declare const DESTINATIONS: Destination[];
2358
+ declare function destinationById(id: string): Destination | undefined;
2359
+ declare function destinationsForChannel(channel: string): Destination[];
2360
+
2361
+ /**
2362
+ * The studio's program: the SHARED layers (text/image/video overlay clips, the
2363
+ * 3D prop pool) as ONE engine stack entry (`config.stack`, @vosjs/core ≥0.21)
2364
+ * that runs after the anchor's program on the same ctx — same scene, camera,
2365
+ * overlayScene, renderer, master clock — with its OWN `ctx.data` and its own
2366
+ * error boundary. The same entry rides every anchor: a recording's card
2367
+ * program and a user's own config alike.
2368
+ *
2369
+ * Everything here is CONSTANT text: a layer edit is `SET_DATA { target }` on
2370
+ * this entry, never a program change (the liveEdit invariant). The paint code
2371
+ * is the take editor's compositor, moved out of its main program unchanged;
2372
+ * it reads only what an entry is given — the renderer size, the output
2373
+ * clock (`ctx.time` on an entry IS the output time), the shared
2374
+ * `window.__vos__` caches and `globalThis.__vosTimeline` — never the anchor's
2375
+ * card geometry.
2376
+ *
2377
+ * The overlay layer mounts in `ctx.overlayScene` (the engine's 2D group,
2378
+ * rendered after every 3D group under the ortho `overlayCamera`), sized to that
2379
+ * camera's bounds, so it fills the frame on any anchor whatever its camera.
2380
+ * Props mount in `ctx.scene` at renderOrder 1.5 (between a recording's card and
2381
+ * its cam bubble) on the ANCHOR's camera: a perspective camera anywhere, or an
2382
+ * orthographic one (a program's `fullscreen` preset), where the prop group
2383
+ * carries the camera pose and a pixel-aspect squash. Lights: the entry adds
2384
+ * its pair when its data says `lights` (the recording anchor), and lazily,
2385
+ * once, when a program's scene turns out to have none (a shader program).
2386
+ */
2387
+ declare const STUDIO_ENTRY_ID = "vosso.studio";
2388
+ interface StudioEntry {
2389
+ id: string;
2390
+ data: Record<string, unknown>;
2391
+ setup: string;
2392
+ createContent: string;
2393
+ onFrame: string;
2394
+ }
2395
+ declare function studioEntry(data: Record<string, unknown>): StudioEntry;
2396
+
2397
+ interface EnvelopePoint {
2398
+ /** output-timeline seconds. */
2399
+ t: number;
2400
+ /** linear gain 0..1. */
2401
+ g: number;
2402
+ }
2403
+ declare function clipEnvelope(clip: Pick<AudioClip, 'start' | 'in' | 'out' | 'gain' | 'fadeIn' | 'fadeOut' | 'loop' | 'loopLen'>): EnvelopePoint[];
2404
+ /** Envelope value at output time `t` (linear interpolation; 0 outside the clip). */
2405
+ declare function envelopeValueAt(env: EnvelopePoint[], t: number): number;
2406
+
2407
+ /**
2408
+ * The studio's audio clips as an ENGINE audio plan:
2409
+ * the shape `@vosjs/core/audio`'s `mixAudio` renders. One builder for every
2410
+ * export path (the device exporter, the fleet's audio page) from the same
2411
+ * lowered data the preview scheduler plays, so what you hear is what exports.
2412
+ *
2413
+ * A clip is OUTPUT-anchored: it plays from `start` for `len` seconds, reading
2414
+ * the source from `in` (looping over `[in, out]` when `loop`), at its gain
2415
+ * envelope (`env`, absolute output seconds, fades included) times the duck
2416
+ * curve when it ducks. The plan samples that at `step` (240/s, the engine's
2417
+ * default); the mixer interpolates between points and treats the loop's
2418
+ * wrap as a seek.
2419
+ *
2420
+ * Structurally typed: the lowered clip, not the doc — this runs on the fleet
2421
+ * from stored config data as well as in the studio.
2422
+ */
2423
+ interface LoweredAudioClip {
2424
+ key: string;
2425
+ start: number;
2426
+ in: number;
2427
+ out: number;
2428
+ gain: number;
2429
+ loop: boolean;
2430
+ len: number;
2431
+ duck: boolean;
2432
+ env: EnvelopePoint[];
2433
+ }
2434
+ interface AudioPlanPoint {
2435
+ t: number;
2436
+ on: boolean;
2437
+ pos: number;
2438
+ gain: number;
2439
+ }
2440
+ interface AudioPlanTrack {
2441
+ id: string;
2442
+ src: string;
2443
+ loop: boolean;
2444
+ points: AudioPlanPoint[];
2445
+ }
2446
+ interface StudioAudioPlan {
2447
+ duration: number;
2448
+ step: number;
2449
+ tracks: AudioPlanTrack[];
2450
+ }
2451
+ declare const AUDIO_PLAN_STEP: number;
2452
+ /** Linear interpolation over absolute-time envelope points; `def` outside an empty one. */
2453
+ declare function envelopeAt(env: readonly EnvelopePoint[], t: number, def: number): number;
2454
+ declare function studioAudioPlan(clips: readonly LoweredAudioClip[], duckEnv: readonly EnvelopePoint[], duration: number, step?: number): StudioAudioPlan;
2455
+
2456
+ /**
2457
+ * Compositor v2 — the layer stage geometry.
2458
+ *
2459
+ * The stage turns the studio's single fullscreen-ortho quad into a three-layer mesh stack
2460
+ * under ONE perspective camera:
2461
+ *
2462
+ * overlay quad screen-space cam bubble + text/image/video overlays
2463
+ * card mesh world-space the existing 2D card painting, on a plane
2464
+ * that can TILT (doc.tilt spans)
2465
+ * background quad screen-space CSS fill + vos background loop
2466
+ *
2467
+ * Every layer is a plane placed perpendicular to the camera axis and centered
2468
+ * on it, sized to exactly fill the camera frustum at its depth. Perpendicular +
2469
+ * centered + frustum-filling ⇒ it projects to the full viewport regardless of
2470
+ * depth, so the background/overlay read as flat screen-space and the CARD, at
2471
+ * `tilt = 0`, projects PIXEL-IDENTICALLY to today's ortho fullscreen quad. Only
2472
+ * the card ever rotates; the perspective camera then gives it real
2473
+ * foreshortening (an ortho camera would only skew it).
2474
+ *
2475
+ * These are pure helpers (no THREE dependency) so the host can mirror the exact
2476
+ * projection the runtime draws with — the world-unit basis that
2477
+ * on-canvas picking builds on (host picks / instance renders). `stage.test.ts`
2478
+ * pins the math; the runtime (lowerToComposition CREATE_CONTENT/ON_FRAME) must
2479
+ * use these SAME constants — change them together.
2480
+ */
2481
+ /**
2482
+ * Camera field of view (degrees). Deliberately gentle (telephoto-ish product
2483
+ * shot) so a card tilt reads as a premium 3D lean, not a fisheye warp. Parity
2484
+ * at tilt = 0 is INDEPENDENT of this value (every layer is sized to fill the
2485
+ * frustum), so it is a pure aesthetic dial for how dramatic tilt looks.
2486
+ */
2487
+ declare const CARD_FOV = 30;
2488
+ /**
2489
+ * Layer depths (world units in front of a camera at the origin looking down
2490
+ * −z). Absolute values are arbitrary — only the ORDER matters (painter's order
2491
+ * is set by renderOrder, not depth) and that near/far bracket them. The card
2492
+ * sits between the background (behind) and the overlay (in front).
2493
+ */
2494
+ declare const OVERLAY_Z = -2;
2495
+ declare const CARD_Z = -4;
2496
+ declare const BACKGROUND_Z = -6;
2497
+ declare const CAMERA_NEAR = 0.1;
2498
+ declare const CAMERA_FAR = 100;
2499
+ interface PlaneSize {
2500
+ width: number;
2501
+ height: number;
2502
+ }
2503
+ /**
2504
+ * The world-space size of a plane that exactly fills a perspective camera's
2505
+ * frustum at `|distance|` in front of it. Height subtends the full vertical FOV;
2506
+ * width follows the viewport aspect. This is the one sizing primitive the whole
2507
+ * stack shares — every layer plane, and the host-side projection basis for
2508
+ * picking, derive from it.
2509
+ */
2510
+ declare function planeSizeAtDepth(distance: number, fovDeg: number, aspect: number): PlaneSize;
2511
+ /**
2512
+ * Project a point on the (untilted) card plane, given in normalized card-canvas
2513
+ * coordinates (u, v ∈ [0,1], v measured from the TOP like a canvas), to
2514
+ * normalized screen coordinates (sx, sy ∈ [0,1], sy from the top). At tilt = 0
2515
+ * this is the identity — the card fills the viewport — so it is exact for the
2516
+ * common case and the basis the tilt/camera matrices extend for
2517
+ * on-canvas picking. Kept here so host and runtime never disagree on
2518
+ * where the card is.
2519
+ */
2520
+ declare function cardPointToScreen(u: number, v: number): {
2521
+ sx: number;
2522
+ sy: number;
2523
+ };
2524
+
2525
+ /** Baked pill geometry: design px at the clip's resolved font size. */
2526
+ interface ResolvedOverlayBox {
2527
+ color: string;
2528
+ opacity: number;
2529
+ /** Paddings/radius in design px (em multiples × resolved size). */
2530
+ padX: number;
2531
+ padY: number;
2532
+ radius: number;
2533
+ }
2534
+ /**
2535
+ * Resolve a clip's background pill (null when absent). Mirrored by
2536
+ * `overlayRect`'s inflation and ON_FRAME's pill draw — change together.
2537
+ */
2538
+ declare function resolveOverlayBox(clip: TextOverlayClip): ResolvedOverlayBox | null;
2539
+ /** A preset's base values (the 5-field house style). */
2540
+ interface OverlayPresetStyle {
2541
+ /** Full CSS font-family stack (primary + fallbacks). */
2542
+ stack: string;
2543
+ weight: number;
2544
+ /** Font size in design px (H = 1080 space), before transform.scale. */
2545
+ size: number;
2546
+ color: string;
2547
+ /** Legibility shadow strength 0..1 (0 = none). */
2548
+ shadow: number;
2549
+ }
2550
+ /** Preset base + the full override surface, resolved to concrete values. */
2551
+ interface ResolvedOverlayStyle extends OverlayPresetStyle {
2552
+ fontStyle: 'normal' | 'italic';
2553
+ align: 'left' | 'center' | 'right';
2554
+ /** Design px at the resolved size. */
2555
+ letterSpacing: number;
2556
+ /** Multiplier (default OVERLAY_LINE_HEIGHT). */
2557
+ lineHeight: number;
2558
+ stroke: TextOverlayStroke | null;
2559
+ }
2560
+ /** The house text styles. Sizes in design px; colors are ink-on-footage. */
2561
+ declare const TEXT_PRESETS: Record<TextOverlayPreset, OverlayPresetStyle>;
2562
+ /** Size override bounds (design px) — same range the inspector slider offers. */
2563
+ declare const OVERLAY_SIZE_MIN = 12;
2564
+ declare const OVERLAY_SIZE_MAX = 200;
2565
+ /**
2566
+ * woff2 faces SETUP preloads when the doc has overlays (latin subset only —
2567
+ * overlay text is product UI copy). URLs are the self-hosted catalog on
2568
+ * assets.vos.so; studio-core stays dependency-free, so the three base faces
2569
+ * are literals — keep them within the catalog `@vosjs/shared` hosts.
2570
+ */
2571
+ declare const OVERLAY_FONT_FACES: {
2572
+ family: string;
2573
+ weight: number;
2574
+ url: string;
2575
+ }[];
2576
+ /** Preset + per-clip overrides → the concrete style baked into ctx.data. */
2577
+ declare function resolveOverlayStyle(clip: TextOverlayClip): ResolvedOverlayStyle;
2578
+ interface OverlayFontFace {
2579
+ family: string;
2580
+ weight: number;
2581
+ url: string;
2582
+ }
2583
+ /**
2584
+ * The hosted face a clip's family/weight overrides resolve to, when it is
2585
+ * NOT one of the three base preset faces (null otherwise — parity: preset
2586
+ * clips carry nothing). Baked per-overlay so ON_FRAME can lazy-load it on a
2587
+ * live style edit (SET_DATA never re-runs SETUP); SETUP awaits the full list
2588
+ * from ctx.data on cold load, which is what export parity rides on.
2589
+ */
2590
+ declare function overlayFaceFor(clip: TextOverlayClip): OverlayFontFace | null;
2591
+ /**
2592
+ * Every woff2 face a doc's overlays need (SETUP await on cold load, and the
2593
+ * host document for measurement): the three base preset faces — ALWAYS, byte
2594
+ * parity for preset-only docs — plus one face per override.
2595
+ */
2596
+ declare function overlayFontFaces(doc: Pick<ProjectDoc, 'overlays'>): OverlayFontFace[];
2597
+ declare function overlayLines(text: string): string[];
2598
+ /**
2599
+ * The canvas font string at a given comp scale — MIRRORS ON_FRAME's
2600
+ * `olW + ' ' + olPx + 'px ' + olStack` (pinned by test).
2601
+ */
2602
+ declare function overlayFontString(style: ResolvedOverlayStyle, scale: number, s: number): string;
2603
+ interface OverlayRect {
2604
+ /** Center-anchored box in DESIGN px (pre-rotation). */
2605
+ cx: number;
2606
+ cy: number;
2607
+ w: number;
2608
+ h: number;
2609
+ /** Rotation in degrees (the caller rotates points into local space to hit-test). */
2610
+ rotation: number;
2611
+ }
2612
+ /**
2613
+ * The drawn bounding box of a text overlay in DESIGN px — the picking
2614
+ * geometry. `measure(text, font)` returns the text width in px for a font
2615
+ * string (the host passes a scratch-canvas measureText; tests stub it).
2616
+ * `frameW`/`frameH` are the design frame size (docCardLayout's W/H) — the
2617
+ * clip's transform.x/y are FRACTIONS of the frame, so the anchor is
2618
+ * x·frameW / y·frameH. Measured at s = 1 (design space), so the result maps
2619
+ * to the canvas by ·s and to CSS by the player's display scale.
2620
+ */
2621
+ declare function overlayRect(clip: OverlayClip, measure: (text: string, font: string, letterSpacingPx?: number) => number, frameW: number, frameH?: number,
2622
+ /** Media kinds: natural aspect (w/h) once known — null/absent = assume 16:9. */
2623
+ mediaAspect?: number | null): OverlayRect;
2624
+ /** Point-in-overlay test (design px), rotation-aware (point → local space). */
2625
+ declare function overlayHit(rect: OverlayRect, px: number, py: number, padPx?: number): boolean;
2626
+
2627
+ declare const TEXT3D_DEPTH_DEFAULT = 0.25;
2628
+ declare const TEXT3D_DEPTH_MIN = 0.02;
2629
+ declare const TEXT3D_DEPTH_MAX = 1;
2630
+ interface BakedText3dMaterial {
2631
+ /** THREE constructor family: MeshStandardMaterial | MeshPhysicalMaterial. */
2632
+ type: 'standard' | 'physical';
2633
+ params: Record<string, unknown>;
2634
+ }
2635
+ interface BakedText3dAsset {
2636
+ kind: 'text3d';
2637
+ text: string;
2638
+ /** Resolved typeface JSON URL (assets.vos.so — the fleet's one origin). */
2639
+ url: string;
2640
+ /** Extrusion depth as a fraction of the glyph height. */
2641
+ depth: number;
2642
+ bevel: boolean;
2643
+ mat: BakedText3dMaterial;
2644
+ }
2645
+ /** Normalize a doc text3d asset into the baked payload (pure, deterministic). */
2646
+ declare function resolveText3dAsset(asset: Extract<ObjectAsset, {
2647
+ kind: 'text3d';
2648
+ }>): BakedText3dAsset;
2649
+
2650
+ /**
2651
+ * Idle cursor fade — the dwell detector behind `CursorStyle.hideWhenIdle`.
2652
+ *
2653
+ * A parked cursor is the most common blemish in screen footage: the dot sits in
2654
+ * frame through every scroll, every typing passage, every pause, drawing the eye
2655
+ * to nothing. This bakes a sparse opacity curve so ON_FRAME can fade it out
2656
+ * during dwells and bring it back the moment the cursor moves again.
2657
+ *
2658
+ * Pure and deterministic — seek must stay a pure function of `t`, so there are
2659
+ * no springs and no state. Output is SOURCE-anchored (like the cursor samples
2660
+ * themselves), which is what makes trims, cuts and speed spans inherit the fade
2661
+ * from one seam.
2662
+ *
2663
+ * Detection runs on the RAW track, not the smoothed path. The smoothed path is
2664
+ * resampled at a fixed cadence and linearly interpolates across gaps, so during
2665
+ * a park it creeps toward wherever the cursor goes next — ground truth for "the
2666
+ * user isn't moving" is the absence of raw events. The smoothing settle after
2667
+ * the last real move is bounded and well under `CURSOR_IDLE_HOLD`, so the dot
2668
+ * is always at rest before the fade begins.
2669
+ *
2670
+ * Two things deliberately do NOT count as movement:
2671
+ *
2672
+ * - **Scrolling.** `scroll` events re-emit the last known position, so a reading
2673
+ * pause looks "active" if you detect idleness from sample gaps. They carry no
2674
+ * real cursor motion and are ignored here, so a long scroll correctly fades
2675
+ * the cursor away — it isn't doing anything.
2676
+ * - **Focus jumps and typing.** `focus`/`key` events synthesize a position at
2677
+ * the focused element's centre, which the cursor never visited — and during
2678
+ * typing the caret is the actor, so the parked dot SHOULD fade.
2679
+ *
2680
+ * Clicks DO break a dwell: a press is not idle. The window before a click ends
2681
+ * `CURSOR_IDLE_FADE_IN` early so the dot is back at full opacity when the ring
2682
+ * blooms under it, rather than ghosting in behind its own click effect.
2683
+ */
2684
+
2685
+ /** Seconds of stillness before the cursor starts fading out. */
2686
+ declare const CURSOR_IDLE_HOLD = 1;
2687
+ /** Fade-out ramp, seconds. */
2688
+ declare const CURSOR_IDLE_FADE_OUT = 0.35;
2689
+ /** Fade-in ramp, seconds. Snappier than the way out — motion draws the eye. */
2690
+ declare const CURSOR_IDLE_FADE_IN = 0.18;
2691
+ /**
2692
+ * Movement epsilon as a fraction of the capture's short edge, floored at 2px
2693
+ * (the recorder's own distance gate). Relative so a 4K take isn't held to a
2694
+ * 1080p take's pixel budget.
2695
+ */
2696
+ declare const CURSOR_IDLE_EPS_FRAC = 0.0025;
2697
+ interface CursorIdleOptions {
2698
+ /** Capture space, for the movement epsilon. */
2699
+ space: {
2700
+ w: number;
2701
+ h: number;
2702
+ };
2703
+ /** SOURCE seconds; the trailing dwell runs to here. */
2704
+ sourceDuration: number;
2705
+ }
2706
+ /** One point on the baked opacity curve. SOURCE seconds → alpha 0..1. */
2707
+ interface CursorFadeKey {
2708
+ t: number;
2709
+ a: number;
2710
+ }
2711
+ /**
2712
+ * Bake the opacity curve for a cursor track. Returns an empty array when
2713
+ * nothing dwells long enough to be worth hiding — callers then emit no
2714
+ * `cursorFade` key at all, so a take with a busy cursor lowers byte-identically
2715
+ * to the pre-feature lowering.
2716
+ */
2717
+ declare function cursorIdleFade(track: CursorTrack, o: CursorIdleOptions): CursorFadeKey[];
2718
+
2719
+ /** Anticipation lead — effects start this many output seconds before the click. */
2720
+ declare const CLICK_FX_PRE = 0.06;
2721
+ /** Base effect durations in output seconds (× the intensity's `dur`). */
2722
+ declare const CLICK_RIPPLE_DUR = 0.45;
2723
+ declare const CLICK_PULSE_DUR = 0.35;
2724
+ declare const CLICK_HIGHLIGHT_FADE = 0.35;
2725
+ /** Synthetic press length when the matching `up` is missing (nav killed it). */
2726
+ declare const CLICK_SYNTH_RELEASE = 0.12;
2727
+ /** A down→up pair longer than this is treated as unmatched (lost `up`). */
2728
+ declare const CLICK_PAIR_MAX = 10;
2729
+ /** Highlight uses the element rect only when it covers ≤ this viewport fraction. */
2730
+ declare const CLICK_RECT_MAX_FRAC = 0.35;
2731
+ interface LoweredClick {
2732
+ /** OUTPUT seconds of mousedown. */
2733
+ ot: number;
2734
+ /** OUTPUT seconds of release (real up, clamped into kept footage). */
2735
+ up: number;
2736
+ /** SOURCE seconds of mousedown — ON_FRAME's cross-cut proximity guard. */
2737
+ st: number;
2738
+ /** click point in cursorSpace px. */
2739
+ x: number;
2740
+ y: number;
2741
+ /** pointer button (0=left). */
2742
+ b: number;
2743
+ /** element rect [x,y,w,h] in cursorSpace px — present only when the
2744
+ * highlight style wants it AND it passed the size/containment gates
2745
+ * (ON_FRAME stays branch-light: r present = draw highlight). */
2746
+ r?: [number, number, number, number];
2747
+ }
2748
+ interface ExtractClickOptions {
2749
+ /** attach gated element rects (highlight style). */
2750
+ rects?: boolean;
2751
+ /** cursorSpace dims — the rect-size gate's denominator. */
2752
+ space: {
2753
+ w: number;
2754
+ h: number;
2755
+ };
2756
+ }
2757
+ /**
2758
+ * Extract OUTPUT-anchored clicks from a raw cursor track. Downs in trimmed-away
2759
+ * footage are dropped (they follow their footage, like every source-anchored
2760
+ * feature); a press pairs with the next `up` of the same button unless another
2761
+ * `down` of that button intervenes (a lost `up` must not chain two presses).
2762
+ */
2763
+ declare function extractClicks(track: CursorTrack, segments: Segment[], opts: ExtractClickOptions): LoweredClick[];
2764
+ /** '#rgb'/'#rrggbb' → [r,g,b] for ctx.data (ON_FRAME composes rgba() per frame). */
2765
+ declare function hexToRgbTriplet(hex: string): [number, number, number] | null;
2766
+
2767
+ /** The doc's segments in canonical explicit form (empty = one full-source span). */
2768
+ declare function effectiveSegments(doc: StudioDoc): Segment[];
2769
+ declare const videoLane: LaneAdapter<ProjectDoc>;
2770
+ /**
2771
+ * Zoom lane — zoom regions as clips ("1.80×"). Spans are SOURCE-anchored
2772
+ * (footage-anchored like speed spans and the cam window); the lane displays
2773
+ * the output extent of each span's KEPT footage (spanOutputExtent — partial
2774
+ * cuts snap the clip's edges, full cuts hide it until the trim is undone).
2775
+ * Move/resize are pointer-true through the FULL rated map — zoom never alters
2776
+ * rates, so no exclusion trick is needed (unlike speedLane). Spans never
2777
+ * overlap: create no-ops inside an existing span, move pushes out of
2778
+ * collisions (or no-ops), resize clamps against neighbors. Level/focus are
2779
+ * edited in the toolbar/inspector, not by gesture. Any gesture promotes the
2780
+ * span to source:'manual' — it survives an auto-zoom regenerate.
2781
+ */
2782
+ declare const zoomLane: LaneAdapter<ProjectDoc>;
2783
+ /**
2784
+ * Tilt lane — card-pose regions as clips (label = "rx°/ry°"). SOURCE-anchored
2785
+ * like zoom spans (footage-anchored through trims and speed changes; the full
2786
+ * rated map applies — tilt doesn't alter rates); non-overlapping. The pose
2787
+ * itself is edited in the span editor (like zoom level), not by gesture. Any
2788
+ * gesture promotes the span to source:'manual' — it survives a Dynamic-tilt
2789
+ * regenerate (the auto-zoom wand contract).
2790
+ */
2791
+ declare const tiltLane: LaneAdapter<ProjectDoc>;
2792
+ /**
2793
+ * Cam-move lane — animated cam layout regions as clips (label = the
2794
+ * pose size as a percent when set). SOURCE-anchored like tilt spans (the
2795
+ * full rated map applies); non-overlapping. The pose itself is edited on the
2796
+ * canvas or in the span editor, never by lane gesture. Structurally the tilt
2797
+ * lane with a different payload; kept separate so neither lane's clamps can
2798
+ * drift the other's.
2799
+ */
2800
+ declare const camMoveLane: LaneAdapter<ProjectDoc>;
2801
+ /**
2802
+ * Webcam lane — the Cam member row of the take group. Its clips are the
2803
+ * visibility window INTERSECTED with each kept segment, at the video lane's
2804
+ * exact output positions, so a split on the Video row visibly splits this row
2805
+ * too. Gestures still edit only the WINDOW (`cam.window`, SOURCE time,
2806
+ * footage-anchored): move slides it (dragging any of its clips moves the one
2807
+ * window), resize lives on the window's REAL edges — the first clip's start
2808
+ * and the last clip's end; the cut boundaries between them belong to the
2809
+ * Video row. There is no remove — hide the bubble via its panel.
2810
+ */
2811
+ declare const camLane: LaneAdapter<ProjectDoc>;
2812
+ /**
2813
+ * Mic sub-row of the take group: mirrors the video lane's cut boundaries
2814
+ * EXACTLY — one clip per kept segment at the same output positions — so the
2815
+ * voice visibly cuts and splits with the footage. VIEW-ONLY by design: cutting
2816
+ * happens on the Video row, because the take has ONE shared `segments` list
2817
+ * (that is what makes sub-track desync structurally impossible; per-row
2818
+ * segments would only buy bugs). Selecting a clip opens the Voice panel
2819
+ * (level/mute); the waveform is the row's content, so items carry no label.
2820
+ */
2821
+ declare const micLane: LaneAdapter<ProjectDoc>;
2822
+ /**
2823
+ * Speed lane — rate spans as clips ("2×"). Spans are SOURCE-anchored (footage
2824
+ * follows them through trims); the lane displays them at the output positions
2825
+ * of the rated pieces they produce, so a span visually contracts as its rate
2826
+ * grows. Spans never overlap: create no-ops inside an existing span, move
2827
+ * pushes out of collisions (or no-ops), resize clamps against neighbors.
2828
+ * The rate itself is edited in the toolbar (like zoom level), not by gesture.
2829
+ * Move/resize are POINTER-TRUE: the dragged edge's resulting output position
2830
+ * is exactly the pointer's (mapped through the rate map without this span),
2831
+ * so edges never lag the pointer at 1/rate speed.
2832
+ */
2833
+ declare const speedLane: LaneAdapter<ProjectDoc>;
2834
+ /**
2835
+ * Music/SFX lane — clips are OUTPUT-anchored (`clip.start` is final-cut
2836
+ * seconds; they do NOT follow footage through trims — see AudioClip). Move
2837
+ * retimes `start`; resizing trims into the source file: the start edge shifts
2838
+ * `in` and `start` together (content stays put under the untouched edge), the
2839
+ * end edge adjusts `out`. Clips are created from the audio inspector, not by
2840
+ * double-click (there is no meaningful "blank" audio clip).
2841
+ */
2842
+ declare const audioLane: LaneAdapter<ProjectDoc>;
2843
+ declare function parsePoseId(id: string): {
2844
+ clipId: string;
2845
+ index: number;
2846
+ } | null;
2847
+ /**
2848
+ * Text-overlay lane (compositor v2) — clips are OUTPUT-anchored like audio
2849
+ * (`start` is final-cut seconds; a title never retimes with trims/speed).
2850
+ * Overlaps are allowed (two titles can coexist — z-order is array order).
2851
+ * Create adds a house 'title' clip at the playhead, centered, lower-third.
2852
+ */
2853
+ declare const overlaysLane: LaneAdapter<ProjectDoc>;
2854
+ /**
2855
+ * Object lane — world-space props. Clips show the span (objects with no
2856
+ * span render as a full-length block and don't move — the span IS the lane's
2857
+ * noun). Created from the toolbar; asset/transform edited in the inspector.
2858
+ */
2859
+ declare const objectsLane: LaneAdapter<ProjectDoc>;
2860
+
2861
+ /**
2862
+ * Waveform peaks — pure downsampling for timeline clip rendering. The host
2863
+ * decodes the file (Web Audio) and hands channel data here; the result is one
2864
+ * max-|sample| value per bucket in [0..1], drawn as symmetric bars.
2865
+ */
2866
+ declare function computePeaks(channels: Float32Array[], buckets: number): Float32Array;
2867
+
2868
+ /** SOURCE-time loudness grid (RMS per window). */
2869
+ interface MicRms {
2870
+ /** RMS value per window, linear 0..1. */
2871
+ values: Float32Array;
2872
+ /** windows per second. */
2873
+ rate: number;
2874
+ }
2875
+ interface DuckOptions {
2876
+ /** RMS above this counts as speech. */
2877
+ threshold: number;
2878
+ /** gain while ducked (≈ -12 dB). */
2879
+ duckTo: number;
2880
+ /** seconds to reach the ducked level once speech starts. */
2881
+ attack: number;
2882
+ /** seconds to recover after speech stops. */
2883
+ release: number;
2884
+ /** output grid resolution, points per second. */
2885
+ gridHz: number;
2886
+ }
2887
+ declare const DEFAULT_DUCK: DuckOptions;
2888
+ /** RMS windows from raw PCM — the host runs this once per recording (cached). */
2889
+ declare function computeMicRms(channels: Float32Array[], sampleRate: number, windowSec?: number): MicRms;
2890
+ /**
2891
+ * The OUTPUT-time duck multiplier curve: walk an output grid, look up the mic
2892
+ * loudness at the mapped SOURCE moment, and smooth engage/recover with
2893
+ * attack/release one-poles. Points are thinned (emitted on ≥1% change).
2894
+ */
2895
+ declare function duckCurve(rms: MicRms, segments: Segment[], durationSec: number, opts?: DuckOptions): EnvelopePoint[];
2896
+
2897
+ /** Rate-aware OUTPUT duration of a doc (what the viewer experiences). */
2898
+ /**
2899
+ * The OUTPUT length of either document: a recording's kept footage
2900
+ * through its speed spans, a program's own length (its `program.duration`,
2901
+ * else the config's).
2902
+ */
2903
+ declare function docOutputDuration(doc: StudioDoc): number;
2904
+ interface MusicBedInput {
2905
+ id: string;
2906
+ /** Durable URL of the track (assets.vos.so catalog or an owned asset). */
2907
+ key: string;
2908
+ name: string;
2909
+ /** Full source-file length, seconds. */
2910
+ trackDuration: number;
2911
+ /** Current output duration of the doc, seconds. */
2912
+ outputDuration: number;
2913
+ /** Whether the recording carries a mic track (ducking default). */
2914
+ hasMic: boolean;
2915
+ }
2916
+ /** A catalog track placed as the doc's background music bed. */
2917
+ declare function musicBedClip(input: MusicBedInput): AudioClip;
2918
+ /**
2919
+ * Is this clip a music BED — background music covering the cut? Beds are
2920
+ * what "add a track" replaces (one bed at a time; trying another vibe must
2921
+ * not stack). A clip the user moved off 0 or shortened mid-cut stopped
2922
+ * being a bed on purpose, so it is theirs to manage and never auto-replaced.
2923
+ */
2924
+ declare function isMusicBed(clip: AudioClip, outputDuration: number): boolean;
2925
+ /**
2926
+ * Re-fit clips that tracked the output end after the duration changed from
2927
+ * `prevDuration` to `nextDuration` (both OUTPUT seconds). Mutates `doc`
2928
+ * (an immer draft in practice); returns whether anything changed.
2929
+ */
2930
+ declare function refillAudioBeds(doc: ProjectDoc, prevDuration: number, nextDuration: number): boolean;
2931
+ /**
2932
+ * The take's VOICE source key, or null when it has none: the mic sidecar when
2933
+ * the take was recorded split (AT), else the legacy mixed track (pre-split
2934
+ * takes carried mic+system in the recording's own file). Ducking, the duck-RMS
2935
+ * decode and every "has a voice?" UI gate share this one derivation.
2936
+ */
2937
+ declare function voiceKey(doc: Pick<ProjectDoc, 'source'> | StudioDoc): string | null;
2938
+
2939
+ /**
2940
+ * The DEFAULT backdrop: the house loop every NEW take opens on
2941
+ * once the flag below is on. A committed constant, not a live vos, the same
2942
+ * way Home's door tiles are a deliberate asset push (decided 2026-08-17):
2943
+ * changing it is a code change with a review, never something an autosave
2944
+ * can re-skin.
2945
+ *
2946
+ * `ground` is the loop's average colour, written into `frame.background` so
2947
+ * the frame before the first decoded frame, the reduced-motion still and
2948
+ * the offline fail-open all land on the loop's own colour. The
2949
+ * keys are the pre-registry bucket objects, which stay in the bucket
2950
+ * forever; the registry's `backdrops/{slug}/…` keys replace them when the
2951
+ * house loop is featured through the verb.
2952
+ *
2953
+ * BACKDROP_DEFAULT_ON is the flip. It stays OFF until the set has a signed-off
2954
+ * seed and the fleet measurement has run: a switch, not a surprise.
2955
+ * Docs already carrying a frame are never touched either way.
2956
+ */
2957
+
2958
+ declare const DEFAULT_BACKDROP: {
2959
+ readonly slug: "soft-beams";
2960
+ readonly title: "Soft Beams";
2961
+ readonly key: "https://assets.vos.so/backgrounds/soft-beams-1080p.webm";
2962
+ readonly key2k: "https://assets.vos.so/backgrounds/soft-beams-2k.webm";
2963
+ readonly poster: "https://assets.vos.so/backgrounds/soft-beams-poster.jpg";
2964
+ readonly duration: 10;
2965
+ readonly ground: "#a7b2d1";
2966
+ };
2967
+ declare const BACKDROP_DEFAULT_ON: boolean;
2968
+ /** The default backdrop as a doc's `frame.backgroundMedia`. */
2969
+ declare function defaultBackdropMedia(): BackgroundMedia;
2970
+ /** A frame style opening on the default backdrop (media + its ground). */
2971
+ declare function withDefaultBackdrop(frame: FrameStyle): FrameStyle;
2972
+
2973
+ export { ASPECT_RATIOS, AUDIO_PLAN_STEP, type AnchorKind, type AspectRatioOption, type AudioClip, type AudioPlanPoint, type AudioPlanTrack, BACKDROP_DEFAULT_ON, BACKGROUND_Z, type BackgroundMedia, type BakedText3dAsset, type BakedText3dMaterial, type BrowserBarStyle, type BuildDigestInput, CAMERA_FAR, CAMERA_NEAR, CAM_CHAIN_GAP, CAM_EASE, CAM_PAN, CAM_PAN_EASE, CAM_RAMP_IN, CAM_RAMP_OUT, CAM_SIZE_MAX, CAM_SIZE_MIN, CAM_SPAN_MIN, CAPTURE_COVERAGE_MIN, CARD_FOV, CARD_Z, CHANNEL_SPECS_HASH, CHANNEL_SPECS_VERIFIED, CLICK_FX_INTENSITY, CLICK_FX_PRE, CLICK_HIGHLIGHT_FADE, CLICK_PAIR_MAX, CLICK_PULSE_DUR, CLICK_RECT_MAX_FRAC, CLICK_RIPPLE_DUR, CLICK_SYNTH_RELEASE, CROP_MAX_PX, CROP_MIN_FRAC, CROP_MIN_PX, CROP_PAD, CURSOR_IDLE_EPS_FRAC, CURSOR_IDLE_FADE_IN, CURSOR_IDLE_FADE_OUT, CURSOR_IDLE_HOLD, type CamBubbleRect, type CamPoseSpan, type CamStyle, type CaptureNormalization, type CardLayout, type ClickFxStyle, type CursorEvent, type CursorFadeKey, type CursorIdleOptions, type CursorStyle, type CursorTrack, DEFAULT_BACKDROP, DEFAULT_BROWSER_BAR, DEFAULT_CAM_POSE, DEFAULT_CAM_STYLE, DEFAULT_CLICK_FX, DEFAULT_CURSOR_STYLE, DEFAULT_DUCK, DEFAULT_FRAME_STYLE, DEFAULT_SPEED_PARAMS, DEFAULT_TILT_POSE, DEFAULT_ZOOM_LEVEL, DEFAULT_ZOOM_STYLE, DESTINATIONS, DIGEST_CROP_MAX, DIGEST_FULL_MAX, DIGEST_VERSION, DOC_SCHEMA_VERSION, DRAG_FIT_LEVEL, type Destination, type Digest, type DigestImageRef, type DigestPlan, type DigestTakeFacts, type DuckOptions, EXPORT_RESOLUTION_OPTIONS, EXPORT_SHORT_EDGE, type EnvelopePoint, type ExportResolution, type ExtractClickOptions, FOLLOW_RECENTER, FOLLOW_SAFE_RATIO, FRAME_BORDER_COLOR_DEFAULT, FRAME_BORDER_DEFAULT, FRAME_BORDER_WIDTH_DEFAULT, type FocusBounds, type FollowEvent, type FollowOptions, type FrameGeometry, type FrameStyle, type LowerProgramOptions, type LoweredAudioClip, type LoweredClick, type LoweredComposition, type LoweredZoomSpan, MINIMAL_BAR_THEMES, MOTION_DELTA, MOTION_EASE, type MediaOverlayClip, type MicRms, type MinimalBarTheme, type Moment, type MomentKind, type MomentsOptions, type MotionKey, type MotionPose, type MotionPose3D, type MusicBedInput, type NormRect, OBJECT_DEFAULT_SCALE, OVERLAY_FONT_FACES, OVERLAY_LINE_HEIGHT, OVERLAY_MEDIA_DEFAULT_RADIUS, OVERLAY_MEDIA_DEFAULT_WIDTH, OVERLAY_MIN_DURATION, OVERLAY_SCALE_MAX, OVERLAY_SIZE_MAX, OVERLAY_SIZE_MIN, OVERLAY_TRANSITION_DUR, OVERLAY_Z, type ObjectAnimation, type ObjectAsset, type ObjectClip, type ObjectPrimitiveShape, type OverlayClip, type OverlayFontFace, type OverlayKind, type OverlayPresetStyle, type OverlayRect, type OverlayTransform, type OverlayTransition, PLAYBACK_ACTIVITY, PROGRAM_RETIME, type PlanOptions, type PlanTiltOptions, type PlaneSize, type ProgramAnchorDoc, type ProgramTweenEdit, type ProjectDoc, type PxRect, type RecordingArtifact, type RecordingMeta, type Rect, type ResolvedOverlayBox, type ResolvedOverlayStyle, SCENE_MOTION, SCENE_QUIET, SPEED_RATE_MAX, SPEED_RATE_MIN, SPEED_SPAN_MIN, STUDIO_ENTRY_ID, STYLE_FIELDS, type SmoothOptions, type SmoothPoint, type SpeedParams, type SpeedSpan, type StepAnchor, type StepSpan, type StudioAudioPlan, type StudioDoc, type StudioEntry, type StyleField, TEXT3D_DEPTH_DEFAULT, TEXT3D_DEPTH_MAX, TEXT3D_DEPTH_MIN, TEXT_PRESETS, TILT_AUTO_DEAD_ZONE, TILT_AUTO_MIN, TILT_CHAIN_GAP, TILT_DEG_MAX, TILT_EASE, TILT_INTENSITY_MAX, TILT_PAN, TILT_PAN_EASE, TILT_RAMP_IN, TILT_RAMP_OUT, TILT_SPAN_MIN, TILT_UI_DEG_MAX, TRANSITION_SPEED_MULT, type Text3dMaterial, type TextFxDirection, type TextFxKind, type TextFxSpec, type TextFxUnit, type TextOverlayBox, type TextOverlayClip, type TextOverlayPreset, type TextOverlayStroke, type TiltPersonality, type TiltSpan, type TiltStyleName, type TranscriptSegment, type TransitionSpeed, WINDOW_FOCUS_MIN, ZOOM_CHAIN_GAP, ZOOM_EASE, ZOOM_LEVELS, ZOOM_LEVEL_MAX, ZOOM_LEVEL_MIN, ZOOM_PAN, ZOOM_PAN_EASE, ZOOM_RAMP_IN, ZOOM_RAMP_IN_OVERLAP, ZOOM_RAMP_OUT, ZOOM_SPAN_MIN, ZOOM_STYLES, ZOOM_STYLE_OPTIONS, type ZoomSpan, type ZoomStyleName, type ZoomStyleParams, type ZoomWindow, anchorKindOf, anchorSourceDuration, aspectRatioValue, audioLane, buildDigest, camBubbleRect, camBubbleRectAt, camLane, camMoveLane, camRectFromPose, camRestPose, camTrackFromDoc, cardPointToScreen, clampCamFrac, clampCamSize, clampFocus, clampSpeedRate, clampTiltDeg, clampZoomLevel, clipEnvelope, clipLength, computeCardLayout, computeMicRms, computePeaks, copyStyle, cropBox, cursorIdleFade, defaultBackdropMedia, deriveViewportCrop, destinationById, destinationsForChannel, docCardLayout, docOutputDuration, docToCropSpace, docToFullSpace, duckCurve, effectiveSegments, envelopeAt, envelopeValueAt, expectedFrameSize, exportSizeFor, extractClicks, focusBounds, followFocusEvents, frameGeometry, hexToRgbTriplet, idleGaps, isMusicBed, isPlayback, isProgramDoc, isRecordingDoc, levelForFocusFraction, lowerProgramDoc, lowerStudioDoc, lowerToComposition, micLane, migrateHostedDoc, momentsFromDoc, motionTrack, musicBedClip, normalizeCaptureSpace, objectMotionBase, objectMotionPoseAt, objectsLane, outputDurationOf, outputRangeToSource, overlayFaceFor, overlayFontFaces, overlayFontString, overlayHit, overlayLines, overlayMotionBase, overlayMotionPoseAt, overlayRect, overlaysLane, pageDisplayUrl, parsePoseId, pickStyle, planAutoSpeed, planAutoTilt, planAutoZoom, planForDigest, planeSizeAtDepth, platformBarKind, programDuration, projectFromArtifact, ratedSegments, recommendedExportResolution, refillAudioBeds, removeSourceRange, removeSpeedInRange, resolveExportSize, resolveOverlayBox, resolveOverlayStyle, resolveText3dAsset, resolveZoomStyle, sceneChanges, scrollRuns, setSpeedInRange, smoothCursor, spanOutputExtent, speedLane, studioAudioPlan, studioEntry, studioLayerData, tiltLane, tiltTrackFromDoc, transitionMult, videoLane, voiceKey, withDefaultBackdrop, wrapProgramLength, zoomCoversRect, zoomLane, zoomSpanForRange, zoomTrackFromDoc, zoomWindow };