playthrough 0.4.2 → 0.5.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.
@@ -1,18 +1,17 @@
1
1
  #!/usr/bin/env -S node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON
2
- import { A as list, C as conversation, D as problemsFinishing, E as notesToAnswer, M as time, O as roundInProgress, S as allNotes, T as lastSeen, _ as readingsOn, b as Refused, j as quote, k as toAccountFor, p as firstLine, w as inOrderJoined } from "./reporter-DE8aueAO.js";
3
- import { D as setupIn, O as repo, _ as placeIn, a as failedAt, b as serves, c as isFlow, f as walkFork, g as compareWithEarlier, h as writtenNow, i as called, k as pausedAt, l as makeFlowsFolder, m as trySetup, o as flowsOf, p as whereItStopped, s as howToWriteFlows, t as ALONGSIDE, u as runFlows, v as shownAt, w as giveBackLeft, x as stopApp, y as keepRunning } from "./flows-BkG0TgHA.js";
2
+ import { A as quote, C as inOrderJoined, D as roundInProgress, E as problemsFinishing, O as toAccountFor, S as conversation, T as notesToAnswer, g as readingsOn, j as time, k as list, p as firstLine, w as lastSeen, x as allNotes, y as Refused } from "./reporter-Dr7dWwSf.js";
3
+ import { A as repo, O as setupIn, S as stopApp, T as giveBackLeft, _ as compareWithEarlier, a as failedAt, b as keepRunning, c as isFlow, d as runFlows, g as writtenNow, h as trySetup, i as called, j as pausedAt, k as copies, l as makeFlowsFolder, m as whereItStopped, o as flowsOf, p as walkFork, s as howToWriteFlows, t as ALONGSIDE, u as recorder, v as placeIn, x as serves, y as shownAt } from "./flows-DDB6AdQ-.js";
4
4
  import { t as read } from "./read-Bg9XVTtn.js";
5
- import { createRequire } from "node:module";
6
5
  import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
6
  import { execFileSync } from "node:child_process";
8
7
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
9
- import { createHash } from "node:crypto";
10
- import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
11
- import { dirname, extname, join, normalize, resolve, sep } from "node:path";
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { constants, networkInterfaces, tmpdir, uptime, userInfo } from "node:os";
10
+ import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
11
+ import { connect } from "node:net";
12
12
  import { chromium, request } from "@playwright/test";
13
13
  import { fileURLToPath } from "node:url";
14
- import { createServer, request as request$1 } from "node:http";
15
- import { connect } from "node:net";
14
+ import { createServer as createServer$1, request as request$1 } from "node:http";
16
15
  // src/node.ts
17
16
  const [major] = process.versions.node.split(".").map(Number);
