From 2d63d504e454657fa4c7d6ddcd33132fbba49008 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 18 Sep 2026 07:27:30 +0000 Subject: [PATCH 1/3] feat: script-aware caption limits and the encrypted-store seed report lint holds captions written in Chinese, Japanese or Korean to their own limits (zh 16 characters per line / 9 per second, ja 13 / 4, ko 16 / 12) instead of the Latin 42 / 20 that let a 32-character Chinese line pass. Messages name the applied default, --fix re-wraps between characters, and an explicit --max-chars / --max-cps applies to every script. Library: captionScript, captionLimits, scriptLimitsExcept, CJK_SCRIPT_LIMITS and LintOptions.scriptLimits. init, quickstart and compile report what the drafts folder held when the skeleton was chosen (template.store). When every project is encrypted (JianYing 6.0+) and nothing could seed the draft, they say so: a WARNING on stderr, template.warning in the JSON, the quickstart create step and the compile warnings list. lint reports template-unverified-store (info) for a bundled-template draft in such a folder, and no longer counts the linted draft as its own store evidence. Tests: test/lint-cjk.test.mjs and test/init-seed-encrypted.test.mjs. --- docs/command-reference.json | 6 +- docs/jianying-encryption.md | 5 + docs/jianying-encryption.zh-CN.md | 5 + src/command-specs.ts | 18 +++- src/compile.ts | 1 + src/factory.ts | 100 +++++++++++++++-- src/index.ts | 27 ++++- src/lib.ts | 6 +- src/lint.ts | 151 ++++++++++++++++++++++++-- src/quickstart.ts | 4 +- test/init-seed-encrypted.test.mjs | 173 ++++++++++++++++++++++++++++++ test/lint-cjk.test.mjs | 130 ++++++++++++++++++++++ 12 files changed, 600 insertions(+), 26 deletions(-) create mode 100644 test/init-seed-encrypted.test.mjs create mode 100644 test/lint-cjk.test.mjs diff --git a/docs/command-reference.json b/docs/command-reference.json index 44739a7..00da6aa 100644 --- a/docs/command-reference.json +++ b/docs/command-reference.json @@ -1,6 +1,6 @@ { "name": "capcut-cli", - "version": "0.23.0", + "version": "0.24.0", "schema_version": 2, "description": "Edit CapCut/JianYing draft_content.json directly. JSON in, JSON out.", "global_flags": [ @@ -134,7 +134,7 @@ ], "type": "number", "required": false, - "description": "Maximum caption characters per line.", + "description": "Maximum caption characters per line. Unset, CJK captions use their own defaults: 16 (zh), 13 (ja), 16 (ko).", "default": 42 }, { @@ -164,7 +164,7 @@ ], "type": "number", "required": false, - "description": "Maximum caption reading speed in characters per second (0 disables).", + "description": "Maximum caption reading speed in characters per second (0 disables). Unset, CJK captions use 9 (zh), 4 (ja), 12 (ko).", "default": 20 }, { diff --git a/docs/jianying-encryption.md b/docs/jianying-encryption.md index 52e03c8..c777f88 100644 --- a/docs/jianying-encryption.md +++ b/docs/jianying-encryption.md @@ -87,3 +87,8 @@ Revisit only if **all** of these hold: decryptor that silently breaks is worse than an honest "not supported." Until then: detect, explain, and collect fixtures. Do not decrypt. + +## What the CLI says on such a store (0.24.0) + +- `init`, `quickstart` and `compile` count the folder's projects in `template.store` (`encrypted` is the JianYing 6.0+ payloads). When none could seed the new draft, a WARNING names the fallback to the bundled template and `template.warning` carries the same text. +- `lint` reports `template-unverified-store` (info) for such a draft: not stale, since there is no readable version to compare against, but unverified for the app that wrote those projects. Opening the draft in JianYing is the test; 11.4 (macOS) is reported to open and upgrade it in place. diff --git a/docs/jianying-encryption.zh-CN.md b/docs/jianying-encryption.zh-CN.md index 3d76f82..083170f 100644 --- a/docs/jianying-encryption.zh-CN.md +++ b/docs/jianying-encryption.zh-CN.md @@ -50,3 +50,8 @@ 3. 有维护者愿意承诺持续跟踪剪映的版本更新 —— 因为一个悄悄失效的解密器,比诚实地说「不支持」更糟糕。 在那之前:只检测、说明情况、收集 fixture。不解密。 + +## 在这样的草稿目录里,CLI 会告诉你什么(0.24.0) + +- `init`、`quickstart` 与 `compile` 会在 `template.store` 里统计目录中的项目(`encrypted` 即剪映 6.0+ 的加密文件)。当没有任何项目可作为新草稿的种子时,会用 WARNING 说明已回退到内置模板,`template.warning` 携带同样的文字。 +- `lint` 会对这样的草稿报告 `template-unverified-store`(info):它不算"过期"(没有可读的版本可比较),但对写出这些项目的应用来说尚未验证。在剪映里打开草稿才是真正的检验;据报告 11.4(macOS)能打开并就地升级。 diff --git a/src/command-specs.ts b/src/command-specs.ts index de0b558..ac47d8e 100644 --- a/src/command-specs.ts +++ b/src/command-specs.ts @@ -276,12 +276,22 @@ export function commandNames(): CommandName[] { const optionsByCommand: Record = { lint: [ - option("max_chars", ["--max-chars"], "number", "Maximum caption characters per line.", { default: 42 }), + option( + "max_chars", + ["--max-chars"], + "number", + "Maximum caption characters per line. Unset, CJK captions use their own defaults: 16 (zh), 13 (ja), 16 (ko).", + { default: 42 }, + ), option("max_cue_secs", ["--max-cue-secs"], "number", "Maximum caption duration in seconds.", { default: 7 }), option("min_gap_ms", ["--min-gap-ms"], "number", "Minimum caption gap in milliseconds.", { default: 0 }), - option("max_cps", ["--max-cps"], "number", "Maximum caption reading speed in characters per second (0 disables).", { - default: 20, - }), + option( + "max_cps", + ["--max-cps"], + "number", + "Maximum caption reading speed in characters per second (0 disables). Unset, CJK captions use 9 (zh), 4 (ja), 12 (ko).", + { default: 20 }, + ), option("safe_area", ["--safe-area"], "number", "Vertical safe-area fraction for captions (0 disables).", { default: 0.85, }), diff --git a/src/compile.ts b/src/compile.ts index dfa285a..3da00f7 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -424,6 +424,7 @@ export function compileDraft(spec: CompileSpec, opts: CompileOptions): CompileRe seed: opts.seed, }); const { filePath } = init; + if (init.template.warning) warnings.push(init.template.warning); const { draft } = loadDraft(filePath); // Canvas + fps from the spec. diff --git a/src/factory.ts b/src/factory.ts index 4c1797b..0b505c7 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -13,6 +13,7 @@ import { import { basename, dirname, resolve } from "node:path"; import { stripBom } from "./bom.js"; import { uuidHex } from "./decorators.js"; +import { detectEncryption } from "./decrypt.js"; import type { Draft, Segment, Timerange, Track } from "./draft.js"; import { findMaterialGlobal, findSegment, makeTrack, writeAtomic } from "./draft.js"; import { findEnum, type Namespace } from "./enums.js"; @@ -229,23 +230,47 @@ export interface StoreScan { seed: StoreSeed | null; /** Newest `platform.app_version` across every readable project (the #67 comparison value). */ newestVersion: string | null; + /** What the folder held, project by project — the part of the scan the + * template report shows the user when nothing could seed. */ + store: StoreScanSummary; +} + +/** + * Per-project outcome of a store scan. A project is a sub-folder holding a + * draft_info.json or draft_content.json. `readable` parsed and declares an + * app version (the only kind that can seed or be compared against); + * `markerless` parsed without one; `encrypted` is the JianYing 6.0+ payload + * this CLI deliberately does not read (docs/jianying-encryption.md) — on a + * JianYing store that is every project the app wrote; `unreadable` is any + * other document JSON.parse rejects. + */ +export interface StoreScanSummary { + projects: number; + readable: number; + markerless: number; + encrypted: number; + unreadable: number; } export function scanStore(draftsDir: string, options: { exclude?: string } = {}): StoreScan { + const store: StoreScanSummary = { projects: 0, readable: 0, markerless: 0, encrypted: 0, unreadable: 0 }; let dirs: string[]; try { dirs = readdirSync(draftsDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); } catch { - return { seed: null, newestVersion: null }; // fresh or unreadable store — nothing to seed from + return { seed: null, newestVersion: null, store }; // fresh or unreadable store — nothing to seed from } const excluded = options.exclude ? resolve(options.exclude) : null; - let best: StoreSeed | null = null; let newest: string | null = null; for (const dir of dirs) { if (excluded !== null && resolve(draftsDir, dir) === excluded) continue; // the draft being repaired is no donor for itself + // The first timeline document present, kept so a project none of whose + // documents parsed can still be told apart: encrypted, or merely broken. + let firstDocument: string | null = null; + let outcome: "readable" | "markerless" | null = null; for (const name of ["draft_info.json", "draft_content.json"]) { const filePath = resolve(draftsDir, dir, name); let candidate: ReturnType; @@ -254,10 +279,14 @@ export function scanStore(draftsDir: string, options: { exclude?: string } = {}) } catch { continue; // a sibling vanishing or unreadable mid-scan is not this draft's problem } + if (candidate.exists && firstDocument === null) firstDocument = filePath; const draft = candidate.draft; if (!draft) continue; const version = draft.platform?.app_version; - if (typeof version !== "string" || version.length === 0) break; // markerless: nothing to rank on + if (typeof version !== "string" || version.length === 0) { + outcome = "markerless"; // nothing to rank on + break; + } if (newest === null || atLeast(version, newest)) newest = version; const seed: StoreSeed = { projectDir: resolve(draftsDir, dir), @@ -268,10 +297,43 @@ export function scanStore(draftsDir: string, options: { exclude?: string } = {}) mtimeMs: candidate.mtime ? Date.parse(candidate.mtime) : 0, }; if (best === null || seedOutranks(seed, best)) best = seed; + outcome = "readable"; break; // one reading per project folder } + if (firstDocument === null) continue; // no timeline document: not a project folder + store.projects++; + if (outcome === "readable") store.readable++; + else if (outcome === "markerless") store.markerless++; + else if (detectEncryption(firstDocument).encrypted) store.encrypted++; + else store.unreadable++; } - return { seed: best, newestVersion: newest }; + return { seed: best, newestVersion: newest, store }; +} + +/** + * A JianYing 6.0+ store holds nothing the CLI can seed from: every project + * the app wrote is an encrypted payload (docs/jianying-encryption.md), so + * `init` falls back to the bundled template — and until now said nothing + * about the projects it skipped, leaving the user to learn whether the app + * accepts that draft by opening it. Name the fallback and what is known + * about it. Fires only when the store holds encrypted projects and no + * readable seed at all: a readable donor is covered by seeding, and an + * explicit `--template` (seed off) is the caller's own choice. + */ +export function encryptedStoreWarning( + scan: StoreScan, + seedMode: "auto" | "always" | "off", + templateVersion: string | null, +): string | null { + if (seedMode === "off" || scan.seed !== null || scan.store.encrypted === 0) return null; + const { projects, encrypted } = scan.store; + const which = encrypted === projects ? `all ${projects}` : `${encrypted} of the ${projects}`; + return ( + `This drafts folder holds ${projects} project(s) and ${which} are encrypted — JianYing 6.0+ writes draft_content.json as an ` + + "encrypted payload, which this CLI does not read — so none could seed the new draft; it was built from the bundled " + + `CapCut ${templateVersion ?? "6.5.0"} template instead. JianYing 11.4 (macOS) is reported to open such a plaintext draft ` + + "and upgrade it in place; other builds are unverified. Open the draft in JianYing to confirm, and see docs/jianying-encryption.md." + ); } export function findStoreSeed(draftsDir: string, options: { exclude?: string } = {}): StoreSeed | null { @@ -341,6 +403,13 @@ export interface TemplateReport { skipped: string[]; /** Top-level keys reset to empty when seeding from a store project. */ reset: string[]; + /** What the drafts folder held when the skeleton was chosen (see StoreScanSummary). */ + store: StoreScanSummary; + /** Why the app may still refuse this skeleton, when the scan could tell: the + * bundled template in a store the app has outgrown (#67, #111), or a store + * whose projects are all encrypted and could not seed. Also written to + * stderr; carried here so quickstart / compile / library callers see it. */ + warning?: string; } // Top-level keys that hold a project's CONTENT rather than its schema/settings. @@ -507,10 +576,19 @@ export function initDraft(opts: InitOptions): { const content = JSON.stringify(draft, null, 0); for (const file of files) writeFileSync(resolve(draftPath, file), content, "utf-8"); filePath = resolve(draftPath, files[0]); - template = { source: "store", path: seed.projectDir, app_version: seed.appVersion, skipped: [], reset }; + template = { + source: "store", + path: seed.projectDir, + app_version: seed.appVersion, + skipped: [], + reset, + store: scan.store, + }; } else { const skipped = copyTemplateDir(opts.templateDir, draftPath); - const versionWarning = templateVersionWarning(templateVersion, scan.newestVersion); + const versionWarning = + templateVersionWarning(templateVersion, scan.newestVersion) ?? + encryptedStoreWarning(scan, seedMode, templateVersion); if (versionWarning) process.stderr.write(`WARNING: ${versionWarning}\n`); // Identity (and the canvas override) land in EVERY plain timeline document @@ -541,7 +619,15 @@ export function initDraft(opts: InitOptions): { // init writes), else draft_info.json, else whatever was stamped. const identityFile = ["draft_content.json", "draft_info.json"].find((file) => stamped.includes(file)); filePath = resolve(draftPath, identityFile ?? stamped[0] ?? templateDoc.file); - template = { source: "path", path: resolve(opts.templateDir), app_version: templateVersion, skipped, reset: [] }; + template = { + source: "path", + path: resolve(opts.templateDir), + app_version: templateVersion, + skipped, + reset: [], + store: scan.store, + ...(versionWarning ? { warning: versionWarning } : {}), + }; } // CapCut's GUI does not scan the Projects folder — it lists drafts from a diff --git a/src/index.ts b/src/index.ts index 998f7e5..813646c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3662,8 +3662,16 @@ function cmdVersion(draft: Draft, filePath: string, flags: Flags): void { } async function cmdLint(draft: Draft, filePath: string, flags: Flags): Promise<{ exitCode: number }> { - const { DEFAULT_LINT_OPTIONS, buildPipReport, fixDraft, lintDraft, lintExitCode, pipLintIssues, summarize } = - await import("./lint.js"); + const { + DEFAULT_LINT_OPTIONS, + buildPipReport, + fixDraft, + lintDraft, + lintExitCode, + pipLintIssues, + scriptLimitsExcept, + summarize, + } = await import("./lint.js"); const opts: LintOptions = { maxCharsPerLine: flags.maxChars ?? DEFAULT_LINT_OPTIONS.maxCharsPerLine, maxCueDurationUs: @@ -3672,6 +3680,12 @@ async function cmdLint(draft: Draft, filePath: string, flags: Flags): Promise<{ flags.minGapMs !== undefined ? flags.minGapMs * 1000 : DEFAULT_LINT_OPTIONS.minGapBetweenCaptionsUs, maxCharsPerSecond: flags.maxCps ?? DEFAULT_LINT_OPTIONS.maxCharsPerSecond, safeAreaFraction: flags.safeArea ?? DEFAULT_LINT_OPTIONS.safeAreaFraction, + // An explicit --max-chars / --max-cps applies to every script; otherwise + // CJK captions follow their own defaults (CJK_SCRIPT_LIMITS). + scriptLimits: scriptLimitsExcept(DEFAULT_LINT_OPTIONS.scriptLimits ?? null, { + maxCharsPerLine: flags.maxChars !== undefined, + maxCharsPerSecond: flags.maxCps !== undefined, + }), checkLocalPaths: flags.noCheckPaths ? false : DEFAULT_LINT_OPTIONS.checkLocalPaths, probeMedia: flags.noProbe ? false : DEFAULT_LINT_OPTIONS.probeMedia, ffprobeCmd: flags.ffprobeCmd, @@ -3699,8 +3713,13 @@ async function cmdLint(draft: Draft, filePath: string, flags: Flags): Promise<{ const inStore = existsSync(path.join(storeDir, "root_meta_info.json")) || isManagedDraftPath(path.resolve(filePath)); if (inStore) { - const { detectStoreAppVersion } = await import("./factory.js"); - opts.storeAppVersion = detectStoreAppVersion(storeDir); + const { scanStore } = await import("./factory.js"); + // The linted draft is no evidence about its own store: excluded, so a + // bundled-template draft alone in a JianYing store is not its own + // "readable 6.5.0 project". + const scan = scanStore(storeDir, { exclude: path.dirname(path.resolve(filePath)) }); + opts.storeAppVersion = scan.newestVersion; + opts.storeEncryptedProjects = scan.store.encrypted; } } } diff --git a/src/lib.ts b/src/lib.ts index e8320f1..8654a9b 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -40,11 +40,15 @@ export { saveDraft, updateTextContent, } from "./draft.js"; -export type { LintIssue, LintOptions, Severity } from "./lint.js"; +export type { CaptionScript, LintIssue, LintOptions, ScriptLimit, ScriptLimits, Severity } from "./lint.js"; export { + CJK_SCRIPT_LIMITS, + captionLimits, + captionScript, DEFAULT_LINT_OPTIONS, lintDraft, lintExitCode, + scriptLimitsExcept, summarize, } from "./lint.js"; export type { RunCommandRequest, RunCommandResult } from "./runner.js"; diff --git a/src/lint.ts b/src/lint.ts index 1443976..de2964b 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -64,6 +64,114 @@ const FIXABLE_CODES = new Set([ // fixable:false instead. export const MIN_CAPTION_DURATION_US = 100_000; +/** The script a caption is written in, as far as the line-length and + * reading-speed rules care: Latin (and everything else), or one of the three + * CJK scripts whose subtitling conventions differ from Latin ones. */ +export type CaptionScript = "latin" | "zh" | "ja" | "ko"; + +export interface ScriptLimit { + maxCharsPerLine?: number; + maxCharsPerSecond?: number; +} + +/** Caption limits that replace `maxCharsPerLine` / `maxCharsPerSecond` for a + * cue written in the given script. An absent key falls back to the Latin + * value for that rule. */ +export type ScriptLimits = Partial, ScriptLimit>>; + +/** + * Where the Latin defaults (42 characters per line, 20 per second) come from + * a Latin alphabet, a CJK character carries a syllable or a word, so a line + * a third as long is already full and a third the speed is already fast: + * the streaming style guides sit at 16 characters per line and 9 per second + * for Simplified Chinese, 13 and 4 for Japanese, 16 and 12 for Korean. A + * 30-character Chinese line passing a 42-character check is the failure this + * table exists for. + */ +export const CJK_SCRIPT_LIMITS: ScriptLimits = { + zh: { maxCharsPerLine: 16, maxCharsPerSecond: 9 }, + ja: { maxCharsPerLine: 13, maxCharsPerSecond: 4 }, + ko: { maxCharsPerLine: 16, maxCharsPerSecond: 12 }, +}; + +const KANA = /[\u3040-\u30ff]/; +const HANGUL = /[\u1100-\u11ff\u3130-\u318f\uac00-\ud7af]/; +const HAN = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/; +// Full-width punctuation and symbols travel with the CJK scripts and count +// towards the share, without deciding which script it is. +const CJK_ANY = + /[\u3000-\u303f\u3040-\u30ff\u3130-\u318f\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/; + +/** + * The script a caption's limits should follow: CJK when at least half of its + * visible characters are CJK, then Japanese if any kana is present, Korean if + * any hangul, else Chinese. A mixed caption below that share (a Latin line + * with one CJK name) keeps the Latin limits. + */ +export function captionScript(text: string): CaptionScript { + let total = 0; + let cjk = 0; + let kana = 0; + let hangul = 0; + let han = 0; + for (const ch of text) { + if (/\s/.test(ch)) continue; + total++; + if (KANA.test(ch)) { + kana++; + cjk++; + } else if (HANGUL.test(ch)) { + hangul++; + cjk++; + } else if (HAN.test(ch)) { + han++; + cjk++; + } else if (CJK_ANY.test(ch)) { + cjk++; + } + } + if (total === 0 || cjk * 2 < total) return "latin"; + if (kana > 0) return "ja"; + if (hangul > 0) return "ko"; + if (han > 0) return "zh"; + return "latin"; +} + +/** The line-length and reading-speed limits for one caption's text, with the + * suffix its messages carry when a script-specific default applied. */ +export function captionLimits( + text: string, + opts: LintOptions, +): { script: CaptionScript; maxCharsPerLine: number; maxCharsPerSecond: number | undefined; note: string } { + const script = captionScript(text); + const limit = script === "latin" ? undefined : opts.scriptLimits?.[script]; + const maxCharsPerLine = limit?.maxCharsPerLine ?? opts.maxCharsPerLine; + const maxCharsPerSecond = limit?.maxCharsPerSecond ?? opts.maxCharsPerSecond; + const applied = limit !== undefined && (limit.maxCharsPerLine !== undefined || limit.maxCharsPerSecond !== undefined); + return { script, maxCharsPerLine, maxCharsPerSecond, note: applied ? `, ${script} default` : "" }; +} + +/** + * The script table minus the rules the caller set explicitly: `--max-chars 30` + * means 30 for every script, while the reading-speed defaults still follow + * the script (and vice versa). Null when nothing script-specific is left. + */ +export function scriptLimitsExcept( + limits: ScriptLimits | null, + explicit: { maxCharsPerLine?: boolean; maxCharsPerSecond?: boolean }, +): ScriptLimits | null { + if (!limits) return null; + const out: ScriptLimits = {}; + for (const [script, limit] of Object.entries(limits) as [Exclude, ScriptLimit][]) { + const kept: ScriptLimit = {}; + if (!explicit.maxCharsPerLine && limit.maxCharsPerLine !== undefined) kept.maxCharsPerLine = limit.maxCharsPerLine; + if (!explicit.maxCharsPerSecond && limit.maxCharsPerSecond !== undefined) + kept.maxCharsPerSecond = limit.maxCharsPerSecond; + if (Object.keys(kept).length > 0) out[script] = kept; + } + return Object.keys(out).length > 0 ? out : null; +} + export interface LintOptions { maxCharsPerLine: number; maxCueDurationUs: number; @@ -101,6 +209,14 @@ export interface LintOptions { * a newer app major is the draft CapCut refuses as "from an unusual path" * (#67, #111). Library callers with no store simply omit it. */ storeAppVersion?: string | null; + /** How many projects in that drafts folder are JianYing 6.0+ encrypted + * payloads the CLI could neither seed from nor compare against. With no + * readable project at all, a bundled-template draft cannot be called stale + * — only unverified for the app that wrote those projects. */ + storeEncryptedProjects?: number; + /** Script-specific replacements for maxCharsPerLine / maxCharsPerSecond + * (see CJK_SCRIPT_LIMITS). Null applies the Latin values to every script. */ + scriptLimits?: ScriptLimits | null; } export const DEFAULT_LINT_OPTIONS: LintOptions = { @@ -111,6 +227,7 @@ export const DEFAULT_LINT_OPTIONS: LintOptions = { probeMedia: true, maxCharsPerSecond: 20, // upper end of the BBC/Netflix reading-speed range safeAreaFraction: 0.85, // |transform.y| past this is under the platform UI + scriptLimits: CJK_SCRIPT_LIMITS, }; export function lintDraft(draft: Draft, opts: LintOptions = DEFAULT_LINT_OPTIONS): LintIssue[] { @@ -199,7 +316,8 @@ export function lintDraft(draft: Draft, opts: LintOptions = DEFAULT_LINT_OPTIONS // catches the opposite failure, a caption that is gone before it can be // read. Only meaningful with both text and a real duration, and counted // on visible characters (whitespace is not read). - const cps = opts.maxCharsPerSecond; + const limits = captionLimits(text, opts); + const cps = limits.maxCharsPerSecond; if (cps !== undefined && cps > 0 && text.length > 0 && s.target_timerange.duration > 0) { const visible = text.replace(/\s+/g, "").length; const seconds = s.target_timerange.duration / 1_000_000; @@ -209,7 +327,7 @@ export function lintDraft(draft: Draft, opts: LintOptions = DEFAULT_LINT_OPTIONS issues.push({ severity: "warning", code: "caption-too-fast", - message: `Caption ${shortId(s.id)} runs at ${rate.toFixed(1)} chars/s (>${cps}) — ${visible} characters in ${Math.round(seconds * 1000)}ms`, + message: `Caption ${shortId(s.id)} runs at ${rate.toFixed(1)} chars/s (>${cps}${limits.note}) — ${visible} characters in ${Math.round(seconds * 1000)}ms`, // Report-only: the repair is either more screen time (which moves // every later caption) or fewer words (an authoring decision). fixable: false, @@ -248,15 +366,15 @@ export function lintDraft(draft: Draft, opts: LintOptions = DEFAULT_LINT_OPTIONS } for (const line of text.split(/\r?\n/)) { - if (line.length > opts.maxCharsPerLine) { + if (line.length > limits.maxCharsPerLine) { issues.push({ severity: "warning", code: "line-too-long", - message: `Caption ${shortId(s.id)} has ${line.length}-char line (>${opts.maxCharsPerLine}): "${line.slice(0, 50)}…"`, + message: `Caption ${shortId(s.id)} has ${line.length}-char line (>${limits.maxCharsPerLine}${limits.note}): "${line.slice(0, 50)}…"`, fixable: FIXABLE_CODES.has("line-too-long") && mat !== undefined && - canFixLineTooLong(mat.content, opts.maxCharsPerLine), + canFixLineTooLong(mat.content, limits.maxCharsPerLine), location: { track: track.name, segment_id: s.id }, }); break; @@ -606,6 +724,27 @@ export function lintDraft(draft: Draft, opts: LintOptions = DEFAULT_LINT_OPTIONS suggested_command: "capcut migrate --from-store # restamps the markers from the store's newest project", }); + } else if ( + !opts.storeAppVersion && + opts.storeEncryptedProjects !== undefined && + opts.storeEncryptedProjects > 0 && + typeof app === "string" && + markerless + ) { + // The JianYing counterpart: the store's projects are all encrypted, so + // there is no version to compare against and nothing that could have + // seeded this draft. Not stale — unverified for the app that wrote them. + const n = opts.storeEncryptedProjects; + issues.push({ + severity: "info", + code: "template-unverified-store", + message: + `Draft declares CapCut ${app} and carries no version/new_version markers (the bundled template), and the ` + + `${n} project(s) in this drafts folder are encrypted (JianYing 6.0+), so nothing could seed it or say which ` + + "app version wrote them — whether this JianYing build opens the draft is unverified (JianYing 11.4 macOS is " + + "reported to open and upgrade it in place; see docs/jianying-encryption.md)", + fixable: false, + }); } } @@ -1081,7 +1220,7 @@ export function fixDraft(draft: Draft, opts: LintOptions = DEFAULT_LINT_OPTIONS) if (!mat) continue; const undoubled = undoubleContent(mat.content); if (undoubled !== null) mat.content = undoubled; - const rewrapped = rewrapContent(mat.content, opts.maxCharsPerLine); + const rewrapped = rewrapContent(mat.content, captionLimits(extractText(mat.content), opts).maxCharsPerLine); if (rewrapped !== null) mat.content = rewrapped; } } diff --git a/src/quickstart.ts b/src/quickstart.ts index 89b6277..3493712 100644 --- a/src/quickstart.ts +++ b/src/quickstart.ts @@ -97,7 +97,9 @@ export function runQuickstart(opts: QuickstartOptions): QuickstartResult { const templateNote = init.template.source === "store" ? ` Skeleton seeded from the store's CapCut ${init.template.app_version} project (${init.template.path}).` - : ""; + : init.template.warning + ? ` ${init.template.warning}` + : ""; steps.push({ step: "create", ok: true, diff --git a/test/init-seed-encrypted.test.mjs b/test/init-seed-encrypted.test.mjs new file mode 100644 index 0000000..8703e66 --- /dev/null +++ b/test/init-seed-encrypted.test.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +// A JianYing 6.0+ drafts folder: every project the app wrote is an encrypted +// payload the CLI deliberately does not read (docs/jianying-encryption.md). +// Seeding (init-seed.test.mjs) has nothing to work with there, so init falls +// back to the bundled template — and must say so, count what it skipped, and +// let lint name the draft as unverified for that app rather than stale. + +function scratch() { + const dir = mkdtempSync(join(tmpdir(), "capcut-init-encrypted-")); + writeFileSync(join(dir, "root_meta_info.json"), JSON.stringify({ all_draft_store: [] })); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +// What an encrypted timeline document looks like to the CLI: bytes that are +// not JSON and do not start with "{" (a corrupted plaintext draft would). +const ENCRYPTED_PAYLOAD = Buffer.concat([Buffer.from([0x8f, 0x2a, 0x11]), Buffer.alloc(253, 0xa7)]); + +function encryptedProject(dir, name) { + const project = join(dir, name); + mkdirSync(project, { recursive: true }); + writeFileSync(join(project, "draft_content.json"), ENCRYPTED_PAYLOAD); + writeFileSync(join(project, "draft_info.json"), ENCRYPTED_PAYLOAD); + writeFileSync( + join(project, "draft_meta_info.json"), + JSON.stringify({ draft_id: `${name}-ID`, draft_name: name, draft_materials: [] }), + ); + return project; +} + +function appProject(dir, name, appVersion = "9.3.0") { + const project = join(dir, name); + mkdirSync(project, { recursive: true }); + writeFileSync( + join(project, "draft_info.json"), + JSON.stringify({ + id: `${name}-ID`, + name, + duration: 0, + fps: 30, + canvas_config: { width: 1920, height: 1080, ratio: "16:9" }, + color_space: 0, + config: { maintrack_adsorb: true }, + free_render_index_mode_on: false, + last_modified_platform: { app_id: 359289, app_source: "cc", app_version: appVersion, os: "mac" }, + new_version: "183.0.0", + platform: { app_id: 359289, app_source: "cc", app_version: appVersion, os: "mac" }, + render_index_track_mode_on: true, + source: "default", + version: 360000, + tracks: [], + materials: { videos: [], texts: [] }, + }), + ); + writeFileSync( + join(project, "draft_meta_info.json"), + JSON.stringify({ draft_id: `${name}-ID`, draft_name: name, draft_materials: [] }), + ); + return project; +} + +describe("capcut init — a drafts folder whose projects are all encrypted", () => { + it("falls back to the bundled template and names the projects it could not seed from", (t) => { + const { dir, cleanup } = scratch(); + t.after(cleanup); + encryptedProject(dir, "encrypted-a"); + encryptedProject(dir, "encrypted-b"); + + const r = spawnCli(["init", "fresh", "--drafts", dir]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.template.source, "path"); + assert.deepEqual(r.json.template.store, { projects: 2, readable: 0, markerless: 0, encrypted: 2, unreadable: 0 }); + assert.match(r.json.template.warning, /holds 2 project\(s\) and all 2 are encrypted/); + assert.match(r.json.template.warning, /docs\/jianying-encryption\.md/); + assert.match(r.stderr, /WARNING: This drafts folder holds 2 project\(s\) and all 2 are encrypted/); + }); + + it("keeps quiet when a readable project seeds the draft beside encrypted ones", (t) => { + const { dir, cleanup } = scratch(); + t.after(cleanup); + appProject(dir, "app-project"); + encryptedProject(dir, "encrypted-a"); + + const r = spawnCli(["init", "fresh", "--drafts", dir]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.template.source, "store"); + assert.equal(r.json.template.app_version, "9.3.0"); + assert.deepEqual(r.json.template.store, { projects: 2, readable: 1, markerless: 0, encrypted: 1, unreadable: 0 }); + assert.equal(r.json.template.warning, undefined); + assert.doesNotMatch(r.stderr, /WARNING:/); + }); + + it("keeps quiet when the caller opted out of seeding", (t) => { + const { dir, cleanup } = scratch(); + t.after(cleanup); + encryptedProject(dir, "encrypted-a"); + + const r = spawnCli(["init", "fresh", "--drafts", dir, "--template", "bundled"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.template.source, "path"); + assert.equal(r.json.template.store.encrypted, 1, "the scan is still reported"); + assert.equal(r.json.template.warning, undefined); + assert.doesNotMatch(r.stderr, /WARNING:/); + }); + + it("tells a broken plaintext document apart from an encrypted one", (t) => { + const { dir, cleanup } = scratch(); + t.after(cleanup); + encryptedProject(dir, "encrypted-a"); + const broken = join(dir, "broken"); + mkdirSync(broken, { recursive: true }); + writeFileSync(join(broken, "draft_content.json"), '{"tracks": ['); + + const r = spawnCli(["init", "fresh", "--drafts", dir]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.deepEqual(r.json.template.store, { projects: 2, readable: 0, markerless: 0, encrypted: 1, unreadable: 1 }); + assert.match(r.json.template.warning, /holds 2 project\(s\) and 1 of the 2 are encrypted/); + }); +}); + +describe("capcut lint — template-unverified-store", () => { + it("names the bundled-template draft as unverified for the app that wrote the encrypted projects", (t) => { + const { dir, cleanup } = scratch(); + t.after(cleanup); + encryptedProject(dir, "encrypted-a"); + encryptedProject(dir, "encrypted-b"); + const init = spawnCli(["init", "fresh", "--drafts", dir]); + assert.equal(init.status, 0, `stderr: ${init.stderr}`); + + const r = spawnCli(["lint", init.json.draft_path, "--no-check-paths"]); + assert.equal(r.status, 0, "an info-level finding does not fail CI"); + const found = r.json.issues.filter((i) => i.code === "template-unverified-store"); + assert.equal(found.length, 1, `expected template-unverified-store; got: ${JSON.stringify(r.json.issues)}`); + assert.equal(found[0].severity, "info"); + assert.equal(found[0].fixable, false); + assert.match(found[0].message, /2 project\(s\) in this drafts folder are encrypted \(JianYing 6\.0\+\)/); + assert.equal(r.json.issues.filter((i) => i.code === "template-stale").length, 0, "not stale: nothing to compare"); + }); + + it("stays silent for a draft the store's readable project seeded", (t) => { + const { dir, cleanup } = scratch(); + t.after(cleanup); + appProject(dir, "app-project"); + encryptedProject(dir, "encrypted-a"); + const init = spawnCli(["init", "fresh", "--drafts", dir]); + assert.equal(init.status, 0, `stderr: ${init.stderr}`); + + const r = spawnCli(["lint", init.json.draft_path, "--no-check-paths"]); + assert.equal(r.json.issues.filter((i) => i.code === "template-unverified-store").length, 0); + }); +}); + +describe("capcut quickstart — a drafts folder whose projects are all encrypted", () => { + it("carries the fallback note in its create step", (t) => { + const { dir, cleanup } = scratch(); + t.after(cleanup); + encryptedProject(dir, "encrypted-a"); + const srt = join(dir, "subs.srt"); + writeFileSync(srt, "1\n00:00:01,000 --> 00:00:03,000\nHello\n\n2\n00:00:03,500 --> 00:00:05,000\nWorld\n"); + + const r = spawnCli(["quickstart", "qs", "--srt", srt, "--drafts", dir]); + assert.ok(r.json, `no JSON; stderr: ${r.stderr}`); + const create = r.json.steps.find((s) => s.step === "create"); + assert.ok(create, "create step present"); + assert.match(create.detail, /holds 1 project\(s\) and all 1 are encrypted/); + assert.match(r.json.template.warning, /encrypted/); + }); +}); diff --git a/test/lint-cjk.test.mjs b/test/lint-cjk.test.mjs new file mode 100644 index 0000000..8c4c1f7 --- /dev/null +++ b/test/lint-cjk.test.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; +import { tmpDraft } from "./helpers/tmp-draft.mjs"; + +// Caption limits by script. The Latin defaults (42 characters per line, 20 +// per second) let a 32-character Chinese line through although a CJK line is +// full at 16 and unreadable past about 9 characters per second; the streaming +// style guides differ per script (zh 16/9, ja 13/4, ko 16/12). An explicit +// --max-chars / --max-cps applies to every script. + +function textMat(id, text) { + return { + id, + type: "text", + content: JSON.stringify({ text, styles: [] }), + font_size: 15, + text_color: "#FFFFFF", + alignment: 1, + }; +} + +function textSeg(id, materialId, startUs, durationUs) { + return { + id, + material_id: materialId, + target_timerange: { start: startUs, duration: durationUs }, + source_timerange: { start: 0, duration: durationUs }, + speed: 1, + volume: 1, + visible: true, + clip: { alpha: 1, rotation: 0, scale: { x: 1, y: 1 }, transform: { x: 0, y: 0 } }, + extra_material_refs: [], + render_index: 0, + }; +} + +/** A fixture copy with one caption of the given text and duration on its own text track. */ +function draftWithCaption(t, text, durationUs) { + const fix = tmpDraft(); + t.after(fix.cleanup); + const draft = JSON.parse(readFileSync(fix.path, "utf-8")); + draft.materials.texts = [...(draft.materials.texts ?? []), textMat("CJK-M", text)]; + draft.tracks.push({ + id: "CJK-T", + type: "text", + name: "CJK-T", + attribute: 0, + segments: [textSeg("CJK-S", "CJK-M", 10_000_000, durationUs)], + }); + writeFileSync(fix.path, JSON.stringify(draft)); + return fix; +} + +function lengthAndSpeed(issues) { + return issues.filter((i) => i.code === "line-too-long" || i.code === "caption-too-fast"); +} + +const ZH_32 = "今天我们来聊一聊剪映草稿的自动化处理方法以及常见的坑,一起看看吧"; // 32 characters +const JA_28 = "きょうはじまるあたらしいものがたりをいっしょにみましょう"; // 28 kana +const KO_30 = "오늘은 새로운 이야기를 함께 시작해 보겠습니다 기대돼요"; // 30 code units + +describe("capcut lint — caption limits follow the caption's script", () => { + it("holds a Chinese caption to 16 characters per line and 9 per second", (t) => { + const fix = draftWithCaption(t, ZH_32, 2_000_000); + const r = spawnCli(["lint", fix.path, "--no-check-paths"]); + const found = lengthAndSpeed(r.json.issues); + const line = found.find((i) => i.code === "line-too-long"); + const speed = found.find((i) => i.code === "caption-too-fast"); + assert.ok(line, `expected line-too-long; got: ${JSON.stringify(r.json.issues)}`); + assert.match(line.message, /32-char line \(>16, zh default\)/); + assert.equal(line.fixable, true, "a CJK line re-wraps between characters"); + assert.ok(speed, `expected caption-too-fast; got: ${JSON.stringify(r.json.issues)}`); + assert.match(speed.message, /chars\/s \(>9, zh default\)/); + }); + + it("holds a Japanese caption to 13 characters per line and 4 per second", (t) => { + const fix = draftWithCaption(t, JA_28, 3_000_000); + const r = spawnCli(["lint", fix.path, "--no-check-paths"]); + const found = lengthAndSpeed(r.json.issues); + assert.match(found.find((i) => i.code === "line-too-long")?.message ?? "", /\(>13, ja default\)/); + assert.match(found.find((i) => i.code === "caption-too-fast")?.message ?? "", /\(>4, ja default\)/); + }); + + it("holds a Korean caption to 16 characters per line and 12 per second", (t) => { + const fix = draftWithCaption(t, KO_30, 1_500_000); + const r = spawnCli(["lint", fix.path, "--no-check-paths"]); + const found = lengthAndSpeed(r.json.issues); + assert.match(found.find((i) => i.code === "line-too-long")?.message ?? "", /\(>16, ko default\)/); + assert.match(found.find((i) => i.code === "caption-too-fast")?.message ?? "", /\(>12, ko default\)/); + }); + + it("leaves Latin captions, and mostly-Latin ones with a CJK word, on the Latin limits", (t) => { + const latin = draftWithCaption(t, "This is a thirty char line ok!", 2_000_000); + assert.deepEqual(lengthAndSpeed(spawnCli(["lint", latin.path, "--no-check-paths"]).json.issues), []); + const mixed = draftWithCaption(t, "Rene 说 hello world today again", 2_000_000); + assert.deepEqual(lengthAndSpeed(spawnCli(["lint", mixed.path, "--no-check-paths"]).json.issues), []); + }); + + it("applies an explicit --max-chars / --max-cps to every script", (t) => { + const fix = draftWithCaption(t, ZH_32, 2_000_000); + const both = spawnCli(["lint", fix.path, "--no-check-paths", "--max-chars", "42", "--max-cps", "20"]); + assert.deepEqual(lengthAndSpeed(both.json.issues), []); + // One explicit rule leaves the other on its script default. + const lineOnly = spawnCli(["lint", fix.path, "--no-check-paths", "--max-chars", "42"]); + assert.deepEqual( + lengthAndSpeed(lineOnly.json.issues).map((i) => i.code), + ["caption-too-fast"], + ); + }); + + it("--fix re-wraps the Chinese line to 16 characters", (t) => { + const fix = draftWithCaption(t, ZH_32, 6_000_000); + const r = spawnCli(["lint", fix.path, "--no-check-paths", "--fix"]); + assert.ok( + r.json.fixed.some((f) => f.code === "line-too-long"), + `fixed: ${JSON.stringify(r.json.fixed)}`, + ); + const draft = JSON.parse(readFileSync(fix.path, "utf-8")); + const text = JSON.parse(draft.materials.texts.find((m) => m.id === "CJK-M").content).text; + assert.equal(text.replace(/\n/g, ""), ZH_32, "characters untouched"); + assert.ok( + text.split("\n").every((l) => l.length <= 16), + `lines: ${JSON.stringify(text.split("\n"))}`, + ); + const again = spawnCli(["lint", fix.path, "--no-check-paths"]); + assert.equal(again.json.issues.filter((i) => i.code === "line-too-long").length, 0); + }); +}); From 91d52e46ef16affbf7bc59a085c482ba239e9593 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 18 Sep 2026 07:30:41 +0000 Subject: [PATCH 2/3] docs: one-command agent install, Chinese skill triggers, plugin manifest 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README (English and Chinese) gains a section on installing the capcut-edit skill into Claude Code, Codex, Cursor, OpenCode and the other agents the skills installer supports (npx skills add renezander030/capcut-cli), plus the Claude Code plugin route. The skill description now also triggers on Chinese requests (剪映, 字幕, 草稿, 剪辑, 切片, 视频编辑), and the plugin manifest's description and keywords match the current command surface. The v0.24.0 highlights replace v0.22.0 in both READMEs. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 9 ++++++--- README.md | 19 ++++++++++++++++++- README.zh-CN.md | 19 ++++++++++++++++++- skills/capcut-edit/SKILL.md | 2 +- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ed971b1..cfe6ed4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ { "name": "capcut-cli", "source": "./", - "description": "Edit CapCut and JianYing projects from Claude Code — subtitles, timing, speed, volume, transitions, masks, templates, cut long-form to shorts." + "description": "Edit CapCut and JianYing (剪映) projects from Claude Code — subtitles and SRT/ASS import, timing, speed, volume, keyframes, masks, filters and effects, templates, lint --fix, render previews, and cutting long-form to shorts." } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 28f0043..295a9fd 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "capcut-cli", - "version": "0.1.4", - "description": "Edit CapCut and JianYing projects from Claude Code — subtitles, timing, speed, volume, templates, cut long-form to shorts.", + "version": "0.2.0", + "description": "Edit CapCut and JianYing (剪映) projects from Claude Code — subtitles and SRT/ASS import, timing, speed, volume, keyframes, masks, filters and effects, templates, lint --fix, render previews, and cutting long-form to shorts.", "author": { "name": "René Zander", "url": "https://github.com/renezander030" @@ -18,7 +18,10 @@ "timeline", "templates", "shorts", - "automation" + "automation", + "剪映", + "agent-skill", + "claude-code-plugin" ], "skills": "./skills/" } diff --git a/README.md b/README.md index 09cc3a9..eb32dd4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,23 @@ JSON in, JSON out: every command reads and writes the local draft store directly - **Queue runner** — `capcut serve` reads JSONL jobs from stdin, for [n8n / Make / Coze](./examples/serve-automation.md) - **Agent sandbox (experimental)** — build [`capcut-core.wasm`](https://github.com/renezander030/capcut-cli/tree/master/wasm/capcut-core) for three read-only MCP tools with zero filesystem, network, environment, clock, random, stdio, or process imports +### Give your agent the skill + +One command installs the `capcut-edit` skill into Claude Code, Codex, Cursor, OpenCode and the other agents the [`skills`](https://skills.sh) installer supports: + +```bash +npx skills add renezander030/capcut-cli +``` + +Claude Code can also load it as a plugin: + +``` +/plugin marketplace add renezander030/capcut-cli +/plugin install capcut-cli@capcut-cli +``` + +The skill teaches the agent every command, the progressive-disclosure habit (inspect first, never dump a whole draft), where the draft store lives on macOS and Windows, and the deterministic scripts for fades, Ken Burns and long-to-short cuts. It triggers on English and Chinese requests alike (剪映, 字幕, 草稿). + ### Capability-free Wasm tools for agents **Using an AI assistant with capcut-cli? Give it a safer “look, don’t touch” mode.** @@ -86,7 +103,7 @@ The host reads a draft and passes its JSON as tool input. The component itself h ## Release notes -> **New in v0.22.0:** nine items mined from what users are hitting across this repo, its forks and the wider CapCut/JianYing tooling. `register --materials` writes the `draft_materials` registration CapCut 9.1 reads to decide what is imported — the fix for every clip showing as "file inaccessible" with a relink prompt ([pyCapCut#13](https://github.com/GuanYixuan/pyCapCut/issues/13)). `export-timeline --captions markers` carries caption cues into the NLE as OTIO timeline markers, and `import-timeline` rebuilds the text track from them (OTIO has no title schema — [OpenTimelineIO#62](https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/62), open since 2017). `caption --script` keeps whisper's word timing but uses your script's wording. `detect-retakes` finds the sentence the speaker fluffed and said again, with the window / min-words / similarity guards that keep it from collapsing a timeline. Plus `render --soft-captions` (a toggleable mov_text stream), `matting` (smart background removal on a clip's material), `init --ratio 9:16` for portrait drafts, IR-style keyframe aliases (`scale`, `x`, `y`, `opacity`) and `--easing hold`. No command was removed and no existing output changed shape. Full details in the [changelog](./CHANGELOG.md). +> **New in v0.24.0:** captions in Chinese, Japanese and Korean are held to their own limits — `lint` flags a 32-character Chinese line and a 15 chars/s cue that the Latin defaults (42, 20) let through, and `--fix` re-wraps between characters (zh 16/9, ja 13/4, ko 16/12; an explicit `--max-chars` / `--max-cps` still applies everywhere). On a JianYing 6.0+ drafts folder, where every app-written project is encrypted, `init` / `quickstart` / `compile` now say that none could seed the new draft (`template.store`, a WARNING) and `lint` reports `template-unverified-store` instead of nothing. Plus a one-command agent install: `npx skills add renezander030/capcut-cli`. Full details in the [changelog](./CHANGELOG.md). > **New in v0.23.0:** drafts that open on the CapCut you actually have. A draft built from the bundled 6.5.0 template is refused by CapCut 8.4+, 8.7 Windows and 9.3 as "from an unusual path" ([#67](https://github.com/renezander030/capcut-cli/issues/67), [#111](https://github.com/renezander030/capcut-cli/issues/111) — the real 8.7 Windows round-trip, negative with the bundled template and positive with one captured from the installed app). `init`, `quickstart` and `compile` now seed new drafts from the newest app-authored project in your drafts folder by default (its version markers and settings, none of its content, never its `Timelines/` mirrors); `migrate --from-store` restamps drafts built earlier, and `lint` reports the stale signature as `template-stale`. Media gets its `local_material_id` link to `draft_materials` at add time — the key JianYing 5.9+ and CapCut 9.3 resolve local clips by ([JmsLdrn/capcut-mcp#1](https://github.com/JmsLdrn/capcut-mcp/issues/1)) — and `lint --fix` writes it for existing drafts (`media-unlinked`). Plus `source-range-exceeds-material`, a `compile --check` that names flat `text-style` keys ([#110](https://github.com/renezander030/capcut-cli/issues/110)), the macOS permission hint on `media-outside-draft`, and `init` stamping both timeline mirrors so `register` accepts its own drafts. No command was removed and no existing output changed shape. Full details in the [changelog](./CHANGELOG.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 6380a82..4d0011b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -59,9 +59,26 @@ JSON 进、JSON 出:每个命令都直接读写本地草稿存储,不用 MCP - **库(Library)** —— `import { loadDraft, lintDraft, saveDraft } from "capcut-cli"`(带类型、零依赖) - **队列执行器** —— `capcut serve` 从 stdin 读取 JSONL 任务,对接 [n8n / Make / Coze](./examples/serve-automation.md) +### 把它装进你的 Agent + +一条命令即可把 `capcut-edit` 技能装进 Claude Code、Codex、Cursor、OpenCode 以及 [`skills`](https://skills.sh) 安装器支持的其他 Agent: + +```bash +npx skills add renezander030/capcut-cli +``` + +Claude Code 也可以把它作为插件加载: + +``` +/plugin marketplace add renezander030/capcut-cli +/plugin install capcut-cli@capcut-cli +``` + +这个技能会教 Agent 每条命令、渐进式读取的习惯(先看概要,绝不整份倒出草稿)、macOS 与 Windows 上草稿目录的位置,以及淡入淡出、Ken Burns、长视频切短的确定性脚本。中英文请求都能触发(剪映、字幕、草稿)。 + ## 发布说明 -> **v0.22.0 新增:** 九项来自本仓库、其分支及更广泛 CapCut/剪映工具生态中真实用户痛点的功能。`register --materials` 会写入 CapCut 9.1 用来判断素材是否已导入的 `draft_materials` 登记——修复所有片段显示为"文件无法访问"并要求重新链接的问题([pyCapCut#13](https://github.com/GuanYixuan/pyCapCut/issues/13))。`export-timeline --captions markers` 把字幕作为 OTIO 时间线标记带进 NLE,`import-timeline` 再由这些标记重建文本轨道(OTIO 没有字幕/标题 schema——[OpenTimelineIO#62](https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/62),自 2017 年悬而未决)。`caption --script` 保留 whisper 的逐词时间,但采用你的脚本文字。`detect-retakes` 找出说错后重说的句子,并以窗口、最少词数、相似度三道守卫防止误剪整条时间线。另有 `render --soft-captions`(可开关的 mov_text 字幕流)、`matting`(对片段素材开启智能抠像)、`init --ratio 9:16`(竖版草稿)、IR 风格的关键帧属性别名(`scale`、`x`、`y`、`opacity`)与 `--easing hold`。没有删除任何命令,现有输出结构均未改变。详见[更新日志](./CHANGELOG.md)。 +> **v0.24.0 新增:** 中文、日文、韩文字幕按各自的规范检查 —— `lint` 会指出 32 字的中文单行和每秒 15 字的字幕(拉丁默认的 42 字 / 每秒 20 字会放过它们),`--fix` 按字重新折行(zh 16/9、ja 13/4、ko 16/12;显式传入 `--max-chars` / `--max-cps` 仍对所有文字生效)。在剪映 6.0+ 的草稿目录里(应用写出的项目全部加密),`init` / `quickstart` / `compile` 现在会明确说明没有任何项目可作为种子(`template.store` 与 WARNING),`lint` 会报告 `template-unverified-store` 而不是沉默。另外,一条命令即可把它装进 Agent:`npx skills add renezander030/capcut-cli`。完整说明见[更新日志](./CHANGELOG.md)。 > **v0.23.0 新增:** 生成的草稿能在你实际安装的 CapCut 里打开。用内置 6.5.0 模板生成的草稿会被 CapCut 8.4+、8.7 Windows 和 9.3 以"项目来自异常路径"拒绝([#67](https://github.com/renezander030/capcut-cli/issues/67)、[#111](https://github.com/renezander030/capcut-cli/issues/111)——这是等待已久的 8.7 Windows 真机验证:内置模板失败,从已安装应用捕获的模板成功)。`init`、`quickstart` 与 `compile` 现在默认以草稿目录中最新的应用生成项目为种子(保留其版本标记与设置,不带任何内容,绝不复制其 `Timelines/` 镜像);`migrate --from-store` 为旧版本生成的草稿重新盖上标记,`lint` 以 `template-stale` 报告过期签名。素材在添加时即写入 `draft_materials` 并回填 `local_material_id`——剪映 5.9+ 与 CapCut 9.3 正是靠这个键定位本地素材([JmsLdrn/capcut-mcp#1](https://github.com/JmsLdrn/capcut-mcp/issues/1)),已有草稿可用 `lint --fix` 补链(`media-unlinked`)。另有 `source-range-exceeds-material` 检查、能指出扁平 `text-style` 键的 `compile --check`([#110](https://github.com/renezander030/capcut-cli/issues/110))、`media-outside-draft` 的 macOS 权限提示,以及 `init` 同时盖章两份时间线镜像,使 `register` 接受自己生成的草稿。没有删除任何命令,现有输出结构均未改变。详见[更新日志](./CHANGELOG.md)。 diff --git a/skills/capcut-edit/SKILL.md b/skills/capcut-edit/SKILL.md index b9f259e..5c40b6a 100644 --- a/skills/capcut-edit/SKILL.md +++ b/skills/capcut-edit/SKILL.md @@ -1,6 +1,6 @@ --- name: capcut-edit -description: Edit CapCut / JianYing video projects — read and write subtitles, timing, speed, volume, templates, animations (fade/ken-burns), and cut long-form to shorts. Use when the user mentions capcut, jianying, subtitles, video editing, draft_content.json, draft_info.json, or cutting videos. +description: Edit CapCut / JianYing video projects — read and write subtitles, timing, speed, volume, templates, animations (fade/ken-burns), and cut long-form to shorts. Use when the user mentions capcut, jianying, subtitles, video editing, draft_content.json, draft_info.json, or cutting videos — in English or Chinese (剪映, 字幕, 草稿, 剪辑, 切片, 视频编辑). --- # capcut-edit From 8c985998ea0cafd63f19ac50afcd8563230333da Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 18 Sep 2026 07:34:15 +0000 Subject: [PATCH 3/3] release: v0.24.0 Version bump and changelog for the script-aware caption limits, the encrypted-store seed report and the agent install docs. --- CHANGELOG.md | 10 ++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ddf74c..3ccd6c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,19 @@ All notable changes to capcut-cli are documented here. The format follows [Keep ## [Unreleased] +## [0.24.0] — 2026-09-18 + +### Added + +- `lint` holds captions written in Chinese, Japanese or Korean to their own limits: 16 characters per line and 9 per second for Chinese, 13 and 4 for Japanese, 16 and 12 for Korean, in place of the Latin 42 and 20 that let a 32-character Chinese line pass. Messages name the applied default (`>16, zh default`), `--fix` re-wraps between characters, and an explicit `--max-chars` / `--max-cps` applies to every script. Library: `captionScript`, `captionLimits`, `scriptLimitsExcept`, `CJK_SCRIPT_LIMITS`, and `LintOptions.scriptLimits` (`null` keeps the Latin limits everywhere). +- `init`, `quickstart` and `compile` report what the drafts folder held when the skeleton was chosen (`template.store`: `projects`, `readable`, `markerless`, `encrypted`, `unreadable`). When every project is encrypted (JianYing 6.0+) and nothing could seed the draft, they say so — a WARNING on stderr, `template.warning` in the JSON, the quickstart `create` step and the compile `warnings` list — and name what is known about the bundled template on that app. +- `lint` reports `template-unverified-store` (info) for a bundled-template draft in a drafts folder whose projects are all encrypted: nothing could have seeded it and no version can be compared, so it is unverified for that app rather than stale. +- README (English and Chinese) documents the one-command agent install, `npx skills add renezander030/capcut-cli`, and the Claude Code plugin route. The `capcut-edit` skill also triggers on Chinese requests (剪映, 字幕, 草稿). + ### Changed - English and Chinese quickstarts now link to the maintainer's GitHub profile for more practical AI agent tools. +- Claude Code plugin manifest 0.2.0: description and keywords match the current command surface. ## [0.23.0] — 2026-09-11 diff --git a/package-lock.json b/package-lock.json index 1cf148e..89997b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "capcut-cli", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "capcut-cli", - "version": "0.23.0", + "version": "0.24.0", "license": "MIT", "bin": { "capcut": "dist/index.js", diff --git a/package.json b/package.json index f651c20..ba2bcbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "capcut-cli", - "version": "0.23.0", + "version": "0.24.0", "description": "Independent, unofficial CLI to create and edit CapCut projects — build drafts from scratch, add video/audio/text, subtitles, timing, speed, volume, templates, cut long-form to shorts. No API needed. Not affiliated with ByteDance.", "type": "module", "bin": {