@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,217 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, opendir, realpath } from 'node:fs/promises';
3
+ import { posix, relative, resolve, sep } from 'node:path';
4
+ import { init, parse } from 'es-module-lexer';
5
+ import { audioError, audioProjectPath, digest } from './storage.js';
6
+ import { writeSharedFile } from '../shared-write.js';
7
+ import { auditAudioProgramArtifact } from './program-artifact-audit.js';
8
+ import { assertAudioProgramBuildClone, readAudioProgramBuildInputs, verifyAudioProgramBuildInputs } from './program-build-inputs.js';
9
+ const SKIP = new Set(['dist', 'node_modules', '.git', '.playcraft', '.playcraft-delivery', '.turbo', 'coverage']);
10
+ const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/i;
11
+ const MAX_SOURCE_BYTES = 16 * 1024 * 1024, MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024, MAX_FILES = 2048, MAX_ENTRIES = 8192;
12
+ async function sourceFiles(root) {
13
+ const files = [], unresolved = [];
14
+ let count = 0;
15
+ const visit = async (directory, depth) => {
16
+ if (depth > 32)
17
+ throw audioError('audio_build_budget_exceeded', 422);
18
+ const handle = await opendir(directory ? await audioProjectPath(root, directory) : await realpath(root), { bufferSize: 32 });
19
+ for await (const entry of handle) {
20
+ if (++count > MAX_ENTRIES)
21
+ throw audioError('audio_build_budget_exceeded', 422);
22
+ if (SKIP.has(entry.name))
23
+ continue;
24
+ const path = directory ? `${directory}/${entry.name}` : entry.name;
25
+ if (entry.isSymbolicLink()) {
26
+ unresolved.push({ file: path, offset: 0, reason: 'symlink_source_skipped' });
27
+ continue;
28
+ }
29
+ if (entry.isDirectory())
30
+ await visit(path, depth + 1);
31
+ else if (entry.isFile() && SOURCE.test(entry.name)) {
32
+ if (files.length >= MAX_FILES)
33
+ throw audioError('audio_build_budget_exceeded', 422);
34
+ files.push(path);
35
+ }
36
+ }
37
+ };
38
+ await visit('', 0);
39
+ return { files: files.sort(), unresolved };
40
+ }
41
+ async function stableBytes(root, path, maximum) {
42
+ const handle = await open(await audioProjectPath(root, path), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
43
+ try {
44
+ const before = await handle.stat();
45
+ if (!before.isFile() || before.nlink !== 1 || before.size > maximum)
46
+ throw audioError('audio_build_budget_exceeded', 422);
47
+ const bytes = await handle.readFile(), after = await handle.stat();
48
+ if (bytes.length !== before.size || before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
49
+ throw audioError('audio_build_input_changed');
50
+ return bytes;
51
+ }
52
+ finally {
53
+ await handle.close();
54
+ }
55
+ }
56
+ function importedPath(file, specifier) {
57
+ if (specifier.includes('\\') || specifier.includes('\0'))
58
+ return undefined;
59
+ if (specifier.startsWith('/'))
60
+ return posix.normalize(specifier.slice(1));
61
+ if (!specifier.startsWith('.'))
62
+ return undefined;
63
+ const path = posix.normalize(posix.join(posix.dirname(file), specifier));
64
+ return path === '..' || path.startsWith('../') ? undefined : path;
65
+ }
66
+ /** Only lexer-confirmed literals in ordinary clone source are changed; immutable packages are never rewritten. */
67
+ export async function rewriteAudioProgramVariantImports(input) {
68
+ if (!['runtime', 'prerender'].includes(input.variant))
69
+ throw audioError('invalid_audio_variant', 400);
70
+ await assertAudioProgramBuildClone(input.sourceRoot, input.cloneRoot);
71
+ await verifyAudioProgramBuildInputs(input.sourceRoot, input.snapshot);
72
+ await verifyAudioProgramBuildInputs(input.cloneRoot, input.snapshot);
73
+ const routes = new Map();
74
+ for (const bundle of input.snapshot.packages)
75
+ for (const path of [bundle.runtimeModulePath, bundle.prerenderModulePath])
76
+ routes.set(path, input.variant === 'runtime' ? bundle.runtimeModulePath : bundle.prerenderModulePath);
77
+ const sources = await sourceFiles(input.cloneRoot), result = {
78
+ variant: input.variant, rewrites: [], unresolved: sources.unresolved, coverage: 'literal-project-relative-imports',
79
+ };
80
+ const changes = [];
81
+ let total = 0;
82
+ await init;
83
+ for (const file of sources.files) {
84
+ const bytes = await stableBytes(input.cloneRoot, file, MAX_SOURCE_FILE_BYTES);
85
+ if ((total += bytes.length) > MAX_SOURCE_BYTES)
86
+ throw audioError('audio_build_budget_exceeded', 422);
87
+ let text;
88
+ try {
89
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
90
+ }
91
+ catch {
92
+ throw audioError('audio_program_source_invalid', 400);
93
+ }
94
+ let imports;
95
+ try {
96
+ [imports] = parse(text, file);
97
+ }
98
+ catch {
99
+ result.unresolved.push({ file, offset: 0, reason: 'module_lexer_parse_failed' });
100
+ continue;
101
+ }
102
+ const replacements = [];
103
+ for (const item of imports) {
104
+ if (item.d === -2)
105
+ continue;
106
+ if (item.n === undefined) {
107
+ if (item.d >= 0)
108
+ result.unresolved.push({ file, offset: item.s, reason: text.slice(item.s, item.e).includes('audio/programs/') ? 'computed_program_import' : 'computed_import_unverified' });
109
+ continue;
110
+ }
111
+ const boundary = item.n.search(/[?#]/), plain = boundary < 0 ? item.n : item.n.slice(0, boundary);
112
+ const resolved = importedPath(file, plain), selected = resolved && routes.get(resolved);
113
+ if (!selected) {
114
+ if (plain.includes('audio/programs/'))
115
+ result.unresolved.push({ file, offset: item.s, reason: 'program_import_path_unresolved' });
116
+ continue;
117
+ }
118
+ if (boundary >= 0) {
119
+ result.unresolved.push({ file, offset: item.s, reason: 'program_import_query_unsupported' });
120
+ continue;
121
+ }
122
+ let replacement = item.n.startsWith('/') ? `/${selected}` : posix.relative(posix.dirname(file), selected);
123
+ if (!item.n.startsWith('/') && !replacement.startsWith('.'))
124
+ replacement = './' + replacement;
125
+ if (selected === resolved && item.n === replacement)
126
+ continue;
127
+ const start = item.d === -1 ? item.s - 1 : item.s, end = item.d === -1 ? item.e + 1 : item.e;
128
+ if (!['"', "'"].includes(text[start]) || text[end - 1] !== text[start]) {
129
+ result.unresolved.push({ file, offset: item.s, reason: 'non_string_program_import' });
130
+ continue;
131
+ }
132
+ replacements.push({ start, end, value: JSON.stringify(replacement), from: item.n, to: replacement });
133
+ }
134
+ if (!replacements.length)
135
+ continue;
136
+ let rewritten = text;
137
+ for (const item of [...replacements].sort((a, b) => b.start - a.start))
138
+ rewritten = rewritten.slice(0, item.start) + item.value + rewritten.slice(item.end);
139
+ changes.push({ file, bytes: Buffer.from(rewritten), beforeHash: digest(bytes), rows: replacements.map(({ from, to }) => ({ from, to })) });
140
+ }
141
+ // Validate the full plan before touching any clone source, then compare each inode's actual bytes.
142
+ for (const change of changes)
143
+ if (digest(await stableBytes(input.cloneRoot, change.file, MAX_SOURCE_FILE_BYTES)) !== change.beforeHash)
144
+ throw audioError('audio_build_input_changed');
145
+ for (const change of changes) {
146
+ await writeSharedFile(input.cloneRoot, await audioProjectPath(input.cloneRoot, change.file), change.bytes);
147
+ for (const row of change.rows)
148
+ result.rewrites.push({ file: change.file, ...row, beforeHash: change.beforeHash, afterHash: digest(change.bytes) });
149
+ }
150
+ await verifyAudioProgramBuildInputs(input.sourceRoot, input.snapshot);
151
+ await verifyAudioProgramBuildInputs(input.cloneRoot, input.snapshot);
152
+ return result;
153
+ }
154
+ /** The callback must return the actual final channel artifact, including its real container format. */
155
+ export async function measureAudioProgramVariants(input) {
156
+ const { capabilities } = input;
157
+ if (typeof capabilities.webAudio !== 'boolean' || typeof capabilities.runtimeSynthesis !== 'boolean'
158
+ || !Number.isSafeInteger(capabilities.maxBytes) || capabilities.maxBytes < 1 || capabilities.maxBytes > 64 * 1024 * 1024)
159
+ throw audioError('invalid_audio_channel_capability', 400);
160
+ if (!capabilities.webAudio)
161
+ throw audioError('audio_delivery_unavailable', 422);
162
+ const checkAbort = () => { if (input.signal?.aborted)
163
+ throw audioError('audio_build_cancelled', 409); };
164
+ checkAbort();
165
+ const snapshot = input.snapshot ?? await readAudioProgramBuildInputs(input.root);
166
+ await verifyAudioProgramBuildInputs(input.root, snapshot);
167
+ const modes = capabilities.runtimeSynthesis ? ['runtime', 'prerender'] : ['prerender'];
168
+ const candidates = [], candidateRoots = new Set();
169
+ for (const variant of modes) {
170
+ checkAbort();
171
+ await verifyAudioProgramBuildInputs(input.root, snapshot);
172
+ try {
173
+ const prepared = await input.prepare(variant);
174
+ await assertAudioProgramBuildClone(input.root, prepared.projectRoot);
175
+ await assertAudioProgramBuildClone(input.root, prepared.artifactRoot);
176
+ const roots = await Promise.all([realpath(prepared.projectRoot), realpath(prepared.artifactRoot)]);
177
+ if ([...candidateRoots].some(old => roots.some(root => root === old || root.startsWith(old + sep) || old.startsWith(root + sep))))
178
+ throw audioError('invalid_audio_build_clone', 400);
179
+ for (const root of roots)
180
+ candidateRoots.add(root);
181
+ const copied = await readAudioProgramBuildInputs(input.root, prepared.projectRoot);
182
+ if (copied.inputHash !== snapshot.inputHash)
183
+ throw audioError('audio_build_input_changed');
184
+ const rewrite = await rewriteAudioProgramVariantImports({ sourceRoot: input.root, cloneRoot: prepared.projectRoot, snapshot, variant });
185
+ if (variant === 'prerender' && rewrite.unresolved.length > 0)
186
+ throw Object.assign(audioError('audio_program_import_unverified', 422), { details: rewrite.unresolved });
187
+ checkAbort();
188
+ const outputPath = await input.build({ ...prepared, variant, rewrite, signal: input.signal });
189
+ checkAbort();
190
+ await verifyAudioProgramBuildInputs(prepared.projectRoot, snapshot);
191
+ const artifactRoot = await realpath(prepared.artifactRoot), absolute = await realpath(outputPath), rel = relative(artifactRoot, absolute);
192
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || resolve(artifactRoot, rel) !== absolute)
193
+ throw audioError('invalid_audio_build_artifact', 400);
194
+ const stat = await lstat(outputPath);
195
+ if (!stat.isFile() || stat.isSymbolicLink())
196
+ throw audioError('invalid_audio_build_artifact', 400);
197
+ const bytes = await stableBytes(artifactRoot, rel.split(sep).join('/'), 64 * 1024 * 1024);
198
+ if (!bytes.length)
199
+ throw audioError('invalid_audio_build_artifact', 400);
200
+ const artifactAudit = auditAudioProgramArtifact(bytes, outputPath, variant);
201
+ candidates.push({ variant, status: 'completed', ...prepared, outputPath, outputSize: bytes.length, outputHash: digest(bytes), rewrite, artifactAudit });
202
+ }
203
+ catch (error) {
204
+ if (input.signal?.aborted)
205
+ throw audioError('audio_build_cancelled', 409);
206
+ candidates.push({ variant, status: 'failed', error: error.code ?? error.message,
207
+ ...(error.details === undefined ? {} : { details: error.details }) });
208
+ }
209
+ await verifyAudioProgramBuildInputs(input.root, snapshot);
210
+ }
211
+ const valid = candidates.filter((candidate) => candidate.status === 'completed' && candidate.outputSize <= capabilities.maxBytes);
212
+ valid.sort((a, b) => a.outputSize - b.outputSize || Number(a.variant === 'runtime') - Number(b.variant === 'runtime'));
213
+ if (!valid.length)
214
+ throw Object.assign(audioError('audio_delivery_unavailable', 422), { details: { channel: capabilities.channel, candidates } });
215
+ return { version: 1, inputHash: snapshot.inputHash, channel: capabilities.channel, capabilities,
216
+ candidates, selected: valid[0], measuredFinalArtifacts: true };
217
+ }
@@ -0,0 +1,107 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, opendir } from 'node:fs/promises';
3
+ import { workspaceAudioBindingSchema } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { audioProjectPath, audioError, digest } from './storage.js';
5
+ import { readAudioArtifactPackage } from './artifact-package.js';
6
+ export async function readAudioReferenceFile(root, path) {
7
+ const target = await audioProjectPath(root, path), file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
8
+ .catch(error => { if (error.code === 'ENOENT')
9
+ return undefined; throw error; });
10
+ if (!file)
11
+ return undefined;
12
+ try {
13
+ const before = await file.stat();
14
+ if (!before.isFile() || before.nlink !== 1 || before.size < 1 || before.size > 16_384)
15
+ throw audioError('invalid_audio_binding', 400);
16
+ const bytes = await file.readFile(), after = await file.stat(), named = await lstat(await audioProjectPath(root, path));
17
+ if (bytes.length !== before.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || named.ino !== before.ino || named.dev !== before.dev)
18
+ throw audioError('audio_binding_conflict');
19
+ return bytes;
20
+ }
21
+ finally {
22
+ await file.close();
23
+ }
24
+ }
25
+ export async function verifyAudioReference(root, sourcePath, history, bytes) {
26
+ const binding = workspaceAudioBindingSchema.parse(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)));
27
+ if (binding.sourcePath !== sourcePath || !history.some(version => version.revision === binding.revision && version.contentHash === binding.sourceContentHash))
28
+ throw audioError('invalid_audio_binding', 400);
29
+ const directory = `dist/audio/${digest(sourcePath)}/${binding.sourceContentHash}/${binding.representationSetHash}`;
30
+ if (binding.modulePath !== `${directory}/sound.mjs`)
31
+ throw audioError('invalid_audio_binding', 400);
32
+ const bundle = await readAudioArtifactPackage(root, `${directory}/sound.${sourcePath.endsWith('.pcscore.json') ? 'pcscore' : 'pcaudio'}.json`);
33
+ if (bundle.sourcePath !== sourcePath || bundle.sourceContentHash !== binding.sourceContentHash
34
+ || digest(bundle.files.get(`${directory}/audition.wav`)) !== binding.auditionContentHash)
35
+ throw audioError('invalid_audio_binding', 400);
36
+ return binding;
37
+ }
38
+ export async function readAudioReferences(root, sourcePath, current, history, loadPrevious) {
39
+ const directory = await audioProjectPath(root, 'source/audio-bindings');
40
+ const handle = await opendir(directory).catch(error => { if (error.code === 'ENOENT')
41
+ return undefined; throw error; });
42
+ const names = [];
43
+ let entries = 0, truncated = false;
44
+ if (handle)
45
+ for await (const entry of handle) {
46
+ if (++entries > 1024) {
47
+ truncated = true;
48
+ break;
49
+ }
50
+ if (entry.isFile() && /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}\.json$/.test(entry.name))
51
+ names.push(entry.name);
52
+ if (names.length > 128) {
53
+ truncated = true;
54
+ break;
55
+ }
56
+ }
57
+ names.sort();
58
+ const deadline = Date.now() + 5_000;
59
+ const references = [];
60
+ const version = (binding) => ({ revision: binding.revision, sourceContentHash: binding.sourceContentHash,
61
+ modulePath: binding.modulePath, representationSetHash: binding.representationSetHash });
62
+ for (const name of names.slice(0, 128)) {
63
+ if (Date.now() > deadline) {
64
+ truncated = true;
65
+ break;
66
+ }
67
+ const bindingPath = `source/audio-bindings/${name}`, bytes = await readAudioReferenceFile(root, bindingPath);
68
+ if (!bytes)
69
+ continue;
70
+ let raw;
71
+ try {
72
+ raw = JSON.parse(bytes.toString('utf8'));
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ if (raw?.sourcePath !== sourcePath || raw?.eventName !== name.slice(0, -5))
78
+ continue;
79
+ const entry = { eventName: raw.eventName, bindingPath, bindingContentHash: digest(bytes), state: 'invalid', history: [] };
80
+ try {
81
+ const binding = await verifyAudioReference(root, sourcePath, history, bytes);
82
+ entry.version = version(binding);
83
+ entry.state = binding.sourceContentHash === current.contentHash ? 'current' : 'different_version';
84
+ let previous = binding.previousBinding;
85
+ const seen = new Set([entry.bindingContentHash]);
86
+ while (previous && entry.history.length < 20 && !seen.has(previous)) {
87
+ if (Date.now() > deadline) {
88
+ truncated = true;
89
+ break;
90
+ }
91
+ seen.add(previous);
92
+ const old = await loadPrevious(previous);
93
+ if (!old || digest(old) !== previous)
94
+ break;
95
+ const checked = await verifyAudioReference(root, sourcePath, history, old);
96
+ if (checked.eventName !== binding.eventName)
97
+ break;
98
+ entry.history.push({ ...version(checked), bindingContentHash: previous });
99
+ previous = checked.previousBinding;
100
+ }
101
+ }
102
+ catch { /* An invalid record remains visible, without an unverified package or restore target. */ }
103
+ references.push(entry);
104
+ }
105
+ return { version: 1, target: { kind: 'workspace-file', path: sourcePath }, sourceRevision: current.revision, sourceContentHash: current.contentHash,
106
+ references, scanLimit: 128, truncated, dynamicReferences: 'unverified' };
107
+ }
@@ -1,9 +1,14 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { parseSoundDocument, serializeSoundDocument, soundSourceRenderHash, soundDurationSeconds, isSoundDocumentPath, audioRenderKey, canonicalAudioJson, assertAudioProtection, assertScoreProtection, } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { parseSoundDocument, serializeSoundDocument, soundSourceRenderHash, soundDurationSeconds, isSoundDocumentPath, audioRenderKey, canonicalAudioJson, assertAudioProtection, assertScoreProtection, audioReferenceActionSchema, audioReferenceActionReceiptSchema, workspaceAudioBindingSchema, } from '@playcraft/common/cloud-workspace/runtime';
3
3
  import { writeSharedFile } from '../shared-write.js';
