@homie-rocks/studio 0.2.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 +52 -11
- package/bin/homie-studio.mjs +132 -27
- package/lib/build.mjs +100 -23
- package/lib/cloudflare.mjs +146 -28
- package/lib/media.mjs +126 -0
- package/lib/port-check.mjs +651 -0
- package/lib/port.mjs +277 -0
- package/lib/scaffold.mjs +47 -18
- package/lib/studio.mjs +1 -1
- package/media/MEDIA.md +81 -0
- package/netplay/netplay.ts +93 -8
- package/package.json +12 -2
- package/port/audio.ts +96 -0
- package/port/bots.ts +97 -0
- package/port/camera.ts +75 -0
- package/port/controls.ts +48 -0
- package/port/early.ts +14 -0
- package/port/global.ts +9 -0
- package/port/hud.ts +96 -0
- package/port/index.ts +29 -0
- package/port/keys.ts +102 -0
- package/port/probe.ts +126 -0
- package/port/room.ts +356 -0
- package/port/sandbox.ts +112 -0
- package/port/touch.ts +378 -0
- package/starters/gem-rush/src/main.ts +9 -0
- package/worker/index.mjs +113 -11
- package/worker/pages.mjs +155 -13
- package/worker/qr.mjs +501 -0
package/lib/port.mjs
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio port plan <folder>` and `homie-studio port import <folder> --id <id>`:
|
|
3
|
+
* the first two steps of making an existing single-player web game multiplayer.
|
|
4
|
+
*
|
|
5
|
+
* plan reads the game without running it (loop, input, state, camera, physics,
|
|
6
|
+
* storage, audio, network, size) and drafts a port plan: a difficulty
|
|
7
|
+
* grade with its reasons, the netplay movement mode, the build mode, the
|
|
8
|
+
* check view, and every risk the sandboxed play frame will hit. The agent
|
|
9
|
+
* then READS the code and writes the final PORT.md; the draft is a start,
|
|
10
|
+
* never the verdict.
|
|
11
|
+
* import copies the game into games/<id>/ untouched except for two lines (the
|
|
12
|
+
* port toolkit first in <head>, a phone-safe viewport), writes game.json,
|
|
13
|
+
* and keeps the licence beside it.
|
|
14
|
+
*/
|
|
15
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
17
|
+
import { GAME_ID } from './studio.mjs';
|
|
18
|
+
|
|
19
|
+
const SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', '.cache', '.parcel-cache', '.vite', 'coverage']);
|
|
20
|
+
const CODE = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.svelte']);
|
|
21
|
+
const TEXT = new Set([...CODE, '.html', '.htm', '.css', '.json', '.md', '.txt', '.glsl', '.frag', '.vert', '.tmx', '.xml', '.svg']);
|
|
22
|
+
const MAX_READ = 2 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
function walk(dir, rel = '', out = []) {
|
|
25
|
+
for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
|
|
26
|
+
if (SKIP.has(e.name)) continue;
|
|
27
|
+
const p = rel ? `${rel}/${e.name}` : e.name;
|
|
28
|
+
if (e.isDirectory()) walk(dir, p, out);
|
|
29
|
+
else if (e.isFile()) out.push({ path: p, bytes: statSync(join(dir, p)).size, ext: extname(e.name).toLowerCase() });
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const count = (text, re) => (text.match(re) ?? []).length;
|
|
35
|
+
|
|
36
|
+
/** A licence file's SPDX-ish kind, from its words. */
|
|
37
|
+
export function licenceOf(dir) {
|
|
38
|
+
const names = readdirSync(dir).filter((n) => /^(licen[cs]e|copying)(\.|$)/i.test(n));
|
|
39
|
+
if (!names.length) {
|
|
40
|
+
const pkg = existsSync(join(dir, 'package.json')) ? JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) : null;
|
|
41
|
+
return { file: null, kind: pkg?.license ? `${pkg.license} (package.json only)` : 'none', permissive: false };
|
|
42
|
+
}
|
|
43
|
+
const text = readFileSync(join(dir, names[0]), 'utf8');
|
|
44
|
+
let kind = 'unknown';
|
|
45
|
+
if (/Permission is hereby granted, free of charge/i.test(text)) kind = 'MIT';
|
|
46
|
+
else if (/Apache License[\s\S]{0,40}Version 2\.0/i.test(text)) kind = 'Apache-2.0';
|
|
47
|
+
else if (/Redistribution and use in source and binary forms/i.test(text)) kind = /Neither the name/i.test(text) ? 'BSD-3-Clause' : 'BSD-2-Clause';
|
|
48
|
+
else if (/Permission to use, copy, modify, and\/or distribute/i.test(text)) kind = 'ISC';
|
|
49
|
+
else if (/GNU AFFERO GENERAL PUBLIC LICENSE/i.test(text)) kind = 'AGPL';
|
|
50
|
+
else if (/GNU LESSER GENERAL PUBLIC LICENSE/i.test(text)) kind = 'LGPL';
|
|
51
|
+
else if (/GNU GENERAL PUBLIC LICENSE/i.test(text)) kind = 'GPL';
|
|
52
|
+
else if (/Mozilla Public License/i.test(text)) kind = 'MPL-2.0';
|
|
53
|
+
else if (/This is free and unencumbered software released into the public domain/i.test(text)) kind = 'Unlicense';
|
|
54
|
+
else if (/Creative Commons/i.test(text)) kind = 'Creative Commons';
|
|
55
|
+
return { file: names[0], kind, permissive: ['MIT', 'Apache-2.0', 'BSD-3-Clause', 'BSD-2-Clause', 'ISC', 'Unlicense'].includes(kind) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read a game folder and draft its port plan. */
|
|
59
|
+
export function planPort(folder) {
|
|
60
|
+
const dir = resolve(folder ?? '.');
|
|
61
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`${dir} is not a folder`);
|
|
62
|
+
const files = walk(dir);
|
|
63
|
+
const texts = [];
|
|
64
|
+
let read = 0;
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
if (!TEXT.has(f.ext) || f.bytes > 1024 * 1024 || read > MAX_READ) continue;
|
|
67
|
+
// Vendored libraries (three.module.js, jquery…) are not the game: counted, not analysed.
|
|
68
|
+
const vendored = /(^|\/)(vendor|libs?|third[-_]?party|build|jsm|addons|node_modules)\//i.test(f.path) || /(^|\/)(GLTFLoader|OBJLoader|FBXLoader|OrbitControls|PointerLockControls|BufferGeometryUtils|Octree|Capsule|OctreeHelper|EffectComposer|RenderPass|UnrealBloomPass)\.js$/.test(f.path) || /(\.min\.js|three(\.module)?\.js|three\.core\.js|jquery[-.\d]*\.js|phaser[.\w-]*\.js|pixi[.\w-]*\.js|matter[.\w-]*\.js|cannon[.\w-]*\.js|howler[.\w-]*\.js|lil-gui[.\w-]*\.js|stats[.\w-]*\.js|typeface\.js)$/i.test(f.path);
|
|
69
|
+
const text = readFileSync(join(dir, f.path), 'utf8');
|
|
70
|
+
read += text.length;
|
|
71
|
+
texts.push({ ...f, text, vendored });
|
|
72
|
+
}
|
|
73
|
+
const game = texts.filter((t) => !t.vendored && (CODE.has(t.ext) || t.ext === '.html' || t.ext === '.htm'));
|
|
74
|
+
const all = game.map((t) => t.text).join('\n');
|
|
75
|
+
const everything = texts.map((t) => t.text).join('\n');
|
|
76
|
+
const loc = game.filter((t) => CODE.has(t.ext)).reduce((n, t) => n + t.text.split('\n').length, 0)
|
|
77
|
+
+ game.filter((t) => t.ext === '.html').reduce((n, t) => n + (t.text.match(/<script(?![^>]*src)[^>]*>[\s\S]*?<\/script>/gi) ?? []).join('\n').split('\n').length, 0);
|
|
78
|
+
const totalBytes = files.reduce((n, f) => n + f.bytes, 0);
|
|
79
|
+
const pkg = existsSync(join(dir, 'package.json')) ? JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) : null;
|
|
80
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
81
|
+
const html = files.filter((f) => f.ext === '.html' || f.ext === '.htm').map((f) => f.path);
|
|
82
|
+
const index = html.find((p) => /(^|\/)index\.html?$/i.test(p) && !p.includes('/')) ?? html.find((p) => /index\.html?$/i.test(p)) ?? html[0] ?? null;
|
|
83
|
+
const indexText = index ? readFileSync(join(dir, index), 'utf8') : '';
|
|
84
|
+
const scripts = [...indexText.matchAll(/<script\b([^>]*)>/gi)].map((m) => ({ src: /src=["']([^"']+)["']/i.exec(m[1])?.[1] ?? null, module: /type=["']module["']/i.test(m[1]) }));
|
|
85
|
+
const has = (re) => re.test(all) || re.test(everything);
|
|
86
|
+
|
|
87
|
+
const engine = [];
|
|
88
|
+
if (has(/from ['"]three['"]|THREE\.|three\.module\.js|three\.core\.js/)) engine.push('three.js');
|
|
89
|
+
if (has(/BABYLON\.|@babylonjs/)) engine.push('babylon.js');
|
|
90
|
+
if (has(/new Phaser\.Game|from ['"]phaser['"]/)) engine.push('phaser');
|
|
91
|
+
if (has(/PIXI\.|from ['"]pixi\.js['"]/)) engine.push('pixi.js');
|
|
92
|
+
if (has(/\bkaboom\(|from ['"]kaboom['"]|from ['"]kaplay['"]/)) engine.push('kaboom');
|
|
93
|
+
if (has(/\bengineInit\(|LittleJS/)) engine.push('littlejs');
|
|
94
|
+
if (has(/\bme\.game\b|melonJS/)) engine.push('melonjs');
|
|
95
|
+
if (/function setup\s*\(|function draw\s*\(/.test(all) && /createCanvas\(/.test(all)) engine.push('p5');
|
|
96
|
+
if (!engine.length && /getContext\(\s*['"]2d['"]/.test(all)) engine.push('canvas-2d');
|
|
97
|
+
if (!engine.length && /getContext\(\s*['"](webgl2?|experimental-webgl)['"]/.test(all)) engine.push('webgl');
|
|
98
|
+
if (!engine.length) engine.push('dom');
|
|
99
|
+
const threeD = engine.some((e) => ['three.js', 'babylon.js', 'webgl'].includes(e));
|
|
100
|
+
|
|
101
|
+
const physics = [];
|
|
102
|
+
for (const [re, name] of [[/matter-js|Matter\.Engine/, 'matter.js'], [/cannon(-es)?|CANNON\./, 'cannon'], [/@dimforge\/rapier|RAPIER\./, 'rapier'], [/planck|Box2D|box2d/, 'box2d/planck'], [/ammo\.js|Ammo\(/, 'ammo.js'], [/oimo/i, 'oimo'], [/\bOctree\b.*capsule|Capsule\b/i, 'three octree collisions']]) {
|
|
103
|
+
if (has(re)) physics.push(name);
|
|
104
|
+
}
|
|
105
|
+
const customPhysics = /gravity/i.test(all) && /(dy|vy|velocity|\.vel)\b/i.test(all);
|
|
106
|
+
|
|
107
|
+
const loop = {
|
|
108
|
+
raf: count(all, /requestAnim(ation)?Frame|setAnimationLoop|\bticker\.add|scene\.registerBeforeRender|engine\.runRenderLoop/g),
|
|
109
|
+
interval: count(all, /setInterval\s*\(/g),
|
|
110
|
+
timeoutLoop: count(all, /setTimeout\s*\(/g),
|
|
111
|
+
};
|
|
112
|
+
const input = {
|
|
113
|
+
keys: count(all, /['"]key(down|up|press)['"]|onkey(down|up|press)|\.key(down|up)\s*\(/gi),
|
|
114
|
+
keyCode: count(all, /keyCode|\.which\b/g),
|
|
115
|
+
mouse: count(all, /['"](mouse(down|up|move)|click|dblclick|wheel|contextmenu)['"]|onclick|\.click\s*\(/gi),
|
|
116
|
+
pointer: count(all, /['"]pointer(down|up|move|cancel)['"]/g),
|
|
117
|
+
touch: count(all, /['"]touch(start|move|end|cancel)['"]|ontouch/g),
|
|
118
|
+
pointerLock: count(all, /requestPointerLock/g),
|
|
119
|
+
gamepad: count(all, /getGamepads/g),
|
|
120
|
+
};
|
|
121
|
+
const camera = {
|
|
122
|
+
perspective: count(all, /PerspectiveCamera/g),
|
|
123
|
+
orthographic: count(all, /OrthographicCamera/g),
|
|
124
|
+
followsHeading: /camera\.(rotation\.y|quaternion)\s*[=.]|camera\.lookAt\([^)]*(dir|heading|forward)/i.test(all),
|
|
125
|
+
firstPerson: input.pointerLock > 0 || /first[-\s]?person|PointerLockControls|FirstPersonControls/i.test(all),
|
|
126
|
+
twoDScroll: /(camera|viewport|cam)\.(x|y)\s*[+\-]?=|ctx\.translate\(/.test(all),
|
|
127
|
+
};
|
|
128
|
+
const storage = { localStorage: count(all, /localStorage/g), sessionStorage: count(all, /sessionStorage/g), indexedDB: count(all, /indexedDB/g), cookie: count(all, /document\.cookie/g) };
|
|
129
|
+
const audio = { webAudio: count(all, /AudioContext/g), media: count(all, /new Audio\(|<audio|\.play\(\)/g), howler: count(everything, /Howl\b|howler/g), tone: count(all, /Tone\./g) };
|
|
130
|
+
const network = {
|
|
131
|
+
fetch: count(all, /\bfetch\s*\(/g), xhr: count(all, /XMLHttpRequest|\$\.(get|getJSON|ajax)\(/g), websocket: count(all, /new WebSocket\(/g),
|
|
132
|
+
absolutePaths: [...new Set([...(indexText + all).matchAll(/(?:src|href)=["'](\/[^/"'][^"']*)["']|fetch\(\s*["'](\/[^/"'][^"']*)["']/g)].map((m) => m[1] ?? m[2]))].slice(0, 12),
|
|
133
|
+
cdn: [...new Set([...indexText.matchAll(/(?:src|href)=["'](https?:\/\/[^"']+)["']/g)].map((m) => new URL(m[1]).host))],
|
|
134
|
+
};
|
|
135
|
+
const workers = count(all, /new (Shared)?Worker\(/g);
|
|
136
|
+
const wasm = files.filter((f) => f.ext === '.wasm').length + count(all, /WebAssembly\./g);
|
|
137
|
+
const dialogs = count(all, /\b(alert|confirm|prompt)\s*\(/g);
|
|
138
|
+
const random = count(all, /Math\.random\(/g);
|
|
139
|
+
const multiplayer = /socket\.io|peerjs|new RTCPeerConnection|colyseus|firebase/i.test(everything);
|
|
140
|
+
const homiePackage = existsSync(join(dir, 'homie.json'));
|
|
141
|
+
const houseApi = /@homie\/(tabletop|arcade)|\bhomie\.(ready|report|showPrivate|on\(|seats)|__homie\//.test(all);
|
|
142
|
+
// Real time: a body moved by time (dt, velocity, speed × delta, a game loop); turn-based: moves on input only.
|
|
143
|
+
const realtime = count(all, /\bdt\b|deltaTime|\bdelta\b|velocity|\.vel\b|\.vx\b|\.vy\b|\bvx\b|\bvy\b|speed\s*\*|mainLoop|gameLoop|getDelta\(|update\(\s*(dt|delta|step)/g);
|
|
144
|
+
const boardWords = count(all, /\b(turn|board|grid|tile|cell|move\(\s*(direction|dir)|swipe)s?\b/gi);
|
|
145
|
+
const turnBased = (loop.raf === 0 && loop.interval === 0) || (realtime <= 2 && boardWords >= 3);
|
|
146
|
+
const timing = /\b(rhythm|beat|bpm|frame[-\s]?perfect|metronome)\b/i.test(all);
|
|
147
|
+
const sideView = !threeD && /gravity/i.test(all) && /jump/i.test(all);
|
|
148
|
+
const ammo = /bullet|projectile|missile|shoot|fire/i.test(all);
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------- grade
|
|
151
|
+
const reasons = [];
|
|
152
|
+
let grade = 'easy';
|
|
153
|
+
const up = (to, why) => { const order = ['easy', 'medium', 'hard', 'not a fit']; if (order.indexOf(to) > order.indexOf(grade)) grade = to; reasons.push(`${to}: ${why}`); };
|
|
154
|
+
if (!index) up('not a fit', 'no index.html: a studio game is a web page');
|
|
155
|
+
if (multiplayer) up('not a fit', 'it already has its own networking (socket.io/peer/firebase): port its rules instead, or keep it on its own server');
|
|
156
|
+
if (network.websocket) up('hard', 'it opens its own WebSocket: a server it depends on will not be there');
|
|
157
|
+
if (wasm && /SharedArrayBuffer/.test(all)) up('not a fit', 'WebAssembly threads need cross-origin isolation, which the sandboxed play frame cannot give');
|
|
158
|
+
if (totalBytes > 60 * 1024 * 1024) up('hard', `${Math.round(totalBytes / 1048576)} MB of files: every visitor downloads all of it (keep a game well under 30 MB)`);
|
|
159
|
+
if (timing) up('hard', 'precise timing (rhythm/beat): every screen needs the same clock; run the beat from the room clock (net.now())');
|
|
160
|
+
if (physics.length && !turnBased) up('hard', `fast physics (${physics.join(', ')}): the host simulates the world; each player owns only their own body`);
|
|
161
|
+
if (threeD && !turnBased) up(physics.length ? 'hard' : 'medium', `real-time 3D (${engine.join(', ')}): camera rules, screen-relative input and a stable yaw are needed`);
|
|
162
|
+
if (camera.followsHeading) up('medium', 'the camera turns with the character: it must stop (input on screen axes, yaw only on look input)');
|
|
163
|
+
if (camera.firstPerson) up('medium', 'first person with pointer lock: phones need a look-drag and a stick, and hits need the host');
|
|
164
|
+
if (!turnBased && !threeD) up(customPhysics ? 'medium' : 'easy', customPhysics ? 'real-time 2D with its own gravity/velocity: owner movement keeps jumps local, the host runs everything else' : 'real-time 2D movement: owner movement');
|
|
165
|
+
if (turnBased) reasons.push('easy: turn-based or grid rules: the host runs the rules from intents (host movement); or a race where everyone plays the same seed');
|
|
166
|
+
if (loc > 6000) up('hard', `${loc} lines of game code: huge state; find the few things other players must see`);
|
|
167
|
+
else if (loc > 2500) up('medium', `${loc} lines of game code`);
|
|
168
|
+
if (ammo && !turnBased) up('medium', 'projectiles and hits: the host decides every hit and carries projectiles in the snapshot');
|
|
169
|
+
if (workers) up('medium', 'it starts Web Workers: a sandboxed frame (opaque origin) cannot load a worker script by URL; inline it or drop it');
|
|
170
|
+
if (network.absolutePaths.length) up('medium', `absolute paths (${network.absolutePaths.slice(0, 3).join(', ')}): the game is served under /<id>/__game/, so every path must be relative`);
|
|
171
|
+
if (grade === 'easy' && !reasons.length) reasons.push('easy: a small game with simple state');
|
|
172
|
+
|
|
173
|
+
const movement = turnBased ? 'host' : 'owner';
|
|
174
|
+
const view = turnBased ? 'board' : camera.firstPerson ? 'first-person' : sideView ? 'side' : 'top';
|
|
175
|
+
const buildMode = pkg?.scripts?.build && Object.keys(deps).some((d) => /^(vite|webpack|parcel|rollup|esbuild|@sveltejs|next)/.test(d)) ? 'command'
|
|
176
|
+
: scripts.some((s) => s.module) || indexText.includes('importmap') ? 'static'
|
|
177
|
+
: 'static';
|
|
178
|
+
|
|
179
|
+
const risks = [];
|
|
180
|
+
if (storage.localStorage || storage.sessionStorage || storage.cookie) risks.push('storage: localStorage/cookies throw in the sandboxed frame; the port toolkit (homie-port.js / port/early) puts in-memory stand-ins first. High scores last for the visit.');
|
|
181
|
+
if (storage.indexedDB) risks.push('indexedDB is blocked in the sandboxed frame: guard every open() or drop it.');
|
|
182
|
+
if (dialogs) risks.push(`${dialogs} alert/confirm/prompt call(s): they block every browser in the room and the host's clock; replace with in-game text.`);
|
|
183
|
+
if (audio.webAudio || audio.media || audio.howler) risks.push('audio: starts suspended; the toolkit resumes it on the first touch/key. Remove any "click to start sound" gate that blocks play.');
|
|
184
|
+
if (input.keys && !input.touch && !input.pointer) risks.push('keyboard only: phones need the touch kit (a floating stick that presses the same keys, small buttons for actions).');
|
|
185
|
+
if (input.mouse && !input.touch) risks.push('mouse input: map it to touch (tap/drag) explicitly; do not rely on emulated mouse events on phones.');
|
|
186
|
+
if (input.pointer && /pointerId\s*[<>]=?\s*0|pointerId\s*===?\s*-1|=\s*-1\s*;?\s*\/\/.*(touch|pointer)/.test(all)) risks.push('a negative pointer id is used as "no finger": iOS Safari ids are negative half the time. Use null.');
|
|
187
|
+
if (loop.raf === 0 && loop.interval > 0) risks.push('the loop runs on setInterval: keep the rules on the host, and make rendering follow snapshots.');
|
|
188
|
+
if (random > 0 && !turnBased) risks.push(`${random} Math.random() call(s): randomness that changes the world must happen on the host only (replicas get the result in snapshots or keyed state).`);
|
|
189
|
+
if (network.cdn.length) risks.push(`loads from other hosts (${network.cdn.join(', ')}): copy those files into the game so it cannot break when a CDN does.`);
|
|
190
|
+
if (camera.perspective && !camera.firstPerson) risks.push('3D camera: verify the owner tests (hold 5 s: straight line, yaw < 10°; alternate 10 s: every press goes the pressed way).');
|
|
191
|
+
if (/antialias\s*:\s*true/.test(all) && threeD) risks.push('MSAA (antialias: true) drew a black world on iPhone WebKit in a past port; turn it off on WebKit or everywhere.');
|
|
192
|
+
if (ammo && !turnBased) risks.push('projectiles/hits: the host decides every hit (a shooter\'s claim may be checked against recent positions); projectiles ride the snapshot.');
|
|
193
|
+
|
|
194
|
+
if (houseApi) risks.push('it is written for a shared TV screen with phone pads (a Homie package): on the web every browser is its own screen and there is no pad. Keep its rules, drive them from netplay seats, keys and the touch kit, and give it a page of its own (index.html that boots the rules and draws them).');
|
|
195
|
+
if (houseApi) up('medium', 'written for a shared TV screen and phone pads: the rules are reusable, the seats and the screen are not');
|
|
196
|
+
const licence = licenceOf(dir);
|
|
197
|
+
if (!licence.file && homiePackage) { licence.kind = 'a Homie package (the house\'s own game; no licence file)'; licence.permissive = true; }
|
|
198
|
+
if (!licence.permissive) risks.push(`licence: ${licence.kind}${licence.file ? ` (${licence.file})` : ''} — publish only what its licence allows; with no licence, do not publish it.`);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
ok: true, command: 'port plan', folder: dir, grade, reasons,
|
|
202
|
+
facts: {
|
|
203
|
+
files: files.length, bytes: totalBytes, loc, index, scripts: scripts.slice(0, 12), engine, physics, customPhysics, loop, input, camera, storage, audio,
|
|
204
|
+
network, workers, wasm, dialogs, random, realtime, boardWords, turnBased, sideView, houseApi, package: pkg ? { name: pkg.name, scripts: Object.keys(pkg.scripts ?? {}), deps: Object.keys(deps).slice(0, 30) } : null,
|
|
205
|
+
},
|
|
206
|
+
recommend: { movement, view, build: buildMode },
|
|
207
|
+
risks, licence,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Copy a game into games/<id>/ as this studio's port of it. */
|
|
212
|
+
export function importPort(root, folder, id, { name, mode } = {}) {
|
|
213
|
+
if (!GAME_ID.test(String(id ?? ''))) throw new Error('give the port an id: --id <lowercase-id> (letters, digits, hyphens)');
|
|
214
|
+
const src = resolve(folder ?? '');
|
|
215
|
+
if (!existsSync(src) || !statSync(src).isDirectory()) throw new Error(`${src} is not a folder`);
|
|
216
|
+
const dest = join(root, 'games', id);
|
|
217
|
+
if (existsSync(dest)) throw new Error(`games/${id} already exists; pick another id`);
|
|
218
|
+
const rel = relative(src, dest);
|
|
219
|
+
if (!rel.startsWith('..') && rel !== '') throw new Error('the game folder cannot contain the studio');
|
|
220
|
+
const plan = planPort(src);
|
|
221
|
+
if (!plan.facts.index) throw new Error(`${src} has no index.html; nothing to port`);
|
|
222
|
+
const buildMode = mode ?? plan.recommend.build;
|
|
223
|
+
mkdirSync(dest, { recursive: true });
|
|
224
|
+
const copied = [];
|
|
225
|
+
const copy = (rel2 = '') => {
|
|
226
|
+
for (const e of readdirSync(join(src, rel2), { withFileTypes: true })) {
|
|
227
|
+
if (SKIP.has(e.name) || (buildMode !== 'command' && e.name === 'dist')) continue;
|
|
228
|
+
const p = rel2 ? `${rel2}/${e.name}` : e.name;
|
|
229
|
+
if (e.isDirectory()) { mkdirSync(join(dest, p), { recursive: true }); copy(p); }
|
|
230
|
+
else if (e.isFile()) { cpSync(join(src, p), join(dest, p)); copied.push(p); }
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
copy();
|
|
234
|
+
// A static game needs index.html at its root (the Worker serves games/<id>/index.html).
|
|
235
|
+
if (buildMode === 'static' && plan.facts.index !== 'index.html') throw new Error(`the game's page is ${plan.facts.index}; move it to the folder's root as index.html first`);
|
|
236
|
+
const title = /<title>([^<]{1,80})<\/title>/i.exec(readFileSync(join(src, plan.facts.index), 'utf8'))?.[1]?.trim();
|
|
237
|
+
const meta = {
|
|
238
|
+
id,
|
|
239
|
+
name: String(name ?? title ?? id).slice(0, 60),
|
|
240
|
+
blurb: '',
|
|
241
|
+
players: { min: 1, max: 8 },
|
|
242
|
+
roundSeconds: 120,
|
|
243
|
+
build: buildMode === 'command' ? { mode: 'command', command: 'npm run build', out: 'dist' } : { mode: buildMode },
|
|
244
|
+
netplay: { v: 1, public: true, movement: plan.recommend.movement },
|
|
245
|
+
port: { from: basename(src), licence: plan.licence.kind, licenceFile: plan.licence.file, grade: plan.grade, view: plan.recommend.view, at: new Date().toISOString() },
|
|
246
|
+
};
|
|
247
|
+
writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
248
|
+
const edits = [];
|
|
249
|
+
if (buildMode === 'static') {
|
|
250
|
+
const indexPath = join(dest, 'index.html');
|
|
251
|
+
let html = readFileSync(indexPath, 'utf8');
|
|
252
|
+
if (!/homie-port\.js/.test(html)) {
|
|
253
|
+
const tag = '<script src="./homie-port.js"></script>';
|
|
254
|
+
// First script in <head>, after a charset declaration if there is one.
|
|
255
|
+
if (/<meta[^>]+charset[^>]*>/i.test(html)) html = html.replace(/<meta[^>]+charset[^>]*>/i, (m) => `${m}\n${tag}`);
|
|
256
|
+
else html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, (m) => `${m}\n${tag}`) : `${tag}\n${html}`;
|
|
257
|
+
edits.push('index.html: loads ./homie-port.js first (sandbox shims, first-touch audio, window.HomiePort)');
|
|
258
|
+
}
|
|
259
|
+
const vp = 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover';
|
|
260
|
+
if (/<meta[^>]+name=["']viewport["'][^>]*>/i.test(html)) {
|
|
261
|
+
html = html.replace(/<meta[^>]+name=["']viewport["'][^>]*>/i, `<meta name="viewport" content="${vp}">`);
|
|
262
|
+
edits.push('index.html: phone-safe viewport (no pinch-zoom under a thumb)');
|
|
263
|
+
} else {
|
|
264
|
+
html = /<meta[^>]+charset[^>]*>/i.test(html) ? html.replace(/<meta[^>]+charset[^>]*>/i, (m) => `${m}\n<meta name="viewport" content="${vp}">`) : html.replace(/<head([^>]*)>/i, (m) => `${m}\n<meta name="viewport" content="${vp}">`);
|
|
265
|
+
edits.push('index.html: added a phone-safe viewport');
|
|
266
|
+
}
|
|
267
|
+
writeFileSync(indexPath, html);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
ok: true, command: 'port import', id, dir: dest, from: src, mode: buildMode, files: copied.length, edits, plan: { grade: plan.grade, movement: plan.recommend.movement, view: plan.recommend.view },
|
|
271
|
+
next: [
|
|
272
|
+
`Write games/${id}/PORT.md: the grade and why, the movement mode, what the host owns, what each browser owns, bots, touch, camera.`,
|
|
273
|
+
buildMode === 'static' ? 'Add the netplay layer as a script after the game\'s own (HomiePort.createRoom …), or an "entry" in game.json bundled to assets/main.js.' : 'Import \'@homie-rocks/studio/port/early\' first, then build the netplay layer with createRoom.',
|
|
274
|
+
`npm run dev (in the background), then: npx --no-install homie-studio port check ${id} --url http://127.0.0.1:8787`,
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
}
|
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. |
|
|
@@ -80,14 +84,27 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
80
84
|
|
|
81
85
|
- \`npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>"\` — a new game from a
|
|
82
86
|
multiplayer starter (one live public room from its first build, bots fill seats).
|
|
87
|
+
- \`npx --no-install homie-studio port plan <folder>\` — read an existing single-player web game and grade
|
|
88
|
+
how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
|
|
89
|
+
owner tests (real touch, a late joiner, a killed host, two browsers finishing a round). The Homie
|
|
90
|
+
plugin's \`port\` skill does the whole job.
|
|
83
91
|
- \`npm run build\` — bundle every game into \`site/dist\`.
|
|
84
92
|
- \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
|
|
85
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).
|
|
86
96
|
- \`npx --no-install homie-studio check <id> --url <site>\` — two headless browsers press Play and must
|
|
87
97
|
land in the same room and finish a round. Run it before you say a game works.
|
|
88
|
-
- \`
|
|
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).
|
|
89
102
|
If Wrangler is not signed in, run \`npx wrangler login\`: the person approves once in
|
|
90
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.
|
|
91
108
|
- \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
|
|
92
109
|
(or call the Homie MCP tool \`studio_publish\`).
|
|
93
110
|
|
|
@@ -109,6 +126,12 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
109
126
|
keychain. Never write a key, token or password into this repository.
|
|
110
127
|
- Never touch a Cloudflare resource this studio did not create (\`studio.json\` says which).
|
|
111
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.
|
|
112
135
|
`;
|
|
113
136
|
}
|
|
114
137
|
|
|
@@ -153,7 +176,12 @@ site/.wrangler/
|
|
|
153
176
|
.dev.vars
|
|
154
177
|
.env
|
|
155
178
|
*.log
|
|
156
|
-
#
|
|
179
|
+
# Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
|
|
180
|
+
games/*/.port/
|
|
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/
|
|
157
185
|
music/**/*.wav
|
|
158
186
|
music/**/*.mp3
|
|
159
187
|
music/**/*.flac
|
|
@@ -168,7 +196,8 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
168
196
|
const studio = {
|
|
169
197
|
name, slug,
|
|
170
198
|
homie: { studio: STUDIO_VERSION, directory: homie },
|
|
171
|
-
|
|
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: [] },
|
|
172
201
|
};
|
|
173
202
|
const pkg = {
|
|
174
203
|
name: `${slug}-studio`,
|
|
@@ -185,12 +214,12 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
185
214
|
'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
|
|
186
215
|
'.gitignore': GITIGNORE,
|
|
187
216
|
'games/README.md': 'One folder per game. Start one with `npx --no-install homie-studio game new <id> --from gem-rush`.\n',
|
|
188
|
-
'music/README.md': 'Songs and stems. `manifest.json` lists them;
|
|
189
|
-
'music/manifest.json': '{ "items": [] }\n',
|
|
190
|
-
'videos/README.md': 'Trailers and
|
|
191
|
-
'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',
|
|
192
221
|
'posts/README.md': 'Markdown posts this studio publishes, one file each.\n',
|
|
193
|
-
'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.
|
|
194
223
|
// The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
|
|
195
224
|
export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
196
225
|
`,
|
|
@@ -248,7 +277,7 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
|
|
|
248
277
|
'npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
|
|
249
278
|
'npm run dev (then: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
|
|
250
279
|
'npm run deploy (then the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish)',
|
|
251
|
-
] };
|
|
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.' };
|
|
252
281
|
}
|
|
253
282
|
|
|
254
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.
|