playthrough 0.4.3 → 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
8
  import { createHash, randomBytes } from "node:crypto";
10
- import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
11
- import { dirname, extname, join, normalize, resolve, sep } from "node:path";
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();
@@ -575,6 +593,7 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
575
593
  const outside = ahead && await reachesOutside(repo, id, flow.name);
576
594
  if (outside) throw new Refused([`${quote(flow.name)} reaches outside the app (${outside}), so it's run only when it's taken over.`]);
577
595
  const app = await repo.app();
596
+ refuseWhileAside(repo);
578
597
  await keepRunning(app, repo.root);
579
598
  const began = Date.now();
580
599
  const { handed, made } = await runTo(repo, id, {
@@ -585,10 +604,14 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
585
604
  },
586
605
  ...did?.length && { did }
587
606
  });
588
- passing ??= {
589
- moments: /* @__PURE__ */ new Map(),
590
- port: passOn(app, (moment) => passing.moments.get(moment), network)
591
- };
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);
592
615
  const moment = randomBytes(12).toString("base64url");
593
616
  passing.moments.set(moment, did?.length ? {
594
617
  ...handed,
@@ -611,6 +634,7 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
611
634
  }
612
635
  const ready = /* @__PURE__ */ new Map();
613
636
  async function keepUp(repo, id, flow, step, actions) {
637
+ refuseWhileAside(repo);
614
638
  await keepRunning(await repo.app(), repo.root);
615
639
  await follow(repo, id, {
616
640
  flow,
@@ -620,7 +644,7 @@ async function keepUp(repo, id, flow, step, actions) {
620
644
  }
621
645
  });
622
646
  }
623
- let passing;
647
+ const passings = /* @__PURE__ */ new Map();
624
648
  async function recorded(repo, id) {
625
649
  const review = await repo.reviews.get(id);
626
650
  const round = review.rounds.findLast((round) => round.number > 0) ?? review.rounds.at(-1);
@@ -629,7 +653,7 @@ async function recorded(repo, id) {
629
653
  }
630
654
  function passOn(app, handed, network) {
631
655
  const target = new URL(app.url);
632
- const server = createServer((request, response) => {
656
+ const server = createServer$1((request, response) => {
633
657
  if (!named(request)) return turnAway(response);
634
658
  const [first, moment] = new URL(request.url ?? "/", "http://playthrough").pathname.split("/").filter(Boolean);
635
659
  if (first === "take-over") return handOver(handed(moment ?? ""), response, !fromThisMachine(request));
@@ -699,7 +723,6 @@ function asIfDirect(request, target) {
699
723
  else delete headers.cookie;
700
724
  return headers;
701
725
  }
702
- const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
703
726
  let RECORDER;
704
727
  const SAYS = `<script>(() => {
705
728
  if (window.parent === window) return;
@@ -1097,6 +1120,394 @@ function notAnswering(response, app) {
1097
1120
  response.end(`The app isn't answering at ${app.url}. Start it as playthrough/app.json says (${app.start}), then reload.`);
1098
1121
  }
1099
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
+ }
1100
1511
  // src/open.ts
1101
1512
  const PORT = 5790;
1102
1513
  const TYPES = {
@@ -1116,13 +1527,16 @@ const TYPES = {
1116
1527
  ".ttf": "font/ttf",
1117
1528
  ".otf": "font/otf"
1118
1529
  };
1119
- async function open(repo, id) {
1120
- 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 ?? ""}`);
1121
1535
  const page = fileURLToPath(new URL("../viewer", import.meta.url));
1122
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."]);
1123
- const you = whoYouAre(repo.root);
1124
- const port = await listen(createServer((request, response) => {
1125
- 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) => {
1126
1540
  if (response.headersSent) return response.end();
1127
1541
  const problems = error instanceof Refused ? error.problems : [String(error?.message ?? error)];
1128
1542
  json(response, error instanceof Refused ? 400 : 500, { problems });
@@ -1130,22 +1544,42 @@ async function open(repo, id) {
1130
1544
  }), PORT);
1131
1545
  console.log(`Playthrough is open at http://localhost:${port}/${id ?? ""}`);
1132
1546
  }
1133
- async function answer(repo, page, you, request, response) {
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) {
1134
1567
  const stranger = fromElsewhere(request);
1135
1568
  if (stranger) return json(response, 403, { problems: [stranger] });
1136
- const reviews = repo.reviews;
1137
1569
  const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
1138
1570
  const [api, collection, id, ...rest] = path.split("/").filter(Boolean);
1139
1571
  if (api !== "api") return serve(page, path, response);
1140
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 });
1141
1574
  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
- })));
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
+ }
1148
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;
1149
1583
  if (request.method === "GET" && rest[0] === "live") return live(repo, id, request, response);