4
4
  import { AudioStore, audioError, audioProjectPath, readAudioSource, digest } from './storage.js';
5
5
  import { publishAudioRepresentations } from './representations.js';
6
6
  import { audioArtifactResources } from './artifact-package.js';
7
+ import { readAudioArtifactPackage } from './artifact-package.js';
8
+ import { createAudioLibrarySnapshot } from './library-package.js';
9
+ import { readImportedAudioLibrarySource } from './library-import.js';
10
+ import { readAudioReferences, readAudioReferenceFile, verifyAudioReference } from './references.js';
11
+ import { writeWorkspaceBinaryFile } from '../binary-write.js';
7
12
  import { readAudioBindingUsage } from './binding-usage.js';
8
13
  const safeId = (id) => typeof id === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(id);
9
14
  const targetId = (target) => target.kind === 'workspace-file' ? `file:${target.path}` : `candidate:${target.taskId}:${target.candidateId}`;
@@ -13,6 +18,7 @@ const publicState = async (record, projectRoot) => {
13
18
  const usage = record.target.kind === 'workspace-file' && record.usage
14
19
  ? await readAudioBindingUsage(projectRoot, record.target.path, current.contentHash, record.history, record.usage) : undefined;
15
20
  return { version: 1, target: record.target, ...current, history: record.history, ...(usage ? { usage } : {}),
21
+ ...(record.libraryOrigin ? { libraryOrigin: record.libraryOrigin } : {}),
16
22
  ...(record.representation ? { representation: record.representation } : {}),
17
23
  ...(record.adoptionReceipt ? { adoptionReceipt: record.adoptionReceipt } : {}) };
18
24
  };
