@playcraft/cli 0.1.67 → 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 (22) hide show
  1. package/dist/commands/audio-program.js +82 -0
  2. package/dist/commands/audio.js +2 -0
  3. package/dist/workspace-runtime/server/audio-assets.js +49 -3
  4. package/dist/workspace-runtime/workspaces/asset-library-import.js +27 -7
  5. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +6 -2
  6. package/dist/workspace-runtime/workspaces/audio-assets/library-import.js +149 -0
  7. package/dist/workspace-runtime/workspaces/audio-assets/library-package.js +26 -0
  8. package/dist/workspace-runtime/workspaces/audio-assets/program-artifact-audit.js +90 -0
  9. package/dist/workspace-runtime/workspaces/audio-assets/program-browser.js +194 -0
  10. package/dist/workspace-runtime/workspaces/audio-assets/program-build-inputs.js +122 -0
  11. package/dist/workspace-runtime/workspaces/audio-assets/program-engine.js +39 -0
  12. package/dist/workspace-runtime/workspaces/audio-assets/program-package.js +289 -0
  13. package/dist/workspace-runtime/workspaces/audio-assets/program-variant-build.js +217 -0
  14. package/dist/workspace-runtime/workspaces/audio-assets/references.js +107 -0
  15. package/dist/workspace-runtime/workspaces/audio-assets/service.js +133 -1
  16. package/dist/workspace-runtime/workspaces/git.js +7 -0
  17. package/package.json +6 -3
  18. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  19. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  20. package/project-template-v2/.playcraft/skills.lock.json +2 -2
  21. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  22. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
