@playcraft/cli 0.1.65 → 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.
Files changed (102) hide show
  1. package/dist/assets/client.js +4 -2
  2. package/dist/assets/messages.js +2 -0
  3. package/dist/audio-source/messages.js +35 -0
  4. package/dist/commands/assets.js +1 -1
  5. package/dist/commands/audio-source.js +109 -0
  6. package/dist/commands/audio.js +11 -4
  7. package/dist/commands/build.js +4 -2
  8. package/dist/commands/tools-context.js +46 -0
  9. package/dist/commands/tools.js +2 -0
  10. package/dist/model-adaptation/project.js +1 -1
  11. package/dist/project-skills/builtin-import.js +69 -22
  12. package/dist/project-skills/commands.js +11 -2
  13. package/dist/project-skills/lifecycle.js +1 -1
  14. package/dist/project-skills/local-store.js +90 -31
  15. package/dist/project-skills/messages.js +16 -0
  16. package/dist/project-skills/migrate-lock.js +166 -0
  17. package/dist/project-skills/remote-cache.js +36 -5
  18. package/dist/utils/agent-api-client.js +18 -1
  19. package/dist/utils/agent-generation-provenance.js +8 -2
  20. package/dist/utils/audio-export.js +249 -0
  21. package/dist/utils/audio-processor.js +22 -99
  22. package/dist/utils/execution-context-bridge.js +28 -0
  23. package/dist/workspace-runtime/batch/control.js +13 -2
  24. package/dist/workspace-runtime/batch/generate.js +43 -2
  25. package/dist/workspace-runtime/batch/prepare.js +4 -0
  26. package/dist/workspace-runtime/batch/skill-artifacts.js +103 -0
  27. package/dist/workspace-runtime/batch/source-capture.js +6 -8
  28. package/dist/workspace-runtime/codex/app-server.js +26 -2
  29. package/dist/workspace-runtime/codex/execution-context-broker.js +61 -0
  30. package/dist/workspace-runtime/codex/execution-context-tools.js +37 -0
  31. package/dist/workspace-runtime/codex/generation-provenance-broker.js +9 -6
  32. package/dist/workspace-runtime/codex/level-editor-tools.js +18 -0
  33. package/dist/workspace-runtime/codex/model-broker.js +15 -2
  34. package/dist/workspace-runtime/codex/scheduled-task-tools.js +1 -1
  35. package/dist/workspace-runtime/previews/finalize.js +1 -1
  36. package/dist/workspace-runtime/previews/output.js +4 -1
  37. package/dist/workspace-runtime/previews/publish.js +5 -2
  38. package/dist/workspace-runtime/previews/runtime-preview.js +38 -3
  39. package/dist/workspace-runtime/serve.js +6 -0
  40. package/dist/workspace-runtime/server/accept-operation.js +85 -13
  41. package/dist/workspace-runtime/server/audio-assets.js +211 -0
  42. package/dist/workspace-runtime/server/audio-export.js +136 -0
  43. package/dist/workspace-runtime/server/directory-access.js +222 -67
  44. package/dist/workspace-runtime/server/dispatch.js +252 -29
  45. package/dist/workspace-runtime/server/http.js +97 -3
  46. package/dist/workspace-runtime/server/operation-record.js +1 -1
  47. package/dist/workspace-runtime/workspaces/artifact-files.js +10 -3
  48. package/dist/workspace-runtime/workspaces/artifacts.js +47 -6
  49. package/dist/workspace-runtime/workspaces/audio-assets/artifact-package.js +104 -0
  50. package/dist/workspace-runtime/workspaces/audio-assets/binding-usage.js +71 -0
  51. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +76 -0
  52. package/dist/workspace-runtime/workspaces/audio-assets/render-worker.js +117 -0
  53. package/dist/workspace-runtime/workspaces/audio-assets/renderer.js +128 -0
  54. package/dist/workspace-runtime/workspaces/audio-assets/representations.js +73 -0
  55. package/dist/workspace-runtime/workspaces/audio-assets/service.js +327 -0
  56. package/dist/workspace-runtime/workspaces/audio-assets/storage.js +125 -0
  57. package/dist/workspace-runtime/workspaces/build-isolation.js +2 -0
  58. package/dist/workspace-runtime/workspaces/composer-context.js +40 -1
  59. package/dist/workspace-runtime/workspaces/empty-template.js +1 -1
  60. package/dist/workspace-runtime/workspaces/execution-context.js +24 -0
  61. package/dist/workspace-runtime/workspaces/files.js +3 -5
  62. package/dist/workspace-runtime/workspaces/level-editor-agent-store.js +143 -0
  63. package/dist/workspace-runtime/workspaces/level-editor-agent.js +410 -0
  64. package/dist/workspace-runtime/workspaces/level-editor-atomic.js +424 -0
  65. package/dist/workspace-runtime/workspaces/level-editor-extensions.js +140 -0
  66. package/dist/workspace-runtime/workspaces/level-editor-format.js +75 -0
  67. package/dist/workspace-runtime/workspaces/level-editor-history-store.js +250 -0
  68. package/dist/workspace-runtime/workspaces/level-editor-json5.js +132 -0
  69. package/dist/workspace-runtime/workspaces/level-editor-requests.js +724 -0
  70. package/dist/workspace-runtime/workspaces/level-editor.js +454 -0
  71. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +62 -22
  72. package/dist/workspace-runtime/workspaces/parameter-production.js +1 -1
  73. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +4 -3
  74. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +19 -5
  75. package/dist/workspace-runtime/workspaces/skills.js +48 -13
  76. package/package.json +6 -4
  77. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  78. package/project-template/.claude/skills/playcraft-level-editor/SKILL.md +34 -0
  79. package/project-template/.claude/skills/playcraft-level-editor/references/authoring-contract.json +3475 -0
  80. package/project-template/.claude/skills/playcraft-level-editor/references/extensions.md +37 -0
  81. package/project-template/.claude/skills/playcraft-level-editor/references/preview.md +15 -0
  82. package/project-template/.claude/skills/playcraft-level-editor/references/protocol-v1.md +35 -0
  83. package/project-template/vite.config.ts +1 -1
  84. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  85. package/project-template-v2/.playcraft/skills.lock.json +9 -2
  86. package/project-template-v2/vite.config.ts +1 -1
  87. package/project-templates/empty/AGENTS.md +8 -4
  88. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  89. package/project-templates/phaser/vite.config.ts +1 -1
  90. package/project-templates/playable-template-phaser/vite.config.ts +1 -1
  91. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  92. package/project-templates/threejs/vite.config.ts +1 -1
  93. package/templates/preview-runtime/v1/README.md +12 -0
  94. package/templates/preview-runtime/v1/build-preview.mjs +17 -8
  95. package/templates/preview-runtime/v1/manifest.json +9 -6
  96. package/templates/preview-runtime/v1/release-guard.mjs +1 -1
  97. package/templates/preview-runtime/v1/runtime.d.mts +1 -3
  98. package/templates/preview-runtime/v1/runtime.d.ts +1 -3
  99. package/templates/preview-runtime/v1/runtime.mjs +1142 -257
  100. package/templates/preview-runtime/v1/types/host.d.ts +29 -0
  101. package/templates/preview-runtime/v1/types/index.d.ts +6 -0
  102. package/templates/preview-runtime/v1/types/level-editor.d.ts +123 -0