@@ -68,6 +74,20 @@ export class WorkspaceAudioAssets {
68
74
  this.checkFormatPath(target.path, version.document);
69
75
  record = { ...record, target, current: version, representation: undefined, adoptionReceipt: undefined,
70
76
  history: [...(record?.history ?? []), summary(version)] };
77
+ try {
78
+ const imported = await readImportedAudioLibrarySource(this.projectRoot, target.path, version.contentHash);
79
+ if (imported) {
80
+ record.libraryOrigin = imported.origin;
81
+ if (imported.current) {
82
+ await this.store.bytes('auditions', imported.current.audition.contentHash, imported.current.wav);
83
+ record.current = { ...version, renderState: 'ready', audition: imported.current.audition };
84
+ record.representation = imported.current.representation;
85
+ }
86
+ }
87
+ }
88
+ catch (error) {
89
+ record.current = { ...version, renderState: 'failed', renderError: error instanceof Error && /^audio_library_/.test(error.message) ? error.message : 'audio_library_dependency_invalid' };
90
+ }
71
91
  await this.saveRecord(record);
72
92
  }
73
93
  }
@@ -78,6 +98,118 @@ export class WorkspaceAudioAssets {
78
98
  return record;
79
99
  }
80
100
  read(target) { return this.exclusive(async () => publicState(await this.observe(target), this.projectRoot)); }
