├── .gitignore ├── .npmrc ├── example.gif ├── .lintstagedrc.js ├── .vscode ├── extensions.json └── settings.json ├── .husky ├── commit-msg └── pre-commit ├── tsup.config.ts ├── .editorconfig ├── .commitlintrc.cjs ├── CHANGELOG.md ├── .changeset ├── config.json └── README.md ├── src ├── types.ts ├── index.ts └── export.ts ├── tsconfig.json ├── eslint.config.js ├── .github └── workflows │ └── publish.yml ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | types 4 | .idea 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | dedupe-peer-dependents=true -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emosheeep/capcut-export/HEAD/example.gif -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '**/*.{ts,tsx,js,jsx}': ['eslint'], 3 | }; 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint -e -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | format: 'esm', 5 | clean: true, 6 | entry: ['src/index.ts'], 7 | }); 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset= utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.trace.server": "verbose", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.commitlintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'subject-case': [ 5 | 0, 6 | 'never', 7 | ['sentence-case', 'start-case', 'pascal-case', 'upper-case'], 8 | ], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # capcut-export 2 | 3 | ## 1.0.1 4 | 5 | ### Patch Changes 6 | 7 | - chore: update package.json and dependencies 8 | 9 | ## 1.0.0 10 | 11 | ### Major Changes 12 | 13 | - feat: initial function that exports video clips from draft info made by jianying 14 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface TimeRange { 2 | start: number; 3 | duration: number; 4 | } 5 | 6 | export interface Segment { 7 | id: string; 8 | material_id: string; 9 | source_timerange: TimeRange; 10 | target_timerange: TimeRange; 11 | } 12 | 13 | export interface VideoMaterial { 14 | id: string; 15 | path: string; 16 | type: string; 17 | } 18 | 19 | export interface Track { 20 | id: string; 21 | type: string; 22 | segments: Segment[]; 23 | } 24 | 25 | export interface DraftInfo { 26 | materials: { 27 | videos: VideoMaterial[]; 28 | }; 29 | tracks: Track[]; 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["esnext"], 5 | "baseUrl": ".", 6 | "rootDir": "src", 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | }, 12 | "resolveJsonModule": true, 13 | "types": ["node"], 14 | "allowJs": true, 15 | "strict": true, 16 | "noImplicitAny": false, 17 | "outDir": "dist", 18 | "allowSyntheticDefaultImports": true, 19 | "skipDefaultLibCheck": true, 20 | "skipLibCheck": true 21 | }, 22 | "include": ["src"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config'; 2 | 3 | export default antfu({ 4 | type: 'lib', 5 | ignores: ['**/{README,CHANGELOG}.md'], 6 | stylistic: { 7 | semi: true, 8 | }, 9 | rules: { 10 | 'perfectionist/sort-imports': 'off', 11 | 'no-console': 'off', 12 | 'style/arrow-parens': ['error', 'always'], 13 | 'node/no-callback-literal': 'off', 14 | 'ts/no-var-requires': 'off', 15 | 'ts/explicit-function-return-type': 'off', 16 | 'ts/no-explicit-any': 'off', 17 | 'ts/ban-ts-comment': 'off', 18 | 'ts/no-non-null-assertion': 'off', 19 | 'style/space-before-function-paren': [ 20 | 'error', 21 | { 22 | anonymous: 'never', 23 | named: 'never', 24 | asyncArrow: 'always', 25 | }, 26 | ], 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/changesets/action 2 | name: Release Package 3 | 4 | on: 5 | workflow_dispatch: 6 | 7 | permissions: 8 | pull-requests: write 9 | contents: write 10 | 11 | jobs: 12 | release-package: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 18 19 | - uses: pnpm/action-setup@v2.2.4 20 | with: 21 | version: 8 22 | run_install: true 23 | 24 | # consume changesets and create pr if it exists, otherwise publish package 25 | - name: Publish to npm 26 | uses: changesets/action@v1 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | with: 31 | publish: npx changeset publish 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 情绪羊 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'zx/globals'; 3 | import { createCommand } from 'commander'; 4 | import updateNotifier from 'update-notifier'; 5 | import { description, name, version } from '../package.json'; 6 | import { exportVideos } from './export'; 7 | 8 | const program = createCommand('ccexp'); 9 | 10 | program 11 | .version(version) 12 | .description(description) 13 | .showHelpAfterError('(add --help for additional information)') 14 | .hook('preAction', () => 15 | updateNotifier({ pkg: { name, version } }).notify({ 16 | isGlobal: true, 17 | })) 18 | .argument('', 'CapCut/Jianying draft info json file.') 19 | .argument('[output]', 'The output directory, default is cwd.') 20 | .option( 21 | '-p,--concurrent ', 22 | `The number of tasks processed in parallel, the default is number of CPU.`, 23 | `${os.cpus().length}`, 24 | ) 25 | .option( 26 | '--offset ', 27 | 'Expand the video clips\' time range to both sides for about specific seconds, default is 2s.', 28 | '2', 29 | ) 30 | .option('--verbose', 'To be verbose.', false) 31 | .action((file, output, options) => 32 | exportVideos(file, output, { 33 | ...options, 34 | concurrent: +options.concurrent, 35 | offset: +options.offset, 36 | }), 37 | ); 38 | 39 | program.parse(); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "capcut-export", 3 | "type": "module", 4 | "version": "1.0.1", 5 | "description": "Export video clips from CapCut editor tracks, helps archive materials.", 6 | "author": "秦旭洋 ", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "github", 10 | "url": "https://github.com/emosheeep/capcut-export" 11 | }, 12 | "keywords": [ 13 | "capcut", 14 | "jianying", 15 | "ffmpeg", 16 | "archive", 17 | "video-export", 18 | "wrapper", 19 | "cli" 20 | ], 21 | "bin": { 22 | "ccexp": "./dist/index.js" 23 | }, 24 | "files": [ 25 | "CHANGELOG.md", 26 | "dist" 27 | ], 28 | "scripts": { 29 | "prepare": "husky", 30 | "prepublishOnly": "npm run build", 31 | "lint": "eslint . --fix --ext .js,.ts", 32 | "build": "tsup", 33 | "watch": "tsup --watch", 34 | "changeset": "changeset", 35 | "versions": "changeset version" 36 | }, 37 | "dependencies": { 38 | "commander": "^12.1.0", 39 | "ora": "^8.1.1", 40 | "p-limit": "^6.1.0", 41 | "update-notifier": "^7.3.1", 42 | "zx": "^8.2.4" 43 | }, 44 | "devDependencies": { 45 | "@antfu/eslint-config": "^3.11.2", 46 | "@changesets/cli": "^2.27.10", 47 | "@commitlint/cli": "^19.6.0", 48 | "@commitlint/config-conventional": "^19.6.0", 49 | "@types/fluent-ffmpeg": "^2.1.27", 50 | "@types/node": "^22.10.2", 51 | "@typescript-eslint/eslint-plugin": "^7.18.0", 52 | "@typescript-eslint/parser": "^7.18.0", 53 | "eslint": "^9.16.0", 54 | "husky": "^9.1.7", 55 | "lint-staged": "^15.2.11", 56 | "tsup": "^8.3.5", 57 | "typescript": "^5.7.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/export.ts: -------------------------------------------------------------------------------- 1 | import type { DraftInfo, TimeRange } from './types'; 2 | import process from 'node:process'; 3 | import ora from 'ora'; 4 | import pLimit from 'p-limit'; 5 | 6 | interface Options { 7 | verbose: boolean; 8 | offset?: number; 9 | concurrent?: number; 10 | } 11 | 12 | /** transform timestamp to second */ 13 | const toSeconds = (num: number) => Number.parseFloat((num / 1000000).toFixed(2)); 14 | 15 | function handleDraftInfo(file: string) { 16 | const draftInfo = fs.readJsonSync(file) as DraftInfo; 17 | const { tracks = [], materials } = draftInfo; 18 | const videoTracks = tracks.filter((v) => v.type === 'video'); 19 | const videoMaterials = new Map( 20 | materials.videos 21 | .filter((v) => v.type === 'video') 22 | .map((v) => [ 23 | v.id, 24 | // ##_draftpath_placeholder_[ID]_##/relative/path 25 | v.path.replace(/##.*##/, path.dirname(file)), 26 | ]), 27 | ); 28 | return { 29 | videoTracks, 30 | videoMaterials, 31 | }; 32 | } 33 | 34 | export async function exportVideos( 35 | file: string, 36 | output: string = process.cwd(), 37 | options: Options, 38 | ) { 39 | const { offset = 2, concurrent = os.cpus().length, verbose } = options; 40 | 41 | $.verbose = verbose; 42 | $.quiet = !verbose; 43 | 44 | const startAt = Date.now(); 45 | const concurrency = pLimit(concurrent); 46 | const outputDir = path.isAbsolute(output) ? output : path.resolve(output); 47 | fs.ensureDir(outputDir); 48 | 49 | const { exitCode } = await $`which ffmpeg`.nothrow(); 50 | if (exitCode) { 51 | return console.log( 52 | `fatal: You should manually install ${chalk.bold('ffmpeg')} first, see https://ffmpeg.org/download.html`, 53 | ); 54 | } 55 | 56 | if (offset <= 0) { 57 | return console.log('fatal: offset must be greater than 0'); 58 | } 59 | 60 | const { videoMaterials, videoTracks } = handleDraftInfo(file); 61 | const videoClips: Array = []; 62 | for (const segment of videoTracks.map((v) => v.segments).flat()) { 63 | const videoPath = videoMaterials.get(segment.material_id); 64 | if (videoPath) { 65 | videoClips.push({ 66 | ...segment.source_timerange, 67 | path: videoPath, 68 | }); 69 | } 70 | } 71 | 72 | const spinner = ora( 73 | `Export ${chalk.cyan(videoClips.length)} video clip(s) from ${chalk.cyan(videoTracks.length)} video track(s).`, 74 | ); 75 | 76 | spinner.start(); 77 | const result = await Promise.allSettled( 78 | videoClips.map((video, index) => 79 | concurrency(async () => { 80 | spinner.prefixText = `${videoClips.length - concurrency.pendingCount}/${videoClips.length}`; 81 | const start = Math.max(0, toSeconds(video.start) - offset); 82 | const duration = toSeconds(video.duration) + offset * 2; 83 | if (concurrent === 1) { 84 | spinner.suffixText = `- start:${start},duration:${duration} - ${video.path}`; 85 | } 86 | 87 | if (!fs.existsSync(video.path)) { 88 | throw new Error(`File not found - ${video.path}`); 89 | } 90 | 91 | const outputFile = path.join( 92 | outputDir, 93 | `Clip_${index + 1}${path.extname(video.path)}`, 94 | ); 95 | 96 | await $`ffmpeg -ss ${start} -t ${duration} -i ${video.path} -c copy ${outputFile} -y`; 97 | }), 98 | ), 99 | ); 100 | spinner.prefixText = ''; 101 | spinner.suffixText = ''; 102 | 103 | const successCount = result.reduce((result, item) => { 104 | if (item.status === 'fulfilled') { 105 | result++; 106 | } 107 | else if (item.reason instanceof Error) { 108 | console.log(`\n${chalk.red.bold('error')} ${item.reason.message}`); 109 | } 110 | else { 111 | console.log(item.reason); 112 | } 113 | return result; 114 | }, 0); 115 | 116 | let text = `Done in ${((Date.now() - startAt) / 1000).toFixed(2)}s, succeeds ${chalk.green(successCount)}`; 117 | 118 | if (successCount < result.length) { 119 | text += `, fails ${chalk.red(result.length - successCount)}`; 120 | } 121 | 122 | if (typeof output === 'string' && path.resolve(output) !== process.cwd()) { 123 | text += `, output into ${output}`; 124 | } 125 | 126 | spinner.succeed(`${text}.`); 127 | } 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CapCut/Jianying Video Clips Export 2 | 3 | > **2024-04-15** Lately I find that `CapCut/Jianying Pro` starts to encrypt the draft file in the latest release version to prevent from being used illegally. This project is also affected and will enter the end of life unfortunately 🙁. 4 | 5 | [![npm version](https://img.shields.io/npm/v/capcut-export)](https://npmjs.com/package/capcut-export) 6 | ![weekly downloads](https://img.shields.io/npm/dw/capcut-export) 7 | ![license](https://img.shields.io/npm/l/capcut-export) 8 | 9 | This project is based on [ffmpeg](https://ffmpeg.org/), which is a very well-known low-level tool for cross-platform video processing. **Please manually install it and add `ffmpeg` to your `PATH` first**. 10 | 11 | > Tips: It's convenient to use `brew install ffmpeg` to get ffmpeg installed on MacOS. 12 | 13 | Here's the running example: 14 | 15 | example.gif 16 | 17 | ## Features 18 | 19 | - ⚡️ Based on ffmpeg, which is fast and reliable. 20 | - 💡 Stream copy, without re-encoding and loss of quality. 21 | 22 | ## Quick Start 23 | 24 | ```shell 25 | pnpm i -g capcut-export # or npm/yarn 26 | ``` 27 | 28 | Then you got `ccexp` command in you `PATH` 29 | 30 | ``` 31 | Usage: ccexp [options] [output] 32 | 33 | Export video clips from CapCut editor tracks, helps archive materials. 34 | 35 | Arguments: 36 | file CapCut/Jianying draft info json file. 37 | output The output directory, default is cwd. 38 | 39 | Options: 40 | -V, --version output the version number 41 | -p,--concurrent The number of tasks processed in parallel, the default is number of CPU. 42 | --offset Expand the video clips' time range to both sides for about specific seconds, default is 2s. 43 | --verbose To be verbose. (default: false) 44 | -h, --help display help for command 45 | ``` 46 | 47 | ## Example 48 | 49 | ```shell 50 | ccexp /path/to/draft_info.json # output video clips into current directory. 51 | ccexp /path/to/draft_info.json ./video # output into `./video` folder. 52 | ccexp /path/to/draft_info.json --offset 5 # expand the time range of the segment by 5 seconds on each side. 53 | ccexp /path/to/draft_info.json -p 1 --verbose # set the concurrency to 1 and show the verbose log, usually uses to debug. 54 | ``` 55 | 56 | # Draft Info File 57 | 58 | Search for a draft info json file in your CapCut/Jianying project folder. 59 | 60 | ## On MacOS 61 | 62 | The file is called `draft_info.json` and is located in 63 | 64 | ``` 65 | /Users/user/Movies/CapCut/User Data/Projects/com.lveditor.draft 66 | ``` 67 | 68 | ## On Windows 69 | 70 | The file is named `draft_content.json` and the default location is: 71 | 72 | ``` 73 | C:\Users\user\AppData\Local\CapCut\User Data\Projects\com.lveditor.draft\ 74 | ``` 75 | 76 | # How it works 77 | 78 | First, the tool will extract **the start time**, **the duration** of the clips, and **the video path** from the draft info file. 79 | 80 | Then it use `ffmpeg` to export specific clips, the command is like: 81 | 82 | ```shell 83 | ffmpeg -ss 1 -t 3 -i /path/to/input.mp4 -c copy /path/to/output.mp4 -y 84 | ``` 85 | 86 | In the above command: 87 | 88 | - `-ss` means the start time of video. 89 | - `-t` means the duration of the video clip. 90 | - `-c copy` means copy media steam without re-encoding, so it's a lossless and fast process and won't lose video quality. 91 | - `-y` means automatic confirmation when needed, used to override if a file of the same name exists. 92 | 93 | It means *export a 3s' video clip to output.mp4 from 1s of input.mp4*, and overwrite the output file if it already exists. 94 | 95 | # Motivation 96 | 97 | Sometimes when we finished video edit, there’ll be many clips in your editor track, and *they usually comes from a single video*. Anyway, we have a lot of video clips to export. 98 | 99 | **Assume that you want to archive the materials**, how will you do? Just export one by one? 100 | 101 | Actually most of editors don’t provide this functionality such as Jianying/CapCut, even if they do, **the quality of the video will suffer after they re-encoded it**. 102 | 103 | So we need a lossless, fast, and simple way to manage to do it, this tool is made for this. **It based on ffmpeg using stream copy without re-encoding, which won't lose quality**. 104 | 105 | # Question 106 | 107 | ## Why is the time range of exported video clips not accurate enough? 108 | 109 | In FFmpeg, when performing video editing or extracting, precision issues may arise due to two primary reasons: 110 | 111 | 1. **Video Keyframes**: FFmpeg uses the `-ss` option to jump to a specific timestamp, and **by default, it seeks to the nearest keyframe**. *A keyframe is a frame that can be fully decoded without the need for other frames*. Normally, a video would have a keyframe every few seconds. **If the timestamp you chose does not directly fall on a keyframe, FFmpeg will choose to start from the nearest keyframe before the chosen timestamp, which can cause precision issues**. 112 | 113 | 2. **The video's codec**: Different video codecs and container formats support precise seeking to different extents. Some formats (such as MP4 and MKV) allow relatively accurate seeking, while others may not. --------------------------------------------------------------------------------