demobites 1.37.0 → 1.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/record.mjs +35 -3
- package/skill/scripts/plan.mjs +53 -4
- package/skill/scripts/probe.mjs +26 -5
- package/skill/scripts/stage-wait.mjs +11 -0
- package/skill/scripts/stills.mjs +39 -0
package/package.json
CHANGED
package/scripts/record.mjs
CHANGED
|
@@ -599,11 +599,13 @@ async function visibleTarget(selector, minY = 0, timeout = 15000) {
|
|
|
599
599
|
// third and re-scan — instead of failing the whole take. Bounded so a
|
|
600
600
|
// genuinely-unreachable target still ends the loop. This is the camera
|
|
601
601
|
// following the subject, not a camera-path change.
|
|
602
|
+
// THE SCROLL LAW (founder's take of 2026-10-18 jumped instead of scrolling): the correction GLIDES, never
|
|
603
|
+
// jumps — the same eased scroll a `scroll` step gets, ~800 ms, toward the target at 40% of the viewport.
|
|
604
|
+
// scrollIntoViewIfNeeded + an instant scrollBy was a cut in the middle of a take.
|
|
602
605
|
if (offVerticalEl && scrollCorrections < 3) {
|
|
603
606
|
scrollCorrections++;
|
|
604
|
-
await offVerticalEl
|
|
605
|
-
await page.
|
|
606
|
-
await page.waitForTimeout(450);
|
|
607
|
+
await glideIntoView(offVerticalEl, 800);
|
|
608
|
+
await page.waitForTimeout(300);
|
|
607
609
|
continue;
|
|
608
610
|
}
|
|
609
611
|
await page.waitForTimeout(500);
|
|
@@ -764,6 +766,36 @@ async function glide(x, y) {
|
|
|
764
766
|
await page.mouse.move(x, y).catch(() => {});
|
|
765
767
|
}
|
|
766
768
|
|
|
769
|
+
/** Ease the page (or the target's own scrolling panel) so the element lands at 40% of the viewport. The same
|
|
770
|
+
* easing as smoothScroll, so a camera correction looks exactly like an authored scroll step. */
|
|
771
|
+
async function glideIntoView(el, ms = 800) {
|
|
772
|
+
await el.evaluate((node, m) => new Promise((res) => {
|
|
773
|
+
const scrolls = (n) => {
|
|
774
|
+
if (!n || n === document.body || n === document.documentElement) return false;
|
|
775
|
+
const cs = getComputedStyle(n);
|
|
776
|
+
return /(auto|scroll|overlay)/.test(cs.overflowY) && n.scrollHeight > n.clientHeight + 40;
|
|
777
|
+
};
|
|
778
|
+
let panel = null;
|
|
779
|
+
for (let p = node.parentElement; p; p = p.parentElement) { if (scrolls(p)) { panel = p; break; } }
|
|
780
|
+
const r = node.getBoundingClientRect();
|
|
781
|
+
const h = panel ? panel.clientHeight : window.innerHeight;
|
|
782
|
+
const topInView = panel ? r.top - panel.getBoundingClientRect().top : r.top;
|
|
783
|
+
const d = Math.round(topInView - h * 0.4);
|
|
784
|
+
if (Math.abs(d) < 2) return res();
|
|
785
|
+
const y0 = panel ? panel.scrollTop : window.scrollY;
|
|
786
|
+
const t0 = performance.now();
|
|
787
|
+
const done = setTimeout(res, m + 500); // never hang the take on a tab that stops painting
|
|
788
|
+
const step = (now) => {
|
|
789
|
+
const e = Math.min(1, (now - t0) / m);
|
|
790
|
+
const ease = e < 0.5 ? 2 * e * e : 1 - Math.pow(-2 * e + 2, 2) / 2;
|
|
791
|
+
if (panel) panel.scrollTo(0, y0 + d * ease);
|
|
792
|
+
else window.scrollTo(0, y0 + d * ease);
|
|
793
|
+
if (e < 1) requestAnimationFrame(step); else { clearTimeout(done); res(); }
|
|
794
|
+
};
|
|
795
|
+
requestAnimationFrame(step);
|
|
796
|
+
}), ms).catch(() => {});
|
|
797
|
+
}
|
|
798
|
+
|
|
767
799
|
async function smoothScroll(dy, ms = 1400, within = null) {
|
|
768
800
|
// `within` (2026-08-28, UC-studio lesson): full-viewport apps scroll INNER
|
|
769
801
|
// panels, not the window — window.scrollTo is a no-op there. When set, it
|
package/skill/scripts/plan.mjs
CHANGED
|
@@ -148,14 +148,58 @@ const explore = (from, lookingFor, depth) => {
|
|
|
148
148
|
for (const sc of res.screens.slice(0, 3)) if (sc.shot && fs.existsSync(sc.shot)) content.push({ type: "image", source: { type: "base64", media_type: "image/jpeg", data: fs.readFileSync(sc.shot).toString("base64") } });
|
|
149
149
|
return content;
|
|
150
150
|
};
|
|
151
|
+
// THE SCROLL LAW (founder's take of 2026-10-18 jumped instead of scrolling): the dry run reports where every
|
|
152
|
+
// click/hover/type target sat in the viewport (probe.mjs, the RESULT line). The geometry is kept with the camera
|
|
153
|
+
// steps it was measured on; when the planner hands in that same storyboard, a scroll step is placed before every
|
|
154
|
+
// target that sat below the fold (bottom > viewport − 120) or above it (top < 0), dy = top − 40% of the viewport,
|
|
155
|
+
// 900 ms, so the camera glides there instead of the correction jumping it into frame.
|
|
156
|
+
const FOLD_MARGIN = 120;
|
|
157
|
+
const FOLD_AT = 0.4;
|
|
158
|
+
let lastProbe = null; // { key, geometry: { steps: [...] } }
|
|
159
|
+
const cameraKey = (sb) => (sb?.steps || []).map((s) => [s.action, s.url || "", s.selector || "", typeof s.dy === "number" ? s.dy : ""].join("|")).join("\n");
|
|
151
160
|
const probe = (sb) => {
|
|
152
161
|
if (!sb || !Array.isArray(sb.steps)) return "storyboard with steps[] required";
|
|
153
162
|
const file = path.join(dir, "probe.json");
|
|
154
163
|
fs.writeFileSync(file, JSON.stringify(sb, null, 2) + "\n");
|
|
155
164
|
const r = run("probe.mjs", [file]);
|
|
156
|
-
const
|
|
165
|
+
const all = (r.stdout || "").split("\n");
|
|
166
|
+
const last = all.filter((l) => l.startsWith("RESULT ")).pop();
|
|
167
|
+
let geometry = null; try { geometry = JSON.parse(last.slice(7)); } catch { /* an older probe, or it died */ }
|
|
168
|
+
lastProbe = geometry ? { key: cameraKey(sb), geometry } : null;
|
|
169
|
+
const text = all.filter((l) => !l.startsWith("RESULT ")).join("\n");
|
|
170
|
+
let out = `${text}${r.stderr ? `\n${r.stderr}` : ""}`.trim();
|
|
171
|
+
const below = foldScrolls(sb, geometry, true);
|
|
172
|
+
if (below.length) out += `\n\nBelow the fold: ${below.map((b) => `step ${b.step} (${b.selector}, top ${b.top} of ${b.viewport})`).join("; ")}. When you hand in this storyboard unchanged, a scroll step (dy ${below.map((b) => b.dy).join(", ")}, 900 ms) is placed before each of them for you; or add your own and probe again.`;
|
|
157
173
|
return out.length > 12000 ? out.slice(0, 12000) + "\n… (cut)" : out || "(no report)";
|
|
158
174
|
};
|
|
175
|
+
/** The scroll steps the geometry asks for. dryRun = true only lists them; otherwise they are spliced into sb.steps. */
|
|
176
|
+
const foldScrolls = (sb, geometry, dryRun = false) => {
|
|
177
|
+
const found = [];
|
|
178
|
+
if (!geometry || !Array.isArray(geometry.steps) || !Array.isArray(sb?.steps)) return found;
|
|
179
|
+
const byStep = new Map(geometry.steps.map((g) => [g.step, g]));
|
|
180
|
+
const steps = sb.steps;
|
|
181
|
+
let offset = 0; // what the inserted scrolls have already moved the page by, on the same screen
|
|
182
|
+
let lastUrl = null;
|
|
183
|
+
const outSteps = [];
|
|
184
|
+
for (const [i, s] of steps.entries()) {
|
|
185
|
+
const g = byStep.get(i + 1);
|
|
186
|
+
if (s.action === "goto") { offset = 0; lastUrl = null; }
|
|
187
|
+
if (g) {
|
|
188
|
+
if (lastUrl !== null && g.url !== lastUrl) offset = 0; // the probe navigated between the two targets
|
|
189
|
+
lastUrl = g.url;
|
|
190
|
+
const top = g.top - offset, bottom = g.bottom - offset;
|
|
191
|
+
if (bottom > g.viewport - FOLD_MARGIN || top < 0) {
|
|
192
|
+
const dy = Math.round(top - g.viewport * FOLD_AT);
|
|
193
|
+
found.push({ step: i + 1, selector: g.selector, top: g.top, viewport: g.viewport, dy });
|
|
194
|
+
if (!dryRun) outSteps.push({ action: "scroll", dy, ms: 900, frame: "wide", label: `bring ${String(g.selector).slice(0, 50)} into view`, inserted: "fold" });
|
|
195
|
+
offset += dy;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
outSteps.push(s);
|
|
199
|
+
}
|
|
200
|
+
if (!dryRun && found.length) sb.steps = outSteps;
|
|
201
|
+
return found;
|
|
202
|
+
};
|
|
159
203
|
const ACTIONS = new Set(["goto", "settle", "scroll", "click", "hover", "type", "expect"]);
|
|
160
204
|
const validate = (sb) => {
|
|
161
205
|
if (!sb || typeof sb !== "object") return "storyboard required";
|
|
@@ -169,11 +213,11 @@ const validate = (sb) => {
|
|
|
169
213
|
if (s.action === "scroll" && typeof s.dy !== "number") return `${at}: scroll needs numeric dy`;
|
|
170
214
|
if (s.frame !== undefined && s.frame !== "close" && s.frame !== "wide") return `${at}: frame must be close or wide`;
|
|
171
215
|
}
|
|
172
|
-
if (sb.steps.length > 14) return "more than 14 steps;
|
|
216
|
+
if (sb.steps.length > 14) return "more than 14 steps; 5 to 8 is the law";
|
|
173
217
|
// THE CAMERA'S CLOCK (founder 2026-10-15): the take may not run past 45 s. The estimate is the
|
|
174
218
|
// camera's own (clock.mjs); the model gets the number and the cure.
|
|
175
219
|
const est = estimateTake(sb.steps);
|
|
176
|
-
if (est > 45) return `the camera would run this take about ${Math.round(est)} s; the law is 30 to 45. Cut words
|
|
220
|
+
if (est > 45) return `the camera would run this take about ${Math.round(est)} s; the law is 30 to 45. Cut words or steps: one line per idea, 3 to 6 narrated lines, no floor on a line, silence on the clicks; never pad, never add.`;
|
|
177
221
|
return null;
|
|
178
222
|
};
|
|
179
223
|
|
|
@@ -304,13 +348,18 @@ const plan = async () => {
|
|
|
304
348
|
if (capped()) out = CAP_TEXT();
|
|
305
349
|
else {
|
|
306
350
|
probes += 1; if (!firstProbeAt) firstProbeAt = Date.now();
|
|
307
|
-
say(`
|
|
351
|
+
say(`Walking the storyboard, ${(input.storyboard?.steps || []).length} steps…`);
|
|
308
352
|
out = probe(input.storyboard);
|
|
309
353
|
if (capped()) out += `\n\n${CAP_TEXT()}`;
|
|
310
354
|
}
|
|
311
355
|
}
|
|
312
356
|
else if (u.name === "submit_storyboard") {
|
|
313
357
|
say("Handing in the storyboard…");
|
|
358
|
+
// The fold scrolls go in before the clock is read, so the estimate is the take the camera will run.
|
|
359
|
+
if (lastProbe && input.storyboard && lastProbe.key === cameraKey(input.storyboard)) {
|
|
360
|
+
const added = foldScrolls(input.storyboard, lastProbe.geometry);
|
|
361
|
+
if (added.length) console.log(` Scroll steps placed before ${added.map((a) => `step ${a.step}`).join(", ")} (below the fold in the dry run).`);
|
|
362
|
+
}
|
|
314
363
|
const bad = validate(input.storyboard);
|
|
315
364
|
if (bad) out = `Not accepted: ${bad}`;
|
|
316
365
|
else { storyboard = input.storyboard; creates = typeof input.creates === "string" ? input.creates : ""; if (typeof input.unsure === "string" && input.unsure.trim()) storyboard.unsure = input.unsure.trim().slice(0, 240); out = "Accepted."; }
|
package/skill/scripts/probe.mjs
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
// report lists the visible controls on that page so the planner can pick the
|
|
9
9
|
// real one, and the run stops there.
|
|
10
10
|
//
|
|
11
|
+
// THE SCROLL LAW (founder's take of 2026-10-18 jumped instead of scrolling):
|
|
12
|
+
// every target reports its geometry back, the element's top and bottom in the
|
|
13
|
+
// viewport at the moment of the probe and the viewport's height, so the
|
|
14
|
+
// planner (plan.mjs) knows which targets sit below the fold and puts a scroll
|
|
15
|
+
// step before each of them. The last line of stdout is machine-readable:
|
|
16
|
+
// RESULT { passed, viewport, steps: [{ step, action, selector, top, bottom, viewport, url }] }
|
|
17
|
+
//
|
|
11
18
|
// node scripts/probe.mjs <storyboard.json>
|
|
12
19
|
import fs from "node:fs";
|
|
13
20
|
import path from "node:path";
|
|
@@ -33,6 +40,9 @@ const ms = (v, d) => (typeof v !== "number" ? d : v < 60 ? v * 1000 : v);
|
|
|
33
40
|
const lines = [];
|
|
34
41
|
const out = (t) => { lines.push(t); console.log(t); };
|
|
35
42
|
let failed = false;
|
|
43
|
+
const VIEWPORT = launchOpts.viewport.height;
|
|
44
|
+
const FOLD_MARGIN = 120;
|
|
45
|
+
const geometry = [];
|
|
36
46
|
|
|
37
47
|
const visible = async (selector, minY) => {
|
|
38
48
|
const loc = page.locator(selector);
|
|
@@ -42,12 +52,20 @@ const visible = async (selector, minY) => {
|
|
|
42
52
|
for (let i = 0; i < Math.min(n, 12); i++) {
|
|
43
53
|
const el = loc.nth(i);
|
|
44
54
|
const box = await el.boundingBox().catch(() => null);
|
|
45
|
-
if (box && box.width > 0 && box.height > 0 && box.y >= (minY || 0)) return el;
|
|
55
|
+
if (box && box.width > 0 && box.height > 0 && box.y >= (minY || 0)) return { el, box };
|
|
46
56
|
}
|
|
47
57
|
await page.waitForTimeout(250);
|
|
48
58
|
}
|
|
49
59
|
return null;
|
|
50
60
|
};
|
|
61
|
+
/** Where the target sat when the probe reached it: top and bottom in the viewport, and the viewport's height. */
|
|
62
|
+
const place = (n, s, box) => {
|
|
63
|
+
const top = Math.round(box.y), bottom = Math.round(box.y + box.height);
|
|
64
|
+
const g = { step: n, action: s.action, selector: s.selector, top, bottom, viewport: VIEWPORT, url: page.url() };
|
|
65
|
+
geometry.push(g);
|
|
66
|
+
const where = bottom > VIEWPORT - FOLD_MARGIN ? " — BELOW THE FOLD: a scroll step goes before it" : top < 0 ? " — ABOVE THE VIEWPORT: a scroll step goes before it" : "";
|
|
67
|
+
return ` (top ${top}, bottom ${bottom} of ${VIEWPORT})${where}`;
|
|
68
|
+
};
|
|
51
69
|
const controls = async () => page.evaluate(() => {
|
|
52
70
|
const vis = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none"; };
|
|
53
71
|
const clean = (t) => (t || "").replace(/\s+/g, " ").trim();
|
|
@@ -86,7 +104,9 @@ for (const [i, s] of steps.entries()) {
|
|
|
86
104
|
await page.waitForTimeout(600);
|
|
87
105
|
out(`step ${n} scroll ${s.dy}: ok`);
|
|
88
106
|
} else if (s.action === "click" || s.action === "hover" || s.action === "expect" || s.action === "type") {
|
|
89
|
-
const
|
|
107
|
+
const hit = await visible(s.selector, s.minY);
|
|
108
|
+
const el = hit?.el ?? null;
|
|
109
|
+
const geo = hit ? place(n, s, hit.box) : "";
|
|
90
110
|
if (!el) {
|
|
91
111
|
out(`step ${n} ${s.action} ${JSON.stringify(s.selector)}: NOT FOUND on ${page.url()}`);
|
|
92
112
|
out(` visible controls here:\n ${(await controls()).join("\n ")}`);
|
|
@@ -101,17 +121,17 @@ for (const [i, s] of steps.entries()) {
|
|
|
101
121
|
if (s.waitLoad) await page.waitForLoadState("load").catch(() => {});
|
|
102
122
|
let revealed = "";
|
|
103
123
|
if (typeof s.reveals === "string" && s.reveals) revealed = (await visible(s.reveals, 0)) ? `, reveals ${JSON.stringify(s.reveals)} seen` : `, reveals ${JSON.stringify(s.reveals)} NOT SEEN`;
|
|
104
|
-
out(`step ${n} click ${JSON.stringify(s.selector)}: ok${revealed} — now at ${page.url()}`);
|
|
124
|
+
out(`step ${n} click ${JSON.stringify(s.selector)}: ok${geo}${revealed} — now at ${page.url()}`);
|
|
105
125
|
} else if (s.action === "type") {
|
|
106
126
|
if (s.clear) await el.fill("");
|
|
107
127
|
await el.click({ timeout: 8000 }).catch(() => {});
|
|
108
128
|
await el.type(String(s.text), { delay: 10 });
|
|
109
129
|
if (s.enter) { await page.keyboard.press("Enter"); await page.waitForTimeout(Math.min(ms(s.after, 1500), 4000)); }
|
|
110
|
-
out(`step ${n} type into ${JSON.stringify(s.selector)}: ok — now at ${page.url()}`);
|
|
130
|
+
out(`step ${n} type into ${JSON.stringify(s.selector)}: ok${geo} — now at ${page.url()}`);
|
|
111
131
|
} else {
|
|
112
132
|
await el.hover().catch(() => {});
|
|
113
133
|
await page.waitForTimeout(400);
|
|
114
|
-
out(`step ${n} ${s.action === "click" ? "pointed at" : s.action} ${JSON.stringify(s.selector)}: ok`);
|
|
134
|
+
out(`step ${n} ${s.action === "click" ? "pointed at" : s.action} ${JSON.stringify(s.selector)}: ok${geo}`);
|
|
115
135
|
}
|
|
116
136
|
} else {
|
|
117
137
|
out(`step ${n}: unknown action ${JSON.stringify(s.action)}`);
|
|
@@ -126,5 +146,6 @@ for (const [i, s] of steps.entries()) {
|
|
|
126
146
|
}
|
|
127
147
|
}
|
|
128
148
|
out(failed ? "PROBE FAILED: fix the steps above and probe again." : `PROBE PASSED: ${steps.length} steps resolved.`);
|
|
149
|
+
console.log("RESULT " + JSON.stringify({ passed: !failed, viewport: VIEWPORT, steps: geometry }));
|
|
129
150
|
await ctx.close();
|
|
130
151
|
process.exit(failed ? 1 : 0);
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// this take; a no-op everywhere else (the terminal flow, the cloud sandbox
|
|
23
23
|
// copy that may not ship listen-event.mjs).
|
|
24
24
|
const postEvent = await import("./listen-event.mjs").then((m) => m.postEvent).catch(() => async () => null);
|
|
25
|
+
const listening = await import("./listen-event.mjs").then((m) => m.listening).catch(() => false);
|
|
25
26
|
/** Where the person watches their takes: the Demos grid. `dashboardUrl` from
|
|
26
27
|
* the server wins (relative or absolute), else <base>/demos. */
|
|
27
28
|
export function gridUrl(base, dashboardUrl) {
|
|
@@ -151,6 +152,16 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
|
|
|
151
152
|
if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
|
|
152
153
|
}
|
|
153
154
|
if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
|
|
155
|
+
// THE STILLS, DRAWN HERE BEFORE "READY" (2026-10-18): the demo's intro and backdrop, by the studio's own code in
|
|
156
|
+
// this recorder's Chrome, remembered by the app so the export draws nothing. Bounded; a miss costs nothing.
|
|
157
|
+
if (listening) {
|
|
158
|
+
try {
|
|
159
|
+
const { spawnSync } = await import("node:child_process");
|
|
160
|
+
const here = (await import("node:path")).dirname((await import("node:url")).fileURLToPath(import.meta.url));
|
|
161
|
+
const r = spawnSync(process.execPath, [(await import("node:path")).join(here, "stills.mjs"), String(approvedBiteId)], { cwd: process.cwd(), encoding: "utf8", env: process.env, timeout: 60000 });
|
|
162
|
+
for (const l of (r.stdout || "").trim().split("\n").filter(Boolean)) console.log(` ${l}`);
|
|
163
|
+
} catch (e) { console.log(` stills: skipped (${String(e?.message ?? e).split("\n")[0]})`); }
|
|
164
|
+
}
|
|
154
165
|
await postEvent("ready", { biteId: approvedBiteId, studioUrl: finalStudioUrl ?? null });
|
|
155
166
|
return { exitCode: 0, status: "completed", biteId: approvedBiteId, studioUrl: finalStudioUrl };
|
|
156
167
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// THE STILLS ARE DRAWN HERE (founder 2026-10-18: "at that point you have a browser… load fabric… create the two
|
|
3
|
+
// images"). After the demo is built and before this recorder says "ready", its own Chrome opens the app's internal
|
|
4
|
+
// still page — the studio's own render code, the design's fonts from the web — for the intro and the backdrop,
|
|
5
|
+
// and hands the PNGs to the app, which remembers them for the export. Seconds here; a 30-second sandbox saved there.
|
|
6
|
+
// Never fatal: on any miss the export draws them itself as before.
|
|
7
|
+
// usage: node stills.mjs <biteId> (base and key from <cwd>/.recorder/config.json)
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
const biteId = Number(process.argv[2] || 0);
|
|
11
|
+
if (!biteId) { console.error("usage: stills.mjs <biteId>"); process.exit(2); }
|
|
12
|
+
const cfg = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), ".recorder", "config.json"), "utf8"));
|
|
13
|
+
const base = (process.env.RECORDER_BASE || cfg.base || "https://app.demobites.com").replace(/\/+$/, "");
|
|
14
|
+
const headers = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
15
|
+
const t0 = Date.now();
|
|
16
|
+
const plan = await fetch(`${base}/api/recorder/stills?bite_id=${biteId}`, { headers, signal: AbortSignal.timeout(15000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
17
|
+
if (!plan?.stills?.length) { console.log("stills: nothing to draw here"); process.exit(0); }
|
|
18
|
+
const { chromium } = await import("playwright");
|
|
19
|
+
const browser = await chromium.launch({ channel: "chrome", headless: true }).catch(async () => chromium.launch({ headless: true }));
|
|
20
|
+
const out = {};
|
|
21
|
+
try {
|
|
22
|
+
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
|
23
|
+
for (const still of plan.stills) {
|
|
24
|
+
const page = await ctx.newPage();
|
|
25
|
+
try {
|
|
26
|
+
const res = await page.goto(still.url, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
27
|
+
if (!res || res.status() !== 200) throw new Error(`the still page answered ${res ? res.status() : "nothing"}`);
|
|
28
|
+
await page.waitForFunction("window.__FABRIC_STILL__ !== undefined", undefined, { timeout: 25000 });
|
|
29
|
+
const r = await page.evaluate("window.__FABRIC_STILL__");
|
|
30
|
+
if (!r?.ok || typeof r.dataUrl !== "string" || !r.dataUrl.startsWith("data:image/png;base64,")) throw new Error(r?.error || "no PNG from the still page");
|
|
31
|
+
out[still.kind] = r.dataUrl.slice("data:image/png;base64,".length);
|
|
32
|
+
console.log(`stills: ${still.kind} drawn (${Math.round(out[still.kind].length * 0.75 / 1024)} KB)`);
|
|
33
|
+
} catch (e) { console.log(`stills: ${still.kind} skipped (${String(e?.message ?? e).split("\n")[0]})`); }
|
|
34
|
+
finally { await page.close().catch(() => {}); }
|
|
35
|
+
}
|
|
36
|
+
} finally { await browser.close().catch(() => {}); }
|
|
37
|
+
if (!Object.keys(out).length) process.exit(0);
|
|
38
|
+
const put = await fetch(`${base}/api/recorder/stills`, { method: "PUT", headers, body: JSON.stringify({ bite_id: biteId, hash: plan.hash, stills: out }), signal: AbortSignal.timeout(30000) }).then((r) => r.json().catch(() => ({ ok: r.ok }))).catch((e) => ({ error: String(e?.message ?? e) }));
|
|
39
|
+
console.log(`stills: ${put?.ok ? `remembered for the export (${Object.keys(out).join(", ")})` : `not kept (${put?.error ?? "?"})`} in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
|