@ossclip/scenes 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.
package/src/stage.ts ADDED
@@ -0,0 +1,764 @@
1
+ import { ZOOM_MAX_SCALE, type FaceCrop, type Layout, type SceneCue } from "@ossclip/core/browser";
2
+
3
+ /** Fractions of the 1080×1920 frame. */
4
+ export interface Rect {
5
+ x: number;
6
+ y: number;
7
+ w: number;
8
+ h: number;
9
+ }
10
+
11
+ export interface VideoSlotState {
12
+ rect: Rect;
13
+ /** 0..1 — fraction of the slot's shorter edge (1 = circle on a square slot). */
14
+ cornerRadius: number;
15
+ blurPx: number;
16
+ /** 0..1 black overlay on top of the video. */
17
+ dim: number;
18
+ opacity: number;
19
+ /**
20
+ * Vertical crop bias for object-position (0 = show the top of the source,
21
+ * 0.5 = center). Slots shorter than the portrait source slice through it
22
+ * and can decapitate the speaker — derived from the measured face so the
23
+ * whole head, chin included, lands in the band (FINDINGS §11/§13).
24
+ */
25
+ objectPosY: number;
26
+ /**
27
+ * Horizontal crop bias, same convention. Only moves off 0.5 when the source
28
+ * is wider than the slot — a landscape take in a vertical slot loses most of
29
+ * its width, and centring it can crop the speaker out (0.5 = center).
30
+ */
31
+ objectPosX: number;
32
+ }
33
+
34
+ /** Output frame pixels. Every rect on the stage is a FRACTION of this, so the
35
+ * only thing the frame changes is pixel-ratio math: how much of the source a
36
+ * slot displays, and therefore where the crop has to sit. */
37
+ export interface FrameSize {
38
+ width: number;
39
+ height: number;
40
+ }
41
+
42
+ /** The vertical frame ossclip was built for, and its landscape sibling (R15). */
43
+ export const PORTRAIT_FRAME: FrameSize = { width: 1080, height: 1920 };
44
+ export const LANDSCAPE_FRAME: FrameSize = { width: 1920, height: 1080 };
45
+
46
+ /** Default frame — portrait, so every existing caller keeps its behaviour. */
47
+ const FRAME_W = PORTRAIT_FRAME.width;
48
+ const FRAME_H = PORTRAIT_FRAME.height;
49
+
50
+ /**
51
+ * Assumed face when none was measured (screen recording, detector miss):
52
+ * an arm's-length selfie puts the face center ~38% down the frame, with the
53
+ * detector's box about a fifth of the frame height. A guess either way lands
54
+ * closer than the old per-layout constants — 0.12 traded the cut forehead for
55
+ * a cut mouth (FINDINGS §13).
56
+ */
57
+ export const DEFAULT_FACE: FaceCrop & { sizeFrac: number } = {
58
+ centerYFrac: 0.38,
59
+ sizeFrac: 0.22,
60
+ // No centerXFrac and no sourceAspect on purpose: an unmeasured face has no
61
+ // horizontal position worth asserting, and a source that never said what
62
+ // shape it is gets assumed to be the frame's own 9:16.
63
+ };
64
+
65
+ /**
66
+ * How far a head extends ABOVE the detector's box, as a multiple of that
67
+ * box's height. pico bounds the face — eyes, nose, mouth — and excludes hair
68
+ * and skull, so centring the box seats the head too low and the crown is cut
69
+ * mid-forehead (FINDINGS §19).
70
+ */
71
+ export const HEAD_ABOVE_FACE = 0.35;
72
+
73
+ /** Preferred position of the head's centre within the slot. */
74
+ const HEAD_ANCHOR_IN_SLOT = 0.45;
75
+
76
+ /**
77
+ * Clearance kept above the crown and below the chin, as fractions of slot
78
+ * height. Floored by what the §15 zoom eats: it scales slot content by up to
79
+ * ZOOM_MAX_SCALE about `50% 40%`, so at peak zoom the band loses
80
+ * `0.4·(1−1/s)` off the top and `0.6·(1−1/s)` off the bottom. Hard-coding
81
+ * smaller margins would let the zoom undo this fix at its peaks while the
82
+ * geometry tests still passed.
83
+ */
84
+ const ZOOM_BITE = 1 - 1 / ZOOM_MAX_SCALE;
85
+ export const HEAD_TOP_MARGIN = 0.4 * ZOOM_BITE;
86
+ export const CHIN_BOTTOM_MARGIN = 0.6 * ZOOM_BITE;
87
+
88
+ /**
89
+ * Whether a slot can hold this whole head — box, margins and all — and
90
+ * whether the crown was in the source to begin with. When this is false the
91
+ * crop is a choice about which end to lose, not a solvable placement.
92
+ */
93
+ export function headFitsSlot(
94
+ rect: Rect,
95
+ face: FaceCrop,
96
+ frame: FrameSize = PORTRAIT_FRAME,
97
+ ): boolean {
98
+ const slotH = rect.h * frame.height;
99
+ const displayedH = displayedHeight(rect, face, frame);
100
+ const size = face.sizeFrac ?? DEFAULT_FACE.sizeFrac;
101
+ const crownFrac = face.centerYFrac - size / 2 - HEAD_ABOVE_FACE * size;
102
+ const needed =
103
+ (1 + HEAD_ABOVE_FACE) * size * displayedH + (HEAD_TOP_MARGIN + CHIN_BOTTOM_MARGIN) * slotH;
104
+ return crownFrac >= 0 && needed <= slotH;
105
+ }
106
+
107
+ /** The frame's own aspect — the assumption when a source doesn't state one. */
108
+ const FRAME_ASPECT = FRAME_W / FRAME_H;
109
+
110
+ /**
111
+ * Displayed height of the source inside a slot under `object-fit: cover`.
112
+ *
113
+ * `cover` scales by whichever axis needs the larger factor. A 9:16 source in
114
+ * any of our slots is width-constrained and spills vertically — which is the
115
+ * only case the crop math used to handle. A landscape source is
116
+ * HEIGHT-constrained: it fills the slot's height exactly and spills sideways,
117
+ * so there is no vertical bias to apply and `objectPosXFor` takes over.
118
+ */
119
+ function displayedHeight(
120
+ rect: Rect,
121
+ face: FaceCrop,
122
+ frame: FrameSize = PORTRAIT_FRAME,
123
+ ): number {
124
+ const slotW = rect.w * frame.width;
125
+ const slotH = rect.h * frame.height;
126
+ return Math.max(slotH, slotW / (face.sourceAspect ?? frame.width / frame.height));
127
+ }
128
+
129
+ /**
130
+ * Vertical object-position for a slot: where to window the source so the
131
+ * speaker's whole head lands in the band.
132
+ *
133
+ * Expressed as a feasible interval rather than one tuned constant — the crown
134
+ * gives an upper bound on the offset, the chin a lower one. Inside the
135
+ * interval we take the preferred anchor; when the band is too short to hold a
136
+ * whole head the interval is empty and we keep the CHIN, because a talking
137
+ * head without a mouth stops reading as speech (FINDINGS §13).
138
+ */
139
+ export function objectPosYFor(
140
+ rect: Rect,
141
+ face: FaceCrop,
142
+ frame: FrameSize = PORTRAIT_FRAME,
143
+ ): number {
144
+ const slotH = rect.h * frame.height;
145
+ const displayedH = displayedHeight(rect, face, frame);
146
+ const overflow = displayedH - slotH;
147
+ if (overflow <= 1) return 0.5; // slot shows the full source height — no bias to apply
148
+
149
+ // sizeFrac is optional on the schema; without it there is no head to model,
150
+ // so fall back to the assumed framing rather than propagating NaN into CSS.
151
+ const size = face.sizeFrac ?? DEFAULT_FACE.sizeFrac;
152
+ const crown = (face.centerYFrac - size / 2 - HEAD_ABOVE_FACE * size) * displayedH;
153
+ const chin = (face.centerYFrac + size / 2) * displayedH;
154
+
155
+ const preferred = (crown + chin) / 2 - HEAD_ANCHOR_IN_SLOT * slotH;
156
+ const crownVisible = crown - HEAD_TOP_MARGIN * slotH;
157
+ const chinVisible = chin + CHIN_BOTTOM_MARGIN * slotH - slotH;
158
+ const offset =
159
+ chinVisible <= crownVisible
160
+ ? Math.min(Math.max(preferred, chinVisible), crownVisible)
161
+ : chinVisible;
162
+
163
+ return Math.min(1, Math.max(0, offset / overflow));
164
+ }
165
+
166
+ /** A band of the SOURCE frame, as fractions of source height. */
167
+ export interface SourceBand {
168
+ y: number;
169
+ h: number;
170
+ }
171
+
172
+ /**
173
+ * Nudge a crop so its window never cuts through burned-in text.
174
+ *
175
+ * A slot shorter than the source shows a window of it, and nothing stopped
176
+ * that window's edge landing halfway through the source's own title — which
177
+ * is exactly what happened on a real reel: the title's box was sliced along
178
+ * its top edge while ossclip printed a competing title underneath
179
+ * (FINDINGS §36).
180
+ *
181
+ * Either answer is defensible, so we take whichever moves the framing least:
182
+ * EXCLUDE the band (start the window below it, ossclip owns the messaging) or
183
+ * INCLUDE it whole (the source's title reads as intended). When neither fits —
184
+ * the band is taller than the window, or the shift would run off the source —
185
+ * the original position stands: a decapitated speaker is worse than a clipped
186
+ * title, and §13 already decided that trade.
187
+ */
188
+ export function avoidSlicingText(
189
+ posY: number,
190
+ rect: Rect,
191
+ face: FaceCrop,
192
+ bands: readonly SourceBand[],
193
+ frame: FrameSize = PORTRAIT_FRAME,
194
+ ): number {
195
+ if (bands.length === 0) return posY;
196
+ const slotH = rect.h * frame.height;
197
+ const displayedH = displayedHeight(rect, face, frame);
198
+ const overflow = displayedH - slotH;
199
+ if (overflow <= 1) return posY; // whole source visible — nothing to slice
200
+
201
+ const winH = slotH / displayedH; // window height, as a fraction of the source
202
+ const travel = 1 - winH; // how far the window's top may move
203
+ if (travel <= 0) return posY;
204
+
205
+ let top = posY * travel;
206
+ // Resolve the worst offender first, then re-check: moving the window can
207
+ // slide a different band across an edge.
208
+ for (let pass = 0; pass < bands.length + 1; pass++) {
209
+ const sliced = bands.find((b) => {
210
+ const bTop = b.y;
211
+ const bBot = b.y + b.h;
212
+ const bottom = top + winH;
213
+ const cutsTop = bTop < top && top < bBot;
214
+ const cutsBottom = bTop < bottom && bottom < bBot;
215
+ return cutsTop || cutsBottom;
216
+ });
217
+ if (!sliced) break;
218
+
219
+ const candidates: number[] = [];
220
+ const exclude = sliced.y + sliced.h; // window starts below the band
221
+ if (exclude <= travel) candidates.push(exclude);
222
+ const excludeAbove = sliced.y - winH; // window ends above the band
223
+ if (excludeAbove >= 0) candidates.push(excludeAbove);
224
+ if (sliced.h <= winH) {
225
+ // Include it whole: the window's top must sit in this interval.
226
+ const lo = Math.max(0, sliced.y + sliced.h - winH);
227
+ const hi = Math.min(travel, sliced.y);
228
+ if (lo <= hi) candidates.push(Math.min(Math.max(top, lo), hi));
229
+ }
230
+ // A title is never worth a mouth. §13 settled that trade; this must not
231
+ // quietly re-open it, so any shift that pushes the chin out of the window
232
+ // is discarded even though it would resolve the slice.
233
+ const size = face.sizeFrac ?? DEFAULT_FACE.sizeFrac;
234
+ const chin = face.centerYFrac + size / 2;
235
+ const viable = candidates.filter((c) => c + winH >= chin);
236
+ if (viable.length === 0) return posY;
237
+ top = viable.reduce((best, c) => (Math.abs(c - top) < Math.abs(best - top) ? c : best));
238
+ }
239
+ return Math.min(1, Math.max(0, top / travel));
240
+ }
241
+
242
+ /**
243
+ * Horizontal object-position: keep the speaker in frame when the source is
244
+ * wider than the slot.
245
+ *
246
+ * A 9:16 take never triggers this — its width matches the slot's and the
247
+ * overflow is vertical. A 16:9 webcam or screen recording in a vertical slot
248
+ * loses about 70% of its width, and centring that blindly crops out a speaker
249
+ * who was sitting to one side. Unmeasured X falls back to centre, which is
250
+ * exactly the old behaviour.
251
+ */
252
+ export function objectPosXFor(
253
+ rect: Rect,
254
+ face: FaceCrop,
255
+ frame: FrameSize = PORTRAIT_FRAME,
256
+ ): number {
257
+ const slotW = rect.w * frame.width;
258
+ const slotH = rect.h * frame.height;
259
+ const displayedW = Math.max(slotW, slotH * (face.sourceAspect ?? frame.width / frame.height));
260
+ const overflow = displayedW - slotW;
261
+ if (overflow <= 1 || face.centerXFrac === undefined) return 0.5;
262
+ // Centre the face in the slot, then clamp to what the source actually has.
263
+ const offset = face.centerXFrac * displayedW - slotW / 2;
264
+ return Math.min(1, Math.max(0, offset / overflow));
265
+ }
266
+
267
+ export interface StageSlots {
268
+ video: VideoSlotState;
269
+ graphic: Rect | null;
270
+ /**
271
+ * Vertical center (fraction of frame height) for captions. Captions are
272
+ * NEVER hidden ("muted-viewing complete", BRAINSTORM §4.5) — each layout
273
+ * reserves a free band clear of the graphic, the platform chrome, and the
274
+ * un-blurred face (FINDINGS §2, §6).
275
+ */
276
+ captionAnchor: number;
277
+ }
278
+
279
+ /**
280
+ * Platform chrome insets — the union of Reels/TikTok/Shorts UI overlays
281
+ * (top: status bar + tabs, bottom: username/caption/ticker, right: action
282
+ * rail). All TEXT and GRAPHICS must stay inside; the video slot may bleed
283
+ * full-frame — a face under the chrome is fine, text under it is not.
284
+ * (FINDINGS §6a.)
285
+ */
286
+ export const SAFE_AREA = { top: 0.12, bottom: 0.22, right: 0.16, left: 0.04 };
287
+
288
+ /**
289
+ * Landscape has no platform chrome to dodge (R15). A 16:9 export plays on
290
+ * YouTube/desktop/embeds: no action rail eating the right 16%, no username
291
+ * ticker eating the bottom 22%. Those insets exist to survive Reels/TikTok
292
+ * overlays, and carrying them into landscape would squeeze every graphic into
293
+ * the middle of a frame that has no such constraint. What remains is ordinary
294
+ * broadcast title-safe margin, plus a little extra at the bottom for the
295
+ * player's own scrub bar.
296
+ */
297
+ export const LANDSCAPE_SAFE_AREA = { top: 0.06, bottom: 0.12, right: 0.05, left: 0.05 };
298
+
299
+ /** Which chrome insets apply to a frame — decided by its shape, not a flag,
300
+ * so the renderer and the editor can never disagree about it. */
301
+ export function safeAreaFor(frame: FrameSize = PORTRAIT_FRAME): typeof SAFE_AREA {
302
+ return frame.width > frame.height ? LANDSCAPE_SAFE_AREA : SAFE_AREA;
303
+ }
304
+
305
+ /** The textual-safe rect for a given frame. */
306
+ export function safeRectFor(frame: FrameSize = PORTRAIT_FRAME): Rect {
307
+ const a = safeAreaFor(frame);
308
+ return { x: a.left, y: a.top, w: 1 - a.left - a.right, h: 1 - a.top - a.bottom };
309
+ }
310
+
311
+ /**
312
+ * Cover-image safe area (FINDINGS §31) — a DIFFERENT constraint from
313
+ * `SAFE_AREA`, and both apply to cover text.
314
+ *
315
+ * `SAFE_AREA` keeps text clear of the player's chrome. This keeps it inside
316
+ * what the Instagram profile GRID will still show: the grid crops a cover to
317
+ * a centre square, so a 1080×1920 cover keeps only y ∈ [420, 1500] — the
318
+ * middle 56% of its height. Text outside that is simply gone from the grid,
319
+ * which is the one place a cover is meant to work.
320
+ */
321
+
322
+ export const COVER_GRID_SAFE = { top: 0.24, bottom: 0.24, left: 0.06, right: 0.06 };
323
+
324
+ /** The rect the grid crop leaves visible. */
325
+ export const COVER_GRID_RECT: Rect = {
326
+ x: COVER_GRID_SAFE.left,
327
+ y: COVER_GRID_SAFE.top,
328
+ w: 1 - COVER_GRID_SAFE.left - COVER_GRID_SAFE.right,
329
+ h: 1 - COVER_GRID_SAFE.top - COVER_GRID_SAFE.bottom,
330
+ };
331
+
332
+ /** The rect everything textual must live in. */
333
+ export const SAFE_RECT: Rect = {
334
+ x: SAFE_AREA.left,
335
+ y: SAFE_AREA.top,
336
+ w: 1 - SAFE_AREA.left - SAFE_AREA.right,
337
+ h: 1 - SAFE_AREA.top - SAFE_AREA.bottom,
338
+ };
339
+
340
+ /** Floors for a hand-set graphic box — match `SceneOverrideSchema.graphicRect`. */
341
+ const GRAPHIC_RECT_MIN_W = 0.08;
342
+ const GRAPHIC_RECT_MIN_H = 0.05;
343
+
344
+ /**
345
+ * Clamp a hand-set graphic rect into `SAFE_RECT` with the minimum size
346
+ * enforced (PLAN 2026-07-31 Task 2). Used in BOTH places: the editor while a
347
+ * handle drag previews, and `SceneLayer` defensively at draw time — so a
348
+ * hand-edited `overrides.json` can't push a graphic under the platform
349
+ * chrome. Same invariant `stage.test.ts` pins for every layout's own slot.
350
+ */
351
+ export function clampGraphicRect(rect: Rect, frame: FrameSize = PORTRAIT_FRAME): Rect {
352
+ const SAFE = safeRectFor(frame);
353
+ // Epsilon-tolerant: SAFE_RECT's bounds are float sums (1 - 0.04 - 0.16 =
354
+ // 0.79999…), and a layout slot that is EXACTLY 0.8 wide must clamp to
355
+ // itself, not to the representation noise.
356
+ const EPS = 1e-9;
357
+ const clamp = (v: number, lo: number, hi: number): number =>
358
+ v < lo - EPS ? lo : v > hi + EPS ? hi : v;
359
+ const w = clamp(rect.w, GRAPHIC_RECT_MIN_W, SAFE.w);
360
+ const h = clamp(rect.h, GRAPHIC_RECT_MIN_H, SAFE.h);
361
+ const x = clamp(rect.x, SAFE.x, SAFE.x + SAFE.w - w);
362
+ const y = clamp(rect.y, SAFE.y, SAFE.y + SAFE.h - h);
363
+ return { x, y, w, h };
364
+ }
365
+
366
+ /**
367
+ * Where cover TEXT may actually go: the intersection of the two constraints.
368
+ *
369
+ * Neither rect contains the other — the grid crop is tighter top and bottom,
370
+ * while the player's action rail eats the right side that a grid tile does
371
+ * not have. A cover is seen in both places, so text has to satisfy both.
372
+ */
373
+ export const COVER_TEXT_RECT: Rect = (() => {
374
+ const x = Math.max(COVER_GRID_RECT.x, SAFE_RECT.x);
375
+ const y = Math.max(COVER_GRID_RECT.y, SAFE_RECT.y);
376
+ const right = Math.min(COVER_GRID_RECT.x + COVER_GRID_RECT.w, SAFE_RECT.x + SAFE_RECT.w);
377
+ const bottom = Math.min(COVER_GRID_RECT.y + COVER_GRID_RECT.h, SAFE_RECT.y + SAFE_RECT.h);
378
+ return { x, y, w: right - x, h: bottom - y };
379
+ })();
380
+
381
+ /** Approximate half-height of a caption line block, for free-band math/tests. */
382
+ export const CAPTION_HALF_BAND = 0.045;
383
+
384
+ /** A vertical interval in frame fractions. */
385
+ export interface Band {
386
+ start: number;
387
+ end: number;
388
+ }
389
+
390
+ /**
391
+ * The vertical gaps `blocked` leaves inside `[start, end]`, tallest first.
392
+ *
393
+ * One implementation for every "put this somewhere nothing else is" problem
394
+ * on the stage — routing a graphic around the source's burned-in text (§26),
395
+ * and placing the cover banner clear of the face (§33). They are the same
396
+ * question about different occupants, and were worth solving once.
397
+ */
398
+ export function freeBands(
399
+ range: Band,
400
+ blocked: ReadonlyArray<{ y: number; h: number }>,
401
+ ): Band[] {
402
+ const clipped = blocked
403
+ .map((r) => ({ start: Math.max(range.start, r.y), end: Math.min(range.end, r.y + r.h) }))
404
+ .filter((r) => r.end > r.start)
405
+ .sort((a, b) => a.start - b.start);
406
+
407
+ const free: Band[] = [];
408
+ let cursor = range.start;
409
+ for (const b of clipped) {
410
+ if (b.start > cursor) free.push({ start: cursor, end: b.start });
411
+ cursor = Math.max(cursor, b.end);
412
+ }
413
+ if (cursor < range.end) free.push({ start: cursor, end: range.end });
414
+ return free.sort((a, b) => b.end - b.start - (a.end - a.start));
415
+ }
416
+
417
+ /**
418
+ * How far a head extends BELOW the detector's box. Smaller than
419
+ * `HEAD_ABOVE_FACE` because pico's box already includes the mouth — what sits
420
+ * under it is a chin and some neck, not a whole skull.
421
+ */
422
+ const HEAD_BELOW_FACE = 0.2;
423
+
424
+ /**
425
+ * The head's extent from a face box, in frame fractions — the §19 expansion,
426
+ * reused. pico bounds the FACE, so anything routing around a head has to grow
427
+ * the box or it lands on hair.
428
+ */
429
+ export function headBand(face: { centerYFrac: number; sizeFrac: number }): Band {
430
+ return {
431
+ start: face.centerYFrac - face.sizeFrac * (0.5 + HEAD_ABOVE_FACE),
432
+ end: face.centerYFrac + face.sizeFrac * (0.5 + HEAD_BELOW_FACE),
433
+ };
434
+ }
435
+
436
+ /** Below this a banner band is too short to hold a headline at cover sizes. */
437
+ const MIN_COVER_BAND_H = 0.13;
438
+
439
+ /**
440
+ * Where the cover banner goes: inside `COVER_TEXT_RECT`, clear of the face
441
+ * (FINDINGS §33).
442
+ *
443
+ * The reference covers all put the banner in the frame's dead space, never
444
+ * across the speaker — a face with a box over its mouth reads as a mistake.
445
+ * When the face leaves no band tall enough (a tight close-up fills the whole
446
+ * grid-safe strip), the full rect comes back: a banner over the face still
447
+ * beats a cover with no headline, and the caller logs that it happened.
448
+ */
449
+ export function coverTextRect(
450
+ face?: { centerYFrac: number; sizeFrac: number } | null,
451
+ frame: FrameSize = PORTRAIT_FRAME,
452
+ ): Rect {
453
+ // The GRID crop is a portrait-cover constraint (R16 §76): it models
454
+ // Instagram cropping a 9:16 cover to a centre square. A 16:9 cover is a
455
+ // YouTube thumbnail, shown whole — applying the square crop there would
456
+ // squeeze the banner into the middle 56% of an already short frame for no
457
+ // reason. Landscape keeps only the player's own safe area.
458
+ const base = frame.width >= frame.height ? safeRectFor(frame) : COVER_TEXT_RECT;
459
+ if (!face) return base;
460
+ const range = { start: base.y, end: base.y + base.h };
461
+ const head = headBand(face);
462
+ const [tallest] = freeBands(range, [{ y: head.start, h: head.end - head.start }]);
463
+ if (!tallest || tallest.end - tallest.start < MIN_COVER_BAND_H) return base;
464
+ return { ...base, y: tallest.start, h: tallest.end - tallest.start };
465
+ }
466
+
467
+ const FULL: Rect = { x: 0, y: 0, w: 1, h: 1 };
468
+
469
+ /** PIP circle: Ø 0.30 of frame width, sitting in the lower third. */
470
+ const PIP_DIAMETER_W = 0.3;
471
+ const PIP_RECT: Rect = {
472
+ x: 0.5 - PIP_DIAMETER_W / 2,
473
+ y: 0.66,
474
+ w: PIP_DIAMETER_W,
475
+ h: (PIP_DIAMETER_W * 1080) / 1920,
476
+ };
477
+
478
+ /**
479
+ * The stage's slot arrangement per layout (PHASE1 §1 table, safe-area'd per
480
+ * FINDINGS §6). Caption anchors sit in the free band each layout reserves:
481
+ * full-bleed → lower third, below the face, above the bottom inset
482
+ * video-top → the gap between the video block and the graphic
483
+ * pip-bubble → between the graphic and the bubble
484
+ * graphic-only → below the graphic (the layout reserves the band)
485
+ * blurred-behind → below the centred graphic (face is blurred — no clash)
486
+ * lower-third → above the band (landscape) / between face and band (portrait)
487
+ * split-left/right → the frame's remaining lower margin, below the graphic
488
+ *
489
+ * The three R15 §54 layouts are landscape-native but defined for BOTH frames
490
+ * (R13's rule: a layout the editor can select must always render): the split
491
+ * axis follows the frame's long edge — video and graphic sit side-by-side in
492
+ * 16:9 and stack in 9:16, where a literal half-width slot would be a sliver.
493
+ * The two portrait splits therefore share one stacked geometry; the side
494
+ * only exists when there are sides.
495
+ */
496
+ export function layoutSlots(
497
+ layout: Layout,
498
+ face: FaceCrop = DEFAULT_FACE,
499
+ /** Burned-in text bands visible right now — the crop must not slice them. */
500
+ textBands: readonly SourceBand[] = [],
501
+ frame: FrameSize = PORTRAIT_FRAME,
502
+ ): StageSlots {
503
+ const posY = (rect: Rect) =>
504
+ avoidSlicingText(objectPosYFor(rect, face, frame), rect, face, textBands, frame);
505
+ const posX = (rect: Rect) => objectPosXFor(rect, face, frame);
506
+ const landscape = frame.width > frame.height;
507
+ switch (layout) {
508
+ case "full-bleed":
509
+ return {
510
+ video: { rect: FULL, cornerRadius: 0, blurPx: 0, dim: 0, opacity: 1, objectPosY: posY(FULL), objectPosX: posX(FULL) },
511
+ graphic: null,
512
+ captionAnchor: 0.7,
513
+ };
514
+ case "lower-third": {
515
+ // Broadcast lower third: the picture stays whole; the card sits in the
516
+ // least valuable band. Landscape keeps it off-centre (left-aligned,
517
+ // clear of the speaker's usual desk-right) with captions above the
518
+ // band; portrait pushes the band to the safe-area floor with captions
519
+ // in the gap under the face.
520
+ const graphic = landscape
521
+ ? { x: 0.05, y: 0.7, w: 0.62, h: 0.18 }
522
+ : { x: 0.04, y: 0.56, w: 0.8, h: 0.22 };
523
+ return {
524
+ video: { rect: FULL, cornerRadius: 0, blurPx: 0, dim: 0, opacity: 1, objectPosY: posY(FULL), objectPosX: posX(FULL) },
525
+ graphic,
526
+ captionAnchor: landscape ? 0.62 : 0.49,
527
+ };
528
+ }
529
+ case "split-left":
530
+ case "split-right": {
531
+ if (landscape) {
532
+ const left = layout === "split-left";
533
+ const rect: Rect = { x: left ? 0 : 0.5, y: 0, w: 0.5, h: 1 };
534
+ return {
535
+ video: { rect, cornerRadius: 0, blurPx: 0, dim: 0, opacity: 1, objectPosY: posY(rect), objectPosX: posX(rect) },
536
+ graphic: left
537
+ ? { x: 0.55, y: 0.2, w: 0.4, h: 0.56 }
538
+ : { x: 0.05, y: 0.2, w: 0.4, h: 0.56 },
539
+ captionAnchor: 0.82,
540
+ };
541
+ }
542
+ const rect: Rect = { x: 0, y: 0, w: 1, h: 0.5 };
543
+ return {
544
+ video: { rect, cornerRadius: 0, blurPx: 0, dim: 0, opacity: 1, objectPosY: posY(rect), objectPosX: posX(rect) },
545
+ graphic: { x: 0.04, y: 0.58, w: 0.8, h: 0.2 },
546
+ captionAnchor: 0.53,
547
+ };
548
+ }
549
+ case "video-top": {
550
+ const rect: Rect = { x: 0, y: 0, w: 1, h: 0.42 };
551
+ return {
552
+ video: { rect, cornerRadius: 0, blurPx: 0, dim: 0, opacity: 1, objectPosY: posY(rect), objectPosX: posX(rect) },
553
+ graphic: { x: 0.04, y: 0.54, w: 0.8, h: 0.24 },
554
+ captionAnchor: 0.48,
555
+ };
556
+ }
557
+ case "pip-bubble":
558
+ return {
559
+ video: {
560
+ rect: PIP_RECT,
561
+ cornerRadius: 1,
562
+ blurPx: 0,
563
+ dim: 0,
564
+ opacity: 1,
565
+ objectPosY: posY(PIP_RECT), objectPosX: posX(PIP_RECT),
566
+ },
567
+ graphic: { x: 0.06, y: 0.14, w: 0.78, h: 0.42 },
568
+ captionAnchor: 0.61,
569
+ };
570
+ case "graphic-only":
571
+ return {
572
+ video: {
573
+ rect: PIP_RECT,
574
+ cornerRadius: 1,
575
+ blurPx: 0,
576
+ dim: 0,
577
+ opacity: 0,
578
+ objectPosY: posY(PIP_RECT), objectPosX: posX(PIP_RECT),
579
+ },
580
+ graphic: { x: 0.04, y: 0.14, w: 0.8, h: 0.54 },
581
+ captionAnchor: 0.73,
582
+ };
583
+ case "blurred-behind":
584
+ return {
585
+ video: { rect: FULL, cornerRadius: 0, blurPx: 22, dim: 0.55, opacity: 1, objectPosY: posY(FULL), objectPosX: posX(FULL) },
586
+ graphic: { x: 0.07, y: 0.24, w: 0.77, h: 0.36 },
587
+ captionAnchor: 0.69,
588
+ };
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Where a graphic floats when its cue's layout defines no slot (R13).
594
+ *
595
+ * `full-bleed` was designed as "talking head only" and returns `graphic:
596
+ * null` — which made it the one layout that silently DELETED the graphic
597
+ * when the user switched to it, selection box and all. Layout and component
598
+ * are independent axes: layout decides where the VIDEO sits, and a cue that
599
+ * has a graphic always renders it. The band is blurred-behind's geometry —
600
+ * the placement most graphics already ship on — over the full-frame video,
601
+ * minus the blur. Deliberately NOT added to `layoutSlots` itself: the slot
602
+ * table is shared stage geometry (caption avoidance, routing candidates),
603
+ * and a phantom full-bleed slot there would make captions steer around a
604
+ * band that is empty on every plain cue.
605
+ */
606
+ export const FULL_BLEED_GRAPHIC_SLOT: Rect = { x: 0.07, y: 0.24, w: 0.77, h: 0.36 };
607
+
608
+ /**
609
+ * The slot a cue's graphic actually renders in — never null. Precedence: the
610
+ * cue's own rect (routed by source-text avoidance or hand-set in the editor,
611
+ * clamped so a hand-edited overrides.json can't push a graphic under the
612
+ * platform chrome), then the layout's slot, then the full-bleed fallback.
613
+ * One resolver for the renderer and the Inspector, so the box the panel
614
+ * edits is byte-for-byte the box the stage draws.
615
+ */
616
+ export function graphicSlotFor(
617
+ cue: { layout: Layout; graphicRect?: Rect | null },
618
+ frame: FrameSize = PORTRAIT_FRAME,
619
+ ): Rect {
620
+ if (cue.graphicRect) return clampGraphicRect(cue.graphicRect, frame);
621
+ return layoutSlots(cue.layout, DEFAULT_FACE, [], frame).graphic ?? FULL_BLEED_GRAPHIC_SLOT;
622
+ }
623
+
624
+ /** Seconds the video slot spends morphing between layouts at a cue boundary. */
625
+ export const LAYOUT_TRANSITION_SEC = 0.35;
626
+
627
+ export function activeCueAt(cues: readonly SceneCue[], tSec: number): SceneCue | null {
628
+ for (const cue of cues) {
629
+ if (tSec >= cue.startSec && tSec < cue.endSec) return cue;
630
+ }
631
+ return null;
632
+ }
633
+
634
+ function lerp(a: number, b: number, p: number): number {
635
+ return a + (b - a) * p;
636
+ }
637
+
638
+ function lerpRect(a: Rect, b: Rect, p: number): Rect {
639
+ return { x: lerp(a.x, b.x, p), y: lerp(a.y, b.y, p), w: lerp(a.w, b.w, p), h: lerp(a.h, b.h, p) };
640
+ }
641
+
642
+ function lerpVideo(a: VideoSlotState, b: VideoSlotState, p: number): VideoSlotState {
643
+ return {
644
+ rect: lerpRect(a.rect, b.rect, p),
645
+ cornerRadius: lerp(a.cornerRadius, b.cornerRadius, p),
646
+ blurPx: lerp(a.blurPx, b.blurPx, p),
647
+ dim: lerp(a.dim, b.dim, p),
648
+ opacity: lerp(a.opacity, b.opacity, p),
649
+ objectPosY: lerp(a.objectPosY, b.objectPosY, p),
650
+ objectPosX: lerp(a.objectPosX, b.objectPosX, p),
651
+ };
652
+ }
653
+
654
+ function easeInOut(p: number): number {
655
+ return p * p * (3 - 2 * p);
656
+ }
657
+
658
+ /**
659
+ * A full-bleed plain cue IS the base stage state — it exists so the timeline
660
+ * shows a block and framing overrides have an id to land on, not to change
661
+ * what renders. It must therefore be INVISIBLE to the morph machinery: a
662
+ * plain cue butts flush against its graphic neighbour (no assembler gap), so
663
+ * the ±1e-3 neighbour probes would see it where today they see a gap, and the
664
+ * slot would complete its end-of-scene morph to base and then snap back to
665
+ * the graphic layout to morph a second time. Filtering keeps graphic↔plain
666
+ * transitions byte-identical to today's cue↔gap. A plain cue whose LAYOUT the
667
+ * user overrode away from full-bleed stays morphable — that's a real staging
668
+ * decision, not filler.
669
+ */
670
+ function morphCues(cues: readonly SceneCue[]): readonly SceneCue[] {
671
+ return cues.filter((c) => !(c.kind === "plain" && c.layout === "full-bleed"));
672
+ }
673
+
674
+ /**
675
+ * The video slot a CUE resolves to: its layout's slot, adjusted by the user's
676
+ * pip override (R14 §52) when — and only when — the resolved layout is
677
+ * `pip-bubble`. Roundness and placement are properties of the BUBBLE;
678
+ * consulting them under other layouts would let a stale override bend a
679
+ * full-frame layout, the same trap §50 closed for the graphic rect.
680
+ * Placement is clamped to keep the bubble on-frame (video may bleed under the
681
+ * platform chrome — that rule is about text, not faces — but not off it).
682
+ */
683
+ function cueVideoSlot(
684
+ cue: SceneCue,
685
+ face: FaceCrop,
686
+ bands: readonly SourceBand[],
687
+ frame: FrameSize,
688
+ ): VideoSlotState {
689
+ const v = layoutSlots(cue.layout, face, bands, frame).video;
690
+ const pip = cue.layout === "pip-bubble" ? cue.pip : undefined;
691
+ if (!pip) return v;
692
+ const rect = {
693
+ ...v.rect,
694
+ x: Math.min(Math.max(pip.x ?? v.rect.x, 0), 1 - v.rect.w),
695
+ y: Math.min(Math.max(pip.y ?? v.rect.y, 0), 1 - v.rect.h),
696
+ };
697
+ return { ...v, rect, cornerRadius: pip.cornerRadius ?? v.cornerRadius };
698
+ }
699
+
700
+ /**
701
+ * The video slot's state at output time t, easing between layouts around cue
702
+ * boundaries. The morph runs INSIDE the cue's own window (start → start+T,
703
+ * end-T → end) so neighbouring cues never fight over the slot.
704
+ */
705
+ export function videoSlotAt(
706
+ allCues: readonly SceneCue[],
707
+ tSec: number,
708
+ face: FaceCrop = DEFAULT_FACE,
709
+ /** Text regions in OUTPUT time; only those on screen now constrain the crop. */
710
+ textRegions: readonly (SourceBand & { startSec: number; endSec: number })[] = [],
711
+ frame: FrameSize = PORTRAIT_FRAME,
712
+ ): VideoSlotState {
713
+ const cues = morphCues(allCues);
714
+ const bands = textRegions.filter((r) => tSec >= r.startSec && tSec < r.endSec);
715
+ const base = layoutSlots("full-bleed", face, bands, frame).video;
716
+ const cue = activeCueAt(cues, tSec);
717
+ if (!cue) return base;
718
+ const target = cueVideoSlot(cue, face, bands, frame);
719
+ const T = Math.min(LAYOUT_TRANSITION_SEC, (cue.endSec - cue.startSec) / 2);
720
+ const sinceStart = tSec - cue.startSec;
721
+ const untilEnd = cue.endSec - tSec;
722
+
723
+ // Each neighbour resolves through its OWN pip override, so a morph into a
724
+ // repositioned bubble eases toward where that bubble actually is.
725
+ const prev = activeCueAt(cues, cue.startSec - 1e-3);
726
+ const next = activeCueAt(cues, cue.endSec + 1e-3);
727
+ const from = prev ? cueVideoSlot(prev, face, bands, frame) : base;
728
+ const to = next ? cueVideoSlot(next, face, bands, frame) : base;
729
+
730
+ if (sinceStart < T) return lerpVideo(from, target, easeInOut(sinceStart / T));
731
+ if (untilEnd < T) return lerpVideo(target, to, easeInOut(1 - untilEnd / T));
732
+ return target;
733
+ }
734
+
735
+ /**
736
+ * Caption anchor at time t. Resolved from the settled layout at the line's
737
+ * start (never animated — a caption sliding mid-word reads as a bug).
738
+ */
739
+ export function captionAnchorAt(cues: readonly SceneCue[], tSec: number): number {
740
+ const cue = activeCueAt(cues, tSec);
741
+ return cue ? layoutSlots(cue.layout).captionAnchor : layoutSlots("full-bleed").captionAnchor;
742
+ }
743
+
744
+ function backdropTarget(layout: Layout): number {
745
+ return layout === "pip-bubble" || layout === "graphic-only" ? 1 : 0;
746
+ }
747
+
748
+ /** Opacity of the solid stage backdrop (theme.bg) behind the demoted video slot. */
749
+ export function backdropOpacityAt(allCues: readonly SceneCue[], tSec: number): number {
750
+ const cues = morphCues(allCues);
751
+ const cue = activeCueAt(cues, tSec);
752
+ if (!cue) return 0;
753
+ const target = backdropTarget(cue.layout);
754
+ const T = Math.min(LAYOUT_TRANSITION_SEC, (cue.endSec - cue.startSec) / 2);
755
+ const prev = activeCueAt(cues, cue.startSec - 1e-3);
756
+ const next = activeCueAt(cues, cue.endSec + 1e-3);
757
+ const from = prev ? backdropTarget(prev.layout) : 0;
758
+ const to = next ? backdropTarget(next.layout) : 0;
759
+ const sinceStart = tSec - cue.startSec;
760
+ const untilEnd = cue.endSec - tSec;
761
+ if (sinceStart < T) return lerp(from, target, easeInOut(sinceStart / T));
762
+ if (untilEnd < T) return lerp(target, to, easeInOut(1 - untilEnd / T));
763
+ return target;
764
+ }