
Bggg Tiktok Capcut
- 4 installs
- 553 repo stars
- Updated August 5, 2026
- binggandata/bggg-skills
bggg-tiktok-capcut is a skill that generates editable CapCut drafts from a template and checks AI video for artifacts.
About
This skill generates new CapCut drafts by applying an existing CapCut template to a local AI video, producing drafts that appear on the CapCut home screen and stay editable. It extracts template caption styles, transitions, and effects, validates that a draft will be indexed, and samples frames to check for AI artifacts. It also does local RIFE frame interpolation. A developer uses it to prepare AI videos for CapCut editing.
- Generates editable CapCut drafts from an existing template and a local AI video
- Extracts template caption styles, transitions, animations, and effects
- Samples frames to check AI artifacts and does local RIFE frame interpolation
Bggg Tiktok Capcut by the numbers
- 4 all-time installs (skills.sh)
- Ranked #1,139 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
bggg-tiktok-capcut capabilities & compatibility
- Capabilities
- video generation
- Use cases
- video generation
- Platforms
- macOS
What bggg-tiktok-capcut says it does
通用 CapCut 草稿生成与 AI 视频检查 skill。
基于现有 CapCut 草稿模板生成新草稿
用本地 RIFE 做高质量补帧预处理
npx skills add https://github.com/binggandata/bggg-skills --skill bggg-tiktok-capcutAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 553 |
| Last updated | August 5, 2026 |
| Repository | binggandata/bggg-skills ↗ |
What it does
Generate an editable CapCut draft from a template and a local AI video, and QA it for AI artifacts.
Who is it for?
Applying a CapCut template to a local AI video to make an editable draft and QA'ing AI artifacts.
When should I use this skill?
A user wants to build a CapCut draft from a template or check an AI video for AI artifacts.
What you get
An editable CapCut draft that shows on the home screen, plus an AI-artifact QA report.
- a CapCut draft directory
- an AI-artifact QA contact sheet
By the numbers
- 6 bundled scripts listed in the script inventory
Files
BGGG TikTok CapCut
这个 skill 只保留通用能力:
- 基于现有 CapCut 草稿模板生成新草稿
- 提取模板里的字幕样式、转场、动画、特效
- 验证草稿是否会被 CapCut 索引并显示
- 对 AI 视频或 CapCut 草稿抽帧做 AI 痕迹检查
- 为明显 AI 痕迹生成修复窗口
- 用本地 RIFE 做高质量补帧预处理
默认 CapCut 草稿根目录:
$HOME/Movies/CapCut/User Data/Projects/com.lveditor.draft如果你的 CapCut 草稿目录不同,设置 CAPCUT_DRAFT_ROOT 或给脚本传 --output-dir / --draft-root。
生成草稿
node <skill-dir>/scripts/create-capcut-draft.mjs \
--template "PL-magicrep-PV-001" \
--video "/path/to/ai-video.mp4" \
--name "ai-video-capcut-001" \
--captions "Line 1\nLine 2\nLine 3" \
--split-at 7.5常用参数:
| 参数 | 说明 |
|---|---|
--template | 已存在的 CapCut 草稿目录名 |
--video | 本地 AI 视频路径 |
--name | 新草稿目录名和首页显示名 |
--captions | 字幕文本,可用 \n 分行 |
--srt | SRT 字幕文件,替代 --captions |
--split-at | 视频切分点,转场会挂在切点前一段 |
--no-transition | 不复制模板转场 |
--no-captions | 不生成字幕轨道 |
--output-dir | 自定义 CapCut 草稿根目录 |
--force | 同名草稿已存在时先替换 |
脚本会同步这些 CapCut 会读取的文件,避免草稿目录存在但首页不显示:
draft_info.jsondraft_meta_info.jsondraft_info.json.baktemplate-2.tmpTimelines/project.jsonTimelines/<draft_info.id>/draft_info.jsonTimelines/<draft_info.id>/template.tmpTimelines/<draft_info.id>/template-2.tmproot_meta_info.json
生成后如果 CapCut 已打开,完全退出再打开,让首页重新加载 root_meta_info.json。
验证草稿
node <skill-dir>/scripts/validate-capcut-draft.mjs \
--draft "ai-video-capcut-001" \
--stale-marker "OLD_TEMPLATE_NAME"验证点包括:
- root 索引是否有对应条目
draft_info.id与Timelines/<id>、project.json是否一致- 顶层和嵌套
draft_info/template副本是否同步 - 视频素材路径是否存在
- 是否有旧模板名或旧素材路径残留
提取模板样式
node <skill-dir>/scripts/extract-template-styles.mjs \
--template "PL-magicrep-PV-001" \
--output ./capcut-template-styles.json输出包含字幕样式、转场、动画、特效、画布和轨道结构。
AI 痕迹检查
对单个视频抽帧:
node <skill-dir>/scripts/extract-ai-artifact-frames.mjs \
--video "/path/to/ai-video.mp4" \
--output-root ./ai-artifact-qa对 CapCut 草稿时间线抽帧:
node <skill-dir>/scripts/extract-ai-artifact-frames.mjs \
--draft "ai-video-capcut-001" \
--output-root ./ai-artifact-qa批量检查本地目录:
node <skill-dir>/scripts/extract-ai-artifact-frames.mjs \
--video-dir "/path/to/videos" \
--output-root ./ai-artifact-qa阅读 references/ai-artifact-qa.md 获取判定标准和 review JSON 结构。
修复规划
node <skill-dir>/scripts/plan-ai-artifact-fixes.mjs \
--review ./ai_artifact_review.json \
--output ./ai_artifact_fix_plan.jsonRIFE 补帧
阅读 references/smart-frame-interpolation.md。常用命令:
node <skill-dir>/scripts/smart-frame-interpolate.mjs \
--input "/path/to/source.mp4" \
--output "/path/to/source_60fps_rife.mp4"故障排查
先运行:
node <skill-dir>/scripts/validate-capcut-draft.mjs --draft "<draft-name>"常见问题见 references/failure-modes.md。
脚本清单
| 脚本 | 用途 |
|---|---|
scripts/create-capcut-draft.mjs | 基于模板生成可显示的 CapCut 草稿 |
scripts/validate-capcut-draft.mjs | 验证单个草稿结构和索引 |
scripts/extract-template-styles.mjs | 提取模板样式 |
scripts/extract-ai-artifact-frames.mjs | 对视频/草稿抽帧生成 contact sheet |
scripts/plan-ai-artifact-fixes.mjs | 根据 review JSON 规划修复窗口 |
scripts/smart-frame-interpolate.mjs | 本地 RIFE 补帧 |
interface:
display_name: "BGGG TikTok CapCut"
short_description: "Build and validate CapCut drafts from AI videos."
default_prompt: "Use this skill to create CapCut drafts from AI videos, extract template styles, validate draft visibility, sample AI artifact frames, plan repairs, or run local RIFE interpolation."
{
"skill_name": "bggg-tiktok-capcut",
"evals": [
{
"id": 1,
"prompt": "把这个 AI 视频套用现有 CapCut 模板生成一个新草稿,字幕我给你三行。",
"expected_output": "Agent should run create-capcut-draft.mjs with a template, source video, draft name, captions, then run validate-capcut-draft.mjs and tell the user to reopen CapCut if it was already running."
},
{
"id": 2,
"prompt": "我看到草稿目录生成了,但 CapCut 首页不显示这个草稿,帮我查原因。",
"expected_output": "Agent should inspect root_meta_info.json, draft_info.path, Timelines/project.json, Timelines/<draft_info.id>, nested draft_info/template files, stale template markers, then repair or regenerate with --force."
},
{
"id": 3,
"prompt": "帮我检查这个 AI 视频有没有明显 AI 痕迹,先抽帧做 contact sheet。",
"expected_output": "Agent should run extract-ai-artifact-frames.mjs with --video or --draft, review contact sheets, and create an issue-oriented review only for publish-blocking artifacts."
},
{
"id": 4,
"prompt": "这个素材慢动作有抽帧感,帮我做不会液化的智能补帧。",
"expected_output": "Agent should use smart-frame-interpolate.mjs with local RIFE ncnn Vulkan, keep TTA enabled, split at scene cuts, avoid minterpolate fallback, and report the output video plus interpolation manifest."
}
]
}
bggg-tiktok-capcut
中文 | English
bggg-tiktok-capcut is a Codex skill for creating editable CapCut drafts, extracting template styles, validating draft structure, and checking AI-video artifacts. It turns a local AI video plus an existing CapCut template draft into a new draft that CapCut can index and open.
Features
- Create a new CapCut draft from an existing template draft.
- Extract caption styles, transitions, animations, and effects.
- Validate root indexes, nested timelines, and media paths.
- Sample frames from a video or draft and create contact sheets for review.
- Plan artifact repair windows from a review JSON file.
- Run local RIFE interpolation for high-quality frame filling.
Install
mkdir -p ~/.codex/skills
cp -R bggg-tiktok-capcut ~/.codex/skills/The default CapCut draft root is:
$HOME/Movies/CapCut/User Data/Projects/com.lveditor.draftOverride it when needed:
export CAPCUT_DRAFT_ROOT="/path/to/com.lveditor.draft"Usage
Create a draft:
node bggg-tiktok-capcut/scripts/create-capcut-draft.mjs \
--template "template-draft-name" \
--video "/path/to/ai-video.mp4" \
--name "new-draft-name" \
--captions "Hook\nProof\nCTA"Validate a draft:
node bggg-tiktok-capcut/scripts/validate-capcut-draft.mjs --draft "new-draft-name"Sample frames for review:
node bggg-tiktok-capcut/scripts/extract-ai-artifact-frames.mjs \
--video "/path/to/ai-video.mp4" \
--output-root bggg-tiktok-capcut/projects/ai-artifact-qaSafety
Do not commit CapCut drafts, videos, screenshots, interpolation intermediates, or review outputs. Keep run artifacts under projects/ or another local work directory.
bggg-tiktok-capcut
中文 | English
bggg-tiktok-capcut 是一个用于 CapCut 草稿生成、模板样式提取和 AI 视频质量检查的 Codex skill。它可以把本地 AI 视频套用已有 CapCut 草稿模板,生成可在 CapCut 首页显示并继续编辑的新草稿,也可以抽帧检查 AI 痕迹并规划修复窗口。
能做什么
- 基于已有 CapCut 草稿模板生成新草稿。
- 提取模板中的字幕样式、转场、动画和特效。
- 验证草稿索引、嵌套 timeline 和素材路径是否一致。
- 对视频或 CapCut 草稿抽帧,生成 contact sheet 和 review 模板。
- 根据 review JSON 规划 AI 痕迹修复窗口。
- 调用本地 RIFE 后端做高质量补帧预处理。
安装
mkdir -p ~/.codex/skills
cp -R bggg-tiktok-capcut ~/.codex/skills/默认 CapCut 草稿目录为:
$HOME/Movies/CapCut/User Data/Projects/com.lveditor.draft如果你的目录不同:
export CAPCUT_DRAFT_ROOT="/path/to/com.lveditor.draft"使用
生成草稿:
node bggg-tiktok-capcut/scripts/create-capcut-draft.mjs \
--template "template-draft-name" \
--video "/path/to/ai-video.mp4" \
--name "new-draft-name" \
--captions "Hook\nProof\nCTA"验证草稿:
node bggg-tiktok-capcut/scripts/validate-capcut-draft.mjs --draft "new-draft-name"抽帧检查:
node bggg-tiktok-capcut/scripts/extract-ai-artifact-frames.mjs \
--video "/path/to/ai-video.mp4" \
--output-root bggg-tiktok-capcut/projects/ai-artifact-qa注意
不要提交 CapCut 草稿、视频、截图、补帧中间文件或 review 结果。运行产物请放在 projects/ 或外部工作目录。
AI Artifact QA Flow
Use this flow after draft generation/validation and before final delivery when the user asks to check AI video artifacts.
Frame Sampling
Extract evenly spaced frames from the final draft timeline, not only from the raw source file.
Frame counts:
- About 15 seconds: 20 frames.
- About 30 seconds: 40 frames.
Use timestamped frame names so findings can map back to the video:
node scripts/extract-ai-artifact-frames.mjs --draft ai-video-capcut-001 --output-root /path/to/qa-run
node scripts/extract-ai-artifact-frames.mjs --video /path/to/ai-video.mp4 --output-root /path/to/qa-run
node scripts/extract-ai-artifact-frames.mjs --video-dir /path/to/videos --output-root /path/to/qa-runThe script writes:
<output-root>/<draft-name>/
├── frames/
│ ├── 001_t00m00s000ms.jpg
│ └── ...
├── frames_manifest.json
└── ai_artifact_review.template.jsonReview Standard
Only mark obvious AI artifacts that affect publish quality. Do not mark ordinary aesthetic issues, mild awkwardness, subjective style dislikes, or normal Seedance variation.
Check each frame for:
1. Text, numbers, logos, package words, App UI: garbled, mirrored, skewed, missing strokes, impossible typography. 2. Hands, fingers, wrists, arms: extra/missing fingers, fused fingers, melted skin, reversed joints, impossible bends. 3. Hand-product intersection: hand passing through boxes, shoes, phones, parcels, bags. 4. Incomplete person/object: missing head, missing shoe half, cut-off product corner, missing body area. 5. Product deformation: wrong logo, color, structure, laces, zipper, screen, buttons, sole shape. 6. Object continuity: sudden appearance/disappearance, duplication, fusion, shape swapping. 7. Physics errors: floating objects, impossible unboxing, impossible grip. 8. Light, reflection, shadow, perspective errors that are obvious enough to hurt trust. 9. Background melting, bending, repetition, or deformation. 10. Over-smoothing, oily surface, local mushiness, edge warping.
Review JSON Schema
Create ai_artifact_review.json from the template. Use this shape:
{
"draft_name": "ai-video-capcut-001",
"duration_sec": 15.04,
"issues": [
{
"frame_file": "frames/010_t00m07s100ms.jpg",
"time_sec": 7.1,
"severity": "high",
"publish_blocking": true,
"categories": ["hands", "hand_product_intersection"],
"description": "Index finger visibly passes through the shoe upper while gripping it.",
"recommended_fix": "Cover 4.1-10.1s with product B-roll or motion blur around the hand movement."
}
]
}Severity guidance:
critical: obviously unpublishable at a glance.high: visible enough that a viewer may notice and lose trust.medium: visible only while pausing or looking carefully; fix only if there are few issues.low: do not process unless user asks for very strict QC.
Fix Selection
A 15-second video normally has at most 1-2 publish-blocking AI issues. A 30-second video normally has at most 1-3. Select the most severe few; do not over-process the whole video.
Generate a repair plan:
node scripts/plan-ai-artifact-fixes.mjs \
--review /path/to/ai_artifact_review.json \
--output /path/to/ai_artifact_fix_plan.jsonThe plan expands each bad frame to a time_sec ± 3s window and recommends fixes. Merge overlapping windows.
Fix Methods
Choose the least disruptive method that hides the artifact:
- Transition: use only at a semantic cut, product reveal, camera move, or change of demonstration state.
- Sticker/overlay: use when a localized text/logo/hand defect can be naturally covered.
- Motion blur: use for hand movement, fast object movement, or short unstable moments.
- Depth blur: use for background melting, bad text in the background, or mild perspective issues outside the subject.
- Filter: use for global over-smoothing/lighting mismatch when the issue is subtle.
- B-roll: use for severe hand/product deformation, impossible physics, product-logo defects, or central object failures.
Avoid:
- Adding a transition at the video end to hide a broken ending.
- Covering too many seconds when only one frame is bad.
- Marking non-publish-blocking aesthetic preferences as AI artifacts.
Reporting
Report:
- Frame extraction output directory.
- Number of frames reviewed.
- Number of publish-blocking issues found.
- Fix windows and chosen fix type.
- Any drafts that should be regenerated instead of patched because the artifact is too central or persistent.
CapCut Failure Modes
Draft Folder Exists But CapCut Homepage Does Not Show It
Likely causes:
root_meta_info.jsonis missing the draft or has stale fields.draft_info.json.pathstill points to the template draft.Timelines/project.json.main_timeline_iddoes not matchdraft_info.id.Timelines/<draft_info.id>/is missing.- Nested
draft_info.json,template.tmp, ortemplate-2.tmpstill contain old template IDs or names. - CapCut was open and cached the old index.
Note: after CapCut loads a draft, it may rewrite some path fields to a value like ##_draftpath_placeholder_<UUID>_##. Treat that as valid CapCut-owned state, not as a broken path.
Fix:
1. Run scripts/validate-capcut-draft.mjs --draft "<draft-name>". 2. Regenerate with scripts/create-capcut-draft.mjs --force .... 3. Quit CapCut completely and reopen it.
Unsupported Media / No Access
Likely causes:
- Video material path points to a missing source file.
- A nested timeline copy still references the old source video.
- CapCut cache under
Timelines/<id>/attachment/patchcontains stale media refs.
Fix:
- Make the video path absolute and readable.
- Regenerate the draft with
--force. - Remove stale patch cache only inside the generated draft if validation still fails.
Transition Appears At Video End
Cause: transition material ref was attached to the final segment instead of the previous segment.
Fix:
- Put the transition ref on the segment before the cut.
- Ensure the final segment has no transition ref.
- Re-run validation and inspect in CapCut.
Subtitle Overflow
Symptom: subtitle text runs outside the 9:16 canvas.
Fix:
- Split long captions into shorter segments.
- Insert newline breaks.
- Use a template with a sane font size and line width.
Abrupt Ending
Symptom: final caption or visual action ends mid-thought.
Fix:
- Trim the source video before draft generation, or regenerate with a cleaner source.
- Do not add a transition at the very end to hide an unfinished ending.
Liquid Frame Interpolation Artifacts
Symptom: faces, hands, logos, text, or backgrounds look melted, warped, oily, or blended between shots.
Fix:
- Use
scripts/smart-frame-interpolate.mjswith local RIFE. - Do not use FFmpeg
minterpolatefallback. - Split at scene cuts and interpolate only continuous motion.
- Never interpolate across product swaps, text/logo changes, or hard cuts.
CapCut Draft Playbook
Use this reference when building or debugging a CapCut draft from an AI-generated video.
Directory Model
Default draft root:
~/Movies/CapCut/User Data/Projects/com.lveditor.draft/A valid generated draft should include:
<draft-name>/
├── draft_info.json
├── draft_info.json.bak
├── draft_meta_info.json
├── draft_cover.jpg
├── template-2.tmp
├── draft_settings
├── Resources/
├── Timelines/
│ ├── project.json
│ ├── project.json.bak
│ └── <draft_info.id>/
│ ├── draft_info.json
│ ├── draft_info.json.bak
│ ├── template.tmp
│ └── template-2.tmp
└── ai_cut_manifest.jsonCapCut does not only read the top-level draft_info.json. Keep the nested timeline copies and Timelines/project.json synchronized with draft_info.id.
Template Selection
Choose a template draft that already has the style you want:
- subtitle font, color, background, border, shadow
- one useful transition at the cut point
- animation/material style if needed
- same target aspect ratio when possible
The template provides style and structure only. The generated draft must replace all source video paths with the new AI video path.
Caption Flow
Use either explicit captions or SRT:
--captions "Hook\nStep 1\nStep 2"
--srt "/path/to/subtitles.srt"The script writes CapCut text materials and text track segments while copying the first template text style.
Transition Flow
CapCut stores transitions as material refs on a video segment. Attach the transition to the segment before the cut.
Correct:
segment 1: extra_material_refs includes transition
segment 2: no transition refWrong:
segment 1: no transition ref
segment 2: extra_material_refs includes transitionThe wrong structure can make CapCut show the transition at the end of the video.
Validation
Run this after generation:
node scripts/validate-capcut-draft.mjs --draft "<draft-name>"If a copied template name or old media path might remain, add:
--stale-marker "OLD_TEMPLATE_NAME"
--stale-marker "/old/source/video.mp4"CapCut Reload
If CapCut was open while writing the draft, quit CapCut completely and reopen it. The homepage list is driven by root_meta_info.json plus per-draft metadata and can cache stale state while the app is running.
Final QA
For visual QA, sample frames from the final draft timeline:
node scripts/extract-ai-artifact-frames.mjs \
--draft "<draft-name>" \
--output-root ./ai-artifact-qaThen review the contact sheet and create an ai_artifact_review.json only for publish-blocking AI issues.
Smart Frame Interpolation
This capability is only for high-quality, performance-heavy frame interpolation before a video is imported into CapCut or before a draft is rebuilt. It is not a beat-sync tool and it is not a general AI-artifact repair method.
Backend Policy
Use local RIFE ncnn Vulkan only:
rife-ncnn-vulkan + rife-v4.6
spatial TTA on
temporal TTA on
target fps 60 by defaultDo not silently fall back to FFmpeg minterpolate, frame blending, or simple optical-flow interpolation. Those methods can create liquid faces, warped hands, drifting logo text, and melting backgrounds. If RIFE is missing, stop and ask for the backend or set:
export BGGG_RIFE_NCNN="/path/to/rife-ncnn-vulkan"
export BGGG_RIFE_MODEL="/path/to/rife-v4.6"The script also searches this skill's optional local backend folder:
bggg-tiktok-capcut/tools/rife-ncnn-vulkan-20221029-macos/Command
Whole video to 60fps, preserving audio:
node scripts/smart-frame-interpolate.mjs \
--input "/path/to/source.mp4" \
--output "/path/to/source_60fps_rife.mp4"Specific continuous range only:
node scripts/smart-frame-interpolate.mjs \
--input "/path/to/source.mp4" \
--output "/path/to/intro_60fps_rife.mp4" \
--start 0 \
--end 2.0Use explicit safe segments:
{
"segments": [
{"label": "lead_in", "start": 0.0, "end": 2.0},
{"label": "demo_motion", "start": 5.2, "end": 8.6}
]
}node scripts/smart-frame-interpolate.mjs \
--input "/path/to/source.mp4" \
--output "/path/to/source_rife_segments.mp4" \
--segments "/path/to/segments.json" \
--no-keep-audioCut Protection
The default mode runs scene-cut detection and splits the source before sending frames to RIFE. Never interpolate across:
- hard cuts
- jump cuts
- outfit or product swaps
- beat-hit frames
- text/logo changes
- generated-object shape changes
Interpolate only continuous motion inside one shot. This is the main reason the output avoids the liquid look.
When To Use
Use this for:
- low-fps source videos that look choppy in CapCut
- slow-motion segments that show repeated frames
- AI-generated videos where a small continuous movement should feel smoother
- pre-import normalization to 60fps before draft creation
Do not use this to hide major AI defects. If hands, products, text, or physics are already wrong, follow ai-artifact-qa.md and either repair with B-roll/overlay or regenerate.
#!/usr/bin/env node
/**
* create-capcut-draft.mjs — 基于模板草稿 + AI 视频生成 CapCut 草稿
*
* 用法:
* node create-capcut-draft.mjs \
* --template "DE-chilebroskii-HV-001" \
* --video "/path/to/ai-video.mp4" \
* --name "my-new-draft" \
* --captions "caption1\ncaption2\ncaption3"
*
* node create-capcut-draft.mjs \
* --template "DE-chilebroskii-HV-001" \
* --video "/path/to/ai-video.mp4" \
* --name "my-new-draft" \
* --srt "/path/to/subtitles.srt"
*
* 环境变量:
* CAPCUT_DRAFT_ROOT — CapCut 草稿根目录
*/
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
const CAPCUT_DRAFT_ROOT = process.env.CAPCUT_DRAFT_ROOT || path.join(os.homedir(), "Movies", "CapCut", "User Data", "Projects", "com.lveditor.draft");
const ONE_SECOND_US = 1_000_000;
const args = parseArgs(process.argv.slice(2));
if (args.help || args.h) {
console.log(`
Usage: node create-capcut-draft.mjs [options]
Options:
--template <name> Template draft name (required)
--video <path> Input video path (required)
--name <name> New draft name (required)
--captions <text> Caption text, one per line (optional)
--srt <path> SRT subtitle file path (optional)
--output-dir <dir> CapCut draft root dir (default: ~/Movies/CapCut/...)
--split-at <seconds> Split video at this timestamp for transition
--transition <name> Transition name to use (default: from template)
--no-transition Do not add transition
--no-captions Do not add captions
--force Replace an existing draft folder with the same name
--help, -h Show this help
Examples:
node create-capcut-draft.mjs --template DE-chilebroskii-HV-001 \\
--video ~/workspace/seedance-skincare-15s.mp4 \\
--name seedance-skincare-001 \\
--captions "Double Cleanse Routine\nStep 1: Apply Oil\nStep 2: Emulsify\nStep 3: Rinse"
`);
process.exit(0);
}
main().catch((err) => {
console.error(err.stack || err.message);
process.exitCode = 1;
});
async function main() {
const templateName = args.template;
const videoPath = args.video ? path.resolve(args.video) : null;
const draftName = args.name;
const outputDir = args["output-dir"] ? path.resolve(args["output-dir"]) : CAPCUT_DRAFT_ROOT;
if (!templateName || !videoPath || !draftName) {
console.error("Error: --template, --video, and --name are required");
process.exitCode = 1;
return;
}
// 1. 读取模板草稿
const templateDir = path.join(outputDir, templateName);
const templateDraftPath = path.join(templateDir, "draft_info.json");
const templateMetaPath = path.join(templateDir, "draft_meta_info.json");
console.log(`Reading template: ${templateDraftPath}`);
const templateDraft = JSON.parse(await fs.readFile(templateDraftPath, "utf-8"));
let templateMeta = null;
try {
templateMeta = JSON.parse(await fs.readFile(templateMetaPath, "utf-8"));
} catch {}
// 2. 探测输入视频
const videoInfo = await probeVideo(videoPath);
console.log(`Video: ${videoInfo.width}x${videoInfo.height}, ${videoInfo.duration}s, ${videoInfo.fps}fps`);
// 3. 提取模板样式
const templateStyles = extractTemplateStyles(templateDraft);
console.log(`Template styles: ${templateStyles.textCount} texts, ${templateStyles.transitionCount} transitions, ${templateStyles.animationCount} animations`);
// 4. 生成新草稿
const nowUs = Date.now() * 1000;
const newDraft = buildDraft({
templateDraft,
templateMeta,
templateStyles,
videoPath,
videoInfo,
draftName,
nowUs,
captions: await resolveCaptions(args),
splitAt: args["split-at"] ? Number(args["split-at"]) : null,
noTransition: args["no-transition"],
noCaptions: args["no-captions"],
});
// 5. 写入新草稿目录
const newDraftDir = path.join(outputDir, draftName);
if (args.force) {
await fs.rm(newDraftDir, { recursive: true, force: true });
}
await fs.mkdir(newDraftDir, { recursive: true });
// 复制模板中的非 JSON 文件(Resources, Timelines, 图片等)
await copyTemplateAssets(templateDir, newDraftDir);
const mediaSize = (await fs.stat(videoPath)).size;
const newMeta = buildMeta(templateMeta, {
draftName,
draftRoot: outputDir,
draftDir: newDraftDir,
draft: newDraft,
templateDir,
templateName,
videoPath,
videoInfo,
mediaSize,
nowUs,
});
await writeDraftPackage({
draftRoot: outputDir,
draftName,
draftDir: newDraftDir,
draft: newDraft,
meta: newMeta,
templateDraft,
templateDir,
templateName,
videoPath,
mediaSize,
nowUs,
});
// 写入 ai_cut_manifest.json
const manifest = {
created_at: new Date().toISOString(),
template: templateName,
source_video: videoPath,
draft_name: draftName,
draft_id: newDraft.id,
video_info: videoInfo,
captions: newDraft._captions || [],
};
await writeJson(path.join(newDraftDir, "ai_cut_manifest.json"), manifest);
// 更新 CapCut 根索引
await updateRootMetaInfo(outputDir, draftName, newDraftDir, videoInfo, {
draft: newDraft,
meta: newMeta,
mediaSize,
nowUs,
});
console.log(`\nDone: ${newDraftDir}`);
console.log(`Draft name: ${draftName}`);
console.log(`Draft id: ${newDraft.id}`);
console.log(`Open/reopen CapCut to edit`);
}
// ==================== 模板样式提取 ====================
function extractTemplateStyles(draft) {
const materials = draft.materials || {};
const styles = {
textCount: (materials.texts || []).length,
transitionCount: (materials.transitions || []).length,
animationCount: (materials.material_animations || []).length,
texts: [],
transitions: [],
animations: [],
effects: [],
canvases: materials.canvases || [],
textTemplates: materials.text_templates || [],
};
// 提取字幕样式(取第一个作为模板)
for (const text of materials.texts || []) {
styles.texts.push({
id: text.id,
content: text.content,
base_content: text.base_content,
font_size: text.font_size,
text_color: text.text_color,
font_path: text.font_path,
font_id: text.font_id,
font_resource_id: text.font_resource_id,
bold_width: text.bold_width,
italic_degree: text.italic_degree,
line_spacing: text.line_spacing,
letter_spacing: text.letter_spacing,
alignment: text.alignment,
line_feed: text.line_feed,
line_max_width: text.line_max_width,
subtitle_keywords_config: text.subtitle_keywords_config,
fonts: text.fonts,
has_shadow: text.has_shadow,
shadow_color: text.shadow_color,
shadow_alpha: text.shadow_alpha,
shadow_distance: text.shadow_distance,
border_alpha: text.border_alpha,
border_color: text.border_color,
border_width: text.border_width,
background_color: text.background_color,
background_alpha: text.background_alpha,
background_style: text.background_style,
background_round_radius: text.background_round_radius,
background_width: text.background_width,
background_height: text.background_height,
});
}
// 提取转场
for (const tr of materials.transitions || []) {
styles.transitions.push({
id: tr.id,
name: tr.name,
effect_id: tr.effect_id,
resource_id: tr.resource_id,
third_resource_id: tr.third_resource_id,
source_platform: tr.source_platform,
path: tr.path,
duration: tr.duration,
is_overlap: tr.is_overlap,
platform: tr.platform,
category_id: tr.category_id,
category_name: tr.category_name,
});
}
// 提取动画
for (const anim of materials.material_animations || []) {
styles.animations.push({
id: anim.id,
type: anim.type,
animations: anim.animations,
});
}
return styles;
}
// ==================== 草稿构建 ====================
function buildDraft(opts) {
const { templateDraft, templateStyles, videoPath, videoInfo, draftName, nowUs, captions, splitAt, noTransition, noCaptions } = opts;
const durationUs = Math.round(videoInfo.duration * ONE_SECOND_US);
const splitUs = splitAt ? Math.round(splitAt * ONE_SECOND_US) : Math.round(durationUs / 2);
const seg1Dur = splitUs;
const seg2Dur = durationUs - splitUs;
// 生成新的 UUID 映射
const idMap = new Map();
const newId = (oldId) => {
if (!oldId) return oldId;
if (!idMap.has(oldId)) {
idMap.set(oldId, randomUUID().toUpperCase());
}
return idMap.get(oldId);
};
// 复制并修改 draft
const draft = JSON.parse(JSON.stringify(templateDraft));
// 更新基本信息
draft.id = randomUUID().toUpperCase();
draft.name = draftName;
draft.path = "";
draft.duration = durationUs;
draft.create_time = nowUs;
draft.update_time = nowUs;
draft.fps = videoInfo.fps || 30;
draft.canvas_config = {
ratio: "original",
width: videoInfo.width,
height: videoInfo.height,
background: null,
};
// 清理旧的字幕识别数据
if (draft.config) {
draft.config.subtitle_recognition_id = "";
draft.config.subtitle_taskinfo = [];
draft.config.lyrics_recognition_id = "";
draft.config.lyrics_taskinfo = [];
}
// 构建 materials
const materials = draft.materials || {};
// 视频素材
const videoMaterialId = randomUUID().toUpperCase();
materials.videos = [{
...((materials.videos || [])[0] || {}),
id: videoMaterialId,
duration: durationUs,
path: videoPath,
media_path: "",
width: videoInfo.width,
height: videoInfo.height,
material_name: path.basename(videoPath),
local_material_id: randomUUID().toLowerCase(),
}];
// 清理不需要的素材类型
materials.audios = [];
materials.stickers = [];
materials.beats = [];
materials.audio_effects = [];
materials.audio_fades = [];
materials.video_effects = [];
materials.plugin_effects = [];
// 转场:保留模板中的转场,但更新 ID
if (!noTransition && templateStyles.transitions.length > 0) {
const tr = templateStyles.transitions[0];
const newTrId = randomUUID().toUpperCase();
materials.transitions = [{
...tr,
id: newTrId,
}];
} else {
materials.transitions = [];
}
// 字幕
const textSegments = [];
const textMaterials = [];
const textAnimations = [];
if (!noCaptions && captions.length > 0 && templateStyles.texts.length > 0) {
const templateText = templateStyles.texts[0];
const totalDuration = durationUs;
const captionDuration = Math.floor(totalDuration / captions.length);
for (let i = 0; i < captions.length; i++) {
const text = captions[i];
const start = i * captionDuration;
const dur = (i === captions.length - 1) ? (totalDuration - start) : captionDuration;
const textMaterialId = randomUUID().toUpperCase();
const textSegId = randomUUID().toUpperCase();
// 构建 text material
const textMaterial = buildTextMaterial(templateText, textMaterialId, text);
textMaterials.push(textMaterial);
// 构建 text segment
textSegments.push({
id: textSegId,
source_timerange: null,
target_timerange: { start, duration: dur },
render_timerange: { start: 0, duration: 0 },
material_id: textMaterialId,
extra_material_refs: [],
render_index: 14000 + i,
track_render_index: 1,
clip: {
scale: { x: 1, y: 1 },
rotation: 0,
transform: { x: 0, y: -0.56 },
flip: { vertical: false, horizontal: false },
alpha: 1,
},
uniform_scale: { on: true, value: 1 },
visible: true,
speed: 1,
volume: 1,
state: 0,
enable_lut: false,
enable_adjust: false,
enable_hsl: false,
group_id: "",
enable_color_curves: true,
enable_hsl_curves: true,
track_attribute: 0,
is_placeholder: false,
template_id: "",
template_scene: "default",
common_keyframes: [],
caption_info: null,
responsive_layout: {
enable: false,
target_follow: "",
size_layout: 0,
horizontal_pos_layout: 0,
vertical_pos_layout: 0,
},
enable_color_match_adjust: false,
enable_color_correct_adjust: false,
enable_adjust_mask: false,
raw_segment_id: "",
lyric_keyframes: null,
enable_video_mask: true,
digital_human_template_group_id: "",
color_correct_alg_result: "",
source: "segmentsourcenormal",
enable_mask_stroke: false,
enable_mask_shadow: false,
enable_color_adjust_pro: false,
});
// 复制动画(如果有)
if (templateStyles.animations.length > 0) {
const anim = templateStyles.animations[0];
const newAnimId = randomUUID().toUpperCase();
textAnimations.push({
...anim,
id: newAnimId,
});
// 把动画引用加到 segment 的 extra_material_refs
const seg = textSegments[textSegments.length - 1];
seg.extra_material_refs.push(newAnimId);
}
}
}
materials.texts = textMaterials;
materials.material_animations = textAnimations;
// 构建 tracks
const videoTrack = {
id: randomUUID().toUpperCase(),
type: "video",
segments: [],
flag: 0,
attribute: 0,
name: "",
is_default_name: true,
path: videoPath,
};
// 视频分段
const seg1Id = randomUUID().toUpperCase();
const seg2Id = randomUUID().toUpperCase();
const baseSeg = {
source_timerange: { start: 0, duration: 0 },
render_timerange: { start: 0, duration: 0 },
desc: "",
state: 0,
speed: 1,
is_loop: false,
is_tone_modify: false,
reverse: false,
intensifies_audio: false,
cartoon: false,
volume: 1,
last_nonzero_volume: 1,
clip: {
scale: { x: 1, y: 1 },
rotation: 0,
transform: { x: 0, y: 0 },
flip: { vertical: false, horizontal: false },
alpha: 1,
},
uniform_scale: { on: true, value: 1 },
material_id: videoMaterialId,
render_index: 0,
keyframe_refs: [],
enable_lut: true,
enable_adjust: true,
enable_hsl: false,
visible: true,
group_id: "",
enable_color_curves: true,
enable_hsl_curves: true,
track_render_index: 0,
hdr_settings: { mode: 1, intensity: 1, nits: 1000 },
enable_color_wheels: true,
track_attribute: 0,
is_placeholder: false,
template_id: "",
enable_smart_color_adjust: false,
template_scene: "default",
common_keyframes: [],
caption_info: null,
responsive_layout: {
enable: false,
target_follow: "",
size_layout: 0,
horizontal_pos_layout: 0,
vertical_pos_layout: 0,
},
enable_color_match_adjust: false,
enable_color_correct_adjust: false,
enable_adjust_mask: false,
raw_segment_id: "",
lyric_keyframes: null,
enable_video_mask: true,
digital_human_template_group_id: "",
color_correct_alg_result: "",
source: "segmentsourcenormal",
enable_mask_stroke: false,
enable_mask_shadow: false,
enable_color_adjust_pro: false,
};
// 第一段
const seg1 = {
...JSON.parse(JSON.stringify(baseSeg)),
id: seg1Id,
source_timerange: { start: 0, duration: seg1Dur },
target_timerange: { start: 0, duration: seg1Dur },
};
// 第二段
const seg2 = {
...JSON.parse(JSON.stringify(baseSeg)),
id: seg2Id,
source_timerange: { start: seg1Dur, duration: seg2Dur },
target_timerange: { start: seg1Dur, duration: seg2Dur },
};
// 添加转场引用到第一段
if (!noTransition && materials.transitions.length > 0) {
const transitionId = materials.transitions[0].id;
seg1.extra_material_refs = [
...(seg1.extra_material_refs || []),
transitionId,
];
}
videoTrack.segments = [seg1, seg2];
// 文本轨道
const textTrack = {
id: randomUUID().toUpperCase(),
type: "text",
segments: textSegments,
flag: 1,
attribute: 0,
name: "",
is_default_name: true,
};
draft.tracks = [videoTrack, textTrack];
// 保留其他 materials 但清理引用
materials.flowers = [];
materials.tail_leaders = [];
materials.images = [];
materials.effects = [];
materials.stickers = [];
materials.audio_effects = [];
materials.audio_fades = [];
materials.beats = [];
materials.video_effects = [];
materials.plugin_effects = [];
// 保存 captions 到草稿对象(用于 manifest)
draft._captions = captions;
return draft;
}
function buildTextMaterial(template, id, text) {
const content = buildTextContent(template, text);
return {
recognize_task_id: "",
id: id,
name: randomUUID().toUpperCase(),
recognize_text: text,
recognize_model: "",
punc_model: "",
type: "text",
content: content,
base_content: "",
words: { start_time: [], end_time: [], text: [] },
current_words: { start_time: [], end_time: [], text: [] },
global_alpha: 1,
combo_info: { text_templates: [] },
caption_template_info: {
resource_id: "",
third_resource_id: "",
resource_name: "",
category_id: "",
category_name: "",
effect_id: "",
request_id: "",
path: "",
is_new: false,
source_platform: 0,
},
layer_weight: 1,
letter_spacing: template.letter_spacing || 0,
text_curve: null,
text_loop_on_path: false,
offset_on_path: 0,
enable_path_typesetting: false,
text_exceeds_path_process_type: 0,
text_typesetting_paths: null,
text_typesetting_paths_file: "",
text_typesetting_path_index: 0,
line_spacing: template.line_spacing || 0.02,
has_shadow: template.has_shadow || false,
shadow_color: template.shadow_color || "",
shadow_alpha: template.shadow_alpha || 0,
shadow_smoothing: 0,
shadow_distance: template.shadow_distance || 5,
shadow_point: { x: 0, y: 0 },
shadow_angle: -45,
shadow_thickness_projection_enable: false,
shadow_thickness_projection_angle: 0,
shadow_thickness_projection_distance: 0,
border_alpha: template.border_alpha || 0,
border_color: template.border_color || "",
border_width: template.border_width || 0,
border_mode: 0,
style_name: "",
text_color: template.text_color || "#ffffffff",
text_alpha: 1,
font_name: "",
font_title: "none",
font_size: template.font_size || 12,
font_path: template.font_path || "",
font_id: template.font_id || "",
font_resource_id: template.font_resource_id || "",
initial_scale: 1,
font_url: "",
typesetting: 0,
alignment: template.alignment || 1,
line_feed: template.line_feed || 1,
use_effect_default_color: true,
is_rich_text: false,
shape_clip_x: false,
shape_clip_y: false,
ktv_color: "",
text_to_audio_ids: [],
bold_width: template.bold_width || 0.008,
italic_degree: template.italic_degree || 10,
underline: false,
underline_width: 0.05,
underline_offset: 0.22,
sub_type: 0,
check_flag: 47,
text_size: 30,
font_category_name: "",
font_source_platform: 0,
font_third_resource_id: "",
font_category_id: "",
add_type: 0,
operation_type: 0,
recognize_type: 0,
fonts: template.fonts || [],
background_color: template.background_color || "",
background_alpha: template.background_alpha || 1,
background_style: template.background_style || 0,
background_round_radius: template.background_round_radius || 0,
background_width: template.background_width || 0.14,
background_height: template.background_height || 0.14,
background_vertical_offset: 0,
background_horizontal_offset: 0,
background_fill: "",
single_char_bg_enable: false,
single_char_bg_color: "",
single_char_bg_alpha: 1,
single_char_bg_round_radius: 0.3,
single_char_bg_width: 0,
single_char_bg_height: 0,
single_char_bg_vertical_offset: 0,
single_char_bg_horizontal_offset: 0,
font_team_id: "",
tts_auto_update: false,
text_preset_resource_id: "",
group_id: `Auto_${Date.now()}`,
preset_id: "",
preset_name: "",
preset_category: "",
preset_category_id: "",
preset_index: 0,
preset_has_set_alignment: false,
force_apply_line_max_width: true,
language: "en-US",
relevance_segment: [],
original_size: [],
fixed_width: -1,
fixed_height: -1,
line_max_width: template.line_max_width || 0.72,
oneline_cutoff: false,
cutoff_postfix: "",
subtitle_template_original_fontsize: 0,
subtitle_keywords: null,
inner_padding: -1,
multi_language_current: "none",
source_from: "",
is_lyric_effect: false,
lyric_group_id: "",
lyrics_template: {
resource_id: "",
resource_name: "",
panel: "",
effect_id: "",
path: "",
category_id: "",
category_name: "",
request_id: "",
},
is_batch_replace: false,
is_words_linear: false,
ssml_content: "",
subtitle_keywords_config: template.subtitle_keywords_config || null,
sub_template_id: 1,
translate_original_text: "",
text: text,
};
}
function buildTextContent(template, text) {
// 尝试从模板的 content 中提取样式结构,替换文本
try {
const templateContent = JSON.parse(template.content || "{}");
if (templateContent.styles && templateContent.styles.length > 0) {
const newStyles = templateContent.styles.map((style) => ({
...style,
range: [0, text.length],
}));
return JSON.stringify({
...templateContent,
styles: newStyles,
text: text,
});
}
} catch {}
// 回退:构建基本内容
return JSON.stringify({
styles: [{
fill: {
content: {
solid: { color: [1, 1, 1] },
render_type: "solid",
},
},
range: [0, text.length],
size: template.font_size || 12,
bold: true,
}],
text: text,
});
}
function buildMeta(templateMeta, ctx) {
const durationUs = Math.round(ctx.videoInfo.duration * ONE_SECOND_US);
const meta = templateMeta && typeof templateMeta === "object"
? JSON.parse(JSON.stringify(templateMeta))
: {};
const videoMaterial = ctx.draft.materials?.videos?.[0] || {};
const nowSec = Math.floor(ctx.nowUs / ONE_SECOND_US);
Object.assign(meta, {
cloud_draft_cover: false,
cloud_draft_sync: false,
draft_cloud_last_action_download: false,
draft_cover: "draft_cover.jpg",
draft_fold_path: ctx.draftDir,
draft_id: randomUUID().toUpperCase(),
draft_is_ai_shorts: false,
draft_is_cloud_temp_draft: false,
draft_is_invisible: false,
draft_is_web_article_video: false,
draft_json_file: null,
draft_name: ctx.draftName,
draft_need_rename_folder: false,
draft_new_version: meta.draft_new_version ?? "",
draft_root_path: ctx.draftDir,
draft_timeline_materials_size_: ctx.mediaSize,
draft_type: meta.draft_type ?? "",
tm_draft_create: ctx.nowUs,
tm_draft_modified: ctx.nowUs,
tm_draft_removed: 0,
tm_duration: durationUs,
});
const materialItem = {
ai_group_type: "",
create_time: nowSec,
duration: durationUs,
enter_from: 0,
extra_info: path.basename(ctx.videoPath),
file_Path: ctx.videoPath,
height: ctx.videoInfo.height,
id: videoMaterial.id || randomUUID().toUpperCase(),
import_time: nowSec,
import_time_ms: ctx.nowUs,
item_source: 1,
md5: "",
metetype: "video",
roughcut_time_range: { duration: durationUs, start: 0 },
sub_time_range: { duration: -1, start: -1 },
type: 0,
width: ctx.videoInfo.width,
};
meta.draft_materials = normalizeDraftMaterials(meta.draft_materials, materialItem);
return replaceStringsDeep(meta, replacementsFor(ctx.templateName, ctx.templateDir, ctx.draftName, ctx.draftDir, ctx.videoPath));
}
function normalizeDraftMaterials(existing, videoItem) {
const buckets = Array.isArray(existing)
? existing.filter((item) => item && typeof item === "object")
: [];
let videoBucket = buckets.find((item) => item.type === 0);
if (!videoBucket) {
videoBucket = { type: 0, value: [] };
buckets.unshift(videoBucket);
}
videoBucket.value = [videoItem];
for (const type of [1, 2, 3, 6, 7, 8]) {
if (!buckets.some((item) => item.type === type)) buckets.push({ type, value: [] });
}
return buckets;
}
// ==================== CapCut 包同步 ====================
async function writeDraftPackage(ctx) {
const replacements = replacementsFor(ctx.templateName, ctx.templateDir, ctx.draftName, ctx.draftDir, ctx.videoPath);
const draft = replaceStringsDeep({ ...ctx.draft, path: ctx.draftDir }, replacements);
const meta = replaceStringsDeep(ctx.meta, replacements);
await writeJson(path.join(ctx.draftDir, "draft_info.json"), draft);
await writeJson(path.join(ctx.draftDir, "draft_info.json.bak"), draft);
await writeJson(path.join(ctx.draftDir, "template-2.tmp"), draft);
await writeJson(path.join(ctx.draftDir, "draft_meta_info.json"), meta, null);
await syncTimelinePackage({ ...ctx, draft, meta, replacements });
await writeDraftSettings(ctx.draftDir, ctx.nowUs);
}
async function syncTimelinePackage(ctx) {
const timelineRoot = path.join(ctx.draftDir, "Timelines");
await fs.mkdir(timelineRoot, { recursive: true });
const entries = await fs.readdir(timelineRoot, { withFileTypes: true }).catch(() => []);
const timelineDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
const targetDir = path.join(timelineRoot, ctx.draft.id);
if (!timelineDirs.includes(ctx.draft.id)) {
const preferred = timelineDirs.find((name) => name === ctx.templateDraft.id) || timelineDirs[0];
if (preferred) {
await fs.rename(path.join(timelineRoot, preferred), targetDir);
} else {
await fs.mkdir(targetDir, { recursive: true });
}
}
for (const name of await fs.readdir(timelineRoot).catch(() => [])) {
const full = path.join(timelineRoot, name);
const stat = await fs.stat(full).catch(() => null);
if (stat?.isDirectory() && name !== ctx.draft.id) {
await fs.rm(full, { recursive: true, force: true });
}
}
await writeJson(path.join(targetDir, "draft_info.json"), ctx.draft);
await writeJson(path.join(targetDir, "draft_info.json.bak"), ctx.draft);
await writeJson(path.join(targetDir, "template-2.tmp"), ctx.draft);
await writeJson(path.join(targetDir, "template.tmp"), ctx.draft);
await writeTimelineProject(path.join(timelineRoot, "project.json"), ctx);
await writeTimelineProject(path.join(timelineRoot, "project.json.bak"), ctx);
}
async function writeTimelineProject(file, ctx) {
const existing = await readJsonIfExists(file);
const project = existing && typeof existing === "object" ? replaceStringsDeep(existing, ctx.replacements) : {};
project.version = project.version ?? 360000;
project.create_time = ctx.nowUs;
project.update_time = ctx.nowUs;
project.main_timeline_id = ctx.draft.id;
project.timelines = [{
create_time: ctx.nowUs,
id: ctx.draft.id,
is_marked_delete: false,
name: project.timelines?.[0]?.name || "时间线01",
update_time: ctx.nowUs,
}];
if (!project.id) project.id = randomUUID().toUpperCase();
await writeJson(file, project);
}
async function writeDraftSettings(draftDir, nowUs) {
const nowSec = Math.floor(nowUs / ONE_SECOND_US);
const file = path.join(draftDir, "draft_settings");
const existing = await fs.readFile(file, "utf8").catch(() => "");
const values = Object.fromEntries(existing
.split(/\r?\n/)
.filter((line) => line.includes("="))
.map((line) => line.split("=", 2)));
const text = [
"[General]",
`draft_create_time=${nowSec}`,
`draft_last_edit_time=${nowSec}`,
`real_edit_keys=${values.real_edit_keys ?? 1}`,
`real_edit_seconds=${values.real_edit_seconds ?? 0}`,
"",
].join("\n");
await fs.writeFile(file, text, "utf8");
}
function replacementsFor(templateName, templateDir, draftName, draftDir, videoPath) {
return [
[templateDir, draftDir],
[`${templateName}.mp4`, path.basename(videoPath)],
[templateName, draftName],
];
}
function replaceStringsDeep(value, replacements) {
if (Array.isArray(value)) return value.map((item) => replaceStringsDeep(item, replacements));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replaceStringsDeep(item, replacements)]));
}
if (typeof value !== "string") return value;
return replacements.reduce((text, [from, to]) => text.split(from).join(to), value);
}
async function readJsonIfExists(file) {
try {
return JSON.parse(await fs.readFile(file, "utf8"));
} catch {
return null;
}
}
async function writeJson(file, value, indent = 2) {
await fs.mkdir(path.dirname(file), { recursive: true });
const space = indent === null ? undefined : indent;
await fs.writeFile(file, `${JSON.stringify(value, null, space)}\n`, "utf8");
}
// ==================== 文件操作 ====================
async function copyTemplateAssets(templateDir, newDraftDir) {
const entries = await fs.readdir(templateDir, { withFileTypes: true });
for (const entry of entries) {
const src = path.join(templateDir, entry.name);
const dest = path.join(newDraftDir, entry.name);
if (entry.name.endsWith(".json")) {
// JSON 文件由主逻辑生成,跳过
continue;
}
if (entry.isDirectory()) {
await fs.mkdir(dest, { recursive: true });
await copyDirRecursive(src, dest);
} else {
await fs.copyFile(src, dest);
}
}
}
async function copyDirRecursive(src, dest) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirRecursive(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
// ==================== 视频探测 ====================
async function probeVideo(videoPath) {
// 尝试找到 ffprobe
const ffprobePath = await findFfprobe();
return new Promise((resolve, reject) => {
const child = spawn(ffprobePath, [
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,r_frame_rate,codec_name,pix_fmt",
"-show_entries", "format=duration,bit_rate",
"-of", "json",
videoPath,
], { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => (stdout += c));
child.stderr.on("data", (c) => (stderr += c));
child.on("close", (code) => {
if (code !== 0) {
return reject(new Error(`ffprobe failed: ${stderr}`));
}
const data = JSON.parse(stdout);
const stream = (data.streams || [])[0] || {};
const format = data.format || {};
let fps = 30;
if (stream.r_frame_rate) {
const [num, den] = stream.r_frame_rate.split("/").map(Number);
fps = den ? num / den : num;
}
resolve({
width: stream.width || 1080,
height: stream.height || 1920,
fps: Math.round(fps),
duration: parseFloat(format.duration) || 0,
bitRate: parseInt(format.bit_rate) || 0,
codec: stream.codec_name || "h264",
pixFmt: stream.pix_fmt || "yuv420p",
});
});
});
}
async function updateRootMetaInfo(draftRoot, draftName, draftDir, videoInfo, ctx) {
const rootMetaPath = path.join(draftRoot, "root_meta_info.json");
let rootMeta;
try {
rootMeta = JSON.parse(await fs.readFile(rootMetaPath, "utf-8"));
} catch {
rootMeta = { all_draft_store: [], draft_ids: 0, root_path: draftRoot };
}
let store = rootMeta.all_draft_store || [];
const draftJsonFile = path.join(draftDir, "draft_info.json");
const draftCover = path.join(draftDir, "draft_cover.jpg");
const existing = store.find((item) => item.draft_name === draftName || item.draft_fold_path === draftDir) || {};
store = store.filter((item) => item === existing || (item.draft_name !== draftName && item.draft_fold_path !== draftDir));
const newEntry = {
cloud_draft_cover: false,
cloud_draft_sync: false,
draft_cloud_last_action_download: false,
draft_cloud_purchase_info: "",
draft_cloud_template_id: "",
draft_cloud_tutorial_info: "",
draft_cloud_videocut_purchase_info: "",
draft_cover: draftCover,
draft_fold_path: draftDir,
draft_is_ai_shorts: false,
draft_is_cloud_temp_draft: false,
draft_is_invisible: false,
draft_is_web_article_video: false,
draft_json_file: draftJsonFile,
draft_name: draftName,
draft_new_version: "",
draft_root_path: draftRoot,
draft_timeline_materials_size: ctx.mediaSize,
draft_type: "",
draft_web_article_video_enter_from: "",
streaming_edit_draft_ready: true,
tm_draft_cloud_completed: "",
tm_draft_cloud_entry_id: -1,
tm_draft_cloud_modified: 0,
tm_draft_cloud_parent_entry_id: -1,
tm_draft_cloud_space_id: -1,
tm_draft_cloud_user_id: -1,
tm_draft_create: ctx.nowUs,
tm_draft_modified: ctx.nowUs,
tm_draft_removed: 0,
tm_duration: Math.round(videoInfo.duration * ONE_SECOND_US),
};
newEntry.draft_id = ctx.meta.draft_id || existing.draft_id || randomUUID().toUpperCase();
const existingIndex = store.indexOf(existing);
if (existingIndex >= 0) {
store[existingIndex] = { ...existing, ...newEntry };
console.log(`Updated existing index entry for: ${draftName}`);
} else {
store.push(newEntry);
console.log(`Added new index entry for: ${draftName}`);
}
rootMeta.all_draft_store = store;
rootMeta.draft_ids = store.length;
rootMeta.root_path = draftRoot;
await writeJson(rootMetaPath, rootMeta);
}
async function findFfprobe() {
// 环境变量优先
if (process.env.FFPROBE_PATH) return process.env.FFPROBE_PATH;
// 常见路径
const candidates = [
"/opt/homebrew/bin/ffprobe",
"/usr/local/bin/ffprobe",
"/usr/bin/ffprobe",
"/bin/ffprobe",
];
for (const p of candidates) {
try {
await fs.access(p);
return p;
} catch {}
}
// 回退到 PATH 中的 ffprobe
return "ffprobe";
}
// ==================== 字幕解析 ====================
async function resolveCaptions(args) {
if (args["no-captions"]) return [];
if (args.srt) {
const srtText = await fs.readFile(path.resolve(args.srt), "utf-8");
return parseSrt(srtText);
}
if (args.captions) {
return String(args.captions).replaceAll("\\n", "\n").split(/\r?\n/).filter((l) => l.trim());
}
return [];
}
function parseSrt(srtText) {
const lines = srtText.split(/\r?\n/);
const captions = [];
let buffer = [];
for (const line of lines) {
if (line.trim() === "" && buffer.length > 0) {
const textLines = buffer.slice(2);
captions.push(textLines.join(" ").trim());
buffer = [];
} else {
buffer.push(line);
}
}
if (buffer.length > 0) {
const textLines = buffer.slice(2);
captions.push(textLines.join(" ").trim());
}
return captions;
}
// ==================== 参数解析 ====================
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const token = argv[i];
if (!token.startsWith("--")) continue;
const key = token.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
out[key] = true;
} else {
if (out[key] === undefined) out[key] = next;
else if (Array.isArray(out[key])) out[key].push(next);
else out[key] = [out[key], next];
i++;
}
}
return out;
}
#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
const CAPCUT_DRAFT_ROOT = process.env.CAPCUT_DRAFT_ROOT || path.join(os.homedir(), "Movies", "CapCut", "User Data", "Projects", "com.lveditor.draft");
const options = parseArgs(process.argv.slice(2));
main().catch((error) => {
console.error(error.stack ?? error.message);
process.exitCode = 1;
});
async function main() {
const jobs = await collectJobs(options);
if (!jobs.length) throw new Error("No --video, --video-dir, or --draft inputs found.");
await fs.mkdir(options.outputRoot, { recursive: true });
const results = [];
for (const job of jobs) {
results.push(await extractJob(job, options));
}
const batch = {
created_at: new Date().toISOString(),
output_root: options.outputRoot,
total: results.length,
results,
};
await writeJson(path.join(options.outputRoot, "batch_manifest.json"), batch);
console.log(JSON.stringify({ total: results.length, output_root: options.outputRoot }, null, 2));
}
async function collectJobs(opts) {
const jobs = [];
for (const video of opts.videos) {
jobs.push(await videoToJob(video));
}
for (const draft of opts.drafts) {
jobs.push(await draftToJob(resolveDraftPath(draft, opts.capcutDraftRoot)));
}
if (opts.videoDir) {
for (const file of await listVideos(opts.videoDir)) {
jobs.push(await videoToJob(file));
}
}
return jobs.sort((a, b) => a.name.localeCompare(b.name, "en", { numeric: true }));
}
async function videoToJob(videoPath) {
const probe = await ffprobeVideo(videoPath);
return {
name: path.basename(videoPath, path.extname(videoPath)),
source_kind: "video",
video_path: videoPath,
duration_us: probe.durationUs,
width: probe.width,
height: probe.height,
map_time: (targetSec) => targetSec,
};
}
async function draftToJob(draftPath) {
const draftFile = path.join(draftPath, "draft_info.json");
const draft = JSON.parse(await fs.readFile(draftFile, "utf8"));
const videoTrack = draft.tracks?.find((track) => track.type === "video");
const videoMaterials = new Map((draft.materials?.videos ?? []).map((item) => [item.id, item]));
const firstMaterial = videoMaterials.get(videoTrack?.segments?.[0]?.material_id) ?? draft.materials?.videos?.[0];
if (!firstMaterial?.path) throw new Error(`Draft has no video material path: ${draftPath}`);
const segments = (videoTrack?.segments ?? [])
.map((segment) => ({
targetStartUs: Number(segment.target_timerange?.start ?? 0),
targetDurationUs: Number(segment.target_timerange?.duration ?? 0),
sourceStartUs: Number(segment.source_timerange?.start ?? segment.target_timerange?.start ?? 0),
sourceDurationUs: Number(segment.source_timerange?.duration ?? segment.target_timerange?.duration ?? 0),
}))
.filter((segment) => segment.targetDurationUs > 0)
.sort((a, b) => a.targetStartUs - b.targetStartUs);
return {
name: path.basename(draftPath),
source_kind: "capcut-draft",
draft_path: draftPath,
video_path: firstMaterial.path,
duration_us: Number(draft.duration ?? 0),
width: Number(firstMaterial.width ?? 0),
height: Number(firstMaterial.height ?? 0),
segments,
map_time: (targetSec) => mapDraftTargetToSource(targetSec, segments),
};
}
async function extractJob(job, opts) {
const durationSec = Math.max(0.001, job.duration_us / 1_000_000);
const frameCount = opts.frameCount ?? (durationSec >= 25 ? 40 : 20);
const outDir = path.join(opts.outputRoot, sanitizeName(job.name));
const framesDir = path.join(outDir, "frames");
await fs.mkdir(framesDir, { recursive: true });
const times = sampleTimes(durationSec, frameCount);
const frames = [];
for (let index = 0; index < times.length; index += 1) {
const targetSec = times[index];
const sourceSec = Math.min(Math.max(0, job.map_time(targetSec)), Math.max(0, durationSec - 0.04));
const file = path.join(framesDir, `${String(index + 1).padStart(3, "0")}_t${formatTimestamp(targetSec)}.jpg`);
await runCommand("ffmpeg", [
"-y",
"-ss", sourceSec.toFixed(3),
"-i", job.video_path,
"-frames:v", "1",
"-vf", "format=yuvj420p",
"-q:v", "2",
file,
]);
frames.push({
index: index + 1,
target_time_sec: Number(targetSec.toFixed(3)),
source_time_sec: Number(sourceSec.toFixed(3)),
file,
});
}
const contactSheet = path.join(outDir, "contact_sheet.jpg");
await createContactSheet(framesDir, contactSheet, frameCount);
const reviewTemplate = {
draft_name: job.name,
video_path: job.video_path,
duration_sec: Number(durationSec.toFixed(3)),
frame_count: frameCount,
contact_sheet: contactSheet,
instructions: "Only mark obvious AI artifacts that affect publish quality. Do not mark ordinary aesthetic issues.",
issues: [],
};
const { map_time, ...manifestJob } = job;
const manifest = {
created_at: new Date().toISOString(),
...manifestJob,
frame_count: frameCount,
output_dir: outDir,
contact_sheet: contactSheet,
frames,
};
await writeJson(path.join(outDir, "frames_manifest.json"), manifest);
await writeJson(path.join(outDir, "ai_artifact_review.template.json"), reviewTemplate);
return {
name: job.name,
output_dir: outDir,
contact_sheet: contactSheet,
frame_count: frameCount,
duration_sec: Number(durationSec.toFixed(3)),
};
}
async function createContactSheet(framesDir, outputFile, frameCount) {
const { cols, rows } = tileLayout(frameCount);
await runCommand("ffmpeg", [
"-y",
"-pattern_type", "glob",
"-i", path.join(framesDir, "*.jpg"),
"-vf", `scale=240:-1,tile=${cols}x${rows}:padding=8:margin=8:color=white`,
"-frames:v", "1",
"-q:v", "2",
outputFile,
]);
}
async function listVideos(dir) {
const out = [];
async function walk(current) {
for (const entry of await fs.readdir(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) await walk(full);
else if (entry.isFile() && /\.(mp4|mov|webm|m4v)$/i.test(entry.name)) out.push(full);
}
}
await walk(path.resolve(dir));
return out;
}
function tileLayout(frameCount) {
if (frameCount <= 20) return { cols: 5, rows: 4 };
return { cols: 8, rows: 5 };
}
function sampleTimes(durationSec, count) {
if (count <= 1) return [0];
const safeEnd = Math.max(0, durationSec - 0.2);
return Array.from({ length: count }, (_, index) => safeEnd * index / (count - 1));
}
function mapDraftTargetToSource(targetSec, segments) {
const targetUs = targetSec * 1_000_000;
const segment = segments.find((item) =>
targetUs >= item.targetStartUs && targetUs <= item.targetStartUs + item.targetDurationUs
) ?? segments.at(-1);
if (!segment) return targetSec;
const offsetUs = Math.max(0, targetUs - segment.targetStartUs);
const factor = segment.targetDurationUs > 0 ? segment.sourceDurationUs / segment.targetDurationUs : 1;
return (segment.sourceStartUs + offsetUs * factor) / 1_000_000;
}
async function ffprobeVideo(file) {
const result = await runCommand("ffprobe", ["-v", "error", "-print_format", "json", "-show_streams", "-show_format", file], true);
const json = JSON.parse(result.stdout);
const video = json.streams?.find((stream) => stream.codec_type === "video") ?? {};
const duration = Number(video.duration ?? json.format?.duration ?? 0);
return {
durationUs: Math.round(duration * 1_000_000),
width: Number(video.width ?? 0),
height: Number(video.height ?? 0),
};
}
function runCommand(command, args, capture = false) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
child.on("error", reject);
child.on("close", (status) => {
if (status !== 0) reject(new Error(`${command} failed with exit ${status}: ${stderr}`));
else resolve(capture ? { stdout, stderr } : { stdout: "", stderr: "" });
});
});
}
async function writeJson(file, value) {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function resolveDraftPath(value, draftRoot) {
if (value.includes(path.sep)) return path.resolve(value);
return path.join(draftRoot, value);
}
function sanitizeName(value) {
return String(value).replace(/[^\w.-]+/g, "_");
}
function formatTimestamp(seconds) {
const totalMs = Math.round(seconds * 1000);
const mm = Math.floor(totalMs / 60000);
const ss = Math.floor((totalMs % 60000) / 1000);
const ms = totalMs % 1000;
return `${String(mm).padStart(2, "0")}m${String(ss).padStart(2, "0")}s${String(ms).padStart(3, "0")}ms`;
}
function parseArgs(argv) {
const opts = {
outputRoot: path.resolve(`ai-artifact-frames-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`),
frameCount: null,
videos: [],
videoDir: null,
drafts: [],
capcutDraftRoot: CAPCUT_DRAFT_ROOT,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const take = () => {
index += 1;
if (index >= argv.length) throw new Error(`Missing value for ${arg}`);
return argv[index];
};
if (arg === "--video") opts.videos.push(path.resolve(take()));
else if (arg === "--video-dir") opts.videoDir = path.resolve(take());
else if (arg === "--draft") opts.drafts.push(take());
else if (arg === "--frame-count") opts.frameCount = Number(take());
else if (arg === "--output-root") opts.outputRoot = path.resolve(take());
else if (arg === "--capcut-draft-root") opts.capcutDraftRoot = path.resolve(take());
else throw new Error(`Unknown option: ${arg}`);
}
return opts;
}
#!/usr/bin/env node
/**
* extract-template-styles.mjs — 从 CapCut 草稿中提取字幕/转场/特效样式配置
*
* 用法:
* node extract-template-styles.mjs --template "DE-chilebroskii-HV-001" --output ./my-styles.json
*
* 输出 JSON 包含:
* - texts: 字幕样式(字体、颜色、背景、描边等)
* - transitions: 转场配置
* - animations: 动画/特效配置
* - canvases: 画布配置
*/
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
const CAPCUT_DRAFT_ROOT = process.env.CAPCUT_DRAFT_ROOT ||
path.join(os.homedir(), "Movies", "CapCut", "User Data", "Projects", "com.lveditor.draft");
const args = parseArgs(process.argv.slice(2));
if (args.help || args.h) {
console.log(`
Usage: node extract-template-styles.mjs [options]
Options:
--template <name> Template draft name (required)
--output <path> Output JSON path (default: ./capcut-template-styles.json)
--draft-root <dir> CapCut draft root dir
--help, -h Show this help
`);
process.exit(0);
}
main().catch((err) => {
console.error(err.stack || err.message);
process.exitCode = 1;
});
async function main() {
const templateName = args.template;
const outputPath = args.output || "./capcut-template-styles.json";
const draftRoot = args["draft-root"] ? path.resolve(args["draft-root"]) : CAPCUT_DRAFT_ROOT;
if (!templateName) {
console.error("Error: --template is required");
process.exitCode = 1;
return;
}
const templateDir = path.join(draftRoot, templateName);
const draftPath = path.join(templateDir, "draft_info.json");
console.log(`Reading template: ${draftPath}`);
const draft = JSON.parse(await fs.readFile(draftPath, "utf-8"));
const styles = extractStyles(draft);
await fs.writeFile(path.resolve(outputPath), JSON.stringify(styles, null, 2));
console.log(`Styles extracted: ${path.resolve(outputPath)}`);
console.log(` Texts: ${styles.texts.length}`);
console.log(` Transitions: ${styles.transitions.length}`);
console.log(` Animations: ${styles.animations.length}`);
console.log(` Effects: ${styles.effects.length}`);
console.log(` Canvases: ${styles.canvases.length}`);
console.log(` Text Templates: ${styles.textTemplates.length}`);
}
function extractStyles(draft) {
const materials = draft.materials || {};
return {
meta: {
draft_name: draft.name,
draft_id: draft.id,
duration: draft.duration,
fps: draft.fps,
canvas: draft.canvas_config,
extracted_at: new Date().toISOString(),
},
texts: (materials.texts || []).map((t) => ({
id: t.id,
content: t.content,
base_content: t.base_content,
font_size: t.font_size,
text_color: t.text_color,
font_path: t.font_path,
font_id: t.font_id,
font_resource_id: t.font_resource_id,
fonts: t.fonts,
bold_width: t.bold_width,
italic_degree: t.italic_degree,
line_spacing: t.line_spacing,
letter_spacing: t.letter_spacing,
alignment: t.alignment,
line_feed: t.line_feed,
line_max_width: t.line_max_width,
has_shadow: t.has_shadow,
shadow_color: t.shadow_color,
shadow_alpha: t.shadow_alpha,
shadow_distance: t.shadow_distance,
border_alpha: t.border_alpha,
border_color: t.border_color,
border_width: t.border_width,
background_color: t.background_color,
background_alpha: t.background_alpha,
background_style: t.background_style,
background_round_radius: t.background_round_radius,
background_width: t.background_width,
background_height: t.background_height,
subtitle_keywords_config: t.subtitle_keywords_config,
})),
transitions: (materials.transitions || []).map((tr) => ({
id: tr.id,
name: tr.name,
effect_id: tr.effect_id,
resource_id: tr.resource_id,
third_resource_id: tr.third_resource_id,
source_platform: tr.source_platform,
path: tr.path,
duration: tr.duration,
is_overlap: tr.is_overlap,
platform: tr.platform,
category_id: tr.category_id,
category_name: tr.category_name,
})),
animations: (materials.material_animations || []).map((a) => ({
id: a.id,
type: a.type,
animations: a.animations,
})),
effects: (materials.effects || []).map((e) => ({
id: e.id,
name: e.name,
effect_id: e.effect_id,
resource_id: e.resource_id,
path: e.path,
})),
canvases: (materials.canvases || []).map((c) => ({
id: c.id,
type: c.type,
color: c.color,
blur: c.blur,
})),
textTemplates: (materials.text_templates || []).map((tt) => ({
id: tt.id,
version: tt.version,
effect_id: tt.effect_id,
resource_id: tt.resource_id,
path: tt.path,
})),
trackStructure: {
videoSegments: (draft.tracks || [])
.filter((t) => t.type === "video")
.flatMap((t) => t.segments || [])
.map((s) => ({
start: s.target_timerange?.start,
duration: s.target_timerange?.duration,
extra_material_refs: s.extra_material_refs,
})),
textSegments: (draft.tracks || [])
.filter((t) => t.type === "text")
.flatMap((t) => t.segments || [])
.map((s) => ({
start: s.target_timerange?.start,
duration: s.target_timerange?.duration,
material_id: s.material_id,
extra_material_refs: s.extra_material_refs,
clip: s.clip,
})),
},
};
}
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const token = argv[i];
if (!token.startsWith("--")) continue;
const key = token.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
out[key] = true;
} else {
out[key] = next;
i++;
}
}
return out;
}
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
const options = parseArgs(process.argv.slice(2));
main().catch((error) => {
console.error(error.stack ?? error.message);
process.exitCode = 1;
});
async function main() {
const review = JSON.parse(await fs.readFile(options.review, "utf8"));
const durationSec = Number(options.durationSec ?? review.duration_sec ?? 0);
if (!Array.isArray(review.issues)) throw new Error("Review JSON must contain issues[].");
const maxIssues = options.maxIssues ?? (durationSec >= 25 ? 3 : 2);
const selected = review.issues
.filter((issue) => issue.publish_blocking !== false)
.sort((a, b) => severityRank(b.severity) - severityRank(a.severity))
.slice(0, maxIssues);
const windows = mergeWindows(selected.map((issue) => ({
start_sec: issueWindowStart(issue, durationSec),
end_sec: issueWindowEnd(issue, durationSec),
primary_issue: issue,
fixes: recommendFixes(issue)
})));
const plan = {
created_at: new Date().toISOString(),
review_file: options.review,
draft_name: review.draft_name ?? null,
duration_sec: durationSec || null,
selected_issue_count: selected.length,
note: "Use the least visible fix that hides the publish-blocking AI artifact. Do not over-process ordinary aesthetic issues.",
windows
};
await fs.mkdir(path.dirname(options.output), { recursive: true });
await fs.writeFile(options.output, `${JSON.stringify(plan, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ selected_issue_count: selected.length, output: options.output }, null, 2));
}
function issueWindowStart(issue, durationSec) {
if (issue.full_timeline === true) return 0;
if (Number.isFinite(Number(issue.window_start_sec))) return Math.max(0, Number(issue.window_start_sec));
const point = Number(issue.time_sec ?? issue.target_time_sec ?? 0);
return Math.max(0, point - 3);
}
function issueWindowEnd(issue, durationSec) {
const duration = Number(durationSec);
if (issue.full_timeline === true && duration > 0) return duration;
if (Number.isFinite(Number(issue.window_end_sec))) {
const end = Number(issue.window_end_sec);
return duration > 0 ? Math.min(duration, end) : end;
}
const point = Number(issue.time_sec ?? issue.target_time_sec ?? 0);
return duration > 0 ? Math.min(duration, point + 3) : point + 3;
}
function recommendFixes(issue) {
const categories = new Set(issue.categories ?? []);
if (categories.has("text_logo_ui")) {
return ["cover with B-roll/product close-up", "localized sticker/blur over text", "depth blur if background text is incidental"];
}
if (categories.has("hands") || categories.has("hand_product_intersection") || categories.has("physics")) {
return ["cut to B-roll over the issue window", "motion blur during hand movement", "foreground product sticker/overlay if it hides the defect"];
}
if (categories.has("object_continuity") || categories.has("product_deformation")) {
return ["insert transition at nearest semantic boundary", "cover with product B-roll", "short crop/zoom plus motion blur"];
}
if (categories.has("background") || categories.has("lighting_perspective") || categories.has("smooth_warp")) {
return ["depth blur", "subtle filter", "B-roll if the artifact is central"];
}
return ["B-roll over issue window", "short motion blur", "sticker/overlay only if natural for the content"];
}
function mergeWindows(windows) {
const sorted = windows.sort((a, b) => a.start_sec - b.start_sec);
const merged = [];
for (const window of sorted) {
const last = merged.at(-1);
if (last && window.start_sec <= last.end_sec) {
last.end_sec = Math.max(last.end_sec, window.end_sec);
last.issues.push(window.primary_issue);
last.fixes = [...new Set([...last.fixes, ...window.fixes])];
} else {
merged.push({
start_sec: Number(window.start_sec.toFixed(3)),
end_sec: Number(window.end_sec.toFixed(3)),
issues: [window.primary_issue],
fixes: window.fixes
});
}
}
return merged;
}
function severityRank(value) {
return { critical: 4, high: 3, medium: 2, low: 1 }[String(value ?? "").toLowerCase()] ?? 0;
}
function parseArgs(argv) {
const opts = {
review: null,
output: null,
durationSec: null,
maxIssues: null
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const take = () => {
index += 1;
if (index >= argv.length) throw new Error(`Missing value for ${arg}`);
return argv[index];
};
if (arg === "--review") opts.review = path.resolve(take());
else if (arg === "--output") opts.output = path.resolve(take());
else if (arg === "--duration-sec") opts.durationSec = Number(take());
else if (arg === "--max-issues") opts.maxIssues = Number(take());
else throw new Error(`Unknown option: ${arg}`);
}
if (!opts.review) throw new Error("--review is required");
if (!opts.output) opts.output = path.join(path.dirname(opts.review), "ai_artifact_fix_plan.json");
return opts;
}
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const SKILL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_TARGET_FPS = 60;
const options = parseArgs(process.argv.slice(2));
main().catch((error) => {
console.error(error.stack ?? error.message);
process.exitCode = 1;
});
async function main() {
if (!options.input) throw new Error("Missing --input video path.");
if (!options.output) throw new Error("Missing --output video path.");
const input = path.resolve(options.input);
const output = path.resolve(options.output);
const media = await ffprobeVideo(input);
if (!media.durationSec) throw new Error(`Could not read video duration: ${input}`);
const backend = await resolveRifeBackend();
const workDir = options.workDir
? path.resolve(options.workDir)
: path.join(path.dirname(output), `.${path.basename(output, path.extname(output))}-rife-work`);
if (!options.keepWork) await fs.rm(workDir, { recursive: true, force: true });
await fs.mkdir(workDir, { recursive: true });
const segments = await buildSegments(input, media, options);
if (!segments.length) throw new Error("No interpolation segments were built.");
const rendered = [];
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
const segmentFile = path.join(workDir, "segments", `segment_${String(index + 1).padStart(3, "0")}.mp4`);
if (segment.durationSec < 0.08) {
await renderPassthroughSegment(input, segmentFile, segment, options.targetFps);
} else {
await renderRifeSegment(input, segmentFile, segment, options.targetFps, backend, path.join(workDir, `rife_${String(index + 1).padStart(3, "0")}`), options);
}
rendered.push(segmentFile);
}
const videoOnly = path.join(workDir, "interpolated_video.mp4");
await concatSegments(rendered, videoOnly, options.targetFps);
const coversWholeTimeline =
Math.abs(segments[0].startSec) < 0.001 &&
Math.abs(segments.at(-1).endSec - media.durationSec) < 0.05;
if (options.keepAudio && media.hasAudio && coversWholeTimeline) {
await muxOriginalAudio(videoOnly, input, output);
} else if (options.keepAudio && media.hasAudio && segments.length === 1) {
await muxTrimmedAudio(videoOnly, input, output, segments[0]);
} else {
await fs.mkdir(path.dirname(output), { recursive: true });
await fs.copyFile(videoOnly, output);
}
const manifest = {
created_at: new Date().toISOString(),
input,
output,
backend: {
engine: "rife-ncnn-vulkan",
binary: backend.binary,
model: backend.model,
spatial_tta: options.spatialTta,
temporal_tta: options.temporalTta,
uhd: options.uhd
},
policy: {
interpolation_backend: "local_rife_only",
no_minterpolate_fallback: true,
split_at_scene_cuts: options.sceneSplit,
protect_hard_cuts: true,
target_fps: options.targetFps
},
media,
segments
};
await writeJson(`${output}.smart-frame-interpolate.json`, manifest);
if (!options.keepWork) await fs.rm(workDir, { recursive: true, force: true });
console.log(JSON.stringify({ output, manifest: `${output}.smart-frame-interpolate.json`, segments: segments.length }, null, 2));
}
async function buildSegments(input, media, opts) {
if (opts.segmentsFile) {
const raw = JSON.parse(await fs.readFile(path.resolve(opts.segmentsFile), "utf8"));
const source = Array.isArray(raw) ? raw : raw.segments;
if (!Array.isArray(source)) throw new Error("--segments must be a JSON array or an object with segments.");
return source.map((item, index) => normalizeSegment(item.start ?? item.start_sec, item.end ?? item.end_sec, media.durationSec, item.label ?? `segment_${index + 1}`));
}
if (opts.startSec !== null || opts.endSec !== null) {
return [normalizeSegment(opts.startSec ?? 0, opts.endSec ?? media.durationSec, media.durationSec, "requested_range")];
}
if (!opts.sceneSplit) return [normalizeSegment(0, media.durationSec, media.durationSec, "full_video")];
const cuts = await detectSceneCuts(input, opts.sceneThreshold);
const points = [0, ...cuts.filter((time) => time > 0.08 && time < media.durationSec - 0.08), media.durationSec];
const segments = [];
for (let index = 0; index < points.length - 1; index += 1) {
const start = points[index];
const end = points[index + 1];
if (end - start >= 0.03) segments.push(normalizeSegment(start, end, media.durationSec, index === 0 ? "scene_001" : `scene_${String(index + 1).padStart(3, "0")}`));
}
return segments;
}
function normalizeSegment(start, end, durationSec, label) {
const startSec = Math.max(0, Number(start ?? 0));
const endSec = Math.min(durationSec, Number(end ?? durationSec));
if (!Number.isFinite(startSec) || !Number.isFinite(endSec) || endSec <= startSec) {
throw new Error(`Invalid segment ${label}: ${start}-${end}`);
}
return {
label,
startSec: Number(startSec.toFixed(6)),
endSec: Number(endSec.toFixed(6)),
durationSec: Number((endSec - startSec).toFixed(6))
};
}
async function renderRifeSegment(input, output, segment, targetFps, backend, workDir, opts) {
const inputDir = path.join(workDir, "input_frames");
const rifeDir = path.join(workDir, "rife_frames");
await fs.rm(workDir, { recursive: true, force: true });
await fs.mkdir(inputDir, { recursive: true });
await fs.mkdir(rifeDir, { recursive: true });
await runCommand("ffmpeg", [
"-y",
"-hide_banner",
"-nostdin",
"-i", input,
"-ss", segment.startSec.toFixed(6),
"-t", segment.durationSec.toFixed(6),
"-an",
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=rgb24",
path.join(inputDir, "%08d.png")
]);
const frameCount = (await fs.readdir(inputDir)).filter((name) => /\.png$/i.test(name)).length;
if (frameCount < 2) {
await renderPassthroughSegment(input, output, segment, targetFps);
return;
}
const targetFrameCount = Math.max(2, Math.round(segment.durationSec * targetFps));
const args = [
"-i", inputDir,
"-o", rifeDir,
"-m", backend.model,
"-n", String(targetFrameCount),
"-j", opts.threadSpec,
"-f", "%08d.png"
];
if (opts.spatialTta) args.push("-x");
if (opts.temporalTta) args.push("-z");
if (opts.uhd) args.push("-u");
await runCommand(backend.binary, args, { timeoutMs: opts.timeoutMs });
await encodeFrameSequence(rifeDir, output, targetFps, segment.durationSec, "slow");
}
async function renderPassthroughSegment(input, output, segment, targetFps) {
await fs.mkdir(path.dirname(output), { recursive: true });
await runCommand("ffmpeg", [
"-y",
"-hide_banner",
"-nostdin",
"-i", input,
"-ss", segment.startSec.toFixed(6),
"-t", segment.durationSec.toFixed(6),
"-an",
"-vf", `fps=${targetFps},scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p`,
"-c:v", "libx264",
"-preset", "slow",
"-crf", "17",
"-pix_fmt", "yuv420p",
output
]);
}
async function encodeFrameSequence(framesDir, output, targetFps, durationSec, preset) {
await fs.mkdir(path.dirname(output), { recursive: true });
await runCommand("ffmpeg", [
"-y",
"-hide_banner",
"-nostdin",
"-framerate", String(targetFps),
"-i", path.join(framesDir, "%08d.png"),
"-vf", `tpad=stop_mode=clone:stop_duration=0.500000,trim=duration=${durationSec.toFixed(6)},setpts=PTS-STARTPTS,format=yuv420p`,
"-an",
"-c:v", "libx264",
"-preset", preset,
"-crf", "17",
"-pix_fmt", "yuv420p",
output
]);
}
async function concatSegments(files, output, targetFps) {
const listFile = `${output}.concat.txt`;
await fs.writeFile(listFile, files.map((file) => `file '${file.replaceAll("'", "'\\''")}'`).join("\n") + "\n", "utf8");
await runCommand("ffmpeg", [
"-y",
"-hide_banner",
"-nostdin",
"-f", "concat",
"-safe", "0",
"-i", listFile,
"-vf", `fps=${targetFps},format=yuv420p`,
"-an",
"-c:v", "libx264",
"-preset", "slow",
"-crf", "17",
"-pix_fmt", "yuv420p",
output
]);
}
async function muxOriginalAudio(videoOnly, source, output) {
await fs.mkdir(path.dirname(output), { recursive: true });
await runCommand("ffmpeg", [
"-y",
"-hide_banner",
"-nostdin",
"-i", videoOnly,
"-i", source,
"-map", "0:v:0",
"-map", "1:a:0?",
"-c:v", "copy",
"-c:a", "aac",
"-b:a", "192k",
"-shortest",
output
]);
}
async function muxTrimmedAudio(videoOnly, source, output, segment) {
await fs.mkdir(path.dirname(output), { recursive: true });
await runCommand("ffmpeg", [
"-y",
"-hide_banner",
"-nostdin",
"-i", videoOnly,
"-ss", segment.startSec.toFixed(6),
"-t", segment.durationSec.toFixed(6),
"-i", source,
"-map", "0:v:0",
"-map", "1:a:0?",
"-c:v", "copy",
"-c:a", "aac",
"-b:a", "192k",
"-shortest",
output
]);
}
async function detectSceneCuts(input, threshold) {
const result = await runCommand("ffmpeg", [
"-hide_banner",
"-nostdin",
"-i", input,
"-vf", `select='gt(scene,${threshold})',showinfo`,
"-an",
"-f", "null",
"-"
], { capture: true });
const text = `${result.stdout}\n${result.stderr}`;
return [...text.matchAll(/pts_time:([0-9.]+)/g)]
.map((match) => Number(match[1]))
.filter((time) => Number.isFinite(time));
}
async function ffprobeVideo(file) {
const result = await runCommand("ffprobe", ["-v", "error", "-print_format", "json", "-show_streams", "-show_format", file], { capture: true });
const json = JSON.parse(result.stdout);
const video = json.streams?.find((stream) => stream.codec_type === "video") ?? {};
const audio = json.streams?.find((stream) => stream.codec_type === "audio") ?? null;
const duration = Number(video.duration ?? json.format?.duration ?? 0);
return {
durationSec: Number(duration.toFixed(6)),
width: Number(video.width ?? 0),
height: Number(video.height ?? 0),
fps: parseFps(video.avg_frame_rate ?? video.r_frame_rate),
hasAudio: Boolean(audio)
};
}
function parseFps(value) {
const [num, den] = String(value ?? "").split("/").map(Number);
if (!num || !den) return null;
return Number((num / den).toFixed(3));
}
async function resolveRifeBackend() {
const candidates = [];
if (process.env.BGGG_RIFE_NCNN) candidates.push({
binary: path.resolve(process.env.BGGG_RIFE_NCNN),
model: process.env.BGGG_RIFE_MODEL ? path.resolve(process.env.BGGG_RIFE_MODEL) : null
});
for (const root of [
path.join(SKILL_ROOT, "tools", "rife-ncnn-vulkan-20221029-macos")
]) {
candidates.push({ binary: path.join(root, "rife-ncnn-vulkan"), model: path.join(root, "rife-v4.6") });
}
const pathBinary = await which("rife-ncnn-vulkan");
if (pathBinary) candidates.push({
binary: pathBinary,
model: process.env.BGGG_RIFE_MODEL ? path.resolve(process.env.BGGG_RIFE_MODEL) : path.join(path.dirname(pathBinary), "rife-v4.6")
});
for (const candidate of candidates) {
if (!candidate.model) continue;
if (await isExecutable(candidate.binary) && await pathExists(path.join(candidate.model, "flownet.param")) && await pathExists(path.join(candidate.model, "flownet.bin"))) {
return candidate;
}
}
throw new Error([
"Smart frame interpolation requires local RIFE ncnn Vulkan.",
"No minterpolate/optical-flow fallback is used because it can create liquid artifacts.",
"Set BGGG_RIFE_NCNN and BGGG_RIFE_MODEL, or place the backend under bggg-tiktok-capcut/tools/rife-ncnn-vulkan-20221029-macos."
].join(" "));
}
async function which(command) {
const result = await runCommand("which", [command], { capture: true, allowFailure: true });
return result.status === 0 ? result.stdout.trim() : null;
}
async function isExecutable(file) {
try {
await fs.access(file, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
async function pathExists(file) {
return Boolean(await fs.stat(file).catch(() => null));
}
async function writeJson(file, value) {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function runCommand(command, args, options = {}) {
const { capture = false, allowFailure = false, timeoutMs = 3_600_000 } = options;
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGTERM");
reject(new Error(`${command} timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (status) => {
clearTimeout(timer);
if (status !== 0 && !allowFailure) reject(new Error(`${command} failed with exit ${status}: ${stderr || stdout}`));
else resolve(capture || allowFailure ? { stdout, stderr, status } : { stdout: "", stderr: "", status });
});
});
}
function parseArgs(argv) {
const opts = {
input: null,
output: null,
targetFps: DEFAULT_TARGET_FPS,
sceneSplit: true,
sceneThreshold: 0.35,
startSec: null,
endSec: null,
segmentsFile: null,
keepAudio: true,
keepWork: false,
workDir: null,
threadSpec: "2:3:2",
spatialTta: true,
temporalTta: true,
uhd: false,
timeoutMs: 3_600_000
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const take = () => {
index += 1;
if (index >= argv.length) throw new Error(`Missing value for ${arg}`);
return argv[index];
};
if (arg === "--input") opts.input = take();
else if (arg === "--output") opts.output = take();
else if (arg === "--target-fps") opts.targetFps = Number(take());
else if (arg === "--scene-threshold") opts.sceneThreshold = Number(take());
else if (arg === "--start") opts.startSec = Number(take());
else if (arg === "--end") opts.endSec = Number(take());
else if (arg === "--segments") opts.segmentsFile = take();
else if (arg === "--work-dir") opts.workDir = take();
else if (arg === "--threads") opts.threadSpec = take();
else if (arg === "--timeout-ms") opts.timeoutMs = Number(take());
else if (arg === "--keep-work") opts.keepWork = true;
else if (arg === "--no-keep-audio") opts.keepAudio = false;
else if (arg === "--no-scene-split") opts.sceneSplit = false;
else if (arg === "--no-spatial-tta") opts.spatialTta = false;
else if (arg === "--no-temporal-tta") opts.temporalTta = false;
else if (arg === "--uhd") opts.uhd = true;
else throw new Error(`Unknown option: ${arg}`);
}
if (!Number.isFinite(opts.targetFps) || opts.targetFps < 24 || opts.targetFps > 120) {
throw new Error("--target-fps must be between 24 and 120.");
}
return opts;
}
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
const CAPCUT_DRAFT_ROOT = process.env.CAPCUT_DRAFT_ROOT || path.join(os.homedir(), "Movies", "CapCut", "User Data", "Projects", "com.lveditor.draft");
const opts = parseArgs(process.argv.slice(2));
main().catch((error) => {
console.error(error.stack ?? error.message);
process.exitCode = 1;
});
async function main() {
const draftDir = resolveDraftPath(opts.draft, opts.capcutDraftRoot);
const result = await validateDraft(draftDir, opts.capcutDraftRoot);
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exitCode = 1;
}
async function validateDraft(draftDir, draftRoot) {
const issues = [];
const draftName = path.basename(draftDir);
const infoPath = path.join(draftDir, "draft_info.json");
const metaPath = path.join(draftDir, "draft_meta_info.json");
const rootMetaPath = path.join(draftRoot, "root_meta_info.json");
const info = await readJson(infoPath, issues, "missing or invalid draft_info.json");
const meta = await readJson(metaPath, issues, "missing or invalid draft_meta_info.json");
const rootMeta = await readJson(rootMetaPath, issues, "missing or invalid root_meta_info.json");
if (!info) return { ok: false, draft_name: draftName, draft_dir: draftDir, issues };
if (info.name !== draftName) issues.push(`draft_info.name mismatch: ${info.name}`);
if (!isValidDraftPath(info.path, draftDir)) issues.push(`draft_info.path mismatch: ${info.path}`);
if (!info.id) issues.push("draft_info.id is empty");
if (!Number(info.duration)) issues.push("draft_info.duration is empty");
const videoTrack = (info.tracks ?? []).find((track) => track.type === "video");
if (!videoTrack) issues.push("missing video track");
if (!Array.isArray(videoTrack?.segments) || videoTrack.segments.length < 1) issues.push("missing video segments");
const textTracks = (info.tracks ?? []).filter((track) => track.type === "text");
const textMaterials = info.materials?.texts ?? [];
const textSegmentCount = textTracks.reduce((sum, track) => sum + (track.segments?.length ?? 0), 0);
if (textSegmentCount !== textMaterials.length) {
issues.push(`text segment/material count mismatch: ${textSegmentCount}/${textMaterials.length}`);
}
for (const video of info.materials?.videos ?? []) {
if (!video.path) issues.push(`video material ${video.id ?? "(no id)"} has empty path`);
else if (!await exists(video.path)) issues.push(`video material missing file: ${video.path}`);
}
const timelineRoot = path.join(draftDir, "Timelines");
const timelineProject = await readJson(path.join(timelineRoot, "project.json"), issues, "missing or invalid Timelines/project.json");
const timelineDirs = (await fs.readdir(timelineRoot, { withFileTypes: true }).catch(() => []))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
if (!timelineDirs.includes(info.id)) issues.push(`missing Timelines/<draft_info.id>: ${info.id}`);
if (timelineDirs.some((name) => name !== info.id)) issues.push(`stale timeline dirs: ${timelineDirs.filter((name) => name !== info.id).join(", ")}`);
if (timelineProject?.main_timeline_id !== info.id) issues.push(`project main_timeline_id mismatch: ${timelineProject?.main_timeline_id}`);
if (timelineProject?.timelines?.[0]?.id !== info.id) issues.push(`project timeline id mismatch: ${timelineProject?.timelines?.[0]?.id}`);
const nestedFiles = [
path.join(timelineRoot, info.id, "draft_info.json"),
path.join(timelineRoot, info.id, "draft_info.json.bak"),
path.join(timelineRoot, info.id, "template.tmp"),
path.join(timelineRoot, info.id, "template-2.tmp"),
path.join(draftDir, "template-2.tmp"),
];
for (const file of nestedFiles) {
const json = await readJson(file, issues, `missing or invalid ${path.relative(draftDir, file)}`);
const rel = path.relative(draftDir, file);
if (path.basename(file) !== "template.tmp") {
if (json?.id && json.id !== info.id) issues.push(`${rel} id mismatch: ${json.id}`);
if (json?.name && json.name !== draftName) issues.push(`${rel} name mismatch: ${json.name}`);
}
if (json?.path && !isValidDraftPath(json.path, draftDir)) issues.push(`${rel} path mismatch: ${json.path}`);
}
if (meta) {
if (meta.draft_name !== draftName) issues.push(`draft_meta_info.draft_name mismatch: ${meta.draft_name}`);
if (meta.draft_fold_path !== draftDir) issues.push(`draft_meta_info.draft_fold_path mismatch: ${meta.draft_fold_path}`);
if (!Number(meta.tm_draft_modified)) issues.push("draft_meta_info.tm_draft_modified is empty");
}
const rootEntry = rootMeta?.all_draft_store?.find((item) => item.draft_name === draftName || item.draft_fold_path === draftDir);
if (!rootEntry) {
issues.push("root_meta_info has no entry for this draft");
} else {
if (rootEntry.draft_fold_path !== draftDir) issues.push(`root draft_fold_path mismatch: ${rootEntry.draft_fold_path}`);
if (rootEntry.draft_json_file !== infoPath) issues.push(`root draft_json_file mismatch: ${rootEntry.draft_json_file}`);
if (!Number(rootEntry.tm_draft_modified)) issues.push("root tm_draft_modified is empty");
if (rootEntry.draft_is_invisible) issues.push("root marks draft invisible");
}
const text = await collectTextFiles(draftDir);
const staleMarkers = opts.staleMarker.filter((marker) => text.includes(marker));
if (staleMarkers.length) issues.push(`stale template markers found: ${staleMarkers.join(", ")}`);
return {
ok: issues.length === 0,
draft_name: draftName,
draft_dir: draftDir,
draft_id: info.id,
duration_us: info.duration ?? null,
video_materials: (info.materials?.videos ?? []).map((item) => item.path),
issues,
};
}
async function readJson(file, issues, issue) {
try {
return JSON.parse(await fs.readFile(file, "utf8"));
} catch {
issues.push(issue);
return null;
}
}
async function collectTextFiles(root) {
const chunks = [];
async function walk(dir) {
for (const entry of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else if (entry.name === "ai_cut_manifest.json") {
continue;
}
else if (/\.(json|tmp)$/.test(entry.name) || entry.name === "draft_settings") {
chunks.push(await fs.readFile(full, "utf8").catch(() => ""));
}
}
}
await walk(root);
return chunks.join("\n");
}
async function exists(file) {
return Boolean(await fs.stat(file).catch(() => null));
}
function resolveDraftPath(value, draftRoot) {
if (!value) throw new Error("--draft is required");
if (value.includes(path.sep)) return path.resolve(value);
return path.join(draftRoot, value);
}
function isValidDraftPath(value, draftDir) {
return value === draftDir || /^##_draftpath_placeholder_[A-F0-9-]+_##$/i.test(String(value ?? ""));
}
function parseArgs(argv) {
const opts = {
draft: null,
capcutDraftRoot: CAPCUT_DRAFT_ROOT,
staleMarker: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const take = () => {
index += 1;
if (index >= argv.length) throw new Error(`Missing value for ${arg}`);
return argv[index];
};
if (arg === "--draft") opts.draft = take();
else if (arg === "--capcut-draft-root") opts.capcutDraftRoot = path.resolve(take());
else if (arg === "--stale-marker") opts.staleMarker.push(take());
else throw new Error(`Unknown option: ${arg}`);
}
return opts;
}
Related skills
FAQ
Where does it write the draft?
By default under $HOME/Movies/CapCut/User Data/Projects/com.lveditor.draft, configurable via CAPCUT_DRAFT_ROOT.
Why validate a draft?
To confirm the root index, draft_info.id, and material paths are consistent so CapCut displays the draft.