@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
@@ -2,22 +2,27 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { lstat, mkdir, open, readFile, realpath, link, unlink } from 'node:fs/promises';
4
4
  import { extname, join, posix, resolve, sep } from 'node:path';
5
- import { WORKSPACE_ARTIFACT_MAX_BYTES, isWorkspaceArtifactOutputPath, workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { WORKSPACE_ARTIFACT_MAX_BYTES, isWorkspaceArtifactOutputPath, workspaceRelativePathSchema, isSoundDocumentPath } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { openSharedFile } from './shared-write.js';
7
+ import { audioArtifactResources } from './audio-assets/artifact-package.js';
7
8
  const formats = {
8
9
  '.png': ['image', 'image/png'], '.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'],
9
10
  '.webp': ['image', 'image/webp'], '.gif': ['image', 'image/gif'], '.avif': ['image', 'image/avif'], '.svg': ['image', 'image/svg+xml'],
10
11
  '.hdr': ['image', 'image/vnd.radiance'], '.exr': ['image', 'image/x-exr'],
11
12
  '.ktx': ['image', 'image/ktx'], '.ktx2': ['image', 'image/ktx2'], '.dds': ['image', 'image/vnd-ms.dds'],
12
13
  '.mp4': ['video', 'video/mp4'], '.webm': ['video', 'video/webm'], '.mov': ['video', 'video/quicktime'],
13
- '.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'], '.ogg': ['audio', 'audio/ogg'], '.m4a': ['audio', 'audio/mp4'], '.flac': ['audio', 'audio/flac'],
14
+ '.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'], '.ogg': ['audio', 'audio/ogg'], '.opus': ['audio', 'audio/ogg'], '.m4a': ['audio', 'audio/mp4'], '.flac': ['audio', 'audio/flac'],
14
15
  '.glb': ['model', 'model/gltf-binary'], '.gltf': ['model', 'model/gltf+json'], '.obj': ['model', 'text/plain'], '.fbx': ['model', 'application/octet-stream'], '.stl': ['model', 'model/stl'], '.usdz': ['model', 'model/vnd.usdz+zip'],
15
16
  '.md': ['document', 'text/markdown'], '.txt': ['document', 'text/plain'], '.pdf': ['document', 'application/pdf'],
