playthrough 0.4.3 → 0.5.1

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,24 +1,88 @@
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-S9fOk0za.js";
3
+ import { A as repo, M as thisCommand, N as pausedAt, 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 stillAt, 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-vi0amwSo.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
8
  import { createHash, randomBytes } from "node:crypto";
10
9
  import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
11
- import { dirname, extname, join, normalize, resolve, sep } from "node:path";
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) {
19
18
  console.error(`Playthrough needs Node 24 or later, and this is Node ${process.versions.node}. Install Node 24, then run this again.`);
20
19
  process.exit(1);
21
20
  }
21
+ // ../page/src/scheme.ts
22
+ function asRecorded(text, recorded) {
23
+ const answers = {
24
+ "prefers-color-scheme": recorded.dark ? "dark" : "light",
25
+ "prefers-reduced-motion": recorded.still ? "reduce" : "no-preference"
26
+ };
27
+ return String(text).replace(/\(\s*(prefers-color-scheme|prefers-reduced-motion)\s*(?::\s*([a-z-]+)\s*)?\)/gi, (_, feature, value) => {
28
+ feature = feature.toLowerCase();
29
+ return (value ? value.toLowerCase() === answers[feature] : feature === "prefers-color-scheme" || recorded.still) ? "(min-width: 0px)" : "(max-width: 0px)";
30
+ });
31
+ }
32
+ function answerAs(view, recorded) {
33
+ const page = view.document;
34
+ const as = (query) => asRecorded(query, recorded);
35
+ const matching = view.matchMedia;
36
+ view.matchMedia = (query) => matching.call(view, as(query));
37
+ const sheet = view.CSSStyleSheet.prototype;
38
+ for (const name of [
39
+ "insertRule",
40
+ "replace",
41
+ "replaceSync"
42
+ ]) {
43
+ const real = sheet[name];
44
+ if (real) sheet[name] = function(text, ...rest) {
45
+ return real.call(this, as(text), ...rest);
46
+ };
47
+ }
48
+ const scheme = recorded.dark ? "dark" : "light";
49
+ const inScheme = (value) => /\blight\b/.test(value) && /\bdark\b/.test(value) ? scheme : value;
50
+ const rewrite = (rules) => {
51
+ for (const rule of rules) {
52
+ if (rule.media && as(rule.media.mediaText) !== rule.media.mediaText) rule.media.mediaText = as(rule.media.mediaText);
53
+ if (rule.style?.colorScheme && inScheme(rule.style.colorScheme) !== rule.style.colorScheme) rule.style.colorScheme = inScheme(rule.style.colorScheme);
54
+ if (rule.styleSheet) restyle(rule.styleSheet);
55
+ if (rule.cssRules) rewrite(rule.cssRules);
56
+ }
57
+ };
58
+ const restyle = (sheet) => {
59
+ try {
60
+ rewrite(sheet.cssRules);
61
+ } catch {}
62
+ };
63
+ const restyleAll = () => {
64
+ for (const sheet of page.styleSheets) restyle(sheet);
65
+ for (const sheet of page.adoptedStyleSheets ?? []) restyle(sheet);
66
+ for (const element of page.querySelectorAll("[media]")) {
67
+ const media = element.getAttribute("media");
68
+ if (as(media) !== media) element.setAttribute("media", as(media));
69
+ }
70
+ for (const meta of page.querySelectorAll("meta[name=\"color-scheme\"]")) {
71
+ const content = meta.getAttribute("content") ?? "";
72
+ if (inScheme(content) !== content) meta.setAttribute("content", inScheme(content));
73
+ }
74
+ };
75
+ const styling = (node) => /^(style|link|source|meta)$/i.test(node?.nodeName ?? "");
76
+ const bringsStyle = (node) => styling(node) || !!node.querySelector?.("style, link, source, meta");
77
+ new view.MutationObserver((changes) => changes.some((change) => styling(change.target) || styling(change.target.parentNode) || [...change.addedNodes].some(bringsStyle)) && restyleAll()).observe(page, {
78
+ subtree: true,
79
+ childList: true,
80
+ characterData: true,
81
+ attributeFilter: ["media", "content"]
82
+ });
83
+ page.addEventListener("load", (event) => styling(event.target) && restyleAll(), true);
84
+ restyleAll();
85
+ }
22
86
  // src/before.ts
