capcut-cli 0.1.5 → 0.2.0

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # capcut-cli
2
2
 
3
- Stop reverse-engineering CapCut's JSON schema every time you need to change a subtitle.
3
+ Create and edit CapCut projects from the command line. Build drafts from scratch, add media, modify subtitles, cut long-form to shorts.
4
4
 
5
5
  ## The problem
6
6
 
@@ -89,6 +89,27 @@ capcut material ./project a1b2c3 # Full detail for one material
89
89
 
90
90
  Progressive disclosure: `info` shows the shape, `materials` shows what's available, `segment`/`material` shows everything about one item. An AI agent navigates overview → list → detail, never gets more data than it needs.
91
91
 
92
+ ### Create (build projects from scratch)
93
+
94
+ No need to open CapCut first. Create a draft, add media, then open in CapCut.
95
+
96
+ ```bash
97
+ # Create an empty draft
98
+ capcut init "My Short" --drafts ~/Movies/CapCut/User\ Data/Projects/com.lveditor.draft
99
+
100
+ # Add media
101
+ capcut add-video ./my-short ./clip.mp4 0s 10s
102
+ capcut add-audio ./my-short ./voiceover.wav 0s 10s --volume 0.9
103
+ capcut add-audio ./my-short ./music.mp3 0s 30s --volume 0.3
104
+
105
+ # Add titles
106
+ capcut add-text ./my-short 0s 5s "My Short" --font-size 24 --color "#FFD700"
107
+ ```
108
+
109
+ `init` creates a valid `draft_content.json` from a built-in template. `add-video` and `add-audio` copy the file into the draft's assets directory so CapCut can find it. Open the project in CapCut and everything links up.
110
+
111
+ Options for `add-video` / `add-audio`: `--volume <0-1>`, `--template <path>` (custom draft template).
112
+
92
113
  ### Add
93
114
 
