@playcraft/cli 0.1.67 → 0.1.69

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.
Files changed (45) hide show
  1. package/dist/asset-library/provenance.js +169 -0
  2. package/dist/commands/audio-program.js +82 -0
  3. package/dist/commands/audio.js +2 -0
  4. package/dist/commands/library-messages.js +27 -9
  5. package/dist/commands/library.js +62 -10
  6. package/dist/model-adaptation/project-template.js +56 -3
  7. package/dist/model-adaptation/project.js +15 -1
  8. package/dist/workspace-runtime/batch/freeze.js +15 -2
  9. package/dist/workspace-runtime/batch/git.js +16 -4
  10. package/dist/workspace-runtime/batch/publication.js +96 -0
  11. package/dist/workspace-runtime/batch/source-candidate.js +2 -1
  12. package/dist/workspace-runtime/batch/source-publish.js +26 -15
  13. package/dist/workspace-runtime/persistence/sqlite-worker.js +2 -1
  14. package/dist/workspace-runtime/serve.js +50 -18
  15. package/dist/workspace-runtime/server/audio-assets.js +49 -3
  16. package/dist/workspace-runtime/server/dispatch.js +42 -9
  17. package/dist/workspace-runtime/server/http.js +12 -2
  18. package/dist/workspace-runtime/verify-artifact.js +2 -1
  19. package/dist/workspace-runtime/workspaces/archive.js +28 -3
  20. package/dist/workspace-runtime/workspaces/asset-library-import.js +60 -9
  21. package/dist/workspace-runtime/workspaces/asset-provenance-verifier.js +36 -0
  22. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +6 -2
  23. package/dist/workspace-runtime/workspaces/audio-assets/library-import.js +149 -0
  24. package/dist/workspace-runtime/workspaces/audio-assets/library-package.js +26 -0
  25. package/dist/workspace-runtime/workspaces/audio-assets/program-artifact-audit.js +90 -0
  26. package/dist/workspace-runtime/workspaces/audio-assets/program-browser.js +194 -0
  27. package/dist/workspace-runtime/workspaces/audio-assets/program-build-inputs.js +122 -0
  28. package/dist/workspace-runtime/workspaces/audio-assets/program-engine.js +39 -0
  29. package/dist/workspace-runtime/workspaces/audio-assets/program-package.js +289 -0
  30. package/dist/workspace-runtime/workspaces/audio-assets/program-variant-build.js +217 -0
  31. package/dist/workspace-runtime/workspaces/audio-assets/references.js +107 -0
  32. package/dist/workspace-runtime/workspaces/audio-assets/service.js +133 -1
  33. package/dist/workspace-runtime/workspaces/git.js +7 -0
  34. package/dist/workspace-runtime/workspaces/registry.js +3 -5
  35. package/dist/workspace-runtime/workspaces/remote-attach-marker.js +55 -0
  36. package/package.json +6 -3
  37. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  38. package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  39. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  40. package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  41. package/project-template-v2/.playcraft/skills.lock.json +4 -4
  42. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  43. package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  44. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  45. package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
