@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.
- 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-program.js +82 -0
- package/dist/commands/audio-source.js +109 -0
- package/dist/commands/audio.js +13 -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 +257 -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/asset-library-import.js +27 -7
- 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 +80 -0
- package/dist/workspace-runtime/workspaces/audio-assets/library-import.js +149 -0
- package/dist/workspace-runtime/workspaces/audio-assets/library-package.js +26 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-artifact-audit.js +90 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-browser.js +194 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-build-inputs.js +122 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-engine.js +39 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-package.js +289 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-variant-build.js +217 -0
- package/dist/workspace-runtime/workspaces/audio-assets/references.js +107 -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 +459 -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/git.js +7 -0
- 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 +9 -4
- package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +92 -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 +92 -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 +92 -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 +92 -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
|
@@ -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; });
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { soundDurationSeconds } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
|
+
import { runDeveloperCommand } from '../developer-command.js';
|
|
10
|
+
import { openScratchToDevelopment } from '../directory-access.js';
|
|
11
|
+
import { workspaceGroup, writeSharedFile } from '../shared-write.js';
|
|
12
|
+
import { audioError, digest } from './storage.js';
|
|
13
|
+
import { stopLauncherExecution } from '../../codex/launcher-client.js';
|
|
14
|
+
import { launcherSocket } from '../developer-command.js';
|
|
15
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
16
|
+
const queues = new WeakMap();
|
|
17
|
+
export async function verifyAudioRenderPcm(path, expectedBytes, expectedHash) {
|
|
18
|
+
const hash = createHash('sha256');
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
for await (const chunk of createReadStream(path, { highWaterMark: 64 * 1024 })) {
|
|
21
|
+
bytes += chunk.length;
|
|
22
|
+
if (bytes > expectedBytes)
|
|
23
|
+
throw audioError('audio_render_failed', 422);
|
|
24
|
+
hash.update(chunk);
|
|
25
|
+
}
|
|
26
|
+
if (bytes !== expectedBytes || hash.digest('hex') !== expectedHash)
|
|
27
|
+
throw audioError('audio_render_failed', 422);
|
|
28
|
+
}
|
|
29
|
+
export async function recoverAudioRenderers(state, scope) {
|
|
30
|
+
for (const writer of state.journal.listManagedWrites(scope)) {
|
|
31
|
+
if (writer.stoppedAt || !writer.writeId.startsWith('audio:'))
|
|
32
|
+
continue;
|
|
33
|
+
if (writer.executionMode === 'launcher' && writer.launcher && writer.launcher.processId === writer.writeId &&
|
|
34
|
+
writer.launcher.runtimeEpoch === writer.runtimeEpoch && launcherSocket(state)) {
|
|
35
|
+
try {
|
|
36
|
+
await stopLauncherExecution(launcherSocket(state), writer.launcher);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw audioError('audio_render_process_unknown', 503);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const absent = (pid) => { try {
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
return error.code === 'ESRCH';
|
|
49
|
+
} };
|
|
50
|
+
if (!writer.pid || !absent(writer.pid) || !absent(-writer.pid))
|
|
51
|
+
throw audioError('audio_render_process_unknown', 503);
|
|
52
|
+
}
|
|
53
|
+
await state.journal.putManagedWrite({ ...writer, stoppedAt: new Date().toISOString() });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function audioRendererScript(kind = 'synth-sfx') {
|
|
57
|
+
const require = createRequire(import.meta.url);
|
|
58
|
+
const entry = require.resolve('@playcraft/common/cloud-workspace/runtime');
|
|
59
|
+
const source = await readFile(join(dirname(entry), 'audio-synthesizer.js'), 'utf8');
|
|
60
|
+
if (/^import\s/m.test(source) || /\bfrom\s+['"]/m.test(source))
|
|
61
|
+
throw audioError('engine_unavailable', 503);
|
|
62
|
+
const strip = (value) => value.replace(/^export\s+(?=(?:async\s+)?function\b|const\b|class\b|let\b)/gm, '').replace(/^\/\/# sourceMappingURL=.*$/gm, '');
|
|
63
|
+
let script = `${strip(source)}\n` + 'globalThis.playcraftAudio={renderAudioDocument,audioBufferToWav,audioPcmMetrics};';
|
|
64
|
+
if (kind === 'score') {
|
|
65
|
+
const score = await readFile(join(dirname(entry), 'score-synthesizer.js'), 'utf8');
|
|
66
|
+
// Only the fixed platform synthesizer may be concatenated; schema/runtime dependencies are not executable project input.
|
|
67
|
+
const imports = [...score.matchAll(/^import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?$/gm)];
|
|
68
|
+
if (imports.some(match => match[2] !== './audio-synthesizer.js'))
|
|
69
|
+
throw audioError('engine_unavailable', 503);
|
|
70
|
+
const body = score.replace(/^import\s+[\s\S]*?\s+from\s+['"][^'"]+['"];?\s*$/gm, '');
|
|
71
|
+
if (/^import\s/m.test(body) || /\bfrom\s+['"]/m.test(body))
|
|
72
|
+
throw audioError('engine_unavailable', 503);
|
|
73
|
+
script = `${strip(source)}\n${strip(body)}\nglobalThis.playcraftAudio={renderAudioDocument:renderSoundDocument,audioBufferToWav:soundBufferToWav,audioPcmMetrics:soundPcmMetrics};`;
|
|
74
|
+
}
|
|
75
|
+
return { script, rendererBuildHash: digest(script) };
|
|
76
|
+
}
|
|
77
|
+
export function workspaceAudioRenderer(state, projectRoot, scope = 'single:basic') {
|
|
78
|
+
const execute = async (document, signal) => {
|
|
79
|
+
await recoverAudioRenderers(state, scope);
|
|
80
|
+
const bundle = await audioRendererScript(document.format === 'playcraft.score' ? 'score' : 'synth-sfx');
|
|
81
|
+
const scratch = await mkdtemp(join(tmpdir(), 'playcraft-audio-render-'));
|
|
82
|
+
try {
|
|
83
|
+
await openScratchToDevelopment(scratch, await workspaceGroup(projectRoot));
|
|
84
|
+
const input = join(scratch, 'input.json'), output = join(scratch, 'result');
|
|
85
|
+
await writeSharedFile(projectRoot, input, JSON.stringify({ document, ...bundle }));
|
|
86
|
+
const worker = fileURLToPath(new URL('./render-worker.js', import.meta.url));
|
|
87
|
+
let log = '';
|
|
88
|
+
const writeId = `audio:${digest(input).slice(0, 24)}`;
|
|
89
|
+
let managed;
|
|
90
|
+
const result = await runDeveloperCommand(state, { processId: writeId, cwd: projectRoot,
|
|
91
|
+
command: `${quote(process.execPath)} ${quote(worker)} ${quote(input)} ${quote(output)}`, timeoutMs: document.format === 'playcraft.score' ? 60_000 : 20_000,
|
|
92
|
+
signal, onOutput: chunk => { log = (log + String(chunk)).slice(-4096); },
|
|
93
|
+
onStarted: async (identity) => {
|
|
94
|
+
managed = { writeId, workspaceId: scope, source: 'background', startedAt: new Date().toISOString(), runtimeEpoch: state.runtimeEpoch,
|
|
95
|
+
pid: identity.pid, executionMode: identity.launcher ? 'launcher' : 'local', launcher: identity.launcher };
|
|
96
|
+
await state.journal.putManagedWrite(managed);
|
|
97
|
+
} });
|
|
98
|
+
if (managed && result.code !== undefined)
|
|
99
|
+
await state.journal.putManagedWrite({ ...managed, stoppedAt: new Date().toISOString() });
|
|
100
|
+
if (result.code !== 0 || result.timedOut || result.canceled) {
|
|
101
|
+
const code = result.canceled ? 'audio_render_cancelled' : /render_peak_exceeded/.test(log) ? 'render_peak_exceeded'
|
|
102
|
+
: /identity|sandbox|browser|executable/i.test(log) ? 'engine_unavailable' : 'audio_render_failed';
|
|
103
|
+
throw audioError(code, code === 'engine_unavailable' ? 503 : 422);
|
|
104
|
+
}
|
|
105
|
+
const meta = JSON.parse(await readFile(`${output}.json`, 'utf8'));
|
|
106
|
+
const wav = await readFile(`${output}.wav`);
|
|
107
|
+
const frames = Math.ceil(soundDurationSeconds(document) * 44100);
|
|
108
|
+
await verifyAudioRenderPcm(`${output}.pcm`, frames * 4, meta.pcmContentHash);
|
|
109
|
+
if (wav.length !== 44 + frames * 2 || !Number.isFinite(meta.metrics?.peak))
|
|
110
|
+
throw audioError('audio_render_failed', 422);
|
|
111
|
+
return { wav, pcmContentHash: meta.pcmContentHash, rendererBuildHash: bundle.rendererBuildHash,
|
|
112
|
+
chromiumVersion: meta.chromiumVersion, metrics: meta.metrics };
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await rm(scratch, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
return (document, signal) => {
|
|
119
|
+
let scopes = queues.get(state);
|
|
120
|
+
if (!scopes) {
|
|
121
|
+
scopes = new Map();
|
|
122
|
+
queues.set(state, scopes);
|
|
123
|
+
}
|
|
124
|
+
const next = (scopes.get(scope) ?? Promise.resolve()).catch(() => undefined).then(() => execute(document, signal));
|
|
125
|
+
scopes.set(scope, next);
|
|
126
|
+
return next;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, open, readFile, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { canonicalAudioJson, audioRepresentationSetHash } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { audioRendererScript } from './renderer.js';
|
|
6
|
+
import { audioProjectPath, audioError, digest } from './storage.js';
|
|
7
|
+
import { ensureSharedDirectory, writeSharedFile } from '../shared-write.js';
|
|
8
|
+
/** Every package member is immutable and verified before the source commit becomes visible. */
|
|
9
|
+
export async function publishAudioRepresentations(root, sourcePath, source, wav) {
|
|
10
|
+
const score = source.document.format === 'playcraft.score';
|
|
11
|
+
const engine = await audioRendererScript(score ? 'score' : 'synth-sfx');
|
|
12
|
+
if (engine.rendererBuildHash !== source.audition.rendererBuildHash)
|
|
13
|
+
throw audioError('engine_build_mismatch', 503);
|
|
14
|
+
const player = score ? 'createStandaloneSoundPlayer' : 'createStandaloneAudioPlayer';
|
|
15
|
+
const runtime = `${engine.script}\nexport { ${score ? 'renderSoundDocument' : 'renderAudioDocument'}, ${player} };\n`;
|
|
16
|
+
const module = `import { ${player} } from './runtime.mjs';\n` +
|
|
17
|
+
`const document=${canonicalAudioJson(source.document)};\n` +
|
|
18
|
+
`export const sourceContentHash=${JSON.stringify(source.contentHash)};\nexport const sourceRenderHash=${JSON.stringify(source.sourceRenderHash)};\n` +
|
|
19
|
+
`export const rendererBuildHash=${JSON.stringify(engine.rendererBuildHash)};\n` +
|
|
20
|
+
`export function createSound(){return ${player}(document);}\n`;
|
|
21
|
+
const bytes = [
|
|
22
|
+
{ path: score ? 'sound.pcscore.json' : 'sound.pcaudio.json', mimeType: 'application/json', role: 'editable', data: Buffer.from(source.sourceText) },
|
|
23
|
+
{ path: 'audition.wav', mimeType: 'audio/wav', role: 'audition', data: Buffer.from(wav) },
|
|
24
|
+
{ path: 'runtime.mjs', mimeType: 'text/javascript', role: 'runtime', data: Buffer.from(runtime) },
|
|
25
|
+
{ path: 'sound.mjs', mimeType: 'text/javascript', role: 'delivery', data: Buffer.from(module) },
|
|
26
|
+
];
|
|
27
|
+
if (digest(wav) !== source.audition.contentHash)
|
|
28
|
+
throw audioError('audio_audition_unavailable', 404);
|
|
29
|
+
const files = bytes.map(({ data, ...entry }) => ({ ...entry, contentHash: digest(data), size: data.length }));
|
|
30
|
+
const manifest = { version: 1, sourcePath, sourceContentHash: source.contentHash, sourceRenderHash: source.sourceRenderHash,
|
|
31
|
+
engine: source.document.engine, rendererBuildHash: engine.rendererBuildHash, chromiumVersion: source.audition.chromiumVersion,
|
|
32
|
+
renderKey: source.audition.renderKey, pcmContentHash: source.audition.pcmContentHash, files };
|
|
33
|
+
const representationSetHash = audioRepresentationSetHash(manifest);
|
|
34
|
+
const relative = `dist/audio/${digest(sourcePath)}/${source.contentHash}/${representationSetHash}`;
|
|
35
|
+
const destination = await audioProjectPath(root, `${relative}/manifest.json`);
|
|
36
|
+
const directory = dirname(destination), staging = `${directory}.${randomUUID()}.tmp`;
|
|
37
|
+
const manifestBytes = Buffer.from(`${canonicalAudioJson({ ...manifest, representationSetHash })}\n`);
|
|
38
|
+
await ensureSharedDirectory(root, dirname(directory));
|
|
39
|
+
await ensureSharedDirectory(root, staging);
|
|
40
|
+
try {
|
|
41
|
+
for (const entry of bytes)
|
|
42
|
+
await writeSharedFile(root, join(staging, entry.path), entry.data);
|
|
43
|
+
await writeSharedFile(root, join(staging, 'manifest.json'), manifestBytes);
|
|
44
|
+
const existing = await lstat(directory).catch(error => { if (error.code === 'ENOENT')
|
|
45
|
+
return undefined; throw error; });
|
|
46
|
+
if (existing) {
|
|
47
|
+
if (!existing.isDirectory() || existing.isSymbolicLink())
|
|
48
|
+
throw audioError('audio_representation_conflict');
|
|
49
|
+
for (const entry of [...bytes, { path: 'manifest.json', data: manifestBytes }]) {
|
|
50
|
+
const member = await audioProjectPath(root, `${relative}/${entry.path}`);
|
|
51
|
+
if (digest(await readFile(member)) !== digest(entry.data))
|
|
52
|
+
throw audioError('audio_representation_conflict');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
await rename(staging, directory);
|
|
57
|
+
const published = await open(dirname(directory), 'r');
|
|
58
|
+
try {
|
|
59
|
+
await published.sync();
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await published.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await rm(staging, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
return { representationSetHash, sourceContentHash: source.contentHash, rendererBuildHash: engine.rendererBuildHash,
|
|
70
|
+
modulePath: `${relative}/sound.mjs`, manifestPath: `${relative}/manifest.json`,
|
|
71
|
+
files: [...files.map(entry => ({ path: `${relative}/${entry.path}`, contentHash: entry.contentHash, size: entry.size })),
|
|
72
|
+
{ path: `${relative}/manifest.json`, contentHash: digest(manifestBytes), size: manifestBytes.length }] };
|
|
73
|
+
}
|