18
17
  if (major < 24) {
@@ -87,7 +86,7 @@ async function recordOnBefore(repo, id, number, { recorded, written }) {
87
86
  const review = await repo.reviews.get(id);
88
87
  const before = review.rounds.find((round) => round.number === 0);
89
88
  if (!before) return;
90
- const ran = await readFile(repo.ranOnBefore(id), "utf8").then(JSON.parse, () => ({}));
89
+ const ran = await readFile(repo.ranOnBefore(id), "utf8").then(JSON.parse).catch(() => ({}));
91
90
  const setup = await writtenNow(repo);
92
91
  const follows = Object.fromEntries(recorded.map(({ flow, doneOn }) => [flow.name, createHash("sha256").update(JSON.stringify([
93
92
  written[flow.name]?.hash,
@@ -162,11 +161,13 @@ const SIGNALS = [
162
161
  "SIGHUP"
163
162
  ];
164
163
  async function asItWas(repo, unrecorded, commit, shared, run) {
164
+ refuseWhileAside(repo);
165
165
  await repo.keepOutOfGit();
166
166
  const aside = {
167
167
  back: here(repo),
168
168
  stash: `Playthrough: your work, set aside while flows record on Before, ${(/* @__PURE__ */ new Date()).toISOString()}`,
169
169
  by: process.pid,
170
+ boot: boot(),
170
171
  ...shared && { shared }
171
172
  };
172
173
  writeFileSync(repo.setAside, JSON.stringify(aside));
@@ -229,8 +230,8 @@ async function asItWas(repo, unrecorded, commit, shared, run) {
229
230
  }
230
231
  }
231
232
  async function putBack(repo) {
232
- const aside = attempt(() => JSON.parse(readFileSync(repo.setAside, "utf8")));
233
- if (!aside || running(aside.by)) return;
233
+ const aside = setAside(repo);
234
+ if (!aside || stillAt(aside)) return;
234
235
  const toDo = here(repo) !== aside.back || !!stashOf(repo, aside);
235
236
  const problem = putWorkBack(repo, aside, false);
236
237
  if (problem) throw stillAside(repo, aside, problem);
@@ -240,6 +241,19 @@ async function putBack(repo) {
240
241
  if (url && !aside.shared && await serves(url)) throw new Refused([back, `Something is still serving ${url}, most likely the app as it was, left running: stop it, then run this again.`]);
241
242
  console.log(`${back}\n`);
242
243
  }
244
+ function refuseWhileAside(repo) {
245
+ const aside = setAside(repo);
246
+ if (aside && aside.by !== process.pid && stillAt(aside)) throw new Refused([`Another playthrough command (process ${aside.by}) is recording flows on Before just now, so the app as it was is in your work's place until it's done, in a minute or so. Try again then.`]);
247
+ }
248
+ const setAside = (repo) => attempt(() => JSON.parse(readFileSync(repo.setAside, "utf8")));
249
+ const stillAt = (aside) => sameBoot(aside.boot) && running(aside.by);
250
+ function boot() {
251
+ return attempt(() => readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()) || Date.now() / 1e3 - uptime();
252
+ }
253
+ function sameBoot(then, now = boot()) {
254
+ if (typeof then === "number" && typeof now === "number") return Math.abs(then - now) < 300;
255
+ return then === void 0 || then === now;
256
+ }
243
257
  function putWorkBack(repo, aside, force) {
244
258
  try {
245
259
  if (here(repo) !== aside.back) {
@@ -318,7 +332,11 @@ function running(pid) {
318
332
  }
319
333
  }
320
334
  function git(repo, args, env) {
321
- return execFileSync("git", args, {
335
+ return execFileSync("git", [
336
+ "-c",
337
+ "core.hooksPath=/dev/null",
338
+ ...args
339
+ ], {
322
340
  cwd: repo.root,
323
341
  env,
324
342
  encoding: "utf8",
@@ -430,11 +448,11 @@ async function followingFor(repo, id, to, fresh = false) {
430
448
  const one = following.get(key(repo, id));
431
449
  if (one && !one.over && !fresh && one.written === written && one.flow === to.flow) return one;
432
450
  one?.stop();
433
- const made = await start$1(repo, id, to, files, written);
451
+ const made = await start$2(repo, id, to, files, written);
434
452
  following.set(key(repo, id), made);
435
453
  return made;
436
454
  }
437
- async function start$1(repo, id, to, files, written) {
455
+ async function start$2(repo, id, to, files, written) {
438
456
  const app = await repo.app();
439
457
  const { started } = await repo.reviews.get(id);
440
458
  const { runAlongside, pageFor } = await alongside();
@@ -557,14 +575,17 @@ async function takeOver$1(repo, id, flowName, stepName) {
557
575
  throw new Refused([`Take over which flow? playthrough take-over ${id} "<flow>" ["<step>"]\nIts flows: ${list(round.flows.map((flow) => quote(flow.name)))}.`]);
558
576
  }
559
577
  console.log(`Running ${quote(flowName)} to ${stepName ? `the end of ${quote(stepName)}` : "its end"}, on the app as it is now…`);
560
- const { port, moment, seconds } = await getReady(repo, id, flowName, { step: stepName });
578
+ const { port, moment, seconds } = await getReady(repo, id, flowName, {
579
+ step: stepName,
580
+ network: true
581
+ });
561
582
  const here = `http://localhost:${port}/take-over/${moment}`;
562
583
  const away = addresses().map((address) => `http://${address}:${port}/take-over/${moment}`);
563
584
  console.log(`
564
585
  Ready in ${seconds}s. Carry on from there in your browser: ${here}${away.length ? `\nOr on your phone, on the same network: ${list(away)}` : ""}
565
586
  Each address hands over the same copy, and the app is passed on through Playthrough while this runs. Ctrl+C ends it.`);
566
587
  }
567
- async function getReady(repo, id, flowName, { step: stepName, actions, did, ahead } = {}) {
588
+ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahead, network } = {}) {
568
589
  const round = await recorded(repo, id);
569
590
  const flow = round.flows.find((flow) => flow.name === flowName);
570
591
  if (!flow) throw new Refused([`${id} has no flow called ${quote(flowName)}. Its flows: ${list(round.flows.map((flow) => quote(flow.name)))}.`]);
@@ -572,6 +593,7 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
572
593
  const outside = ahead && await reachesOutside(repo, id, flow.name);
573
594
  if (outside) throw new Refused([`${quote(flow.name)} reaches outside the app (${outside}), so it's run only when it's taken over.`]);
574
595
  const app = await repo.app();
596
+ refuseWhileAside(repo);
575
597
  await keepRunning(app, repo.root);
576
598
  const began = Date.now();
577
599
  const { handed, made } = await runTo(repo, id, {
@@ -582,12 +604,15 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
582
604
  },
583
605
  ...did?.length && { did }
584
606
  });
585
- passing ??= {
586
- moments: /* @__PURE__ */ new Map(),
587
- port: passOn(app, (moment) => passing.moments.get(moment)),
588
- numbered: 0
589
- };
590
- const moment = String(++passing.numbered);
607
+ if (!passings.has(app.url)) {
608
+ const moments = /* @__PURE__ */ new Map();
609
+ passings.set(app.url, {
610
+ moments,
611
+ port: passOn(app, (moment) => moments.get(moment), network)
612
+ });
613
+ }
614
+ const passing = passings.get(app.url);
615
+ const moment = randomBytes(12).toString("base64url");
591
616
  passing.moments.set(moment, did?.length ? {
592
617
  ...handed,
593
618
  did
@@ -609,6 +634,7 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
609
634
  }
610
635
  const ready = /* @__PURE__ */ new Map();
611
636
  async function keepUp(repo, id, flow, step, actions) {
637
+ refuseWhileAside(repo);
612
638
  await keepRunning(await repo.app(), repo.root);
613
639
  await follow(repo, id, {
614
640
  flow,
@@ -618,18 +644,20 @@ async function keepUp(repo, id, flow, step, actions) {
618
644
  }
619
645
  });
620
646
  }
621
- let passing;
647
+ const passings = /* @__PURE__ */ new Map();
622
648
  async function recorded(repo, id) {
623
649
  const review = await repo.reviews.get(id);
624
650
  const round = review.rounds.findLast((round) => round.number > 0) ?? review.rounds.at(-1);
625
651
  if (!round) throw new Refused([`${id} has nothing recorded yet, so there's nothing to take over. Record it first: playthrough record ${id}`]);
626
652
  return round;
627
653
  }
628
- function passOn(app, handed) {
654
+ function passOn(app, handed, network) {
629
655
  const target = new URL(app.url);
630
- const server = createServer((request, response) => {
656
+ const server = createServer$1((request, response) => {
657
+ if (!named(request)) return turnAway(response);
631
658
  const [first, moment] = new URL(request.url ?? "/", "http://playthrough").pathname.split("/").filter(Boolean);
632
- if (first === "take-over") return handOver(handed(moment ?? ""), response);
659
+ if (first === "take-over") return handOver(handed(moment ?? ""), response, !fromThisMachine(request));
660
+ if (!passed(request)) return turnAway(response);
633
661
  const page = /text\/html/.test(request.headers.accept ?? "");
634
662
  const headers = asIfDirect(request, target);
635
663
  if (page) delete headers["accept-encoding"];
@@ -640,7 +668,7 @@ function passOn(app, handed) {
640
668
  path: request.url,
641
669
  headers
642
670
  }, (answer) => {
643
- const told = fromTheApp(answer, target);
671
+ const told = fromTheApp(answer, target, request);
644
672
  if (!page || !/text\/html/.test(String(answer.headers["content-type"])) || answer.headers["content-encoding"]) {
645
673
  response.writeHead(answer.statusCode ?? 502, told);
646
674
  answer.pipe(response);
@@ -662,6 +690,7 @@ function passOn(app, handed) {
662
690
  request.pipe(forward);
663
691
  });
664
692
  server.on("upgrade", (request, socket, head) => {
693
+ if (!named(request) || !passed(request)) return void socket.destroy();
665
694
  const upstream = connect(Number(target.port || 80), target.hostname, () => {
666
695
  const headers = Object.entries(asIfDirect(request, target)).flatMap(([name, value]) => [value ?? []].flat().map((one) => `${name}: ${one}`));
667
696
  upstream.write(`${request.method} ${request.url} HTTP/1.1\r\n${headers.join("\r\n")}\r\n\r\n`);
@@ -671,7 +700,15 @@ function passOn(app, handed) {
671
700
  upstream.on("error", () => socket.destroy());
672
701
  socket.on("error", () => upstream.destroy());
673
702
  });
674
- return listen(server, PORT$1, "0.0.0.0");
703
+ return listen(server, PORT$1, network ? "0.0.0.0" : "127.0.0.1");
704
+ }
705
+ const named = (request) => thisMachine(request.headers.host) || addresses().includes(request.headers.host?.replace(/:\d+$/, "") ?? "");
706
+ const PASS = `playthrough-pass=${randomBytes(16).toString("base64url")}`;
707
+ const fromThisMachine = (request) => /^(127\.|::1$|::ffff:127\.)/.test(request.socket.remoteAddress ?? "");
708
+ const passed = (request) => fromThisMachine(request) || !!request.headers.cookie?.split(/;\s*/).includes(PASS);
709
+ function turnAway(response) {
710
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
711
+ response.end("Playthrough passes the app on to this machine, and to whoever opened the address of a moment taken over. Open that address first.");
675
712
  }
676
713
  function asIfDirect(request, target) {
677
714
  const headers = {
@@ -681,9 +718,11 @@ function asIfDirect(request, target) {
681
718
  const from = `http://${request.headers.host}`;
682
719
  if (headers.origin) headers.origin = target.origin;
683
720
  if (headers.referer?.startsWith(from)) headers.referer = target.origin + headers.referer.slice(from.length);
721
+ const cookie = headers.cookie?.split(/;\s*/).filter((one) => one !== PASS).join("; ");
722
+ if (cookie) headers.cookie = cookie;
723
+ else delete headers.cookie;
684
724
  return headers;
685
725
  }
686
- const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
687
726
  let RECORDER;
688
727
  const SAYS = `<script>(() => {
689
728
  if (window.parent === window) return;
@@ -1023,17 +1062,19 @@ const SAYS = `<script>(() => {
1023
1062
  };
1024
1063
  addEventListener("load", () => setTimeout(settle, 50));
1025
1064
  })();<\/script>`;
1026
- function fromTheApp(answer, target) {
1065
+ function fromTheApp(answer, target, request) {
1027
1066
  const headers = { ...answer.headers };
1028
1067
  delete headers["x-frame-options"];
1029
- headers["access-control-allow-origin"] = "*";
1068
+ const origin = request.headers.origin;
1069
+ if (origin && URL.canParse(origin) && thisMachine(new URL(origin).host)) headers["access-control-allow-origin"] = origin;
1070
+ headers.vary = [headers.vary, "Origin"].filter(Boolean).join(", ");
1030
1071
  const policy = headers["content-security-policy"];
1031
1072
  if (policy) headers["content-security-policy"] = [policy].flat().map((rule) => rule.replace(/frame-ancestors[^;]*;?/i, ""));
1032
1073
  if (headers.location?.startsWith(target.origin)) headers.location = headers.location.slice(target.origin.length) || "/";
1033
1074
  if (headers["set-cookie"]) headers["set-cookie"] = headers["set-cookie"].map((cookie) => cookie.replace(/;\s*Domain=[^;]*/i, ""));
1034
1075
  return headers;
1035
1076
  }
1036
- function handOver(handed, response) {
1077
+ function handOver(handed, response, elsewhere) {
1037
1078
  if (!handed) {
1038
1079
  response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1039
1080
  response.end("There's no moment ready at this address. Take it over again from the viewer, or with playthrough take-over.");
@@ -1043,6 +1084,7 @@ function handOver(handed, response) {
1043
1084
  const again = handed.again ?? [];
1044
1085
  const { pathname, search, hash } = new URL(url);
1045
1086
  const setCookies = cookies.map(({ name, value, path, expires, httpOnly }) => `${name}=${value}; Path=${path}; SameSite=Lax${httpOnly ? "; HttpOnly" : ""}${expires > 0 ? `; Expires=${(/* @__PURE__ */ new Date(expires * 1e3)).toUTCString()}` : ""}`);
1087
+ if (elsewhere) setCookies.push(`${PASS}; Path=/; SameSite=Lax; HttpOnly`);
1046
1088
  const keep = JSON.stringify({
1047
1089
  local,
1048
1090
  session,
@@ -1078,6 +1120,394 @@ function notAnswering(response, app) {
1078
1120
  response.end(`The app isn't answering at ${app.url}. Start it as playthrough/app.json says (${app.start}), then reload.`);
1079
1121
  }
1080
1122
  const addresses = () => Object.values(networkInterfaces()).flat().filter((face) => face && face.family === "IPv4" && !face.internal).map((face) => face.address);
1123
+ // src/mp4.ts
1124
+ function mp4({ width, height, fps, avcC, frames }) {
1125
+ const scale = fps * 1e3;
1126
+ const length = frames.length * 1e3;
1127
+ const keys = frames.flatMap((frame, i) => frame.key ? [i + 1] : []);
1128
+ const moov = (offset) => box("moov", [full("mvhd", 0, 0, [
1129
+ u32(0),
1130
+ u32(0),
1131
+ u32(scale),
1132
+ u32(length),
1133
+ u32(65536),
1134
+ u16(256),
1135
+ zeros(10),
1136
+ matrix,
1137
+ zeros(24),
1138
+ u32(2)
1139
+ ]), box("trak", [full("tkhd", 0, 3, [
1140
+ u32(0),
1141
+ u32(0),
1142
+ u32(1),
1143
+ u32(0),
1144
+ u32(length),
1145
+ zeros(8),
1146
+ u16(0),
1147
+ u16(0),
1148
+ u16(0),
1149
+ u16(0),
1150
+ matrix,
1151
+ u32(width << 16),
1152
+ u32(height << 16)
1153
+ ]), box("mdia", [
1154
+ full("mdhd", 0, 0, [
1155
+ u32(0),
1156
+ u32(0),
1157
+ u32(scale),
1158
+ u32(length),
1159
+ u16(21956),
1160
+ u16(0)
1161
+ ]),
1162
+ full("hdlr", 0, 0, [
1163
+ u32(0),
1164
+ ascii("vide"),
1165
+ zeros(12),
1166
+ ascii("Playthrough\0")
1167
+ ]),
1168
+ box("minf", [
1169
+ full("vmhd", 0, 1, [zeros(8)]),
1170
+ box("dinf", [full("dref", 0, 0, [u32(1), full("url ", 0, 1, [])])]),
1171
+ box("stbl", [
1172
+ full("stsd", 0, 0, [u32(1), box("avc1", [
1173
+ zeros(6),
1174
+ u16(1),
1175
+ zeros(16),
1176
+ u16(width),
1177
+ u16(height),
1178
+ u32(4718592),
1179
+ u32(4718592),
1180
+ u32(0),
1181
+ u16(1),
1182
+ zeros(32),
1183
+ u16(24),
1184
+ u16(65535),
1185
+ box("avcC", [avcC])
1186
+ ])]),
1187
+ full("stts", 0, 0, [
1188
+ u32(1),
1189
+ u32(frames.length),
1190
+ u32(1e3)
1191
+ ]),
1192
+ full("stss", 0, 0, [u32(keys.length), ...keys.map(u32)]),
1193
+ full("stsc", 0, 0, [
1194
+ u32(1),
1195
+ u32(1),
1196
+ u32(frames.length),
1197
+ u32(1)
1198
+ ]),
1199
+ full("stsz", 0, 0, [
1200
+ u32(0),
1201
+ u32(frames.length),
1202
+ ...frames.map((frame) => u32(frame.data.length))
1203
+ ]),
1204
+ full("stco", 0, 0, [u32(1), u32(offset)])
1205
+ ])
1206
+ ])
1207
+ ])])]);
1208
+ const ftyp = box("ftyp", [
1209
+ ascii("isom"),
1210
+ u32(512),
1211
+ ascii("isom"),
1212
+ ascii("iso2"),
1213
+ ascii("avc1"),
1214
+ ascii("mp41")
1215
+ ]);
1216
+ return join$1([
1217
+ ftyp,
1218
+ moov(ftyp.length + moov(0).length + 8),
1219
+ box("mdat", frames.map((frame) => frame.data))
1220
+ ]);
1221
+ }
1222
+ const matrix = join$1([
1223
+ 65536,
1224
+ 0,
1225
+ 0,
1226
+ 0,
1227
+ 65536,
1228
+ 0,
1229
+ 0,
1230
+ 0,
1231
+ 1073741824
1232
+ ].map(u32));
1233
+ function box(type, parts) {
1234
+ const body = join$1(parts);
1235
+ return join$1([
1236
+ u32(body.length + 8),
1237
+ ascii(type),
1238
+ body
1239
+ ]);
1240
+ }
1241
+ const full = (type, version, flags, parts) => box(type, [u32(version << 24 | flags), ...parts]);
1242
+ function u32(value) {
1243
+ const bytes = /* @__PURE__ */ new Uint8Array(4);
1244
+ new DataView(bytes.buffer).setUint32(0, value >>> 0);
1245
+ return bytes;
1246
+ }
1247
+ function u16(value) {
1248
+ const bytes = /* @__PURE__ */ new Uint8Array(2);
1249
+ new DataView(bytes.buffer).setUint16(0, value);
1250
+ return bytes;
1251
+ }
1252
+ const zeros = (length) => new Uint8Array(length);
1253
+ const ascii = (text) => new TextEncoder().encode(text);
1254
+ function join$1(parts) {
1255
+ const joined = new Uint8Array(parts.reduce((length, part) => length + part.length, 0));
1256
+ let at = 0;
1257
+ for (const part of parts) {
1258
+ joined.set(part, at);
1259
+ at += part.length;
1260
+ }
1261
+ return joined;
1262
+ }
1263
+ // src/video.ts
1264
+ const FPS = 60;
1265
+ const WIDEST = 1920;
1266
+ const ROOM = 32;
1267
+ const CAPTION = 26;
1268
+ const GAP = 20;
1269
+ const TALLEST = 1920;
1270
+ const LARGEST = 2;
1271
+ const PER_FRAME = .009;
1272
+ async function video(port, id, filmed, shape, onLeft) {
1273
+ const beside = filmed.beside !== void 0;
1274
+ const room = {
1275
+ width: (beside ? 2 * shape.width + GAP : shape.width) + 64,
1276
+ height: shape.height + CAPTION + 64
1277
+ };
1278
+ const larger = Math.min(WIDEST / room.width, TALLEST / (room.height - (beside ? 0 : CAPTION)), LARGEST);
1279
+ const viewport = {
1280
+ width: Math.round(larger * room.width),
1281
+ height: Math.round(larger * room.height)
1282
+ };
1283
+ const browser = await chromium.launch();
1284
+ try {
1285
+ const page = await (await browser.newContext({
1286
+ viewport,
1287
+ colorScheme: filmed.dark ? "dark" : "light"
1288
+ })).newPage();
1289
+ const { dark, ...shown } = filmed;
1290
+ await page.goto(`http://localhost:${port}/${encodeURIComponent(id)}?video=${encodeURIComponent(JSON.stringify({
1291
+ ...shown,
1292
+ larger
1293
+ }))}`);
1294
+ await page.waitForFunction((frames) => !!window.video && document.querySelectorAll("[data-frame]:not([hidden]) iframe").length >= frames, beside ? 2 : 1);
1295
+ await page.evaluate(() => document.fonts.ready);
1296
+ await page.waitForTimeout(600);
1297
+ const crop = await page.evaluate(around, {
1298
+ room: larger * ROOM,
1299
+ caption: beside ? larger * CAPTION : 0
1300
+ });
1301
+ return await film(page, await encoder(await browser.newPage(), crop), onLeft);
1302
+ } finally {
1303
+ await browser.close();
1304
+ }
1305
+ }
1306
+ function around({ room, caption }) {
1307
+ const boxes = [...document.querySelectorAll("[data-frame]:not([hidden])")].map((frame) => frame.getBoundingClientRect());
1308
+ const left = Math.max(0, Math.min(...boxes.map((box) => box.left)) - room);
1309
+ const top = Math.max(0, Math.min(...boxes.map((box) => box.top)) - caption - room);
1310
+ const right = Math.min(innerWidth, Math.max(...boxes.map((box) => box.right)) + room);
1311
+ const bottom = Math.min(innerHeight, Math.max(...boxes.map((box) => box.bottom)) + room);
1312
+ return {
1313
+ x: left,
1314
+ y: top,
1315
+ width: right - left,
1316
+ height: bottom - top
1317
+ };
1318
+ }
1319
+ async function encoder(page, crop) {
1320
+ const k = Math.min(1, WIDEST / crop.width);
1321
+ const even = (value) => 2 * Math.round(value / 2);
1322
+ const size = {
1323
+ width: even(crop.width * k),
1324
+ height: even(crop.height * k)
1325
+ };
1326
+ await page.route("https://video.playthrough/", (route) => route.fulfill({
1327
+ contentType: "text/html",
1328
+ body: ""
1329
+ }));
1330
+ await page.goto("https://video.playthrough/");
1331
+ await page.evaluate(start$1, {
1332
+ ...size,
1333
+ fps: FPS,
1334
+ crop
1335
+ });
1336
+ return {
1337
+ page,
1338
+ size
1339
+ };
1340
+ }
1341
+ async function film(page, { page: putting, size }, onLeft) {
1342
+ const length = await page.evaluate(() => window.video.length);
1343
+ const browser = await page.context().newCDPSession(page);
1344
+ let start = Infinity;
1345
+ let drawn;
1346
+ let last;
1347
+ let made = 0;
1348
+ let done = 0;
1349
+ let going = Promise.resolve();
1350
+ const upTo = (at) => {
1351
+ for (; made / FPS <= at; made++) {
1352
+ const [jpeg, key] = [drawn === last ? void 0 : drawn, made % 120 === 0];
1353
+ last = drawn;
1354
+ going = going.then(() => putting.evaluate(add, {
1355
+ jpeg,
1356
+ key
1357
+ })).then(() => void done++);
1358
+ }
1359
+ };
1360
+ let firstDrawn = () => {};
1361
+ browser.on("Page.screencastFrame", ({ data, metadata, sessionId }) => {
1362
+ upTo((metadata.timestamp ?? Date.now() / 1e3) - start);
1363
+ drawn = data;
1364
+ firstDrawn();
1365
+ browser.send("Page.screencastFrameAck", { sessionId }).catch(() => {});
1366
+ });
1367
+ await new Promise((drew) => {
1368
+ firstDrawn = drew;
1369
+ browser.send("Page.startScreencast", {
1370
+ format: "jpeg",
1371
+ quality: 92,
1372
+ maxWidth: page.viewportSize().width,
1373
+ maxHeight: page.viewportSize().height
1374
+ });
1375
+ });
1376
+ start = Date.now() / 1e3;
1377
+ const telling = setInterval(() => onLeft(Math.max(0, start + length - Date.now() / 1e3) + (made - done) * PER_FRAME), 250);
1378
+ try {
1379
+ await page.evaluate(() => window.video.play());
1380
+ upTo(Date.now() / 1e3 - start);
1381
+ await browser.send("Page.stopScreencast");
1382
+ await going;
1383
+ } finally {
1384
+ clearInterval(telling);
1385
+ }
1386
+ const finished = await putting.evaluate(finish);
1387
+ const bytes = (base64) => new Uint8Array(Buffer.from(base64, "base64"));
1388
+ return mp4({
1389
+ ...size,
1390
+ fps: FPS,
1391
+ avcC: bytes(finished.avcC),
1392
+ frames: finished.frames.map((frame) => ({
1393
+ data: bytes(frame.data),
1394
+ key: frame.key
1395
+ }))
1396
+ });
1397
+ }
1398
+ async function start$1({ width, height, fps, crop }) {
1399
+ const base64 = (bytes) => btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""));
1400
+ const state = {
1401
+ encoder: new VideoEncoder({
1402
+ output: (chunk, metadata) => {
1403
+ const data = new Uint8Array(chunk.byteLength);
1404
+ chunk.copyTo(data);
1405
+ state.frames.push({
1406
+ data: base64(data),
1407
+ key: chunk.type === "key"
1408
+ });
1409
+ const description = metadata?.decoderConfig?.description;
1410
+ if (description) state.avcC = base64(ArrayBuffer.isView(description) ? new Uint8Array(description.buffer, description.byteOffset, description.byteLength) : new Uint8Array(description));
1411
+ },
1412
+ error: (error) => console.error(error)
1413
+ }),
1414
+ canvas: new OffscreenCanvas(width, height),
1415
+ crop,
1416
+ fps,
1417
+ i: 0,
1418
+ picture: new Uint8Array(width * height * 3 / 2),
1419
+ frames: []
1420
+ };
1421
+ window.encoding = state;
1422
+ for (const codec of [
1423
+ "avc1.640033",
1424
+ "avc1.64002a",
1425
+ "avc1.640028"
1426
+ ]) if ((await VideoEncoder.isConfigSupported({
1427
+ codec,
1428
+ width,
1429
+ height
1430
+ })).supported) {
1431
+ state.encoder.configure({
1432
+ codec,
1433
+ width,
1434
+ height,
1435
+ bitrate: 6e6,
1436
+ framerate: fps,
1437
+ avc: { format: "avc" }
1438
+ });
1439
+ return;
1440
+ }
1441
+ throw new Error(`This browser can't make an H.264 video ${width}×${height} wide.`);
1442
+ }
1443
+ async function add({ jpeg, key }) {
1444
+ const state = window.encoding;
1445
+ const { width, height } = state.canvas;
1446
+ if (jpeg) {
1447
+ const picture = await createImageBitmap(await (await fetch(`data:image/jpeg;base64,${jpeg}`)).blob());
1448
+ const draw = state.canvas.getContext("2d", { willReadFrequently: true });
1449
+ draw.imageSmoothingQuality = "high";
1450
+ draw.drawImage(picture, state.crop.x, state.crop.y, state.crop.width, state.crop.height, 0, 0, width, height);
1451
+ picture.close();
1452
+ const rgba = draw.getImageData(0, 0, width, height).data;
1453
+ const [brightness, blue, red] = [
1454
+ state.picture.subarray(0, width * height),
1455
+ state.picture.subarray(width * height, width * height * 5 / 4),
1456
+ state.picture.subarray(width * height * 5 / 4)
1457
+ ];
1458
+ for (let y = 0; y < height; y += 2) for (let x = 0; x < width; x += 2) {
1459
+ let [r, g, b] = [
1460
+ 0,
1461
+ 0,
1462
+ 0
1463
+ ];
1464
+ for (const at of [
1465
+ y * width + x,
1466
+ y * width + x + 1,
1467
+ (y + 1) * width + x,
1468
+ (y + 1) * width + x + 1
1469
+ ]) {
1470
+ const [dotR, dotG, dotB] = [
1471
+ rgba[at * 4],
1472
+ rgba[at * 4 + 1],
1473
+ rgba[at * 4 + 2]
1474
+ ];
1475
+ brightness[at] = 16 + (11966 * dotR + 40254 * dotG + 4064 * dotB + 32768 >> 16);
1476
+ r += dotR;
1477
+ g += dotG;
1478
+ b += dotB;
1479
+ }
1480
+ const square = y / 2 * (width / 2) + x / 2;
1481
+ blue[square] = 128 + (-6596 * r - 22189 * g + 28785 * b + 131072 >> 18);
1482
+ red[square] = 128 + (28785 * r - 26145 * g - 2640 * b + 131072 >> 18);
1483
+ }
1484
+ }
1485
+ const frame = new VideoFrame(state.picture, {
1486
+ format: "I420",
1487
+ codedWidth: width,
1488
+ codedHeight: height,
1489
+ timestamp: state.i * 1e6 / state.fps,
1490
+ duration: 1e6 / state.fps,
1491
+ colorSpace: {
1492
+ primaries: "bt709",
1493
+ transfer: "bt709",
1494
+ matrix: "bt709",
1495
+ fullRange: false
1496
+ }
1497
+ });
1498
+ state.encoder.encode(frame, { keyFrame: key });
1499
+ frame.close();
1500
+ state.i++;
1501
+ while (state.encoder.encodeQueueSize > 8) await new Promise((done) => setTimeout(done, 5));
1502
+ }
1503
+ async function finish() {
1504
+ const state = window.encoding;
1505
+ await state.encoder.flush();
1506
+ return {
1507
+ avcC: state.avcC,
1508
+ frames: state.frames
1509
+ };
1510
+ }
1081
1511
  // src/open.ts
1082
1512
  const PORT = 5790;
1083
1513
  const TYPES = {
@@ -1097,13 +1527,16 @@ const TYPES = {
1097
1527
  ".ttf": "font/ttf",
1098
1528
  ".otf": "font/otf"
1099
1529
  };
1100
- async function open(repo, id) {
1101
- if (id) await repo.reviews.get(id);
1530
+ async function open(here, id) {
1531
+ const app = everyCopy(here);
1532
+ if (id) await (await app.holding(id)).reviews.get(id);
1533
+ const already = await openAlready(app.folder);
1534
+ if (already) return console.log(`Playthrough is already open at http://localhost:${already}/${id ?? ""}`);
1102
1535
  const page = fileURLToPath(new URL("../viewer", import.meta.url));
1103
1536
  if (!await readFile(join(page, "index.html")).catch(() => void 0)) throw new Refused(["The viewer isn't built yet. Build it from core with npm run build."]);
1104
- const you = whoYouAre(repo.root);
1105
- const port = await listen(createServer((request, response) => {
1106
- answer(repo, page, you, request, response).catch((error) => {
1537
+ const you = whoYouAre(here.root);
1538
+ const port = await listen(createServer$1((request, response) => {
1539
+ answer(app, page, you, request, response).catch((error) => {
1107
1540
  if (response.headersSent) return response.end();
1108
1541
  const problems = error instanceof Refused ? error.problems : [String(error?.message ?? error)];
1109
1542
  json(response, error instanceof Refused ? 400 : 500, { problems });
@@ -1111,20 +1544,42 @@ async function open(repo, id) {
1111
1544
  }), PORT);
1112
1545
  console.log(`Playthrough is open at http://localhost:${port}/${id ?? ""}`);
1113
1546
  }
1114
- async function answer(repo, page, you, request, response) {
1115
- const reviews = repo.reviews;
1547
+ function everyCopy(here) {
1548
+ const all = () => copies(here.root).map((root) => root === here.root ? here : repo(root));
1549
+ const found = /* @__PURE__ */ new Map();
1550
+ return {
1551
+ all,
1552
+ folder: all()[0]?.root ?? here.root,
1553
+ async holding(id) {
1554
+ if (!found.has(id)) {
1555
+ for (const copy of all()) if ((await copy.reviews.list()).includes(id) && !found.has(id)) found.set(id, copy);
1556
+ }
1557
+ return found.get(id) ?? here;
1558
+ }
1559
+ };
1560
+ }
1561
+ async function openAlready(folder) {
1562
+ const ports = Array.from({ length: 21 }, (_, i) => PORT + i);
1563
+ const asked = (port) => fetch(`http://127.0.0.1:${port}/api/app`, { signal: AbortSignal.timeout(1e3) }).then((answer) => answer.json()).then((said) => said.folder === folder ? port : void 0, () => void 0);
1564
+ return (await Promise.all(ports.map(asked))).find((port) => port !== void 0);
1565
+ }
1566
+ async function answer(app, page, you, request, response) {
1567
+ const stranger = fromElsewhere(request);
1568
+ if (stranger) return json(response, 403, { problems: [stranger] });
1116
1569
  const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
1117
1570
  const [api, collection, id, ...rest] = path.split("/").filter(Boolean);
1118
1571
  if (api !== "api") return serve(page, path, response);
1119
1572
  if (request.method === "GET" && collection === "you") return json(response, 200, { name: you });
1573
+ if (request.method === "GET" && collection === "app") return json(response, 200, { folder: app.folder });
1120
1574
  if (collection !== "reviews") return json(response, 404, { problems: [`There's nothing at ${path}.`] });
1121
- if (request.method === "GET" && !id) return json(response, 200, (await Promise.all((await reviews.list()).map((id) => reviews.get(id)))).map(({ id, title, rounds, accepted }) => ({
1122
- id,
1123
- title,
1124
- rounds: rounds.filter((round) => round.finished && round.number > 0).length,
1125
- accepted: !!accepted
1126
- })));
1575
+ if (request.method === "GET" && !id) {
1576
+ const read = async (copy) => Promise.all((await copy.reviews.list()).map((id) => copy.reviews.get(id).catch(() => void 0)));
1577
+ const every = (await Promise.all(app.all().map(read))).flat();
1578
+ return json(response, 200, every.filter((review, i) => review && every.findIndex((first) => first?.id === review.id) === i));
1579
+ }
1127
1580
  if (!id) return json(response, 404, { problems: [`There's nothing at ${path}.`] });
1581
+ const repo = await app.holding(id);
1582
+ const reviews = repo.reviews;
1128
1583
  if (request.method === "GET" && rest[0] === "live") return live(repo, id, request, response);
1129
1584
  if (request.method === "GET" && rest[0] === "rounds" && rest[2] === "files" && rest[3]) {
1130
1585
  const name = rest.slice(3).join("/");
@@ -1137,6 +1592,7 @@ async function answer(repo, page, you, request, response) {
1137
1592
  });
1138
1593
  return response.end(bytes);
1139
1594
  }
1595
+ if (request.method === "POST" && rest[0] === "video") return saveVideo(repo, id, request, response);
1140
1596
  if (request.method === "POST" && rest[0] === "take-over") return takeOver(repo, id, request, response);
1141
1597
  if (request.method === "POST" && rest[0] === "follow") return followAlong(repo, id, request, response);
1142
1598
  if (request.method === "POST" && rest[0] === "ready-in") return readyInFor(repo, id, request, response);
@@ -1218,6 +1674,34 @@ function paced(events) {
1218
1674
  });
1219
1675
  }
1220
1676
  const readingOf = (event) => event.type === 5 && event.data?.tag === "reading" ? event.data.payload : void 0;
1677
+ async function saveVideo(repo, id, request, response) {
1678
+ const filmed = await body(request);
1679
+ const rounds = (await repo.reviews.get(id)).rounds.filter((round) => round.number === filmed.round || round.number === filmed.beside);
1680
+ const screens = [];
1681
+ for (const round of rounds) {
1682
+ const flow = round.flows.find((flow) => flow.name === filmed.flow);
1683
+ const kept = flow && await repo.reviews.file(id, round.number, flow.recording);
1684
+ if (kept) {
1685
+ for (const event of JSON.parse(Buffer.from(kept).toString("utf8"))) if (event.type === 4) screens.push(event.data);
1686
+ }
1687
+ }
1688
+ if (!screens.length) return json(response, 404, { problems: [`Round ${filmed.round} of ${id} has no recording of “${filmed.flow}”.`] });
1689
+ const shape = {
1690
+ width: Math.max(...screens.map((screen) => screen.width)),
1691
+ height: Math.max(...screens.map((screen) => screen.height))
1692
+ };
1693
+ response.writeHead(200, {
1694
+ "content-type": "application/x-ndjson",
1695
+ "cache-control": "no-store"
1696
+ });
1697
+ const say = (line) => response.write(`${JSON.stringify(line)}\n`);
1698
+ try {
1699
+ say({ video: Buffer.from(await video(request.socket.localPort, id, filmed, shape, (left) => say({ left: Math.round(left * 10) / 10 }))).toString("base64") });
1700
+ } catch (error) {
1701
+ say({ problems: [`The video couldn't be made: ${error.message.split("\n")[0]}`] });
1702
+ }
1703
+ response.end();
1704
+ }
1221
1705
  async function readyInFor(repo, id, request, response) {
1222
1706
  const { flow, step, actions } = await body(request);
1223
1707
  json(response, 200, { in: await readyIn(repo, id, {
@@ -1244,6 +1728,12 @@ async function serve(page, path, response) {
1244
1728
  });
1245
1729
  response.end(await readFile(join(page, "index.html")));
1246
1730
  }
1731
+ function fromElsewhere(request) {
1732
+ const { host, origin } = request.headers;
1733
+ if (!thisMachine(host)) return `Playthrough answers only at localhost, not at “${host}”. Open http://localhost:${request.socket.localPort}/.`;
1734
+ if (origin !== void 0 && origin !== `http://${host}`) return `Only Playthrough's own page can ask this, not ${origin}.`;
1735
+ }
1736
+ const thisMachine = (host) => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host ?? "");
1247
1737
  async function body(request) {
1248
1738
  const chunks = [];
1249
1739
  for await (const chunk of request) chunks.push(chunk);
@@ -1406,7 +1896,8 @@ async function walk(repo, id) {
1406
1896
  const saved = review.rounds.find((round) => round.number === number);
1407
1897
  const walked = await walkForks(repo, review, number, saved?.flows ?? review.rounds.findLast((round) => round.number > 0)?.flows ?? []);
1408
1898
  if (!walked.length) return console.log(`Nothing to walk: round ${number} answers no note made in the app.`);
1409
- if (saved) {
1899
+ const now = saved && (await repo.reviews.get(id)).rounds.find((round) => round.number === number);
1900
+ if (now) {
1410
1901
  const placed = (reply) => {
1411
1902
  const on = walked.find((one) => one.note === reply.note)?.on;
1412
1903
  return on ? {
@@ -1415,8 +1906,8 @@ async function walk(repo, id) {
1415
1906
  } : reply;
1416
1907
  };
1417
1908
  await repo.reviews.saveRound(id, {
1418
- ...saved,
1419
- replies: saved.replies.map(placed)
1909
+ ...now,
1910
+ replies: now.replies.map(placed)
1420
1911
  });
1421
1912
  }
1422
1913
  console.log(`\n${walked.map(saidOf).join("\n")}`);
@@ -1434,7 +1925,7 @@ async function record(repo, id) {
1434
1925
  `Fix ${failed.length > 1 ? "them" : "it"} and record again, which runs ${failed.length > 1 ? "them" : "it"} first. ${whereItStopped(repo, id, failed)}`
1435
1926
  ]);
1436
1927
  await recordOnBefore(repo, id, number, ran);
1437
- const saved = review.rounds.find((round) => round.number === number);
1928
+ const saved = (await repo.reviews.get(id)).rounds.find((round) => round.number === number);
1438
1929
  const joined = inOrderJoined(review);
1439
1930
  const place = (name) => joined.includes(name) ? joined.indexOf(name) : joined.length;
1440
1931
  const flows = recorded.map(({ flow }) => {
@@ -1532,30 +2023,32 @@ const changed = (count, since) => {
1532
2023
  return count ? `${count} ${count > 1 ? "changes" : "change"} since ${round}.` : `Nothing changed since ${round}.`;
1533
2024
  };
1534
2025
  // src/start.ts
1535
- async function start(repo, id, title, asked) {
1536
- if (!(await repo.reviews.list()).includes(id)) {
2026
+ async function start(repo$1, id, title, asked) {
2027
+ if (!(await repo$1.reviews.list()).includes(id)) {
2028
+ if ([repo$1.tried, repo$1.borrowed].some((own) => basename(own) === id)) throw new Refused([`“${id}” is the name of a folder Playthrough keeps for itself. Give the review another name.`]);
2029
+ for (const other of copies(repo$1.root).filter((root) => root !== repo$1.root)) if ((await repo(other).reviews.list()).includes(id)) throw new Refused([`There's a review called “${id}” in another worktree, at ${other}. Give this one another name.`]);
1537
2030
  if (!title || !asked) throw new Refused([`There's no review called “${id}” yet. Start it with a few words for the change, as a pull request's title, and what was asked, in the person's words: playthrough start ${id} "<title>" "<what was asked>"`]);
1538
- await repo.reviews.start(id, title, asked);
1539
- await repo.keepOutOfGit();
1540
- await makeFlowsFolder(repo, id);
2031
+ await repo$1.reviews.start(id, title, asked);
2032
+ await repo$1.keepOutOfGit();
2033
+ await makeFlowsFolder(repo$1, id);
1541
2034
  return console.log(`Started ${id}: “${title}”.
1542
2035
 
1543
2036
  Write its flows before you change anything. They show what was asked, so write them from the task, not from the code.
1544
2037
 
1545
- ${howToWriteFlows(repo, id)}
2038
+ ${howToWriteFlows(repo$1, id)}
1546
2039
 
1547
2040
  Then run playthrough start ${id} again. It tries the app's setup, and keeps the app as it is as Before, to record each flow on with round 1, so the person can compare. If the app is new, there's no Before: go straight to the work.`);
1548
2041
  }
1549
- const review = await repo.reviews.get(id);
2042
+ const review = await repo$1.reviews.get(id);
1550
2043
  if (title && title !== review.title) throw new Refused([`There's already a review called “${id}”, of “${review.title}”. Give this one another name, or carry that one on: playthrough start ${id}`]);
1551
2044
  const last = review.rounds.at(-1);
1552
2045
  if (last?.number === 0) throw new Refused([`Before is kept already. Change the app, then record round 1: playthrough record ${id}`]);
1553
2046
  if (last) throw new Refused([`Before is how the app was before the work, and ${id} has round ${last.number} already. Record the round being worked on: playthrough record ${id}`]);
1554
- await flowsOf(repo, id);
1555
- await trySetup(repo, `playthrough start ${id}`);
1556
- const commit = keepBefore(repo, id);
2047
+ await flowsOf(repo$1, id);
2048
+ await trySetup(repo$1, `playthrough start ${id}`);
2049
+ const commit = keepBefore(repo$1, id);
1557
2050
  if (commit) {
1558
- await repo.reviews.saveRound(id, {
2051
+ await repo$1.reviews.saveRound(id, {
1559
2052
  number: 0,
1560
2053
  by: agent(),
1561
2054
  flows: [],
@@ -1563,7 +2056,7 @@ Then run playthrough start ${id} again. It tries the app's setup, and keeps the
1563
2056
  accounts: {},
1564
2057
  commit
1565
2058
  });
1566
- await repo.reviews.finish(id, 0);
2059
+ await repo$1.reviews.finish(id, 0);
1567
2060
  }
1568
2061
  console.log(`
1569
2062
  ${commit ? "Kept Before: the app as it is now. Each flow is recorded on it with round 1, as far as the app as it was takes it, for the person to compare with." : "This folder isn't in git, or has no commits yet, so there's no Before: the app as it is can't be put back once you've changed it."}
@@ -1573,26 +2066,30 @@ Now change the app. Keep each flow's name, and each step's while it does the sam
1573
2066
  // src/noted.ts
1574
2067
  async function pictureNotes(repo, review, round, notes) {
1575
2068
  const shown = /* @__PURE__ */ new Map();
1576
- const browser = await chromium.launch();
2069
+ let browser;
1577
2070
  try {
1578
2071
  for (const id of notes) {
1579
2072
  if (allNotes(review).get(id)?.note.on?.app) continue;
1580
2073
  const found = await whereSeen(repo, review, id, round).catch(() => void 0);
1581
- if (!found) continue;
2074
+ const picture = found && await repo.reviews.file(review.id, found.round, found.picture);
2075
+ if (!found || !picture) continue;
2076
+ browser ??= await chromium.launch();
1582
2077
  const page = await browser.newPage({ viewport: {
1583
2078
  width: found.width,
1584
2079
  height: found.height
1585
2080
  } });
1586
- const picture = Buffer.from(await repo.reviews.file(review.id, found.round, found.picture)).toString("base64");
1587
- await page.setContent(drawn(picture, found));
1588
- await page.locator("img").evaluate((image) => image.decode());
1589
- const name = `notes/${id}.png`;
1590
- await repo.reviews.saveFile(review.id, round, name, await page.screenshot());
1591
- await page.close();
1592
- shown.set(id, repo.show(join(dirname(repo.round(review.id, round)), "files", name)));
2081
+ try {
2082
+ await page.setContent(drawn(Buffer.from(picture).toString("base64"), found));
2083
+ await page.locator("img").evaluate((image) => image.decode());
2084
+ const name = `notes/${id}.png`;
2085
+ await repo.reviews.saveFile(review.id, round, name, await page.screenshot());
2086
+ shown.set(id, repo.show(join(dirname(repo.round(review.id, round)), "files", name)));
2087
+ } catch {} finally {
2088
+ await page.close();
2089
+ }
1593
2090
  }
1594
2091
  } catch {} finally {
1595
- await browser.close();
2092
+ await browser?.close();
1596
2093
  }
1597
2094
  return shown;
1598
2095
  }
@@ -1719,7 +2216,6 @@ const share = (fraction) => `${Math.round(fraction * 100)}%`;
1719
2216
  const HELP = readFileSync(new URL("../INSTRUCTIONS.md", import.meta.url), "utf8").trimEnd();
1720
2217
  const given = process.argv.slice(2);
1721
2218
  const [command, review, ...words] = given;
1722
- const needs = (usage) => new Refused([`That needs more: ${usage}`]);
1723
2219
  const takes = {
1724
2220
  start: [2, `playthrough start <review> "<title>" "<what was asked>"`],
1725
2221
  record: [0, "playthrough record <review>"],
@@ -1731,26 +2227,18 @@ const takes = {
1731
2227
  const limit = Object.hasOwn(takes, command ?? "") ? takes[command] : void 0;
1732
2228
  try {
1733
2229
  if (!command || command === "help" || given.some((word) => ["--help", "-h"].includes(word))) console.log(HELP);
1734
- else if (limit && words.length > limit[0]) throw new Refused([`That's more than it takes: ${limit[1]}`]);
2230
+ else if (!limit) throw new Refused([`There's no command “${command}”. Run playthrough alone for what each one does.`]);
2231
+ else if (words.length > limit[0]) throw new Refused([`That's more than it takes: ${limit[1]}`]);
2232
+ else if (!review && command !== "open") throw new Refused([`That needs more: ${limit[1]}`]);
1735
2233
  else {
1736
2234
  await putBack(repo());
1737
- if (command === "start") {
1738
- if (!review) throw needs(takes.start[1]);
1739
- await start(repo(), review, words[0], words[1]);
1740
- } else if (command === "record") {
1741
- if (!review) throw needs(takes.record[1]);
1742
- await record(repo(), review);
1743
- } else if (command === "wait") {
1744
- if (!review) throw needs(takes.wait[1]);
1745
- await wait(repo(), review);
1746
- } else if (command === "walk") {
1747
- if (!review) throw needs(takes.walk[1]);
1748
- await walk(repo(), review);
1749
- } else if (command === "open") await open(repo(), review);
1750
- else if (command === "take-over") {
1751
- if (!review) throw needs(takes["take-over"][1]);
1752
- await takeOver$1(repo(), review, words[0], words[1]);
1753
- } else throw new Refused([`There's no command “${command}”. Run playthrough alone for what each one does.`]);
2235
+ if (command !== "open" && command !== "wait") refuseWhileAside(repo());
2236
+ if (command === "open") await open(repo(), review);
2237
+ else if (command === "start") await start(repo(), review, words[0], words[1]);
2238
+ else if (command === "record") await record(repo(), review);
2239
+ else if (command === "wait") await wait(repo(), review);
2240
+ else if (command === "walk") await walk(repo(), review);
2241
+ else if (command === "take-over") await takeOver$1(repo(), review, words[0], words[1]);
1754
2242
  }
1755
2243
  } catch (error) {
1756
2244
  if (!(error instanceof Refused)) throw error;