@playcraft/cli 0.0.18 → 0.0.21

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.
@@ -0,0 +1,125 @@
1
+ import { build as viteBuild } from 'vite';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { ViteConfigBuilder } from './vite/config-builder.js';
5
+ import { PLATFORM_CONFIGS } from './vite/platform-configs.js';
6
+ /**
7
+ * Vite 构建器 - 使用 Vite 构建 Playable Ads
8
+ *
9
+ * 职责:
10
+ * 1. 验证输入是有效的基础构建
11
+ * 2. 创建 Vite 配置
12
+ * 3. 执行 Vite 构建
13
+ * 4. 验证输出大小
14
+ * 5. 生成报告
15
+ */
16
+ export class ViteBuilder {
17
+ baseBuildDir;
18
+ options;
19
+ sizeReport;
20
+ constructor(baseBuildDir, options) {
21
+ this.baseBuildDir = baseBuildDir;
22
+ this.options = options;
23
+ const platformConfig = PLATFORM_CONFIGS[options.platform];
24
+ this.sizeReport = {
25
+ engine: 0,
26
+ assets: {},
27
+ total: 0,
28
+ limit: platformConfig.sizeLimit,
29
+ };
30
+ }
31
+ /**
32
+ * 执行构建
33
+ */
34
+ async build() {
35
+ // 1. 验证输入
36
+ await this.validateBaseBuild();
37
+ // 2. 创建 Vite 配置
38
+ const configBuilder = new ViteConfigBuilder(this.baseBuildDir, this.options.platform, this.options);
39
+ const viteConfig = configBuilder.create();
40
+ // 3. 执行 Vite 构建
41
+ await viteBuild(viteConfig);
42
+ // 4. 验证输出大小
43
+ const outputPath = this.getOutputPath();
44
+ await this.validateSize(outputPath);
45
+ // 5. 生成报告
46
+ this.generateReport(outputPath);
47
+ return outputPath;
48
+ }
49
+ /**
50
+ * 验证基础构建
51
+ */
52
+ async validateBaseBuild() {
53
+ const requiredFiles = [
54
+ 'index.html',
55
+ 'config.json',
56
+ '__start__.js',
57
+ ];
58
+ const missingFiles = [];
59
+ for (const file of requiredFiles) {
60
+ try {
61
+ await fs.access(path.join(this.baseBuildDir, file));
62
+ }
63
+ catch (error) {
64
+ missingFiles.push(file);
65
+ }
66
+ }
67
+ if (missingFiles.length > 0) {
68
+ throw new Error(`基础构建产物缺少必需文件: ${missingFiles.join(', ')}\n` +
69
+ `请确保输入目录包含完整的多文件构建产物。`);
70
+ }
71
+ }
72
+ /**
73
+ * 获取输出路径
74
+ */
75
+ getOutputPath() {
76
+ const platformConfig = PLATFORM_CONFIGS[this.options.platform];
77
+ const outputDir = this.options.outputDir || './dist';
78
+ if (platformConfig.outputFormat === 'zip') {
79
+ return path.join(outputDir, 'playable.zip');
80
+ }
81
+ return path.join(outputDir, platformConfig.outputFileName);
82
+ }
83
+ /**
84
+ * 验证输出大小
85
+ */
86
+ async validateSize(outputPath) {
87
+ try {
88
+ const stats = await fs.stat(outputPath);
89
+ this.sizeReport.total = stats.size;
90
+ this.sizeReport.assets[path.basename(outputPath)] = stats.size;
91
+ if (stats.size > this.sizeReport.limit) {
92
+ const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
93
+ const limitMB = (this.sizeReport.limit / 1024 / 1024).toFixed(2);
94
+ console.warn(`⚠️ 警告: 文件大小 ${sizeMB} MB 超过限制 ${limitMB} MB`);
95
+ }
96
+ }
97
+ catch (error) {
98
+ console.warn(`警告: 无法读取输出文件: ${outputPath}`);
99
+ }
100
+ }
101
+ /**
102
+ * 生成报告
103
+ */
104
+ generateReport(outputPath) {
105
+ // 报告已在 validateSize 中生成
106
+ // 这里可以添加额外的报告逻辑
107
+ }
108
+ /**
109
+ * 获取大小报告
110
+ */
111
+ getSizeReport() {
112
+ return this.sizeReport;
113
+ }
114
+ /**
115
+ * 格式化字节数
116
+ */
117
+ formatBytes(bytes) {
118
+ if (bytes === 0)
119
+ return '0 Bytes';
120
+ const k = 1024;
121
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
122
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
123
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
124
+ }
125
+ }
@@ -0,0 +1,82 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ /**
5
+ * Lightweight HTTP client for calling PlayCraft backend APIs from inside a sandbox.
6
+ * Reads credentials from environment variables or .playcraft.json.
7
+ */
8
+ export class AgentApiClient {
9
+ apiUrl;
10
+ token;
11
+ constructor() {
12
+ let apiUrl = process.env.PLAYCRAFT_API_URL || '';
13
+ let token = process.env.PLAYCRAFT_SANDBOX_TOKEN || '';
14
+ if (!apiUrl || !token) {
15
+ const config = AgentApiClient.loadConfig();
16
+ apiUrl = apiUrl || config.apiUrl;
17
+ token = token || config.token;
18
+ }
19
+ if (!apiUrl || !token) {
20
+ throw new Error('Missing PlayCraft API credentials. Set PLAYCRAFT_API_URL and PLAYCRAFT_SANDBOX_TOKEN, ' +
21
+ 'or create a .playcraft.json file.');
22
+ }
23
+ this.apiUrl = apiUrl.replace(/\/+$/, '');
24
+ this.token = token;
25
+ }
26
+ /** Absolute pathname under /api/agent/tools (path may omit leading slash). */
27
+ toolsRequestUrl(path) {
28
+ const p = path.startsWith('/') ? path : `/${path}`;
29
+ return new URL(`/api/agent/tools${p}`, this.apiUrl);
30
+ }
31
+ async post(path, body) {
32
+ const url = this.toolsRequestUrl(path).href;
33
+ const res = await fetch(url, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ 'X-Sandbox-Token': this.token,
38
+ },
39
+ body: JSON.stringify(body),
40
+ });
41
+ if (!res.ok) {
42
+ const text = await res.text().catch(() => res.statusText);
43
+ throw new Error(`API ${res.status}: ${text}`);
44
+ }
45
+ return res.json();
46
+ }
47
+ async get(path, params) {
48
+ const url = this.toolsRequestUrl(path);
49
+ if (params) {
50
+ for (const [k, v] of Object.entries(params)) {
51
+ if (v !== undefined && v !== '')
52
+ url.searchParams.set(k, v);
53
+ }
54
+ }
55
+ const res = await fetch(url, {
56
+ headers: { 'X-Sandbox-Token': this.token },
57
+ });
58
+ if (!res.ok) {
59
+ const text = await res.text().catch(() => res.statusText);
60
+ throw new Error(`API ${res.status}: ${text}`);
61
+ }
62
+ return res.json();
63
+ }
64
+ static loadConfig() {
65
+ const searchPaths = [
66
+ join(process.cwd(), '.playcraft.json'),
67
+ '/project/.playcraft.json',
68
+ join(homedir(), '.playcraft.json'),
69
+ ];
70
+ for (const p of searchPaths) {
71
+ if (existsSync(p)) {
72
+ try {
73
+ return JSON.parse(readFileSync(p, 'utf-8'));
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ }
79
+ }
80
+ return { apiUrl: '', token: '' };
81
+ }
82
+ }
@@ -0,0 +1,269 @@
1
+ import path from 'path';
2
+ import os from 'os';
3
+ import fsp from 'fs/promises';
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
+ }
31
+ /**
32
+ * Wrap an ffmpeg command in a Promise.
33
+ */
34
+ function runFfmpeg(cmd) {
35
+ return new Promise((resolve, reject) => {
36
+ cmd
37
+ .on('end', () => resolve())
38
+ .on('error', (err) => reject(err))
39
+ .run();
40
+ });
41
+ }
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
+ /**
69
+ * Compress audio file. Supports manual parameters or --target-size auto mode.
70
+ */
71
+ 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);
90
+ }
91
+ cmd.output(resolvedOutput);
92
+ await runFfmpeg(cmd);
93
+ }
94
+ /**
95
+ * Trim audio to a specific time range.
96
+ */
97
+ export async function trimAudio(inputPath, outputPath, start, end) {
98
+ const resolvedInput = path.resolve(inputPath);
99
+ const resolvedOutput = path.resolve(outputPath);
100
+ await ensureOutputDir(resolvedOutput);
101
+ const duration = end - start;
102
+ if (duration <= 0)
103
+ throw new Error(`end must be greater than start (start=${start}, end=${end})`);
104
+ const cmd = ffmpeg(resolvedInput)
105
+ .setStartTime(start)
106
+ .setDuration(duration)
107
+ .output(resolvedOutput);
108
+ await runFfmpeg(cmd);
109
+ }
110
+ /**
111
+ * Convert audio format (e.g. WAV → MP3).
112
+ */
113
+ export async function convertAudio(inputPath, outputPath, bitrate) {
114
+ const resolvedInput = path.resolve(inputPath);
115
+ const resolvedOutput = path.resolve(outputPath);
116
+ await ensureOutputDir(resolvedOutput);
117
+ const cmd = ffmpeg(resolvedInput);
118
+ if (bitrate) {
119
+ cmd.audioBitrate(bitrate);
120
+ }
121
+ cmd.output(resolvedOutput);
122
+ await runFfmpeg(cmd);
123
+ }
124
+ /**
125
+ * Add fade-in and/or fade-out effects.
126
+ */
127
+ export async function fadeAudio(inputPath, outputPath, options) {
128
+ const resolvedInput = path.resolve(inputPath);
129
+ const resolvedOutput = path.resolve(outputPath);
130
+ await ensureOutputDir(resolvedOutput);
131
+ const info = await getAudioInfo(resolvedInput);
132
+ const duration = info.duration;
133
+ const filters = [];
134
+ if (options.fadeIn && options.fadeIn > 0) {
135
+ filters.push(`afade=t=in:st=0:d=${options.fadeIn}`);
136
+ }
137
+ if (options.fadeOut && options.fadeOut > 0) {
138
+ const fadeOutStart = Math.max(0, duration - options.fadeOut);
139
+ filters.push(`afade=t=out:st=${fadeOutStart}:d=${options.fadeOut}`);
140
+ }
141
+ if (!filters.length) {
142
+ throw new Error('At least one of --fade-in or --fade-out must be specified');
143
+ }
144
+ const cmd = ffmpeg(resolvedInput)
145
+ .audioFilters(filters)
146
+ .output(resolvedOutput);
147
+ await runFfmpeg(cmd);
148
+ }
149
+ /**
150
+ * Concatenate multiple audio files into one.
151
+ */
152
+ export async function concatAudio(options) {
153
+ const resolvedOutputPath = path.resolve(options.output);
154
+ await ensureOutputDir(resolvedOutputPath);
155
+ const tempListFile = path.join(os.tmpdir(), `playcraft_concat_${Date.now()}.txt`);
156
+ const lines = options.inputs
157
+ .map((p) => `file '${path.resolve(p).replace(/'/g, "'\\''")}'`)
158
+ .join('\n');
159
+ await fsp.writeFile(tempListFile, lines, 'utf-8');
160
+ try {
161
+ const cmd = ffmpeg()
162
+ .input(tempListFile)
163
+ .inputOptions(['-f', 'concat', '-safe', '0'])
164
+ .audioCodec('libmp3lame')
165
+ .output(resolvedOutputPath);
166
+ await runFfmpeg(cmd);
167
+ }
168
+ finally {
169
+ await fsp.rm(tempListFile, { force: true }).catch(() => { });
170
+ }
171
+ }
172
+ /**
173
+ * Normalize audio loudness to a target LUFS level.
174
+ */
175
+ export async function normalizeAudio(inputPath, outputPath, targetLoudness) {
176
+ const resolvedInput = path.resolve(inputPath);
177
+ const resolvedOutput = path.resolve(outputPath);
178
+ await ensureOutputDir(resolvedOutput);
179
+ const cmd = ffmpeg(resolvedInput)
180
+ .audioFilters(`loudnorm=I=${targetLoudness}:LRA=11:TP=-1.5`)
181
+ .output(resolvedOutput);
182
+ await runFfmpeg(cmd);
183
+ }
184
+ /**
185
+ * 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.
187
+ */
188
+ export async function loopAudio(inputPath, outputPath, crossfadeDuration) {
189
+ const resolvedInput = path.resolve(inputPath);
190
+ const resolvedOutput = path.resolve(outputPath);
191
+ await ensureOutputDir(resolvedOutput);
192
+ const info = await getAudioInfo(resolvedInput);
193
+ const duration = info.duration;
194
+ if (crossfadeDuration <= 0) {
195
+ throw new Error('crossfade must be greater than 0');
196
+ }
197
+ if (crossfadeDuration * 2 >= duration) {
198
+ throw new Error(`crossfade (${crossfadeDuration}s) is too long for audio duration (${duration.toFixed(1)}s)`);
199
+ }
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
+ }
225
+ }
226
+ /**
227
+ * Mix multiple audio tracks together with optional volume and time offset per track.
228
+ */
229
+ export async function mixAudio(tracks, outputPath) {
230
+ if (tracks.length < 2) {
231
+ throw new Error('At least 2 input tracks are required for mixing');
232
+ }
233
+ const resolvedOutputPath = path.resolve(outputPath);
234
+ await ensureOutputDir(resolvedOutputPath);
235
+ const cmd = ffmpeg();
236
+ for (const track of tracks) {
237
+ cmd.input(path.resolve(track.input));
238
+ }
239
+ const filterParts = [];
240
+ const mixInputs = [];
241
+ for (let i = 0; i < tracks.length; i++) {
242
+ const track = tracks[i];
243
+ const volume = track.volume ?? 1.0;
244
+ const offset = track.offset ?? 0;
245
+ let label = `[${i}:a]`;
246
+ if (offset > 0) {
247
+ const delayedLabel = `[a${i}_delayed]`;
248
+ filterParts.push(`${label}adelay=${Math.round(offset * 1000)}|${Math.round(offset * 1000)}${delayedLabel}`);
249
+ label = delayedLabel;
250
+ }
251
+ if (volume !== 1.0) {
252
+ const volLabel = `[a${i}_vol]`;
253
+ filterParts.push(`${label}volume=${volume}${volLabel}`);
254
+ label = volLabel;
255
+ }
256
+ mixInputs.push(label);
257
+ }
258
+ filterParts.push(`${mixInputs.join('')}amix=inputs=${tracks.length}:duration=longest:dropout_transition=0[out]`);
259
+ // FFmpeg filtergraph: separate chains with `;` (do not rely on fluent-ffmpeg array joining).
260
+ cmd
261
+ .complexFilter(filterParts.join(';'))
262
+ .outputOptions(['-map', '[out]'])
263
+ .output(resolvedOutputPath);
264
+ await runFfmpeg(cmd);
265
+ }
266
+ async function ensureOutputDir(filePath) {
267
+ const dir = path.dirname(filePath);
268
+ await fsp.mkdir(dir, { recursive: true });
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.0.18",
3
+ "version": "0.0.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,13 +17,13 @@
17
17
  "build": "tsc",
18
18
  "start": "node dist/index.js",
19
19
  "test": "vitest run",
20
- "link": "pnpm build && npm link",
20
+ "link": "node scripts/bump-local.js && pnpm build && npm link",
21
21
  "unlink": "npm unlink -g @playcraft/cli",
22
22
  "release": "node scripts/release.js"
23
23
  },
