From 7d485944c1d84b246f8fe77de72430ab078eab90 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Sat, 3 Oct 2026 11:12:35 +0000 Subject: [PATCH] Fix Windows active timeline writes, compile metadata, and fixture redaction --- CHANGELOG.md | 6 + docs/command-reference.json | 6 +- docs/reviews/issues-50-133-134.md | 86 +++++++ docs/version-support.md | 1 + docs/version-support.zh-CN.md | 1 + src/command-specs.ts | 9 +- src/compile.ts | 81 +++--- src/draft.ts | 2 + src/factory.ts | 16 +- src/fixture.ts | 28 ++- src/index.ts | 46 ++-- src/render.ts | 3 +- src/replace.ts | 5 +- src/store.ts | 236 +++++++++++++++--- test/active-timeline.test.mjs | 177 +++++++++++++ test/compile-media-canvas.test.mjs | 132 ++++++++++ test/fixture-check.test.mjs | 37 ++- .../capcut-8.7-windows-active/README.md | 21 ++ .../draft_content.json | 1 + .../Timelines/project.json | 1 + .../draft_content.json | 1 + .../draft_meta_info.json | 1 + .../capcut-8.7-windows-active/template-2.tmp | 1 + test/sync-timelines-nested.test.mjs | 4 +- 24 files changed, 796 insertions(+), 106 deletions(-) create mode 100644 docs/reviews/issues-50-133-134.md create mode 100644 test/active-timeline.test.mjs create mode 100644 test/compile-media-canvas.test.mjs create mode 100644 test/fixtures/capcut-8.7-windows-active/README.md create mode 100644 test/fixtures/capcut-8.7-windows-active/Timelines/C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52/draft_content.json create mode 100644 test/fixtures/capcut-8.7-windows-active/Timelines/project.json create mode 100644 test/fixtures/capcut-8.7-windows-active/draft_content.json create mode 100644 test/fixtures/capcut-8.7-windows-active/draft_meta_info.json create mode 100644 test/fixtures/capcut-8.7-windows-active/template-2.tmp diff --git a/CHANGELOG.md b/CHANGELOG.md index 057ff57..177a77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to capcut-cli are documented here. The format follows [Keep ## [Unreleased] +### Fixed + +- `fixture` redacts JSON-escaped Windows home paths, including paths inside JSON strings (#134). Every new bundle automatically runs the existing residual-value check; the CLI exits nonzero and `SANITIZE_REPORT.json` records failure if a recognizable leak remains. `fixture --check` still verifies an existing bundle. +- `compile` resolves ratio-only and explicit-dimension canvases through the same resolver as `init` and `quickstart`, including `--check`. Video/audio material and registration durations use the full probed source duration while segments retain their requested durations and in-points. Source ranges, including speed, are validated before any draft is created (#133). +- On the fixture-backed CapCut 8.7.0 Windows layout, reads follow the validated `Timelines/project.json` active pointer. Normal writes synchronize that document, its readable mirrors, and the readable root mirrors through the transactional write path, preserving document IDs and other timelines. `sync-timelines` uses active → root on this layout, including with `--nested`; existing divergent root edits remain visible in its plan and newer-mirror gate. Assets and metadata remain at the project root. Other versions/OSes keep their prior selection behavior; a patched app round-trip remains pending (#50). + ## [0.26.0] — 2026-09-25 ### Added diff --git a/docs/command-reference.json b/docs/command-reference.json index 9e056ef..dd6dd0c 100644 --- a/docs/command-reference.json +++ b/docs/command-reference.json @@ -5137,7 +5137,7 @@ ], "type": "boolean", "required": false, - "description": "Scan the finished bundle (SANITIZE_REPORT.json and README included) for residual home paths, emails, device ids and the account name, reporting file:line per finding and exiting non-zero on any. With only a bundle directory as the argument, re-checks an existing bundle without rebuilding." + "description": "Scan the finished bundle (SANITIZE_REPORT.json and README included) for residual home paths, emails, device ids and the account name, reporting file:line per finding and exiting non-zero on any. New bundles are checked automatically. With only a bundle directory as the argument, re-checks an existing bundle without rebuilding." } ], "mutates": false, @@ -5170,7 +5170,7 @@ ], "type": "boolean", "required": false, - "description": "Rewrite only the drifted mirror files from draft_content.json (default: print the plan only)." + "description": "Rewrite only the drifted mirrors from the canonical timeline (default: print the plan only). On the evidenced Windows 8.7.0 active layout, the selected nested document is canonical." }, { "name": "nested", @@ -5179,7 +5179,7 @@ ], "type": "boolean", "required": false, - "description": "Also reconcile the nested Timelines// documents (draft_info.json, draft_content.json, template-2.tmp), each keeping its own GUID — the workaround verified on CapCut Mac 9.2.8 in issue #50, as an explicit opt-in. Timelines/project.json is never touched." + "description": "Also reconcile the nested Timelines// documents (draft_info.json, draft_content.json, template-2.tmp), each keeping its own GUID — the workaround verified on CapCut Mac 9.2.8 in issue #50, as an explicit opt-in. On Windows 8.7.0 active layouts only the selected timeline and root mirrors are reconciled, even with this flag. Timelines/project.json is never touched." } ], "mutates": true, diff --git a/docs/reviews/issues-50-133-134.md b/docs/reviews/issues-50-133-134.md new file mode 100644 index 0000000..cd9a0bf --- /dev/null +++ b/docs/reviews/issues-50-133-134.md @@ -0,0 +1,86 @@ +# Review: active timelines, compilation, and fixture redaction + +This patch addresses #133 and #134 and the CapCut **8.7.0 Windows** case in #50. +The Windows field fixture is credited in `test/fixtures/capcut-8.7-windows-active/README.md`. +It proves file structure and divergence. The reporter supplied the sentinel +open/close observation; a patched CLI round-trip still needs to run in CapCut. + +## Build and automated checks + +```sh +npm ci +npm run build +npm run lint +node --test test/active-timeline.test.mjs test/compile-media-canvas.test.mjs test/fixture-check.test.mjs +npm test +``` + +## Fixture redaction (#134) + +With CapCut closed, collect a bundle from a Windows project: + +```sh +node dist/index.js fixture --out +node dist/index.js fixture --check +``` + +Both should succeed. `redaction_check.ok` must be true without specifying +`--check` during creation. Inspect `draft_meta_info.json`: `draft_root_path` +must contain `C:\\Users\\USER`, with no real account name. JSON strings +inside other JSON must remain parseable. Check the reports too. Copy a test +JSON with an unredacted home path into the bundle and re-check: expect exit 1, +file/line findings, and no private value echoed to stderr. A failed check means +the bundle still needs review before sharing. + +## Compiler (#133) + +Use the issue's spec and a source whose duration is known. Run: + +```sh +node dist/index.js compile --check +node dist/index.js compile --out +node dist/index.js lint +``` + +For `ratio: "9:16"`, the plan and every written document must say 1080×1920 +and `9:16`. For the reported 89.28-second source, materials and sidecar entries +must have duration 89,280,000 µs. Segments stay six seconds long and retain +source starts 20,000,000 and 40,000,000 µs. The source-range warning should +disappear. Open the new draft in the app and verify the portrait canvas and +that the two cuts show their intended source moments. This also tests how +the app materializes a newly created, root-only project; that behavior is +separate from selecting a pre-existing active timeline. + +Try a source start plus speed-adjusted duration beyond the source end. Both +`--check` and compilation should reject it before creating an output folder. + +## Existing active project (#50) + +Use a copy of an app-created project from CapCut 8.7.0.3685 on Windows, with +the app fully closed. Retain a backup of the complete folder. + +```sh +node dist/index.js diagnose +node dist/index.js texts +node dist/index.js set-text "ACTIVE TIMELINE PATCH" +node dist/index.js diagnose +``` + +The first report must select `Timelines//draft_content.json` +and show the active timeline ID. If roots already contain unsynchronized old +CLI edits, they appear as divergence; review those copies before doing a new +edit, since a normal write starts from the active timeline. For an explicit +repair, `sync-timelines ` previews the active → root direction and +retains its newer-mirror refusal unless deliberately overridden. + +After the write, the active document and readable mirrors must all contain +the new caption. IDs, envelopes, `Timelines/project.json`, and other timelines +must be preserved. The final diagnose report should show no content divergence. +Open CapCut: verify the new caption. Close the project, re-run `texts` and +`diagnose`, and verify that the caption survives. Repeat with `add-text` and +`add-video`; imported assets and `draft_materials` must live at the project +root. Test `restore` to confirm the active document and mirrors return together. + +Record the desktop version, OS, commands, and before/after result in #50. +Keep #50 open until this patched round-trip passes. The patch makes no new +compatibility claim for 7.x, Mac 9.2.8-beta4, or other Windows versions. diff --git a/docs/version-support.md b/docs/version-support.md index c01d646..5074f0e 100644 --- a/docs/version-support.md +++ b/docs/version-support.md @@ -20,6 +20,7 @@ Run `capcut version ` for schema flags and `capcut diagnose - | 6.5–8.0 | expected-compatible | unverified | No committed app-created fixtures. Enum/schema changes appear additive. | | 7.9 / 8.9 (International, macOS) | reported | one field measured | A scan of 38 app-authored drafts on one machine established that `materials.texts[].content` → `styles[].range` holds UTF-16 code units, not UTF-16LE bytes: 211 text materials in code units, none in bytes ([#85](https://github.com/renezander030/capcut-cli/issues/85)). That settled a real writer bug, fixed in 0.19.1. It is a measurement of one field, not a suite run — no sanitized app-created folder from either version is committed yet, so these versions stay off "fixture-tested". | | 8.4.0 (International, macOS) | reported | root → nested on first open | A draft created from a copy of a real 8.4 project (`init --template`), edited on the root files only, opened with every segment intact; the app then created `Timelines/project.json` and `Timelines//` itself from the root documents ([#50](https://github.com/renezander030/capcut-cli/issues/50), IrizaD, 2026-08-31). The bundled 6.5.0 template's draft was refused on the same build (see the 8.7 row). This is the materialisation direction v0.23's seeding relies on: a seeded draft ships no `Timelines/` and lets the app build it. | +| 8.7.0 Windows, existing active timeline | fixture-tested storage selection; patched desktop round-trip pending | active document → root mirrors | The app-authored fixture from [#50](https://github.com/renezander030/capcut-cli/issues/50#issuecomment-5954728670), fpisasale / Spirito Digitale, 2026-10-02, is committed under `test/fixtures/capcut-8.7-windows-active/`. A valid active pointer selects the nested document; ordinary writes synchronize only that timeline and the root mirrors. Other timelines are preserved. The reporter observed nested content winning on open and close on 8.7.0.3685; a patched app round-trip remains pending. This evidence is specific to this storage shape and OS; the version-only registry retains its narrower existing evidence label. | | 8.7 Windows | reported + synthetic-tested + one real round-trip (negative with the bundled template, positive with a captured one) | adapter shipped; seed from the store | Issue #35 reports that `draft_content.json` edits may be ignored in favour of `template-2.tmp` / `draft_meta_info.json`. v0.11 discovers nested/string JSON timeline envelopes, selects modern storage, synchronizes every readable target, and provides `diagnose --bundle` and `fixture --out` (one-command sanitized bundle). v0.13 adds `sync-timelines` to reconcile an already-drifted mirror (plan by default, `--apply` to write); `diagnose` names it as the remedy. **Real validation ([#111](https://github.com/renezander030/capcut-cli/issues/111), 2026-09-10):** every draft built from the bundled 6.5.0 template — `quickstart` included — was refused by a real 8.7.0 Windows install as "from an unusual path"; `register --materials`, `relink` and `sync-timelines` changed nothing, because the cause is the template's stale markers (#67), not the path. Compiled against a template captured from the installed app, the same pipeline round-tripped on 9.3.0 (open, edit, save, close, reopen — edit persisted). v0.23 makes that the default: `init` / `quickstart` / `compile` seed new drafts from the store's newest app-authored project, and `migrate --from-store` restamps drafts already built. A reporter-provided real folder is still required before marking this fixture-tested. | | 9.2.8-beta4 (International, macOS) | reported | nested document authoritative — artifact pending | On this build the app reads `Timelines//draft_info.json`; the root `draft_info.json` / `template-2.tmp` are legacy mirrors, so a root-only write is a silent no-op from the app's point of view ([#50](https://github.com/renezander030/capcut-cli/issues/50), k12ktv, 2026-08-23). `sync-timelines --nested --apply` is the opt-in repair for a project whose nested document exists; the canonical flip stays gated on the requested fixture. Note that `init --template ` used to copy the donor's `Timelines/` too, which is exactly how the report's new draft opened with the donor's empty timeline — v0.23 never carries `Timelines/` over. | | 9.3.0 (International, macOS) | reported | opens tool-built drafts seeded from a real project | The #111 reporter's real round-trip (above), and the shell-first recipe published at [zxypro1/capcut-shell-inject](https://github.com/zxypro1/capcut-shell-inject) (2026-09-11): create an empty project in the app, quit, write into that folder with this CLI, then `sync-timelines --nested --apply` and `register --materials --apply`. Both are the same finding — the app accepts a draft whose schema markers it wrote itself — and `--template auto` (the v0.23 default) is that recipe automated. Also on 9.3.0: a video material's blank `local_material_id` leaves the clip unresolvable with no in-app repair ([JmsLdrn/capcut-mcp#1](https://github.com/JmsLdrn/capcut-mcp/issues/1)); v0.23 links it at add time and `lint --fix` repairs existing drafts (`media-unlinked`). | diff --git a/docs/version-support.zh-CN.md b/docs/version-support.zh-CN.md index 1d8a333..00de1de 100644 --- a/docs/version-support.zh-CN.md +++ b/docs/version-support.zh-CN.md @@ -21,6 +21,7 @@ CapCut 和剪映在不断演进一套没有文档记录的本地磁盘 schema。 | 6.2.8 | fixture-tested | 已支持 | 权威 fixture 位于 `test/draft_content.json`;完整命令集均可用。 | | 6.5–8.0 | expected-compatible | 未验证 | 尚无已提交的、由应用创建的 fixture。枚举/schema 变化看起来是增量式的。 | | 7.9 / 8.9(国际版,macOS) | reported | 仅实测一个字段 | 在一台机器上扫描 38 份由 App 创建的草稿后确认:`materials.texts[].content` → `styles[].range` 存的是 UTF-16 码元,而非 UTF-16LE 字节 —— 211 个文本素材全部是码元,没有一个是字节([#85](https://github.com/renezander030/capcut-cli/issues/85))。这解决了一个真实的写入 bug,已在 0.19.1 修复。这只是对一个字段的实测,不是完整套件的测试 —— 这两个版本目前都还没有已提交的、脱敏后的应用创建文件夹,因此暂不标注为 fixture-tested。 | +| 8.7.0 Windows,已有活动时间线 | 存储选择 fixture-tested;修补版桌面往返待验证 | 活动文档 → 根目录镜像 | [#50](https://github.com/renezander030/capcut-cli/issues/50#issuecomment-5954728670) 的应用创建样本已保存于 `test/fixtures/capcut-8.7-windows-active/`。有效活动指针选择嵌套文档,普通写入同步该时间线与根目录镜像,保留其他时间线。报告者在 8.7.0.3685 上观察到嵌套内容在打开及关闭时获胜;修补版仍需桌面往返测试。此证据仅适用于该 Windows 存储形状,按版本查询的注册表保持原有证据标签。 | | 8.7 Windows | reported + synthetic-tested | 适配已发布,真实验证待定 | Issue #35 报告称,对 `draft_content.json` 的修改可能被应用忽略,转而采用 `template-2.tmp` / `draft_meta_info.json`。v0.11 起可发现嵌套/字符串形式的 JSON 时间线信封,会选择更新的存储,同步每一个可读的目标,并提供 `diagnose --bundle` 与 `fixture --out`(一条命令生成脱敏包)。v0.13 新增 `sync-timelines`,用于协调已经漂移的镜像(默认只出计划,`--apply` 才写入);`diagnose` 会把它列为应对方案。在标记为 fixture-tested 之前,仍需要报告者提供一份真实文件夹。 | | 9.x | expected-compatible | 未验证 | `common_masks` 可能与旧版蒙版字段共存。请使用 `version`、`diagnose` 和 `migrate`;不要把这一行视为桌面应用层面的验证。 | | 10.x(Mac 与 Windows) | reported | 写入被护栏拦截 | 据报告,新版本会把工具写入的草稿判定为已损坏(「内容已损坏」;pyJianYingDraft#177、#194 对应剪映 10.8 上的同类问题;Mac 主文件据报告为 `draft_info.json`,Jianying-CapCut2XML#4)。目前没有 fixture;修改类命令在没有 `--force-write` 时会拒绝执行。欢迎提供 fixture —— 见下方「写入时版本护栏」一节。 | diff --git a/src/command-specs.ts b/src/command-specs.ts index 4e6acc6..046dae8 100644 --- a/src/command-specs.ts +++ b/src/command-specs.ts @@ -726,7 +726,8 @@ const optionsByCommand: Record = { "boolean", "Scan the finished bundle (SANITIZE_REPORT.json and README included) for residual home paths, emails, " + "device ids and the account name, reporting file:line per finding and exiting non-zero on any. " + - "With only a bundle directory as the argument, re-checks an existing bundle without rebuilding.", + "New bundles are checked automatically. With only a bundle directory as the argument, " + + "re-checks an existing bundle without rebuilding.", ), ], "sync-timelines": [ @@ -734,7 +735,8 @@ const optionsByCommand: Record = { "apply", ["--apply"], "boolean", - "Rewrite only the drifted mirror files from draft_content.json (default: print the plan only).", + "Rewrite only the drifted mirrors from the canonical timeline (default: print the plan only). " + + "On the evidenced Windows 8.7.0 active layout, the selected nested document is canonical.", ), option( "nested", @@ -742,7 +744,8 @@ const optionsByCommand: Record = { "boolean", "Also reconcile the nested Timelines// documents (draft_info.json, draft_content.json, template-2.tmp), " + "each keeping its own GUID — the workaround verified on CapCut Mac 9.2.8 in issue #50, as an explicit opt-in. " + - "Timelines/project.json is never touched.", + "On Windows 8.7.0 active layouts only the selected timeline and root mirrors are reconciled, " + + "even with this flag. Timelines/project.json is never touched.", ), ], "replace-media": [ diff --git a/src/compile.ts b/src/compile.ts index 3da00f7..9856f0a 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -20,9 +20,10 @@ import { applyTemplate, copyTextStyle, initDraft, + resolveCanvas, setAudioFade, } from "./factory.js"; -import { probeMedia } from "./probe.js"; +import { type MediaProbe, probeMedia } from "./probe.js"; import { parseSrt } from "./srt.js"; /** @@ -204,6 +205,7 @@ export function validateSpec(spec: unknown): asserts spec is CompileSpec { if (!spec || typeof spec !== "object") 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 (!Array.isArray(s.tracks) || s.tracks.length === 0) { throw new Error("compile: spec.tracks must be a non-empty array"); } @@ -226,10 +228,16 @@ export function validateSpec(spec: unknown): asserts spec is CompileSpec { throw new Error(`compile: ${where}.ref must be a non-empty string`); } for (const field of ["speed", "opacity", "rotation", "scale", "sourceStart"] as const) { - if (item[field] !== undefined && typeof item[field] !== "number") { + if (item[field] !== undefined && (typeof item[field] !== "number" || !Number.isFinite(item[field]))) { throw new Error(`compile: ${where}.${field} must be a number`); } } + if (typeof item.sourceStart === "number" && item.sourceStart < 0) { + throw new Error(`compile: ${where}.sourceStart must be >= 0`); + } + if (typeof item.speed === "number" && item.speed <= 0) { + throw new Error(`compile: ${where}.speed must be > 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`); @@ -365,7 +373,33 @@ export function substitutePlaceholders(value: T, row: Record return value; } +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); + 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) { + throw new Error( + `compile: duration omitted for ${item.path}, but ffprobe could not determine it. Pass duration explicitly or install ffprobe.`, + ); + } + const sourceEnd = sourceStart + Math.round(duration * speed); + 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)`, + ); + } + // Without probe evidence, keep the entire requested source range addressable. + // This is a lower bound, not a claim about the actual file's duration. + return { duration, sourceDuration: photo ? duration : (media?.durationUs ?? sourceEnd) }; +} + export function planCompile(spec: CompileSpec, specDir: string): CompilePlan { + validateSpec(spec); + const canvas = resolveCanvas(spec); const media: string[] = []; const refs: string[] = []; for (const track of spec.tracks) { @@ -374,9 +408,7 @@ export function planCompile(spec: CompileSpec, specDir: string): CompilePlan { if (track.type === "text") 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 (item.duration === undefined && !probeMedia(abs)?.durationUs) { - throw new Error(`compile: duration omitted for ${item.path}, but ffprobe could not determine it`); - } + itemTiming(item, probeMedia(abs)); media.push(abs); } } @@ -391,10 +423,10 @@ export function planCompile(spec: CompileSpec, specDir: string): CompilePlan { ok: true, name: spec.name ?? "compiled-draft", canvas: { - width: spec.width ?? 1920, - height: spec.height ?? 1080, + width: canvas?.width ?? 1920, + height: canvas?.height ?? 1080, fps: spec.fps ?? 30, - ratio: spec.ratio ?? "original", + ratio: canvas?.ratio ?? "original", }, tracks: spec.tracks.length, items: spec.tracks.reduce((sum, track) => sum + track.items.length, 0), @@ -422,19 +454,15 @@ export function compileDraft(spec: CompileSpec, opts: CompileOptions): CompileRe templateDir: opts.templateDir, draftsDir: dirname(opts.outDir), seed: opts.seed, + 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. - if (spec.width && spec.height) { - draft.canvas_config = { - width: spec.width, - height: spec.height, - ratio: spec.ratio ?? draft.canvas_config?.ratio ?? "original", - }; - } + const canvas = resolveCanvas(spec); + if (canvas) draft.canvas_config = canvas; if (spec.fps) draft.fps = spec.fps; draft.name = displayName; @@ -447,28 +475,16 @@ export function compileDraft(spec: CompileSpec, opts: CompileOptions): CompileRe const start = Math.round(item.start * US); const sourcePath = track.type === "text" ? null : resolvePath(item.path as string, opts.specDir); const media = sourcePath ? probeMedia(sourcePath) : null; - const duration = item.duration !== undefined ? Math.round(item.duration * US) : (media?.durationUs ?? 0); - if (track.type !== "text" && duration <= 0) { - throw new Error( - `compile: duration omitted for ${item.path}, but ffprobe could not determine it. ` + - "Pass duration explicitly or install ffprobe.", - ); - } - if ( - item.duration !== undefined && - media?.durationUs && - item.type !== "photo" && - duration > media.durationUs + 10_000 - ) { - throw new Error( - `compile: duration for ${item.path} exceeds source duration (${duration} > ${media.durationUs}us)`, - ); - } + const { duration, sourceDuration } = + track.type === "text" + ? { duration: Math.round((item.duration as number) * US), sourceDuration: 0 } + : itemTiming(item, media); if (track.type === "video") { const result = addVideo(draft, filePath, { path: sourcePath as string, start, duration, + sourceDuration, type: item.type, width: item.width ?? media?.width ?? undefined, height: item.height ?? media?.height ?? undefined, @@ -482,6 +498,7 @@ export function compileDraft(spec: CompileSpec, opts: CompileOptions): CompileRe path: sourcePath as string, start, duration, + sourceDuration, volume: item.volume, trackName: track.name, }); diff --git a/src/draft.ts b/src/draft.ts index e5042e8..69181ec 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -18,6 +18,7 @@ import { basename, dirname, join, resolve } from "node:path"; import { appVersionEvidence, formatAppVersionDriftWarning, trackAppVersion } from "./app-versions.js"; import { stripBom } from "./bom.js"; import { + assertActiveTimelineUnchanged, type DraftCandidate, type DraftStore, discoverDraftStore, @@ -409,6 +410,7 @@ export function saveDraft( process.stderr.write(`WARNING: ${nestedTimelinesWriteWarning(store.version)}\n`); } + assertActiveTimelineUnchanged(store); if (!forceWrite) assertTargetsUnchangedOnDisk(store.targets); sortTracks(draft); diff --git a/src/factory.ts b/src/factory.ts index bdc9a77..f5e5247 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -18,7 +18,7 @@ import type { Draft, Segment, Timerange, Track } from "./draft.js"; import { findMaterialGlobal, findSegment, makeTrack, writeAtomic } from "./draft.js"; import { findEnum, type Namespace } from "./enums.js"; import { PHOTO_META_DURATION_US, registerMediumInSidecar } from "./materials-register.js"; -import { isManagedDraftPath, parseCandidate } from "./store.js"; +import { draftProjectDir, isManagedDraftPath, parseCandidate } from "./store.js"; import { storedTextLength } from "./text-offsets.js"; import { atLeast, versionTuple } from "./version.js"; import { fetchWikimediaAsset, isWikimediaUrl, type WikimediaAsset } from "./wikimedia.js"; @@ -1889,6 +1889,7 @@ export interface AddAudioOptions { path: string; // absolute path to audio file start: number; // microseconds duration: number; // microseconds (0 = use file duration) + sourceDuration?: number; // full media duration, independent of the segment volume?: number; // 0.0-1.0, default 1.0 trackName?: string; // default "audio" // Placeholder clip (import-timeline: MissingReference / media not on disk): @@ -1909,7 +1910,7 @@ export function addAudio( // Copy file into draft assets directory (collision-safe). Placeholder clips // reference their (possibly empty/broken) path verbatim — nothing to copy. - const draftDir = dirname(filePath); + const draftDir = draftProjectDir(filePath); const assetsDir = resolve(draftDir, "assets", "audio"); const destPath = opts.placeholder ? opts.placeholder.path : copyAssetDeduped(opts.path, assetsDir, "audio.mp3"); // Use the local assets path — CapCut rewrites to placeholder on open @@ -1932,7 +1933,7 @@ export function addAudio( id: matId, path: localPath, name: filename, - duration: opts.duration, + duration: opts.sourceDuration ?? opts.duration, type: "extract_music", category_id: "", category_name: "local", @@ -1964,7 +1965,7 @@ export function addAudio( path: localPath, name: filename, kind: "music", - durationUs: opts.duration, + durationUs: opts.sourceDuration ?? opts.duration, width: 0, height: 0, }); @@ -1991,6 +1992,7 @@ export interface AddVideoOptions { path: string; // absolute path to video/image file start: number; // microseconds duration: number; // microseconds + sourceDuration?: number; // full media duration, independent of the segment type?: "video" | "photo"; // default: inferred from extension width?: number; // default 1920 height?: number; // default 1080 @@ -2018,7 +2020,7 @@ export function addVideo( // Copy file into draft assets directory (collision-safe). Placeholder clips // reference their (possibly empty/broken) path verbatim — nothing to copy. - const draftDir = dirname(filePath); + const draftDir = draftProjectDir(filePath); const assetsDir = resolve(draftDir, "assets", "video"); const destPath = opts.placeholder ? opts.placeholder.path : copyAssetDeduped(opts.path, assetsDir, "media"); // Use the local assets path — CapCut rewrites to placeholder on open @@ -2042,7 +2044,7 @@ export function addVideo( path: localPath, material_name: filename, type: materialType, - duration: opts.duration, + duration: opts.sourceDuration ?? opts.duration, width, height, category_id: "", @@ -2102,7 +2104,7 @@ export function addVideo( path: localPath, name: filename, kind: materialType === "photo" ? "photo" : "video", - durationUs: materialType === "photo" ? PHOTO_META_DURATION_US : opts.duration, + durationUs: materialType === "photo" ? PHOTO_META_DURATION_US : (opts.sourceDuration ?? opts.duration), width, height, }); diff --git a/src/fixture.ts b/src/fixture.ts index 24dd080..b9e8ec8 100644 --- a/src/fixture.ts +++ b/src/fixture.ts @@ -30,7 +30,7 @@ interface Redactor { // Applied to the raw file text so it works regardless of envelope shape // (root JSON, nested object, or the string-JSON used by template-2.tmp). const REDACTORS: Redactor[] = [ - { kind: "windows_user", pattern: /([A-Za-z]:\\Users\\)[^\\/"<>:|?*]+/g, replace: "$1USER" }, + { kind: "windows_user", pattern: /([A-Za-z]:\\+Users\\+)[^\\/"<>:|?*]+/g, replace: "$1USER" }, { kind: "windows_user_fwd", pattern: /([A-Za-z]:\/Users\/)[^/"<>:|?*]+/g, replace: "$1USER" }, { kind: "macos_user", pattern: /(\/Users\/)[^/"]+/g, replace: "$1USER" }, { kind: "linux_user", pattern: /(\/home\/)[^/"]+/g, replace: "$1USER" }, @@ -68,6 +68,7 @@ export interface SanitizeReport { media_excluded: boolean; mask_keyframe_evidence: MaskKeyframeSummary; notes: string[]; + redaction_check: RedactionCheck; } function redact(raw: string, tally: Record): { text: string; count: number } { @@ -440,7 +441,7 @@ export function sanitizeDraftBundle(input: string, outDir: string): SanitizeRepo const { text: safeSourceDir } = redact(store.projectDir, tally); const { text: safeOutDir } = redact(out, tally); - const sanitize: SanitizeReport = { + const sanitize: Omit = { ok: true, source_dir: safeSourceDir, out_dir: safeOutDir, @@ -471,7 +472,16 @@ export function sanitizeDraftBundle(input: string, outDir: string): SanitizeRepo ], }; writeFileSync(join(out, "SANITIZE_REPORT.json"), `${JSON.stringify(sanitize, null, 2)}\n`, "utf-8"); - return sanitize; + // Check the finished bundle, including generated reports. Never advertise + // success based only on the number of substitutions made by the redactors. + const check = verifyBundleRedaction(out); + const checked: SanitizeReport = { + ...sanitize, + ok: check.ok, + redaction_check: { ...check, bundle_dir: safeOutDir }, + }; + writeFileSync(join(out, "SANITIZE_REPORT.json"), `${JSON.stringify(checked, null, 2)}\n`, "utf-8"); + return checked; } // --- Nested-Timelines evidence (issue #50) ---------------------------------- @@ -621,7 +631,11 @@ export function buildNestedTimelinesEvidence(input: string): NestedTimelinesEvid } } - const root = store.canonical; + const root = store.activeTimeline + ? (store.candidates.find((candidate) => candidate.name === "template-2.tmp" && candidate.parseable) ?? + store.candidates.find((candidate) => candidate.name === "draft_content.json" && candidate.parseable) ?? + store.canonical) + : store.canonical; const comparisons: NestedRootComparison[] = []; for (const rel of store.nestedTimelines) { if (!/^Timelines\/[^/]+\/draft_(?:info|content)\.json$/.test(rel)) continue; @@ -721,7 +735,7 @@ const CHECK_ALLOWED_EMAIL = "redacted@example.com"; // C:\Users), keeping the path shape — a bundle built from a home-dir project // legitimately contains those placeholder paths, and flagging them would fail // every correctly redacted bundle. -const CHECK_HOME_PATH = /(?:\/Users\/|\/home\/|[A-Za-z]:\\+Users\\+)([A-Za-z0-9._-]{2,})/; +const CHECK_HOME_PATH = /(?:\/Users\/|\/home\/|[A-Za-z]:\\+Users\\+)([^\\/"<>:|?*\r\n]+)/g; const CHECK_HOME_PLACEHOLDER = "USER"; const CHECK_EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; // A device key whose value is a non-empty string other than the redactor's @@ -776,8 +790,8 @@ export function verifyBundleRedaction(bundleDir: string): RedactionCheck { const lines = text.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; - const home = line.match(CHECK_HOME_PATH); - if (home && home[1] !== CHECK_HOME_PLACEHOLDER) { + const homes = [...line.matchAll(CHECK_HOME_PATH)]; + if (homes.some((home) => home[1] !== CHECK_HOME_PLACEHOLDER)) { findings.push({ file: rel, line: i + 1, kind: "home-path" }); } else if (usernameRe?.test(line)) { findings.push({ file: rel, line: i + 1, kind: "username" }); diff --git a/src/index.ts b/src/index.ts index e058f40..5b31da8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,9 +59,12 @@ import type { LintOptions } from "./lint.js"; import type { TextStylePreset } from "./preset.js"; import type { SegmentCue } from "./srt.js"; import { + ACTIVE_TIMELINE_WINDOWS_ACTION, + assertActiveTimelineUnchanged, defaultDraftsDir, diagnoseDraftStore, discoverDraftStore, + draftProjectDir, editorProcesses, NESTED_TIMELINES_MODERN_ACTION, nestedTimelinesAction, @@ -2333,7 +2336,7 @@ async function cmdImportTimeline(positional: string[], flags: Flags): Promise= 8.7 storage the layout value stays content-/info-primary by design, // so the same question needs the claim-free note instead of silence. - if (store.layout === "timelines-nested") v.support.notes.push(nestedTimelinesAction(store.version)); + if (store.activeTimeline) v.support.notes.push(ACTIVE_TIMELINE_WINDOWS_ACTION); + else if (store.layout === "timelines-nested") v.support.notes.push(nestedTimelinesAction(store.version)); else if (store.nestedTimelines.length > 0) v.support.notes.push(NESTED_TIMELINES_MODERN_ACTION); if (flags.human) { console.log(`App: ${v.app}${v.app_source !== "unknown" ? ` (${v.app_source})` : ""}`); @@ -3825,7 +3829,7 @@ async function cmdLint(draft: Draft, filePath: string, flags: Flags): Promise<{ checkLocalPaths: flags.noCheckPaths ? false : DEFAULT_LINT_OPTIONS.checkLocalPaths, probeMedia: flags.noProbe ? false : DEFAULT_LINT_OPTIONS.probeMedia, ffprobeCmd: flags.ffprobeCmd, - draftDir: path.dirname(path.resolve(filePath)), + draftDir: draftProjectDir(filePath), dryRun: isDryRun(), frameGrid: flags.frameGrid, }; @@ -3846,7 +3850,7 @@ async function cmdLint(draft: Draft, filePath: string, flags: Flags): Promise<{ d.last_modified_platform === undefined; if (markerless) { const { isManagedDraftPath } = await import("./store.js"); - const storeDir = path.dirname(path.dirname(path.resolve(filePath))); + const storeDir = path.dirname(draftProjectDir(filePath)); const inStore = existsSync(path.join(storeDir, "root_meta_info.json")) || isManagedDraftPath(path.resolve(filePath)); if (inStore) { @@ -3854,7 +3858,7 @@ async function cmdLint(draft: Draft, filePath: string, flags: Flags): Promise<{ // The linted draft is no evidence about its own store: excluded, so a // bundled-template draft alone in a JianYing store is not its own // "readable 6.5.0 project". - const scan = scanStore(storeDir, { exclude: path.dirname(path.resolve(filePath)) }); + const scan = scanStore(storeDir, { exclude: draftProjectDir(filePath) }); opts.storeAppVersion = scan.newestVersion; opts.storeEncryptedProjects = scan.store.encrypted; } @@ -4030,7 +4034,7 @@ async function cmdMigrate(draft: Draft, filePath: string, flags: Flags): Promise // the way init's seeding does. if (flags.like !== undefined || flags.fromStore) { if (flags.like !== undefined && flags.fromStore) die("--like and --from-store are mutually exclusive."); - const projectDir = path.dirname(path.resolve(filePath)); + const projectDir = draftProjectDir(filePath); let donorPath: string; if (flags.like !== undefined) { donorPath = path.resolve(flags.like); @@ -4587,10 +4591,8 @@ async function cmdRename(positional: string[], flags: Flags): Promise { // 0 ok, 1 via die(), 2 when a mirror exists that the CLI cannot reconcile. function cmdSyncTimelines(projectPath: string | undefined, flags: Flags): number { if (!projectPath) die("Usage: capcut sync-timelines [--nested] [--apply] [--force-write]"); - const { plan, canonicalDraft, canonicalCandidate, driftedCandidates, nestedDriftedCandidates } = planTimelineSync( - projectPath, - { nested: flags.nested === true }, - ); + const { store, plan, canonicalDraft, canonicalCandidate, driftedCandidates, nestedDriftedCandidates } = + planTimelineSync(projectPath, { nested: flags.nested === true }); const warnUnreconcilable = (): void => { if (flags.quiet) return; @@ -4738,6 +4740,7 @@ function cmdSyncTimelines(projectPath: string | undefined, flags: Flags): number // Optimistic concurrency: neither the canonical source nor a mirror we are // about to rewrite may have changed on disk between the plan read and now. const nestedCandidates = nestedDriftedCandidates.map((entry) => entry.candidate); + assertActiveTimelineUnchanged(store); if (!flags.forceWrite) assertTargetsUnchangedOnDisk([canonicalCandidate, ...driftedCandidates, ...nestedCandidates]); commitDraftTargets(driftedCandidates, canonicalDraft); // Nested documents keep their own GUID (issue #50's verified 9.2.8 workaround @@ -4917,7 +4920,7 @@ async function cmdRelink(draft: Draft, filePath: string, flags: Flags): Promise< 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 = path.dirname(path.resolve(filePath)); + const draftDir = draftProjectDir(filePath); const relinked: Array<{ id: string; from: string; to: string; staged: boolean }> = []; let missing = 0; let ok = 0; @@ -5614,7 +5617,10 @@ async function cmdRender(draft: Draft, filePath: string, flags: Flags): Promise< }; if (opts.dryRun) { // Build-only: surface the plan; no ffmpeg needed. - const plan = buildRenderPlan(draft, { ...opts, out: opts.out ?? path.join(path.dirname(filePath), "preview.mp4") }); + const plan = buildRenderPlan(draft, { + ...opts, + out: opts.out ?? path.join(draftProjectDir(filePath), "preview.mp4"), + }); out({ ok: true, executed: false, ...plan }, flags); return; } @@ -5928,15 +5934,19 @@ async function main(): Promise { // Scan the real output path from the flag: report.out_dir is itself // redacted (a home-dir project reports /home/USER/…), so it is display // data, not a filesystem path. - const check = flags.check ? verifyBundleRedaction(path.resolve(flags.out)) : null; - out(check ? { ...report, ok: report.ok && check.ok, redaction_check: check } : report, flags); + const check = report.redaction_check; + out(report, flags); if (!flags.quiet) { const total = Object.values(report.redaction_kinds).reduce((a, b) => a + b, 0); process.stderr.write(`Sanitized bundle: ${report.out_dir} (${report.files.length} files, ${total} redactions)\n`); - process.stderr.write(`Review the files, then attach the folder to issue #35.\n`); + process.stderr.write( + check.ok + ? "Review the files, then attach the folder to the relevant issue.\n" + : "Review the redaction findings before sharing this bundle.\n", + ); } - if (check) printCheck(check); - process.exit(check && !check.ok ? 1 : 0); + printCheck(check); + process.exit(check.ok ? 0 : 1); } // `register` reads draft_content.json directly — no loadDraft: the draft may diff --git a/src/render.ts b/src/render.ts index 7e32947..692a8fe 100644 --- a/src/render.ts +++ b/src/render.ts @@ -4,6 +4,7 @@ import { dirname, extname, join } from "node:path"; import type { Draft, Segment } from "./draft.js"; import { extractText } from "./draft.js"; import { renderSrt } from "./srt.js"; +import { draftProjectDir } from "./store.js"; /** * Headless ffmpeg proxy renderer. @@ -701,7 +702,7 @@ export function buildRenderPlan(draft: Draft, opts: RenderOptions): RenderPlan { } export function renderDraft(draft: Draft, filePath: string, opts: RenderOptions): RenderResult { - const out = opts.out ?? join(dirname(filePath), "preview.mp4"); + const out = opts.out ?? join(draftProjectDir(filePath), "preview.mp4"); const capabilities = probeFfmpegCapabilities(opts.ffmpegCmd ?? "ffmpeg"); if (!capabilities.available) { throw new Error( diff --git a/src/replace.ts b/src/replace.ts index 1a645f0..7067e15 100644 --- a/src/replace.ts +++ b/src/replace.ts @@ -7,9 +7,10 @@ // metadata (duration, dimensions). Pure JSON + file copy, like `add-video`. import { copyFileSync, existsSync, mkdirSync } from "node:fs"; -import { basename, dirname, resolve } from "node:path"; +import { basename, resolve } from "node:path"; import { type Draft, findMaterialGlobal, findSegment } from "./draft.js"; import { probeMedia } from "./probe.js"; +import { draftProjectDir } from "./store.js"; export interface ReplaceMediaOptions { segmentId: string; @@ -68,7 +69,7 @@ export function replaceMedia(draft: Draft, filePath: string, opts: ReplaceMediaO // Copy the replacement into the draft's assets dir, mirroring addVideo/addAudio. const kind = assetKind(type); - const draftDir = dirname(filePath); + const draftDir = draftProjectDir(filePath); const filename = basename(opts.newPath) || (kind === "audio" ? "audio" : "media"); const assetsDir = resolve(draftDir, "assets", kind); mkdirSync(assetsDir, { recursive: true }); diff --git a/src/store.ts b/src/store.ts index 2e187b3..44897a0 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,8 +1,8 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { homedir, platform } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { stripBom } from "./bom.js"; import type { Draft } from "./draft.js"; import { assessWriteSafety, atLeast, isPreRelease } from "./version.js"; @@ -24,6 +24,8 @@ export interface DraftCandidate { draft: Draft | null; timelineHash: string | null; error?: string; + /** Selected active-timeline writes preserve each document's own identity. */ + keepGuid?: string; } /** Which primary project file drives this store: draft_content.json (the @@ -33,9 +35,15 @@ export interface DraftCandidate { * carrying a nested Timelines/ directory (CapCut 7.x is reported to keep the * live document at Timelines//draft_info.json with the root * file a regenerated mirror — issue #50; DETECTION ONLY, reads and writes - * still target the root candidates), or neither (timeline readable only from - * a mirror such as template-2.tmp). */ -export type DraftStoreLayout = "content-primary" | "info-primary" | "timelines-nested" | "unknown"; + * still target the root candidates), the fixture-backed Windows 8.7.0 active + * timeline layout, or neither (timeline readable only from a mirror). */ +export type DraftStoreLayout = "content-primary" | "info-primary" | "timelines-nested" | "timelines-active" | "unknown"; + +interface ActiveTimeline { + id: string; + pointerPath: string; + pointerRaw: string; +} export interface DraftStore { projectDir: string; @@ -48,10 +56,11 @@ export interface DraftStore { layout: DraftStoreLayout; /** Project-relative paths of the nested Timelines/ documents (issue #50): * Timelines/project.json plus every Timelines//draft_info.json / - * draft_content.json found. Reporting only — never read as a timeline - * source, never part of the write set. Populated whenever the structure - * exists, even on >= 8.7 stores where the layout value stays untouched. */ + * draft_content.json found. This discovery list is for reporting; only the + * validated selected active documents may enter the normal write set. + * Populated whenever the structure exists, including unverified stores. */ nestedTimelines: string[]; + activeTimeline?: ActiveTimeline; } export interface DraftStoreReport { @@ -80,6 +89,7 @@ export interface DraftStoreReport { error?: string; }>; next_actions: string[]; + active_timeline?: { id: string; canonical: string }; /** Present only when the timeline references local media that * draft_meta_info.json's `draft_materials` provably does not register — * see assessMediaRegistration. Informational: no exit-code change. */ @@ -273,6 +283,11 @@ export const NESTED_TIMELINES_ACTION = "explicit opt-in repair. Evidence for this layout is report-only — if you have such a project, contribute a " + "bundle: `capcut fixture --out `."; +export const ACTIVE_TIMELINE_WINDOWS_ACTION = + "CapCut 8.7.0 Windows active timeline selected from Timelines/project.json (issue #50). Normal writes " + + "synchronize that timeline and the readable root mirrors. Other timelines are preserved. Selection is " + + "fixture-tested; a patched open/close round-trip in the app is still required."; + /** * The same structure on >= 8.7 storage, where `layout` deliberately stays at its * content-/info-primary value so the 7.x claim never relabels a modern store. @@ -288,7 +303,7 @@ export const NESTED_TIMELINES_ACTION = export const NESTED_TIMELINES_MODERN_ACTION = "Timelines/ directory with a nested timeline document, on CapCut >= 8.7 storage. No discard risk is claimed " + "here and none is ruled out: the 7.x report in issue #50 and the 8.5.0 open/close round trip in issue #68 both " + - "predate this storage generation, so what the app does with the nested document on >= 8.7 is unevidenced in " + + "predate this storage generation, so what the app does with the nested document on this store is unevidenced in " + "either direction. Edit commands read and write the project-root files only; " + "`capcut sync-timelines --nested --apply` copies the root timeline into the nested documents as an " + "explicit opt-in repair. If this project opens in your app " + @@ -366,10 +381,15 @@ export function nestedTimelinesAction(appVersion: string | null): string { ); } -function candidatePaths(input: string): { projectDir: string; requested: string | null; paths: string[] } { +function candidatePaths( + input: string, + nestedRoot = true, +): { projectDir: string; requested: string | null; paths: string[] } { const resolved = resolve(input); const isFile = existsSync(resolved) && statSync(resolved).isFile(); - const projectDir = isFile ? dirname(resolved) : resolved; + const fileDir = dirname(resolved); + const nestedFile = nestedRoot && isFile && basename(dirname(fileDir)) === "Timelines"; + const projectDir = nestedFile ? dirname(dirname(fileDir)) : isFile ? fileDir : resolved; const requested = isFile ? resolved : null; const paths = requested ? [requested] : []; for (const name of STANDARD_FILES) { @@ -379,6 +399,95 @@ function candidatePaths(input: string): { projectDir: string; requested: string return { projectDir, requested, paths }; } +/** Asset files and project metadata belong at the project root even when + * the canonical timeline document lives below Timelines//. */ +export function draftProjectDir(filePath: string): string { + const dir = dirname(resolve(filePath)); + return basename(dirname(dir)) === "Timelines" && existsSync(filePath) ? discoverDraftStore(filePath).projectDir : dir; +} + +function containedPath(projectDir: string, path: string): boolean { + try { + const rel = relative(realpathSync(projectDir), realpathSync(path)); + return ( + !isAbsolute(rel) && + rel !== ".." && + !rel.startsWith(`..${sep}`) && + rel === relative(resolve(projectDir), resolve(path)) + ); + } catch { + return false; + } +} + +function isEvidencedWindowsDraft(draft: Draft | null): boolean { + if (!draft) return false; + const markers = [draft.platform, draft.last_modified_platform].filter((marker) => marker !== undefined); + return ( + draft.platform !== undefined && + markers.every((marker) => { + if (!marker || typeof marker !== "object") return false; + const value = marker as Record; + return value.app_version === "8.7.0" && value.os === "windows" && value.app_source === "cc"; + }) + ); +} + +/** Only the Windows 8.7.0 case has the sentinel experiment and field fixture + * in #50. Layout presence alone does not establish authority on other builds. */ +function selectActiveTimeline( + projectDir: string, + root: DraftCandidate, + version: string | null, +): { + active: ActiveTimeline; + candidates: DraftCandidate[]; +} | null { + if (version !== "8.7.0" || !isEvidencedWindowsDraft(root.draft)) return null; + const pointerPath = join(projectDir, "Timelines", "project.json"); + if (!containedPath(projectDir, pointerPath)) return null; + try { + const pointerRaw = stripBom(readFileSync(pointerPath, "utf-8")); + const pointer = JSON.parse(pointerRaw) as Record; + const id = pointer.main_timeline_id ?? pointer.id; + if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) return null; + if ( + Array.isArray(pointer.timelines) && + pointer.timelines.some((entry) => entry?.id === id && entry.is_marked_delete === true) + ) + return null; + const candidates: DraftCandidate[] = []; + for (const name of ["draft_content.json", "draft_info.json", "template-2.tmp"]) { + const rel = join("Timelines", id, name); + const path = join(projectDir, rel); + if (!containedPath(projectDir, path)) continue; + const candidate = parseCandidate(path); + candidates.push({ ...candidate, name: rel.split(sep).join("/") }); + } + const primary = candidates.find((candidate) => candidate.parseable && candidate.draft); + if (!primary || !isEvidencedWindowsDraft(primary.draft)) return null; + if (candidates.some((candidate) => candidate.parseable && !isEvidencedWindowsDraft(candidate.draft))) return null; + return { active: { id, pointerPath, pointerRaw }, candidates }; + } catch { + // A malformed or inaccessible pointer must never redirect a root write. + return null; + } +} + +export function assertActiveTimelineUnchanged(store: DraftStore): void { + if (!store.activeTimeline) return; + const { pointerPath, pointerRaw } = store.activeTimeline; + if ( + !containedPath(store.projectDir, pointerPath) || + stripBom(readFileSync(pointerPath, "utf-8")) !== pointerRaw || + store.targets.some((target) => !containedPath(store.projectDir, target.path)) + ) { + throw new Error( + "refused [active-timeline-changed]: The active timeline pointer or target path changed after load. Reload and retry.", + ); + } +} + /** Newest app version any readable timeline in the set declares, or null when * none carries a marker. The store's version evidence covers every sibling, so * a mirror written by a newer build raises it. */ @@ -390,9 +499,13 @@ function highestVersion(parseable: DraftCandidate[]): string | null { } export function discoverDraftStore(input: string): DraftStore { - const { projectDir, requested, paths } = candidatePaths(input); - const candidates = paths.map(parseCandidate); - const parseable = candidates.filter((candidate) => candidate.parseable && candidate.draft); + return discoverStore(input, true); +} + +function discoverStore(input: string, nestedRoot: boolean): DraftStore { + const { projectDir, requested, paths } = candidatePaths(input, nestedRoot); + let candidates = paths.map(parseCandidate); + let parseable = candidates.filter((candidate) => candidate.parseable && candidate.draft); if (parseable.length === 0) { const found = candidates.filter((candidate) => candidate.exists).map((candidate) => candidate.name); const detail = found.length > 0 ? `Found ${found.join(", ")}, but none contained a readable timeline.` : ""; @@ -414,6 +527,31 @@ export function discoverDraftStore(input: string): DraftStore { .find((candidate): candidate is DraftCandidate => Boolean(candidate)); canonical ??= parseable[0]; + const root = + parseable.find((candidate) => candidate.path === join(projectDir, "template-2.tmp")) ?? + parseable.find((candidate) => candidate.path === join(projectDir, "draft_content.json")); + const selection = + root && (!requested || STANDARD_FILES.some((name) => basename(requested) === name)) + ? selectActiveTimeline(projectDir, root, version) + : null; + if (!selection && nestedRoot && requested && dirname(requested) !== projectDir) { + return discoverStore(input, false); + } + if (selection) { + const activeDir = join(projectDir, "Timelines", selection.active.id); + if (requested && dirname(requested) !== projectDir && dirname(requested) !== activeDir) { + throw new Error( + "This file belongs to an inactive timeline. Pass the project directory to edit the selected active timeline.", + ); + } + candidates = [...candidates.filter((candidate) => dirname(candidate.path) === projectDir), ...selection.candidates]; + parseable = candidates.filter((candidate) => candidate.parseable && candidate.draft); + canonical = selection.candidates.find((candidate) => candidate.parseable && candidate.draft) as DraftCandidate; + for (const candidate of parseable) { + candidate.keepGuid = dirname(candidate.path) === activeDir ? selection.active.id : candidate.draft?.id; + } + } + const contentReadable = parseable.some((candidate) => candidate.name === "draft_content.json"); const infoReadable = parseable.some((candidate) => candidate.name === "draft_info.json"); // Issue #50 detection only: the nested layout changes NOTHING about @@ -429,14 +567,24 @@ export function discoverDraftStore(input: string): DraftStore { candidates, version, modernStorage, + ...(selection ? { activeTimeline: selection.active } : {}), // Getter so the timeline hashes it compares stay unforced: only `diagnose` // reads this, and forcing them here would put the cost straight back on // every command discovery runs for. get diverged(): boolean { - return new Set(parseable.map((candidate) => candidate.timelineHash).filter(Boolean)).size > 1; + return ( + new Set( + parseable + .map((candidate) => + selection && candidate.draft ? timelineHashWithoutId(candidate.draft) : candidate.timelineHash, + ) + .filter(Boolean), + ).size > 1 + ); }, - layout: - nested.present && !modernStorage + layout: selection + ? "timelines-active" + : nested.present && !modernStorage ? "timelines-nested" : contentReadable ? "content-primary" @@ -492,9 +640,13 @@ export function storeAfterWrite(store: DraftStore, draft: Draft, written: Map { const index = order.indexOf(candidate.path); return index < 0 ? order.length : index; @@ -524,11 +676,21 @@ export function storeAfterWrite(store: DraftStore, draft: Draft, written: Map candidate.timelineHash).filter(Boolean)).size > 1; + return ( + new Set( + targets + .map((candidate) => + store.activeTimeline && candidate.draft ? timelineHashWithoutId(candidate.draft) : candidate.timelineHash, + ) + .filter(Boolean), + ).size > 1 + ); }, - layout: - store.nestedTimelines.length > 0 && !modernStorage + layout: store.activeTimeline + ? "timelines-active" + : store.nestedTimelines.length > 0 && !modernStorage ? "timelines-nested" : contentReadable ? "content-primary" @@ -566,6 +728,7 @@ function indentOf(raw: string | null): string | number { } export function serializeDraftCandidate(candidate: DraftCandidate, draft: Draft): string { + if (candidate.keepGuid !== undefined) draft = { ...draft, id: candidate.keepGuid }; if (!candidate.raw || candidate.envelopePath.length === 0) { return JSON.stringify(draft, null, indentOf(candidate.raw)); } @@ -705,6 +868,7 @@ export interface TimelineSyncPlan { } export interface TimelineSyncResult { + store: DraftStore; plan: TimelineSyncPlan; canonicalDraft: Draft; canonicalCandidate: DraftCandidate; @@ -811,17 +975,19 @@ export function planTimelineSync(input: string, opts: { nested?: boolean } = {}) // The promotion is presence-based (no readable draft_content.json), not // keyed on store.layout, so the detection-only timelines-nested value // (issue #50) cannot change which file the repair reads from. - const canonical = - store.targets.find((candidate) => candidate.name === "draft_content.json") ?? - store.targets.find((candidate) => candidate.name === "draft_info.json"); + const canonical = store.activeTimeline + ? store.canonical + : (store.targets.find((candidate) => candidate.name === "draft_content.json") ?? + store.targets.find((candidate) => candidate.name === "draft_info.json")); if (!canonical?.draft) { throw new Error( "sync-timelines needs a readable draft_content.json or draft_info.json (the canonical timeline source). " + "Run `capcut diagnose ` to inspect what is on disk.", ); } - const canonicalNote = - canonical.name === "draft_info.json" + const canonicalNote = store.activeTimeline + ? "The selected active timeline is canonical on the evidenced CapCut 8.7.0 Windows layout (issue #50). Only its documents and the root mirrors are reconciled; other timelines are preserved." + : canonical.name === "draft_info.json" ? "draft_info.json is the canonical source: this project has no draft_content.json (draft_info-primary " + "layout, reported as the primary project file on newer Mac builds). Round-trip evidence for this layout " + "is synthetic-only — if this project opens fine in your app, contribute a bundle: " + @@ -849,7 +1015,9 @@ export function planTimelineSync(input: string, opts: { nested?: boolean } = {}) } continue; } - const inSync = candidate.timelineHash === canonical.timelineHash; + const inSync = store.activeTimeline + ? timelineHashWithoutId(candidate.draft) === timelineHashWithoutId(canonicalDraft) + : candidate.timelineHash === canonical.timelineHash; targets.push(syncTarget(candidate, inSync ? "in_sync" : "drifted", candidate.draft.id !== canonicalDraft.id)); if (!inSync) { drifted.push(candidate.name); @@ -866,7 +1034,7 @@ export function planTimelineSync(input: string, opts: { nested?: boolean } = {}) // the root mirrors; the differences are that nested documents keep their own // GUID on rewrite (the verified 9.2.8 workaround writes the timeline id) and // therefore compare by id-normalized timeline hash. - const nestedDocs = nestedSyncDocPaths(store.projectDir); + const nestedDocs = store.activeTimeline ? [] : nestedSyncDocPaths(store.projectDir); const nestedDriftedCandidates: TimelineSyncResult["nestedDriftedCandidates"] = []; if (opts.nested === true) { const canonicalContentHash = timelineHashWithoutId(canonicalDraft); @@ -903,6 +1071,7 @@ export function planTimelineSync(input: string, opts: { nested?: boolean } = {}) } return { + store, plan: { project_dir: store.projectDir, canonical: canonical.name, @@ -1073,7 +1242,9 @@ export function diagnoseDraftStore(input: string): DraftStoreReport { "your app, contribute a bundle: `capcut fixture --out `.", ); } - if (store.layout === "timelines-nested") actions.push(nestedTimelinesAction(store.version)); + if (store.activeTimeline) { + actions.push(ACTIVE_TIMELINE_WINDOWS_ACTION); + } else if (store.layout === "timelines-nested") actions.push(nestedTimelinesAction(store.version)); else if (store.nestedTimelines.length > 0) actions.push(NESTED_TIMELINES_MODERN_ACTION); if (running.length > 0) actions.push(`Close ${running.join(" / ")} before editing this managed draft.`); if (actions.length === 0) @@ -1092,6 +1263,9 @@ export function diagnoseDraftStore(input: string): DraftStoreReport { diverged: store.diverged, layout: store.layout, nested_timelines: store.nestedTimelines, + ...(store.activeTimeline + ? { active_timeline: { id: store.activeTimeline.id, canonical: store.canonical.name } } + : {}), write_guard: safety?.action ?? "ok", editor_running: running, candidates: store.candidates.map((candidate) => ({ diff --git a/test/active-timeline.test.mjs b/test/active-timeline.test.mjs new file mode 100644 index 0000000..96f08ad --- /dev/null +++ b/test/active-timeline.test.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, 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 { loadDraft, saveDraft } from "../dist/draft.js"; +import { discoverDraftStore, planTimelineSync } from "../dist/store.js"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "capcut-8.7-windows-active"); +const ID = "C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52"; +const active = `Timelines/${ID}/draft_content.json`; +const read = (path) => JSON.parse(readFileSync(path, "utf-8")); + +function setup(t) { + const dir = mkdtempSync(join(tmpdir(), "capcut-active-")); + cpSync(fixture, dir, { recursive: true }); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +describe("CapCut 8.7.0 Windows active timeline (#50)", () => { + it("reads the selected nested document and reports root-vs-active divergence", (t) => { + const dir = setup(t); + const store = discoverDraftStore(dir); + assert.equal(store.canonical.name, active); + assert.equal(store.canonical.draft.materials.texts.length, 1); + assert.equal(store.diverged, true); + const r = spawnCli(["diagnose", dir]); + assert.equal(r.json.diverged, true); + assert.equal(r.json.active_timeline.id, ID); + assert.equal(r.json.canonical, active); + const comparison = r.json.nested_evidence.root_vs_nested.find((item) => item.nested_file === active); + assert.equal(comparison.root_file, "template-2.tmp"); + assert.equal(comparison.identical, false); + const version = spawnCli(["version", dir]); + assert.ok(version.json.support.notes.some((note) => note.includes("active timeline selected"))); + }); + + it("stages new media and registers metadata at the project root, and restores the active timeline", (t) => { + const dir = setup(t); + const media = join(dir, "clip.mp4"); + writeFileSync(media, "dummy media"); + const r = spawnCli(["add-video", dir, media, "0", "1s"]); + assert.equal(r.status, 0, r.stderr); + const material = read(join(dir, active)).materials.videos.find((item) => item.path.startsWith(dir)); + assert.ok(material.path.startsWith(join(dir, "assets", "video"))); + assert.ok(!existsSync(join(dir, "Timelines", ID, "assets"))); + assert.ok(!existsSync(join(dir, "Timelines", ID, "draft_meta_info.json"))); + const meta = read(join(dir, "draft_meta_info.json")); + assert.ok(meta.draft_materials.flatMap((group) => group.value).some((item) => item.file_Path === material.path)); + const restored = spawnCli(["restore", dir]); + assert.equal(restored.status, 0, restored.stderr); + assert.equal(read(join(dir, active)).materials.videos.length, 1); + assert.equal(discoverDraftStore(dir).diverged, false); + }); + + it("writes the active document and root mirrors, preserving archived timelines and IDs", (t) => { + const dir = setup(t); + const archive = join(dir, "Timelines", "archived", "draft_content.json"); + mkdirSync(dirname(archive)); + writeFileSync(archive, JSON.stringify({ ...read(join(dir, active)), id: "archived" })); + const before = readFileSync(archive, "utf-8"); + const pointer = readFileSync(join(dir, "Timelines", "project.json"), "utf-8"); + const root = read(join(dir, "draft_content.json")); + root.id = "project-root-id"; + writeFileSync(join(dir, "draft_content.json"), JSON.stringify(root)); + // Synthetic extra mirror: exercise the existing envelope-aware serializer. + const nestedMirror = join(dir, "Timelines", ID, "template-2.tmp"); + writeFileSync(nestedMirror, JSON.stringify({ keep: "envelope", draft: JSON.stringify(read(join(dir, active))) })); + const r = spawnCli(["add-text", join(dir, "draft_content.json"), "0", "1s", "PATCHED"]); + assert.equal(r.status, 0, r.stderr); + assert.equal(read(join(dir, "draft_content.json")).id, "project-root-id"); + assert.equal(read(join(dir, active)).id, ID); + assert.equal(readFileSync(archive, "utf-8"), before); + assert.equal(readFileSync(join(dir, "Timelines", "project.json"), "utf-8"), pointer); + for (const name of [active, "draft_content.json", "template-2.tmp"]) { + assert.ok(read(join(dir, name)).materials.texts.some((text) => JSON.parse(text.content).text === "PATCHED")); + assert.ok(existsSync(`${join(dir, name)}.bak`)); + } + assert.equal(read(nestedMirror).keep, "envelope"); + assert.equal(JSON.parse(read(nestedMirror).draft).id, ID); + assert.equal(discoverDraftStore(dir).diverged, false); + assert.throws(() => discoverDraftStore(archive), /inactive timeline/); + }); + + it("sync plans use nested → root and never include archived documents, even with --nested", (t) => { + const dir = setup(t); + const archive = join(dir, "Timelines", "archived", "draft_content.json"); + mkdirSync(dirname(archive)); + writeFileSync(archive, readFileSync(join(dir, active))); + for (const nested of [false, true]) { + const { plan } = planTimelineSync(dir, { nested }); + assert.equal(plan.canonical, active); + assert.deepEqual(plan.drifted.sort(), ["draft_content.json", "template-2.tmp"]); + assert.ok(!plan.targets.some((target) => target.file.includes("archived"))); + } + const before = readFileSync(archive, "utf-8"); + const r = spawnCli(["sync-timelines", dir, "--nested", "--apply", "--force-write"]); + assert.equal(r.status, 0, r.stderr); + assert.equal(discoverDraftStore(dir).diverged, false); + assert.equal(readFileSync(archive, "utf-8"), before); + }); + + it("keeps unverified versions and OSes on the existing root selection", (t) => { + for (const platform of [ + { app_version: "8.7.1", os: "windows", app_source: "cc" }, + { app_version: "8.7.0", os: "mac", app_source: "cc" }, + { app_version: "8.7.0", os: "windows", app_source: "lv" }, + ]) { + const dir = setup(t); + for (const file of ["draft_content.json", "template-2.tmp", active]) { + const d = read(join(dir, file)); + d.platform = platform; + writeFileSync(join(dir, file), JSON.stringify(d)); + } + assert.equal(discoverDraftStore(dir).canonical.name, "template-2.tmp"); + assert.equal(discoverDraftStore(dir).activeTimeline, undefined); + } + }); + + it("does not infer current Windows 8.7.0 authority from an old creation marker", (t) => { + const dir = setup(t); + for (const file of ["draft_content.json", "template-2.tmp", active]) { + const d = read(join(dir, file)); + d.last_modified_platform = { app_source: "cc", app_version: "9.2.8-beta4", os: "mac" }; + writeFileSync(join(dir, file), JSON.stringify(d)); + } + assert.equal(discoverDraftStore(dir).activeTimeline, undefined); + assert.equal(discoverDraftStore(dir).canonical.name, "template-2.tmp"); + }); + + it("does not redirect through malformed, missing, or escaping pointers", (t) => { + const dir = setup(t); + for (const raw of [ + "invalid JSON", + JSON.stringify({ main_timeline_id: "../../outside" }), + JSON.stringify({ main_timeline_id: "missing" }), + JSON.stringify({ main_timeline_id: ID, timelines: [{ id: ID, is_marked_delete: true }] }), + ]) { + writeFileSync(join(dir, "Timelines", "project.json"), raw); + assert.equal(discoverDraftStore(dir).canonical.name, "template-2.tmp"); + } + }); + + it("does not follow a symlink that takes the active document outside the project", (t) => { + if (process.platform === "win32") return t.skip("symlink creation requires Windows privileges"); + const dir = setup(t); + const outside = setup(t); + rmSync(join(dir, "Timelines", ID), { recursive: true }); + symlinkSync(join(outside, "Timelines", ID), join(dir, "Timelines", ID), "dir"); + const before = readFileSync(join(outside, active), "utf-8"); + assert.equal(discoverDraftStore(dir).canonical.name, "template-2.tmp"); + assert.equal(spawnCli(["add-text", dir, "0", "1s", "ROOT ONLY"]).status, 0); + assert.equal(readFileSync(join(outside, active), "utf-8"), before); + }); + + it("checks the active pointer between load and save and permits repeated saves", (t) => { + const dir = setup(t); + process.env.CAPCUT_CLI_APP_VERSIONS = join(dir, "versions.json"); + t.after(() => { + delete process.env.CAPCUT_CLI_APP_VERSIONS; + }); + const { filePath, draft } = loadDraft(dir); + draft.name = "first"; + saveDraft(filePath, draft); + draft.name = "second"; + saveDraft(filePath, draft); + assert.equal(read(join(dir, active)).name, "second"); + assert.equal(discoverDraftStore(dir).diverged, false); + const before = readFileSync(join(dir, active), "utf-8"); + writeFileSync(join(dir, "Timelines", "project.json"), JSON.stringify({ main_timeline_id: "changed" })); + assert.throws(() => saveDraft(filePath, draft), /active-timeline-changed/); + assert.equal(readFileSync(join(dir, active), "utf-8"), before); + }); +}); diff --git a/test/compile-media-canvas.test.mjs b/test/compile-media-canvas.test.mjs new file mode 100644 index 0000000..6a4a21f --- /dev/null +++ b/test/compile-media-canvas.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +function setup(t) { + const dir = mkdtempSync(join(tmpdir(), "capcut-compile-133-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const spec = join(dir, "spec.json"); + const out = join(dir, "Built"); + return { dir, spec, out }; +} + +function writeSpec(s, fields = {}, items) { + writeFileSync( + s.spec, + JSON.stringify({ + name: "Built", + ...fields, + tracks: items + ? [{ type: "video", items }] + : [{ type: "text", items: [{ text: "TEST", start: 0, duration: 12 }] }], + }), + ); +} + +function readDraft(s) { + return JSON.parse(readFileSync(join(s.out, "draft_content.json"), "utf-8")); +} + +describe("compile canvas and source ranges (#133)", () => { + for (const fields of [ + { ratio: "9:16" }, + { width: 1080, height: 1920 }, + { ratio: "9:16", width: 720, height: 1280 }, + ]) { + it(`resolves the same canvas for --check and compile: ${JSON.stringify(fields)}`, (t) => { + const s = setup(t); + writeSpec(s, fields); + const plan = spawnCli(["compile", s.spec, "--check"]); + assert.equal(plan.status, 0, plan.stderr); + const compiled = spawnCli(["compile", s.spec, "--out", s.out]); + assert.equal(compiled.status, 0, compiled.stderr); + const { fps, ...canvas } = plan.json.canvas; + assert.equal(canvas.ratio, "9:16"); + assert.deepEqual(readDraft(s).canvas_config, canvas); + }); + } + + it("rejects invalid canvas flags before creating a draft", (t) => { + const s = setup(t); + for (const fields of [{ width: 1080 }, { ratio: "sideways" }, { width: -1, height: 1920 }]) { + writeSpec(s, fields); + for (const extra of [["--check"], ["--out", s.out]]) { + assert.equal(spawnCli(["compile", s.spec, ...extra]).status, 1); + assert.equal(existsSync(s.out), false); + } + } + }); + + it("keeps nonzero in-points addressable when ffprobe cannot read the source", (t) => { + const s = setup(t); + writeFileSync(join(s.dir, "clip.mp4"), "unprobeable media"); + writeSpec(s, { ratio: "9:16" }, [{ path: "clip.mp4", start: 0, sourceStart: 40, duration: 6 }]); + const r = spawnCli(["compile", s.spec, "--out", s.out]); + assert.equal(r.status, 0, r.stderr); + const d = readDraft(s); + assert.equal(d.materials.videos[0].duration, 46_000_000); + assert.deepEqual(d.tracks[0].segments[0].source_timerange, { start: 40_000_000, duration: 6_000_000 }); + }); + + it("uses the full probed duration for both cuts and sidecar metadata", (t) => { + if (spawnSync("ffprobe", ["-version"]).status !== 0) return t.skip("ffprobe not installed"); + const s = setup(t); + // A real, small PCM source supplies an exact 89.28-second ffprobe duration. + // Both factories must separate source duration from timeline duration. + const samples = 89_280; + const wav = Buffer.alloc(44 + samples, 128); + wav.write("RIFF", 0); + wav.writeUInt32LE(36 + samples, 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(samples, 40); + writeFileSync(join(s.dir, "clip.wav"), wav); + const items = [20, 40].map((sourceStart, i) => ({ + ref: `v${i}`, + path: "clip.wav", + start: i * 6, + sourceStart, + duration: 6, + })); + writeSpec(s, { ratio: "9:16" }, items); + const spec = JSON.parse(readFileSync(s.spec, "utf-8")); + spec.tracks.push({ type: "audio", items: [{ path: "clip.wav", start: 0, sourceStart: 20, duration: 6 }] }); + writeFileSync(s.spec, JSON.stringify(spec)); + const r = spawnCli(["compile", s.spec, "--out", s.out]); + assert.equal(r.status, 0, r.stderr); + const d = readDraft(s); + for (const material of [...d.materials.videos, ...d.materials.audios]) assert.equal(material.duration, 89_280_000); + assert.deepEqual( + d.tracks.find((track) => track.type === "video").segments.map((seg) => seg.source_timerange), + [ + { start: 20_000_000, duration: 6_000_000 }, + { start: 40_000_000, duration: 6_000_000 }, + ], + ); + const meta = JSON.parse(readFileSync(join(s.out, "draft_meta_info.json"), "utf-8")); + const registered = meta.draft_materials.flatMap((group) => group.value); + assert.ok(registered.length > 0); + for (const material of registered) assert.equal(material.duration, 89_280_000); + items[0].sourceStart = 80; + items[0].speed = 2; + writeSpec(s, {}, items); + const badOut = join(s.dir, "Invalid"); + for (const flags of [["--check"], ["--out", badOut]]) { + const bad = spawnCli(["compile", s.spec, ...flags]); + assert.equal(bad.status, 1, bad.stderr); + assert.match(bad.stderr, /source range.*exceeds source duration/); + assert.equal(existsSync(badOut), false); + } + }); +}); diff --git a/test/fixture-check.test.mjs b/test/fixture-check.test.mjs index aa6b98c..c8d3a0a 100644 --- a/test/fixture-check.test.mjs +++ b/test/fixture-check.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, describe, it } from "node:test"; @@ -22,6 +22,41 @@ function project() { } describe("fixture --check (redaction verification)", () => { + it("redacts JSON-escaped Windows paths and embedded JSON without --check (#134)", () => { + const p = project(); + after(p.cleanup); + const out = join(p.dir, "bundle"); + const privatePath = "C:\\Users\\Francesco Rossi\\AppData\\Local\\CapCut"; + writeFileSync(join(p.dir, "draft_meta_info.json"), JSON.stringify({ draft_root_path: privatePath })); + const path = join(p.dir, "draft_content.json"); + const draft = JSON.parse(readFileSync(path, "utf-8")); + draft.embedded = JSON.stringify({ path: privatePath }); + draft.extra = "C:/Users/Francesco Rossi/clip.mp4"; + writeFileSync(path, JSON.stringify(draft)); + const r = spawnCli(["fixture", p.dir, "--out", out]); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.json.redaction_check.ok, true); + for (const name of ["draft_content.json", "draft_meta_info.json", "SANITIZE_REPORT.json"]) { + assert.doesNotMatch(readFileSync(join(out, name), "utf-8"), /Francesco Rossi/); + } + const bundled = JSON.parse(readFileSync(join(out, "draft_content.json"), "utf-8")); + assert.equal(JSON.parse(bundled.embedded).path, "C:\\Users\\USER\\AppData\\Local\\CapCut"); + }); + + it("fails bundle creation automatically when a recognizable username survives", () => { + const p = project(); + after(p.cleanup); + const out = join(p.dir, "bundle"); + // Existing files in a reused output directory are part of the bundle too. + assert.equal(spawnCli(["fixture", p.dir, "--out", out]).status, 0); + writeFileSync(join(out, "unrecognized.json"), JSON.stringify({ a: "/home/USER/ok", b: "C:\\Users\\É\\secret" })); + const r = spawnCli(["fixture", p.dir, "--out", out]); + assert.equal(r.status, 1, r.stderr); + assert.equal(r.json.ok, false); + assert.equal(JSON.parse(readFileSync(join(out, "SANITIZE_REPORT.json"), "utf-8")).ok, false); + assert.ok(r.json.redaction_check.findings.some((finding) => finding.file === "unrecognized.json")); + assert.doesNotMatch(r.stderr, /É/); + }); it("builds a bundle and passes the check when nothing private survives", () => { const p = project(); after(p.cleanup); diff --git a/test/fixtures/capcut-8.7-windows-active/README.md b/test/fixtures/capcut-8.7-windows-active/README.md new file mode 100644 index 0000000..dcd3498 --- /dev/null +++ b/test/fixtures/capcut-8.7-windows-active/README.md @@ -0,0 +1,21 @@ +# CapCut 8.7.0 Windows active timeline + +App-authored fixture supplied by fpisasale / Spirito Digitale on 2026-10-02: +https://gist.github.com/fpisasale/8b344dccd4f4a731a35ef49ca55f0ff0 + +Experiment and environment (CapCut desktop 8.7.0.3685, Windows 11, CLI 0.26.0): +https://github.com/renezander030/capcut-cli/issues/50#issuecomment-5954728670 + +The supplied files are preserved in their original form, with the gist's +flattened filenames restored to directories. No media is included. The author +ran fixture redaction and manually removed the Windows username missed by #134. + +The capture follows an app close round-trip and one more root-only add-text. +Both root documents contain COPIA TIMELINES and SOLO RADICE; the document +selected by main_timeline_id contains only COPIA TIMELINES. The pointer's +project id differs from main_timeline_id. + +This fixture establishes on-disk structure and divergence. The author reported +that the app loads the nested sentinel and overwrites the root on close. +A patched CLI open/close round-trip has not yet been independently verified. +It supplies no evidence for other OSes, versions, or root-only new drafts. diff --git a/test/fixtures/capcut-8.7-windows-active/Timelines/C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52/draft_content.json b/test/fixtures/capcut-8.7-windows-active/Timelines/C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52/draft_content.json new file mode 100644 index 0000000..9c185fd --- /dev/null +++ b/test/fixtures/capcut-8.7-windows-active/Timelines/C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52/draft_content.json @@ -0,0 +1 @@ +{"id":"C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52","version":360000,"new_version":"171.0.0","name":"","duration":89300000,"create_time":0,"update_time":0,"fps":30.0,"is_drop_frame_timecode":false,"color_space":0,"config":{"video_mute":false,"record_audio_last_index":1,"extract_audio_last_index":1,"original_sound_last_index":1,"subtitle_recognition_id":"","subtitle_taskinfo":[],"lyrics_recognition_id":"","lyrics_taskinfo":[],"subtitle_sync":true,"lyrics_sync":true,"voice_change_sync":false,"sticker_max_index":1,"adjust_max_index":1,"material_save_mode":0,"export_range":null,"maintrack_adsorb":true,"combination_max_index":1,"attachment_info":[],"zoom_info_params":null,"system_font_list":[],"multi_language_mode":"none","multi_language_main":"none","multi_language_current":"none","multi_language_list":[],"subtitle_keywords_config":null,"use_float_render":false},"canvas_config":{"ratio":"9:16","width":1080,"height":1920,"background":null},"tracks":[{"id":"8791E4C1-F5EA-4fb4-BD65-2FD37508ABC6","type":"video","segments":[{"id":"1D19838B-E980-4e2b-A0CC-5A42470301F5","source_timerange":{"start":0,"duration":89300000},"target_timerange":{"start":0,"duration":89300000},"render_timerange":{"start":0,"duration":0},"desc":"","state":0,"speed":1.0,"is_loop":false,"is_tone_modify":false,"reverse":false,"intensifies_audio":false,"cartoon":false,"volume":1.0,"last_nonzero_volume":1.0,"clip":{"scale":{"x":4.739999999999999,"y":4.739999999999999},"rotation":0.0,"transform":{"x":0.32407407407407407,"y":-0.10416666666666669},"flip":{"vertical":false,"horizontal":false},"alpha":1.0},"uniform_scale":{"on":true,"value":1.0},"material_id":"46329E06-84DA-4e33-B862-D991BCFCD3F4","extra_material_refs":["784AEB2D-657B-4f08-96C6-B364278719F3","A3316AA6-DEBD-4422-9BBB-8725B89578B9","7BD087AC-04E6-404a-985B-0234C3BE5B11","A5ED61F8-446A-4230-8940-F4863A7EFE9F","396FCFFC-9E35-4e1e-944E-6C1D508AD8FE","BE49926C-B7C5-41ac-8EBC-B1604009458D"],"render_index":0,"keyframe_refs":[],"enable_lut":true,"enable_adjust":true,"enable_hsl":false,"visible":true,"group_id":"","enable_color_curves":true,"enable_hsl_curves":true,"track_render_index":0,"hdr_settings":{"mode":1,"intensity":1.0,"nits":1000},"enable_color_wheels":true,"track_attribute":0,"is_placeholder":false,"template_id":"","enable_smart_color_adjust":false,"template_scene":"default","common_keyframes":[],"caption_info":null,"responsive_layout":{"enable":false,"target_follow":"","size_layout":0,"horizontal_pos_layout":0,"vertical_pos_layout":0},"enable_color_match_adjust":false,"enable_color_correct_adjust":false,"enable_adjust_mask":false,"raw_segment_id":"","lyric_keyframes":null,"enable_video_mask":true,"digital_human_template_group_id":"","color_correct_alg_result":"","source":"segmentsourcenormal","enable_mask_stroke":false,"enable_mask_shadow":false,"enable_color_adjust_pro":false}],"flag":0,"attribute":0,"name":"","is_default_name":true},{"id":"A205341A-4228-4f11-B712-C3DA0E8FD0CC","type":"text","segments":[{"id":"E54B4AF4-79A3-4e75-83B3-039FF8D87126","source_timerange":null,"target_timerange":{"start":8966666,"duration":3000000},"render_timerange":{"start":0,"duration":0},"desc":"","state":0,"speed":1.0,"is_loop":false,"is_tone_modify":false,"reverse":false,"intensifies_audio":false,"cartoon":false,"volume":1.0,"last_nonzero_volume":1.0,"clip":{"scale":{"x":1.0,"y":1.0},"rotation":0.0,"transform":{"x":0.0,"y":0.0},"flip":{"vertical":false,"horizontal":false},"alpha":1.0},"uniform_scale":{"on":true,"value":1.0},"material_id":"EC013097-CA75-49e2-8D67-B760BC04C8CF","extra_material_refs":["96FA630D-B877-488c-8455-615C83E37720"],"render_index":14000,"keyframe_refs":[],"enable_lut":false,"enable_adjust":false,"enable_hsl":false,"visible":true,"group_id":"","enable_color_curves":true,"enable_hsl_curves":true,"track_render_index":1,"hdr_settings":null,"enable_color_wheels":true,"track_attribute":0,"is_placeholder":false,"template_id":"","enable_smart_color_adjust":false,"template_scene":"default","common_keyframes":[],"caption_info":null,"responsive_layout":{"enable":false,"target_follow":"","size_layout":0,"horizontal_pos_layout":0,"vertical_pos_layout":0},"enable_color_match_adjust":false,"enable_color_correct_adjust":false,"enable_adjust_mask":false,"raw_segment_id":"","lyric_keyframes":null,"enable_video_mask":true,"digital_human_template_group_id":"","color_correct_alg_result":"","source":"segmentsourcenormal","enable_mask_stroke":false,"enable_mask_shadow":false,"enable_color_adjust_pro":false}],"flag":0,"attribute":0,"name":"","is_default_name":true}],"group_container":null,"materials":{"flowers":[],"videos":[{"id":"46329E06-84DA-4e33-B862-D991BCFCD3F4","unique_id":"","type":"video","duration":89300000,"path":"C:/Users/USER/Desktop/test-montaggio-C0148.MP4","media_path":"","local_id":"","has_audio":true,"reverse_path":"","intensifies_path":"","reverse_intensifies_path":"","intensifies_audio_path":"","cartoon_path":"","width":3840,"height":2160,"category_id":"","category_name":"local","material_id":"","material_name":"test-montaggio-C0148.MP4","material_url":"","crop":{"upper_left_x":0.0,"upper_left_y":0.0,"upper_right_x":1.0,"upper_right_y":0.0,"lower_left_x":0.0,"lower_left_y":1.0,"lower_right_x":1.0,"lower_right_y":1.0},"crop_ratio":"free","audio_fade":null,"crop_scale":1.0,"extra_type_option":0,"stable":{"stable_level":0,"matrix_path":"","time_range":{"start":0,"duration":0}},"matting":{"flag":0,"path":"","interactiveTime":[],"has_use_quick_brush":false,"strokes":[],"has_use_quick_eraser":false,"expansion":0,"feather":0,"reverse":false,"custom_matting_id":"","enable_matting_stroke":false,"is_clould":false,"mask_video_path":"","cloud_product_fps":0.0},"source":0,"source_platform":0,"formula_id":"","check_flag":62978047,"video_algorithm":{"algorithms":[],"time_range":null,"path":"","gameplay_configs":[],"ai_in_painting_config":[],"complement_frame_config":null,"motion_blur_config":null,"deflicker":null,"noise_reduction":null,"quality_enhance":null,"super_resolution":null,"ai_background_configs":[],"smart_complement_frame":null,"aigc_generate":null,"aigc_generate_list":[],"mouth_shape_driver":null,"ai_expression_driven":null,"ai_motion_driven":null,"image_interpretation":null,"story_video_modify_video_config":{"task_id":"","is_overwrite_last_video":false,"tracker_task_id":""},"skip_algorithm_index":[]},"is_unified_beauty_mode":false,"is_set_beauty_mode":false,"object_locked":null,"smart_motion":null,"multi_camera_info":null,"freeze":null,"picture_from":"none","picture_set_category_id":"","picture_set_category_name":"","team_id":"","local_material_id":"5f45aacc-553e-4597-a0a2-2695fad25f42","origin_material_id":"","request_id":"","has_sound_separated":false,"is_text_edit_overdub":false,"is_ai_generate_content":false,"aigc_type":"none","is_copyright":false,"aigc_history_id":"","aigc_item_id":"","local_material_from":"","smart_match_info":null,"beauty_face_preset_infos":[],"beauty_body_preset_id":"","beauty_face_auto_preset":{"preset_id":"","name":"","rate_map":"","scene":""},"beauty_face_auto_preset_infos":[],"beauty_body_auto_preset":null,"live_photo_timestamp":-1,"live_photo_cover_path":"","content_feature_info":null,"corner_pin":null,"surface_trackings":[],"video_mask_stroke":{"resource_id":"","path":"","type":"","color":"","size":0.0,"alpha":0.0,"distance":0.0,"texture":0.0,"horizontal_shift":0.0,"vertical_shift":0.0},"video_mask_shadow":{"resource_id":"","path":"","color":"","alpha":0.0,"blur":0.0,"distance":0.0,"angle":0.0}}],"tail_leaders":[],"audios":[],"images":[],"texts":[{"recognize_task_id":"","id":"EC013097-CA75-49e2-8D67-B760BC04C8CF","name":"","recognize_text":"","recognize_model":"","punc_model":"","type":"text","content":"{\"text\":\"COPIA TIMELINES\",\"styles\":[{\"fill\":{\"content\":{\"render_type\":\"solid\",\"solid\":{\"color\":[1,1,1]}}},\"font\":{\"path\":\"C:/Users/USER/AppData/Local/CapCut/Apps/8.7.0.3685/Resources/Font/SystemFont/en.ttf\",\"id\":\"\"},\"size\":15,\"range\":[0,15]}]}","base_content":"","words":{"start_time":[],"end_time":[],"text":[]},"current_words":{"start_time":[],"end_time":[],"text":[]},"global_alpha":1.0,"combo_info":{"text_templates":[]},"caption_template_info":{"resource_id":"","third_resource_id":"","resource_name":"","category_id":"","category_name":"","effect_id":"","request_id":"","path":"","is_new":false,"source_platform":0},"layer_weight":1,"letter_spacing":0.0,"text_curve":null,"text_loop_on_path":false,"offset_on_path":0.0,"enable_path_typesetting":false,"text_exceeds_path_process_type":0,"text_typesetting_paths":null,"text_typesetting_paths_file":"","text_typesetting_path_index":0,"line_spacing":0.02,"has_shadow":false,"shadow_color":"","shadow_alpha":0.9,"shadow_smoothing":0.45,"shadow_distance":5.0,"shadow_point":{"x":0.6363961030678928,"y":-0.6363961030678928},"shadow_angle":-45.0,"shadow_thickness_projection_enable":false,"shadow_thickness_projection_angle":0.0,"shadow_thickness_projection_distance":0.0,"border_alpha":1.0,"border_color":"","border_width":0.08,"border_mode":0,"style_name":"","text_color":"#FFFFFF","text_alpha":1.0,"font_name":"","font_title":"none","font_size":15.0,"font_path":"C:/Users/USER/AppData/Local/CapCut/Apps/8.7.0.3685/Resources/Font/SystemFont/en.ttf","font_id":"","font_resource_id":"","initial_scale":1.0,"font_url":"","typesetting":0,"alignment":1,"line_feed":1,"use_effect_default_color":true,"is_rich_text":false,"shape_clip_x":false,"shape_clip_y":false,"ktv_color":"","text_to_audio_ids":[],"bold_width":0.0,"italic_degree":0,"underline":false,"underline_width":0.05,"underline_offset":0.22,"sub_type":0,"check_flag":7,"text_size":30,"font_category_name":"","font_source_platform":0,"font_third_resource_id":"","font_category_id":"","add_type":0,"operation_type":0,"recognize_type":0,"fonts":[],"background_color":"","background_alpha":1.0,"background_style":0,"background_round_radius":0.0,"background_width":0.14,"background_height":0.14,"background_vertical_offset":0.0,"background_horizontal_offset":0.0,"background_fill":"","single_char_bg_enable":false,"single_char_bg_color":"","single_char_bg_alpha":1.0,"single_char_bg_round_radius":0.3,"single_char_bg_width":0.0,"single_char_bg_height":0.0,"single_char_bg_vertical_offset":0.0,"single_char_bg_horizontal_offset":0.0,"font_team_id":"","tts_auto_update":false,"text_preset_resource_id":"","group_id":"","preset_id":"","preset_name":"","preset_category":"","preset_category_id":"","preset_index":0,"preset_has_set_alignment":false,"force_apply_line_max_width":false,"language":"","relevance_segment":[],"original_size":[],"fixed_width":-1.0,"fixed_height":-1.0,"line_max_width":0.82,"oneline_cutoff":false,"cutoff_postfix":"","subtitle_template_original_fontsize":0.0,"subtitle_keywords":null,"inner_padding":-1.0,"multi_language_current":"none","source_from":"","is_lyric_effect":false,"lyric_group_id":"","lyrics_template":{"resource_id":"","resource_name":"","panel":"","effect_id":"","path":"","category_id":"","category_name":"","request_id":""},"is_batch_replace":false,"is_words_linear":false,"ssml_content":"","subtitle_keywords_config":null,"sub_template_id":-1,"translate_original_text":""}],"effects":[],"stickers":[],"canvases":[{"id":"7BD087AC-04E6-404a-985B-0234C3BE5B11","type":"canvas_color","color":"","blur":0.0,"image":"","album_image":"","image_id":"","image_name":"","source_platform":0,"team_id":""}],"transitions":[],"audio_effects":[],"audio_fades":[],"beats":[],"material_animations":[{"id":"96FA630D-B877-488c-8455-615C83E37720","type":"sticker_animation","animations":[],"multi_language_current":"none"}],"placeholders":[],"placeholder_infos":[{"id":"A3316AA6-DEBD-4422-9BBB-8725B89578B9","type":"placeholder_info","meta_type":"none","res_path":"","res_text":"","error_path":"","error_text":""}],"speeds":[{"id":"784AEB2D-657B-4f08-96C6-B364278719F3","type":"speed","mode":0,"speed":1.0,"curve_speed":null}],"common_mask":[],"chromas":[],"text_templates":[],"realtime_denoises":[],"audio_pannings":[],"audio_pitch_shifts":[],"video_trackings":[],"hsl":[],"drafts":[],"color_curves":[],"hsl_curves":[],"primary_color_wheels":[],"log_color_wheels":[],"video_effects":[],"audio_balances":[],"handwrites":[],"manual_deformations":[],"manual_beautys":[],"plugin_effects":[],"sound_channel_mappings":[{"id":"A5ED61F8-446A-4230-8940-F4863A7EFE9F","type":"none","audio_channel_mapping":0,"is_config_open":false}],"green_screens":[],"shapes":[],"material_colors":[{"id":"396FCFFC-9E35-4e1e-944E-6C1D508AD8FE","is_color_clip":false,"is_gradient":false,"solid_color":"","gradient_colors":[],"gradient_percents":[],"gradient_angle":90.0,"width":0.0,"height":0.0}],"digital_humans":[],"digital_human_model_dressing":[],"smart_crops":[],"ai_translates":[],"audio_track_indexes":[],"loudnesses":[],"vocal_beautifys":[],"vocal_separations":[{"id":"BE49926C-B7C5-41ac-8EBC-B1604009458D","type":"vocal_separation","choice":0,"removed_sounds":[],"time_range":null,"production_path":"","final_algorithm":"","enter_from":""}],"smart_relights":[],"time_marks":[],"multi_language_refs":[],"video_shadows":[],"video_strokes":[],"video_radius":[]},"keyframes":{"videos":[],"audios":[],"texts":[],"stickers":[],"filters":[],"adjusts":[],"handwrites":[],"effects":[]},"keyframe_graph_list":[],"platform":{"os":"windows","os_version":"10.0.26200","app_id":359289,"app_version":"8.7.0","app_source":"cc","device_id":"","hard_disk_id":"","mac_address":""},"last_modified_platform":{"os":"windows","os_version":"10.0.26200","app_id":359289,"app_version":"8.7.0","app_source":"cc","device_id":"","hard_disk_id":"","mac_address":""},"mutable_config":null,"cover":null,"retouch_cover":null,"extra_info":null,"relationships":[],"render_index_track_mode_on":true,"free_render_index_mode_on":false,"static_cover_image_path":"","source":"default","time_marks":null,"path":"","lyrics_effects":[],"uneven_animation_template_info":{"composition":"","content":"","order":"","sub_template_info_list":[]},"draft_type":"video","smart_ads_info":{"page_from":"","routine":"","draft_url":""},"function_assistant_info":{"smart_rec_applied":false,"fixed_rec_applied":false,"auto_adjust":false,"auto_adjust_segid_list":[],"color_correction":false,"color_correction_segid_list":[],"enhance_quality":false,"smooth_slow_motion":false,"deflicker_segid_list":[],"video_noise_segid_list":[],"enhance_quality_segid_list":[],"smart_segid_list":[],"retouch":false,"retouch_segid_list":[],"enhande_voice":false,"enhance_voice_segid_list":[],"audio_noise_segid_list":[],"auto_caption":false,"auto_caption_segid_list":[],"auto_caption_template_id":"","caption_opt":false,"caption_opt_segid_list":[],"eye_correction":false,"eye_correction_segid_list":[],"normalize_loudness":false,"normalize_loudness_segid_list":[],"normalize_loudness_audio_denoise_segid_list":[],"auto_adjust_fixed":false,"auto_adjust_fixed_value":50.0,"color_correction_fixed":false,"color_correction_fixed_value":50.0,"normalize_loudness_fixed":false,"enhande_voice_fixed":false,"retouch_fixed":false,"enhance_quality_fixed":false,"smooth_slow_motion_fixed":false,"fps":{"num":0,"den":1}}} \ No newline at end of file diff --git a/test/fixtures/capcut-8.7-windows-active/Timelines/project.json b/test/fixtures/capcut-8.7-windows-active/Timelines/project.json new file mode 100644 index 0000000..fedc13c --- /dev/null +++ b/test/fixtures/capcut-8.7-windows-active/Timelines/project.json @@ -0,0 +1 @@ +{"config":{"color_space":-1,"render_index_track_mode_on":false,"use_float_render":false},"create_time":1790948522805614,"id":"3A950C5C-67A3-4308-8E78-3A97980E1ADF","main_timeline_id":"C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52","timelines":[{"create_time":1790948522805614,"id":"C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52","is_marked_delete":false,"name":"Timeline 01","update_time":1790948522805614}],"update_time":1790948522805614,"version":0} \ No newline at end of file diff --git a/test/fixtures/capcut-8.7-windows-active/draft_content.json b/test/fixtures/capcut-8.7-windows-active/draft_content.json new file mode 100644 index 0000000..d88dc3c --- /dev/null +++ b/test/fixtures/capcut-8.7-windows-active/draft_content.json @@ -0,0 +1 @@ +{"id":"C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52","version":360000,"new_version":"171.0.0","name":"","duration":89300000,"create_time":0,"update_time":0,"fps":30,"is_drop_frame_timecode":false,"color_space":0,"config":{"video_mute":false,"record_audio_last_index":1,"extract_audio_last_index":1,"original_sound_last_index":1,"subtitle_recognition_id":"","subtitle_taskinfo":[],"lyrics_recognition_id":"","lyrics_taskinfo":[],"subtitle_sync":true,"lyrics_sync":true,"voice_change_sync":false,"sticker_max_index":1,"adjust_max_index":1,"material_save_mode":0,"export_range":null,"maintrack_adsorb":true,"combination_max_index":1,"attachment_info":[],"zoom_info_params":null,"system_font_list":[],"multi_language_mode":"none","multi_language_main":"none","multi_language_current":"none","multi_language_list":[],"subtitle_keywords_config":null,"use_float_render":false},"canvas_config":{"ratio":"9:16","width":1080,"height":1920,"background":null},"tracks":[{"id":"8791E4C1-F5EA-4fb4-BD65-2FD37508ABC6","type":"video","segments":[{"id":"1D19838B-E980-4e2b-A0CC-5A42470301F5","source_timerange":{"start":0,"duration":89300000},"target_timerange":{"start":0,"duration":89300000},"render_timerange":{"start":0,"duration":0},"desc":"","state":0,"speed":1,"is_loop":false,"is_tone_modify":false,"reverse":false,"intensifies_audio":false,"cartoon":false,"volume":1,"last_nonzero_volume":1,"clip":{"scale":{"x":4.739999999999999,"y":4.739999999999999},"rotation":0,"transform":{"x":0.32407407407407407,"y":-0.10416666666666669},"flip":{"vertical":false,"horizontal":false},"alpha":1},"uniform_scale":{"on":true,"value":1},"material_id":"46329E06-84DA-4e33-B862-D991BCFCD3F4","extra_material_refs":["784AEB2D-657B-4f08-96C6-B364278719F3","A3316AA6-DEBD-4422-9BBB-8725B89578B9","7BD087AC-04E6-404a-985B-0234C3BE5B11","A5ED61F8-446A-4230-8940-F4863A7EFE9F","396FCFFC-9E35-4e1e-944E-6C1D508AD8FE","BE49926C-B7C5-41ac-8EBC-B1604009458D"],"render_index":0,"keyframe_refs":[],"enable_lut":true,"enable_adjust":true,"enable_hsl":false,"visible":true,"group_id":"","enable_color_curves":true,"enable_hsl_curves":true,"track_render_index":0,"hdr_settings":{"mode":1,"intensity":1,"nits":1000},"enable_color_wheels":true,"track_attribute":0,"is_placeholder":false,"template_id":"","enable_smart_color_adjust":false,"template_scene":"default","common_keyframes":[],"caption_info":null,"responsive_layout":{"enable":false,"target_follow":"","size_layout":0,"horizontal_pos_layout":0,"vertical_pos_layout":0},"enable_color_match_adjust":false,"enable_color_correct_adjust":false,"enable_adjust_mask":false,"raw_segment_id":"","lyric_keyframes":null,"enable_video_mask":true,"digital_human_template_group_id":"","color_correct_alg_result":"","source":"segmentsourcenormal","enable_mask_stroke":false,"enable_mask_shadow":false,"enable_color_adjust_pro":false}],"flag":0,"attribute":0,"name":"","is_default_name":true},{"id":"A205341A-4228-4f11-B712-C3DA0E8FD0CC","type":"text","segments":[{"id":"E54B4AF4-79A3-4e75-83B3-039FF8D87126","source_timerange":null,"target_timerange":{"start":8966666,"duration":3000000},"render_timerange":{"start":0,"duration":0},"desc":"","state":0,"speed":1,"is_loop":false,"is_tone_modify":false,"reverse":false,"intensifies_audio":false,"cartoon":false,"volume":1,"last_nonzero_volume":1,"clip":{"scale":{"x":1,"y":1},"rotation":0,"transform":{"x":0,"y":0},"flip":{"vertical":false,"horizontal":false},"alpha":1},"uniform_scale":{"on":true,"value":1},"material_id":"EC013097-CA75-49e2-8D67-B760BC04C8CF","extra_material_refs":["96FA630D-B877-488c-8455-615C83E37720"],"render_index":14000,"keyframe_refs":[],"enable_lut":false,"enable_adjust":false,"enable_hsl":false,"visible":true,"group_id":"","enable_color_curves":true,"enable_hsl_curves":true,"track_render_index":1,"hdr_settings":null,"enable_color_wheels":true,"track_attribute":0,"is_placeholder":false,"template_id":"","enable_smart_color_adjust":false,"template_scene":"default","common_keyframes":[],"caption_info":null,"responsive_layout":{"enable":false,"target_follow":"","size_layout":0,"horizontal_pos_layout":0,"vertical_pos_layout":0},"enable_color_match_adjust":false,"enable_color_correct_adjust":false,"enable_adjust_mask":false,"raw_segment_id":"","lyric_keyframes":null,"enable_video_mask":true,"digital_human_template_group_id":"","color_correct_alg_result":"","source":"segmentsourcenormal","enable_mask_stroke":false,"enable_mask_shadow":false,"enable_color_adjust_pro":false},{"id":"a39ca480-585e-4bad-a7a9-e2fe61daff98","material_id":"7dc394db-8aa4-4fed-be3e-3ea9df3f44e3","raw_segment_id":"A205341A-4228-4f11-B712-C3DA0E8FD0CC","target_timerange":{"start":1000000,"duration":4000000},"source_timerange":{"start":0,"duration":4000000},"speed":1,"volume":1,"visible":true,"reverse":false,"clip":{"alpha":1,"rotation":0,"scale":{"x":1,"y":1},"transform":{"x":0,"y":0},"flip":{"horizontal":false,"vertical":false}},"render_index":15000,"track_render_index":0,"track_attribute":0,"extra_material_refs":["acfdcd77-3ff4-4504-91a1-ef11d3d987d1","bfd94e0e-3621-470c-bcb9-c1b1c5edfee3","7d26d1bc-294b-4a77-860b-1c86ca179177","f4d0cbcd-05a3-4acd-8734-56329b245084"],"common_keyframes":[],"keyframe_refs":[]}],"flag":0,"attribute":0,"name":"","is_default_name":true}],"group_container":null,"materials":{"flowers":[],"videos":[{"id":"46329E06-84DA-4e33-B862-D991BCFCD3F4","unique_id":"","type":"video","duration":89300000,"path":"C:/Users/USER/Desktop/test-montaggio-C0148.MP4","media_path":"","local_id":"","has_audio":true,"reverse_path":"","intensifies_path":"","reverse_intensifies_path":"","intensifies_audio_path":"","cartoon_path":"","width":3840,"height":2160,"category_id":"","category_name":"local","material_id":"","material_name":"test-montaggio-C0148.MP4","material_url":"","crop":{"upper_left_x":0,"upper_left_y":0,"upper_right_x":1,"upper_right_y":0,"lower_left_x":0,"lower_left_y":1,"lower_right_x":1,"lower_right_y":1},"crop_ratio":"free","audio_fade":null,"crop_scale":1,"extra_type_option":0,"stable":{"stable_level":0,"matrix_path":"","time_range":{"start":0,"duration":0}},"matting":{"flag":0,"path":"","interactiveTime":[],"has_use_quick_brush":false,"strokes":[],"has_use_quick_eraser":false,"expansion":0,"feather":0,"reverse":false,"custom_matting_id":"","enable_matting_stroke":false,"is_clould":false,"mask_video_path":"","cloud_product_fps":0},"source":0,"source_platform":0,"formula_id":"","check_flag":62978047,"video_algorithm":{"algorithms":[],"time_range":null,"path":"","gameplay_configs":[],"ai_in_painting_config":[],"complement_frame_config":null,"motion_blur_config":null,"deflicker":null,"noise_reduction":null,"quality_enhance":null,"super_resolution":null,"ai_background_configs":[],"smart_complement_frame":null,"aigc_generate":null,"aigc_generate_list":[],"mouth_shape_driver":null,"ai_expression_driven":null,"ai_motion_driven":null,"image_interpretation":null,"story_video_modify_video_config":{"task_id":"","is_overwrite_last_video":false,"tracker_task_id":""},"skip_algorithm_index":[]},"is_unified_beauty_mode":false,"is_set_beauty_mode":false,"object_locked":null,"smart_motion":null,"multi_camera_info":null,"freeze":null,"picture_from":"none","picture_set_category_id":"","picture_set_category_name":"","team_id":"","local_material_id":"5f45aacc-553e-4597-a0a2-2695fad25f42","origin_material_id":"","request_id":"","has_sound_separated":false,"is_text_edit_overdub":false,"is_ai_generate_content":false,"aigc_type":"none","is_copyright":false,"aigc_history_id":"","aigc_item_id":"","local_material_from":"","smart_match_info":null,"beauty_face_preset_infos":[],"beauty_body_preset_id":"","beauty_face_auto_preset":{"preset_id":"","name":"","rate_map":"","scene":""},"beauty_face_auto_preset_infos":[],"beauty_body_auto_preset":null,"live_photo_timestamp":-1,"live_photo_cover_path":"","content_feature_info":null,"corner_pin":null,"surface_trackings":[],"video_mask_stroke":{"resource_id":"","path":"","type":"","color":"","size":0,"alpha":0,"distance":0,"texture":0,"horizontal_shift":0,"vertical_shift":0},"video_mask_shadow":{"resource_id":"","path":"","color":"","alpha":0,"blur":0,"distance":0,"angle":0}}],"tail_leaders":[],"audios":[],"images":[],"texts":[{"recognize_task_id":"","id":"EC013097-CA75-49e2-8D67-B760BC04C8CF","name":"","recognize_text":"","recognize_model":"","punc_model":"","type":"text","content":"{\"text\":\"COPIA TIMELINES\",\"styles\":[{\"fill\":{\"content\":{\"render_type\":\"solid\",\"solid\":{\"color\":[1,1,1]}}},\"font\":{\"path\":\"C:/Users/USER/AppData/Local/CapCut/Apps/8.7.0.3685/Resources/Font/SystemFont/en.ttf\",\"id\":\"\"},\"size\":15,\"range\":[0,15]}]}","base_content":"","words":{"start_time":[],"end_time":[],"text":[]},"current_words":{"start_time":[],"end_time":[],"text":[]},"global_alpha":1,"combo_info":{"text_templates":[]},"caption_template_info":{"resource_id":"","third_resource_id":"","resource_name":"","category_id":"","category_name":"","effect_id":"","request_id":"","path":"","is_new":false,"source_platform":0},"layer_weight":1,"letter_spacing":0,"text_curve":null,"text_loop_on_path":false,"offset_on_path":0,"enable_path_typesetting":false,"text_exceeds_path_process_type":0,"text_typesetting_paths":null,"text_typesetting_paths_file":"","text_typesetting_path_index":0,"line_spacing":0.02,"has_shadow":false,"shadow_color":"","shadow_alpha":0.9,"shadow_smoothing":0.45,"shadow_distance":5,"shadow_point":{"x":0.6363961030678928,"y":-0.6363961030678928},"shadow_angle":-45,"shadow_thickness_projection_enable":false,"shadow_thickness_projection_angle":0,"shadow_thickness_projection_distance":0,"border_alpha":1,"border_color":"","border_width":0.08,"border_mode":0,"style_name":"","text_color":"#FFFFFF","text_alpha":1,"font_name":"","font_title":"none","font_size":15,"font_path":"C:/Users/USER/AppData/Local/CapCut/Apps/8.7.0.3685/Resources/Font/SystemFont/en.ttf","font_id":"","font_resource_id":"","initial_scale":1,"font_url":"","typesetting":0,"alignment":1,"line_feed":1,"use_effect_default_color":true,"is_rich_text":false,"shape_clip_x":false,"shape_clip_y":false,"ktv_color":"","text_to_audio_ids":[],"bold_width":0,"italic_degree":0,"underline":false,"underline_width":0.05,"underline_offset":0.22,"sub_type":0,"check_flag":7,"text_size":30,"font_category_name":"","font_source_platform":0,"font_third_resource_id":"","font_category_id":"","add_type":0,"operation_type":0,"recognize_type":0,"fonts":[],"background_color":"","background_alpha":1,"background_style":0,"background_round_radius":0,"background_width":0.14,"background_height":0.14,"background_vertical_offset":0,"background_horizontal_offset":0,"background_fill":"","single_char_bg_enable":false,"single_char_bg_color":"","single_char_bg_alpha":1,"single_char_bg_round_radius":0.3,"single_char_bg_width":0,"single_char_bg_height":0,"single_char_bg_vertical_offset":0,"single_char_bg_horizontal_offset":0,"font_team_id":"","tts_auto_update":false,"text_preset_resource_id":"","group_id":"","preset_id":"","preset_name":"","preset_category":"","preset_category_id":"","preset_index":0,"preset_has_set_alignment":false,"force_apply_line_max_width":false,"language":"","relevance_segment":[],"original_size":[],"fixed_width":-1,"fixed_height":-1,"line_max_width":0.82,"oneline_cutoff":false,"cutoff_postfix":"","subtitle_template_original_fontsize":0,"subtitle_keywords":null,"inner_padding":-1,"multi_language_current":"none","source_from":"","is_lyric_effect":false,"lyric_group_id":"","lyrics_template":{"resource_id":"","resource_name":"","panel":"","effect_id":"","path":"","category_id":"","category_name":"","request_id":""},"is_batch_replace":false,"is_words_linear":false,"ssml_content":"","subtitle_keywords_config":null,"sub_template_id":-1,"translate_original_text":""},{"id":"7dc394db-8aa4-4fed-be3e-3ea9df3f44e3","type":"text","content":"{\"styles\":[{\"range\":[0,11],\"size\":20,\"bold\":false,\"italic\":false,\"underline\":false,\"fill\":{\"alpha\":1,\"content\":{\"render_type\":\"solid\",\"solid\":{\"alpha\":1,\"color\":[1,0.23137254901960785,0.18823529411764706]}}}}],\"text\":\"SOLO RADICE\"}","alignment":1,"font_size":20,"text_color":"#FF3B30","typesetting":0,"letter_spacing":0,"line_spacing":0.02,"line_feed":1,"line_max_width":0.82,"force_apply_line_max_width":false,"check_flag":7,"fixed_width":-1,"fixed_height":-1}],"effects":[],"stickers":[],"canvases":[{"id":"7BD087AC-04E6-404a-985B-0234C3BE5B11","type":"canvas_color","color":"","blur":0,"image":"","album_image":"","image_id":"","image_name":"","source_platform":0,"team_id":""}],"transitions":[],"audio_effects":[],"audio_fades":[],"beats":[],"material_animations":[{"id":"96FA630D-B877-488c-8455-615C83E37720","type":"sticker_animation","animations":[],"multi_language_current":"none"}],"placeholders":[],"placeholder_infos":[{"id":"A3316AA6-DEBD-4422-9BBB-8725B89578B9","type":"placeholder_info","meta_type":"none","res_path":"","res_text":"","error_path":"","error_text":""},{"id":"bfd94e0e-3621-470c-bcb9-c1b1c5edfee3","type":"placeholder_info","error_path":"","error_text":"","meta_type":"none","res_path":"","res_text":""}],"speeds":[{"id":"784AEB2D-657B-4f08-96C6-B364278719F3","type":"speed","mode":0,"speed":1,"curve_speed":null},{"id":"acfdcd77-3ff4-4504-91a1-ef11d3d987d1","type":"speed","speed":1,"mode":0,"curve_speed":null}],"common_mask":[],"chromas":[],"text_templates":[],"realtime_denoises":[],"audio_pannings":[],"audio_pitch_shifts":[],"video_trackings":[],"hsl":[],"drafts":[],"color_curves":[],"hsl_curves":[],"primary_color_wheels":[],"log_color_wheels":[],"video_effects":[],"audio_balances":[],"handwrites":[],"manual_deformations":[],"manual_beautys":[],"plugin_effects":[],"sound_channel_mappings":[{"id":"A5ED61F8-446A-4230-8940-F4863A7EFE9F","type":"none","audio_channel_mapping":0,"is_config_open":false},{"id":"7d26d1bc-294b-4a77-860b-1c86ca179177","type":"none","audio_channel_mapping":0,"is_config_open":false}],"green_screens":[],"shapes":[],"material_colors":[{"id":"396FCFFC-9E35-4e1e-944E-6C1D508AD8FE","is_color_clip":false,"is_gradient":false,"solid_color":"","gradient_colors":[],"gradient_percents":[],"gradient_angle":90,"width":0,"height":0}],"digital_humans":[],"digital_human_model_dressing":[],"smart_crops":[],"ai_translates":[],"audio_track_indexes":[],"loudnesses":[],"vocal_beautifys":[],"vocal_separations":[{"id":"BE49926C-B7C5-41ac-8EBC-B1604009458D","type":"vocal_separation","choice":0,"removed_sounds":[],"time_range":null,"production_path":"","final_algorithm":"","enter_from":""},{"id":"f4d0cbcd-05a3-4acd-8734-56329b245084","type":"vocal_separation","choice":0,"enter_from":"","final_algorithm":"","production_path":"","removed_sounds":[],"time_range":null}],"smart_relights":[],"time_marks":[],"multi_language_refs":[],"video_shadows":[],"video_strokes":[],"video_radius":[]},"keyframes":{"videos":[],"audios":[],"texts":[],"stickers":[],"filters":[],"adjusts":[],"handwrites":[],"effects":[]},"keyframe_graph_list":[],"platform":{"os":"windows","os_version":"10.0.26200","app_id":359289,"app_version":"8.7.0","app_source":"cc","device_id":"","hard_disk_id":"","mac_address":""},"last_modified_platform":{"os":"windows","os_version":"10.0.26200","app_id":359289,"app_version":"8.7.0","app_source":"cc","device_id":"","hard_disk_id":"","mac_address":""},"mutable_config":null,"cover":null,"retouch_cover":null,"extra_info":null,"relationships":[],"render_index_track_mode_on":true,"free_render_index_mode_on":false,"static_cover_image_path":"","source":"default","time_marks":null,"path":"","lyrics_effects":[],"uneven_animation_template_info":{"composition":"","content":"","order":"","sub_template_info_list":[]},"draft_type":"video","smart_ads_info":{"page_from":"","routine":"","draft_url":""},"function_assistant_info":{"smart_rec_applied":false,"fixed_rec_applied":false,"auto_adjust":false,"auto_adjust_segid_list":[],"color_correction":false,"color_correction_segid_list":[],"enhance_quality":false,"smooth_slow_motion":false,"deflicker_segid_list":[],"video_noise_segid_list":[],"enhance_quality_segid_list":[],"smart_segid_list":[],"retouch":false,"retouch_segid_list":[],"enhande_voice":false,"enhance_voice_segid_list":[],"audio_noise_segid_list":[],"auto_caption":false,"auto_caption_segid_list":[],"auto_caption_template_id":"","caption_opt":false,"caption_opt_segid_list":[],"eye_correction":false,"eye_correction_segid_list":[],"normalize_loudness":false,"normalize_loudness_segid_list":[],"normalize_loudness_audio_denoise_segid_list":[],"auto_adjust_fixed":false,"auto_adjust_fixed_value":50,"color_correction_fixed":false,"color_correction_fixed_value":50,"normalize_loudness_fixed":false,"enhande_voice_fixed":false,"retouch_fixed":false,"enhance_quality_fixed":false,"smooth_slow_motion_fixed":false,"fps":{"num":0,"den":1}}} \ No newline at end of file diff --git a/test/fixtures/capcut-8.7-windows-active/draft_meta_info.json b/test/fixtures/capcut-8.7-windows-active/draft_meta_info.json new file mode 100644 index 0000000..0f35358 --- /dev/null +++ b/test/fixtures/capcut-8.7-windows-active/draft_meta_info.json @@ -0,0 +1 @@ +{"cloud_draft_cover":false,"cloud_draft_sync":false,"cloud_package_completed_time":"","draft_cloud_capcut_purchase_info":"","draft_cloud_last_action_download":false,"draft_cloud_package_type":"","draft_cloud_purchase_info":"","draft_cloud_template_id":"","draft_cloud_tutorial_info":"","draft_cloud_videocut_purchase_info":"","draft_cover":"draft_cover.jpg","draft_deeplink_url":"","draft_enterprise_info":{"draft_enterprise_extra":"","draft_enterprise_id":"","draft_enterprise_name":"","enterprise_material":[]},"draft_fold_path":"C:/Users/USER/AppData/Local/CapCut/User Data/Projects/com.lveditor.draft/collaudo-zero","draft_id":"4D190E00-C5DF-434c-A682-2BF677900FE1","draft_is_ae_produce":false,"draft_is_ai_packaging_used":false,"draft_is_ai_shorts":false,"draft_is_ai_translate":false,"draft_is_article_video_draft":false,"draft_is_cloud_temp_draft":false,"draft_is_from_deeplink":"false","draft_is_invisible":false,"draft_is_pippit_draft":false,"draft_is_web_article_video":false,"draft_materials":[{"type":0,"value":[{"ai_group_type":"","create_time":1790948522,"duration":33333,"enter_from":0,"extra_info":"","file_Path":"","height":0,"id":"cd484075-d92a-4bc9-b45c-d093d2f9e71b","import_time":1790948522,"import_time_ms":1790948522551466,"item_source":1,"md5":"","metetype":"none","roughcut_time_range":{"duration":33333,"start":0},"sub_time_range":{"duration":-1,"start":-1},"type":0,"width":0},{"ai_group_type":"","create_time":1790942829,"duration":89280000,"enter_from":0,"extra_info":"test-montaggio-C0148.MP4","file_Path":"C:/Users/USER/Desktop/test-montaggio-C0148.MP4","height":2160,"id":"5f45aacc-553e-4597-a0a2-2695fad25f42","import_time":1790948556,"import_time_ms":1790948555699018,"item_source":1,"md5":"","metetype":"video","roughcut_time_range":{"duration":89280000,"start":0},"sub_time_range":{"duration":-1,"start":-1},"type":0,"width":3840}]},{"type":1,"value":[]},{"type":2,"value":[]},{"type":3,"value":[]},{"type":6,"value":[]},{"type":7,"value":[]},{"type":8,"value":[]}],"draft_materials_copied_info":[],"draft_name":"collaudo-zero","draft_need_rename_folder":false,"draft_new_version":"","draft_removable_storage_device":"","draft_root_path":"C:\\Users\\USER\\AppData\\Local\\CapCut\\User Data\\Projects\\com.lveditor.draft","draft_segment_extra_info":[],"draft_timeline_materials_size_":615346239,"draft_type":"","draft_web_article_video_enter_from":"","tm_draft_cloud_completed":"","tm_draft_cloud_entry_id":-1,"tm_draft_cloud_modified":0,"tm_draft_cloud_parent_entry_id":-1,"tm_draft_cloud_space_id":-1,"tm_draft_cloud_user_id":-1,"tm_draft_create":1790948522573925,"tm_draft_modified":1790950047328070,"tm_draft_removed":0,"tm_duration":89300000} \ No newline at end of file diff --git a/test/fixtures/capcut-8.7-windows-active/template-2.tmp b/test/fixtures/capcut-8.7-windows-active/template-2.tmp new file mode 100644 index 0000000..d88dc3c --- /dev/null +++ b/test/fixtures/capcut-8.7-windows-active/template-2.tmp @@ -0,0 +1 @@ +{"id":"C1EFCDF2-B885-48ff-A2F8-C33A6C4F4A52","version":360000,"new_version":"171.0.0","name":"","duration":89300000,"create_time":0,"update_time":0,"fps":30,"is_drop_frame_timecode":false,"color_space":0,"config":{"video_mute":false,"record_audio_last_index":1,"extract_audio_last_index":1,"original_sound_last_index":1,"subtitle_recognition_id":"","subtitle_taskinfo":[],"lyrics_recognition_id":"","lyrics_taskinfo":[],"subtitle_sync":true,"lyrics_sync":true,"voice_change_sync":false,"sticker_max_index":1,"adjust_max_index":1,"material_save_mode":0,"export_range":null,"maintrack_adsorb":true,"combination_max_index":1,"attachment_info":[],"zoom_info_params":null,"system_font_list":[],"multi_language_mode":"none","multi_language_main":"none","multi_language_current":"none","multi_language_list":[],"subtitle_keywords_config":null,"use_float_render":false},"canvas_config":{"ratio":"9:16","width":1080,"height":1920,"background":null},"tracks":[{"id":"8791E4C1-F5EA-4fb4-BD65-2FD37508ABC6","type":"video","segments":[{"id":"1D19838B-E980-4e2b-A0CC-5A42470301F5","source_timerange":{"start":0,"duration":89300000},"target_timerange":{"start":0,"duration":89300000},"render_timerange":{"start":0,"duration":0},"desc":"","state":0,"speed":1,"is_loop":false,"is_tone_modify":false,"reverse":false,"intensifies_audio":false,"cartoon":false,"volume":1,"last_nonzero_volume":1,"clip":{"scale":{"x":4.739999999999999,"y":4.739999999999999},"rotation":0,"transform":{"x":0.32407407407407407,"y":-0.10416666666666669},"flip":{"vertical":false,"horizontal":false},"alpha":1},"uniform_scale":{"on":true,"value":1},"material_id":"46329E06-84DA-4e33-B862-D991BCFCD3F4","extra_material_refs":["784AEB2D-657B-4f08-96C6-B364278719F3","A3316AA6-DEBD-4422-9BBB-8725B89578B9","7BD087AC-04E6-404a-985B-0234C3BE5B11","A5ED61F8-446A-4230-8940-F4863A7EFE9F","396FCFFC-9E35-4e1e-944E-6C1D508AD8FE","BE49926C-B7C5-41ac-8EBC-B1604009458D"],"render_index":0,"keyframe_refs":[],"enable_lut":true,"enable_adjust":true,"enable_hsl":false,"visible":true,"group_id":"","enable_color_curves":true,"enable_hsl_curves":true,"track_render_index":0,"hdr_settings":{"mode":1,"intensity":1,"nits":1000},"enable_color_wheels":true,"track_attribute":0,"is_placeholder":false,"template_id":"","enable_smart_color_adjust":false,"template_scene":"default","common_keyframes":[],"caption_info":null,"responsive_layout":{"enable":false,"target_follow":"","size_layout":0,"horizontal_pos_layout":0,"vertical_pos_layout":0},"enable_color_match_adjust":false,"enable_color_correct_adjust":false,"enable_adjust_mask":false,"raw_segment_id":"","lyric_keyframes":null,"enable_video_mask":true,"digital_human_template_group_id":"","color_correct_alg_result":"","source":"segmentsourcenormal","enable_mask_stroke":false,"enable_mask_shadow":false,"enable_color_adjust_pro":false}],"flag":0,"attribute":0,"name":"","is_default_name":true},{"id":"A205341A-4228-4f11-B712-C3DA0E8FD0CC","type":"text","segments":[{"id":"E54B4AF4-79A3-4e75-83B3-039FF8D87126","source_timerange":null,"target_timerange":{"start":8966666,"duration":3000000},"render_timerange":{"start":0,"duration":0},"desc":"","state":0,"speed":1,"is_loop":false,"is_tone_modify":false,"reverse":false,"intensifies_audio":false,"cartoon":false,"volume":1,"last_nonzero_volume":1,"clip":{"scale":{"x":1,"y":1},"rotation":0,"transform":{"x":0,"y":0},"flip":{"vertical":false,"horizontal":false},"alpha":1},"uniform_scale":{"on":true,"value":1},"material_id":"EC013097-CA75-49e2-8D67-B760BC04C8CF","extra_material_refs":["96FA630D-B877-488c-8455-615C83E37720"],"render_index":14000,"keyframe_refs":[],"enable_lut":false,"enable_adjust":false,"enable_hsl":false,"visible":true,"group_id":"","enable_color_curves":true,"enable_hsl_curves":true,"track_render_index":1,"hdr_settings":null,"enable_color_wheels":true,"track_attribute":0,"is_placeholder":false,"template_id":"","enable_smart_color_adjust":false,"template_scene":"default","common_keyframes":[],"caption_info":null,"responsive_layout":{"enable":false,"target_follow":"","size_layout":0,"horizontal_pos_layout":0,"vertical_pos_layout":0},"enable_color_match_adjust":false,"enable_color_correct_adjust":false,"enable_adjust_mask":false,"raw_segment_id":"","lyric_keyframes":null,"enable_video_mask":true,"digital_human_template_group_id":"","color_correct_alg_result":"","source":"segmentsourcenormal","enable_mask_stroke":false,"enable_mask_shadow":false,"enable_color_adjust_pro":false},{"id":"a39ca480-585e-4bad-a7a9-e2fe61daff98","material_id":"7dc394db-8aa4-4fed-be3e-3ea9df3f44e3","raw_segment_id":"A205341A-4228-4f11-B712-C3DA0E8FD0CC","target_timerange":{"start":1000000,"duration":4000000},"source_timerange":{"start":0,"duration":4000000},"speed":1,"volume":1,"visible":true,"reverse":false,"clip":{"alpha":1,"rotation":0,"scale":{"x":1,"y":1},"transform":{"x":0,"y":0},"flip":{"horizontal":false,"vertical":false}},"render_index":15000,"track_render_index":0,"track_attribute":0,"extra_material_refs":["acfdcd77-3ff4-4504-91a1-ef11d3d987d1","bfd94e0e-3621-470c-bcb9-c1b1c5edfee3","7d26d1bc-294b-4a77-860b-1c86ca179177","f4d0cbcd-05a3-4acd-8734-56329b245084"],"common_keyframes":[],"keyframe_refs":[]}],"flag":0,"attribute":0,"name":"","is_default_name":true}],"group_container":null,"materials":{"flowers":[],"videos":[{"id":"46329E06-84DA-4e33-B862-D991BCFCD3F4","unique_id":"","type":"video","duration":89300000,"path":"C:/Users/USER/Desktop/test-montaggio-C0148.MP4","media_path":"","local_id":"","has_audio":true,"reverse_path":"","intensifies_path":"","reverse_intensifies_path":"","intensifies_audio_path":"","cartoon_path":"","width":3840,"height":2160,"category_id":"","category_name":"local","material_id":"","material_name":"test-montaggio-C0148.MP4","material_url":"","crop":{"upper_left_x":0,"upper_left_y":0,"upper_right_x":1,"upper_right_y":0,"lower_left_x":0,"lower_left_y":1,"lower_right_x":1,"lower_right_y":1},"crop_ratio":"free","audio_fade":null,"crop_scale":1,"extra_type_option":0,"stable":{"stable_level":0,"matrix_path":"","time_range":{"start":0,"duration":0}},"matting":{"flag":0,"path":"","interactiveTime":[],"has_use_quick_brush":false,"strokes":[],"has_use_quick_eraser":false,"expansion":0,"feather":0,"reverse":false,"custom_matting_id":"","enable_matting_stroke":false,"is_clould":false,"mask_video_path":"","cloud_product_fps":0},"source":0,"source_platform":0,"formula_id":"","check_flag":62978047,"video_algorithm":{"algorithms":[],"time_range":null,"path":"","gameplay_configs":[],"ai_in_painting_config":[],"complement_frame_config":null,"motion_blur_config":null,"deflicker":null,"noise_reduction":null,"quality_enhance":null,"super_resolution":null,"ai_background_configs":[],"smart_complement_frame":null,"aigc_generate":null,"aigc_generate_list":[],"mouth_shape_driver":null,"ai_expression_driven":null,"ai_motion_driven":null,"image_interpretation":null,"story_video_modify_video_config":{"task_id":"","is_overwrite_last_video":false,"tracker_task_id":""},"skip_algorithm_index":[]},"is_unified_beauty_mode":false,"is_set_beauty_mode":false,"object_locked":null,"smart_motion":null,"multi_camera_info":null,"freeze":null,"picture_from":"none","picture_set_category_id":"","picture_set_category_name":"","team_id":"","local_material_id":"5f45aacc-553e-4597-a0a2-2695fad25f42","origin_material_id":"","request_id":"","has_sound_separated":false,"is_text_edit_overdub":false,"is_ai_generate_content":false,"aigc_type":"none","is_copyright":false,"aigc_history_id":"","aigc_item_id":"","local_material_from":"","smart_match_info":null,"beauty_face_preset_infos":[],"beauty_body_preset_id":"","beauty_face_auto_preset":{"preset_id":"","name":"","rate_map":"","scene":""},"beauty_face_auto_preset_infos":[],"beauty_body_auto_preset":null,"live_photo_timestamp":-1,"live_photo_cover_path":"","content_feature_info":null,"corner_pin":null,"surface_trackings":[],"video_mask_stroke":{"resource_id":"","path":"","type":"","color":"","size":0,"alpha":0,"distance":0,"texture":0,"horizontal_shift":0,"vertical_shift":0},"video_mask_shadow":{"resource_id":"","path":"","color":"","alpha":0,"blur":0,"distance":0,"angle":0}}],"tail_leaders":[],"audios":[],"images":[],"texts":[{"recognize_task_id":"","id":"EC013097-CA75-49e2-8D67-B760BC04C8CF","name":"","recognize_text":"","recognize_model":"","punc_model":"","type":"text","content":"{\"text\":\"COPIA TIMELINES\",\"styles\":[{\"fill\":{\"content\":{\"render_type\":\"solid\",\"solid\":{\"color\":[1,1,1]}}},\"font\":{\"path\":\"C:/Users/USER/AppData/Local/CapCut/Apps/8.7.0.3685/Resources/Font/SystemFont/en.ttf\",\"id\":\"\"},\"size\":15,\"range\":[0,15]}]}","base_content":"","words":{"start_time":[],"end_time":[],"text":[]},"current_words":{"start_time":[],"end_time":[],"text":[]},"global_alpha":1,"combo_info":{"text_templates":[]},"caption_template_info":{"resource_id":"","third_resource_id":"","resource_name":"","category_id":"","category_name":"","effect_id":"","request_id":"","path":"","is_new":false,"source_platform":0},"layer_weight":1,"letter_spacing":0,"text_curve":null,"text_loop_on_path":false,"offset_on_path":0,"enable_path_typesetting":false,"text_exceeds_path_process_type":0,"text_typesetting_paths":null,"text_typesetting_paths_file":"","text_typesetting_path_index":0,"line_spacing":0.02,"has_shadow":false,"shadow_color":"","shadow_alpha":0.9,"shadow_smoothing":0.45,"shadow_distance":5,"shadow_point":{"x":0.6363961030678928,"y":-0.6363961030678928},"shadow_angle":-45,"shadow_thickness_projection_enable":false,"shadow_thickness_projection_angle":0,"shadow_thickness_projection_distance":0,"border_alpha":1,"border_color":"","border_width":0.08,"border_mode":0,"style_name":"","text_color":"#FFFFFF","text_alpha":1,"font_name":"","font_title":"none","font_size":15,"font_path":"C:/Users/USER/AppData/Local/CapCut/Apps/8.7.0.3685/Resources/Font/SystemFont/en.ttf","font_id":"","font_resource_id":"","initial_scale":1,"font_url":"","typesetting":0,"alignment":1,"line_feed":1,"use_effect_default_color":true,"is_rich_text":false,"shape_clip_x":false,"shape_clip_y":false,"ktv_color":"","text_to_audio_ids":[],"bold_width":0,"italic_degree":0,"underline":false,"underline_width":0.05,"underline_offset":0.22,"sub_type":0,"check_flag":7,"text_size":30,"font_category_name":"","font_source_platform":0,"font_third_resource_id":"","font_category_id":"","add_type":0,"operation_type":0,"recognize_type":0,"fonts":[],"background_color":"","background_alpha":1,"background_style":0,"background_round_radius":0,"background_width":0.14,"background_height":0.14,"background_vertical_offset":0,"background_horizontal_offset":0,"background_fill":"","single_char_bg_enable":false,"single_char_bg_color":"","single_char_bg_alpha":1,"single_char_bg_round_radius":0.3,"single_char_bg_width":0,"single_char_bg_height":0,"single_char_bg_vertical_offset":0,"single_char_bg_horizontal_offset":0,"font_team_id":"","tts_auto_update":false,"text_preset_resource_id":"","group_id":"","preset_id":"","preset_name":"","preset_category":"","preset_category_id":"","preset_index":0,"preset_has_set_alignment":false,"force_apply_line_max_width":false,"language":"","relevance_segment":[],"original_size":[],"fixed_width":-1,"fixed_height":-1,"line_max_width":0.82,"oneline_cutoff":false,"cutoff_postfix":"","subtitle_template_original_fontsize":0,"subtitle_keywords":null,"inner_padding":-1,"multi_language_current":"none","source_from":"","is_lyric_effect":false,"lyric_group_id":"","lyrics_template":{"resource_id":"","resource_name":"","panel":"","effect_id":"","path":"","category_id":"","category_name":"","request_id":""},"is_batch_replace":false,"is_words_linear":false,"ssml_content":"","subtitle_keywords_config":null,"sub_template_id":-1,"translate_original_text":""},{"id":"7dc394db-8aa4-4fed-be3e-3ea9df3f44e3","type":"text","content":"{\"styles\":[{\"range\":[0,11],\"size\":20,\"bold\":false,\"italic\":false,\"underline\":false,\"fill\":{\"alpha\":1,\"content\":{\"render_type\":\"solid\",\"solid\":{\"alpha\":1,\"color\":[1,0.23137254901960785,0.18823529411764706]}}}}],\"text\":\"SOLO RADICE\"}","alignment":1,"font_size":20,"text_color":"#FF3B30","typesetting":0,"letter_spacing":0,"line_spacing":0.02,"line_feed":1,"line_max_width":0.82,"force_apply_line_max_width":false,"check_flag":7,"fixed_width":-1,"fixed_height":-1}],"effects":[],"stickers":[],"canvases":[{"id":"7BD087AC-04E6-404a-985B-0234C3BE5B11","type":"canvas_color","color":"","blur":0,"image":"","album_image":"","image_id":"","image_name":"","source_platform":0,"team_id":""}],"transitions":[],"audio_effects":[],"audio_fades":[],"beats":[],"material_animations":[{"id":"96FA630D-B877-488c-8455-615C83E37720","type":"sticker_animation","animations":[],"multi_language_current":"none"}],"placeholders":[],"placeholder_infos":[{"id":"A3316AA6-DEBD-4422-9BBB-8725B89578B9","type":"placeholder_info","meta_type":"none","res_path":"","res_text":"","error_path":"","error_text":""},{"id":"bfd94e0e-3621-470c-bcb9-c1b1c5edfee3","type":"placeholder_info","error_path":"","error_text":"","meta_type":"none","res_path":"","res_text":""}],"speeds":[{"id":"784AEB2D-657B-4f08-96C6-B364278719F3","type":"speed","mode":0,"speed":1,"curve_speed":null},{"id":"acfdcd77-3ff4-4504-91a1-ef11d3d987d1","type":"speed","speed":1,"mode":0,"curve_speed":null}],"common_mask":[],"chromas":[],"text_templates":[],"realtime_denoises":[],"audio_pannings":[],"audio_pitch_shifts":[],"video_trackings":[],"hsl":[],"drafts":[],"color_curves":[],"hsl_curves":[],"primary_color_wheels":[],"log_color_wheels":[],"video_effects":[],"audio_balances":[],"handwrites":[],"manual_deformations":[],"manual_beautys":[],"plugin_effects":[],"sound_channel_mappings":[{"id":"A5ED61F8-446A-4230-8940-F4863A7EFE9F","type":"none","audio_channel_mapping":0,"is_config_open":false},{"id":"7d26d1bc-294b-4a77-860b-1c86ca179177","type":"none","audio_channel_mapping":0,"is_config_open":false}],"green_screens":[],"shapes":[],"material_colors":[{"id":"396FCFFC-9E35-4e1e-944E-6C1D508AD8FE","is_color_clip":false,"is_gradient":false,"solid_color":"","gradient_colors":[],"gradient_percents":[],"gradient_angle":90,"width":0,"height":0}],"digital_humans":[],"digital_human_model_dressing":[],"smart_crops":[],"ai_translates":[],"audio_track_indexes":[],"loudnesses":[],"vocal_beautifys":[],"vocal_separations":[{"id":"BE49926C-B7C5-41ac-8EBC-B1604009458D","type":"vocal_separation","choice":0,"removed_sounds":[],"time_range":null,"production_path":"","final_algorithm":"","enter_from":""},{"id":"f4d0cbcd-05a3-4acd-8734-56329b245084","type":"vocal_separation","choice":0,"enter_from":"","final_algorithm":"","production_path":"","removed_sounds":[],"time_range":null}],"smart_relights":[],"time_marks":[],"multi_language_refs":[],"video_shadows":[],"video_strokes":[],"video_radius":[]},"keyframes":{"videos":[],"audios":[],"texts":[],"stickers":[],"filters":[],"adjusts":[],"handwrites":[],"effects":[]},"keyframe_graph_list":[],"platform":{"os":"windows","os_version":"10.0.26200","app_id":359289,"app_version":"8.7.0","app_source":"cc","device_id":"","hard_disk_id":"","mac_address":""},"last_modified_platform":{"os":"windows","os_version":"10.0.26200","app_id":359289,"app_version":"8.7.0","app_source":"cc","device_id":"","hard_disk_id":"","mac_address":""},"mutable_config":null,"cover":null,"retouch_cover":null,"extra_info":null,"relationships":[],"render_index_track_mode_on":true,"free_render_index_mode_on":false,"static_cover_image_path":"","source":"default","time_marks":null,"path":"","lyrics_effects":[],"uneven_animation_template_info":{"composition":"","content":"","order":"","sub_template_info_list":[]},"draft_type":"video","smart_ads_info":{"page_from":"","routine":"","draft_url":""},"function_assistant_info":{"smart_rec_applied":false,"fixed_rec_applied":false,"auto_adjust":false,"auto_adjust_segid_list":[],"color_correction":false,"color_correction_segid_list":[],"enhance_quality":false,"smooth_slow_motion":false,"deflicker_segid_list":[],"video_noise_segid_list":[],"enhance_quality_segid_list":[],"smart_segid_list":[],"retouch":false,"retouch_segid_list":[],"enhande_voice":false,"enhance_voice_segid_list":[],"audio_noise_segid_list":[],"auto_caption":false,"auto_caption_segid_list":[],"auto_caption_template_id":"","caption_opt":false,"caption_opt_segid_list":[],"eye_correction":false,"eye_correction_segid_list":[],"normalize_loudness":false,"normalize_loudness_segid_list":[],"normalize_loudness_audio_denoise_segid_list":[],"auto_adjust_fixed":false,"auto_adjust_fixed_value":50,"color_correction_fixed":false,"color_correction_fixed_value":50,"normalize_loudness_fixed":false,"enhande_voice_fixed":false,"retouch_fixed":false,"enhance_quality_fixed":false,"smooth_slow_motion_fixed":false,"fps":{"num":0,"den":1}}} \ No newline at end of file diff --git a/test/sync-timelines-nested.test.mjs b/test/sync-timelines-nested.test.mjs index 6689b1f..967b16f 100644 --- a/test/sync-timelines-nested.test.mjs +++ b/test/sync-timelines-nested.test.mjs @@ -13,7 +13,9 @@ function canonicalDraft() { duration: 1_000_000, fps: 30, canvas_config: { width: 1080, height: 1920, ratio: "9:16" }, - platform: { app_source: "cc", app_version: "8.7.0", os: "windows" }, + // Preserve the opt-in repair contract on an unverified build. Windows + // 8.7.0 now selects the active document (covered in active-timeline.test). + platform: { app_source: "cc", app_version: "8.7.1", os: "windows" }, tracks: [{ id: "T1", type: "text", name: "text", attribute: 0, segments: [] }], materials: { videos: [],