@foldspace_npm/harness 0.1.31 → 0.1.32
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/CLAUDE.md +27 -8
- package/README.md +25 -2
- package/bin/ask.mjs +16 -145
- package/bin/attach.mjs +2 -0
- package/bin/cli.mjs +22 -0
- package/bin/inject.mjs +2 -14
- package/bin/record.mjs +412 -0
- package/package.json +1 -1
- package/src/attach-daemon.mjs +18 -0
- package/src/badge-auto.mjs +38 -1
- package/src/cdp-client.mjs +7 -1
- package/src/chrome-binary.mjs +28 -0
- package/src/cli-help.mjs +1 -0
- package/src/cli-registry.mjs +53 -3
- package/src/init.mjs +170 -16
- package/src/panel-driver.mjs +283 -0
- package/src/session-events.mjs +1 -0
- package/src/upgrade.mjs +87 -1
- package/src/video/README.md +29 -0
- package/src/video/assets/foldspace-mark.svg +27 -0
- package/src/video/assets/logo-dark.svg +68 -0
- package/src/video/assets/logo-light.svg +56 -0
- package/src/video/brand.mjs +46 -0
- package/src/video/compose.mjs +476 -0
- package/src/video/composer/composer.mjs +554 -0
- package/src/video/composer/index.html +13 -0
- package/src/video/fonts/OFL.txt +93 -0
- package/src/video/fonts/Poppins-ExtraBold.woff2 +0 -0
- package/src/video/fonts/Poppins-Regular.woff2 +0 -0
- package/src/video/fonts/Poppins-SemiBold.woff2 +0 -0
- package/src/video/recorder.mjs +331 -0
- package/src/video/scenes.mjs +253 -0
- package/src/video/timeline.mjs +726 -0
- package/src/video/vendor/mp4-muxer.LICENSE +21 -0
- package/src/video/vendor/mp4-muxer.min.mjs +5 -0
- package/templates/agent-starter/README.md +5 -0
- package/templates/agent-starter/claude-settings.json +1 -0
- package/templates/agent-starter/gitignore +3 -0
- package/templates/agent-starter/skills/share-a-video/SKILL.md +235 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
// The video's timeline, as pure functions: which moment of a recording shows
|
|
2
|
+
// at each instant of the video, when a caption fades, where the clips
|
|
3
|
+
// dissolve, where the logo goes. No I/O and no Node imports: the composer page
|
|
4
|
+
// loads this same file in the browser, and the tests run it in Node.
|
|
5
|
+
//
|
|
6
|
+
// Times are in seconds. A scene's source times are seconds since its recording
|
|
7
|
+
// started; its output times are seconds since its clip started in the video.
|
|
8
|
+
|
|
9
|
+
export const FPS = 30;
|
|
10
|
+
|
|
11
|
+
// The look settled on after five versions of a customer demo video: the
|
|
12
|
+
// agent's thinking kept but never a long wait, the caption only once the
|
|
13
|
+
// answer is on screen. 4-5 scenes run about 40 s.
|
|
14
|
+
export const TIMING = Object.freeze({
|
|
15
|
+
leadS: 0.4, // the still panel before the first key
|
|
16
|
+
typingMaxS: 1.8, // typing, sped up to at most this
|
|
17
|
+
typingMinS: 1.0,
|
|
18
|
+
headS: 0.8, // just after Enter: the question bubble and "Thinking..." at real speed
|
|
19
|
+
waitMaxS: 0.8, // the rest of the thinking, sped up to at most this
|
|
20
|
+
waitMinS: 0.4,
|
|
21
|
+
answerMaxS: 1.6, // the card loading and the answer streaming in
|
|
22
|
+
answerMinS: 1.0,
|
|
23
|
+
idleGapS: 0.7, // no new picture for longer than this is an idle wait...
|
|
24
|
+
idleKeepS: 0.35, // ...shown for this long
|
|
25
|
+
captionDelayS: 0.25, // after the answer settles
|
|
26
|
+
captionFadeS: 0.35,
|
|
27
|
+
captionHoldMaxS: 2.6,
|
|
28
|
+
captionHoldMinS: 2.0,
|
|
29
|
+
tailS: 0.15, // after the caption has gone
|
|
30
|
+
openingS: 3.0, // the opening card: two lines and "Built with", nothing more to read
|
|
31
|
+
endS: 3.2,
|
|
32
|
+
dissolveS: 0.5,
|
|
33
|
+
fadeOutS: 0.5, // the last moment of the video fades out
|
|
34
|
+
// 4-5 scenes after the opening card: about 40 s.
|
|
35
|
+
maxTotalS: 45,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ------------------------------------------------------------ time maps
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split [start, end] into pieces at real speed, except that a stretch with no
|
|
42
|
+
* new picture for longer than `gapS` is shown for `keepS`: nothing on screen
|
|
43
|
+
* changes during it, so less of it is the same picture.
|
|
44
|
+
* `frameTimes` are the source times of the recorded pictures, ascending.
|
|
45
|
+
* Returns [{ srcStart, srcEnd, outS }].
|
|
46
|
+
*/
|
|
47
|
+
export function compressIdleGaps(frameTimes, start, end, { gapS = TIMING.idleGapS, keepS = TIMING.idleKeepS } = {}) {
|
|
48
|
+
if (!(end > start)) return [];
|
|
49
|
+
const inside = frameTimes.filter((t) => t > start && t < end);
|
|
50
|
+
const marks = [start, ...inside, end];
|
|
51
|
+
const pieces = [];
|
|
52
|
+
let runStart = start;
|
|
53
|
+
for (let i = 0; i < marks.length - 1; i++) {
|
|
54
|
+
const a = marks[i];
|
|
55
|
+
const b = marks[i + 1];
|
|
56
|
+
if (b - a > gapS) {
|
|
57
|
+
if (a > runStart) pieces.push({ srcStart: runStart, srcEnd: a, outS: a - runStart });
|
|
58
|
+
pieces.push({ srcStart: a, srcEnd: b, outS: keepS });
|
|
59
|
+
runStart = b;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (end > runStart) pieces.push({ srcStart: runStart, srcEnd: end, outS: end - runStart });
|
|
63
|
+
return pieces;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const total = (pieces) => pieces.reduce((sum, piece) => sum + piece.outS, 0);
|
|
67
|
+
|
|
68
|
+
/** Speed pieces up evenly so they last at most `maxS`; never slowed down. */
|
|
69
|
+
export function speedTo(pieces, maxS) {
|
|
70
|
+
const length = total(pieces);
|
|
71
|
+
if (!(length > maxS) || !(maxS > 0)) return pieces;
|
|
72
|
+
const factor = maxS / length;
|
|
73
|
+
return pieces.map((piece) => ({ ...piece, outS: piece.outS * factor }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Pieces laid end to end from `outStart`: [{ outStart, outEnd, srcStart, srcEnd }]. */
|
|
77
|
+
function lay(pieces, outStart) {
|
|
78
|
+
const segments = [];
|
|
79
|
+
let at = outStart;
|
|
80
|
+
for (const piece of pieces) {
|
|
81
|
+
if (!(piece.outS > 0)) continue;
|
|
82
|
+
segments.push({ outStart: at, outEnd: at + piece.outS, srcStart: piece.srcStart, srcEnd: piece.srcEnd });
|
|
83
|
+
at += piece.outS;
|
|
84
|
+
}
|
|
85
|
+
return { segments, end: at };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The time budgets for a tightness `k` (0 = roomy, 1 = tightest): typing,
|
|
90
|
+
* thinking, answer and caption hold shrink together, from max toward min.
|
|
91
|
+
*/
|
|
92
|
+
export function budgets(k = 0, timing = TIMING) {
|
|
93
|
+
const between = (max, min) => max - (max - min) * Math.min(1, Math.max(0, k));
|
|
94
|
+
return {
|
|
95
|
+
typingS: between(timing.typingMaxS, timing.typingMinS),
|
|
96
|
+
waitS: between(timing.waitMaxS, timing.waitMinS),
|
|
97
|
+
answerS: between(timing.answerMaxS, timing.answerMinS),
|
|
98
|
+
holdS: between(timing.captionHoldMaxS, timing.captionHoldMinS),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The time map of one scene. `events` are source times in seconds:
|
|
104
|
+
* typingStart, sent, firstReply (may be null), settled. Returns the segments
|
|
105
|
+
* (output time -> source time), the clip's length, and when the caption shows.
|
|
106
|
+
*
|
|
107
|
+
* lead still panel real speed
|
|
108
|
+
* typing the question typed sped up to at most typingS
|
|
109
|
+
* head bubble + "Thinking..." real speed
|
|
110
|
+
* wait the rest of the thinking sped up to at most waitS
|
|
111
|
+
* answer card loading, answer streaming sped up to at most answerS
|
|
112
|
+
* settled the answer, then the caption real speed (the last picture holds)
|
|
113
|
+
*/
|
|
114
|
+
export function sceneTimeMap(events, frameTimes, { k = 0, timing = TIMING } = {}) {
|
|
115
|
+
const budget = budgets(k, timing);
|
|
116
|
+
const idle = { gapS: timing.idleGapS, keepS: timing.idleKeepS };
|
|
117
|
+
const typingStart = Math.max(0, events.typingStart);
|
|
118
|
+
const sent = Math.max(typingStart, events.sent);
|
|
119
|
+
const settled = Math.max(sent, events.settled);
|
|
120
|
+
const headEnd = Math.min(settled, sent + timing.headS);
|
|
121
|
+
// The poll that saw the first reply ran up to half a poll after it; start
|
|
122
|
+
// the answer a little before, so the first words are not skipped.
|
|
123
|
+
const firstReply = events.firstReply == null ? settled : events.firstReply;
|
|
124
|
+
const answerStart = Math.min(settled, Math.max(headEnd, firstReply - 0.4));
|
|
125
|
+
const leadStart = Math.max(0, typingStart - timing.leadS);
|
|
126
|
+
|
|
127
|
+
const phases = [
|
|
128
|
+
[{ srcStart: leadStart, srcEnd: typingStart, outS: typingStart - leadStart }],
|
|
129
|
+
speedTo(compressIdleGaps(frameTimes, typingStart, sent, idle), budget.typingS),
|
|
130
|
+
compressIdleGaps(frameTimes, sent, headEnd, idle),
|
|
131
|
+
speedTo(compressIdleGaps(frameTimes, headEnd, answerStart, idle), budget.waitS),
|
|
132
|
+
speedTo(compressIdleGaps(frameTimes, answerStart, settled, idle), budget.answerS),
|
|
133
|
+
];
|
|
134
|
+
const segments = [];
|
|
135
|
+
let at = 0;
|
|
136
|
+
const marks = {};
|
|
137
|
+
const names = ["lead", "typing", "head", "wait", "answer"];
|
|
138
|
+
phases.forEach((pieces, index) => {
|
|
139
|
+
marks[names[index]] = at;
|
|
140
|
+
const laid = lay(pieces, at);
|
|
141
|
+
segments.push(...laid.segments);
|
|
142
|
+
at = laid.end;
|
|
143
|
+
});
|
|
144
|
+
marks.settled = at;
|
|
145
|
+
const caption = captionWindow(at, { ...timing, holdS: budget.holdS });
|
|
146
|
+
const clipS = caption.outEnd + timing.tailS;
|
|
147
|
+
// After the answer settles, the recording plays on at real speed; past its
|
|
148
|
+
// last picture, that picture holds.
|
|
149
|
+
segments.push({ outStart: at, outEnd: clipS, srcStart: settled, srcEnd: settled + (clipS - at) });
|
|
150
|
+
return { segments, durationS: clipS, marks, caption };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** When a caption fades in, holds and fades out, from the moment the answer settles. */
|
|
154
|
+
export function captionWindow(settledAt, timing = TIMING) {
|
|
155
|
+
const holdS = timing.holdS ?? timing.captionHoldMaxS;
|
|
156
|
+
const inStart = settledAt + timing.captionDelayS;
|
|
157
|
+
const inEnd = inStart + timing.captionFadeS;
|
|
158
|
+
const outStart = inEnd + holdS;
|
|
159
|
+
const outEnd = outStart + timing.captionFadeS;
|
|
160
|
+
return { inStart, inEnd, outStart, outEnd };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The caption's opacity at clip time `t`: 0 while typing and working, 1 while it holds. */
|
|
164
|
+
export function captionAlpha(caption, t) {
|
|
165
|
+
if (t <= caption.inStart || t >= caption.outEnd) return 0;
|
|
166
|
+
if (t < caption.inEnd) return (t - caption.inStart) / (caption.inEnd - caption.inStart);
|
|
167
|
+
if (t <= caption.outStart) return 1;
|
|
168
|
+
return (caption.outEnd - t) / (caption.outEnd - caption.outStart);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The source time shown at clip time `t`. */
|
|
172
|
+
export function sourceTime(segments, t) {
|
|
173
|
+
if (!segments.length) return t;
|
|
174
|
+
let lo = 0;
|
|
175
|
+
let hi = segments.length - 1;
|
|
176
|
+
while (lo < hi) {
|
|
177
|
+
const mid = (lo + hi + 1) >> 1;
|
|
178
|
+
if (segments[mid].outStart <= t) lo = mid;
|
|
179
|
+
else hi = mid - 1;
|
|
180
|
+
}
|
|
181
|
+
const segment = segments[lo];
|
|
182
|
+
if (t <= segment.outStart) return segment.srcStart;
|
|
183
|
+
const span = segment.outEnd - segment.outStart;
|
|
184
|
+
const share = span > 0 ? Math.min(1, (t - segment.outStart) / span) : 1;
|
|
185
|
+
return segment.srcStart + (segment.srcEnd - segment.srcStart) * share;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The last picture recorded at or before `srcT` (the first one before any). */
|
|
189
|
+
export function frameIndexAt(frameTimes, srcT) {
|
|
190
|
+
if (!frameTimes.length) return -1;
|
|
191
|
+
let lo = 0;
|
|
192
|
+
let hi = frameTimes.length - 1;
|
|
193
|
+
if (srcT < frameTimes[0]) return 0;
|
|
194
|
+
while (lo < hi) {
|
|
195
|
+
const mid = (lo + hi + 1) >> 1;
|
|
196
|
+
if (frameTimes[mid] <= srcT) lo = mid;
|
|
197
|
+
else hi = mid - 1;
|
|
198
|
+
}
|
|
199
|
+
return lo;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ------------------------------------------------------------ whole video
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Clips one after another, each dissolving into the next over `dissolveS`.
|
|
206
|
+
* Returns each clip's start and the video's length.
|
|
207
|
+
*/
|
|
208
|
+
export function layoutClips(durations, dissolveS = TIMING.dissolveS) {
|
|
209
|
+
const starts = [];
|
|
210
|
+
let at = 0;
|
|
211
|
+
durations.forEach((duration, index) => {
|
|
212
|
+
starts.push(at);
|
|
213
|
+
at += duration - (index < durations.length - 1 ? dissolveS : 0);
|
|
214
|
+
});
|
|
215
|
+
return { starts, durations: [...durations], totalS: Math.max(0, at), dissolveS };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The clips showing at video time `t`: one, or two while one dissolves into
|
|
220
|
+
* the next. `alpha` is how strongly each is drawn over what is below it.
|
|
221
|
+
*/
|
|
222
|
+
export function activeClips(layout, t) {
|
|
223
|
+
const showing = [];
|
|
224
|
+
layout.starts.forEach((start, index) => {
|
|
225
|
+
const end = start + layout.durations[index];
|
|
226
|
+
if (t >= start && t < end) showing.push(index);
|
|
227
|
+
});
|
|
228
|
+
if (!showing.length) {
|
|
229
|
+
const last = layout.starts.length - 1;
|
|
230
|
+
return last < 0 ? [] : [{ index: last, localT: layout.durations[last], alpha: 1 }];
|
|
231
|
+
}
|
|
232
|
+
return showing.map((index, order) => {
|
|
233
|
+
const localT = t - layout.starts[index];
|
|
234
|
+
const alpha = order === 0 ? 1 : Math.min(1, localT / layout.dissolveS);
|
|
235
|
+
return { index, localT, alpha };
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The fade to black at the very end of the video, over `fadeOutS`. */
|
|
240
|
+
export function fadeOutAlpha(totalS, t, fadeOutS = TIMING.fadeOutS) {
|
|
241
|
+
if (t <= totalS - fadeOutS) return 1;
|
|
242
|
+
return Math.max(0, (totalS - t) / fadeOutS);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The video's length for a tightness `k`. */
|
|
246
|
+
export function videoLength(scenes, { k = 0, titleCard = true, endCard = true, timing = TIMING } = {}) {
|
|
247
|
+
const durations = [
|
|
248
|
+
...(titleCard ? [timing.openingS] : []),
|
|
249
|
+
...scenes.map((scene) => sceneTimeMap(scene.events, scene.frameTimes, { k, timing }).durationS),
|
|
250
|
+
...(endCard ? [timing.endS] : []),
|
|
251
|
+
];
|
|
252
|
+
return layoutClips(durations, timing.dissolveS).totalS;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The roomiest tightness that keeps the video within `maxTotalS`: typing,
|
|
257
|
+
* thinking, answer and caption hold shrink together toward their minimums.
|
|
258
|
+
* When even the tightest is too long, says by how much (too many scenes).
|
|
259
|
+
*/
|
|
260
|
+
export function fitToLength(scenes, { titleCard = true, endCard = true, timing = TIMING } = {}) {
|
|
261
|
+
const length = (k) => videoLength(scenes, { k, titleCard, endCard, timing });
|
|
262
|
+
if (length(0) <= timing.maxTotalS) return { k: 0, totalS: length(0), overS: 0 };
|
|
263
|
+
if (length(1) > timing.maxTotalS) {
|
|
264
|
+
return { k: 1, totalS: length(1), overS: length(1) - timing.maxTotalS };
|
|
265
|
+
}
|
|
266
|
+
let lo = 0;
|
|
267
|
+
let hi = 1;
|
|
268
|
+
for (let step = 0; step < 24; step++) {
|
|
269
|
+
const mid = (lo + hi) / 2;
|
|
270
|
+
if (length(mid) <= timing.maxTotalS) hi = mid;
|
|
271
|
+
else lo = mid;
|
|
272
|
+
}
|
|
273
|
+
return { k: hi, totalS: length(hi), overS: 0 };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ------------------------------------------------------------ layout
|
|
277
|
+
|
|
278
|
+
/** `src` scaled to fit inside `dst`, centred: never cropped, never zoomed past fit. */
|
|
279
|
+
export function containRect(srcW, srcH, dstW, dstH) {
|
|
280
|
+
const scale = Math.min(dstW / srcW, dstH / srcH);
|
|
281
|
+
const w = srcW * scale;
|
|
282
|
+
const h = srcH * scale;
|
|
283
|
+
return { x: (dstW - w) / 2, y: (dstH - h) / 2, w, h, scale };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A box in the recorded viewport (CSS px) placed in the video frame. */
|
|
287
|
+
export function placeBox(box, viewport, content) {
|
|
288
|
+
const sx = content.w / viewport.width;
|
|
289
|
+
const sy = content.h / viewport.height;
|
|
290
|
+
return { x: content.x + box.x * sx, y: content.y + box.y * sy, w: box.width * sx, h: box.height * sy };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const overlap = (a, b) =>
|
|
294
|
+
Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) *
|
|
295
|
+
Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
|
|
296
|
+
|
|
297
|
+
/** The smallest box around `boxes` ({x, y, w, h}), or null. */
|
|
298
|
+
export function unionBox(boxes) {
|
|
299
|
+
const list = boxes.filter(Boolean);
|
|
300
|
+
if (!list.length) return null;
|
|
301
|
+
const x = Math.min(...list.map((box) => box.x));
|
|
302
|
+
const y = Math.min(...list.map((box) => box.y));
|
|
303
|
+
return {
|
|
304
|
+
x,
|
|
305
|
+
y,
|
|
306
|
+
w: Math.max(...list.map((box) => box.x + box.w)) - x,
|
|
307
|
+
h: Math.max(...list.map((box) => box.y + box.h)) - y,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Where the small Foldspace logo may go, in this order when several are
|
|
312
|
+
// equally calm: the frame's corners, and the corners of the app's free area
|
|
313
|
+
// beside the agent panel. Top-left comes late: it is where an app puts its own
|
|
314
|
+
// logo and name. Bottom-left is the caption's.
|
|
315
|
+
export const LOGO_SPOTS = ["top-right", "bottom-right", "beside-panel-bottom", "beside-panel-top", "top-left", "bottom-left"];
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The places the logo could go: { spot, order, x, y, w, h }. `panel` is the
|
|
319
|
+
* agent panel's box in the video frame (every scene's, as one box); beside it
|
|
320
|
+
* means the corner of the app area next to it, at the top and at the bottom.
|
|
321
|
+
*/
|
|
322
|
+
export function logoCandidates({ width, height, logo, margin = 28, gap = 20, content = null, panel = null }) {
|
|
323
|
+
const spots = [];
|
|
324
|
+
const add = (spot, x, y) => spots.push({ spot, order: LOGO_SPOTS.indexOf(spot), x, y, w: logo.w, h: logo.h });
|
|
325
|
+
for (const corner of ["top-right", "bottom-right", "top-left", "bottom-left"]) {
|
|
326
|
+
add(
|
|
327
|
+
corner,
|
|
328
|
+
corner.endsWith("left") ? margin : width - margin - logo.w,
|
|
329
|
+
corner.startsWith("top") ? margin : height - margin - logo.h,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (panel && content) {
|
|
333
|
+
const onRight = panel.x + panel.w / 2 > content.x + content.w / 2;
|
|
334
|
+
const x = onRight ? panel.x - gap - logo.w : panel.x + panel.w + gap;
|
|
335
|
+
const fits = x >= content.x + margin && x + logo.w <= content.x + content.w - margin;
|
|
336
|
+
if (fits) {
|
|
337
|
+
add("beside-panel-top", x, Math.max(content.y, 0) + margin);
|
|
338
|
+
add("beside-panel-bottom", x, Math.min(content.y + content.h, height) - margin - logo.h);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return spots.sort((a, b) => a.order - b.order);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The spot for the logo: one that touches none of `avoid` (the agent panel in
|
|
346
|
+
* every scene, the caption cards), with `pad` to spare. Among the clear ones a
|
|
347
|
+
* spot wholly in the letterbox, off the product, wins; then the calmest -
|
|
348
|
+
* `busy[spot]` is how much the app's pictures change across that patch (its
|
|
349
|
+
* logo, a menu, text), so the Foldspace logo never sits on the app's own
|
|
350
|
+
* logo or controls. Spots within `calmWithin` of the calmest count as calm,
|
|
351
|
+
* and the order of LOGO_SPOTS decides. If every spot touches something, the
|
|
352
|
+
* one that covers least.
|
|
353
|
+
*/
|
|
354
|
+
export function chooseLogoSpot({ candidates, avoid = [], content = null, busy = {}, pad = 12, calmWithin = 0.01 }) {
|
|
355
|
+
const padded = (box) => ({ x: box.x - pad, y: box.y - pad, w: box.w + pad * 2, h: box.h + pad * 2 });
|
|
356
|
+
const scored = candidates.map((candidate) => ({
|
|
357
|
+
...candidate,
|
|
358
|
+
covered: avoid.reduce((sum, other) => sum + overlap(padded(candidate), other), 0),
|
|
359
|
+
offProduct: content ? overlap(candidate, content) === 0 : false,
|
|
360
|
+
busy: Number.isFinite(busy[candidate.spot]) ? busy[candidate.spot] : 0,
|
|
361
|
+
}));
|
|
362
|
+
const clear = scored.filter((candidate) => candidate.covered === 0);
|
|
363
|
+
let pool;
|
|
364
|
+
if (clear.length) {
|
|
365
|
+
const off = clear.filter((candidate) => candidate.offProduct);
|
|
366
|
+
pool = off.length ? off : clear;
|
|
367
|
+
const calmest = Math.min(...pool.map((candidate) => candidate.busy));
|
|
368
|
+
pool = pool.filter((candidate) => candidate.busy <= calmest + calmWithin).sort((a, b) => a.order - b.order);
|
|
369
|
+
} else {
|
|
370
|
+
pool = [...scored].sort((a, b) => a.covered - b.covered || a.order - b.order);
|
|
371
|
+
}
|
|
372
|
+
const best = pool[0];
|
|
373
|
+
return { spot: best.spot, x: best.x, y: best.y, w: best.w, h: best.h, clear: best.covered === 0 };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* How busy a patch of pixels is, 0 (flat) to 1: the mean step in brightness
|
|
378
|
+
* between neighbouring pixels. Text, icons and logos score high, a plain
|
|
379
|
+
* header or empty background near 0. `rgba` is ImageData's data. Also returns
|
|
380
|
+
* the patch's mean brightness, to pick a logo ink that reads on it.
|
|
381
|
+
*/
|
|
382
|
+
export function patchStats(rgba, width, height) {
|
|
383
|
+
if (!(width > 1 && height > 1)) return { busy: 0, brightness: 0 };
|
|
384
|
+
const lum = new Float32Array(width * height);
|
|
385
|
+
let sum = 0;
|
|
386
|
+
for (let i = 0; i < width * height; i++) {
|
|
387
|
+
const value = (0.2126 * rgba[i * 4] + 0.7152 * rgba[i * 4 + 1] + 0.0722 * rgba[i * 4 + 2]) / 255;
|
|
388
|
+
lum[i] = value;
|
|
389
|
+
sum += value;
|
|
390
|
+
}
|
|
391
|
+
let steps = 0;
|
|
392
|
+
let count = 0;
|
|
393
|
+
for (let y = 0; y < height - 1; y++) {
|
|
394
|
+
for (let x = 0; x < width - 1; x++) {
|
|
395
|
+
const at = y * width + x;
|
|
396
|
+
steps += Math.abs(lum[at + 1] - lum[at]) + Math.abs(lum[at + width] - lum[at]);
|
|
397
|
+
count += 2;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { busy: steps / count, brightness: sum / (width * height) };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ------------------------------------------------------------ caption
|
|
404
|
+
|
|
405
|
+
// Poppins' own line metrics, as fractions of the size. A line `lineHeight`
|
|
406
|
+
// high puts its baseline where a browser would, so the cards come out as
|
|
407
|
+
// their approved HTML mockups.
|
|
408
|
+
export const ASCENT = 1.05;
|
|
409
|
+
export const DESCENT = 0.35;
|
|
410
|
+
/** Where the baseline of `size` text sits in a line `lineHeight` high, from its top. */
|
|
411
|
+
export const baselineIn = (lineHeight, size) => (lineHeight - (ASCENT + DESCENT) * size) / 2 + ASCENT * size;
|
|
412
|
+
/** A browser's "normal" line height for Poppins. */
|
|
413
|
+
export const normalLine = (size) => (ASCENT + DESCENT) * size;
|
|
414
|
+
|
|
415
|
+
// The caption card on a 1080-high frame, as the approved mockup (option A,
|
|
416
|
+
// after the product video's storyboard): at most 1040 px wide, bottom-left.
|
|
417
|
+
// Sizes in px; the spacings and the highlight's padding in em.
|
|
418
|
+
export const CAPTION = Object.freeze({
|
|
419
|
+
margin: 48,
|
|
420
|
+
maxW: 1040,
|
|
421
|
+
border: 2,
|
|
422
|
+
radius: 22,
|
|
423
|
+
padX: 36,
|
|
424
|
+
padTop: 30,
|
|
425
|
+
padBottom: 34,
|
|
426
|
+
pill: 21, // the capability pills: white on blue, as the storyboard's
|
|
427
|
+
pillLine: 1.5,
|
|
428
|
+
pillPadX: 15,
|
|
429
|
+
gap: 10, // between the pills and "2 OF 4"
|
|
430
|
+
place: 19, // "2 OF 4"
|
|
431
|
+
placeGap: 8, // beyond the gap, after the last pill
|
|
432
|
+
placeSpacing: 0.1,
|
|
433
|
+
question: 24, // the question, small and muted
|
|
434
|
+
questionTop: 22,
|
|
435
|
+
line: 46, // the scene's line, large and white
|
|
436
|
+
lineMin: 30, // smaller only when it would not fit two lines
|
|
437
|
+
lineStep: 1.2,
|
|
438
|
+
lineSpacing: -0.02,
|
|
439
|
+
lineTop: 10,
|
|
440
|
+
maxLines: 2,
|
|
441
|
+
markPad: 0.12, // the blue block behind *highlighted* words
|
|
442
|
+
markRadius: 0.06,
|
|
443
|
+
});
|
|
444
|
+
export const OPEN_QUOTE = "“";
|
|
445
|
+
export const CLOSE_QUOTE = "”";
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* A scene's line, as written in scenes.json: a "\n" breaks it, and words
|
|
449
|
+
* between asterisks go on the blue highlight. Returns { paragraphs, marks,
|
|
450
|
+
* problem }: each paragraph (one per forced line) a list of words, each word
|
|
451
|
+
* a list of pieces { text, span } - `span` is the highlight it is in, or -1.
|
|
452
|
+
* `problem` is null, "unbalanced" (an asterisk without its pair), "across"
|
|
453
|
+
* (a highlight running over the line break) or "empty" (nothing between two
|
|
454
|
+
* asterisks).
|
|
455
|
+
*/
|
|
456
|
+
export function parseMarked(text) {
|
|
457
|
+
const source = String(text ?? "");
|
|
458
|
+
if ((source.match(/\*/g) || []).length % 2) return { paragraphs: [], marks: 0, problem: "unbalanced" };
|
|
459
|
+
let marks = 0;
|
|
460
|
+
let problem = null;
|
|
461
|
+
const paragraphs = source.split("\n").map((paragraph) => {
|
|
462
|
+
const parts = paragraph.split("*");
|
|
463
|
+
// An odd number of asterisks in one line: its pair is on the other line.
|
|
464
|
+
if (parts.length % 2 === 0) problem = problem || "across";
|
|
465
|
+
const words = [];
|
|
466
|
+
let word = [];
|
|
467
|
+
const endWord = () => {
|
|
468
|
+
if (word.length) words.push(word);
|
|
469
|
+
word = [];
|
|
470
|
+
};
|
|
471
|
+
parts.forEach((part, index) => {
|
|
472
|
+
const marked = index % 2 === 1;
|
|
473
|
+
if (marked && !part.trim()) problem = problem || "empty";
|
|
474
|
+
const span = marked ? marks++ : -1;
|
|
475
|
+
// Spaces end a word; a highlight that starts or ends mid-word shares it.
|
|
476
|
+
part.split(/(\s+)/).forEach((chunk) => {
|
|
477
|
+
if (!chunk) return;
|
|
478
|
+
if (/^\s+$/.test(chunk)) endWord();
|
|
479
|
+
else word.push({ text: chunk, span });
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
endWord();
|
|
483
|
+
return words;
|
|
484
|
+
});
|
|
485
|
+
return { paragraphs, marks, problem };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** The visible characters of a line: no asterisks, no break, single spaces. */
|
|
489
|
+
export function visibleText(text) {
|
|
490
|
+
return String(text ?? "")
|
|
491
|
+
.replace(/\*/g, "")
|
|
492
|
+
.replace(/\s+/g, " ")
|
|
493
|
+
.trim();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Words on one line, as runs to draw: { text, span } where neighbours in the
|
|
498
|
+
* same highlight join up with the space between them, as a browser draws an
|
|
499
|
+
* inline span.
|
|
500
|
+
*/
|
|
501
|
+
function runsOf(words) {
|
|
502
|
+
const runs = [];
|
|
503
|
+
words.forEach((word, index) => {
|
|
504
|
+
const before = index > 0 ? words[index - 1].at(-1).span : -1;
|
|
505
|
+
if (index > 0) {
|
|
506
|
+
// The space is in the highlight only when it is inside one span.
|
|
507
|
+
const span = before >= 0 && before === word[0].span ? before : -1;
|
|
508
|
+
const last = runs.at(-1);
|
|
509
|
+
if (last.span === span) last.text += " ";
|
|
510
|
+
else runs.push({ text: " ", span });
|
|
511
|
+
}
|
|
512
|
+
for (const piece of word) {
|
|
513
|
+
const last = runs.at(-1);
|
|
514
|
+
if (last && last.span === piece.span) last.text += piece.text;
|
|
515
|
+
else runs.push({ ...piece });
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
return runs;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** One drawn line: its runs with their widths, a highlight's padding on each side of its block. */
|
|
522
|
+
function lineOf(words, measure, pad) {
|
|
523
|
+
let x = 0;
|
|
524
|
+
const runs = runsOf(words).map((run) => {
|
|
525
|
+
const marked = run.span >= 0;
|
|
526
|
+
const w = measure(run.text) + (marked ? pad * 2 : 0);
|
|
527
|
+
const placed = { text: run.text, mark: marked, x, w, textX: x + (marked ? pad : 0) };
|
|
528
|
+
x += w;
|
|
529
|
+
return placed;
|
|
530
|
+
});
|
|
531
|
+
return { runs, w: x };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* One paragraph in one line, or two as even as they come; null when neither
|
|
536
|
+
* fits `maxWidth`.
|
|
537
|
+
*/
|
|
538
|
+
function balance(words, maxWidth, measure, pad) {
|
|
539
|
+
const whole = lineOf(words, measure, pad);
|
|
540
|
+
if (whole.w <= maxWidth) return [whole];
|
|
541
|
+
let best = null;
|
|
542
|
+
for (let cut = 1; cut < words.length; cut++) {
|
|
543
|
+
const first = lineOf(words.slice(0, cut), measure, pad);
|
|
544
|
+
const second = lineOf(words.slice(cut), measure, pad);
|
|
545
|
+
if (first.w > maxWidth || second.w > maxWidth) continue;
|
|
546
|
+
const widest = Math.max(first.w, second.w);
|
|
547
|
+
if (!best || widest < best.widest) best = { widest, lines: [first, second] };
|
|
548
|
+
}
|
|
549
|
+
return best ? best.lines : null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Words wrapped to `maxWidth`, one after another, however many lines it takes. */
|
|
553
|
+
function greedy(words, maxWidth, measure, pad) {
|
|
554
|
+
const lines = [];
|
|
555
|
+
let current = [];
|
|
556
|
+
for (const word of words) {
|
|
557
|
+
if (current.length && lineOf([...current, word], measure, pad).w > maxWidth) {
|
|
558
|
+
lines.push(lineOf(current, measure, pad));
|
|
559
|
+
current = [];
|
|
560
|
+
}
|
|
561
|
+
current.push(word);
|
|
562
|
+
}
|
|
563
|
+
if (current.length) lines.push(lineOf(current, measure, pad));
|
|
564
|
+
return lines;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* The scene's line laid out: at its full size in at most two lines - a "\n"
|
|
569
|
+
* breaks it there, otherwise it wraps as evenly as it can - and a little
|
|
570
|
+
* smaller only when it will not fit. `measure(text, size)` is the width of
|
|
571
|
+
* text at that size. Returns { size, lines: [{ runs, w }] }.
|
|
572
|
+
*/
|
|
573
|
+
export function layoutLine(text, maxWidth, measure, c = CAPTION) {
|
|
574
|
+
const { paragraphs } = parseMarked(text);
|
|
575
|
+
for (let size = c.line; size >= c.lineMin; size -= 2) {
|
|
576
|
+
const at = (value) => measure(value, size);
|
|
577
|
+
const pad = c.markPad * size;
|
|
578
|
+
const laid = paragraphs.map((words) => balance(words, maxWidth, at, pad));
|
|
579
|
+
if (laid.some((lines) => !lines)) continue;
|
|
580
|
+
const lines = laid.flat();
|
|
581
|
+
if (lines.length <= c.maxLines) return { size, lines };
|
|
582
|
+
}
|
|
583
|
+
// Too long even at the smallest size (the file check warns): wrap it all.
|
|
584
|
+
const at = (value) => measure(value, c.lineMin);
|
|
585
|
+
return { size: c.lineMin, lines: paragraphs.flatMap((words) => greedy(words, maxWidth, at, c.markPad * c.lineMin)) };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Plain words wrapped to `maxWidth`, keeping any line breaks already there. */
|
|
589
|
+
export function wrapWords(text, maxWidth, measure) {
|
|
590
|
+
const lines = [];
|
|
591
|
+
for (const paragraph of String(text || "").split("\n")) {
|
|
592
|
+
let line = "";
|
|
593
|
+
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
|
|
594
|
+
const next = line ? `${line} ${word}` : word;
|
|
595
|
+
if (line && measure(next) > maxWidth) {
|
|
596
|
+
lines.push(line);
|
|
597
|
+
line = word;
|
|
598
|
+
} else {
|
|
599
|
+
line = next;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
lines.push(line);
|
|
603
|
+
}
|
|
604
|
+
return lines;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The caption card, laid out bottom-left of the frame: its box, and where
|
|
609
|
+
* each part goes inside it. Row 1: the capability pills and "2 OF 4"; row 2:
|
|
610
|
+
* the question, small; row 3: the scene's line, large, with its highlights.
|
|
611
|
+
* `measure(text, { weight, size, spacing })` is the width of Poppins text
|
|
612
|
+
* (spacing in em). As a browser sizes the mockup: as wide as its widest row,
|
|
613
|
+
* at most 1040 px (less on a narrow frame).
|
|
614
|
+
*/
|
|
615
|
+
export function captionLayout(caption, { height, width, measure, content = null }, c = CAPTION) {
|
|
616
|
+
const outer = Math.min(c.maxW, width - c.margin * 2);
|
|
617
|
+
const inner = outer - (c.border + c.padX) * 2;
|
|
618
|
+
const left = c.border + c.padX;
|
|
619
|
+
let y = c.border + c.padTop;
|
|
620
|
+
|
|
621
|
+
// Row 1, its items centred on one line.
|
|
622
|
+
const pillH = c.pill * c.pillLine;
|
|
623
|
+
const pills = caption.capabilities.map((name) => ({
|
|
624
|
+
name,
|
|
625
|
+
w: measure(name, { weight: 600, size: c.pill }) + c.pillPadX * 2,
|
|
626
|
+
}));
|
|
627
|
+
const placeText = String(caption.place).toUpperCase();
|
|
628
|
+
const placeLine = normalLine(c.place);
|
|
629
|
+
const rowH = pills.length ? Math.max(pillH, placeLine) : placeLine;
|
|
630
|
+
let x = left;
|
|
631
|
+
for (const pill of pills) {
|
|
632
|
+
Object.assign(pill, { x, y: y + (rowH - pillH) / 2, h: pillH, textX: x + c.pillPadX });
|
|
633
|
+
pill.baseline = pill.y + baselineIn(pillH, c.pill);
|
|
634
|
+
x += pill.w + c.gap;
|
|
635
|
+
}
|
|
636
|
+
if (pills.length) x += c.placeGap;
|
|
637
|
+
const placeW = measure(placeText, { weight: 600, size: c.place, spacing: c.placeSpacing });
|
|
638
|
+
const place = { text: placeText, x, baseline: y + (rowH - placeLine) / 2 + baselineIn(placeLine, c.place) };
|
|
639
|
+
const rowW = x + placeW - left;
|
|
640
|
+
y += rowH + c.questionTop;
|
|
641
|
+
|
|
642
|
+
// Row 2: the question between curly quotes.
|
|
643
|
+
const questionMeasure = (value) => measure(value, { weight: 400, size: c.question });
|
|
644
|
+
const questionStep = normalLine(c.question);
|
|
645
|
+
const questionLines = wrapWords(`${OPEN_QUOTE}${caption.question}${CLOSE_QUOTE}`, inner, questionMeasure).map(
|
|
646
|
+
(text, index) => ({ text, x: left, baseline: y + index * questionStep + baselineIn(questionStep, c.question) }),
|
|
647
|
+
);
|
|
648
|
+
const questionW = Math.max(...questionLines.map((line) => questionMeasure(line.text)));
|
|
649
|
+
y += questionLines.length * questionStep + c.lineTop;
|
|
650
|
+
|
|
651
|
+
// Row 3: the line.
|
|
652
|
+
const laid = layoutLine(caption.line, inner, (value, size) => measure(value, { weight: 600, size, spacing: c.lineSpacing }), c);
|
|
653
|
+
const lineStep = laid.size * c.lineStep;
|
|
654
|
+
const lines = laid.lines.map((line, index) => {
|
|
655
|
+
const top = y + index * lineStep;
|
|
656
|
+
const baseline = top + baselineIn(lineStep, laid.size);
|
|
657
|
+
return {
|
|
658
|
+
baseline,
|
|
659
|
+
w: line.w,
|
|
660
|
+
runs: line.runs.map((run) => ({ ...run, x: left + run.x, textX: left + run.textX })),
|
|
661
|
+
};
|
|
662
|
+
});
|
|
663
|
+
// The highlight block covers the font's whole height, as a browser paints an inline background.
|
|
664
|
+
const mark = { top: -ASCENT * laid.size, h: (ASCENT + DESCENT) * laid.size, radius: c.markRadius * laid.size };
|
|
665
|
+
y += lines.length * lineStep + c.padBottom + c.border;
|
|
666
|
+
|
|
667
|
+
const textW = Math.min(inner, Math.max(rowW, questionW, ...lines.map((line) => line.w)));
|
|
668
|
+
const w = Math.round(textW + left * 2);
|
|
669
|
+
const h = Math.round(y);
|
|
670
|
+
// On a letterboxed frame (the square preset) the card sits on the picture,
|
|
671
|
+
// never across the black band below it: a see-through card would show a
|
|
672
|
+
// seam there. If the picture is too short for it, the frame's foot it is.
|
|
673
|
+
const pictureFoot = content ? Math.min(height, content.y + content.h) : height;
|
|
674
|
+
const onPicture = pictureFoot - h - c.margin;
|
|
675
|
+
const top = content && onPicture >= content.y + c.margin ? onPicture : height - h - c.margin;
|
|
676
|
+
return {
|
|
677
|
+
x: c.margin,
|
|
678
|
+
y: Math.round(top),
|
|
679
|
+
w,
|
|
680
|
+
h,
|
|
681
|
+
radius: c.radius,
|
|
682
|
+
border: c.border,
|
|
683
|
+
pills,
|
|
684
|
+
place,
|
|
685
|
+
questionLines,
|
|
686
|
+
line: { size: laid.size, lines, mark },
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ------------------------------------------------------------ colour
|
|
691
|
+
|
|
692
|
+
/** [r, g, b] 0-255 of #RRGGBB; null for anything else. */
|
|
693
|
+
export function parseHex(hex) {
|
|
694
|
+
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ""));
|
|
695
|
+
return m ? [m[1], m[2], m[3]].map((part) => parseInt(part, 16)) : null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const toHex = (channels) =>
|
|
699
|
+
`#${channels
|
|
700
|
+
.map((value) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0"))
|
|
701
|
+
.join("")
|
|
702
|
+
.toUpperCase()}`;
|
|
703
|
+
|
|
704
|
+
/** WCAG relative luminance of #RRGGBB, 0..1 (sRGB linearised); null for anything else. */
|
|
705
|
+
export function relativeLuminance(hex) {
|
|
706
|
+
const channels = parseHex(hex);
|
|
707
|
+
if (!channels) return null;
|
|
708
|
+
const [r, g, b] = channels.map((value) => {
|
|
709
|
+
const c = value / 255;
|
|
710
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
711
|
+
});
|
|
712
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** WCAG contrast ratio of two #RRGGBB colours, 1..21. */
|
|
716
|
+
export function contrastRatio(a, b) {
|
|
717
|
+
const [light, dark] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
|
|
718
|
+
return (light + 0.05) / (dark + 0.05);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** `top` at `alpha` over `bottom`, as #RRGGBB. */
|
|
722
|
+
export function blendHex(top, alpha, bottom) {
|
|
723
|
+
const a = parseHex(top);
|
|
724
|
+
const b = parseHex(bottom);
|
|
725
|
+
return toHex(a.map((value, index) => value * alpha + b[index] * (1 - alpha)));
|
|
726
|
+
}
|