1150
1584
  if (request.method === "GET" && rest[0] === "rounds" && rest[2] === "files" && rest[3]) {
1151
1585
  const name = rest.slice(3).join("/");
@@ -1158,6 +1592,7 @@ async function answer(repo, page, you, request, response) {
1158
1592
  });
1159
1593
  return response.end(bytes);
1160
1594
  }
1595
+ if (request.method === "POST" && rest[0] === "video") return saveVideo(repo, id, request, response);
1161
1596
  if (request.method === "POST" && rest[0] === "take-over") return takeOver(repo, id, request, response);
1162
1597
  if (request.method === "POST" && rest[0] === "follow") return followAlong(repo, id, request, response);
1163
1598
  if (request.method === "POST" && rest[0] === "ready-in") return readyInFor(repo, id, request, response);
@@ -1239,6 +1674,34 @@ function paced(events) {
1239
1674
  });
1240
1675
  }
1241
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
+ }
1242
1705
  async function readyInFor(repo, id, request, response) {
1243
1706
  const { flow, step, actions } = await body(request);
1244
1707
  json(response, 200, { in: await readyIn(repo, id, {
@@ -1433,7 +1896,8 @@ async function walk(repo, id) {
1433
1896
  const saved = review.rounds.find((round) => round.number === number);
1434
1897
  const walked = await walkForks(repo, review, number, saved?.flows ?? review.rounds.findLast((round) => round.number > 0)?.flows ?? []);
1435
1898
  if (!walked.length) return console.log(`Nothing to walk: round ${number} answers no note made in the app.`);
1436
- if (saved) {
1899
+ const now = saved && (await repo.reviews.get(id)).rounds.find((round) => round.number === number);
1900
+ if (now) {
1437
1901
  const placed = (reply) => {
1438
1902
  const on = walked.find((one) => one.note === reply.note)?.on;
1439
1903
  return on ? {
@@ -1442,8 +1906,8 @@ async function walk(repo, id) {
1442
1906
  } : reply;
1443
1907
  };
1444
1908
  await repo.reviews.saveRound(id, {
1445
- ...saved,
1446
- replies: saved.replies.map(placed)
1909
+ ...now,
1910
+ replies: now.replies.map(placed)
1447
1911
  });
1448
1912
  }
1449
1913
  console.log(`\n${walked.map(saidOf).join("\n")}`);
@@ -1461,7 +1925,7 @@ async function record(repo, id) {
1461
1925
  `Fix ${failed.length > 1 ? "them" : "it"} and record again, which runs ${failed.length > 1 ? "them" : "it"} first. ${whereItStopped(repo, id, failed)}`
1462
1926
  ]);
1463
1927
  await recordOnBefore(repo, id, number, ran);
1464
- const saved = review.rounds.find((round) => round.number === number);
1928
+ const saved = (await repo.reviews.get(id)).rounds.find((round) => round.number === number);
1465
1929
  const joined = inOrderJoined(review);
1466
1930
  const place = (name) => joined.includes(name) ? joined.indexOf(name) : joined.length;
1467
1931
  const flows = recorded.map(({ flow }) => {
@@ -1559,30 +2023,32 @@ const changed = (count, since) => {
1559
2023
  return count ? `${count} ${count > 1 ? "changes" : "change"} since ${round}.` : `Nothing changed since ${round}.`;
1560
2024
  };
1561
2025
  // src/start.ts
1562
- async function start(repo, id, title, asked) {
1563
- 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.`]);
1564
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>"`]);
1565
- await repo.reviews.start(id, title, asked);
1566
- await repo.keepOutOfGit();
1567
- await makeFlowsFolder(repo, id);
2031
+ await repo$1.reviews.start(id, title, asked);
2032
+ await repo$1.keepOutOfGit();
2033
+ await makeFlowsFolder(repo$1, id);
1568
2034
  return console.log(`Started ${id}: “${title}”.
1569
2035
 
1570
2036
  Write its flows before you change anything. They show what was asked, so write them from the task, not from the code.
1571
2037
 
1572
- ${howToWriteFlows(repo, id)}
2038
+ ${howToWriteFlows(repo$1, id)}
1573
2039
 
1574
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.`);
1575
2041
  }
1576
- const review = await repo.reviews.get(id);
2042
+ const review = await repo$1.reviews.get(id);
1577
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}`]);
1578
2044
  const last = review.rounds.at(-1);
1579
2045
  if (last?.number === 0) throw new Refused([`Before is kept already. Change the app, then record round 1: playthrough record ${id}`]);
1580
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}`]);
1581
- await flowsOf(repo, id);
1582
- await trySetup(repo, `playthrough start ${id}`);
1583
- 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);
1584
2050
  if (commit) {
1585
- await repo.reviews.saveRound(id, {
2051
+ await repo$1.reviews.saveRound(id, {
1586
2052
  number: 0,
1587
2053
  by: agent(),
1588
2054
  flows: [],
@@ -1590,7 +2056,7 @@ Then run playthrough start ${id} again. It tries the app's setup, and keeps the
1590
2056
  accounts: {},
1591
2057
  commit
1592
2058
  });
1593
- await repo.reviews.finish(id, 0);
2059
+ await repo$1.reviews.finish(id, 0);
1594
2060
  }
1595
2061
  console.log(`
1596
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."}
@@ -1600,26 +2066,30 @@ Now change the app. Keep each flow's name, and each step's while it does the sam
1600
2066
  // src/noted.ts
1601
2067
  async function pictureNotes(repo, review, round, notes) {
1602
2068
  const shown = /* @__PURE__ */ new Map();
1603
- const browser = await chromium.launch();
2069
+ let browser;
1604
2070
  try {
1605
2071
  for (const id of notes) {
1606
2072
  if (allNotes(review).get(id)?.note.on?.app) continue;
1607
2073
  const found = await whereSeen(repo, review, id, round).catch(() => void 0);
1608
- 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();
1609
2077
  const page = await browser.newPage({ viewport: {
1610
2078
  width: found.width,
1611
2079
  height: found.height
1612
2080
  } });
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)));
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
+ }
1620
2090
  }
1621
2091
  } catch {} finally {
1622
- await browser.close();
2092
+ await browser?.close();
1623
2093
  }
1624
2094
  return shown;
1625
2095
  }
@@ -1746,7 +2216,6 @@ const share = (fraction) => `${Math.round(fraction * 100)}%`;
1746
2216
  const HELP = readFileSync(new URL("../INSTRUCTIONS.md", import.meta.url), "utf8").trimEnd();
1747
2217
  const given = process.argv.slice(2);
1748
2218
  const [command, review, ...words] = given;
1749
- const needs = (usage) => new Refused([`That needs more: ${usage}`]);
1750
2219
  const takes = {
1751
2220
  start: [2, `playthrough start <review> "<title>" "<what was asked>"`],
1752
2221
  record: [0, "playthrough record <review>"],
@@ -1758,26 +2227,18 @@ const takes = {
1758
2227
  const limit = Object.hasOwn(takes, command ?? "") ? takes[command] : void 0;
1759
2228
  try {
1760
2229
  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]}`]);
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]}`]);
1762
2233
  else {
1763
2234
  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.`]);
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]);
1781
2242
  }
1782
2243
  } catch (error) {
1783
2244
  if (!(error instanceof Refused)) throw error;