@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/fit.ts ADDED
@@ -0,0 +1,536 @@
1
+ import type { SceneComponentId } from "@ossclip/core/browser";
2
+
3
+ /**
4
+ * The fill contract (FINDINGS §23).
5
+ *
6
+ * Components were authored at one fixed type scale and centred in their slot,
7
+ * so what they actually filled was accidental: a three-chip FlowDiagram
8
+ * occupied 8% of `graphic-only`, a one-line TitleCard 12%, while a full
9
+ * TerminalMock overflowed to 169% and bled outside the platform safe area with
10
+ * nothing clipping it. The reference frames fill their space, and that is much
11
+ * of why they read as designed rather than sparse.
12
+ *
13
+ * So the stage now scales each graphic to its slot. `estimateHeightPx` models
14
+ * a component's natural height at a given content width, in the same
15
+ * em-relative style `flowLayout` already uses — analytic and pure, not
16
+ * measured, because these values must be unit-testable in Node and because
17
+ * one component's height depends on an image that may not have decoded yet.
18
+ *
19
+ * The models only have to be good to a few percent: `FILL_TARGET` leaves
20
+ * headroom, and being wrong in the safe direction (over-estimating height,
21
+ * hence under-scaling) costs a little air rather than an overflow.
22
+ */
23
+
24
+ /** Fraction of the slot height a fitted graphic aims to occupy. */
25
+ export const FILL_TARGET = 0.94;
26
+ /** Components that solve their own type against the slot, needing no scale. */
27
+ const SELF_FITTING = new Set<SceneComponentId>([
28
+ "FlowDiagram",
29
+ "StrikethroughReveal",
30
+ "ChatMock",
31
+ "BulletList",
32
+ ]);
33
+
34
+ /**
35
+ * Components whose type is solved against the slot directly, so the stage's
36
+ * uniform scale would only cancel out. FlowDiagram and StrikethroughReveal
37
+ * are WIDTH-bound — a row of chips or a line of big words can only grow
38
+ * until it hits the slot's edge. ChatMock joined them in R11 Task 3 for the
39
+ * inverse reason: its bubble is capped at 82% of its own box, so magnifying
40
+ * it NARROWS the text it can hold — `fitScale`'s ×2.4 turned a 26-character
41
+ * message into a five-line one-word column. Its type solves against the
42
+ * real slot in `chatMetrics` instead.
43
+ */
44
+ export function isSelfFitting(component: SceneComponentId): boolean {
45
+ return SELF_FITTING.has(component);
46
+ }
47
+ /**
48
+ * Never blow a small card up past this. A one-line terminal window stretched
49
+ * to fill the tallest slot would need ~7×, giving it a 200px title bar — the
50
+ * ceiling is a taste limit, and content that hits it simply keeps some air.
51
+ */
52
+ export const MAX_SCALE = 2.4;
53
+ /** Never shrink past legibility on a phone; overflow is clipped instead. */
54
+ const MIN_SCALE = 0.45;
55
+
56
+ /**
57
+ * Rough advance width per character, as a fraction of font size. Slightly
58
+ * conservative: over-estimating width predicts extra wrapped lines, hence a
59
+ * taller estimate and a smaller scale — air rather than overflow. (Less
60
+ * conservative than `flowMetrics`' 0.78, where a bad guess breaks the layout
61
+ * outright rather than costing a few pixels of fill.)
62
+ */
63
+ const CHAR_W_BOLD = 0.58;
64
+ const CHAR_W_UPPER = 0.72;
65
+ /** Monospace advances are uniform and wider than proportional text. */
66
+ const CHAR_W_MONO = 0.62;
67
+
68
+ /** Height of `chars` of text wrapped into `widthPx`, in pixels. */
69
+ function textHeight(
70
+ chars: number,
71
+ fontSizePx: number,
72
+ widthPx: number,
73
+ lineHeight: number,
74
+ charW: number,
75
+ ): number {
76
+ const perLine = Math.max(1, Math.floor(widthPx / Math.max(1, charW * fontSizePx)));
77
+ const lines = Math.max(1, Math.ceil(chars / perLine));
78
+ return lines * fontSizePx * lineHeight;
79
+ }
80
+
81
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
82
+ const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
83
+
84
+ /**
85
+ * A component's natural height at its authored type scale, given the content
86
+ * width it will lay out in. Mirrors each component's own box model — see the
87
+ * pixel values in `components/*.tsx`.
88
+ */
89
+ export function estimateHeightPx(
90
+ component: SceneComponentId,
91
+ props: Record<string, unknown>,
92
+ widthPx: number,
93
+ heightPx = Infinity,
94
+ ): number {
95
+ switch (component) {
96
+ case "TitleCard": {
97
+ const inner = widthPx - 80; // padding "0 40px"
98
+ const emphasis = str(props.emphasis);
99
+ const gap = 28;
100
+ let h = 0;
101
+ let blocks = 0;
102
+ if (str(props.eyebrow)) {
103
+ h += 34 * 1.2;
104
+ blocks++;
105
+ }
106
+ if (emphasis) {
107
+ h += 210 * 0.95;
108
+ blocks++;
109
+ }
110
+ const titleFont = emphasis ? 64 : 96;
111
+ h += textHeight(str(props.title).length, titleFont, inner, 1.05, CHAR_W_UPPER);
112
+ blocks++;
113
+ if (str(props.sub)) {
114
+ h += textHeight(str(props.sub).length, 40, inner, 1.2, CHAR_W_BOLD);
115
+ blocks++;
116
+ }
117
+ return h + gap * Math.max(0, blocks - 1);
118
+ }
119
+ case "StatCard": {
120
+ // Card: padding 44 top/bottom + the taller of label / 110px value.
121
+ const label = textHeight(str(props.label).length, 42, widthPx * 0.55, 1.15, CHAR_W_UPPER);
122
+ let h = 88 + Math.max(label, 110 * 1.15) + 4;
123
+ if (str(props.caption)) h += 30 + (38 * 1.2 + 32 + 4);
124
+ return h;
125
+ }
126
+ case "RuleCard": {
127
+ const inner = widthPx - 60 - 96; // root padding + card padding
128
+ let h = 80; // card padding 40 top/bottom
129
+ h += 30 * 1.2 + 14; // kicker + marginBottom
130
+ h += textHeight(str(props.text).length, 72, inner, 1.02, CHAR_W_UPPER);
131
+ if (str(props.struck)) h += 26 + textHeight(str(props.struck).length, 44, inner, 1.2, CHAR_W_UPPER);
132
+ return h;
133
+ }
134
+ case "StrikethroughReveal": {
135
+ const lines = arr(props.lines).map((l) => str((l as Record<string, unknown>)?.text));
136
+ const font = revealMetrics(lines, widthPx, heightPx);
137
+ const rows = lines.reduce((n, l) => n + revealRows(l, font, widthPx).length, 0);
138
+ return rows * font * 1.08 + font * 0.2 * Math.max(0, rows - 1);
139
+ }
140
+ case "FlowDiagram": {
141
+ // Self-fitting: flowMetrics already solves against both budgets.
142
+ const nodes = arr(props.nodes).map((n) => str(n));
143
+ const n = Math.max(1, nodes.length);
144
+ const { mode, fontSize } = flowMetrics(nodes, widthPx, heightPx);
145
+ return mode === "row" ? fontSize * CHIP_H + 4 : fontSize * (CHIP_H * n + ARROW_ROW_H * (n - 1));
146
+ }
147
+ case "TerminalMock": {
148
+ const windows = arr(props.windows);
149
+ let h = 0;
150
+ for (const w of windows) {
151
+ const lines = arr((w as Record<string, unknown>)?.lines);
152
+ h += 56.8 + 2; // titlebar + its border
153
+ h += Math.max(1, lines.length) * 27 * 1.2 + 8 * Math.max(0, lines.length - 1) + 36;
154
+ h += 4; // window border
155
+ }
156
+ h += 22 * Math.max(0, windows.length - 1);
157
+ if (str(props.fanOut)) h += 22 + 40 * 1.2;
158
+ return h;
159
+ }
160
+ case "ChatMock": {
161
+ // One model, two callers (R11 Task 3.3): the metric solved the font
162
+ // against this same stack-height function, so the two cannot disagree.
163
+ const texts = chatBubbles(props).map((b) => b.text);
164
+ const font = chatMetrics(texts, { widthPx, heightPx });
165
+ return chatStackHeightPx(texts, font, widthPx);
166
+ }
167
+ case "ScreenshotFrame": {
168
+ // The placeholder is a fixed 420px block; a real image is unbounded and
169
+ // assumed 16:9 (the fit only has to be close — it is clamped either way).
170
+ const frame = widthPx * 0.94;
171
+ const body = str(props.src) ? (frame * 9) / 16 : 420 + 60;
172
+ return body + 4;
173
+ }
174
+ case "BulletList": {
175
+ // Self-fitting like the reveal: rows of nowrap uppercase text. Same
176
+ // model bulletMetrics solves against — the two cannot disagree.
177
+ const items = arr(props.items).map((i) => str(i));
178
+ const font = bulletMetrics(items, widthPx, heightPx, Boolean(str(props.title)));
179
+ return bulletStackHeightPx(items.length, font, Boolean(str(props.title)));
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Width the component cannot shrink below at its authored type scale, or 0
186
+ * when everything in it wraps.
187
+ *
188
+ * Scaling up narrows the layout box by the same factor it magnifies, which is
189
+ * harmless for text that reflows — but `white-space: pre` and `nowrap` content
190
+ * keeps its width and simply runs off the edge. That is exactly what clipped
191
+ * "$ ossclip produce raw.mp4" in the terminal once graphics started filling
192
+ * their slot, so the fill scale is bounded by this too.
193
+ */
194
+ /** Longest unbreakable word in a string, in characters. */
195
+ const longestWord = (v: unknown): number =>
196
+ str(v)
197
+ .split(/\s+/)
198
+ .reduce((max, w) => Math.max(max, w.length), 0);
199
+
200
+ export function estimateMinWidthPx(
201
+ component: SceneComponentId,
202
+ props: Record<string, unknown>,
203
+ ): number {
204
+ switch (component) {
205
+ case "RuleCard": {
206
+ // §46: `AI HARNESS` wrapped to two lines and HARNESS — one unbreakable
207
+ // word — was wider than the card, spilling past the rounded rect until
208
+ // the slot's overflow:hidden severed it. The height model knows how
209
+ // text WRAPS but a word cannot, so the longest word is a hard floor on
210
+ // width, exactly like ChatMock's §28a rule. Fonts, paddings and
211
+ // letter-spacings mirror RuleCard.tsx.
212
+ const kicker = longestWord(props.kicker) * 30 * (CHAR_W_UPPER + 0.28);
213
+ const text = longestWord(props.text) * 72 * CHAR_W_UPPER;
214
+ const struck = longestWord(props.struck) * 44 * (CHAR_W_UPPER + 0.04);
215
+ const widest = Math.max(kicker, text, struck);
216
+ return widest > 0 ? 60 + 96 + widest : 0; // root "0 30px" + card "40px 48px"
217
+ }
218
+ case "TitleCard": {
219
+ // Same rule; fonts and spacings mirror TitleCard.tsx. The emphasis
220
+ // block is one huge token (a "861%"-style stat) and renders at 210px.
221
+ const titleFont = str(props.emphasis) ? 64 : 96;
222
+ const eyebrow = longestWord(props.eyebrow) * 34 * (CHAR_W_UPPER + 0.35);
223
+ const emphasis = longestWord(props.emphasis) * 210 * CHAR_W_UPPER;
224
+ const title = longestWord(props.title) * titleFont * (CHAR_W_UPPER + 0.02);
225
+ const sub = longestWord(props.sub) * 40 * CHAR_W_BOLD;
226
+ const widest = Math.max(eyebrow, emphasis, title, sub);
227
+ return widest > 0 ? 80 + widest : 0; // root padding "0 40px"
228
+ }
229
+ case "TerminalMock": {
230
+ let longest = 0;
231
+ for (const w of arr(props.windows)) {
232
+ for (const l of arr((w as Record<string, unknown>)?.lines)) {
233
+ longest = Math.max(longest, str(l).length);
234
+ }
235
+ }
236
+ // root padding 60 + window border 4 + body padding 44
237
+ return longest > 0 ? 108 + longest * 27 * CHAR_W_MONO : 0;
238
+ }
239
+ case "StatCard": {
240
+ // The card is a row: label | gap | value. The value is `nowrap`, and the
241
+ // label can only shrink to its longest word — so the row has a hard
242
+ // floor, and scaling past it pushes the value out through the card's
243
+ // right edge (which is exactly what it did once graphics started
244
+ // filling their slot).
245
+ const value = str(props.value).length * 110 * CHAR_W_UPPER;
246
+ if (value === 0) return 0;
247
+ const label = longestWord(props.label) * 42 * CHAR_W_UPPER;
248
+ return 60 + 104 + label + 40 + value; // root pad + card pad + row
249
+ }
250
+ default:
251
+ return 0;
252
+ }
253
+ }
254
+
255
+ /** Bullet list typography (R16 §67), reveal-style bounds. */
256
+ const BULLET_MIN_FONT = 36;
257
+ const BULLET_MAX_FONT = 120;
258
+ /** Row: 1.15 line-height + 0.45em gap; the kicker title ≈ one smaller row. */
259
+ const BULLET_ROW_H = 1.6;
260
+ const BULLET_TITLE_H = 1.1;
261
+ /** Glyph column: the ▸ plus its gap, in ems. */
262
+ const BULLET_GLYPH_W = 1.1;
263
+
264
+ /** Height of the whole list at a font — ONE model, two callers. */
265
+ export function bulletStackHeightPx(rows: number, font: number, hasTitle: boolean): number {
266
+ return (Math.max(1, rows) * BULLET_ROW_H + (hasTitle ? BULLET_TITLE_H : 0)) * font;
267
+ }
268
+
269
+ /**
270
+ * Type size for a BulletList, solved against the real slot like the reveal:
271
+ * the longest item must fit one row (they are nowrap — a wrapped bullet stops
272
+ * reading as a list), and the stack must fit the height budget.
273
+ */
274
+ export function bulletMetrics(
275
+ items: readonly string[],
276
+ widthPx = 831,
277
+ heightPx = Infinity,
278
+ hasTitle = false,
279
+ ): number {
280
+ const longest = items.reduce((max, t) => Math.max(max, t.length), 0);
281
+ if (longest === 0) return BULLET_MIN_FONT;
282
+ const widthFit = widthPx / (longest * CHAR_W_UPPER + BULLET_GLYPH_W);
283
+ const heightFit =
284
+ (heightPx * FILL_TARGET) /
285
+ (Math.max(1, items.length) * BULLET_ROW_H + (hasTitle ? BULLET_TITLE_H : 0));
286
+ return Math.max(
287
+ BULLET_MIN_FONT,
288
+ Math.min(BULLET_MAX_FONT, Math.floor(Math.min(widthFit, heightFit))),
289
+ );
290
+ }
291
+
292
+ /** Base type size a reveal line is authored at, its floor, and its ceiling. */
293
+ const REVEAL_FONT = 92;
294
+ const REVEAL_MIN_FONT = 44;
295
+ const REVEAL_MAX_FONT = 150;
296
+ /** Arrow-ish glyphs a reveal line may be broken at, longest first. */
297
+ const REVEAL_BREAKS = [" → ", " -> ", " → ", " > "];
298
+
299
+ /**
300
+ * Break a reveal line into the rows it will actually render as.
301
+ *
302
+ * A wrapped line strands its arrow at the end of a row and — worse — leaves
303
+ * the strike rule drawn *between* the rows instead of through either, because
304
+ * the rule is positioned against the whole block (FINDINGS §27). So the line
305
+ * is treated as an unbreakable unit like FlowDiagram's row: it scales to fit,
306
+ * and only when it cannot fit at the legibility floor is it broken — at the
307
+ * arrow, with the arrow LEADING the next row, never trailing the previous
308
+ * one. Each returned row is struck independently.
309
+ */
310
+ export function revealRows(text: string, fontSizePx: number, widthPx: number): string[] {
311
+ const fits = (s: string) => s.length * fontSizePx * CHAR_W_UPPER <= widthPx;
312
+ if (fits(text)) return [text];
313
+ for (const sep of REVEAL_BREAKS) {
314
+ if (!text.includes(sep)) continue;
315
+ const parts = text.split(sep);
316
+ const rows = parts.map((part, i) => (i === 0 ? part : `${sep.trim()} ${part}`.trim()));
317
+ if (rows.every(fits)) return rows;
318
+ }
319
+ return [text];
320
+ }
321
+
322
+ /**
323
+ * Type size for a StrikethroughReveal, sized so its longest line fits one row
324
+ * AND the block fills its slot. Falls to the floor and lets `revealRows` break
325
+ * at an arrow beyond that.
326
+ *
327
+ * Width-bound like FlowDiagram's row, so the stage's uniform scale cancels out
328
+ * against it — this solves both budgets directly instead (FINDINGS §23/§27).
329
+ */
330
+ export function revealMetrics(
331
+ lines: readonly string[],
332
+ widthPx = 831,
333
+ heightPx = Infinity,
334
+ ): number {
335
+ const longest = lines.reduce((max, l) => Math.max(max, l.length), 0);
336
+ if (longest === 0) return REVEAL_FONT;
337
+ const widthFit = widthPx / (longest * CHAR_W_UPPER);
338
+ const heightFit = heightPx / (Math.max(1, lines.length) * 1.28);
339
+ return Math.max(
340
+ REVEAL_MIN_FONT,
341
+ Math.min(REVEAL_MAX_FONT, Math.floor(Math.min(widthFit, heightFit))),
342
+ );
343
+ }
344
+
345
+ /**
346
+ * Chat typography (R11 Task 3), all in COMPOSITION px now that ChatMock is
347
+ * self-fitting (the old `CHAT_FONT = 40` was a layout-space number that only
348
+ * made sense before ×2.4 magnification stopped applying):
349
+ * - `CHAT_TARGET_LINE_CHARS` is the measure — overlay-caption typography
350
+ * wraps around ~22 characters, not body-text 45+.
351
+ * - `CHAT_WRAP_FONT` caps a WRAPPING exchange at caption-sized type. The cap
352
+ * is what makes widening the box REWRAP the text (strictly more characters
353
+ * per line) instead of just magnifying it — the property the Task 2 handle
354
+ * depends on; without it the font scales with the slot and the wrap comes
355
+ * out the same at every width.
356
+ * - `CHAT_MAX_FONT` is what a single short line — the CTA word — may grow
357
+ * to: today's 40 × 2.4 ceiling, expressed directly.
358
+ */
359
+ const CHAT_MIN_FONT = 22;
360
+ const CHAT_WRAP_FONT = 44;
361
+ const CHAT_MAX_FONT = 96;
362
+ const CHAT_TARGET_LINE_CHARS = 22;
363
+ /** Bubble geometry as multiples of the font size — see ChatMock's Bubble. */
364
+ const BUBBLE_PAD_X = 0.85;
365
+ const BUBBLE_MAX_WIDTH = 0.82;
366
+
367
+ /** Height of the bubble stack at a given font. ONE model, two callers
368
+ * (`chatMetrics`' height fit and `estimateHeightPx`) — no disagreement. */
369
+ function chatStackHeightPx(texts: readonly string[], font: number, widthPx: number): number {
370
+ const inner = widthPx - 80; // root padding "0 40px"
371
+ const textW = inner * BUBBLE_MAX_WIDTH - 2 * font * BUBBLE_PAD_X;
372
+ let h = 0;
373
+ for (const t of texts) {
374
+ h += textHeight(t.length, font, textW, 1.2, CHAR_W_BOLD) + font * 1.2 + 4;
375
+ }
376
+ return h + font * 0.5 * Math.max(0, texts.length - 1);
377
+ }
378
+
379
+ /**
380
+ * The bubbles a ChatMock actually renders.
381
+ *
382
+ * A CTA scene shows ONE bubble carrying the keyword and nothing else
383
+ * (FINDINGS §28b). The model likes to add a reply — "link sent 🔗" — which is
384
+ * reassurance for something that has not happened, competes with the word the
385
+ * viewer is supposed to type, and appears nowhere in the reference. The ask is
386
+ * the whole message, so it gets the whole frame. Conversational scenes with no
387
+ * keyword keep the full exchange.
388
+ *
389
+ * Lives here rather than in the component because it decides how many boxes
390
+ * are laid out, which the height model must agree with exactly.
391
+ */
392
+ export function chatBubbles(
393
+ props: Record<string, unknown>,
394
+ ): Array<{ from: "user" | "agent"; text: string }> {
395
+ const keyword = str(props.keyword);
396
+ if (keyword) return [{ from: "user", text: `"${keyword.toUpperCase()}"` }];
397
+ return arr(props.messages).map((m) => {
398
+ const msg = (m ?? {}) as Record<string, unknown>;
399
+ return { from: msg.from === "agent" ? "agent" : "user", text: str(msg.text) };
400
+ });
401
+ }
402
+
403
+ /**
404
+ * Type size for chat bubbles, solved against the REAL slot (R11 Task 3).
405
+ *
406
+ * Sized by LINE LENGTH: the line the type must fit is the whole message when
407
+ * it is short (a CTA word grows toward `CHAT_MAX_FONT` and fills its
408
+ * bubble), and the ~22-character target measure when it wraps (capped at
409
+ * caption-sized `CHAT_WRAP_FONT`, so a wider slot means MORE words per line,
410
+ * never just bigger ones). The old sizer used the longest WORD as the sizer
411
+ * itself, which — through the fill magnifier narrowing the layout box —
412
+ * rendered a 26-character message as five one-word lines.
413
+ *
414
+ * §28a's invariant is kept EXACTLY, as the hard upper bound it always was:
415
+ * the longest unbreakable word must still fit inside bubble-minus-padding,
416
+ * because wrapping cannot save a single word. And the stack must fit the
417
+ * slot's height budget — the font shrinks (down to `CHAT_MIN_FONT`) until
418
+ * the same height model `estimateHeightPx` uses says it fits.
419
+ */
420
+ export function chatMetrics(
421
+ texts: readonly string[],
422
+ slot: { widthPx?: number; heightPx?: number } = {},
423
+ ): number {
424
+ const widthPx = slot.widthPx ?? 831;
425
+ const heightPx = slot.heightPx ?? Infinity;
426
+ const inner = widthPx - 80; // root padding "0 40px"
427
+ const longestText = texts.reduce((max, t) => Math.max(max, t.length), 0);
428
+ if (longestText === 0) return CHAT_WRAP_FONT;
429
+ const longestWord = texts
430
+ .flatMap((t) => t.split(/\s+/))
431
+ .reduce((max, w) => Math.max(max, w.length), 0);
432
+ // bubbleWidth = font·(chars·CHAR_W + 2·PAD_X) ≤ inner·MAX_WIDTH
433
+ const fitChars = (chars: number): number =>
434
+ (inner * BUBBLE_MAX_WIDTH) / (chars * CHAR_W_BOLD + 2 * BUBBLE_PAD_X);
435
+ const line = Math.min(longestText, CHAT_TARGET_LINE_CHARS);
436
+ const ceiling = longestText > CHAT_TARGET_LINE_CHARS ? CHAT_WRAP_FONT : CHAT_MAX_FONT;
437
+ let font = Math.floor(Math.min(fitChars(line), fitChars(longestWord), ceiling));
438
+ while (
439
+ font > CHAT_MIN_FONT &&
440
+ chatStackHeightPx(texts, font, widthPx) > heightPx * FILL_TARGET
441
+ ) {
442
+ font -= 1;
443
+ }
444
+ return Math.max(CHAT_MIN_FONT, font);
445
+ }
446
+
447
+ /** Below this the chips stop reading on a phone — switch shape, don't shrink. */
448
+ export const MIN_ROW_FONT = 26;
449
+ export const MIN_STACK_FONT = 22;
450
+ /** A stack of huge chips stops reading as a diagram and starts reading as a list. */
451
+ const MAX_STACK_FONT = 76;
452
+
453
+ /** Chip is ~2.1em tall; a stacked arrow row adds ~2.05em with its gaps. */
454
+ const CHIP_H = 2.1;
455
+ const ARROW_ROW_H = 2.05;
456
+
457
+ /**
458
+ * FlowDiagram's row/stack decision, given the real slot it must fill.
459
+ *
460
+ * A row is bounded by WIDTH, so scaling cannot make it taller — three chips in
461
+ * the 1037px-tall `graphic-only` slot fill 8% of it no matter what, which is
462
+ * precisely the strip §23 reported. The slot's own shape therefore picks the
463
+ * shape of the diagram: whichever orientation fills more of the height wins,
464
+ * subject to legibility floors. Wide, short slots keep the reference's
465
+ * horizontal flow; tall slots get a vertical one.
466
+ */
467
+ export function flowMetrics(
468
+ nodes: readonly string[],
469
+ widthPx: number,
470
+ heightPx = Infinity,
471
+ ): { mode: "row" | "stack"; fontSize: number } {
472
+ const chars = nodes.reduce((acc, n) => acc + n.length, 0);
473
+ const n = Math.max(1, nodes.length);
474
+ // Conservative width model, all ∝ fontSize. Uppercase 900-weight runs
475
+ // ~0.74em/char + 0.04em letter-spacing; chip padding 2×0.8em; arrow =
476
+ // pad 0.55 + glyph ~0.7 + gap 0.55. The old 0.62em/char model was what
477
+ // let real copy wrap at a font the math said fit (FINDINGS §12) —
478
+ // overestimating costs a couple of font px, underestimating breaks layout.
479
+ const CHAR_W = 0.78;
480
+ const CHIP_PAD = 1.6;
481
+ const ARROW_W = 1.8;
482
+ const budget = widthPx - 20; // root padding "0 10px"
483
+
484
+ const rowFont = Math.floor(
485
+ Math.min(budget / (CHAR_W * chars + CHIP_PAD * n + ARROW_W * (n - 1)), heightPx / CHIP_H),
486
+ );
487
+ const longest = Math.max(...nodes.map((node) => node.length), 1);
488
+ const stackUnits = CHIP_H * n + ARROW_ROW_H * (n - 1);
489
+ const stackFont = Math.floor(
490
+ Math.min(budget / (CHAR_W * longest + CHIP_PAD), heightPx / stackUnits, MAX_STACK_FONT),
491
+ );
492
+
493
+ const rowFits = rowFont >= MIN_ROW_FONT;
494
+ const stackFits = stackFont >= MIN_STACK_FONT;
495
+ if (!stackFits) return { mode: "row", fontSize: Math.max(MIN_ROW_FONT, rowFont) };
496
+ if (!rowFits) return { mode: "stack", fontSize: stackFont };
497
+ // Both are legible — take the one that uses the slot.
498
+ return rowFont * CHIP_H >= stackFont * stackUnits
499
+ ? { mode: "row", fontSize: rowFont }
500
+ : { mode: "stack", fontSize: stackFont };
501
+ }
502
+
503
+ /**
504
+ * Uniform scale that makes a component fill its slot.
505
+ *
506
+ * The component lays out at `slotW / scale` and is then scaled by `scale`, so
507
+ * its rendered width is exactly the slot width while its type grows — three
508
+ * chips end up visibly bigger than six, which is the point of §23. Solved by
509
+ * bisection because the height model depends on the content width, which
510
+ * depends on the scale.
511
+ */
512
+ export function fitScale(
513
+ component: SceneComponentId,
514
+ props: Record<string, unknown>,
515
+ slot: { widthPx: number; heightPx: number },
516
+ ): number {
517
+ // A width-bound component cannot be made taller by scaling — narrowing it
518
+ // shrinks its type by exactly the factor the scale restores. FlowDiagram
519
+ // solves both budgets itself instead.
520
+ if (SELF_FITTING.has(component)) return 1;
521
+ const target = slot.heightPx * FILL_TARGET;
522
+ const fits = (k: number): boolean => estimateHeightPx(component, props, slot.widthPx / k) * k <= target;
523
+ if (!fits(MIN_SCALE)) return MIN_SCALE; // content overflows even shrunk — clip rather than vanish
524
+ // Content that cannot reflow caps the scale outright.
525
+ const minWidth = estimateMinWidthPx(component, props);
526
+ const widthCap = minWidth > 0 ? slot.widthPx / minWidth : MAX_SCALE;
527
+ let lo = MIN_SCALE;
528
+ let hi = Math.max(MIN_SCALE, Math.min(MAX_SCALE, widthCap));
529
+ if (hi <= lo) return lo;
530
+ for (let i = 0; i < 24; i++) {
531
+ const mid = (lo + hi) / 2;
532
+ if (fits(mid)) lo = mid;
533
+ else hi = mid;
534
+ }
535
+ return lo;
536
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * React-free surface of @ossclip/scenes: the stage geometry and the layout
3
+ * solvers. Importable from Node (the CLI plans around the source's own text
4
+ * before rendering) without pulling React or Remotion into the process.
5
+ */
6
+ export * from "./stage";
7
+ export * from "./source-fit";
8
+ export * from "./fit";
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { EdlVideo, type EdlVideoProps } from "./EdlVideo";
2
+ export { CaptionTrack, type CaptionTrackProps } from "./CaptionTrack";
3
+ export { VideoStage } from "./VideoStage";
4
+ export { SceneLayer } from "./SceneLayer";
5
+ export * from "./stage";