23
87
  function keepBefore(repo, id) {
24
88
  if (!attempt(() => git(repo, ["rev-parse", "--is-inside-work-tree"]))) return void 0;
@@ -87,7 +151,7 @@ async function recordOnBefore(repo, id, number, { recorded, written }) {
87
151
  const review = await repo.reviews.get(id);
88
152
  const before = review.rounds.find((round) => round.number === 0);
89
153
  if (!before) return;
90
- const ran = await readFile(repo.ranOnBefore(id), "utf8").then(JSON.parse, () => ({}));
154
+ const ran = await readFile(repo.ranOnBefore(id), "utf8").then(JSON.parse).catch(() => ({}));
91
155
  const setup = await writtenNow(repo);
92
156
  const follows = Object.fromEntries(recorded.map(({ flow, doneOn }) => [flow.name, createHash("sha256").update(JSON.stringify([
93
157
  written[flow.name]?.hash,
@@ -162,11 +226,12 @@ const SIGNALS = [
162
226
  "SIGHUP"
163
227
  ];
164
228
  async function asItWas(repo, unrecorded, commit, shared, run) {
229
+ refuseWhileAside(repo);
165
230
  await repo.keepOutOfGit();
166
231
  const aside = {
167
232
  back: here(repo),
168
233
  stash: `Playthrough: your work, set aside while flows record on Before, ${(/* @__PURE__ */ new Date()).toISOString()}`,
169
- by: process.pid,
234
+ ...thisCommand(),
170
235
  ...shared && { shared }
171
236
  };
172
237
  writeFileSync(repo.setAside, JSON.stringify(aside));
@@ -229,8 +294,8 @@ async function asItWas(repo, unrecorded, commit, shared, run) {
229
294
  }
230
295
  }
231
296
  async function putBack(repo) {
232
- const aside = attempt(() => JSON.parse(readFileSync(repo.setAside, "utf8")));
233
- if (!aside || running(aside.by)) return;
297
+ const aside = setAside(repo);
298
+ if (!aside || stillAt(aside)) return;
234
299
  const toDo = here(repo) !== aside.back || !!stashOf(repo, aside);
235
300
  const problem = putWorkBack(repo, aside, false);
236
301
  if (problem) throw stillAside(repo, aside, problem);
@@ -240,6 +305,11 @@ async function putBack(repo) {
240
305
  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
306
  console.log(`${back}\n`);
242
307
  }
308
+ function refuseWhileAside(repo) {
309
+ const aside = setAside(repo);
310
+ 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.`]);
311
+ }
312
+ const setAside = (repo) => attempt(() => JSON.parse(readFileSync(repo.setAside, "utf8")));
243
313
  function putWorkBack(repo, aside, force) {
244
314
  try {
245
315
  if (here(repo) !== aside.back) {
@@ -310,15 +380,12 @@ async function caughtUp(url) {
310
380
  await new Promise((done) => setTimeout(done, 1e3));
311
381
  await fetch(url, { signal: AbortSignal.timeout(6e4) }).then((response) => response.body?.cancel(), () => {});
312
382
  }
313
- function running(pid) {
314
- try {
315
- return process.kill(pid, 0);
316
- } catch (error) {
317
- return error.code === "EPERM";
318
- }
319
- }
320
383
  function git(repo, args, env) {
321
- return execFileSync("git", args, {
384
+ return execFileSync("git", [
385
+ "-c",
386
+ "core.hooksPath=/dev/null",
387
+ ...args
388
+ ], {
322
389
  cwd: repo.root,
323
390
  env,
324
391
  encoding: "utf8",
@@ -430,11 +497,11 @@ async function followingFor(repo, id, to, fresh = false) {
430
497
  const one = following.get(key(repo, id));
431
498
  if (one && !one.over && !fresh && one.written === written && one.flow === to.flow) return one;
432
499
  one?.stop();
433
- const made = await start$1(repo, id, to, files, written);
500
+ const made = await start$2(repo, id, to, files, written);
434
501
  following.set(key(repo, id), made);
435
502
  return made;
436
503
  }
437
- async function start$1(repo, id, to, files, written) {
504
+ async function start$2(repo, id, to, files, written) {
438
505
  const app = await repo.app();
439
506
  const { started } = await repo.reviews.get(id);
440
507
  const { runAlongside, pageFor } = await alongside();
@@ -575,7 +642,8 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
575
642
  const outside = ahead && await reachesOutside(repo, id, flow.name);
576
643
  if (outside) throw new Refused([`${quote(flow.name)} reaches outside the app (${outside}), so it's run only when it's taken over.`]);
577
644
  const app = await repo.app();
578
- await keepRunning(app, repo.root);
645
+ refuseWhileAside(repo);
646
+ await keepRunning(app, repo.root, { ready: true });
579
647
  const began = Date.now();
580
648
  const { handed, made } = await runTo(repo, id, {
581
649
  flow: flow.name,
@@ -585,10 +653,14 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
585
653
  },
586
654
  ...did?.length && { did }
587
655
  });
588
- passing ??= {
589
- moments: /* @__PURE__ */ new Map(),
590
- port: passOn(app, (moment) => passing.moments.get(moment), network)
591
- };
656
+ if (!passings.has(app.url)) {
657
+ const moments = /* @__PURE__ */ new Map();
658
+ passings.set(app.url, {
659
+ moments,
660
+ port: passOn(app, (moment) => moments.get(moment), network)
661
+ });
662
+ }
663
+ const passing = passings.get(app.url);
592
664
  const moment = randomBytes(12).toString("base64url");
593
665
  passing.moments.set(moment, did?.length ? {
594
666
  ...handed,
@@ -611,7 +683,8 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
611
683
  }
612
684
  const ready = /* @__PURE__ */ new Map();
613
685
  async function keepUp(repo, id, flow, step, actions) {
614
- await keepRunning(await repo.app(), repo.root);
686
+ refuseWhileAside(repo);
687
+ await keepRunning(await repo.app(), repo.root, { ready: true });
615
688
  await follow(repo, id, {
616
689
  flow,
617
690
  ...step && {
@@ -620,7 +693,7 @@ async function keepUp(repo, id, flow, step, actions) {
620
693
  }
621
694
  });
622
695
  }
623
- let passing;
696
+ const passings = /* @__PURE__ */ new Map();
624
697
  async function recorded(repo, id) {
625
698
  const review = await repo.reviews.get(id);
626
699
  const round = review.rounds.findLast((round) => round.number > 0) ?? review.rounds.at(-1);
@@ -629,7 +702,7 @@ async function recorded(repo, id) {
629
702
  }
630
703
  function passOn(app, handed, network) {
631
704
  const target = new URL(app.url);
632
- const server = createServer((request, response) => {
705
+ const server = createServer$1((request, response) => {
633
706
  if (!named(request)) return turnAway(response);
634
707
  const [first, moment] = new URL(request.url ?? "/", "http://playthrough").pathname.split("/").filter(Boolean);
635
708
  if (first === "take-over") return handOver(handed(moment ?? ""), response, !fromThisMachine(request));
@@ -699,7 +772,6 @@ function asIfDirect(request, target) {
699
772
  else delete headers.cookie;
700
773
  return headers;
701
774
  }
702
- const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
703
775
  let RECORDER;
704
776
  const SAYS = `<script>(() => {
705
777
  if (window.parent === window) return;
@@ -715,52 +787,11 @@ const SAYS = `<script>(() => {
715
787
  Object.assign(Shifted, { now: () => Real.now() + ahead, parse: Real.parse, UTC: Real.UTC });
716
788
  window.Date = Shifted;
717
789
  }
718
- // In the browser the flow was recorded in, whatever yours says: light or dark, still or moving, in its languages. A
719
- // page asks in its scripts and its styles, and each asking is answered before the app hears it.
790
+ // In the browser the flow was recorded in, whatever yours says: light or dark, still or moving, in its languages.
720
791
  const browser = JSON.parse(sessionStorage.getItem("playthrough:browser") || "null");
721
792
  if (browser) {
722
- const recorded = { "prefers-color-scheme": browser.dark ? "dark" : "light", "prefers-reduced-motion": browser.still ? "reduce" : "no-preference" };
723
- const as = (query) =>
724
- String(query).replace(/\\(\\s*(prefers-color-scheme|prefers-reduced-motion)\\s*(?::\\s*([a-z-]+)\\s*)?\\)/gi, (_, feature, value) => {
725
- feature = feature.toLowerCase();
726
- const holds = value ? value.toLowerCase() === recorded[feature] : feature === "prefers-color-scheme" || browser.still;
727
- return holds ? "(min-width: 0px)" : "(max-width: 0px)";
728
- });
729
- const matching = window.matchMedia;
730
- window.matchMedia = (query) => matching.call(window, as(query));
731
- for (const name of ["insertRule", "replace", "replaceSync"]) {
732
- const real = CSSStyleSheet.prototype[name];
733
- if (real) CSSStyleSheet.prototype[name] = function (text, ...rest) { return real.call(this, as(text), ...rest); };
734
- }
735
- // light-dark(), system colours, form controls and scrollbars follow the page's colour-scheme, not a media query.
736
- const scheme = browser.dark ? "dark" : "light";
737
- const inScheme = (value) => (/\\blight\\b/.test(value) && /\\bdark\\b/.test(value) ? scheme : value);
738
- const rewrite = (rules) => {
739
- for (const rule of rules) {
740
- if (rule.media && as(rule.media.mediaText) !== rule.media.mediaText) rule.media.mediaText = as(rule.media.mediaText);
741
- if (rule.style?.colorScheme && inScheme(rule.style.colorScheme) !== rule.style.colorScheme) rule.style.colorScheme = inScheme(rule.style.colorScheme);
742
- if (rule.styleSheet) restyle(rule.styleSheet);
743
- if (rule.cssRules) rewrite(rule.cssRules);
744
- }
745
- };
746
- const restyle = (sheet) => { try { rewrite(sheet.cssRules); } catch {} };
747
- const restyleAll = () => {
748
- for (const sheet of document.styleSheets) restyle(sheet);
749
- for (const sheet of document.adoptedStyleSheets ?? []) restyle(sheet);
750
- for (const element of document.querySelectorAll("[media]")) {
751
- const media = element.getAttribute("media");
752
- if (as(media) !== media) element.setAttribute("media", as(media));
753
- }
754
- for (const meta of document.querySelectorAll('meta[name="color-scheme"]')) {
755
- const content = meta.getAttribute("content") ?? "";
756
- if (inScheme(content) !== content) meta.setAttribute("content", inScheme(content));
757
- }
758
- };
759
- const styling = (node) => /^(style|link|source|meta)$/i.test(node?.nodeName);
760
- const bringsStyle = (node) => styling(node) || !!node.querySelector?.("style, link, source, meta");
761
- new MutationObserver((changes) => changes.some((change) => styling(change.target) || styling(change.target.parentNode) || [...change.addedNodes].some(bringsStyle)) && restyleAll())
762
- .observe(document, { subtree: true, childList: true, characterData: true, attributeFilter: ["media", "content"] });
763
- document.addEventListener("load", (event) => styling(event.target) && restyleAll(), true);
793
+ ${asRecorded.toString()}
794
+ (${answerAs.toString()})(window, browser);
764
795
  const languages = browser.languages;
765
796
  Object.defineProperty(navigator, "language", { get: () => languages[0] });
766
797
  Object.defineProperty(navigator, "languages", { get: () => languages });
@@ -799,6 +830,9 @@ const SAYS = `<script>(() => {
799
830
  const yours = (mine) => { hidden.disabled = mine; if (mine) sessionStorage.setItem("playthrough:yours", "1"); else sessionStorage.removeItem("playthrough:yours"); };
800
831
  yours(!!sessionStorage.getItem("playthrough:yours"));
801
832
  addEventListener("message", ({ source, data }) => { if (source === parent && data?.playthrough === "yours") yours(data.yours); });
833
+ // Taken over, the page is scrolled where the recording was at that moment: made ready at a step's end, it was where
834
+ // the step left it, a screen or more from a moment partway through a page being read.
835
+ addEventListener("message", ({ source, data }) => { if (source === parent && data?.playthrough === "scroll") scrollTo({ left: data.x, top: data.y, behavior: "instant" }); });
802
836
  const typing = (target) => !!target?.closest?.('input:not([type=button]):not([type=submit]):not([type=checkbox]):not([type=radio]):not([type=range]), textarea, [contenteditable=""], [contenteditable=true]');
803
837
  // Every move, as it comes: held for a frame, the name trailed the pointer like a smoothed one. Not the earlier raw
804
838
  // updates: in a scaled frame, Chrome gives them the page's position rather than the app's.
@@ -1017,7 +1051,9 @@ const SAYS = `<script>(() => {
1017
1051
  sampling: { mousemove: 20, scroll: 16, input: "all" },
1018
1052
  maskAllInputs: false,
1019
1053
  inlineStylesheet: true,
1020
- }) && ((recording = true), readNow());
1054
+ }) && ((recording = true), said(), readNow());
1055
+ // Light or dark, still or moving, as the flow was recorded, for your recording to be played back as it was too.
1056
+ const said = () => window.rrwebRecord.record.addCustomEvent("browser", { dark: matchMedia("(prefers-color-scheme: dark)").matches, still: matchMedia("(prefers-reduced-motion: reduce)").matches });
1021
1057
  addEventListener("message", ({ source, data }) => source === parent && data?.playthrough === "yours" && record());
1022
1058
  const settle = async () => {
1023
1059
  await rested();
@@ -1097,6 +1133,394 @@ function notAnswering(response, app) {
1097
1133
  response.end(`The app isn't answering at ${app.url}. Start it as playthrough/app.json says (${app.start}), then reload.`);
1098
1134
  }
1099
1135
  const addresses = () => Object.values(networkInterfaces()).flat().filter((face) => face && face.family === "IPv4" && !face.internal).map((face) => face.address);
1136
+ // src/mp4.ts
1137
+ function mp4({ width, height, fps, avcC, frames }) {
1138
+ const scale = fps * 1e3;
1139
+ const length = frames.length * 1e3;
1140
+ const keys = frames.flatMap((frame, i) => frame.key ? [i + 1] : []);
1141
+ const moov = (offset) => box("moov", [full("mvhd", 0, 0, [
1142
+ u32(0),
1143
+ u32(0),
1144
+ u32(scale),
1145
+ u32(length),
1146
+ u32(65536),
1147
+ u16(256),
1148
+ zeros(10),
1149
+ matrix,
1150
+ zeros(24),
1151
+ u32(2)
1152
+ ]), box("trak", [full("tkhd", 0, 3, [
1153
+ u32(0),
1154
+ u32(0),
1155
+ u32(1),
1156
+ u32(0),
1157
+ u32(length),
1158
+ zeros(8),
1159
+ u16(0),
1160
+ u16(0),
1161
+ u16(0),
1162
+ u16(0),
1163
+ matrix,
1164
+ u32(width << 16),
1165
+ u32(height << 16)
1166
+ ]), box("mdia", [
1167
+ full("mdhd", 0, 0, [
1168
+ u32(0),
1169
+ u32(0),
1170
+ u32(scale),
1171
+ u32(length),
1172
+ u16(21956),
1173
+ u16(0)
1174
+ ]),
1175
+ full("hdlr", 0, 0, [
1176
+ u32(0),
1177
+ ascii("vide"),
1178
+ zeros(12),
1179
+ ascii("Playthrough\0")
1180
+ ]),
1181
+ box("minf", [
1182
+ full("vmhd", 0, 1, [zeros(8)]),
1183
+ box("dinf", [full("dref", 0, 0, [u32(1), full("url ", 0, 1, [])])]),
1184
+ box("stbl", [
1185
+ full("stsd", 0, 0, [u32(1), box("avc1", [
1186
+ zeros(6),
1187
+ u16(1),
1188
+ zeros(16),
1189
+ u16(width),
1190
+ u16(height),
1191
+ u32(4718592),
1192
+ u32(4718592),
1193
+ u32(0),
1194
+ u16(1),
1195
+ zeros(32),
1196
+ u16(24),
1197
+ u16(65535),
1198
+ box("avcC", [avcC])
1199
+ ])]),
1200
+ full("stts", 0, 0, [
1201
+ u32(1),
1202
+ u32(frames.length),
1203
+ u32(1e3)
1204
+ ]),
1205
+ full("stss", 0, 0, [u32(keys.length), ...keys.map(u32)]),
1206
+ full("stsc", 0, 0, [
1207
+ u32(1),
1208
+ u32(1),
1209
+ u32(frames.length),
1210
+ u32(1)
1211
+ ]),
1212
+ full("stsz", 0, 0, [
1213
+ u32(0),
1214
+ u32(frames.length),
1215
+ ...frames.map((frame) => u32(frame.data.length))
1216
+ ]),
1217
+ full("stco", 0, 0, [u32(1), u32(offset)])
1218
+ ])
1219
+ ])
1220
+ ])])]);
1221
+ const ftyp = box("ftyp", [
1222
+ ascii("isom"),
1223
+ u32(512),
1224
+ ascii("isom"),
1225
+ ascii("iso2"),
1226
+ ascii("avc1"),
1227
+ ascii("mp41")
1228
+ ]);
1229
+ return join$1([
1230
+ ftyp,
1231
+ moov(ftyp.length + moov(0).length + 8),
1232
+ box("mdat", frames.map((frame) => frame.data))
1233
+ ]);
1234
+ }
1235
+ const matrix = join$1([
1236
+ 65536,
1237
+ 0,
1238
+ 0,
1239
+ 0,
1240
+ 65536,
1241
+ 0,
1242
+ 0,
1243
+ 0,
1244
+ 1073741824
1245
+ ].map(u32));
1246
+ function box(type, parts) {
1247
+ const body = join$1(parts);
1248
+ return join$1([
1249
+ u32(body.length + 8),
1250
+ ascii(type),
1251
+ body
1252
+ ]);
1253
+ }
1254
+ const full = (type, version, flags, parts) => box(type, [u32(version << 24 | flags), ...parts]);
1255
+ function u32(value) {
1256
+ const bytes = /* @__PURE__ */ new Uint8Array(4);
1257
+ new DataView(bytes.buffer).setUint32(0, value >>> 0);
1258
+ return bytes;
1259
+ }
1260
+ function u16(value) {
1261
+ const bytes = /* @__PURE__ */ new Uint8Array(2);
1262
+ new DataView(bytes.buffer).setUint16(0, value);
1263
+ return bytes;
1264
+ }
1265
+ const zeros = (length) => new Uint8Array(length);
1266
+ const ascii = (text) => new TextEncoder().encode(text);
1267
+ function join$1(parts) {
1268
+ const joined = new Uint8Array(parts.reduce((length, part) => length + part.length, 0));
1269
+ let at = 0;
1270
+ for (const part of parts) {
1271
+ joined.set(part, at);
1272
+ at += part.length;
1273
+ }
1274
+ return joined;
1275
+ }
1276
+ // src/video.ts
1277
+ const FPS = 60;
1278
+ const WIDEST = 1920;
1279
+ const ROOM = 32;
1280
+ const CAPTION = 26;
1281
+ const GAP = 20;
1282
+ const TALLEST = 1920;
1283
+ const LARGEST = 2;
1284
+ const PER_FRAME = .009;
1285
+ async function video(port, id, filmed, shape, onLeft) {
1286
+ const beside = filmed.beside !== void 0;
1287
+ const room = {
1288
+ width: (beside ? 2 * shape.width + GAP : shape.width) + 64,
1289
+ height: shape.height + CAPTION + 64
1290
+ };
1291
+ const larger = Math.min(WIDEST / room.width, TALLEST / (room.height - (beside ? 0 : CAPTION)), LARGEST);
1292
+ const viewport = {
1293
+ width: Math.round(larger * room.width),
1294
+ height: Math.round(larger * room.height)
1295
+ };
1296
+ const browser = await chromium.launch();
1297
+ try {
1298
+ const page = await (await browser.newContext({
1299
+ viewport,
1300
+ colorScheme: filmed.dark ? "dark" : "light"
1301
+ })).newPage();
1302
+ const { dark, ...shown } = filmed;
1303
+ await page.goto(`http://localhost:${port}/${encodeURIComponent(id)}?video=${encodeURIComponent(JSON.stringify({
1304
+ ...shown,
1305
+ larger
1306
+ }))}`);
1307
+ await page.waitForFunction((frames) => !!window.video && document.querySelectorAll("[data-frame]:not([hidden]) iframe").length >= frames, beside ? 2 : 1);
1308
+ await page.evaluate(() => document.fonts.ready);
1309
+ await page.waitForTimeout(600);
1310
+ const crop = await page.evaluate(around, {
1311
+ room: larger * ROOM,
1312
+ caption: beside ? larger * CAPTION : 0
1313
+ });
1314
+ return await film(page, await encoder(await browser.newPage(), crop), onLeft);
1315
+ } finally {
1316
+ await browser.close();
1317
+ }
1318
+ }
1319
+ function around({ room, caption }) {
1320
+ const boxes = [...document.querySelectorAll("[data-frame]:not([hidden])")].map((frame) => frame.getBoundingClientRect());
1321
+ const left = Math.max(0, Math.min(...boxes.map((box) => box.left)) - room);
1322
+ const top = Math.max(0, Math.min(...boxes.map((box) => box.top)) - caption - room);
1323
+ const right = Math.min(innerWidth, Math.max(...boxes.map((box) => box.right)) + room);
1324
+ const bottom = Math.min(innerHeight, Math.max(...boxes.map((box) => box.bottom)) + room);
1325
+ return {
1326
+ x: left,
1327
+ y: top,
1328
+ width: right - left,
1329
+ height: bottom - top
1330
+ };
1331
+ }
1332
+ async function encoder(page, crop) {
1333
+ const k = Math.min(1, WIDEST / crop.width);
1334
+ const even = (value) => 2 * Math.round(value / 2);
1335
+ const size = {
1336
+ width: even(crop.width * k),
1337
+ height: even(crop.height * k)
1338
+ };
1339
+ await page.route("https://video.playthrough/", (route) => route.fulfill({
1340
+ contentType: "text/html",
1341
+ body: ""
1342
+ }));
1343
+ await page.goto("https://video.playthrough/");
1344
+ await page.evaluate(start$1, {
1345
+ ...size,
1346
+ fps: FPS,
1347
+ crop
1348
+ });
1349
+ return {
1350
+ page,
1351
+ size
1352
+ };
1353
+ }
1354
+ async function film(page, { page: putting, size }, onLeft) {
1355
+ const length = await page.evaluate(() => window.video.length);
1356
+ const browser = await page.context().newCDPSession(page);
1357
+ let start = Infinity;
1358
+ let drawn;
1359
+ let last;
1360
+ let made = 0;
1361
+ let done = 0;
1362
+ let going = Promise.resolve();
1363
+ const upTo = (at) => {
1364
+ for (; made / FPS <= at; made++) {
1365
+ const [jpeg, key] = [drawn === last ? void 0 : drawn, made % 120 === 0];
1366
+ last = drawn;
1367
+ going = going.then(() => putting.evaluate(add, {
1368
+ jpeg,
1369
+ key
1370
+ })).then(() => void done++);
1371
+ }
1372
+ };
1373
+ let firstDrawn = () => {};
1374
+ browser.on("Page.screencastFrame", ({ data, metadata, sessionId }) => {
1375
+ upTo((metadata.timestamp ?? Date.now() / 1e3) - start);
1376
+ drawn = data;
1377
+ firstDrawn();
1378
+ browser.send("Page.screencastFrameAck", { sessionId }).catch(() => {});
1379
+ });
1380
+ await new Promise((drew) => {
1381
+ firstDrawn = drew;
1382
+ browser.send("Page.startScreencast", {
1383
+ format: "jpeg",
1384
+ quality: 92,
1385
+ maxWidth: page.viewportSize().width,
1386
+ maxHeight: page.viewportSize().height
1387
+ });
1388
+ });
1389
+ start = Date.now() / 1e3;
1390
+ const telling = setInterval(() => onLeft(Math.max(0, start + length - Date.now() / 1e3) + (made - done) * PER_FRAME), 250);
1391
+ try {
1392
+ await page.evaluate(() => window.video.play());
1393
+ upTo(Date.now() / 1e3 - start);
1394
+ await browser.send("Page.stopScreencast");
1395
+ await going;
1396
+ } finally {
1397
+ clearInterval(telling);
1398
+ }
1399
+ const finished = await putting.evaluate(finish);
1400
+ const bytes = (base64) => new Uint8Array(Buffer.from(base64, "base64"));
1401
+ return mp4({
1402
+ ...size,
1403
+ fps: FPS,
1404
+ avcC: bytes(finished.avcC),
1405
+ frames: finished.frames.map((frame) => ({
1406
+ data: bytes(frame.data),
1407
+ key: frame.key
1408
+ }))
1409
+ });
1410
+ }
1411
+ async function start$1({ width, height, fps, crop }) {
1412
+ const base64 = (bytes) => btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""));
1413
+ const state = {
1414
+ encoder: new VideoEncoder({
1415
+ output: (chunk, metadata) => {
1416
+ const data = new Uint8Array(chunk.byteLength);
1417
+ chunk.copyTo(data);
1418
+ state.frames.push({
1419
+ data: base64(data),
1420
+ key: chunk.type === "key"
1421
+ });
1422
+ const description = metadata?.decoderConfig?.description;
1423
+ if (description) state.avcC = base64(ArrayBuffer.isView(description) ? new Uint8Array(description.buffer, description.byteOffset, description.byteLength) : new Uint8Array(description));
1424
+ },
1425
+ error: (error) => console.error(error)
1426
+ }),
1427
+ canvas: new OffscreenCanvas(width, height),
1428
+ crop,
1429
+ fps,
1430
+ i: 0,
1431
+ picture: new Uint8Array(width * height * 3 / 2),
1432
+ frames: []
1433
+ };
1434
+ window.encoding = state;
1435
+ for (const codec of [
1436
+ "avc1.640033",
1437
+ "avc1.64002a",
1438
+ "avc1.640028"
1439
+ ]) if ((await VideoEncoder.isConfigSupported({
1440
+ codec,
1441
+ width,
1442
+ height
1443
+ })).supported) {
1444
+ state.encoder.configure({
1445
+ codec,
1446
+ width,
1447
+ height,
1448
+ bitrate: 6e6,
1449
+ framerate: fps,
1450
+ avc: { format: "avc" }
1451
+ });
1452
+ return;
1453
+ }
1454
+ throw new Error(`This browser can't make an H.264 video ${width}×${height} wide.`);
1455
+ }
1456
+ async function add({ jpeg, key }) {
1457
+ const state = window.encoding;
1458
+ const { width, height } = state.canvas;
1459
+ if (jpeg) {
1460
+ const picture = await createImageBitmap(await (await fetch(`data:image/jpeg;base64,${jpeg}`)).blob());
1461
+ const draw = state.canvas.getContext("2d", { willReadFrequently: true });
1462
+ draw.imageSmoothingQuality = "high";
1463
+ draw.drawImage(picture, state.crop.x, state.crop.y, state.crop.width, state.crop.height, 0, 0, width, height);
1464
+ picture.close();
1465
+ const rgba = draw.getImageData(0, 0, width, height).data;
1466
+ const [brightness, blue, red] = [
1467
+ state.picture.subarray(0, width * height),
1468
+ state.picture.subarray(width * height, width * height * 5 / 4),
1469
+ state.picture.subarray(width * height * 5 / 4)
1470
+ ];
1471
+ for (let y = 0; y < height; y += 2) for (let x = 0; x < width; x += 2) {
1472
+ let [r, g, b] = [
1473
+ 0,
1474
+ 0,
1475
+ 0
1476
+ ];
1477
+ for (const at of [
1478
+ y * width + x,
1479
+ y * width + x + 1,
1480
+ (y + 1) * width + x,
1481
+ (y + 1) * width + x + 1
1482
+ ]) {
1483
+ const [dotR, dotG, dotB] = [
1484
+ rgba[at * 4],
1485
+ rgba[at * 4 + 1],
1486
+ rgba[at * 4 + 2]
1487
+ ];
1488
+ brightness[at] = 16 + (11966 * dotR + 40254 * dotG + 4064 * dotB + 32768 >> 16);
1489
+ r += dotR;
1490
+ g += dotG;
1491
+ b += dotB;
1492
+ }
1493
+ const square = y / 2 * (width / 2) + x / 2;
1494
+ blue[square] = 128 + (-6596 * r - 22189 * g + 28785 * b + 131072 >> 18);
1495
+ red[square] = 128 + (28785 * r - 26145 * g - 2640 * b + 131072 >> 18);
1496
+ }
1497
+ }
1498
+ const frame = new VideoFrame(state.picture, {
1499
+ format: "I420",
1500
+ codedWidth: width,
1501
+ codedHeight: height,
1502
+ timestamp: state.i * 1e6 / state.fps,
1503
+ duration: 1e6 / state.fps,
1504
+ colorSpace: {
1505
+ primaries: "bt709",
1506
+ transfer: "bt709",
1507
+ matrix: "bt709",
1508
+ fullRange: false
1509
+ }
1510
+ });
1511
+ state.encoder.encode(frame, { keyFrame: key });
1512
+ frame.close();
1513
+ state.i++;
1514
+ while (state.encoder.encodeQueueSize > 8) await new Promise((done) => setTimeout(done, 5));
1515
+ }
1516
+ async function finish() {
1517
+ const state = window.encoding;
1518
+ await state.encoder.flush();
1519
+ return {
1520
+ avcC: state.avcC,
1521
+ frames: state.frames
1522
+ };
1523
+ }
1100
1524
  // src/open.ts
1101
1525
  const PORT = 5790;
1102
1526
  const TYPES = {
@@ -1116,13 +1540,16 @@ const TYPES = {
1116
1540
  ".ttf": "font/ttf",
1117
1541
  ".otf": "font/otf"
1118
1542
  };
1119
- async function open(repo, id) {
1120
- if (id) await repo.reviews.get(id);
1543
+ async function open(here, id) {
1544
+ const app = everyCopy(here);
1545
+ if (id) await (await app.holding(id)).reviews.get(id);
1546
+ const already = await openAlready(app.folder);
1547
+ if (already) return console.log(`Playthrough is already open at http://localhost:${already}/${id ?? ""}`);
1121
1548
  const page = fileURLToPath(new URL("../viewer", import.meta.url));
1122
1549
  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."]);
1123
- const you = whoYouAre(repo.root);
1124
- const port = await listen(createServer((request, response) => {
1125
- answer(repo, page, you, request, response).catch((error) => {
1550
+ const you = whoYouAre(here.root);
1551
+ const port = await listen(createServer$1((request, response) => {
1552
+ answer(app, page, you, request, response).catch((error) => {
1126
1553
  if (response.headersSent) return response.end();
1127
1554
  const problems = error instanceof Refused ? error.problems : [String(error?.message ?? error)];
1128
1555
  json(response, error instanceof Refused ? 400 : 500, { problems });
@@ -1130,22 +1557,42 @@ async function open(repo, id) {
1130
1557
  }), PORT);
1131
1558
  console.log(`Playthrough is open at http://localhost:${port}/${id ?? ""}`);
1132
1559
  }
1133
- async function answer(repo, page, you, request, response) {
1560
+ function everyCopy(here) {
1561
+ const all = () => copies(here.root).map((root) => root === here.root ? here : repo(root));
1562
+ const found = /* @__PURE__ */ new Map();
1563
+ return {
1564
+ all,
1565
+ folder: all()[0]?.root ?? here.root,
1566
+ async holding(id) {
1567
+ if (!found.has(id)) {
1568
+ for (const copy of all()) if ((await copy.reviews.list()).includes(id) && !found.has(id)) found.set(id, copy);
1569
+ }
1570
+ return found.get(id) ?? here;
1571
+ }
1572
+ };
1573
+ }
1574
+ async function openAlready(folder) {
1575
+ const ports = Array.from({ length: 21 }, (_, i) => PORT + i);
1576
+ 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);
1577
+ return (await Promise.all(ports.map(asked))).find((port) => port !== void 0);
1578
+ }
1579
+ async function answer(app, page, you, request, response) {
1134
1580
  const stranger = fromElsewhere(request);
1135
1581
  if (stranger) return json(response, 403, { problems: [stranger] });
1136
- const reviews = repo.reviews;
1137
1582
  const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
1138
1583
  const [api, collection, id, ...rest] = path.split("/").filter(Boolean);
1139
1584
  if (api !== "api") return serve(page, path, response);
1140
1585
  if (request.method === "GET" && collection === "you") return json(response, 200, { name: you });
1586
+ if (request.method === "GET" && collection === "app") return json(response, 200, { folder: app.folder });
1141
1587
  if (collection !== "reviews") return json(response, 404, { problems: [`There's nothing at ${path}.`] });
1142
- 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 }) => ({
1143
- id,
1144
- title,
1145
- rounds: rounds.filter((round) => round.finished && round.number > 0).length,
1146
- accepted: !!accepted
1147
- })));
1588
+ if (request.method === "GET" && !id) {
1589
+ const read = async (copy) => Promise.all((await copy.reviews.list()).map((id) => copy.reviews.get(id).catch(() => void 0)));
1590
+ const every = (await Promise.all(app.all().map(read))).flat();
1591
+ return json(response, 200, every.filter((review, i) => review && every.findIndex((first) => first?.id === review.id) === i));
1592
+ }
1148
1593
  if (!id) return json(response, 404, { problems: [`There's nothing at ${path}.`] });
1594
+ const repo = await app.holding(id);
1595
+ const reviews = repo.reviews;
1149
1596
  if (request.method === "GET" && rest[0] === "live") return live(repo, id, request, response);
1150
1597
  if (request.method === "GET" && rest[0] === "rounds" && rest[2] === "files" && rest[3]) {
1151
1598
  const name = rest.slice(3).join("/");
@@ -1156,8 +1603,14 @@ async function answer(repo, page, you, request, response) {
1156
1603
  "content-type": TYPES[extname(path)] ?? "application/octet-stream",
1157
1604
  "cache-control": "max-age=31536000, immutable"
1158
1605
  });
1606
+ const as = new URL(request.url ?? "/", "http://localhost").searchParams.get("as");
1607
+ if (as !== null && extname(path) === ".svg") return response.end(asRecorded(Buffer.from(bytes).toString("utf8"), {
1608
+ dark: as.includes("dark"),
1609
+ still: as.includes("still")
1610
+ }));
1159
1611
  return response.end(bytes);
1160
1612
  }
1613
+ if (request.method === "POST" && rest[0] === "video") return saveVideo(repo, id, request, response);
1161
1614
  if (request.method === "POST" && rest[0] === "take-over") return takeOver(repo, id, request, response);
1162
1615
  if (request.method === "POST" && rest[0] === "follow") return followAlong(repo, id, request, response);
1163
1616
  if (request.method === "POST" && rest[0] === "ready-in") return readyInFor(repo, id, request, response);
@@ -1239,6 +1692,34 @@ function paced(events) {
1239
1692
  });
1240
1693
  }
1241
1694
  const readingOf = (event) => event.type === 5 && event.data?.tag === "reading" ? event.data.payload : void 0;
1695
+ async function saveVideo(repo, id, request, response) {
1696
+ const filmed = await body(request);
1697
+ const rounds = (await repo.reviews.get(id)).rounds.filter((round) => round.number === filmed.round || round.number === filmed.beside);
1698
+ const screens = [];
1699
+ for (const round of rounds) {
1700
+ const flow = round.flows.find((flow) => flow.name === filmed.flow);
1701
+ const kept = flow && await repo.reviews.file(id, round.number, flow.recording);
1702
+ if (kept) {
1703
+ for (const event of JSON.parse(Buffer.from(kept).toString("utf8"))) if (event.type === 4) screens.push(event.data);
1704
+ }
1705
+ }
1706
+ if (!screens.length) return json(response, 404, { problems: [`Round ${filmed.round} of ${id} has no recording of “${filmed.flow}”.`] });
1707
+ const shape = {
1708
+ width: Math.max(...screens.map((screen) => screen.width)),
1709
+ height: Math.max(...screens.map((screen) => screen.height))
1710
+ };
1711
+ response.writeHead(200, {
1712
+ "content-type": "application/x-ndjson",
1713
+ "cache-control": "no-store"
1714
+ });
1715
+ const say = (line) => response.write(`${JSON.stringify(line)}\n`);
1716
+ try {
1717
+ say({ video: Buffer.from(await video(request.socket.localPort, id, filmed, shape, (left) => say({ left: Math.round(left * 10) / 10 }))).toString("base64") });
1718
+ } catch (error) {
1719
+ say({ problems: [`The video couldn't be made: ${error.message.split("\n")[0]}`] });
1720
+ }
1721
+ response.end();
1722
+ }
1242
1723
  async function readyInFor(repo, id, request, response) {
1243
1724
  const { flow, step, actions } = await body(request);
1244
1725
  json(response, 200, { in: await readyIn(repo, id, {
@@ -1433,7 +1914,8 @@ async function walk(repo, id) {
1433
1914
  const saved = review.rounds.find((round) => round.number === number);
1434
1915
  const walked = await walkForks(repo, review, number, saved?.flows ?? review.rounds.findLast((round) => round.number > 0)?.flows ?? []);
1435
1916
  if (!walked.length) return console.log(`Nothing to walk: round ${number} answers no note made in the app.`);
1436
- if (saved) {
1917
+ const now = saved && (await repo.reviews.get(id)).rounds.find((round) => round.number === number);
1918
+ if (now) {
1437
1919
  const placed = (reply) => {
1438
1920
  const on = walked.find((one) => one.note === reply.note)?.on;
1439
1921
  return on ? {
@@ -1442,8 +1924,8 @@ async function walk(repo, id) {
1442
1924
  } : reply;
1443
1925
  };
1444
1926
  await repo.reviews.saveRound(id, {
1445
- ...saved,
1446
- replies: saved.replies.map(placed)
1927
+ ...now,
1928
+ replies: now.replies.map(placed)
1447
1929
  });
1448
1930
  }
1449
1931
  console.log(`\n${walked.map(saidOf).join("\n")}`);
@@ -1461,7 +1943,7 @@ async function record(repo, id) {
1461
1943
  `Fix ${failed.length > 1 ? "them" : "it"} and record again, which runs ${failed.length > 1 ? "them" : "it"} first. ${whereItStopped(repo, id, failed)}`
1462
1944
  ]);
1463
1945
  await recordOnBefore(repo, id, number, ran);
1464
- const saved = review.rounds.find((round) => round.number === number);
1946
+ const saved = (await repo.reviews.get(id)).rounds.find((round) => round.number === number);
1465
1947
  const joined = inOrderJoined(review);
1466
1948
  const place = (name) => joined.includes(name) ? joined.indexOf(name) : joined.length;
1467
1949
  const flows = recorded.map(({ flow }) => {
@@ -1559,30 +2041,32 @@ const changed = (count, since) => {
1559
2041
  return count ? `${count} ${count > 1 ? "changes" : "change"} since ${round}.` : `Nothing changed since ${round}.`;
1560
2042
  };
1561
2043
  // src/start.ts
1562
- async function start(repo, id, title, asked) {
1563
- if (!(await repo.reviews.list()).includes(id)) {
2044
+ async function start(repo$1, id, title, asked) {
2045
+ if (!(await repo$1.reviews.list()).includes(id)) {
2046
+ 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.`]);
2047
+ 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.`]);
1564
2048
  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>"`]);
1565
- await repo.reviews.start(id, title, asked);
1566
- await repo.keepOutOfGit();
1567
- await makeFlowsFolder(repo, id);
2049
+ await repo$1.reviews.start(id, title, asked);
2050
+ await repo$1.keepOutOfGit();
2051
+ await makeFlowsFolder(repo$1, id);
1568
2052
  return console.log(`Started ${id}: “${title}”.
1569
2053
 
1570
2054
  Write its flows before you change anything. They show what was asked, so write them from the task, not from the code.
1571
2055
 
1572
- ${howToWriteFlows(repo, id)}
2056
+ ${howToWriteFlows(repo$1, id)}
1573
2057
 
1574
2058
  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.`);
1575
2059
  }
1576
- const review = await repo.reviews.get(id);
2060
+ const review = await repo$1.reviews.get(id);
1577
2061
  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}`]);
1578
2062
  const last = review.rounds.at(-1);
1579
2063
  if (last?.number === 0) throw new Refused([`Before is kept already. Change the app, then record round 1: playthrough record ${id}`]);
1580
2064
  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}`]);
1581
- await flowsOf(repo, id);
1582
- await trySetup(repo, `playthrough start ${id}`);
1583
- const commit = keepBefore(repo, id);
2065
+ await flowsOf(repo$1, id);
2066
+ await trySetup(repo$1, `playthrough start ${id}`);
2067
+ const commit = keepBefore(repo$1, id);
1584
2068
  if (commit) {
1585
- await repo.reviews.saveRound(id, {
2069
+ await repo$1.reviews.saveRound(id, {
1586
2070
  number: 0,
1587
2071
  by: agent(),
1588
2072
  flows: [],
@@ -1590,7 +2074,7 @@ Then run playthrough start ${id} again. It tries the app's setup, and keeps the
1590
2074
  accounts: {},
1591
2075
  commit
1592
2076
  });
1593
- await repo.reviews.finish(id, 0);
2077
+ await repo$1.reviews.finish(id, 0);
1594
2078
  }
1595
2079
  console.log(`
1596
2080
  ${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."}
@@ -1600,26 +2084,30 @@ Now change the app. Keep each flow's name, and each step's while it does the sam
1600
2084
  // src/noted.ts
1601
2085
  async function pictureNotes(repo, review, round, notes) {
1602
2086
  const shown = /* @__PURE__ */ new Map();
1603
- const browser = await chromium.launch();
2087
+ let browser;
1604
2088
  try {
1605
2089
  for (const id of notes) {
1606
2090
  if (allNotes(review).get(id)?.note.on?.app) continue;
1607
2091
  const found = await whereSeen(repo, review, id, round).catch(() => void 0);
1608
- if (!found) continue;
2092
+ const picture = found && await repo.reviews.file(review.id, found.round, found.picture);
2093
+ if (!found || !picture) continue;
2094
+ browser ??= await chromium.launch();
1609
2095
  const page = await browser.newPage({ viewport: {
1610
2096
  width: found.width,
1611
2097
  height: found.height
1612
2098
  } });
1613
- const picture = Buffer.from(await repo.reviews.file(review.id, found.round, found.picture)).toString("base64");
1614
- await page.setContent(drawn(picture, found));
1615
- await page.locator("img").evaluate((image) => image.decode());
1616
- const name = `notes/${id}.png`;
1617
- await repo.reviews.saveFile(review.id, round, name, await page.screenshot());
1618
- await page.close();
1619
- shown.set(id, repo.show(join(dirname(repo.round(review.id, round)), "files", name)));
2099
+ try {
2100
+ await page.setContent(drawn(Buffer.from(picture).toString("base64"), found));
2101
+ await page.locator("img").evaluate((image) => image.decode());
2102
+ const name = `notes/${id}.png`;
2103
+ await repo.reviews.saveFile(review.id, round, name, await page.screenshot());
2104
+ shown.set(id, repo.show(join(dirname(repo.round(review.id, round)), "files", name)));
2105
+ } catch {} finally {
2106
+ await page.close();
2107
+ }
1620
2108
  }
1621
2109
  } catch {} finally {
1622
- await browser.close();
2110
+ await browser?.close();
1623
2111
  }
1624
2112
  return shown;
1625
2113
  }
@@ -1746,7 +2234,6 @@ const share = (fraction) => `${Math.round(fraction * 100)}%`;
1746
2234
  const HELP = readFileSync(new URL("../INSTRUCTIONS.md", import.meta.url), "utf8").trimEnd();
1747
2235
  const given = process.argv.slice(2);
1748
2236
  const [command, review, ...words] = given;
1749
- const needs = (usage) => new Refused([`That needs more: ${usage}`]);
1750
2237
  const takes = {
1751
2238
  start: [2, `playthrough start <review> "<title>" "<what was asked>"`],
1752
2239
  record: [0, "playthrough record <review>"],
@@ -1758,26 +2245,18 @@ const takes = {
1758
2245
  const limit = Object.hasOwn(takes, command ?? "") ? takes[command] : void 0;
1759
2246
  try {
1760
2247
  if (!command || command === "help" || given.some((word) => ["--help", "-h"].includes(word))) console.log(HELP);
1761
- else if (limit && words.length > limit[0]) throw new Refused([`That's more than it takes: ${limit[1]}`]);
2248
+ else if (!limit) throw new Refused([`There's no command “${command}”. Run playthrough alone for what each one does.`]);
2249
+ else if (words.length > limit[0]) throw new Refused([`That's more than it takes: ${limit[1]}`]);
2250
+ else if (!review && command !== "open") throw new Refused([`That needs more: ${limit[1]}`]);
1762
2251
  else {
1763
2252
  await putBack(repo());
1764
- if (command === "start") {
1765
- if (!review) throw needs(takes.start[1]);
1766
- await start(repo(), review, words[0], words[1]);
1767
- } else if (command === "record") {
1768
- if (!review) throw needs(takes.record[1]);
1769
- await record(repo(), review);
1770
- } else if (command === "wait") {
1771
- if (!review) throw needs(takes.wait[1]);
1772
- await wait(repo(), review);
1773
- } else if (command === "walk") {
1774
- if (!review) throw needs(takes.walk[1]);
1775
- await walk(repo(), review);
1776
- } else if (command === "open") await open(repo(), review);
1777
- else if (command === "take-over") {
1778
- if (!review) throw needs(takes["take-over"][1]);
1779
- await takeOver$1(repo(), review, words[0], words[1]);
1780
- } else throw new Refused([`There's no command “${command}”. Run playthrough alone for what each one does.`]);
2253
+ if (command !== "open" && command !== "wait") refuseWhileAside(repo());
2254
+ if (command === "open") await open(repo(), review);
2255
+ else if (command === "start") await start(repo(), review, words[0], words[1]);
2256
+ else if (command === "record") await record(repo(), review);
2257
+ else if (command === "wait") await wait(repo(), review);
2258
+ else if (command === "walk") await walk(repo(), review);
2259
+ else if (command === "take-over") await takeOver$1(repo(), review, words[0], words[1]);
1781
2260
  }
1782
2261
  } catch (error) {
1783
2262
  if (!(error instanceof Refused)) throw error;