@homie-rocks/studio 0.17.0 → 0.18.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 +16 -11
- package/bin/homie-studio.mjs +53 -9
- package/lib/build.mjs +9 -5
- package/lib/cloudflare.mjs +135 -9
- package/lib/media.mjs +185 -16
- package/lib/scaffold.mjs +15 -9
- package/lib/template-history.json +28 -0
- package/lib/upgrade.mjs +27 -0
- package/media/MEDIA.md +68 -17
- package/package.json +2 -2
- package/worker/index.mjs +80 -14
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -414,21 +414,26 @@ it says the frame rate is not a person's, and judges only seats, rooms and round
|
|
|
414
414
|
|
|
415
415
|
`deploy` creates only what Cloudflare's free Workers plan gives a new account with no
|
|
416
416
|
payment method: one Worker, one D1 database and two SQLite-backed Durable Objects
|
|
417
|
-
(`Table`, `Lobby`). It never creates
|
|
417
|
+
(`Table`, `Lobby`). It never creates R2. A brand-new Cloudflare account verifies
|
|
418
418
|
its email address before it can run a Worker; a missing `workers.dev` subdomain is
|
|
419
419
|
registered by Wrangler when Claude Code or Codex runs the deploy.
|
|
420
420
|
|
|
421
|
-
Storage for
|
|
421
|
+
Storage for songs and videos is a separate, optional step. Once a studio has it, its big media
|
|
422
|
+
lives in R2 by default:
|
|
422
423
|
|
|
423
424
|
```sh
|
|
424
|
-
npx homie-studio storage add #
|
|
425
|
-
npx homie-studio
|
|
426
|
-
npx homie-studio
|
|
425
|
+
npx homie-studio storage add # the studio's R2 bucket, bound as MEDIA
|
|
426
|
+
npx homie-studio media move --dry-run # what goes: over 1 MiB, or left out of git
|
|
427
|
+
npx homie-studio deploy # moves it (uploaded, read back, SHA-256 checked), then deploys
|
|
427
428
|
```
|
|
428
429
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
430
|
+
Each moved file keeps its address (`/videos/<slug>/<file>.mp4`), served from R2 with byte ranges,
|
|
431
|
+
HEAD, ETags and the site's own cache headers, and the site's static files stop carrying it; the file
|
|
432
|
+
stays in the studio folder, and the committed manifest names its R2 copy, so a deploy from another
|
|
433
|
+
computer or Workers Builds still has it (`media/MEDIA.md`). Cloudflare asks for a payment method on
|
|
434
|
+
the account before R2 works, even inside R2's free tier (10 GB-month of storage, 1 million writes and
|
|
435
|
+
10 million reads a month; no egress fees; then US$0.015 per GB-month), so `storage add` refuses with
|
|
436
|
+
the dashboard link until the account has R2 turned on, and creates nothing.
|
|
432
437
|
The free plan's daily limits (100,000 Worker requests; D1 5 million rows read and 100,000
|
|
433
438
|
written) reset at 00:00 UTC; past them requests fail until the reset and nothing is charged.
|
|
434
439
|
|
|
@@ -446,7 +451,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
446
451
|
| `bin/homie-studio.mjs` | The CLI. |
|
|
447
452
|
| `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
|
|
448
453
|
| `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
|
|
449
|
-
| `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations; refuses resources it did not create. In Workers Builds, migrations and deploy only. `storage add`: the optional R2 bucket. |
|
|
454
|
+
| `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations; refuses resources it did not create. In Workers Builds, migrations and deploy only. `storage add`: the optional R2 bucket; `media move`: big media into it, checked by SHA-256 (every deploy runs it). |
|
|
450
455
|
| `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it, with each one's frame rate; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. `lib/chrome.mjs`: which Chrome, and how (the GPU on a Mac, SwiftShader on Linux). |
|
|
451
456
|
| `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
|
|
452
457
|
| `lib/progress.mjs`, `lib/setup.mjs` | The progress feed (`progress …`), a build the chat opened (`progress attach`), a change as a pull request (`progress change`, `progress pr`), and `setup attach`. |
|
|
@@ -455,10 +460,10 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
455
460
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
456
461
|
| `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
|
|
457
462
|
| `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
|
|
458
|
-
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/<game>/watch` watches a live room from any player's view (NETPLAY.md section 16); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from
|
|
463
|
+
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/<game>/watch` watches a live room from any player's view (NETPLAY.md section 16); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site's files or, once moved, from the studio's R2 at the same address; a loose file from `media put` at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
|
|
459
464
|
| `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
|
|
460
465
|
| `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
|
|
461
|
-
| `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's
|
|
466
|
+
| `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's R2, or a link). `media list` shows it; `media move` puts the big ones in R2 and records each one's SHA-256; `media put` uploads a loose file to `/media/<key>`. |
|
|
462
467
|
| `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
|
|
463
468
|
| `starters/gem-rush/` | The reference multiplayer starter. |
|
|
464
469
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -32,8 +32,14 @@
|
|
|
32
32
|
* migrations and deploys: the Worker and database are the Deploy button's.
|
|
33
33
|
* homie-studio publish
|
|
34
34
|
* homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
|
|
35
|
-
* homie-studio media
|
|
36
|
-
* homie-studio media
|
|
35
|
+
* homie-studio media list (every song and video page, where each file is served from, what moves to R2)
|
|
36
|
+
* homie-studio media move [<file>...] [--dry-run] [--verify]
|
|
37
|
+
* (with storage: the big files of published songs and videos into R2, each
|
|
38
|
+
* read back and checked by SHA-256 before the site stops carrying it; the
|
|
39
|
+
* address stays the same and the file stays in this folder. With no file:
|
|
40
|
+
* everything over studio.json media.r2Over, 1 MiB unless set, or left out of
|
|
41
|
+
* git. Every deploy does it too. --verify re-checks what R2 holds)
|
|
42
|
+
* homie-studio media put <file> [--as <key>] (a loose file at /media/<key>; for a song or video, media move)
|
|
37
43
|
* homie-studio status
|
|
38
44
|
* homie-studio upgrade [--apply] [--diff]
|
|
39
45
|
* (what this version's template adds to an existing studio: AGENTS.md
|
|
@@ -136,14 +142,14 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, wr
|
|
|
136
142
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
137
143
|
import { build } from '../lib/build.mjs';
|
|
138
144
|
import { check } from '../lib/check.mjs';
|
|
139
|
-
import { ciDeploy, deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
145
|
+
import { ciDeploy, deploy, deployPlan, mediaMove, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
140
146
|
import { setupAttach } from '../lib/setup.mjs';
|
|
141
147
|
import { chromeArgs, findChrome, installChrome, noChrome } from '../lib/chrome.mjs';
|
|
142
148
|
import { publish } from '../lib/directory.mjs';
|
|
143
149
|
import { look } from '../lib/look.mjs';
|
|
144
150
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
145
151
|
import { portCheck } from '../lib/port-check.mjs';
|
|
146
|
-
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
152
|
+
import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
|
|
147
153
|
import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
|
|
148
154
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
149
155
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
@@ -164,7 +170,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
|
|
|
164
170
|
|
|
165
171
|
const argv = process.argv.slice(2);
|
|
166
172
|
const flags = new Map();
|
|
167
|
-
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai'];
|
|
173
|
+
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify'];
|
|
168
174
|
const positional = [];
|
|
169
175
|
for (let i = 0; i < argv.length; i++) {
|
|
170
176
|
const a = argv[i];
|
|
@@ -210,10 +216,31 @@ function print(result) {
|
|
|
210
216
|
case 'media list':
|
|
211
217
|
for (const kind of ['music', 'videos']) {
|
|
212
218
|
lines.push(`${kind}/manifest.json: ${result[kind].pages.length} page(s)`);
|
|
213
|
-
for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from}`).join(', ')})`);
|
|
219
|
+
for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from === 'r2' ? 'R2' : f.from}`).join(', ')})`);
|
|
214
220
|
for (const m of result[kind].skipped) lines.push(` left out: ${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
|
|
221
|
+
for (const m of result[kind].notes ?? []) lines.push(` note: ${m.item} ${m.file}: ${m.why}`);
|
|
215
222
|
}
|
|
223
|
+
if (result.moves.length) {
|
|
224
|
+
lines.push('', `Big media${result.r2 ? ' for R2' : ''} (over ${result.over === null ? '(off)' : lineOf(result.over)}, or left out of git):`);
|
|
225
|
+
for (const m of result.moves) lines.push(` ${m.path} ${m.bytes ? sizeOf(m.bytes) : ''} ${m.state === 'too-big' ? m.why : m.reason ?? ''}`);
|
|
226
|
+
}
|
|
227
|
+
if (result.storage) lines.push('', result.storage);
|
|
228
|
+
if (result.r2) lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media once these are in. ${result.cost}`);
|
|
229
|
+
break;
|
|
230
|
+
case 'media move': {
|
|
231
|
+
const verb = result.dryRun ? 'Would move' : 'Moved';
|
|
232
|
+
if (result.ok === false && result.needs === 'storage') { lines.push(result.why); break; }
|
|
233
|
+
if (result.off) { lines.push(result.why); break; }
|
|
234
|
+
const todo = result.rows.filter((r) => r.state === 'move');
|
|
235
|
+
if (result.dryRun) lines.push(todo.length ? `${verb} ${todo.length} file(s) to R2 ${result.bucket}:` : 'Nothing to move: every big file is in R2 already, or none is published.', ...todo.map((r) => ` ${r.path} ${sizeOf(r.bytes)} (${r.reason})`));
|
|
236
|
+
else lines.push(result.moved.length ? `${verb} ${result.moved.length} file(s) to R2 ${result.bucket}, each read back and checked by SHA-256:` : 'Nothing moved.', ...result.moved.map((m) => ` ${m.path} ${sizeOf(m.bytes)} -> served at ${m.url} (the same address)`));
|
|
237
|
+
for (const f of result.failed ?? []) lines.push(` NOT moved: ${f.path}: ${f.why}`);
|
|
238
|
+
for (const v of result.verified ?? []) lines.push(` ${v.ok ? 'verified' : 'MISMATCH'}: ${v.path}${v.why ? `: ${v.why}` : ''}`);
|
|
239
|
+
for (const r of result.rows.filter((x) => x.state === 'too-big' || x.state === 'unlisted' || x.state === 'missing')) lines.push(` ${r.state}: ${r.path}: ${r.why}`);
|
|
240
|
+
if (result.next) lines.push('', result.next);
|
|
241
|
+
lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media. ${result.cost}`, ...(result.warning ? [`Warning: ${result.warning}`] : []));
|
|
216
242
|
break;
|
|
243
|
+
}
|
|
217
244
|
case 'deploy':
|
|
218
245
|
if (result.ci) {
|
|
219
246
|
lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}`));
|
|
@@ -221,7 +248,8 @@ function print(result) {
|
|
|
221
248
|
}
|
|
222
249
|
lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
|
|
223
250
|
...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
|
|
224
|
-
...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
|
|
251
|
+
...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
|
|
252
|
+
...(result.media?.moved ?? []).map((m) => ` moved to R2 (checked by SHA-256): ${m.path}, still at ${m.url}`), ...(result.media?.failed ?? []).map((m) => ` not moved to R2: ${m.path}: ${m.why}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
|
|
225
253
|
result.claim ? 'The site claimed itself in the directory: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the site claims itself when the directory first reads it; publish does).');
|
|
226
254
|
break;
|
|
227
255
|
case 'deploy plan':
|
|
@@ -410,6 +438,12 @@ function print(result) {
|
|
|
410
438
|
if (!flags.has('diff')) lines.push(' (--diff shows how each differs from the template: "-" this studio\'s lines, "+" the template\'s)');
|
|
411
439
|
}
|
|
412
440
|
if (result.uncommitted && !result.applied && result.changes.length) lines.push('', `Note: ${result.uncommitted} file(s) have uncommitted changes; commit them first so the upgrade is a change of its own.`);
|
|
441
|
+
if (result.media) {
|
|
442
|
+
lines.push('', `Big media (songs and videos over ${result.media.over === null ? '(off)' : lineOf(result.media.over)}, or left out of git):`);
|
|
443
|
+
for (const r of result.media.inR2) lines.push(` = ${r.path} in R2`);
|
|
444
|
+
for (const r of result.media.big) lines.push(` > ${r.path} ${r.bytes ? sizeOf(r.bytes) : ''} ${r.state === 'too-big' ? r.why : r.reason ?? ''}`);
|
|
445
|
+
if (result.media.next) lines.push(` ${result.media.next}`);
|
|
446
|
+
}
|
|
413
447
|
if (result.next?.length) lines.push('', 'Next:', ...result.next.map((n) => ` ${n}`));
|
|
414
448
|
break;
|
|
415
449
|
}
|
|
@@ -553,11 +587,21 @@ async function main() {
|
|
|
553
587
|
return { ok: false, command: 'agents', why: `unknown: agents ${sub ?? ''} (pass, passes, revoke, brain, brain key, sit)` };
|
|
554
588
|
}
|
|
555
589
|
if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
|
|
590
|
+
if (cmd === 'media' && sub === 'move') return mediaMove(root, { paths: positional.slice(2), dryRun: flags.has('dry-run') || flags.has('plan'), verify: flags.has('verify'), log });
|
|
556
591
|
if (cmd === 'media' && sub === 'list') {
|
|
557
592
|
const studio = readStudio(root);
|
|
558
593
|
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
559
|
-
|
|
560
|
-
|
|
594
|
+
// What a deploy would serve (deploy: true), so "from r2" means the site no longer carries the file.
|
|
595
|
+
const view = (kind) => { const r = resolveMedia(root, kind, { r2, deploy: true }); return { pages: r.entries.map((e) => ({ slug: e.slug, title: e.title, kind: e.kind, files: e.files.map(({ abs, rel, ...f }) => f) })), skipped: r.skipped, notes: r.notes }; };
|
|
596
|
+
const over = r2OverOf(studio);
|
|
597
|
+
const plan = mediaPlan(root, { over });
|
|
598
|
+
const moves = plan.filter((x) => x.state === 'move' || x.state === 'too-big').map(({ abs, held, ...x }) => x);
|
|
599
|
+
const inR2 = plan.filter((x) => x.state === 'r2' || x.state === 'move').reduce((n, x) => n + (x.bytes ?? 0), 0);
|
|
600
|
+
return {
|
|
601
|
+
ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos'), over, moves, inR2,
|
|
602
|
+
storage: r2 ? (moves.length ? 'the next deploy (or media move now) puts these in R2, checked by SHA-256; their addresses stay the same' : 'every big file is in R2') : (moves.length ? 'no storage: these stay on the site (files up to 25 MiB each), and a deploy from another computer or Workers Builds leaves out any file git does not keep. homie-studio storage add (a payment method on the Cloudflare account, asked first) fixes both' : null),
|
|
603
|
+
cost: R2_COST,
|
|
604
|
+
};
|
|
561
605
|
}
|
|
562
606
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
563
607
|
}
|
package/lib/build.mjs
CHANGED
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
* site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
|
|
16
16
|
* the studio's owner (worker/stats-page.mjs), never listed or indexed
|
|
17
17
|
* site/dist/<file> site/public, as it is (fonts, a logo, hero footage)
|
|
18
|
-
* site/dist/music/..., site/dist/videos/... media files the site serves itself
|
|
18
|
+
* site/dist/music/..., site/dist/videos/... media files the site serves itself. A file the studio's R2 has (media
|
|
19
|
+
* move: the same bytes, by SHA-256) is left out of a deploy's build and
|
|
20
|
+
* served from R2 at the same address; a local build still copies it when it
|
|
21
|
+
* fits, for `dev` (whose R2 is empty). Workers Builds (WORKERS_CI=1) builds
|
|
22
|
+
* as a deploy.
|
|
19
23
|
*
|
|
20
24
|
* game.json "build" picks how a game becomes files (a ported game keeps its own shape):
|
|
21
25
|
* (absent) / { "mode": "bundle" } src/main.ts (or "entry") bundled by esbuild — new games and ES-module ports
|
|
@@ -201,7 +205,7 @@ export function seatsFor(g, net = netplayOf(g)) {
|
|
|
201
205
|
return { min: Math.min(max, minNamed ?? 1), max, asked };
|
|
202
206
|
}
|
|
203
207
|
|
|
204
|
-
export async function build(root, { only = null, log = () => {} } = {}) {
|
|
208
|
+
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1' } = {}) {
|
|
205
209
|
const require = createRequire(join(root, 'package.json'));
|
|
206
210
|
let esbuild;
|
|
207
211
|
try { esbuild = require('esbuild'); } catch { esbuild = await import('esbuild'); }
|
|
@@ -270,9 +274,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
270
274
|
// Songs and videos (music/ and videos/ manifests): rebuilt with every full build; a one-game build keeps them.
|
|
271
275
|
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
272
276
|
let media = null;
|
|
273
|
-
if (!only) media = buildMedia(root, dist, { r2, log });
|
|
277
|
+
if (!only) media = buildMedia(root, dist, { r2, deploy, log });
|
|
274
278
|
else {
|
|
275
|
-
try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
|
|
279
|
+
try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [], notes: [] }; } catch { media = { songs: [], videos: [], skipped: [], notes: [] }; }
|
|
276
280
|
}
|
|
277
281
|
// The site around the games (site/SITE.md): the look, each game's landing, posts, and what site/ overrides.
|
|
278
282
|
const theme = readTheme(root, { log });
|
|
@@ -345,7 +349,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
345
349
|
writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
|
|
346
350
|
return {
|
|
347
351
|
ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
348
|
-
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
|
|
352
|
+
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped, mediaNotes: media.notes ?? [],
|
|
349
353
|
posts: posts.map((p) => p.slug), postsSkipped, codexes, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
350
354
|
landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
|
|
351
355
|
};
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -9,19 +9,24 @@
|
|
|
9
9
|
* brand-new account with no payment method, by the names in studio.json:
|
|
10
10
|
* the Worker (pages + rooms), a D1 database, and the Table/Lobby Durable
|
|
11
11
|
* Objects (SQLite-backed, declared by the Worker).
|
|
12
|
-
* It never creates
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* It never creates R2: Cloudflare asks for a payment method before R2 works,
|
|
13
|
+
* even inside its free tier, so storage for large media is its own later step
|
|
14
|
+
* (`homie-studio storage add`) that the person agrees to. Once a studio has it,
|
|
15
|
+
* deploy binds it and moves the big songs and videos there first (mediaMove:
|
|
16
|
+
* uploaded, read back, checked by SHA-256), so the site stops carrying them and
|
|
17
|
+
* serves them from R2 at the same addresses.
|
|
15
18
|
*
|
|
16
19
|
* It REFUSES to touch a Worker, database or bucket of that name that this
|
|
17
20
|
* studio did not create (studio.json `cloudflare.created` is the record), so it
|
|
18
21
|
* can never overwrite someone's existing site. `deploy --plan` says all of this
|
|
19
22
|
* before anything happens and calls nothing.
|
|
20
23
|
*/
|
|
21
|
-
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
22
26
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
27
|
import { join } from 'node:path';
|
|
24
28
|
import { build } from './build.mjs';
|
|
29
|
+
import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File, sizeOf } from './media.mjs';
|
|
25
30
|
import { repoOf } from './repo.mjs';
|
|
26
31
|
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
27
32
|
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
@@ -101,11 +106,11 @@ export function deployPlan(root) {
|
|
|
101
106
|
{ kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
102
107
|
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
103
108
|
storage
|
|
104
|
-
? { kind: 'R2 bucket', name: storage, what: '
|
|
109
|
+
? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
|
|
105
110
|
: { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
|
|
106
111
|
],
|
|
107
112
|
cost: storage
|
|
108
|
-
?
|
|
113
|
+
? `Free on Cloudflare's Workers Free plan. ${R2_COST} Beyond the free tier Cloudflare bills the account directly.`
|
|
109
114
|
: 'Free: everything above is on Cloudflare\'s Workers Free plan, which needs no payment method. Its daily limits (100,000 Worker requests; D1 5 million rows read and 100,000 written) reset at 00:00 UTC; past them requests fail until the reset, nothing is charged.',
|
|
110
115
|
login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
|
|
111
116
|
address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
|
|
@@ -158,9 +163,22 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
158
163
|
for (const line of announced) log(line);
|
|
159
164
|
}
|
|
160
165
|
|
|
161
|
-
|
|
166
|
+
// Big media into the studio's R2 before the build (0.18.0): only with storage this studio made, so a free account
|
|
167
|
+
// with no payment method is never asked anything about R2. Each file is read back and its SHA-256 compared before
|
|
168
|
+
// the build stops carrying it; a file that did not make it stays on the site (when it fits) and is named here.
|
|
169
|
+
const storage = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
170
|
+
let media = null;
|
|
171
|
+
if (storage) {
|
|
172
|
+
media = await mediaMove(root, { auto: true, accountId, log });
|
|
173
|
+
for (const m of media.moved ?? []) step(`moved ${m.path} (${sizeOf(m.bytes)}) to R2 and checked it by SHA-256; the site serves it at the same address, ${m.url}`);
|
|
174
|
+
for (const m of media.failed ?? []) step(`not moved to R2: ${m.path}: ${m.why}`);
|
|
175
|
+
if (media.why && !media.ok) step(`big media stays on the site this time: ${media.why}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const b = await build(root, { log, deploy: true });
|
|
162
179
|
// A new studio goes live with its own Home ("First game coming soon") before it has a game.
|
|
163
180
|
step(b.catalogue.length || b.songs.length || b.videos.length ? `built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)` : 'built the home page (no game yet: "First game coming soon")');
|
|
181
|
+
for (const n of b.mediaNotes ?? []) step(`note: ${n.file}: ${n.why}`);
|
|
164
182
|
|
|
165
183
|
// The Worker: never one this studio did not make.
|
|
166
184
|
if (!created.has(`worker:${cf.worker}`)) {
|
|
@@ -189,7 +207,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
189
207
|
|
|
190
208
|
// Storage (R2) is bound only when `storage add` made the bucket. Deploy itself never creates or lists R2, so a
|
|
191
209
|
// free account with no payment method deploys the whole studio.
|
|
192
|
-
const r2 =
|
|
210
|
+
const r2 = storage;
|
|
193
211
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
194
212
|
|
|
195
213
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
@@ -237,6 +255,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
237
255
|
writeStudio(root, { ...readStudio(root), cloudflare: next });
|
|
238
256
|
return {
|
|
239
257
|
ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
258
|
+
...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
|
|
240
259
|
games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
|
|
241
260
|
songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
|
|
242
261
|
videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
|
|
@@ -400,5 +419,112 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
400
419
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root) }));
|
|
401
420
|
}
|
|
402
421
|
log(`created R2 ${bucket}`);
|
|
403
|
-
return { ok: true, command: 'storage add', bucket, account: accountId, next: ['npm run deploy (binds the bucket as MEDIA
|
|
422
|
+
return { ok: true, command: 'storage add', bucket, account: accountId, cost: R2_COST, next: ['npx --no-install homie-studio media move --dry-run (which songs and videos go to R2: over 1 MiB, or left out of git)', 'npm run deploy (binds the bucket as MEDIA, moves them, checks each by SHA-256, and serves them from R2 at the same addresses; the files stay in this folder)'] };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** One Wrangler command, run without blocking (an upload of a music video takes a while); the last of its words kept. */
|
|
426
|
+
function wranglerRun(bin, args, { cwd, env }) {
|
|
427
|
+
return new Promise((done) => {
|
|
428
|
+
const p = spawn(bin, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
429
|
+
let out = '';
|
|
430
|
+
const keep = (d) => { out = `${out}${d}`.slice(-8000); };
|
|
431
|
+
p.stdout.on('data', keep);
|
|
432
|
+
p.stderr.on('data', keep);
|
|
433
|
+
p.on('error', (e) => done({ code: 1, out: String(e.message) }));
|
|
434
|
+
p.on('close', (code) => done({ code: code ?? 1, out: out.replace(ANSI, '') }));
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Read an object back from R2 (`wrangler r2 object get --pipe`) and hash it as it streams: nothing is written to disk. */
|
|
439
|
+
function hashRemote(bin, path, { cwd, env }) {
|
|
440
|
+
return new Promise((done) => {
|
|
441
|
+
const p = spawn(bin, ['r2', 'object', 'get', path, '--remote', '--pipe'], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
442
|
+
const h = createHash('sha256');
|
|
443
|
+
let bytes = 0;
|
|
444
|
+
let err = '';
|
|
445
|
+
p.stdout.on('data', (d) => { h.update(d); bytes += d.length; });
|
|
446
|
+
p.stderr.on('data', (d) => { err = `${err}${d}`.slice(-4000); });
|
|
447
|
+
p.on('error', (e) => done({ code: 1, err: String(e.message) }));
|
|
448
|
+
p.on('close', (code) => done({ code: code ?? 1, sha256: h.digest('hex'), bytes, err: err.replace(ANSI, '') }));
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/*
|
|
453
|
+
* `homie-studio media move [<file>...] [--dry-run] [--verify]` (and every `deploy` of a studio with storage): the big
|
|
454
|
+
* public files of the studio's published songs and videos go into its own R2, at the key that is their path, so the
|
|
455
|
+
* site keeps serving them at the same address.
|
|
456
|
+
*
|
|
457
|
+
* 1. hash the file on this computer (SHA-256);
|
|
458
|
+
* 2. `wrangler r2 object put <bucket>/<path>`;
|
|
459
|
+
* 3. read it back (`wrangler r2 object get --pipe`) and hash what R2 returns;
|
|
460
|
+
* 4. only when size and hash match, record `r2: { key, sha256, bytes, at }` on the manifest's file.
|
|
461
|
+
*
|
|
462
|
+
* The next deploy's build leaves a recorded file out of the site's static assets (and serves it from R2). The file
|
|
463
|
+
* on this computer is never deleted, moved or changed. A file that fails any step is named, and stays on the site.
|
|
464
|
+
* `--verify` reads every file already recorded back from R2 and checks its hash again (it changes nothing).
|
|
465
|
+
*/
|
|
466
|
+
export async function mediaMove(root, { paths = null, dryRun = false, verify = false, auto = false, accountId = null, log = () => {} } = {}) {
|
|
467
|
+
const studio = readStudio(root);
|
|
468
|
+
const cf = { created: [], ...studio.cloudflare };
|
|
469
|
+
const bucket = cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
470
|
+
const over = r2OverOf(studio);
|
|
471
|
+
const named = Array.isArray(paths) && paths.length ? paths : null;
|
|
472
|
+
const rows = mediaPlan(root, { over, paths: named });
|
|
473
|
+
const view = (r) => ({ path: r.path, kind: r.kind, slug: r.slug, role: r.role, state: r.state, bytes: r.bytes ?? null, ...(r.reason ? { reason: r.reason } : {}), ...(r.why ? { why: r.why } : {}) });
|
|
474
|
+
const held = rows.filter((r) => r.state === 'r2');
|
|
475
|
+
const todo = rows.filter((r) => r.state === 'move');
|
|
476
|
+
const inR2 = [...held, ...todo].reduce((n, r) => n + (r.bytes ?? 0), 0);
|
|
477
|
+
const base = {
|
|
478
|
+
command: 'media move', bucket, over, rows: rows.map(view), inR2, cost: R2_COST,
|
|
479
|
+
// Over the free 10 GB-month, Cloudflare bills the account: say so with the number.
|
|
480
|
+
...(inR2 > 10e9 ? { warning: `R2 would hold ${sizeOf(inR2)} of this studio's media, over the 10 GB-month free tier: about US$${(((inR2 - 10e9) / 1e9) * 0.015).toFixed(2)} a month more, billed by Cloudflare to the account` } : {}),
|
|
481
|
+
};
|
|
482
|
+
if (!bucket) {
|
|
483
|
+
return { ok: false, ...base, needs: 'storage', why: 'this studio has no storage yet, so its media stays on the site (files up to 25 MiB each). `npx --no-install homie-studio storage add` adds an R2 bucket; Cloudflare asks for a payment method on the account before R2 works, so ask the person first. Games never need it.' };
|
|
484
|
+
}
|
|
485
|
+
if (auto && over === null && !named) return { ok: true, ...base, off: true, moved: [], failed: [], why: 'studio.json media.r2Over is false: nothing moves on its own' };
|
|
486
|
+
if (dryRun || (!todo.length && !verify)) return { ok: true, ...base, dryRun, moved: [], failed: [], next: todo.length ? 'npx --no-install homie-studio media move (then npm run deploy)' : null };
|
|
487
|
+
const bin = wranglerBin(root);
|
|
488
|
+
if (!bin) return { ok: false, ...base, why: 'Wrangler is not installed in this studio yet: run `npm install` in the studio folder first' };
|
|
489
|
+
const account = accountId || process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || null;
|
|
490
|
+
const opts = { cwd: workerDir(root), env: { ...process.env, ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
491
|
+
const moved = [];
|
|
492
|
+
const failed = [];
|
|
493
|
+
const verified = [];
|
|
494
|
+
if (verify) {
|
|
495
|
+
for (const r of held) {
|
|
496
|
+
const key = r.held?.key ?? r.path;
|
|
497
|
+
const got = await hashRemote(bin, `${bucket}/${key}`, opts);
|
|
498
|
+
const ok = got.code === 0 && got.sha256 === r.held?.sha256 && got.bytes === Number(r.held?.bytes);
|
|
499
|
+
verified.push({ path: r.path, key, ok, bytes: got.bytes, ...(ok ? {} : { why: got.code !== 0 ? `could not read it back: ${got.err.trim().split('\n').slice(-2).join(' ')}` : 'R2 returned different bytes than were recorded' }) });
|
|
500
|
+
log(`${ok ? 'verified' : 'MISMATCH'} ${r.path} in R2 ${bucket}/${key}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
for (const r of todo) {
|
|
504
|
+
const key = r.path;
|
|
505
|
+
const url = `/${key.split('/').map(encodeURIComponent).join('/')}`;
|
|
506
|
+
const sha256 = sha256File(r.abs);
|
|
507
|
+
log(`uploading ${r.path} (${sizeOf(r.bytes)}) to R2 ${bucket}/${key}`);
|
|
508
|
+
const put = await wranglerRun(bin, ['r2', 'object', 'put', `${bucket}/${key}`, '--file', r.abs, '--content-type', r.type, '--remote'], opts);
|
|
509
|
+
// Where the file is meanwhile: on the site when it fits; a bigger one keeps an older R2 copy, or waits.
|
|
510
|
+
const meanwhile = r.bytes <= MAX_ASSET_BYTES ? ' It stays on the site.' : r.held ? ' Its page keeps playing the older copy in R2.' : ' At this size the site cannot carry it, so its page waits until it is in R2.';
|
|
511
|
+
if (put.code !== 0) {
|
|
512
|
+
failed.push({ path: r.path, bytes: r.bytes, why: `${explainCloudflare(put.out, account)?.why ?? `the upload failed: ${put.out.trim().split('\n').slice(-3).join(' ')}`}${meanwhile}` });
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const got = await hashRemote(bin, `${bucket}/${key}`, opts);
|
|
516
|
+
if (got.code !== 0 || got.sha256 !== sha256 || got.bytes !== r.bytes) {
|
|
517
|
+
failed.push({ path: r.path, bytes: r.bytes, why: `${got.code !== 0 ? `uploaded, but it could not be read back to check it (${got.err.trim().split('\n').slice(-2).join(' ')}); nothing recorded.` : `R2 returned ${got.bytes} bytes with a different SHA-256 from this file's ${r.bytes}; nothing recorded.`}${meanwhile}` });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const at = new Date().toISOString();
|
|
521
|
+
const manifests = recordMove(root, r.path, { key, sha256, bytes: r.bytes, at });
|
|
522
|
+
moved.push({ path: r.path, key, bytes: r.bytes, sha256, url, manifests });
|
|
523
|
+
log(`checked ${r.path} in R2 by SHA-256 (${sha256.slice(0, 12)}…); its address stays ${url}`);
|
|
524
|
+
}
|
|
525
|
+
const bad = verified.filter((v) => !v.ok);
|
|
526
|
+
return {
|
|
527
|
+
ok: !failed.length && !bad.length, ...base, moved, failed, ...(verify ? { verified } : {}),
|
|
528
|
+
...(moved.length ? { next: 'npm run deploy: the site stops carrying these files and serves them from R2 at the same addresses. The files stay in this folder; commit the manifests (they name each file\'s R2 copy), never the media.' } : {}),
|
|
529
|
+
};
|
|
404
530
|
}
|
package/lib/media.mjs
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A studio's music/ and videos/ manifests (media/MEDIA.md): what the site shows,
|
|
3
|
-
* and where each file's bytes come from (R2
|
|
4
|
-
* into the site, or an absolute https address).
|
|
3
|
+
* and where each file's bytes come from (the studio's R2, a file in the repository
|
|
4
|
+
* copied into the site, or an absolute https address).
|
|
5
|
+
*
|
|
6
|
+
* BIG MEDIA LIVES IN R2 (0.18.0). Once a studio has storage (`homie-studio storage add`), every public file of a
|
|
7
|
+
* published song or video that is over `R2_OVER` bytes (studio.json `media.r2Over`), or that git leaves out of the
|
|
8
|
+
* repository, goes to the studio's own bucket: `homie-studio media move` (and every `deploy`) uploads it, reads it
|
|
9
|
+
* back and compares SHA-256, and only then records `r2: { key, sha256, bytes, at }` on the manifest's file. A deploy
|
|
10
|
+
* build then stops carrying that file in the site's static assets, and the Worker serves the very same address
|
|
11
|
+
* (`/<path>`) from R2, with byte ranges and the same cache headers. The local file is never deleted.
|
|
5
12
|
*/
|
|
6
|
-
import {
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
|
|
7
16
|
import { dirname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
8
17
|
|
|
9
18
|
export const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
@@ -13,6 +22,15 @@ export const KINDS = {
|
|
|
13
22
|
};
|
|
14
23
|
/** Cloudflare Workers static assets: one file may be at most 25 MiB. */
|
|
15
24
|
export const MAX_ASSET_BYTES = 25 * 1024 * 1024;
|
|
25
|
+
/**
|
|
26
|
+
* Over this many bytes a public file of a published entry is kept in the studio's R2 once it has storage (the
|
|
27
|
+
* default for studio.json `media.r2Over`; `false` there keeps every file on the site unless `media move` names it).
|
|
28
|
+
*/
|
|
29
|
+
export const R2_OVER = 1024 * 1024;
|
|
30
|
+
/** Wrangler uploads one object of at most 300 MiB (`wrangler r2 object put`). */
|
|
31
|
+
export const MAX_PUT_BYTES = 300 * 1024 * 1024;
|
|
32
|
+
/** What R2 costs, said the same way everywhere (Cloudflare's R2 pricing page, read 2026-10-01). */
|
|
33
|
+
export const R2_COST = 'R2 is on the studio\'s own Cloudflare account. Serving its files costs nothing in bandwidth (R2 has no egress fees). Storage is free up to 10 GB-month, then US$0.015 per GB-month; uploads (Class A) are free up to 1 million a month and reads (Class B) up to 10 million a month. Cloudflare asks for a payment method on the account before R2 works, even inside the free tier.';
|
|
16
34
|
const TYPES = {
|
|
17
35
|
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.wav': 'audio/wav', '.flac': 'audio/flac',
|
|
18
36
|
'.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
|
|
@@ -40,15 +58,64 @@ function inside(root, rel) {
|
|
|
40
58
|
const abs = resolve(root, normalize(rel));
|
|
41
59
|
return abs.startsWith(`${resolve(root)}${sep}`) ? abs : null;
|
|
42
60
|
}
|
|
61
|
+
/** A file's address on the site: its path in the studio, each part encoded (the same before and after it moves to R2). */
|
|
62
|
+
const siteUrlOf = (root, abs) => `/${relative(root, abs).split(sep).map(encodeURIComponent).join('/')}`;
|
|
63
|
+
const relOf = (root, abs) => relative(root, abs).split(sep).join('/');
|
|
64
|
+
const fileOf = (abs) => { try { const st = statSync(abs); return st.isFile() ? st.size : null; } catch { return null; } };
|
|
65
|
+
|
|
66
|
+
/** studio.json `media.r2Over`: bytes over which a public file goes to R2, or null (`false`: only `media move <file>`). */
|
|
67
|
+
export function r2OverOf(studio) {
|
|
68
|
+
const v = studio?.media?.r2Over;
|
|
69
|
+
if (v === false || v === 'off') return null;
|
|
70
|
+
return Number.isFinite(v) && v >= 0 ? v : R2_OVER;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Sizes for people: "45.4 MB", "1.20 GB". */
|
|
74
|
+
export const sizeOf = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`);
|
|
75
|
+
/** A size line as it was set: "1 MiB" for 1048576, else as sizeOf says it. */
|
|
76
|
+
export const lineOf = (n) => (n > 0 && n % 1048576 === 0 ? `${n / 1048576} MiB` : n > 0 && n % 1024 === 0 && n < 1048576 ? `${n / 1024} KiB` : sizeOf(n));
|
|
77
|
+
|
|
78
|
+
const hashes = new Map();
|
|
79
|
+
/** SHA-256 of a file, read in 8 MiB pieces; remembered per path, size and modified time for the life of the command. */
|
|
80
|
+
export function sha256File(abs) {
|
|
81
|
+
const st = statSync(abs);
|
|
82
|
+
const memo = `${abs}\0${st.size}\0${st.mtimeMs}`;
|
|
83
|
+
if (hashes.has(memo)) return hashes.get(memo);
|
|
84
|
+
const h = createHash('sha256');
|
|
85
|
+
const fd = openSync(abs, 'r');
|
|
86
|
+
const buf = Buffer.allocUnsafe(8 << 20);
|
|
87
|
+
try {
|
|
88
|
+
for (;;) { const n = readSync(fd, buf, 0, buf.length, null); if (!n) break; h.update(buf.subarray(0, n)); }
|
|
89
|
+
} finally { closeSync(fd); }
|
|
90
|
+
const hex = h.digest('hex');
|
|
91
|
+
hashes.set(memo, hex);
|
|
92
|
+
return hex;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The R2 record `media move` wrote on a manifest file (its key and the SHA-256 it read back from R2), or null. */
|
|
96
|
+
export function heldOf(f) {
|
|
97
|
+
const r = f?.r2;
|
|
98
|
+
return r && typeof r === 'object' && typeof r.key === 'string' && r.key && !r.key.startsWith('players/') && /^[0-9a-f]{64}$/.test(String(r.sha256 ?? '')) ? r : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether R2 has exactly this file: the size and the SHA-256 that `media move` read back from it. */
|
|
102
|
+
export function sameAsHeld(abs, held) {
|
|
103
|
+
const bytes = fileOf(abs);
|
|
104
|
+
return bytes !== null && held && Number(held.bytes) === bytes && sha256File(abs) === held.sha256;
|
|
105
|
+
}
|
|
43
106
|
|
|
44
107
|
/**
|
|
45
108
|
* Every entry of one manifest with its files resolved, and why anything is left out.
|
|
46
|
-
* `r2`: the studio has its own bucket (so a `key` is served at /media/<key
|
|
109
|
+
* `r2`: the studio has its own bucket (so a `key` is served at /media/<key>, and a file `media move` put there at its
|
|
110
|
+
* own address). `deploy`: a build that goes live (`homie-studio deploy`, or Workers Builds): a file whose exact bytes
|
|
111
|
+
* are in R2 is not copied into the site. Without it (a local `build`, `dev`), a file that is also on this computer is
|
|
112
|
+
* still copied when it fits, because `wrangler dev`'s own R2 is empty.
|
|
47
113
|
*/
|
|
48
|
-
export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
114
|
+
export function resolveMedia(root, kind, { r2 = false, deploy = false } = {}) {
|
|
49
115
|
const manifest = readManifest(root, kind);
|
|
50
116
|
const entries = [];
|
|
51
117
|
const skipped = [];
|
|
118
|
+
const notes = [];
|
|
52
119
|
const seen = new Set();
|
|
53
120
|
// The manifest's own picture (music: `cover`, videos: `poster`): an album cover for every song that has none of
|
|
54
121
|
// its own. A path in the studio (copied into the site) or an https:// address, like any file.
|
|
@@ -68,20 +135,45 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
|
68
135
|
for (const f of list) {
|
|
69
136
|
if (!f || f.public === false) continue;
|
|
70
137
|
let src = null;
|
|
138
|
+
// A key from `media put` (before 0.18.0): served at /media/<key>, as it always was.
|
|
71
139
|
if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };
|
|
140
|
+
// A file `media move` put in R2: served at its own address, from R2 once the site no longer carries it.
|
|
141
|
+
const held = heldOf(f);
|
|
142
|
+
if (!src && held && r2 && f.path) {
|
|
143
|
+
const abs = inside(root, f.path);
|
|
144
|
+
if (!abs) { skipped.push({ kind, item: item.slug, file: f.path, why: 'the path is outside the studio' }); continue; }
|
|
145
|
+
const url = siteUrlOf(root, abs);
|
|
146
|
+
const local = fileOf(abs);
|
|
147
|
+
const fromR2 = { from: 'r2', url, key: held.key, bytes: Number(held.bytes) || null };
|
|
148
|
+
if (local === null) src = fromR2;
|
|
149
|
+
else if (!deploy) src = local <= MAX_ASSET_BYTES ? { from: 'site', url, abs, rel: relative(root, abs), bytes: local } : fromR2;
|
|
150
|
+
else if (sameAsHeld(abs, held)) src = { ...fromR2, bytes: local };
|
|
151
|
+
else if (local <= MAX_ASSET_BYTES) {
|
|
152
|
+
src = { from: 'site', url, abs, rel: relative(root, abs), bytes: local };
|
|
153
|
+
notes.push({ kind, item: item.slug, file: f.path, why: 'changed since it went to R2: the site carries this copy until `homie-studio media move` uploads it again (deploy does it when the studio has storage)' });
|
|
154
|
+
} else {
|
|
155
|
+
src = fromR2;
|
|
156
|
+
notes.push({ kind, item: item.slug, file: f.path, why: `changed since it went to R2 (${held.at ?? 'earlier'}), and at ${sizeOf(local)} it is too big for the site: the page plays the copy in R2 until \`homie-studio media move ${f.path}\` uploads this one` });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
72
159
|
if (!src && f.path) {
|
|
73
160
|
const abs = inside(root, f.path);
|
|
74
161
|
if (!abs) { skipped.push({ kind, item: item.slug, file: f.path, why: 'the path is outside the studio' }); continue; }
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (bytes <= MAX_ASSET_BYTES) src = { from: 'site', url:
|
|
78
|
-
else skipped.push({ kind, item: item.slug, file: f.path, why: `${Math.round(bytes / 1048576)} MiB is over the 25 MiB the site serves itself:
|
|
79
|
-
} else if (f.key && !r2) skipped.push({ kind, item: item.slug, file: f.path, why: `the file is not on this computer and its
|
|
80
|
-
else if (!f.key && !f.url) skipped.push({ kind, item: item.slug, file: f.path, why: 'the file is not on this computer and has no R2
|
|
162
|
+
const bytes = fileOf(abs);
|
|
163
|
+
if (bytes !== null) {
|
|
164
|
+
if (bytes <= MAX_ASSET_BYTES) src = { from: 'site', url: siteUrlOf(root, abs), abs, rel: relative(root, abs), bytes };
|
|
165
|
+
else skipped.push({ kind, item: item.slug, file: f.path, why: `${Math.round(bytes / 1048576)} MiB is over the 25 MiB the site serves itself: give the studio storage (homie-studio storage add; Cloudflare asks for a payment method first, so ask the person) and the next deploy moves it to R2 (or homie-studio media move ${f.path} now), or make it smaller` });
|
|
166
|
+
} else if ((f.key || held) && !r2) skipped.push({ kind, item: item.slug, file: f.path, why: `the file is not on this computer and its copy in R2 ("${f.key ?? held.key}") needs the studio's storage (homie-studio storage add)` });
|
|
167
|
+
else if (!f.key && !held && !f.url) skipped.push({ kind, item: item.slug, file: f.path, why: 'the file is not on this computer and has no R2 copy or url' });
|
|
81
168
|
}
|
|
82
169
|
if (!src && typeof f.url === 'string' && /^https:\/\//.test(f.url)) src = { from: 'url', url: f.url };
|
|
83
170
|
if (!src) continue;
|
|
84
|
-
files.push({
|
|
171
|
+
files.push({
|
|
172
|
+
role: String(f.role ?? 'file'), name: f.name ?? null, type: f.type ?? typeOf(f.path ?? f.key ?? f.url), bytes: f.bytes ?? src.bytes ?? null, bars: f.bars ?? null, url: src.url, from: src.from,
|
|
173
|
+
// The Worker serves this address from R2 (the site does not carry it): the catalogue says which key.
|
|
174
|
+
...(src.from === 'r2' && src.key ? { r2: src.key } : {}),
|
|
175
|
+
...(src.abs ? { abs: src.abs, rel: src.rel } : {}),
|
|
176
|
+
});
|
|
85
177
|
}
|
|
86
178
|
const main = kind === 'music' ? 'audio' : 'video';
|
|
87
179
|
if (!files.some((f) => f.role === main)) { skipped.push({ kind, item: item.slug, why: `no playable "${main}" file reachable (see the file lines above)` }); continue; }
|
|
@@ -94,27 +186,104 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
|
94
186
|
files,
|
|
95
187
|
});
|
|
96
188
|
}
|
|
97
|
-
return { entries, skipped };
|
|
189
|
+
return { entries, skipped, notes };
|
|
98
190
|
}
|
|
99
191
|
|
|
100
192
|
/** Copy the site-served files into site/dist and return the catalogue rows (no local paths in them). */
|
|
101
|
-
export function buildMedia(root, dist, { r2 = false, log = () => {} } = {}) {
|
|
193
|
+
export function buildMedia(root, dist, { r2 = false, deploy = false, log = () => {} } = {}) {
|
|
102
194
|
const out = {};
|
|
103
195
|
const skipped = [];
|
|
196
|
+
const notes = [];
|
|
197
|
+
let inR2 = 0;
|
|
104
198
|
for (const kind of ['music', 'videos']) {
|
|
105
|
-
const r = resolveMedia(root, kind, { r2 });
|
|
199
|
+
const r = resolveMedia(root, kind, { r2, deploy });
|
|
106
200
|
skipped.push(...r.skipped);
|
|
201
|
+
notes.push(...r.notes);
|
|
107
202
|
out[kind] = r.entries.map((e) => ({
|
|
108
203
|
...e,
|
|
109
204
|
files: e.files.map(({ abs, rel, from, ...f }) => {
|
|
110
205
|
if (from === 'site') { const dest = join(dist, rel); mkdirSync(dirname(dest), { recursive: true }); cpSync(abs, dest); }
|
|
206
|
+
if (from === 'r2' && f.r2) inR2++;
|
|
111
207
|
return f;
|
|
112
208
|
}),
|
|
113
209
|
}));
|
|
114
210
|
if (out[kind].length) log(`${kind}: ${out[kind].map((e) => e.slug).join(', ')}`);
|
|
115
211
|
}
|
|
212
|
+
if (inR2) log(`${inR2} file(s) served from the studio's R2 at their own addresses (not in the site's files)`);
|
|
116
213
|
for (const s of skipped) log(`${s.kind}/${s.item}${s.file ? ` ${s.file}` : ''}: ${s.why}`);
|
|
117
|
-
|
|
214
|
+
for (const s of notes) log(`note: ${s.kind}/${s.item} ${s.file}: ${s.why}`);
|
|
215
|
+
return { songs: out.music, videos: out.videos, skipped, notes };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The files git leaves out of the repository (its .gitignore): a deploy from another computer, or Workers Builds, would not have them. */
|
|
219
|
+
export function gitIgnored(root, rels) {
|
|
220
|
+
if (!rels.length) return new Set();
|
|
221
|
+
const r = spawnSync('git', ['check-ignore', '--stdin'], { cwd: root, input: `${rels.join('\n')}\n`, encoding: 'utf8' });
|
|
222
|
+
if (r.status !== 0 && r.status !== 1) return new Set();
|
|
223
|
+
return new Set(String(r.stdout ?? '').split('\n').map((l) => l.trim()).filter(Boolean));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* What `media move` would do, file by file: every public file of a published song or video (or exactly the files
|
|
228
|
+
* named), with its state:
|
|
229
|
+
* move it goes to R2 (over the size, git leaves it out, or it was named; or it changed since it went there)
|
|
230
|
+
* r2 R2 already has these exact bytes (size and SHA-256 match the record)
|
|
231
|
+
* site it stays on the site (small enough, and in the repository)
|
|
232
|
+
* media it has a /media/<key> address from `media put` (left as it is)
|
|
233
|
+
* too-big over the 300 MiB one Wrangler upload takes
|
|
234
|
+
* missing not on this computer
|
|
235
|
+
* unlisted named, but in no manifest
|
|
236
|
+
*/
|
|
237
|
+
export function mediaPlan(root, { over = R2_OVER, paths = null } = {}) {
|
|
238
|
+
const want = paths ? new Set(paths.map((p) => { const abs = inside(root, relative(root, resolve(String(p)))); return abs ? relOf(root, abs) : String(p); })) : null;
|
|
239
|
+
const found = [];
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
for (const kind of ['music', 'videos']) {
|
|
242
|
+
for (const item of readManifest(root, kind).items) {
|
|
243
|
+
if (!item || typeof item !== 'object') continue;
|
|
244
|
+
for (const f of Array.isArray(item.files) ? item.files : []) {
|
|
245
|
+
if (!f || f.public === false || typeof f.path !== 'string') continue;
|
|
246
|
+
const abs = inside(root, f.path);
|
|
247
|
+
if (!abs) continue;
|
|
248
|
+
const rel = relOf(root, abs);
|
|
249
|
+
if (seen.has(rel) || (want ? !want.has(rel) : item.published !== true)) continue;
|
|
250
|
+
seen.add(rel);
|
|
251
|
+
found.push({ kind, slug: item.slug ?? null, role: String(f.role ?? 'file'), path: rel, abs, type: f.type ?? typeOf(rel), f });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const ignored = gitIgnored(root, found.map((r) => r.path));
|
|
256
|
+
const rows = found.map(({ f, ...row }) => {
|
|
257
|
+
const held = heldOf(f);
|
|
258
|
+
if (typeof f.key === 'string' && f.key) return { ...row, state: 'media', why: `in R2 at /media/${f.key} since \`media put\`; left as it is` };
|
|
259
|
+
if (row.path.startsWith('players/')) return { ...row, state: 'site', why: 'players/ is the players\' own part of the bucket' };
|
|
260
|
+
const bytes = fileOf(row.abs);
|
|
261
|
+
if (bytes === null) return { ...row, state: held ? 'r2' : 'missing', bytes: held ? Number(held.bytes) : null, ...(held ? { held } : { why: 'not on this computer' }) };
|
|
262
|
+
const reason = want ? 'named' : over !== null && bytes > over ? `over ${lineOf(over)}` : ignored.has(row.path) ? 'git leaves it out of the repository' : null;
|
|
263
|
+
if (held && sameAsHeld(row.abs, held)) return { ...row, bytes, state: 'r2', held };
|
|
264
|
+
if (!reason && !held) return { ...row, bytes, state: 'site' };
|
|
265
|
+
if (bytes > MAX_PUT_BYTES) return { ...row, bytes, state: 'too-big', why: `${sizeOf(bytes)} is over the 300 MiB one Wrangler upload takes: make a smaller delivery (the video skill's cuts are far smaller), or upload it with an S3 tool and record it by hand` };
|
|
266
|
+
return { ...row, bytes, state: 'move', reason: held ? 'changed since it went to R2' : reason, ...(held ? { held } : {}) };
|
|
267
|
+
});
|
|
268
|
+
if (want) for (const p of want) if (!seen.has(p)) rows.push({ path: p, state: 'unlisted', why: 'not a public file of any entry in music/manifest.json or videos/manifest.json: add it to an entry first' });
|
|
269
|
+
return rows;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** After R2 has the file and its SHA-256 matched: record it on every manifest file with that path. */
|
|
273
|
+
export function recordMove(root, rel, { key, sha256, bytes, at = new Date().toISOString() }) {
|
|
274
|
+
const touched = [];
|
|
275
|
+
for (const kind of ['music', 'videos']) {
|
|
276
|
+
const m = readManifest(root, kind);
|
|
277
|
+
let hit = false;
|
|
278
|
+
for (const item of m.items) {
|
|
279
|
+
for (const f of Array.isArray(item?.files) ? item.files : []) {
|
|
280
|
+
const abs = typeof f?.path === 'string' ? inside(root, f.path) : null;
|
|
281
|
+
if (abs && relOf(root, abs) === rel) { f.bytes = bytes; f.r2 = { key, sha256, bytes, at }; hit = true; }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (hit) { writeManifest(root, kind, m); touched.push(relative(root, manifestPath(root, kind))); }
|
|
285
|
+
}
|
|
286
|
+
return touched;
|
|
118
287
|
}
|
|
119
288
|
|
|
120
289
|
/** `homie-studio media put`: record an uploaded file's R2 key on the entry that names it (or a bare entry). */
|
package/lib/scaffold.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* package.json @homie-rocks/studio and wrangler, pinned
|
|
10
10
|
* games/<id>/ one folder per game (game.json, index.html, src/)
|
|
11
11
|
* music/ videos/ manifests in the repo; the big files go to the studio's own storage
|
|
12
|
-
* (R2) once it is added with `homie-studio storage add`
|
|
12
|
+
* (R2) once it is added with `homie-studio storage add` (media move, deploy)
|
|
13
13
|
* posts/ the studio's news and drops (markdown; the site's Posts, with feeds)
|
|
14
14
|
* site/ the studio's Worker (pages, rooms), its look (theme.json), its D1 migrations (no R2
|
|
15
15
|
* binding until the studio adds storage: a free Cloudflare account
|
|
@@ -105,7 +105,7 @@ directory lists its games; homie.rocks does not host them.
|
|
|
105
105
|
| Path | What it is |
|
|
106
106
|
| --- | --- |
|
|
107
107
|
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
|
|
108
|
-
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug
|
|
108
|
+
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`. Without storage the site serves each file itself (up to 25 MiB). Once the studio has storage (see below), its big media (over 1 MiB, or left out of git) lives in its own R2: every deploy uploads it, checks it by SHA-256 and serves it from R2 at the same address. Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
|
|
109
109
|
| \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
|
|
110
110
|
| \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`) and its D1 migrations. |
|
|
111
111
|
| \`wrangler.jsonc\` | The Worker's Cloudflare config (the Worker, D1, the Table and Lobby Durable Objects, and \`previews\` for branch Previews), at the root, where Cloudflare's Workers Builds reads it. A studio made before 0.10.0 keeps it in \`site/\` and deploys from a computer; every command finds either. |
|
|
@@ -145,10 +145,16 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
145
145
|
the D1 migrations and deploys (it never creates or refuses anything); on every other branch it runs
|
|
146
146
|
\`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms. The live site claims itself
|
|
147
147
|
in the homie.rocks directory the first time it is read, so nothing is stored by hand.
|
|
148
|
-
- \`npx --no-install homie-studio storage add\` — only when the studio
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
- \`npx --no-install homie-studio storage add\` — only when the studio has songs or videos: an R2
|
|
149
|
+
bucket on the studio's own Cloudflare account. Cloudflare asks for a payment method on the account
|
|
150
|
+
before R2 works, so this is a separate step the person agrees to; nothing else needs it. R2 has no
|
|
151
|
+
egress fees; storage is free up to 10 GB-month, then US$0.015 per GB-month.
|
|
152
|
+
- \`npx --no-install homie-studio media move\` (\`--dry-run\` first) — with storage, the big files of
|
|
153
|
+
published songs and videos (over 1 MiB, or left out of git; studio.json \`media.r2Over\` changes the
|
|
154
|
+
size) go to R2: each is uploaded, read back and checked by SHA-256, and only then does the site stop
|
|
155
|
+
carrying it; it keeps its address, and the file stays in this folder. Every \`npm run deploy\` does
|
|
156
|
+
it too; once a file is in R2 (the committed manifest says so), a deploy from another computer or
|
|
157
|
+
Workers Builds still serves it. \`media list\` says where each file is served from.
|
|
152
158
|
- \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
|
|
153
159
|
(or call the Homie MCP tool \`studio_publish\`).
|
|
154
160
|
- \`npx --no-install homie-studio stats\` — the studio's own numbers, for its owner: visits, Play presses,
|
|
@@ -450,7 +456,7 @@ site/.wrangler/
|
|
|
450
456
|
games/*/.port/
|
|
451
457
|
# Screenshots from check and look runs.
|
|
452
458
|
.checks/
|
|
453
|
-
# Large media lives in this studio's storage (R2, after storage add) or is served by the site; the manifests beside it are committed.
|
|
459
|
+
# Large media lives in this studio's storage (R2, after storage add: media move) or is served by the site; the manifests beside it (which name each file's R2 copy) are committed.
|
|
454
460
|
# Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
|
|
455
461
|
music/**/work/
|
|
456
462
|
videos/**/work/
|
|
@@ -504,9 +510,9 @@ export function studioFiles({ name, slug, homie, template = false }) {
|
|
|
504
510
|
'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
|
|
505
511
|
'.gitignore': GITIGNORE,
|
|
506
512
|
'games/README.md': 'One folder per game. A new studio has none (its home page says "First game coming soon"). Start one with `npx --no-install homie-studio game new <id> --from gem-rush` when the person asks for a copy of the starter, or once their game is planned.\n',
|
|
507
|
-
'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`.
|
|
513
|
+
'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. Without storage the site serves files up to 25 MiB itself. With storage (`npx --no-install homie-studio storage add`), the big ones live in the studio\'s R2: `npx --no-install homie-studio media move` and every deploy put them there, checked by SHA-256, at the same address. Never into git.\n',
|
|
508
514
|
'music/manifest.json': '{ "v": 1, "items": [] }\n',
|
|
509
|
-
'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`.
|
|
515
|
+
'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. Without storage the site serves files up to 25 MiB itself. With storage (`npx --no-install homie-studio storage add`), the big ones live in the studio\'s R2: `npx --no-install homie-studio media move` and every deploy put them there, checked by SHA-256, at the same address. Never into git.\n',
|
|
510
516
|
'videos/manifest.json': '{ "v": 1, "items": [] }\n',
|
|
511
517
|
'posts/README.md': POSTS_README,
|
|
512
518
|
'site/theme.json': themeFile(slug),
|
|
@@ -518,6 +518,34 @@
|
|
|
518
518
|
"## Beta": "9d02638207a26e73"
|
|
519
519
|
}
|
|
520
520
|
}
|
|
521
|
+
},
|
|
522
|
+
"0.18.0": {
|
|
523
|
+
"files": {
|
|
524
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
525
|
+
"README.md": "684b3cca8001673c",
|
|
526
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
527
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
528
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
529
|
+
"videos/README.md": "16babc3437d5545b",
|
|
530
|
+
"posts/README.md": "37841aae476bd540",
|
|
531
|
+
"site/README.md": "ca131cd0472464fb",
|
|
532
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
533
|
+
},
|
|
534
|
+
"sections": {
|
|
535
|
+
"AGENTS.md": {
|
|
536
|
+
"(intro)": "214aa46eb3a895d5",
|
|
537
|
+
"## Layout": "eb1942414b8af7f8",
|
|
538
|
+
"## Commands (all through the pinned CLI in node_modules)": "9c51f189412e6a9f",
|
|
539
|
+
"## The site": "ab922bb6bde23293",
|
|
540
|
+
"## Making games": "a8a893eeada33bf5",
|
|
541
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
542
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
543
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
544
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
545
|
+
"## Rules": "969318425c99f5a5",
|
|
546
|
+
"## Beta": "9d02638207a26e73"
|
|
547
|
+
}
|
|
548
|
+
}
|
|
521
549
|
}
|
|
522
550
|
}
|
|
523
551
|
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -21,11 +21,16 @@
|
|
|
21
21
|
* Without --apply it changes nothing and says what --apply would do: the person agrees first. It never touches
|
|
22
22
|
* studio.json beyond `homie.studio`, the studio's look (site/theme.json), its Worker config (deploy's), its games,
|
|
23
23
|
* media or posts.
|
|
24
|
+
*
|
|
25
|
+
* Media (0.18.0): the plan also says which songs and videos are big media (over studio.json media.r2Over, 1 MiB
|
|
26
|
+
* unless set, or left out of git), what is in the studio's R2 already, and the step that moves the rest
|
|
27
|
+
* (`media move`, which every deploy of a studio with storage also runs). Upgrade itself never moves a file.
|
|
24
28
|
*/
|
|
25
29
|
import { createHash } from 'node:crypto';
|
|
26
30
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
27
31
|
import { spawnSync } from 'node:child_process';
|
|
28
32
|
import { dirname, join } from 'node:path';
|
|
33
|
+
import { R2_COST, mediaPlan, r2OverOf, sizeOf } from './media.mjs';
|
|
29
34
|
import { studioFiles } from './scaffold.mjs';
|
|
30
35
|
import { readStudio } from './studio.mjs';
|
|
31
36
|
import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
@@ -210,14 +215,36 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
210
215
|
|
|
211
216
|
const git = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
|
|
212
217
|
const dirty = git.status === 0 ? git.stdout.split('\n').filter(Boolean).length : null;
|
|
218
|
+
const media = mediaNote(root, studio);
|
|
213
219
|
return {
|
|
214
220
|
ok: true, command: 'upgrade', applied: false, root, studio: name, from, to: STUDIO_VERSION,
|
|
215
221
|
changes, kept,
|
|
222
|
+
...(media ? { media } : {}),
|
|
216
223
|
...(dirty ? { uncommitted: dirty } : {}),
|
|
217
224
|
next: changes.length ? ['npx --no-install homie-studio upgrade --apply (after the person agrees)'] : [],
|
|
218
225
|
};
|
|
219
226
|
}
|
|
220
227
|
|
|
228
|
+
/** What the studio's big media does from this version on: what R2 holds, what would go there, and the step that does it. */
|
|
229
|
+
function mediaNote(root, studio) {
|
|
230
|
+
const cf = studio.cloudflare ?? {};
|
|
231
|
+
const storage = cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
232
|
+
const over = r2OverOf(studio);
|
|
233
|
+
let rows = [];
|
|
234
|
+
try { rows = mediaPlan(root, { over }); } catch { return null; }
|
|
235
|
+
const big = rows.filter((r) => r.state === 'move' || r.state === 'too-big').map(({ abs, held, ...r }) => r);
|
|
236
|
+
const inR2 = rows.filter((r) => r.state === 'r2').map(({ abs, held, ...r }) => r);
|
|
237
|
+
if (!big.length && !inR2.length) return null;
|
|
238
|
+
const bytes = big.reduce((n, r) => n + (r.bytes ?? 0), 0);
|
|
239
|
+
return {
|
|
240
|
+
storage, over, big, inR2,
|
|
241
|
+
next: !big.length ? null : storage
|
|
242
|
+
? `npx --no-install homie-studio media move --dry-run, then media move (the next npm run deploy also does it): ${big.length} file(s), ${sizeOf(bytes)}, go to R2 ${storage}, each read back and checked by SHA-256 before the site stops carrying it; the addresses stay the same and the files stay in this folder. Upgrade itself moves nothing.`
|
|
243
|
+
: `no storage: these stay on the site (up to 25 MiB a file), and a deploy from another computer or Workers Builds leaves out any that git does not keep. With the person's agreement: npx --no-install homie-studio storage add (Cloudflare asks for a payment method on the account first), then media move.`,
|
|
244
|
+
cost: R2_COST,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
221
248
|
function writeAtomic(path, text) {
|
|
222
249
|
mkdirSync(dirname(path), { recursive: true });
|
|
223
250
|
const tmp = `${path}.upgrade-${process.pid}`;
|
package/media/MEDIA.md
CHANGED
|
@@ -75,20 +75,71 @@ homie.rocks shows a cover only from the studio's own site, as a `.jpg`, `.png`,
|
|
|
75
75
|
|
|
76
76
|
## Where the bytes come from
|
|
77
77
|
|
|
78
|
-
A studio needs no storage for songs and videos: the site serves
|
|
79
|
-
file of a
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
78
|
+
A new studio needs no storage for songs and videos: the site serves each file itself, up to 25 MiB
|
|
79
|
+
a file (Cloudflare's limit for one static file of a Worker). Once the studio has storage, its big
|
|
80
|
+
media lives in R2 instead. For every public file of a published entry, the build picks the first
|
|
81
|
+
that applies:
|
|
82
|
+
|
|
83
|
+
1. `r2`: the file is in the studio's own R2 bucket, put there by `homie-studio media move` (or a
|
|
84
|
+
deploy). The site serves it at **the same address it always had**, `/<path>` (for example
|
|
85
|
+
`/videos/trailer/trailer.mp4`), from R2, with byte ranges (206: phones seek with them, Safari needs
|
|
86
|
+
them to play a video at all), HEAD, an ETag and `If-None-Match` (304), and the same
|
|
87
|
+
`Cache-Control` as the site's own files (`public, max-age=0, must-revalidate`). A deploy's build
|
|
88
|
+
no longer carries the file in the site's static assets.
|
|
89
|
+
2. `key`: a file `homie-studio media put` uploaded before 0.18.0 is served at `/media/<key>`, as it
|
|
90
|
+
always was (`media move` leaves it alone).
|
|
91
|
+
3. `path`: the file is in the studio folder; the build copies it into the site as `/<path>` when it
|
|
92
|
+
is 25 MiB or smaller, and the Worker answers byte ranges for it too. This is the default, and it
|
|
93
|
+
needs nothing but a deploy.
|
|
94
|
+
4. `url`: an absolute `https://` address somewhere else.
|
|
95
|
+
|
|
96
|
+
A file with none of these is left out and `media list` says so.
|
|
97
|
+
|
|
98
|
+
### Big media goes to R2, by default, once the studio has storage
|
|
99
|
+
|
|
100
|
+
`homie-studio storage add` makes the studio's R2 bucket (one step the person agrees to: Cloudflare
|
|
101
|
+
asks for a payment method on the account before R2 works, even inside its free tier). From then on,
|
|
102
|
+
every `npm run deploy` first moves the studio's big media into it, and `homie-studio media move` does
|
|
103
|
+
the same on its own:
|
|
104
|
+
|
|
105
|
+
- **What moves:** every public file of a published song or video that is over 1 MiB, or that git
|
|
106
|
+
leaves out of the repository (`*.mp4`, `*.mp3`, `*.wav`… under `music/` and `videos/`, by the
|
|
107
|
+
studio's `.gitignore`). A deploy from another computer, or Cloudflare's Workers Builds, does not
|
|
108
|
+
have git-ignored files, so a file that is only on this computer would disappear from the site;
|
|
109
|
+
in R2 it stays. `studio.json` sets another size: `"media": { "r2Over": 5242880 }` (bytes), or
|
|
110
|
+
`"media": { "r2Over": false }` to move nothing on its own (`media move <file>` still moves the file
|
|
111
|
+
it names).
|
|
112
|
+
- **How:** each file is hashed (SHA-256), uploaded with `wrangler r2 object put` to the key that is
|
|
113
|
+
its path, read back with `wrangler r2 object get --pipe` and hashed again. Only when the size and
|
|
114
|
+
the hash match does `media move` record it on the manifest's file:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{ "role": "video", "path": "videos/trailer/trailer.mp4", "type": "video/mp4", "bytes": 45431041,
|
|
118
|
+
"r2": { "key": "videos/trailer/trailer.mp4", "sha256": "8efe9b96…", "bytes": 45431041, "at": "2026-10-02T03:00:00Z" } }
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A copy that comes back different is refused, nothing is recorded, and the file stays on the site.
|
|
122
|
+
The file on this computer is never deleted, moved or changed.
|
|
123
|
+
- **After a re-cut** at the same path, the next `media move` or deploy sees a different hash and
|
|
124
|
+
uploads it again; until then a deploy's site carries the changed file itself when it fits.
|
|
125
|
+
- **`media move --dry-run`** says what would move and calls nothing; **`media move --verify`** reads
|
|
126
|
+
every file R2 already holds back and checks its hash again.
|
|
127
|
+
- **Local `dev`** still copies a moved file into the local site when it is 25 MiB or smaller,
|
|
128
|
+
because `wrangler dev`'s own R2 is empty; a bigger one does not play locally.
|
|
129
|
+
|
|
130
|
+
**What it costs (Cloudflare's R2 pricing):** serving costs nothing in bandwidth (R2 has no egress
|
|
131
|
+
fees). Storage is free up to 10 GB-month, then US$0.015 per GB-month; uploads (Class A operations)
|
|
132
|
+
are free up to 1 million a month, reads (Class B) up to 10 million a month. A video served from R2
|
|
133
|
+
costs one or two reads per byte range a player asks for. Beyond the free tier Cloudflare bills the
|
|
134
|
+
studio's own account; `media move` and `media list` say how much the studio's media adds up to, and
|
|
135
|
+
warn past 10 GB. Wrangler uploads at most 300 MiB in one go: a bigger file is refused with the
|
|
136
|
+
reason (the video skill's deliveries are far smaller).
|
|
137
|
+
|
|
138
|
+
Why not the site's own files for everything? A Worker's static files are at most 25 MiB each (and
|
|
139
|
+
20,000 per version on the free plan); every deploy of a site carrying them uploads whichever
|
|
140
|
+
changed; a byte range of a static file is cut out by the Worker after reading the whole file; and
|
|
141
|
+
files git does not keep exist only on the computer that made them.
|
|
142
|
+
|
|
143
|
+
Media never goes into git: the studio's `.gitignore` keeps `*.wav`, `*.mp3`, `*.flac`, `*.mp4`,
|
|
144
|
+
`*.mov` and `*.webm` under `music/` and `videos/` out of the repository. Commit the manifests: they
|
|
145
|
+
name each file's R2 copy, which is all a deploy from anywhere else needs.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game,
|
|
3
|
+
"version": "0.18.0",
|
|
4
|
+
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"homepage": "https://homie.rocks/studio/",
|
package/worker/index.mjs
CHANGED
|
@@ -37,7 +37,10 @@
|
|
|
37
37
|
* knocks for a Homie box, so it stops knocking (a studio site is not a box)
|
|
38
38
|
* /.well-known/homie-studio.json what the homie.rocks directory reads
|
|
39
39
|
* /games/<id>/source.json a public game's source for remixing, with its credit and licence (worker/license.mjs)
|
|
40
|
-
* /media/<key> the studio's
|
|
40
|
+
* /media/<key> a loose file in the studio's R2 (`media put`), with byte ranges
|
|
41
|
+
* /music/<slug>/<file>, /videos/<slug>/<file> a song's or video's files: from the site's own files, or, for a
|
|
42
|
+
* file `media move` put in the studio's R2 (the catalogue names its key), from R2 at the
|
|
43
|
+
* same address, with byte ranges, HEAD, ETag and the site's own cache headers
|
|
41
44
|
* /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
|
|
42
45
|
* /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
|
|
43
46
|
* /_studio/office the owner's back office: live rooms and who is in them, kick, mute, announce, close,
|
|
@@ -278,21 +281,50 @@ async function assetWithRange(request, env, url) {
|
|
|
278
281
|
return new Response(body.subarray(r.start, r.end + 1), { status: 206, headers });
|
|
279
282
|
}
|
|
280
283
|
|
|
281
|
-
/**
|
|
282
|
-
|
|
284
|
+
/** Does an If-None-Match header name this ETag (or `*`)? Weak and strong forms compare alike for a GET. */
|
|
285
|
+
function etagMatches(header, etag) {
|
|
286
|
+
if (!header || !etag) return false;
|
|
287
|
+
const bare = (t) => String(t).trim().replace(/^W\//, '');
|
|
288
|
+
return header.split(',').some((t) => t.trim() === '*' || bare(t) === bare(etag));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A file from the studio's R2 bucket, with byte ranges (206), HEAD, and If-None-Match (304).
|
|
293
|
+
* `cache`: the Cache-Control it is served with. /media/<key> keeps its hour; a song or video served at its own
|
|
294
|
+
* address (media move) gets exactly what the site's static files get, `public, max-age=0, must-revalidate` with an
|
|
295
|
+
* ETag, so moving a file to R2 changes nothing a browser or a phone sees. `type`: the catalogue's type, for an
|
|
296
|
+
* object stored without one.
|
|
297
|
+
*/
|
|
298
|
+
async function mediaObject(request, env, key, { cache = 'public, max-age=3600', type = null } = {}) {
|
|
283
299
|
const range = request.headers.get('range');
|
|
300
|
+
const inm = request.headers.get('if-none-match');
|
|
301
|
+
const headOnly = request.method === 'HEAD';
|
|
284
302
|
let head = null;
|
|
285
|
-
if (range
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
303
|
+
if (range || inm || headOnly) {
|
|
304
|
+
head = await env.MEDIA.head(key);
|
|
305
|
+
if (!head) return new Response('not found', { status: 404 });
|
|
306
|
+
}
|
|
307
|
+
const base = (obj) => {
|
|
308
|
+
const headers = new Headers();
|
|
309
|
+
obj.writeHttpMetadata?.(headers);
|
|
310
|
+
if (!headers.get('content-type') && type) headers.set('content-type', type);
|
|
311
|
+
if (obj.httpEtag) headers.set('etag', obj.httpEtag);
|
|
312
|
+
if (obj.uploaded) headers.set('last-modified', new Date(obj.uploaded).toUTCString());
|
|
313
|
+
headers.set('cache-control', cache);
|
|
314
|
+
headers.set('accept-ranges', 'bytes');
|
|
315
|
+
return headers;
|
|
316
|
+
};
|
|
317
|
+
if (head && inm && etagMatches(inm, head.httpEtag)) return new Response(null, { status: 304, headers: base(head) });
|
|
318
|
+
const r = head && range ? parseRange(range, head.size) : null;
|
|
319
|
+
if (r?.unsatisfiable) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${head.size}`, 'accept-ranges': 'bytes' } });
|
|
320
|
+
if (headOnly) {
|
|
321
|
+
const headers = base(head);
|
|
322
|
+
headers.set('content-length', String(head.size));
|
|
323
|
+
return new Response(null, { headers });
|
|
324
|
+
}
|
|
289
325
|
const obj = await env.MEDIA.get(key, r ? { range: { offset: r.start, length: r.end - r.start + 1 } } : undefined);
|
|
290
326
|
if (!obj) return new Response('not found', { status: 404 });
|
|
291
|
-
const headers =
|
|
292
|
-
obj.writeHttpMetadata(headers);
|
|
293
|
-
headers.set('etag', obj.httpEtag);
|
|
294
|
-
headers.set('cache-control', 'public, max-age=3600');
|
|
295
|
-
headers.set('accept-ranges', 'bytes');
|
|
327
|
+
const headers = base(obj);
|
|
296
328
|
if (r) {
|
|
297
329
|
headers.set('content-range', `bytes ${r.start}-${r.end}/${head.size}`);
|
|
298
330
|
headers.set('content-length', String(r.end - r.start + 1));
|
|
@@ -302,6 +334,30 @@ async function mediaObject(request, env, key) {
|
|
|
302
334
|
return new Response(obj.body, { headers });
|
|
303
335
|
}
|
|
304
336
|
|
|
337
|
+
/** The same Cache-Control the site's static files are served with (Workers static assets' default). */
|
|
338
|
+
const ASSET_CACHE = 'public, max-age=0, must-revalidate';
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A song's or video's file that `media move` put in R2, by the address it always had (/music/<slug>/x.mp3,
|
|
342
|
+
* /videos/<slug>/x.mp4): the catalogue names its key (`r2`). Only files the catalogue lists as public are served this
|
|
343
|
+
* way, never any other key of the bucket.
|
|
344
|
+
*/
|
|
345
|
+
async function heldInR2(env, getCat, path) {
|
|
346
|
+
if (!env.MEDIA) return null;
|
|
347
|
+
const cat = await getCat();
|
|
348
|
+
let want;
|
|
349
|
+
try { want = decodeURIComponent(path); } catch { return null; }
|
|
350
|
+
for (const e of [...(cat.songs ?? []), ...(cat.videos ?? [])]) {
|
|
351
|
+
for (const f of e.files ?? []) {
|
|
352
|
+
if (typeof f.r2 !== 'string' || !f.r2 || f.r2.startsWith('players/') || typeof f.url !== 'string') continue;
|
|
353
|
+
let at;
|
|
354
|
+
try { at = decodeURIComponent(f.url); } catch { continue; }
|
|
355
|
+
if (at === want) return f;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
305
361
|
/**
|
|
306
362
|
* A catalogue media row as the directory reads it (absolute addresses). A song's cover (a video's poster) is its
|
|
307
363
|
* own, else the manifest's default, else the landing still of the game it was made for (site.mjs mediaArt).
|
|
@@ -617,7 +673,12 @@ async function route(request, env, ctx) {
|
|
|
617
673
|
}
|
|
618
674
|
if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.', cat);
|
|
619
675
|
const file = await assetWithRange(request, env, url);
|
|
620
|
-
if (file.status === 404)
|
|
676
|
+
if (file.status === 404) {
|
|
677
|
+
// Not in the site's files: a song's or video's file that lives in the studio's R2 (media move), same address.
|
|
678
|
+
const held = await heldInR2(env, getCat, path);
|
|
679
|
+
if (held) return mediaObject(request, env, held.r2, { cache: ASSET_CACHE, type: held.type ?? null });
|
|
680
|
+
return notFoundPage('Nothing here.', cat);
|
|
681
|
+
}
|
|
621
682
|
return file;
|
|
622
683
|
}
|
|
623
684
|
if (path === '/' || path === '/index.html') {
|
|
@@ -928,7 +989,12 @@ async function weekOf(env, game) {
|
|
|
928
989
|
/** Everything else is a file of the built site (site/public, the games' files), with byte ranges for media. */
|
|
929
990
|
async function fallback(request, env, url, getCat) {
|
|
930
991
|
const asset = MEDIA_FILE.test(url.pathname) && request.headers.get('range') ? await assetWithRange(request, env, url) : await env.ASSETS.fetch(request);
|
|
931
|
-
if (asset.status === 404)
|
|
992
|
+
if (asset.status === 404) {
|
|
993
|
+
// A manifest file outside music/ and videos/ that media move put in R2 keeps its address too.
|
|
994
|
+
const held = (request.method === 'GET' || request.method === 'HEAD') ? await heldInR2(env, getCat, url.pathname) : null;
|
|
995
|
+
if (held) return mediaObject(request, env, held.r2, { cache: ASSET_CACHE, type: held.type ?? null });
|
|
996
|
+
return notFoundPage('Nothing here.', await getCat());
|
|
997
|
+
}
|
|
932
998
|
return asset;
|
|
933
999
|
}
|
|
934
1000
|
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.18.0';
|