@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,149 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, open, readdir, readFile, rename, rm } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { canonicalAudioJson, audioRepresentationSetHash, inspectAudioLibrarySnapshot, audioLibraryOriginSchema, audioLibraryPayloadHash } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { audioRendererScript } from './renderer.js';
6
+ import { readAudioArtifactPackage } from './artifact-package.js';
7
+ import { audioProjectPath, audioError, digest } from './storage.js';
8
+ import { ensureSharedDirectory, writeSharedFile } from '../shared-write.js';
9
+ export const isEditableAudioLibraryAsset = (asset) => {
10
+ if (asset.files.some(file => /(?:^|\/)sound\.(?:pcaudio|pcscore)\.json$/.test(file.path)))
11
+ return true;
12
+ try {
13
+ const file = asset.files.find(file => file.path === 'manifest.json');
14
+ const value = file && JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'));
15
+ return Boolean(value && (value.sourcePath !== undefined || value.representationSetHash !== undefined));
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ };
21
+ function inspectAsset(asset, requirePlatformProof = true) {
22
+ if (requirePlatformProof && !asset.audioSource)
23
+ throw audioError('audio_library_source_unverified', 409);
24
+ try {
25
+ const manifestFile = asset.files.find(file => file.path === 'manifest.json');
26
+ if (!manifestFile)
27
+ throw new Error();
28
+ const manifest = JSON.parse(Buffer.from(manifestFile.content, 'base64').toString('utf8'));
29
+ const checked = inspectAudioLibrarySnapshot({ version: 1, target: asset.audioSource?.source.target ?? { kind: 'workspace-file', path: manifest.sourcePath },
30
+ revision: asset.audioSource?.source.revision ?? 1, sourcePath: manifest.sourcePath,
31
+ files: asset.files.map(file => ({ path: file.path, mimeType: file.mimeType, content: file.content,
32
+ contentHash: file.sha256, size: Buffer.from(file.content, 'base64').length })) });
33
+ if (asset.audioSource && audioLibraryPayloadHash(checked.source) !== audioLibraryPayloadHash(asset.audioSource.source))
34
+ throw new Error();
35
+ return checked;
36
+ }
37
+ catch {
38
+ throw audioError('invalid_audio_library_package', 400);
39
+ }
40
+ }
41
+ async function trustedEngine(checked) {
42
+ const engine = await audioRendererScript(checked.document.format === 'playcraft.score' ? 'score' : 'synth-sfx');
43
+ const suffix = checked.document.format === 'playcraft.score' ? '\nexport { renderSoundDocument, createStandaloneSoundPlayer };\n'
44
+ : '\nexport { renderAudioDocument, createStandaloneAudioPlayer };\n';
45
+ if (checked.source.rendererBuildHash !== engine.rendererBuildHash || Buffer.from(checked.files.get('runtime.mjs')).toString('utf8') !== engine.script + suffix)
46
+ throw audioError('audio_library_engine_unavailable', 409);
47
+ }
48
+ /** Verify against platform engine bytes before publishing any executable member. */
49
+ export async function prepareAudioLibraryImport(root, target, asset, assertCurrent) {
50
+ const checked = inspectAsset(asset);
51
+ await trustedEngine(checked);
52
+ await assertCurrent();
53
+ const sourcePath = `${target}/${checked.source.entryPath}`;
54
+ await audioProjectPath(root, sourcePath);
55
+ const { representationSetHash: _previous, ...oldPayload } = checked.manifest;
56
+ const payload = { ...oldPayload, sourcePath }, representationSetHash = audioRepresentationSetHash(payload);
57
+ const manifest = Buffer.from(`${canonicalAudioJson({ ...payload, representationSetHash })}\n`);
58
+ const directory = `dist/audio/${digest(sourcePath)}/${checked.source.sourceContentHash}/${representationSetHash}`;
59
+ const members = new Map([...checked.files].map(([path, bytes]) => [path, Buffer.from(bytes)]));
60
+ members.set('manifest.json', manifest);
61
+ const destination = dirname(await audioProjectPath(root, `${directory}/manifest.json`));
62
+ await ensureSharedDirectory(root, dirname(destination));
63
+ const staging = `${destination}.${randomUUID()}.tmp`;
64
+ await ensureSharedDirectory(root, staging);
65
+ try {
66
+ for (const [path, bytes] of members)
67
+ await writeSharedFile(root, join(staging, path), bytes);
68
+ await assertCurrent();
69
+ const existing = await lstat(destination).catch(error => { if (error.code === 'ENOENT')
70
+ return undefined; throw error; });
71
+ if (existing) {
72
+ if (!existing.isDirectory() || existing.isSymbolicLink() || (await readdir(destination)).sort().join(',') !== [...members.keys()].sort().join(','))
73
+ throw audioError('audio_representation_conflict');
74
+ for (const [path, bytes] of members)
75
+ if (digest(await readFile(await audioProjectPath(root, `${directory}/${path}`))) !== digest(bytes))
76
+ throw audioError('audio_representation_conflict');
77
+ }
78
+ else {
79
+ await rename(staging, destination);
80
+ const parent = await open(dirname(destination), 'r');
81
+ try {
82
+ await parent.sync();
83
+ }
84
+ finally {
85
+ await parent.close();
86
+ }
87
+ }
88
+ }
89
+ finally {
90
+ await rm(staging, { recursive: true, force: true });
91
+ }
92
+ const origin = { assetId: asset.assetId, contentHash: asset.contentHash, libraryPath: asset.libraryPath,
93
+ sourcePath: checked.source.sourcePath, sourceContentHash: checked.source.sourceContentHash, representationSetHash: checked.source.representationSetHash };
94
+ const metadata = Buffer.from(`${canonicalAudioJson({ version: 1, sourcePath, sourceContentHash: checked.source.sourceContentHash,
95
+ primaryPath: `${directory}/${checked.source.entryPath}`, representationSetHash, origin })}\n`);
96
+ const files = [{ path: checked.source.entryPath, bytes: members.get(checked.source.entryPath), mimeType: 'application/json' },
97
+ { path: 'audio-library.json', bytes: metadata, mimeType: 'application/json' }].map(({ bytes, ...file }) => ({ ...file, content: bytes.toString('base64'), sha256: digest(bytes) }));
98
+ const portable = { ...asset, files,
99
+ contentHash: digest(JSON.stringify(files.map(file => [file.path, file.sha256]).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0))) };
100
+ return { asset: portable, packagePaths: [...members.keys()].map(path => `${directory}/${path}`) };
101
+ }
102
+ /** Recover verified import ancestry; reuse its audition only while the current source still matches. */
103
+ export async function readImportedAudioLibrarySource(root, sourcePath, contentHash) {
104
+ const directory = dirname(sourcePath), sidecar = await audioProjectPath(root, `${directory === '.' ? '' : directory + '/'}audio-library.json`);
105
+ const stat = await lstat(sidecar).catch(error => { if (error.code === 'ENOENT')
106
+ return undefined; throw error; });
107
+ if (!stat)
108
+ return undefined;
109
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 65_536)
110
+ throw audioError('audio_library_dependency_invalid', 409);
111
+ const metadata = JSON.parse(await readFile(sidecar, 'utf8'));
112
+ if (metadata.sourcePath !== sourcePath)
113
+ return undefined;
114
+ if (metadata.version !== 1 || typeof metadata.primaryPath !== 'string')
115
+ throw audioError('audio_library_dependency_invalid', 409);
116
+ try {
117
+ const packaged = await readAudioArtifactPackage(root, metadata.primaryPath);
118
+ const files = [...packaged.files].map(([path, bytes]) => ({ path: path.slice(path.lastIndexOf('/') + 1), content: bytes.toString('base64'), sha256: digest(bytes),
119
+ mimeType: path.endsWith('.mjs') ? 'text/javascript' : path.endsWith('.wav') ? 'audio/wav' : 'application/json' }));
120
+ const asset = { assetId: 'verified-local', name: 'sound', libraryPath: '', visibility: 'team', kind: 'bundle', contentHash: ''.padStart(64, '0'), files };
121
+ const checked = inspectAsset(asset, false);
122
+ await trustedEngine(checked);
123
+ if (checked.source.sourcePath !== sourcePath || checked.source.sourceContentHash !== metadata.sourceContentHash || checked.source.representationSetHash !== metadata.representationSetHash)
124
+ throw new Error();
125
+ const origin = audioLibraryOriginSchema.parse(metadata.origin), { representationSetHash: _relocated, ...payload } = checked.manifest;
126
+ if (origin.sourceContentHash !== checked.source.sourceContentHash
127
+ || audioRepresentationSetHash({ ...payload, sourcePath: origin.sourcePath }) !== origin.representationSetHash)
128
+ throw new Error();
129
+ const wav = Buffer.from(checked.files.get('audition.wav')), frames = (wav.length - 44) / 2;
130
+ let peak = 0, sum = 0;
131
+ for (let frame = 0; frame < frames; frame++) {
132
+ const value = wav.readInt16LE(44 + frame * 2) / 32768;
133
+ peak = Math.max(peak, Math.abs(value));
134
+ sum += value * value;
135
+ }
136
+ if (peak > .9801)
137
+ throw new Error();
138
+ const current = { wav, audition: { mimeType: 'audio/wav', size: wav.length, contentHash: digest(wav), pcmContentHash: checked.manifest.pcmContentHash,
139
+ rendererBuildHash: checked.source.rendererBuildHash, chromiumVersion: checked.manifest.chromiumVersion, renderKey: checked.manifest.renderKey,
140
+ metrics: { peak, rms: Math.sqrt(sum / frames), frames, sampleRate: 44100, channels: 1, silent: peak === 0 } },
141
+ representation: { sourceContentHash: checked.source.sourceContentHash, rendererBuildHash: checked.source.rendererBuildHash, representationSetHash: checked.source.representationSetHash,
142
+ modulePath: `${dirname(metadata.primaryPath)}/sound.mjs`, manifestPath: `${dirname(metadata.primaryPath)}/manifest.json`,
143
+ files: [...packaged.files].map(([path, bytes]) => ({ path, size: bytes.length, contentHash: digest(bytes) })) } };
144
+ return { origin, current: checked.source.sourceContentHash === contentHash ? current : undefined };
145
+ }
146
+ catch (error) {
147
+ throw audioError(error?.message === 'audio_library_engine_unavailable' ? 'audio_library_engine_unavailable' : 'audio_library_dependency_invalid', 409);
148
+ }
149
+ }
@@ -0,0 +1,26 @@
1
+ import { canonicalAudioJson, audioRepresentationSetHash, audioLibraryModule, inspectAudioLibrarySnapshot } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { audioRendererScript } from './renderer.js';
3
+ import { audioError, digest } from './storage.js';
4
+ /** Package a verified audition and its fixed engine; saving to the library does not adopt the candidate. */
5
+ export async function createAudioLibrarySnapshot(input) {
6
+ const score = input.document.format === 'playcraft.score', engine = await audioRendererScript(score ? 'score' : 'synth-sfx');
7
+ if (engine.rendererBuildHash !== input.audition.rendererBuildHash)
8
+ throw audioError('engine_build_mismatch', 503);
9
+ const runtime = engine.script + (score ? '\nexport { renderSoundDocument, createStandaloneSoundPlayer };\n'
10
+ : '\nexport { renderAudioDocument, createStandaloneAudioPlayer };\n');
11
+ const members = [
12
+ { path: score ? 'sound.pcscore.json' : 'sound.pcaudio.json', mimeType: 'application/json', role: 'editable', bytes: Buffer.from(input.sourceText) },
13
+ { path: 'audition.wav', mimeType: 'audio/wav', role: 'audition', bytes: Buffer.from(input.wav) },
14
+ { path: 'runtime.mjs', mimeType: 'text/javascript', role: 'runtime', bytes: Buffer.from(runtime) },
15
+ { path: 'sound.mjs', mimeType: 'text/javascript', role: 'delivery', bytes: Buffer.from(audioLibraryModule(input.document, input.contentHash, engine.rendererBuildHash)) },
16
+ ];
17
+ const files = members.map(({ bytes, ...file }) => ({ ...file, size: bytes.length, contentHash: digest(bytes) }));
18
+ const payload = { version: 1, sourcePath: input.sourcePath, sourceContentHash: input.contentHash, sourceRenderHash: input.sourceRenderHash,
19
+ engine: input.document.engine, rendererBuildHash: engine.rendererBuildHash, chromiumVersion: input.audition.chromiumVersion,
20
+ renderKey: input.audition.renderKey, pcmContentHash: input.audition.pcmContentHash, files };
21
+ const manifest = Buffer.from(`${canonicalAudioJson({ ...payload, representationSetHash: audioRepresentationSetHash(payload) })}\n`);
22
+ const snapshot = { version: 1, target: input.target, revision: input.revision, sourcePath: input.sourcePath,
23
+ files: [...members.map(({ bytes, role: _role, ...file }) => ({ ...file, size: bytes.length, contentHash: digest(bytes), content: bytes.toString('base64') })),
24
+ { path: 'manifest.json', mimeType: 'application/json', size: manifest.length, contentHash: digest(manifest), content: manifest.toString('base64') }] };
25
+ return inspectAudioLibrarySnapshot(snapshot).snapshot;
26
+ }
@@ -0,0 +1,90 @@
1
+ import { inflateRawSync } from 'node:zlib';
2
+ import { extname } from 'node:path';
3
+ import { audioError } from './storage.js';
4
+ const MAX_BYTES = 64 * 1024 * 1024, MAX_ENTRIES = 4096;
5
+ const MARKERS = { runtime: 'playcraft.audio-program.runtime.v1', prerender: 'playcraft.audio-program.prerender.v1' };
6
+ const invalid = () => audioError('audio_program_artifact_unverified', 422);
7
+ function crc32(bytes) {
8
+ let crc = 0xffffffff;
9
+ for (const byte of bytes) {
10
+ crc ^= byte;
11
+ for (let i = 0; i < 8; i++)
12
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
13
+ }
14
+ return (crc ^ 0xffffffff) >>> 0;
15
+ }
16
+ /** Inspect archive members, never compressed binary substrings or evaluated project code. */
17
+ function zipTextEntries(bytes) {
18
+ let end = -1;
19
+ for (let i = bytes.length - 22, minimum = Math.max(0, bytes.length - 65557); i >= minimum; i--)
20
+ if (bytes.readUInt32LE(i) === 0x06054b50 && i + 22 + bytes.readUInt16LE(i + 20) === bytes.length) {
21
+ end = i;
22
+ break;
23
+ }
24
+ if (end < 0 || bytes.readUInt16LE(end + 4) !== 0 || bytes.readUInt16LE(end + 6) !== 0)
25
+ throw invalid();
26
+ const count = bytes.readUInt16LE(end + 10), length = bytes.readUInt32LE(end + 12), offset = bytes.readUInt32LE(end + 16);
27
+ if (count > MAX_ENTRIES || count !== bytes.readUInt16LE(end + 8) || offset + length !== end)
28
+ throw invalid();
29
+ const entries = [];
30
+ let cursor = offset, inflated = 0;
31
+ for (let i = 0; i < count; i++) {
32
+ if (cursor + 46 > end || bytes.readUInt32LE(cursor) !== 0x02014b50)
33
+ throw invalid();
34
+ const flags = bytes.readUInt16LE(cursor + 8), method = bytes.readUInt16LE(cursor + 10), checksum = bytes.readUInt32LE(cursor + 16);
35
+ const packed = bytes.readUInt32LE(cursor + 20), size = bytes.readUInt32LE(cursor + 24), nameLength = bytes.readUInt16LE(cursor + 28);
36
+ const next = cursor + 46 + nameLength + bytes.readUInt16LE(cursor + 30) + bytes.readUInt16LE(cursor + 32), local = bytes.readUInt32LE(cursor + 42);
37
+ if (next > end || flags & 1 || ![0, 8].includes(method) || size > MAX_BYTES || local + 30 > offset || bytes.readUInt16LE(cursor + 34))
38
+ throw invalid();
39
+ const nameBytes = bytes.subarray(cursor + 46, cursor + 46 + nameLength), name = new TextDecoder('utf-8', { fatal: true }).decode(nameBytes);
40
+ if (!name || name.includes('\\') || name.startsWith('/') || name.split('/').includes('..') || name.includes('\0'))
41
+ throw invalid();
42
+ if (bytes.readUInt32LE(local) !== 0x04034b50 || bytes.readUInt16LE(local + 8) !== method || bytes.readUInt16LE(local + 6) !== flags)
43
+ throw invalid();
44
+ const localNameLength = bytes.readUInt16LE(local + 26), start = local + 30 + localNameLength + bytes.readUInt16LE(local + 28);
45
+ if (!bytes.subarray(local + 30, local + 30 + localNameLength).equals(nameBytes) || start + packed > offset)
46
+ throw invalid();
47
+ if (/\.(?:[cm]?js|html?)$/i.test(name)) {
48
+ if ((inflated += size) > MAX_BYTES)
49
+ throw invalid();
50
+ const data = bytes.subarray(start, start + packed), value = method === 0 ? data : inflateRawSync(data, { maxOutputLength: Math.max(1, size) });
51
+ if (value.length !== size || crc32(value) !== checksum)
52
+ throw invalid();
53
+ entries.push({ name, bytes: value });
54
+ }
55
+ cursor = next;
56
+ }
57
+ if (cursor !== end || !entries.length)
58
+ throw invalid();
59
+ return entries;
60
+ }
61
+ export function auditAudioProgramArtifact(bytes, outputPath, variant) {
62
+ if (!bytes.length || bytes.length > MAX_BYTES || !['runtime', 'prerender'].includes(variant))
63
+ throw invalid();
64
+ const extension = extname(outputPath).toLowerCase(), format = extension === '.zip' ? 'zip' : extension === '.html' || extension === '.htm' ? 'html' : undefined;
65
+ if (!format)
66
+ throw invalid();
67
+ const entries = format === 'zip' ? zipTextEntries(bytes) : [{ name: outputPath, bytes }];
68
+ const result = { format, inspectedFiles: entries.length, inspectedBytes: 0, expectedMarkerCount: 0, forbiddenMarkerCount: 0, coverage: 'html-javascript-and-explicit-base64-data' };
69
+ const seen = new Set();
70
+ const inspect = (data, depth) => {
71
+ if ((result.inspectedBytes += data.length) > MAX_BYTES)
72
+ throw invalid();
73
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(data);
74
+ result.expectedMarkerCount += text.split(MARKERS[variant]).length - 1;
75
+ result.forbiddenMarkerCount += text.split(MARKERS[variant === 'runtime' ? 'prerender' : 'runtime']).length - 1;
76
+ for (const match of text.matchAll(/data:(?:text\/(?:javascript|html)|application\/(?:javascript|json));base64,([A-Za-z0-9+/]+={0,2})/g)) {
77
+ if (depth >= 3 || match[1].length > MAX_BYTES * 4 / 3)
78
+ throw invalid();
79
+ if (seen.has(match[1]))
80
+ continue;
81
+ seen.add(match[1]);
82
+ inspect(Buffer.from(match[1], 'base64'), depth + 1);
83
+ }
84
+ };
85
+ for (const entry of entries)
86
+ inspect(entry.bytes, 0);
87
+ if (result.forbiddenMarkerCount || !result.expectedMarkerCount)
88
+ throw Object.assign(invalid(), { details: { variant, ...result } });
89
+ return result;
90
+ }
@@ -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
+ }