demobites 1.27.0 → 1.29.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 +91 -33
- package/scripts/trim.mjs +45 -0
- package/skill/scripts/clock.mjs +4 -2
package/package.json
CHANGED
package/scripts/record.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { chromium } from "playwright";
|
|
13
13
|
import fs from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { ffmpeg as FFMPEG } from "./media-tools.mjs";
|
|
15
17
|
// LISTENING (2026-10-14): the app's mailbox, when `npx demobite listen` runs
|
|
16
18
|
// this take; a no-op everywhere else (the terminal flow, the cloud sandbox
|
|
17
19
|
// copy that may not ship listen-event.mjs).
|
|
@@ -146,7 +148,8 @@ fs.mkdirSync(profileDir, { recursive: true });
|
|
|
146
148
|
// a customer's own uploaded voice. Holding a shot to cover an estimated line
|
|
147
149
|
// is what produced a 60-second take with the cursor parked for 12 seconds.
|
|
148
150
|
const DEFAULT_SETTLE_MS = 2200;
|
|
149
|
-
|
|
151
|
+
// 1.6 s (was 3.2 s; the planner's clock budgets 2 s for the beat — founder 2026-10-18: planned 31 s, filmed 46 s).
|
|
152
|
+
const DEFAULT_HOVER_DWELL = 1600;
|
|
150
153
|
const DEFAULT_CLICK_DWELL = 700;
|
|
151
154
|
const DEFAULT_CLICK_AFTER = 2000;
|
|
152
155
|
const CLOSING_BEAT_MS = 2200;
|
|
@@ -385,14 +388,64 @@ const postFrame = async () => {
|
|
|
385
388
|
const within = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout ${ms} ms`)), ms))]);
|
|
386
389
|
try {
|
|
387
390
|
frameCdp ??= await within(ctx.newCDPSession(page), 4000);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
+
// THE CAMERA'S VIEWPORT IS NEVER TOUCHED (founder's take of 2026-10-18, bite 1234: grey quarter-size frames at
|
|
392
|
+
// 0.6 s, 8.3 s, 11.1 s, 16.1 s). A captureScreenshot with a clip at scale 0.25 made Chromium emulate that scale
|
|
393
|
+
// for the capture, and the video screencast recorded those frames. So: capture at scale 1, no clip, and shrink
|
|
394
|
+
// the JPEG OUTSIDE the page with ffmpeg. No ffmpeg → no thumbnail, never a glitch.
|
|
395
|
+
const { data } = await within(frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 60 }), 4000);
|
|
396
|
+
const small = await within(shrinkJpeg(Buffer.from(data, "base64"), 480), 4000);
|
|
397
|
+
if (small) await within(postEvent("frame", { ...frameStep, jpeg: small.toString("base64") }), 8000);
|
|
391
398
|
} catch (e) {
|
|
392
399
|
if (!frameWarned) { frameWarned = true; console.error(`frame: dropped (${String(e?.message ?? e).split("\n")[0]})`); }
|
|
393
400
|
} finally { framing = false; }
|
|
394
401
|
};
|
|
395
402
|
const framer = setInterval(() => void postFrame(), FRAME_EVERY_MS);
|
|
403
|
+
/** A JPEG scaled to `width` by ffmpeg, in memory; null when ffmpeg is not there. */
|
|
404
|
+
let ffmpegPath;
|
|
405
|
+
function shrinkJpeg(buf, width) {
|
|
406
|
+
if (ffmpegPath === undefined) { try { ffmpegPath = FFMPEG() || null; } catch { ffmpegPath = null; } }
|
|
407
|
+
if (!ffmpegPath) return Promise.resolve(null);
|
|
408
|
+
return new Promise((resolve) => {
|
|
409
|
+
const ff = spawn(ffmpegPath, ["-hide_banner", "-loglevel", "error", "-f", "image2pipe", "-i", "pipe:0", "-vf", `scale=${width}:-2`, "-q:v", "7", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1"], { stdio: ["pipe", "pipe", "ignore"] });
|
|
410
|
+
const out = [];
|
|
411
|
+
ff.stdout.on("data", (d) => out.push(d));
|
|
412
|
+
ff.on("error", () => resolve(null));
|
|
413
|
+
ff.on("close", (code) => resolve(code === 0 && out.length ? Buffer.concat(out) : null));
|
|
414
|
+
ff.stdin.on("error", () => {});
|
|
415
|
+
ff.stdin.end(buf);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* THE PICTURE IS STILL (founder 2026-10-18: "wait till the page loads before you're actually recording"; bite 1234
|
|
421
|
+
* opened on skeleton cards). Paint is not load: text and boxes exist while thumbnails and skeletons still shimmer.
|
|
422
|
+
* This waits until no skeleton / busy / shimmer element shows AND two stills 350 ms apart are identical, up to
|
|
423
|
+
* `maxMs`. Stills are full-frame captures at scale 1: they never touch the camera's viewport.
|
|
424
|
+
*/
|
|
425
|
+
async function settledPicture(maxMs, label) {
|
|
426
|
+
const deadline = Date.now() + maxMs;
|
|
427
|
+
let prev = null;
|
|
428
|
+
while (Date.now() < deadline) {
|
|
429
|
+
const busy = await page.evaluate(() => {
|
|
430
|
+
let n = 0;
|
|
431
|
+
for (const el of document.querySelectorAll('.animate-pulse, [class*="skeleton" i], [class*="shimmer" i], [aria-busy="true"], [data-loading="true"]')) {
|
|
432
|
+
const r = el.getBoundingClientRect();
|
|
433
|
+
if (r.width > 8 && r.height > 8) n++;
|
|
434
|
+
}
|
|
435
|
+
return n;
|
|
436
|
+
}).catch(() => 0);
|
|
437
|
+
let shot = null;
|
|
438
|
+
try {
|
|
439
|
+
frameCdp ??= await ctx.newCDPSession(page);
|
|
440
|
+
shot = (await frameCdp.send("Page.captureScreenshot", { format: "jpeg", quality: 25 })).data;
|
|
441
|
+
} catch { frameCdp = null; }
|
|
442
|
+
if (!busy && shot && prev && shot === prev) return true;
|
|
443
|
+
prev = shot;
|
|
444
|
+
await page.waitForTimeout(350);
|
|
445
|
+
}
|
|
446
|
+
console.log(`STILL GATE (${label}): the picture kept changing for ${Math.round(maxMs / 1000)} s — going on anyway.`);
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
396
449
|
|
|
397
450
|
/** Ask the element what cursor it shows. Takes the locator visibleBox already
|
|
398
451
|
* resolved — re-scanning cost ~1s of dead video per step, which inflated a
|
|
@@ -629,9 +682,13 @@ async function revealedBox(step) {
|
|
|
629
682
|
// Pace matched to the reference: a 350px move lands in ~0.75s INCLUDING its
|
|
630
683
|
// deceleration tail. The ballistic shape covers most distance early, so the
|
|
631
684
|
// same wall duration reads far snappier than min-jerk did.
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
685
|
+
// THE GLIDE IS PACED BY THE CLOCK (founder's take of 2026-10-18, bite 1234: four glides took 7.2 s, 3.6 s, 3.5 s and
|
|
686
|
+
// 4.8 s against this cap — the loop awaited a browser round trip on each of its ~60 sub-steps, so its pace was the
|
|
687
|
+
// protocol's latency, not the design). Now the recorded cursor advances by wall time at 60 Hz, and the real mouse
|
|
688
|
+
// follows with at most one move in flight, skipping points it cannot keep up with.
|
|
689
|
+
const GLIDE_MIN_MS = 420;
|
|
690
|
+
const GLIDE_MAX_MS = 900;
|
|
691
|
+
const GLIDE_PER_PX = 0.85;
|
|
635
692
|
|
|
636
693
|
// LAW (ballistic motion, measured off the founder's reference hero-demo,
|
|
637
694
|
// 2026-08-09): the life is in the VELOCITY PROFILE, not the path. Frame-by-
|
|
@@ -668,7 +725,6 @@ async function glide(x, y) {
|
|
|
668
725
|
// not the 2x capture space, or every move doubles in duration and bow.
|
|
669
726
|
const designDist = dist / SUPERSAMPLE;
|
|
670
727
|
const dur = Math.min(GLIDE_MAX_MS, Math.max(GLIDE_MIN_MS, designDist * GLIDE_PER_PX));
|
|
671
|
-
const steps = Math.max(12, Math.round(dur / 16));
|
|
672
728
|
// Near-straight trace: the reference hero-demo's moves are straight to
|
|
673
729
|
// within 2% (path/chord 1.02) — a big swoop reads as fake, a trace of
|
|
674
730
|
// curvature reads as a hand. 4.5% capped 20px, DETERMINISTIC.
|
|
@@ -679,36 +735,30 @@ async function glide(x, y) {
|
|
|
679
735
|
const c1y = y0 + (y - y0) * 0.3 + perpY * arcMag * sign;
|
|
680
736
|
const c2x = x0 + (x - x0) * 0.75 + perpX * arcMag * 0.35 * sign;
|
|
681
737
|
const c2y = y0 + (y - y0) * 0.75 + perpY * arcMag * 0.35 * sign;
|
|
682
|
-
|
|
683
|
-
|
|
738
|
+
const started = Date.now();
|
|
739
|
+
let inflight = null;
|
|
740
|
+
for (;;) {
|
|
741
|
+
const f = Math.min(1, (Date.now() - started) / dur);
|
|
742
|
+
const e = ballistic(f);
|
|
684
743
|
const u = 1 - e;
|
|
685
744
|
const nx = u * u * u * x0 + 3 * u * u * e * c1x + 3 * u * e * e * c2x + e * e * e * x;
|
|
686
745
|
const ny = u * u * u * y0 + 3 * u * u * e * c1y + 3 * u * e * e * c2y + e * e * e * y;
|
|
687
|
-
// LAW (record what you animate, founder 2026-08-09):
|
|
688
|
-
//
|
|
689
|
-
// OLD position for the entire glide and then teleported. 98% of a real
|
|
690
|
-
// take's events were duplicates and the cursor jumped 458px in 0ms. Every
|
|
691
|
-
// reported symptom (rigid, jumping, too fast, missing where the action is,
|
|
692
|
-
// appearing and reappearing) came from that one omission — at a 3x zoom
|
|
693
|
-
// the visible frame is 640x360, so a cursor parked at the previous target
|
|
694
|
-
// is off-frame entirely. Advance the live position EVERY frame and record
|
|
695
|
-
// it, so the event track is the motion the viewer actually sees.
|
|
746
|
+
// LAW (record what you animate, founder 2026-08-09): advance the live position EVERY frame and record it, so
|
|
747
|
+
// the event track is the motion the viewer actually sees.
|
|
696
748
|
cx = nx; cy = ny;
|
|
697
749
|
pushMouse("move", nx, ny);
|
|
698
|
-
// The burned-in cursor only exists in the STANDALONE ending.
|
|
699
|
-
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
if (
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
// LAW (mouse-coordinate clicks): the real mouse tracks the drawn cursor,
|
|
706
|
-
// so hover states fire naturally and the click lands where the pulse is.
|
|
707
|
-
await page.mouse.move(nx, ny);
|
|
708
|
-
await page.waitForTimeout(dur / steps);
|
|
750
|
+
// The burned-in cursor only exists in the STANDALONE ending.
|
|
751
|
+
if (BURN_CURSOR) await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [nx, ny]);
|
|
752
|
+
// LAW (mouse-coordinate clicks): the real mouse tracks the drawn cursor, so hover states fire naturally and the
|
|
753
|
+
// click lands where the pulse is. One move in flight at a time: a slow round trip skips points, never the clock.
|
|
754
|
+
if (!inflight) inflight = page.mouse.move(nx, ny).catch(() => {}).finally(() => { inflight = null; });
|
|
755
|
+
if (f >= 1) break;
|
|
756
|
+
await page.waitForTimeout(16);
|
|
709
757
|
}
|
|
758
|
+
if (inflight) await inflight;
|
|
710
759
|
cx = x; cy = y;
|
|
711
760
|
pushMouse("move", x, y);
|
|
761
|
+
await page.mouse.move(x, y).catch(() => {});
|
|
712
762
|
}
|
|
713
763
|
|
|
714
764
|
async function smoothScroll(dy, ms = 1400, within = null) {
|
|
@@ -821,7 +871,7 @@ try {
|
|
|
821
871
|
// then stamp the window so manifest.mjs cuts it out with a fade —
|
|
822
872
|
// the viewer sees page one, a fade, page two. No zoom or narration
|
|
823
873
|
// may live inside that window (manifest.mjs keeps them out).
|
|
824
|
-
await page.waitForLoadState("networkidle", { timeout:
|
|
874
|
+
await page.waitForLoadState("networkidle", { timeout: 2500 }).catch(() => {});
|
|
825
875
|
const paintDeadline = Date.now() + 10000;
|
|
826
876
|
while (Date.now() < paintDeadline) {
|
|
827
877
|
const painted = await page.evaluate(() => {
|
|
@@ -836,6 +886,7 @@ try {
|
|
|
836
886
|
if (painted) break;
|
|
837
887
|
await page.waitForTimeout(200);
|
|
838
888
|
}
|
|
889
|
+
await settledPicture(3000, "navigation");
|
|
839
890
|
await page.waitForTimeout(350);
|
|
840
891
|
rec.nav = { from: navIssuedAt, to: t() };
|
|
841
892
|
}
|
|
@@ -871,6 +922,7 @@ try {
|
|
|
871
922
|
if (!painted) {
|
|
872
923
|
console.log("PAINT GATE: page never showed real content within 15s — the take would open on a blank frame. Judge the footage carefully.");
|
|
873
924
|
}
|
|
925
|
+
await settledPicture(8000, "first frame");
|
|
874
926
|
const nextStep = STORYBOARD.steps[STORYBOARD.steps.indexOf(step) + 1];
|
|
875
927
|
if (nextStep?.action === "expect" && nextStep.selector) {
|
|
876
928
|
const sel = nextStep.text
|
|
@@ -1073,7 +1125,7 @@ try {
|
|
|
1073
1125
|
// the dialog opened in the middle of the screen. A step framed wide
|
|
1074
1126
|
// already frames its result; a click that changed the URL leaves the
|
|
1075
1127
|
// landing to the settle that follows (framed wide there).
|
|
1076
|
-
if (page.url() !== urlBeforeClick) landingPending = true;
|
|
1128
|
+
if (page.url() !== urlBeforeClick) { landingPending = true; await settledPicture(2500, "landing"); }
|
|
1077
1129
|
if (step.reveals !== false && !stepWide) {
|
|
1078
1130
|
const opened = await revealedBox(step);
|
|
1079
1131
|
if (opened) {
|
|
@@ -1168,7 +1220,9 @@ try {
|
|
|
1168
1220
|
// the rest of the take.
|
|
1169
1221
|
await page.waitForLoadState("domcontentloaded", { timeout: 30000 }).catch(() => {});
|
|
1170
1222
|
if (page.url() !== urlBefore) {
|
|
1171
|
-
|
|
1223
|
+
// The still gate replaces a networkidle that long-pollers never let end (6 s of dead air, 2026-10-18).
|
|
1224
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 6000 }).catch(() => {});
|
|
1225
|
+
await settledPicture(2500, "landing");
|
|
1172
1226
|
await ensureCursor();
|
|
1173
1227
|
await applyHideCss();
|
|
1174
1228
|
await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [x, y]);
|
|
@@ -1232,6 +1286,10 @@ try {
|
|
|
1232
1286
|
}
|
|
1233
1287
|
// Closing beat so the last action breathes before the cut ends.
|
|
1234
1288
|
await page.waitForTimeout(CLOSING_BEAT_MS);
|
|
1289
|
+
// THE TAKE SAYS WHAT IT COST (2026-10-18): per step, so a slow take explains itself.
|
|
1290
|
+
const glides = manifest.camera.filter((c) => c.glide).map((c) => c.glide.t_end - c.glide.t_start);
|
|
1291
|
+
manifest.timing = { filmed_s: Math.round(t() * 100) / 100, steps: manifest.steps.map((st) => ({ n: st.n, action: st.action, s: Math.round(((st.t_end ?? 0) - (st.t_start ?? 0)) * 100) / 100 })), glide_s: Math.round(glides.reduce((a, b) => a + b, 0) * 100) / 100, glides: glides.length };
|
|
1292
|
+
console.log(`timing: filmed ${manifest.timing.filmed_s}s; steps ${manifest.timing.steps.map((st) => `${st.n}:${st.s}s`).join(" ")}; glides ${glides.length} totalling ${manifest.timing.glide_s}s`);
|
|
1235
1293
|
} catch (e) {
|
|
1236
1294
|
failure = e;
|
|
1237
1295
|
}
|
package/scripts/trim.mjs
CHANGED
|
@@ -150,6 +150,51 @@ execFileSync(FFMPEG(), [
|
|
|
150
150
|
clean,
|
|
151
151
|
], { stdio: "inherit" });
|
|
152
152
|
|
|
153
|
+
// GLITCH GUARD (founder 2026-10-18, bite 1234: "extremely bad glitches"): a frame the camera should never have
|
|
154
|
+
// produced — the page painted small over a grey canvas (a viewport emulation caught by the screencast) — must never
|
|
155
|
+
// reach a demo, whatever caused it. Each such frame is replaced by the last good one (freezeframes), the count is
|
|
156
|
+
// logged and kept in the manifest. Detection: the bottom-right quadrant is flat mid-grey (Y≈128, almost no spread);
|
|
157
|
+
// real UI there is white, dark or busy. Needs signalstats + freezeframes in the ffmpeg build; without them it skips.
|
|
158
|
+
try {
|
|
159
|
+
const filters = execFileSync(FFMPEG(), ["-hide_banner", "-filters"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
160
|
+
if (/\bfreezeframes\b/.test(filters) && /\bsignalstats\b/.test(filters)) {
|
|
161
|
+
const stats = spawnSync(FFMPEG(), ["-hide_banner", "-loglevel", "error", "-i", clean, "-vf", "crop=iw/2:ih/2:iw/2:ih/2,signalstats,metadata=print:file=-", "-f", "null", "-"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
162
|
+
const bad = [];
|
|
163
|
+
let frame = -1, yavg = null, ymin = null, ymax = null;
|
|
164
|
+
const flush = () => { if (frame >= 0 && yavg !== null && yavg >= 118 && yavg <= 138 && ymin !== null && ymax !== null && ymax - ymin <= 24) bad.push(frame); };
|
|
165
|
+
for (const line of (stats.stdout || "").split("\n")) {
|
|
166
|
+
const f = line.match(/^frame:(\d+)/);
|
|
167
|
+
if (f) { flush(); frame = Number(f[1]); yavg = ymin = ymax = null; continue; }
|
|
168
|
+
const m = line.match(/lavfi\.signalstats\.(YAVG|YMIN|YMAX)=([\d.]+)/);
|
|
169
|
+
if (!m) continue;
|
|
170
|
+
if (m[1] === "YAVG") yavg = Number(m[2]); else if (m[1] === "YMIN") ymin = Number(m[2]); else ymax = Number(m[2]);
|
|
171
|
+
}
|
|
172
|
+
flush();
|
|
173
|
+
const total = frame + 1;
|
|
174
|
+
if (bad.length && bad.length < total * 0.5) {
|
|
175
|
+
const runs = [];
|
|
176
|
+
for (const n of bad) { const r = runs[runs.length - 1]; if (r && n === r.last + 1) r.last = n; else runs.push({ first: n, last: n }); }
|
|
177
|
+
// Replace each run with the frame before it; a run at the very start takes the first good frame after it.
|
|
178
|
+
const chain = runs.map((r) => ({ ...r, replace: r.first > 0 ? r.first - 1 : Math.min(total - 1, r.last + 1) })).filter((r) => r.replace >= 0 && r.replace < total);
|
|
179
|
+
const labels = chain.map((_, i) => `[r${i}]`).join("");
|
|
180
|
+
let graph = `[0:v]split=${chain.length + 1}[a0]${labels};`;
|
|
181
|
+
chain.forEach((r, i) => { graph += `[a${i}][r${i}]freezeframes=first=${r.first}:last=${r.last}:replace=${r.replace}[a${i + 1}];`; });
|
|
182
|
+
graph += `[a${chain.length}]format=yuv420p[out]`;
|
|
183
|
+
const fixed = path.join(dir, "clean-fixed.mp4");
|
|
184
|
+
void postEvent("finishing", { text: `Replacing ${bad.length} glitch frame${bad.length === 1 ? "" : "s"}…` });
|
|
185
|
+
execFileSync(FFMPEG(), ["-y", "-loglevel", "error", "-i", clean, "-filter_complex", graph, "-map", "[out]", "-c:v", "libx264", "-preset", "medium", "-crf", "19", "-movflags", "+faststart", fixed], { stdio: "inherit" });
|
|
186
|
+
fs.renameSync(fixed, clean);
|
|
187
|
+
man.glitch = { frames: bad.length, windows: chain.map((r) => ({ first: r.first, last: r.last })) };
|
|
188
|
+
fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
|
|
189
|
+
console.log(`glitch guard: ${bad.length} frame${bad.length === 1 ? "" : "s"} in ${chain.length} window${chain.length === 1 ? "" : "s"} replaced by the last good frame (${chain.map((r) => `${(r.first / 30).toFixed(2)}–${((r.last + 1) / 30).toFixed(2)}s`).join(", ")})`);
|
|
190
|
+
} else if (bad.length) {
|
|
191
|
+
console.log(`glitch guard: ${bad.length} of ${total} frames look padded — too many to be a glitch, left as filmed`);
|
|
192
|
+
} else console.log(`glitch guard: clean (${total} frames)`);
|
|
193
|
+
} else console.log("glitch guard: skipped (ffmpeg build without signalstats/freezeframes)");
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.error("glitch guard: skipped (" + String(e?.message ?? e).split("\n")[0] + ")");
|
|
196
|
+
}
|
|
197
|
+
|
|
153
198
|
const dur = execFileSync(FFPROBE(), [
|
|
154
199
|
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clean,
|
|
155
200
|
]).toString().trim();
|
package/skill/scripts/clock.mjs
CHANGED
|
@@ -14,8 +14,10 @@ export const secondsOf = (s, firstNarrated) => {
|
|
|
14
14
|
case "goto": return 0.1;
|
|
15
15
|
case "settle": return Math.max(ms(s.ms, 1200) / 1000, say) + slack;
|
|
16
16
|
case "scroll": return Math.max(ms(s.ms, 1900) / 1000, say) + slack;
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// A click: the glide (~0.7 s), the dwell and the press (0.9 s), then the beat; a click that opens another page
|
|
18
|
+
// lands first (~1 s). A hover: the glide, then the beat (1.6 s at least). Numbers of record.mjs 1.29.0.
|
|
19
|
+
case "click": return 0.7 + 0.9 + Math.max(ms(s.after, 2000) / 1000, say) + (/href|^a\b|url/i.test(String(s.selector ?? "")) || s.url ? 1.0 : 0.3) + slack;
|
|
20
|
+
case "hover": return 0.7 + Math.max(ms(s.dwell, 1600) / 1000, say) + slack;
|
|
19
21
|
case "type": return 1.0 + String(s.text ?? "").length * 0.05 + Math.max(ms(s.after, 1500) / 1000, say) + slack;
|
|
20
22
|
case "expect": return 0.3;
|
|
21
23
|
default: return 1 + say;
|