@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
|
@@ -1,7 +1,47 @@
|
|
|
1
|
-
import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, takesWorkspaceDirectory, isTurnControl, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
-
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
1
|
+
import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, takesWorkspaceDirectory, isTurnControl, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { canExecuteKind, executeOperation, reconcileLevelEditorTransaction } from './dispatch.js';
|
|
3
3
|
import { buildOperationRecord } from './operation-record.js';
|
|
4
4
|
import { preflightSkillExecution } from '../workspaces/skill-execution-context.js';
|
|
5
|
+
import { reconcileLevelEditorRetryRequest } from '../workspaces/level-editor-requests.js';
|
|
6
|
+
async function reconcileRetryRegistration(state, scopeKey, operation) {
|
|
7
|
+
const proof = await reconcileLevelEditorRetryRequest(state, operation);
|
|
8
|
+
if (!proof)
|
|
9
|
+
return undefined;
|
|
10
|
+
let current = operation;
|
|
11
|
+
if (current.status === 'queued')
|
|
12
|
+
current = transitionOperation(current, 'dispatched');
|
|
13
|
+
if (current.status === 'dispatched')
|
|
14
|
+
current = transitionOperation(current, 'accepted');
|
|
15
|
+
return state.journal.putOperation(scopeKey, transitionOperation(current, 'completed', { result: proof, error: undefined }));
|
|
16
|
+
}
|
|
17
|
+
/** Observation completes proven saves and control registrations without resubmitting their execution. */
|
|
18
|
+
export async function observeLevelEditorTransaction(state, operation) {
|
|
19
|
+
if (operation.kind === 'level-editor.request' &&
|
|
20
|
+
['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status)) {
|
|
21
|
+
const scopeKey = operationScopeKey(operation);
|
|
22
|
+
return state.journal
|
|
23
|
+
.exclusive(scopeKey, async () => {
|
|
24
|
+
const current = state.journal.getOperation(scopeKey);
|
|
25
|
+
if (!current || current.operationId !== operation.operationId)
|
|
26
|
+
return current ?? operation;
|
|
27
|
+
return (await reconcileRetryRegistration(state, scopeKey, current)) ?? current;
|
|
28
|
+
})
|
|
29
|
+
.catch(() => operation);
|
|
30
|
+
}
|
|
31
|
+
if (operation.kind !== 'level-editor.transaction' || !['dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
32
|
+
return operation;
|
|
33
|
+
const scopeKey = operationScopeKey(operation);
|
|
34
|
+
return state.journal
|
|
35
|
+
.exclusive(scopeKey, async () => {
|
|
36
|
+
const current = state.journal.getOperation(scopeKey);
|
|
37
|
+
if (!current ||
|
|
38
|
+
current.operationId !== operation.operationId ||
|
|
39
|
+
!['dispatched', 'accepted', 'uncertain'].includes(current.status))
|
|
40
|
+
return current ?? operation;
|
|
41
|
+
return (await reconcileLevelEditorTransaction(state, scopeKey, current)) ?? current;
|
|
42
|
+
})
|
|
43
|
+
.catch(() => operation);
|
|
44
|
+
}
|
|
5
45
|
/**
|
|
6
46
|
* Shared operation acceptance: lease fence, idempotency, capability, record creation and
|
|
7
47
|
* execution. HTTP supplies its transport/session checks through `validate`; in-sandbox Agent
|
|
@@ -38,26 +78,51 @@ export async function acceptOperationCore(state, parsed, options) {
|
|
|
38
78
|
}
|
|
39
79
|
if (!decision.operation.gatewayOperationId) {
|
|
40
80
|
decision.operation = await state.journal.putOperation(scopeKey, {
|
|
41
|
-
...decision.operation,
|
|
81
|
+
...decision.operation,
|
|
82
|
+
gatewayOperationId: parsed.gatewayOperationId,
|
|
42
83
|
});
|
|
43
84
|
}
|
|
44
85
|
}
|
|
45
|
-
const retryWithRecovery = decision.action === 'reuse' &&
|
|
46
|
-
|
|
47
|
-
|
|
86
|
+
const retryWithRecovery = decision.action === 'reuse' &&
|
|
87
|
+
parsed.kind === 'session.turn' &&
|
|
88
|
+
Boolean(parsed.sessionRecovery) &&
|
|
89
|
+
decision.operation.status === 'failed' &&
|
|
90
|
+
isLostThreadError(decision.operation.error);
|
|
48
91
|
if (decision.action === 'reuse' && !retryWithRecovery) {
|
|
92
|
+
if (decision.operation.kind === 'level-editor.request') {
|
|
93
|
+
const reconciled = await reconcileRetryRegistration(state, scopeKey, decision.operation).catch(() => undefined);
|
|
94
|
+
if (reconciled)
|
|
95
|
+
return { rejected: false, operation: reconciled, executed: reconciled, reused: true };
|
|
96
|
+
}
|
|
97
|
+
if (decision.operation.kind === 'level-editor.transaction' && ['dispatched', 'accepted', 'uncertain'].includes(decision.operation.status)) {
|
|
98
|
+
const validation = await options.validate?.(scopeKey);
|
|
99
|
+
if (validation)
|
|
100
|
+
return { rejected: true, refusal: validation };
|
|
101
|
+
const reconciled = await reconcileLevelEditorTransaction(state, scopeKey, decision.operation, options.executionPayload).catch(() => undefined);
|
|
102
|
+
if (reconciled)
|
|
103
|
+
return { rejected: false, operation: reconciled, executed: reconciled, reused: true };
|
|
104
|
+
}
|
|
49
105
|
if (decision.operation.status === 'uncertain') {
|
|
50
106
|
return rejected(409, runtimeError('uncertain_operation', decision.operation.error ?? 'native_receipt_lost', {
|
|
51
107
|
operationId: decision.operation.operationId,
|
|
52
108
|
}));
|
|
53
109
|
}
|
|
54
|
-
if (decision.operation.status === 'queued' &&
|
|
55
|
-
|
|
110
|
+
if (decision.operation.status === 'queued' &&
|
|
111
|
+
takesWorkspaceDirectory(decision.operation.kind) &&
|
|
112
|
+
!isTurnControl(decision.operation.kind) &&
|
|
113
|
+
!state.journal
|
|
114
|
+
.deferredDirectoryOperations()
|
|
115
|
+
.some((item) => item.operationId === decision.operation.operationId)) {
|
|
56
116
|
const queued = await state.journal.putPendingOperation(scopeKey, decision.operation, options.executionPayload, parsed.sessionRecovery);
|
|
57
117
|
const executed = await executeOperation(state, scopeKey, queued, options.executionPayload, parsed.sessionRecovery);
|
|
58
118
|
return { rejected: false, operation: executed, executed, reused: true };
|
|
59
119
|
}
|
|
60
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
rejected: false,
|
|
122
|
+
operation: decision.operation,
|
|
123
|
+
executed: decision.operation,
|
|
124
|
+
reused: true,
|
|
125
|
+
};
|
|
61
126
|
}
|
|
62
127
|
const validation = await options.validate?.(scopeKey);
|
|
63
128
|
if (validation)
|
|
@@ -68,17 +133,24 @@ export async function acceptOperationCore(state, parsed, options) {
|
|
|
68
133
|
policy = await preflightSkillExecution(state, parsed);
|
|
69
134
|
}
|
|
70
135
|
catch (error) {
|
|
71
|
-
return rejected(409, {
|
|
72
|
-
|
|
136
|
+
return rejected(409, {
|
|
137
|
+
error: error instanceof Error && /^(?:context_|invalid_composer_context|untrusted_runtime_field)/.test(error.message)
|
|
138
|
+
? error.message
|
|
139
|
+
: 'context_skill_unavailable',
|
|
140
|
+
});
|
|
73
141
|
}
|
|
74
142
|
const operation = buildOperationRecord(state, parsed, payloadHash, options.leaseFence, executable, policy);
|
|
75
143
|
if (!executable) {
|
|
76
144
|
const saved = await state.journal.putOperation(scopeKey, operation);
|
|
77
|
-
return rejected(409, runtimeError('capability_unavailable', state.journal.destroyed()
|
|
145
|
+
return rejected(409, runtimeError('capability_unavailable', state.journal.destroyed()
|
|
146
|
+
? 'runtime_destroyed'
|
|
147
|
+
: (state.nativeError ?? 'adapter_not_started'), { operationId: saved.operationId }));
|
|
78
148
|
}
|
|
79
149
|
state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, options.leaseFence);
|
|
80
150
|
await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
|
|
81
|
-
const queued = takesWorkspaceDirectory(operation.kind) &&
|
|
151
|
+
const queued = takesWorkspaceDirectory(operation.kind) &&
|
|
152
|
+
!isTurnControl(operation.kind) &&
|
|
153
|
+
operation.kind !== 'runtime.destroy'
|
|
82
154
|
? await state.journal.putPendingOperation(scopeKey, operation, options.executionPayload, parsed.sessionRecovery)
|
|
83
155
|
: await state.journal.putOperation(scopeKey, operation);
|
|
84
156
|
const executed = await executeOperation(state, scopeKey, queued, options.executionPayload, parsed.sessionRecovery);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { workspaceAudioTargetSchema, workspaceRelativePathSchema, workspaceAudioProtectionSchema, isSoundDocumentPath } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { WorkspaceAudioAssets } from '../workspaces/audio-assets/service.js';
|
|
4
|
+
import { audioRendererScript, workspaceAudioRenderer } from '../workspaces/audio-assets/renderer.js';
|
|
5
|
+
import { audioError } from '../workspaces/audio-assets/storage.js';
|
|
6
|
+
import { workspaceRoot, revisionScope } from '../workspaces/registry.js';
|
|
7
|
+
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
8
|
+
import { wakeSourceEventUploader } from './source-uploader.js';
|
|
9
|
+
import { withDirectoryWrite } from './directory-access.js';
|
|
10
|
+
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
11
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
|
12
|
+
const actionSchema = z.object({
|
|
13
|
+
action: z.enum(['create-candidate', 'save', 'render', 'adopt', 'rollback', 'bind']),
|
|
14
|
+
requestId: z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/), target: z.unknown().refine(value => value === undefined || workspaceAudioTargetSchema.safeParse(value).success).optional(),
|
|
15
|
+
taskId: z.string().uuid().optional(), candidateId: z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/).optional(),
|
|
16
|
+
document: z.unknown().optional(), expectedRevision: z.number().int().positive().optional(), expectedContentHash: hash.nullable().optional(),
|
|
17
|
+
path: z.string().refine(value => isSoundDocumentPath(value) && workspaceRelativePathSchema.safeParse(value).success).optional(),
|
|
18
|
+
expectedDestinationRevision: z.number().int().nonnegative().optional(), expectedDestinationContentHash: hash.nullable().optional(),
|
|
19
|
+
versionRevision: z.number().int().positive().optional(), eventName: z.string().max(64).optional(),
|
|
20
|
+
protection: z.unknown().refine(value => value === undefined || workspaceAudioProtectionSchema.safeParse(value).success).optional(),
|
|
21
|
+
}).strict();
|
|
22
|
+
const services = new WeakMap();
|
|
23
|
+
const probes = new WeakMap();
|
|
24
|
+
function send(response, status, value) {
|
|
25
|
+
if (response.destroyed || response.headersSent)
|
|
26
|
+
return;
|
|
27
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
28
|
+
response.end(JSON.stringify(value));
|
|
29
|
+
}
|
|
30
|
+
async function body(request) {
|
|
31
|
+
let size = 0;
|
|
32
|
+
const chunks = [];
|
|
33
|
+
for await (const chunk of request) {
|
|
34
|
+
size += Buffer.byteLength(chunk);
|
|
35
|
+
if (size > 96 * 1024)
|
|
36
|
+
throw audioError('audio_budget_exceeded', 413);
|
|
37
|
+
chunks.push(Buffer.from(chunk));
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw audioError('invalid_audio_request', 400);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function targetFrom(url) {
|
|
47
|
+
const value = url.searchParams.has('path') ? { kind: 'workspace-file', path: url.searchParams.get('path') } :
|
|
48
|
+
{ kind: 'generation-candidate', taskId: url.searchParams.get('taskId'), candidateId: url.searchParams.get('candidateId') };
|
|
49
|
+
const parsed = workspaceAudioTargetSchema.safeParse(value);
|
|
50
|
+
if (!parsed.success)
|
|
51
|
+
throw audioError('invalid_audio_target', 400);
|
|
52
|
+
return parsed.data;
|
|
53
|
+
}
|
|
54
|
+
export async function workspaceAudioAssetService(state, workspaceId, directoryName) {
|
|
55
|
+
const root = await workspaceRoot(state, workspaceId, directoryName), scope = `${workspaceId ?? 'single'}:${directoryName}`;
|
|
56
|
+
let entries = services.get(state);
|
|
57
|
+
if (!entries) {
|
|
58
|
+
entries = new Map();
|
|
59
|
+
services.set(state, entries);
|
|
60
|
+
}
|
|
61
|
+
let value = entries.get(scope);
|
|
62
|
+
if (!value || value.projectRoot !== root) {
|
|
63
|
+
value = new WorkspaceAudioAssets(root, state.config.stateDir, scope, {
|
|
64
|
+
render: workspaceAudioRenderer(state, root, scope),
|
|
65
|
+
onWrite: async (path, version, paths, requestId) => {
|
|
66
|
+
await state.journal.putFileRevision(path, { revision: version.revision, contentHash: version.contentHash }, revisionScope(state, workspaceId, directoryName));
|
|
67
|
+
for (const type of ['file.written', 'files.changed'])
|
|
68
|
+
await state.journal.allocateSourceEvent({
|
|
69
|
+
runtimeId: state.config.runtimeId, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
|
|
70
|
+
workspaceId, operationId: requestId, type, occurredAt: new Date().toISOString(),
|
|
71
|
+
payload: { ...(workspaceId ? { workspaceId } : {}), directoryName, path, paths, revision: version.revision, contentHash: version.contentHash },
|
|
72
|
+
});
|
|
73
|
+
wakeSourceEventUploader(state);
|
|
74
|
+
if (workspaceId && paths.some(item => item.startsWith('dist/')))
|
|
75
|
+
await refreshWorkspaceArtifacts(state, workspaceId, { paths, source: { kind: 'generated', operationId: requestId, tool: 'audio' } }, directoryName);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
entries.set(scope, value);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
async function rendererProbe(state, root, scope, kind = 'synth-sfx') {
|
|
83
|
+
let entries = probes.get(state);
|
|
84
|
+
if (!entries) {
|
|
85
|
+
entries = new Map();
|
|
86
|
+
probes.set(state, entries);
|
|
87
|
+
}
|
|
88
|
+
const key = `${scope}:${kind}`;
|
|
89
|
+
let probe = entries.get(key);
|
|
90
|
+
if (!probe) {
|
|
91
|
+
probe = (async () => {
|
|
92
|
+
try {
|
|
93
|
+
const bundle = await audioRendererScript(kind);
|
|
94
|
+
const source = kind === 'score' ? { format: 'playcraft.score', formatVersion: 1, name: 'score-renderer-probe',
|
|
95
|
+
engine: { id: 'playcraft-webaudio-score', version: '1.0.0' }, dependencies: [],
|
|
96
|
+
content: { kind: 'score', tempoBpm: 120, timeSignature: { numerator: 4, denominator: 4 }, lengthBeats: 1,
|
|
97
|
+
masterGain: .1, loop: { enabled: false, startBeat: 0, endBeat: 1 }, tracks: [{ id: 'probe', name: 'Probe', instrument: 'sine',
|
|
98
|
+
gain: .1, muted: false, solo: false, notes: [{ id: 'probe_note', startBeat: 0, durationBeats: .5, pitch: 69, velocity: .1 }], automation: [] }] } }
|
|
99
|
+
: { format: 'playcraft.audio', formatVersion: 1, name: 'renderer-probe',
|
|
100
|
+
engine: { id: 'playcraft-webaudio-sfx', version: '1.0.0' }, dependencies: [],
|
|
101
|
+
content: { kind: 'synth-sfx', durationSeconds: .05, seed: 0, masterGain: .1,
|
|
102
|
+
voices: [{ id: 'probe', startSeconds: 0, durationSeconds: .05, gain: .1, source: { kind: 'oscillator', waveform: 'sine', frequencyHz: 440 },
|
|
103
|
+
envelope: { attackSeconds: .002, decaySeconds: .01, sustainLevel: .5, releaseSeconds: .01 }, filter: null }], automation: [] } };
|
|
104
|
+
await workspaceAudioRenderer(state, root, scope)(source);
|
|
105
|
+
return { ready: true, rendererBuildHash: bundle.rendererBuildHash };
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { ready: false, reason: 'engine_unavailable' };
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
entries.set(key, probe);
|
|
112
|
+
const timer = setTimeout(() => entries.delete(key), 30_000);
|
|
113
|
+
timer.unref();
|
|
114
|
+
}
|
|
115
|
+
return probe;
|
|
116
|
+
}
|
|
117
|
+
/** Audio protocol negotiation is independent of legacy binary audio export. */
|
|
118
|
+
export async function handleAudioAssetRequest(state, request, response, url) {
|
|
119
|
+
if (!['/runtime/audio/capabilities', '/runtime/audio/assets', '/runtime/audio/content', '/runtime/audio/actions'].includes(url.pathname))
|
|
120
|
+
return false;
|
|
121
|
+
try {
|
|
122
|
+
if (request.headers['x-playcraft-audio-protocol'] !== '1')
|
|
123
|
+
throw audioError('audio_protocol_required', 426);
|
|
124
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? undefined, directoryName = url.searchParams.get('directoryName') ?? 'basic';
|
|
125
|
+
const root = await workspaceRoot(state, workspaceId, directoryName), enabled = process.env.CLOUD_WORKSPACE_AUDIO_ASSETS_ENABLED === 'true';
|
|
126
|
+
if (request.method === 'GET' && url.pathname === '/runtime/audio/capabilities') {
|
|
127
|
+
const renderer = enabled ? await rendererProbe(state, root, `${workspaceId ?? 'single'}:${directoryName}`) : { ready: false, reason: 'feature_disabled' };
|
|
128
|
+
const scoreRenderer = enabled && request.headers['x-playcraft-audio-score-protocol'] === '1'
|
|
129
|
+
? await rendererProbe(state, root, `${workspaceId ?? 'single'}:${directoryName}`, 'score') : { ready: false, reason: enabled ? 'audio_score_protocol_required' : 'feature_disabled' };
|
|
130
|
+
send(response, 200, { version: 1, audioAssetVersion: 1, audioScoreVersion: 1, audioScoreContextVersion: 1,
|
|
131
|
+
enabled, writable: enabled, documentVersions: [1], contentKinds: request.headers['x-playcraft-audio-score-protocol'] === '1' ? ['synth-sfx', 'score'] : ['synth-sfx'],
|
|
132
|
+
renderer: { ...renderer, engineId: 'playcraft-webaudio-sfx', engineVersion: '1.0.0' },
|
|
133
|
+
scoreRenderer: { ...scoreRenderer, engineId: 'playcraft-webaudio-score', engineVersion: '1.0.0' },
|
|
134
|
+
operations: { read: true, content: true, generate: enabled && renderer.ready, save: enabled, render: enabled && renderer.ready,
|
|
135
|
+
adopt: enabled && renderer.ready, rollback: enabled, bind: enabled && renderer.ready } });
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
const assets = await workspaceAudioAssetService(state, workspaceId, directoryName);
|
|
139
|
+
const requireScore = (score) => { if (score && request.headers['x-playcraft-audio-score-protocol'] !== '1')
|
|
140
|
+
throw audioError('audio_score_protocol_required', 426); };
|
|
141
|
+
if (request.method === 'GET' && url.pathname === '/runtime/audio/assets') {
|
|
142
|
+
const result = await assets.read(targetFrom(url));
|
|
143
|
+
requireScore(result.document.format === 'playcraft.score');
|
|
144
|
+
send(response, 200, result);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (request.method === 'GET' && url.pathname === '/runtime/audio/content') {
|
|
148
|
+
const revision = url.searchParams.has('revision') ? Number(url.searchParams.get('revision')) : undefined;
|
|
149
|
+
if (revision !== undefined && (!Number.isSafeInteger(revision) || revision < 1))
|
|
150
|
+
throw audioError('invalid_audio_request', 400);
|
|
151
|
+
const target = targetFrom(url), current = await assets.read(target);
|
|
152
|
+
requireScore(current.document.format === 'playcraft.score');
|
|
153
|
+
const result = await assets.content(target, revision, url.searchParams.get('representation') === 'source' ? 'source' : 'audition');
|
|
154
|
+
response.writeHead(200, { 'content-type': result.mimeType, 'content-length': result.bytes.length, etag: `"${result.contentHash}"`, 'cache-control': 'private, no-store' });
|
|
155
|
+
response.end(result.bytes);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (request.method === 'POST' && url.pathname === '/runtime/audio/actions') {
|
|
159
|
+
if (!enabled)
|
|
160
|
+
throw audioError('audio_feature_disabled', 403);
|
|
161
|
+
if (state.journal.destroyed())
|
|
162
|
+
throw audioError('runtime_destroyed');
|
|
163
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
164
|
+
if (sessionId) {
|
|
165
|
+
const session = state.journal.getSession(sessionId);
|
|
166
|
+
if (!session || session.workspaceId !== workspaceId || (session.directoryName ?? 'basic') !== directoryName)
|
|
167
|
+
throw audioError('audio_scope_mismatch', 403);
|
|
168
|
+
if (session.permissionMode === 'read-only')
|
|
169
|
+
throw audioError('plan_mode_write_forbidden', 403);
|
|
170
|
+
}
|
|
171
|
+
const parsed = actionSchema.safeParse(await body(request));
|
|
172
|
+
if (!parsed.success)
|
|
173
|
+
throw audioError('invalid_audio_request', 400);
|
|
174
|
+
const input = parsed.data;
|
|
175
|
+
const scoreDocument = input.document && typeof input.document === 'object' && 'format' in input.document && input.document.format === 'playcraft.score';
|
|
176
|
+
const existing = input.target?.kind === 'generation-candidate' ? await assets.read(input.target) : undefined;
|
|
177
|
+
requireScore(!!scoreDocument || input.path?.endsWith('.pcscore.json') === true
|
|
178
|
+
|| input.target?.kind === 'workspace-file' && input.target.path.endsWith('.pcscore.json') || existing?.document.format === 'playcraft.score');
|
|
179
|
+
const abort = new AbortController(), cancel = () => abort.abort(), close = () => { if (!response.writableEnded)
|
|
180
|
+
cancel(); };
|
|
181
|
+
request.once('aborted', cancel);
|
|
182
|
+
response.once('close', close);
|
|
183
|
+
try {
|
|
184
|
+
const result = await withDirectoryWrite(state, { workspaceId, directoryName, wait: false, sessionId: sessionId ?? undefined }, async () => {
|
|
185
|
+
if (await batchDirectoryHeld(state, workspaceId, directoryName))
|
|
186
|
+
throw audioError('batch_workspace_readonly', 403);
|
|
187
|
+
const path = parsed.data.target && parsed.data.target.kind === 'workspace-file'
|
|
188
|
+
? parsed.data.target.path : parsed.data.action === 'adopt'
|
|
189
|
+
? parsed.data.path ?? `source/audio/${parsed.data.target?.candidateId ?? 'candidate'}.${existing?.document.format === 'playcraft.score' ? 'pcscore' : 'pcaudio'}.json` : undefined;
|
|
190
|
+
const execute = () => assets.action(parsed.data, abort.signal);
|
|
191
|
+
return path ? state.journal.exclusive(`file:${revisionScope(state, workspaceId, directoryName) ?? 'single'}:${path}`, execute) : execute();
|
|
192
|
+
});
|
|
193
|
+
send(response, 200, result);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
request.off('aborted', cancel);
|
|
197
|
+
response.off('close', close);
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
throw audioError('method_not_allowed', 405);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
const code = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code :
|
|
205
|
+
error instanceof Error && /^[a-z][a-z0-9_]+$/.test(error.message) ? error.message : 'audio_request_failed';
|
|
206
|
+
const status = error && typeof error === 'object' && 'status' in error && typeof error.status === 'number' ? error.status :
|
|
207
|
+
code === 'directory_busy' || code === 'protected_range_changed' ? 409 : /invalid|unsupported|budget/.test(code) ? 400 : 500;
|
|
208
|
+
send(response, status, { error: code });
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { mkdtemp, open, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { WORKSPACE_AUDIO_EXPORT_MAX_BYTES, WORKSPACE_AUDIO_EXPORT_MAX_SECONDS, WORKSPACE_AUDIO_EXPORT_MIME_TYPES, workspaceAudioExportOptionsSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { exportAudio, getAudioInfo } from '../../utils/audio-processor.js';
|
|
8
|
+
let activeExports = 0;
|
|
9
|
+
/** Transcoding is temporary; saving the result uses the normal managed binary write. */
|
|
10
|
+
export async function serveAudioExport(request, response, url) {
|
|
11
|
+
const json = (status, error) => {
|
|
12
|
+
if (response.destroyed || response.headersSent)
|
|
13
|
+
return;
|
|
14
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
15
|
+
response.end(JSON.stringify({ error }));
|
|
16
|
+
};
|
|
17
|
+
const query = Object.fromEntries([...url.searchParams].filter(([name]) => !['workspaceId', 'directoryName'].includes(name)));
|
|
18
|
+
const parsed = workspaceAudioExportOptionsSchema.safeParse(query);
|
|
19
|
+
if (!parsed.success || request.headers['content-type']?.split(';')[0]?.trim() !== 'application/octet-stream') {
|
|
20
|
+
json(400, 'audio_export_invalid_options');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const declaredSize = Number(request.headers['content-length']);
|
|
24
|
+
if (declaredSize > WORKSPACE_AUDIO_EXPORT_MAX_BYTES) {
|
|
25
|
+
json(413, 'audio_export_input_too_large');
|
|
26
|
+
request.resume();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (activeExports >= 2) {
|
|
30
|
+
json(429, 'audio_export_busy');
|
|
31
|
+
request.resume();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
activeExports++;
|
|
35
|
+
const abort = new AbortController();
|
|
36
|
+
const cancel = () => {
|
|
37
|
+
abort.abort();
|
|
38
|
+
if (!request.complete)
|
|
39
|
+
request.destroy();
|
|
40
|
+
};
|
|
41
|
+
const close = () => { if (!response.writableEnded)
|
|
42
|
+
cancel(); };
|
|
43
|
+
request.once('aborted', cancel);
|
|
44
|
+
response.once('close', close);
|
|
45
|
+
const timeout = setTimeout(cancel, 120_000);
|
|
46
|
+
timeout.unref();
|
|
47
|
+
let directory;
|
|
48
|
+
try {
|
|
49
|
+
directory = await mkdtemp(join(tmpdir(), 'playcraft-audio-export-'));
|
|
50
|
+
const input = join(directory, 'input.bin');
|
|
51
|
+
// private: temporary transcoding input belongs to this controller request.
|
|
52
|
+
const file = await open(input, 'wx', 0o600);
|
|
53
|
+
let size = 0;
|
|
54
|
+
try {
|
|
55
|
+
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
|
56
|
+
abort.signal.throwIfAborted();
|
|
57
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
58
|
+
size += bytes.length;
|
|
59
|
+
if (size > WORKSPACE_AUDIO_EXPORT_MAX_BYTES) {
|
|
60
|
+
json(413, 'audio_export_input_too_large');
|
|
61
|
+
request.resume();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await file.writeFile(bytes);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await file.close();
|
|
69
|
+
}
|
|
70
|
+
if (!size) {
|
|
71
|
+
json(400, 'audio_export_empty_input');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const info = await getAudioInfo(input);
|
|
75
|
+
if (!Number.isFinite(info.duration) || info.duration <= 0) {
|
|
76
|
+
json(400, 'audio_export_invalid_input');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (info.duration > WORKSPACE_AUDIO_EXPORT_MAX_SECONDS) {
|
|
80
|
+
json(400, 'audio_export_too_long');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const { targetBytes, ...options } = parsed.data;
|
|
84
|
+
const output = join(directory, `audio.${options.format}`);
|
|
85
|
+
const result = await exportAudio(input, output, {
|
|
86
|
+
...options, channels: options.channels,
|
|
87
|
+
...(targetBytes === undefined ? {} : { targetSize: `${targetBytes}B` }), signal: abort.signal,
|
|
88
|
+
});
|
|
89
|
+
if (result.fileSize > WORKSPACE_AUDIO_EXPORT_MAX_BYTES) {
|
|
90
|
+
json(413, 'audio_export_output_too_large');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (targetBytes !== undefined && result.fileSize > targetBytes) {
|
|
94
|
+
json(422, 'audio_export_target_size_unreachable');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
abort.signal.throwIfAborted();
|
|
98
|
+
response.writeHead(200, {
|
|
99
|
+
'content-type': WORKSPACE_AUDIO_EXPORT_MIME_TYPES[options.format],
|
|
100
|
+
'content-length': result.fileSize,
|
|
101
|
+
'content-disposition': `attachment; filename="audio.${options.format}"`,
|
|
102
|
+
'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
|
|
103
|
+
});
|
|
104
|
+
await pipeline(createReadStream(output), response, { signal: abort.signal });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (response.headersSent)
|
|
108
|
+
response.destroy(error instanceof Error ? error : undefined);
|
|
109
|
+
else {
|
|
110
|
+
const detail = error instanceof Error ? error.message : '';
|
|
111
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
|
112
|
+
if (abort.signal.aborted)
|
|
113
|
+
json(408, 'audio_export_cancelled');
|
|
114
|
+
else if (/target.*size|target.*budget|budget.*small/i.test(`${code} ${detail}`))
|
|
115
|
+
json(422, 'audio_export_target_size_unreachable');
|
|
116
|
+
else if (/encoder.*(unavailable|not found)|unknown encoder|cannot find ffmpeg/i.test(`${code} ${detail}`))
|
|
117
|
+
json(503, 'audio_export_encoder_unavailable');
|
|
118
|
+
else if (/invalid.*option|unsupported.*(format|rate|bitrate|channel)/i.test(`${code} ${detail}`))
|
|
119
|
+
json(400, 'audio_export_invalid_options');
|
|
120
|
+
else
|
|
121
|
+
json(422, 'audio_export_failed');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
clearTimeout(timeout);
|
|
126
|
+
request.off('aborted', cancel);
|
|
127
|
+
response.off('close', close);
|
|
128
|
+
try {
|
|
129
|
+
if (directory)
|
|
130
|
+
await rm(directory, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
activeExports--;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|