@@ -0,0 +1,82 @@
1
+ import { constants } from 'node:fs';
2
+ import { dirname, posix, resolve } from 'node:path';
3
+ import { parseAudioDynamicProgram, serializeAudioDynamicProgram, validateAudioDynamicProgram } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { readAudioArtifactPackage } from '../workspace-runtime/workspaces/audio-assets/artifact-package.js';
5
+ import { audioProjectPath, digest } from '../workspace-runtime/workspaces/audio-assets/storage.js';
6
+ import { compileAudioProgram, readAudioProgramFile, readAudioProgramPackage } from '../workspace-runtime/workspaces/audio-assets/program-package.js';
7
+ import { ensureSharedDirectory, openSharedFile } from '../workspace-runtime/workspaces/shared-write.js';
8
+ const messages = {
9
+ en: { topic: 'Advanced game state audio programs', init: 'Create a bounded program from a saved sound bundle',
10
+ compile: 'Validate all dependencies and render state variants with the sandboxed browser', inspect: 'Inspect a verified immutable program bundle',
11
+ validate: 'Validate program syntax without rendering', root: 'Project root', asset: 'Saved immutable sound source in dist/audio',
12
+ sample: 'Optional short mono PCM (Pulse Code Modulation, 脉冲编码调制) 16-bit WAV (Waveform Audio File Format, 波形音频文件格式) sample', output: 'New .pcaudioprogram.json source path', path: 'Program source path', manifest: 'Compiled program manifest path', failed: 'Audio program failed', defaultName: 'Game sound' },
13
+ zh: { topic: '高级游戏状态音频程序', init: '从已保存的完整声音包创建有界状态程序', compile: '校验依赖并用保留沙箱的浏览器渲染状态',
14
+ inspect: '检查已验证的不可变音频程序包', validate: '校验程序格式,不执行渲染', root: '工程根目录', asset: 'dist/audio 中已保存的不可变声音源',
15
+ sample: '可选短单声道十六位脉冲编码调制采样文件', output: '新 .pcaudioprogram.json 源路径', path: '音频程序源路径', manifest: '已编译的音频程序清单路径', failed: '音频程序操作失败', defaultName: '游戏声音' },
16
+ };
17
+ const t = (key) => messages[(process.env.PLAYCRAFT_LANGUAGE || process.env.LANG || 'en').startsWith('zh') ? 'zh' : 'en'][key];
18
+ const result = (action) => async (options) => {
19
+ try {
20
+ console.log(JSON.stringify(await action(options), null, 2));
21
+ }
22
+ catch (error) {
23
+ console.error(`${t('failed')}: ${error instanceof Error ? error.message : String(error)}`);
24
+ process.exitCode = 1;
25
+ }
26
+ };
27
+ const reference = async (root, path, max) => {
28
+ const data = await readAudioProgramFile(root, path, max);
29
+ return { path, contentHash: digest(data), size: data.length };
30
+ };
31
+ /** Advanced entry is opt-in; the default sound editor retains its simpler controls. */
32
+ export function registerAudioProgramCommands(audio) {
33
+ const program = audio.command('program').description(t('topic'));
34
+ const scoped = (command) => command.option('--root <directory>', t('root'), '.');
35
+ scoped(program.command('init').description(t('init'))).requiredOption('--asset <path>', t('asset')).option('--sample <path>', t('sample'))
36
+ .requiredOption('--output <path>', t('output')).action(result(async (options) => {
37
+ const root = resolve(options.root), path = options.output;
38
+ if (!path.endsWith('.pcaudioprogram.json') || path.startsWith('dist/'))
39
+ throw new Error('invalid_audio_path');
40
+ await readAudioArtifactPackage(root, options.asset);
41
+ const assets = [{ id: 'sound', kind: 'synth', source: await reference(root, options.asset, 65536),
42
+ manifest: await reference(root, `${posix.dirname(options.asset)}/manifest.json`, 65536) }];
43
+ if (options.sample)
44
+ assets.push({ id: 'sample', kind: 'sample', file: await reference(root, options.sample, 176444) });
45
+ const base = { assetId: 'sound', gain: .5, pitchSemitones: 0, lowpassHz: null, loop: true, rangeBeats: null };
46
+ const document = validateAudioDynamicProgram({ format: 'playcraft.audio-program', formatVersion: 1, name: t('defaultName'),
47
+ transition: 'restart', masterGain: .6, initialState: 'idle', assets,
48
+ states: [{ id: 'idle', durationSeconds: 4, layers: [base] }, { id: 'action', durationSeconds: 4, layers: [{ ...base, lowpassHz: 3000 },
49
+ ...(options.sample ? [{ assetId: 'sample', gain: .2, pitchSemitones: 0, lowpassHz: null, loop: false, rangeBeats: null }] : [])] }] });
50
+ const destination = await audioProjectPath(root, path, true);
51
+ await ensureSharedDirectory(root, dirname(destination));
52
+ const handle = await openSharedFile(root, destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
53
+ try {
54
+ await handle.writeFile(serializeAudioDynamicProgram(document));
55
+ await handle.sync();
56
+ }
57
+ finally {
58
+ await handle.close();
59
+ }
60
+ return { path, contentHash: digest(serializeAudioDynamicProgram(document)), stateIds: document.states.map(state => state.id) };
61
+ }));
62
+ scoped(program.command('validate').description(t('validate'))).requiredOption('--path <path>', t('path')).action(result(async (options) => {
63
+ const document = parseAudioDynamicProgram(await readAudioProgramFile(resolve(options.root), options.path, 65536));
64
+ return { valid: true, stateIds: document.states.map(state => state.id), assetIds: document.assets.map(asset => asset.id) };
65
+ }));
66
+ scoped(program.command('compile').description(t('compile'))).requiredOption('--path <path>', t('path')).action(result(async (options) => {
67
+ const abort = new AbortController(), cancel = () => abort.abort();
68
+ process.once('SIGINT', cancel);
69
+ try {
70
+ const value = await compileAudioProgram(resolve(options.root), options.path, abort.signal);
71
+ return { manifestPath: value.manifestPath, semanticHash: value.semanticHash, runtimeModulePath: value.runtimeModulePath,
72
+ prerenderModulePath: value.prerenderModulePath, states: value.manifest.states };
73
+ }
74
+ finally {
75
+ process.off('SIGINT', cancel);
76
+ }
77
+ }));
78
+ scoped(program.command('inspect').description(t('inspect'))).requiredOption('--manifest <path>', t('manifest')).action(result(async (options) => {
79
+ const value = await readAudioProgramPackage(resolve(options.root), options.manifest);
80
+ return { manifest: value.manifest, runtimeModulePath: value.runtimeModulePath, prerenderModulePath: value.prerenderModulePath };
81
+ }));
82
+ }
@@ -1,5 +1,6 @@
1
1
  import { getAudioInfo, compressAudio, trimAudio, convertAudio, fadeAudio, concatAudio, normalizeAudio, loopAudio, mixAudio, } from '../utils/audio-processor.js';
2
2
  import { registerAudioSourceCommands } from './audio-source.js';
3
+ import { registerAudioProgramCommands } from './audio-program.js';
3
4
  import { audioSourceMessage } from '../audio-source/messages.js';
4
5
  /**
5
6
  * Parse and validate `audio mix --volumes` / `--offsets` against `--inputs`.
@@ -49,6 +50,7 @@ export function registerAudioCommands(program) {
49
50
  .command('audio')
50
51
  .description(audioSourceMessage('audioTopic'));
51
52
  registerAudioSourceCommands(audio);
53
+ registerAudioProgramCommands(audio);
52
54
  // ── audio-info ──────────────────────────────────────────────────────────
53
55
  audio
54
56
  .command('info')
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { workspaceAudioTargetSchema, workspaceRelativePathSchema, workspaceAudioProtectionSchema, isSoundDocumentPath } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { workspaceAudioTargetSchema, workspaceRelativePathSchema, workspaceAudioProtectionSchema, isSoundDocumentPath, audioReferenceActionSchema } from '@playcraft/common/cloud-workspace/runtime';
3
3
  import { WorkspaceAudioAssets } from '../workspaces/audio-assets/service.js';
4
4
  import { audioRendererScript, workspaceAudioRenderer } from '../workspaces/audio-assets/renderer.js';
5
5
  import { audioError } from '../workspaces/audio-assets/storage.js';
@@ -116,7 +116,8 @@ async function rendererProbe(state, root, scope, kind = 'synth-sfx') {
116
116
  }
117
117
  /** Audio protocol negotiation is independent of legacy binary audio export. */
118
118
  export async function handleAudioAssetRequest(state, request, response, url) {
119
- if (!['/runtime/audio/capabilities', '/runtime/audio/assets', '/runtime/audio/content', '/runtime/audio/actions'].includes(url.pathname))
119
+ if (!['/runtime/audio/capabilities', '/runtime/audio/assets', '/runtime/audio/content', '/runtime/audio/actions',
120
+ '/runtime/audio/library-snapshot', '/runtime/audio/references'].includes(url.pathname))
120
121
  return false;
121
122
  try {
122
123
  if (request.headers['x-playcraft-audio-protocol'] !== '1')
@@ -127,7 +128,7 @@ export async function handleAudioAssetRequest(state, request, response, url) {
127
128
  const renderer = enabled ? await rendererProbe(state, root, `${workspaceId ?? 'single'}:${directoryName}`) : { ready: false, reason: 'feature_disabled' };
128
129
  const scoreRenderer = enabled && request.headers['x-playcraft-audio-score-protocol'] === '1'
129
130
  ? await rendererProbe(state, root, `${workspaceId ?? 'single'}:${directoryName}`, 'score') : { ready: false, reason: enabled ? 'audio_score_protocol_required' : 'feature_disabled' };
130
- send(response, 200, { version: 1, audioAssetVersion: 1, audioScoreVersion: 1, audioScoreContextVersion: 1,
131
+ send(response, 200, { version: 1, audioAssetVersion: 1, audioScoreVersion: 1, audioScoreContextVersion: 1, audioLibraryVersion: 1, audioReferencesVersion: 1,
131
132
  enabled, writable: enabled, documentVersions: [1], contentKinds: request.headers['x-playcraft-audio-score-protocol'] === '1' ? ['synth-sfx', 'score'] : ['synth-sfx'],
132
133
  renderer: { ...renderer, engineId: 'playcraft-webaudio-sfx', engineVersion: '1.0.0' },
133
134
  scoreRenderer: { ...scoreRenderer, engineId: 'playcraft-webaudio-score', engineVersion: '1.0.0' },
@@ -144,6 +145,51 @@ export async function handleAudioAssetRequest(state, request, response, url) {
144
145
  send(response, 200, result);
145
146
  return true;
146
147
  }
148
+ if (request.method === 'GET' && url.pathname === '/runtime/audio/library-snapshot') {
149
+ if (!enabled)
150
+ throw audioError('audio_feature_disabled', 403);
151
+ const target = targetFrom(url), revision = Number(url.searchParams.get('revision')), hash = url.searchParams.get('contentHash');
152
+ if (!Number.isSafeInteger(revision) || revision < 1 || !hash || !/^[a-f0-9]{64}$/.test(hash))
153
+ throw audioError('invalid_audio_request', 400);
154
+ const current = await assets.read(target);
155
+ requireScore(current.document.format === 'playcraft.score');
156
+ send(response, 200, await assets.librarySnapshot(target, revision, hash));
157
+ return true;
158
+ }
159
+ if (request.method === 'GET' && url.pathname === '/runtime/audio/references') {
160
+ const target = targetFrom(url);
161
+ if (target.kind !== 'workspace-file')
162
+ throw audioError('invalid_audio_target', 400);
163
+ const current = await assets.read(target);
164
+ requireScore(current.document.format === 'playcraft.score');
165
+ send(response, 200, await assets.references(target));
166
+ return true;
167
+ }
168
+ if (request.method === 'POST' && url.pathname === '/runtime/audio/references') {
169
+ if (!enabled)
170
+ throw audioError('audio_feature_disabled', 403);
171
+ if (state.journal.destroyed())
172
+ throw audioError('runtime_destroyed');
173
+ const input = audioReferenceActionSchema.safeParse(await body(request));
174
+ if (!input.success)
175
+ throw audioError('invalid_audio_request', 400);
176
+ requireScore(input.data.target.path.endsWith('.pcscore.json'));
177
+ const sessionId = url.searchParams.get('sessionId');
178
+ if (sessionId) {
179
+ const session = state.journal.getSession(sessionId);
180
+ if (!session || session.workspaceId !== workspaceId || (session.directoryName ?? 'basic') !== directoryName)
181
+ throw audioError('audio_scope_mismatch', 403);
182
+ if (session.permissionMode === 'read-only')
183
+ throw audioError('plan_mode_write_forbidden', 403);
184
+ }
185
+ const result = await withDirectoryWrite(state, { workspaceId, directoryName, wait: false, sessionId: sessionId ?? undefined }, async () => {
186
+ if (await batchDirectoryHeld(state, workspaceId, directoryName))
187
+ throw audioError('batch_workspace_readonly', 403);
188
+ return state.journal.exclusive(`file:${revisionScope(state, workspaceId, directoryName) ?? 'single'}:source/audio-bindings/${input.data.eventName}.json`, () => assets.referenceAction(input.data));
189
+ });
190
+ send(response, 200, result);
191
+ return true;
192
+ }
147
193
  if (request.method === 'GET' && url.pathname === '/runtime/audio/content') {
148
194
  const revision = url.searchParams.has('revision') ? Number(url.searchParams.get('revision')) : undefined;
149
195
  if (revision !== undefined && (!Number.isSafeInteger(revision) || revision < 1))
@@ -6,6 +6,8 @@ import { BINARY_WORKSPACE_FILE_MAX_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES
6
6
  import { observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from './binary-write.js';
7
7
  import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
8
8
  import { ensureSharedDirectory } from './shared-write.js';
9
+ import { isEditableAudioLibraryAsset, prepareAudioLibraryImport } from './audio-assets/library-import.js';
10
+ import { readAudioArtifactPackage } from './audio-assets/artifact-package.js';
9
11
  const RESERVED = new Set(['.git', 'node_modules', '.playcraft-temp', '.tmp']);
10
12
  const digest = (value) => createHash('sha256').update(value).digest('hex');
11
13
  const missing = (error) => error?.code === 'ENOENT';
@@ -158,6 +160,9 @@ async function readTransaction(root, transaction) {
158
160
  || !/^[a-f0-9]{64}$/.test(value.incomingHash))
159
161
  throw new AssetLibraryImportError('invalid_library_import');
160
162
  validPath(value.target);
163
+ if (value.audioPackagePaths && (value.audioPackagePaths.length !== 5 || new Set(value.audioPackagePaths).size !== 5
164
+ || value.audioPackagePaths.some(path => !/^dist\/audio\/[a-f0-9]{64}\/[a-f0-9]{64}\/[a-f0-9]{64}\/(?:sound\.(?:pcaudio|pcscore)\.json|audition\.wav|runtime\.mjs|sound\.mjs|manifest\.json)$/.test(path))))
165
+ throw new AssetLibraryImportError('invalid_library_import');
161
166
  if ((value.operationId !== undefined || value.assetId !== undefined || value.paths !== undefined)
162
167
  && (typeof value.operationId !== 'string' || !value.operationId || value.operationId.length > 128
163
168
  || typeof value.assetId !== 'string' || !value.assetId || value.assetId.length > 128
@@ -203,8 +208,17 @@ export async function recoverInterruptedLibraryImports(root) {
203
208
  throw new AssetLibraryImportError('invalid_library_import_path');
204
209
  if (current?.isDirectory() && !current.isSymbolicLink() && !incoming
205
210
  && await directorySnapshot(target) === manifest.incomingHash) {
211
+ if (manifest.audioPackagePaths) {
212
+ const primary = manifest.audioPackagePaths.find(path => /\/sound\.(?:pcaudio|pcscore)\.json$/.test(path));
213
+ if (!primary)
214
+ throw new AssetLibraryImportError('invalid_library_import');
215
+ const packaged = await readAudioArtifactPackage(rootReal, primary);
216
+ if (!packaged.sourcePath.startsWith(`${manifest.target}/`) || packaged.files.size !== 5
217
+ || [...packaged.files.keys()].some(path => !manifest.audioPackagePaths.includes(path)))
218
+ throw new AssetLibraryImportError('invalid_library_import');
219
+ }
206
220
  if (manifest.operationId && manifest.assetId && manifest.paths)
207
- result.published.push({ operationId: manifest.operationId, assetId: manifest.assetId, paths: manifest.paths });
221
+ result.published.push({ operationId: manifest.operationId, assetId: manifest.assetId, paths: [...manifest.paths, ...(manifest.audioPackagePaths ?? [])] });
208
222
  await removeTransaction(rootReal, transaction);
209
223
  result.recovered.push(manifest.target);
210
224
  continue;
@@ -236,7 +250,7 @@ export async function recoverInterruptedLibraryImports(root) {
236
250
  }
237
251
  return result;
238
252
  }
239
- async function publishBundle(root, target, asset, replace, assertCurrent, operationId) {
253
+ async function publishBundle(root, target, asset, replace, assertCurrent, operationId, audioPackagePaths) {
240
254
  const parent = dirname(target) === '.' ? '' : dirname(target);
241
255
  await createPublicDirectory(root, parent);
242
256
  const previous = await statTarget(root, target);
@@ -256,6 +270,7 @@ async function publishBundle(root, target, asset, replace, assertCurrent, operat
256
270
  await writeWorkspaceBinaryFile(staged, { path: file.path, contentBase64: file.content, expectedContentHash: null });
257
271
  }
258
272
  const manifest = { version: 1, target, phase: 'prepared', originalHash: originalHash ?? null,
273
+ ...(audioPackagePaths ? { audioPackagePaths } : {}),
259
274
  incomingHash: await directorySnapshot(staged), ...(operationId ? { operationId, assetId: asset.assetId,
260
275
  paths: asset.files.map(file => `${target}/${file.path}`) } : {}) };
261
276
  await saveTransaction(root, transaction, manifest);
@@ -364,9 +379,13 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
364
379
  }
365
380
  }
366
381
  if (asset.kind === 'bundle') {
367
- const paths = asset.files.map(file => validPath(`${target}/${file.path}`));
368
- publishedTransaction = await publishBundle(rootReal, target, asset, payload.collision === 'replace', assertCurrent, options.operationId);
382
+ const audio = isEditableAudioLibraryAsset(asset) ? await prepareAudioLibraryImport(rootReal, target, asset, assertCurrent) : undefined;
383
+ const incoming = audio?.asset ?? asset;
384
+ const paths = incoming.files.map(file => validPath(`${target}/${file.path}`));
385
+ publishedTransaction = await publishBundle(rootReal, target, incoming, payload.collision === 'replace', assertCurrent, options.operationId, audio?.packagePaths);
369
386
  result.paths.push(...paths);
387
+ if (audio)
388
+ result.paths.push(...audio.packagePaths);
370
389
  }
371
390
  else {
372
391
  const observed = existing ? await observeWorkspaceBinaryFile(rootReal, target) : undefined;
@@ -378,9 +397,10 @@ export async function importWorkspaceLibraryAssets(root, selection, snapshot, as
378
397
  }
379
398
  catch (error) {
380
399
  const code = error instanceof AssetLibraryImportError ? error.message
381
- : error?.code === 'content_conflict' ? 'content_conflict'
382
- : ['EACCES', 'EPERM'].includes(String(error?.code)) ? 'workspace_permission_denied'
383
- : 'asset_library_import_failed';
400
+ : error instanceof Error && ['invalid_audio_library_package', 'audio_library_engine_unavailable', 'audio_library_source_unverified', 'audio_representation_conflict'].includes(error.message) ? error.message
401
+ : error?.code === 'content_conflict' ? 'content_conflict'
402
+ : ['EACCES', 'EPERM'].includes(String(error?.code)) ? 'workspace_permission_denied'
403
+ : 'asset_library_import_failed';
384
404
  result.failures.push({ assetId, error: code });
385
405
  }
386
406
  processed.add(assetId);
@@ -4,6 +4,7 @@ import { canonicalAudioJson } from '@playcraft/common/cloud-workspace/runtime';
4
4
  import { readAudioArtifactPackage } from './artifact-package.js';
5
5
  import { audioError, audioProjectPath, digest, readAudioSource } from './storage.js';
6
6
  import { ensureSharedDirectory, writeSharedFile } from '../shared-write.js';
7
+ import { readAudioProgramBuildInputs } from './program-build-inputs.js';
7
8
  const hashPart = /^[a-f0-9]{64}$/;
8
9
  const MAX_PACKAGES = 256;
9
10
  const MAX_PACKAGE_BYTES = 64 * 1024 * 1024;
@@ -65,12 +66,15 @@ export async function readAudioBuildInputs(root, destination) {
65
66
  }
66
67
  packages.push({ primaryPath, sourcePath: bundle.sourcePath, sourceContentHash: sourceText === undefined ? null : digest(sourceText), files });
67
68
  }
68
- return { version: 1, inputHash: digest(canonicalAudioJson(packages)), packages };
69
+ const programs = await readAudioProgramBuildInputs(root, destination);
70
+ // Existing projects retain their original snapshot identity when no state programs are present.
71
+ return { version: 1, inputHash: digest(canonicalAudioJson(programs.packages.length ? { packages, programs } : packages)), packages,
72
+ ...(programs.packages.length ? { programs } : {}) };
69
73
  }
70
74
  export async function verifyAudioBuildInputs(root, snapshot) {
71
75
  if ((await readAudioBuildInputs(root)).inputHash !== snapshot.inputHash)
72
76
  throw audioError('audio_build_input_changed');
73
77
  }
74
78
  export function markAudioBuildInputs(bytes, snapshot) {
75
- return snapshot.packages.length ? Buffer.concat([Buffer.from(`<!--playcraft-audio-inputs:v1:${snapshot.inputHash}-->\n`), bytes]) : bytes;
79
+ return snapshot.packages.length || snapshot.programs?.packages.length ? Buffer.concat([Buffer.from(`<!--playcraft-audio-inputs:v1:${snapshot.inputHash}-->\n`), bytes]) : bytes;
76
80
  }
@@ -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
+ }