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