24
24
  "dependencies": {
25
- "@playcraft/common": "^0.0.8",
26
- "@playcraft/build": "^0.0.15",
25
+ "@playcraft/build": "^0.0.19",
26
+ "@playcraft/common": "^0.0.10",
27
27
  "chokidar": "^4.0.3",
28
28
  "commander": "^13.1.0",
29
29
  "cors": "^2.8.6",
@@ -32,10 +32,13 @@
32
32
  "drizzle-orm": "^0.45.1",
33
33
  "execa": "^9.6.1",
34
34
  "express": "^5.2.1",
35
+ "fluent-ffmpeg": "^2.1.3",
35
36
  "inquirer": "^9.3.8",
36
37
  "latest-version": "^7.0.0",
38
+ "music-metadata": "^11.12.3",
37
39
  "ora": "^8.2.0",
38
40
  "picocolors": "^1.1.1",
41
+ "sharp": "^0.34.5",
39
42
  "update-notifier": "^7.3.1",
40
43
  "ws": "^8.19.0",
41
44
  "zod": "^3.25.76"
@@ -43,8 +46,10 @@
43
46
  "devDependencies": {
44
47
  "@types/cors": "^2.8.19",
45
48
  "@types/express": "^5.0.6",
49
+ "@types/fluent-ffmpeg": "^2.1.28",
46
50
  "@types/inquirer": "^9.0.9",
47
51
  "@types/node": "^22.19.8",
52
+ "@types/sharp": "^0.32.0",
48
53
  "@types/update-notifier": "^6.0.8",
49
54
  "@types/ws": "^8.18.1",
50
55
  "typescript": "^5.9.3",