16
17
  '.html': ['document', 'text/html'], '.csv': ['document', 'text/csv'], '.docx': ['document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
17
18
  '.pptx': ['document', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], '.xlsx': ['document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
18
19
  '.zip': ['file', 'application/zip'],
19
20
  };
20
- export function artifactFormat(path) { return formats[extname(path).toLowerCase()]; }
21
+ export function artifactFormat(path) {
22
+ if (isSoundDocumentPath(path))
23
+ return ['audio', 'application/json'];
24
+ return formats[extname(path).toLowerCase()];
25
+ }
21
26
  export function artifactMime(path) { return artifactFormat(path)?.[1] ?? 'application/octet-stream'; }
22
27
  /** Reject every redirected path segment, including links that happen to remain in the workspace. */
23
28
  export async function artifactPath(root, path) {
@@ -152,6 +157,8 @@ async function binaryModelDocument(root, path) {
152
157
  }
153
158
  /** Only explicit model references become members; neighboring names never imply a relationship. */
154
159
  export async function artifactResources(root, path) {
160
+ if (isSoundDocumentPath(path))
161
+ return audioArtifactResources(root, path);
155
162
  const resources = new Set();
156
163
  const add = (from, uri) => { const result = resourcePath(from, uri); if (result)
157
164
  resources.add(result); };
@@ -1,18 +1,23 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { lstat, mkdir, open, readdir, rename, unlink } from 'node:fs/promises';
4
- import { basename, join } from 'node:path';
4
+ import { basename, dirname, join } from 'node:path';
5
5
  import sharp from 'sharp';
6
- import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, workspaceModelBuildSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, isSoundDocumentPath, workspaceModelBuildSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { artifactBundleDigest, artifactDirectory, artifactFormat, artifactPath, artifactResources, snapshotArtifactBundle, snapshotArtifactFile } from './artifact-files.js';
8
8
  import { ARTIFACT_RECEIPTS_DIRECTORY, readArtifactReceipt } from './artifact-receipts.js';
9
9
  import { ensureSharedDirectory } from './shared-write.js';
10
10
  import { validateWorkspacePlayable } from '../previews/finalize.js';
11
11
  import { PREVIEW_HTML_MAX_BYTES, WORKSPACE_PLAYABLE_OUTPUT_PATH } from '@playcraft/common/cloud-workspace/runtime';
12
12
  import { applyWorkspaceModelBuildStates, sameWorkspaceModelBuild } from './model-build-inputs.js';
13
+ import { audioArtifactDetails, validateAudioArtifactVersion } from './audio-assets/artifact-package.js';
14
+ import { readAudioSource } from './audio-assets/storage.js';
13
15
  const serial = new Map();
14
16
  function digest(text) { return createHash('sha256').update(text).digest('hex'); }
15
- function artifactId(workspaceId, path) { return `artifact_${digest(`${workspaceId}\0${path}`).slice(0, 40)}`; }
17
+ function artifactId(workspaceId, path) {
18
+ const audioKey = /^dist\/audio\/([a-f0-9]{64})\/[a-f0-9]{64}\/[a-f0-9]{64}\/sound\.(?:pcaudio|pcscore)\.json$/.exec(path)?.[1];
19
+ return `artifact_${digest(`${workspaceId}\0${audioKey ? `audio-source:${audioKey}` : path}`).slice(0, 40)}`;
20
+ }
16
21
  function outputArtifacts(artifacts) {
17
22
  return artifacts.flatMap(artifact => {
18
23
  const versions = artifact.versions.filter(isWorkspaceArtifactOutputVersion);
@@ -152,10 +157,34 @@ export class WorkspaceArtifactStore {
152
157
  catch { /* An incomplete receipt stays pending; the next scan retries its original bytes. */ }
153
158
  }
154
159
  const candidates = await discoverPaths(root);
155
- // Models are inspected before images so their explicitly referenced textures stay under the model.
156
- candidates.sort((a, b) => Number(artifactFormat(b)?.[0] === 'model') - Number(artifactFormat(a)?.[0] === 'model') || a.localeCompare(b));
160
+ // Compound sounds and models claim their explicitly listed resources before standalone media.
161
+ const priority = (path) => isSoundDocumentPath(path) ? 2 : Number(artifactFormat(path)?.[0] === 'model');
162
+ candidates.sort((a, b) => priority(b) - priority(a) || a.localeCompare(b));
157
163
  const visible = outputArtifacts(catalog.artifacts);
158
164
  const members = new Set(visible.flatMap(artifact => artifact.versions.flatMap(version => version.files.filter(file => file.role !== 'primary').map(file => file.path))));
165
+ const audioMembers = new Set();
166
+ const currentAudio = new Set();
167
+ for (const path of candidates.filter(isSoundDocumentPath)) {
168
+ try {
169
+ const details = await audioArtifactDetails(root, path);
170
+ for (const member of details.resources) {
171
+ members.add(member);
172
+ audioMembers.add(member);
173
+ }
174
+ const source = await readAudioSource(root, details.sourcePath).catch(() => undefined);
175
+ if (source !== undefined && digest(source) === details.sourceContentHash)
176
+ currentAudio.add(path);
177
+ }
178
+ catch {
179
+ // An incomplete sound package stays visible as ordinary files, never as a standalone audio fallback.
180
+ for (const candidate of candidates.filter(value => dirname(value) === dirname(path))) {
181
+ members.add(candidate);
182
+ if (candidate !== path)
183
+ audioMembers.add(candidate);
184
+ }
185
+ }
186
+ }
187
+ candidates.sort((a, b) => priority(b) - priority(a) || Number(currentAudio.has(a)) - Number(currentAudio.has(b)) || a.localeCompare(b));
159
188
  for (const path of candidates) {
160
189
  if (members.has(path))
161
190
  continue;
@@ -165,7 +194,9 @@ export class WorkspaceArtifactStore {
165
194
  continue;
166
195
  try {
167
196
  const current = visible.find(artifact => artifact.id === artifactId(this.scopeId, path));
168
- const version = current?.versions.find(entry => entry.id === current.currentVersionId);
197
+ const version = isSoundDocumentPath(path)
198
+ ? current?.versions.find(entry => entry.path === path && entry.id === catalog.observations[path]?.versionId) ?? current?.versions.find(entry => entry.path === path)
199
+ : current?.versions.find(entry => entry.id === current.currentVersionId);
169
200
  const knownWrite = sourcePaths.includes(path) && Boolean(source.operationId) && version?.source.operationId !== source.operationId;
170
201
  const observation = catalog.observations[path];
171
202
  if (!knownWrite && version && (path !== WORKSPACE_PLAYABLE_OUTPUT_PATH || current?.kind === 'playable')
@@ -229,6 +260,8 @@ export class WorkspaceArtifactStore {
229
260
  }
230
261
  catalog.artifacts = catalog.artifacts.filter(artifact => {
231
262
  const current = artifact.versions.find(version => version.id === artifact.currentVersionId);
263
+ if (audioMembers.has(current.path))
264
+ return false;
232
265
  return !isWorkspaceArtifactOutputVersion(current) || current.source.kind !== 'discovered' || !members.has(current.path);
233
266
  });
234
267
  catalog.artifacts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
@@ -238,6 +271,10 @@ export class WorkspaceArtifactStore {
238
271
  return { list, changed: original !== JSON.stringify(list.artifacts) };
239
272
  }
240
273
  async versionKind(objects, version) {
274
+ if (isSoundDocumentPath(version.path)) {
275
+ await validateAudioArtifactVersion(objects, version.path, version.files);
276
+ return 'audio';
277
+ }
241
278
  if (version.path !== WORKSPACE_PLAYABLE_OUTPUT_PATH)
242
279
  return artifactFormat(version.path)[0];
243
280
  if (version.files.length !== 1)
@@ -260,11 +297,15 @@ export class WorkspaceArtifactStore {
260
297
  }
261
298
  async add(catalog, version, kind, objects) {
262
299
  const id = artifactId(this.scopeId, version.path);
300
+ const audioName = kind === 'audio' && isSoundDocumentPath(version.path)
301
+ ? (await validateAudioArtifactVersion(objects, version.path, version.files)).name : undefined;
263
302
  let artifact = catalog.artifacts.find(item => item.id === id);
264
303
  if (!artifact) {
265
304
  artifact = { id, kind, name: basename(version.path), currentVersionId: version.id, updatedAt: version.createdAt, versions: [] };
266
305
  catalog.artifacts.push(artifact);
267
306
  }
307
+ if (audioName)
308
+ artifact.name = audioName;
268
309
  if (kind === 'playable' && artifact.kind !== 'playable') {
269
310
  const valid = await Promise.all(artifact.versions.map(async (item) => {
270
311
  try {
@@ -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);
@@ -0,0 +1,104 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { posix } from 'node:path';
4
+ import { parseSoundDocument, soundSourceRenderHash, audioRepresentationSetHash, canonicalAudioJson, isSoundDocumentPath, workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { audioProjectPath, audioError, digest } from './storage.js';
6
+ const hash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
7
+ async function bytes(root, path, maximum) {
8
+ const file = await open(await audioProjectPath(root, path), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
9
+ try {
10
+ const before = await file.stat();
11
+ if (!before.isFile() || before.nlink !== 1 || before.size > maximum)
12
+ throw audioError('invalid_audio_package', 400);
13
+ const result = await file.readFile(), after = await file.stat();
14
+ if (result.length !== before.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
15
+ throw audioError('invalid_audio_package', 400);
16
+ return result;
17
+ }
18
+ finally {
19
+ await file.close();
20
+ }
21
+ }
22
+ /** A source alone is editable; only a fully verified manifest can identify a delivered sound bundle. */
23
+ export async function audioArtifactResources(root, primaryPath) {
24
+ return (await audioArtifactDetails(root, primaryPath)).resources;
25
+ }
26
+ export async function audioArtifactDetails(root, primaryPath) {
27
+ return inspectPackage(primaryPath, (path, maximum) => bytes(root, path, maximum));
28
+ }
29
+ /** Build snapshots retain the exact verified bytes, avoiding a second unverified file read. */
30
+ export async function readAudioArtifactPackage(root, primaryPath) {
31
+ const files = new Map();
32
+ const details = await inspectPackage(primaryPath, async (path, maximum) => {
33
+ const data = await bytes(root, path, maximum);
34
+ files.set(path, data);
35
+ return data;
36
+ });
37
+ return { ...details, files };
38
+ }
39
+ export async function validateAudioArtifactVersion(objects, primaryPath, files) {
40
+ if (files.length !== 5 || new Set(files.map(file => file.path)).size !== files.length ||
41
+ files.filter(file => file.role === 'primary').length !== 1 || !files.some(file => file.path === primaryPath && file.role === 'primary'))
42
+ throw audioError('invalid_audio_package', 400);
43
+ return inspectPackage(primaryPath, async (path, maximum) => {
44
+ const record = files.find(file => file.path === path);
45
+ if (!record || !hash(record.sha256))
46
+ throw audioError('invalid_audio_package', 400);
47
+ const result = await bytes(objects, record.sha256, maximum);
48
+ if (result.length !== record.size || digest(result) !== record.sha256)
49
+ throw audioError('invalid_audio_package', 400);
50
+ return result;
51
+ });
52
+ }
53
+ async function inspectPackage(primaryPath, read) {
54
+ const sourceBytes = await read(primaryPath, 65536), document = parseSoundDocument(sourceBytes);
55
+ const sourceName = document.format === 'playcraft.score' ? 'sound.pcscore.json' : 'sound.pcaudio.json';
56
+ const directory = posix.dirname(primaryPath), manifestPath = `${directory}/manifest.json`;
57
+ const manifestBytes = await read(manifestPath, 65536);
58
+ let manifest;
59
+ try {
60
+ manifest = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(manifestBytes));
61
+ }
62
+ catch {
63
+ throw audioError('invalid_audio_package', 400);
64
+ }
65
+ const allowed = ['version', 'sourcePath', 'sourceContentHash', 'sourceRenderHash', 'engine', 'rendererBuildHash', 'chromiumVersion',
66
+ 'renderKey', 'pcmContentHash', 'files', 'representationSetHash'];
67
+ if (!manifest || Array.isArray(manifest) || Object.keys(manifest).length !== allowed.length || Object.keys(manifest).some(key => !allowed.includes(key)) ||
68
+ manifest.version !== 1 || !hash(manifest.sourceContentHash) || manifest.sourceContentHash !== digest(sourceBytes) ||
69
+ manifest.sourceRenderHash !== soundSourceRenderHash(document) || canonicalAudioJson(manifest.engine) !== canonicalAudioJson(document.engine) ||
70
+ !hash(manifest.rendererBuildHash) || !hash(manifest.renderKey) || !hash(manifest.pcmContentHash) ||
71
+ typeof manifest.chromiumVersion !== 'string' || !manifest.chromiumVersion || manifest.chromiumVersion.length > 128 ||
72
+ typeof manifest.sourcePath !== 'string' || !isSoundDocumentPath(manifest.sourcePath) ||
73
+ !manifest.sourcePath.endsWith(document.format === 'playcraft.score' ? '.pcscore.json' : '.pcaudio.json') ||
74
+ !workspaceRelativePathSchema.safeParse(manifest.sourcePath).success || manifest.sourcePath.split('/').some(part => ['.git', '.playcraft', 'node_modules'].includes(part)) ||
75
+ !hash(manifest.representationSetHash) || !Array.isArray(manifest.files) || manifest.files.length !== 4)
76
+ throw audioError('invalid_audio_package', 400);
77
+ if (digest(manifestBytes) !== digest(`${canonicalAudioJson(manifest)}\n`))
78
+ throw audioError('invalid_audio_package', 400);
79
+ const { representationSetHash, ...payload } = manifest;
80
+ if (audioRepresentationSetHash(payload) !== representationSetHash ||
81
+ directory !== `dist/audio/${digest(manifest.sourcePath)}/${manifest.sourceContentHash}/${representationSetHash}`)
82
+ throw audioError('invalid_audio_package', 400);
83
+ const expected = new Map([[sourceName, ['application/json', 'editable']], ['audition.wav', ['audio/wav', 'audition']],
84
+ ['runtime.mjs', ['text/javascript', 'runtime']], ['sound.mjs', ['text/javascript', 'delivery']]]);
85
+ const resources = new Set([manifestPath]);
86
+ for (const file of manifest.files) {
87
+ if (!file || Array.isArray(file) || Object.keys(file).sort().join(',') !== 'contentHash,mimeType,path,role,size' ||
88
+ typeof file.path !== 'string' || !expected.has(file.path) || !hash(file.contentHash) || !Number.isSafeInteger(file.size) ||
89
+ file.size < 1 || file.size > (document.format === 'playcraft.score' ? 6 * 1024 * 1024 : 2 * 1024 * 1024))
90
+ throw audioError('invalid_audio_package', 400);
91
+ const [mimeType, role] = expected.get(file.path);
92
+ expected.delete(file.path);
93
+ if (file.mimeType !== mimeType || file.role !== role)
94
+ throw audioError('invalid_audio_package', 400);
95
+ const path = `${directory}/${file.path}`, member = await read(path, file.size);
96
+ if (member.length !== file.size || digest(member) !== file.contentHash)
97
+ throw audioError('invalid_audio_package', 400);
98
+ if (path !== primaryPath)
99
+ resources.add(path);
100
+ }
101
+ if (expected.size || !primaryPath.endsWith('/' + sourceName))
102
+ throw audioError('invalid_audio_package', 400);
103
+ return { resources: [...resources].sort(), name: document.name, sourcePath: manifest.sourcePath, sourceContentHash: manifest.sourceContentHash };
104
+ }
@@ -0,0 +1,71 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open } from 'node:fs/promises';
3
+ import { workspaceAudioBindingSchema } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { readAudioArtifactPackage } from './artifact-package.js';
5
+ import { audioProjectPath, digest } from './storage.js';
6
+ const maximumBindingBytes = 16_384;
7
+ /** Observe the tracked public binding without rewriting the source, binding, or private history. */
8
+ export async function readAudioBindingUsage(root, sourcePath, sourceContentHash, history, tracked) {
9
+ if (!/^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/.test(tracked.eventName))
10
+ return undefined;
11
+ const bindingPath = `source/audio-bindings/${tracked.eventName}.json`;
12
+ const location = { status: 'integration_required', bindingPath, eventName: tracked.eventName };
13
+ const invalid = { ...location, bindingState: 'invalid' };
14
+ if (tracked.bindingPath !== bindingPath)
15
+ return invalid;
16
+ try {
17
+ const path = await audioProjectPath(root, tracked.bindingPath);
18
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
19
+ let bytes;
20
+ try {
21
+ const before = await file.stat();
22
+ if (!before.isFile() || before.nlink !== 1 || before.size < 1 || before.size > maximumBindingBytes)
23
+ return invalid;
24
+ // Recheck the path after opening, before reading any bytes through the retained descriptor.
25
+ const named = await lstat(await audioProjectPath(root, tracked.bindingPath));
26
+ if (named.dev !== before.dev || named.ino !== before.ino)
27
+ return invalid;
28
+ const buffer = Buffer.alloc(before.size + 1);
29
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0), after = await file.stat();
30
+ if (bytesRead !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)
31
+ return invalid;
32
+ bytes = buffer.subarray(0, bytesRead);
33
+ }
34
+ finally {
35
+ await file.close();
36
+ }
37
+ const parsed = workspaceAudioBindingSchema.safeParse(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)));
38
+ if (!parsed.success || parsed.data.eventName !== tracked.eventName)
39
+ return invalid;
40
+ const binding = parsed.data;
41
+ if (binding.sourcePath !== sourcePath)
42
+ return { ...location, bindingState: 'different_source' };
43
+ if (!history.some(version => version.revision === binding.revision && version.contentHash === binding.sourceContentHash))
44
+ return invalid;
45
+ const directory = `dist/audio/${digest(sourcePath)}/${binding.sourceContentHash}/${binding.representationSetHash}`;
46
+ if (binding.modulePath !== `${directory}/sound.mjs`)
47
+ return invalid;
48
+ const bundle = await readAudioArtifactPackage(root, `${directory}/sound.${sourcePath.endsWith('.pcscore.json') ? 'pcscore' : 'pcaudio'}.json`);
49
+ const audition = bundle.files.get(`${directory}/audition.wav`);
50
+ if (bundle.sourcePath !== sourcePath || bundle.sourceContentHash !== binding.sourceContentHash
51
+ || !audition || digest(audition) !== binding.auditionContentHash)
52
+ return invalid;
53
+ return { ...location, bindingState: binding.sourceContentHash === sourceContentHash ? 'current' : 'different_version',
54
+ sourceContentHash: binding.sourceContentHash, revision: binding.revision, modulePath: binding.modulePath,
55
+ representationSetHash: binding.representationSetHash,
56
+ ...(binding.previousBinding ? { previousContentHash: binding.previousBinding } : {}) };
57
+ }
58
+ catch (error) {
59
+ // Only an absent binding is missing; broken package members are invalid bindings.
60
+ if (error.code === 'ENOENT') {
61
+ try {
62
+ await lstat(await audioProjectPath(root, tracked.bindingPath));
63
+ }
64
+ catch (missing) {
65
+ if (missing.code === 'ENOENT')
66
+ return { ...location, bindingState: 'missing' };
67
+ }
68
+ }
69
+ return invalid;
70
+ }
71
+ }
@@ -0,0 +1,80 @@
1
+ import { lstat, readdir } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { canonicalAudioJson } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { readAudioArtifactPackage } from './artifact-package.js';
5
+ import { audioError, audioProjectPath, digest, readAudioSource } from './storage.js';
6
+ import { ensureSharedDirectory, writeSharedFile } from '../shared-write.js';
7
+ import { readAudioProgramBuildInputs } from './program-build-inputs.js';
8
+ const hashPart = /^[a-f0-9]{64}$/;
9
+ const MAX_PACKAGES = 256;
10
+ const MAX_PACKAGE_BYTES = 64 * 1024 * 1024;
11
+ async function packagePaths(root) {
12
+ const paths = [];
13
+ const visit = async (path, depth) => {
14
+ const absolute = await audioProjectPath(root, path);
15
+ const stat = await lstat(absolute).catch(error => { if (error.code === 'ENOENT')
16
+ return undefined; throw error; });
17
+ if (!stat?.isDirectory() || stat.isSymbolicLink())
18
+ return;
19
+ if (depth === 3) {
20
+ if (paths.length >= MAX_PACKAGES)
21
+ throw audioError('audio_build_budget_exceeded', 422);
22
+ const names = await readdir(absolute);
23
+ for (const name of ['sound.pcaudio.json', 'sound.pcscore.json'])
24
+ if (names.includes(name)) {
25
+ if (paths.length >= MAX_PACKAGES)
26
+ throw audioError('audio_build_budget_exceeded', 422);
27
+ paths.push(`${path}/${name}`);
28
+ }
29
+ return;
30
+ }
31
+ for (const entry of (await readdir(absolute, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)))
32
+ if (entry.isDirectory() && !entry.isSymbolicLink() && hashPart.test(entry.name))
33
+ await visit(`${path}/${entry.name}`, depth + 1);
34
+ };
35
+ await visit('dist/audio', 0);
36
+ return paths;
37
+ }
38
+ /** Only validated immutable sound packages join source inputs; unrelated dist outputs stay excluded. */
39
+ export async function readAudioBuildInputs(root, destination) {
40
+ const packages = [];
41
+ let totalBytes = 0;
42
+ for (const primaryPath of await packagePaths(root)) {
43
+ let bundle;
44
+ try {
45
+ bundle = await readAudioArtifactPackage(root, primaryPath);
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ const sourceText = await readAudioSource(root, bundle.sourcePath);
51
+ const files = [...bundle.files].map(([path, bytes]) => ({ path, contentHash: digest(bytes) })).sort((a, b) => a.path.localeCompare(b.path));
52
+ totalBytes += [...bundle.files.values()].reduce((total, bytes) => total + bytes.length, 0) + (sourceText === undefined ? 0 : Buffer.byteLength(sourceText));
53
+ if (totalBytes > MAX_PACKAGE_BYTES)
54
+ throw audioError('audio_build_budget_exceeded', 422);
55
+ if (destination) {
56
+ for (const [path, bytes] of bundle.files) {
57
+ const target = await audioProjectPath(destination, path, true);
58
+ await ensureSharedDirectory(destination, dirname(target));
59
+ await writeSharedFile(destination, target, bytes);
60
+ }
61
+ if (sourceText !== undefined) {
62
+ const target = await audioProjectPath(destination, bundle.sourcePath, true);
63
+ await ensureSharedDirectory(destination, dirname(target));
64
+ await writeSharedFile(destination, target, sourceText);
65
+ }
66
+ }
67
+ packages.push({ primaryPath, sourcePath: bundle.sourcePath, sourceContentHash: sourceText === undefined ? null : digest(sourceText), files });
68
+ }
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 } : {}) };
73
+ }
74
+ export async function verifyAudioBuildInputs(root, snapshot) {
75
+ if ((await readAudioBuildInputs(root)).inputHash !== snapshot.inputHash)
76
+ throw audioError('audio_build_input_changed');
77
+ }
78
+ export function markAudioBuildInputs(bytes, snapshot) {
79
+ return snapshot.packages.length || snapshot.programs?.packages.length ? Buffer.concat([Buffer.from(`<!--playcraft-audio-inputs:v1:${snapshot.inputHash}-->\n`), bytes]) : bytes;
80
+ }