@playcraft/cli 0.1.66 → 0.1.68

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 (113) hide show
  1. package/dist/assets/client.js +4 -2
  2. package/dist/assets/messages.js +2 -0
  3. package/dist/audio-source/messages.js +35 -0
  4. package/dist/commands/assets.js +1 -1
  5. package/dist/commands/audio-program.js +82 -0
  6. package/dist/commands/audio-source.js +109 -0
  7. package/dist/commands/audio.js +13 -4
  8. package/dist/commands/build.js +4 -2
  9. package/dist/commands/tools-context.js +46 -0
  10. package/dist/commands/tools.js +2 -0
  11. package/dist/project-skills/builtin-import.js +69 -22
  12. package/dist/project-skills/commands.js +11 -2
  13. package/dist/project-skills/lifecycle.js +1 -1
  14. package/dist/project-skills/local-store.js +90 -31
  15. package/dist/project-skills/messages.js +16 -0
  16. package/dist/project-skills/migrate-lock.js +166 -0
  17. package/dist/project-skills/remote-cache.js +36 -5
  18. package/dist/utils/agent-api-client.js +18 -1
  19. package/dist/utils/agent-generation-provenance.js +8 -2
  20. package/dist/utils/audio-export.js +249 -0
  21. package/dist/utils/audio-processor.js +22 -99
  22. package/dist/utils/execution-context-bridge.js +28 -0
  23. package/dist/workspace-runtime/batch/control.js +13 -2
  24. package/dist/workspace-runtime/batch/generate.js +43 -2
  25. package/dist/workspace-runtime/batch/prepare.js +4 -0
  26. package/dist/workspace-runtime/batch/skill-artifacts.js +103 -0
  27. package/dist/workspace-runtime/batch/source-capture.js +6 -8
  28. package/dist/workspace-runtime/codex/app-server.js +26 -2
  29. package/dist/workspace-runtime/codex/execution-context-broker.js +61 -0
  30. package/dist/workspace-runtime/codex/execution-context-tools.js +37 -0
  31. package/dist/workspace-runtime/codex/generation-provenance-broker.js +9 -6
  32. package/dist/workspace-runtime/codex/level-editor-tools.js +18 -0
  33. package/dist/workspace-runtime/codex/model-broker.js +15 -2
  34. package/dist/workspace-runtime/codex/scheduled-task-tools.js +1 -1
  35. package/dist/workspace-runtime/previews/finalize.js +1 -1
  36. package/dist/workspace-runtime/previews/output.js +4 -1
  37. package/dist/workspace-runtime/previews/publish.js +5 -2
  38. package/dist/workspace-runtime/previews/runtime-preview.js +38 -3
  39. package/dist/workspace-runtime/serve.js +6 -0
  40. package/dist/workspace-runtime/server/accept-operation.js +85 -13
  41. package/dist/workspace-runtime/server/audio-assets.js +257 -0
  42. package/dist/workspace-runtime/server/audio-export.js +136 -0
  43. package/dist/workspace-runtime/server/directory-access.js +222 -67
  44. package/dist/workspace-runtime/server/dispatch.js +252 -29
  45. package/dist/workspace-runtime/server/http.js +97 -3
  46. package/dist/workspace-runtime/server/operation-record.js +1 -1
  47. package/dist/workspace-runtime/workspaces/artifact-files.js +10 -3
  48. package/dist/workspace-runtime/workspaces/artifacts.js +47 -6
  49. package/dist/workspace-runtime/workspaces/asset-library-import.js +27 -7
  50. package/dist/workspace-runtime/workspaces/audio-assets/artifact-package.js +104 -0
  51. package/dist/workspace-runtime/workspaces/audio-assets/binding-usage.js +71 -0
  52. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +80 -0
  53. package/dist/workspace-runtime/workspaces/audio-assets/library-import.js +149 -0
  54. package/dist/workspace-runtime/workspaces/audio-assets/library-package.js +26 -0
  55. package/dist/workspace-runtime/workspaces/audio-assets/program-artifact-audit.js +90 -0
  56. package/dist/workspace-runtime/workspaces/audio-assets/program-browser.js +194 -0
  57. package/dist/workspace-runtime/workspaces/audio-assets/program-build-inputs.js +122 -0
  58. package/dist/workspace-runtime/workspaces/audio-assets/program-engine.js +39 -0
  59. package/dist/workspace-runtime/workspaces/audio-assets/program-package.js +289 -0
  60. package/dist/workspace-runtime/workspaces/audio-assets/program-variant-build.js +217 -0
  61. package/dist/workspace-runtime/workspaces/audio-assets/references.js +107 -0
  62. package/dist/workspace-runtime/workspaces/audio-assets/render-worker.js +117 -0
  63. package/dist/workspace-runtime/workspaces/audio-assets/renderer.js +128 -0
  64. package/dist/workspace-runtime/workspaces/audio-assets/representations.js +73 -0
  65. package/dist/workspace-runtime/workspaces/audio-assets/service.js +459 -0
  66. package/dist/workspace-runtime/workspaces/audio-assets/storage.js +125 -0
  67. package/dist/workspace-runtime/workspaces/build-isolation.js +2 -0
  68. package/dist/workspace-runtime/workspaces/composer-context.js +40 -1
  69. package/dist/workspace-runtime/workspaces/empty-template.js +1 -1
  70. package/dist/workspace-runtime/workspaces/execution-context.js +24 -0
  71. package/dist/workspace-runtime/workspaces/files.js +3 -5
  72. package/dist/workspace-runtime/workspaces/git.js +7 -0
  73. package/dist/workspace-runtime/workspaces/level-editor-agent-store.js +143 -0
  74. package/dist/workspace-runtime/workspaces/level-editor-agent.js +410 -0
  75. package/dist/workspace-runtime/workspaces/level-editor-atomic.js +424 -0
  76. package/dist/workspace-runtime/workspaces/level-editor-extensions.js +140 -0
  77. package/dist/workspace-runtime/workspaces/level-editor-format.js +75 -0
  78. package/dist/workspace-runtime/workspaces/level-editor-history-store.js +250 -0
  79. package/dist/workspace-runtime/workspaces/level-editor-json5.js +132 -0
  80. package/dist/workspace-runtime/workspaces/level-editor-requests.js +724 -0
  81. package/dist/workspace-runtime/workspaces/level-editor.js +454 -0
  82. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +62 -22
  83. package/dist/workspace-runtime/workspaces/parameter-production.js +1 -1
  84. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +4 -3
  85. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +19 -5
  86. package/dist/workspace-runtime/workspaces/skills.js +48 -13
  87. package/package.json +9 -4
  88. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +92 -4
  89. package/project-template/.claude/skills/playcraft-level-editor/SKILL.md +34 -0
  90. package/project-template/.claude/skills/playcraft-level-editor/references/authoring-contract.json +3475 -0
  91. package/project-template/.claude/skills/playcraft-level-editor/references/extensions.md +37 -0
  92. package/project-template/.claude/skills/playcraft-level-editor/references/preview.md +15 -0
  93. package/project-template/.claude/skills/playcraft-level-editor/references/protocol-v1.md +35 -0
  94. package/project-template/vite.config.ts +1 -1
  95. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +92 -4
  96. package/project-template-v2/.playcraft/skills.lock.json +9 -2
  97. package/project-template-v2/vite.config.ts +1 -1
  98. package/project-templates/empty/AGENTS.md +2 -0
  99. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +92 -4
  100. package/project-templates/phaser/vite.config.ts +1 -1
  101. package/project-templates/playable-template-phaser/vite.config.ts +1 -1
  102. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +92 -4
  103. package/project-templates/threejs/vite.config.ts +1 -1
  104. package/templates/preview-runtime/v1/README.md +12 -0
  105. package/templates/preview-runtime/v1/build-preview.mjs +17 -8
  106. package/templates/preview-runtime/v1/manifest.json +9 -6
  107. package/templates/preview-runtime/v1/release-guard.mjs +1 -1
  108. package/templates/preview-runtime/v1/runtime.d.mts +1 -3
  109. package/templates/preview-runtime/v1/runtime.d.ts +1 -3
  110. package/templates/preview-runtime/v1/runtime.mjs +1142 -257
  111. package/templates/preview-runtime/v1/types/host.d.ts +29 -0
  112. package/templates/preview-runtime/v1/types/index.d.ts +6 -0
  113. package/templates/preview-runtime/v1/types/level-editor.d.ts +123 -0
