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.
- package/INSTRUCTIONS.md +14 -5
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/{flows-BkG0TgHA.js → flows-DDB6AdQ-.js} +76 -29
- package/src/playthrough.js +539 -78
- package/src/playwright.config.js +1 -1
- package/src/{reporter-DE8aueAO.js → reporter-Dr7dWwSf.js} +14 -5
- package/src/reporter.js +1 -1
- package/src/test.js +2 -2
- package/types/cli/src/flows.d.ts +1 -0
- package/types/cli/src/repo.d.ts +4 -0
- package/types/cli/src/reporter.d.ts +0 -1
- package/viewer/assets/index-DVlUrKEE.js +194 -0
- package/viewer/assets/index-DhzHaV6E.css +2 -0
- package/viewer/index.html +2 -2
- package/viewer/assets/index-BkU2wK28.js +0 -193
- package/viewer/assets/index-gTfnUrcK.css +0 -2
package/src/playthrough.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env -S node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON
|
|
2
|
-
import { A as
|
|
3
|
-
import {
|
|
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 =
|
|
233
|
-
if (!aside ||
|
|
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",
|
|
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$
|
|
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$
|
|
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
|
-
|
|
589
|
-
moments
|
|
590
|
-
|
|
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
|
-
|
|
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(
|
|
1120
|
-
|
|
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(
|
|
1124
|
-
const port = await listen(createServer((request, response) => {
|
|
1125
|
-
answer(
|
|
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
|
-
|
|
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)
|
|
1143
|
-
id
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
1446
|
-
replies:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
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
|
|
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
|
|
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
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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;
|