@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,554 @@
|
|
|
1
|
+
// The composer: runs in a hidden Chrome that `foldspace record` launches. It
|
|
2
|
+
// reads the plan (made in Node from the recording), draws every frame of the
|
|
3
|
+
// video on a canvas - the opening card, the recorded scenes, captions, logo -
|
|
4
|
+
// encodes H.264 with WebCodecs and muxes an MP4, then posts the file back.
|
|
5
|
+
//
|
|
6
|
+
// The look is the one a customer demo video settled on - the whole product on
|
|
7
|
+
// screen (scaled to fit, never cropped or zoomed), a caption card bottom-left
|
|
8
|
+
// once each answer has settled - in Foldspace's brand, as the product video's
|
|
9
|
+
// storyboard draws it: its one blue on black, white text, Poppins, the
|
|
10
|
+
// official logo. The opening card meets the product's agent; each caption
|
|
11
|
+
// names the capabilities its scene shows and says what the user gets.
|
|
12
|
+
|
|
13
|
+
import { ArrayBufferTarget, Muxer } from "../vendor/mp4-muxer.min.mjs";
|
|
14
|
+
import { BRAND, CAPTION_EDGE, CAPTION_FILL, LOGO_FILES, MARK_RATIO, WORDMARK_RATIO } from "../brand.mjs";
|
|
15
|
+
import {
|
|
16
|
+
CAPTION,
|
|
17
|
+
FPS,
|
|
18
|
+
activeClips,
|
|
19
|
+
baselineIn,
|
|
20
|
+
captionAlpha,
|
|
21
|
+
captionLayout,
|
|
22
|
+
chooseLogoSpot,
|
|
23
|
+
containRect,
|
|
24
|
+
fadeOutAlpha,
|
|
25
|
+
frameIndexAt,
|
|
26
|
+
logoCandidates,
|
|
27
|
+
normalLine,
|
|
28
|
+
parseHex,
|
|
29
|
+
patchStats,
|
|
30
|
+
placeBox,
|
|
31
|
+
sourceTime,
|
|
32
|
+
unionBox,
|
|
33
|
+
wrapWords,
|
|
34
|
+
} from "../timeline.mjs";
|
|
35
|
+
|
|
36
|
+
const BG = BRAND.black;
|
|
37
|
+
const WHITE = BRAND.white;
|
|
38
|
+
const FONT = "Poppins, Roboto, sans-serif";
|
|
39
|
+
const rgba = (hex, alpha) => `rgba(${parseHex(hex).join(", ")}, ${alpha})`;
|
|
40
|
+
// H.264 High, then Main, then Baseline - all level 4.0, enough for 1920x1080 at 30 fps.
|
|
41
|
+
const CODECS = ["avc1.640028", "avc1.4d0028", "avc1.42e028"];
|
|
42
|
+
|
|
43
|
+
const post = (name, body, type = "application/json") =>
|
|
44
|
+
fetch(`../${name}`, { method: "POST", body, headers: { "content-type": type } });
|
|
45
|
+
const fail = (code, message) => post("fail", JSON.stringify({ code, message: String(message || code) }));
|
|
46
|
+
|
|
47
|
+
window.addEventListener("error", (event) => fail("page_error", event.message));
|
|
48
|
+
window.addEventListener("unhandledrejection", (event) => fail("page_error", event.reason?.message || event.reason));
|
|
49
|
+
|
|
50
|
+
async function loadFonts() {
|
|
51
|
+
for (const [file, weight] of [
|
|
52
|
+
["Poppins-Regular.woff2", "400"],
|
|
53
|
+
["Poppins-SemiBold.woff2", "600"],
|
|
54
|
+
["Poppins-ExtraBold.woff2", "800"],
|
|
55
|
+
]) {
|
|
56
|
+
const face = new FontFace("Poppins", `url(../fonts/${file})`, { weight });
|
|
57
|
+
await face.load();
|
|
58
|
+
document.fonts.add(face);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function loadImage(url) {
|
|
63
|
+
const image = new Image();
|
|
64
|
+
image.src = url;
|
|
65
|
+
await image.decode();
|
|
66
|
+
return image;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function pickCodec(width, height, bitrate) {
|
|
70
|
+
if (typeof VideoEncoder !== "function" || typeof VideoFrame !== "function") return null;
|
|
71
|
+
for (const codec of CODECS) {
|
|
72
|
+
const config = {
|
|
73
|
+
codec,
|
|
74
|
+
width,
|
|
75
|
+
height,
|
|
76
|
+
bitrate,
|
|
77
|
+
framerate: FPS,
|
|
78
|
+
latencyMode: "quality",
|
|
79
|
+
hardwareAcceleration: "no-preference",
|
|
80
|
+
avc: { format: "avc" },
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
const support = await VideoEncoder.isConfigSupported(config);
|
|
84
|
+
if (support.supported) return config;
|
|
85
|
+
} catch {
|
|
86
|
+
// try the next profile
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ------------------------------------------------------------ text
|
|
93
|
+
|
|
94
|
+
function font(weight, size) {
|
|
95
|
+
return `${weight} ${size}px ${FONT}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Words wrapped to `maxWidth` in the font set on `ctx`, keeping any line breaks already there. */
|
|
99
|
+
function wrap(ctx, text, maxWidth) {
|
|
100
|
+
return wrapWords(text, maxWidth, (value) => ctx.measureText(value).width);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The largest size, down to `min`, at which `text` fits on one line; wraps below that. */
|
|
104
|
+
function fitText(ctx, text, weight, size, min, maxWidth, spacing = 0) {
|
|
105
|
+
let at = size;
|
|
106
|
+
for (; at > min; at -= 2) {
|
|
107
|
+
ctx.font = font(weight, at);
|
|
108
|
+
ctx.letterSpacing = `${spacing * at}px`;
|
|
109
|
+
if (ctx.measureText(text).width <= maxWidth) {
|
|
110
|
+
ctx.letterSpacing = "0px";
|
|
111
|
+
return { size: at, lines: [text] };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
ctx.font = font(weight, min);
|
|
115
|
+
ctx.letterSpacing = `${spacing * min}px`;
|
|
116
|
+
const lines = wrap(ctx, text, maxWidth);
|
|
117
|
+
ctx.letterSpacing = "0px";
|
|
118
|
+
return { size: min, lines };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function drawLines(ctx, lines, x, y, lineHeight, align = "left") {
|
|
122
|
+
ctx.textAlign = align;
|
|
123
|
+
lines.forEach((line, index) => ctx.fillText(line, x, y + index * lineHeight));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Eyebrows are spaced capitals, as on foldspace.ai.
|
|
127
|
+
const EYEBROW_SPACING = 0.12;
|
|
128
|
+
|
|
129
|
+
function paintBlack(ctx, width, height) {
|
|
130
|
+
ctx.fillStyle = BG;
|
|
131
|
+
ctx.fillRect(0, 0, width, height);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The card's opening fade, from black. */
|
|
135
|
+
function fadeIn(ctx, card, localT, { width, height }) {
|
|
136
|
+
if (card.fadeInS && localT < card.fadeInS) {
|
|
137
|
+
ctx.fillStyle = rgba(BG, 1 - localT / card.fadeInS);
|
|
138
|
+
ctx.fillRect(0, 0, width, height);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ------------------------------------------------------------ cards
|
|
143
|
+
|
|
144
|
+
// On a 1080-high frame. The title shrinks for a long product name or the
|
|
145
|
+
// square frame, down to `titleMin`, and only then wraps.
|
|
146
|
+
const OPENING = { title: 96, titleMin: 56, lineHeight: 1.1, side: 80, gap: 34, by: 24, byGap: 14, logo: 20 };
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The opening card, on black and centred: "Meet the <product>" in white over
|
|
150
|
+
* "Product Agent" in Foldspace blue, both ExtraBold at one size, then "Built
|
|
151
|
+
* with" in muted grey followed by the white wordmark.
|
|
152
|
+
*/
|
|
153
|
+
function drawOpening(ctx, card, localT, { width, height }, art) {
|
|
154
|
+
paintBlack(ctx, width, height);
|
|
155
|
+
const title = fitText(ctx, `Meet the ${card.product}`, 800, OPENING.title, OPENING.titleMin, width - OPENING.side * 2);
|
|
156
|
+
const lines = [...title.lines.map((text) => ({ text, color: WHITE })), { text: "Product Agent", color: BRAND.blue }];
|
|
157
|
+
const step = title.size * OPENING.lineHeight;
|
|
158
|
+
const byLine = normalLine(OPENING.by);
|
|
159
|
+
let y = (height - (lines.length * step + OPENING.gap + byLine)) / 2;
|
|
160
|
+
ctx.textBaseline = "alphabetic";
|
|
161
|
+
ctx.textAlign = "center";
|
|
162
|
+
ctx.font = font(800, title.size);
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
ctx.fillStyle = line.color;
|
|
165
|
+
ctx.fillText(line.text, width / 2, y + baselineIn(step, title.size));
|
|
166
|
+
y += step;
|
|
167
|
+
}
|
|
168
|
+
y += OPENING.gap;
|
|
169
|
+
ctx.font = font(400, OPENING.by);
|
|
170
|
+
const byW = ctx.measureText("Built with").width;
|
|
171
|
+
const logoW = OPENING.logo * WORDMARK_RATIO;
|
|
172
|
+
const x = (width - (byW + OPENING.byGap + logoW)) / 2;
|
|
173
|
+
ctx.textAlign = "left";
|
|
174
|
+
ctx.fillStyle = BRAND.muted;
|
|
175
|
+
ctx.fillText("Built with", x, y + baselineIn(byLine, OPENING.by));
|
|
176
|
+
ctx.drawImage(art.onDark, x + byW + OPENING.byGap, y + (byLine - OPENING.logo) / 2, logoW, OPENING.logo);
|
|
177
|
+
fadeIn(ctx, card, localT, { width, height });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The closing card, on black and centred: the blue mark, the eyebrow in
|
|
182
|
+
* muted spaced capitals, the title in white, a short blue rule, and the
|
|
183
|
+
* last line in muted grey.
|
|
184
|
+
*/
|
|
185
|
+
function drawEndCard(ctx, card, localT, { width, height }, art) {
|
|
186
|
+
paintBlack(ctx, width, height);
|
|
187
|
+
const maxWidth = width - 160;
|
|
188
|
+
const eyebrow = fitText(ctx, card.eyebrow.toUpperCase(), 600, 28, 18, maxWidth, EYEBROW_SPACING);
|
|
189
|
+
const title = fitText(ctx, card.title, 800, 84, 48, maxWidth);
|
|
190
|
+
const sub = fitText(ctx, card.sub, 400, 30, 20, maxWidth);
|
|
191
|
+
const parts = [
|
|
192
|
+
...(card.mark ? [{ kind: "mark", h: 60, gap: 30 }] : []),
|
|
193
|
+
{ kind: "eyebrow", ...eyebrow, step: eyebrow.size * 1.3, gap: 18 },
|
|
194
|
+
{ kind: "title", ...title, step: title.size * 1.15, gap: 30 },
|
|
195
|
+
{ kind: "rule", w: 120, h: 6, gap: 30 },
|
|
196
|
+
{ kind: "sub", ...sub, step: sub.size * 1.3, gap: 0 },
|
|
197
|
+
];
|
|
198
|
+
for (const part of parts) if (part.lines) part.h = part.lines.length * part.step;
|
|
199
|
+
const cx = width / 2;
|
|
200
|
+
let y = Math.round((height - parts.reduce((sum, part) => sum + part.h + part.gap, 0)) / 2);
|
|
201
|
+
ctx.textBaseline = "top";
|
|
202
|
+
for (const part of parts) {
|
|
203
|
+
if (part.kind === "mark") {
|
|
204
|
+
const w = part.h * MARK_RATIO;
|
|
205
|
+
ctx.drawImage(art.mark, cx - w / 2, y, w, part.h);
|
|
206
|
+
} else if (part.kind === "eyebrow") {
|
|
207
|
+
ctx.fillStyle = BRAND.muted;
|
|
208
|
+
ctx.font = font(600, part.size);
|
|
209
|
+
ctx.letterSpacing = `${EYEBROW_SPACING * part.size}px`;
|
|
210
|
+
// The spacing trails the last letter: shift by half of it to stay centred.
|
|
211
|
+
drawLines(ctx, part.lines, cx + (EYEBROW_SPACING * part.size) / 2, y, part.step, "center");
|
|
212
|
+
ctx.letterSpacing = "0px";
|
|
213
|
+
} else if (part.kind === "title") {
|
|
214
|
+
ctx.fillStyle = WHITE;
|
|
215
|
+
ctx.font = font(800, part.size);
|
|
216
|
+
drawLines(ctx, part.lines, cx, y, part.step, "center");
|
|
217
|
+
} else if (part.kind === "rule") {
|
|
218
|
+
ctx.fillStyle = BRAND.blue;
|
|
219
|
+
ctx.beginPath();
|
|
220
|
+
ctx.roundRect(Math.round(cx - part.w / 2), y, part.w, part.h, part.h / 2);
|
|
221
|
+
ctx.fill();
|
|
222
|
+
} else if (part.kind === "sub") {
|
|
223
|
+
ctx.fillStyle = BRAND.muted;
|
|
224
|
+
ctx.font = font(400, part.size);
|
|
225
|
+
drawLines(ctx, part.lines, cx, y, part.step, "center");
|
|
226
|
+
}
|
|
227
|
+
y += part.h + part.gap;
|
|
228
|
+
}
|
|
229
|
+
fadeIn(ctx, card, localT, { width, height });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ------------------------------------------------------------ caption
|
|
233
|
+
|
|
234
|
+
/** The width of Poppins text: what timeline.mjs's captionLayout measures with. */
|
|
235
|
+
function measurer(ctx) {
|
|
236
|
+
return (text, { weight, size, spacing = 0 }) => {
|
|
237
|
+
ctx.font = font(weight, size);
|
|
238
|
+
ctx.letterSpacing = `${spacing * size}px`;
|
|
239
|
+
const width = ctx.measureText(text).width;
|
|
240
|
+
ctx.letterSpacing = "0px";
|
|
241
|
+
return width;
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The caption card's size and parts, bottom-left of the picture. */
|
|
246
|
+
function measureCaption(ctx, caption, { width, height }, viewport) {
|
|
247
|
+
const content = viewport ? containRect(viewport.width, viewport.height, width, height) : null;
|
|
248
|
+
return captionLayout(caption, { width, height, measure: measurer(ctx), content });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The panel colour at 88%, with a faint white edge, as the storyboard's
|
|
253
|
+
* cards. Row 1: the capabilities as blue pills with white names, then "2 OF
|
|
254
|
+
* 4" in muted grey. Row 2: the question, small and muted, in curly quotes.
|
|
255
|
+
* Row 3: the scene's line, large and white, its highlighted words on a blue
|
|
256
|
+
* block - each line's part of a highlight its own block, as a browser draws
|
|
257
|
+
* the storyboard's Marked.
|
|
258
|
+
*/
|
|
259
|
+
function drawCaption(ctx, card, alpha) {
|
|
260
|
+
if (!(alpha > 0)) return;
|
|
261
|
+
const c = CAPTION;
|
|
262
|
+
ctx.save();
|
|
263
|
+
ctx.globalAlpha = alpha;
|
|
264
|
+
ctx.translate(card.x, card.y);
|
|
265
|
+
ctx.beginPath();
|
|
266
|
+
ctx.roundRect(0, 0, card.w, card.h, card.radius);
|
|
267
|
+
ctx.fillStyle = rgba(CAPTION_FILL.color, CAPTION_FILL.alpha);
|
|
268
|
+
ctx.fill();
|
|
269
|
+
ctx.beginPath();
|
|
270
|
+
ctx.roundRect(card.border / 2, card.border / 2, card.w - card.border, card.h - card.border, card.radius - card.border / 2);
|
|
271
|
+
ctx.lineWidth = card.border;
|
|
272
|
+
ctx.strokeStyle = CAPTION_EDGE;
|
|
273
|
+
ctx.stroke();
|
|
274
|
+
ctx.textBaseline = "alphabetic";
|
|
275
|
+
ctx.textAlign = "left";
|
|
276
|
+
|
|
277
|
+
ctx.font = font(600, c.pill);
|
|
278
|
+
for (const pill of card.pills) {
|
|
279
|
+
ctx.beginPath();
|
|
280
|
+
ctx.roundRect(pill.x, pill.y, pill.w, pill.h, pill.h / 2);
|
|
281
|
+
ctx.fillStyle = BRAND.blue;
|
|
282
|
+
ctx.fill();
|
|
283
|
+
ctx.fillStyle = WHITE;
|
|
284
|
+
ctx.fillText(pill.name, pill.textX, pill.baseline);
|
|
285
|
+
}
|
|
286
|
+
ctx.fillStyle = BRAND.muted;
|
|
287
|
+
ctx.font = font(600, c.place);
|
|
288
|
+
ctx.letterSpacing = `${c.placeSpacing * c.place}px`;
|
|
289
|
+
ctx.fillText(card.place.text, card.place.x, card.place.baseline);
|
|
290
|
+
ctx.letterSpacing = "0px";
|
|
291
|
+
|
|
292
|
+
ctx.fillStyle = BRAND.muted;
|
|
293
|
+
ctx.font = font(400, c.question);
|
|
294
|
+
for (const line of card.questionLines) ctx.fillText(line.text, line.x, line.baseline);
|
|
295
|
+
|
|
296
|
+
// Line by line, the blocks first and the words over them, as a browser
|
|
297
|
+
// paints inline backgrounds.
|
|
298
|
+
const { size, lines, mark } = card.line;
|
|
299
|
+
ctx.font = font(600, size);
|
|
300
|
+
ctx.letterSpacing = `${c.lineSpacing * size}px`;
|
|
301
|
+
for (const line of lines) {
|
|
302
|
+
for (const run of line.runs) {
|
|
303
|
+
if (!run.mark) continue;
|
|
304
|
+
ctx.beginPath();
|
|
305
|
+
ctx.roundRect(run.x, line.baseline + mark.top, run.w, mark.h, mark.radius);
|
|
306
|
+
ctx.fillStyle = BRAND.blue;
|
|
307
|
+
ctx.fill();
|
|
308
|
+
}
|
|
309
|
+
ctx.fillStyle = WHITE;
|
|
310
|
+
for (const run of line.runs) ctx.fillText(run.text, run.textX, line.baseline);
|
|
311
|
+
}
|
|
312
|
+
ctx.letterSpacing = "0px";
|
|
313
|
+
ctx.restore();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ------------------------------------------------------------ logo
|
|
317
|
+
|
|
318
|
+
// The official wordmark, small: 20 px high (about 190 px wide) on a
|
|
319
|
+
// 1080-high frame. No backing pill - that read as a watermark bar - so the
|
|
320
|
+
// white one goes on dark patches and the dark one on light patches.
|
|
321
|
+
const LOGO_H = 20;
|
|
322
|
+
|
|
323
|
+
function measureLogo() {
|
|
324
|
+
return { w: Math.round(LOGO_H * WORDMARK_RATIO), h: LOGO_H };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The wordmark; `ink` "light" (the white one) on a dark patch, "dark" on a light one. */
|
|
328
|
+
function drawLogo(ctx, art, logo, place, alpha, ink = "light") {
|
|
329
|
+
if (!(alpha > 0)) return;
|
|
330
|
+
const light = ink !== "dark";
|
|
331
|
+
ctx.save();
|
|
332
|
+
ctx.globalAlpha = alpha;
|
|
333
|
+
// A soft halo of the opposite tone keeps it apart from what is behind it.
|
|
334
|
+
ctx.shadowColor = light ? "rgba(0, 0, 0, 0.5)" : "rgba(255, 255, 255, 0.7)";
|
|
335
|
+
ctx.shadowBlur = 6;
|
|
336
|
+
ctx.drawImage(light ? art.onDark : art.onLight, place.x, place.y, logo.w, logo.h);
|
|
337
|
+
ctx.restore();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Look at each scene's first and last picture under every spot the logo could
|
|
342
|
+
* take: how busy each patch is (the app's own logo, a menu, text) and how
|
|
343
|
+
* bright. Returns { busy: { spot: mean }, brightness: [per clip { spot: mean }] }.
|
|
344
|
+
*/
|
|
345
|
+
async function lookUnderSpots(clips, candidates, { width, height }) {
|
|
346
|
+
const scratch = new OffscreenCanvas(width, height);
|
|
347
|
+
const sctx = scratch.getContext("2d", { alpha: false, willReadFrequently: true });
|
|
348
|
+
const busy = {};
|
|
349
|
+
const seen = {};
|
|
350
|
+
const brightness = clips.map(() => ({}));
|
|
351
|
+
for (const [index, clip] of clips.entries()) {
|
|
352
|
+
if (clip.kind !== "scene" || !clip.frames.length) continue;
|
|
353
|
+
const sums = {};
|
|
354
|
+
let looked = 0;
|
|
355
|
+
for (const pick of new Set([0, clip.frames.length - 1])) {
|
|
356
|
+
let picture;
|
|
357
|
+
try {
|
|
358
|
+
const response = await fetch(`../rec/${clip.dir}/${clip.frames[pick]}`);
|
|
359
|
+
picture = await createImageBitmap(await response.blob());
|
|
360
|
+
} catch {
|
|
361
|
+
continue; // a picture that will not load is one less look
|
|
362
|
+
}
|
|
363
|
+
sctx.fillStyle = BG;
|
|
364
|
+
sctx.fillRect(0, 0, width, height);
|
|
365
|
+
const fit = containRect(picture.width, picture.height, width, height);
|
|
366
|
+
sctx.drawImage(picture, fit.x, fit.y, fit.w, fit.h);
|
|
367
|
+
picture.close();
|
|
368
|
+
looked += 1;
|
|
369
|
+
for (const spot of candidates) {
|
|
370
|
+
// A little around it too: an edge just outside still crowds it.
|
|
371
|
+
const x = Math.max(0, Math.round(spot.x - 8));
|
|
372
|
+
const y = Math.max(0, Math.round(spot.y - 8));
|
|
373
|
+
const w = Math.min(width - x, Math.round(spot.w + 16));
|
|
374
|
+
const h = Math.min(height - y, Math.round(spot.h + 16));
|
|
375
|
+
const stats = patchStats(sctx.getImageData(x, y, w, h).data, w, h);
|
|
376
|
+
busy[spot.spot] = (busy[spot.spot] || 0) + stats.busy;
|
|
377
|
+
seen[spot.spot] = (seen[spot.spot] || 0) + 1;
|
|
378
|
+
sums[spot.spot] = (sums[spot.spot] || 0) + stats.brightness;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
for (const spot of Object.keys(sums)) brightness[index][spot] = sums[spot] / looked;
|
|
382
|
+
}
|
|
383
|
+
for (const spot of Object.keys(busy)) busy[spot] /= seen[spot];
|
|
384
|
+
return { busy, brightness };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ------------------------------------------------------------ scenes
|
|
388
|
+
|
|
389
|
+
/** The recorded pictures of one scene, decoded as they are needed. */
|
|
390
|
+
function frameSource(scene) {
|
|
391
|
+
let index = -1;
|
|
392
|
+
let bitmap = null;
|
|
393
|
+
return {
|
|
394
|
+
async at(srcT) {
|
|
395
|
+
const wanted = frameIndexAt(scene.frameTimes, srcT);
|
|
396
|
+
if (wanted < 0) return null;
|
|
397
|
+
if (wanted !== index) {
|
|
398
|
+
// A picture that will not load or decode leaves the one before on
|
|
399
|
+
// screen: one lost picture is not worth a lost video.
|
|
400
|
+
try {
|
|
401
|
+
const response = await fetch(`../rec/${scene.dir}/${scene.frames[wanted]}`);
|
|
402
|
+
if (!response.ok) throw new Error(`picture ${wanted}: ${response.status}`);
|
|
403
|
+
const next = await createImageBitmap(await response.blob());
|
|
404
|
+
bitmap?.close();
|
|
405
|
+
bitmap = next;
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (!bitmap) throw error;
|
|
408
|
+
}
|
|
409
|
+
index = wanted;
|
|
410
|
+
}
|
|
411
|
+
return bitmap;
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function drawScene(ctx, scene, source, captionCard, localT, { width, height }) {
|
|
417
|
+
ctx.fillStyle = BG;
|
|
418
|
+
ctx.fillRect(0, 0, width, height);
|
|
419
|
+
const picture = await source.at(sourceTime(scene.segments, localT));
|
|
420
|
+
if (picture) {
|
|
421
|
+
const fit = containRect(picture.width, picture.height, width, height);
|
|
422
|
+
ctx.imageSmoothingEnabled = true;
|
|
423
|
+
ctx.imageSmoothingQuality = "high";
|
|
424
|
+
ctx.drawImage(picture, fit.x, fit.y, fit.w, fit.h);
|
|
425
|
+
}
|
|
426
|
+
drawCaption(ctx, captionCard, captionAlpha(scene.caption, localT));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ------------------------------------------------------------ main
|
|
430
|
+
|
|
431
|
+
async function main() {
|
|
432
|
+
const plan = await (await fetch("../plan.json")).json();
|
|
433
|
+
const { width, height } = plan;
|
|
434
|
+
await loadFonts();
|
|
435
|
+
const art = {
|
|
436
|
+
onDark: await loadImage(`../assets/${LOGO_FILES.onDark}`),
|
|
437
|
+
onLight: await loadImage(`../assets/${LOGO_FILES.onLight}`),
|
|
438
|
+
mark: await loadImage(`../assets/${LOGO_FILES.mark}`),
|
|
439
|
+
};
|
|
440
|
+
const config = await pickCodec(width, height, plan.bitrate);
|
|
441
|
+
if (!config) {
|
|
442
|
+
await fail("no_h264", "This Chrome cannot encode H.264 video.");
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
447
|
+
const ctx = canvas.getContext("2d", { alpha: false });
|
|
448
|
+
const layer = new OffscreenCanvas(width, height);
|
|
449
|
+
const layerCtx = layer.getContext("2d", { alpha: false });
|
|
450
|
+
|
|
451
|
+
const clips = plan.clips;
|
|
452
|
+
const sources = clips.map((clip) => (clip.kind === "scene" ? frameSource(clip) : null));
|
|
453
|
+
const captions = clips.map((clip) => (clip.kind === "scene" ? measureCaption(ctx, clip.captionText, plan, clip.viewport) : null));
|
|
454
|
+
|
|
455
|
+
// One spot for the whole video, clear of the agent panel in every scene and
|
|
456
|
+
// of every caption card, and on the calmest part of the app - never on its
|
|
457
|
+
// own logo. The ink follows each scene's brightness there.
|
|
458
|
+
const logo = measureLogo();
|
|
459
|
+
const avoid = [];
|
|
460
|
+
const panels = [];
|
|
461
|
+
let content = null;
|
|
462
|
+
clips.forEach((clip, index) => {
|
|
463
|
+
if (clip.kind !== "scene") return;
|
|
464
|
+
avoid.push(captions[index]);
|
|
465
|
+
if (!clip.viewport) return;
|
|
466
|
+
const fit = containRect(clip.viewport.width, clip.viewport.height, width, height);
|
|
467
|
+
content = content || fit;
|
|
468
|
+
for (const box of clip.panel) panels.push(placeBox(box, clip.viewport, fit));
|
|
469
|
+
});
|
|
470
|
+
avoid.push(...panels);
|
|
471
|
+
const candidates = logoCandidates({ width, height, logo, content, panel: unionBox(panels) });
|
|
472
|
+
const under = await lookUnderSpots(clips, candidates, plan);
|
|
473
|
+
const place = chooseLogoSpot({ candidates, avoid, content, busy: under.busy });
|
|
474
|
+
const inks = clips.map((clip, index) =>
|
|
475
|
+
clip.kind === "scene" && (under.brightness[index][place.spot] ?? 0) > 0.55 ? "dark" : "light",
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const muxer = new Muxer({
|
|
479
|
+
target: new ArrayBufferTarget(),
|
|
480
|
+
video: { codec: "avc", width, height, frameRate: FPS },
|
|
481
|
+
fastStart: "in-memory",
|
|
482
|
+
firstTimestampBehavior: "offset",
|
|
483
|
+
});
|
|
484
|
+
let encodeError = null;
|
|
485
|
+
const encoder = new VideoEncoder({
|
|
486
|
+
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
|
|
487
|
+
error: (error) => {
|
|
488
|
+
encodeError = error;
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
encoder.configure(config);
|
|
492
|
+
|
|
493
|
+
// The small logo is drawn with each scene, in that scene's ink, so a
|
|
494
|
+
// dissolve blends one into the other. The cards carry the logo themselves.
|
|
495
|
+
const drawClip = async (target, index, localT) => {
|
|
496
|
+
const clip = clips[index];
|
|
497
|
+
if (clip.kind === "scene") {
|
|
498
|
+
await drawScene(target, clip, sources[index], captions[index], localT, plan);
|
|
499
|
+
drawLogo(target, art, logo, place, 1, inks[index]);
|
|
500
|
+
} else if (clip.kind === "opening") {
|
|
501
|
+
drawOpening(target, clip, localT, plan, art);
|
|
502
|
+
} else {
|
|
503
|
+
drawEndCard(target, clip, localT, plan, art);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const total = Math.round(plan.layout.totalS * FPS);
|
|
508
|
+
const posterAt = Math.min(total - 1, Math.max(0, Math.round(plan.posterS * FPS)));
|
|
509
|
+
let poster = null;
|
|
510
|
+
for (let i = 0; i < total; i++) {
|
|
511
|
+
const t = i / FPS;
|
|
512
|
+
const showing = activeClips(plan.layout, t);
|
|
513
|
+
for (const [order, item] of showing.entries()) {
|
|
514
|
+
if (order === 0) {
|
|
515
|
+
await drawClip(ctx, item.index, item.localT);
|
|
516
|
+
} else {
|
|
517
|
+
await drawClip(layerCtx, item.index, item.localT);
|
|
518
|
+
ctx.save();
|
|
519
|
+
ctx.globalAlpha = item.alpha;
|
|
520
|
+
ctx.drawImage(layer, 0, 0);
|
|
521
|
+
ctx.restore();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const fade = fadeOutAlpha(plan.layout.totalS, t);
|
|
525
|
+
if (fade < 1) {
|
|
526
|
+
ctx.fillStyle = rgba(BG, 1 - fade);
|
|
527
|
+
ctx.fillRect(0, 0, width, height);
|
|
528
|
+
}
|
|
529
|
+
if (i === posterAt) poster = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.9 });
|
|
530
|
+
|
|
531
|
+
const frame = new VideoFrame(canvas, {
|
|
532
|
+
timestamp: Math.round((i * 1e6) / FPS),
|
|
533
|
+
duration: Math.round(1e6 / FPS),
|
|
534
|
+
});
|
|
535
|
+
encoder.encode(frame, { keyFrame: i % (FPS * 2) === 0 });
|
|
536
|
+
frame.close();
|
|
537
|
+
if (encodeError) throw encodeError;
|
|
538
|
+
while (encoder.encodeQueueSize > 4) await new Promise((resolve) => setTimeout(resolve, 1));
|
|
539
|
+
if (i % (FPS * 2) === 0) post("progress", JSON.stringify({ frame: i, of: total })).catch(() => {});
|
|
540
|
+
}
|
|
541
|
+
await encoder.flush();
|
|
542
|
+
if (encodeError) throw encodeError;
|
|
543
|
+
encoder.close();
|
|
544
|
+
muxer.finalize();
|
|
545
|
+
|
|
546
|
+
await post("out", muxer.target.buffer, "video/mp4");
|
|
547
|
+
if (poster) await post("poster", poster, "image/jpeg");
|
|
548
|
+
await post(
|
|
549
|
+
"done",
|
|
550
|
+
JSON.stringify({ frames: total, codec: config.codec, logo: place.spot, logoClear: place.clear }),
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
main().catch((error) => fail("page_error", error?.message || error));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>foldspace record - composer</title>
|
|
6
|
+
<style>html, body { margin: 0; background: #000000; }</style>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<!-- Loaded in a hidden Chrome by `foldspace record`. It draws the video
|
|
10
|
+
frame by frame and hands the MP4 back; nothing here is ever shown. -->
|
|
11
|
+
<script type="module" src="composer.mjs"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
http://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|