diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b010027 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +dist +.git +.github +test +docs +media +examples +*.md +.husky +hooks +skills +.claude-plugin +marketplace.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fc0de18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['18', '20', '22'] + name: test (Node ${{ matrix.node }}) + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run build + - run: npm test + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 799913b..d12fdc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to capcut-cli are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] — 2026-05-29 + +Distribution and integration release. No breaking changes to existing commands; everything stays zero-dep, JSON-by-default, and pipeable. + +### Added + +- **`capcut doctor`** — environment preflight that inspects the machine, not a draft: Node version (hard requirement, ≥ 18), a whisper binary on `PATH` (for `caption`), `ANTHROPIC_API_KEY` (for `translate`), and the default per-OS CapCut/JianYing project directory. JSON by default, `-H` for a human checklist. Exits `1` only on a hard failure. +- **Importable Node library** — `import { loadDraft, saveDraft, findSegment, findMaterial, getTracksByType, extractText, updateTextContent, lintDraft, detectVersion, runDoctor } from "capcut-cli"`, with types. New `src/lib.ts` entry point; `package.json` `exports`/`main`/`types` map to `dist/lib.js`; `tsconfig` now emits `.d.ts`. Importing the package no longer executes the CLI. +- **Dockerfile + `.dockerignore`** — zero-dep multi-stage build; the final image is Node + `dist/` + `templates/`. Drafts mount at `/work`. Also runs `serve` over a stdin pipe. +- **GitHub Action (`action.yml`)** — composite action wrapping `capcut lint` so drafts can be gated in CI; `lint` exit code `2` (errors) fails the job. `uses: renezander030/capcut-cli@v0.6`. +- **Three new shipped templates** — `caption-pop` (bold white center subtitle), `lower-third` (handle/name attribution), `hook-question` (large top-of-frame hook). Catalogue grows 3 → 6, all validated by the roundtrip suite. +- **`serve-automation.md` example** — JSONL job/result contract and four integration paths (local pipe, n8n Execute Command, cloud builders via webhook→queue-file, Docker). + +### CI / Quality + +- **GitHub Actions CI** — test matrix across Node 18 / 20 / 22 plus a Biome lint job, on every push and pull request. +- **Fuzz / injection test suite** — 12 malformed `draft_content.json` inputs (non-JSON, truncated, wrong-shape, prototype-pollution attempts, deep nesting) across six read commands assert graceful failure: no hang, no leaked stack trace, single-line JSON error on stderr. Plus a prototype-pollution non-regression check. +- Test suite grew to 113 passing tests (doctor, fuzz, library, and the three new templates added their own coverage). + ## [0.5.0] — 2026-05-25 Six new commands voted in from [Discussion #1](https://github.com/renezander030/capcut-cli/discussions/1), shipped as a single release. All keep the zero-dep, JSON-by-default, pipeable design. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f34b6e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# capcut-cli — zero runtime deps, so the final image is just Node + dist/. +# Build: docker build -t capcut-cli . +# Run: docker run --rm -v "$PWD:/work" capcut-cli info /work/draft_content.json +# cat jobs.jsonl | docker run --rm -i -v "$PWD:/work" capcut-cli serve + +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig.json biome.json ./ +COPY src ./src +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +# No `npm install` here: the package declares zero runtime dependencies. +COPY --from=build /app/dist ./dist +COPY templates ./templates +COPY package.json ./ +# Drafts are mounted at runtime; /work is the conventional mount point. +WORKDIR /work +ENTRYPOINT ["node", "/app/dist/index.js"] +CMD ["--help"] diff --git a/README.md b/README.md index cf8a6c8..be5746f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # capcut-cli +[![CI](https://github.com/renezander030/capcut-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/renezander030/capcut-cli/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/capcut-cli.svg)](https://www.npmjs.com/package/capcut-cli) [![npm downloads](https://img.shields.io/npm/dm/capcut-cli.svg)](https://www.npmjs.com/package/capcut-cli) [![node](https://img.shields.io/node/v/capcut-cli.svg)](https://nodejs.org) @@ -7,15 +8,25 @@ English | [中文](./README.zh-CN.md) -**The CapCut/JianYing toolkit that survives the next ByteDance update — and auto-captions with real caption objects.** +**The CapCut/JianYing CLI any LLM agent can drive — zero dependencies, no server, both namespaces in one binary.** -A pure CLI any LLM can drive: no MCP server, no HTTP daemon, no state. Inspect drafts, build from scratch, add media, modify subtitles, auto-caption with whisper, translate to N languages, cut long-form to shorts. Zero runtime deps, both CapCut and JianYing namespaces in one binary, JSON output by default. +Every command reads and writes `draft_content.json` directly: JSON in, JSON out, no MCP server, no HTTP daemon, no state to babysit. That makes it a deterministic boundary any model (Claude, DeepSeek, GLM, Kimi) can call from a pipeline. Inspect drafts, build from scratch, add media, edit subtitles, auto-caption with whisper, translate to N languages, and cut long-form to shorts. Because there is no private API in the loop, it keeps working across ByteDance updates — and `caption` writes real caption objects, not the text-segment mimics other tools settle for. + +**Use it three ways:** + +- **CLI** — `npm install -g capcut-cli`, then `capcut ` +- **Library** — `import { loadDraft, lintDraft, saveDraft } from "capcut-cli"` (typed, zero-dep) +- **Queue runner** — `capcut serve` reads JSONL jobs from stdin and drops into [n8n / Make / Coze](./examples/serve-automation.md) + +Run `capcut doctor` first to verify your environment (Node, whisper, draft directory). + +**New in v0.6** — `doctor` (environment preflight), an importable Node library (`import { … } from "capcut-cli"`), an official [Dockerfile](./Dockerfile), a [GitHub Action](./action.yml) that lints drafts in CI, three new shipped templates (`caption-pop`, `lower-third`, `hook-question`), and a CI matrix across Node 18/20/22. **New in v0.4** — `caption` (whisper → real caption objects, not the import-srt text-mimics), `migrate` (mask ↔ common_masks across CapCut/JianYing version jumps), `lint` (schema-aware checks: overlaps, line length, missing files), `version` (detect support status), `translate` (Anthropic-API multi-language draft clone), `add-sfx`, `chroma`, `serve` (stateless JSONL queue runner for n8n/Coze/Make), and `export --batch` (EXPERIMENTAL macOS UI-automated render queue). -## v0.5 — vote on what ships next +## v0.5 — shipped, community-voted -Open for community vote on **[Discussion #1](https://github.com/renezander030/capcut-cli/discussions/1)**. 👍 the comments for the features you want most — I ship the top 3-5 as a single v0.5 release within ~2 weeks. +All six features below were voted in on **[Discussion #1](https://github.com/renezander030/capcut-cli/discussions/1)** and shipped together in v0.5. Want a say in what lands next? 👍 the comments there, or open a new discussion. - ✅ `audio-fade --in --fade-out ` — fade-in / fade-out on audio segments (proper `audio_fades` objects, not volume keyframes) **shipped in v0.5** - ✅ `bubble-text --bubble ` / 花字 — bubble / decorative text effects + `enums --bubbles` discovery **shipped in v0.5** @@ -24,7 +35,7 @@ Open for community vote on **[Discussion #1](https://github.com/renezander030/ca - ✅ `import-ass ` — ASS subtitle import alongside existing `import-srt` **shipped in v0.5** - ✅ `mix-mode ` — blend modes per video segment (multiply, screen, overlay, …) **shipped in v0.5** -> Voting closes when v0.5 ships. If your feature is missing, drop a comment on Discussion #1. +> All six shipped in v0.5.0. If the feature you want is missing, drop a comment on Discussion #1. ## Workflow @@ -129,7 +140,15 @@ Status of every feature shipped. ✅ = implemented, ⬜ = roadmap. Section ancho - ✅ `caption` — whisper shell-out (openai-whisper / whisper.cpp / faster-whisper) → real caption-track segments with `sub_type` + `caption_template_info` (addresses pyJianYingDraft #148 — no more text-segment mimics) - ✅ `translate` — Anthropic-API multi-language draft clone, zero runtime deps (uses built-in `fetch`). `--dry-run` for safe inspection. Original stays untouched -### v0.5 — new commands (in progress) +### v0.6 — distribution & integration +- ✅ `doctor` — environment preflight: Node version, whisper binary (for `caption`), `ANTHROPIC_API_KEY` (for `translate`), default CapCut/JianYing project directory. Exits 1 only on hard failures +- ✅ Node **library** — `import { loadDraft, lintDraft, saveDraft, detectVersion, runDoctor } from "capcut-cli"` — the core, typed and zero-dep, importable without running the CLI +- ✅ [**Dockerfile**](./Dockerfile) — zero-dep multi-stage image; `docker run --rm -v "$PWD:/work" capcut-cli info /work/draft_content.json` +- ✅ [**GitHub Action**](./action.yml) — `uses: renezander030/capcut-cli@v0.6` to lint drafts in CI (exit 2 on errors fails the job) +- ✅ Three new templates — `caption-pop`, `lower-third`, `hook-question` (six shipped templates total) +- ✅ CI matrix across Node 18 / 20 / 22 + Biome lint on every push and PR + +### v0.5 — new commands (shipped) - ✅ `mix-mode` — set blend mode on a video segment (normal · multiply · screen · overlay · soft-light · hard-light · color-dodge · color-burn · darken · lighten · difference · exclusion) - ✅ `audio-fade` — fade-in / fade-out on an audio segment via `materials.audio_fades[]` (real fade material, not `volume` keyframes) - ✅ `add-cover` — set the draft's cover frame (thumbnail) to a local image (PNG/JPG); `--time ` defaults to 0 @@ -187,6 +206,49 @@ Add the marketplace, then enable the plugin: This gives Claude Code the `/capcut-cli:capcut-edit` skill -- it learns every command, the progressive disclosure navigation pattern, and how to find your CapCut projects on macOS/Windows. Auto-installs the CLI on first enable. +### Use as a Node library + +The core is importable and typed — no shelling out, no CLI process: + +```ts +import { loadDraft, lintDraft, saveDraft, detectVersion } from "capcut-cli"; + +const { draft, filePath } = loadDraft("./my-project/draft_content.json"); +console.log(detectVersion(draft).support.status); // supported | untested | known-broken +const issues = lintDraft(draft); // [{ severity, code, message, location }] +saveDraft(filePath, draft); +``` + +Importing the package never runs the CLI. Exposed: `loadDraft`, `saveDraft`, `findSegment`, `findMaterial`, `getTracksByType`, `extractText`, `updateTextContent`, `lintDraft`, `detectVersion`, `runDoctor`, plus their types. + +### Docker + +Zero runtime deps, so the image is just Node + the build output. Mount your drafts at `/work`: + +```bash +docker build -t capcut-cli . +docker run --rm -v "$PWD:/work" capcut-cli info /work/draft_content.json +cat jobs.jsonl | docker run --rm -i -v "$PWD:/work" capcut-cli serve +``` + +### GitHub Action — lint drafts in CI + +Gate caption quality (overlaps, line length, missing files) on every push. `lint` exits `2` on errors, which fails the job: + +```yaml +- uses: renezander030/capcut-cli@v0.6 + with: + project: ./drafts/my-short + args: --max-chars 32 --max-cue-secs 6 +``` + +### Verify your environment + +```bash +capcut doctor # JSON report; exit 1 only on a hard failure (Node < 18) +capcut doctor -H # human-readable checklist +``` + ### Why a CLI, not an MCP server Other CapCut / JianYing tooling exposes an HTTP API or MCP server. `capcut-cli` deliberately does not: diff --git a/README.zh-CN.md b/README.zh-CN.md index 4d5ec57..b60a660 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,5 +1,6 @@ # capcut-cli +[![CI](https://github.com/renezander030/capcut-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/renezander030/capcut-cli/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/capcut-cli.svg)](https://www.npmjs.com/package/capcut-cli) [![npm downloads](https://img.shields.io/npm/dm/capcut-cli.svg)](https://www.npmjs.com/package/capcut-cli) [![node](https://img.shields.io/node/v/capcut-cli.svg)](https://nodejs.org) @@ -7,15 +8,25 @@ [English](./README.md) | 中文 -**剪映 / CapCut 工具链,扛得住字节跳动下次改版 —— 自动加的字幕也是真字幕对象(不是 import-srt 那种文本伪装)。** +**任何大模型 Agent 都能驱动的剪映 / CapCut 命令行 —— 零依赖、无服务、CapCut + 剪映共用一个二进制。** -任何能输出 JSON 的大模型都能驱动它:不用 MCP 服务,不用 HTTP 守护进程,无状态。命令行查看工程、从零搭草稿、加素材、改字幕、用 whisper 自动打字幕、一键克隆成多语言版本、把长视频切成短片。直接读写 `draft_content.json`,零运行时依赖,CapCut + 剪映两个命名空间共用一个二进制。 +每个命令都直接读写 `draft_content.json`:JSON 进、JSON 出,不用 MCP 服务,不用 HTTP 守护进程,无状态。任何模型(Claude、DeepSeek、GLM、Kimi)都能在流水线里直接调用这个确定性边界。命令行查看工程、从零搭草稿、加素材、改字幕、用 whisper 自动打字幕、一键克隆成多语言版本、把长视频切成短片。因为链路里没有任何私有 API,所以扛得住字节跳动下次改版 —— 而且 `caption` 写的是真字幕对象,不是别的工具那种文本伪装。 + +**三种用法:** + +- **命令行** —— `npm install -g capcut-cli`,然后 `capcut ` +- **代码库** —— `import { loadDraft, lintDraft, saveDraft } from "capcut-cli"`(带类型、零依赖) +- **队列执行器** —— `capcut serve` 从 stdin 读 JSONL 任务,可对接 [n8n / Make / 扣子 Coze](./examples/serve-automation.md) + +先跑 `capcut doctor` 检查环境(Node、whisper、草稿目录)。 + +**v0.6 新增** —— `doctor`(环境预检)、可导入的 Node 代码库(`import { … } from "capcut-cli"`)、官方 [Dockerfile](./Dockerfile)、在 CI 里检查草稿的 [GitHub Action](./action.yml)、三个新模板(`caption-pop`、`lower-third`、`hook-question`),以及覆盖 Node 18/20/22 的 CI 矩阵。 **v0.4 新增** —— `caption`(whisper → 真字幕对象,不再是 import-srt 那种文本伪装)、`migrate`(剪映 5.9 / CapCut 9.6 之间的 `mask` ↔ `common_masks` schema 迁移)、`lint`(字幕检查:重叠、行长、缺失素材文件)、`version`(检测兼容状态)、`translate`(多语言草稿克隆,走 Anthropic API)、`add-sfx`、`chroma`、`serve`(无状态 JSONL 队列 —— 对接 n8n / Coze / 扣子 / Make)、`export --batch`(**实验性** macOS UI 自动化批量导出)。 -## v0.5 投票决定下一步 +## v0.5 已发布(社区投票决定) -下面是 v0.5 候选功能,欢迎到 **[Discussion #1](https://github.com/renezander030/capcut-cli/discussions/1)** 给你想要的功能 👍 —— 我会按票数把前 3-5 个打包进一个 v0.5 release(目标 2 周内出)。 +下面六个功能都是在 **[Discussion #1](https://github.com/renezander030/capcut-cli/discussions/1)** 投票选出、并在 v0.5 一起发布的。想决定下一步加什么?去那里给评论点 👍,或者开一个新 discussion。 - `audio-fade --in <秒> --out <秒>` —— 音频淡入淡出(写真正的 `audio_fades` 对象,不再用音量关键帧凑) - `bubble-text --bubble ` / 花字 —— 文本气泡 / 花字特效 + `enums --bubbles` 枚举发现 @@ -24,7 +35,7 @@ - `import-ass ` —— ASS 字幕导入(跟现有 `import-srt` 并存) - `mix-mode <模式>` —— 视频片段混合模式(正片叠底 / 滤色 / 叠加 …) -> 投票截止到 v0.5 发布为止。如果你想要的功能不在列表里,去 Discussion #1 留言。 +> 六个功能都已在 v0.5.0 发布。如果你想要的功能不在列表里,去 Discussion #1 留言。 > **想要完整的国产大模型 + 剪映短视频流水线?** `capcut-cli` 是引擎,配套的 **[病毒短视频蓝图(完整教程 + 蓝图下载)](https://renezander.com/zh-cn/guides/automate-xiaohongshu-capcut-cli/?utm_source=capcut-cli&utm_medium=readme&utm_campaign=hero-cn)** 给你完整方法 —— DeepSeek / GLM / Kimi / Qwen 都能跑,专为 **小红书 + 抖音** 优化(不是 YouTube),**支付宝 / 微信支付** 通过 Stripe 直接下单。 diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..ba8cd1a --- /dev/null +++ b/action.yml @@ -0,0 +1,30 @@ +name: capcut-cli +description: Lint CapCut/JianYing drafts in CI — caption overlaps, line length, cue duration, missing material refs and files. +author: renezander030 +branding: + icon: check-square + color: green + +inputs: + project: + description: Path to draft_content.json (or its parent directory) to lint. + required: true + args: + description: 'Extra flags passed to `capcut lint` (e.g. "--max-chars 32 --max-cue-secs 6").' + required: false + default: '' + version: + description: capcut-cli version to install from npm. + required: false + default: latest + +runs: + using: composite + steps: + - name: Install capcut-cli + shell: bash + run: npm install -g "capcut-cli@${{ inputs.version }}" + - name: Lint draft + shell: bash + # `lint` exits 0 (clean) / 1 (warnings) / 2 (errors) — exit 2 fails the job. + run: capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H diff --git a/examples/README.md b/examples/README.md index 78d0ffc..8c9c771 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,6 +12,7 @@ Copy-paste recipes for common CapCut / JianYing workflows. Every recipe is one s | [keyframe-zoom.md](./keyframe-zoom.md) | Programmatic Ken Burns zoom-in/out keyframes on one segment | | [keyframe-pan.md](./keyframe-pan.md) | Unfinished-pan keyframe pattern for epilogue / payoff stills | | [verify-vo-alignment.md](./verify-vo-alignment.md) | Pre-flight check on ElevenLabs voiceover + word-level timestamps | +| [serve-automation.md](./serve-automation.md) | Wire the stateless JSONL queue runner into n8n / Make / Coze / Docker | All shell-only recipes assume `capcut` is on your `$PATH` (`npm install -g capcut-cli`). The three keyframe / VO recipes ship with companion Python scripts under [`./scripts/`](./scripts/) — Python 3.9+, no external deps. diff --git a/examples/serve-automation.md b/examples/serve-automation.md new file mode 100644 index 0000000..baea8cf --- /dev/null +++ b/examples/serve-automation.md @@ -0,0 +1,82 @@ +# Wire capcut-cli into n8n / Make / Coze — no HTTP server + +`capcut serve` is a **stateless JSONL queue runner**. It reads one job per line from +stdin (or a `--queue` file), dispatches each to the CLI, and writes one JSON result +per line to stdout. No daemon, no port, no state between runs. That makes it a clean +fit for any automation tool that can run a shell command or pipe bytes. + +## The job format + +One JSON object per line. `cmd` is required; `project` and `args` are optional: + +```jsonl +{"cmd":"info","project":"/work/draft_content.json"} +{"cmd":"add-text","project":"/work/draft_content.json","args":["8s","2s","Subscribe","--font-size","16"]} +{"cmd":"import-srt","project":"/work/draft_content.json","args":["/work/captions.srt"]} +{"cmd":"lint","project":"/work/draft_content.json"} +``` + +Each result line is `{ok, cmd, args, status, stdout, stderr}`. `ok` is `true` when the +command exited `0`; `stdout` is the parsed JSON the command would have printed. Because +`lint` exits `2` on errors, a lint job comes back `{"ok":false,"status":2,...}` — handle +that in your flow to gate a render. + +## Local / cron + +```bash +cat jobs.jsonl | capcut serve > results.jsonl +# or read from a file the upstream step wrote: +capcut serve --queue jobs.jsonl > results.jsonl +``` + +Add `--fail-fast` to stop at the first failing job. + +## n8n (self-hosted — Execute Command node) + +n8n's **Execute Command** node runs on the n8n host, so `capcut` just needs to be on its +`PATH` (`npm install -g capcut-cli`, or use the Docker image below). Build the JSONL in a +Function node, then pipe it in: + +``` +Command: capcut serve +Input: {{ $json.jobs }} // a JSONL string from the previous node +``` + +A Function node to turn structured items into JSONL: + +```js +// one n8n item per job → a single JSONL string +return [{ json: { jobs: items.map(i => JSON.stringify(i.json)).join("\n") } }]; +``` + +Parse `results.jsonl` back out in the next Function node by splitting on newlines and +`JSON.parse`-ing each line. + +## Make / Coze (cloud) — webhook → queue file → serve + +Cloud builders can't run a binary directly. The stateless model still fits: have the +cloud scenario **write a queue file** (or POST JSONL to a tiny endpoint on a host you +control), then run `capcut serve --queue` on that host from cron or a file-watch. The +boundary is the JSONL file — the cloud side never needs to know about the CLI internals. + +```bash +# on your host: drain whatever the cloud scenario dropped, every minute +* * * * * test -s /srv/capcut/inbox.jsonl && \ + capcut serve --queue /srv/capcut/inbox.jsonl > /srv/capcut/outbox.jsonl && \ + : > /srv/capcut/inbox.jsonl +``` + +## Docker (no global install) + +The published image runs `serve` over a stdin pipe — drafts are mounted at `/work`: + +```bash +cat jobs.jsonl | docker run --rm -i -v "$PWD:/work" capcut-cli serve > results.jsonl +``` + +Build the image from this repo with `docker build -t capcut-cli .`. + +> **Why no HTTP mode?** A long-lived server adds a port to secure, state to reset, and a +> process to babysit. A queue runner that starts, drains, and exits composes with the +> retry/idempotency model your automation tool already has. If you genuinely need HTTP, +> put `serve` behind a one-line handler that pipes the request body to it. diff --git a/media/og-card.png b/media/og-card.png new file mode 100644 index 0000000..ab390a8 Binary files /dev/null and b/media/og-card.png differ diff --git a/package.json b/package.json index 0896f45..c891c3a 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,21 @@ { "name": "capcut-cli", - "version": "0.5.0", + "version": "0.6.0", "description": "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.", "type": "module", "bin": { "capcut": "dist/index.js", "capcut-cli": "dist/index.js" }, + "main": "./dist/lib.js", + "types": "./dist/lib.d.ts", + "exports": { + ".": { + "types": "./dist/lib.d.ts", + "import": "./dist/lib.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "examples", diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..03ce412 --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,116 @@ +import { existsSync } from "node:fs"; +import { homedir, platform, release } from "node:os"; +import { delimiter, join } from "node:path"; + +export type CheckStatus = "ok" | "warn" | "missing"; + +export interface DoctorCheck { + name: string; + status: CheckStatus; + detail: string; + /** Commands degraded or unavailable when this check is not "ok". */ + affects?: string[]; + /** How to fix, when not "ok". */ + fix?: string; +} + +export interface DoctorReport { + ok: boolean; + platform: string; + node: string; + checks: DoctorCheck[]; +} + +/** Minimal cross-platform PATH lookup — no `which`/`where` shell-out. */ +function onPath(cmd: string): string | null { + const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean); + const exts = platform() === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""]; + for (const dir of dirs) { + for (const ext of exts) { + const full = join(dir, cmd + ext); + if (existsSync(full)) return full; + } + } + return null; +} + +function nodeMajor(): number { + return Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10); +} + +/** Default per-OS CapCut/JianYing project directories. */ +function draftDirs(): { label: string; path: string }[] { + const home = homedir(); + if (platform() === "darwin") { + return [ + { label: "CapCut (macOS)", path: join(home, "Movies/CapCut/User Data/Projects/com.lveditor.draft") }, + { label: "JianYing (macOS)", path: join(home, "Movies/JianyingPro/User Data/Projects/com.lveditor.draft") }, + ]; + } + if (platform() === "win32") { + const local = process.env.LOCALAPPDATA ?? join(home, "AppData/Local"); + return [ + { label: "CapCut (Windows)", path: join(local, "CapCut/User Data/Projects/com.lveditor.draft") }, + { label: "JianYing (Windows)", path: join(local, "JianyingPro/User Data/Projects/com.lveditor.draft") }, + ]; + } + return []; +} + +export function runDoctor(): DoctorReport { + const checks: DoctorCheck[] = []; + + // Node runtime — hard requirement. + const major = nodeMajor(); + checks.push({ + name: "node", + status: major >= 18 ? "ok" : "missing", + detail: `Node ${process.versions.node}${major >= 18 ? "" : " (capcut-cli needs >= 18)"}`, + affects: major >= 18 ? undefined : ["*"], + fix: major >= 18 ? undefined : "Upgrade to Node 18 or newer.", + }); + + // whisper — needed by `caption`. + const whisper = onPath("whisper") ?? onPath("whisper-cli") ?? onPath("faster-whisper"); + checks.push({ + name: "whisper", + status: whisper ? "ok" : "warn", + detail: whisper ? `found: ${whisper}` : "no whisper binary on PATH", + affects: ["caption"], + fix: whisper ? undefined : "pip install openai-whisper · brew install whisper-cpp · or pass --whisper-cmd ", + }); + + // ANTHROPIC_API_KEY — needed by `translate`. + const hasKey = Boolean(process.env.ANTHROPIC_API_KEY); + checks.push({ + name: "anthropic-api-key", + status: hasKey ? "ok" : "warn", + detail: hasKey ? "ANTHROPIC_API_KEY is set" : "ANTHROPIC_API_KEY not set", + affects: ["translate"], + fix: hasKey ? undefined : "export ANTHROPIC_API_KEY=… (or pass --api-key) to use `translate`.", + }); + + // CapCut / JianYing project directories — informational. + const dirs = draftDirs(); + if (dirs.length === 0) { + checks.push({ + name: "draft-dir", + status: "warn", + detail: `no default project directory for ${platform()} — pass the draft path explicitly`, + }); + } else { + for (const d of dirs) { + const found = existsSync(d.path); + checks.push({ + name: "draft-dir", + status: found ? "ok" : "warn", + detail: `${d.label}: ${found ? "found" : "not found"} (${d.path})`, + fix: found ? undefined : "Open a project in CapCut/JianYing once, or pass the draft path directly.", + }); + } + } + + // `ok` reflects only hard failures (missing), not optional-tool warnings. + const ok = !checks.some((c) => c.status === "missing"); + return { ok, platform: `${platform()} ${release()}`, node: process.versions.node, checks }; +} diff --git a/src/index.ts b/src/index.ts index 199952b..c6c7971 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ import { transitionSlugs, } from "./decorators.js"; import { detectEncryption } from "./decrypt.js"; +import { type DoctorCheck, runDoctor } from "./doctor.js"; import type { Draft, Segment, Track } from "./draft.js"; import { extractText, @@ -305,6 +306,10 @@ Encryption (v0.6 — detection scaffold): decrypt Detect JianYing 6.0+ encryption and report next steps. (Decryption algorithm not bundled; clear error UX + workaround docs.) + doctor + Check the environment, not a draft: Node version, whisper binary + (for caption), ANTHROPIC_API_KEY (for translate), and the default + CapCut/JianYing project directory. Exit 1 only on hard failures. Stateless queue runner (v0.5): serve [--queue ] [--fail-fast] @@ -1901,6 +1906,25 @@ function cmdBatch(draft: Draft, filePath: string, flags: Flags): void { out({ ok: true, succeeded: ok, failed: fail }, flags); } +function cmdDoctor(flags: Flags): boolean { + const report = runDoctor(); + if (flags.human) { + const glyph: Record = { ok: "✓", warn: "!", missing: "✗" }; + console.log(`Platform: ${report.platform}`); + console.log(`Node: ${report.node}`); + console.log(""); + for (const c of report.checks) { + console.log(`[${glyph[c.status]}] ${c.name.padEnd(18)} ${c.detail}`); + if (c.status !== "ok" && c.fix) console.log(` → ${c.fix}`); + } + console.log(""); + console.log(report.ok ? "Ready." : "Missing a hard requirement — see ✗ above."); + } else { + out(report, flags); + } + return report.ok; +} + // --- Main --- async function main(): Promise { @@ -1920,6 +1944,11 @@ async function main(): Promise { process.exit(0); } + // `doctor` inspects the environment, not a draft — no project needed. + if (cmd === "doctor") { + process.exit(cmdDoctor(flags) ? 0 : 1); + } + // `serve` reads jobs from stdin/queue file — no project needed. if (cmd === "serve") { await cmdServe(flags); diff --git a/src/lib.ts b/src/lib.ts new file mode 100644 index 0000000..c971949 --- /dev/null +++ b/src/lib.ts @@ -0,0 +1,47 @@ +/** + * Public library entry point for capcut-cli. + * + * The CLI lives in `index.ts` (it runs `main()` on import, so it is not + * importable as a library). This module re-exports the stable, side-effect-free + * core so other tools can read, inspect, lint, and write CapCut/JianYing drafts + * programmatically: + * + * import { loadDraft, saveDraft, lintDraft } from "capcut-cli"; + * + * const { draft, filePath } = loadDraft("./draft_content.json"); + * const issues = lintDraft(draft); + * saveDraft(filePath, draft); + */ + +export type { CheckStatus, DoctorCheck, DoctorReport } from "./doctor.js"; +export { runDoctor } from "./doctor.js"; +export type { + Draft, + MaterialAudio, + MaterialText, + MaterialVideo, + Segment, + Timerange, + Track, +} from "./draft.js"; +export { + extractText, + findDraft, + findMaterial, + findMaterialGlobal, + findSegment, + getMaterialTypes, + getTracksByType, + loadDraft, + saveDraft, + updateTextContent, +} from "./draft.js"; +export type { LintIssue, LintOptions, Severity } from "./lint.js"; +export { + DEFAULT_LINT_OPTIONS, + lintDraft, + lintExitCode, + summarize, +} from "./lint.js"; +export type { AppSource, VersionInfo } from "./version.js"; +export { detectVersion } from "./version.js"; diff --git a/templates/caption-pop.json b/templates/caption-pop.json new file mode 100644 index 0000000..9f953f6 --- /dev/null +++ b/templates/caption-pop.json @@ -0,0 +1,79 @@ +{ + "name": "caption-pop", + "type": "text", + "segment": { + "id": "a1b2c3d4-0001-4a01-9001-000000000001", + "material_id": "a1b2c3d4-0002-4a02-9002-000000000002", + "raw_segment_id": "a1b2c3d4-0003-4a03-9003-000000000003", + "target_timerange": { "start": 0, "duration": 2500000 }, + "source_timerange": { "start": 0, "duration": 2500000 }, + "speed": 1, + "volume": 1, + "visible": true, + "reverse": false, + "clip": { + "alpha": 1, + "rotation": 0, + "scale": { "x": 1, "y": 1 }, + "transform": { "x": 0, "y": -0.75 }, + "flip": { "horizontal": false, "vertical": false } + }, + "render_index": 15000, + "track_render_index": 0, + "track_attribute": 0, + "extra_material_refs": [ + "a1b2c3d4-0004-4a04-9004-000000000004", + "a1b2c3d4-0005-4a05-9005-000000000005", + "a1b2c3d4-0006-4a06-9006-000000000006", + "a1b2c3d4-0007-4a07-9007-000000000007" + ], + "common_keyframes": [], + "keyframe_refs": [] + }, + "material": { + "type": "texts", + "data": { + "id": "a1b2c3d4-0002-4a02-9002-000000000002", + "type": "text", + "content": "{\"styles\":[{\"range\":[0,7],\"size\":15,\"bold\":true,\"italic\":false,\"underline\":false,\"fill\":{\"alpha\":1,\"content\":{\"render_type\":\"solid\",\"solid\":{\"alpha\":1,\"color\":[1,1,1]}}}}],\"text\":\"CAPTION\"}", + "alignment": 1, + "font_size": 15, + "text_color": "#FFFFFF", + "typesetting": 0, + "letter_spacing": 0, + "line_spacing": 0.02, + "line_feed": 1, + "line_max_width": 0.82, + "force_apply_line_max_width": false, + "check_flag": 7, + "fixed_width": -1, + "fixed_height": -1, + "has_shadow": true, + "shadow_alpha": 0.8, + "shadow_color": "#000000", + "shadow_distance": 6, + "border_width": 0.08, + "border_color": "#000000", + "border_alpha": 1, + "has_border": true + } + }, + "extra_materials": [ + { + "type": "speeds", + "data": { "id": "a1b2c3d4-0004-4a04-9004-000000000004", "type": "speed", "speed": 1, "mode": 0, "curve_speed": null } + }, + { + "type": "placeholder_infos", + "data": { "id": "a1b2c3d4-0005-4a05-9005-000000000005", "type": "placeholder_info", "error_path": "", "error_text": "", "meta_type": "none", "res_path": "", "res_text": "" } + }, + { + "type": "sound_channel_mappings", + "data": { "id": "a1b2c3d4-0006-4a06-9006-000000000006", "type": "none", "audio_channel_mapping": 0, "is_config_open": false } + }, + { + "type": "vocal_separations", + "data": { "id": "a1b2c3d4-0007-4a07-9007-000000000007", "type": "vocal_separation", "choice": 0, "enter_from": "", "final_algorithm": "", "production_path": "", "removed_sounds": [], "time_range": null } + } + ] +} diff --git a/templates/hook-question.json b/templates/hook-question.json new file mode 100644 index 0000000..6be4dd7 --- /dev/null +++ b/templates/hook-question.json @@ -0,0 +1,79 @@ +{ + "name": "hook-question", + "type": "text", + "segment": { + "id": "c1b2c3d4-0001-4c01-9c01-000000000001", + "material_id": "c1b2c3d4-0002-4c02-9c02-000000000002", + "raw_segment_id": "c1b2c3d4-0003-4c03-9c03-000000000003", + "target_timerange": { "start": 0, "duration": 3000000 }, + "source_timerange": { "start": 0, "duration": 3000000 }, + "speed": 1, + "volume": 1, + "visible": true, + "reverse": false, + "clip": { + "alpha": 1, + "rotation": 0, + "scale": { "x": 1, "y": 1 }, + "transform": { "x": 0, "y": 0.55 }, + "flip": { "horizontal": false, "vertical": false } + }, + "render_index": 15000, + "track_render_index": 0, + "track_attribute": 0, + "extra_material_refs": [ + "c1b2c3d4-0004-4c04-9c04-000000000004", + "c1b2c3d4-0005-4c05-9c05-000000000005", + "c1b2c3d4-0006-4c06-9c06-000000000006", + "c1b2c3d4-0007-4c07-9c07-000000000007" + ], + "common_keyframes": [], + "keyframe_refs": [] + }, + "material": { + "type": "texts", + "data": { + "id": "c1b2c3d4-0002-4c02-9c02-000000000002", + "type": "text", + "content": "{\"styles\":[{\"range\":[0,16],\"size\":19,\"bold\":true,\"italic\":false,\"underline\":false,\"fill\":{\"alpha\":1,\"content\":{\"render_type\":\"solid\",\"solid\":{\"alpha\":1,\"color\":[1,1,1]}}}}],\"text\":\"Wait for it...?\"}", + "alignment": 1, + "font_size": 19, + "text_color": "#FFFFFF", + "typesetting": 0, + "letter_spacing": 0, + "line_spacing": 0.02, + "line_feed": 1, + "line_max_width": 0.82, + "force_apply_line_max_width": false, + "check_flag": 7, + "fixed_width": -1, + "fixed_height": -1, + "has_shadow": true, + "shadow_alpha": 0.8, + "shadow_color": "#000000", + "shadow_distance": 7, + "border_width": 0.07, + "border_color": "#000000", + "border_alpha": 1, + "has_border": true + } + }, + "extra_materials": [ + { + "type": "speeds", + "data": { "id": "c1b2c3d4-0004-4c04-9c04-000000000004", "type": "speed", "speed": 1, "mode": 0, "curve_speed": null } + }, + { + "type": "placeholder_infos", + "data": { "id": "c1b2c3d4-0005-4c05-9c05-000000000005", "type": "placeholder_info", "error_path": "", "error_text": "", "meta_type": "none", "res_path": "", "res_text": "" } + }, + { + "type": "sound_channel_mappings", + "data": { "id": "c1b2c3d4-0006-4c06-9c06-000000000006", "type": "none", "audio_channel_mapping": 0, "is_config_open": false } + }, + { + "type": "vocal_separations", + "data": { "id": "c1b2c3d4-0007-4c07-9c07-000000000007", "type": "vocal_separation", "choice": 0, "enter_from": "", "final_algorithm": "", "production_path": "", "removed_sounds": [], "time_range": null } + } + ] +} diff --git a/templates/lower-third.json b/templates/lower-third.json new file mode 100644 index 0000000..cf76bd7 --- /dev/null +++ b/templates/lower-third.json @@ -0,0 +1,79 @@ +{ + "name": "lower-third", + "type": "text", + "segment": { + "id": "b1b2c3d4-0001-4b01-9b01-000000000001", + "material_id": "b1b2c3d4-0002-4b02-9b02-000000000002", + "raw_segment_id": "b1b2c3d4-0003-4b03-9b03-000000000003", + "target_timerange": { "start": 0, "duration": 4000000 }, + "source_timerange": { "start": 0, "duration": 4000000 }, + "speed": 1, + "volume": 1, + "visible": true, + "reverse": false, + "clip": { + "alpha": 1, + "rotation": 0, + "scale": { "x": 1, "y": 1 }, + "transform": { "x": -0.42, "y": -0.62 }, + "flip": { "horizontal": false, "vertical": false } + }, + "render_index": 15000, + "track_render_index": 0, + "track_attribute": 0, + "extra_material_refs": [ + "b1b2c3d4-0004-4b04-9b04-000000000004", + "b1b2c3d4-0005-4b05-9b05-000000000005", + "b1b2c3d4-0006-4b06-9b06-000000000006", + "b1b2c3d4-0007-4b07-9b07-000000000007" + ], + "common_keyframes": [], + "keyframe_refs": [] + }, + "material": { + "type": "texts", + "data": { + "id": "b1b2c3d4-0002-4b02-9b02-000000000002", + "type": "text", + "content": "{\"styles\":[{\"range\":[0,9],\"size\":8,\"bold\":true,\"italic\":false,\"underline\":false,\"fill\":{\"alpha\":1,\"content\":{\"render_type\":\"solid\",\"solid\":{\"alpha\":1,\"color\":[1,1,1]}}}}],\"text\":\"@handle\"}", + "alignment": 0, + "font_size": 8, + "text_color": "#FFFFFF", + "typesetting": 0, + "letter_spacing": 0, + "line_spacing": 0.02, + "line_feed": 1, + "line_max_width": 0.82, + "force_apply_line_max_width": false, + "check_flag": 7, + "fixed_width": -1, + "fixed_height": -1, + "has_shadow": true, + "shadow_alpha": 0.6, + "shadow_color": "#000000", + "shadow_distance": 4, + "border_width": 0.04, + "border_color": "#000000", + "border_alpha": 1, + "has_border": true + } + }, + "extra_materials": [ + { + "type": "speeds", + "data": { "id": "b1b2c3d4-0004-4b04-9b04-000000000004", "type": "speed", "speed": 1, "mode": 0, "curve_speed": null } + }, + { + "type": "placeholder_infos", + "data": { "id": "b1b2c3d4-0005-4b05-9b05-000000000005", "type": "placeholder_info", "error_path": "", "error_text": "", "meta_type": "none", "res_path": "", "res_text": "" } + }, + { + "type": "sound_channel_mappings", + "data": { "id": "b1b2c3d4-0006-4b06-9b06-000000000006", "type": "none", "audio_channel_mapping": 0, "is_config_open": false } + }, + { + "type": "vocal_separations", + "data": { "id": "b1b2c3d4-0007-4b07-9b07-000000000007", "type": "vocal_separation", "choice": 0, "enter_from": "", "final_algorithm": "", "production_path": "", "removed_sounds": [], "time_range": null } + } + ] +} diff --git a/test/doctor.test.mjs b/test/doctor.test.mjs new file mode 100644 index 0000000..cee799e --- /dev/null +++ b/test/doctor.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +describe("capcut doctor", () => { + it("runs without a project and emits a structured report", () => { + const r = spawnCli(["doctor"]); + // Exit 0 because the test runner is Node >= 18 (the only hard requirement). + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(r.json, "stdout should be valid JSON"); + assert.equal(r.json.ok, true); + assert.equal(typeof r.json.platform, "string"); + assert.equal(typeof r.json.node, "string"); + assert.ok(Array.isArray(r.json.checks)); + assert.ok(r.json.checks.length > 0); + }); + + it("reports the node check as ok on a supported runtime", () => { + const r = spawnCli(["doctor"]); + const node = r.json.checks.find((c) => c.name === "node"); + assert.ok(node, "should include a node check"); + assert.equal(node.status, "ok"); + }); + + it("includes whisper, anthropic-api-key, and draft-dir checks with valid statuses", () => { + const r = spawnCli(["doctor"]); + const names = new Set(r.json.checks.map((c) => c.name)); + assert.ok(names.has("whisper")); + assert.ok(names.has("anthropic-api-key")); + for (const c of r.json.checks) { + assert.ok(["ok", "warn", "missing"].includes(c.status), `bad status: ${c.status}`); + } + }); + + it("renders a human-readable layout with -H", () => { + const r = spawnCli(["doctor", "-H"]); + assert.equal(r.status, 0); + assert.match(r.stdout, /Platform:/); + assert.match(r.stdout, /Node:/); + assert.match(r.stdout, /whisper/); + }); +}); diff --git a/test/fuzz.test.mjs b/test/fuzz.test.mjs new file mode 100644 index 0000000..4b07b45 --- /dev/null +++ b/test/fuzz.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +/** Write a draft_content.json with arbitrary raw bytes; return the dir path. */ +function draftWith(raw) { + const dir = mkdtempSync(join(tmpdir(), "capcut-cli-fuzz-")); + writeFileSync(join(dir, "draft_content.json"), raw); + return dir; +} + +// Read-only commands should never crash, hang, or leak a stack trace on bad input. +const READ_CMDS = ["info", "version", "lint", "texts", "tracks", "segments"]; + +const MALFORMED = { + "not JSON at all": "}{ this is not json", + "empty file": "", + "truncated JSON": '{"tracks": [', + "JSON array, not object": "[]", + "JSON scalar": "42", + "JSON null": "null", + "object missing tracks": '{"id": "x", "name": "y"}', + "tracks not an array": '{"tracks": {"nope": true}}', + "segment missing fields": '{"tracks": [{"segments": [{}]}]}', + "prototype-pollution attempt": '{"__proto__": {"polluted": true}, "tracks": []}', + "constructor-pollution attempt": '{"constructor": {"prototype": {"x": 1}}, "tracks": []}', + "deeply nested garbage": `{"tracks": ${"[".repeat(50)}${"]".repeat(50)}}`, +}; + +describe("fuzz: malformed draft_content.json", () => { + const dirs = []; + after(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + }); + + for (const [label, raw] of Object.entries(MALFORMED)) { + it(`handles "${label}" gracefully across read commands`, () => { + const dir = draftWith(raw); + dirs.push(dir); + for (const cmd of READ_CMDS) { + const r = spawnCli([cmd, dir], { timeout: 10_000 }); + // Must terminate (not hang → status null) — either clean (0) or a handled error. + assert.notEqual(r.status, null, `${cmd} on "${label}" timed out / was killed`); + // A non-zero exit must carry a single-line JSON error on stderr, not a raw stack. + if (r.status !== 0) { + assert.doesNotMatch(r.stderr, /\n\s+at\s/, `${cmd} on "${label}" leaked a stack trace`); + const firstLine = r.stderr.trim().split("\n")[0]; + assert.doesNotThrow( + () => JSON.parse(firstLine), + `${cmd} on "${label}" should report a JSON error, got: ${firstLine}`, + ); + } + } + }); + } + + it("does not pollute Object.prototype when parsing a hostile draft", () => { + const dir = draftWith('{"__proto__": {"pwned": true}, "tracks": []}'); + dirs.push(dir); + const r = spawnCli(["info", dir]); + assert.notEqual(r.status, null); + // The test process must be unaffected regardless of what the child did. + assert.equal({}.pwned, undefined); + }); +}); diff --git a/test/lib.test.mjs b/test/lib.test.mjs new file mode 100644 index 0000000..abf6609 --- /dev/null +++ b/test/lib.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LIB = join(__dirname, "..", "dist", "lib.js"); +const FIXTURE = join(__dirname, "draft_content.json"); + +describe("library entry point (dist/lib.js)", () => { + it("exports the stable core API and importing it does not run the CLI", async () => { + const lib = await import(LIB); + for (const name of ["loadDraft", "saveDraft", "lintDraft", "detectVersion", "runDoctor", "findSegment"]) { + assert.equal(typeof lib[name], "function", `missing export: ${name}`); + } + }); + + it("can load, inspect, and lint a draft programmatically", async () => { + const { loadDraft, lintDraft, detectVersion } = await import(LIB); + const { draft, filePath } = loadDraft(FIXTURE); + assert.ok(Array.isArray(draft.tracks)); + assert.equal(typeof filePath, "string"); + assert.ok(Array.isArray(lintDraft(draft))); + assert.ok(["CapCut", "JianYing", "unknown"].includes(detectVersion(draft).app)); + }); +}); diff --git a/test/template.test.mjs b/test/template.test.mjs index 1bc6cc5..2c1855f 100644 --- a/test/template.test.mjs +++ b/test/template.test.mjs @@ -47,7 +47,7 @@ describe("capcut save-template + apply-template", () => { }); describe("shipped templates apply cleanly", () => { - for (const name of ["gold-title", "end-card", "subscribe-cta"]) { + for (const name of ["gold-title", "end-card", "subscribe-cta", "caption-pop", "lower-third", "hook-question"]) { it(`templates/${name}.json roundtrips into a fresh draft`, () => { const fix = tmpDraft(); try { diff --git a/tsconfig.json b/tsconfig.json index 3946cf5..470dfc5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "rootDir": "src", "strict": true, "esModuleInterop": true, - "declaration": false, + "declaration": true, "sourceMap": false }, "include": ["src"]