@@ -0,0 +1,194 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { chmod, mkdtemp, open, readFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { chromium } from 'playwright-core';
7
+ import { AUDIO_DYNAMIC_LIMITS, parseAudioDynamicProgram, serializeAudioDynamicProgram, validateAudioDynamicProgram, } from '@playcraft/common/cloud-workspace/runtime';
8
+ import { audioProgramRendererScript } from './program-engine.js';
9
+ import { AUDIO_RENDER_CHUNK_BYTES, encodeAudioRenderChunk, writeAudioRenderOutput } from './render-worker.js';
10
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
11
+ function inputSnapshot(program, files, stateId) {
12
+ const checked = parseAudioDynamicProgram(serializeAudioDynamicProgram(validateAudioDynamicProgram(program)));
13
+ const state = checked.states.find(item => item.id === stateId);
14
+ if (!state)
15
+ throw new Error('invalid_audio_state');
16
+ if (!files || ![Object.prototype, null].includes(Object.getPrototypeOf(files)))
17
+ throw new Error('audio_dependency_mismatch');
18
+ const dependencies = checked.assets.flatMap(asset => asset.kind === 'synth' ? [asset.source, asset.manifest] : [asset.file]);
19
+ if (dependencies.reduce((sum, ref) => sum + ref.size, 0) > AUDIO_DYNAMIC_LIMITS.inputBytes)
20
+ throw new Error('audio_program_budget_exceeded');
21
+ const encoded = Object.create(null);
22
+ let total = 0;
23
+ for (const [path, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(files))) {
24
+ if (!('value' in descriptor) || !(descriptor.value instanceof Uint8Array))
25
+ throw new Error('audio_dependency_mismatch');
26
+ total += descriptor.value.byteLength;
27
+ if (total > AUDIO_DYNAMIC_LIMITS.inputBytes)
28
+ throw new Error('audio_program_budget_exceeded');
29
+ const bytes = Buffer.from(descriptor.value), references = dependencies.filter(ref => ref.path === path);
30
+ if (!references.length || references.some(ref => bytes.length !== ref.size || digest(bytes) !== ref.contentHash))
31
+ throw new Error('audio_dependency_mismatch');
32
+ encoded[path] = bytes.toString('base64');
33
+ }
34
+ if (dependencies.some(ref => !Object.hasOwn(encoded, ref.path)))
35
+ throw new Error('audio_dependency_missing');
36
+ return { program: checked, files: encoded, stateId, frames: Math.ceil(state.durationSeconds * 44100) };
37
+ }
38
+ function checkedMetrics(value, frames) {
39
+ if (!value || value.frames !== frames || value.channels !== 1 || value.sampleRate !== 44100
40
+ || !Number.isFinite(value.peak) || value.peak < 0 || !Number.isFinite(value.rms) || value.rms < 0
41
+ || value.rms > value.peak + 1e-12 || typeof value.silent !== 'boolean' || value.silent !== (value.peak === 0))
42
+ throw new Error('invalid_audio_output');
43
+ if (value.peak > .98)
44
+ throw new Error('render_peak_exceeded');
45
+ return value;
46
+ }
47
+ async function verifyPcm(path, frames, expectedHash, assertActive) {
48
+ const hash = createHash('sha256');
49
+ let bytes = 0, peak = 0, energy = 0;
50
+ for await (const chunk of createReadStream(path, { highWaterMark: AUDIO_RENDER_CHUNK_BYTES })) {
51
+ assertActive();
52
+ bytes += chunk.length;
53
+ if (bytes > frames * 4 || chunk.length % 4)
54
+ throw new Error('invalid_audio_output');
55
+ hash.update(chunk);
56
+ for (let offset = 0; offset < chunk.length; offset += 4) {
57
+ const sample = chunk.readFloatLE(offset);
58
+ if (!Number.isFinite(sample))
59
+ throw new Error('invalid_audio_output');
60
+ peak = Math.max(peak, Math.abs(sample));
61
+ energy += sample * sample;
62
+ }
63
+ }
64
+ if (bytes !== frames * 4 || hash.digest('hex') !== expectedHash)
65
+ throw new Error('invalid_audio_output');
66
+ return checkedMetrics({ frames, channels: 1, sampleRate: 44100, peak, rms: Math.sqrt(energy / frames), silent: peak === 0 }, frames);
67
+ }
68
+ async function readWave(path, frames, assertActive) {
69
+ const file = await open(path, 'r');
70
+ try {
71
+ const size = 44 + frames * 2;
72
+ if ((await file.stat()).size !== size)
73
+ throw new Error('invalid_audio_output');
74
+ const bytes = Buffer.alloc(size);
75
+ for (let offset = 0; offset < size;) {
76
+ assertActive();
77
+ const { bytesRead } = await file.read(bytes, offset, Math.min(AUDIO_RENDER_CHUNK_BYTES, size - offset), offset);
78
+ if (!bytesRead)
79
+ throw new Error('invalid_audio_output');
80
+ offset += bytesRead;
81
+ }
82
+ if (bytes.toString('ascii', 0, 4) !== 'RIFF' || bytes.toString('ascii', 8, 12) !== 'WAVE'
83
+ || bytes.toString('ascii', 12, 16) !== 'fmt ' || bytes.toString('ascii', 36, 40) !== 'data'
84
+ || bytes.readUInt32LE(4) !== size - 8 || bytes.readUInt32LE(16) !== 16 || bytes.readUInt16LE(20) !== 1
85
+ || bytes.readUInt16LE(22) !== 1 || bytes.readUInt32LE(24) !== 44100 || bytes.readUInt32LE(28) !== 88200
86
+ || bytes.readUInt16LE(32) !== 2 || bytes.readUInt16LE(34) !== 16 || bytes.readUInt32LE(40) !== frames * 2)
87
+ throw new Error('invalid_audio_output');
88
+ return bytes;
89
+ }
90
+ finally {
91
+ await file.close();
92
+ }
93
+ }
94
+ /** The compile caller owns this process; only fixed platform code is executed by its owned browser. */
95
+ export async function renderAudioProgramInBrowser(program, files, stateId, signal) {
96
+ const uid = process.getuid?.();
97
+ if (typeof uid !== 'number' || uid <= 0)
98
+ throw new Error('audio_renderer_identity_unavailable');
99
+ let cancelled = signal?.aborted === true, timedOut = false, browser, closing, scratch;
100
+ const assertActive = () => { if (timedOut)
101
+ throw new Error('audio_render_timeout'); if (cancelled || signal?.aborted)
102
+ throw new Error('audio_render_cancelled'); };
103
+ const closeOwnedBrowser = () => browser ? closing ??= browser.close() : Promise.resolve();
104
+ const cancel = () => { cancelled = true; void closeOwnedBrowser().catch(() => undefined); };
105
+ assertActive();
106
+ const snapshot = inputSnapshot(program, files, stateId);
107
+ signal?.addEventListener('abort', cancel, { once: true });
108
+ process.once('SIGTERM', cancel);
109
+ const deadline = setTimeout(() => { timedOut = true; cancel(); }, 60_000);
110
+ deadline.unref?.();
111
+ try {
112
+ assertActive();
113
+ const engine = await audioProgramRendererScript();
114
+ if (Buffer.byteLength(engine.script) > 512 * 1024 || !/^[a-f0-9]{64}$/.test(engine.rendererBuildHash)
115
+ || digest(engine.script) !== engine.rendererBuildHash)
116
+ throw new Error('engine_build_mismatch');
117
+ assertActive();
118
+ scratch = await mkdtemp(join(tmpdir(), 'playcraft-audio-program-'));
119
+ await chmod(scratch, 0o700);
120
+ const output = join(scratch, 'render');
121
+ browser = await chromium.launch({ executablePath: process.env.PLAYCRAFT_CHROMIUM_PATH || '/usr/bin/chromium',
122
+ headless: true, chromiumSandbox: true, timeout: 8000, handleSIGTERM: false, handleSIGINT: false, handleSIGHUP: false,
123
+ args: ['--mute-audio', '--disable-background-networking'] });
124
+ assertActive();
125
+ const chromiumVersion = browser.version();
126
+ const context = await browser.newContext({ serviceWorkers: 'block', acceptDownloads: false });
127
+ await context.route('**/*', route => route.abort('blockedbyclient'));
128
+ const page = await context.newPage();
129
+ await page.addScriptTag({ content: engine.script });
130
+ assertActive();
131
+ const rendered = await page.evaluateHandle(async ({ program, files, stateId, frames }) => {
132
+ const engine = globalThis.playcraftAudioProgram;
133
+ const decoded = Object.create(null);
134
+ let size = 0;
135
+ for (const [path, value] of Object.entries(files)) {
136
+ const text = atob(value);
137
+ size += text.length;
138
+ if (size > 2 * 1024 * 1024)
139
+ throw new Error('audio_program_budget_exceeded');
140
+ const bytes = new Uint8Array(text.length);
141
+ for (let index = 0; index < text.length; index++)
142
+ bytes[index] = text.charCodeAt(index);
143
+ decoded[path] = bytes;
144
+ }
145
+ const buffer = await engine.renderAudioDynamicProgram(program, decoded, stateId);
146
+ if (buffer.length !== frames || buffer.length > 2_646_000 || buffer.numberOfChannels !== 1 || buffer.sampleRate !== 44100)
147
+ throw new Error('invalid_audio_output');
148
+ const metrics = engine.soundPcmMetrics(buffer);
149
+ if (metrics.peak > .98)
150
+ throw new Error('render_peak_exceeded');
151
+ return { buffer, wav: engine.soundBufferToWav(buffer), metrics };
152
+ }, snapshot);
153
+ let summary;
154
+ try {
155
+ summary = await rendered.evaluate(value => ({ frames: value.buffer.length, wavBytes: value.wav.byteLength, metrics: value.metrics }));
156
+ checkedMetrics(summary.metrics, snapshot.frames);
157
+ assertActive();
158
+ await writeAudioRenderOutput(output, summary, chromiumVersion, request => {
159
+ assertActive();
160
+ return rendered.evaluate(encodeAudioRenderChunk, request);
161
+ }, snapshot.frames);
162
+ }
163
+ finally {
164
+ await rendered.dispose();
165
+ }
166
+ await closeOwnedBrowser();
167
+ assertActive();
168
+ const meta = JSON.parse(await readFile(`${output}.json`, 'utf8'));
169
+ if (!/^[a-f0-9]{64}$/.test(meta.pcmContentHash))
170
+ throw new Error('invalid_audio_output');
171
+ const metrics = await verifyPcm(`${output}.pcm`, snapshot.frames, meta.pcmContentHash, assertActive);
172
+ if (metrics.peak !== summary.metrics.peak || Math.abs(metrics.rms - summary.metrics.rms) > 1e-12)
173
+ throw new Error('invalid_audio_output');
174
+ const wav = await readWave(`${output}.wav`, snapshot.frames, assertActive);
175
+ assertActive();
176
+ return { wav, pcmContentHash: meta.pcmContentHash, chromiumVersion, metrics, rendererBuildHash: engine.rendererBuildHash };
177
+ }
178
+ catch (error) {
179
+ assertActive();
180
+ throw error;
181
+ }
182
+ finally {
183
+ try {
184
+ await closeOwnedBrowser();
185
+ }
186
+ finally {
187
+ signal?.removeEventListener('abort', cancel);
188
+ process.removeListener('SIGTERM', cancel);
189
+ clearTimeout(deadline);
190
+ if (scratch)
191
+ await rm(scratch, { recursive: true, force: true });
192
+ }
193
+ }
194
+ }
@@ -0,0 +1,122 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, opendir, realpath } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, relative, sep } from 'node:path';
4
+ import { canonicalAudioJson } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { readAudioProgramPackage } from './program-package.js';
6
+ import { audioError, audioProjectPath, digest } from './storage.js';
7
+ import { ensureSharedDirectory, writeSharedFile } from '../shared-write.js';
8
+ export const AUDIO_PROGRAM_BUILD_PROTOCOL = 1;
9
+ const HASH = /^[a-f0-9]{64}$/;
10
+ const MAX_PACKAGES = 256, MAX_ENTRIES = 8192, MAX_BYTES = 64 * 1024 * 1024;
11
+ /** A caller must provide an already-created build clone, never a source alias or ancestor. */
12
+ export async function assertAudioProgramBuildClone(sourceRoot, cloneRoot) {
13
+ const [source, clone, stat] = await Promise.all([realpath(sourceRoot), realpath(cloneRoot), lstat(cloneRoot)]);
14
+ const rel = relative(source, clone), ancestor = relative(clone, source);
15
+ if (!stat.isDirectory() || stat.isSymbolicLink() || !rel || !ancestor || !ancestor.startsWith(`..${sep}`) && ancestor !== '..' && !isAbsolute(ancestor)
16
+ || !rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel) && !rel.startsWith(`.playcraft-delivery${sep}`))
17
+ throw audioError('invalid_audio_build_clone', 400);
18
+ }
19
+ async function manifests(root) {
20
+ const found = [];
21
+ let entries = 0;
22
+ const visit = async (path, depth) => {
23
+ const absolute = await audioProjectPath(root, path);
24
+ const stat = await lstat(absolute).catch(error => { if (error.code === 'ENOENT')
25
+ return undefined; throw error; });
26
+ if (!stat)
27
+ return;
28
+ if (!stat.isDirectory() || stat.isSymbolicLink())
29
+ throw audioError('invalid_audio_path', 400);
30
+ const directory = await opendir(absolute, { bufferSize: 32 });
31
+ for await (const entry of directory) {
32
+ if (++entries > MAX_ENTRIES)
33
+ throw audioError('audio_build_budget_exceeded', 422);
34
+ if (depth === 3) {
35
+ if (entry.name === 'manifest.json') {
36
+ if (!entry.isFile() || entry.isSymbolicLink())
37
+ throw audioError('audio_program_package_invalid', 422);
38
+ if (found.length >= MAX_PACKAGES)
39
+ throw audioError('audio_build_budget_exceeded', 422);
40
+ found.push(`${path}/manifest.json`);
41
+ }
42
+ }
43
+ else if (HASH.test(entry.name)) {
44
+ if (!entry.isDirectory() || entry.isSymbolicLink())
45
+ throw audioError('audio_program_package_invalid', 422);
46
+ await visit(`${path}/${entry.name}`, depth + 1);
47
+ }
48
+ }
49
+ };
50
+ await visit('dist/audio/programs', 0);
51
+ return found.sort();
52
+ }
53
+ async function currentSource(root, path) {
54
+ const absolute = await audioProjectPath(root, path);
55
+ const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
56
+ .catch(error => { if (error.code === 'ENOENT')
57
+ return undefined; throw error; });
58
+ if (!handle)
59
+ return undefined;
60
+ try {
61
+ const before = await handle.stat();
62
+ if (!before.isFile() || before.nlink !== 1 || before.size > 65536)
63
+ throw audioError('invalid_audio_program', 400);
64
+ const bytes = await handle.readFile(), after = await handle.stat();
65
+ if (bytes.length !== before.size || before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
66
+ throw audioError('audio_build_input_changed');
67
+ return bytes;
68
+ }
69
+ finally {
70
+ await handle.close();
71
+ }
72
+ }
73
+ /** The authoritative reader verifies the complete program package before any ignored output joins a clone. */
74
+ export async function readAudioProgramBuildInputs(root, destination) {
75
+ if (destination)
76
+ await assertAudioProgramBuildClone(root, destination);
77
+ const packages = [], copied = new Map();
78
+ let total = 0;
79
+ const capture = async (path, bytes) => {
80
+ const contentHash = digest(bytes), old = copied.get(path);
81
+ if (old && (old.contentHash !== contentHash || old.size !== bytes.length))
82
+ throw audioError('audio_build_input_changed');
83
+ if (!old) {
84
+ if ((total += bytes.length) > MAX_BYTES)
85
+ throw audioError('audio_build_budget_exceeded', 422);
86
+ copied.set(path, { contentHash, size: bytes.length });
87
+ if (destination) {
88
+ const target = await audioProjectPath(destination, path, true);
89
+ await ensureSharedDirectory(destination, dirname(target));
90
+ await writeSharedFile(destination, target, bytes);
91
+ }
92
+ }
93
+ return { path, contentHash, size: bytes.length };
94
+ };
95
+ for (const manifestPath of await manifests(root)) {
96
+ let bundle;
97
+ try {
98
+ bundle = await readAudioProgramPackage(root, manifestPath);
99
+ }
100
+ catch (error) {
101
+ throw Object.assign(audioError('audio_program_package_invalid', 422), { details: { manifestPath,
102
+ reason: typeof error?.code === 'string' ? error.code : 'validation_failed' } });
103
+ }
104
+ const source = await currentSource(root, bundle.sourcePath), files = [];
105
+ for (const [path, bytes] of [...bundle.files].sort(([a], [b]) => a.localeCompare(b)))
106
+ files.push(await capture(path, bytes));
107
+ if (source !== undefined)
108
+ await capture(bundle.sourcePath, source);
109
+ packages.push({ manifestPath, sourcePath: bundle.sourcePath, sourceContentHash: bundle.sourceContentHash,
110
+ currentSourceContentHash: source === undefined ? null : digest(source), semanticHash: bundle.semanticHash,
111
+ runtimeModulePath: bundle.runtimeModulePath, prerenderModulePath: bundle.prerenderModulePath, files });
112
+ }
113
+ return { version: 1, inputHash: digest(canonicalAudioJson(packages)), packages };
114
+ }
115
+ export async function verifyAudioProgramBuildInputs(root, snapshot) {
116
+ if (snapshot.version !== 1 || digest(canonicalAudioJson(snapshot.packages)) !== snapshot.inputHash
117
+ || (await readAudioProgramBuildInputs(root)).inputHash !== snapshot.inputHash)
118
+ throw audioError('audio_build_input_changed');
119
+ }
120
+ export function markAudioProgramBuildInputs(bytes, snapshot) {
121
+ return snapshot.packages.length ? Buffer.concat([Buffer.from(`<!--playcraft-audio-program-inputs:v1:${snapshot.inputHash}-->\n`), bytes]) : bytes;
122
+ }
@@ -0,0 +1,39 @@
1
+ import { createRequire } from 'node:module';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { assertUniqueJsonKeys, audioSha256, AudioRenderError } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { audioRendererScript } from './renderer.js';
6
+ import { audioError, digest } from './storage.js';
7
+ const strip = (text) => text.replace(/^export\s+(?=(?:async\s+)?function\b|const\b|class\b|let\b)/gm, '')
8
+ .replace(/^\/\/# sourceMappingURL=.*$/gm, '');
9
+ async function body(name, permitted) {
10
+ const entry = createRequire(import.meta.url).resolve('@playcraft/common/cloud-workspace/runtime');
11
+ const source = await readFile(join(dirname(entry), name), 'utf8');
12
+ const pattern = /^import\s+[\s\S]*?\s+from\s+['"]([^'"]+)['"];?\s*$/gm;
13
+ if ([...source.matchAll(pattern)].some(match => !permitted.includes(match[1])))
14
+ throw audioError('engine_unavailable', 503);
15
+ const result = source.replace(pattern, '');
16
+ if (/^import\s|\bfrom\s+['"]/m.test(result))
17
+ throw audioError('engine_unavailable', 503);
18
+ return strip(result);
19
+ }
20
+ /** Only bundled platform code is executable; project dependencies remain validated bytes. */
21
+ export async function audioProgramRendererScript() {
22
+ const score = await audioRendererScript('score');
23
+ const document = await body('audio-dynamic-document.js', ['./audio-document.js', './audio-synthesizer.js']);
24
+ const hybrid = await body('audio-hybrid-player.js', ['./audio-document.js', './audio-synthesizer.js', './score-synthesizer.js', './audio-dynamic-document.js']);
25
+ const script = `${score.script}\n${audioSha256.toString()}\n${assertUniqueJsonKeys.toString()}\n${document}\n${hybrid}\n` +
26
+ 'globalThis.playcraftAudioProgram={renderAudioDynamicProgram,soundBufferToWav,soundPcmMetrics};';
27
+ return { script, rendererBuildHash: digest(script) };
28
+ }
29
+ export async function audioProgramGameRuntime() {
30
+ const { script, rendererBuildHash } = await audioProgramRendererScript();
31
+ const code = script.replace(/^globalThis\.playcraftAudio(?:Program)?\s*=.*;?$/gm, '') +
32
+ '\nexport {createAudioHybridPlayer,createStandaloneAudioPlayer,createStandaloneSoundPlayer};\n';
33
+ return { code, contentHash: digest(code), rendererBuildHash };
34
+ }
35
+ export async function audioProgramPrerenderRuntime() {
36
+ const player = await body('audio-prerender-player.js', ['./audio-document.js', './audio-synthesizer.js']);
37
+ const code = `${AudioRenderError.toString()}\n${audioSha256.toString()}\n${player}\nexport {createAudioPrerenderProgramPlayer};\n`;
38
+ return { code, contentHash: digest(code) };
39
+ }
@@ -0,0 +1,289 @@
1
+ import { constants } from 'node:fs';
2
+ import { link, open, rename, rm } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { dirname, posix } from 'node:path';
5
+ import { AUDIO_DYNAMIC_ENGINE, AUDIO_DYNAMIC_LIMITS, parseAudioDynamicProgram, audioDynamicProgramSemanticHash, canonicalAudioJson, isAudioDynamicProgramPath, readAudioHybridSample, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { audioProjectPath, audioError, digest } from './storage.js';
7
+ import { readAudioArtifactPackage } from './artifact-package.js';
8
+ import { audioRendererScript } from './renderer.js';
9
+ import { audioProgramGameRuntime, audioProgramPrerenderRuntime } from './program-engine.js';
10
+ import { renderAudioProgramInBrowser } from './program-browser.js';
11
+ import { ensureSharedDirectory, openSharedFile } from '../shared-write.js';
12
+ const MAX_PACKAGE = 64 * 1024 * 1024, MAX_WAV = 16 * 1024 * 1024;
13
+ const json = (value) => Buffer.from(canonicalAudioJson(value) + '\n');
14
+ const validHash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
15
+ const fail = () => audioError('invalid_audio_program_package', 400);
16
+ const role = (path, local) => local ? path.endsWith('.json') ? 'source' : 'module'
17
+ : path.endsWith('.wav') ? 'audio' : path.endsWith('runtime.mjs') ? 'runtime' : path.endsWith('manifest.json') ? 'manifest' : path.endsWith('.json') ? 'source' : 'module';
18
+ function verifyWave(data, frames) {
19
+ if (data.length !== 44 + frames * 2 || data.toString('ascii', 0, 4) !== 'RIFF' || data.toString('ascii', 8, 12) !== 'WAVE'
20
+ || data.toString('ascii', 12, 16) !== 'fmt ' || data.toString('ascii', 36, 40) !== 'data' || data.readUInt32LE(4) !== data.length - 8
21
+ || data.readUInt32LE(16) !== 16 || data.readUInt16LE(20) !== 1 || data.readUInt16LE(22) !== 1 || data.readUInt32LE(24) !== 44100
22
+ || data.readUInt32LE(28) !== 88200 || data.readUInt16LE(32) !== 2 || data.readUInt16LE(34) !== 16 || data.readUInt32LE(40) !== frames * 2)
23
+ throw fail();
24
+ for (let offset = 44; offset < data.length; offset += 2)
25
+ if (Math.abs(data.readInt16LE(offset) / 32768) > .98005)
26
+ throw fail();
27
+ }
28
+ export async function readAudioProgramFile(root, path, maximum = MAX_PACKAGE) {
29
+ const handle = await open(await audioProjectPath(root, path), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
30
+ try {
31
+ const before = await handle.stat();
32
+ if (!before.isFile() || before.nlink !== 1 || before.size < 1 || before.size > maximum)
33
+ throw fail();
34
+ const data = await handle.readFile(), after = await handle.stat();
35
+ if (data.length !== before.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
36
+ throw fail();
37
+ return data;
38
+ }
39
+ finally {
40
+ await handle.close();
41
+ }
42
+ }
43
+ /** Project JavaScript is never imported. Synth dependencies must match the bundled platform engine. */
44
+ async function inputs(root, program) {
45
+ const files = Object.create(null), closure = new Map();
46
+ for (const asset of program.assets) {
47
+ if (asset.kind === 'synth') {
48
+ const artifact = await readAudioArtifactPackage(root, asset.source.path);
49
+ const directory = posix.dirname(asset.source.path), source = artifact.files.get(asset.source.path);
50
+ const engine = await audioRendererScript(asset.source.path.endsWith('.pcscore.json') ? 'score' : 'synth-sfx');
51
+ const suffix = asset.source.path.endsWith('.pcscore.json')
52
+ ? '\nexport { renderSoundDocument, createStandaloneSoundPlayer };\n'
53
+ : '\nexport { renderAudioDocument, createStandaloneAudioPlayer };\n';
54
+ if (!artifact.files.get(`${directory}/runtime.mjs`)?.equals(Buffer.from(engine.script + suffix)))
55
+ throw audioError('engine_build_mismatch', 409);
56
+ for (const [path, bytes] of artifact.files)
57
+ closure.set(path, bytes);
58
+ files[asset.source.path] = source;
59
+ files[asset.manifest.path] = artifact.files.get(asset.manifest.path);
60
+ }
61
+ else {
62
+ const data = await readAudioProgramFile(root, asset.file.path, AUDIO_DYNAMIC_LIMITS.sampleBytes);
63
+ readAudioHybridSample(data);
64
+ files[asset.file.path] = data;
65
+ closure.set(asset.file.path, data);
66
+ }
67
+ }
68
+ const refs = program.assets.flatMap(asset => asset.kind === 'synth' ? [asset.source, asset.manifest] : [asset.file]);
69
+ if (refs.some(ref => !files[ref.path] || files[ref.path].length !== ref.size || digest(files[ref.path]) !== ref.contentHash))
70
+ throw audioError('audio_dependency_mismatch', 409);
71
+ if (Object.values(files).reduce((sum, bytes) => sum + bytes.length, 0) > AUDIO_DYNAMIC_LIMITS.inputBytes)
72
+ throw audioError('audio_program_budget_exceeded', 400);
73
+ return { files, closure };
74
+ }
75
+ function descriptor(program, states) {
76
+ return { semanticHash: audioDynamicProgramSemanticHash(program), transition: program.transition,
77
+ initialState: program.initialState, states: states.map(({ id, durationSeconds, path, contentHash, size }) => ({ id, durationSeconds, path, contentHash, size })) };
78
+ }
79
+ function moduleText(directory, runtimePath, factory, configuration, files) {
80
+ let importPath = posix.relative(directory, runtimePath);
81
+ if (!importPath.startsWith('.'))
82
+ importPath = './' + importPath;
83
+ const encoded = Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)).map(([path, data]) => [path, Buffer.from(data).toString('base64')]));
84
+ return Buffer.from(`import {${factory}} from ${JSON.stringify(importPath)};\nconst configuration=${JSON.stringify(configuration)};\nconst encoded=${JSON.stringify(encoded)};\n` +
85
+ 'function dependencies(){return Object.fromEntries(Object.entries(encoded).map(([path,text])=>[path,Uint8Array.from(atob(text),character=>character.charCodeAt(0))]));}\n' +
86
+ `export const semanticHash=${JSON.stringify(configuration.semanticHash ?? audioDynamicProgramSemanticHash(configuration))};\n` +
87
+ 'export function createSound(context){if(!context){const key=Symbol.for("playcraft.audio.context.v1");context=globalThis[key];if(!context||context.state==="closed"){const Constructor=globalThis.AudioContext||globalThis.webkitAudioContext;if(!Constructor)throw new Error("audio_context_unavailable");context=globalThis[key]=new Constructor();}}' +
88
+ `return Object.assign(${factory}(context,configuration,dependencies()),{representation:${JSON.stringify(factory === 'createAudioHybridPlayer'
89
+ ? 'playcraft.audio-program.runtime.v1' : 'playcraft.audio-program.prerender.v1')}});}\n`);
90
+ }
91
+ async function representations(program, files, states, directory) {
92
+ const runtime = await audioProgramGameRuntime(), prerender = await audioProgramPrerenderRuntime();
93
+ const runtimePath = `dist/audio/shared/${runtime.contentHash}/runtime.mjs`;
94
+ const prerenderPath = `dist/audio/shared/${prerender.contentHash}/runtime.mjs`;
95
+ return { runtime, prerender, runtimePath, prerenderPath,
96
+ module: moduleText(directory, runtimePath, 'createAudioHybridPlayer', program, files),
97
+ prerenderModule: (waves) => moduleText(directory, prerenderPath, 'createAudioPrerenderProgramPlayer', descriptor(program, states), waves) };
98
+ }
99
+ function member(path, data, role, local) {
100
+ return { path, contentHash: digest(data), size: data.length, role, local };
101
+ }
102
+ function paths(directory, manifest, runtimePath, prerenderPath) {
103
+ const toFull = (file) => ({ path: file.local ? `${directory}/${file.path}` : file.path,
104
+ contentHash: file.contentHash, size: file.size, role: file.role });
105
+ const runtime = manifest.files.filter(file => file.local && file.path === 'program.mjs' || !file.local && file.path === runtimePath).map(toFull);
106
+ const prerender = manifest.files.filter(file => file.local && file.path === 'prerender.mjs' || !file.local && file.path === prerenderPath).map(toFull);
107
+ return { runtimeModulePath: `${directory}/program.mjs`, prerenderModulePath: `${directory}/prerender.mjs`, runtimeMembers: runtime, prerenderMembers: prerender };
108
+ }
109
+ /** Compile a separate advanced state program without changing any existing sound revision. */
110
+ export async function compileAudioProgram(root, sourcePath, signal) {
111
+ if (!isAudioDynamicProgramPath(sourcePath) || sourcePath.startsWith('dist/'))
112
+ throw audioError('invalid_audio_path', 400);
113
+ const sourceBytes = await readAudioProgramFile(root, sourcePath, 65536), program = parseAudioDynamicProgram(sourceBytes);
114
+ if (program.states.reduce((sum, state) => sum + Math.ceil(state.durationSeconds * 44100) * 2 + 44, 0) > MAX_WAV)
115
+ throw audioError('audio_program_budget_exceeded', 400);
116
+ const { files: inputFiles, closure } = await inputs(root, program), states = [], waves = {};
117
+ const game = await audioProgramGameRuntime();
118
+ for (const state of program.states) {
119
+ const result = await renderAudioProgramInBrowser(program, inputFiles, state.id, signal);
120
+ if (result.rendererBuildHash !== game.rendererBuildHash)
121
+ throw audioError('engine_build_mismatch', 409);
122
+ const contentHash = digest(result.wav), path = `dist/audio/shared/${contentHash}/state.wav`;
123
+ waves[path] = result.wav;
124
+ states.push({ id: state.id, durationSeconds: state.durationSeconds, path, contentHash,
125
+ size: result.wav.length, pcmContentHash: result.pcmContentHash, chromiumVersion: result.chromiumVersion, frames: result.metrics.frames });
126
+ }
127
+ const sourceContentHash = digest(sourceBytes), parent = `dist/audio/programs/${digest(sourcePath)}/${sourceContentHash}`;
128
+ const placeholder = `${parent}/${'0'.repeat(64)}`;
129
+ const variants = await representations(program, inputFiles, states, placeholder);
130
+ const local = new Map([['program.pcaudioprogram.json', sourceBytes], ['program.mjs', variants.module], ['prerender.mjs', variants.prerenderModule(waves)]]);
131
+ const external = new Map(closure);
132
+ external.set(variants.runtimePath, Buffer.from(variants.runtime.code));
133
+ external.set(variants.prerenderPath, Buffer.from(variants.prerender.code));
134
+ for (const [path, bytes] of Object.entries(waves))
135
+ external.set(path, Buffer.from(bytes));
136
+ if ([...external.values(), ...local.values()].reduce((sum, data) => sum + data.length, 0) > MAX_PACKAGE)
137
+ throw audioError('audio_program_budget_exceeded', 400);
138
+ const members = [...local].map(([path, data]) => member(path, data, role(path, true), true))
139
+ .concat([...external].map(([path, data]) => member(path, data, role(path, false), false)))
140
+ .sort((a, b) => a.path.localeCompare(b.path));
141
+ const payload = { format: 'playcraft.audio-program-package', version: 1, sourcePath, sourceContentHash,
142
+ semanticHash: audioDynamicProgramSemanticHash(program), engine: AUDIO_DYNAMIC_ENGINE, rendererBuildHash: game.rendererBuildHash, states, files: members };
143
+ const manifest = { ...payload, representationSetHash: digest(json(payload)) };
144
+ const directory = `${parent}/${manifest.representationSetHash}`;
145
+ if (!sourceBytes.equals(await readAudioProgramFile(root, sourcePath, 65536)))
146
+ throw audioError('audio_revision_conflict', 409);
147
+ // Shared content is immutable. Exclusive writes never replace another package's bytes.
148
+ for (const [path, bytes] of external)
149
+ await immutable(root, path, bytes);
150
+ const target = await audioProjectPath(root, directory, true);
151
+ const stagingPath = `${parent}/stage-${randomUUID()}`, staging = await audioProjectPath(root, stagingPath);
152
+ await ensureSharedDirectory(root, staging);
153
+ try {
154
+ for (const [path, bytes] of [...local, ['manifest.json', json(manifest)]])
155
+ await immutable(root, `${stagingPath}/${path}`, bytes);
156
+ try {
157
+ await rename(staging, target);
158
+ }
159
+ catch (error) {
160
+ if (!['EEXIST', 'ENOTEMPTY'].includes(error.code ?? ''))
161
+ throw error;
162
+ await readAudioProgramPackage(root, `${directory}/manifest.json`);
163
+ }
164
+ }
165
+ finally {
166
+ await rm(staging, { recursive: true, force: true });
167
+ }
168
+ return readAudioProgramPackage(root, `${directory}/manifest.json`);
169
+ }
170
+ async function immutable(root, path, data) {
171
+ const target = await audioProjectPath(root, path, true);
172
+ await ensureSharedDirectory(root, dirname(target));
173
+ const existing = await readAudioProgramFile(root, path).catch(error => { if (error.code === 'ENOENT')
174
+ return undefined; throw error; });
175
+ if (existing) {
176
+ if (!existing.equals(data))
177
+ throw audioError('audio_package_conflict', 409);
178
+ return;
179
+ }
180
+ const temporary = `${target}.${randomUUID()}.tmp`;
181
+ let collided = false;
182
+ try {
183
+ const handle = await openSharedFile(root, temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
184
+ try {
185
+ await handle.writeFile(data);
186
+ await handle.sync();
187
+ }
188
+ finally {
189
+ await handle.close();
190
+ }
191
+ // An exclusive hard-link publishes complete bytes without replacing a concurrent writer.
192
+ try {
193
+ await link(temporary, target);
194
+ }
195
+ catch (error) {
196
+ if (error.code !== 'EEXIST')
197
+ throw error;
198
+ collided = true;
199
+ }
200
+ }
201
+ finally {
202
+ await rm(temporary, { force: true });
203
+ }
204
+ if (collided && !(await readAudioProgramFile(root, path)).equals(data))
205
+ throw audioError('audio_package_conflict', 409);
206
+ const parent = await open(dirname(target), 'r');
207
+ try {
208
+ await parent.sync();
209
+ }
210
+ finally {
211
+ await parent.close();
212
+ }
213
+ }
214
+ /** Verify every byte and regenerate both fixed module templates before a build can use them. */
215
+ export async function readAudioProgramPackage(root, manifestPath) {
216
+ if (!/^dist\/audio\/programs\/[a-f0-9]{64}\/[a-f0-9]{64}\/[a-f0-9]{64}\/manifest\.json$/.test(manifestPath))
217
+ throw fail();
218
+ const manifestBytes = await readAudioProgramFile(root, manifestPath, 65536);
219
+ let manifest;
220
+ try {
221
+ manifest = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(manifestBytes));
222
+ }
223
+ catch {
224
+ throw fail();
225
+ }
226
+ if (!manifest || Array.isArray(manifest) || Object.keys(manifest).sort().join(',') !== 'engine,files,format,rendererBuildHash,representationSetHash,semanticHash,sourceContentHash,sourcePath,states,version'
227
+ || manifest.format !== 'playcraft.audio-program-package' || manifest.version !== 1 || !validHash(manifest.sourceContentHash)
228
+ || !validHash(manifest.semanticHash) || !validHash(manifest.rendererBuildHash) || !validHash(manifest.representationSetHash)
229
+ || typeof manifest.sourcePath !== 'string' || !isAudioDynamicProgramPath(manifest.sourcePath) || manifest.sourcePath.startsWith('dist/')
230
+ || canonicalAudioJson(manifest.engine) !== canonicalAudioJson(AUDIO_DYNAMIC_ENGINE) || !Array.isArray(manifest.files) || manifest.files.length > 80
231
+ || !Array.isArray(manifest.states) || manifest.states.length < 1 || manifest.states.length > 16 || !manifestBytes.equals(json(manifest)))
232
+ throw fail();
233
+ const { representationSetHash, ...payload } = manifest, directory = posix.dirname(manifestPath);
234
+ if (digest(json(payload)) !== representationSetHash || directory !== `dist/audio/programs/${digest(manifest.sourcePath)}/${manifest.sourceContentHash}/${representationSetHash}`)
235
+ throw fail();
236
+ const files = new Map(), localNames = new Set(['program.pcaudioprogram.json', 'program.mjs', 'prerender.mjs']);
237
+ let total = 0;
238
+ for (const file of manifest.files) {
239
+ if (!file || Object.keys(file).sort().join(',') !== 'contentHash,local,path,role,size' || !validHash(file.contentHash)
240
+ || typeof file.path !== 'string' || typeof file.local !== 'boolean' || !Number.isSafeInteger(file.size) || file.size < 1
241
+ || file.size > MAX_PACKAGE || file.role !== role(file.path, file.local)
242
+ || (total += file.size) > MAX_PACKAGE || file.local && !localNames.delete(file.path)
243
+ || !file.local && !file.path.startsWith('dist/audio/'))
244
+ throw fail();
245
+ const path = file.local ? `${directory}/${file.path}` : file.path;
246
+ if (files.has(path))
247
+ throw fail();
248
+ const data = await readAudioProgramFile(root, path, file.size);
249
+ if (data.length !== file.size || digest(data) !== file.contentHash)
250
+ throw fail();
251
+ files.set(path, data);
252
+ }
253
+ if (localNames.size)
254
+ throw fail();
255
+ const source = files.get(`${directory}/program.pcaudioprogram.json`), program = parseAudioDynamicProgram(source);
256
+ if (digest(source) !== manifest.sourceContentHash || audioDynamicProgramSemanticHash(program) !== manifest.semanticHash
257
+ || manifest.states.length !== program.states.length || manifest.states.reduce((sum, state) => sum + state.size, 0) > MAX_WAV)
258
+ throw fail();
259
+ const dependencies = await inputs(root, program), waves = {};
260
+ for (let i = 0; i < program.states.length; i++) {
261
+ const state = manifest.states[i], expected = program.states[i];
262
+ if (!state || Object.keys(state).sort().join(',') !== 'chromiumVersion,contentHash,durationSeconds,frames,id,path,pcmContentHash,size'
263
+ || state.id !== expected.id || state.durationSeconds !== expected.durationSeconds || state.frames !== Math.ceil(expected.durationSeconds * 44100)
264
+ || !validHash(state.contentHash) || !validHash(state.pcmContentHash) || state.path !== `dist/audio/shared/${state.contentHash}/state.wav`
265
+ || state.size !== 44 + state.frames * 2 || typeof state.chromiumVersion !== 'string' || !state.chromiumVersion || state.chromiumVersion.length > 128)
266
+ throw fail();
267
+ const wave = files.get(state.path);
268
+ if (!wave || wave.length !== state.size || digest(wave) !== state.contentHash)
269
+ throw fail();
270
+ verifyWave(wave, state.frames);
271
+ waves[state.path] = wave;
272
+ }
273
+ const variants = await representations(program, dependencies.files, manifest.states, directory);
274
+ if (variants.runtime.rendererBuildHash !== manifest.rendererBuildHash || !files.get(variants.runtimePath)?.equals(Buffer.from(variants.runtime.code))
275
+ || !files.get(variants.prerenderPath)?.equals(Buffer.from(variants.prerender.code)) || !files.get(`${directory}/program.mjs`)?.equals(variants.module)
276
+ || !files.get(`${directory}/prerender.mjs`)?.equals(variants.prerenderModule(waves)))
277
+ throw audioError('engine_build_mismatch', 409);
278
+ const expected = new Map(dependencies.closure);
279
+ expected.set(variants.runtimePath, Buffer.from(variants.runtime.code));
280
+ expected.set(variants.prerenderPath, Buffer.from(variants.prerender.code));
281
+ for (const [path, data] of Object.entries(waves))
282
+ expected.set(path, Buffer.from(data));
283
+ const external = manifest.files.filter(file => !file.local);
284
+ if (external.length !== expected.size || external.some(file => !expected.get(file.path)?.equals(files.get(file.path))))
285
+ throw fail();
286
+ files.set(manifestPath, manifestBytes);
287
+ return { manifest, manifestPath, sourcePath: manifest.sourcePath, sourceContentHash: manifest.sourceContentHash,
288
+ semanticHash: manifest.semanticHash, rendererBuildHash: manifest.rendererBuildHash, program, files, ...paths(directory, manifest, variants.runtimePath, variants.prerenderPath) };
289
+ }