@@ -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
+ }
@@ -0,0 +1,217 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, opendir, realpath } from 'node:fs/promises';
3
+ import { posix, relative, resolve, sep } from 'node:path';
4
+ import { init, parse } from 'es-module-lexer';
5
+ import { audioError, audioProjectPath, digest } from './storage.js';
6
+ import { writeSharedFile } from '../shared-write.js';
7
+ import { auditAudioProgramArtifact } from './program-artifact-audit.js';
8
+ import { assertAudioProgramBuildClone, readAudioProgramBuildInputs, verifyAudioProgramBuildInputs } from './program-build-inputs.js';
9
+ const SKIP = new Set(['dist', 'node_modules', '.git', '.playcraft', '.playcraft-delivery', '.turbo', 'coverage']);
10
+ const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/i;
11
+ const MAX_SOURCE_BYTES = 16 * 1024 * 1024, MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024, MAX_FILES = 2048, MAX_ENTRIES = 8192;
12
+ async function sourceFiles(root) {
13
+ const files = [], unresolved = [];
14
+ let count = 0;
15
+ const visit = async (directory, depth) => {
16
+ if (depth > 32)
17
+ throw audioError('audio_build_budget_exceeded', 422);
18
+ const handle = await opendir(directory ? await audioProjectPath(root, directory) : await realpath(root), { bufferSize: 32 });
19
+ for await (const entry of handle) {
20
+ if (++count > MAX_ENTRIES)
21
+ throw audioError('audio_build_budget_exceeded', 422);
22
+ if (SKIP.has(entry.name))
23
+ continue;
24
+ const path = directory ? `${directory}/${entry.name}` : entry.name;
25
+ if (entry.isSymbolicLink()) {
26
+ unresolved.push({ file: path, offset: 0, reason: 'symlink_source_skipped' });
27
+ continue;
28
+ }
29
+ if (entry.isDirectory())
30
+ await visit(path, depth + 1);
31
+ else if (entry.isFile() && SOURCE.test(entry.name)) {
32
+ if (files.length >= MAX_FILES)
33
+ throw audioError('audio_build_budget_exceeded', 422);
34
+ files.push(path);
35
+ }
36
+ }
37
+ };
38
+ await visit('', 0);
39
+ return { files: files.sort(), unresolved };
40
+ }
41
+ async function stableBytes(root, path, maximum) {
42
+ const handle = await open(await audioProjectPath(root, path), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
43
+ try {
44
+ const before = await handle.stat();
45
+ if (!before.isFile() || before.nlink !== 1 || before.size > maximum)
46
+ throw audioError('audio_build_budget_exceeded', 422);
47
+ const bytes = await handle.readFile(), after = await handle.stat();
48
+ if (bytes.length !== before.size || before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
49
+ throw audioError('audio_build_input_changed');
50
+ return bytes;
51
+ }
52
+ finally {
53
+ await handle.close();
54
+ }
55
+ }
56
+ function importedPath(file, specifier) {
57
+ if (specifier.includes('\\') || specifier.includes('\0'))
58
+ return undefined;
59
+ if (specifier.startsWith('/'))
60
+ return posix.normalize(specifier.slice(1));
61
+ if (!specifier.startsWith('.'))
62
+ return undefined;
63
+ const path = posix.normalize(posix.join(posix.dirname(file), specifier));
64
+ return path === '..' || path.startsWith('../') ? undefined : path;
65
+ }
66
+ /** Only lexer-confirmed literals in ordinary clone source are changed; immutable packages are never rewritten. */
67
+ export async function rewriteAudioProgramVariantImports(input) {
68
+ if (!['runtime', 'prerender'].includes(input.variant))
69
+ throw audioError('invalid_audio_variant', 400);
70
+ await assertAudioProgramBuildClone(input.sourceRoot, input.cloneRoot);
71
+ await verifyAudioProgramBuildInputs(input.sourceRoot, input.snapshot);
72
+ await verifyAudioProgramBuildInputs(input.cloneRoot, input.snapshot);
73
+ const routes = new Map();
74
+ for (const bundle of input.snapshot.packages)
75
+ for (const path of [bundle.runtimeModulePath, bundle.prerenderModulePath])
76
+ routes.set(path, input.variant === 'runtime' ? bundle.runtimeModulePath : bundle.prerenderModulePath);
77
+ const sources = await sourceFiles(input.cloneRoot), result = {
78
+ variant: input.variant, rewrites: [], unresolved: sources.unresolved, coverage: 'literal-project-relative-imports',
79
+ };
80
+ const changes = [];
81
+ let total = 0;
82
+ await init;
83
+ for (const file of sources.files) {
84
+ const bytes = await stableBytes(input.cloneRoot, file, MAX_SOURCE_FILE_BYTES);
85
+ if ((total += bytes.length) > MAX_SOURCE_BYTES)
86
+ throw audioError('audio_build_budget_exceeded', 422);
87
+ let text;
88
+ try {
89
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
90
+ }
91
+ catch {
92
+ throw audioError('audio_program_source_invalid', 400);
93
+ }
94
+ let imports;
95
+ try {
96
+ [imports] = parse(text, file);
97
+ }
98
+ catch {
99
+ result.unresolved.push({ file, offset: 0, reason: 'module_lexer_parse_failed' });
100
+ continue;
101
+ }
102
+ const replacements = [];
103
+ for (const item of imports) {
104
+ if (item.d === -2)
105
+ continue;
106
+ if (item.n === undefined) {
107
+ if (item.d >= 0)
108
+ result.unresolved.push({ file, offset: item.s, reason: text.slice(item.s, item.e).includes('audio/programs/') ? 'computed_program_import' : 'computed_import_unverified' });
109
+ continue;
110
+ }
111
+ const boundary = item.n.search(/[?#]/), plain = boundary < 0 ? item.n : item.n.slice(0, boundary);
112
+ const resolved = importedPath(file, plain), selected = resolved && routes.get(resolved);
113
+ if (!selected) {
114
+ if (plain.includes('audio/programs/'))
115
+ result.unresolved.push({ file, offset: item.s, reason: 'program_import_path_unresolved' });
116
+ continue;
117
+ }
118
+ if (boundary >= 0) {
119
+ result.unresolved.push({ file, offset: item.s, reason: 'program_import_query_unsupported' });
120
+ continue;
121
+ }
122
+ let replacement = item.n.startsWith('/') ? `/${selected}` : posix.relative(posix.dirname(file), selected);
123
+ if (!item.n.startsWith('/') && !replacement.startsWith('.'))
124
+ replacement = './' + replacement;
125
+ if (selected === resolved && item.n === replacement)
126
+ continue;
127
+ const start = item.d === -1 ? item.s - 1 : item.s, end = item.d === -1 ? item.e + 1 : item.e;
128
+ if (!['"', "'"].includes(text[start]) || text[end - 1] !== text[start]) {
129
+ result.unresolved.push({ file, offset: item.s, reason: 'non_string_program_import' });
130
+ continue;
131
+ }
132
+ replacements.push({ start, end, value: JSON.stringify(replacement), from: item.n, to: replacement });
133
+ }
134
+ if (!replacements.length)
135
+ continue;
136
+ let rewritten = text;
137
+ for (const item of [...replacements].sort((a, b) => b.start - a.start))
138
+ rewritten = rewritten.slice(0, item.start) + item.value + rewritten.slice(item.end);
139
+ changes.push({ file, bytes: Buffer.from(rewritten), beforeHash: digest(bytes), rows: replacements.map(({ from, to }) => ({ from, to })) });
140
+ }
141
+ // Validate the full plan before touching any clone source, then compare each inode's actual bytes.
142
+ for (const change of changes)
143
+ if (digest(await stableBytes(input.cloneRoot, change.file, MAX_SOURCE_FILE_BYTES)) !== change.beforeHash)
144
+ throw audioError('audio_build_input_changed');
145
+ for (const change of changes) {
146
+ await writeSharedFile(input.cloneRoot, await audioProjectPath(input.cloneRoot, change.file), change.bytes);
147
+ for (const row of change.rows)
148
+ result.rewrites.push({ file: change.file, ...row, beforeHash: change.beforeHash, afterHash: digest(change.bytes) });
149
+ }
150
+ await verifyAudioProgramBuildInputs(input.sourceRoot, input.snapshot);
151
+ await verifyAudioProgramBuildInputs(input.cloneRoot, input.snapshot);
152
+ return result;
153
+ }
154
+ /** The callback must return the actual final channel artifact, including its real container format. */
155
+ export async function measureAudioProgramVariants(input) {
156
+ const { capabilities } = input;
157
+ if (typeof capabilities.webAudio !== 'boolean' || typeof capabilities.runtimeSynthesis !== 'boolean'
158
+ || !Number.isSafeInteger(capabilities.maxBytes) || capabilities.maxBytes < 1 || capabilities.maxBytes > 64 * 1024 * 1024)
159
+ throw audioError('invalid_audio_channel_capability', 400);
160
+ if (!capabilities.webAudio)
161
+ throw audioError('audio_delivery_unavailable', 422);
162
+ const checkAbort = () => { if (input.signal?.aborted)
163
+ throw audioError('audio_build_cancelled', 409); };
164
+ checkAbort();
165
+ const snapshot = input.snapshot ?? await readAudioProgramBuildInputs(input.root);
166
+ await verifyAudioProgramBuildInputs(input.root, snapshot);
167
+ const modes = capabilities.runtimeSynthesis ? ['runtime', 'prerender'] : ['prerender'];
168
+ const candidates = [], candidateRoots = new Set();
169
+ for (const variant of modes) {
170
+ checkAbort();
171
+ await verifyAudioProgramBuildInputs(input.root, snapshot);
172
+ try {
173
+ const prepared = await input.prepare(variant);
174
+ await assertAudioProgramBuildClone(input.root, prepared.projectRoot);
175
+ await assertAudioProgramBuildClone(input.root, prepared.artifactRoot);
176
+ const roots = await Promise.all([realpath(prepared.projectRoot), realpath(prepared.artifactRoot)]);
177
+ if ([...candidateRoots].some(old => roots.some(root => root === old || root.startsWith(old + sep) || old.startsWith(root + sep))))
178
+ throw audioError('invalid_audio_build_clone', 400);
179
+ for (const root of roots)
180
+ candidateRoots.add(root);
181
+ const copied = await readAudioProgramBuildInputs(input.root, prepared.projectRoot);
182
+ if (copied.inputHash !== snapshot.inputHash)
183
+ throw audioError('audio_build_input_changed');
184
+ const rewrite = await rewriteAudioProgramVariantImports({ sourceRoot: input.root, cloneRoot: prepared.projectRoot, snapshot, variant });
185
+ if (variant === 'prerender' && rewrite.unresolved.length > 0)
186
+ throw Object.assign(audioError('audio_program_import_unverified', 422), { details: rewrite.unresolved });
187
+ checkAbort();
188
+ const outputPath = await input.build({ ...prepared, variant, rewrite, signal: input.signal });
189
+ checkAbort();
190
+ await verifyAudioProgramBuildInputs(prepared.projectRoot, snapshot);
191
+ const artifactRoot = await realpath(prepared.artifactRoot), absolute = await realpath(outputPath), rel = relative(artifactRoot, absolute);
192
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || resolve(artifactRoot, rel) !== absolute)
193
+ throw audioError('invalid_audio_build_artifact', 400);
194
+ const stat = await lstat(outputPath);
195
+ if (!stat.isFile() || stat.isSymbolicLink())
196
+ throw audioError('invalid_audio_build_artifact', 400);
197
+ const bytes = await stableBytes(artifactRoot, rel.split(sep).join('/'), 64 * 1024 * 1024);
198
+ if (!bytes.length)
199
+ throw audioError('invalid_audio_build_artifact', 400);
200
+ const artifactAudit = auditAudioProgramArtifact(bytes, outputPath, variant);
201
+ candidates.push({ variant, status: 'completed', ...prepared, outputPath, outputSize: bytes.length, outputHash: digest(bytes), rewrite, artifactAudit });
202
+ }
203
+ catch (error) {
204
+ if (input.signal?.aborted)
205
+ throw audioError('audio_build_cancelled', 409);
206
+ candidates.push({ variant, status: 'failed', error: error.code ?? error.message,
207
+ ...(error.details === undefined ? {} : { details: error.details }) });
208
+ }
209
+ await verifyAudioProgramBuildInputs(input.root, snapshot);
210
+ }
211
+ const valid = candidates.filter((candidate) => candidate.status === 'completed' && candidate.outputSize <= capabilities.maxBytes);
212
+ valid.sort((a, b) => a.outputSize - b.outputSize || Number(a.variant === 'runtime') - Number(b.variant === 'runtime'));
213
+ if (!valid.length)
214
+ throw Object.assign(audioError('audio_delivery_unavailable', 422), { details: { channel: capabilities.channel, candidates } });
215
+ return { version: 1, inputHash: snapshot.inputHash, channel: capabilities.channel, capabilities,
216
+ candidates, selected: valid[0], measuredFinalArtifacts: true };
217
+ }
@@ -0,0 +1,107 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, opendir } from 'node:fs/promises';
3
+ import { workspaceAudioBindingSchema } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { audioProjectPath, audioError, digest } from './storage.js';
5
+ import { readAudioArtifactPackage } from './artifact-package.js';
6
+ export async function readAudioReferenceFile(root, path) {
7
+ const target = await audioProjectPath(root, path), file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
8
+ .catch(error => { if (error.code === 'ENOENT')
9
+ return undefined; throw error; });
10
+ if (!file)
11
+ return undefined;
12
+ try {
13
+ const before = await file.stat();
14
+ if (!before.isFile() || before.nlink !== 1 || before.size < 1 || before.size > 16_384)
15
+ throw audioError('invalid_audio_binding', 400);
16
+ const bytes = await file.readFile(), after = await file.stat(), named = await lstat(await audioProjectPath(root, path));
17
+ if (bytes.length !== before.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || named.ino !== before.ino || named.dev !== before.dev)
18
+ throw audioError('audio_binding_conflict');
19
+ return bytes;
20
+ }
21
+ finally {
22
+ await file.close();
23
+ }
24
+ }
25
+ export async function verifyAudioReference(root, sourcePath, history, bytes) {
26
+ const binding = workspaceAudioBindingSchema.parse(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)));
27
+ if (binding.sourcePath !== sourcePath || !history.some(version => version.revision === binding.revision && version.contentHash === binding.sourceContentHash))
28
+ throw audioError('invalid_audio_binding', 400);
29
+ const directory = `dist/audio/${digest(sourcePath)}/${binding.sourceContentHash}/${binding.representationSetHash}`;
30
+ if (binding.modulePath !== `${directory}/sound.mjs`)
31
+ throw audioError('invalid_audio_binding', 400);
32
+ const bundle = await readAudioArtifactPackage(root, `${directory}/sound.${sourcePath.endsWith('.pcscore.json') ? 'pcscore' : 'pcaudio'}.json`);
33
+ if (bundle.sourcePath !== sourcePath || bundle.sourceContentHash !== binding.sourceContentHash
34
+ || digest(bundle.files.get(`${directory}/audition.wav`)) !== binding.auditionContentHash)
35
+ throw audioError('invalid_audio_binding', 400);
36
+ return binding;
37
+ }
38
+ export async function readAudioReferences(root, sourcePath, current, history, loadPrevious) {
39
+ const directory = await audioProjectPath(root, 'source/audio-bindings');
40
+ const handle = await opendir(directory).catch(error => { if (error.code === 'ENOENT')
41
+ return undefined; throw error; });
42
+ const names = [];
43
+ let entries = 0, truncated = false;
44
+ if (handle)
45
+ for await (const entry of handle) {
46
+ if (++entries > 1024) {
47
+ truncated = true;
48
+ break;
49
+ }
50
+ if (entry.isFile() && /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}\.json$/.test(entry.name))
51
+ names.push(entry.name);
52
+ if (names.length > 128) {
53
+ truncated = true;
54
+ break;
55
+ }
56
+ }
57
+ names.sort();
58
+ const deadline = Date.now() + 5_000;
59
+ const references = [];
60
+ const version = (binding) => ({ revision: binding.revision, sourceContentHash: binding.sourceContentHash,
61
+ modulePath: binding.modulePath, representationSetHash: binding.representationSetHash });
62
+ for (const name of names.slice(0, 128)) {
63
+ if (Date.now() > deadline) {
64
+ truncated = true;
65
+ break;
66
+ }
67
+ const bindingPath = `source/audio-bindings/${name}`, bytes = await readAudioReferenceFile(root, bindingPath);
68
+ if (!bytes)
69
+ continue;
70
+ let raw;
71
+ try {
72
+ raw = JSON.parse(bytes.toString('utf8'));
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ if (raw?.sourcePath !== sourcePath || raw?.eventName !== name.slice(0, -5))
78
+ continue;
79
+ const entry = { eventName: raw.eventName, bindingPath, bindingContentHash: digest(bytes), state: 'invalid', history: [] };
80
+ try {
81
+ const binding = await verifyAudioReference(root, sourcePath, history, bytes);
82
+ entry.version = version(binding);
83
+ entry.state = binding.sourceContentHash === current.contentHash ? 'current' : 'different_version';
84
+ let previous = binding.previousBinding;
85
+ const seen = new Set([entry.bindingContentHash]);
86
+ while (previous && entry.history.length < 20 && !seen.has(previous)) {
87
+ if (Date.now() > deadline) {
88
+ truncated = true;
89
+ break;
90
+ }
91
+ seen.add(previous);
92
+ const old = await loadPrevious(previous);
93
+ if (!old || digest(old) !== previous)
94
+ break;
95
+ const checked = await verifyAudioReference(root, sourcePath, history, old);
96
+ if (checked.eventName !== binding.eventName)
97
+ break;
98
+ entry.history.push({ ...version(checked), bindingContentHash: previous });
99
+ previous = checked.previousBinding;
100
+ }
101
+ }
102
+ catch { /* An invalid record remains visible, without an unverified package or restore target. */ }
103
+ references.push(entry);
104
+ }
105
+ return { version: 1, target: { kind: 'workspace-file', path: sourcePath }, sourceRevision: current.revision, sourceContentHash: current.contentHash,
106
+ references, scanLimit: 128, truncated, dynamicReferences: 'unverified' };
107
+ }