@homie-rocks/studio 0.8.0 → 0.10.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/README.md +90 -8
- package/bin/homie-studio.mjs +90 -13
- package/lib/build.mjs +38 -1
- package/lib/check.mjs +109 -48
- package/lib/chrome.mjs +126 -0
- package/lib/cloudflare.mjs +85 -16
- package/lib/look.mjs +8 -12
- package/lib/media.mjs +8 -1
- package/lib/port-check.mjs +8 -13
- package/lib/progress.mjs +115 -6
- package/lib/scaffold.mjs +131 -22
- package/lib/setup.mjs +90 -0
- package/lib/site.mjs +8 -1
- package/lib/studio.mjs +11 -0
- package/lib/template-history.json +180 -0
- package/lib/upgrade.mjs +285 -0
- package/lib/version.mjs +7 -3
- package/media/MEDIA.md +13 -0
- package/package.json +1 -1
- package/site/SITE.md +51 -6
- package/worker/index.mjs +125 -25
- package/worker/pages.mjs +67 -11
- package/worker/site.mjs +64 -19
- package/worker/stats.mjs +17 -0
- package/worker/version.mjs +1 -1
package/lib/check.mjs
CHANGED
|
@@ -8,21 +8,25 @@
|
|
|
8
8
|
* `report` (optional; the open progress feed, lib/progress.mjs) hears each step as it goes green or red, gets a
|
|
9
9
|
* small picture of the computer's view, and can ask the check to stop: then the browsers close and the result
|
|
10
10
|
* says `stopped`. Without it the check is exactly what it was.
|
|
11
|
+
*
|
|
12
|
+
* UNDER LOAD (a busy computer, another project's browsers): Chrome gets 150 s to start, a page 90 s to open and a
|
|
13
|
+
* seat 90 s. A browser that stalls for 10 s is let go by the relay and comes back with its seat token; the round it
|
|
14
|
+
* was away for lists its body as a bot, so that round cannot prove anything and the check waits for the next one
|
|
15
|
+
* (up to `rounds` rounds). A person the game lists as a bot while it keeps their seat (an idle player driven by
|
|
16
|
+
* the game's autopilot) is still that person. When no round counts, the result says which seat was missing from
|
|
17
|
+
* each finished round and whether that browser lost its connection, instead of only "no round finished".
|
|
11
18
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import { tmpdir } from 'node:os';
|
|
19
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
20
|
+
import { cpus, loadavg, tmpdir } from 'node:os';
|
|
14
21
|
import { join } from 'node:path';
|
|
15
|
-
|
|
16
|
-
const CHROMES = [
|
|
17
|
-
process.env.CHROME_PATH,
|
|
18
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
19
|
-
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
20
|
-
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
21
|
-
].filter(Boolean);
|
|
22
|
+
import { SOFTWARE_GL, chromeArgs, findChrome, measureFrames, noChrome } from './chrome.mjs';
|
|
22
23
|
|
|
23
24
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
24
25
|
|
|
25
26
|
/** The steps a progress feed shows for this check, in order. */
|
|
27
|
+
/** Chrome's own start, on a loaded computer (a cold start there took over a minute). */
|
|
28
|
+
export const LAUNCH_TIMEOUT_MS = 150_000;
|
|
29
|
+
|
|
26
30
|
export const CHECK_STEPS = [
|
|
27
31
|
['computer-seated', 'A computer gets a seat'],
|
|
28
32
|
['phone-seated', 'A phone gets a seat'],
|
|
@@ -32,10 +36,29 @@ export const CHECK_STEPS = [
|
|
|
32
36
|
|
|
33
37
|
class Stopped extends Error {}
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
/**
|
|
40
|
+
* WHICH FINISHED ROUND PROVES THE TWO BROWSERS PLAYED TOGETHER. `browsers`: each `{ kind, seats, seen }`, where
|
|
41
|
+
* `seats` is every seat it held (a Set) and `seen` the rounds it saw finish after both were seated (a Map from
|
|
42
|
+
* `n:endsAt` to `{ n, endsAt, results, ms }`). A round counts when both saw it and each has a row carrying one of
|
|
43
|
+
* its seats, whatever the row's `bot` says (a game may hand an idle person's body to its autopilot and still keep
|
|
44
|
+
* their seat on the row: that is still the person). A round seen by both without one of them is a miss, kept in
|
|
45
|
+
* `missed` with who was missing and the rows, so the check can say why. Returns the round that counts, or null.
|
|
46
|
+
*/
|
|
47
|
+
export function judgeRounds(browsers, missed = new Map()) {
|
|
48
|
+
const mine = new Set(browsers.flatMap((b) => [...b.seats]));
|
|
49
|
+
for (const [key, r] of browsers[0]?.seen ?? []) {
|
|
50
|
+
if (missed.has(key) || !browsers.every((b) => b.seen.has(key))) continue;
|
|
51
|
+
const absent = browsers.filter((b) => !(r.results ?? []).some((row) => b.seats.has(row?.seat))).map((b) => b.kind);
|
|
52
|
+
if (!absent.length) return { ...r, key, overMs: browsers.map((b) => b.seen.get(key).ms), people: r.results.filter((row) => !row?.bot || mine.has(row?.seat)).length };
|
|
53
|
+
missed.set(key, { n: r.n, missing: absent, rows: (r.results ?? []).slice(0, 12).map((row) => ({ seat: row?.seat ?? null, name: row?.name ?? null, bot: Boolean(row?.bot) })) });
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
|
|
36
59
|
if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
|
|
37
|
-
const chrome =
|
|
38
|
-
if (!chrome) return { ok: false, command: 'check', why:
|
|
60
|
+
const chrome = findChrome();
|
|
61
|
+
if (!chrome) return { ok: false, command: 'check', why: noChrome() };
|
|
39
62
|
let puppeteer;
|
|
40
63
|
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
41
64
|
const base = String(url).replace(/\/+$/, '');
|
|
@@ -49,6 +72,8 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
49
72
|
{ name: 'phone', viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 } },
|
|
50
73
|
];
|
|
51
74
|
const players = [];
|
|
75
|
+
// The playing loop runs until this is false: the round is settled, or the check ends in any way (finally).
|
|
76
|
+
let playingOn = true;
|
|
52
77
|
const step = (id, state, opts = {}) => { try { report?.check?.(id, state, { label: CHECK_STEPS.find(([k]) => k === id)?.[1], ...opts }); } catch { /* a feed never breaks a check */ } };
|
|
53
78
|
const halt = () => { if (report?.stopped?.()) throw new Stopped('stopped by the person'); };
|
|
54
79
|
// The computer's view, small (480 px wide), for the feed's preview.
|
|
@@ -69,8 +94,8 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
69
94
|
const profile = mkdtempSync(join(tmpdir(), 'homie-studio-check-'));
|
|
70
95
|
profiles.push(profile);
|
|
71
96
|
const browser = await puppeteer.launch({
|
|
72
|
-
executablePath: chrome, headless: true, userDataDir: profile,
|
|
73
|
-
args: [
|
|
97
|
+
executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
|
|
98
|
+
args: [...chromeArgs(), '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
|
|
74
99
|
});
|
|
75
100
|
browsers.push(browser);
|
|
76
101
|
const page = await browser.newPage();
|
|
@@ -78,13 +103,13 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
78
103
|
const ua = await browser.userAgent();
|
|
79
104
|
await page.setUserAgent(`${ua} ${userAgentTag}`);
|
|
80
105
|
const t0 = Date.now();
|
|
81
|
-
await page.goto(play, { waitUntil: 'domcontentloaded', timeout:
|
|
106
|
+
await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
|
82
107
|
players.push({ kind: kind.name, page, t0 });
|
|
83
108
|
log(`${kind.name} opened ${play}`);
|
|
84
109
|
}
|
|
85
110
|
// Seated: the shell knows its room and the game reported a seat.
|
|
86
111
|
const seated = await Promise.all(players.map(async (p) => {
|
|
87
|
-
const deadline = Date.now() +
|
|
112
|
+
const deadline = Date.now() + 90_000;
|
|
88
113
|
while (Date.now() < deadline) {
|
|
89
114
|
const s = await p.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, players: s.facts?.counts?.players ?? null } : null; }).catch(() => null);
|
|
90
115
|
if (s?.room && s.seat !== null && s.seat !== undefined && s.role) {
|
|
@@ -95,67 +120,102 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
95
120
|
halt();
|
|
96
121
|
await sleep(250);
|
|
97
122
|
}
|
|
98
|
-
step(`${p.kind}-seated`, 'fail', { note: 'no seat within
|
|
123
|
+
step(`${p.kind}-seated`, 'fail', { note: 'no seat within 90 s' });
|
|
99
124
|
return null;
|
|
100
125
|
}));
|
|
101
|
-
if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within
|
|
126
|
+
if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 90 s', seated };
|
|
102
127
|
const sameRoom = seated[0].room === seated[1].room;
|
|
103
128
|
log(`seated: ${seated.map((s, i) => `${kinds[i].name} room ${s.room} seat ${s.seat} ${s.role}`).join('; ')}`);
|
|
104
129
|
step('same-room', sameRoom ? 'pass' : 'fail', { note: sameRoom ? `room ${seated[0].room}` : `rooms ${seated[0].room} and ${seated[1].room}` });
|
|
105
130
|
if (!sameRoom) return { ok: false, command: 'check', why: `the two browsers landed in different rooms (${seated[0].room}, ${seated[1].room})`, seated };
|
|
106
131
|
step('round', 'running');
|
|
107
|
-
// Play: the computer holds keys
|
|
132
|
+
// Play: the computer holds keys; the phone drags from the lower left (a stick) and swipes across the bottom
|
|
133
|
+
// middle (a wheel, a slider). A game that hands an untouched player to its autopilot, or lets an idle one go,
|
|
134
|
+
// sees a hand on both. It plays until the check is over, through as many rounds as that takes.
|
|
108
135
|
const playing = (async () => {
|
|
109
136
|
const keys = ['KeyD', 'KeyS', 'KeyA', 'KeyW'];
|
|
110
|
-
|
|
137
|
+
const vp = kinds[1].viewport;
|
|
138
|
+
for (let i = 0; playingOn && i < 5000; i++) {
|
|
111
139
|
const k = keys[i % keys.length];
|
|
112
140
|
await players[0].page.keyboard.down(k).catch(() => {});
|
|
113
141
|
await sleep(600);
|
|
114
142
|
await players[0].page.keyboard.up(k).catch(() => {});
|
|
115
|
-
const
|
|
116
|
-
await players[1].page.touchscreen.touchStart(
|
|
117
|
-
await players[1].page.touchscreen.touchMove(
|
|
143
|
+
const [x, y, dx, dy] = i % 3 === 2 ? [vp.width * 0.5, vp.height * 0.86, i % 2 ? 70 : -70, 0] : [vp.width * 0.2, vp.height * 0.8, i % 2 ? 60 : -60, -40];
|
|
144
|
+
await players[1].page.touchscreen.touchStart(x, y).catch(() => {});
|
|
145
|
+
await players[1].page.touchscreen.touchMove(x + dx, y + dy).catch(() => {});
|
|
118
146
|
await sleep(300);
|
|
119
147
|
await players[1].page.touchscreen.touchEnd().catch(() => {});
|
|
120
|
-
if (
|
|
148
|
+
if (report?.stopped?.()) break;
|
|
121
149
|
}
|
|
122
150
|
})();
|
|
123
151
|
if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
|
|
124
152
|
await sleep(1500);
|
|
125
153
|
await picture('Playing: computer and phone in one room');
|
|
126
|
-
//
|
|
127
|
-
//
|
|
154
|
+
// How fast each browser draws the game while it plays (reported, never judged: a VM without a GPU draws
|
|
155
|
+
// with SwiftShader, which is not a person's frame rate).
|
|
156
|
+
const frames = await Promise.all(players.map((p) => measureFrames(p.page)));
|
|
157
|
+
const software = frames.some((f) => f.renderer && SOFTWARE_GL.test(f.renderer));
|
|
158
|
+
const drawn = frames.map((f, i) => `${kinds[i].name} ${f.fps ?? '?'} fps`).join(', ');
|
|
159
|
+
log(`drawing: ${drawn}${frames[0]?.renderer ? ` (${frames[0].renderer})` : ''}`);
|
|
160
|
+
// Only a round that finishes AFTER both browsers were seated, seen by both, with BOTH of them in its results,
|
|
161
|
+
// counts: an idle room remembers its last round for a while, and that stale result proves nothing. A browser is
|
|
162
|
+
// in a round when a row carries a seat it held (a reconnect keeps its seat; a game may still mark an idle
|
|
163
|
+
// person's row as a bot, and that is still the person). A round finished without one of them is a miss: its
|
|
164
|
+
// cause is kept, and the check waits for the next round, up to `rounds` of them.
|
|
128
165
|
const bothSeated = Math.max(...players.map((p) => p.seatedAt));
|
|
129
|
-
const seats =
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
166
|
+
for (const p of players) { p.seats = new Set([p.seat]); p.reconnects = null; p.reconnects0 = null; p.closed = null; p.seen = new Map(); }
|
|
167
|
+
const t1 = Date.now();
|
|
168
|
+
const budget = roundTimeoutMs * Math.max(1, rounds);
|
|
169
|
+
const missed = new Map();
|
|
170
|
+
let hit = null;
|
|
171
|
+
while (!hit && Date.now() - t1 < budget && missed.size < Math.max(1, rounds)) {
|
|
172
|
+
for (const p of players) {
|
|
173
|
+
const s = await p.page.evaluate(() => {
|
|
174
|
+
const sh = window.__shell;
|
|
175
|
+
return sh ? { seat: sh.seat, room: sh.room, closed: sh.closed, reconnects: sh.stats?.reconnects ?? null, results: (sh.results ?? []).map((x) => ({ n: x.n, endsAt: x.endsAt, at: x.at, results: x.results })) } : null;
|
|
176
|
+
}).catch(() => null);
|
|
177
|
+
if (!s) continue;
|
|
178
|
+
if (Number.isInteger(s.seat)) p.seats.add(s.seat);
|
|
179
|
+
if (Number.isFinite(s.reconnects)) { p.reconnects0 ??= s.reconnects; p.reconnects = s.reconnects; }
|
|
180
|
+
if (s.closed) p.closed = s.closed;
|
|
181
|
+
for (const r of s.results) if (r.at > bothSeated && Array.isArray(r.results) && !p.seen.has(`${r.n}:${r.endsAt}`)) p.seen.set(`${r.n}:${r.endsAt}`, { ...r, room: s.room, ms: Date.now() - p.t0 });
|
|
141
182
|
}
|
|
142
|
-
|
|
143
|
-
|
|
183
|
+
const before = missed.size;
|
|
184
|
+
hit = judgeRounds(players, missed);
|
|
185
|
+
for (const m of [...missed.values()].slice(before)) log(`round ${m.n} finished without the ${m.missing.join(' and the ')}: waiting for the next one`);
|
|
186
|
+
if (hit) break;
|
|
187
|
+
halt();
|
|
188
|
+
await sleep(700);
|
|
189
|
+
}
|
|
190
|
+
playingOn = false;
|
|
144
191
|
await playing.catch(() => {});
|
|
145
192
|
if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-round-over.png`) }).catch(() => {});
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
193
|
+
const connection = players.map((p) => ({ browser: p.kind, seats: [...p.seats], reconnects: p.reconnects !== null && p.reconnects0 !== null ? p.reconnects - p.reconnects0 : null, closed: p.closed }));
|
|
194
|
+
const load = { perCore: +(loadavg()[0] / Math.max(1, cpus().length)).toFixed(2), average: +loadavg()[0].toFixed(1), cores: cpus().length };
|
|
195
|
+
if (!hit) {
|
|
196
|
+
const misses = [...missed.values()];
|
|
197
|
+
const dropped = connection.filter((c) => c.reconnects > 0).map((c) => `the ${c.browser} lost its connection ${c.reconnects} time${c.reconnects === 1 ? '' : 's'}`);
|
|
198
|
+
const why = misses.length
|
|
199
|
+
? `${misses.length} round${misses.length === 1 ? '' : 's'} finished, and none had both browsers in it: ${misses.map((m) => `round ${m.n} left out the ${m.missing.join(' and the ')}`).join('; ')}.${dropped.length ? ` ${dropped.join(', ')} (a browser silent for 10 s is let go by the relay and its body plays as a bot until it is back).` : ' The game listed that seat\'s body as a bot with no seat: it may hand an idle player to a bot.'}${load.perCore > 1.5 ? ` This computer is busy (load ${load.average} on ${load.cores} cores): run the check again when it is quieter.` : ''}`
|
|
200
|
+
: `no round finished within ${Math.round(budget / 1000)} s of both browsers being seated${load.perCore > 1.5 ? ` (this computer is busy: load ${load.average} on ${load.cores} cores)` : ''}`;
|
|
201
|
+
step('round', 'fail', { note: misses.length ? `${misses.length} round(s) without both` : 'no round finished in time' });
|
|
202
|
+
return { ok: false, command: 'check', why, play, room: seated[0].room, seated, missed: misses, connection, load, totalMs: Date.now() - started };
|
|
203
|
+
}
|
|
204
|
+
const humans = hit.people;
|
|
205
|
+
step('round', humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...hit.overMs), note: `round ${hit.n}: ${humans} people, ${hit.results.length - humans} bot${hit.results.length - humans === 1 ? '' : 's'}${missed.size ? ` (after ${missed.size} round(s) without both)` : ''}; ${drawn}${software ? ' (software GL: not measurable here)' : ''}` });
|
|
206
|
+
await picture(`Round ${hit.n} finished with both; the next one is on`);
|
|
151
207
|
return {
|
|
152
|
-
ok: sameRoom &&
|
|
208
|
+
ok: sameRoom && humans >= 2,
|
|
153
209
|
command: 'check',
|
|
154
210
|
play,
|
|
155
211
|
room: seated[0].room,
|
|
156
212
|
seats: seated.map((s, i) => ({ browser: kinds[i].name, seat: s.seat, role: s.role, seatedMs: s.ms })),
|
|
157
|
-
round: { n:
|
|
158
|
-
|
|
213
|
+
round: { n: hit.n, humans, bots: hit.results.length - humans, results: hit.results, overMs: hit.overMs },
|
|
214
|
+
...(missed.size ? { missed: [...missed.values()] } : {}),
|
|
215
|
+
connection,
|
|
216
|
+
frames: frames.map((f, i) => ({ browser: kinds[i].name, fps: f.fps, renderer: f.renderer })),
|
|
217
|
+
...(software ? { software: 'This machine has no GPU (WebGL is SwiftShader): seats, rooms and rounds are measured; the frame rate is not a person\'s.' } : {}),
|
|
218
|
+
why: humans >= 2 ? undefined : `the round's results list ${humans} person(s); both browsers should be in it`,
|
|
159
219
|
totalMs: Date.now() - started,
|
|
160
220
|
};
|
|
161
221
|
} catch (error) {
|
|
@@ -163,6 +223,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
|
|
|
163
223
|
for (const [id] of CHECK_STEPS) { try { if (['pending', 'running'].includes(report?.state?.(id))) step(id, 'skip', { note: 'stopped' }); } catch { /* fine */ } }
|
|
164
224
|
return { ok: false, command: 'check', stopped: true, why: 'stopped by the person', play, totalMs: Date.now() - started };
|
|
165
225
|
} finally {
|
|
226
|
+
playingOn = false;
|
|
166
227
|
for (const b of browsers) await b.close().catch(() => {});
|
|
167
228
|
for (const p of profiles) rmSync(p, { recursive: true, force: true });
|
|
168
229
|
}
|
package/lib/chrome.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH CHROME THE CHECKS RUN, AND HOW (check, port check, look).
|
|
3
|
+
*
|
|
4
|
+
* On a Mac: that Mac's Chrome, on its GPU (`--use-angle=metal`). A software renderer runs a 3D game at a
|
|
5
|
+
* few frames a second and makes it look stuck, so a Mac never falls back to one.
|
|
6
|
+
*
|
|
7
|
+
* On Linux (a Claude Code cloud session, GitHub Actions, a container) there is usually no GPU and often no
|
|
8
|
+
* Chrome. `homie-studio chrome install` puts Chrome for Testing in this user's cache (from
|
|
9
|
+
* storage.googleapis.com, which a Claude Code cloud session's default "Trusted" network reaches), and the
|
|
10
|
+
* checks render WebGL with SwiftShader (`--enable-unsafe-swiftshader`, needed since Chrome 137), headless,
|
|
11
|
+
* without the setuid sandbox when they run as root (as a container does). What they measure there is seats,
|
|
12
|
+
* rooms and rounds on the room's own clock; a frame rate on SwiftShader is not a person's, so the checks
|
|
13
|
+
* report it with the renderer's name and never judge it.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** Where `homie-studio chrome install` keeps Chrome for Testing (one copy per user, shared by every studio). */
|
|
20
|
+
export const CHROME_CACHE = join(homedir(), '.cache', 'homie-studio', 'chrome');
|
|
21
|
+
|
|
22
|
+
/** The newest build of `browser` in a @puppeteer/browsers or Playwright cache, or null. */
|
|
23
|
+
function newestIn(dir, rel) {
|
|
24
|
+
if (!existsSync(dir)) return null;
|
|
25
|
+
const builds = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
|
|
26
|
+
.sort((a, b) => b.localeCompare(a, 'en', { numeric: true }));
|
|
27
|
+
for (const b of builds) {
|
|
28
|
+
for (const r of rel) {
|
|
29
|
+
const path = join(dir, b, ...r);
|
|
30
|
+
if (existsSync(path)) return path;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Chrome for this machine: CHROME_PATH, an installed Chrome, or one in a known cache. */
|
|
37
|
+
export function findChrome() {
|
|
38
|
+
const fixed = [
|
|
39
|
+
process.env.CHROME_PATH,
|
|
40
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
41
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
42
|
+
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/opt/google/chrome/chrome',
|
|
43
|
+
].filter(Boolean);
|
|
44
|
+
const found = fixed.find((p) => existsSync(p));
|
|
45
|
+
if (found) return found;
|
|
46
|
+
const home = homedir();
|
|
47
|
+
return newestIn(join(CHROME_CACHE, 'chrome'), [['chrome-linux64', 'chrome'], ['chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'], ['chrome-mac-x64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing']])
|
|
48
|
+
?? newestIn(join(home, '.cache', 'puppeteer', 'chrome'), [['chrome-linux64', 'chrome']])
|
|
49
|
+
?? newestIn(join(home, '.cache', 'ms-playwright'), [['chrome-linux', 'chrome'], ['chrome-linux64', 'chrome']]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Linux without a GPU renders with SwiftShader: say so with every number measured there. */
|
|
53
|
+
export const SOFTWARE_GL = /swiftshader|llvmpipe|softpipe|software/i;
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* Chrome's own sandbox needs unprivileged user namespaces. Running as root (a container), or on a Linux that
|
|
57
|
+
* restricts them with AppArmor (Ubuntu 23.10 and later: a system Chrome package brings its own AppArmor profile,
|
|
58
|
+
* Chrome for Testing does not), Chrome refuses to start without --no-sandbox. The checks only open the studio's
|
|
59
|
+
* own site, in throwaway profiles.
|
|
60
|
+
*/
|
|
61
|
+
function noSandboxNeeded() {
|
|
62
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) return true;
|
|
63
|
+
try { return readFileSync('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').trim() === '1'; } catch { return false; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The launch flags for this machine (the GPU on a Mac; SwiftShader, headless and container-safe on Linux). */
|
|
67
|
+
export function chromeArgs() {
|
|
68
|
+
if (process.platform === 'darwin') return ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist'];
|
|
69
|
+
return [
|
|
70
|
+
'--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--disable-dev-shm-usage',
|
|
71
|
+
...(noSandboxNeeded() ? ['--no-sandbox'] : []),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Why there is no Chrome, and the one command that fixes it on this machine. */
|
|
76
|
+
export function noChrome() {
|
|
77
|
+
return process.platform === 'linux'
|
|
78
|
+
? 'no Chrome on this machine: run `npx --no-install homie-studio chrome install` (Chrome for Testing into .cache/homie-studio in the home folder, from storage.googleapis.com), or set CHROME_PATH'
|
|
79
|
+
: 'no Chrome found: install Google Chrome, or set CHROME_PATH';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `homie-studio chrome install`: Chrome for Testing (stable) into CHROME_CACHE, through @puppeteer/browsers (it comes
|
|
84
|
+
* with puppeteer-core). About 170 MB, once per user; a second run finds it and downloads nothing.
|
|
85
|
+
*/
|
|
86
|
+
export async function installChrome({ log = () => {}, fresh = false } = {}) {
|
|
87
|
+
// `fresh`: Chrome for Testing even when the machine has a Chrome (what a machine without one would get).
|
|
88
|
+
const existing = fresh ? null : findChrome();
|
|
89
|
+
if (existing) return { ok: true, command: 'chrome install', chrome: existing, already: true };
|
|
90
|
+
let browsers;
|
|
91
|
+
try { browsers = await import('@puppeteer/browsers'); } catch { return { ok: false, command: 'chrome install', why: '@puppeteer/browsers is missing (it comes with @homie-rocks/studio: npm install)' }; }
|
|
92
|
+
const platform = browsers.detectBrowserPlatform();
|
|
93
|
+
if (!platform) return { ok: false, command: 'chrome install', why: `Chrome for Testing has no build for ${process.platform} ${process.arch}` };
|
|
94
|
+
const buildId = await browsers.resolveBuildId(browsers.Browser.CHROME, platform, 'stable');
|
|
95
|
+
log(`downloading Chrome for Testing ${buildId} (${platform}) into ${CHROME_CACHE}`);
|
|
96
|
+
const got = await browsers.install({ browser: browsers.Browser.CHROME, buildId, cacheDir: CHROME_CACHE });
|
|
97
|
+
return { ok: true, command: 'chrome install', chrome: got.executablePath, buildId, platform };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The frame rate a page's game frame draws at, over `ms` (requestAnimationFrame in the game's own frame, or the
|
|
102
|
+
* page's), and the renderer WebGL reports there. Never throws: { fps: null } when it cannot tell.
|
|
103
|
+
*/
|
|
104
|
+
export async function measureFrames(page, ms = 2000) {
|
|
105
|
+
const frame = page.frames().find((f) => /\/__game\//.test(f.url())) ?? page.mainFrame();
|
|
106
|
+
// Runs inside the frame (puppeteer sends the function itself, so a game's CSP never blocks it).
|
|
107
|
+
const probe = (span) => new Promise((resolve) => {
|
|
108
|
+
let renderer = null;
|
|
109
|
+
try {
|
|
110
|
+
const c = document.createElement('canvas');
|
|
111
|
+
const gl = c.getContext('webgl2') || c.getContext('webgl');
|
|
112
|
+
const d = gl && gl.getExtension('WEBGL_debug_renderer_info');
|
|
113
|
+
renderer = gl ? String(d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
|
|
114
|
+
} catch { renderer = null; }
|
|
115
|
+
let n = 0;
|
|
116
|
+
const t0 = performance.now();
|
|
117
|
+
const tick = () => { n++; if (performance.now() - t0 < span) requestAnimationFrame(tick); else resolve({ fps: Math.round((n * 1000) / (performance.now() - t0)), renderer }); };
|
|
118
|
+
requestAnimationFrame(tick);
|
|
119
|
+
});
|
|
120
|
+
try {
|
|
121
|
+
return await Promise.race([
|
|
122
|
+
frame.evaluate(probe, ms),
|
|
123
|
+
new Promise((r) => setTimeout(() => r({ fps: null, renderer: null }), ms + 5000)),
|
|
124
|
+
]);
|
|
125
|
+
} catch { return { fps: null, renderer: null }; }
|
|
126
|
+
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
* before anything happens and calls nothing.
|
|
20
20
|
*/
|
|
21
21
|
import { spawnSync } from 'node:child_process';
|
|
22
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { build } from './build.mjs';
|
|
25
25
|
import { ensureLocalIgnored, ensureStatsMigration, wranglerConfig } from './scaffold.mjs';
|
|
26
|
-
import { LOCAL_STATE, isWorkersDev, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
|
|
26
|
+
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
27
27
|
|
|
28
28
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
29
29
|
|
|
@@ -36,7 +36,7 @@ export function wranglerBin(root) {
|
|
|
36
36
|
export function runner(root, env) {
|
|
37
37
|
const bin = wranglerBin(root);
|
|
38
38
|
if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
|
|
39
|
-
return (args, { cwd =
|
|
39
|
+
return (args, { cwd = workerDir(root), input } = {}) => {
|
|
40
40
|
const res = spawnSync(bin, args, {
|
|
41
41
|
cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
|
|
42
42
|
input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
|
|
@@ -186,7 +186,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
186
186
|
const r2 = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
187
187
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
188
188
|
|
|
189
|
-
writeFileSync(
|
|
189
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
190
190
|
const added = ensureStatsMigration(root);
|
|
191
191
|
if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
|
|
192
192
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
@@ -211,20 +211,14 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
211
211
|
const url = siteUrl(root);
|
|
212
212
|
step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url, ...(workersDev && workersDev !== url ? { workersDev } : {}) });
|
|
213
213
|
|
|
214
|
-
// The homie.rocks directory claim
|
|
215
|
-
//
|
|
214
|
+
// The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
|
|
215
|
+
// reading it once now is all it takes; nothing is stored by hand.
|
|
216
216
|
const directory = homie || studio.homie?.directory || 'https://homie.rocks';
|
|
217
217
|
let claim = null;
|
|
218
218
|
if (url) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (res.ok && /^[a-f0-9]{16,128}$/.test(body.claim ?? '')) claim = body.claim;
|
|
223
|
-
} catch { /* the directory is optional for a live site */ }
|
|
224
|
-
if (claim) {
|
|
225
|
-
const put = w(['d1', 'execute', cf.d1, '--remote', '--command', `INSERT OR REPLACE INTO meta (key, value) VALUES ('homie_claim', '${claim}')`]);
|
|
226
|
-
step(put.code === 0 ? 'directory claim stored' : 'could not store the directory claim (publish will say so)');
|
|
227
|
-
}
|
|
219
|
+
claim = await warmClaim(url);
|
|
220
|
+
if (claim) step('the site claimed itself in the directory');
|
|
221
|
+
else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
|
|
228
222
|
}
|
|
229
223
|
|
|
230
224
|
// studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
|
|
@@ -240,6 +234,81 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
240
234
|
};
|
|
241
235
|
}
|
|
242
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
|
|
239
|
+
* serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
|
|
240
|
+
* can take a few seconds to answer everywhere, so it asks three times.
|
|
241
|
+
*/
|
|
242
|
+
export async function warmClaim(site, { tries = 3, wait = 2500 } = {}) {
|
|
243
|
+
if (process.env.HOMIE_STUDIO_WARM === '0') return null; // the toolkit's own tests: their live site is made up
|
|
244
|
+
for (let i = 0; i < tries; i++) {
|
|
245
|
+
try {
|
|
246
|
+
const res = await fetch(`${String(site).replace(/\/+$/, '')}/.well-known/homie-studio.json`, { headers: { accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(15_000) });
|
|
247
|
+
if (res.ok) {
|
|
248
|
+
const body = await res.json();
|
|
249
|
+
if (/^[a-f0-9]{16,128}$/.test(body?.claim ?? '')) return body.claim;
|
|
250
|
+
}
|
|
251
|
+
} catch { /* not answering yet */ }
|
|
252
|
+
if (i < tries - 1) await new Promise((r) => setTimeout(r, wait));
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The Worker's name and D1 database, as the studio's wrangler.jsonc says (Cloudflare's forms may have renamed them). */
|
|
258
|
+
export function configNames(root) {
|
|
259
|
+
try {
|
|
260
|
+
const text = readFileSync(configPath(root), 'utf8');
|
|
261
|
+
const json = JSON.parse(text.replace(/^\s*\/\/.*$/gm, ''));
|
|
262
|
+
return { worker: json.name ?? null, d1: json.d1_databases?.[0]?.database_name ?? null, binding: json.d1_databases?.[0]?.binding ?? 'DB' };
|
|
263
|
+
} catch { return { worker: null, d1: null, binding: 'DB' }; }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/*
|
|
267
|
+
* `npm run deploy` IN WORKERS BUILDS (WORKERS_CI=1, or `deploy --ci`). Cloudflare's own CI has already run
|
|
268
|
+
* `npm run build`, and holds its own API token for this one account; the Worker and its database were made by the
|
|
269
|
+
* "Deploy to Cloudflare" flow or by an earlier deploy. So this only applies the D1 migrations (by binding name: the
|
|
270
|
+
* form may have renamed the database) and deploys. It creates nothing by hand and refuses nothing, and writes
|
|
271
|
+
* nothing back (a CI checkout is thrown away). On the very first deploy the database may not exist yet: Wrangler
|
|
272
|
+
* creates the one the binding names while it deploys, and the migrations run right after.
|
|
273
|
+
*/
|
|
274
|
+
export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
275
|
+
const w = runner(root, {});
|
|
276
|
+
const names = configNames(root);
|
|
277
|
+
const steps = [];
|
|
278
|
+
const step = (what) => { steps.push({ what }); log(what); };
|
|
279
|
+
const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
|
|
280
|
+
if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
|
|
281
|
+
if (ensureStatsMigration(root)) step('added the stats migration (the studio\'s own counters)');
|
|
282
|
+
const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
|
|
283
|
+
let migrate = apply();
|
|
284
|
+
const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
|
|
285
|
+
if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
286
|
+
if (!first) step('D1 migrations applied');
|
|
287
|
+
const started = Date.now();
|
|
288
|
+
const dep = w(['deploy']);
|
|
289
|
+
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
290
|
+
step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
|
|
291
|
+
if (first) {
|
|
292
|
+
migrate = apply();
|
|
293
|
+
if (migrate.code !== 0) return refuse(`D1 migrations failed after the first deploy: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
294
|
+
step('D1 migrations applied (the database was created with this first deploy)');
|
|
295
|
+
}
|
|
296
|
+
// The live address, from what Wrangler printed (a custom domain in studio.json wins).
|
|
297
|
+
const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? null;
|
|
298
|
+
let url = null;
|
|
299
|
+
try { url = siteUrl(root) ?? workersDev; } catch { url = workersDev; }
|
|
300
|
+
const claim = url ? await warmClaim(url) : null;
|
|
301
|
+
step(claim ? 'the site claimed itself in the directory' : 'no directory claim yet (the site claims itself when the directory first reads it)');
|
|
302
|
+
let games = [];
|
|
303
|
+
try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
|
|
304
|
+
return {
|
|
305
|
+
ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
|
|
306
|
+
commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
|
|
307
|
+
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })), songs: [], videos: [],
|
|
308
|
+
claim: Boolean(claim),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
243
312
|
/*
|
|
244
313
|
* `homie-studio storage add` — the studio's own storage for large media (songs,
|
|
245
314
|
* videos, big art), as an R2 bucket on its account, bound as MEDIA and served at
|
|
@@ -283,7 +352,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
283
352
|
const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
|
|
284
353
|
writeStudio(root, next);
|
|
285
354
|
if (next.cloudflare.d1Id) {
|
|
286
|
-
writeFileSync(
|
|
355
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root) }));
|
|
287
356
|
}
|
|
288
357
|
log(`created R2 ${bucket}`);
|
|
289
358
|
return { ok: true, command: 'storage add', bucket, account: accountId, next: ['npm run deploy (binds the bucket as MEDIA; the site serves it at /media/<key>)', 'npx --no-install homie-studio media put <file>'] };
|
package/lib/look.mjs
CHANGED
|
@@ -7,16 +7,11 @@
|
|
|
7
7
|
* `--shots` (default .studio/look/), which git ignores. One Chrome, closed at the end; its visits are house QA
|
|
8
8
|
* (the studio's stats leave them out).
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { tmpdir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { chromeArgs, findChrome, noChrome } from './chrome.mjs';
|
|
13
14
|
|
|
14
|
-
const CHROMES = [
|
|
15
|
-
process.env.CHROME_PATH,
|
|
16
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
17
|
-
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
18
|
-
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
19
|
-
].filter(Boolean);
|
|
20
15
|
|
|
21
16
|
export const LOOK_DEVICES = {
|
|
22
17
|
computer: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
|
@@ -29,16 +24,17 @@ const nameOf = (path) => path.replace(/^\/+|\/+$/g, '').replace(/[^a-z0-9]+/gi,
|
|
|
29
24
|
|
|
30
25
|
export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVICES), log = () => {} }) {
|
|
31
26
|
if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
|
|
32
|
-
const chrome =
|
|
33
|
-
if (!chrome) return { ok: false, command: 'look', why:
|
|
27
|
+
const chrome = findChrome();
|
|
28
|
+
if (!chrome) return { ok: false, command: 'look', why: noChrome() };
|
|
34
29
|
let puppeteer;
|
|
35
30
|
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'look', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
36
31
|
const base = String(url).replace(/\/+$/, '');
|
|
37
32
|
mkdirSync(shots, { recursive: true });
|
|
38
33
|
const profile = mkdtempSync(join(tmpdir(), 'homie-studio-look-'));
|
|
34
|
+
// 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
|
|
39
35
|
const browser = await puppeteer.launch({
|
|
40
|
-
executablePath: chrome, headless: true, userDataDir: profile,
|
|
41
|
-
args: [
|
|
36
|
+
executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 180_000,
|
|
37
|
+
args: [...chromeArgs(), '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
|
|
42
38
|
});
|
|
43
39
|
const rows = [];
|
|
44
40
|
try {
|
|
@@ -51,7 +47,7 @@ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVIC
|
|
|
51
47
|
await page.setViewport(LOOK_DEVICES[device]);
|
|
52
48
|
await page.setUserAgent(`${await browser.userAgent()} homie-studio-check look`);
|
|
53
49
|
const t0 = Date.now();
|
|
54
|
-
const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout:
|
|
50
|
+
const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 90_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
|
|
55
51
|
const ms = Date.now() - t0;
|
|
56
52
|
await sleep(900);
|
|
57
53
|
const fold = await page.evaluate(() => {
|
package/lib/media.mjs
CHANGED
|
@@ -50,6 +50,11 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
|
50
50
|
const entries = [];
|
|
51
51
|
const skipped = [];
|
|
52
52
|
const seen = new Set();
|
|
53
|
+
// The manifest's own picture (music: `cover`, videos: `poster`): an album cover for every song that has none of
|
|
54
|
+
// its own. A path in the studio (copied into the site) or an https:// address, like any file.
|
|
55
|
+
const artRole = kind === 'music' ? 'cover' : 'poster';
|
|
56
|
+
const shared = manifest[artRole];
|
|
57
|
+
const sharedFile = typeof shared === 'string' ? (/^https:\/\//.test(shared) ? { url: shared } : { path: shared }) : shared && typeof shared === 'object' ? shared : null;
|
|
53
58
|
for (const item of manifest.items) {
|
|
54
59
|
if (!item || typeof item !== 'object') continue;
|
|
55
60
|
const label = item.slug ?? item.key ?? item.file ?? '(unnamed)';
|
|
@@ -58,7 +63,9 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
|
58
63
|
seen.add(item.slug);
|
|
59
64
|
if (item.published !== true) { skipped.push({ kind, item: label, why: 'not published yet ("published": true shows it)' }); continue; }
|
|
60
65
|
const files = [];
|
|
61
|
-
|
|
66
|
+
const own = Array.isArray(item.files) ? item.files : [];
|
|
67
|
+
const list = sharedFile && !own.some((f) => f?.role === artRole) ? [...own, { ...sharedFile, role: artRole }] : own;
|
|
68
|
+
for (const f of list) {
|
|
62
69
|
if (!f || f.public === false) continue;
|
|
63
70
|
let src = null;
|
|
64
71
|
if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };
|