94
115
  ```bash
package/dist/factory.js CHANGED
@@ -1,10 +1,33 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFileSync, writeFileSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, cpSync, copyFileSync } from "node:fs";
3
+ import { resolve, dirname } from "node:path";
3
4
  import { findMaterialGlobal } from "./draft.js";
4
5
  // --- UUID generation ---
5
6
  export function uuid() {
6
7
  return randomUUID();
7
8
  }
9
+ export function initDraft(opts) {
10
+ const draftPath = resolve(opts.draftsDir, opts.name);
11
+ if (existsSync(draftPath)) {
12
+ throw new Error(`Draft already exists: ${draftPath}. Delete it first or use a different name.`);
13
+ }
14
+ cpSync(opts.templateDir, draftPath, { recursive: true });
15
+ // Find the draft file
16
+ const candidates = ["draft_info.json", "draft_content.json"];
17
+ for (const c of candidates) {
18
+ const fp = resolve(draftPath, c);
19
+ if (existsSync(fp)) {
20
+ // Update the draft name
21
+ const raw = readFileSync(fp, "utf-8");
22
+ const draft = JSON.parse(raw);
23
+ draft.name = opts.name;
24
+ draft.id = uuid();
25
+ writeFileSync(fp, JSON.stringify(draft, null, 0), "utf-8");
26
+ return { draftPath, filePath: fp };
27
+ }
28
+ }
29
+ throw new Error(`No draft_info.json or draft_content.json found in template: ${opts.templateDir}`);
30
+ }
8
31
  export function createCompanionMaterials(trackType) {
9
32
  const speed = { id: uuid(), type: "speed", speed: 1, mode: 0, curve_speed: null };
10
33
  const placeholder = {
@@ -160,6 +183,163 @@ export function addText(draft, filePath, opts) {
160
183
  track.segments.push(seg);
161
184
  return { segmentId: segId, materialId: matId, trackId: track.id };
162
185
  }
186
+ export function addAudio(draft, filePath, opts) {
187
+ const segId = uuid();
188
+ const matId = uuid();
189
+ const trackName = opts.trackName ?? "audio";
190
+ const volume = opts.volume ?? 1.0;
191
+ // Copy file into draft assets directory
192
+ const draftDir = dirname(filePath);
193
+ const filename = opts.path.split("/").pop() || "audio.mp3";
194
+ const assetsDir = resolve(draftDir, "assets", "audio");
195
+ mkdirSync(assetsDir, { recursive: true });
196
+ const destPath = resolve(assetsDir, filename);
197
+ if (!existsSync(destPath)) {
198
+ copyFileSync(opts.path, destPath);
199
+ }
200
+ // Use the local assets path — CapCut rewrites to placeholder on open
201
+ const localPath = destPath;
202
+ // Find or create audio track
203
+ let track = draft.tracks.find(t => t.type === "audio" && t.name === trackName);
204
+ if (!track) {
205
+ track = {
206
+ id: uuid(),
207
+ type: "audio",
208
+ name: trackName,
209
+ attribute: 0,
210
+ segments: [],
211
+ is_default_name: false,
212
+ flag: 0,
213
+ };
214
+ draft.tracks.push(track);
215
+ }
216
+ // Create companion materials
217
+ const companions = createCompanionMaterials("audio");
218
+ registerCompanions(draft, companions);
219
+ // Create audio material
220
+ const audioMaterial = {
221
+ id: matId,
222
+ path: localPath,
223
+ name: filename,
224
+ duration: opts.duration,
225
+ type: "extract_music",
226
+ category_id: "",
227
+ category_name: "local",
228
+ check_flag: 1,
229
+ music_id: "",
230
+ request_id: "",
231
+ source_platform: 0,
232
+ team_id: "",
233
+ text_id: "",
234
+ tone_category_id: "",
235
+ tone_category_name: "",
236
+ tone_effect_id: "",
237
+ tone_effect_name: "",
238
+ tone_platform: "",
239
+ tone_second_category_id: "",
240
+ tone_second_category_name: "",
241
+ tone_speaker: "",
242
+ tone_type: "",
243
+ wave_points: [],
244
+ };
245
+ draft.materials.audios.push(audioMaterial);
246
+ // Create segment
247
+ const timerange = { start: opts.start, duration: opts.duration };
248
+ const seg = baseSegment(segId, matId, track.id, timerange, companions.ids, 11000);
249
+ seg.volume = volume;
250
+ track.segments.push(seg);
251
+ // Update project duration if needed
252
+ const segEnd = opts.start + opts.duration;
253
+ if (segEnd > draft.duration) {
254
+ draft.duration = segEnd;
255
+ }
256
+ return { segmentId: segId, materialId: matId, trackId: track.id };
257
+ }
258
+ export function addVideo(draft, filePath, opts) {
259
+ const segId = uuid();
260
+ const matId = uuid();
261
+ const trackName = opts.trackName ?? "video";
262
+ const width = opts.width ?? 1920;
263
+ const height = opts.height ?? 1080;
264
+ // Infer type from extension if not provided
265
+ const ext = opts.path.split(".").pop()?.toLowerCase() || "";
266
+ const materialType = opts.type ?? (["jpg", "jpeg", "png", "webp", "bmp", "tiff"].includes(ext) ? "photo" : "video");
267
+ // Copy file into draft assets directory
268
+ const draftDir = dirname(filePath);
269
+ const filename = opts.path.split("/").pop() || "media";
270
+ const assetsDir = resolve(draftDir, "assets", "video");
271
+ mkdirSync(assetsDir, { recursive: true });
272
+ const destPath = resolve(assetsDir, filename);
273
+ if (!existsSync(destPath)) {
274
+ copyFileSync(opts.path, destPath);
275
+ }
276
+ // Use the local assets path — CapCut rewrites to placeholder on open
277
+ const localPath = destPath;
278
+ // Find or create video track
279
+ let track = draft.tracks.find(t => t.type === "video" && t.name === trackName);
280
+ if (!track) {
281
+ track = {
282
+ id: uuid(),
283
+ type: "video",
284
+ name: trackName,
285
+ attribute: 0,
286
+ segments: [],
287
+ is_default_name: false,
288
+ flag: 0,
289
+ };
290
+ draft.tracks.push(track);
291
+ }
292
+ // Create companion materials
293
+ const companions = createCompanionMaterials("video");
294
+ registerCompanions(draft, companions);
295
+ // Create video material
296
+ const videoMaterial = {
297
+ id: matId,
298
+ path: localPath,
299
+ material_name: filename,
300
+ type: materialType,
301
+ duration: opts.duration,
302
+ width,
303
+ height,
304
+ category_id: "",
305
+ category_name: "local",
306
+ check_flag: 7,
307
+ crop: { lower_left_x: 0, lower_left_y: 1, lower_right_x: 1, lower_right_y: 1, upper_left_x: 0, upper_left_y: 0, upper_right_x: 1, upper_right_y: 0 },
308
+ has_audio: materialType === "video",
309
+ extra_type_option: 0,
310
+ formula_id: "",
311
+ freeze: null,
312
+ intensifies_audio_path: "",
313
+ intensifies_path: "",
314
+ is_ai_generate_content: false,
315
+ is_copyright: false,
316
+ is_text_edit_overdub: false,
317
+ is_unified_beauty_mode: false,
318
+ local_id: "",
319
+ local_material_id: "",
320
+ material_url: "",
321
+ media_path: "",
322
+ object_locked: null,
323
+ origin_material_id: "",
324
+ request_id: "",
325
+ reverse_path: "",
326
+ source_platform: 0,
327
+ stable: { matrix_path: "", stable_level: 0, time_range: { duration: 0, start: 0 } },
328
+ team_id: "",
329
+ video_algorithm: { algorithms: [], deflicker: null, motion_blur_config: null, noise_reduction: null, path: "", quality_enhance: null, time_range: null },
330
+ };
331
+ draft.materials.videos.push(videoMaterial);
332
+ // Create segment
333
+ const timerange = { start: opts.start, duration: opts.duration };
334
+ const seg = baseSegment(segId, matId, track.id, timerange, companions.ids, 14000);
335
+ track.segments.push(seg);
336
+ // Update project duration if needed
337
+ const segEnd = opts.start + opts.duration;
338
+ if (segEnd > draft.duration) {
339
+ draft.duration = segEnd;
340
+ }
341
+ return { segmentId: segId, materialId: matId, trackId: track.id };
342
+ }
163
343
  export function cutProject(draft, opts) {
164
344
  const { start, end } = opts;
165
345
  const duration = end - start;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { loadDraft, saveDraft, extractText, updateTextContent, findSegment, findMaterial, findMaterialGlobal, getMaterialTypes, getTracksByType } from "./draft.js";
4
4
  import { formatTime, formatDuration, parseTimeInput, srtTime } from "./time.js";
5
- import { addText, cutProject, saveTemplate, applyTemplate } from "./factory.js";
5
+ import { addText, addAudio, addVideo, cutProject, saveTemplate, applyTemplate, initDraft } from "./factory.js";
6
6
  const HELP = `capcut-cli -- fast edits to CapCut projects
