diff --git a/CHANGELOG.md b/CHANGELOG.md index e133cdd..0610bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to capcut-cli are documented here. The format follows [Keep ## [Unreleased] +## [0.27.0] — 2026-10-03 + +### Added + +- `relink --dir --recursive` searches nested media folders. Duplicate basenames are reported in `ambiguous` and left unchanged; prefix remapping matches whole path components, and directory symlinks are not followed. + +### Fixed + +- `replace-media` stages the requested bytes even when a different file with the same basename already exists. Occupied hash filenames are checked by content; dry runs create no asset directories or files, and directories/non-media segments are rejected before mutation. +- Replacement and relink refresh changed media's `local_material_id` links and imported-media entries when a readable `draft_meta_info.json` exists. Sidecar and timeline writes share conflict checks, backups, and rollback; existing and unrelated import entries are preserved. Bare timelines still use `register --materials --apply` to create registration metadata. +- `compile` derives target durations from rounded start and end boundaries, keeping adjacent fractional-second clips contiguous across text, video, audio, photos, and timed operations. +- Compile operation payloads are checked with the same builders used by real writes before creating output. Failed builds remove only the output directory created by that invocation; successful builds register in the project index after the draft is saved. +- `serve` binds a job ID to its effective command payload and execution settings. Identical submissions still deduplicate; reusing an ID for a different job returns a failure without executing the conflicting job. +- Queue project locks resolve project roots, relative paths, timeline files, and symlink aliases so concurrent writers to the same project serialize. +- Queue input and limits reject malformed or non-finite values. Combined child output is checked again at exit before reading it, so a fast process cannot bypass the configured capture threshold. Overflow results omit captured output; the threshold is polled during execution and is not a hard disk quota. +- `import-timeline` resolves relative media references against the OTIO document's directory and decodes local `file:` URLs, including escaped spaces. Remote URLs and inaccessible references remain placeholders. + ## [0.26.1] — 2026-10-03 ### Fixed diff --git a/README.md b/README.md index 86f90b5..059426f 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,9 @@ The host reads a draft and passes its JSON as tool input. The component itself h ## Release notes -> **New in v0.26.0:** exact frame-grid lint/fix; character-level Chinese/Japanese script alignment with an optional match gate; explicit caption audio-stream selection; safe ripple delete and boundary shifts; scalable FFmpeg filter scripts; nested OTIO import; CRF/bitrate proxy controls; progressive word-reveal captions; and atomic whole-track `restyle`. Full details in the [changelog](./CHANGELOG.md). +> **New in v0.27.0:** content-safe media replacement; automatic import registration after replacement and relink; recursive, ambiguity-safe relinking; exact fractional-second compile boundaries; operation preflight and failed-build cleanup; payload-bound queue IDs; canonical project locks; bounded queue results; and local OTIO file/relative references. Full details in the [changelog](./CHANGELOG.md). -> **New in v0.25.0:** `caption` follows the transcript's script. Whisper's "words" for Chinese and Japanese are single characters or short tokens, so the Latin defaults (four words per cue, joined with spaces) produced fragments with spaces between the characters; cues are now joined without spaces and bounded by characters alone, at the width `lint` holds captions to (zh 16, ja 13, ko 16), and the result reports `caption_script`. An explicit `--max-words` / `--max-chars` still wins. 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). +> **Fixed in v0.26.1:** ratio-only compile canvases and full source-media durations; JSON-escaped Windows fixture redaction with automatic leak verification; and active-timeline edits on the fixture-backed CapCut 8.7.0 Windows layout. A patched desktop round-trip remains pending. Full details in the [changelog](./CHANGELOG.md). ## Built with capcut-cli diff --git a/README.zh-CN.md b/README.zh-CN.md index 291014f..358d2ed 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -82,11 +82,9 @@ Claude Code 也可以把它作为插件加载: ## 发布说明 -> **v0.26.0 新增:** 精确帧网格检查/修复;中日文按字对齐脚本并可设置匹配率门槛;字幕音轨选择;安全波纹删除和边界平移;大型 FFmpeg 滤镜脚本;嵌套 OTIO 导入;CRF/码率预览控制;逐词显现字幕;以及整轨原子化 `restyle`。完整说明见[更新日志](./CHANGELOG.md)。 +> **v0.27.0 新增:** 按文件内容安全替换媒体;替换和重链接后自动更新媒体导入登记;递归搜索并报告同名歧义;精确处理小数秒时间边界;编译前校验操作并清理失败输出;将队列 ID 绑定到任务参数;统一项目锁;限制队列输出;以及解析 OTIO 的本地文件 URL 和相对路径。完整说明见[更新日志](./CHANGELOG.md)。 -> **v0.25.0 新增:** `caption` 按转写文本的文字来分句。Whisper 对中文、日文给出的"词"是单个字或很短的片段,按拉丁默认(每句 4 词、用空格连接)会生成字与字之间带空格的碎片;现在中日文按字直接连接、只按字数上限分句,上限就是 `lint` 对字幕的行宽(zh 16、ja 13、ko 16),结果里会报告 `caption_script`。显式传入的 `--max-words` / `--max-chars` 仍然优先。完整说明见[更新日志](./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.26.1 修复:** 仅指定比例的编译画布与完整源媒体时长;对 JSON 转义的 Windows 路径进行 fixture 脱敏并自动检查泄漏;以及基于真实 fixture 的 CapCut 8.7.0 Windows 活动时间线编辑。修复后的桌面应用往返验证仍待完成。完整说明见[更新日志](./CHANGELOG.md)。 ## 使用 capcut-cli 构建 diff --git a/docs/command-reference.json b/docs/command-reference.json index a209bc0..35e2b03 100644 --- a/docs/command-reference.json +++ b/docs/command-reference.json @@ -1,6 +1,6 @@ { "name": "capcut-cli", - "version": "0.26.1", + "version": "0.27.0", "schema_version": 2, "description": "Edit CapCut/JianYing draft_content.json directly. JSON in, JSON out.", "global_flags": [ @@ -4531,7 +4531,7 @@ { "name": "relink", "summary": "Repair broken media paths (--dir or --from/--to).", - "usage": "capcut relink (--dir | --from --to ) [--stage]", + "usage": "capcut relink (--dir [--recursive] | --from --to ) [--stage]", "positionals": [ { "name": "project", @@ -4552,7 +4552,16 @@ ], "type": "path", "required": false, - "description": "Directory containing replacement files." + "description": "Directory containing replacement files; ambiguous basenames are reported and left unchanged." + }, + { + "name": "recursive", + "flags": [ + "--recursive" + ], + "type": "boolean", + "required": false, + "description": "Search nested directories under --dir; directory symlinks are not followed." }, { "name": "from", diff --git a/docs/command-reference.md b/docs/command-reference.md index d5c06f5..9e1873b 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -64,7 +64,7 @@ | `prune` | `capcut prune ` | yes | Remove materials no segment references. | | `register` | `capcut register [--apply] [--materials] [--drafts ]` | yes | Repair an existing draft's registration metadata (draft_meta_info.json + root_meta_info.json entry) from a read-only draft_content.json so the CapCut app lists it (plan by default; --apply writes with .bak); --materials also registers the timeline's media in draft_materials (the CapCut 9.1 relink-prompt fix). | | `rename` | `capcut rename [--drafts ]` | yes | Rename a draft after creation: the folder on disk plus draft_name and every self-referential path in draft_meta_info.json and the store's root_meta_info.json entry, transactionally (refuses when the target folder exists). | -| `relink` | `capcut relink (--dir \| --from --to ) [--stage]` | yes | Repair broken media paths (--dir or --from/--to). | +| `relink` | `capcut relink (--dir [--recursive] \| --from --to ) [--stage]` | yes | Repair broken media paths (--dir or --from/--to). | | `replace-media` | `capcut replace-media [--retime]` | yes | Swap a segment's source file (placeholder > final) keeping its timing, effects, and keyframes. | | `timeline` | `capcut timeline [--cols ]` | no | Show the track/segment layout (JSON, or -H ASCII bars). | | `projects` | `capcut projects [query] [--drafts ] [--names]` | no | List CapCut/JianYing draft folders on disk. | diff --git a/docs/command-reference.zh-CN.md b/docs/command-reference.zh-CN.md index f5e8467..e8ae8a9 100644 --- a/docs/command-reference.zh-CN.md +++ b/docs/command-reference.zh-CN.md @@ -65,7 +65,7 @@ | `prune` | `capcut prune ` | 是 | 删除没有任何片段引用的素材。 | | `register` | `capcut register [--apply] [--drafts ]` | 是 | 从只读的 draft_content.json 修复已有草稿的注册元数据(draft_meta_info.json + root_meta_info.json 条目),让 CapCut 应用能列出它(默认只出计划;--apply 写入并留 .bak)。 | | `rename` | `capcut rename [--drafts ]` | 是 | 在创建后重命名草稿:磁盘上的文件夹,加上 draft_meta_info.json 和存储 root_meta_info.json 条目里的 draft_name 及所有自引用路径,事务性完成(目标文件夹已存在时拒绝)。 | -| `relink` | `capcut relink (--dir \| --from --to )` | 是 | 修复失效的媒体路径(--dir 或 --from/--to)。 | +| `relink` | `capcut relink (--dir [--recursive] \| --from --to ) [--stage]` | 是 | 修复失效的媒体路径(--dir 可递归搜索;同名歧义会报告并保持原路径;--from/--to 按路径边界映射)。 | | `replace-media` | `capcut replace-media [--retime]` | 是 | 替换片段的源文件(占位素材 → 成片素材),保留其时间、特效与关键帧。 | | `timeline` | `capcut timeline [--cols ]` | 否 | 显示轨道/片段布局(JSON;-H 显示 ASCII 条形图)。 | | `projects` | `capcut projects [query] [--drafts ] [--names]` | 否 | 列出磁盘上的 CapCut/剪映草稿文件夹。 | diff --git a/docs/reviews/v0.27.0.md b/docs/reviews/v0.27.0.md new file mode 100644 index 0000000..5a16969 --- /dev/null +++ b/docs/reviews/v0.27.0.md @@ -0,0 +1,59 @@ +# v0.27.0 review guide + +This release makes local media edits, draft compilation, queued automation, and +OTIO imports reliable at their file and timing boundaries. + +| Behavior | Regression coverage | +|---|---| +| Same-name replacement uses the requested bytes; dry-run creates no assets | `test/replace.test.mjs` | +| Replacement/relink preserve imported entries and update local IDs in a guarded transaction | `test/media-registration-edit.test.mjs`, `test/changed-media-registration.test.mjs` | +| Nested media search reports duplicate basenames and respects prefix boundaries | `test/relink-recursive.test.mjs` | +| Fractional clip boundaries remain contiguous at 136 BPM | `test/compile-fractional-timing.test.mjs` | +| Invalid operations fail before creation; failures remove only their owned output | `test/compile-safety.test.mjs` | +| Identical queue IDs deduplicate; conflicting payloads never execute | `test/serve-boundaries.test.mjs` | +| Relative, file, directory, symlink, and active-timeline project aliases serialize | `test/serve-boundaries.test.mjs` | +| Fast output and stale size observations cannot bypass bounded result reads | `test/serve-boundaries.test.mjs` | +| Relative OTIO paths and local file URLs find real media from another working directory | `test/otio-media-paths.test.mjs` | + +## Verify from source and from the package + +```bash +npm ci +npm test +npm run lint +npm run docs:commands +npm --prefix wasm/capcut-core ci +npm run wasm:verify +npm pack --pack-destination /tmp +npm run smoke:package -- /tmp/capcut-cli-0.27.0.tgz . +``` + +The package smoke requires `ffprobe` on PATH. It installs the tarball into a fresh +temporary directory, exercises CLI and library entry points, and checks compile +metadata/timing/preflight, replacement bytes/import IDs, queue ID conflicts, +fixture redaction, and active-timeline edits/restoration. It removes its temporary +projects afterward. On Windows, substitute a writable temporary tarball directory +for `/tmp`. + +## Scope and limits + +Queue IDs and locks last for one invocation. They cover declared project paths and +explicit destinations; implicit compile destinations, `compile --data` outputs, +separate runners, and shared library-index writes need external serialization. +Output limits are checked during execution and at exit; they are capture thresholds, +not hard disk quotas. Overflow jobs are not retried. + +Media registration refresh requires a readable sidecar. Bare timeline files retain +their existing behavior and can use `register --materials --apply` to create one. +Existing imports remain intact; a reused import is conflict-checked without rewriting +or backing up an unchanged sidecar. + +The existing version guards and fixture evidence limits remain in place. These +checks validate file behavior; a patched CapCut 8.7.0 Windows desktop round-trip +remains pending as recorded in [version support](../version-support.md). + +The release package is `capcut-cli` on npm. The Python client continues to forward +commands to that CLI and has no source change in this release. The optional Wasm +component remains hosted through Wassette; this release adds no standalone MCP +server package. GitHub release artifacts can include the verified component and +checksum after maintainer review. diff --git a/examples/serve-automation.md b/examples/serve-automation.md index 81f4d62..4c8fe59 100644 --- a/examples/serve-automation.md +++ b/examples/serve-automation.md @@ -81,3 +81,25 @@ Build the image from this repo with `docker build -t capcut-cli .`. > 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. + +## Queue identity and capture limits + +IDs last for one queue drain. An ID binds to the effective argv and execution settings +(timeout, retries, backoff, and output threshold). Repeating that payload returns the +original result with `deduplicated: true`; reusing the ID for different arguments or +settings returns a failure without executing the conflicting job. Use a new ID for +an intentional new edit. + +Project positionals and explicit project destinations are locked by their resolved +project root, including relative paths, timeline files, and symlink aliases. Locks +are local to this queue invocation; separate runners and the desktop editor do not +share them. Commands with implicit destinations, such as `compile --data`, should be +run separately or with one worker. + +`cmd`, `id`, and `project` must be non-empty strings when present; `args` must be an +array of strings. Workers must be an integer from 1 to 32, timeout a positive integer +in milliseconds, and retries/backoff non-negative integers. `--max-buffer-mb` sets a +positive combined stdout/stderr capture threshold. It is checked every 25 ms and +again at exit before reading the files, so it is not a hard disk quota. An overflow +result has `ok: false` and `overflow: true`, omits captured output, and is not retried. +Other failed jobs keep the configured retry policy. diff --git a/package-lock.json b/package-lock.json index ba1b286..c9aa996 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "capcut-cli", - "version": "0.26.1", + "version": "0.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "capcut-cli", - "version": "0.26.1", + "version": "0.27.0", "license": "MIT", "bin": { "capcut": "dist/index.js", diff --git a/package.json b/package.json index fac719f..a9659f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "capcut-cli", - "version": "0.26.1", + "version": "0.27.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": { @@ -43,6 +43,7 @@ "dev": "node --import tsx src/index.ts", "extract-enums": "python3 scripts/extract-enums.py", "test": "npm run build && node --test --test-reporter=spec", + "smoke:package": "node scripts/release-smoke.mjs", "test:fast": "node --test --test-reporter=spec", "lint": "biome check --error-on-warnings src/ test/", "lint:fix": "biome check --write src/ test/", diff --git a/scripts/release-smoke.mjs b/scripts/release-smoke.mjs new file mode 100644 index 0000000..7ef97e5 --- /dev/null +++ b/scripts/release-smoke.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const [tarballArg, sourceArg = "."] = process.argv.slice(2); +if (!tarballArg) throw new Error("Usage: npm run smoke:package -- [source-root]"); +const tarball = resolve(tarballArg); +const sourceRoot = resolve(sourceArg); +const expectedVersion = JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8")).version; +const root = mkdtempSync(join(tmpdir(), "capcut-package-smoke-")); +const load = (path) => JSON.parse(readFileSync(path, "utf8")); +try { + const npmArgs = ["install", "--prefix", root, "--ignore-scripts", "--no-audit", "--no-fund", "--offline", tarball]; + const installed = process.env.npm_execpath + ? spawnSync(process.execPath, [process.env.npm_execpath, ...npmArgs], { encoding: "utf8" }) + : spawnSync("npm", npmArgs, { encoding: "utf8" }); + assert.equal(installed.status, 0, installed.stderr); + const packageRoot = join(root, "node_modules", "capcut-cli"); + const binary = join(packageRoot, "dist", "index.js"); + const invoke = (args, status = 0) => { + const r = spawnSync(process.execPath, [binary, ...args], { encoding: "utf8" }); + assert.equal(r.status, status, `${args[0]}: ${r.stderr}\n${r.stdout}`); + return { ...r, json: args[0] === "--version" || !r.stdout.trim() ? undefined : JSON.parse(r.stdout) }; + }; + assert.equal(invoke(["--version"]).stdout.trim(), expectedVersion); + assert.equal(load(join(packageRoot, "package.json")).version, expectedVersion); + const lib = await import(pathToFileURL(join(packageRoot, "dist", "lib.js"))); + assert.equal(typeof lib.loadDraft, "function"); + assert.ok(existsSync(join(packageRoot, "docs", "command-reference.json"))); + + const beat = (60 / 136) * 4; + const beatsSpec = join(root, "beats.json"); + writeFileSync( + beatsSpec, + JSON.stringify({ + name: "Beats", + tracks: [ + { + type: "text", + items: Array.from({ length: 8 }, (_, i) => ({ + start: i * beat, + duration: beat, + text: "beat", + ref: `beat-${i}`, + })), + }, + ], + }), + ); + const beatsOut = join(root, "beats"); + invoke(["compile", beatsSpec, "--out", beatsOut, "--template", "bundled"]); + const beatSegments = load(join(beatsOut, "draft_content.json")).tracks[0].segments; + for (let i = 1; i < beatSegments.length; i++) + assert.equal( + beatSegments[i - 1].target_timerange.start + beatSegments[i - 1].target_timerange.duration, + beatSegments[i].target_timerange.start, + ); + const invalidSpec = load(beatsSpec); + invalidSpec.operations = [{ op: "keyframe", target: "beat-0", property: "not-real", time: 0, value: 1 }]; + writeFileSync(beatsSpec, JSON.stringify(invalidSpec)); + invoke(["compile", beatsSpec, "--check"], 1); + invoke(["compile", beatsSpec, "--out", join(root, "bad-operation")], 1); + assert.equal(existsSync(join(root, "bad-operation")), false); + + const wav = Buffer.alloc(44 + 89_280, 128); + wav.write("RIFF", 0); + wav.writeUInt32LE(36 + 89_280, 4); + wav.write("WAVEfmt ", 8); + wav.writeUInt32LE(16, 16); + wav.writeUInt16LE(1, 20); + wav.writeUInt16LE(1, 22); + wav.writeUInt32LE(1000, 24); + wav.writeUInt32LE(1000, 28); + wav.writeUInt16LE(1, 32); + wav.writeUInt16LE(8, 34); + wav.write("data", 36); + wav.writeUInt32LE(89_280, 40); + writeFileSync(join(root, "clip.wav"), wav); + const specPath = join(root, "spec.json"); + const items = [20, 40].map((sourceStart, i) => ({ path: "clip.wav", sourceStart, start: i * 6, duration: 6 })); + writeFileSync(specPath, JSON.stringify({ name: "Smoke", ratio: "9:16", tracks: [{ type: "video", items }] })); + assert.equal(invoke(["compile", specPath, "--check"]).json.canvas.ratio, "9:16"); + const out = join(root, "compiled"); + invoke(["compile", specPath, "--out", out]); + const draft = load(join(out, "draft_content.json")); + assert.deepEqual(draft.canvas_config, { width: 1080, height: 1920, ratio: "9:16" }); + for (const material of draft.materials.videos) assert.equal(material.duration, 89_280_000); + assert.deepEqual( + draft.tracks[0].segments.map((s) => s.source_timerange), + [ + { start: 20_000_000, duration: 6_000_000 }, + { start: 40_000_000, duration: 6_000_000 }, + ], + ); + for (const material of load(join(out, "draft_meta_info.json")).draft_materials.flatMap((group) => group.value)) { + assert.equal(material.duration, 89_280_000); + } + items[0].sourceStart = 80; + items[0].speed = 2; + writeFileSync(specPath, JSON.stringify({ name: "Invalid", tracks: [{ type: "video", items }] })); + const invalidOut = join(root, "invalid"); + invoke(["compile", specPath, "--out", invalidOut], 1); + assert.equal(existsSync(invalidOut), false); + + const replacementDir = join(root, "new-media"); + const { mkdirSync } = await import("node:fs"); + mkdirSync(replacementDir); + const newClip = join(replacementDir, "clip.wav"); + const newBytes = Buffer.from(wav); + newBytes[1000] = 12; + writeFileSync(newClip, newBytes); + const segmentId = draft.tracks[0].segments[0].id; + const replace = invoke(["replace-media", out, segmentId, newClip]).json; + assert.deepEqual(readFileSync(replace.new_path), newBytes); + assert.notEqual(replace.new_path, draft.materials.videos[0].path); + const replacedDraft = load(join(out, "draft_content.json")); + const replacedMaterial = replacedDraft.materials.videos.find((m) => m.id === replace.material_id); + const imported = load(join(out, "draft_meta_info.json")) + .draft_materials.flatMap((g) => g.value) + .find((e) => e.file_Path === replace.new_path); + assert.equal(replacedMaterial.local_material_id, imported.id); + + const jobs = [ + { id: "same", cmd: "info", project: out }, + { id: "same", cmd: "info", project: out }, + { id: "same", cmd: "tracks", project: out }, + ]; + const queue = join(root, "jobs.jsonl"); + writeFileSync(queue, jobs.map((job) => JSON.stringify(job)).join("\n")); + const queueResult = spawnSync(process.execPath, [binary, "serve", "--queue", queue, "--workers", "3"], { + encoding: "utf8", + }); + assert.equal(queueResult.status, 0, queueResult.stderr); + const results = queueResult.stdout.trim().split("\n").map(JSON.parse); + assert.equal(results.filter((r) => r.ok).length, 2); + assert.equal(results.filter((r) => r.deduplicated).length, 1); + assert.match(results.find((r) => !r.ok).stderr, /different command payload/); + + const privatePath = "C:\\Users\\Smoke Account\\AppData\\Local\\CapCut"; + const metaPath = join(out, "draft_meta_info.json"); + const meta = load(metaPath); + meta.draft_root_path = privatePath; + writeFileSync(metaPath, JSON.stringify(meta)); + draft.embedded = JSON.stringify({ path: privatePath }); + writeFileSync(join(out, "draft_content.json"), JSON.stringify(draft)); + const bundle = join(root, "bundle"); + assert.equal(invoke(["fixture", out, "--out", bundle]).json.redaction_check.ok, true); + for (const name of ["draft_content.json", "draft_meta_info.json", "SANITIZE_REPORT.json"]) { + assert.doesNotMatch(readFileSync(join(bundle, name), "utf8"), /Smoke Account/); + } + assert.equal( + JSON.parse(load(join(bundle, "draft_content.json")).embedded).path, + "C:\\Users\\USER\\AppData\\Local\\CapCut", + ); + invoke(["fixture", bundle, "--check"]); + writeFileSync(join(bundle, "leak.json"), JSON.stringify({ path: privatePath })); + const leak = invoke(["fixture", bundle, "--check"], 1); + assert.equal(leak.json.ok, false); + assert.doesNotMatch(leak.stderr, /Smoke Account/); + + const project = join(root, "active"); + cpSync(join(sourceRoot, "test", "fixtures", "capcut-8.7-windows-active"), project, { recursive: true }); + const pointerPath = join(project, "Timelines", "project.json"); + const pointerBefore = readFileSync(pointerPath, "utf8"); + const pointer = JSON.parse(pointerBefore); + const activePath = join(project, "Timelines", pointer.main_timeline_id || pointer.id, "draft_content.json"); + const activeBefore = load(activePath); + const segment = activeBefore.tracks.find((track) => track.type === "text").segments[0]; + const documentPaths = [activePath, join(project, "draft_content.json"), join(project, "template-2.tmp")]; + const ids = documentPaths.map((path) => load(path).id); + invoke(["set-text", project, segment.id, "PACKAGE RELEASE SMOKE"]); + for (const [i, path] of documentPaths.entries()) { + const document = load(path); + assert.equal(document.id, ids[i]); + assert.match(JSON.stringify(document), /PACKAGE RELEASE SMOKE/); + } + assert.equal(readFileSync(pointerPath, "utf8"), pointerBefore); + invoke(["texts", project]); + invoke(["restore", project]); + assert.deepEqual(load(activePath), activeBefore); + console.log( + "PASS: fresh tarball install, CLI/library/version, compile/canvas/source duration/preflight, fractional timing, operation preflight, same-name replacement/media registration, queue payload dedup, escaped-path redaction/leak rejection, active-timeline writes/IDs/pointer/restore.", + ); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/src/command-specs.ts b/src/command-specs.ts index 046dae8..e69fc88 100644 --- a/src/command-specs.ts +++ b/src/command-specs.ts @@ -236,7 +236,7 @@ const usages = { prune: "capcut prune ", register: "capcut register [--apply] [--materials] [--drafts ]", rename: "capcut rename [--drafts ]", - relink: "capcut relink (--dir | --from --to ) [--stage]", + relink: "capcut relink (--dir [--recursive] | --from --to ) [--stage]", timeline: "capcut timeline [--cols ]", projects: "capcut projects [query] [--drafts ] [--names]", diff: "capcut diff ", @@ -691,7 +691,18 @@ const optionsByCommand: Record = { option("effect_id", ["--effect-id"], "string", "Effect id for an --add entry that carries both ids."), ], relink: [ - option("dir", ["--dir"], "path", "Directory containing replacement files."), + option( + "dir", + ["--dir"], + "path", + "Directory containing replacement files; ambiguous basenames are reported and left unchanged.", + ), + option( + "recursive", + ["--recursive"], + "boolean", + "Search nested directories under --dir; directory symlinks are not followed.", + ), option("from", ["--from"], "path", "Old path prefix."), option("to", ["--to"], "path", "New path prefix."), option( @@ -931,6 +942,7 @@ optionsByCommand["image-anim"] = optionsByCommand["text-anim"]; // --word-reveal, --min-script-match, --audio-stream -> caption (v0.26 caption controls) // --from -> shift-all; --ripple -> remove (v0.26 boundary-safe ripple editing) // --frame-grid -> lint (v0.26 exact integer timeline preflight) +// --recursive -> relink (v0.27 nested media search) // Everywhere else they fall through to the positional stream verbatim, matching // pre-release behaviour where these tokens were unknown and preserved. export const RELEASE_SCOPED_FLAGS: ReadonlySet = new Set([ @@ -976,6 +988,7 @@ export const RELEASE_SCOPED_FLAGS: ReadonlySet = new Set([ "--pad", "--pip", "--stage", + "--recursive", "--preset", "--ratio", "--rect", diff --git a/src/compile.ts b/src/compile.ts index 9856f0a..951fa01 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, rmSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, resolve } from "node:path"; import { stripBom } from "./bom.js"; import { @@ -20,6 +20,7 @@ import { applyTemplate, copyTextStyle, initDraft, + registerDraftInIndex, resolveCanvas, setAudioFade, } from "./factory.js"; @@ -202,15 +203,18 @@ export function parseSpec(raw: string): CompileSpec { } export function validateSpec(spec: unknown): asserts spec is CompileSpec { - if (!spec || typeof spec !== "object") throw new Error("compile: spec must be a JSON object"); + if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw new Error("compile: spec must be a JSON object"); const s = spec as Record; validateDraftName(s.name); resolveCanvas(s as unknown as CompileSpec); + if (s.fps !== undefined && (typeof s.fps !== "number" || !Number.isFinite(s.fps) || s.fps <= 0)) { + throw new Error("compile: spec.fps must be a finite number > 0"); + } if (!Array.isArray(s.tracks) || s.tracks.length === 0) { throw new Error("compile: spec.tracks must be a non-empty array"); } s.tracks.forEach((t, ti) => { - if (!t || typeof t !== "object") throw new Error(`compile: tracks[${ti}] must be an object`); + if (!t || typeof t !== "object" || Array.isArray(t)) throw new Error(`compile: tracks[${ti}] must be an object`); const track = t as Record; if (typeof track.type !== "string" || !VALID_TRACK_TYPES.has(track.type)) { throw new Error(`compile: tracks[${ti}].type must be one of video|audio|text (got ${String(track.type)})`); @@ -218,16 +222,34 @@ export function validateSpec(spec: unknown): asserts spec is CompileSpec { if (!Array.isArray(track.items) || track.items.length === 0) { throw new Error(`compile: tracks[${ti}].items must be a non-empty array`); } + if (track.name !== undefined && typeof track.name !== "string") { + throw new Error(`compile: tracks[${ti}].name must be a string`); + } track.items.forEach((it, ii) => { - const item = it as Record; const where = `tracks[${ti}].items[${ii}]`; - if (typeof item.start !== "number" || item.start < 0) { + if (!it || typeof it !== "object" || Array.isArray(it)) { + throw new Error(`compile: ${where} must be an object`); + } + const item = it as Record; + if (typeof item.start !== "number" || !Number.isFinite(item.start) || item.start < 0) { throw new Error(`compile: ${where}.start must be a number >= 0 (seconds)`); } if (item.ref !== undefined && (typeof item.ref !== "string" || item.ref.length === 0)) { throw new Error(`compile: ${where}.ref must be a non-empty string`); } - for (const field of ["speed", "opacity", "rotation", "scale", "sourceStart"] as const) { + for (const field of [ + "speed", + "opacity", + "rotation", + "scale", + "sourceStart", + "volume", + "fontSize", + "x", + "y", + "width", + "height", + ] as const) { if (item[field] !== undefined && (typeof item[field] !== "number" || !Number.isFinite(item[field]))) { throw new Error(`compile: ${where}.${field} must be a number`); } @@ -238,11 +260,25 @@ export function validateSpec(spec: unknown): asserts spec is CompileSpec { if (typeof item.speed === "number" && item.speed <= 0) { throw new Error(`compile: ${where}.speed must be > 0`); } + if (item.type !== undefined && item.type !== "video" && item.type !== "photo") { + throw new Error(`compile: ${where}.type must be video or photo`); + } + if (item.color !== undefined) validateColor(item.color, `${where}.color`); + for (const field of ["fontSize", "width", "height"] as const) { + if (typeof item[field] === "number" && item[field] <= 0) { + throw new Error(`compile: ${where}.${field} must be > 0`); + } + } + for (const field of ["volume", "opacity"] as const) { + if (typeof item[field] === "number" && (item[field] < 0 || (field === "opacity" && item[field] > 1))) { + throw new Error(`compile: ${where}.${field} must be ${field === "opacity" ? "between 0 and 1" : ">= 0"}`); + } + } if (track.type === "text") { if (typeof item.text !== "string" || item.text.length === 0) { throw new Error(`compile: ${where}.text is required for text tracks`); } - if (typeof item.duration !== "number" || item.duration <= 0) { + if (typeof item.duration !== "number" || !Number.isFinite(item.duration) || item.duration <= 0) { throw new Error(`compile: ${where}.duration (seconds) is required for text tracks`); } } else { @@ -252,11 +288,14 @@ export function validateSpec(spec: unknown): asserts spec is CompileSpec { if ( track.type === "video" && item.type === "photo" && - (typeof item.duration !== "number" || item.duration <= 0) + (typeof item.duration !== "number" || !Number.isFinite(item.duration) || item.duration <= 0) ) { throw new Error(`compile: ${where}.duration (seconds) is required for photos`); } - if (item.duration !== undefined && (typeof item.duration !== "number" || item.duration <= 0)) { + if ( + item.duration !== undefined && + (typeof item.duration !== "number" || !Number.isFinite(item.duration) || item.duration <= 0) + ) { throw new Error(`compile: ${where}.duration must be > 0 when provided`); } } @@ -278,6 +317,8 @@ export function validateSpec(spec: unknown): asserts spec is CompileSpec { throw new Error(`compile: operations[${index}].op is required`); } const op = operation as Record; + const where = `operations[${index}]`; + validateOperationPayload(op, where); if ( ![ "transition", @@ -312,6 +353,12 @@ export function validateSpec(spec: unknown): asserts spec is CompileSpec { if (op.op === "text-ranges" && !Array.isArray(op.ranges)) { throw new Error(`compile: operations[${index}].ranges must be an array of range objects`); } + if (op.op === "template" && op.ref !== undefined) { + if (typeof op.ref !== "string" || op.ref.length === 0) + throw new Error(`compile: ${where}.ref must be a non-empty string`); + if (refs.has(op.ref)) throw new Error(`compile: duplicate ref '${op.ref}'`); + refs.add(op.ref); + } // Pre-flight the keyframe easing with the exact validation the real write // performs, so --check rejects what compile would reject and a bad easing // never fails AFTER initDraft seeded the draft directory (orphan dir). @@ -328,6 +375,155 @@ export function validateSpec(spec: unknown): asserts spec is CompileSpec { } } +function validateColor(value: unknown, where: string): void { + if (typeof value !== "string" || !/^#[0-9a-f]{6}$/i.test(value)) { + throw new Error(`compile: ${where} must be a #RRGGBB color`); + } +} + +function finiteField(op: Record, key: string, where: string, required = false, min?: number): void { + const value = op[key]; + if (value === undefined && !required) return; + if (typeof value !== "number" || !Number.isFinite(value) || (min !== undefined && value < min)) { + throw new Error(`compile: ${where}.${key} must be a finite number${min === undefined ? "" : ` >= ${min}`}`); + } +} + +function validateOperationPayload(op: Record, where: string): void { + for (const key of ["trackName", "styleRef", "text"] as const) { + if (op[key] !== undefined && typeof op[key] !== "string") + throw new Error(`compile: ${where}.${key} must be a string`); + } + if (op.jianying !== undefined && typeof op.jianying !== "boolean") + throw new Error(`compile: ${where}.jianying must be a boolean`); + if (["transition", "filter", "effect"].includes(String(op.op))) { + if (typeof op.slug !== "string" || op.slug.length === 0) + throw new Error(`compile: ${where}.slug must be a non-empty string`); + } + if (["filter", "effect", "template"].includes(String(op.op))) { + finiteField(op, "start", where, true, 0); + finiteField(op, "duration", where, true, 0); + targetTiming(op.start as number, op.duration as number); + } + if (op.op === "transition" && op.duration !== undefined) { + finiteField(op, "duration", where, true, 0); + targetTiming(0, op.duration as number); + } + if (op.op === "filter") { + finiteField(op, "intensity", where, false, 0); + if ((op.intensity as number) > 1) throw new Error(`compile: ${where}.intensity must be between 0 and 1`); + } + if (op.op === "effect" && op.params !== undefined) { + if (!Array.isArray(op.params) || op.params.some((value) => typeof value !== "number" || !Number.isFinite(value))) { + throw new Error(`compile: ${where}.params must be an array of finite numbers`); + } + } + if (op.op === "keyframe") { + if (typeof op.property !== "string" || op.property.length === 0) + throw new Error(`compile: ${where}.property must be a non-empty string`); + finiteField(op, "time", where, true, 0); + finiteField(op, "value", where, true); + } + if (op.op === "audio-fade") { + finiteField(op, "fadeIn", where, false, 0); + finiteField(op, "fadeOut", where, false, 0); + } + if (op.op === "captions") finiteField(op, "timeOffset", where); + if (op.op === "template" || op.op === "captions") { + if (typeof op.path !== "string" || op.path.length === 0) + throw new Error(`compile: ${where}.path must be a non-empty string`); + } + if (op.op === "text-style" && op.style && typeof op.style === "object" && !Array.isArray(op.style)) { + const style = op.style as Record; + for (const key of [ + "alpha", + "fixedWidth", + "fixedHeight", + "shadowAlpha", + "shadowAngle", + "shadowDistance", + "shadowSmoothing", + "borderWidth", + "borderAlpha", + "bgAlpha", + "bgStyle", + "bgRoundRadius", + "bgWidth", + "bgHeight", + "bgHOffset", + "bgVOffset", + ]) { + finiteField(style, key, `${where}.style`); + } + for (const key of ["alpha", "shadowAlpha", "borderAlpha", "bgAlpha"]) { + if (style[key] !== undefined && ((style[key] as number) < 0 || (style[key] as number) > 1)) { + throw new Error(`compile: ${where}.style.${key} must be between 0 and 1`); + } + } + for (const key of ["vertical", "shadow"]) { + if (style[key] !== undefined && typeof style[key] !== "boolean") + throw new Error(`compile: ${where}.style.${key} must be a boolean`); + } + for (const key of ["shadowColor", "borderColor", "bgColor"]) { + if (style[key] !== undefined) validateColor(style[key], `${where}.style.${key}`); + } + } + if (op.op === "text-ranges" && Array.isArray(op.ranges)) { + for (const [index, range] of op.ranges.entries()) { + const label = `${where}.ranges[${index}]`; + if (!range || typeof range !== "object" || Array.isArray(range)) + throw new Error(`compile: ${label} must be an object`); + const r = range as Record; + finiteField(r, "start", label, true, 0); + finiteField(r, "end", label, true, 0); + finiteField(r, "font_size", label, false, 0); + finiteField(r, "font_alpha", label, false, 0); + if (r.font_size === 0) throw new Error(`compile: ${label}.font_size must be > 0`); + if ((r.font_alpha as number) > 1) throw new Error(`compile: ${label}.font_alpha must be between 0 and 1`); + if (r.font_color !== undefined) validateColor(r.font_color, `${label}.font_color`); + for (const key of ["bold", "italic", "underline"]) { + if (r[key] !== undefined && typeof r[key] !== "boolean") + throw new Error(`compile: ${label}.${key} must be a boolean`); + } + } + } +} + +function targetTiming(startSeconds: number, durationSeconds: number): { start: number; duration: number } { + const start = Math.round(startSeconds * US); + const end = Math.round((startSeconds + durationSeconds) * US); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end <= start) { + throw new Error( + "compile: target timing must span at least one microsecond and fit within safe integer microseconds", + ); + } + return { start, duration: end - start }; +} + +function validateTemplateFile(path: string): void { + const value: unknown = JSON.parse(stripBom(readFileSync(path, "utf-8"))); + const record = (item: unknown): item is Record => + !!item && typeof item === "object" && !Array.isArray(item); + if ( + !record(value) || + typeof value.type !== "string" || + !["text", "video", "audio", "sticker", "effect", "filter"].includes(value.type) || + !record(value.segment) || + !record(value.material) || + typeof value.material.type !== "string" || + !record(value.material.data) || + !Array.isArray(value.extra_materials) + ) { + throw new Error( + `compile: invalid template payload: ${path}; expected type, segment, material {type, data}, and extra_materials`, + ); + } + for (const extra of value.extra_materials) { + if (!record(extra) || typeof extra.type !== "string" || !record(extra.data)) + throw new Error(`compile: invalid template extra material: ${path}`); + } +} + function resolvePath(p: string, specDir: string): string { return isAbsolute(p) ? p : resolve(specDir, p); } @@ -376,17 +572,19 @@ export function substitutePlaceholders(value: T, row: Record function itemTiming(item: CompileItem, media: MediaProbe | null): { duration: number; sourceDuration: number } { const photo = item.type === "photo" || /\.(?:jpg|jpeg|png|webp|bmp|tiff)$/i.test(item.path ?? ""); const sourceStart = Math.round((item.sourceStart ?? 0) * US); + if (!Number.isSafeInteger(sourceStart)) + throw new Error("compile: sourceStart must fit within safe integer microseconds"); const speed = item.speed ?? 1; - const duration = - item.duration !== undefined - ? Math.round(item.duration * US) - : Math.round(((media?.durationUs ?? 0) - sourceStart) / speed); - if (duration <= 0) { + const durationSeconds = item.duration ?? ((media?.durationUs ?? 0) - sourceStart) / speed / US; + if (durationSeconds <= 0) { throw new Error( `compile: duration omitted for ${item.path}, but ffprobe could not determine it. Pass duration explicitly or install ffprobe.`, ); } + const duration = targetTiming(item.start, durationSeconds).duration; const sourceEnd = sourceStart + Math.round(duration * speed); + if (!Number.isSafeInteger(sourceEnd)) + throw new Error("compile: source range must fit within safe integer microseconds"); if (!photo && media?.durationUs && sourceEnd > media.durationUs + 10_000) { throw new Error( `compile: source range for ${item.path} exceeds source duration (${sourceEnd} > ${media.durationUs}us)`, @@ -405,9 +603,13 @@ export function planCompile(spec: CompileSpec, specDir: string): CompilePlan { for (const track of spec.tracks) { for (const item of track.items) { if (item.ref) refs.push(item.ref); - if (track.type === "text") continue; + if (track.type === "text") { + targetTiming(item.start, item.duration as number); + continue; + } const abs = resolvePath(item.path as string, specDir); if (!existsSync(abs)) throw new Error(`compile: media file not found: ${item.path} (resolved: ${abs})`); + if (!statSync(abs).isFile()) throw new Error(`compile: media path must be a regular file: ${item.path}`); itemTiming(item, probeMedia(abs)); media.push(abs); } @@ -417,8 +619,21 @@ export function planCompile(spec: CompileSpec, specDir: string): CompilePlan { const abs = resolvePath(operation.path, specDir); if (!existsSync(abs)) throw new Error(`compile: ${operation.op} file not found: ${operation.path} (resolved: ${abs})`); + if (!statSync(abs).isFile()) + throw new Error(`compile: ${operation.op} path must be a regular file: ${operation.path}`); + if (operation.op === "template") validateTemplateFile(abs); media.push(abs); } + const preview = { + id: "compile-preview", + name: spec.name ?? "compiled-draft", + duration: 0, + fps: spec.fps ?? 30, + canvas_config: canvas ?? { width: 1920, height: 1080, ratio: "original" }, + tracks: [], + materials: { videos: [], audios: [], texts: [] }, + } as unknown as Draft; + populateDraft(spec, specDir, preview, resolve(specDir, "__compile_preview__", "draft_content.json"), [], true); return { ok: true, name: spec.name ?? "compiled-draft", @@ -454,18 +669,75 @@ export function compileDraft(spec: CompileSpec, opts: CompileOptions): CompileRe templateDir: opts.templateDir, draftsDir: dirname(opts.outDir), seed: opts.seed, + deferRegistration: true, + cleanupOnError: true, canvas: resolveCanvas(spec) ?? undefined, }); const { filePath } = init; if (init.template.warning) warnings.push(init.template.warning); - const { draft } = loadDraft(filePath); - - // Canvas + fps from the spec. - const canvas = resolveCanvas(spec); - if (canvas) draft.canvas_config = canvas; - if (spec.fps) draft.fps = spec.fps; - draft.name = displayName; + const owned = lstatSync(init.draftPath); + try { + const { draft } = loadDraft(filePath); + const canvas = resolveCanvas(spec); + if (canvas) draft.canvas_config = canvas; + if (spec.fps) draft.fps = spec.fps; + draft.name = displayName; + const { segments, maxEnd, refs } = populateDraft(spec, opts.specDir, draft, filePath, warnings); + draft.duration = maxEnd; + saveDraft(filePath, draft); + // Read and merge the current store only after the draft has been built. + // A failed build never inserts an entry or restores an old index snapshot. + try { + if ( + !registerDraftInIndex({ + draftsDir: dirname(opts.outDir), + draftPath: init.draftPath, + filePath, + draftId: draft.id, + name: displayName, + nowMs: Date.now(), + durationUs: maxEnd, + }) + ) + warnings.push( + "compile: draft built, but the store index could not be read; run register --apply to register it", + ); + } catch (error) { + warnings.push(`compile: draft built, but registration failed: ${(error as Error).message}`); + } + return { + ok: true, + name: displayName, + draft_path: opts.outDir, + file_path: filePath, + tracks: spec.tracks.length, + segments, + duration_us: maxEnd, + warnings, + refs: Object.fromEntries(refs), + template: init.template, + }; + } catch (error) { + // Only remove the directory this call created. A replacement at the same + // path belongs to another writer and must survive our failure. + if (existsSync(init.draftPath)) { + const current = lstatSync(init.draftPath); + if (current.isDirectory() && current.dev === owned.dev && current.ino === owned.ino) { + rmSync(init.draftPath, { recursive: true, force: true }); + } + } + throw error; + } +} +function populateDraft( + spec: CompileSpec, + specDir: string, + draft: Draft, + filePath: string, + warnings: string[], + preview = false, +): { segments: number; maxEnd: number; refs: Map } { let segments = 0; let maxEnd = 0; const refs = new Map(); @@ -473,11 +745,11 @@ export function compileDraft(spec: CompileSpec, opts: CompileOptions): CompileRe for (const track of spec.tracks) { for (const item of track.items) { const start = Math.round(item.start * US); - const sourcePath = track.type === "text" ? null : resolvePath(item.path as string, opts.specDir); + const sourcePath = track.type === "text" ? null : resolvePath(item.path as string, specDir); const media = sourcePath ? probeMedia(sourcePath) : null; const { duration, sourceDuration } = track.type === "text" - ? { duration: Math.round((item.duration as number) * US), sourceDuration: 0 } + ? { duration: targetTiming(item.start, item.duration as number).duration, sourceDuration: 0 } : itemTiming(item, media); if (track.type === "video") { const result = addVideo(draft, filePath, { @@ -489,6 +761,7 @@ export function compileDraft(spec: CompileSpec, opts: CompileOptions): CompileRe width: item.width ?? media?.width ?? undefined, height: item.height ?? media?.height ?? undefined, trackName: track.name, + ...(preview ? { placeholder: { path: sourcePath as string, name: basename(sourcePath as string) } } : {}), }); applyItemProperties(draft, result.segmentId, item); if (item.ref) refs.set(item.ref, result.segmentId); @@ -501,6 +774,7 @@ export function compileDraft(spec: CompileSpec, opts: CompileOptions): CompileRe sourceDuration, volume: item.volume, trackName: track.name, + ...(preview ? { placeholder: { path: sourcePath as string, name: basename(sourcePath as string) } } : {}), }); applyItemProperties(draft, result.segmentId, item); if (item.ref) refs.set(item.ref, result.segmentId); @@ -526,136 +800,131 @@ export function compileDraft(spec: CompileSpec, opts: CompileOptions): CompileRe } } - for (const operation of spec.operations ?? []) { + for (const [index, operation] of (spec.operations ?? []).entries()) { const resolveRef = (ref: string): string => { const id = refs.get(ref); if (!id) throw new Error(`compile: unresolved ref '${ref}'`); return id; }; - switch (operation.op) { - case "transition": - addTransition( - draft, - resolveRef(operation.target), - operation.slug, - operation.duration === undefined ? undefined : Math.round(operation.duration * US), - operation.jianying ? "jianying" : "capcut", - ); - break; - case "filter": { - const result = addFilter(draft, { - slug: operation.slug, - start: Math.round(operation.start * US), - duration: Math.round(operation.duration * US), - intensity: operation.intensity, - trackName: operation.trackName, - namespace: operation.jianying ? "jianying" : "capcut", - }); - maxEnd = Math.max(maxEnd, Math.round((operation.start + operation.duration) * US)); - segments++; - void result; - break; - } - case "effect": - addEffect(draft, { - slug: operation.slug, - start: Math.round(operation.start * US), - duration: Math.round(operation.duration * US), - params: operation.params, - trackName: operation.trackName, - namespace: operation.jianying ? "jianying" : "capcut", - }); - maxEnd = Math.max(maxEnd, Math.round((operation.start + operation.duration) * US)); - segments++; - break; - case "keyframe": - warnings.push( - ...addKeyframes(draft, resolveRef(operation.target), [ - { - property: operation.property, - timeUs: Math.round(operation.time * US), - value: operation.value, - easing: operation.easing, - }, - ]).warnings, - ); - break; - case "audio-fade": - setAudioFade(draft, resolveRef(operation.target), { - fadeInUs: operation.fadeIn === undefined ? undefined : Math.round(operation.fadeIn * US), - fadeOutUs: operation.fadeOut === undefined ? undefined : Math.round(operation.fadeOut * US), - }); - break; - case "text-style": - setTextStyle(draft, resolveRef(operation.target), operation.style); - break; - case "text-ranges": - setTextRanges(draft, resolveRef(operation.target), operation.ranges); - break; - case "template": { - const result = applyTemplate( - draft, - resolvePath(operation.path, opts.specDir), - Math.round(operation.start * US), - Math.round(operation.duration * US), - { text: operation.text }, - ); - if (operation.ref) refs.set(operation.ref, result.segmentId); - maxEnd = Math.max(maxEnd, Math.round((operation.start + operation.duration) * US)); - segments++; - break; - } - case "captions": { - const cues = parseSrt(stripBom(readFileSync(resolvePath(operation.path, opts.specDir), "utf-8"))); - const offset = Math.round((operation.timeOffset ?? 0) * US); - for (const cue of cues) { - const result = addText(draft, filePath, { - text: cue.text, - start: cue.startUs + offset, - duration: cue.endUs - cue.startUs, - trackName: operation.trackName ?? "captions", + try { + switch (operation.op) { + case "transition": + addTransition( + draft, + resolveRef(operation.target), + operation.slug, + operation.duration === undefined ? undefined : Math.round(operation.duration * US), + operation.jianying ? "jianying" : "capcut", + ); + break; + case "filter": { + const result = addFilter(draft, { + slug: operation.slug, + start: Math.round(operation.start * US), + duration: targetTiming(operation.start, operation.duration).duration, + intensity: operation.intensity, + trackName: operation.trackName, + namespace: operation.jianying ? "jianying" : "capcut", }); - const material = draft.materials.texts.find((item) => item.id === result.materialId) as unknown as Record< - string, - unknown - >; - material.sub_type = 1; - material.caption_template_info = { - category_id: "", - category_name: "", - effect_id: "", - is_new: false, - resource_id: "", - }; - if (operation.styleRef) { - const styleId = refs.get(operation.styleRef); - if (styleId) copyTextStyle(draft, styleId, result.materialId); - else - warnings.push(`compile captions styleRef '${operation.styleRef}' did not resolve; base style retained`); - } - maxEnd = Math.max(maxEnd, cue.endUs + offset); + maxEnd = Math.max(maxEnd, Math.round((operation.start + operation.duration) * US)); + segments++; + void result; + break; + } + case "effect": + addEffect(draft, { + slug: operation.slug, + start: Math.round(operation.start * US), + duration: targetTiming(operation.start, operation.duration).duration, + params: operation.params, + trackName: operation.trackName, + namespace: operation.jianying ? "jianying" : "capcut", + }); + maxEnd = Math.max(maxEnd, Math.round((operation.start + operation.duration) * US)); + segments++; + break; + case "keyframe": + warnings.push( + ...addKeyframes(draft, resolveRef(operation.target), [ + { + property: operation.property, + timeUs: Math.round(operation.time * US), + value: operation.value, + easing: operation.easing, + }, + ]).warnings, + ); + break; + case "audio-fade": + setAudioFade(draft, resolveRef(operation.target), { + fadeInUs: operation.fadeIn === undefined ? undefined : Math.round(operation.fadeIn * US), + fadeOutUs: operation.fadeOut === undefined ? undefined : Math.round(operation.fadeOut * US), + }); + break; + case "text-style": + setTextStyle(draft, resolveRef(operation.target), operation.style); + break; + case "text-ranges": + setTextRanges(draft, resolveRef(operation.target), operation.ranges); + break; + case "template": { + const result = applyTemplate( + draft, + resolvePath(operation.path, specDir), + Math.round(operation.start * US), + targetTiming(operation.start, operation.duration).duration, + { text: operation.text }, + ); + if (operation.ref) refs.set(operation.ref, result.segmentId); + maxEnd = Math.max(maxEnd, Math.round((operation.start + operation.duration) * US)); segments++; + break; + } + case "captions": { + const cues = parseSrt(stripBom(readFileSync(resolvePath(operation.path, specDir), "utf-8"))); + if (cues.length === 0) throw new Error("captions file contains no cues"); + const offset = Math.round((operation.timeOffset ?? 0) * US); + for (const cue of cues) { + if (!cue.text || cue.startUs + offset < 0 || !Number.isSafeInteger(cue.endUs + offset)) { + throw new Error( + `captions cue ${cue.index} must contain text and have non-negative, safe integer timing after timeOffset`, + ); + } + const result = addText(draft, filePath, { + text: cue.text, + start: cue.startUs + offset, + duration: cue.endUs - cue.startUs, + trackName: operation.trackName ?? "captions", + }); + const material = draft.materials.texts.find((item) => item.id === result.materialId) as unknown as Record< + string, + unknown + >; + material.sub_type = 1; + material.caption_template_info = { + category_id: "", + category_name: "", + effect_id: "", + is_new: false, + resource_id: "", + }; + if (operation.styleRef) { + const styleId = refs.get(operation.styleRef); + if (styleId) copyTextStyle(draft, styleId, result.materialId); + else + warnings.push(`compile captions styleRef '${operation.styleRef}' did not resolve; base style retained`); + } + maxEnd = Math.max(maxEnd, cue.endUs + offset); + segments++; + } + break; } - break; } + } catch (error) { + throw new Error(`compile: operations[${index}]: ${(error as Error).message}`); } } - - draft.duration = maxEnd; - saveDraft(filePath, draft); - - return { - ok: true, - name: displayName, - draft_path: opts.outDir, - file_path: filePath, - tracks: spec.tracks.length, - segments, - duration_us: maxEnd, - warnings, - refs: Object.fromEntries(refs), - template: init.template, - }; + return { segments, maxEnd, refs }; } function applyItemProperties(draft: Draft, segmentId: string, item: CompileItem): void { diff --git a/src/draft.ts b/src/draft.ts index 69181ec..2c91163 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -8,6 +8,7 @@ import { openSync, readdirSync, readFileSync, + realpathSync, renameSync, rmSync, unlinkSync, @@ -296,20 +297,56 @@ export function assertTargetsUnchangedOnDisk(targets: DraftCandidate[]): void { // file actually written, rolling back on a partial commit. Writes EXACTLY the // given targets — callers decide the write set (saveDraft: every readable // sibling; sync-timelines: only the drifted mirrors). No-ops under --dry-run. +export interface AdditionalDraftFile { + path: string; + /** Bytes observed while planning; null only when the file did not exist. */ + raw: string | null; + content: string; +} + export function commitDraftTargets( targets: DraftCandidate[], draft: Draft, - options: { backup?: boolean } = {}, + options: { backup?: boolean; additionalFiles?: AdditionalDraftFile[] } = {}, ): Map { const written = new Map(); if (dryRun) return written; // Prepare every replacement before renaming any target. This keeps the // multi-file write as close to a transaction as the filesystem allows. - const prepared = targets.map((target) => { - const content = serializeDraftCandidate(target, draft); - return { target, temp: writeTemp(target.path, content), content }; - }); + const replacements = [ + ...targets.map((target) => ({ + path: target.path, + raw: target.raw, + content: serializeDraftCandidate(target, draft), + })), + ...(options.additionalFiles ?? []), + ]; + const paths = new Set(); + for (const item of replacements) { + let path = existsSync(item.path) + ? realpathSync.native(item.path) + : resolve(realpathSync.native(dirname(item.path)), basename(item.path)); + if (process.platform === "win32") path = path.toLowerCase(); + if (paths.has(path)) throw new Error(`Duplicate draft transaction target: ${path}`); + paths.add(path); + } + for (const item of options.additionalFiles ?? []) { + const current = existsSync(item.path) ? readFileSync(item.path, "utf-8") : null; + if (current !== item.raw) { + throw new Error(`refused [draft-changed-on-disk]: Draft sidecar changed after it was planned: ${item.path}`); + } + } + const prepared: Array<{ target: AdditionalDraftFile; temp: string; content: string }> = []; + try { + for (const target of replacements) { + if (options.additionalFiles?.includes(target) && target.content === target.raw) continue; + prepared.push({ target, temp: writeTemp(target.path, target.content), content: target.content }); + } + } catch (error) { + for (const item of prepared) if (existsSync(item.temp)) unlinkSync(item.temp); + throw error; + } const committed: typeof prepared = []; try { @@ -332,6 +369,7 @@ export function commitDraftTargets( // Roll back targets already renamed during a partial commit. for (const item of committed.reverse()) { if (item.target.raw !== null) writeAtomic(item.target.path, item.target.raw); + else if (existsSync(item.target.path)) unlinkSync(item.target.path); } for (const item of prepared) { if (existsSync(item.temp)) unlinkSync(item.temp); @@ -346,7 +384,7 @@ export function commitDraftTargets( export function saveDraft( filePath: string, draft: Draft, - options: { backup?: boolean; skipVersionGuard?: boolean } = {}, + options: { backup?: boolean; skipVersionGuard?: boolean; additionalFiles?: AdditionalDraftFile[] } = {}, ): void { if (dryRun) { // Version guard, warning only: dry-run writes nothing, so it never blocks, @@ -435,7 +473,9 @@ export function saveDraft( // the project again: a re-discovery re-read, re-parsed and re-hashed every // sibling — the single most expensive step of a write on a large draft — to // establish what the write already knew. - loadContexts.set(resolved, { store: storeAfterWrite(store, draft, written) }); + const timelinePaths = new Set(store.targets.map((target) => target.path)); + const timelineWrites = new Map([...written].filter(([path]) => timelinePaths.has(path))); + loadContexts.set(resolved, { store: storeAfterWrite(store, draft, timelineWrites) }); } function writeAndSync(path: string, content: string): void { diff --git a/src/factory.ts b/src/factory.ts index f5e5247..9cd47c9 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -1,12 +1,18 @@ import { createHash, randomUUID } from "node:crypto"; import { + closeSync, + constants, copyFileSync, cpSync, existsSync, + lstatSync, mkdirSync, + openSync, readdirSync, readFileSync, + readSync, renameSync, + rmSync, statSync, writeFileSync, } from "node:fs"; @@ -61,15 +67,61 @@ export function uuid(): string { // --- Asset copy (collision-safe) --- function fileSha1(path: string): string { - return createHash("sha1").update(readFileSync(path)).digest("hex"); + const hash = createHash("sha1"); + const fd = openSync(path, "r"); + const buffer = Buffer.allocUnsafe(64 * 1024); + try { + for (;;) { + const count = readSync(fd, buffer, 0, buffer.length, null); + if (count === 0) break; + hash.update(buffer.subarray(0, count)); + } + return hash.digest("hex"); + } finally { + closeSync(fd); + } } /** True if both paths exist and have byte-identical content. */ function sameContent(a: string, b: string): boolean { - if (statSync(a).size !== statSync(b).size) return false; + const source = statSync(a); + const destination = statSync(b); + if (!destination.isFile() || source.size !== destination.size) return false; return fileSha1(a) === fileSha1(b); } +export interface AssetCopyPlan { + destination: string; + copyNeeded: boolean; + collision: boolean; +} + +/** Choose a content-correct destination without creating directories or files. */ +export function planAssetCopy(srcPath: string, assetsDir: string, fallbackName: string): AssetCopyPlan { + if (!statSync(srcPath).isFile()) throw new Error(`Media source must be a regular file: ${srcPath}`); + const filename = basename(srcPath) || fallbackName; + const destination = resolve(assetsDir, filename); + if (!existsSync(destination)) return { destination, copyNeeded: true, collision: false }; + if (sameContent(srcPath, destination)) return { destination, copyNeeded: false, collision: false }; + + const dot = filename.lastIndexOf("."); + const stem = dot > 0 ? filename.slice(0, dot) : filename; + const ext = dot > 0 ? filename.slice(dot) : ""; + const hash = fileSha1(srcPath); + // A hash-suffixed filename can itself already hold unrelated content. Check + // each candidate rather than treating the suffix as proof of its bytes. + for (let length = 8; length <= hash.length; length += 4) { + const candidate = resolve(assetsDir, `${stem}.${hash.slice(0, length)}${ext}`); + if (!existsSync(candidate)) return { destination: candidate, copyNeeded: true, collision: true }; + if (sameContent(srcPath, candidate)) return { destination: candidate, copyNeeded: false, collision: true }; + } + for (let suffix = 1; ; suffix++) { + const candidate = resolve(assetsDir, `${stem}.${hash}.${suffix}${ext}`); + if (!existsSync(candidate)) return { destination: candidate, copyNeeded: true, collision: true }; + if (sameContent(srcPath, candidate)) return { destination: candidate, copyNeeded: false, collision: true }; + } +} + /** * Copy a source file into an assets directory, keyed by its basename. * @@ -83,31 +135,18 @@ function sameContent(a: string, b: string): boolean { * Returns the destination path the draft should reference. */ export function copyAssetDeduped(srcPath: string, assetsDir: string, fallbackName: string): string { + const plan = planAssetCopy(srcPath, assetsDir, fallbackName); + if (!plan.copyNeeded) return plan.destination; mkdirSync(assetsDir, { recursive: true }); const filename = basename(srcPath) || fallbackName; - const destPath = resolve(assetsDir, filename); - - if (!existsSync(destPath)) { - copyFileSync(srcPath, destPath); - return destPath; - } - // Destination exists — same source (intentional no-op) or basename collision? - if (sameContent(srcPath, destPath)) return destPath; - - // Different source resolving to the same basename: de-collide by content hash. - const dot = filename.lastIndexOf("."); - const stem = dot > 0 ? filename.slice(0, dot) : filename; - const ext = dot > 0 ? filename.slice(dot) : ""; - const dedupName = `${stem}.${fileSha1(srcPath).slice(0, 8)}${ext}`; - const dedupPath = resolve(assetsDir, dedupName); - if (!existsSync(dedupPath)) { - copyFileSync(srcPath, dedupPath); + copyFileSync(srcPath, plan.destination, constants.COPYFILE_EXCL); + if (plan.collision) { console.warn( `Warning: "assets/${basename(assetsDir)}/${filename}" already exists from a different source file; ` + - `copied "${srcPath}" to "${dedupName}" instead. The draft references the correct content.`, + `copied "${srcPath}" to "${basename(plan.destination)}" instead. The draft references the correct content.`, ); } - return dedupPath; + return plan.destination; } // --- Init (create new empty draft) --- @@ -128,6 +167,10 @@ export interface InitOptions { * as-is (`--template bundled`, or an explicit --template directory). */ seed?: "auto" | "always" | "off"; + /** Initialize the local sidecar now, but register in the root index after a successful build. */ + deferRegistration?: boolean; + /** Remove this newly created directory if initialization fails. */ + cleanupOnError?: boolean; } export interface CanvasConfig { @@ -548,107 +591,123 @@ export function initDraft(opts: InitOptions): { const seed = seedMode === "off" ? null : scan.seed; const useSeed = seed !== null && (seedMode === "always" || storeOutgrowsTemplate(templateVersion, seed.appVersion)); - let filePath: string; - let template: TemplateReport; - if (seed && useSeed) { - // Skeleton from the store's project: its schema markers and settings, no - // content. Canvas and fps stay the template's defaults (or the caller's - // override) so a portrait donor cannot flip every new draft to portrait. - const fps = typeof templateDoc.draft.fps === "number" && templateDoc.draft.fps > 0 ? templateDoc.draft.fps : 30; - const canvas: CanvasConfig = opts.canvas ?? - (templateDoc.draft.canvas_config as CanvasConfig | undefined) ?? { width: 1920, height: 1080, ratio: "16:9" }; - const { draft, reset } = seedDraftSkeleton(seed.draft, { - name: opts.name, - id: draftId, - canvas, - fps, - nowMs, - materialKeys: Object.keys(templateDoc.draft.materials ?? {}), - }); - mkdirSync(draftPath, { recursive: true }); - // The same file set the bundled template ships (both root mirrors), plus - // the template-2.tmp mirror when the seed project keeps a readable one — - // the >= 8.7 document the store's write path maintains from then on. - // draft_content.json is the registered identity file: the one `register` - // reads first, so the sidecar it verifies against agrees from the start. - const files = ["draft_content.json", "draft_info.json"]; - if (parseCandidate(resolve(seed.projectDir, "template-2.tmp")).parseable) files.push("template-2.tmp"); - 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, - store: scan.store, - }; - } else { - const skipped = copyTemplateDir(opts.templateDir, draftPath); - 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 - // the template ships — the bundled template carries draft_info.json and - // draft_content.json as mirrors, and a mirror left with id "" / name "" is - // exactly the drift sync-timelines exists to repair (and what made - // `register` refuse the CLI's own drafts, #111). An enveloped or binary - // template-2.tmp is left as copied; the first timeline write reconciles it. - const stamped: string[] = []; - for (const file of TEMPLATE_TIMELINE_FILES) { - const fp = resolve(draftPath, file); - if (!existsSync(fp)) continue; - let parsed: Record; - try { - parsed = JSON.parse(stripBom(readFileSync(fp, "utf-8"))) as Record; - } catch { - continue; + // Exclusive creation establishes ownership: a concurrent creator is never + // mistaken for a directory this invocation may clean up. + mkdirSync(opts.draftsDir, { recursive: true }); + mkdirSync(draftPath); + const owned = lstatSync(draftPath); + try { + let filePath: string; + let template: TemplateReport; + if (seed && useSeed) { + // Skeleton from the store's project: its schema markers and settings, no + // content. Canvas and fps stay the template's defaults (or the caller's + // override) so a portrait donor cannot flip every new draft to portrait. + const fps = typeof templateDoc.draft.fps === "number" && templateDoc.draft.fps > 0 ? templateDoc.draft.fps : 30; + const canvas: CanvasConfig = opts.canvas ?? + (templateDoc.draft.canvas_config as CanvasConfig | undefined) ?? { width: 1920, height: 1080, ratio: "16:9" }; + const { draft, reset } = seedDraftSkeleton(seed.draft, { + name: opts.name, + id: draftId, + canvas, + fps, + nowMs, + materialKeys: Object.keys(templateDoc.draft.materials ?? {}), + }); + mkdirSync(draftPath, { recursive: true }); + // The same file set the bundled template ships (both root mirrors), plus + // the template-2.tmp mirror when the seed project keeps a readable one — + // the >= 8.7 document the store's write path maintains from then on. + // draft_content.json is the registered identity file: the one `register` + // reads first, so the sidecar it verifies against agrees from the start. + const files = ["draft_content.json", "draft_info.json"]; + if (parseCandidate(resolve(seed.projectDir, "template-2.tmp")).parseable) files.push("template-2.tmp"); + 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, + store: scan.store, + }; + } else { + const skipped = copyTemplateDir(opts.templateDir, draftPath); + 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 + // the template ships — the bundled template carries draft_info.json and + // draft_content.json as mirrors, and a mirror left with id "" / name "" is + // exactly the drift sync-timelines exists to repair (and what made + // `register` refuse the CLI's own drafts, #111). An enveloped or binary + // template-2.tmp is left as copied; the first timeline write reconciles it. + const stamped: string[] = []; + for (const file of TEMPLATE_TIMELINE_FILES) { + const fp = resolve(draftPath, file); + if (!existsSync(fp)) continue; + let parsed: Record; + try { + parsed = JSON.parse(stripBom(readFileSync(fp, "utf-8"))) as Record; + } catch { + continue; + } + if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.tracks)) continue; + if (opts.canvas) parsed.canvas_config = { ...opts.canvas }; + parsed.name = opts.name; + parsed.id = draftId; + writeFileSync(fp, JSON.stringify(parsed, null, 0), "utf-8"); + stamped.push(file); } - if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.tracks)) continue; - if (opts.canvas) parsed.canvas_config = { ...opts.canvas }; - parsed.name = opts.name; - parsed.id = draftId; - writeFileSync(fp, JSON.stringify(parsed, null, 0), "utf-8"); - stamped.push(file); + // Registered identity file: draft_content.json when the template ships it + // (what `register` reads first, so its verification agrees with the sidecar + // 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: [], + store: scan.store, + ...(versionWarning ? { warning: versionWarning } : {}), + }; } - // Registered identity file: draft_content.json when the template ships it - // (what `register` reads first, so its verification agrees with the sidecar - // 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: [], - store: scan.store, - ...(versionWarning ? { warning: versionWarning } : {}), - }; - } - // CapCut's GUI does not scan the Projects folder — it lists drafts from a - // central index, root_meta_info.json, at the root of com.lveditor.draft/. - // Without an entry there a freshly created folder stays invisible. Register - // it (and write the per-folder draft_meta_info.json sidecar) so the new draft - // shows up. Best-effort: a failure here must not fail draft creation. - let registered = false; - try { - registered = registerDraftInIndex({ - draftsDir: opts.draftsDir, - draftPath, - filePath, - draftId, - name: opts.name, - nowMs, - }); - } catch { - registered = false; + // CapCut's GUI does not scan the Projects folder — it lists drafts from a + // central index, root_meta_info.json, at the root of com.lveditor.draft/. + // Without an entry there a freshly created folder stays invisible. Register + // it (and write the per-folder draft_meta_info.json sidecar) so the new draft + // shows up. Best-effort: a failure here must not fail draft creation. + let registered = false; + try { + registered = registerDraftInIndex({ + draftsDir: opts.draftsDir, + draftPath, + filePath, + draftId, + name: opts.name, + nowMs, + index: !opts.deferRegistration, + }); + } catch { + registered = false; + } + return { draftPath, filePath, registered, canvas: opts.canvas ? { ...opts.canvas } : null, template }; + } catch (error) { + if (opts.cleanupOnError && existsSync(draftPath)) { + const current = lstatSync(draftPath); + if (current.isDirectory() && current.dev === owned.dev && current.ino === owned.ino) { + rmSync(draftPath, { recursive: true, force: true }); + } + } + throw error; } - return { draftPath, filePath, registered, canvas: opts.canvas ? { ...opts.canvas } : null, template }; } interface RegisterOptions { @@ -659,6 +718,7 @@ interface RegisterOptions { name: string; nowMs: number; durationUs?: number; // draft duration in microseconds (tm_duration); init drafts start at 0 + index?: boolean; // false initializes only the per-folder sidecar } /** @@ -724,13 +784,32 @@ export function registerDraftInIndex(opts: RegisterOptions): boolean { const metaPath = resolve(opts.draftPath, "draft_meta_info.json"); if (!existsSync(metaPath)) { writeFileSync(metaPath, JSON.stringify(buildDraftEntry(opts), null, 0), "utf-8"); + } else if (opts.durationUs !== undefined) { + // A deferred compile has accumulated imported media in this sidecar. + // Stamp its final identity/timing without discarding those registrations. + const meta = JSON.parse(stripBom(readFileSync(metaPath, "utf-8"))) as unknown; + if (!meta || typeof meta !== "object" || Array.isArray(meta)) return false; + const fields = meta as Record; + writeAtomic( + metaPath, + JSON.stringify( + { + ...fields, + ...IDENTIFYING_FIELDS(opts), + tm_draft_create: fields.tm_draft_create ?? opts.nowMs * 1000, + }, + null, + 0, + ), + ); } + if (opts.index === false) return false; const indexPath = resolve(opts.draftsDir, "root_meta_info.json"); if (!existsSync(indexPath)) { // No index yet (fresh CapCut / custom --drafts dir): create a minimal one. - writeFileSync(indexPath, JSON.stringify({ all_draft_store: [buildDraftEntry(opts)] }, null, 0), "utf-8"); + writeAtomic(indexPath, JSON.stringify({ all_draft_store: [buildDraftEntry(opts)] }, null, 0)); return true; } @@ -757,7 +836,7 @@ export function registerDraftInIndex(opts: RegisterOptions): boolean { // Back up the user's index (it lists ALL their projects) before overwriting. writeFileSync(`${indexPath}.bak`, raw, "utf-8"); - writeFileSync(indexPath, JSON.stringify(index, null, 0), "utf-8"); + writeAtomic(indexPath, JSON.stringify(index, null, 0)); return true; } diff --git a/src/index.ts b/src/index.ts index 5b31da8..29f9112 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1060,6 +1060,7 @@ interface Flags { version?: boolean; // relink / projects / timeline / restore dir?: string; + recursive?: boolean; step?: number; list?: boolean; cols?: number; @@ -1459,15 +1460,17 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } { } else if (a === "--fail-fast") { flags.failFast = true; } else if (a === "--workers" && i + 1 < args.length) { - flags.workers = parseInt(args[++i], 10); + flags.workers = Number(args[++i]); } else if (a === "--retries" && i + 1 < args.length) { - flags.retries = parseInt(args[++i], 10); + flags.retries = Number(args[++i]); } else if (a === "--timeout" && i + 1 < args.length) { - flags.timeoutMs = parseInt(args[++i], 10); + flags.timeoutMs = Number(args[++i]); } else if (a === "--backoff-ms" && i + 1 < args.length) { - flags.backoffMs = parseInt(args[++i], 10); + flags.backoffMs = Number(args[++i]); } else if (a === "--max-buffer-mb" && i + 1 < args.length) { - flags.maxBufferMb = parseFloat(args[++i]); + flags.maxBufferMb = Number(args[++i]); + } else if (a === "--recursive") { + flags.recursive = true; } else if (a === "--dir" && i + 1 < args.length) { flags.dir = args[++i]; } else if (a === "--step" && i + 1 < args.length) { @@ -2308,7 +2311,7 @@ async function cmdImportTimeline(positional: string[], flags: Flags): Promise { retries: flags.retries, timeoutMs: flags.timeoutMs, backoffMs: flags.backoffMs, - maxBufferBytes: flags.maxBufferMb ? Math.round(flags.maxBufferMb * 1024 * 1024) : undefined, + maxBufferBytes: flags.maxBufferMb === undefined ? undefined : flags.maxBufferMb * 1024 * 1024, }); // Write a final summary line at end (JSON only, stderr to avoid mixing with per-job results) process.stderr.write(`${JSON.stringify({ summary: result })}\n`); @@ -4910,80 +4913,29 @@ async function cmdPrune(draft: Draft, filePath: string, flags: Flags): Promise for each material whose path is missing, look for a file // with the same basename in and repoint to it. // --from

--to prefix-replace on every material path. +async function saveChangedMedia(draft: Draft, filePath: string, ids: string[]): Promise { + const { planChangedMediaRegistration } = await import("./materials-register.js"); + const sidecar = planChangedMediaRegistration(draft, draftProjectDir(filePath), ids); + saveDraft(filePath, draft, { additionalFiles: sidecar ? [sidecar] : [] }); +} + async function cmdRelink(draft: Draft, filePath: string, flags: Flags): Promise { - if (!flags.dir && !(flags.from && flags.to)) { - die("Usage: capcut relink (--dir | --from --to ) [--stage]"); - } - const { copyAssetDeduped } = await import("./factory.js"); - const dirIndex = new Map(); - if (flags.dir) { - if (!existsSync(flags.dir)) die(`--dir not found: ${flags.dir}`); - for (const f of readdirSync(flags.dir)) dirIndex.set(path.basename(f), path.join(flags.dir as string, f)); - } - const draftDir = draftProjectDir(filePath); - const relinked: Array<{ id: string; from: string; to: string; staged: boolean }> = []; - let missing = 0; - let ok = 0; - let staged = 0; - for (const [kind, arr] of Object.entries(draft.materials)) { - if (!Array.isArray(arr)) continue; - for (const m of arr) { - const mat = m as { id?: string; path?: unknown; material_name?: unknown; name?: unknown }; - if (typeof mat.path !== "string" || mat.path === "") continue; - let p = mat.path; - let changed = false; - if (flags.from && flags.to && p.startsWith(flags.from)) { - p = flags.to + p.slice(flags.from.length); - changed = true; - } - if (!existsSync(p) && flags.dir) { - const hit = dirIndex.get(path.basename(p)); - if (hit) { - p = hit; - changed = true; - } - } - // --stage: copy the file this run just relinked into assets// and - // point the material at the copy — the repaired draft leaves portable - // (pyJianYingDraft#177: a draft whose media lives outside the project - // folder black-screens when the folder moves machines). Same - // copyAssetDeduped path add-video/add-audio use, so re-runs are no-ops. - // A file copy is a side effect no draft write rolls back, so --dry-run - // skips the copy and the plan keeps the resolved external path. - let didStage = false; - if ( - changed && - flags.stage && - !isDryRun() && - (kind === "videos" || kind === "audios") && - existsSync(p) && - !path.resolve(p).startsWith(draftDir + path.sep) - ) { - const assetKind = kind === "audios" ? "audio" : "video"; - const destPath = copyAssetDeduped( - p, - path.resolve(draftDir, "assets", assetKind), - assetKind === "audio" ? "audio.mp3" : "media", - ); - p = destPath; - didStage = true; - staged++; - // Keep the display-name fields tracking the staged file, the - // replace-media convention — only visible when de-collision renamed. - const filename = path.basename(destPath); - if ("material_name" in mat) mat.material_name = filename; - if ("name" in mat) mat.name = filename; - } - if (changed && p !== mat.path) { - relinked.push({ id: mat.id ?? "", from: mat.path, to: p, staged: didStage }); - mat.path = p; - } - if (existsSync(p)) ok++; - else missing++; - } - } - if (relinked.length > 0) saveDraft(filePath, draft); - out({ ok: true, relinked: relinked.length, staged, still_missing: missing, present: ok, changes: relinked }, flags); + const { relinkMedia } = await import("./relink.js"); + const result = relinkMedia(draft, filePath, { + dir: flags.dir, + from: flags.from, + to: flags.to, + recursive: flags.recursive, + stage: flags.stage, + dryRun: isDryRun(), + }); + if (result.relinked > 0) + await saveChangedMedia( + draft, + filePath, + result.changes.map((change) => change.id), + ); + out(result, flags); } // `replace-media` swaps a segment's source file in place (placeholder > final), @@ -4997,7 +4949,7 @@ async function cmdReplaceMedia(draft: Draft, filePath: string, positional: strin retime: flags.retime, dryRun: isDryRun(), }); - saveDraft(filePath, draft); // no-ops under --dry-run + await saveChangedMedia(draft, filePath, [result.material_id]); // no-ops under --dry-run out(result, flags); if (!flags.quiet && result.warning) process.stderr.write(`Warning: ${result.warning}\n`); } diff --git a/src/interchange.ts b/src/interchange.ts index d745c43..f90da09 100644 --- a/src/interchange.ts +++ b/src/interchange.ts @@ -1,4 +1,5 @@ -import { basename } from "node:path"; +import { basename, isAbsolute, resolve, win32 } from "node:path"; +import { fileURLToPath } from "node:url"; import type { Draft, Segment } from "./draft.js"; import { extractText } from "./draft.js"; import { framesFor } from "./time.js"; @@ -382,7 +383,22 @@ function clipPlan(node: OtioObject, rate: number, trackLabel: string, skipped: O }; } -export function otioToImportPlan(doc: unknown): ImportPlan { +/** Resolve local references without fetching remote URLs or changing foreign Windows paths. */ +export function resolveOtioMediaPath(reference: string, mediaDir?: string): string { + if (/^file:/i.test(reference)) { + try { + return fileURLToPath(reference); + } catch { + return reference; + } + } + if (isAbsolute(reference) || win32.isAbsolute(reference) || /^[a-z][a-z0-9+.-]*:/i.test(reference)) { + return reference; + } + return mediaDir === undefined ? reference : resolve(mediaDir, reference); +} + +export function otioToImportPlan(doc: unknown, options: { mediaDir?: string } = {}): ImportPlan { if (schemaOf(doc) !== "Timeline.1") { throw new Error( `import-timeline: not an OpenTimelineIO Timeline.1 document (OTIO_SCHEMA: ${schemaOf(doc) || "missing"})`, @@ -564,6 +580,7 @@ export function otioToImportPlan(doc: unknown): ImportPlan { if (itemSchema === "Clip.1") { const clip = clipPlan(item as OtioObject, rate, label, skipped); if (!clip) continue; + if (clip.mediaPath !== null) clip.mediaPath = resolveOtioMediaPath(clip.mediaPath, options.mediaDir); clip.targetStartUs = cursorUs; cursorUs += clip.targetDurationUs; clips.push(clip); diff --git a/src/lib.ts b/src/lib.ts index 8a40033..a40b19a 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -18,6 +18,7 @@ export { commandNames, GLOBAL_OPTION_SPECS } from "./command-specs.js"; export type { CheckStatus, DoctorCheck, DoctorReport } from "./doctor.js"; export { runDoctor } from "./doctor.js"; export type { + AdditionalDraftFile, Draft, MaterialAudio, MaterialText, diff --git a/src/materials-register.ts b/src/materials-register.ts index 3a95ac6..5d9b8bc 100644 --- a/src/materials-register.ts +++ b/src/materials-register.ts @@ -200,10 +200,25 @@ export function registeredEntries(groups: unknown): Array<{ path: string; id: st // entry are preserved as they are. function appendToImportedGroup(sidecar: Record, entries: MetaMaterialEntry[]): void { const groupsRaw = sidecar.draft_materials; - const groups: Array> = Array.isArray(groupsRaw) - ? (groupsRaw.filter((g) => g && typeof g === "object" && !Array.isArray(g)) as Array>) - : []; - let group0 = groups.find((g) => g.type === IMPORTED_MEDIA_GROUP_TYPE); + // Some older sidecars use a dictionary of entry arrays. Keep that shape and + // every existing value when adding the imported-media group. + if (groupsRaw && typeof groupsRaw === "object" && !Array.isArray(groupsRaw)) { + const groups = groupsRaw as Record; + let key = String(IMPORTED_MEDIA_GROUP_TYPE); + while (groups[key] !== undefined && !Array.isArray(groups[key])) key = `${key}_imported`; + if (!Array.isArray(groups[key])) groups[key] = []; + (groups[key] as unknown[]).push(...entries); + return; + } + const groups: unknown[] = Array.isArray(groupsRaw) ? groupsRaw : []; + let group0 = groups.find( + (g): g is Record => + !!g && + typeof g === "object" && + !Array.isArray(g) && + (g as Record).type === IMPORTED_MEDIA_GROUP_TYPE && + ((g as Record).value === undefined || Array.isArray((g as Record).value)), + ); if (!group0) { group0 = { type: IMPORTED_MEDIA_GROUP_TYPE, value: [] }; groups.unshift(group0); @@ -243,6 +258,84 @@ export function registerMediumInSidecar( return { entryId: entry.id, created: true }; } +export interface ChangedMediaRegistration { + path: string; + /** Sidecar bytes used to plan this edit, for the caller's conflict check and backup. */ + raw: string; + content: string; +} + +/** + * Refresh registration for changed media without writing files. The caller + * commits the returned sidecar together with the timeline after its write + * guards pass. Only the requested materials are linked; every existing entry + * is preserved, including the old source's entry. + * A reused entry returns a byte-identical plan so the caller can check its + * sidecar snapshot without rewriting the file or creating a backup. + * + * Missing or unreadable sidecars, or no selected local media, return null. + * Unavailable sidecars leave links unchanged. A bare timeline remains editable; + * `register --materials --apply` repairs its sidecar. + */ +export function planChangedMediaRegistration( + draft: Draft, + projectDir: string, + materialIds: Iterable, +): ChangedMediaRegistration | null { + const changed = new Set(materialIds); + if (changed.size === 0) return null; + const sidecarPath = resolve(projectDir, "draft_meta_info.json"); + let raw: string; + let sidecar: Record; + try { + raw = readFileSync(sidecarPath, "utf-8"); + const parsed: unknown = JSON.parse(stripBom(raw)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + sidecar = parsed as Record; + } catch { + return null; + } + + const byPath = new Map(); + for (const entry of registeredEntries(sidecar.draft_materials)) { + if (entry.id !== "" && !byPath.has(normalise(entry.path, projectDir))) + byPath.set(normalise(entry.path, projectDir), entry.id); + } + let appended = false; + let selected = false; + for (const kind of ["videos", "audios"] as const) { + for (const mat of draft.materials[kind] ?? []) { + const material = mat as Record; + if (typeof material.id !== "string" || !changed.has(material.id) || !isLocalPath(material.path)) continue; + selected = true; + const path = normalise(material.path, projectDir); + let entryId = byPath.get(path); + if (!entryId) { + const photo = kind === "videos" && material.type === "photo"; + const entry = buildMetaMaterialEntry({ + path: material.path, + name: + typeof material.material_name === "string" && material.material_name !== "" + ? material.material_name + : typeof material.name === "string" && material.name !== "" + ? material.name + : basename(material.path), + kind: kind === "audios" ? "music" : photo ? "photo" : "video", + durationUs: photo ? PHOTO_META_DURATION_US : typeof material.duration === "number" ? material.duration : 0, + width: typeof material.width === "number" ? material.width : 0, + height: typeof material.height === "number" ? material.height : 0, + }); + appendToImportedGroup(sidecar, [entry]); + entryId = entry.id; + byPath.set(path, entryId); + appended = true; + } + material.local_material_id = entryId; + } + } + return selected ? { path: sidecarPath, raw, content: appended ? JSON.stringify(sidecar) : raw } : null; +} + export interface UnlinkedMaterial { materialId: string; kind: "videos" | "audios"; diff --git a/src/relink.ts b/src/relink.ts new file mode 100644 index 0000000..439712b --- /dev/null +++ b/src/relink.ts @@ -0,0 +1,120 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { basename, isAbsolute, relative, resolve, sep, win32 } from "node:path"; +import type { Draft } from "./draft.js"; +import { copyAssetDeduped } from "./factory.js"; +import { draftProjectDir } from "./store.js"; + +export interface RelinkOptions { + dir?: string; + from?: string; + to?: string; + recursive?: boolean; + stage?: boolean; + dryRun?: boolean; +} + +function localFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function portableBasename(path: string): string { + return path.includes("\\") ? win32.basename(path) : basename(path); +} + +function remapPrefix(path: string, from: string, to: string): string { + const prefix = from.replace(/[/\\]+$/, ""); + if (path === prefix) return to; + if (path.startsWith(prefix) && /[/\\]/.test(path.charAt(prefix.length))) { + const separator = to.includes("\\") && !to.includes("/") ? "\\" : "/"; + const suffix = path.slice(prefix.length + 1).replace(/[/\\]/g, separator); + return to.replace(/[/\\]+$/, "") + separator + suffix; + } + return path; +} + +/** Plan file recovery by basename; multiple candidates remain unresolved. */ +export function relinkMedia(draft: Draft, filePath: string, opts: RelinkOptions) { + if (!opts.dir && !(opts.from && opts.to)) { + throw new Error("relink requires --dir or both --from and --to"); + } + if ((opts.from === undefined) !== (opts.to === undefined)) { + throw new Error("relink requires both --from and --to"); + } + const index = new Map(); + if (opts.dir) { + const root = resolve(opts.dir); + if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`--dir is not a directory: ${opts.dir}`); + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const path = resolve(dir, entry.name); + if (entry.isDirectory() && opts.recursive) walk(path); + // Do not follow directory symlinks: recursion must not loop or leave the search tree. + else if (entry.isFile() || (entry.isSymbolicLink() && localFile(path))) { + const matches = index.get(entry.name) ?? []; + matches.push(path); + index.set(entry.name, matches); + } + } + }; + walk(root); + } + const projectDir = draftProjectDir(filePath); + const localPath = (path: string) => (isAbsolute(path) || win32.isAbsolute(path) ? path : resolve(projectDir, path)); + const changes: Array<{ id: string; from: string; to: string; staged: boolean }> = []; + const ambiguous: Array<{ id: string; path: string; candidates: string[] }> = []; + let missing = 0; + let present = 0; + let staged = 0; + for (const [kind, values] of Object.entries(draft.materials)) { + if (!Array.isArray(values)) continue; + for (const material of values) { + const mat = material as Record; + if (typeof mat.path !== "string" || !mat.path || /^[a-z][a-z0-9+.-]*:\/\//i.test(mat.path)) continue; + const original = mat.path; + let path = opts.from && opts.to ? remapPrefix(original, opts.from, opts.to) : original; + if (!localFile(localPath(path)) && opts.dir) { + const matches = index.get(portableBasename(path)) ?? []; + if (matches.length === 1) path = matches[0]; + else if (matches.length > 1) { + ambiguous.push({ id: typeof mat.id === "string" ? mat.id : "", path: original, candidates: matches }); + missing++; + continue; + } + } + const changed = path !== original; + let didStage = false; + const diskPath = localPath(path); + const inside = relative(projectDir, diskPath); + if ( + changed && + opts.stage && + !opts.dryRun && + (kind === "videos" || kind === "audios") && + localFile(diskPath) && + (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) + ) { + const assetKind = kind === "audios" ? "audio" : "video"; + path = copyAssetDeduped( + diskPath, + resolve(projectDir, "assets", assetKind), + assetKind === "audio" ? "audio.mp3" : "media", + ); + didStage = true; + staged++; + if ("material_name" in mat) mat.material_name = basename(path); + if ("name" in mat) mat.name = basename(path); + } + if (changed && path !== original) { + changes.push({ id: typeof mat.id === "string" ? mat.id : "", from: original, to: path, staged: didStage }); + mat.path = path; + } + if (localFile(localPath(path))) present++; + else missing++; + } + } + return { ok: true, relinked: changes.length, staged, still_missing: missing, present, changes, ambiguous }; +} diff --git a/src/replace.ts b/src/replace.ts index 7067e15..c225f0b 100644 --- a/src/replace.ts +++ b/src/replace.ts @@ -6,9 +6,10 @@ // points a chosen segment at a different file and refreshes its intrinsic // metadata (duration, dimensions). Pure JSON + file copy, like `add-video`. -import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { basename, resolve } from "node:path"; import { type Draft, findMaterialGlobal, findSegment } from "./draft.js"; +import { copyAssetDeduped, planAssetCopy } from "./factory.js"; import { probeMedia } from "./probe.js"; import { draftProjectDir } from "./store.js"; @@ -39,10 +40,6 @@ export interface ReplaceMediaResult { warning?: string; } -function assetKind(materialType: string): "audio" | "video" { - return materialType === "audios" ? "audio" : "video"; -} - function num(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -54,6 +51,7 @@ function num(value: unknown): number | null { */ export function replaceMedia(draft: Draft, filePath: string, opts: ReplaceMediaOptions): ReplaceMediaResult { if (!existsSync(opts.newPath)) throw new Error(`Replacement file not found: ${opts.newPath}`); + if (!statSync(opts.newPath).isFile()) throw new Error(`Replacement media must be a regular file: ${opts.newPath}`); const hit = findSegment(draft, opts.segmentId); if (!hit) throw new Error(`Segment not found: ${opts.segmentId}`); @@ -63,23 +61,23 @@ export function replaceMedia(draft: Draft, filePath: string, opts: ReplaceMediaO const found = findMaterialGlobal(draft, materialId); if (!found) throw new Error(`Material ${materialId} for segment ${opts.segmentId} not found.`); const { type, material } = found; + if (type !== "videos" && type !== "audios") { + throw new Error(`Cannot replace ${type} material ${materialId}: replace-media requires a video or audio material.`); + } const oldPath = typeof material.path === "string" ? material.path : ""; const oldDuration = num(material.duration); // Copy the replacement into the draft's assets dir, mirroring addVideo/addAudio. - const kind = assetKind(type); + const kind = type === "audios" ? "audio" : "video"; const draftDir = draftProjectDir(filePath); const filename = basename(opts.newPath) || (kind === "audio" ? "audio" : "media"); const assetsDir = resolve(draftDir, "assets", kind); - mkdirSync(assetsDir, { recursive: true }); - const destPath = resolve(assetsDir, filename); - if (!opts.dryRun && resolve(opts.newPath) !== destPath && !existsSync(destPath)) { - copyFileSync(opts.newPath, destPath); - } - const probe = probeMedia(opts.newPath, opts.ffprobeCmd ?? "ffprobe"); const newDuration = probe?.durationUs && probe.durationUs > 0 ? probe.durationUs : null; + const destPath = opts.dryRun + ? planAssetCopy(opts.newPath, assetsDir, filename).destination + : copyAssetDeduped(opts.newPath, assetsDir, filename); // Update the source pointer and the name field this material type uses. material.path = destPath; diff --git a/src/serve.ts b/src/serve.ts index 8fca51a..0cae429 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -1,17 +1,27 @@ import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { closeSync, createReadStream, existsSync, mkdtempSync, openSync, - readFileSync, + readSync, + realpathSync, rmSync, statSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { createInterface } from "node:readline"; +import { + buildCommandSpecs, + commandDeclaresFlag, + commandNames, + GLOBAL_OPTION_SPECS, + RELEASE_SCOPED_FLAGS, +} from "./command-specs.js"; +import { defaultDraftsDir, discoverDraftStore } from "./store.js"; export interface ServeOptions { queuePath?: string; @@ -60,9 +70,12 @@ export interface ServeSummary { * Jobs for different projects may run in parallel; jobs targeting the same * project are serialized so two writers never race. An optional stable `id` * deduplicates retries from external orchestrators. Each job has bounded - * output, configurable timeout, retry/backoff, and one JSON result line. + * output capture, configurable timeout, retry/backoff, and one JSON result + * line. Capture thresholds are checked every 25ms and when the child exits; + * they are not hard disk quotas. Locks and IDs last for this queue invocation. */ export async function serveQueue(opts: ServeOptions): Promise { + validateOptions(opts); if (opts.queuePath && !existsSync(opts.queuePath)) throw new Error(`Queue file not found: ${opts.queuePath}`); const input = opts.queuePath ? createReadStream(opts.queuePath, "utf-8") : process.stdin; const reader = createInterface({ input, crlfDelay: Infinity }); @@ -73,11 +86,14 @@ export async function serveQueue(opts: ServeOptions): Promise { const line = rawLine.trim(); if (!line) continue; try { - const job = JSON.parse(line) as JobInput; - if (!job || typeof job.cmd !== "string" || job.cmd.length === 0) { - throw new Error("missing or invalid 'cmd' field"); + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // Parser diagnostics can include part of a malformed credential value. + throw new Error("invalid JSON"); } - jobs.push(job); + jobs.push(validateJob(parsed)); } catch (error) { const result: JobResult = { ok: false, @@ -96,18 +112,32 @@ export async function serveQueue(opts: ServeOptions): Promise { let failed = immediate.length; let deduplicated = 0; const projectLocks = new Map>(); - const idResults = new Map>(); + const idResults = new Map }>(); + // Resolve all identities before executing jobs, while paths still represent + // the same project layout. Normal writes may change the selected timeline. + const lockKeys = new Map(jobs.map((job) => [job, projectLockKeys(job)])); const execute = async (job: JobInput): Promise => { + const fingerprint = jobFingerprint(opts, job); if (job.id) { const previous = idResults.get(job.id); if (previous) { - const result = await previous; + if (previous.fingerprint !== fingerprint) { + return { + id: job.id, + ok: false, + cmd: job.cmd, + args: redactArgs(jobArgs(job)), + status: null, + stderr: "job id already used with a different command payload or execution limits", + }; + } + const result = await previous.result; return { ...result, deduplicated: true }; } } - const operation = withProjectLock(projectLocks, job.project, () => runWithRetries(opts, job)); - if (job.id) idResults.set(job.id, operation); + const operation = withProjectLocks(projectLocks, lockKeys.get(job) ?? [], () => runWithRetries(opts, job)); + if (job.id) idResults.set(job.id, { fingerprint, result: operation }); return operation; }; @@ -125,7 +155,7 @@ export async function serveQueue(opts: ServeOptions): Promise { if (!(await record(job))) break; } } else { - const workerCount = Math.max(1, Math.min(Math.floor(opts.workers ?? 1), 32)); + const workerCount = opts.workers ?? 1; let cursor = 0; const workers = Array.from({ length: workerCount }, async () => { while (cursor < jobs.length) { @@ -139,27 +169,167 @@ export async function serveQueue(opts: ServeOptions): Promise { return { succeeded, failed, deduplicated }; } -function withProjectLock( +const MAX_TIMER_MS = 2_147_483_647; + +function validateInteger(value: unknown, field: string, minimum: number, maximum = Number.MAX_SAFE_INTEGER): void { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new Error(`${field} must be an integer between ${minimum} and ${maximum}`); + } +} + +function validString(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && !value.includes("\0"); +} + +function validateOptions(opts: ServeOptions): void { + if (!validString(opts.cliPath)) throw new Error("cliPath must be a non-empty string without NUL bytes"); + if (opts.queuePath !== undefined && !validString(opts.queuePath)) + throw new Error("queuePath must be a non-empty string"); + if (opts.failFast !== undefined && typeof opts.failFast !== "boolean") throw new Error("failFast must be a boolean"); + if (opts.workers !== undefined) validateInteger(opts.workers, "workers", 1, 32); + if (opts.retries !== undefined) validateInteger(opts.retries, "retries", 0); + if (opts.timeoutMs !== undefined) validateInteger(opts.timeoutMs, "timeoutMs", 1, MAX_TIMER_MS); + if (opts.backoffMs !== undefined) validateInteger(opts.backoffMs, "backoffMs", 0, MAX_TIMER_MS); + if (opts.maxBufferBytes !== undefined) validateInteger(opts.maxBufferBytes, "maxBufferBytes", 1); +} + +function validateJob(value: unknown): JobInput { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("job must be an object"); + const job = value as Record; + if (!validString(job.cmd)) throw new Error("missing or invalid 'cmd' field"); + if (job.id !== undefined && !validString(job.id)) throw new Error("id must be a non-empty string without NUL bytes"); + if (job.project !== undefined && !validString(job.project)) throw new Error("project must be a non-empty string"); + if ( + job.args !== undefined && + (!Array.isArray(job.args) || job.args.some((arg) => typeof arg !== "string" || arg.includes("\0"))) + ) { + throw new Error("args must be an array of strings without NUL bytes"); + } + if (job.timeoutMs !== undefined) validateInteger(job.timeoutMs, "timeoutMs", 1, MAX_TIMER_MS); + if (job.retries !== undefined) validateInteger(job.retries, "retries", 0); + return job as unknown as JobInput; +} + +function jobArgs(job: JobInput): string[] { + return [job.cmd, ...(job.project === undefined ? [] : [job.project]), ...(job.args ?? [])]; +} + +function jobFingerprint(opts: ServeOptions, job: JobInput): string { + // Fixed field order, normalized defaults, and the exact spawned argv bind an + // ID to its effective operation. Credentials are hashed and never reported. + return createHash("sha256") + .update( + JSON.stringify({ + cliPath: resolve(opts.cliPath), + cwd: process.cwd(), + args: jobArgs(job), + timeoutMs: job.timeoutMs ?? opts.timeoutMs ?? 300_000, + retries: job.retries ?? opts.retries ?? 0, + backoffMs: opts.backoffMs ?? 250, + maxBufferBytes: opts.maxBufferBytes ?? 16 * 1024 * 1024, + }), + ) + .digest("hex"); +} + +const COMMAND_SPECS = buildCommandSpecs(commandNames(), {}); +const OPTION_TYPES = new Map( + [...GLOBAL_OPTION_SPECS, ...COMMAND_SPECS.flatMap((spec) => spec.options)].flatMap((option) => + option.flags.map((flag) => [flag, option.type] as const), + ), +); +const TIMELINE_FILES = new Set(["draft_content.json", "draft_info.json", "draft_meta_info.json", "template-2.tmp"]); + +function canonicalPath(input: string): string { + let current = resolve(input); + const suffix: string[] = []; + while (true) { + try { + current = join(realpathSync.native(current), ...suffix); + break; + } catch { + const parent = dirname(current); + if (parent === current) { + current = join(current, ...suffix); + break; + } + suffix.unshift(basename(current)); + current = parent; + } + } + return current; +} + +function projectKey(input: string): string { + const canonical = canonicalPath(input); + let root = canonical; + try { + root = discoverDraftStore(canonical).projectDir; + } catch { + // New draft destinations and unreadable stores still need a stable lock. + if ((existsSync(canonical) && statSync(canonical).isFile()) || TIMELINE_FILES.has(basename(canonical))) { + root = dirname(canonical); + if (basename(dirname(root)) === "Timelines" && TIMELINE_FILES.has(basename(canonical))) { + root = dirname(dirname(root)); + } + } + } + const key = canonicalPath(root); + return process.platform === "win32" ? key.toLowerCase() : key; +} + +function projectLockKeys(job: JobInput): string[] { + const spec = COMMAND_SPECS.find((candidate) => candidate.name === job.cmd); + if (!spec) return []; + const positionals: string[] = []; + const options = new Map(); + const args = jobArgs(job).slice(1); + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + const type = OPTION_TYPES.get(arg); + if (type && (!RELEASE_SCOPED_FLAGS.has(arg) || commandDeclaresFlag(job.cmd, arg))) { + if (type !== "boolean" && index + 1 < args.length) options.set(arg, args[++index]); + } else { + positionals.push(arg); + } + } + const paths = spec.positionals.flatMap((argument, index) => + /^project(?:$|-)/.test(argument.name) && positionals[index] && !positionals[index].startsWith("-") + ? [positionals[index]] + : [], + ); + if (job.cmd === "import-timeline" && options.has("--into")) paths.push(options.get("--into") as string); + if (["compile", "import-timeline", "cut", "concat", "translate"].includes(job.cmd) && options.has("--out")) { + paths.push(options.get("--out") as string); + } + if (["init", "quickstart"].includes(job.cmd) && positionals[0]) { + const drafts = options.get("--drafts") ?? defaultDraftsDir(); + if (drafts) paths.push(resolve(drafts, positionals[0])); + } + if (job.cmd === "rename" && positionals[0] && positionals[1] && !/[/\\]/.test(positionals[1])) { + paths.push(join(dirname(projectKey(positionals[0])), positionals[1])); + } + return [...new Set(paths.map(projectKey))].sort(); +} + +function withProjectLocks( locks: Map>, - project: string | undefined, + projects: string[], operation: () => Promise, ): Promise { - if (!project) return operation(); - const previous = locks.get(project) ?? Promise.resolve(); - const current = previous.catch(() => undefined).then(operation); - locks.set( - project, - current.then( - () => undefined, - () => undefined, - ), + const previous = projects.map((project) => locks.get(project) ?? Promise.resolve()); + const current = Promise.all(previous.map((pending) => pending.catch(() => undefined))).then(operation); + const released = current.then( + () => undefined, + () => undefined, ); + for (const project of projects) locks.set(project, released); return current; } async function runWithRetries(opts: ServeOptions, job: JobInput): Promise { const started = Date.now(); - const retries = Math.max(0, Math.floor(job.retries ?? opts.retries ?? 0)); + const retries = job.retries ?? opts.retries ?? 0; let result: JobResult = { ok: false, cmd: job.cmd, args: [], status: null }; for (let attempt = 1; attempt <= retries + 1; attempt++) { result = await runJob(opts.cliPath, job, { @@ -167,8 +337,9 @@ async function runWithRetries(opts: ServeOptions, job: JobInput): Promise remainingBytes) return { text: "", bytes, overflow: true }; + chunks.push(buffer.subarray(0, read)); + } + return { text: "", bytes, overflow: true }; + } finally { + closeSync(fd); + } +} + function runJob( cliPath: string, job: JobInput, limits: { timeoutMs: number; maxBufferBytes: number }, ): Promise { - const args: string[] = [job.cmd]; - if (job.project) args.push(job.project); - if (Array.isArray(job.args)) args.push(...job.args.map(String)); + const args = jobArgs(job); const echoedArgs = redactArgs(args); return new Promise((resolve) => { const captureDir = mkdtempSync(join(tmpdir(), "capcut-serve-")); @@ -220,6 +410,8 @@ function runJob( let settled = false; let timedOut = false; let overflow = false; + let captureError: string | undefined; + const capturedBytes = () => statSync(stdoutPath).size + statSync(stderrPath).size; const finish = (result: JobResult) => { if (settled) return; @@ -234,15 +426,13 @@ function runJob( child.kill("SIGKILL"); }, limits.timeoutMs); const bufferWatch = setInterval(() => { - const bytes = [stdoutPath, stderrPath].reduce((sum, path) => { - try { - return sum + statSync(path).size; - } catch { - return sum; + try { + if (capturedBytes() > limits.maxBufferBytes) { + overflow = true; + child.kill("SIGKILL"); } - }, 0); - if (bytes > limits.maxBufferBytes) { - overflow = true; + } catch { + captureError = "unable to inspect captured output"; child.kill("SIGKILL"); } }, 25); @@ -251,8 +441,27 @@ function runJob( ); child.on("close", (status) => { if (settled) return; - const out = readFileSync(stdoutPath, "utf-8"); - const err = readFileSync(stderrPath, "utf-8").trim(); + let out = ""; + let err = ""; + try { + // A child can write and exit between two polling ticks. Stat first so + // oversized output is never loaded into memory, including this race. + overflow ||= capturedBytes() > limits.maxBufferBytes; + if (!overflow && !captureError) { + const stdout = readCapture(stdoutPath, limits.maxBufferBytes); + overflow = stdout.overflow; + if (!overflow) { + const stderr = readCapture(stderrPath, limits.maxBufferBytes - stdout.bytes); + overflow = stderr.overflow; + if (!overflow) { + out = stdout.text; + err = stderr.text.trim(); + } + } + } + } catch { + captureError = "unable to read captured output"; + } let parsed: unknown = out; try { if (out.trim()) parsed = JSON.parse(out); @@ -261,16 +470,16 @@ function runJob( } finish({ id: job.id, - ok: status === 0 && !timedOut && !overflow, + ok: status === 0 && !timedOut && !overflow && !captureError, cmd: job.cmd, args: echoedArgs, status, - stdout: parsed, + stdout: overflow || captureError ? undefined : parsed, stderr: overflow ? `output exceeded ${limits.maxBufferBytes} bytes` : timedOut ? `timed out after ${limits.timeoutMs}ms` - : err || undefined, + : captureError || err || undefined, timed_out: timedOut || undefined, overflow: overflow || undefined, }); diff --git a/test/changed-media-registration.test.mjs b/test/changed-media-registration.test.mjs new file mode 100644 index 0000000..2f3687c --- /dev/null +++ b/test/changed-media-registration.test.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { planChangedMediaRegistration } from "../dist/materials-register.js"; + +function fixture(meta) { + const dir = mkdtempSync(join(tmpdir(), "capcut-changed-registration-")); + const path = join(dir, "draft_meta_info.json"); + if (meta !== undefined) writeFileSync(path, typeof meta === "string" ? meta : JSON.stringify(meta, null, 2)); + after(() => rmSync(dir, { recursive: true, force: true })); + return { dir, path }; +} + +function draft(materials) { + return { materials: { videos: [], audios: [], ...materials } }; +} + +describe("changed-media registration planning", () => { + it("links only changed media to new entries and preserves every existing entry without writes", () => { + const oldEntry = { id: "old-entry", file_Path: "old.mp4", app_field: "keep old source" }; + const unrelated = { id: "other-entry", file_Path: "other.wav", app_field: { preserve: true } }; + const meta = { + app_metadata: "unchanged", + draft_materials: [{ type: 0, value: [oldEntry] }, { type: 2, value: [unrelated] }, null], + }; + const f = fixture(meta); + const before = readFileSync(f.path, "utf-8"); + const changed = { + id: "video", + path: "final.mp4", + local_material_id: "old-entry", + duration: 3_500_000, + width: 640, + height: 360, + }; + const untouched = { id: "untouched", path: "missing.mp4", local_material_id: "old-link" }; + const content = draft({ videos: [changed, untouched] }); + + const plan = planChangedMediaRegistration(content, f.dir, ["video"]); + assert.equal(plan.path, f.path); + assert.equal(plan.raw, before); + const proposed = JSON.parse(plan.content); + assert.equal(proposed.app_metadata, "unchanged"); + assert.deepEqual(proposed.draft_materials[0].value[0], oldEntry); + assert.deepEqual(proposed.draft_materials[1], meta.draft_materials[1]); + assert.equal(proposed.draft_materials[2], null, "opaque group entries are preserved"); + const entry = proposed.draft_materials[0].value[1]; + assert.equal(entry.file_Path, "final.mp4"); + assert.equal(entry.duration, 3_500_000); + assert.equal(entry.width, 640); + assert.equal(entry.height, 360); + assert.equal(changed.local_material_id, entry.id); + assert.equal(untouched.local_material_id, "old-link", "unselected media is never registered or relinked"); + assert.equal(readFileSync(f.path, "utf-8"), before, "planning leaves sidecar bytes unchanged"); + assert.equal(existsSync(`${f.path}.bak`), false); + assert.equal(existsSync(join(f.dir, "assets")), false); + }); + + it("reuses a path registered in any group and changes its link without rewriting the sidecar", () => { + const f = fixture({ draft_materials: [{ type: 2, value: [{ id: "known-entry", file_Path: "final.mp4" }] }] }); + const before = readFileSync(f.path, "utf-8"); + const material = { id: "video", path: join(f.dir, "final.mp4"), local_material_id: "stale-id" }; + const plan = planChangedMediaRegistration(draft({ videos: [material] }), f.dir, ["video"]); + assert.deepEqual( + plan, + { path: f.path, raw: before, content: before }, + "reused entries retain a conflict-check snapshot", + ); + assert.equal(material.local_material_id, "known-entry"); + assert.equal(readFileSync(f.path, "utf-8"), before); + }); + + it("returns no sidecar plan when no selected material has a local media path", () => { + const f = fixture({ draft_materials: [{ type: 0, value: [] }] }); + const material = { id: "video", path: "local.mp4", local_material_id: "unchanged" }; + const remote = { id: "remote", path: "https://example.com/remote.mp4", local_material_id: "remote-link" }; + const content = draft({ videos: [material, remote] }); + assert.equal(planChangedMediaRegistration(content, f.dir, ["unknown", "remote"]), null); + assert.equal(material.local_material_id, "unchanged"); + assert.equal(remote.local_material_id, "remote-link"); + }); + + it("registers a shared file once and links each changed material to the same entry", () => { + const f = fixture({ draft_materials: [{ type: 0, value: [] }] }); + const first = { id: "first", path: "shared.mp4" }; + const second = { id: "second", path: "./shared.mp4" }; + const plan = planChangedMediaRegistration(draft({ videos: [first, second] }), f.dir, ["first", "second"]); + const entries = JSON.parse(plan.content).draft_materials[0].value; + assert.equal(entries.length, 1); + assert.equal(first.local_material_id, entries[0].id); + assert.equal(second.local_material_id, entries[0].id); + }); + + it("retains legacy dictionary groups and registers audio without video dimensions", () => { + const preserved = [{ id: "existing", file_Path: "old.wav", app_field: "keep" }]; + const f = fixture({ draft_materials: { imported: preserved, custom: "opaque" } }); + const material = { id: "audio", path: "new.wav", name: "Narration", duration: 9_000_000, width: 99, height: 99 }; + const plan = planChangedMediaRegistration(draft({ audios: [material] }), f.dir, ["audio"]); + const groups = JSON.parse(plan.content).draft_materials; + assert.deepEqual(groups.imported, preserved); + assert.equal(groups.custom, "opaque"); + assert.equal(groups["0"][0].metetype, "music"); + assert.equal(groups["0"][0].extra_info, "Narration"); + assert.equal(groups["0"][0].duration, 9_000_000); + assert.equal(groups["0"][0].width, 0); + assert.equal(groups["0"][0].height, 0); + assert.equal(material.local_material_id, groups["0"][0].id); + }); + + it("leaves missing or unreadable sidecars and existing links untouched", () => { + for (const meta of [undefined, "invalid json", "[]"]) { + const f = fixture(meta); + const material = { id: "video", path: "new.mp4", local_material_id: "app-link" }; + const before = JSON.stringify(material); + assert.equal(planChangedMediaRegistration(draft({ videos: [material] }), f.dir, ["video"]), null); + assert.equal(JSON.stringify(material), before); + assert.equal(existsSync(`${f.path}.bak`), false); + assert.equal(existsSync(f.path), meta !== undefined, "planner never fabricates a sidecar"); + if (meta !== undefined) assert.equal(readFileSync(f.path, "utf-8"), meta); + } + }); +}); diff --git a/test/compile-fractional-timing.test.mjs b/test/compile-fractional-timing.test.mjs new file mode 100644 index 0000000..a15898d --- /dev/null +++ b/test/compile-fractional-timing.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { compileDraft } from "../dist/compile.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const US = 1_000_000; +const bar = (60 / 136) * 4; + +describe("compile fractional-second target boundaries", () => { + it("keeps adjacent music bars contiguous across all item and timed-operation kinds", (t) => { + const dir = mkdtempSync(join(tmpdir(), "capcut-compile-fractional-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + for (const name of ["clip.mp4", "music.mp3", "photo.png"]) writeFileSync(join(dir, name), "unprobeable"); + const items = (extra) => Array.from({ length: 8 }, (_, i) => ({ start: i * bar, duration: bar, ...extra })); + const operations = [ + ...items({ op: "filter", slug: "vintage", trackName: "filters" }), + ...items({ op: "effect", slug: "shake", trackName: "effects" }), + ...items({ op: "template", path: join(root, "templates", "subscribe-cta.json") }), + ]; + const spec = { + tracks: [ + { type: "text", name: "titles", items: items({ text: "BAR" }) }, + { type: "video", name: "clips", items: items({ path: "clip.mp4", sourceStart: 4, speed: 1.25 }) }, + { type: "video", name: "photos", items: items({ path: "photo.png", type: "photo" }) }, + { type: "audio", name: "music", items: items({ path: "music.mp3", sourceStart: 2, speed: 0.75 }) }, + ], + operations, + }; + const result = compileDraft(spec, { + specDir: dir, + outDir: join(dir, "Built"), + templateDir: join(root, "templates", "_init"), + seed: "off", + }); + const draft = JSON.parse(readFileSync(result.file_path, "utf-8")); + // Templates use the first text track, so compare their eight-segment group separately. + for (const track of draft.tracks) { + const groups = track.name === "titles" ? [track.segments.slice(0, 8), track.segments.slice(8)] : [track.segments]; + for (const group of groups) { + assert.equal(group.length, 8, track.name); + for (let i = 0; i < group.length; i++) { + const range = group[i].target_timerange; + assert.equal(range.start, Math.round(i * bar * US)); + assert.equal(range.start + range.duration, Math.round((i * bar + bar) * US)); + if (i > 0) + assert.equal( + group[i - 1].target_timerange.start + group[i - 1].target_timerange.duration, + range.start, + `${track.name} boundary ${i}`, + ); + } + } + } + assert.equal(result.duration_us, Math.round(8 * bar * US)); + for (const track of draft.tracks.filter((item) => item.name === "clips" || item.name === "music")) { + const speed = track.name === "clips" ? 1.25 : 0.75; + const sourceStart = track.name === "clips" ? 4 : 2; + for (const segment of track.segments) { + assert.equal(segment.source_timerange.start, sourceStart * US); + assert.equal(segment.source_timerange.duration, Math.round(segment.target_timerange.duration * speed)); + assert.equal(segment.speed, speed); + } + } + }); +}); diff --git a/test/compile-safety.test.mjs b/test/compile-safety.test.mjs new file mode 100644 index 0000000..ef5bfc0 --- /dev/null +++ b/test/compile-safety.test.mjs @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { compileDraft, planCompile } from "../dist/compile.js"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const templateDir = join(root, "templates", "_init"); + +function setup(t) { + const dir = fs.mkdtempSync(join(tmpdir(), "capcut-compile-safety-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const opts = { specDir: dir, templateDir, outDir: join(dir, "Built"), seed: "off" }; + const spec = { tracks: [{ type: "text", items: [{ ref: "title", text: "Hook", start: 0, duration: 2 }] }] }; + return { dir, opts, spec }; +} + +function rejected(t, operations, pattern) { + const s = setup(t); + s.spec.operations = operations; + const index = join(s.dir, "root_meta_info.json"); + const original = '{"all_draft_store":[{"draft_id":"unrelated","draft_fold_path":"elsewhere"}],"custom":true}'; + fs.writeFileSync(index, original); + assert.throws(() => planCompile(s.spec, s.dir), pattern); + assert.throws(() => compileDraft(s.spec, s.opts), pattern); + assert.equal(fs.existsSync(s.opts.outDir), false); + assert.equal(fs.readFileSync(index, "utf-8"), original); + assert.equal(fs.existsSync(`${index}.bak`), false); +} + +describe("compile operation preflight", () => { + for (const [operation, pattern] of [ + [{ op: "transition", target: "title", slug: "does-not-exist" }, /Unknown transition/], + [{ op: "filter", slug: "does-not-exist", start: 0, duration: 1 }, /Unknown filter/], + [{ op: "effect", slug: "does-not-exist", start: 0, duration: 1 }, /Unknown effect/], + [{ op: "effect", slug: "shake", start: 0, duration: 1, params: {} }, /params.*array/], + [{ op: "filter", slug: "vintage", start: -1, duration: 1 }, /start.*finite number/], + [{ op: "filter", slug: "vintage", start: 0, duration: 0 }, /at least one microsecond/], + [ + { op: "keyframe", target: "title", property: "does-not-exist", time: 0, value: 1 }, + /Unsupported keyframe property/, + ], + [{ op: "keyframe", target: "title", property: "alpha", time: -1, value: 1 }, /time.*finite number/], + [{ op: "keyframe", target: "title", property: "alpha", time: 0, value: "bad" }, /value.*finite number/], + [{ op: "audio-fade", target: "title", fadeIn: 1 }, /only applies to audio/], + [{ op: "audio-fade", target: "title" }, /requires at least one/], + [{ op: "audio-fade", target: "title", fadeIn: -1 }, /fadeIn.*finite number/], + [{ op: "text-style", target: "title", style: { alpha: "bad" } }, /style.alpha.*finite number/], + [{ op: "text-style", target: "title", style: { shadow: "true" } }, /style.shadow.*boolean/], + [{ op: "text-style", target: "title", style: { borderColor: "red" } }, /borderColor.*RRGGBB/], + [{ op: "text-ranges", target: "title", ranges: [] }, /at least one range/], + [{ op: "text-ranges", target: "title", ranges: [null] }, /ranges\[0\].*object/], + [{ op: "text-ranges", target: "title", ranges: [{ start: 0, end: 10 }] }, /out of bounds/], + [ + { + op: "text-ranges", + target: "title", + ranges: [ + { start: 0, end: 3 }, + { start: 2, end: 4 }, + ], + }, + /overlapping ranges/, + ], + [ + { op: "text-ranges", target: "title", ranges: [{ start: 0, end: 1, font_size: "big" }] }, + /font_size.*finite number/, + ], + ]) { + it(`rejects ${JSON.stringify(operation)} before creating output`, (t) => rejected(t, [operation], pattern)); + } + + it("checks state-dependent operation conflicts, even after valid operations", (t) => { + rejected( + t, + [ + { op: "text-style", target: "title", style: { alpha: 0.5 } }, + { op: "transition", target: "title", slug: "dissolve" }, + { op: "transition", target: "title", slug: "dissolve" }, + ], + /operations\[2\].*already has a transition/, + ); + }); + + it("rejects text operations and caption style refs targeting a video", (t) => { + const s = setup(t); + fs.writeFileSync(join(s.dir, "clip.mp4"), "unprobeable"); + fs.writeFileSync(join(s.dir, "captions.srt"), "1\n00:00:00,000 --> 00:00:01,000\nHello\n"); + s.spec.tracks.push({ type: "video", items: [{ ref: "hero", path: "clip.mp4", start: 0, duration: 2 }] }); + for (const operation of [ + { op: "text-style", target: "hero", style: { alpha: 0.5 } }, + { op: "text-ranges", target: "hero", ranges: [{ start: 0, end: 1 }] }, + { op: "captions", path: "captions.srt", styleRef: "hero" }, + ]) { + s.spec.operations = [operation]; + assert.throws(() => planCompile(s.spec, s.dir), /Text material not found|not a text segment/); + assert.equal(fs.existsSync(s.opts.outDir), false); + } + }); + + it("parses templates and captions during --check", (t) => { + const s = setup(t); + const specPath = join(s.dir, "spec.json"); + const input = join(s.dir, "operation.json"); + for (const content of ["{bad", JSON.stringify({ type: "text", segment: {}, material: {}, extra_materials: [] })]) { + fs.writeFileSync(input, content); + s.spec.operations = [{ op: "template", path: input, start: 0, duration: 1 }]; + fs.writeFileSync(specPath, JSON.stringify(s.spec)); + const result = spawnCli(["compile", specPath, "--check"]); + assert.equal(result.status, 1); + assert.equal(fs.existsSync(s.opts.outDir), false); + } + for (const content of ["not srt", "1\n00:00:02,000 --> 00:00:01,000\nWrong order\n", ""]) { + fs.writeFileSync(input, content); + s.spec.operations = [{ op: "captions", path: input }]; + fs.writeFileSync(specPath, JSON.stringify(s.spec)); + const result = spawnCli(["compile", specPath, "--check"]); + assert.equal(result.status, 1); + } + }); + + it("allows later operations to reference a previously applied template", (t) => { + const s = setup(t); + s.spec.operations = [ + { + op: "template", + ref: "cta", + path: join(root, "templates", "subscribe-cta.json"), + text: "Follow", + start: 1, + duration: 1, + }, + { op: "text-style", target: "cta", style: { alpha: 0.5 } }, + { op: "text-ranges", target: "cta", ranges: [{ start: 0, end: 6, bold: true }] }, + ]; + assert.equal(planCompile(s.spec, s.dir).ok, true); + const result = compileDraft(s.spec, s.opts); + assert.ok(result.refs.cta); + const draft = JSON.parse(fs.readFileSync(result.file_path, "utf-8")); + const segment = draft.tracks.flatMap((track) => track.segments).find((item) => item.id === result.refs.cta); + assert.equal(draft.materials.texts.find((material) => material.id === segment.material_id).text_alpha, 0.5); + }); + + it("rejects duplicate template refs", (t) => { + rejected( + t, + [{ op: "template", ref: "title", path: join(root, "templates", "subscribe-cta.json"), start: 0, duration: 1 }], + /duplicate ref/, + ); + }); + + it("rejects non-finite library values and sub-microsecond text durations", (t) => { + const s = setup(t); + for (const change of [{ start: Infinity }, { duration: NaN }, { duration: 0.00000001 }, { color: "blue" }]) { + const invalid = structuredClone(s.spec); + Object.assign(invalid.tracks[0].items[0], change); + assert.throws(() => planCompile(invalid, s.dir)); + assert.throws(() => compileDraft(invalid, s.opts)); + assert.equal(fs.existsSync(s.opts.outDir), false); + } + }); +}); + +describe("compile failure cleanup", () => { + it("removes a partially initialized directory without changing the store index", (t) => { + const s = setup(t); + const index = join(s.dir, "root_meta_info.json"); + const before = '{"all_draft_store":[],"custom":"keep"}'; + fs.writeFileSync(index, before); + const original = fs.cpSync; + fs.cpSync = () => { + throw new Error("injected template copy failure"); + }; + syncBuiltinESMExports(); + try { + assert.throws(() => compileDraft(s.spec, s.opts), /template copy failure/); + } finally { + fs.cpSync = original; + syncBuiltinESMExports(); + } + assert.equal(fs.existsSync(s.opts.outDir), false); + assert.equal(fs.readFileSync(index, "utf-8"), before); + assert.equal(fs.existsSync(`${index}.bak`), false); + }); + + it("removes failed output while preserving unrelated store edits made during the build", (t) => { + const s = setup(t); + const index = join(s.dir, "root_meta_info.json"); + fs.writeFileSync(index, '{"all_draft_store":[],"custom":"before"}'); + const media = join(s.dir, "clip.mp4"); + fs.writeFileSync(media, "unprobeable media"); + s.spec.tracks.push({ type: "video", items: [{ path: media, start: 0, duration: 2 }] }); + const changed = '{"all_draft_store":[{"draft_id":"concurrent","draft_fold_path":"elsewhere"}],"custom":"after"}'; + const original = fs.copyFileSync; + fs.copyFileSync = (source, ...args) => { + if (source === media) { + fs.writeFileSync(index, changed); + throw new Error("injected media copy failure"); + } + return original(source, ...args); + }; + syncBuiltinESMExports(); + try { + assert.throws(() => compileDraft(s.spec, s.opts), /media copy failure/); + } finally { + fs.copyFileSync = original; + syncBuiltinESMExports(); + } + assert.equal(fs.existsSync(s.opts.outDir), false); + assert.equal(fs.readFileSync(index, "utf-8"), changed); + assert.equal(fs.existsSync(`${index}.bak`), false); + }); + + it("refuses preexisting output and preserves its contents", (t) => { + const s = setup(t); + fs.mkdirSync(s.opts.outDir); + const sentinel = join(s.opts.outDir, "keep.txt"); + fs.writeFileSync(sentinel, "keep me"); + assert.throws(() => compileDraft(s.spec, s.opts), /already exists/); + assert.equal(fs.readFileSync(sentinel, "utf-8"), "keep me"); + }); + + it("preserves another writer's replacement directory at the output path", (t) => { + const s = setup(t); + const media = join(s.dir, "clip.mp4"); + fs.writeFileSync(media, "unprobeable media"); + s.spec.tracks.push({ type: "video", items: [{ path: media, start: 0, duration: 2 }] }); + const original = fs.copyFileSync; + fs.copyFileSync = (source, ...args) => { + if (source === media) { + fs.renameSync(s.opts.outDir, join(s.dir, "Moved")); + fs.mkdirSync(s.opts.outDir); + fs.writeFileSync(join(s.opts.outDir, "keep.txt"), "another writer"); + throw new Error("output directory replaced"); + } + return original(source, ...args); + }; + syncBuiltinESMExports(); + try { + assert.throws(() => compileDraft(s.spec, s.opts), /output directory replaced/); + } finally { + fs.copyFileSync = original; + syncBuiltinESMExports(); + } + assert.equal(fs.readFileSync(join(s.opts.outDir, "keep.txt"), "utf-8"), "another writer"); + assert.equal(fs.existsSync(join(s.dir, "root_meta_info.json")), false); + }); + + it("registers completed duration and preserves existing index entries", (t) => { + const s = setup(t); + s.spec.name = "Display title"; + const media = join(s.dir, "clip.mp4"); + fs.writeFileSync(media, "unprobeable media"); + s.spec.tracks.push({ type: "video", items: [{ path: media, start: 0, duration: 2 }] }); + const index = join(s.dir, "root_meta_info.json"); + fs.writeFileSync( + index, + '{"all_draft_store":[{"draft_id":"existing","draft_fold_path":"elsewhere"}],"custom":true}', + ); + const result = compileDraft(s.spec, s.opts); + const parsed = JSON.parse(fs.readFileSync(index, "utf-8")); + assert.equal(parsed.custom, true); + assert.equal(parsed.all_draft_store[0].draft_id, "existing"); + assert.equal(parsed.all_draft_store[1].tm_duration, result.duration_us); + assert.equal(parsed.all_draft_store[1].draft_fold_path, s.opts.outDir); + const sidecar = JSON.parse(fs.readFileSync(join(s.opts.outDir, "draft_meta_info.json"), "utf-8")); + assert.equal(sidecar.tm_duration, result.duration_us); + assert.equal(sidecar.draft_name, "Display title"); + const imported = sidecar.draft_materials.flatMap((group) => group.value); + assert.equal(imported.length, 1); + const draft = JSON.parse(fs.readFileSync(result.file_path, "utf-8")); + assert.equal(imported[0].id, draft.materials.videos[0].local_material_id); + }); + + it("retains the actionable omitted-duration error when the source cannot be probed", (t) => { + const s = setup(t); + fs.writeFileSync(join(s.dir, "clip.mp4"), "unprobeable media"); + s.spec.tracks.push({ type: "video", items: [{ path: "clip.mp4", start: 0 }] }); + assert.throws(() => planCompile(s.spec, s.dir), /Pass duration explicitly or install ffprobe/); + assert.equal(fs.existsSync(s.opts.outDir), false); + }); +}); diff --git a/test/media-registration-edit.test.mjs b/test/media-registration-edit.test.mjs new file mode 100644 index 0000000..912fde6 --- /dev/null +++ b/test/media-registration-edit.test.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import fs, { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { loadDraft, saveDraft } from "../dist/draft.js"; +import { planChangedMediaRegistration } from "../dist/materials-register.js"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; +import { tmpDraft } from "./helpers/tmp-draft.mjs"; + +function fixture() { + const fix = tmpDraft(); + const draft = JSON.parse(readFileSync(fix.path, "utf8")); + const segment = draft.tracks.find((track) => track.type === "video").segments[0]; + const material = draft.materials.videos.find((material) => material.id === segment.material_id); + material.path = join(fix.dir, "missing", "clip.mp4"); + material.local_material_id = "old-import"; + writeFileSync(fix.path, JSON.stringify(draft)); + const metaPath = join(fix.dir, "draft_meta_info.json"); + const old = { id: "old-import", file_Path: material.path, custom: "preserve old import" }; + const unrelated = { id: "other-import", file_Path: "/unrelated/file.mp4", custom: "preserve other import" }; + writeFileSync(metaPath, JSON.stringify({ draft_materials: [{ type: 0, value: [old, unrelated] }], custom: "keep" })); + const folder = join(fix.dir, "incoming"); + mkdirSync(folder); + const media = join(folder, "clip.mp4"); + writeFileSync(media, "new media bytes"); + return { ...fix, metaPath, old, unrelated, media, folder, segment, material }; +} + +describe("media edit registration transactions", () => { + it("rejects duplicate targets reached through a directory alias", () => { + const fix = fixture(); + try { + const alias = join(fix.dir, "alias"); + symlinkSync(fix.dir, alias, process.platform === "win32" ? "junction" : "dir"); + const { draft, filePath } = loadDraft(fix.path); + const before = readFileSync(fix.path, "utf8"); + assert.throws( + () => + saveDraft(filePath, draft, { + additionalFiles: [{ path: join(alias, "draft_content.json"), raw: before, content: "wrong document" }], + }), + /Duplicate draft transaction target/, + ); + assert.equal(readFileSync(fix.path, "utf8"), before); + assert.equal(existsSync(`${fix.path}.bak`), false); + } finally { + fix.cleanup(); + } + }); + + it("rejects Windows target aliases with different casing", { skip: process.platform !== "win32" }, () => { + const fix = fixture(); + try { + const { draft, filePath } = loadDraft(fix.path); + const before = readFileSync(fix.path, "utf8"); + assert.throws( + () => + saveDraft(filePath, draft, { + additionalFiles: [{ path: fix.path.toUpperCase(), raw: before, content: "wrong document" }], + }), + /Duplicate draft transaction target/, + ); + assert.equal(readFileSync(fix.path, "utf8"), before); + } finally { + fix.cleanup(); + } + }); + + it("checks a reused registration snapshot without rewriting or backing up an unchanged sidecar", () => { + const fix = fixture(); + try { + const sidecar = JSON.parse(readFileSync(fix.metaPath, "utf8")); + sidecar.draft_materials[0].value.push({ id: "reused-import", file_Path: fix.media }); + writeFileSync(fix.metaPath, JSON.stringify(sidecar)); + const { draft, filePath } = loadDraft(fix.path); + const material = draft.materials.videos.find((material) => material.id === fix.material.id); + material.path = fix.media; + const planned = planChangedMediaRegistration(draft, fix.dir, [material.id]); + const before = readFileSync(fix.metaPath, "utf8"); + assert.equal(planned.content, before); + saveDraft(filePath, draft, { additionalFiles: [planned] }); + assert.equal(readFileSync(fix.metaPath, "utf8"), before); + assert.equal(existsSync(`${fix.metaPath}.bak`), false); + assert.equal( + JSON.parse(readFileSync(fix.path, "utf8")).materials.videos.find((value) => value.id === material.id) + .local_material_id, + "reused-import", + ); + const freshPlan = planChangedMediaRegistration(draft, fix.dir, [material.id]); + writeFileSync(fix.metaPath, JSON.stringify({ draft_materials: [] })); + const timeline = readFileSync(fix.path, "utf8"); + assert.throws(() => saveDraft(filePath, draft, { additionalFiles: [freshPlan] }), /sidecar changed/); + assert.equal(readFileSync(fix.path, "utf8"), timeline); + } finally { + fix.cleanup(); + } + }); + + for (const command of ["replace-media", "relink"]) { + it(`${command} updates the local ID and preserves previous and unrelated imports`, () => { + const fix = fixture(); + try { + const beforeMeta = readFileSync(fix.metaPath, "utf8"); + const args = + command === "replace-media" + ? [command, fix.path, fix.segment.id, fix.media] + : [command, fix.path, "--dir", fix.folder, "--stage"]; + const result = spawnCli(args); + assert.equal(result.status, 0, result.stderr); + const draft = JSON.parse(readFileSync(fix.path, "utf8")); + const material = draft.materials.videos.find((material) => material.id === fix.material.id); + const meta = JSON.parse(readFileSync(fix.metaPath, "utf8")); + const entries = meta.draft_materials[0].value; + assert.deepEqual(entries[0], fix.old); + assert.deepEqual(entries[1], fix.unrelated); + const imported = entries.find((entry) => entry.file_Path === material.path); + assert.ok(imported); + assert.equal(material.local_material_id, imported.id); + assert.notEqual(material.local_material_id, "old-import"); + assert.equal(meta.custom, "keep"); + assert.equal(readFileSync(`${fix.metaPath}.bak`, "utf8"), beforeMeta); + } finally { + fix.cleanup(); + } + }); + + it(`${command} leaves timeline, sidecar and backups unchanged under --dry-run`, () => { + const fix = fixture(); + try { + const timeline = readFileSync(fix.path, "utf8"); + const sidecar = readFileSync(fix.metaPath, "utf8"); + const args = + command === "replace-media" + ? [command, fix.path, fix.segment.id, fix.media] + : [command, fix.path, "--dir", fix.folder, "--stage"]; + const result = spawnCli([...args, "--dry-run"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(fix.path, "utf8"), timeline); + assert.equal(readFileSync(fix.metaPath, "utf8"), sidecar); + assert.equal(existsSync(`${fix.metaPath}.bak`), false); + assert.equal(existsSync(join(fix.dir, "assets")), false); + } finally { + fix.cleanup(); + } + }); + + it(`${command} does not change registration when the write guard refuses the draft`, () => { + const fix = fixture(); + try { + const draft = JSON.parse(readFileSync(fix.path, "utf8")); + draft.platform.app_version = "99.0.0"; + writeFileSync(fix.path, JSON.stringify(draft)); + const timeline = readFileSync(fix.path, "utf8"); + const sidecar = readFileSync(fix.metaPath, "utf8"); + const args = + command === "replace-media" + ? [command, fix.path, fix.segment.id, fix.media] + : [command, fix.path, "--dir", fix.folder]; + const result = spawnCli(args); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /version-boundary/); + assert.equal(readFileSync(fix.path, "utf8"), timeline); + assert.equal(readFileSync(fix.metaPath, "utf8"), sidecar); + assert.equal(existsSync(`${fix.metaPath}.bak`), false); + } finally { + fix.cleanup(); + } + }); + } + + it("refuses a changed sidecar before writing any timeline or backup", () => { + const fix = fixture(); + try { + const { draft, filePath } = loadDraft(fix.path); + const material = draft.materials.videos.find((material) => material.id === fix.material.id); + material.path = fix.media; + const planned = planChangedMediaRegistration(draft, fix.dir, [material.id]); + writeFileSync(fix.metaPath, '{"updated_by_app":true}'); + const before = readFileSync(fix.path, "utf8"); + assert.throws(() => saveDraft(filePath, draft, { additionalFiles: [planned] }), /sidecar changed/); + assert.equal(readFileSync(fix.path, "utf8"), before); + assert.equal(readFileSync(fix.metaPath, "utf8"), '{"updated_by_app":true}'); + assert.equal(existsSync(`${fix.path}.bak`), false); + } finally { + fix.cleanup(); + } + }); + + it("rolls back committed timeline bytes if sidecar publication fails", () => { + const fix = fixture(); + const rename = fs.renameSync; + try { + const { draft, filePath } = loadDraft(fix.path); + const material = draft.materials.videos.find((material) => material.id === fix.material.id); + material.path = fix.media; + const planned = planChangedMediaRegistration(draft, fix.dir, [material.id]); + const before = readFileSync(fix.path, "utf8"); + const beforeMeta = readFileSync(fix.metaPath, "utf8"); + fs.renameSync = (from, to) => { + if (to === fix.metaPath) throw new Error("injected sidecar commit failure"); + return rename(from, to); + }; + syncBuiltinESMExports(); + assert.throws(() => saveDraft(filePath, draft, { additionalFiles: [planned] }), /injected sidecar/); + assert.equal(readFileSync(fix.path, "utf8"), before); + assert.equal(readFileSync(fix.metaPath, "utf8"), beforeMeta); + } finally { + fs.renameSync = rename; + syncBuiltinESMExports(); + fix.cleanup(); + } + }); +}); diff --git a/test/otio-media-paths.test.mjs b/test/otio-media-paths.test.mjs new file mode 100644 index 0000000..6039ff3 --- /dev/null +++ b/test/otio-media-paths.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, it } from "node:test"; +import { pathToFileURL } from "node:url"; +import { draftToOtio, otioToImportPlan, resolveOtioMediaPath } from "../dist/interchange.js"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; +import { tmpDraft } from "./helpers/tmp-draft.mjs"; + +describe("OTIO local media references", () => { + for (const kind of ["relative", "file-uri"]) { + it(`imports an accessible ${kind} reference when cwd differs from the OTIO folder`, () => { + const fix = tmpDraft(); + try { + const folder = join(fix.dir, "handoff"); + mkdirSync(folder); + const media = join(folder, "clip with space.mp4"); + writeFileSync(media, "source bytes"); + const draft = JSON.parse(readFileSync(fix.path, "utf8")); + const track = draft.tracks.find((track) => track.type === "video"); + const segment = track.segments[0]; + track.segments = [segment]; + draft.tracks = [track]; + const material = draft.materials.videos.find((material) => material.id === segment.material_id); + material.path = kind === "relative" ? "clip with space.mp4" : pathToFileURL(media).href; + const { doc } = draftToOtio(draft); + const input = join(folder, "edit.otio"); + writeFileSync(input, JSON.stringify(doc)); + const output = join(fix.dir, "imported"); + const result = spawnCli(["import-timeline", input, "--out", output, "--template", "bundled"], { + cwd: resolve("templates"), + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.json.placeholders.length, 0); + const imported = JSON.parse(readFileSync(result.json.file_path, "utf8")); + const importedMedia = imported.materials.videos.find((material) => material.path); + assert.equal(readFileSync(importedMedia.path, "utf8"), "source bytes"); + } finally { + fix.cleanup(); + } + }); + } + + it("keeps remote URLs and foreign Windows paths available as placeholder references", () => { + assert.equal(resolveOtioMediaPath("https://example.com/clip.mp4", "/handoff"), "https://example.com/clip.mp4"); + assert.equal(resolveOtioMediaPath("C:\\media\\clip.mp4", "/handoff"), "C:\\media\\clip.mp4"); + assert.equal( + resolveOtioMediaPath("file://foreign-host/clip.mp4"), + process.platform === "win32" ? "\\\\foreign-host\\clip.mp4" : "file://foreign-host/clip.mp4", + ); + const fix = tmpDraft(); + try { + const draft = JSON.parse(readFileSync(fix.path, "utf8")); + const { doc } = draftToOtio(draft); + assert.deepEqual(otioToImportPlan(doc), otioToImportPlan(doc, {}), "library default remains independent of cwd"); + } finally { + fix.cleanup(); + } + }); +}); diff --git a/test/relink-recursive.test.mjs b/test/relink-recursive.test.mjs new file mode 100644 index 0000000..173f4fe --- /dev/null +++ b/test/relink-recursive.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { extractText } from "../dist/draft.js"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; +import { tmpDraft } from "./helpers/tmp-draft.mjs"; + +function setMedia(fix, paths) { + const draft = JSON.parse(readFileSync(fix.path, "utf8")); + draft.materials.videos = paths.map((path, index) => ({ id: `video-${index}`, path })); + draft.materials.audios = []; + writeFileSync(fix.path, JSON.stringify(draft)); +} + +describe("recursive relink", () => { + it("preserves --recursive as free text on unrelated commands", () => { + const fix = tmpDraft(); + try { + const result = spawnCli(["add-text", fix.path, "0s", "1s", "--recursive"]); + assert.equal(result.status, 0, result.stderr); + const draft = JSON.parse(readFileSync(fix.path, "utf8")); + assert.ok(draft.materials.texts.some((material) => extractText(material.content) === "--recursive")); + } finally { + fix.cleanup(); + } + }); + + it("recovers nested media only when requested and handles Windows basenames", () => { + const fix = tmpDraft(); + try { + const media = join(fix.dir, "media"); + mkdirSync(join(media, "nested"), { recursive: true }); + const file = join(media, "nested", "clip.mp4"); + writeFileSync(file, "media bytes"); + setMedia(fix, ["C:\\old\\clip.mp4"]); + const flat = spawnCli(["relink", fix.path, "--dir", media]); + assert.equal(flat.json.relinked, 0); + const recursive = spawnCli(["relink", fix.path, "--dir", media, "--recursive"]); + assert.equal(recursive.status, 0, recursive.stderr); + assert.equal(recursive.json.relinked, 1); + assert.equal(recursive.json.changes[0].to, file); + } finally { + fix.cleanup(); + } + }); + + it("leaves ambiguous basenames unchanged and reports all candidate files", () => { + const fix = tmpDraft(); + try { + const media = join(fix.dir, "media"); + for (const name of ["one", "two"]) { + mkdirSync(join(media, name), { recursive: true }); + writeFileSync(join(media, name, "clip.mp4"), name); + } + setMedia(fix, ["/missing/clip.mp4"]); + const before = readFileSync(fix.path, "utf8"); + const result = spawnCli(["relink", fix.path, "--dir", media, "--recursive", "--stage"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.json.relinked, 0); + assert.equal(result.json.still_missing, 1); + assert.equal(result.json.ambiguous[0].candidates.length, 2); + assert.equal(readFileSync(fix.path, "utf8"), before); + assert.equal(existsSync(join(fix.dir, "assets")), false); + } finally { + fix.cleanup(); + } + }); + + it("matches prefixes at a path boundary, including a trailing separator", () => { + const fix = tmpDraft(); + try { + setMedia(fix, ["/old/root/a.mp4", "/old/root-extra/b.mp4"]); + const result = spawnCli(["relink", fix.path, "--from", "/old/root/", "--to", "/new/root"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.json.relinked, 1); + const materials = JSON.parse(readFileSync(fix.path, "utf8")).materials.videos; + assert.equal(materials[0].path, "/new/root/a.mp4"); + assert.equal(materials[1].path, "/old/root-extra/b.mp4"); + } finally { + fix.cleanup(); + } + }); + + it("does not change timeline bytes or stage assets in recursive dry-run", () => { + const fix = tmpDraft(); + try { + const media = join(fix.dir, "media", "nested"); + mkdirSync(media, { recursive: true }); + writeFileSync(join(media, "clip.mp4"), "media bytes"); + setMedia(fix, ["/missing/clip.mp4"]); + const before = readFileSync(fix.path, "utf8"); + const result = spawnCli([ + "relink", + fix.path, + "--dir", + join(fix.dir, "media"), + "--recursive", + "--stage", + "--dry-run", + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.json.relinked, 1); + assert.equal(readFileSync(fix.path, "utf8"), before); + assert.equal(existsSync(join(fix.dir, "assets")), false); + } finally { + fix.cleanup(); + } + }); +}); diff --git a/test/replace.test.mjs b/test/replace.test.mjs index b475023..8ed2094 100644 --- a/test/replace.test.mjs +++ b/test/replace.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { after, describe, it } from "node:test"; import { spawnCli } from "./helpers/spawn-cli.mjs"; @@ -52,6 +53,89 @@ describe("capcut replace-media", () => { assert.equal(r.json.dryRun, true); assert.equal(readFileSync(fix.path, "utf-8"), before, "draft must be byte-identical under --dry-run"); assert.equal(existsSync(join(fix.dir, "assets", "video", "final.mp4")), false, "must not copy media in dry-run"); + assert.equal(existsSync(join(fix.dir, "assets")), false, "must not create directories in dry-run"); + assert.equal(existsSync(`${fix.path}.bak`), false, "must not create a draft backup in dry-run"); + assert.equal(existsSync(join(fix.dir, "draft_meta_info.json.bak")), false, "must not back up the sidecar"); + }); + + it("copies different bytes sharing a basename without changing segment identity or timing", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + const before = JSON.parse(readFileSync(fix.path, "utf-8")); + const segment = before.tracks.find((t) => t.type === "video").segments[0]; + const sharedSegment = { + ...structuredClone(segment), + id: "shared-replacement-segment", + target_timerange: { + ...segment.target_timerange, + start: segment.target_timerange.start + segment.target_timerange.duration, + }, + }; + before.tracks.find((t) => t.type === "video").segments.push(sharedSegment); + writeFileSync(fix.path, JSON.stringify(before)); + const assets = join(fix.dir, "assets", "video"); + mkdirSync(assets, { recursive: true }); + writeFileSync(join(assets, "clip.mp4"), "OLD-BYTES"); + const replacement = join(fix.dir, "clip.mp4"); + writeFileSync(replacement, "NEW-BYTES"); + + const preview = spawnCli(["replace-media", fix.path, segment.id, replacement, "--dry-run"]); + assert.equal(preview.status, 0, preview.stderr); + assert.equal(existsSync(preview.json.new_path), false, "preview must not copy its chosen destination"); + const result = spawnCli(["replace-media", fix.path, segment.id, replacement]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.json.new_path, preview.json.new_path, "preview and commit choose the same destination"); + assert.equal(result.json.shared_with_segments, 1, "replacement reports other uses of the shared material"); + assert.notEqual(result.json.new_path, join(assets, "clip.mp4")); + assert.equal(readFileSync(result.json.new_path, "utf-8"), "NEW-BYTES"); + assert.equal(readFileSync(join(assets, "clip.mp4"), "utf-8"), "OLD-BYTES"); + const afterDraft = JSON.parse(readFileSync(fix.path, "utf-8")); + const afterSegment = afterDraft.tracks.flatMap((t) => t.segments).find((s) => s.id === segment.id); + assert.deepEqual(afterSegment, segment, "replacement preserves timing, effects and IDs"); + assert.deepEqual( + afterDraft.tracks.flatMap((t) => t.segments).find((s) => s.id === sharedSegment.id), + sharedSegment, + "other segments keep their timing and continue sharing the same material", + ); + assert.equal(materialPath(fix.path, segment.material_id), result.json.new_path); + }); + + it("checks occupied hash filenames instead of trusting their suffix", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + const assets = join(fix.dir, "assets", "video"); + mkdirSync(assets, { recursive: true }); + const replacement = join(fix.dir, "clip.mp4"); + writeFileSync(replacement, "ACTUAL-REPLACEMENT"); + const hash = createHash("sha1").update("ACTUAL-REPLACEMENT").digest("hex"); + writeFileSync(join(assets, "clip.mp4"), "ORIGINAL"); + const occupied = join(assets, `clip.${hash.slice(0, 8)}.mp4`); + writeFileSync(occupied, "UNRELATED-FILE"); + const result = spawnCli(["replace-media", fix.path, videoSegmentId(fix.path), replacement]); + assert.equal(result.status, 0, result.stderr); + assert.notEqual(result.json.new_path, occupied); + assert.equal(readFileSync(result.json.new_path, "utf-8"), "ACTUAL-REPLACEMENT"); + assert.equal(readFileSync(occupied, "utf-8"), "UNRELATED-FILE"); + }); + + it("rejects directories and non-media segments before creating assets", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + const before = readFileSync(fix.path, "utf-8"); + const directoryResult = spawnCli(["replace-media", fix.path, videoSegmentId(fix.path), fix.dir]); + assert.notEqual(directoryResult.status, 0); + assert.match(directoryResult.stderr, /regular file/); + assert.equal(readFileSync(fix.path, "utf-8"), before); + assert.equal(existsSync(join(fix.dir, "assets")), false); + + const replacement = join(fix.dir, "clip.mp4"); + writeFileSync(replacement, "BYTES"); + const textSegment = JSON.parse(before).tracks.find((t) => t.type === "text").segments[0]; + const textResult = spawnCli(["replace-media", fix.path, textSegment.id, replacement]); + assert.notEqual(textResult.status, 0); + assert.match(textResult.stderr, /requires a video or audio material/); + assert.equal(readFileSync(fix.path, "utf-8"), before); + assert.equal(existsSync(join(fix.dir, "assets")), false); }); it("fails clearly on an unknown segment id", () => { diff --git a/test/serve-boundaries.test.mjs b/test/serve-boundaries.test.mjs new file mode 100644 index 0000000..034228b --- /dev/null +++ b/test/serve-boundaries.test.mjs @@ -0,0 +1,467 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { after, describe, it } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { tmpDraft } from "./helpers/tmp-draft.mjs"; + +const SERVE_URL = pathToFileURL(join(dirname(fileURLToPath(import.meta.url)), "../dist/serve.js")).href; + +function setup() { + const dir = mkdtempSync(join(tmpdir(), "capcut-serve-boundaries-")); + const child = join(dir, "child.mjs"); + const runner = join(dir, "runner.mjs"); + const log = join(dir, "events.jsonl"); + writeFileSync( + runner, + `import { serveQueue } from ${JSON.stringify(SERVE_URL)}; +const options = JSON.parse(process.argv[4]); +let staleStatReads = 0; +if (options.delayPolling) { + const interval = globalThis.setInterval; + globalThis.setInterval = (callback) => interval(callback, 60000); + delete options.delayPolling; +} +if (options.staleCaptureStats) { + const { default: fs } = await import("node:fs"); + const { syncBuiltinESMExports } = await import("node:module"); + const stat = fs.statSync; + fs.statSync = (path, ...args) => { + const result = stat(path, ...args); + if (/[\\\\/]capcut-serve-[^\\\\/]+[\\\\/](stdout|stderr)$/.test(String(path))) { + staleStatReads++; + return { ...result, size: 0 }; + } + return result; + }; + syncBuiltinESMExports(); + delete options.staleCaptureStats; +} +try { + const summary = await serveQueue({ cliPath: process.argv[2], queuePath: process.argv[3], ...options }); + process.stderr.write(JSON.stringify({ summary, staleStatReads })); +} catch (error) { + process.stderr.write(error.message); + process.exitCode = 1; +} +`, + ); + return { + dir, + child, + log, + script(source) { + writeFileSync(child, source); + }, + run(jobs, options = {}) { + const queue = join(dir, "queue.jsonl"); + writeFileSync(queue, jobs.map((job) => JSON.stringify(job)).join("\n")); + const stdoutPath = join(dir, "runner.stdout"); + const stderrPath = join(dir, "runner.stderr"); + const stdout = openSync(stdoutPath, "w"); + const stderr = openSync(stderrPath, "w"); + let processResult; + try { + processResult = spawnSync(process.execPath, [runner, child, queue, JSON.stringify(options)], { + stdio: ["ignore", stdout, stderr], + timeout: 15_000, + }); + } finally { + closeSync(stdout); + closeSync(stderr); + } + const out = readFileSync(stdoutPath, "utf-8"); + const err = readFileSync(stderrPath, "utf-8"); + return { + status: processResult.status, + stdout: out, + stderr: err, + results: out.trim() ? out.trim().split("\n").map(JSON.parse) : [], + }; + }, + events() { + return readFileSync(log, "utf-8").trim().split("\n").map(JSON.parse); + }, + cleanup() { + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +function eventScript(log) { + return `import { appendFileSync } from "node:fs"; +const id = process.argv.at(-1); +appendFileSync(${JSON.stringify(log)}, JSON.stringify({ event: "begin", id }) + "\\n"); +await new Promise(resolve => setTimeout(resolve, 90)); +appendFileSync(${JSON.stringify(log)}, JSON.stringify({ event: "end", id }) + "\\n"); +process.stdout.write(JSON.stringify({ id })); +`; +} + +describe("serve queue identity and capture boundaries", () => { + it("deduplicates equal effective payloads despite omitted defaults and object key order", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script(eventScript(fixture.log)); + const r = fixture.run( + [ + { id: "same", cmd: "info", project: "/missing-project", args: ["job"] }, + { args: ["/missing-project", "job"], retries: 0, timeoutMs: 300_000, cmd: "info", id: "same" }, + ], + { workers: 2 }, + ); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.results.every((result) => result.ok)); + assert.equal(r.results.filter((result) => result.deduplicated).length, 1); + assert.equal(fixture.events().filter((event) => event.event === "begin").length, 1); + }); + + it("rejects changed commands, arguments, projects, and execution limits for a used id", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script(eventScript(fixture.log)); + const original = { id: "bound", cmd: "info", project: "/project-one", args: ["original"] }; + const r = fixture.run( + [ + original, + { ...original, cmd: "tracks" }, + { ...original, args: ["changed"] }, + { ...original, project: "/project-two" }, + { ...original, timeoutMs: 1000 }, + { ...original, retries: 1 }, + ], + { workers: 6 }, + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.results.filter((result) => result.ok).length, 1); + const rejected = r.results.filter((result) => !result.ok); + assert.equal(rejected.length, 5); + assert.ok(rejected.every((result) => /different command payload/.test(result.stderr))); + assert.ok(rejected.every((result) => !result.deduplicated && result.status === null)); + assert.equal(fixture.events().filter((event) => event.event === "begin").length, 1); + }); + + it("keeps changed credential values out of dedup conflict results", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script('process.stdout.write("{}")'); + const r = fixture.run( + [ + { id: "credential", cmd: "translate", args: ["--api-key", "secret-one"] }, + { id: "credential", cmd: "translate", args: ["--api-key", "secret-two"] }, + ], + { workers: 2 }, + ); + assert.doesNotMatch(r.stdout, /secret-one|secret-two/); + assert.equal(r.results.filter((result) => !result.ok).length, 1); + assert.ok(r.results.every((result) => result.args.at(-1) === "***")); + }); + + it("serializes directory, timeline-file, relative, and symlink project aliases", () => { + const fixture = setup(); + const draft = tmpDraft(); + after(fixture.cleanup); + after(draft.cleanup); + const alias = join(fixture.dir, "project-alias"); + symlinkSync(draft.dir, alias, process.platform === "win32" ? "junction" : "dir"); + fixture.script(eventScript(fixture.log)); + const jobs = [ + { cmd: "info", project: draft.dir, args: ["directory"] }, + { cmd: "info", project: draft.path, args: ["file"] }, + { cmd: "info", args: ["--human", relative(process.cwd(), draft.dir), "relative"] }, + { cmd: "info", args: [join(alias, "draft_content.json"), "symlink"] }, + ]; + if (process.platform === "win32") { + jobs.push({ cmd: "info", project: draft.path.toUpperCase(), args: ["case-alias"] }); + } else { + const fileAlias = join(fixture.dir, "file-alias.json"); + symlinkSync(draft.path, fileAlias, "file"); + jobs.push({ cmd: "info", project: fileAlias, args: ["file-symlink"] }); + } + const r = fixture.run(jobs, { workers: jobs.length }); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.results.every((result) => result.ok)); + const events = fixture.events(); + assert.deepEqual( + events.map((event) => event.event), + jobs.flatMap(() => ["begin", "end"]), + ); + }); + + it("serializes an active nested timeline with its root project and mirror", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script(eventScript(fixture.log)); + const root = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "capcut-8.7-windows-active"); + const r = fixture.run( + [ + { cmd: "info", project: root, args: ["root"] }, + { cmd: "info", project: join(root, "draft_content.json"), args: ["mirror"] }, + { + cmd: "info", + args: [join(root, "Timelines", "C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52", "draft_content.json"), "active"], + }, + ], + { workers: 3 }, + ); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual( + fixture.events().map((event) => event.event), + ["begin", "end", "begin", "end", "begin", "end"], + ); + }); + + it("serializes --into append targets with ordinary project mutations", () => { + const fixture = setup(); + const draft = tmpDraft(); + after(fixture.cleanup); + after(draft.cleanup); + fixture.script(eventScript(fixture.log)); + const r = fixture.run( + [ + { cmd: "import-timeline", args: ["cut.otio", "--into", draft.dir, "append"] }, + { cmd: "info", args: [draft.path, "inspect"] }, + ], + { workers: 2 }, + ); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual( + fixture.events().map((event) => event.event), + ["begin", "end", "begin", "end"], + ); + }); + + it("locks the named creation destination, rather than the name in the working directory", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script(eventScript(fixture.log)); + const target = join(fixture.dir, "Created"); + const r = fixture.run( + [ + { cmd: "init", args: ["Created", "--drafts", fixture.dir, "init"] }, + { cmd: "quickstart", args: ["Created", "--drafts", fixture.dir, "quickstart"] }, + { cmd: "info", args: [target, "directory"] }, + { cmd: "info", args: [join(target, "draft_content.json"), "file"] }, + ], + { workers: 4 }, + ); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual( + fixture.events().map((event) => event.event), + ["begin", "end", "begin", "end", "begin", "end", "begin", "end"], + ); + }); + + it("locks explicit new project destinations shared by compile, import, and translated drafts", () => { + const fixture = setup(); + const draft = tmpDraft(); + after(fixture.cleanup); + after(draft.cleanup); + fixture.script(eventScript(fixture.log)); + const target = join(fixture.dir, "Built"); + const r = fixture.run( + [ + { cmd: "compile", args: ["spec.json", "--out", target, "compile"] }, + { cmd: "import-timeline", args: ["cut.otio", "--out", target, "import"] }, + { + cmd: "translate", + project: draft.dir, + args: ["--out", join(target, "draft_content.json"), "translate"], + }, + { cmd: "info", args: [target, "inspect"] }, + ], + { workers: 4 }, + ); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual( + fixture.events().map((event) => event.event), + ["begin", "end", "begin", "end", "begin", "end", "begin", "end"], + ); + }); + + it("locks both source projects and the sibling destination of a rename", () => { + const fixture = setup(); + const first = tmpDraft(); + const second = tmpDraft(); + after(fixture.cleanup); + after(first.cleanup); + after(second.cleanup); + fixture.script(eventScript(fixture.log)); + const newName = `renamed-${first.dir.split(/[/\\]/).at(-1)}`; + const r = fixture.run( + [ + { cmd: "concat", args: [first.dir, second.dir, "concat"] }, + { cmd: "rename", args: [first.path, newName, "rename"] }, + { cmd: "info", args: [join(dirname(first.dir), newName), "renamed-target"] }, + { cmd: "info", args: [second.path, "second-source"] }, + ], + { workers: 4 }, + ); + assert.equal(r.status, 0, r.stderr); + const events = fixture.events(); + const indexOf = (event, id) => events.findIndex((entry) => entry.event === event && entry.id === id); + assert.ok(indexOf("end", "concat") < indexOf("begin", "rename")); + assert.ok(indexOf("end", "concat") < indexOf("begin", "second-source")); + assert.ok(indexOf("end", "rename") < indexOf("begin", "renamed-target")); + }); + + it("allows separate projects to run concurrently and does not treat non-project arguments as projects", () => { + const fixture = setup(); + const first = tmpDraft(); + const second = tmpDraft(); + after(fixture.cleanup); + after(first.cleanup); + after(second.cleanup); + fixture.script(`import { appendFileSync, readFileSync } from "node:fs"; +const log = ${JSON.stringify(fixture.log)}; +const id = process.argv.at(-1); +appendFileSync(log, JSON.stringify({ event: "begin", id }) + "\\n"); +const deadline = Date.now() + 4000; +while (readFileSync(log, "utf-8").trim().split("\\n").map(JSON.parse).filter(event => event.event === "begin").length < 3) { + if (Date.now() > deadline) process.exit(2); + await new Promise(resolve => setTimeout(resolve, 10)); +} +appendFileSync(log, JSON.stringify({ event: "end", id }) + "\\n"); +process.stdout.write(JSON.stringify({ id }));`); + const r = fixture.run( + [ + { cmd: "info", args: [first.dir, "first"] }, + { cmd: "info", args: [second.dir, "second"] }, + { cmd: "catalogue", args: [first.dir, "catalogue"] }, + ], + { workers: 3 }, + ); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.results.every((result) => result.ok)); + assert.deepEqual( + fixture + .events() + .slice(0, 3) + .map((event) => event.event), + ["begin", "begin", "begin"], + ); + }); + + it("detects combined output at child exit before reading when it misses the polling ticks", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script( + 'import { writeSync } from "node:fs"; writeSync(1, "o".repeat(600)); writeSync(2, "e".repeat(600));', + ); + const r = fixture.run([{ cmd: "info" }], { maxBufferBytes: 1000, delayPolling: true }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.results[0].status, 0); + assert.equal(r.results[0].ok, false); + assert.equal(r.results[0].overflow, true); + assert.equal(r.results[0].stdout, undefined); + assert.match(r.results[0].stderr, /output exceeded 1000 bytes/); + assert.doesNotMatch(r.stdout, /o{10}|e{10}/); + }); + + it("preserves output exactly at the combined capture threshold", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script( + 'import { writeSync } from "node:fs"; writeSync(1, "o".repeat(500)); writeSync(2, "e".repeat(500));', + ); + const r = fixture.run([{ cmd: "info" }], { maxBufferBytes: 1000 }); + assert.equal(r.results[0].ok, true); + assert.equal(r.results[0].stdout, "o".repeat(500)); + assert.equal(r.results[0].stderr, "e".repeat(500)); + }); + + it("bounds reads even when capture-size observations are stale", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script('import { writeSync } from "node:fs"; writeSync(1, "x".repeat(3000));'); + const r = fixture.run([{ cmd: "info" }], { maxBufferBytes: 1000, staleCaptureStats: true }); + assert.equal(r.status, 0, r.stderr); + assert.ok(JSON.parse(r.stderr).staleStatReads >= 2, "the fixture must return stale capture sizes"); + assert.equal(r.results[0].overflow, true); + assert.equal(r.results[0].stdout, undefined); + assert.doesNotMatch(r.stdout, /x{10}/); + }); + + it("does not repeat an operation that exhausts its output capture threshold", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script(`import { appendFileSync, writeSync } from "node:fs"; +appendFileSync(${JSON.stringify(fixture.log)}, '{}\\n'); +writeSync(1, 'x'.repeat(2000));`); + const r = fixture.run([{ cmd: "info" }], { maxBufferBytes: 1000, retries: 2, backoffMs: 0 }); + assert.equal(r.results[0].overflow, true); + assert.equal(r.results[0].attempts, 1); + assert.equal(fixture.events().length, 1); + }); + + it("rejects malformed jobs without coercing arguments or spawning and continues to valid jobs", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script(eventScript(fixture.log)); + const invalid = [ + null, + [], + { cmd: "" }, + { cmd: "info", id: 7 }, + { cmd: "info", id: "" }, + { cmd: "info", project: [] }, + { cmd: "info", args: "not-an-array" }, + { cmd: "info", args: [42] }, + { cmd: "info", args: ["nul\0argument"] }, + { cmd: "info", timeoutMs: 0 }, + { cmd: "info", timeoutMs: 2_147_483_648 }, + { cmd: "info", timeoutMs: 1.5 }, + { cmd: "info", retries: -1 }, + { cmd: "info", retries: 1.5 }, + { cmd: "info", retries: "1" }, + { cmd: "info", retries: null }, + ]; + const r = fixture.run([...invalid, { cmd: "info", args: ["valid"] }]); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.results.filter((result) => !result.ok).length, invalid.length); + assert.equal(r.results.filter((result) => result.ok).length, 1); + assert.equal(fixture.events().filter((event) => event.event === "begin").length, 1); + }); + + it("rejects invalid queue limits before starting any jobs", () => { + const fixture = setup(); + after(fixture.cleanup); + fixture.script('process.stdout.write("{}")'); + for (const options of [ + { workers: 0 }, + { workers: 1.5 }, + { workers: 33 }, + { retries: -1 }, + { retries: 0.5 }, + { timeoutMs: 0 }, + { timeoutMs: 2_147_483_648 }, + { timeoutMs: "10" }, + { backoffMs: -1 }, + { backoffMs: 1.5 }, + { maxBufferBytes: 0 }, + { maxBufferBytes: 1.5 }, + { maxBufferBytes: null }, + ]) { + const r = fixture.run([{ cmd: "info" }], options); + assert.equal(r.status, 1, JSON.stringify(options)); + assert.match(r.stderr, /must be an integer/); + assert.equal(r.results.length, 0); + } + }); + + it("rejects non-finite numeric limits before reading a queue", async () => { + const { serveQueue } = await import(SERVE_URL); + for (const [field, value] of [ + ["workers", Number.NaN], + ["workers", Number.POSITIVE_INFINITY], + ["retries", Number.NEGATIVE_INFINITY], + ["timeoutMs", Number.POSITIVE_INFINITY], + ["backoffMs", Number.NaN], + ["maxBufferBytes", Number.POSITIVE_INFINITY], + ]) { + await assert.rejects(serveQueue({ cliPath: "unused.mjs", [field]: value }), /must be an integer/); + } + }); +});