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/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/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/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/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": { 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 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); + }); +});