@homie-rocks/studio 0.17.0 → 0.18.1

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.
@@ -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 or binds R2: Cloudflare asks for a payment method before R2
13
- * works, even inside its free tier, so storage for large media is its own later
14
- * step (`homie-studio storage add`) that the person agrees to.
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: 'large media for /media/<key> (added with storage add)', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
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
- ? 'Free on Cloudflare\'s Workers Free plan. R2 storage is free up to 10 GB-month; beyond that Cloudflare bills the account directly.'
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
- const b = await build(root, { log });
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 = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
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; the site serves it at /media/<key>)', 'npx --no-install homie-studio media put <file>'] };
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/doctor.mjs CHANGED
@@ -8,6 +8,9 @@
8
8
  * Homie tools are there: --connector yes|no)
9
9
  * Cloudflare to go online: signed in, and the account's email verified (Cloudflare checks that at the
10
10
  * first deploy; a deploy that went through proves it)
11
+ * Workers AI for AI guides only when a server's guides think with Workers AI: the model they use
12
+ * (HOMIE_BRAIN_MODEL, else the default) answers on the studio's account, not paid-only
13
+ * or retired (lib/brain-probe.mjs: one tiny call, about 0.1 of the free daily neurons)
11
14
  * Chrome for checks the two-browser check, the look pictures and playtests
12
15
  * ffmpeg recommended sound effects and the theme, trailers and hero footage
13
16
  * GitHub optional a private backup, publishing by pull request, building from the Claude app
@@ -17,12 +20,14 @@
17
20
  *
18
21
  * It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
19
22
  * tools, `wrangler whoami`, `gh auth status`, `elevenlabs auth status`, one GET to the directory and, when a fal key
20
- * is set, fal's free pricing API), never changes anything, and never prints a key, a token or an account's name.
23
+ * is set, fal's free pricing API; when a server's AI guides use Workers AI, one read of the live database and one
24
+ * one-word call to the model), never changes anything, and never prints a key, a token or an account's name or id.
21
25
  * Every check has a time limit, so it answers in seconds even with no network.
22
26
  */
23
27
  import { spawn } from 'node:child_process';
24
28
  import { existsSync, readFileSync } from 'node:fs';
25
29
  import { join } from 'node:path';
30
+ import { workersAiRow } from './brain-probe.mjs';
26
31
  import { findChrome } from './chrome.mjs';
27
32
  import { whyFailed } from './net.mjs';
28
33
  import { isOurs } from './statusline.mjs';
@@ -98,7 +103,8 @@ export async function setupStatus({
98
103
  if (!bin || !existsSync(bin)) return { signedIn: null };
99
104
  const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
100
105
  if (r.code === 124) return { signedIn: null, why: 'Wrangler did not answer in time' };
101
- try { const d = JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))); return { signedIn: Boolean(d.loggedIn), accounts: Array.isArray(d.accounts) ? d.accounts.length : null }; } catch { return { signedIn: false }; }
106
+ // The accounts' ids stay in this process (the Workers AI row calls the studio's own account); never printed.
107
+ try { const d = JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))); return { signedIn: Boolean(d.loggedIn), accounts: Array.isArray(d.accounts) ? d.accounts.length : null, accountIds: Array.isArray(d.accounts) ? d.accounts.map((a) => a?.id).filter(Boolean) : [] }; } catch { return { signedIn: false }; }
102
108
  })(),
103
109
  (async () => {
104
110
  const git = await exec('git', ['--version']);
@@ -182,6 +188,15 @@ export async function setupStatus({
182
188
  });
183
189
  }
184
190
 
191
+ // Workers AI, only when a server's AI guides think with it: does the model they use answer on this account?
192
+ {
193
+ const local = root ? readLocal(root) : {};
194
+ const live = Boolean(local.deployedAt || local.connectedAt || (studio?.cloudflare?.created ?? []).some((c) => String(c).startsWith('worker:')));
195
+ const bin = root ? join(root, 'node_modules', '.bin', win ? 'wrangler.cmd' : 'wrangler') : null;
196
+ const row = root ? await workersAiRow({ root, studio, env, exec, fetchFn, who, bin, remote: live && Boolean(who.signedIn || env.CLOUDFLARE_API_TOKEN), cli }) : null;
197
+ if (row) rows.push(row);
198
+ }
199
+
185
200
  // Chrome, for the checks.
186
201
  {
187
202
  const found = chrome();
@@ -254,6 +269,7 @@ export async function setupStatus({
254
269
  { feature: 'Backup and pull requests', state: ready(by.github.state), needs: ['github'] },
255
270
  { feature: 'Songs and game scores', state: ready(by.elevenlabs.state), needs: ['elevenlabs'] },
256
271
  { feature: 'Painted art and generated video', state: ready(by.fal.state), needs: ['fal'] },
272
+ ...(by['workers-ai'] ? [{ feature: 'AI guides that think (Workers AI)', state: ready(by['workers-ai'].state), needs: ['workers-ai'] }] : []),
257
273
  ];
258
274
  const now = rows.filter((r) => ['missing', 'act'].includes(r.state) && r.fix);
259
275
  const meanwhile = rows.filter((r) => r.fix?.open && r.state !== 'ok' && (r.fix.who === 'person' || r.state === 'later'));
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 key, a file in the repository copied
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 { cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
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
- if (existsSync(abs) && statSync(abs).isFile()) {
76
- const bytes = statSync(abs).size;
77
- if (bytes <= MAX_ASSET_BYTES) src = { from: 'site', url: `/${relative(root, abs).split(sep).map(encodeURIComponent).join('/')}`, abs, rel: relative(root, abs), bytes };
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: make it smaller, or give the studio storage (homie-studio storage add; Cloudflare asks for a payment method first, so ask the person) and upload it (homie-studio media put ${f.path})` });
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 storage key "${f.key}" needs the studio's storage (homie-studio storage add)` });
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 key or url' });
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({ 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, ...(src.abs ? { abs: src.abs, rel: src.rel } : {}) });
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
- return { songs: out.music, videos: out.videos, skipped };
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>/\`, served from the site itself (files up to 25 MiB) or, for larger media, from the studio's storage once it has storage (see below; \`npx --no-install homie-studio media put <file>\`). Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
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 needs large media (songs,
149
- videos): an R2 bucket for \`media put\`, served at \`/media/<key>\`. Cloudflare asks for a
150
- payment method on the account before R2 works (its first 10 GB a month are free), so this
151
- is a separate step the person agrees to; nothing else needs it.
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>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
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>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
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),