@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/LICENSE +27 -0
- package/README.md +20 -0
- package/package.json +38 -0
- package/src/CaptionTrack.tsx +209 -0
- package/src/EdlVideo.tsx +87 -0
- package/src/SceneLayer.tsx +184 -0
- package/src/VideoStage.tsx +264 -0
- package/src/anim.ts +26 -0
- package/src/components/BulletList.tsx +91 -0
- package/src/components/ChatMock.tsx +96 -0
- package/src/components/FlowDiagram.tsx +138 -0
- package/src/components/RuleCard.tsx +86 -0
- package/src/components/ScreenshotFrame.tsx +94 -0
- package/src/components/StatCard.tsx +90 -0
- package/src/components/StrikethroughReveal.tsx +130 -0
- package/src/components/TerminalMock.tsx +105 -0
- package/src/components/TitleCard.tsx +95 -0
- package/src/content-crop.ts +179 -0
- package/src/editable.ts +51 -0
- package/src/fit.ts +536 -0
- package/src/geometry.ts +8 -0
- package/src/index.ts +5 -0
- package/src/source-fit.ts +218 -0
- package/src/stage.ts +764 -0
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
|
+
}
|
package/src/geometry.ts
ADDED
|
@@ -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