@@ -0,0 +1,249 @@
1
+ import path from 'node:path';
2
+ import fsp from 'node:fs/promises';
3
+ import { spawn } from 'node:child_process';
4
+ import * as musicMetadata from 'music-metadata';
5
+ export class AudioExportError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = 'AudioExportError';
11
+ }
12
+ }
13
+ function invalidOptions(message) {
14
+ throw new AudioExportError('audio_export_invalid_options', message);
15
+ }
16
+ const SAMPLE_RATES = [8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000];
17
+ const OPUS_SAMPLE_RATES = [8000, 12000, 16000, 24000, 48000];
18
+ const MP3_LOWEST_BITRATES = [8, 16, 24, 32, 40, 48, 56, 64];
19
+ const MP3_LOW_BITRATES = [8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
20
+ const MP3_HIGH_BITRATES = [32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320];
21
+ const CODECS = {
22
+ mp3: 'libmp3lame',
23
+ ogg: 'libvorbis',
24
+ opus: 'libopus',
25
+ wav: 'pcm_s16le',
26
+ };
27
+ export async function getAudioInfo(inputPath) {
28
+ const resolvedPath = path.resolve(inputPath);
29
+ let stat;
30
+ try {
31
+ stat = await fsp.stat(resolvedPath);
32
+ }
33
+ catch (err) {
34
+ if (err.code === 'ENOENT')
35
+ throw new Error(`File not found: ${resolvedPath}`);
36
+ throw err;
37
+ }
38
+ const meta = await musicMetadata.parseFile(resolvedPath);
39
+ return {
40
+ duration: meta.format.duration ?? 0,
41
+ fileSize: stat.size,
42
+ format: meta.format.container ?? path.extname(resolvedPath).slice(1),
43
+ bitrate: Math.round((meta.format.bitrate ?? 0) / 1000),
44
+ sampleRate: meta.format.sampleRate ?? 44100,
45
+ channels: meta.format.numberOfChannels ?? 2,
46
+ };
47
+ }
48
+ function targetBytes(size) {
49
+ const match = size.trim().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
50
+ const scales = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
51
+ const bytes = match
52
+ ? Math.floor(Number(match[1]) * scales[(match[2] ?? 'B').toUpperCase()])
53
+ : NaN;
54
+ if (!Number.isSafeInteger(bytes) || bytes <= 0)
55
+ invalidOptions('targetSize must be a positive size, such as 30KB');
56
+ return bytes;
57
+ }
58
+ function exportFormat(outputPath, explicit) {
59
+ const extension = path.extname(outputPath).slice(1).toLowerCase();
60
+ const format = explicit ?? extension;
61
+ if (!Object.hasOwn(CODECS, format))
62
+ invalidOptions(`Unsupported audio export format: ${format}`);
63
+ if (extension !== format)
64
+ invalidOptions(`Output extension must be .${format}`);
65
+ return format;
66
+ }
67
+ function validateOptions(format, options) {
68
+ if (options.channels !== undefined && options.channels !== 1 && options.channels !== 2) {
69
+ invalidOptions('channels must be 1 or 2');
70
+ }
71
+ const rates = format === 'opus' ? OPUS_SAMPLE_RATES : SAMPLE_RATES;
72
+ if (options.sampleRate !== undefined && !rates.includes(options.sampleRate)) {
73
+ invalidOptions(`Unsupported ${format} sampleRate: ${options.sampleRate}; supported: ${rates.join(', ')}`);
74
+ }
75
+ if (options.bitrate === undefined)
76
+ return;
77
+ const bitrate = options.bitrate;
78
+ if (format === 'wav')
79
+ invalidOptions('wav uses uncompressed 16-bit audio; set sampleRate and channels instead of bitrate');
80
+ const min = format === 'opus' ? 6 : format === 'ogg' ? 16 : 8;
81
+ const max = format === 'opus' ? 510 : format === 'ogg' ? 500 : 320;
82
+ if (!Number.isInteger(bitrate) || bitrate < min || bitrate > max) {
83
+ invalidOptions(`${format} bitrate must be an integer between ${min} and ${max}`);
84
+ }
85
+ if (format === 'mp3' && ![...MP3_LOW_BITRATES, ...MP3_HIGH_BITRATES].includes(bitrate)) {
86
+ invalidOptions(`Unsupported mp3 bitrate: ${bitrate}`);
87
+ }
88
+ if (format === 'mp3' && options.sampleRate !== undefined) {
89
+ const bitrates = mp3Bitrates(options.sampleRate);
90
+ if (!bitrates.includes(bitrate))
91
+ invalidOptions(`mp3 bitrate ${bitrate} is incompatible with sampleRate ${options.sampleRate}`);
92
+ }
93
+ }
94
+ function mp3Bitrates(rate) {
95
+ return rate <= 12000 ? MP3_LOWEST_BITRATES : rate < 32000 ? MP3_LOW_BITRATES : MP3_HIGH_BITRATES;
96
+ }
97
+ /** Run one bounded encoder process and terminate it immediately on cancellation. */
98
+ export async function runAudioFfmpeg(args, signal) {
99
+ signal?.throwIfAborted();
100
+ await new Promise((resolve, reject) => {
101
+ const child = spawn(process.env.FFMPEG_PATH || 'ffmpeg', ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y', ...args], {
102
+ stdio: ['ignore', 'ignore', 'pipe'],
103
+ });
104
+ let stderr = '';
105
+ let cancelled = false;
106
+ const abort = () => {
107
+ cancelled = true;
108
+ child.kill('SIGKILL');
109
+ };
110
+ const timeout = setTimeout(abort, 120_000);
111
+ signal?.addEventListener('abort', abort, { once: true });
112
+ child.stderr.on('data', (chunk) => {
113
+ stderr = (stderr + String(chunk)).slice(-8192);
114
+ });
115
+ const cleanup = () => {
116
+ clearTimeout(timeout);
117
+ signal?.removeEventListener('abort', abort);
118
+ };
119
+ child.once('error', (err) => {
120
+ cleanup();
121
+ reject(err);
122
+ });
123
+ child.once('close', (code) => {
124
+ cleanup();
125
+ if (cancelled)
126
+ reject(signal?.reason ?? new AudioExportError('audio_export_failed', 'Audio encoding timed out'));
127
+ else if (code !== 0)
128
+ reject(new AudioExportError('audio_export_failed', `Audio encoding failed: ${stderr.trim() || `exit ${code}`}`));
129
+ else
130
+ resolve();
131
+ });
132
+ if (signal?.aborted)
133
+ abort();
134
+ });
135
+ }
136
+ /** Publish only a completed output; failed work never replaces an existing file. */
137
+ export async function writeAudioOutput(outputPath, produce, signal) {
138
+ const output = path.resolve(outputPath);
139
+ await fsp.mkdir(path.dirname(output), { recursive: true });
140
+ const staging = await fsp.mkdtemp(path.join(path.dirname(output), '.playcraft-audio-'));
141
+ try {
142
+ const temporary = path.join(staging, path.basename(output));
143
+ const result = await produce(temporary);
144
+ signal?.throwIfAborted();
145
+ await fsp.rename(temporary, output);
146
+ return result;
147
+ }
148
+ finally {
149
+ await fsp.rm(staging, { recursive: true, force: true });
150
+ }
151
+ }
152
+ function encodingRate(format, bitrate, requested, sourceRate) {
153
+ if (requested !== undefined)
154
+ return requested;
155
+ if (format === 'opus')
156
+ return 48000;
157
+ let ceiling = sourceRate;
158
+ if (format === 'mp3') {
159
+ if (bitrate < 16)
160
+ ceiling = Math.min(ceiling, 8000);
161
+ else if (bitrate < 32 || bitrate === 144)
162
+ ceiling = Math.min(ceiling, 24000);
163
+ else if (bitrate > 160)
164
+ ceiling = Math.max(ceiling, 32000);
165
+ if (bitrate > 64)
166
+ ceiling = Math.max(ceiling, 16000);
167
+ }
168
+ if (format === 'ogg' && bitrate < 48)
169
+ ceiling = Math.min(ceiling, 16000);
170
+ return [...SAMPLE_RATES].reverse().find((rate) => rate <= ceiling) ?? SAMPLE_RATES[0];
171
+ }
172
+ function encodingBitrate(format, requested, rate) {
173
+ if (format === 'mp3') {
174
+ const supported = mp3Bitrates(rate);
175
+ return [...supported].reverse().find((value) => value <= requested) ?? supported[0];
176
+ }
177
+ return Math.max(format === 'opus' ? 6 : 16, Math.floor(requested));
178
+ }
179
+ /** Export from the original source on every attempt and verify the final byte budget. */
180
+ export async function exportAudio(inputPath, outputPath, options = {}) {
181
+ try {
182
+ const format = exportFormat(outputPath, options.format);
183
+ validateOptions(format, options);
184
+ const budget = options.targetSize === undefined ? undefined : targetBytes(options.targetSize);
185
+ const signals = [options.signal, AbortSignal.timeout(120_000)].filter((signal) => !!signal);
186
+ const signal = AbortSignal.any(signals);
187
+ signal.throwIfAborted();
188
+ const source = await getAudioInfo(inputPath);
189
+ if (!Number.isFinite(source.duration) || source.duration <= 0)
190
+ invalidOptions('Input audio must have a positive duration');
191
+ const channels = options.channels ?? (source.channels === 1 ? 1 : 2);
192
+ let bitrate = options.bitrate ?? 128;
193
+ if (budget !== undefined && format !== 'wav')
194
+ bitrate = Math.min(bitrate, Math.floor((budget * 8) / source.duration / 1000));
195
+ return await writeAudioOutput(outputPath, async (temporary) => {
196
+ let previousBitrate = Infinity;
197
+ for (let attempt = 0; attempt < 8; attempt++) {
198
+ signal.throwIfAborted();
199
+ const rate = encodingRate(format, bitrate, options.sampleRate, source.sampleRate);
200
+ const actualBitrate = encodingBitrate(format, bitrate, rate);
201
+ if (format !== 'wav' && actualBitrate >= previousBitrate)
202
+ break;
203
+ previousBitrate = actualBitrate;
204
+ const args = [
205
+ '-i',
206
+ path.resolve(inputPath),
207
+ '-map',
208
+ '0:a:0',
209
+ '-vn',
210
+ '-map_metadata',
211
+ '-1',
212
+ '-c:a',
213
+ CODECS[format],
214
+ '-ar',
215
+ String(rate),
216
+ '-ac',
217
+ String(channels),
218
+ ];
219
+ if (format !== 'wav')
220
+ args.push('-b:a', `${actualBitrate}k`);
221
+ if (format === 'mp3')
222
+ args.push('-id3v2_version', '0');
223
+ if (format === 'opus')
224
+ args.push('-vbr', 'off');
225
+ args.push('-f', format === 'opus' ? 'ogg' : format, temporary);
226
+ await runAudioFfmpeg(args, signal);
227
+ const result = await getAudioInfo(temporary);
228
+ signal.throwIfAborted();
229
+ const matchingRate = result.sampleRate === rate || (format === 'opus' && result.sampleRate === 48000);
230
+ if (!matchingRate ||
231
+ result.channels !== channels ||
232
+ (format === 'mp3' && result.bitrate !== actualBitrate)) {
233
+ throw new AudioExportError('audio_export_failed', 'Encoder changed the requested sample rate, channels or bitrate');
234
+ }
235
+ if (budget === undefined || result.fileSize <= budget)
236
+ return result;
237
+ if (format === 'wav')
238
+ break;
239
+ bitrate = Math.min(actualBitrate - 1, Math.floor(((actualBitrate * budget) / result.fileSize) * 0.97));
240
+ }
241
+ throw new AudioExportError('audio_export_target_size_unreachable', `Cannot meet targetSize ${options.targetSize} with ${format} and the requested sample rate/channels; increase the budget or change export settings`);
242
+ }, signal);
243
+ }
244
+ catch (error) {
245
+ if (error instanceof AudioExportError || options.signal?.aborted)
246
+ throw error;
247
+ throw new AudioExportError('audio_export_failed', error instanceof Error ? error.message : String(error));
248
+ }
249
+ }
@@ -2,32 +2,8 @@ import path from 'path';
2
2
  import os from 'os';
