luckiest-co 1.0.13 → 1.0.14
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-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/luckiest-design-website/ATTRIBUTION.md +38 -0
- package/skills/luckiest-design-website/CHANGELOG.md +104 -0
- package/skills/luckiest-design-website/LICENSE +21 -0
- package/skills/luckiest-design-website/SKILL.md +372 -0
- package/skills/luckiest-design-website/UPSTREAM-CHANGELOG.md +526 -0
- package/skills/luckiest-design-website/engine/scrollcraft.css +432 -0
- package/skills/luckiest-design-website/engine/scrollcraft.js +1167 -0
- package/skills/luckiest-design-website/evals/evals.json +33 -0
- package/skills/luckiest-design-website/references/assets.md +286 -0
- package/skills/luckiest-design-website/references/device-diag.html +214 -0
- package/skills/luckiest-design-website/references/devices.md +466 -0
- package/skills/luckiest-design-website/references/feel.md +277 -0
- package/skills/luckiest-design-website/references/registry-examples.md +305 -0
- package/skills/luckiest-design-website/references/taste.md +304 -0
- package/skills/luckiest-design-website/references/template.html +138 -0
- package/skills/luckiest-design-website/references/uniqueness.md +480 -0
- package/skills/luckiest-design-website/references/verify.md +381 -0
- package/skills/luckiest-design-website/references/worldflight.md +349 -0
- package/skills/luckiest-design-website/references/worlds.md +178 -0
- package/skills/luckiest-design-website/scripts/doctor.mjs +177 -0
- package/skills/luckiest-design-website/scripts/encode.sh +80 -0
- package/skills/luckiest-design-website/scripts/kie.mjs +202 -0
- package/skills/luckiest-design-website/scripts/serve.mjs +52 -0
- package/skills/luckiest-design-website/scripts/shoot.mjs +644 -0
- package/skills/luckiest-design-website/scripts/workspace.mjs +106 -0
- package/skills/luckiest-design-website/scripts/worldflight-assert.mjs +273 -0
- package/skills/luckiest-design-website/templates/FINGERPRINTS.md +65 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scrollcraft verification harness: shoot the page's own scroll.
|
|
4
|
+
*
|
|
5
|
+
* Walks the page in N evenly spaced scroll positions, waits for the scrub video
|
|
6
|
+
* to actually settle at each one, screenshots it, and reports what the engine
|
|
7
|
+
* thinks is on screen. Then tiles the frames into one contact sheet, because a
|
|
8
|
+
* dead middle only shows up in contiguous frames: any single screenshot is a
|
|
9
|
+
* frame the transition may not survive.
|
|
10
|
+
*
|
|
11
|
+
* node shoot.mjs --url http://localhost:4500 --out lab/shots --steps 12
|
|
12
|
+
* node shoot.mjs --url ... --width 375 --height 812 --out lab/mobile
|
|
13
|
+
* node shoot.mjs --url ... --reduced-motion --out lab/reduced
|
|
14
|
+
*
|
|
15
|
+
* Uses the INSTALLED Chrome, not bundled Chromium: Chromium ships without the
|
|
16
|
+
* h264 decoder, so every scrub clip would silently fail to paint and the run
|
|
17
|
+
* would "pass" against posters.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
|
|
23
|
+
// The skill lives outside the project it is building, so resolve playwright
|
|
24
|
+
// from the BUILD project's node_modules (cwd), not from next to this file.
|
|
25
|
+
// Run `npm i playwright-core` in the build project once.
|
|
26
|
+
let chromium;
|
|
27
|
+
try {
|
|
28
|
+
({ chromium } = createRequire(path.join(process.cwd(), "package.json"))("playwright-core"));
|
|
29
|
+
} catch {
|
|
30
|
+
console.error("playwright-core not found. Run this in the build project after:\n npm i playwright-core");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const argv = process.argv.slice(2);
|
|
35
|
+
const arg = (n, d) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
|
|
36
|
+
const has = (n) => argv.includes(n);
|
|
37
|
+
|
|
38
|
+
const URL = arg("--url", "http://localhost:4500");
|
|
39
|
+
const OUT = path.resolve(arg("--out", "lab/shots"));
|
|
40
|
+
const STEPS = parseInt(arg("--per-act", arg("--steps", "6")), 10); // samples PER ACT
|
|
41
|
+
const W = parseInt(arg("--width", "1440"), 10);
|
|
42
|
+
const H = parseInt(arg("--height", "900"), 10);
|
|
43
|
+
const REDUCED = has("--reduced-motion");
|
|
44
|
+
|
|
45
|
+
const CHROME = [
|
|
46
|
+
process.env.SCROLLCRAFT_CHROME,
|
|
47
|
+
// Windows
|
|
48
|
+
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
|
49
|
+
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|
50
|
+
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
|
|
51
|
+
// macOS
|
|
52
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
53
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
54
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
55
|
+
// Linux
|
|
56
|
+
"/usr/bin/google-chrome",
|
|
57
|
+
"/usr/bin/google-chrome-stable",
|
|
58
|
+
"/usr/bin/chromium",
|
|
59
|
+
"/usr/bin/chromium-browser",
|
|
60
|
+
"/snap/bin/chromium",
|
|
61
|
+
].find((p) => p && fs.existsSync(p));
|
|
62
|
+
|
|
63
|
+
if (!CHROME) {
|
|
64
|
+
console.error("No installed Chrome found. Set SCROLLCRAFT_CHROME to its path.");
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
69
|
+
|
|
70
|
+
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
|
71
|
+
const page = await browser.newPage({
|
|
72
|
+
viewport: { width: W, height: H },
|
|
73
|
+
deviceScaleFactor: 2,
|
|
74
|
+
reducedMotion: REDUCED ? "reduce" : "no-preference",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const consoleErrors = [];
|
|
78
|
+
page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
|
|
79
|
+
page.on("pageerror", (e) => consoleErrors.push(String(e)));
|
|
80
|
+
const failed = [];
|
|
81
|
+
page.on("requestfailed", (r) => failed.push(`${r.failure()?.errorText} ${r.url()}`));
|
|
82
|
+
|
|
83
|
+
// Not networkidle: the engine keeps clips in flight as you scroll, and a
|
|
84
|
+
// webfont connection can stay open, so idle may never arrive. Wait for the
|
|
85
|
+
// engine's own ready signal and for the faces to land, since line splitting
|
|
86
|
+
// measures real line boxes and is wrong before the real face is applied.
|
|
87
|
+
await page.goto(URL, { waitUntil: "domcontentloaded" });
|
|
88
|
+
await page.waitForSelector("html.sc-ready", { timeout: 15000 });
|
|
89
|
+
await page.evaluate(() => document.fonts.ready);
|
|
90
|
+
await page.waitForTimeout(700);
|
|
91
|
+
|
|
92
|
+
const doc = await page.evaluate(() => {
|
|
93
|
+
const world = document.querySelector('[data-sc-mode="worldflight"]');
|
|
94
|
+
return {
|
|
95
|
+
height: document.body.scrollHeight,
|
|
96
|
+
vh: innerHeight,
|
|
97
|
+
acts: [...document.querySelectorAll("[data-sc-act]")].map((a) => a.dataset.scAct),
|
|
98
|
+
world: world
|
|
99
|
+
? {
|
|
100
|
+
seam: parseFloat(world.dataset.scSeam) || 0.12,
|
|
101
|
+
segs: [...world.querySelectorAll("[data-sc-segment]")].map((s) => ({
|
|
102
|
+
w: parseFloat(s.dataset.scW) || 1.3,
|
|
103
|
+
linger: parseFloat(s.dataset.scLinger) || 0,
|
|
104
|
+
label: s.dataset.scWaypoint || "",
|
|
105
|
+
})),
|
|
106
|
+
}
|
|
107
|
+
: null,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
const WORLD = doc.world;
|
|
111
|
+
const maxScroll = doc.height - doc.vh;
|
|
112
|
+
|
|
113
|
+
if (WORLD) {
|
|
114
|
+
const total = WORLD.segs.reduce((s, g) => s + g.w, 0);
|
|
115
|
+
console.log(`page: worldflight, ${WORLD.segs.length} legs over ${total.toFixed(2)}vh ` +
|
|
116
|
+
`(track ${(doc.height / doc.vh).toFixed(1)} viewport-heights), seam ${WORLD.seam}vh`);
|
|
117
|
+
console.log(` legs: ${WORLD.segs.map((g, i) => `${i}:${g.label || "-"}@${g.w}vh`).join(" ")}`);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(`page: ${(doc.height / doc.vh).toFixed(1)} viewport-heights, acts: ${doc.acts.join(" > ")}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Wait for the playhead to ARRIVE, not merely to stop seeking. The engine lerps
|
|
123
|
+
// currentTime toward a target on its own rAF loop, so after any scroll jump
|
|
124
|
+
// there is a stretch of ~15 frames during which every clip on the page is
|
|
125
|
+
// somewhere it will never be again. Screenshot in that window and the sheet is a
|
|
126
|
+
// set of frames the reader is never shown, the dead-scroll comparison runs on
|
|
127
|
+
// mid-lerp noise, and the whole run is unrepeatable.
|
|
128
|
+
async function settle(timeout = 4000) {
|
|
129
|
+
const t0 = Date.now();
|
|
130
|
+
let last = null;
|
|
131
|
+
for (;;) {
|
|
132
|
+
const now = await page.evaluate(() => {
|
|
133
|
+
const insts = (window.ScrollCraft && window.ScrollCraft.instances) || [];
|
|
134
|
+
const clips = [].concat(...insts.map((i) => i.clips || [])).filter((c) => c.ready);
|
|
135
|
+
// Pages on an older engine expose no instances; fall back to watching
|
|
136
|
+
// currentTime go quiet, which reaches the same state more slowly.
|
|
137
|
+
if (clips.length) {
|
|
138
|
+
const arrived = clips.every((c) => Math.abs(c.cur - c.target) < 0.002 && !c.el.seeking);
|
|
139
|
+
return arrived ? "arrived" : "moving";
|
|
140
|
+
}
|
|
141
|
+
return [...document.querySelectorAll("video[data-sc-scrub]")]
|
|
142
|
+
.map((v) => (v.seeking ? "seeking" : v.currentTime.toFixed(3))).join("|");
|
|
143
|
+
});
|
|
144
|
+
if (now === "arrived") return true;
|
|
145
|
+
if (now !== "moving" && now === last && !now.includes("seeking")) return true;
|
|
146
|
+
if (Date.now() - t0 > timeout) return false;
|
|
147
|
+
last = now;
|
|
148
|
+
await page.waitForTimeout(60);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Sample WITHIN each act, not uniformly down the document. Uniform sampling
|
|
153
|
+
// distributes positions by page length, so a short act gets one sample that
|
|
154
|
+
// lands wherever it lands, and adding a section elsewhere silently moves every
|
|
155
|
+
// sample. That produces "this cue never reaches full opacity" reports that come
|
|
156
|
+
// and go with unrelated edits. Per-act sampling hits the same fractions of
|
|
157
|
+
// every act every run, so the findings mean something.
|
|
158
|
+
//
|
|
159
|
+
// A worldflight has no acts to sample within; its unit is the leg, and its
|
|
160
|
+
// geometry lives entirely in the weights, so positions are computed from the
|
|
161
|
+
// track rather than measured off the DOM. Both sides of every seam are added on
|
|
162
|
+
// top: the crossfade is the frame this mode is judged on, and it occupies about
|
|
163
|
+
// a tenth of a viewport, so uniform sampling steps straight over it.
|
|
164
|
+
const positions = WORLD ? await page.evaluate((perSeg) => {
|
|
165
|
+
const root = document.querySelector('[data-sc-mode="worldflight"]');
|
|
166
|
+
const segs = [...root.querySelectorAll("[data-sc-segment]")];
|
|
167
|
+
const top = root.getBoundingClientRect().top + scrollY;
|
|
168
|
+
const seam = parseFloat(root.dataset.scSeam) || 0.12;
|
|
169
|
+
const fracs = Array.from({ length: perSeg }, (_, i) => (perSeg === 1 ? 0.5 : i / (perSeg - 1)));
|
|
170
|
+
const out = [];
|
|
171
|
+
let c = 0;
|
|
172
|
+
segs.forEach((s, i) => {
|
|
173
|
+
const w = parseFloat(s.dataset.scW) || 1.3;
|
|
174
|
+
fracs.forEach((f) => {
|
|
175
|
+
const p = 0.02 + f * 0.96;
|
|
176
|
+
out.push(Math.round(top + (c + w * p) * innerHeight));
|
|
177
|
+
});
|
|
178
|
+
c += w;
|
|
179
|
+
if (i < segs.length - 1) {
|
|
180
|
+
[-0.5, -0.2, 0.2, 0.5].forEach((k) => out.push(Math.round(top + (c + k * seam) * innerHeight)));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
const max = document.body.scrollHeight - innerHeight;
|
|
184
|
+
out.push(0, max);
|
|
185
|
+
return [...new Set(out.map((y) => Math.max(0, Math.min(max, y))))].sort((a, b) => a - b);
|
|
186
|
+
}, Math.max(2, Math.round(STEPS))) : await page.evaluate((perAct) => {
|
|
187
|
+
const out = [];
|
|
188
|
+
const fracs = Array.from({ length: perAct }, (_, i) => (perAct === 1 ? 0.5 : i / (perAct - 1)));
|
|
189
|
+
document.querySelectorAll("[data-sc-act]").forEach((el) => {
|
|
190
|
+
const top = el.getBoundingClientRect().top + scrollY;
|
|
191
|
+
const h = el.offsetHeight;
|
|
192
|
+
const pinned = ["scrub", "pin", "pan"].includes(el.dataset.scAct);
|
|
193
|
+
fracs.forEach((f) => {
|
|
194
|
+
// Nudge off the exact endpoints: p=0 and p=1 sit on the seam between two
|
|
195
|
+
// acts, where which one you are "in" is ambiguous.
|
|
196
|
+
const p = 0.02 + f * 0.96;
|
|
197
|
+
out.push(Math.round(pinned ? top + (h - innerHeight) * p : top - innerHeight + (h + innerHeight) * p));
|
|
198
|
+
});
|
|
199
|
+
// A pinned stage is on screen for a viewport BEFORE its pinned travel begins
|
|
200
|
+
// and a viewport AFTER it ends, and the loop above samples only inside the
|
|
201
|
+
// travel. Those two slides are exactly where a clip mapped to pinned
|
|
202
|
+
// progress sits frozen on its first or last frame, so not sampling them is
|
|
203
|
+
// why a frozen clip could pass this harness. Sample them.
|
|
204
|
+
if (el.dataset.scAct === "scrub") {
|
|
205
|
+
// `v` is the fraction of the viewport the stage covers at that position.
|
|
206
|
+
// Sample the part of each slide where the stage is still MOSTLY on screen,
|
|
207
|
+
// because that is where a frozen frame is conspicuous, and because the
|
|
208
|
+
// frozen-clip check needs consecutive samples that are both well past its
|
|
209
|
+
// visibility gate before it will call anything.
|
|
210
|
+
[0.6, 0.75, 0.9].forEach((v) => {
|
|
211
|
+
out.push(Math.round(top - innerHeight * (1 - v))); // sliding in
|
|
212
|
+
out.push(Math.round(top + h - innerHeight * v)); // sliding out
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
const max = document.body.scrollHeight - innerHeight;
|
|
217
|
+
out.push(max);
|
|
218
|
+
return [...new Set(out.map((y) => Math.max(0, Math.min(max, y))))].sort((a, b) => a - b);
|
|
219
|
+
}, Math.max(2, Math.round(STEPS)));
|
|
220
|
+
|
|
221
|
+
const report = [];
|
|
222
|
+
for (let i = 0; i < positions.length; i++) {
|
|
223
|
+
const y = positions[i];
|
|
224
|
+
const p = maxScroll ? y / maxScroll : 0;
|
|
225
|
+
await page.evaluate((y) => scrollTo({ top: y, behavior: "instant" }), y);
|
|
226
|
+
await page.waitForTimeout(180);
|
|
227
|
+
const settled = await settle();
|
|
228
|
+
|
|
229
|
+
const state = await page.evaluate(() => {
|
|
230
|
+
// A kinetic heading carries its real opacity on the split line units; the
|
|
231
|
+
// engine forces the element itself to 1. Reading the element therefore
|
|
232
|
+
// reports every kinetic headline as fully present, including on frames
|
|
233
|
+
// where every one of its lines is at 0. Take the strongest line instead:
|
|
234
|
+
// the heading is "peaked" when at least one unit has arrived.
|
|
235
|
+
const cueOpacity = (el) => {
|
|
236
|
+
const o = parseFloat(getComputedStyle(el).opacity) || 0;
|
|
237
|
+
const units = el.querySelectorAll(".sc-split__i");
|
|
238
|
+
if (!units.length) return o;
|
|
239
|
+
let m = 0;
|
|
240
|
+
units.forEach((u) => { m = Math.max(m, parseFloat(getComputedStyle(u).opacity) || 0); });
|
|
241
|
+
return o * m;
|
|
242
|
+
};
|
|
243
|
+
const vis = [];
|
|
244
|
+
// A worldflight's copy blocks are windowed against the whole track rather
|
|
245
|
+
// than an act's progress, but they are the same thing to a reader: type that
|
|
246
|
+
// has to arrive, hold, and leave. Grade them identically.
|
|
247
|
+
document.querySelectorAll("[data-sc-cue],[data-sc-copy]").forEach((el) => {
|
|
248
|
+
const o = cueOpacity(el);
|
|
249
|
+
if (o <= 0.02) return;
|
|
250
|
+
// On screen, not merely non-transparent. An element parked off-viewport
|
|
251
|
+
// at opacity 1 is not a visible cue, and counting it produces phantom
|
|
252
|
+
// findings that send you chasing a bug the reader never sees.
|
|
253
|
+
const r = el.getBoundingClientRect();
|
|
254
|
+
if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) return;
|
|
255
|
+
vis.push({ t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 46), o: +o.toFixed(2) });
|
|
256
|
+
});
|
|
257
|
+
const clips = [...document.querySelectorAll("video[data-sc-scrub]")].map((v) => ({
|
|
258
|
+
// A continuous world legitimately keeps its clip chain outside the act
|
|
259
|
+
// stack, driven by the page's own scroll value rather than by an act's
|
|
260
|
+
// progress. Falling back to the clip's own class keeps that case
|
|
261
|
+
// reporting instead of taking the whole run down before it writes
|
|
262
|
+
// anything.
|
|
263
|
+
painted: (v.closest("[data-sc-act]") ?? v).classList.contains("sc-has-clip"),
|
|
264
|
+
t: +(v.currentTime || 0).toFixed(2),
|
|
265
|
+
dur: +(v.duration || 0).toFixed(2),
|
|
266
|
+
// How much of the viewport this clip's stage actually covers. A frozen
|
|
267
|
+
// playhead only matters while the reader can see the stage.
|
|
268
|
+
vis: (() => {
|
|
269
|
+
const st = v.closest("[data-sc-stage]") || v.parentElement;
|
|
270
|
+
if (!st) return 0;
|
|
271
|
+
const b = st.getBoundingClientRect();
|
|
272
|
+
return +(Math.max(0, Math.min(b.bottom, innerHeight) - Math.max(b.top, 0)) / innerHeight).toFixed(3);
|
|
273
|
+
})(),
|
|
274
|
+
}));
|
|
275
|
+
// Rails and wipes move without changing any cue or clip time, so without
|
|
276
|
+
// these a panning section reads as dead scroll.
|
|
277
|
+
const rails = [...document.querySelectorAll("[data-sc-pan]")]
|
|
278
|
+
.map((r) => Math.round(new DOMMatrixReadOnly(getComputedStyle(r).transform).m41));
|
|
279
|
+
const wipes = [...document.querySelectorAll("[data-sc-reveal]")]
|
|
280
|
+
.map((r) => getComputedStyle(r).clipPath);
|
|
281
|
+
// Which act owns the middle of the viewport right now.
|
|
282
|
+
let act = "-";
|
|
283
|
+
document.querySelectorAll("[data-sc-act]").forEach((a) => {
|
|
284
|
+
const r = a.getBoundingClientRect();
|
|
285
|
+
if (r.top <= innerHeight / 2 && r.bottom >= innerHeight / 2) act = a.dataset.scAct;
|
|
286
|
+
});
|
|
287
|
+
// Where each pinned stage physically sits. Before an act reaches its pin
|
|
288
|
+
// point the stage slides up the screen while its progress is still clamped
|
|
289
|
+
// to 0, so the clip and cues are frozen and yet the view is very much
|
|
290
|
+
// moving. Without this the run-up to every pinned act reads as dead scroll.
|
|
291
|
+
const stages = [...document.querySelectorAll("[data-sc-stage]")]
|
|
292
|
+
.map((s) => Math.round(s.getBoundingClientRect().top));
|
|
293
|
+
// Worldflight legs. Opacity IS the crossfade, so it is state, not styling:
|
|
294
|
+
// two samples with the same clip times but different leg opacities are a
|
|
295
|
+
// dissolve in progress, not dead scroll.
|
|
296
|
+
const segs = [...document.querySelectorAll("[data-sc-segment]")].map((el, i) => {
|
|
297
|
+
const v = el.querySelector("video");
|
|
298
|
+
return {
|
|
299
|
+
i, label: el.dataset.scWaypoint || "",
|
|
300
|
+
op: +(parseFloat(getComputedStyle(el).opacity) || 0).toFixed(3),
|
|
301
|
+
painted: el.classList.contains("sc-has-clip"),
|
|
302
|
+
t: v ? +(v.currentTime || 0).toFixed(3) : null,
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
const world = document.querySelector('[data-sc-mode="worldflight"]');
|
|
306
|
+
// Bespoke fixed stages can use flow markers for document travel while all
|
|
307
|
+
// visible motion happens outside the engine's pin/scrub devices. Those
|
|
308
|
+
// pages publish a compact representation of their actual visual state so
|
|
309
|
+
// dead-scroll verification does not silently skip the whole experience.
|
|
310
|
+
const customEls = [...document.querySelectorAll("[data-sc-verify-state]")];
|
|
311
|
+
const custom = customEls.map((el) => el.getAttribute("data-sc-verify-state") || "");
|
|
312
|
+
const customHold = customEls.some((el) => el.getAttribute("data-sc-verify-hold") === "true");
|
|
313
|
+
return {
|
|
314
|
+
cues: vis, clips, rails, wipes, act, stages, segs, custom, customHold,
|
|
315
|
+
seg: world ? +(world.style.getPropertyValue("--sc-seg") || -1) : null,
|
|
316
|
+
segp: world ? +(world.style.getPropertyValue("--sc-segp") || 0) : null,
|
|
317
|
+
bg: getComputedStyle(document.documentElement).getPropertyValue("--sc-canvas").trim(),
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Flat NN.png so ffmpeg can read the set as a numbered sequence for the
|
|
322
|
+
// contact sheet. The scroll offset lives in report.json, not the filename.
|
|
323
|
+
const name = `${String(i).padStart(2, "0")}.png`;
|
|
324
|
+
await page.screenshot({ path: path.join(OUT, name) });
|
|
325
|
+
|
|
326
|
+
// Contrast, measured on the COMPOSITED page rather than on the source media.
|
|
327
|
+
// Sampling the video directly ignores every scrim, gradient and blend on top
|
|
328
|
+
// of it, so a page can read as failing while looking fine, or the reverse.
|
|
329
|
+
// Hide the text, shoot the same frame, hand the pixels back to the page, and
|
|
330
|
+
// sample the real background under each line. Text over a scrubbing clip is
|
|
331
|
+
// the one contrast case a static audit cannot cover: the frame beneath a
|
|
332
|
+
// headline changes as you scroll, so it can pass on the poster and fail three
|
|
333
|
+
// hundred pixels later. The direction is picked per line: light type fails on
|
|
334
|
+
// the brightest patch, dark type on the darkest one.
|
|
335
|
+
//
|
|
336
|
+
// Fixed chrome is hidden along with the text. A fixed bar paints in FRONT of
|
|
337
|
+
// whatever scrolls under it, so its own mark is not the background behind a
|
|
338
|
+
// headline passing beneath it, and leaving it in reports a spurious failure
|
|
339
|
+
// on an act that is fine.
|
|
340
|
+
await page.evaluate(() => {
|
|
341
|
+
document.querySelectorAll("body *").forEach((el) => {
|
|
342
|
+
if (getComputedStyle(el).position !== "fixed") return;
|
|
343
|
+
// A worldflight's stage and copy layer are fixed too, and they are the
|
|
344
|
+
// exact opposite case: the stage IS the background behind every line, and
|
|
345
|
+
// the copy layer carries the scrim that makes the line legible. Hiding
|
|
346
|
+
// them samples the page ground instead of the film and reports the whole
|
|
347
|
+
// page as failing while it looks fine.
|
|
348
|
+
if (el.closest("[data-sc-world],[data-sc-world-copy]")) return;
|
|
349
|
+
el.setAttribute("data-sc-shot-fixed", "");
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
await page.addStyleTag({
|
|
353
|
+
content: "[data-sc-cue],[data-sc-cue] *,[data-sc-copy],[data-sc-copy] *," +
|
|
354
|
+
"[data-sc-shot-fixed]{visibility:hidden!important}",
|
|
355
|
+
});
|
|
356
|
+
const bare = (await page.screenshot({ type: "jpeg", quality: 80 })).toString("base64");
|
|
357
|
+
const contrast = await page.evaluate(async ({ b64, dpr }) => {
|
|
358
|
+
const img = new Image();
|
|
359
|
+
img.src = "data:image/jpeg;base64," + b64;
|
|
360
|
+
await img.decode();
|
|
361
|
+
const c = document.createElement("canvas");
|
|
362
|
+
const g = c.getContext("2d", { willReadFrequently: true });
|
|
363
|
+
const lum = (r, gr, b) => {
|
|
364
|
+
const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
|
|
365
|
+
return 0.2126 * f(r) + 0.7152 * f(gr) + 0.0722 * f(b);
|
|
366
|
+
};
|
|
367
|
+
const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
368
|
+
const cueOpacity = (el) => {
|
|
369
|
+
const o = parseFloat(getComputedStyle(el).opacity) || 0;
|
|
370
|
+
const units = el.querySelectorAll(".sc-split__i");
|
|
371
|
+
if (!units.length) return o;
|
|
372
|
+
let m = 0;
|
|
373
|
+
units.forEach((u) => { m = Math.max(m, parseFloat(getComputedStyle(u).opacity) || 0); });
|
|
374
|
+
return o * m;
|
|
375
|
+
};
|
|
376
|
+
const out = [];
|
|
377
|
+
document.querySelectorAll("[data-sc-cue],[data-sc-copy]").forEach((el) => {
|
|
378
|
+
if (cueOpacity(el) < 0.85) return;
|
|
379
|
+
if (!(el.textContent || "").trim()) return;
|
|
380
|
+
const r = el.getBoundingClientRect();
|
|
381
|
+
if (r.width < 8 || r.height < 8 || r.bottom < 0 || r.top > innerHeight) return;
|
|
382
|
+
// Clamp the sampled rect to the viewport. The part of a pinned act's copy
|
|
383
|
+
// that has scrolled above the fold is not on screen, so whatever sits in
|
|
384
|
+
// those pixels is not the background behind anything the reader can see.
|
|
385
|
+
const vl = Math.max(0, r.left), vt = Math.max(0, r.top);
|
|
386
|
+
const vr = Math.min(innerWidth, r.right), vb = Math.min(innerHeight, r.bottom);
|
|
387
|
+
if (vr - vl < 8 || vb - vt < 8) return;
|
|
388
|
+
const x = vl * dpr, y2 = vt * dpr;
|
|
389
|
+
const w = Math.min((vr - vl) * dpr, img.width - x), h = Math.min((vb - vt) * dpr, img.height - y2);
|
|
390
|
+
if (w < 2 || h < 2) return;
|
|
391
|
+
c.width = 32; c.height = 16;
|
|
392
|
+
g.drawImage(img, x, y2, w, h, 0, 0, 32, 16);
|
|
393
|
+
const d = g.getImageData(0, 0, 32, 16).data;
|
|
394
|
+
let maxL = 0, minL = 1, sum = 0, n = 0;
|
|
395
|
+
for (let k = 0; k < d.length; k += 4) {
|
|
396
|
+
const L = lum(d[k], d[k + 1], d[k + 2]);
|
|
397
|
+
if (L > maxL) maxL = L;
|
|
398
|
+
if (L < minL) minL = L;
|
|
399
|
+
sum += L; n++;
|
|
400
|
+
}
|
|
401
|
+
const cs = getComputedStyle(el);
|
|
402
|
+
const fg = cs.color.match(/[\d.]+/g).map(Number);
|
|
403
|
+
const fl = lum(fg[0], fg[1], fg[2]);
|
|
404
|
+
// An element that paints its own opaque background (a button, a chip) is
|
|
405
|
+
// an ordinary static contrast case: grade its text against that fill, not
|
|
406
|
+
// against whatever the page happens to show behind it. Hiding the element
|
|
407
|
+
// to sample the backdrop necessarily hides its background too, so without
|
|
408
|
+
// this every solid CTA reports a spurious failure.
|
|
409
|
+
const bg = (cs.backgroundColor.match(/[\d.]+/g) || []).map(Number);
|
|
410
|
+
const opaqueBg = bg.length >= 3 && (bg.length < 4 || bg[3] > 0.5);
|
|
411
|
+
if (opaqueBg) {
|
|
412
|
+
const bl = lum(bg[0], bg[1], bg[2]);
|
|
413
|
+
out.push({
|
|
414
|
+
t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),
|
|
415
|
+
dir: "own-fill",
|
|
416
|
+
worst: +ratio(fl, bl).toFixed(2), mean: +ratio(fl, bl).toFixed(2),
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// Pick the direction from the foreground. Light type on a dark page fails
|
|
421
|
+
// on the brightest patch under it; dark type on a light page (a high-key
|
|
422
|
+
// world, ink over media) fails on the DARKEST patch, and grading that
|
|
423
|
+
// against maxL is the most lenient reading available, so a page can report
|
|
424
|
+
// clean over text that is failing. Compare the ink to the mean background
|
|
425
|
+
// and grade against whichever extreme is on the ink's own side.
|
|
426
|
+
const meanL = sum / n;
|
|
427
|
+
const dark = fl < meanL;
|
|
428
|
+
out.push({
|
|
429
|
+
t: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40),
|
|
430
|
+
dir: dark ? "dark-on-light" : "light-on-dark",
|
|
431
|
+
worst: +ratio(fl, dark ? minL : maxL).toFixed(2),
|
|
432
|
+
mean: +ratio(fl, meanL).toFixed(2),
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
return out;
|
|
436
|
+
}, { b64: bare, dpr: 2 });
|
|
437
|
+
await page.evaluate(() => {
|
|
438
|
+
const t = [...document.querySelectorAll("style")].pop();
|
|
439
|
+
if (t && t.textContent.includes("data-sc-cue")) t.remove();
|
|
440
|
+
document.querySelectorAll("[data-sc-shot-fixed]").forEach((el) => el.removeAttribute("data-sc-shot-fixed"));
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
report.push({ i, y, pct: +(p * 100).toFixed(0), settled, contrast, ...state });
|
|
444
|
+
if (WORLD) {
|
|
445
|
+
console.log(` ${name} settled=${settled} copy=${state.cues.length} leg=${state.seg}@${state.segp} ` +
|
|
446
|
+
`legs=${state.segs.map((g) => `${g.op > 0.002 ? (g.painted ? g.t : "poster") : "-"}${g.op > 0.002 && g.op < 0.998 ? "*" + g.op : ""}`).join(",")}`);
|
|
447
|
+
} else {
|
|
448
|
+
console.log(` ${name} settled=${settled} cues=${state.cues.length} clips=${state.clips.map((c) => (c.painted ? c.t : "poster")).join(",")}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
fs.writeFileSync(path.join(OUT, "report.json"), JSON.stringify({ doc, report, consoleErrors, failed }, null, 2));
|
|
453
|
+
|
|
454
|
+
if (consoleErrors.length) console.log("\nCONSOLE ERRORS:\n " + consoleErrors.join("\n "));
|
|
455
|
+
if (failed.length) console.log("\nFAILED REQUESTS:\n " + failed.join("\n "));
|
|
456
|
+
|
|
457
|
+
// Dead-scroll detector: consecutive positions where nothing visibly changed.
|
|
458
|
+
// This is the failure the eye misses and the reason to shoot contiguously.
|
|
459
|
+
// Opacity counts as change: a cue mid-fade is motion, and comparing only which
|
|
460
|
+
// cues exist would call a crossfade dead.
|
|
461
|
+
const sig = (s) => JSON.stringify([
|
|
462
|
+
s.cues.map((c) => c.t + ":" + c.o),
|
|
463
|
+
s.clips.map((c) => c.t),
|
|
464
|
+
s.rails,
|
|
465
|
+
s.wipes,
|
|
466
|
+
s.stages,
|
|
467
|
+
s.custom || [],
|
|
468
|
+
]);
|
|
469
|
+
// Only inside pinned acts. A flow section or a footer that holds still across
|
|
470
|
+
// two sample positions is a page behaving correctly, not dead scroll, and
|
|
471
|
+
// flagging it trains you to ignore the signal.
|
|
472
|
+
const PINNED = new Set(["scrub", "pin", "pan"]);
|
|
473
|
+
const dead = [];
|
|
474
|
+
if (WORLD) {
|
|
475
|
+
// Every pixel of a worldflight track is pinned by construction, so there is
|
|
476
|
+
// no "correctly still" region to exclude and the whole page is fair game.
|
|
477
|
+
// Three independent things can carry the motion: the film advancing, a leg
|
|
478
|
+
// dissolving into the next, and a copy window opening or closing. Dead scroll
|
|
479
|
+
// is all three holding at once.
|
|
480
|
+
const wsig = (s) => JSON.stringify([
|
|
481
|
+
s.segs.map((g) => g.t),
|
|
482
|
+
s.segs.map((g) => g.op),
|
|
483
|
+
s.cues.map((c) => c.t + ":" + c.o),
|
|
484
|
+
]);
|
|
485
|
+
// Not under reduced motion. There the film is deliberately never fetched, so
|
|
486
|
+
// the middle of a leg holds a single still frame and every pair of samples in
|
|
487
|
+
// it is identical BY DESIGN. Flagging that reports the accessibility path as
|
|
488
|
+
// broken every single run, which is how a real finding gets ignored. What
|
|
489
|
+
// matters here is whether the story still reads, and the copy-window and
|
|
490
|
+
// contrast passes below answer that.
|
|
491
|
+
for (let i = 1; !REDUCED && i < report.length; i++) {
|
|
492
|
+
const a = report[i - 1], b = report[i];
|
|
493
|
+
// Tighter than the act gate: a leg is about one viewport of scroll, so a
|
|
494
|
+
// quarter-viewport window would only ever compare four points per leg.
|
|
495
|
+
if (b.y - a.y < doc.vh * 0.12) continue;
|
|
496
|
+
if (wsig(a) === wsig(b)) {
|
|
497
|
+
dead.push(`${a.pct}% -> ${b.pct}% (leg ${a.seg} > ${b.seg})`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
for (let i = 1; i < report.length; i++) {
|
|
502
|
+
const a = report[i - 1], b = report[i];
|
|
503
|
+
const hasCustomState = (a.custom?.length || 0) > 0 || (b.custom?.length || 0) > 0;
|
|
504
|
+
if (!PINNED.has(a.act) && !PINNED.has(b.act) && !hasCustomState) continue;
|
|
505
|
+
// A page may explicitly declare an authored hold, such as a resolved close
|
|
506
|
+
// or the stable accessibility frame under reduced motion. It has to be
|
|
507
|
+
// declared by the visible stage; ordinary flow content stays exempt as it
|
|
508
|
+
// was before this custom-state path existed.
|
|
509
|
+
if (a.customHold && b.customHold) continue;
|
|
510
|
+
// Two samples a few dozen pixels apart SHOULD look the same. Only flag a gap
|
|
511
|
+
// wide enough that a reader would notice nothing happening in it.
|
|
512
|
+
if (b.y - a.y < doc.vh * 0.25) continue;
|
|
513
|
+
if (sig(a) === sig(b)) dead.push(`${a.pct}% -> ${b.pct}% (${a.act} > ${b.act})`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
console.log(dead.length ? `\nDEAD SCROLL between: ${dead.join(", ")}`
|
|
517
|
+
: WORLD && REDUCED ? "\ndead-scroll check skipped: reduced motion holds each leg on one still frame by design"
|
|
518
|
+
: "\nno dead scroll detected");
|
|
519
|
+
|
|
520
|
+
// FROZEN CLIP. The reader is scrolling, a scrub stage is on screen, and its
|
|
521
|
+
// playhead is not moving: a still photograph sliding up the page. Dead scroll
|
|
522
|
+
// cannot see this, because the stage IS moving, which is the whole problem.
|
|
523
|
+
//
|
|
524
|
+
// A hold on the first or last frame is always a defect. A hold in the middle
|
|
525
|
+
// can be an intentional `dwell` settle, so it only counts once it outlasts one.
|
|
526
|
+
// Skipped under reduced motion, where no clip is ever fetched on purpose.
|
|
527
|
+
if (!REDUCED) {
|
|
528
|
+
const nClips = report[0]?.clips?.length || 0;
|
|
529
|
+
const VIS = 0.55, EPS = 0.012, MIN = doc.vh * 0.15;
|
|
530
|
+
const frozen = [];
|
|
531
|
+
for (let c = 0; c < nClips; c++) {
|
|
532
|
+
let run = null;
|
|
533
|
+
const flush = () => {
|
|
534
|
+
if (!run) return;
|
|
535
|
+
const kind = run.t < 0.05 ? "entry" : (run.dur && run.t > run.dur - 0.08 ? "exit" : "mid");
|
|
536
|
+
const need = kind === "mid" ? doc.vh * 0.5 : MIN;
|
|
537
|
+
if (run.to - run.from >= need) frozen.push({ c, kind, ...run });
|
|
538
|
+
run = null;
|
|
539
|
+
};
|
|
540
|
+
for (let i = 1; i < report.length; i++) {
|
|
541
|
+
const a = report[i - 1].clips?.[c], b = report[i].clips?.[c];
|
|
542
|
+
if (!a || !b) { flush(); continue; }
|
|
543
|
+
const seen = a.vis >= VIS && b.vis >= VIS;
|
|
544
|
+
const stuck = Math.abs(b.t - a.t) < EPS;
|
|
545
|
+
if (seen && stuck && b.painted) {
|
|
546
|
+
if (!run) run = { from: report[i - 1].y, to: report[i].y, t: b.t, dur: b.dur };
|
|
547
|
+
else run.to = report[i].y;
|
|
548
|
+
} else flush();
|
|
549
|
+
}
|
|
550
|
+
flush();
|
|
551
|
+
}
|
|
552
|
+
if (frozen.length) {
|
|
553
|
+
console.log("\nFROZEN CLIP (still image while the page moves):\n " + frozen.map((f) => {
|
|
554
|
+
const px = f.to - f.from;
|
|
555
|
+
const where = f.kind === "entry" ? "held on its FIRST frame while the stage slides in"
|
|
556
|
+
: f.kind === "exit" ? "held on its LAST frame while the stage slides out"
|
|
557
|
+
: `held mid-clip at ${f.t.toFixed(2)}s, longer than a dwell settle`;
|
|
558
|
+
return `clip ${f.c}: ${px}px (${(px / doc.vh).toFixed(2)} viewports) ${where}`;
|
|
559
|
+
}).join("\n ") + "\n Fix: let the clip map across the stage's whole visible life. That is the\n engine default; data-sc-clip-map=\"travel\" turns it off. See devices.md.");
|
|
560
|
+
} else if (nClips) {
|
|
561
|
+
console.log(`all ${nClips} scrub clip(s) keep moving whenever they are on screen`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Worldflight findings. A leg that never reaches full opacity is a weight or a
|
|
566
|
+
// seam that is wrong: the reader is shown a permanent dissolve between two
|
|
567
|
+
// clips and never the leg itself. A leg stuck on its poster is a clip that
|
|
568
|
+
// never loaded or never decoded, and it passes every other check on this page
|
|
569
|
+
// because a poster looks exactly like a paused film.
|
|
570
|
+
if (WORLD) {
|
|
571
|
+
const segPeak = {};
|
|
572
|
+
report.forEach((s) => (s.segs || []).forEach((g) => {
|
|
573
|
+
const k = `${g.i}${g.label ? ' "' + g.label + '"' : ""}`;
|
|
574
|
+
segPeak[k] = segPeak[k] || { op: 0, painted: false, hasClip: g.t !== null };
|
|
575
|
+
segPeak[k].op = Math.max(segPeak[k].op, g.op);
|
|
576
|
+
segPeak[k].painted = segPeak[k].painted || g.painted;
|
|
577
|
+
}));
|
|
578
|
+
const faint = Object.entries(segPeak).filter(([, v]) => v.op < 0.99);
|
|
579
|
+
// Under reduced motion no clip is ever fetched, on purpose. Every leg is
|
|
580
|
+
// legitimately on its poster, and reporting that as a fault buries the one
|
|
581
|
+
// finding this pass exists for: whether the story still reads without motion.
|
|
582
|
+
const posters = REDUCED ? [] : Object.entries(segPeak).filter(([, v]) => v.hasClip && !v.painted);
|
|
583
|
+
if (faint.length) console.log("\nLEGS THAT NEVER REACH FULL OPACITY:\n " +
|
|
584
|
+
faint.map(([k, v]) => `${v.op.toFixed(2)} leg ${k}`).join("\n "));
|
|
585
|
+
if (posters.length) console.log("\nLEGS STUCK ON POSTER (clip never painted):\n " +
|
|
586
|
+
posters.map(([k]) => `leg ${k}`).join("\n "));
|
|
587
|
+
if (!faint.length && !posters.length)
|
|
588
|
+
console.log(`all ${Object.keys(segPeak).length} legs reach full opacity` +
|
|
589
|
+
(REDUCED ? " (posters only, as reduced motion requires)" : " and paint a real frame"));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Cues that never reach full strength anywhere on the page. A headline peaking
|
|
593
|
+
// at 0.6 is a mis-set cue window, and it is invisible as a bug because the
|
|
594
|
+
// element IS there, just never quite arriving.
|
|
595
|
+
const peak = {};
|
|
596
|
+
report.forEach((s) => s.cues.forEach((c) => { peak[c.t] = Math.max(peak[c.t] || 0, c.o); }));
|
|
597
|
+
const weak = Object.entries(peak).filter(([, o]) => o < 0.8);
|
|
598
|
+
if (weak.length) console.log("\nCUES THAT NEVER PEAK:\n " + weak.map(([t, o]) => `${o} "${t}"`).join("\n "));
|
|
599
|
+
|
|
600
|
+
// Contrast over media, graded at the worst frame each line is ever shown on.
|
|
601
|
+
const worstBy = {};
|
|
602
|
+
report.forEach((s) => (s.contrast || []).forEach((c) => {
|
|
603
|
+
if (!worstBy[c.t] || c.worst < worstBy[c.t].worst) worstBy[c.t] = c;
|
|
604
|
+
}));
|
|
605
|
+
const fails = Object.values(worstBy).filter((c) => c.worst < 3);
|
|
606
|
+
const thin = Object.values(worstBy).filter((c) => c.worst >= 3 && c.worst < 4.5);
|
|
607
|
+
if (fails.length) console.log("\nCONTRAST FAIL (worst frame < 3:1):\n " +
|
|
608
|
+
fails.map((c) => `${c.worst}:1 (mean ${c.mean}) "${c.t}"`).join("\n "));
|
|
609
|
+
if (thin.length) console.log("\nCONTRAST THIN (3:1 to 4.5:1, ok for large display type only):\n " +
|
|
610
|
+
thin.map((c) => `${c.worst}:1 "${c.t}"`).join("\n "));
|
|
611
|
+
if (!fails.length && !thin.length && Object.keys(worstBy).length)
|
|
612
|
+
console.log("\ncontrast over media: all cues clear 4.5:1 at their worst frame");
|
|
613
|
+
|
|
614
|
+
await browser.close();
|
|
615
|
+
|
|
616
|
+
// Contact sheet. The point of shooting contiguously is to look at the frames
|
|
617
|
+
// side by side; a folder of 20 PNGs does not get looked at that way.
|
|
618
|
+
const FFMPEG = [
|
|
619
|
+
process.env.SCROLLCRAFT_FFMPEG,
|
|
620
|
+
...(fs.existsSync(path.join(process.env.HOME || "", "AppData/Local/Microsoft/WinGet/Packages"))
|
|
621
|
+
? fs.readdirSync(path.join(process.env.HOME, "AppData/Local/Microsoft/WinGet/Packages"))
|
|
622
|
+
.filter((d) => d.startsWith("Gyan.FFmpeg"))
|
|
623
|
+
.flatMap((d) => {
|
|
624
|
+
const base = path.join(process.env.HOME, "AppData/Local/Microsoft/WinGet/Packages", d);
|
|
625
|
+
return fs.readdirSync(base).map((v) => path.join(base, v, "bin/ffmpeg.exe"));
|
|
626
|
+
})
|
|
627
|
+
: []),
|
|
628
|
+
"/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg", "ffmpeg",
|
|
629
|
+
].find((p) => p && (p === "ffmpeg" || fs.existsSync(p)));
|
|
630
|
+
|
|
631
|
+
if (FFMPEG) {
|
|
632
|
+
const cols = Math.min(5, report.length);
|
|
633
|
+
const rows = Math.ceil(report.length / cols);
|
|
634
|
+
const { spawnSync } = await import("node:child_process");
|
|
635
|
+
const r = spawnSync(FFMPEG, [
|
|
636
|
+
"-y", "-v", "error", "-i", path.join(OUT, "%02d.png"),
|
|
637
|
+
"-vf", `scale=520:-1,tile=${cols}x${rows}`, "-frames:v", "1",
|
|
638
|
+
path.join(OUT, "sheet.png"),
|
|
639
|
+
]);
|
|
640
|
+
if (r.status === 0) console.log(`contact sheet: ${path.join(OUT, "sheet.png")}`);
|
|
641
|
+
else console.log("contact sheet skipped (needs a full ffmpeg build; scale/tile are missing from stripped ones)");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
console.log(`\nshots + report.json in ${OUT}`);
|