7
7
 
8
8
  Usage: capcut <command> <project> [options]
@@ -27,7 +27,23 @@ Detail (drill into one item):
27
27
  segment <project> <id> Full detail for one segment + its material
28
28
  material <project> <id> Full detail for one material
29
29
 
30
+ Create:
31
+ init <name> [--template <dir>] [--drafts <dir>]
32
+ Create a new empty draft from template. Defaults:
33
+ --template ../CapCutAPI/template (relative to capcut-cli)
34
+ --drafts ~/Movies/CapCut/User Data/Projects/com.lveditor.draft
35
+
30
36
  Add:
37
+ add-audio <project> <file> <start> <duration> [options]
38
+ Add an audio segment (VO, music, SFX). Options:
39
+ --volume <n> Volume 0.0-1.0 (default: 1.0)
40
+ --track-name <s> Track name (default: "audio")
41
+
42
+ add-video <project> <file> <start> <duration> [options]
43
+ Add a video or image segment. Type auto-detected from extension.
44
+ Options:
45
+ --track-name <s> Track name (default: "video")
46
+
31
47
  add-text <project> <start> <duration> <text> [options]
32
48
  Add a text segment. Options:
33
49
  --font-size <n> Font size (default: 15)
@@ -95,6 +111,15 @@ function parseFlags(args) {
95
111
  else if (a === "--track-name" && i + 1 < args.length) {
96
112
  flags.trackName = args[++i];
97
113
  }
114
+ else if (a === "--volume" && i + 1 < args.length) {
115
+ flags.volume = parseFloat(args[++i]);
116
+ }
117
+ else if (a === "--template" && i + 1 < args.length) {
118
+ flags.template = args[++i];
119
+ }
120
+ else if (a === "--drafts" && i + 1 < args.length) {
121
+ flags.drafts = args[++i];
122
+ }
98
123
  else
99
124
  positional.push(a);
100
125
  }
@@ -462,6 +487,45 @@ function cmdMaterialDetail(draft, matId, flags) {
462
487
  }
463
488
  }
464
489
  // --- Add commands ---
