@homie-rocks/studio 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -11
- package/bin/homie-studio.mjs +99 -25
- package/lib/build.mjs +17 -2
- package/lib/cloudflare.mjs +146 -28
- package/lib/media.mjs +126 -0
- package/lib/scaffold.mjs +41 -18
- package/lib/studio.mjs +1 -1
- package/media/MEDIA.md +81 -0
- package/package.json +3 -2
- package/worker/index.mjs +94 -11
- package/worker/pages.mjs +125 -3
package/lib/media.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
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).
|
|
5
|
+
*/
|
|
6
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
10
|
+
export const KINDS = {
|
|
11
|
+
music: ['song', 'score', 'loop', 'stem', 'sfx'],
|
|
12
|
+
videos: ['trailer', 'music-video', 'cutscene', 'clip'],
|
|
13
|
+
};
|
|
14
|
+
/** Cloudflare Workers static assets: one file may be at most 25 MiB. */
|
|
15
|
+
export const MAX_ASSET_BYTES = 25 * 1024 * 1024;
|
|
16
|
+
const TYPES = {
|
|
17
|
+
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.wav': 'audio/wav', '.flac': 'audio/flac',
|
|
18
|
+
'.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
|
|
19
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.vtt': 'text/vtt', '.json': 'application/json',
|
|
20
|
+
};
|
|
21
|
+
export const typeOf = (path) => TYPES[(/\.[a-z0-9]+$/i.exec(String(path)) ?? [''])[0].toLowerCase()] ?? 'application/octet-stream';
|
|
22
|
+
|
|
23
|
+
export function manifestPath(root, kind) { return join(root, kind, 'manifest.json'); }
|
|
24
|
+
|
|
25
|
+
export function readManifest(root, kind) {
|
|
26
|
+
const file = manifestPath(root, kind);
|
|
27
|
+
if (!existsSync(file)) return { v: 1, items: [] };
|
|
28
|
+
const m = JSON.parse(readFileSync(file, 'utf8'));
|
|
29
|
+
return { v: 1, ...m, items: Array.isArray(m.items) ? m.items : [] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeManifest(root, kind, manifest) {
|
|
33
|
+
mkdirSync(join(root, kind), { recursive: true });
|
|
34
|
+
writeFileSync(manifestPath(root, kind), `${JSON.stringify({ v: 1, ...manifest }, null, 2)}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A path inside the studio, or null (no absolute paths, no `..` out of the repository). */
|
|
38
|
+
function inside(root, rel) {
|
|
39
|
+
if (typeof rel !== 'string' || !rel || rel.startsWith('/') || /^[a-z]+:/i.test(rel)) return null;
|
|
40
|
+
const abs = resolve(root, normalize(rel));
|
|
41
|
+
return abs.startsWith(`${resolve(root)}${sep}`) ? abs : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 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>).
|
|
47
|
+
*/
|
|
48
|
+
export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
49
|
+
const manifest = readManifest(root, kind);
|
|
50
|
+
const entries = [];
|
|
51
|
+
const skipped = [];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const item of manifest.items) {
|
|
54
|
+
if (!item || typeof item !== 'object') continue;
|
|
55
|
+
const label = item.slug ?? item.key ?? item.file ?? '(unnamed)';
|
|
56
|
+
if (!SLUG.test(String(item.slug ?? '')) || !item.title) { if (item.published !== false) skipped.push({ kind, item: label, why: 'no slug and title: kept in the manifest, no page' }); continue; }
|
|
57
|
+
if (seen.has(item.slug)) { skipped.push({ kind, item: label, why: 'a second entry with the same slug' }); continue; }
|
|
58
|
+
seen.add(item.slug);
|
|
59
|
+
if (item.published !== true) { skipped.push({ kind, item: label, why: 'not published yet ("published": true shows it)' }); continue; }
|
|
60
|
+
const files = [];
|
|
61
|
+
for (const f of Array.isArray(item.files) ? item.files : []) {
|
|
62
|
+
if (!f || f.public === false) continue;
|
|
63
|
+
let src = null;
|
|
64
|
+
if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };
|
|
65
|
+
if (!src && f.path) {
|
|
66
|
+
const abs = inside(root, f.path);
|
|
67
|
+
if (!abs) { skipped.push({ kind, item: item.slug, file: f.path, why: 'the path is outside the studio' }); continue; }
|
|
68
|
+
if (existsSync(abs) && statSync(abs).isFile()) {
|
|
69
|
+
const bytes = statSync(abs).size;
|
|
70
|
+
if (bytes <= MAX_ASSET_BYTES) src = { from: 'site', url: `/${relative(root, abs).split(sep).map(encodeURIComponent).join('/')}`, abs, rel: relative(root, abs), bytes };
|
|
71
|
+
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})` });
|
|
72
|
+
} 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)` });
|
|
73
|
+
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' });
|
|
74
|
+
}
|
|
75
|
+
if (!src && typeof f.url === 'string' && /^https:\/\//.test(f.url)) src = { from: 'url', url: f.url };
|
|
76
|
+
if (!src) continue;
|
|
77
|
+
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 } : {}) });
|
|
78
|
+
}
|
|
79
|
+
const main = kind === 'music' ? 'audio' : 'video';
|
|
80
|
+
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; }
|
|
81
|
+
entries.push({
|
|
82
|
+
slug: item.slug, kind: KINDS[kind].includes(item.kind) ? item.kind : KINDS[kind][0], title: String(item.title).slice(0, 120), blurb: String(item.blurb ?? '').slice(0, 600),
|
|
83
|
+
duration: Number.isFinite(item.duration) ? item.duration : null, bpm: Number.isFinite(item.bpm) ? item.bpm : null, key: item.key ?? null,
|
|
84
|
+
lyrics: typeof item.lyrics === 'string' ? item.lyrics.slice(0, 8000) : null, credits: typeof item.credits === 'string' ? item.credits.slice(0, 600) : null,
|
|
85
|
+
rights: item.rights && typeof item.rights === 'object' ? item.rights : null, for: item.for && typeof item.for === 'object' ? item.for : null,
|
|
86
|
+
honesty: typeof item.honesty === 'string' ? item.honesty.slice(0, 600) : null, made: item.made?.at ? { at: item.made.at, provider: item.made.provider ?? null } : null,
|
|
87
|
+
files,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return { entries, skipped };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Copy the site-served files into site/dist and return the catalogue rows (no local paths in them). */
|
|
94
|
+
export function buildMedia(root, dist, { r2 = false, log = () => {} } = {}) {
|
|
95
|
+
const out = {};
|
|
96
|
+
const skipped = [];
|
|
97
|
+
for (const kind of ['music', 'videos']) {
|
|
98
|
+
const r = resolveMedia(root, kind, { r2 });
|
|
99
|
+
skipped.push(...r.skipped);
|
|
100
|
+
out[kind] = r.entries.map((e) => ({
|
|
101
|
+
...e,
|
|
102
|
+
files: e.files.map(({ abs, rel, from, ...f }) => {
|
|
103
|
+
if (from === 'site') { const dest = join(dist, rel); mkdirSync(dirname(dest), { recursive: true }); cpSync(abs, dest); }
|
|
104
|
+
return f;
|
|
105
|
+
}),
|
|
106
|
+
}));
|
|
107
|
+
if (out[kind].length) log(`${kind}: ${out[kind].map((e) => e.slug).join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
for (const s of skipped) log(`${s.kind}/${s.item}${s.file ? ` ${s.file}` : ''}: ${s.why}`);
|
|
110
|
+
return { songs: out.music, videos: out.videos, skipped };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `homie-studio media put`: record an uploaded file's R2 key on the entry that names it (or a bare entry). */
|
|
114
|
+
export function recordUpload(root, rel, key, bytes, siteUrl) {
|
|
115
|
+
const kind = rel.startsWith('videos/') ? 'videos' : 'music';
|
|
116
|
+
const m = readManifest(root, kind);
|
|
117
|
+
let hit = false;
|
|
118
|
+
for (const item of m.items) {
|
|
119
|
+
for (const f of Array.isArray(item?.files) ? item.files : []) {
|
|
120
|
+
if (f.path === rel) { f.key = key; f.bytes = bytes; hit = true; }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url: siteUrl ? `${siteUrl}/media/${key}` : null, at: new Date().toISOString() }];
|
|
124
|
+
writeManifest(root, kind, m);
|
|
125
|
+
return { kind, manifest: relative(root, manifestPath(root, kind)), entry: hit };
|
|
126
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
* studio.json the studio's name, slug, and its Cloudflare resources
|
|
8
8
|
* package.json @homie-rocks/studio and wrangler, pinned
|
|
9
9
|
* games/<id>/ one folder per game (game.json, index.html, src/)
|
|
10
|
-
* music/ videos/ manifests in the repo; the big files
|
|
10
|
+
* music/ videos/ manifests in the repo; the big files go to the studio's own storage
|
|
11
|
+
* (R2) once it is added with `homie-studio storage add`
|
|
11
12
|
* posts/ words the studio publishes
|
|
12
|
-
* site/ the studio's Worker (pages, rooms), its D1 migrations
|
|
13
|
+
* site/ the studio's Worker (pages, rooms), its D1 migrations (no R2
|
|
14
|
+
* binding until the studio adds storage: a free Cloudflare account
|
|
15
|
+
* without a payment method cannot use R2, and a new studio needs none)
|
|
13
16
|
* .claude/skills/ skills only this studio uses
|
|
14
17
|
*
|
|
15
18
|
* It writes only into a folder that is new or empty, never guesses one, and
|
|
@@ -47,7 +50,8 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null }) {
|
|
|
47
50
|
observability: { enabled: true },
|
|
48
51
|
};
|
|
49
52
|
return `// This studio's site on its own Cloudflare account (written by homie-studio; \`homie-studio deploy\`
|
|
50
|
-
// fills in the D1 id
|
|
53
|
+
// fills in the D1 id). Everything here runs on Cloudflare's Workers Free plan. There is no R2 binding
|
|
54
|
+
// until \`homie-studio storage add\` gives the studio storage for large media.
|
|
51
55
|
${JSON.stringify(config, null, 2)}
|
|
52
56
|
`;
|
|
53
57
|
}
|
|
@@ -55,19 +59,19 @@ ${JSON.stringify(config, null, 2)}
|
|
|
55
59
|
function agentsMd({ name, slug }) {
|
|
56
60
|
return `# ${name}
|
|
57
61
|
|
|
58
|
-
This folder is a
|
|
62
|
+
This folder is a studio: **${name}** (\`${slug}\`). It is one repository.
|
|
59
63
|
Its games, music, videos and posts live here; its website and public game rooms
|
|
60
|
-
run on the studio's **own Cloudflare account** (
|
|
61
|
-
Table/Lobby Durable Objects
|
|
62
|
-
\`package.json\`. The homie.rocks
|
|
63
|
-
not host them.
|
|
64
|
+
run on the studio's **own Cloudflare account** (one Worker, one D1 database and
|
|
65
|
+
the Table/Lobby Durable Objects, all on Cloudflare's free Workers plan), built
|
|
66
|
+
from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
|
|
67
|
+
directory lists its games; homie.rocks does not host them.
|
|
64
68
|
|
|
65
69
|
## Layout
|
|
66
70
|
|
|
67
71
|
| Path | What it is |
|
|
68
72
|
| --- | --- |
|
|
69
73
|
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
|
|
70
|
-
| \`music/\`, \`videos/\` | \`manifest.json\` lists each
|
|
74
|
+
| \`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. |
|
|
71
75
|
| \`posts/\` | Markdown the studio publishes. |
|
|
72
76
|
| \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
|
|
73
77
|
| \`studio.json\` | The studio's name, slug and Cloudflare resource names. |
|
|
@@ -87,11 +91,20 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
87
91
|
- \`npm run build\` — bundle every game into \`site/dist\`.
|
|
88
92
|
- \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
|
|
89
93
|
Durable Object, D1): open the printed address in two browsers and they share a room.
|
|
94
|
+
Stop it with \`npx --no-install homie-studio dev --stop\` (this studio's dev server only;
|
|
95
|
+
never \`pkill\` by name, which stops other projects' dev servers too).
|
|
90
96
|
- \`npx --no-install homie-studio check <id> --url <site>\` — two headless browsers press Play and must
|
|
91
97
|
land in the same room and finish a round. Run it before you say a game works.
|
|
92
|
-
- \`
|
|
98
|
+
- \`npx --no-install homie-studio deploy --plan\` — says what deploy will create on Cloudflare and what it
|
|
99
|
+
costs, and changes nothing. Tell the person before the first deploy.
|
|
100
|
+
- \`npm run deploy\` — the site on this studio's Cloudflare: one Worker, one D1 database, two
|
|
101
|
+
SQLite-backed Durable Objects, all on the free Workers plan (no payment method needed).
|
|
93
102
|
If Wrangler is not signed in, run \`npx wrangler login\`: the person approves once in
|
|
94
103
|
their browser. It never overwrites a Worker or database this studio did not create.
|
|
104
|
+
- \`npx --no-install homie-studio storage add\` — only when the studio needs large media (songs,
|
|
105
|
+
videos): an R2 bucket for \`media put\`, served at \`/media/<key>\`. Cloudflare asks for a
|
|
106
|
+
payment method on the account before R2 works (its first 10 GB a month are free), so this
|
|
107
|
+
is a separate step the person agrees to; nothing else needs it.
|
|
95
108
|
- \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
|
|
96
109
|
(or call the Homie MCP tool \`studio_publish\`).
|
|
97
110
|
|
|
@@ -113,6 +126,12 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
113
126
|
keychain. Never write a key, token or password into this repository.
|
|
114
127
|
- Never touch a Cloudflare resource this studio did not create (\`studio.json\` says which).
|
|
115
128
|
- Nothing in this studio needs \`~/.homie\` or a Homie box.
|
|
129
|
+
|
|
130
|
+
## Beta
|
|
131
|
+
|
|
132
|
+
Homie for studios is in beta. When something breaks, or a game you want to port does
|
|
133
|
+
not fit, open an issue at https://github.com/homie-rocks/homie/issues/new/choose (a bug,
|
|
134
|
+
a port request or a question). Leave keys, tokens and private addresses out of it.
|
|
116
135
|
`;
|
|
117
136
|
}
|
|
118
137
|
|
|
@@ -159,7 +178,10 @@ site/.wrangler/
|
|
|
159
178
|
*.log
|
|
160
179
|
# Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
|
|
161
180
|
games/*/.port/
|
|
162
|
-
# Large media lives in this studio's R2; the manifests beside it are committed.
|
|
181
|
+
# Large media lives in this studio's storage (R2, after storage add) or is served by the site; the manifests beside it are committed.
|
|
182
|
+
# Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
|
|
183
|
+
music/**/work/
|
|
184
|
+
videos/**/work/
|
|
163
185
|
music/**/*.wav
|
|
164
186
|
music/**/*.mp3
|
|
165
187
|
music/**/*.flac
|
|
@@ -174,7 +196,8 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
174
196
|
const studio = {
|
|
175
197
|
name, slug,
|
|
176
198
|
homie: { studio: STUDIO_VERSION, directory: homie },
|
|
177
|
-
|
|
199
|
+
// r2 stays null until `homie-studio storage add`: a new studio deploys with no R2 at all.
|
|
200
|
+
cloudflare: { worker, d1: `${slug}-db`, r2: null, accountId: null, url: null, created: [] },
|
|
178
201
|
};
|
|
179
202
|
const pkg = {
|
|
180
203
|
name: `${slug}-studio`,
|
|
@@ -191,12 +214,12 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
191
214
|
'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
|
|
192
215
|
'.gitignore': GITIGNORE,
|
|
193
216
|
'games/README.md': 'One folder per game. Start one with `npx --no-install homie-studio game new <id> --from gem-rush`.\n',
|
|
194
|
-
'music/README.md': 'Songs and stems. `manifest.json` lists them;
|
|
195
|
-
'music/manifest.json': '{ "items": [] }\n',
|
|
196
|
-
'videos/README.md': 'Trailers and
|
|
197
|
-
'videos/manifest.json': '{ "items": [] }\n',
|
|
217
|
+
'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',
|
|
218
|
+
'music/manifest.json': '{ "v": 1, "items": [] }\n',
|
|
219
|
+
'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',
|
|
220
|
+
'videos/manifest.json': '{ "v": 1, "items": [] }\n',
|
|
198
221
|
'posts/README.md': 'Markdown posts this studio publishes, one file each.\n',
|
|
199
|
-
'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1 and R2.
|
|
222
|
+
'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
|
|
200
223
|
// The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
|
|
201
224
|
export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
202
225
|
`,
|
|
@@ -254,7 +277,7 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
|
|
|
254
277
|
'npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
|
|
255
278
|
'npm run dev (then: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
|
|
256
279
|
'npm run deploy (then the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish)',
|
|
257
|
-
] };
|
|
280
|
+
], online: 'Going online creates one Worker, one D1 database and two Durable Objects on your own Cloudflare account: free plan, no payment method, no R2. `npx --no-install homie-studio deploy --plan` says exactly what, and changes nothing.' };
|
|
258
281
|
}
|
|
259
282
|
|
|
260
283
|
export const _test = { relative };
|
package/lib/studio.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
|
|
9
9
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
10
|
export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
11
|
-
const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios']);
|
|
11
|
+
const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts']);
|
|
12
12
|
|
|
13
13
|
export function findStudio(from = process.cwd()) {
|
|
14
14
|
let at = resolve(from);
|
package/media/MEDIA.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Studio media: music/ and videos/
|
|
2
|
+
|
|
3
|
+
A studio's songs, scores, loops, trailers, music videos and cutscenes live in the studio
|
|
4
|
+
repository as folders plus one manifest per kind. The manifests are the contract between
|
|
5
|
+
whatever made the media (the Homie plugin's `music` and `video` skills, or a person) and the
|
|
6
|
+
studio site, which turns every published entry into a page:
|
|
7
|
+
|
|
8
|
+
| Manifest | Page | Listed in |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| `music/manifest.json` | `/music/<slug>/` (an audio player) | the home page, `/music/`, `/.well-known/homie-studio.json` `songs` |
|
|
11
|
+
| `videos/manifest.json` | `/videos/<slug>/` (a video player) | the home page, `/videos/`, `/.well-known/homie-studio.json` `videos` |
|
|
12
|
+
|
|
13
|
+
`homie-studio build` reads both manifests. `homie-studio media list` prints what the site will
|
|
14
|
+
show and why anything is left out.
|
|
15
|
+
|
|
16
|
+
## An entry
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"v": 1,
|
|
21
|
+
"items": [
|
|
22
|
+
{
|
|
23
|
+
"slug": "theme",
|
|
24
|
+
"kind": "song",
|
|
25
|
+
"title": "Night Owls Theme",
|
|
26
|
+
"blurb": "The studio's theme: a short sung hook over a driving synth line.",
|
|
27
|
+
"published": true,
|
|
28
|
+
"duration": 16.0,
|
|
29
|
+
"bpm": 120,
|
|
30
|
+
"key": "A minor",
|
|
31
|
+
"files": [
|
|
32
|
+
{ "role": "audio", "path": "music/theme/theme.mp3", "type": "audio/mpeg" },
|
|
33
|
+
{ "role": "cover", "path": "music/theme/cover.jpg", "type": "image/jpeg" },
|
|
34
|
+
{ "role": "loop", "path": "music/theme/theme-loop.ogg", "type": "audio/ogg", "bars": 8 },
|
|
35
|
+
{ "role": "master", "path": "music/theme/theme-master.wav", "type": "audio/wav", "public": false }
|
|
36
|
+
],
|
|
37
|
+
"lyrics": "…",
|
|
38
|
+
"credits": "Music made with Eleven Music (ElevenLabs).",
|
|
39
|
+
"rights": { "provider": "elevenlabs", "plan": "creator", "commercial": true, "attribution": null },
|
|
40
|
+
"for": { "game": "crown-thief" },
|
|
41
|
+
"made": { "provider": "elevenlabs", "model": "music_v2", "at": "2026-09-30T02:00:00Z", "receipt": "music/theme/receipt.json" }
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- The manifest's order is the site's order (the plugin's skills put a new entry first).
|
|
48
|
+
- `slug`: lowercase letters, digits and hyphens, up to 40; it is the page address and never changes
|
|
49
|
+
once published. `title` is required for a page; an entry without `slug` and `title` is kept in
|
|
50
|
+
the manifest but gets no page.
|
|
51
|
+
- `kind`: music is `song`, `score`, `loop`, `stem` or `sfx`; video is `trailer`, `music-video`,
|
|
52
|
+
`cutscene` or `clip`.
|
|
53
|
+
- `published`: only `true` entries get a page and a directory listing. Work in progress stays
|
|
54
|
+
`false`.
|
|
55
|
+
- `files[].role`: music uses `audio` (what the player plays), `cover`, `loop`, `stem`,
|
|
56
|
+
`master`; video uses `video` (the 16:9 cut the player plays), `vertical` (the 9:16 cut),
|
|
57
|
+
`poster`, `captions` (WebVTT), `master`. `"public": false` keeps a file off the site (masters,
|
|
58
|
+
raw captures). Loops and stems are offered for download on the song page, so another game can
|
|
59
|
+
use them.
|
|
60
|
+
- `rights` and `credits` are shown on the page, in plain words. Say what the provider's terms
|
|
61
|
+
say for the plan the file was made on; nothing on the site claims more.
|
|
62
|
+
|
|
63
|
+
## Where the bytes come from
|
|
64
|
+
|
|
65
|
+
A studio needs no storage for songs and videos: the site serves them itself. For every public
|
|
66
|
+
file of a published entry, the build picks the first that exists:
|
|
67
|
+
|
|
68
|
+
1. `key`: the file is in the studio's storage, an R2 bucket that `homie-studio storage add`
|
|
69
|
+
creates (Cloudflare asks for a payment method on the account before R2 works, so the person
|
|
70
|
+
agrees first) and `homie-studio media put <file>` uploads to; the site serves it at
|
|
71
|
+
`/media/<key>`, with byte ranges so phones can seek.
|
|
72
|
+
2. `path`: the file is in the studio folder; the build copies it into the site as `/<path>` when
|
|
73
|
+
it is 25 MiB or smaller (Cloudflare's limit for one static file), and the Worker answers byte
|
|
74
|
+
ranges for it too. This is the default, and it needs nothing but a deploy.
|
|
75
|
+
3. `url`: an absolute `https://` address somewhere else.
|
|
76
|
+
|
|
77
|
+
A file with none of these is left out and `media list` says so. Media never goes into git: the
|
|
78
|
+
studio's `.gitignore` keeps `*.wav`, `*.mp3`, `*.flac`, `*.mp4`, `*.mov` and `*.webm` under
|
|
79
|
+
`music/` and `videos/` out of the repository, so without storage the site is deployed from the
|
|
80
|
+
computer that has the files. Keep deliveries under 25 MiB (the video skill's cuts do) or add
|
|
81
|
+
storage for bigger ones.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A
|
|
3
|
+
"version": "0.5.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), song and video pages from the music/ and videos/ manifests, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"homepage": "https://homie.rocks/studio/",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"port/",
|
|
37
37
|
"worker/",
|
|
38
38
|
"starters/",
|
|
39
|
+
"media/",
|
|
39
40
|
"README.md",
|
|
40
41
|
"LICENSE",
|
|
41
42
|
"NOTICE"
|
package/worker/index.mjs
CHANGED
|
@@ -15,19 +15,22 @@
|
|
|
15
15
|
* /<game>/api/lobby which public room to join (Lobby Durable Object)
|
|
16
16
|
* /api/games the catalogue plus live counts (D1 + Lobby)
|
|
17
17
|
* /.well-known/homie-studio.json what the homie.rocks directory reads
|
|
18
|
-
* /
|
|
18
|
+
* /music/, /music/<slug>/ the studio's songs, scores and loops (music/manifest.json)
|
|
19
|
+
* /videos/, /videos/<slug>/ its trailers, music videos and cutscenes (videos/manifest.json)
|
|
20
|
+
* /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
|
|
19
21
|
*
|
|
20
22
|
* Bindings (site/wrangler.jsonc, written by `homie-studio new`): ASSETS (the
|
|
21
23
|
* built site), TABLE and LOBBY (SQLite-backed Durable Objects, free plan
|
|
22
|
-
* friendly), DB (D1: plays, finished rounds, the directory claim) and,
|
|
23
|
-
*
|
|
24
|
+
* friendly), DB (D1: plays, finished rounds, the directory claim) and, only
|
|
25
|
+
* after `homie-studio storage add` (R2 needs a payment method on the account),
|
|
26
|
+
* MEDIA. Everything else runs on Cloudflare's free Workers plan.
|
|
24
27
|
*
|
|
25
28
|
* The relay is the netplay contract's own room.mjs (NETPLAY.md v1 rev 2), run
|
|
26
29
|
* unchanged inside the Table, so a game that plays in `homie-studio dev`
|
|
27
30
|
* plays the same way here.
|
|
28
31
|
*/
|
|
29
32
|
import { NetRoom } from './room.mjs';
|
|
30
|
-
import { gamePage, homePage, notFoundPage, playPage } from './pages.mjs';
|
|
33
|
+
import { gamePage, homePage, mediaIndexPage, notFoundPage, playPage, songPage, videoPage } from './pages.mjs';
|
|
31
34
|
import { qrSvg } from './qr.mjs';
|
|
32
35
|
|
|
33
36
|
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
@@ -62,6 +65,71 @@ async function liveCounts(env, games) {
|
|
|
62
65
|
return out;
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
const MEDIA_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
69
|
+
|
|
70
|
+
/** `bytes=a-b`, `bytes=a-`, `bytes=-n` against a length, or null (a malformed or multi-range header is served whole). */
|
|
71
|
+
function parseRange(header, size) {
|
|
72
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? '').trim());
|
|
73
|
+
if (!m || (m[1] === '' && m[2] === '')) return null;
|
|
74
|
+
let start; let end;
|
|
75
|
+
if (m[1] === '') { const n = Number(m[2]); start = Math.max(0, size - n); end = size - 1; }
|
|
76
|
+
else { start = Number(m[1]); end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1); }
|
|
77
|
+
if (!(start <= end) || start >= size) return { unsatisfiable: true };
|
|
78
|
+
return { start, end };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A media file from the site's own assets, answering a byte range (phones seek audio and video with them). */
|
|
82
|
+
async function assetWithRange(request, env, url) {
|
|
83
|
+
const res = await env.ASSETS.fetch(new Request(url.toString(), { method: 'GET' }));
|
|
84
|
+
const range = request.headers.get('range');
|
|
85
|
+
if (!res.ok || !range || res.status === 206) return res;
|
|
86
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
87
|
+
const r = parseRange(range, body.byteLength);
|
|
88
|
+
const headers = new Headers(res.headers);
|
|
89
|
+
headers.set('accept-ranges', 'bytes');
|
|
90
|
+
if (!r) return new Response(body, { status: 200, headers });
|
|
91
|
+
if (r.unsatisfiable) { headers.set('content-range', `bytes */${body.byteLength}`); return new Response(null, { status: 416, headers }); }
|
|
92
|
+
headers.set('content-range', `bytes ${r.start}-${r.end}/${body.byteLength}`);
|
|
93
|
+
headers.set('content-length', String(r.end - r.start + 1));
|
|
94
|
+
return new Response(body.subarray(r.start, r.end + 1), { status: 206, headers });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A file from the studio's R2 bucket, with byte ranges. */
|
|
98
|
+
async function mediaObject(request, env, key) {
|
|
99
|
+
const range = request.headers.get('range');
|
|
100
|
+
let head = null;
|
|
101
|
+
if (range) head = await env.MEDIA.head(key);
|
|
102
|
+
if (range && !head) return new Response('not found', { status: 404 });
|
|
103
|
+
const r = head ? parseRange(range, head.size) : null;
|
|
104
|
+
if (r?.unsatisfiable) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${head.size}` } });
|
|
105
|
+
const obj = await env.MEDIA.get(key, r ? { range: { offset: r.start, length: r.end - r.start + 1 } } : undefined);
|
|
106
|
+
if (!obj) return new Response('not found', { status: 404 });
|
|
107
|
+
const headers = new Headers();
|
|
108
|
+
obj.writeHttpMetadata(headers);
|
|
109
|
+
headers.set('etag', obj.httpEtag);
|
|
110
|
+
headers.set('cache-control', 'public, max-age=3600');
|
|
111
|
+
headers.set('accept-ranges', 'bytes');
|
|
112
|
+
if (r) {
|
|
113
|
+
headers.set('content-range', `bytes ${r.start}-${r.end}/${head.size}`);
|
|
114
|
+
headers.set('content-length', String(r.end - r.start + 1));
|
|
115
|
+
return new Response(obj.body, { status: 206, headers });
|
|
116
|
+
}
|
|
117
|
+
headers.set('content-length', String(obj.size));
|
|
118
|
+
return new Response(obj.body, { headers });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A catalogue media row as the directory reads it (absolute addresses). */
|
|
122
|
+
function mediaRow(e, origin, kind) {
|
|
123
|
+
const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u ?? null);
|
|
124
|
+
const main = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'audio' : 'video'));
|
|
125
|
+
const art = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'cover' : 'poster'));
|
|
126
|
+
return {
|
|
127
|
+
slug: e.slug, kind: e.kind, title: e.title, blurb: e.blurb ?? '', duration: e.duration ?? null,
|
|
128
|
+
page: `${origin}/${kind}/${e.slug}/`, [kind === 'music' ? 'audio' : 'video']: abs(main?.url), [kind === 'music' ? 'cover' : 'poster']: abs(art?.url),
|
|
129
|
+
credits: e.credits ?? null, rights: e.rights ?? null, for: e.for ?? null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
65
133
|
/** The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). */
|
|
66
134
|
async function gameDocument(request, env, url, game, meta) {
|
|
67
135
|
const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
|
|
@@ -124,6 +192,8 @@ export default {
|
|
|
124
192
|
id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
|
|
125
193
|
page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: g.cover ? `${url.origin}/games/${g.id}/${g.cover}` : null,
|
|
126
194
|
})),
|
|
195
|
+
songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music')),
|
|
196
|
+
videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos')),
|
|
127
197
|
}, 200, { 'access-control-allow-origin': '*' });
|
|
128
198
|
}
|
|
129
199
|
if (path === '/api/games') {
|
|
@@ -133,13 +203,26 @@ export default {
|
|
|
133
203
|
if (path.startsWith('/media/')) {
|
|
134
204
|
if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
|
|
135
205
|
const key = decodeURIComponent(path.slice('/media/'.length));
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
206
|
+
if (!key || key.includes('..')) return new Response('not found', { status: 404 });
|
|
207
|
+
return mediaObject(request, env, key);
|
|
208
|
+
}
|
|
209
|
+
if (parts[0] === 'music' || parts[0] === 'videos') {
|
|
210
|
+
const kind = parts[0];
|
|
211
|
+
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/`, 301);
|
|
212
|
+
const cat = await catalogue(env, url.origin);
|
|
213
|
+
if (parts.length === 1) return mediaIndexPage(cat, kind);
|
|
214
|
+
const slug = parts[1];
|
|
215
|
+
const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
|
|
216
|
+
const entry = MEDIA_SLUG.test(slug) ? list.find((e) => e.slug === slug) : null;
|
|
217
|
+
if (parts.length === 2) {
|
|
218
|
+
if (!entry) return notFoundPage(`No ${kind === 'music' ? 'song' : 'video'} called "${slug}" here.`);
|
|
219
|
+
if (!path.endsWith('/')) return Response.redirect(`${url.origin}/${kind}/${slug}/`, 301);
|
|
220
|
+
return kind === 'music' ? songPage(cat, entry, url.origin) : videoPage(cat, entry, url.origin);
|
|
221
|
+
}
|
|
222
|
+
if (parts.some((p) => p === '..')) return notFoundPage('Nothing here.');
|
|
223
|
+
const file = await assetWithRange(request, env, url);
|
|
224
|
+
if (file.status === 404) return notFoundPage('Nothing here.');
|
|
225
|
+
return file;
|
|
143
226
|
}
|
|
144
227
|
if (path === '/' || path === '/index.html') {
|
|
145
228
|
const cat = await catalogue(env, url.origin);
|