makaron-cli 0.11.3 → 0.11.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makaron-cli",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "AI image editing, video generation, music creation, and marketplace skill workflows via CLI. Agents can self-register, install skills, create projects, and produce creative media.",
5
5
  "author": {
6
6
  "name": "Makaron AI",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makaron-cli",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "AI image editing, video generation, music creation, and marketplace skill workflows via CLI. Agents can self-register, install skills, create projects, and produce creative media.",
5
5
  "displayName": "Makaron",
6
6
  "shortDescription": "AI image/video/music creation from the terminal",
package/README.md CHANGED
@@ -107,6 +107,7 @@ Returns immediately:
107
107
  | Fix one moment in a video from a screenshot | `npx makaron-cli chat --project <id> --image screenshot.png "@4 this frame should be Paris; only fix this moment"` |
108
108
  | Cut or assemble video | `npx makaron-cli chat --project <id> --video clip.mp4 "cut out the dead air and keep the best 20 seconds"` |
109
109
  | Add music | `npx makaron-cli chat --project <id> "add calm piano background music"` |
110
+ | Beat-sync video from audio | `npx makaron-cli chat --project auto --audio beat.mp3 --video-model seedance-fast --video-resolution 480p "make a beat-synced video"` |
110
111
  | Create motion design | `npx makaron-cli chat --project <id> "make an animated Instagram story with this image"` |
111
112
 
112
113
  ### Marketplace skills
@@ -159,6 +160,20 @@ Video files are uploaded via signed URL. CLI local video uploads support `.mp4`,
159
160
  The agent understands video content natively — it can analyze scenes, edit, extend, and compose videos. Seedance video-reference editing is still limited to ~15s provider references, so longer uploaded videos should be split/prepared by the agent before model submission; Kling remains the base/direct edit path.
160
161
  Use `chat --project <id|auto> --video ...` for any project/timeline video work. Direct `video create` is standalone and does not write timeline entries.
161
162
 
163
+ ### With reference audio (MP3/WAV)
164
+
165
+ Attach a short song, beat, or voice recording when the video should follow audio pacing:
166
+
167
+ ```bash
168
+ npx makaron-cli chat --project auto \
169
+ --audio beat.mp3 \
170
+ --video-model seedance-fast \
171
+ --video-resolution 480p \
172
+ -b "make a 15s beat-synced video"
173
+ ```
174
+
175
+ `--audio` accepts repeatable local files or public URLs. Local MP3/WAV files must be 2-15s and <=15MB; reference audio currently works with Seedance video generation.
176
+
162
177
  ### Fix one video moment from a screenshot
163
178
 
164
179
  When a video is mostly good but one moment needs a local fix, attach a screenshot of the problem frame and describe the correction in normal language:
package/bin/makaron.mjs CHANGED
@@ -38,6 +38,11 @@ const MAX_VIDEO_UPLOAD_DURATION = 120;
38
38
  const MAX_VIDEO_UPLOAD_DURATION_TOLERANCE = 1;
39
39
  const MAX_VIDEO_PROVIDER_REFERENCE_DURATION = 15;
40
40
  const MAX_VIDEO_PROVIDER_REFERENCE_DURATION_TOLERANCE = 0.5;
41
+ const MIN_AUDIO_REFERENCE_DURATION = 2;
42
+ const MAX_AUDIO_REFERENCE_DURATION = 15;
43
+ const MAX_AUDIO_REFERENCE_DURATION_TOLERANCE = 0.5;
44
+ const MAX_AUDIO_REFERENCE_FILE_SIZE_MB = 15;
45
+ const MAX_AUDIO_REFERENCE_FILE_SIZE = MAX_AUDIO_REFERENCE_FILE_SIZE_MB * 1024 * 1024;
41
46
  const MAX_VIDEO_FRAME_PIXELS = 2_086_876;
42
47
  const SEEDANCE_MIN_VIDEO_FRAME_PIXELS = 409_600;
43
48
  const SEEDANCE_MIN_VIDEO_SIDE = 300;
@@ -274,6 +279,7 @@ Options:
274
279
  --project <id|auto> Project to work in. Use "auto" to create one.
275
280
  --image <file|url> Attach a reference image or screenshot. Repeatable.
276
281
  --video <file|url> Attach a video to the project timeline. Repeatable.
282
+ --audio <file|url> Attach a song, beat, or voice reference. MP3/WAV, repeatable.
277
283
  --skill <id|label|name> Use an installed skill or auto-install a matched marketplace skill.
278
284
  --model <name> Preferred image/model route.
279
285
  --video-model <name> Preferred video model: seedance-fast, seedance-mini, seedance, kling, or grok.
@@ -305,6 +311,10 @@ What you can ask:
305
311
  Music
306
312
  makaron chat --project <id> "add calm piano background music"
307
313
 
314
+ Reference audio / beat sync
315
+ makaron chat --project auto --audio beat.mp3 --video-model seedance-fast --video-resolution 480p "用这个音乐做卡点视频"
316
+ makaron chat --project <id> --audio https://example.com/beat.mp3 "add this as the soundtrack"
317
+
308
318
  Motion design
309
319
  makaron chat --project <id> "make an animated Instagram story with this image"
310
320
 
@@ -450,6 +460,7 @@ async function submitRun(baseUrl, headers, projectId, prompt, opts = {}) {
450
460
  if (opts.videoResolution) body.videoResolution = opts.videoResolution;
451
461
  if (opts.currentSnapshotIndex != null) body.currentSnapshotIndex = opts.currentSnapshotIndex;
452
462
  if (opts.isNsfw) body.isNsfw = opts.isNsfw;
463
+ if (opts.audioAttachments?.length) body.audioAttachments = opts.audioAttachments;
453
464
 
454
465
  const res = await fetch(`${baseUrl}/api/agent/run`, {
455
466
  method: 'POST',
@@ -1000,13 +1011,13 @@ function readImageAsDataUrl(filePath) {
1000
1011
  * 2. PUT file directly to Supabase Storage (no Vercel body limit)
1001
1012
  * Returns public URL on success, null on failure.
1002
1013
  */
1003
- async function uploadFileViaSignedUrl(baseUrl, headers, projectId, filePath, contentType) {
1014
+ async function uploadFileViaSignedUrl(baseUrl, headers, projectId, filePath, contentType, options = {}) {
1004
1015
  const filename = path.basename(filePath);
1005
1016
  // Step 1: get signed upload URL
1006
1017
  const urlRes = await fetch(`${baseUrl}/api/storage/upload-url`, {
1007
1018
  method: 'POST',
1008
1019
  headers: { 'Content-Type': 'application/json', ...headers },
1009
- body: JSON.stringify({ projectId, filename, contentType }),
1020
+ body: JSON.stringify({ projectId, filename, contentType, uploadKind: options.uploadKind }),
1010
1021
  });
1011
1022
  if (!urlRes.ok) {
1012
1023
  process.stderr.write(`⚠️ Failed to get upload URL: ${await urlRes.text()}\n`);
@@ -1108,6 +1119,125 @@ function probeLocalVideo(videoPath) {
1108
1119
  return probeVideoWithFfprobe(videoPath) || probeVideoWithFfmpeg(videoPath);
1109
1120
  }
1110
1121
 
1122
+ function probeAudioDurationWithFfprobe(audioPath) {
1123
+ try {
1124
+ const out = execFileSync('ffprobe', [
1125
+ '-v', 'error',
1126
+ '-show_entries', 'format=duration',
1127
+ '-of', 'json',
1128
+ audioPath,
1129
+ ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
1130
+ const data = JSON.parse(out);
1131
+ const duration = Number(data.format?.duration);
1132
+ if (Number.isFinite(duration) && duration > 0) return duration;
1133
+ } catch { /* ffprobe unavailable or file unsupported */ }
1134
+ return null;
1135
+ }
1136
+
1137
+ function getAudioMimeFromExt(ext) {
1138
+ if (ext === 'mp3') return 'audio/mpeg';
1139
+ if (ext === 'wav') return 'audio/wav';
1140
+ return null;
1141
+ }
1142
+
1143
+ function validateAudioReferenceFile(audioPath) {
1144
+ if (!fs.existsSync(audioPath)) {
1145
+ return { ok: false, error: `Audio file not found: ${audioPath}` };
1146
+ }
1147
+ const stat = fs.statSync(audioPath);
1148
+ if (stat.size === 0) {
1149
+ return { ok: false, error: `Audio file is empty: ${audioPath}` };
1150
+ }
1151
+ if (stat.size > MAX_AUDIO_REFERENCE_FILE_SIZE) {
1152
+ return { ok: false, error: `Audio too large: ${(stat.size / 1024 / 1024).toFixed(1)}MB (max ${MAX_AUDIO_REFERENCE_FILE_SIZE_MB}MB).` };
1153
+ }
1154
+ const ext = path.extname(audioPath).slice(1).toLowerCase();
1155
+ const mime = getAudioMimeFromExt(ext);
1156
+ if (!mime) {
1157
+ return { ok: false, error: `Unsupported audio format: .${ext || 'unknown'}. Use MP3 or WAV.` };
1158
+ }
1159
+ const duration = probeAudioDurationWithFfprobe(audioPath);
1160
+ if (duration == null) {
1161
+ return { ok: false, error: 'Cannot read audio duration. Install ffprobe or use a public MP3/WAV URL.' };
1162
+ }
1163
+ if (duration < MIN_AUDIO_REFERENCE_DURATION) {
1164
+ return { ok: false, error: `Audio too short: ${formatSeconds(duration)}s (min ${MIN_AUDIO_REFERENCE_DURATION}s).` };
1165
+ }
1166
+ if (duration > MAX_AUDIO_REFERENCE_DURATION + MAX_AUDIO_REFERENCE_DURATION_TOLERANCE) {
1167
+ return { ok: false, error: `Audio too long: ${formatSeconds(duration)}s (max ${MAX_AUDIO_REFERENCE_DURATION}s, with ${MAX_AUDIO_REFERENCE_DURATION_TOLERANCE}s metadata tolerance).` };
1168
+ }
1169
+ return { ok: true, mime, meta: { duration, fileSizeBytes: stat.size } };
1170
+ }
1171
+
1172
+ function validateAudioReferenceUrl(audioUrl) {
1173
+ let parsed;
1174
+ try {
1175
+ parsed = new URL(audioUrl);
1176
+ } catch {
1177
+ return { ok: false, error: `Invalid audio URL: ${audioUrl}` };
1178
+ }
1179
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1180
+ return { ok: false, error: `Invalid audio URL protocol: ${parsed.protocol}` };
1181
+ }
1182
+ const ext = path.extname(parsed.pathname).slice(1).toLowerCase();
1183
+ const mime = getAudioMimeFromExt(ext);
1184
+ if (!mime) {
1185
+ return { ok: false, error: `Unsupported audio URL format: .${ext || 'unknown'}. Use an MP3 or WAV URL.` };
1186
+ }
1187
+ return { ok: true, mime };
1188
+ }
1189
+
1190
+ async function probeAudioReferenceUrl(audioUrl) {
1191
+ const controller = new AbortController();
1192
+ const timer = setTimeout(() => controller.abort(), 6000);
1193
+ try {
1194
+ const res = await fetch(audioUrl, {
1195
+ method: 'HEAD',
1196
+ signal: controller.signal,
1197
+ headers: { Accept: 'audio/mpeg,audio/wav,audio/wave,audio/x-wav,*/*' },
1198
+ });
1199
+ if (!res.ok) return { ok: true, warning: `Could not HEAD probe audio URL (${res.status}); storing external URL as-is.` };
1200
+ const contentLength = Number(res.headers.get('content-length') || 0);
1201
+ if (contentLength > MAX_AUDIO_REFERENCE_FILE_SIZE) {
1202
+ return { ok: false, error: `Audio URL appears too large: ${(contentLength / 1024 / 1024).toFixed(1)}MB (max ${MAX_AUDIO_REFERENCE_FILE_SIZE_MB}MB).` };
1203
+ }
1204
+ const contentType = (res.headers.get('content-type') || '').toLowerCase();
1205
+ if (contentType && !contentType.includes('audio/') && !contentType.includes('octet-stream')) {
1206
+ return { ok: false, error: `Audio URL content-type is not audio: ${contentType}` };
1207
+ }
1208
+ return { ok: true, fileSizeBytes: contentLength || undefined };
1209
+ } catch {
1210
+ return { ok: true, warning: 'Could not HEAD probe audio URL; storing external URL as-is.' };
1211
+ } finally {
1212
+ clearTimeout(timer);
1213
+ }
1214
+ }
1215
+
1216
+ function titleFromAudioInput(value) {
1217
+ if (isHttpUrl(value)) {
1218
+ try {
1219
+ const name = decodeURIComponent(new URL(value).pathname.split('/').filter(Boolean).pop() || '');
1220
+ return name || 'Reference audio';
1221
+ } catch {
1222
+ return 'Reference audio';
1223
+ }
1224
+ }
1225
+ return path.basename(value);
1226
+ }
1227
+
1228
+ async function importAudioTracks(baseUrl, headers, projectId, audios) {
1229
+ const res = await fetch(`${baseUrl}/api/music/import`, {
1230
+ method: 'POST',
1231
+ headers: { 'Content-Type': 'application/json', ...headers },
1232
+ body: JSON.stringify({ projectId, audios }),
1233
+ });
1234
+ if (!res.ok) {
1235
+ process.stderr.write(`❌ Failed to import audio: ${await res.text()}\n`);
1236
+ process.exit(1);
1237
+ }
1238
+ return await res.json();
1239
+ }
1240
+
1111
1241
  function validateVideoFile(videoPath, options = {}) {
1112
1242
  const maxDuration = options.maxDuration ?? MAX_VIDEO_UPLOAD_DURATION;
1113
1243
  const durationTolerance = options.durationTolerance ?? MAX_VIDEO_UPLOAD_DURATION_TOLERANCE;
@@ -1236,6 +1366,7 @@ Commands:
1236
1366
  chat --project <id> "message" Chat (non-blocking, polls for result)
1237
1367
  chat --project <id> --skill <id> Use or auto-install a marketplace skill
1238
1368
  chat --project <id> --video <file> Attach video to conversation
1369
+ chat --project <id> --audio <file> Attach song/beat/voice reference
1239
1370
  chat --project <id> -b "message" Background: submit and print runId
1240
1371
  chat --project <id> --stream "msg" Legacy: stream SSE in real-time
1241
1372
  chat --project <id> --json "msg" Output structured JSON result
@@ -1435,6 +1566,7 @@ if (!command || command === '--help' || command === '-h' || command === 'help')
1435
1566
  let projectId = null;
1436
1567
  const chatImages = [];
1437
1568
  const chatVideos = [];
1569
+ const chatAudios = [];
1438
1570
  const promptParts = [];
1439
1571
  let useStream = false;
1440
1572
  let background = false;
@@ -1447,6 +1579,7 @@ if (!command || command === '--help' || command === '-h' || command === 'help')
1447
1579
  if (args[i] === '--project' && args[i + 1]) projectId = args[++i];
1448
1580
  else if (args[i] === '--image' && args[i + 1]) chatImages.push(args[++i]);
1449
1581
  else if (args[i] === '--video' && args[i + 1]) chatVideos.push(args[++i]);
1582
+ else if (args[i] === '--audio' && args[i + 1]) chatAudios.push(args[++i]);
1450
1583
  else if (args[i] === '--skill' && args[i + 1]) activeSkill = args[++i];
1451
1584
  else if (args[i].startsWith('--skill=')) activeSkill = args[i].slice('--skill='.length);
1452
1585
  else if (args[i] === '--stream') useStream = true;
@@ -1469,6 +1602,8 @@ if (!command || command === '--help' || command === '-h' || command === 'help')
1469
1602
  const imageFileList = chatImages.filter(p => !p.startsWith('http://') && !p.startsWith('https://'));
1470
1603
  const prevalidatedVideoUrlList = chatVideos.filter(p => p.startsWith('http://') || p.startsWith('https://'));
1471
1604
  const prevalidatedVideoFileList = chatVideos.filter(p => !p.startsWith('http://') && !p.startsWith('https://'));
1605
+ const prevalidatedAudioUrlList = chatAudios.filter(p => p.startsWith('http://') || p.startsWith('https://'));
1606
+ const prevalidatedAudioFileList = chatAudios.filter(p => !p.startsWith('http://') && !p.startsWith('https://'));
1472
1607
  const prevalidatedVideoMetas = new Map();
1473
1608
  for (const videoPath of prevalidatedVideoFileList) {
1474
1609
  const valid = validateVideoFile(videoPath);
@@ -1478,6 +1613,33 @@ if (!command || command === '--help' || command === '-h' || command === 'help')
1478
1613
  }
1479
1614
  prevalidatedVideoMetas.set(videoPath, valid.meta);
1480
1615
  }
1616
+ const prevalidatedAudioMetas = new Map();
1617
+ for (const audioUrl of prevalidatedAudioUrlList) {
1618
+ const valid = validateAudioReferenceUrl(audioUrl);
1619
+ if (!valid.ok) {
1620
+ process.stderr.write(`❌ ${valid.error}\n`);
1621
+ process.exit(1);
1622
+ }
1623
+ const probed = await probeAudioReferenceUrl(audioUrl);
1624
+ if (!probed.ok) {
1625
+ process.stderr.write(`❌ ${probed.error}\n`);
1626
+ process.exit(1);
1627
+ }
1628
+ if (probed.warning) process.stderr.write(`⚠️ ${probed.warning}\n`);
1629
+ prevalidatedAudioMetas.set(audioUrl, { ...valid, ...probed });
1630
+ }
1631
+ for (const audioPath of prevalidatedAudioFileList) {
1632
+ const valid = validateAudioReferenceFile(audioPath);
1633
+ if (!valid.ok) {
1634
+ process.stderr.write(`❌ ${valid.error}\n`);
1635
+ process.exit(1);
1636
+ }
1637
+ prevalidatedAudioMetas.set(audioPath, valid);
1638
+ }
1639
+ if (useStream && chatAudios.length > 0) {
1640
+ process.stderr.write('❌ --audio is supported in the default async chat path. Remove --stream and retry.\n');
1641
+ process.exit(1);
1642
+ }
1481
1643
 
1482
1644
  // --project auto: create a new project (with images/videos if provided)
1483
1645
  if (!projectId || projectId === 'auto') {
@@ -1529,6 +1691,48 @@ if (!command || command === '--help' || command === '-h' || command === 'help')
1529
1691
 
1530
1692
  // Upload videos to project timeline (via /api/projects/create with videoUrls)
1531
1693
  let finalPrompt = resolvedSkill ? `[Active skill: ${resolvedSkill}]\n${prompt}` : prompt;
1694
+ let audioAttachments = [];
1695
+ if (chatAudios.length > 0) {
1696
+ const audioImports = [];
1697
+ for (const audioUrl of prevalidatedAudioUrlList) {
1698
+ const valid = prevalidatedAudioMetas.get(audioUrl);
1699
+ audioImports.push({
1700
+ audioUrl,
1701
+ title: titleFromAudioInput(audioUrl),
1702
+ mimeType: valid.mime,
1703
+ fileSizeBytes: valid.fileSizeBytes,
1704
+ source: 'cli_url',
1705
+ });
1706
+ }
1707
+ for (const audioPath of prevalidatedAudioFileList) {
1708
+ const valid = prevalidatedAudioMetas.get(audioPath);
1709
+ process.stderr.write(`🎵 Uploading ${path.basename(audioPath)} (${(fs.statSync(audioPath).size / 1024 / 1024).toFixed(1)}MB)...\n`);
1710
+ const url = await uploadFileViaSignedUrl(baseUrl, headers, projectId, audioPath, valid.mime, { uploadKind: 'audio' });
1711
+ if (!url) {
1712
+ process.stderr.write(`❌ Failed to upload audio: ${audioPath}\n`);
1713
+ process.exit(1);
1714
+ }
1715
+ audioImports.push({
1716
+ audioUrl: url,
1717
+ title: titleFromAudioInput(audioPath),
1718
+ duration: valid.meta.duration,
1719
+ mimeType: valid.mime,
1720
+ fileSizeBytes: valid.meta.fileSizeBytes,
1721
+ source: 'cli_upload',
1722
+ });
1723
+ process.stderr.write(`🎵 Uploaded: ${path.basename(audioPath)}\n`);
1724
+ }
1725
+
1726
+ const imported = await importAudioTracks(baseUrl, headers, projectId, audioImports);
1727
+ audioAttachments = (imported.tracks || []).map(track => ({
1728
+ audioUrl: track.audioUrl,
1729
+ title: track.title,
1730
+ duration: track.duration,
1731
+ trackIndex: track.trackIndex,
1732
+ }));
1733
+ process.stderr.write(`🎵 Imported ${audioAttachments.length} reference audio track(s)\n`);
1734
+ }
1735
+
1532
1736
  if (chatVideos.length > 0) {
1533
1737
  // Upload local files via signed URL (no size limit, works with API key auth)
1534
1738
  const uploadedVideoUrls = [...prevalidatedVideoUrlList];
@@ -1586,7 +1790,7 @@ if (!command || command === '--help' || command === '-h' || command === 'help')
1586
1790
  for (const task of results.musicTasks) await pollMusic(baseUrl, headers, task.taskId);
1587
1791
  } else {
1588
1792
  // Default: fire-and-forget + poll
1589
- const { runId } = await submitRun(baseUrl, headers, projectId, finalPrompt, { videoModel, videoResolution, preferredModel });
1793
+ const { runId } = await submitRun(baseUrl, headers, projectId, finalPrompt, { videoModel, videoResolution, preferredModel, audioAttachments });
1590
1794
  if (background) {
1591
1795
  // Just print runId and exit
1592
1796
  if (jsonOutput) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makaron-cli",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "Talk to Makaron Agent from the terminal — create projects, edit images, generate videos",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -94,6 +94,7 @@ Returns immediately:
94
94
  | Fix one moment in a video from a screenshot | `npx makaron-cli chat --project <id> --image screenshot.png "@4 this frame should be Paris; only fix this moment"` |
95
95
  | Cut or assemble video | `npx makaron-cli chat --project <id> --video clip.mp4 "cut out the dead air and keep the best 20 seconds"` |
96
96
  | Add music | `npx makaron-cli chat --project <id> "add calm piano background music"` |
97
+ | Beat-sync video from audio | `npx makaron-cli chat --project auto --audio beat.mp3 --video-model seedance-fast --video-resolution 480p "make a beat-synced video"` |
97
98
  | Create motion design | `npx makaron-cli chat --project <id> "make an animated Instagram story with this image"` |
98
99
 
99
100
  ### Marketplace skills
@@ -158,6 +159,20 @@ Supported formats: MP4, MOV, WebM. CLI local video uploads support max 50MB, max
158
159
 
159
160
  Use `chat --project <id|auto> --video ...` for any project/timeline video work. Direct video commands are standalone raw-tool calls.
160
161
 
162
+ ### With reference audio (MP3/WAV)
163
+
164
+ Attach a short song, beat, or voice recording when the video should follow audio pacing:
165
+
166
+ ```bash
167
+ npx makaron-cli chat --project auto \
168
+ --audio beat.mp3 \
169
+ --video-model seedance-fast \
170
+ --video-resolution 480p \
171
+ -b "make a 15s beat-synced video"
172
+ ```
173
+
174
+ `--audio` accepts repeatable local files or public URLs. Local MP3/WAV files must be 2-15s and <=15MB; reference audio currently works with Seedance video generation.
175
+
161
176
  ### Fix one video moment from a screenshot
162
177
 
163
178
  When a video is mostly good but one moment needs a local fix, attach a screenshot of the problem frame and describe the correction in normal language: