capcut-cli 0.1.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.
@@ -0,0 +1,370 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { findMaterialGlobal } from "./draft.js";
4
+ // --- UUID generation ---
5
+ export function uuid() {
6
+ return randomUUID();
7
+ }
8
+ export function createCompanionMaterials(trackType) {
9
+ const speed = { id: uuid(), type: "speed", speed: 1, mode: 0, curve_speed: null };
10
+ const placeholder = {
11
+ id: uuid(), type: "placeholder_info",
12
+ error_path: "", error_text: "", meta_type: "none", res_path: "", res_text: "",
13
+ };
14
+ const scm = {
15
+ id: uuid(), type: "none",
16
+ audio_channel_mapping: 0, is_config_open: false,
17
+ };
18
+ const vocal = {
19
+ id: uuid(), type: "vocal_separation",
20
+ choice: 0, enter_from: "", final_algorithm: "",
21
+ production_path: "", removed_sounds: [], time_range: null,
22
+ };
23
+ const refs = {
24
+ ids: [speed.id, placeholder.id, scm.id, vocal.id],
25
+ materials: [
26
+ { type: "speeds", data: speed },
27
+ { type: "placeholder_infos", data: placeholder },
28
+ { type: "sound_channel_mappings", data: scm },
29
+ { type: "vocal_separations", data: vocal },
30
+ ],
31
+ };
32
+ if (trackType === "video") {
33
+ const canvas = {
34
+ id: uuid(), type: "canvas_color",
35
+ album_image: "", blur: 0, color: "", image: "",
36
+ image_id: "", image_name: "", source_platform: 0, team_id: "",
37
+ };
38
+ const matColor = {
39
+ id: uuid(), type: "material_color",
40
+ gradient_angle: 90, gradient_colors: [], gradient_percents: [],
41
+ height: 0, is_color_clip: false, is_gradient: false, solid_color: "", width: 0,
42
+ };
43
+ refs.ids.push(canvas.id, matColor.id);
44
+ refs.materials.push({ type: "canvases", data: canvas }, { type: "material_colors", data: matColor });
45
+ }
46
+ return refs;
47
+ }
48
+ export function registerCompanions(draft, companions) {
49
+ for (const { type, data } of companions.materials) {
50
+ if (!draft.materials[type])
51
+ draft.materials[type] = [];
52
+ draft.materials[type].push(data);
53
+ }
54
+ }
55
+ // --- Base segment ---
56
+ function baseSegment(id, materialId, trackId, timerange, companionIds, renderIndex) {
57
+ return {
58
+ id,
59
+ material_id: materialId,
60
+ raw_segment_id: trackId,
61
+ target_timerange: { ...timerange },
62
+ source_timerange: { start: 0, duration: timerange.duration },
63
+ speed: 1,
64
+ volume: 1,
65
+ visible: true,
66
+ reverse: false,
67
+ clip: {
68
+ alpha: 1,
69
+ rotation: 0,
70
+ scale: { x: 1, y: 1 },
71
+ transform: { x: 0, y: 0 },
72
+ flip: { horizontal: false, vertical: false },
73
+ },
74
+ render_index: renderIndex,
75
+ track_render_index: 0,
76
+ track_attribute: 0,
77
+ extra_material_refs: companionIds,
78
+ common_keyframes: [],
79
+ keyframe_refs: [],
80
+ };
81
+ }
82
+ function hexToRgb(hex) {
83
+ const h = hex.replace("#", "");
84
+ return [
85
+ parseInt(h.slice(0, 2), 16) / 255,
86
+ parseInt(h.slice(2, 4), 16) / 255,
87
+ parseInt(h.slice(4, 6), 16) / 255,
88
+ ];
89
+ }
90
+ function buildTextContent(text, fontSize, color) {
91
+ const encoded = Buffer.from(text, "utf16le");
92
+ return JSON.stringify({
93
+ styles: [{
94
+ range: [0, encoded.length],
95
+ size: fontSize,
96
+ bold: false,
97
+ italic: false,
98
+ underline: false,
99
+ fill: {
100
+ alpha: 1,
101
+ content: {
102
+ render_type: "solid",
103
+ solid: { alpha: 1, color },
104
+ },
105
+ },
106
+ }],
107
+ text,
108
+ });
109
+ }
110
+ export function addText(draft, filePath, opts) {
111
+ const segId = uuid();
112
+ const matId = uuid();
113
+ const fontSize = opts.fontSize ?? 15;
114
+ const color = opts.color ?? "#FFFFFF";
115
+ const rgb = hexToRgb(color);
116
+ const alignment = opts.alignment ?? 1;
117
+ const trackName = opts.trackName ?? "text";
118
+ // Find or create text track
119
+ let track = draft.tracks.find(t => t.type === "text" && (t.name === trackName || !opts.trackName));
120
+ if (!track) {
121
+ track = {
122
+ id: uuid(),
123
+ type: "text",
124
+ name: trackName,
125
+ attribute: 0,
126
+ segments: [],
127
+ is_default_name: false,
128
+ flag: 0,
129
+ };
130
+ draft.tracks.push(track);
131
+ }
132
+ // Create companion materials
133
+ const companions = createCompanionMaterials("text");
134
+ registerCompanions(draft, companions);
135
+ // Create text material
136
+ const textMaterial = {
137
+ id: matId,
138
+ type: "text",
139
+ content: buildTextContent(opts.text, fontSize, rgb),
140
+ alignment,
141
+ font_size: fontSize,
142
+ text_color: color,
143
+ typesetting: 0,
144
+ letter_spacing: 0,
145
+ line_spacing: 0.02,
146
+ line_feed: 1,
147
+ line_max_width: 0.82,
148
+ force_apply_line_max_width: false,
149
+ check_flag: 7,
150
+ fixed_width: -1,
151
+ fixed_height: -1,
152
+ };
153
+ draft.materials.texts.push(textMaterial);
154
+ // Create segment
155
+ const timerange = { start: opts.start, duration: opts.duration };
156
+ const seg = baseSegment(segId, matId, track.id, timerange, companions.ids, 15000);
157
+ if (opts.x !== undefined || opts.y !== undefined) {
158
+ seg.clip.transform = { x: opts.x ?? 0, y: opts.y ?? 0 };
159
+ }
160
+ track.segments.push(seg);
161
+ return { segmentId: segId, materialId: matId, trackId: track.id };
162
+ }
163
+ export function cutProject(draft, opts) {
164
+ const { start, end } = opts;
165
+ const duration = end - start;
166
+ let kept = 0;
167
+ let removed = 0;
168
+ // Collect material IDs to remove
169
+ const removedMaterialIds = new Set();
170
+ const removedExtraRefs = new Set();
171
+ for (const track of draft.tracks) {
172
+ const surviving = [];
173
+ for (const seg of track.segments) {
174
+ const segStart = seg.target_timerange.start;
175
+ const segEnd = segStart + seg.target_timerange.duration;
176
+ // Skip segments entirely outside the range
177
+ if (segEnd <= start || segStart >= end) {
178
+ removedMaterialIds.add(seg.material_id);
179
+ for (const ref of seg.extra_material_refs)
180
+ removedExtraRefs.add(ref);
181
+ removed++;
182
+ continue;
183
+ }
184
+ // Clip segment to range
185
+ const clippedStart = Math.max(segStart, start);
186
+ const clippedEnd = Math.min(segEnd, end);
187
+ const trimFromStart = clippedStart - segStart;
188
+ const newDuration = clippedEnd - clippedStart;
189
+ // Adjust source_timerange for the trim
190
+ if (seg.source_timerange) {
191
+ seg.source_timerange.start += Math.round(trimFromStart * seg.speed);
192
+ seg.source_timerange.duration = Math.round(newDuration * seg.speed);
193
+ }
194
+ seg.target_timerange.start = clippedStart - start; // rebase to 0
195
+ seg.target_timerange.duration = newDuration;
196
+ surviving.push(seg);
197
+ kept++;
198
+ }
199
+ track.segments = surviving;
200
+ }
201
+ // Remove empty tracks
202
+ draft.tracks = draft.tracks.filter(t => t.segments.length > 0);
203
+ // Clean up orphaned materials (only if not referenced by surviving segments)
204
+ const survivingMatIds = new Set();
205
+ const survivingExtraRefs = new Set();
206
+ for (const track of draft.tracks) {
207
+ for (const seg of track.segments) {
208
+ survivingMatIds.add(seg.material_id);
209
+ for (const ref of seg.extra_material_refs)
210
+ survivingExtraRefs.add(ref);
211
+ }
212
+ }
213
+ for (const [key, arr] of Object.entries(draft.materials)) {
214
+ if (!Array.isArray(arr))
215
+ continue;
216
+ draft.materials[key] = arr.filter((m) => {
217
+ if (!m || typeof m.id !== "string")
218
+ return true;
219
+ const id = m.id;
220
+ // Keep if referenced by any surviving segment
221
+ if (survivingMatIds.has(id) || survivingExtraRefs.has(id))
222
+ return true;
223
+ // Remove if only referenced by removed segments
224
+ if (removedMaterialIds.has(id) || removedExtraRefs.has(id))
225
+ return false;
226
+ // Keep anything not directly tracked (safety)
227
+ return true;
228
+ });
229
+ }
230
+ // Update project duration
231
+ draft.duration = duration;
232
+ return { kept, removed };
233
+ }
234
+ export function saveTemplate(draft, segId, name, outPath) {
235
+ const shortId = segId.toLowerCase();
236
+ let foundSeg = null;
237
+ let foundTrack = null;
238
+ for (const track of draft.tracks) {
239
+ for (const seg of track.segments) {
240
+ if (seg.id === segId || seg.id.toLowerCase().startsWith(shortId)) {
241
+ foundSeg = seg;
242
+ foundTrack = track;
243
+ break;
244
+ }
245
+ }
246
+ if (foundSeg)
247
+ break;
248
+ }
249
+ if (!foundSeg || !foundTrack)
250
+ throw new Error(`Segment not found: ${segId}`);
251
+ // Resolve primary material
252
+ const mat = findMaterialGlobal(draft, foundSeg.material_id);
253
+ if (!mat)
254
+ throw new Error(`Material not found for segment: ${segId}`);
255
+ // Resolve extra material refs
256
+ const extras = [];
257
+ for (const refId of foundSeg.extra_material_refs) {
258
+ const extra = findMaterialGlobal(draft, refId);
259
+ if (extra)
260
+ extras.push({ type: extra.type, data: { ...extra.material } });
261
+ }
262
+ const template = {
263
+ name,
264
+ type: foundTrack.type,
265
+ segment: { ...foundSeg },
266
+ material: { type: mat.type, data: { ...mat.material } },
267
+ extra_materials: extras,
268
+ };
269
+ writeFileSync(outPath, JSON.stringify(template, null, 2), "utf-8");
270
+ return template;
271
+ }
272
+ export function applyTemplate(draft, templatePath, start, duration, overrides) {
273
+ const template = JSON.parse(readFileSync(templatePath, "utf-8"));
274
+ // Generate new IDs for everything
275
+ const idMap = new Map();
276
+ function remapId(oldId) {
277
+ if (!idMap.has(oldId))
278
+ idMap.set(oldId, uuid());
279
+ return idMap.get(oldId);
280
+ }
281
+ const newSegId = uuid();
282
+ const newMatId = uuid();
283
+ // Clone and remap the material
284
+ const newMat = deepCloneWithIdRemap(template.material.data, remapId);
285
+ newMat.id = newMatId;
286
+ // If text and override provided, update content
287
+ if (overrides?.text && template.type === "text" && typeof newMat.content === "string") {
288
+ try {
289
+ const parsed = JSON.parse(newMat.content);
290
+ if (parsed.text !== undefined) {
291
+ parsed.text = overrides.text;
292
+ if (parsed.styles && parsed.styles.length > 0) {
293
+ const encoded = Buffer.from(overrides.text, "utf16le");
294
+ parsed.styles[0].range = [0, encoded.length];
295
+ }
296
+ newMat.content = JSON.stringify(parsed);
297
+ }
298
+ }
299
+ catch { /* keep original content */ }
300
+ }
301
+ // Register primary material
302
+ if (!draft.materials[template.material.type])
303
+ draft.materials[template.material.type] = [];
304
+ draft.materials[template.material.type].push(newMat);
305
+ // Clone and register extra materials
306
+ const newExtraIds = [];
307
+ for (const extra of template.extra_materials) {
308
+ const newExtra = deepCloneWithIdRemap(extra.data, remapId);
309
+ newExtraIds.push(newExtra.id);
310
+ if (!draft.materials[extra.type])
311
+ draft.materials[extra.type] = [];
312
+ draft.materials[extra.type].push(newExtra);
313
+ }
314
+ // Also add companion materials if the template didn't have them
315
+ if (newExtraIds.length === 0) {
316
+ const companions = createCompanionMaterials(template.type);
317
+ registerCompanions(draft, companions);
318
+ newExtraIds.push(...companions.ids);
319
+ }
320
+ // Find or create track
321
+ let track = draft.tracks.find(t => t.type === template.type);
322
+ if (!track) {
323
+ track = {
324
+ id: uuid(),
325
+ type: template.type,
326
+ name: template.name || template.type,
327
+ attribute: 0,
328
+ segments: [],
329
+ is_default_name: true,
330
+ flag: 0,
331
+ };
332
+ draft.tracks.push(track);
333
+ }
334
+ // Clone segment with new IDs and timing
335
+ const newSeg = { ...template.segment };
336
+ newSeg.id = newSegId;
337
+ newSeg.material_id = newMatId;
338
+ newSeg.raw_segment_id = track.id;
339
+ newSeg.target_timerange = { start, duration };
340
+ if (template.segment.source_timerange) {
341
+ newSeg.source_timerange = { start: 0, duration };
342
+ }
343
+ newSeg.extra_material_refs = newExtraIds;
344
+ // Apply position/scale overrides
345
+ if (overrides && newSeg.clip && typeof newSeg.clip === "object") {
346
+ const clip = newSeg.clip;
347
+ if (overrides.x !== undefined || overrides.y !== undefined) {
348
+ clip.transform = {
349
+ x: overrides.x ?? clip.transform?.x ?? 0,
350
+ y: overrides.y ?? clip.transform?.y ?? 0,
351
+ };
352
+ }
353
+ if (overrides.scaleX !== undefined || overrides.scaleY !== undefined) {
354
+ clip.scale = {
355
+ x: overrides.scaleX ?? clip.scale?.x ?? 1,
356
+ y: overrides.scaleY ?? clip.scale?.y ?? 1,
357
+ };
358
+ }
359
+ }
360
+ track.segments.push(newSeg);
361
+ return { segmentId: newSegId, materialId: newMatId, trackId: track.id };
362
+ }
363
+ function deepCloneWithIdRemap(obj, remapId) {
364
+ const clone = JSON.parse(JSON.stringify(obj));
365
+ // Remap the id field
366
+ if (typeof clone.id === "string") {
367
+ clone.id = remapId(clone.id);
368
+ }
369
+ return clone;
370
+ }