101
+ references(target) {
102
+ return this.exclusive(async () => {
103
+ if (target.kind !== 'workspace-file')
104
+ throw audioError('invalid_audio_target', 400);
105
+ const record = await this.observe(target);
106
+ return readAudioReferences(this.projectRoot, target.path, record.current, record.history, hash => this.previousBinding(hash));
107
+ });
108
+ }
109
+ async previousBinding(hash) {
110
+ try {
111
+ return await readFile(this.store.blobPath('bindings', hash));
112
+ }
113
+ catch (error) {
114
+ if (error.code === 'ENOENT')
115
+ return undefined;
116
+ throw error;
117
+ }
118
+ }
119
+ referenceAction(raw) {
120
+ return this.exclusive(async () => {
121
+ const input = audioReferenceActionSchema.parse(raw), payloadHash = digest(canonicalAudioJson(input));
122
+ let intent = await this.store.read('reference-requests', input.requestId);
123
+ if (intent && intent.payloadHash !== payloadHash)
124
+ throw audioError('audio_request_conflict');
125
+ if (intent?.status === 'complete')
126
+ return audioReferenceActionReceiptSchema.parse(intent.result);
127
+ const record = await this.observe(input.target), bindingPath = `source/audio-bindings/${input.eventName}.json`;
128
+ const current = await readAudioReferenceFile(this.projectRoot, bindingPath), currentHash = current ? digest(current) : null;
129
+ const alreadyWritten = intent?.status === 'pending' && currentHash === digest(Buffer.from(intent.entry, 'base64'));
130
+ if (!alreadyWritten) {
131
+ this.expected(record, { action: 'bind', requestId: input.requestId, target: input.target,
132
+ expectedRevision: input.expectedRevision, expectedContentHash: input.expectedContentHash });
133
+ if (currentHash !== input.expectedBindingContentHash)
134
+ throw audioError('audio_binding_conflict');
135
+ }
136
+ if (!intent) {
137
+ let bytes;
138
+ if (input.action === 'replace') {
139
+ if (record.current.renderState !== 'ready' || !record.current.audition || !record.representation
140
+ || record.representation.sourceContentHash !== record.current.contentHash)
141
+ throw audioError('audio_audition_unavailable', 409);
142
+ const entry = workspaceAudioBindingSchema.parse({ version: 1, operationId: input.requestId, eventName: input.eventName,
143
+ sourcePath: input.target.path, sourceContentHash: record.current.contentHash, revision: record.current.revision,
144
+ auditionContentHash: record.current.audition.contentHash, modulePath: record.representation.modulePath,
145
+ representationSetHash: record.representation.representationSetHash, integrationStatus: 'integration_required', previousBinding: currentHash });
146
+ bytes = Buffer.from(`${canonicalAudioJson(entry)}\n`);
147
+ await verifyAudioReference(this.projectRoot, input.target.path, record.history, bytes);
148
+ }
149
+ else {
150
+ const snapshot = await readAudioReferences(this.projectRoot, input.target.path, record.current, record.history, hash => this.previousBinding(hash));
151
+ const tracked = snapshot.references.find(reference => reference.eventName === input.eventName);
152
+ if (!tracked?.history.some(version => version.bindingContentHash === input.previousBindingHash))
153
+ throw audioError('invalid_audio_binding', 400);
154
+ const previous = await this.previousBinding(input.previousBindingHash);
155
+ if (!previous || digest(previous) !== input.previousBindingHash)
156
+ throw audioError('invalid_audio_binding', 400);
157
+ await verifyAudioReference(this.projectRoot, input.target.path, record.history, previous);
158
+ bytes = Buffer.from(previous);
159
+ }
160
+ if (current)
161
+ await this.store.bytes('bindings', currentHash, current);
162
+ intent = { payloadHash, status: 'pending', beforeHash: currentHash, entry: bytes.toString('base64') };
163
+ await this.store.write('reference-requests', input.requestId, intent);
164
+ }
165
+ const bytes = Buffer.from(intent.entry, 'base64'), binding = await verifyAudioReference(this.projectRoot, input.target.path, record.history, bytes);
166
+ if (binding.eventName !== input.eventName)
167
+ throw audioError('invalid_audio_binding', 400);
168
+ if (!alreadyWritten) {
169
+ try {
170
+ await writeWorkspaceBinaryFile(this.projectRoot, { path: bindingPath, contentBase64: intent.entry, expectedContentHash: intent.beforeHash });
171
+ }
172
+ catch (error) {
173
+ if (error.code === 'content_conflict')
174
+ throw audioError('audio_binding_conflict');
175
+ throw error;
176
+ }
177
+ }
178
+ record.usage = { status: 'integration_required', bindingPath, eventName: input.eventName, sourceContentHash: binding.sourceContentHash,
179
+ ...(binding.previousBinding ? { previousContentHash: binding.previousBinding } : {}) };
180
+ await this.saveRecord(record);
181
+ await this.options.onWrite?.(input.target.path, record.current, [bindingPath], input.requestId);
182
+ const result = audioReferenceActionReceiptSchema.parse({ version: 1, requestId: input.requestId, action: input.action, target: input.target,
183
+ eventName: input.eventName, bindingPath, bindingContentHash: digest(bytes), sourceContentHash: binding.sourceContentHash,
184
+ revision: binding.revision, modulePath: binding.modulePath, representationSetHash: binding.representationSetHash, integrationStatus: 'integration_required' });
185
+ await this.store.write('reference-requests', input.requestId, { ...intent, status: 'complete', result });
186
+ return result;
187
+ });
188
+ }
189
+ librarySnapshot(target, revision, contentHash) {
190
+ return this.exclusive(async () => {
191
+ const record = await this.observe(target), version = record.current;
192
+ if (version.revision !== revision || version.contentHash !== contentHash)
193
+ throw audioError('audio_revision_conflict');
194
+ if (version.renderState !== 'ready' || !version.audition)
195
+ throw audioError('audio_audition_unavailable', 404);
196
+ const sourcePath = target.kind === 'workspace-file' ? target.path
197
+ : `source/audio/${target.candidateId}.${version.document.format === 'playcraft.score' ? 'pcscore' : 'pcaudio'}.json`;
198
+ if (record.representation?.sourceContentHash === contentHash) {
199
+ const entry = `${record.representation.modulePath.slice(0, -'sound.mjs'.length)}sound.${version.document.format === 'playcraft.score' ? 'pcscore' : 'pcaudio'}.json`;
200
+ const packaged = await readAudioArtifactPackage(this.projectRoot, entry);
201
+ const files = [...packaged.files].map(([path, bytes]) => ({ path: path.slice(path.lastIndexOf('/') + 1),
202
+ mimeType: path.endsWith('.wav') ? 'audio/wav' : path.endsWith('.mjs') ? 'text/javascript' : 'application/json',
203
+ size: bytes.length, contentHash: digest(bytes), content: bytes.toString('base64') }));
204
+ return { version: 1, target, revision, sourcePath, files };
205
+ }
206
+ const wav = await readFile(this.store.blobPath('auditions', version.audition.contentHash));
207
+ if (digest(wav) !== version.audition.contentHash || wav.length !== version.audition.size)
208
+ throw audioError('audio_audition_unavailable', 404);
209
+ return createAudioLibrarySnapshot({ target, revision, sourcePath, document: version.document, sourceText: version.sourceText,
210
+ contentHash, sourceRenderHash: version.sourceRenderHash, audition: version.audition, wav });
211
+ });
212
+ }
81
213
  async content(target, revision, representation = 'audition') {
82
214
  const record = await this.observe(target), version = revision === undefined || revision === record.current.revision ? record.current :
83
215
  await this.store.read('versions', `${targetId(target)}:${revision}`);
@@ -357,7 +357,14 @@ export async function saveWorkspaceGit(root, input) {
357
357
  // user (or an agent) finishes the merge with an explicit commit; saves resume after it.
358
358
  if ((await mergeState(rootReal)).merging)
359
359
  return { pushed: false, withheld: [] };
360
+ const { readAudioBuildInputs, verifyAudioBuildInputs } = await import('./audio-assets/build-inputs.js');
361
+ const audio = await readAudioBuildInputs(rootReal);
360
362
  await git(rootReal, ['add', '-A'], 60000);
363
+ // dist is normally ignored. Save only verified immutable audio closures, never unrelated outputs.
364
+ const audioPaths = [...new Set([...audio.packages, ...(audio.programs?.packages ?? [])].flatMap(bundle => bundle.files.map(file => file.path)))];
365
+ for (let index = 0; index < audioPaths.length; index += 64)
366
+ await git(rootReal, ['--literal-pathspecs', 'add', '-f', '--', ...audioPaths.slice(index, index + 64)], 60000);
367
+ await verifyAudioBuildInputs(rootReal, audio);
361
368
  const staged = (await git(rootReal, ['diff', '--cached', '--name-only', '-z'], 60000)).split('\0').filter(Boolean);
362
369
  const withheld = staged.filter(isCredentialFile);
363
370
  await unstage(rootReal, withheld);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -44,6 +44,8 @@
44
44
  "verify:parameter-skill": "node scripts/generate-parameter-skill.mjs --check && node --test scripts/generate-parameter-skill.test.mjs",
45
45
  "verify:level-editor-skill": "node scripts/generate-level-editor-skill.mjs --check",
46
46
  "verify:asset-skills": "node scripts/generate-asset-skills.mjs --check && node --test scripts/generate-asset-skills.test.mjs",
47
+ "generate:audio-program-companion": "node scripts/generate-audio-program-companion.mjs",
48
+ "verify:audio-program-companion": "node scripts/generate-audio-program-companion.mjs --check",
47
49
  "verify:agent-stop": "node scripts/verify-agent-stop.mjs",
48
50
  "verify-sprite-overlay": "node scripts/verify-sprite-overlay.mjs",
49
51
  "link": "node scripts/bump-local.js && pnpm build && npm link",
@@ -54,8 +56,8 @@
54
56
  "@gltf-transform/core": "^4.3.0",
55
57
  "@gltf-transform/extensions": "^4.3.0",
56
58
  "@gltf-transform/functions": "^4.3.0",
57
- "@playcraft/build": "0.1.67",
58
- "@playcraft/common": "0.1.67",
59
+ "@playcraft/build": "0.1.68",
60
+ "@playcraft/common": "0.1.68",
59
61
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
60
62
  "chokidar": "^4.0.3",
61
63
  "commander": "^13.1.0",
@@ -64,6 +66,7 @@
64
66
  "dotenv": "^17.2.3",
65
67
  "drizzle-orm": "^0.45.1",
66
68
  "earcut": "3.2.4",
69
+ "es-module-lexer": "1.7.0",
67
70
  "execa": "^9.6.1",
68
71
  "express": "^5.2.1",
69
72
  "fluent-ffmpeg": "^2.1.3",
@@ -3,7 +3,7 @@ name: playcraft-audio-generation
3
3
  description: Use when generating or replacing playable audio, reusing library audio, managing durable audio tasks, or locally compressing, trimming, looping, normalizing, and mixing sound.
4
4
  triggers: 生成音效,生成bgm,生成背景音乐,generate sfx,generate bgm,generate audio,音频,音效,背景音乐,sfx,bgm,loop音频,音频压缩,音频处理,audio,compress audio,fade,normalize,音频后处理,音频管线
5
5
  compatibility: agent,opencode
6
- version: 1.3.0
6
+ version: 1.4.0
7
7
  surfaces:
8
8
  - sandbox-agent
9
9
  ---
@@ -16,7 +16,7 @@ AI (Artificial Intelligence, 人工智能) 音频包含 SFX (Sound Effects, 音
16
16
 
17
17
  ### 云工作区的可编辑短音效
18
18
 
19
- 仅在当前锁定技能版本与工作区能力支持时使用本段流程。先运行 `playcraft audio source capabilities`,检查 `enabled`、`writable`、对应 `operations`;生成与渲染还须 `renderer.ready`。能力缺失或关闭时保留源及已有试听,报告不可用,不替换工程技能锁,不移除声音上下文后继续执行。`synth-sfx` 保持短音效流程;仅在 `audioScoreVersion:1` 与 `audioScoreContextVersion:1` 均声明时使用下述乐谱/多轨流程。外部采样和动态混合仍不支持。
19
+ 仅在当前锁定技能版本与工作区能力支持时使用本段流程。先运行 `playcraft audio source capabilities`,检查 `enabled`、`writable`、对应 `operations`;生成与渲染还须 `renderer.ready`。能力缺失或关闭时保留源及已有试听,报告不可用,不替换工程技能锁,不移除声音上下文后继续执行。`synth-sfx` 保持短音效流程;仅在 `audioScoreVersion:1` 与 `audioScoreContextVersion:1` 均声明时使用下述乐谱/多轨流程。动态混合使用独立高级程序入口,不写入既有音效或乐谱源。
20
20
 
21
21
  默认从描述、试听、采用开始,只展示适用的时长、音量、音高、明亮度简单控件;用户要求细节后再展开精确参数。工程源在 `source/audio/` 等 `dist/` 外目录,最终表示包位于 `dist/audio/`。未采用候选保持私有,不因续改而强制写入工程。
22
22
 
@@ -84,6 +84,24 @@ playcraft audio source adopt --task <taskId> --candidate <candidateId> \
84
84
 
85
85
  音乐完整包成员为 `sound.pcscore.json / audition.wav / runtime.mjs / sound.mjs / manifest.json`,只使用回执中的固定模块。`createSound().prepare()` 不默认展开整段音乐采样;`play()` 按乐谱和循环段调度,返回的 `stop()` 或播放器 `stop()/dispose()` 会结束循环并释放资源。场景退出、页面隐藏或切换试听时应调用停止,禁止遗留循环。单次播放可传 `play({loop:false})`;指定段落或轨道试听通过受支持的 `selection` 参数。试听渲染文件本身是完整单次表示,循环起止需随乐谱一起使用。
86
86
 
87
+ ### 高级:游戏状态与短采样混合
88
+
89
+ 用户明确要求按游戏状态改变声音,或组合短采样与合成音时,先检查 `playcraft audio program --help` 是否存在。旧工作区不支持该命令时保留现有源和技能锁并报告。不要为普通生成和简单调整主动创建音频程序。
90
+
91
+ ```bash
92
+ # --asset 必须是采用回执中的完整不可变声音包成员;可选 --sample 是 dist/audio 中有摘要目录的短采样。
93
+ playcraft audio program init --asset <sound.pcscore.json或sound.pcaudio.json固定路径> --output source/audio/states.pcaudioprogram.json
94
+ playcraft audio program validate --path source/audio/states.pcaudioprogram.json
95
+ playcraft audio program compile --path source/audio/states.pcaudioprogram.json
96
+ playcraft audio program inspect --manifest <编译回执manifestPath>
97
+ ```
98
+
99
+ 程序是独立的 `playcraft.audio-program@1`,最多 8 项资产、16 个离散状态、每状态 4 层及 60 秒。状态明确指定增益、半音、低通与乐谱拍段;切换采用停止并重启语义,不承诺无缝交叉淡化。短采样只接受单声道 44100 Hz、PCM(Pulse Code Modulation,脉冲编码调制)16 位标准波形文件,每个最多 2 秒、最多 4 个;不接受外部地址或任意脚本。依赖必须带精确路径、内容摘要和大小。编译会校验完整声音包及平台引擎,渲染所有状态并拒绝超峰值;不静默截断或归一化。全部状态波形总量限 16 MiB,输入依赖限 2 MiB。
100
+
101
+ 从编译回执的 `runtimeModulePath` 导入 `createSound`,在明确交互中 `await player.play('idle')`;状态变化时 `await player.setState('action')`。切换场景或页面隐藏时停止,销毁时 `dispose()`。`prerenderModulePath` 使用同一接口,已把音高、滤波和混合烘焙到各状态波形,播放不重复处理;两种方案仍要求浏览器支持音频上下文。模块与共享引擎均不可变,修改程序后重新编译,保留旧模块用于回退。只修改 `dist/audio` 包内文件会触发校验失败。
102
+
103
+ 渠道选型必须按完整实际构建产物字节数比较,包括运行时、嵌入依赖和波形;源 JSON(JavaScript Object Notation,JavaScript 对象表示法)大小不能代表最终压缩收益。未知渠道能力采用保守回退,渠道不支持音频上下文时不能宣称预渲染模块可用。完整渠道包通过平台交付页生成与下载,不保存到工作区 `dist/`。
104
+
87
105
  ### 复用素材与持久化音频任务
88
106
 
89
107
  ```bash
@@ -3,7 +3,7 @@ name: playcraft-audio-generation
3
3
  description: Use when generating or replacing playable audio, reusing library audio, managing durable audio tasks, or locally compressing, trimming, looping, normalizing, and mixing sound.
4
4
  triggers: 生成音效,生成bgm,生成背景音乐,generate sfx,generate bgm,generate audio,音频,音效,背景音乐,sfx,bgm,loop音频,音频压缩,音频处理,audio,compress audio,fade,normalize,音频后处理,音频管线
5
5
  compatibility: agent,opencode
6
- version: 1.3.0
6
+ version: 1.4.0
7
7
  surfaces:
8
8
  - sandbox-agent
9
9
  ---
@@ -16,7 +16,7 @@ AI (Artificial Intelligence, 人工智能) 音频包含 SFX (Sound Effects, 音
16
16
 
17
17
  ### 云工作区的可编辑短音效
18
18
 
19
- 仅在当前锁定技能版本与工作区能力支持时使用本段流程。先运行 `playcraft audio source capabilities`,检查 `enabled`、`writable`、对应 `operations`;生成与渲染还须 `renderer.ready`。能力缺失或关闭时保留源及已有试听,报告不可用,不替换工程技能锁,不移除声音上下文后继续执行。`synth-sfx` 保持短音效流程;仅在 `audioScoreVersion:1` 与 `audioScoreContextVersion:1` 均声明时使用下述乐谱/多轨流程。外部采样和动态混合仍不支持。
19
+ 仅在当前锁定技能版本与工作区能力支持时使用本段流程。先运行 `playcraft audio source capabilities`,检查 `enabled`、`writable`、对应 `operations`;生成与渲染还须 `renderer.ready`。能力缺失或关闭时保留源及已有试听,报告不可用,不替换工程技能锁,不移除声音上下文后继续执行。`synth-sfx` 保持短音效流程;仅在 `audioScoreVersion:1` 与 `audioScoreContextVersion:1` 均声明时使用下述乐谱/多轨流程。动态混合使用独立高级程序入口,不写入既有音效或乐谱源。
20
20
 
21
21
  默认从描述、试听、采用开始,只展示适用的时长、音量、音高、明亮度简单控件;用户要求细节后再展开精确参数。工程源在 `source/audio/` 等 `dist/` 外目录,最终表示包位于 `dist/audio/`。未采用候选保持私有,不因续改而强制写入工程。
22
22
 
@@ -84,6 +84,24 @@ playcraft audio source adopt --task <taskId> --candidate <candidateId> \
84
84
 
85
85
  音乐完整包成员为 `sound.pcscore.json / audition.wav / runtime.mjs / sound.mjs / manifest.json`,只使用回执中的固定模块。`createSound().prepare()` 不默认展开整段音乐采样;`play()` 按乐谱和循环段调度,返回的 `stop()` 或播放器 `stop()/dispose()` 会结束循环并释放资源。场景退出、页面隐藏或切换试听时应调用停止,禁止遗留循环。单次播放可传 `play({loop:false})`;指定段落或轨道试听通过受支持的 `selection` 参数。试听渲染文件本身是完整单次表示,循环起止需随乐谱一起使用。
86
86
 
87
+ ### 高级:游戏状态与短采样混合
88
+
89
+ 用户明确要求按游戏状态改变声音,或组合短采样与合成音时,先检查 `playcraft audio program --help` 是否存在。旧工作区不支持该命令时保留现有源和技能锁并报告。不要为普通生成和简单调整主动创建音频程序。
90
+
91
+ ```bash
92
+ # --asset 必须是采用回执中的完整不可变声音包成员;可选 --sample 是 dist/audio 中有摘要目录的短采样。
93
+ playcraft audio program init --asset <sound.pcscore.json或sound.pcaudio.json固定路径> --output source/audio/states.pcaudioprogram.json
94
+ playcraft audio program validate --path source/audio/states.pcaudioprogram.json
95
+ playcraft audio program compile --path source/audio/states.pcaudioprogram.json
96
+ playcraft audio program inspect --manifest <编译回执manifestPath>
97
+ ```
98
+
99
+ 程序是独立的 `playcraft.audio-program@1`,最多 8 项资产、16 个离散状态、每状态 4 层及 60 秒。状态明确指定增益、半音、低通与乐谱拍段;切换采用停止并重启语义,不承诺无缝交叉淡化。短采样只接受单声道 44100 Hz、PCM(Pulse Code Modulation,脉冲编码调制)16 位标准波形文件,每个最多 2 秒、最多 4 个;不接受外部地址或任意脚本。依赖必须带精确路径、内容摘要和大小。编译会校验完整声音包及平台引擎,渲染所有状态并拒绝超峰值;不静默截断或归一化。全部状态波形总量限 16 MiB,输入依赖限 2 MiB。
100
+
101
+ 从编译回执的 `runtimeModulePath` 导入 `createSound`,在明确交互中 `await player.play('idle')`;状态变化时 `await player.setState('action')`。切换场景或页面隐藏时停止,销毁时 `dispose()`。`prerenderModulePath` 使用同一接口,已把音高、滤波和混合烘焙到各状态波形,播放不重复处理;两种方案仍要求浏览器支持音频上下文。模块与共享引擎均不可变,修改程序后重新编译,保留旧模块用于回退。只修改 `dist/audio` 包内文件会触发校验失败。
102
+
103
+ 渠道选型必须按完整实际构建产物字节数比较,包括运行时、嵌入依赖和波形;源 JSON(JavaScript Object Notation,JavaScript 对象表示法)大小不能代表最终压缩收益。未知渠道能力采用保守回退,渠道不支持音频上下文时不能宣称预渲染模块可用。完整渠道包通过平台交付页生成与下载,不保存到工作区 `dist/`。
104
+
87
105
  ### 复用素材与持久化音频任务
88
106
 
89
107
  ```bash