@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,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
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
+
}
|