3
3
  import fsp from 'fs/promises';
4
4
  import ffmpeg from 'fluent-ffmpeg';
5
- import * as musicMetadata from 'music-metadata';
6
- /**
7
- * Parse a human-readable size string like "30KB", "1.5MB" into bytes.
8
- */
9
- function parseSizeToBytes(sizeStr) {
10
- const match = sizeStr.trim().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
11
- if (!match)
12
- throw new Error(`Invalid size format: ${sizeStr}. Use e.g. "30KB", "1.5MB"`);
13
- const value = parseFloat(match[1]);
14
- const unit = (match[2] ?? 'B').toUpperCase();
15
- const multipliers = { B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024 };
16
- return Math.round(value * (multipliers[unit] ?? 1));
17
- }
18
- /**
19
- * Calculate the bitrate needed to hit a target file size for an audio file.
20
- * targetBytes: desired file size in bytes
21
- * durationSeconds: audio duration
22
- * Returns bitrate in kbps (clamped to 8-320).
23
- */
24
- function calcBitrateForSize(targetBytes, durationSeconds) {
25
- if (durationSeconds <= 0)
26
- return 64;
27
- const targetBits = targetBytes * 8;
28
- const bitrateKbps = Math.floor(targetBits / durationSeconds / 1000);
29
- return Math.max(8, Math.min(320, bitrateKbps));
30
- }
5
+ import { exportAudio, getAudioInfo, runAudioFfmpeg, writeAudioOutput } from './audio-export.js';
6
+ export { exportAudio, getAudioInfo, AudioExportError } from './audio-export.js';
31
7
  /**
32
8
  * Wrap an ffmpeg command in a Promise.
33
9
  */