490
+ function cmdAddAudio(draft, filePath, positional, flags) {
491
+ const audioPath = positional[2];
492
+ const startStr = positional[3];
493
+ const durationStr = positional[4];
494
+ if (!audioPath || !startStr || !durationStr)
495
+ die("Usage: capcut add-audio <project> <file> <start> <duration>");
496
+ const absPath = audioPath.startsWith("/") ? audioPath : process.cwd() + "/" + audioPath;
497
+ const start = parseTimeInput(startStr);
498
+ const duration = parseTimeInput(durationStr);
499
+ const opts = {
500
+ path: absPath,
501
+ start,
502
+ duration,
503
+ volume: flags.volume,
504
+ trackName: flags.trackName,
505
+ };
506
+ const result = addAudio(draft, filePath, opts);
507
+ saveDraft(filePath, draft);
508
+ out({ ok: true, segment_id: result.segmentId, material_id: result.materialId, track_id: result.trackId, path: absPath, start_us: start, duration_us: duration }, flags);
509
+ }
510
+ function cmdAddVideo(draft, filePath, positional, flags) {
511
+ const videoPath = positional[2];
512
+ const startStr = positional[3];
513
+ const durationStr = positional[4];
514
+ if (!videoPath || !startStr || !durationStr)
515
+ die("Usage: capcut add-video <project> <file> <start> <duration>");
516
+ const absPath = videoPath.startsWith("/") ? videoPath : process.cwd() + "/" + videoPath;
517
+ const start = parseTimeInput(startStr);
518
+ const duration = parseTimeInput(durationStr);
519
+ const opts = {
520
+ path: absPath,
521
+ start,
522
+ duration,
523
+ trackName: flags.trackName,
524
+ };
525
+ const result = addVideo(draft, filePath, opts);
526
+ saveDraft(filePath, draft);
527
+ out({ ok: true, segment_id: result.segmentId, material_id: result.materialId, track_id: result.trackId, path: absPath, start_us: start, duration_us: duration }, flags);
528
+ }
465
529
  function cmdAddText(draft, filePath, positional, flags) {
466
530
  const startStr = positional[2];
467
531
  const durationStr = positional[3];
@@ -614,6 +678,20 @@ function main() {
614
678
  const { positional, flags } = parseFlags(raw);
615
679
  const cmd = positional[0];
616
680
  const projectPath = positional[1];
681
+ // init doesn't need an existing project
682
+ if (cmd === "init") {
683
+ const name = projectPath; // positional[1] is the name for init
684
+ if (!name)
685
+ die("Missing name. Usage: capcut init <name> [--template <dir>] [--drafts <dir>]");
686
+ const cliDir = new URL(".", import.meta.url).pathname.replace(/\/dist\/$/, "");
687
+ const templateDir = flags.template ?? cliDir + "/../CapCutAPI/template";
688
+ const draftsDir = flags.drafts ?? (process.env.HOME || "~") + "/Movies/CapCut/User Data/Projects/com.lveditor.draft";
689
+ const result = initDraft({ name, templateDir, draftsDir });
690
+ out({ ok: true, name, draft_path: result.draftPath, file_path: result.filePath }, flags);
691
+ if (!flags.quiet)
692
+ process.stderr.write(`Created: ${result.draftPath}\n`);
693
+ process.exit(0);
694
+ }
617
695
  if (!projectPath)
618
696
  die("Missing project path. Run 'capcut --help' for usage.");
619
697
  const { draft, filePath } = loadDraft(projectPath);
@@ -672,6 +750,14 @@ function main() {
672
750
  requireArgs(positional, 3, "capcut material <project> <id>");
673
751
  cmdMaterialDetail(draft, positional[2], flags);
674
752
  break;
753
+ case "add-audio":
754
+ requireArgs(positional, 5, "capcut add-audio <project> <file> <start> <duration>");
755
+ cmdAddAudio(draft, filePath, positional, flags);
756
+ break;
757
+ case "add-video":
758
+ requireArgs(positional, 5, "capcut add-video <project> <file> <start> <duration>");
759
+ cmdAddVideo(draft, filePath, positional, flags);
760
+ break;
675
761
  case "add-text":
676
762
  requireArgs(positional, 5, "capcut add-text <project> <start> <duration> <text>");
677
763
  cmdAddText(draft, filePath, positional, flags);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "capcut-cli",
3
- "version": "0.1.5",
4
- "description": "CLI to edit CapCut and JianYing project files subtitles, timing, speed, volume, templates, cut long-form to shorts. Reads and writes draft_content.json / draft_info.json directly.",
3
+ "version": "0.2.0",
4
+ "description": "CLI to create and edit CapCut projects build drafts from scratch, add video/audio/text, subtitles, timing, speed, volume, templates, cut long-form to shorts. No API needed.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "capcut": "dist/index.js",