@playcraft/cli 0.1.66 → 0.1.67
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.
- package/dist/assets/client.js +4 -2
- package/dist/assets/messages.js +2 -0
- package/dist/audio-source/messages.js +35 -0
- package/dist/commands/assets.js +1 -1
- package/dist/commands/audio-source.js +109 -0
- package/dist/commands/audio.js +11 -4
- package/dist/commands/build.js +4 -2
- package/dist/commands/tools-context.js +46 -0
- package/dist/commands/tools.js +2 -0
- package/dist/project-skills/builtin-import.js +69 -22
- package/dist/project-skills/commands.js +11 -2
- package/dist/project-skills/lifecycle.js +1 -1
- package/dist/project-skills/local-store.js +90 -31
- package/dist/project-skills/messages.js +16 -0
- package/dist/project-skills/migrate-lock.js +166 -0
- package/dist/project-skills/remote-cache.js +36 -5
- package/dist/utils/agent-api-client.js +18 -1
- package/dist/utils/agent-generation-provenance.js +8 -2
- package/dist/utils/audio-export.js +249 -0
- package/dist/utils/audio-processor.js +22 -99
- package/dist/utils/execution-context-bridge.js +28 -0
- package/dist/workspace-runtime/batch/control.js +13 -2
- package/dist/workspace-runtime/batch/generate.js +43 -2
- package/dist/workspace-runtime/batch/prepare.js +4 -0
- package/dist/workspace-runtime/batch/skill-artifacts.js +103 -0
- package/dist/workspace-runtime/batch/source-capture.js +6 -8
- package/dist/workspace-runtime/codex/app-server.js +26 -2
- package/dist/workspace-runtime/codex/execution-context-broker.js +61 -0
- package/dist/workspace-runtime/codex/execution-context-tools.js +37 -0
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +9 -6
- package/dist/workspace-runtime/codex/level-editor-tools.js +18 -0
- package/dist/workspace-runtime/codex/model-broker.js +15 -2
- package/dist/workspace-runtime/codex/scheduled-task-tools.js +1 -1
- package/dist/workspace-runtime/previews/finalize.js +1 -1
- package/dist/workspace-runtime/previews/output.js +4 -1
- package/dist/workspace-runtime/previews/publish.js +5 -2
- package/dist/workspace-runtime/previews/runtime-preview.js +38 -3
- package/dist/workspace-runtime/serve.js +6 -0
- package/dist/workspace-runtime/server/accept-operation.js +85 -13
- package/dist/workspace-runtime/server/audio-assets.js +211 -0
- package/dist/workspace-runtime/server/audio-export.js +136 -0
- package/dist/workspace-runtime/server/directory-access.js +222 -67
- package/dist/workspace-runtime/server/dispatch.js +252 -29
- package/dist/workspace-runtime/server/http.js +97 -3
- package/dist/workspace-runtime/server/operation-record.js +1 -1
- package/dist/workspace-runtime/workspaces/artifact-files.js +10 -3
- package/dist/workspace-runtime/workspaces/artifacts.js +47 -6
- package/dist/workspace-runtime/workspaces/audio-assets/artifact-package.js +104 -0
- package/dist/workspace-runtime/workspaces/audio-assets/binding-usage.js +71 -0
- package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +76 -0
- package/dist/workspace-runtime/workspaces/audio-assets/render-worker.js +117 -0
- package/dist/workspace-runtime/workspaces/audio-assets/renderer.js +128 -0
- package/dist/workspace-runtime/workspaces/audio-assets/representations.js +73 -0
- package/dist/workspace-runtime/workspaces/audio-assets/service.js +327 -0
- package/dist/workspace-runtime/workspaces/audio-assets/storage.js +125 -0
- package/dist/workspace-runtime/workspaces/build-isolation.js +2 -0
- package/dist/workspace-runtime/workspaces/composer-context.js +40 -1
- package/dist/workspace-runtime/workspaces/empty-template.js +1 -1
- package/dist/workspace-runtime/workspaces/execution-context.js +24 -0
- package/dist/workspace-runtime/workspaces/files.js +3 -5
- package/dist/workspace-runtime/workspaces/level-editor-agent-store.js +143 -0
- package/dist/workspace-runtime/workspaces/level-editor-agent.js +410 -0
- package/dist/workspace-runtime/workspaces/level-editor-atomic.js +424 -0
- package/dist/workspace-runtime/workspaces/level-editor-extensions.js +140 -0
- package/dist/workspace-runtime/workspaces/level-editor-format.js +75 -0
- package/dist/workspace-runtime/workspaces/level-editor-history-store.js +250 -0
- package/dist/workspace-runtime/workspaces/level-editor-json5.js +132 -0
- package/dist/workspace-runtime/workspaces/level-editor-requests.js +724 -0
- package/dist/workspace-runtime/workspaces/level-editor.js +454 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +62 -22
- package/dist/workspace-runtime/workspaces/parameter-production.js +1 -1
- package/dist/workspace-runtime/workspaces/remix-reference-skill.js +4 -3
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +19 -5
- package/dist/workspace-runtime/workspaces/skills.js +48 -13
- package/package.json +6 -4
- package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-template/.claude/skills/playcraft-level-editor/SKILL.md +34 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/authoring-contract.json +3475 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/extensions.md +37 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/preview.md +15 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/protocol-v1.md +35 -0
- package/project-template/vite.config.ts +1 -1
- package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-template-v2/.playcraft/skills.lock.json +9 -2
- package/project-template-v2/vite.config.ts +1 -1
- package/project-templates/empty/AGENTS.md +2 -0
- package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-templates/phaser/vite.config.ts +1 -1
- package/project-templates/playable-template-phaser/vite.config.ts +1 -1
- package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-templates/threejs/vite.config.ts +1 -1
- package/templates/preview-runtime/v1/README.md +12 -0
- package/templates/preview-runtime/v1/build-preview.mjs +17 -8
- package/templates/preview-runtime/v1/manifest.json +9 -6
- package/templates/preview-runtime/v1/release-guard.mjs +1 -1
- package/templates/preview-runtime/v1/runtime.d.mts +1 -3
- package/templates/preview-runtime/v1/runtime.d.ts +1 -3
- package/templates/preview-runtime/v1/runtime.mjs +1142 -257
- package/templates/preview-runtime/v1/types/host.d.ts +29 -0
- package/templates/preview-runtime/v1/types/index.d.ts +6 -0
- 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) {
|
|
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) {
|
|
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
|
-
//
|
|
156
|
-
|
|
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 =
|
|
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 {
|
|
@@ -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,76 @@
|
|
|
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
|
+
const hashPart = /^[a-f0-9]{64}$/;
|
|
8
|
+
const MAX_PACKAGES = 256;
|
|
9
|
+
const MAX_PACKAGE_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
async function packagePaths(root) {
|
|
11
|
+
const paths = [];
|
|
12
|
+
const visit = async (path, depth) => {
|
|
13
|
+
const absolute = await audioProjectPath(root, path);
|
|
14
|
+
const stat = await lstat(absolute).catch(error => { if (error.code === 'ENOENT')
|
|
15
|
+
return undefined; throw error; });
|
|
16
|
+
if (!stat?.isDirectory() || stat.isSymbolicLink())
|
|
17
|
+
return;
|
|
18
|
+
if (depth === 3) {
|
|
19
|
+
if (paths.length >= MAX_PACKAGES)
|
|
20
|
+
throw audioError('audio_build_budget_exceeded', 422);
|
|
21
|
+
const names = await readdir(absolute);
|
|
22
|
+
for (const name of ['sound.pcaudio.json', 'sound.pcscore.json'])
|
|
23
|
+
if (names.includes(name)) {
|
|
24
|
+
if (paths.length >= MAX_PACKAGES)
|
|
25
|
+
throw audioError('audio_build_budget_exceeded', 422);
|
|
26
|
+
paths.push(`${path}/${name}`);
|
|
27
|
+
}
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const entry of (await readdir(absolute, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)))
|
|
31
|
+
if (entry.isDirectory() && !entry.isSymbolicLink() && hashPart.test(entry.name))
|
|
32
|
+
await visit(`${path}/${entry.name}`, depth + 1);
|
|
33
|
+
};
|
|
34
|
+
await visit('dist/audio', 0);
|
|
35
|
+
return paths;
|
|
36
|
+
}
|
|
37
|
+
/** Only validated immutable sound packages join source inputs; unrelated dist outputs stay excluded. */
|
|
38
|
+
export async function readAudioBuildInputs(root, destination) {
|
|
39
|
+
const packages = [];
|
|
40
|
+
let totalBytes = 0;
|
|
41
|
+
for (const primaryPath of await packagePaths(root)) {
|
|
42
|
+
let bundle;
|
|
43
|
+
try {
|
|
44
|
+
bundle = await readAudioArtifactPackage(root, primaryPath);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const sourceText = await readAudioSource(root, bundle.sourcePath);
|
|
50
|
+
const files = [...bundle.files].map(([path, bytes]) => ({ path, contentHash: digest(bytes) })).sort((a, b) => a.path.localeCompare(b.path));
|
|
51
|
+
totalBytes += [...bundle.files.values()].reduce((total, bytes) => total + bytes.length, 0) + (sourceText === undefined ? 0 : Buffer.byteLength(sourceText));
|
|
52
|
+
if (totalBytes > MAX_PACKAGE_BYTES)
|
|
53
|
+
throw audioError('audio_build_budget_exceeded', 422);
|
|
54
|
+
if (destination) {
|
|
55
|
+
for (const [path, bytes] of bundle.files) {
|
|
56
|
+
const target = await audioProjectPath(destination, path, true);
|
|
57
|
+
await ensureSharedDirectory(destination, dirname(target));
|
|
58
|
+
await writeSharedFile(destination, target, bytes);
|
|
59
|
+
}
|
|
60
|
+
if (sourceText !== undefined) {
|
|
61
|
+
const target = await audioProjectPath(destination, bundle.sourcePath, true);
|
|
62
|
+
await ensureSharedDirectory(destination, dirname(target));
|
|
63
|
+
await writeSharedFile(destination, target, sourceText);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
packages.push({ primaryPath, sourcePath: bundle.sourcePath, sourceContentHash: sourceText === undefined ? null : digest(sourceText), files });
|
|
67
|
+
}
|
|
68
|
+
return { version: 1, inputHash: digest(canonicalAudioJson(packages)), packages };
|
|
69
|
+
}
|
|
70
|
+
export async function verifyAudioBuildInputs(root, snapshot) {
|
|
71
|
+
if ((await readAudioBuildInputs(root)).inputHash !== snapshot.inputHash)
|
|
72
|
+
throw audioError('audio_build_input_changed');
|
|
73
|
+
}
|
|
74
|
+
export function markAudioBuildInputs(bytes, snapshot) {
|
|
75
|
+
return snapshot.packages.length ? Buffer.concat([Buffer.from(`<!--playcraft-audio-inputs:v1:${snapshot.inputHash}-->\n`), bytes]) : bytes;
|
|
76
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { open, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { chromium } from 'playwright-core';
|
|
5
|
+
export const AUDIO_RENDER_CHUNK_BYTES = 64 * 1024;
|
|
6
|
+
/** This function is serialized into the isolated browser and must have no module closure. */
|
|
7
|
+
export function encodeAudioRenderChunk(rendered, request) {
|
|
8
|
+
const { kind, offset, length } = request;
|
|
9
|
+
if (!['pcm', 'wav'].includes(kind) || !Number.isSafeInteger(offset) || offset < 0
|
|
10
|
+
|| !Number.isSafeInteger(length) || length < 1 || length > 64 * 1024)
|
|
11
|
+
throw new Error('audio_budget_exceeded');
|
|
12
|
+
const total = kind === 'pcm' ? rendered.buffer.length * 4 : rendered.wav.byteLength;
|
|
13
|
+
if (offset + length > total || kind === 'pcm' && (offset % 4 || length % 4))
|
|
14
|
+
throw new Error('invalid_audio_output');
|
|
15
|
+
let data;
|
|
16
|
+
if (kind === 'pcm') {
|
|
17
|
+
data = new Uint8Array(length);
|
|
18
|
+
const view = new DataView(data.buffer), samples = rendered.buffer.getChannelData(0);
|
|
19
|
+
for (let index = 0; index < length / 4; index++)
|
|
20
|
+
view.setFloat32(index * 4, samples[offset / 4 + index], true);
|
|
21
|
+
}
|
|
22
|
+
else
|
|
23
|
+
data = new Uint8Array(rendered.wav, offset, length);
|
|
24
|
+
let text = '';
|
|
25
|
+
for (let index = 0; index < data.length; index += 16384)
|
|
26
|
+
text += String.fromCharCode(...data.subarray(index, index + 16384));
|
|
27
|
+
return btoa(text);
|
|
28
|
+
}
|
|
29
|
+
/** Acknowledged, bounded chunks keep the control transport and worker from retaining the entire payload. */
|
|
30
|
+
export async function writeAudioRenderOutput(outputPath, summary, chromiumVersion, readChunk, maximumFrames) {
|
|
31
|
+
if (!Number.isSafeInteger(summary.frames) || summary.frames < 1 || summary.frames > maximumFrames
|
|
32
|
+
|| maximumFrames > 2_646_000 || summary.wavBytes !== 44 + summary.frames * 2)
|
|
33
|
+
throw new Error('audio_budget_exceeded');
|
|
34
|
+
const created = [], files = [];
|
|
35
|
+
const openOutput = async (suffix) => {
|
|
36
|
+
// shared: the render worker runs as the development identity and the controller reads its bytes.
|
|
37
|
+
const path = `${outputPath}.${suffix}`, file = await open(path, 'wx', 0o660);
|
|
38
|
+
created.push(path);
|
|
39
|
+
files.push(file);
|
|
40
|
+
return file;
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
const pcmHash = createHash('sha256');
|
|
44
|
+
for (const kind of ['pcm', 'wav']) {
|
|
45
|
+
const file = await openOutput(kind), total = kind === 'pcm' ? summary.frames * 4 : summary.wavBytes;
|
|
46
|
+
for (let offset = 0; offset < total; offset += AUDIO_RENDER_CHUNK_BYTES) {
|
|
47
|
+
const length = Math.min(AUDIO_RENDER_CHUNK_BYTES, total - offset);
|
|
48
|
+
const encoded = await readChunk({ kind, offset, length });
|
|
49
|
+
if (typeof encoded !== 'string' || encoded.length !== Math.ceil(length / 3) * 4
|
|
50
|
+
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded))
|
|
51
|
+
throw new Error('invalid_audio_output');
|
|
52
|
+
const chunk = Buffer.from(encoded, 'base64');
|
|
53
|
+
if (chunk.length !== length || chunk.toString('base64') !== encoded)
|
|
54
|
+
throw new Error('invalid_audio_output');
|
|
55
|
+
await file.writeFile(chunk);
|
|
56
|
+
if (kind === 'pcm')
|
|
57
|
+
pcmHash.update(chunk);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const metadata = await openOutput('json');
|
|
61
|
+
await metadata.writeFile(JSON.stringify({ chromiumVersion, metrics: summary.metrics, pcmContentHash: pcmHash.digest('hex') }));
|
|
62
|
+
for (const file of files)
|
|
63
|
+
await file.close();
|
|
64
|
+
return created;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
await Promise.allSettled(files.map(file => file.close()));
|
|
68
|
+
await Promise.all(created.map(path => rm(path, { force: true })));
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** This worker executes only the verified platform graph, never a project page. */
|
|
73
|
+
async function main() {
|
|
74
|
+
const [inputPath, outputPath] = process.argv.slice(2);
|
|
75
|
+
const uid = process.getuid?.();
|
|
76
|
+
if (!inputPath || !outputPath || typeof uid !== 'number' || uid <= 0)
|
|
77
|
+
throw new Error('audio_renderer_identity_unavailable');
|
|
78
|
+
const input = JSON.parse(await readFile(inputPath, 'utf8'));
|
|
79
|
+
if (Buffer.byteLength(input.script) > 512 * 1024 || createHash('sha256').update(input.script).digest('hex') !== input.rendererBuildHash)
|
|
80
|
+
throw new Error('engine_build_mismatch');
|
|
81
|
+
const browser = await chromium.launch({ executablePath: process.env.PLAYCRAFT_CHROMIUM_PATH || '/usr/bin/chromium',
|
|
82
|
+
headless: true, chromiumSandbox: true, timeout: 8000, args: ['--mute-audio', '--disable-background-networking'] });
|
|
83
|
+
let created = [];
|
|
84
|
+
try {
|
|
85
|
+
try {
|
|
86
|
+
const context = await browser.newContext({ serviceWorkers: 'block', acceptDownloads: false });
|
|
87
|
+
await context.route('**/*', route => route.abort('blockedbyclient'));
|
|
88
|
+
const page = await context.newPage();
|
|
89
|
+
await page.addScriptTag({ content: input.script });
|
|
90
|
+
const rendered = await page.evaluateHandle(async (document) => {
|
|
91
|
+
const engine = globalThis.playcraftAudio;
|
|
92
|
+
const buffer = await engine.renderAudioDocument(document);
|
|
93
|
+
const maximumFrames = document && typeof document === 'object' && 'format' in document && document.format === 'playcraft.score' ? 2_646_000 : 441_000;
|
|
94
|
+
if (buffer.length > maximumFrames || buffer.numberOfChannels !== 1)
|
|
95
|
+
throw new Error('audio_budget_exceeded');
|
|
96
|
+
return { buffer, wav: engine.audioBufferToWav(buffer), metrics: engine.audioPcmMetrics(buffer) };
|
|
97
|
+
}, input.document);
|
|
98
|
+
try {
|
|
99
|
+
const summary = await rendered.evaluate(value => ({ frames: value.buffer.length, wavBytes: value.wav.byteLength, metrics: value.metrics }));
|
|
100
|
+
const score = input.document && typeof input.document === 'object' && 'format' in input.document && input.document.format === 'playcraft.score';
|
|
101
|
+
created = await writeAudioRenderOutput(outputPath, summary, browser.version(), request => rendered.evaluate(encodeAudioRenderChunk, request), score ? 2_646_000 : 441_000);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await rendered.dispose();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await browser.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
await Promise.all(created.map(path => rm(path, { force: true })));
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
117
|
+
void main().catch(error => { process.stderr.write(`${error instanceof Error ? error.message : 'audio_render_failed'}\n`); process.exitCode = 1; });
|