UNPKG

capcut-export

Version:

Export video clips from CapCut editor tracks, helps archive materials.

130 lines (125 loc) 4.53 kB
#!/usr/bin/env node // src/index.ts import "zx/globals"; import { createCommand } from "commander"; import updateNotifier from "update-notifier"; // package.json var name = "capcut-export"; var version = "1.0.1"; var description = "Export video clips from CapCut editor tracks, helps archive materials."; // src/export.ts import ora from "ora"; import pLimit from "p-limit"; var toSeconds = (num) => parseFloat((num / 1e6).toFixed(2)); function handleDraftInfo(file) { const draftInfo = fs.readJsonSync(file); const { tracks = [], materials } = draftInfo; const videoTracks = tracks.filter((v) => v.type === "video"); const videoMaterials = new Map( materials.videos.filter((v) => v.type === "video").map((v) => [ v.id, // ##_draftpath_placeholder_[ID]_##/relative/path v.path.replace(/##.*##/, path.dirname(file)) ]) ); return { videoTracks, videoMaterials }; } async function exportVideos(file, output = process.cwd(), options) { const { offset = 2, concurrent = os.cpus().length, verbose } = options; $.verbose = verbose; const startAt = Date.now(); const concurrency = pLimit(concurrent); const outputDir = path.isAbsolute(output) ? output : path.resolve(output); fs.ensureDir(outputDir); const { exitCode } = await $`which ffmpeg`; if (exitCode) { return console.log( `fatal: You should manually install ${chalk.bold("ffmpeg")} first, see https://ffmpeg.org/download.html` ); } if (offset <= 0) { return console.log("fatal: offset must be greater than 0"); } const { videoMaterials, videoTracks } = handleDraftInfo(file); const videoClips = []; for (const segment of videoTracks.map((v) => v.segments).flat()) { const videoPath = videoMaterials.get(segment.material_id); if (videoPath) { videoClips.push({ ...segment.source_timerange, path: videoPath }); } } const spinner = ora( `Export ${chalk.cyan(videoClips.length)} video clip(s) from ${chalk.cyan(videoTracks.length)} video track(s).` ); spinner.start(); const result = await Promise.allSettled( videoClips.map( (video, index) => concurrency(async () => { spinner.prefixText = `${videoClips.length - concurrency.pendingCount}/${videoClips.length}`; const start = Math.max(0, toSeconds(video.start) - offset); const duration = toSeconds(video.duration) + offset * 2; if (concurrent === 1) { spinner.suffixText = `- start:${start},duration:${duration} - ${video.path}`; } if (!fs.existsSync(video.path)) { throw new Error(`File not found - ${video.path}`); } const outputFile = path.join( outputDir, `Clip_${index + 1}${path.extname(video.path)}` ); await $`ffmpeg -ss ${start} -t ${duration} -i ${video.path} -c copy ${outputFile} -y`; }) ) ); spinner.prefixText = ""; spinner.suffixText = ""; const successCount = result.reduce((result2, item) => { if (item.status === "fulfilled") { result2++; } else if (item.reason instanceof Error) { console.log(` ${chalk.red.bold("error")} ${item.reason.message}`); } else { console.log(item.reason); } return result2; }, 0); let text = `Done in ${((Date.now() - startAt) / 1e3).toFixed(2)}s, succeeds ${chalk.green(successCount)}`; if (successCount < result.length) { text += `, fails ${chalk.red(result.length - successCount)}`; } if (typeof output === "string" && path.resolve(output) !== process.cwd()) { text += `, output into ${output}`; } spinner.succeed(`${text}.`); } // src/index.ts var program = createCommand("ccexp"); program.version(version).description(description).showHelpAfterError("(add --help for additional information)").hook( "preAction", () => updateNotifier({ pkg: { name, version } }).notify({ isGlobal: true }) ).argument("<file>", "CapCut/Jianying draft info json file.").argument("[output]", "The output directory, default is cwd.").option( "-p,--concurrent <number>", `The number of tasks processed in parallel, the default is number of CPU.`, `${os.cpus().length}` ).option( "--offset <number>", "Expand the video clips' time range to both sides for about specified seconds, default is 2s.", "2" ).option("--verbose", "To be verbose.", false).action( (file, output, options) => exportVideos(file, output, { ...options, concurrent: +options.concurrent, offset: +options.offset }) ); program.parse();