capcut-export
Version:
Export video clips from CapCut editor tracks, helps archive materials.
130 lines (125 loc) • 4.53 kB
JavaScript
// 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();