@@ -39,57 +15,14 @@ function runFfmpeg(cmd) {
39
15
  .run();
40
16
  });
41
17
  }
42
- /**
43
- * Get audio file metadata (duration, format, bitrate, sample rate, channels).
44
- */
45
- export async function getAudioInfo(inputPath) {
46
- const resolvedPath = path.resolve(inputPath);
47
- let stat;
48
- try {
49
- stat = await fsp.stat(resolvedPath);
50
- }
51
- catch (err) {
52
- const code = err.code;
53
- if (code === 'ENOENT') {
54
- throw new Error(`File not found: ${resolvedPath}`);
55
- }
56
- throw err;
57
- }
58
- const meta = await musicMetadata.parseFile(resolvedPath);
59
- return {
60
- duration: meta.format.duration ?? 0,
61
- fileSize: stat.size,
62
- format: meta.format.container ?? path.extname(resolvedPath).replace('.', ''),
63
- bitrate: Math.round((meta.format.bitrate ?? 0) / 1000),
64
- sampleRate: meta.format.sampleRate ?? 44100,
65
- channels: meta.format.numberOfChannels ?? 2,
66
- };
67
- }
68
18
  /**
69
19
  * Compress audio file. Supports manual parameters or --target-size auto mode.
70
20
  */
71
21
  export async function compressAudio(inputPath, outputPath, options) {
72
- const resolvedInput = path.resolve(inputPath);
73
- const resolvedOutput = path.resolve(outputPath);
74
- await ensureOutputDir(resolvedOutput);
75
- let bitrate = options.bitrate ?? 128;
76
- const sampleRate = options.sampleRate;
77
- const mono = options.mono ?? false;
78
- if (options.targetSize) {
79
- const targetBytes = parseSizeToBytes(options.targetSize);
80
- const info = await getAudioInfo(resolvedInput);
81
- bitrate = calcBitrateForSize(targetBytes, info.duration);
82
- console.log(`Auto bitrate for ${options.targetSize}: ${bitrate}kbps (duration=${info.duration.toFixed(1)}s)`);
83
- }
84
- const cmd = ffmpeg(resolvedInput).audioBitrate(bitrate);
85
- if (sampleRate) {
86
- cmd.audioFrequency(sampleRate);
87
- }
88
- if (mono) {
89
- cmd.audioChannels(1);
22
+ if (options.mono && options.channels !== undefined && options.channels !== 1) {
23
+ throw new Error('mono cannot be combined with channels=2');
90
24
  }
91
- cmd.output(resolvedOutput);
92
- await runFfmpeg(cmd);
25
+ await exportAudio(inputPath, outputPath, { ...options, channels: options.mono ? 1 : options.channels });
93
26
  }
94
27
  /**
95
28
  * Trim audio to a specific time range.
@@ -183,7 +116,8 @@ export async function normalizeAudio(inputPath, outputPath, targetLoudness) {
183
116
  }
184
117
  /**
185
118
  * Create a seamless loop by crossfading the beginning and end of the audio.
186
- * Strategy: mix the faded-out tail over the faded-in beginning so the loop point is seamless.
119
+ * Crossfade the tail into the head, then append it after the unchanged middle.
120
+ * The output lasts one crossfade less than the input and is encoded only once.
187
121
  */
188
122
  export async function loopAudio(inputPath, outputPath, crossfadeDuration) {
189
123
  const resolvedInput = path.resolve(inputPath);
@@ -191,37 +125,26 @@ export async function loopAudio(inputPath, outputPath, crossfadeDuration) {
191
125
  await ensureOutputDir(resolvedOutput);
192
126
  const info = await getAudioInfo(resolvedInput);
193
127
  const duration = info.duration;
194
- if (crossfadeDuration <= 0) {
128
+ if (!Number.isFinite(crossfadeDuration) || crossfadeDuration <= 0) {
195
129
  throw new Error('crossfade must be greater than 0');
196
130
  }
197
131
  if (crossfadeDuration * 2 >= duration) {
198
132
  throw new Error(`crossfade (${crossfadeDuration}s) is too long for audio duration (${duration.toFixed(1)}s)`);
199
133
  }
200
- const ts = Date.now();
201
- const tempMain = path.join(os.tmpdir(), `playcraft_loop_main_${ts}.mp3`);
202
- const tempTail = path.join(os.tmpdir(), `playcraft_loop_tail_${ts}.mp3`);
203
- const tempTailFaded = path.join(os.tmpdir(), `playcraft_loop_tailf_${ts}.mp3`);
204
- try {
205
- // Main: original without the last crossfade section, fade-out at end
206
- await trimAudio(resolvedInput, tempMain, 0, duration - crossfadeDuration);
207
- await fadeAudio(tempMain, tempMain, { fadeOut: crossfadeDuration });
208
- // Tail: last crossfade seconds, fade-in
209
- await trimAudio(resolvedInput, tempTail, duration - crossfadeDuration, duration);
210
- await fadeAudio(tempTail, tempTailFaded, { fadeIn: crossfadeDuration });
211
- // Mix: overlay tail (starts at t=0) on main beginning, output duration = main duration
212
- const mixCmd = ffmpeg()
213
- .input(tempMain)
214
- .input(tempTailFaded)
215
- .complexFilter([
216
- `[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=0[out]`,
217
- ])
218
- .outputOptions(['-map', '[out]'])
219
- .output(resolvedOutput);
220
- await runFfmpeg(mixCmd);
221
- }
222
- finally {
223
- await Promise.all([tempMain, tempTail, tempTailFaded].map((tmp) => fsp.rm(tmp, { force: true }).catch(() => { })));
224
- }
134
+ // The seam is built from two fades mixed with `amix` instead of `acrossfade`: acrossfade where
135
+ // both inputs are exactly the crossfade length is a degenerate case older FFmpeg releases
136
+ // resolve to an empty stream, which silently shortened the loop by the whole crossfade.
137
+ const graph = [
138
+ '[0:a:0]asplit=3[headsrc][midsrc][tailsrc]',
139
+ `[headsrc]atrim=start=0:end=${crossfadeDuration},asetpts=PTS-STARTPTS,afade=t=in:st=0:d=${crossfadeDuration}[head]`,
140
+ `[midsrc]atrim=start=${crossfadeDuration}:end=${duration - crossfadeDuration},asetpts=PTS-STARTPTS[mid]`,
141
+ `[tailsrc]atrim=start=${duration - crossfadeDuration}:end=${duration},asetpts=PTS-STARTPTS,afade=t=out:st=0:d=${crossfadeDuration}[tail]`,
142
+ '[tail][head]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[blend]',
143
+ '[mid][blend]concat=n=2:v=0:a=1[out]',
144
+ ].join(';');
145
+ await writeAudioOutput(resolvedOutput, temporary => runAudioFfmpeg([
146
+ '-i', resolvedInput, '-filter_complex', graph, '-map', '[out]', temporary,
147
+ ]));
225
148
  }
226
149
  /**
227
150
  * Mix multiple audio tracks together with optional volume and time offset per track.
@@ -0,0 +1,28 @@
1
+ import { workspaceExecutionContextReadSchema } from '@playcraft/common/cloud-workspace/runtime';
2
+ /** A per-session broker capability supplies shell scope; its identity is never decoded in the command. */
3
+ export async function readExecutionContextFromBridge(request, env = process.env) {
4
+ const endpoint = env.PLAYCRAFT_AGENT_GENERATION_ENDPOINT;
5
+ const capability = env.PLAYCRAFT_AGENT_GENERATION_CAPABILITY;
6
+ if (!endpoint && !capability)
7
+ return undefined;
8
+ if (!endpoint || endpoint.length > 1024 || !capability || !/^[A-Za-z0-9_-]{16,128}$/.test(capability))
9
+ throw new Error('execution_context_source_unavailable');
10
+ let url;
11
+ try {
12
+ url = new URL(endpoint);
13
+ }
14
+ catch {
15
+ throw new Error('execution_context_source_unavailable');
16
+ }
17
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password
18
+ || url.search || url.hash || !/^\/[0-9a-f-]{36}\/[0-9a-f]{32}\/generation-provenance$/.test(url.pathname))
19
+ throw new Error('execution_context_source_unavailable');
20
+ url.pathname = url.pathname.replace(/generation-provenance$/, 'execution-context');
21
+ const response = await fetch(url, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
22
+ headers: { authorization: `Bearer ${capability}`, 'content-type': 'application/json' }, body: JSON.stringify(request) });
23
+ if (!response.ok) {
24
+ await response.body?.cancel();
25
+ throw new Error(response.status === 403 ? 'execution_context_access_denied' : 'execution_context_unavailable');
26
+ }
27
+ return workspaceExecutionContextReadSchema.parse(await response.json());
28
+ }
@@ -1,7 +1,7 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { sandboxBatchMemberStopSchema } from '@playcraft/common/cloud-workspace/runtime';
3
3
  import { processHasExited, stopProcessGroup } from './processes.js';
4
- import { settleObservedGenerate } from './generate.js';
4
+ import { reconcileFailedCreate, settleObservedGenerate } from './generate.js';
5
5
  import { memberStatus, readMember, serializeRun, writeMember, writeStopMarker } from './records.js';
6
6
  async function prove(record) {
7
7
  let stopped = true;
@@ -24,9 +24,20 @@ function sessionBusy(state, record) {
24
24
  }
25
25
  /** Read-only besides reconciling process proof. Does not start work or advance a phase. */
26
26
  export async function readBatchMemberStatus(state, runId) {
27
- const record = await readMember(state, runId);
27
+ let record = await readMember(state, runId);
28
28
  if (!record)
29
29
  return undefined;
30
+ if (record.phase === 'generate' && record.state === 'stopping' && !record.stopRequested) {
31
+ const settled = await serializeRun(runId, async () => {
32
+ const latest = await readMember(state, runId);
33
+ return latest ? reconcileFailedCreate(state, latest) : undefined;
34
+ });
35
+ if (!settled)
36
+ return undefined;
37
+ record = settled;
38
+ if (record.state === 'failed')
39
+ return memberStatus(record);
40
+ }
30
41
  if (record.phase === 'generate' && record.state === 'running') {
31
42
  const settled = await settleObservedGenerate(state, record);
32
43
  if (settled.state !== 'running')
@@ -26,6 +26,18 @@ function operation(state, input) {
26
26
  };
27
27
  return { scopeKey: operationScopeKey(record), record };
28
28
  }
29
+ function failedCreateWithoutNativeWork(state, record, created) {
30
+ if (!record.sessionId || state.journal.getSession(record.sessionId) || created.status !== 'failed' ||
31
+ created.nativeRequestId || created.nativeThreadId || created.nativeTurnId)
32
+ return false;
33
+ return state.journal.listOperations().every(({ operation: item }) => {
34
+ if (item.sessionId !== record.sessionId || !['session.create', 'session.turn'].includes(item.kind) ||
35
+ item.operationId === created.operationId)
36
+ return true;
37
+ return item.status === 'failed' && !item.nativeRequestId && !item.nativeThreadId && !item.nativeTurnId &&
38
+ (item.kind !== 'session.turn' || item.error === 'unknown_session');
39
+ });
40
+ }
29
41
  function turnTerminal(event) {
30
42
  if (event.kind === 'approval_request')
31
43
  return 'needs_user';
@@ -124,8 +136,21 @@ export async function generateBatchMember(state, body) {
124
136
  try {
125
137
  if (!state.journal.getSession(sessionId)) {
126
138
  const created = operation(state, { kind: 'session.create', clientRequestId: `batch-create:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) } });
127
- if (!state.journal.getOperation(created.scopeKey))
128
- await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) });
139
+ const result = state.journal.getOperation(created.scopeKey) ?? await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) });
140
+ if (failedCreateWithoutNativeWork(state, record, result)) {
141
+ // A failed creation with no native receipt cannot have started a turn. Still prove all other writers stopped.
142
+ const writersStopped = await stopMemberWriters(state, { ...record, sessionId: undefined });
143
+ if (!failedCreateWithoutNativeWork(state, record, result)) {
144
+ return memberStatus(await writeMember(state, { ...record, state: 'unknown', writersStopped: false,
145
+ holding: true, operationId: result.operationId, error: 'native_receipt_lost' }));
146
+ }
147
+ return memberStatus(await writeMember(state, { ...record, state: writersStopped ? 'failed' : 'stopping',
148
+ writersStopped, holding: !writersStopped, operationId: result.operationId, error: result.error ?? 'batch_session_create_failed' }));
149
+ }
150
+ if (result.status !== 'completed' || !state.journal.getSession(sessionId)) {
151
+ return memberStatus(await writeMember(state, { ...record, state: 'unknown', writersStopped: false,
152
+ holding: true, operationId: result.operationId, error: 'native_receipt_lost' }));
153
+ }
129
154
  }
130
155
  if (await readStopMarker(state, command.runId))
131
156
  return memberStatus(await stoppedRecord(state, command, record));
@@ -226,6 +251,22 @@ async function reconcileGenerate(state, record) {
226
251
  }
227
252
  return record;
228
253
  }
254
+ /** Recover failures recorded by older runtimes which tried a turn after creation had failed. */
255
+ export async function reconcileFailedCreate(state, record) {
256
+ if (record.phase !== 'generate' || record.state !== 'stopping' || record.stopRequested || !record.sessionId ||
257
+ state.journal.getSession(record.sessionId))
258
+ return record;
259
+ const operations = state.journal.listOperations().map(item => item.operation).filter(item => item.workspaceId === record.workspaceId && item.directoryName === record.directoryName && item.sessionId === record.sessionId);
260
+ const created = operations.find(item => item.kind === 'session.create' && item.clientRequestId === `batch-create:${record.runId}`);
261
+ if (!created || (record.error !== 'unknown_session' && record.error !== created.error) ||
262
+ !failedCreateWithoutNativeWork(state, record, created))
263
+ return record;
264
+ const writersStopped = await stopMemberWriters(state, { ...record, sessionId: undefined });
265
+ if (!writersStopped || !failedCreateWithoutNativeWork(state, record, created))
266
+ return record;
267
+ return writeMember(state, { ...record, state: 'failed', writersStopped: true, holding: false,
268
+ operationId: created.operationId, error: created.error ?? 'batch_session_create_failed' });
269
+ }
229
270
  async function completeGeneration(state, record, root) {
230
271
  try {
231
272
  const afterTree = await snapshotWorkspaceTree(root);
@@ -13,6 +13,7 @@ import { restoreArchive } from './source-capture.js';
13
13
  import { processHasExited, runProcessGroup } from './processes.js';
14
14
  import { createInstallLog } from './install-log.js';
15
15
  import { stopMemberWriters } from './writer-proof.js';
16
+ import { restoreBatchSkillArtifacts } from './skill-artifacts.js';
16
17
  import { filesystemObjectStore, memberStatus, readMember, readSnapshot, readStopMarker, serializeRun, sourceCacheDir, writeMember, } from './records.js';
17
18
  const BRANCH = /^remix\/draft-[a-f0-9]{16}$/;
18
19
  function hashCommand(value) {
@@ -189,9 +190,12 @@ export async function prepareBatchMember(state, body, options) {
189
190
  await materialize(state, snapshot, staging, options?.objectStore ?? filesystemObjectStore(state), sourceRoot);
190
191
  const commit = command.resumeFromCommit ?? snapshot.commitHash;
191
192
  await checkoutBranch(staging, command.branchName, commit, repositoryUrl);
193
+ await restoreBatchSkillArtifacts(sourceRoot, staging);
192
194
  const { rename } = await import('node:fs/promises');
193
195
  await rename(staging, root);
194
196
  }
197
+ else
198
+ await restoreBatchSkillArtifacts(sourceRoot, root);
195
199
  await registerWorkspaceDirectory(state, {
196
200
  workspaceId: command.workspaceId,
197
201
  directoryName: command.directoryName,
@@ -0,0 +1,103 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, realpath } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { effectiveProjectSkills, extractBundleArchive, readProjectSkillsLockFile, sourceTreeDigest } from '@playcraft/common/project-skills';
6
+ import { excludeFromGit } from '../workspaces/dependencies.js';
7
+ import { ensureSharedDirectory, writeSharedFile } from '../workspaces/shared-write.js';
8
+ import { readWorkspaceSkillSnapshot } from '../workspaces/skills.js';
9
+ /** Copy only artifacts selected by the member's frozen lock; missing files retain the platform fallback. */
10
+ export async function restoreBatchSkillArtifacts(source, member) {
11
+ if (await readProjectSkillsLockFile(member) === null)
12
+ return;
13
+ const snapshot = await readWorkspaceSkillSnapshot(member);
14
+ const entries = effectiveProjectSkills(snapshot.declaration, snapshot.lock, 'sandbox-agent');
15
+ const sourceRoot = await realpath(source).catch((error) => {
16
+ if (error.code === 'ENOENT')
17
+ return undefined;
18
+ throw error;
19
+ });
20
+ if (!sourceRoot)
21
+ return;
22
+ const directories = [];
23
+ try {
24
+ let directory = sourceRoot;
25
+ for (const part of ['', '.playcraft', 'skill-artifacts']) {
26
+ if (part)
27
+ directory = join(directory, part);
28
+ const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW).catch((error) => {
29
+ if (error.code === 'ENOENT')
30
+ return undefined;
31
+ throw error;
32
+ });
33
+ if (!handle)
34
+ return;
35
+ directories.push(handle);
36
+ // Pin the directory inode on Linux so concurrent changes cannot redirect the read.
37
+ if (process.platform === 'linux')
38
+ directory = `/proc/self/fd/${handle.fd}`;
39
+ }
40
+ for (const entry of entries) {
41
+ const hash = entry.revision.artifactHash;
42
+ let name = `${hash}.tar.gz`;
43
+ const openArtifact = (filename) => open(join(directory, filename), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch((error) => {
44
+ if (error.code === 'ENOENT')
45
+ return undefined;
46
+ throw error;
47
+ });
48
+ let handle = await openArtifact(name);
49
+ if (!handle && snapshot.lock.lockVersion === 4) {
50
+ name = `content-${entry.revision.contentHash}.tar.gz`;
51
+ handle = await openArtifact(name);
52
+ }
53
+ if (!handle)
54
+ continue;
55
+ let body;
56
+ try {
57
+ const stat = await handle.stat({ bigint: true });
58
+ if (!stat.isFile() || stat.nlink !== 1n || stat.size > 80n * 1024n * 1024n)
59
+ throw new Error('batch_snapshot_skill_artifact_invalid');
60
+ body = Buffer.alloc(Number(stat.size));
61
+ let offset = 0;
62
+ while (offset < body.length) {
63
+ const { bytesRead } = await handle.read(body, offset, body.length - offset, offset);
64
+ if (!bytesRead)
65
+ throw new Error('batch_snapshot_skill_artifact_invalid');
66
+ offset += bytesRead;
67
+ }
68
+ const probe = Buffer.alloc(1);
69
+ const extra = await handle.read(probe, 0, 1, offset);
70
+ const after = await handle.stat({ bigint: true });
71
+ if (extra.bytesRead || stat.dev !== after.dev || stat.ino !== after.ino || stat.size !== after.size ||
72
+ stat.mtimeNs !== after.mtimeNs || stat.ctimeNs !== after.ctimeNs)
73
+ throw new Error('batch_snapshot_skill_artifact_invalid');
74
+ if (name.startsWith('content-')) {
75
+ const extracted = extractBundleArchive(body);
76
+ if (extracted.issues.length || sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) !== entry.revision.contentHash)
77
+ throw new Error('batch_snapshot_skill_artifact_invalid');
78
+ }
79
+ else if (createHash('sha256').update(body).digest('hex') !== hash)
80
+ throw new Error('batch_snapshot_skill_artifact_invalid');
81
+ }
82
+ finally {
83
+ await handle.close();
84
+ }
85
+ const targetDir = join(member, '.playcraft', 'skill-artifacts');
86
+ const target = join(targetDir, name);
87
+ const existing = await lstat(target).catch((error) => {
88
+ if (error.code === 'ENOENT')
89
+ return undefined;
90
+ throw error;
91
+ });
92
+ if (existing)
93
+ continue; // The normal skill loader validates existing files before use.
94
+ await excludeFromGit(member, '/.playcraft/skill-artifacts/');
95
+ await ensureSharedDirectory(member, targetDir);
96
+ await writeSharedFile(member, target, body);
97
+ }
98
+ }
99
+ finally {
100
+ for (const handle of directories.reverse())
101
+ await handle.close();
102
+ }
103
+ }