From 4607c3fb33ed3b7af65e828922c831d708fd8bd3 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 10:44:52 +0000 Subject: [PATCH 01/11] feat(sync-timelines): opt-in --nested repair for Timelines// documents (#50) --- src/command-specs.ts | 10 +- src/index.ts | 57 ++++++++- src/store.ts | 121 ++++++++++++++++++- test/sync-timelines-nested.test.mjs | 172 ++++++++++++++++++++++++++++ 4 files changed, 348 insertions(+), 12 deletions(-) create mode 100644 test/sync-timelines-nested.test.mjs diff --git a/src/command-specs.ts b/src/command-specs.ts index 81bc432..9ea52b7 100644 --- a/src/command-specs.ts +++ b/src/command-specs.ts @@ -221,7 +221,7 @@ const usages = { doctor: "capcut doctor", diagnose: "capcut diagnose [--bundle ]", fixture: "capcut fixture --out ", - "sync-timelines": "capcut sync-timelines [--apply]", + "sync-timelines": "capcut sync-timelines [--nested] [--apply]", restore: "capcut restore [--step | --list]", serve: "capcut serve [--queue ] [options]", decrypt: "capcut decrypt ", @@ -538,6 +538,14 @@ const optionsByCommand: Record = { "boolean", "Rewrite only the drifted mirror files from draft_content.json (default: print the plan only).", ), + option( + "nested", + ["--nested"], + "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.", + ), ], "replace-media": [ option("retime", ["--retime"], "boolean", "Fit the segment to the new clip instead of preserving in/out."), diff --git a/src/index.ts b/src/index.ts index 1f3a5fb..61415ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -805,6 +805,8 @@ interface Flags { volume?: number; template?: string; drafts?: string; + // sync-timelines + nested?: boolean; // keyframe easing?: string; // Phase 1 decorators @@ -1351,6 +1353,8 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } { flags.plan = true; } else if (a === "--apply") { flags.apply = true; + } else if (a === "--nested") { + flags.nested = true; } else if (a === "--sync") { flags.sync = true; } else if (a === "--add") { @@ -3963,8 +3967,11 @@ async function cmdRename(positional: string[], flags: Flags): Promise { // --force-write. Plan-only by default; --apply writes. Returns the exit code: // 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 [--apply] [--force-write]"); - const { plan, canonicalDraft, canonicalCandidate, driftedCandidates } = planTimelineSync(projectPath); + if (!projectPath) die("Usage: capcut sync-timelines [--nested] [--apply] [--force-write]"); + const { plan, canonicalDraft, canonicalCandidate, driftedCandidates, nestedDriftedCandidates } = planTimelineSync( + projectPath, + { nested: flags.nested === true }, + ); const warnUnreconcilable = (): void => { if (flags.quiet) return; @@ -3986,6 +3993,17 @@ function cmdSyncTimelines(projectPath: string | undefined, flags: Flags): number const noteCanonical = (): void => { if (!flags.quiet && plan.canonical_note) process.stderr.write(`NOTE: ${plan.canonical_note}\n`); }; + // Never let the nested layout pass silently (issue #50): when Timelines// + // documents exist and this run did not include them, say so and point at the + // opt-in instead of reporting "in sync" about files the plan never looked at. + const noteNestedSkipped = (): void => { + if (!flags.quiet && !plan.nested_included && plan.nested_available > 0) { + process.stderr.write( + `NOTE: ${plan.nested_available} nested Timelines/ document(s) present but not included in this repair — ` + + "pass --nested to reconcile them too (issue #50).\n", + ); + } + }; if (plan.in_sync) { const ok = plan.unreconcilable.length === 0; @@ -3995,6 +4013,7 @@ function cmdSyncTimelines(projectPath: string | undefined, flags: Flags): number out({ ok, applied: false, message, ...plan, in_sync: ok }, flags); if (!flags.quiet) process.stderr.write(`${message}\n`); noteCanonical(); + noteNestedSkipped(); warnUnreconcilable(); return ok ? 0 : 2; } @@ -4006,9 +4025,18 @@ function cmdSyncTimelines(projectPath: string | undefined, flags: Flags): number const canonicalTarget = plan.targets.find((target) => target.state === "canonical"); process.stderr.write(`plan: canonical ${plan.canonical} (mtime ${canonicalTarget?.mtime})\n`); noteCanonical(); + noteNestedSkipped(); for (const target of plan.targets) { if (target.state !== "drifted") continue; - const guidNote = target.guid_drifted ? ` [stale GUID ${target.guid} -> canonical]` : ""; + // Nested documents keep their own GUID on rewrite (issue #50's verified + // workaround); only root mirrors get reconciled to the canonical id. + const guidNote = target.nested + ? target.guid_drifted + ? ` [keeps its own GUID ${target.guid}]` + : "" + : target.guid_drifted + ? ` [stale GUID ${target.guid} -> canonical]` + : ""; process.stderr.write( `plan: rewrite ${target.file} (envelope: ${target.envelope}, mtime ${target.mtime})${guidNote}\n`, ); @@ -4090,8 +4118,24 @@ 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. - if (!flags.forceWrite) assertTargetsUnchangedOnDisk([canonicalCandidate, ...driftedCandidates]); + const nestedCandidates = nestedDriftedCandidates.map((entry) => entry.candidate); + if (!flags.forceWrite) assertTargetsUnchangedOnDisk([canonicalCandidate, ...driftedCandidates, ...nestedCandidates]); commitDraftTargets(driftedCandidates, canonicalDraft); + // Nested documents keep their own GUID (issue #50's verified 9.2.8 workaround + // writes the timeline id into the nested document): commit per GUID group so + // each rewrite carries the id the app expects to find there. + const nestedGroups = new Map(); + for (const entry of nestedDriftedCandidates) { + const group = nestedGroups.get(entry.keepGuid) ?? []; + group.push(entry.candidate); + nestedGroups.set(entry.keepGuid, group); + } + for (const [guid, group] of nestedGroups) { + commitDraftTargets( + group, + guid === null || guid === canonicalDraft.id ? canonicalDraft : { ...canonicalDraft, id: guid }, + ); + } // App auto-upgrade tripwire: --apply writes outside saveDraft, so it runs // the same warn-only last-seen comparison (the JSON result below picks the @@ -4102,7 +4146,7 @@ function cmdSyncTimelines(projectPath: string | undefined, flags: Flags): number if (track.drift) process.stderr.write(`WARNING: ${formatAppVersionDriftWarning(track.drift)}\n`); } - const verify = planTimelineSync(projectPath); + const verify = planTimelineSync(projectPath, { nested: flags.nested === true }); if (!verify.plan.in_sync) { die( `sync-timelines wrote the targets but they still diverge (${verify.plan.drifted.join(", ")}). ` + @@ -4125,7 +4169,8 @@ function cmdSyncTimelines(projectPath: string | undefined, flags: Flags): number }, flags, ); - if (!flags.quiet) process.stderr.write(`Reconciled from draft_content.json: ${plan.drifted.join(", ")}\n`); + if (!flags.quiet) process.stderr.write(`Reconciled from ${plan.canonical}: ${plan.drifted.join(", ")}\n`); + noteNestedSkipped(); warnUnreconcilable(); return ok ? 0 : 2; } diff --git a/src/store.ts b/src/store.ts index 8cb6198..39bfe5e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -259,7 +259,8 @@ export const NESTED_TIMELINES_WRITE_WARNING = "Timelines//draft_info.json and to regenerate the project-root files from it, so this root-mirror edit may " + "be discarded the next time the project opens. The CLI still writes the root files only — no verified fixture " + "for the nested layout exists yet. If you have such a project, contribute a bundle: " + - "`capcut fixture --out `."; + "`capcut fixture --out `. To copy this edit into the nested documents explicitly, run " + + "`capcut sync-timelines --nested --apply` (opt-in repair)."; /** The `diagnose` next_action / `version` note naming the layout — same shape * as the draft_info-primary action below (name the layout, state the risk, @@ -268,8 +269,9 @@ export const NESTED_TIMELINES_ACTION = "Timelines/ directory with a nested timeline document: CapCut 7.x is reported to keep the live document at " + "Timelines//draft_info.json, with the project-root file a regenerated mirror (issue #50). Edit commands " + "still read and write the project-root files, so CapCut 7.x may discard those edits on the next open. " + - "Evidence for this layout is report-only — if you have such a project, contribute a bundle: " + - "`capcut fixture --out `."; + "`capcut sync-timelines --nested --apply` copies the root timeline into the nested documents as an " + + "explicit opt-in repair. Evidence for this layout is report-only — if you have such a project, contribute a " + + "bundle: `capcut fixture --out `."; /** * The same structure on >= 8.7 storage, where `layout` deliberately stays at its @@ -287,7 +289,9 @@ 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 " + - "either direction. Edit commands read and write the project-root files only. If this project opens in your app " + + "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 " + "with a CLI edit intact — or without it — that is the artifact issue #50 has been blocked on: " + "`capcut fixture --out `."; @@ -611,6 +615,10 @@ export interface TimelineSyncTarget { timeline_hash: string | null; guid: string | null; guid_drifted: boolean; + /** Set on Timelines// documents included by the --nested opt-in. These + * keep their own GUID on repair: the verified 9.2.8 workaround in issue #50 + * writes the timeline id into the nested document, not the root draft id. */ + nested?: boolean; tracks?: number; segments?: number; } @@ -640,6 +648,12 @@ export interface TimelineSyncPlan { targets: TimelineSyncTarget[]; drifted: string[]; unreconcilable: TimelineSyncUnreconcilable[]; + /** Count of Timelines// documents on disk that --nested would cover — + * reported even when the run did not include them, so the plan can point at + * the opt-in instead of silently ignoring the nested layout (issue #50). */ + nested_available: number; + /** Whether this plan was computed with the --nested opt-in. */ + nested_included: boolean; } export interface TimelineSyncResult { @@ -647,6 +661,10 @@ export interface TimelineSyncResult { canonicalDraft: Draft; canonicalCandidate: DraftCandidate; driftedCandidates: DraftCandidate[]; + /** Drifted Timelines// documents (--nested only). Each carries the GUID + * the rewrite must keep: the nested document's own timeline id, per the + * verified issue-#50 workaround — never the canonical root draft id. */ + nestedDriftedCandidates: Array<{ candidate: DraftCandidate; keepGuid: string | null }>; } function syncTarget(candidate: DraftCandidate, state: TimelineSyncTarget["state"], guidDrifted: boolean) { @@ -663,6 +681,46 @@ function syncTarget(candidate: DraftCandidate, state: TimelineSyncTarget["state" }; } +// Nested sync targets probed inside each Timelines// directory: the +// timeline documents detection already knows, plus the same-directory +// template-2.tmp mirror the 9.2.8 report names (issue #50). project.json is +// the pointer file and is never a sync target — the repair must not rewrite +// which timeline the app considers active. +const NESTED_SYNC_FILES = [...NESTED_TIMELINE_FILES, "template-2.tmp"] as const; + +function nestedSyncDocPaths(projectDir: string): string[] { + const timelinesDir = join(projectDir, "Timelines"); + let entries: string[]; + try { + if (!statSync(timelinesDir).isDirectory()) return []; + entries = readdirSync(timelinesDir).sort(); + } catch { + return []; + } + const rel: string[] = []; + for (const entry of entries) { + const entryDir = join(timelinesDir, entry); + try { + if (!statSync(entryDir).isDirectory()) continue; + } catch { + continue; + } + for (const name of NESTED_SYNC_FILES) { + if (existsSync(join(entryDir, name))) rel.push(`Timelines/${entry}/${name}`); + } + } + return rel; +} + +/** Timeline hash with the draft id normalized away. Nested Timelines// + * documents keep their own GUID on repair (issue #50's verified workaround + * writes the timeline id), so their sync state must compare timeline content, + * not identity — otherwise a correctly repaired nested document would report + * drifted forever. */ +function timelineHashWithoutId(draft: Draft): string { + return hash(JSON.stringify({ ...draft, id: "" })); +} + /** * Plan for `sync-timelines` (issue #39, symptom #35): draft_content.json is * canonical (draft_info.json on the draft_info-primary Mac layout — see @@ -677,8 +735,17 @@ function syncTarget(candidate: DraftCandidate, state: TimelineSyncTarget["state" * project directory or its draft_content.json path; any other explicitly * named file is rejected so the plan and the write always cover the same * target set. + * + * With `nested: true` (the --nested opt-in, issue #50) the plan additionally + * covers every Timelines// timeline document plus its template-2.tmp + * mirror, in the same canonical -> mirror direction and behind the same + * newer-mirror refusal. Nested documents keep their own GUID on rewrite and + * compare by id-normalized timeline hash; Timelines/project.json is never a + * target. This is the 9.2.8 workaround verified in issue #50, mechanized — + * it does not change which file any command reads (PR #51's canonical flip + * stays rejected pending a field artifact). */ -export function planTimelineSync(input: string): TimelineSyncResult { +export function planTimelineSync(input: string, opts: { nested?: boolean } = {}): TimelineSyncResult { const resolved = resolve(input); if (existsSync(resolved) && statSync(resolved).isFile() && basename(resolved) !== "draft_content.json") { throw new Error( @@ -746,6 +813,47 @@ export function planTimelineSync(input: string): TimelineSyncResult { } } + // Nested Timelines// documents (issue #50), behind the --nested opt-in. + // Same direction (canonical -> mirror) and same newer-mirror hazard gate as + // 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 nestedDriftedCandidates: TimelineSyncResult["nestedDriftedCandidates"] = []; + if (opts.nested === true) { + const canonicalContentHash = timelineHashWithoutId(canonicalDraft); + for (const rel of nestedDocs) { + const parsed = parseCandidate(join(store.projectDir, rel)); + if (!parsed.exists) continue; + // Display and write under the project-relative name so nested rows never + // collide with the root files they mirror. + const candidate = { ...parsed, name: rel } as DraftCandidate; + if (!parsed.parseable || !parsed.draft) { + unreconcilable.push({ + file: rel, + reason: parsed.error ?? "no readable timeline", + workaround: + "The CLI cannot reconcile this nested document. Build a redacted bundle with `capcut fixture --out ` " + + "and attach it to issue #50 so support for this storage layout can be added.", + }); + continue; + } + const inSync = timelineHashWithoutId(parsed.draft) === canonicalContentHash; + targets.push({ + ...syncTarget(candidate, inSync ? "in_sync" : "drifted", parsed.draft.id !== canonicalDraft.id), + nested: true, + }); + if (!inSync) { + drifted.push(rel); + nestedDriftedCandidates.push({ candidate, keepGuid: parsed.draft.id ?? null }); + const mirrorMtime = parsed.mtime ? Date.parse(parsed.mtime) : Number.NaN; + if (Number.isFinite(canonicalMtime) && Number.isFinite(mirrorMtime) && mirrorMtime > canonicalMtime) { + newerMirrors.push(rel); + } + } + } + } + return { plan: { project_dir: store.projectDir, @@ -761,10 +869,13 @@ export function planTimelineSync(input: string): TimelineSyncResult { targets, drifted, unreconcilable, + nested_available: nestedDocs.length, + nested_included: opts.nested === true, }, canonicalDraft, canonicalCandidate: canonical, driftedCandidates, + nestedDriftedCandidates, }; } diff --git a/test/sync-timelines-nested.test.mjs b/test/sync-timelines-nested.test.mjs new file mode 100644 index 0000000..6689b1f --- /dev/null +++ b/test/sync-timelines-nested.test.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +// The canonical timeline: what the CLI wrote to the root files. +function canonicalDraft() { + return { + id: "guid-canonical", + name: "edited-by-cli", + 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" }, + tracks: [{ id: "T1", type: "text", name: "text", attribute: 0, segments: [] }], + materials: { + videos: [], + audios: [], + texts: [], + speeds: [], + material_animations: [], + audio_fades: [], + transitions: [], + }, + }; +} + +// The stale nested timeline: the pre-edit state still sitting under +// Timelines// after a root-file write the app then ignored. +function staleNested() { + return { ...canonicalDraft(), id: "tl-1", name: "before-cli-edit", tracks: [] }; +} + +// Nested Timelines// fixture (issue #50, CapCut Mac 9.2.8 report): the +// root files agree with each other, but the nested documents still hold the +// pre-edit timeline under the timeline id ("tl-1") — exactly the state after +// a CLI write that the app then discarded. project.json is the pointer. +function nestedFixture({ newerNested = false } = {}) { + const dir = mkdtempSync(join(tmpdir(), "capcut-sync-nested-")); + writeFileSync(join(dir, "draft_content.json"), JSON.stringify(canonicalDraft(), null, 2)); + writeFileSync(join(dir, "draft_info.json"), JSON.stringify(canonicalDraft(), null, 2)); + mkdirSync(join(dir, "Timelines", "tl-1"), { recursive: true }); + writeFileSync(join(dir, "Timelines", "project.json"), JSON.stringify({ main_timeline_id: "tl-1" }, null, 2)); + writeFileSync(join(dir, "Timelines", "tl-1", "draft_info.json"), JSON.stringify(staleNested(), null, 2)); + writeFileSync( + join(dir, "Timelines", "tl-1", "template-2.tmp"), + JSON.stringify({ draft_content: JSON.stringify(staleNested()) }, null, 2), + ); + const past = new Date(Date.now() - 3_600_000); + if (newerNested) { + utimesSync(join(dir, "draft_content.json"), past, past); + utimesSync(join(dir, "draft_info.json"), past, past); + } else { + utimesSync(join(dir, "Timelines", "tl-1", "draft_info.json"), past, past); + utimesSync(join(dir, "Timelines", "tl-1", "template-2.tmp"), past, past); + } + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +describe("sync-timelines --nested (issue #50)", () => { + it("default run reports nested documents as available but never touches or counts them", () => { + const f = nestedFixture(); + after(f.cleanup); + const nestedBefore = readFileSync(join(f.dir, "Timelines", "tl-1", "draft_info.json"), "utf-8"); + + const r = spawnCli(["sync-timelines", f.dir]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.in_sync, true, "root targets agree; nested must not flip the verdict without --nested"); + assert.equal(r.json.nested_available, 2); + assert.equal(r.json.nested_included, false); + assert.ok( + !r.json.targets.some((t) => t.file.startsWith("Timelines/")), + "nested rows must not appear without --nested", + ); + assert.match(r.stderr, /--nested/, "the run must point at the opt-in instead of staying silent"); + assert.equal( + readFileSync(join(f.dir, "Timelines", "tl-1", "draft_info.json"), "utf-8"), + nestedBefore, + "default run must not write nested documents", + ); + }); + + it("--nested plan lists the drifted nested documents, keeping their own GUID", () => { + const f = nestedFixture(); + after(f.cleanup); + + const r = spawnCli(["sync-timelines", f.dir, "--nested"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.applied, false); + assert.equal(r.json.nested_included, true); + assert.deepEqual(r.json.drifted.sort(), ["Timelines/tl-1/draft_info.json", "Timelines/tl-1/template-2.tmp"]); + const row = r.json.targets.find((t) => t.file === "Timelines/tl-1/draft_info.json"); + assert.equal(row.state, "drifted"); + assert.equal(row.nested, true); + assert.match(r.stderr, /keeps its own GUID tl-1/); + assert.equal( + JSON.parse(readFileSync(join(f.dir, "Timelines", "tl-1", "draft_info.json"), "utf-8")).name, + "before-cli-edit", + "plan must not write", + ); + }); + + it("--nested --apply copies the root timeline into the nested documents, preserving each GUID and the pointer", () => { + const f = nestedFixture(); + after(f.cleanup); + const pointerBefore = readFileSync(join(f.dir, "Timelines", "project.json"), "utf-8"); + const rootBefore = readFileSync(join(f.dir, "draft_content.json"), "utf-8"); + + const r = spawnCli(["sync-timelines", f.dir, "--nested", "--apply"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.applied, true); + assert.equal(r.json.in_sync, true, "the post-write verify must see the nested documents as reconciled"); + assert.deepEqual(r.json.reconciled.sort(), ["Timelines/tl-1/draft_info.json", "Timelines/tl-1/template-2.tmp"]); + + // Nested draft_info.json: canonical timeline, but the document keeps the + // timeline id: the verified 9.2.8 workaround writes tl-1, not the root id. + const nestedInfo = JSON.parse(readFileSync(join(f.dir, "Timelines", "tl-1", "draft_info.json"), "utf-8")); + assert.equal(nestedInfo.id, "tl-1", "nested document must keep its own GUID"); + assert.equal(nestedInfo.name, "edited-by-cli"); + assert.equal(nestedInfo.tracks.length, 1); + + // Nested template-2.tmp keeps its string-JSON envelope and its GUID. + const envelope = JSON.parse(readFileSync(join(f.dir, "Timelines", "tl-1", "template-2.tmp"), "utf-8")); + const mirrored = JSON.parse(envelope.draft_content); + assert.equal(mirrored.id, "tl-1"); + assert.equal(mirrored.name, "edited-by-cli"); + + // The pointer and the root files are untouched. + assert.equal(readFileSync(join(f.dir, "Timelines", "project.json"), "utf-8"), pointerBefore); + assert.equal(readFileSync(join(f.dir, "draft_content.json"), "utf-8"), rootBefore); + + // Backups of the pre-repair nested documents exist next to them. + assert.ok( + readFileSync(join(f.dir, "Timelines", "tl-1", "draft_info.json.bak"), "utf-8").includes("before-cli-edit"), + ); + }); + + it("--nested --apply refuses to roll back a nested document newer than the canonical unless --force-write", () => { + const f = nestedFixture({ newerNested: true }); + after(f.cleanup); + + const refused = spawnCli(["sync-timelines", f.dir, "--nested", "--apply"]); + assert.notEqual(refused.status, 0, "a newer nested document may hold app edits and must refuse"); + assert.match(refused.stderr, /OLDER/); + assert.equal( + JSON.parse(readFileSync(join(f.dir, "Timelines", "tl-1", "draft_info.json"), "utf-8")).name, + "before-cli-edit", + "refusal must not write", + ); + + const forced = spawnCli(["sync-timelines", f.dir, "--nested", "--apply", "--force-write"]); + assert.equal(forced.status, 0, `stderr: ${forced.stderr}`); + assert.equal(JSON.parse(readFileSync(join(f.dir, "Timelines", "tl-1", "draft_info.json"), "utf-8")).id, "tl-1"); + }); + + it("--nested reports an unreadable nested template-2.tmp as unreconcilable and still repairs the readable one", () => { + const f = nestedFixture(); + after(f.cleanup); + writeFileSync(join(f.dir, "Timelines", "tl-1", "template-2.tmp"), " not json"); + + const r = spawnCli(["sync-timelines", f.dir, "--nested", "--apply"]); + assert.equal(r.status, 2, `an unreconcilable target must exit 2, stderr: ${r.stderr}`); + assert.equal(r.json.applied, true); + assert.ok(r.json.unreconcilable.some((u) => u.file === "Timelines/tl-1/template-2.tmp")); + assert.match(r.json.unreconcilable.find((u) => u.file.startsWith("Timelines/")).workaround, /issue #50/); + const nestedInfo = JSON.parse(readFileSync(join(f.dir, "Timelines", "tl-1", "draft_info.json"), "utf-8")); + assert.equal(nestedInfo.name, "edited-by-cli", "the readable nested document must still be repaired"); + assert.equal(nestedInfo.id, "tl-1"); + }); +}); From 5a39f7e776725d6b79b2cd5e51dac4a156793f03 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 10:52:43 +0000 Subject: [PATCH 02/11] feat(lint): --pip validation report for the PIP + local-mask workflow (#78) --- src/command-specs.ts | 13 ++++ src/index.ts | 39 +++++++++++- src/lint.ts | 86 +++++++++++++++++++++++++++ test/lint-pip.test.mjs | 131 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 test/lint-pip.test.mjs diff --git a/src/command-specs.ts b/src/command-specs.ts index 9ea52b7..a7444c4 100644 --- a/src/command-specs.ts +++ b/src/command-specs.ts @@ -255,6 +255,13 @@ const optionsByCommand: Record = { option("no_check_paths", ["--no-check-paths"], "boolean", "Skip local media path checks."), option("fix", ["--fix"], "boolean", "Mechanically repair fixable issues and write the draft."), option("no_probe", ["--no-probe"], "boolean", "Skip ffprobe media checks (VFR / unreadable media)."), + option( + "pip", + ["--pip"], + "boolean", + "Validate the PIP + local-mask workflow (issue #78): report overlay / overlay-keyframe / mask-attachment " + + "counts and missing media by path, and fail the exit code on an orphaned (never-attached) mask.", + ), FFPROBE, ], segments: [TRACK], @@ -682,6 +689,9 @@ optionsByCommand["image-anim"] = optionsByCommand["text-anim"]; // --encoder -> render (v0.20 proxy video encoder) // --threshold-db, --min-silence, --pad -> detect-silence (v0.20 silence spans) // --text, --text-file, --tts-cmd -> tts (v0.20 voiceover synthesis) +// --nested -> sync-timelines (v0.21 nested Timelines/ repair) +// --pip -> lint (v0.21 PIP + mask validation report) +// --stage -> add-video, add-audio, replace-media, quickstart, relink (v0.21 media staging) // Everywhere else they fall through to the positional stream verbatim, matching // pre-release behaviour where these tokens were unknown and preserved. export const RELEASE_SCOPED_FLAGS: ReadonlySet = new Set([ @@ -707,8 +717,11 @@ export const RELEASE_SCOPED_FLAGS: ReadonlySet = new Set([ "--mask-field", "--min-gap", "--min-silence", + "--nested", "--new-track", "--pad", + "--pip", + "--stage", "--preset", "--ratio", "--rect", diff --git a/src/index.ts b/src/index.ts index 61415ba..b0301cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -807,6 +807,8 @@ interface Flags { drafts?: string; // sync-timelines nested?: boolean; + // lint + pip?: boolean; // keyframe easing?: string; // Phase 1 decorators @@ -1355,6 +1357,8 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } { flags.apply = true; } else if (a === "--nested") { flags.nested = true; + } else if (a === "--pip") { + flags.pip = true; } else if (a === "--sync") { flags.sync = true; } else if (a === "--add") { @@ -3424,7 +3428,8 @@ function cmdVersion(draft: Draft, filePath: string, flags: Flags): void { } async function cmdLint(draft: Draft, filePath: string, flags: Flags): Promise<{ exitCode: number }> { - const { DEFAULT_LINT_OPTIONS, fixDraft, lintDraft, lintExitCode, summarize } = await import("./lint.js"); + const { DEFAULT_LINT_OPTIONS, buildPipReport, fixDraft, lintDraft, lintExitCode, pipLintIssues, summarize } = + await import("./lint.js"); const opts: LintOptions = { maxCharsPerLine: flags.maxChars ?? DEFAULT_LINT_OPTIONS.maxCharsPerLine, maxCueDurationUs: @@ -3440,11 +3445,26 @@ async function cmdLint(draft: Draft, filePath: string, flags: Flags): Promise<{ dryRun: isDryRun(), }; + // The --pip report (issue #78): counts for the PIP + local-mask workflow's + // silent failure modes, printed alongside the ordinary issues in both output + // modes. The loud side (mask-orphaned warnings) joins the issue list so the + // exit code fails CI when the mask never got attached. + const printPipHuman = (report: ReturnType | null): void => { + if (!report) return; + console.log( + `pip: ${report.overlays} overlay(s) · ${report.overlay_keyframes} overlay keyframe(s) · ` + + `${report.masks_attached} mask(s) attached · ${report.masks_orphaned} orphaned`, + ); + for (const missing of report.missing_media) console.log(`pip: missing media ${missing}`); + }; + if (flags.fix) { const { fixed, remaining } = fixDraft(draft, opts); // Only write if we actually repaired something. --dry-run (global) is // honored by saveDraft, which leaves the file and its .bak untouched. if (fixed.length > 0) saveDraft(filePath, draft); + const pipReport = flags.pip ? buildPipReport(draft, remaining) : null; + if (flags.pip) remaining.push(...pipLintIssues(draft)); const summary = summarize(remaining); const exitCode = lintExitCode(summary); if (flags.human) { @@ -3465,13 +3485,25 @@ async function cmdLint(draft: Draft, filePath: string, flags: Flags): Promise<{ `${fixed.length} fixed · ${summary.errors} errors · ${summary.warnings} warnings · ${summary.info} info`, ); } + printPipHuman(pipReport); } else { - out({ ok: summary.errors === 0, fixed, summary, issues: remaining }, flags); + out( + { + ok: summary.errors === 0, + fixed, + summary, + issues: remaining, + ...(pipReport ? { pip_report: pipReport } : {}), + }, + flags, + ); } return { exitCode }; } const issues = lintDraft(draft, opts); + const pipReport = flags.pip ? buildPipReport(draft, issues) : null; + if (flags.pip) issues.push(...pipLintIssues(draft)); const summary = summarize(issues); const exitCode = lintExitCode(summary); if (flags.human) { @@ -3486,8 +3518,9 @@ async function cmdLint(draft: Draft, filePath: string, flags: Flags): Promise<{ console.log(""); console.log(`${summary.errors} errors · ${summary.warnings} warnings · ${summary.info} info`); } + printPipHuman(pipReport); } else { - out({ ok: summary.errors === 0, summary, issues }, flags); + out({ ok: summary.errors === 0, summary, issues, ...(pipReport ? { pip_report: pipReport } : {}) }, flags); } return { exitCode }; } diff --git a/src/lint.ts b/src/lint.ts index e77f491..fd7bd78 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -618,6 +618,92 @@ export function lintDraft(draft: Draft, opts: LintOptions = DEFAULT_LINT_OPTIONS return issues; } +export interface PipReport { + overlays: number; + overlay_keyframes: number; + masks_attached: number; + masks_orphaned: number; + missing_media: string[]; +} + +/** PIP + local-mask validation (issue #78, split from #44): the four ways the + * discussion-#43 workflow can be silently wrong, as countable facts — the + * overlay never landed (overlays), the mask never got attached + * (masks_orphaned), the keyframes did not write (overlay_keyframes), the + * copied clip points at missing media (missing_media, by path — sourced from + * the missing-file issues the ordinary lint walk already found, so the two + * never disagree). Overlays are segments on every video track above the + * first: the layer order sortTracks maintains and `duplicate` builds. */ +export function buildPipReport(draft: Draft, issues: LintIssue[]): PipReport { + const videoTracks = (draft.tracks ?? []).filter((track) => track.type === "video"); + let overlays = 0; + let overlayKeyframes = 0; + for (const track of videoTracks.slice(1)) { + for (const seg of track.segments ?? []) { + overlays++; + const lists = (seg as Segment & { common_keyframes?: Array<{ keyframe_list?: unknown[] }> }).common_keyframes; + for (const list of lists ?? []) { + if (Array.isArray(list?.keyframe_list)) overlayKeyframes += list.keyframe_list.length; + } + } + } + const masks = maskAttachment(draft); + const missing = new Set(); + for (const issue of issues) { + if (issue.code === "missing-file" && issue.location?.path) missing.add(issue.location.path); + } + return { + overlays, + overlay_keyframes: overlayKeyframes, + masks_attached: masks.attached.length, + masks_orphaned: masks.orphaned.length, + missing_media: [...missing], + }; +} + +/** Mask materials by attachment: a mask is attached when some segment's + * extra_material_refs carries its id (how `mask` and the app wire them), and + * orphaned otherwise. All three variant arrays are read — attachment is a + * different question from which single array the installed build reads + * (mask-field-mismatch covers that one). */ +function maskAttachment(draft: Draft): { attached: string[]; orphaned: string[] } { + const refs = new Set(); + for (const { segment } of allSegments(draft)) { + for (const ref of segment.extra_material_refs ?? []) refs.add(ref); + } + const attached: string[] = []; + const orphaned: string[] = []; + for (const key of ["masks", "common_mask", "common_masks"]) { + const arr = (draft.materials as Record | undefined)?.[key]; + if (!Array.isArray(arr)) continue; + for (const mat of arr) { + const id = (mat as { id?: string } | null)?.id; + if (!id) continue; + (refs.has(id) ? attached : orphaned).push(id); + } + } + return { attached, orphaned }; +} + +/** The loud side of the --pip report: one warning per orphaned mask, so the + * exit code fails CI exactly when the workflow's mask never got attached. + * Emitted only under --pip: the check encodes the PIP workflow's expectation, + * and an ordinary draft carrying an unreferenced mask is not necessarily + * damaged (the #88 lesson) — but in a pipeline that just tried to attach one, + * it is precisely the failure being looked for. */ +export function pipLintIssues(draft: Draft): LintIssue[] { + const { orphaned } = maskAttachment(draft); + return orphaned.map((id) => ({ + severity: "warning" as const, + code: "mask-orphaned", + message: + `Mask material ${id} is not referenced by any segment's extra_material_refs, so it will not appear in the app. ` + + "Attach it with `capcut mask ` or drop it with `capcut prune `.", + fixable: false, + location: { material_id: id }, + })); +} + // Every effect_id/resource_id the CLI could have written into a draft: the // bundled enums.json (both namespaces, all categories) plus the inline // knossos-verified starter catalogues that enums.json doesn't carry. diff --git a/test/lint-pip.test.mjs b/test/lint-pip.test.mjs new file mode 100644 index 0000000..937cf43 --- /dev/null +++ b/test/lint-pip.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +// A draft built the way the PIP + local-mask workflow (discussion #43, +// issues #44/#77/#78) builds one: a main video track, a duplicate on an upper +// video track carrying the mask and the transform keyframes. +function pipDraft(dir, { orphanMask = false, missingMedia = false } = {}) { + const mediaPath = join(dir, missingMedia ? "gone.mp4" : "clip.mp4"); + if (!missingMedia) writeFileSync(join(dir, "clip.mp4"), "stub"); + const segBase = { + target_timerange: { start: 0, duration: 1_000_000 }, + source_timerange: { start: 0, duration: 1_000_000 }, + }; + return { + id: "guid-pip", + name: "pip-draft", + 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" }, + tracks: [ + { + id: "T-main", + type: "video", + name: "video", + attribute: 0, + segments: [{ ...segBase, id: "SEG-MAIN", material_id: "V1" }], + }, + { + id: "T-pip", + type: "video", + name: "video-pip", + attribute: 0, + segments: [ + { + ...segBase, + id: "SEG-PIP", + material_id: "V2", + extra_material_refs: orphanMask ? [] : ["MASK-1"], + common_keyframes: [ + { + id: "KFL-1", + property_type: "KFTypePositionX", + keyframe_list: [ + { id: "KF-1", time_offset: 0, values: [0] }, + { id: "KF-2", time_offset: 500_000, values: [0.4] }, + ], + }, + ], + }, + ], + }, + ], + materials: { + videos: [ + { id: "V1", type: "video", path: mediaPath, duration: 1_000_000 }, + { id: "V2", type: "video", path: mediaPath, duration: 1_000_000 }, + ], + texts: [], + speeds: [], + common_mask: [{ id: "MASK-1", type: "mask", resource_type: "circle", name: "circle" }], + }, + }; +} + +function fixture(opts) { + const dir = mkdtempSync(join(tmpdir(), "capcut-lint-pip-")); + writeFileSync(join(dir, "draft_content.json"), JSON.stringify(pipDraft(dir, opts), null, 2)); + return { dir, path: join(dir, "draft_content.json"), cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +describe("lint --pip (issue #78)", () => { + it("counts overlays, overlay keyframes and attached masks on a known layout", () => { + const f = fixture(); + after(f.cleanup); + const r = spawnCli(["lint", f.path, "--pip", "--no-probe"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.deepEqual(r.json.pip_report, { + overlays: 1, + overlay_keyframes: 2, + masks_attached: 1, + masks_orphaned: 0, + missing_media: [], + }); + assert.ok(!r.json.issues.some((i) => i.code === "mask-orphaned")); + }); + + it("fails the exit code and reports the mask when it never got attached", () => { + const f = fixture({ orphanMask: true }); + after(f.cleanup); + const r = spawnCli(["lint", f.path, "--pip", "--no-probe"]); + assert.equal(r.status, 1, `an orphaned mask is a warning and must fail CI — stderr: ${r.stderr}`); + assert.equal(r.json.pip_report.masks_orphaned, 1); + assert.equal(r.json.pip_report.masks_attached, 0); + const issue = r.json.issues.find((i) => i.code === "mask-orphaned"); + assert.ok(issue, "the orphaned mask must be a loud issue, not only a count"); + assert.equal(issue.severity, "warning"); + assert.equal(issue.location.material_id, "MASK-1"); + assert.match(issue.message, /capcut mask /); + }); + + it("reports missing media by path, not just as a count", () => { + const f = fixture({ missingMedia: true }); + after(f.cleanup); + const r = spawnCli(["lint", f.path, "--pip", "--no-probe"]); + assert.equal(r.status, 2, "missing media is an error"); + assert.equal(r.json.pip_report.missing_media.length, 1); + assert.match(r.json.pip_report.missing_media[0], /gone\.mp4$/); + }); + + it("without --pip the report is absent and no mask-orphaned issue fires", () => { + const f = fixture({ orphanMask: true }); + after(f.cleanup); + const r = spawnCli(["lint", f.path, "--no-probe"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.pip_report, undefined); + assert.ok(!r.json.issues.some((i) => i.code === "mask-orphaned")); + }); + + it("-H prints the report line for humans", () => { + const f = fixture(); + after(f.cleanup); + const r = spawnCli(["lint", f.path, "--pip", "--no-probe", "-H"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.match(r.stdout, /pip: 1 overlay\(s\) · 2 overlay keyframe\(s\) · 1 mask\(s\) attached · 0 orphaned/); + }); +}); From 1fbe7f322ab05835e86f7283b0cd374e77d71213 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 10:58:21 +0000 Subject: [PATCH 03/11] feat(catalogue): cross-category name/id lookup over bundled + harvested resources --- src/catalogue.ts | 128 ++++++++++++++++++++++++++++++++++++++++ src/command-specs.ts | 32 +++++++++- src/index.ts | 40 +++++++++++++ test/catalogue.test.mjs | 74 +++++++++++++++++++++++ 4 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 src/catalogue.ts create mode 100644 test/catalogue.test.mjs diff --git a/src/catalogue.ts b/src/catalogue.ts new file mode 100644 index 0000000..3684ee0 --- /dev/null +++ b/src/catalogue.ts @@ -0,0 +1,128 @@ +import { bubbleCatalogue } from "./decorators.js"; +import { type Category, type EnumEntry, listEnum, type Namespace } from "./enums.js"; +import { filterCatalogue } from "./factory.js"; +import { userEntriesForCategory } from "./user-enums.js"; + +/** + * Cross-category catalogue lookup (`capcut catalogue `): name/slug -> + * resource_id in one call, without knowing which category flag to pass to + * `enums`. The pain it removes is well documented across the ecosystem — + * users hand-extract resource ids from the app when a name is all they have + * (pyCapCut#12: newer effect ids missing from static tables; + * pyJianYingDraft#174: no extraction path at all for encrypted-era resources) + * — and `harvest-enums` already collects ids into the user catalogue, but + * nothing let you FIND one again by name. + */ + +/** Every category `catalogue` searches: the enums.json categories plus the + * two starter catalogues that live in code (filters, bubbles). Mirrors the + * category list lint's unknown-slug check covers. */ +export const SEARCHABLE_CATEGORIES: readonly string[] = [ + "transitions", + "masks", + "image_intros", + "image_outros", + "image_combos", + "text_intros", + "text_outros", + "text_loop_anims", + "scene_effects", + "character_effects", + "audio_effects", + "fonts", + "filters", + "bubbles", +]; + +export interface CatalogueMatch { + category: string; + slug: string; + member: string; + name: string | null; + effect_id: string | null; + resource_id: string | null; + resource_type: string | null; + /** bundled = shipped table or starter catalogue; user = harvest-enums + * catalogue (~/.config/capcut-cli/user-enums.json). */ + source: "bundled" | "user"; +} + +interface Ranked { + match: CatalogueMatch; + score: number; +} + +// Lower is better: 0 exact, 1 prefix, 2 substring. Ids only match exactly — +// pasting a resource_id/effect_id answers "what is this id?" without letting +// every query fuzzy-hit long numeric strings. +function scoreEntry(entry: EnumEntry, q: string): number | null { + const texts = [entry.slug, entry.member, entry.name ?? "", entry.title ?? ""] + .filter((t) => t.length > 0) + .map((t) => t.toLowerCase()); + if (texts.some((t) => t === q)) return 0; + if (entry.resource_id === q || entry.effect_id === q) return 0; + if (texts.some((t) => t.startsWith(q))) return 1; + if (texts.some((t) => t.includes(q))) return 2; + return null; +} + +function toMatch(entry: EnumEntry, category: string, source: CatalogueMatch["source"]): CatalogueMatch { + return { + category, + slug: entry.slug, + member: entry.member, + name: entry.name ?? entry.title ?? null, + effect_id: entry.effect_id ?? null, + resource_id: entry.resource_id ?? null, + resource_type: entry.resource_type ?? null, + source, + }; +} + +/** Entries of one category with their provenance. listEnum appends user + * entries after the bundled table, so everything past the bundled count came + * from the user catalogue. */ +function categoryEntries( + category: string, + namespace: Namespace, +): Array<{ entry: EnumEntry; source: CatalogueMatch["source"] }> { + if (category === "bubbles") { + return bubbleCatalogue().map((entry) => ({ entry, source: "bundled" as const })); + } + const combined = listEnum(category as Category, namespace); + const userCount = userEntriesForCategory(category as Category).length; + const bundledCount = combined.length - userCount; + const rows = combined.map((entry, index) => ({ + entry, + source: (index < bundledCount ? "bundled" : "user") as CatalogueMatch["source"], + })); + // The capcut namespace ships filters as a starter catalogue in code, not in + // enums.json — same merge `enums --filters` does. + if (category === "filters" && namespace === "capcut") { + return [...filterCatalogue().map((entry) => ({ entry, source: "bundled" as const })), ...rows]; + } + return rows; +} + +export function searchCatalogue( + query: string, + opts: { namespace?: Namespace; kind?: string; limit?: number } = {}, +): CatalogueMatch[] { + const namespace = opts.namespace ?? "capcut"; + const limit = opts.limit ?? 20; + const q = query.trim().toLowerCase(); + const categories = opts.kind ? [opts.kind] : SEARCHABLE_CATEGORIES; + const ranked: Ranked[] = []; + for (const category of categories) { + for (const { entry, source } of categoryEntries(category, namespace)) { + const score = scoreEntry(entry, q); + if (score === null) continue; + ranked.push({ match: toMatch(entry, category, source), score }); + } + } + ranked.sort( + (a, b) => + a.score - b.score || a.match.category.localeCompare(b.match.category) || a.match.slug.localeCompare(b.match.slug), + ); + return ranked.slice(0, limit).map((r) => r.match); +} diff --git a/src/command-specs.ts b/src/command-specs.ts index a7444c4..ec5d61b 100644 --- a/src/command-specs.ts +++ b/src/command-specs.ts @@ -216,6 +216,7 @@ const usages = { describe: "capcut describe", completions: "capcut completions ", enums: "capcut enums [--jianying]", + catalogue: "capcut catalogue [--kind ] [--limit ] [--jianying]", "harvest-enums": "capcut harvest-enums [ | --sync | --add ] [--apply] [--catalogue ]", doctor: "capcut doctor", @@ -512,6 +513,33 @@ const optionsByCommand: Record = { option("drafts", ["--drafts"], "path", "Draft store root when the draft does not live inside a known one."), ], rename: [option("drafts", ["--drafts"], "path", "Draft store root when the draft does not live inside a known one.")], + catalogue: [ + option( + "kind", + ["--kind"], + "enum", + "Search only this category (default: all categories, filters and bubbles included).", + { + values: [ + "transitions", + "masks", + "image_intros", + "image_outros", + "image_combos", + "text_intros", + "text_outros", + "text_loop_anims", + "scene_effects", + "character_effects", + "audio_effects", + "fonts", + "filters", + "bubbles", + ], + }, + ), + option("limit", ["--limit"], "number", "Keep only the N best matches.", { default: 20 }), + ], "harvest-enums": [ option("apply", ["--apply"], "boolean", "Write the new entries into the user catalogue (default: plan only)."), option( @@ -691,6 +719,7 @@ optionsByCommand["image-anim"] = optionsByCommand["text-anim"]; // --text, --text-file, --tts-cmd -> tts (v0.20 voiceover synthesis) // --nested -> sync-timelines (v0.21 nested Timelines/ repair) // --pip -> lint (v0.21 PIP + mask validation report) +// --kind -> catalogue (v0.21 cross-category lookup); --limit also scopes there // --stage -> add-video, add-audio, replace-media, quickstart, relink (v0.21 media staging) // Everywhere else they fall through to the positional stream verbatim, matching // pre-release behaviour where these tokens were unknown and preserved. @@ -713,6 +742,7 @@ export const RELEASE_SCOPED_FLAGS: ReadonlySet = new Set([ "--keep-track", "--keyword-color", "--keyword-size", + "--kind", "--limit", "--mask-field", "--min-gap", @@ -796,7 +826,7 @@ const mutating = new Set([ "compile", ]); -const arrayOutputs = new Set(["tracks", "segments", "texts", "materials", "enums", "templates"]); +const arrayOutputs = new Set(["tracks", "segments", "texts", "materials", "enums", "catalogue", "templates"]); const textOutputs = new Set(["export-srt", "export-ass", "export-timeline", "completions"]); const fileOutputs = new Set([ "render", diff --git a/src/index.ts b/src/index.ts index b0301cc..8da54e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -809,6 +809,8 @@ interface Flags { nested?: boolean; // lint pip?: boolean; + // catalogue + kind?: string; // keyframe easing?: string; // Phase 1 decorators @@ -1359,6 +1361,8 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } { flags.nested = true; } else if (a === "--pip") { flags.pip = true; + } else if (a === "--kind" && i + 1 < args.length) { + flags.kind = args[++i]; } else if (a === "--sync") { flags.sync = true; } else if (a === "--add") { @@ -3195,6 +3199,36 @@ async function cmdEnums(flags: Flags): Promise { } } +async function cmdCatalogue(query: string | undefined, flags: Flags): Promise { + const { SEARCHABLE_CATEGORIES, searchCatalogue } = await import("./catalogue.js"); + if (!query) die("Usage: capcut catalogue [--kind ] [--limit ] [--jianying]"); + if (flags.kind && !SEARCHABLE_CATEGORIES.includes(flags.kind)) { + die(`Unknown --kind "${flags.kind}". Categories: ${SEARCHABLE_CATEGORIES.join(", ")}`); + } + const matches = searchCatalogue(query, { + namespace: flags.jianying ? "jianying" : "capcut", + kind: flags.kind, + limit: flags.limit ?? 20, + }); + if (flags.human) { + if (matches.length === 0) { + console.log(`No catalogue entries match "${query}".`); + return; + } + console.log( + "Category Slug Name Resource ID Source", + ); + for (const m of matches) { + console.log( + `${m.category.padEnd(19)} ${(m.slug || "(non-ascii)").padEnd(33)} ${(m.name ?? m.member).slice(0, 22).padEnd(23)} ${(m.resource_id ?? "").padEnd(21)} ${m.source}`, + ); + } + process.stderr.write(`\n${matches.length} match(es)\n`); + } else { + out(matches, flags); + } +} + // --- v0.14: keyword emphasis + colour cycling (caption, import-srt) --- const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/; @@ -5139,6 +5173,12 @@ async function main(): Promise { process.exit(0); } + // `catalogue` is the cross-category search over the same tables — no project needed. + if (cmd === "catalogue") { + await cmdCatalogue(positional[1], flags); + process.exit(0); + } + // `doctor` inspects the environment, not a draft — no project needed. if (cmd === "doctor") { process.exit((await cmdDoctor(flags)) ? 0 : 1); diff --git a/test/catalogue.test.mjs b/test/catalogue.test.mjs new file mode 100644 index 0000000..85d2355 --- /dev/null +++ b/test/catalogue.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +describe("catalogue (cross-category resource lookup)", () => { + it("finds a bundled entry by exact slug and returns its resource id", () => { + const r = spawnCli(["catalogue", "circle", "--kind", "masks"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(Array.isArray(r.json)); + const hit = r.json.find((m) => m.slug === "circle" && m.category === "masks"); + assert.ok(hit, "the circle mask must be found"); + assert.equal(hit.source, "bundled"); + assert.ok(hit.resource_id, "the whole point is the resource id"); + }); + + it("searches every category by substring, exact matches first", () => { + const r = spawnCli(["catalogue", "circle"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(r.json.length >= 1); + assert.equal(r.json[0].slug, "circle", "exact slug match must rank before substring matches"); + }); + + it("reverse lookup: pasting a resource id answers what it is", () => { + const bySlug = spawnCli(["catalogue", "circle", "--kind", "masks"]); + const id = bySlug.json.find((m) => m.slug === "circle").resource_id; + const r = spawnCli(["catalogue", id]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json[0].slug, "circle"); + assert.equal(r.json[0].resource_id, id); + }); + + it("--limit caps the result list", () => { + const r = spawnCli(["catalogue", "i", "--limit", "3"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(r.json.length <= 3); + }); + + it("rejects an unknown --kind with the category list", () => { + const r = spawnCli(["catalogue", "circle", "--kind", "nonsense"]); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /masks/); + }); + + it("finds harvest-enums user-catalogue entries and labels their source", () => { + const dir = mkdtempSync(join(tmpdir(), "capcut-catalogue-")); + after(() => rmSync(dir, { recursive: true, force: true })); + const cataloguePath = join(dir, "user-enums.json"); + writeFileSync( + cataloguePath, + JSON.stringify({ + version: 1, + entries: [{ kind: "masks", slug: "snowfly-window", name: "Snowfly Window", resource_id: "9999999001" }], + }), + ); + + const r = spawnCli(["catalogue", "snowfly"], { env: { CAPCUT_CLI_USER_ENUMS: cataloguePath } }); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + const hit = r.json.find((m) => m.slug === "snowfly-window"); + assert.ok(hit, "harvested entries must be searchable by name"); + assert.equal(hit.source, "user"); + assert.equal(hit.resource_id, "9999999001"); + assert.equal(hit.category, "masks"); + }); + + it("-H prints a table with the resource id column", () => { + const r = spawnCli(["catalogue", "circle", "--kind", "masks", "-H"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.match(r.stdout, /Resource ID/); + assert.match(r.stdout, /circle/); + }); +}); From 1c13f74fb07d26243cb378ffe911a715f636df25 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 11:14:32 +0000 Subject: [PATCH 04/11] fix(hooks): pre-commit test gate reported tail's exit status, not the suite's --- .husky/pre-commit | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 831d2bd..ea9794d 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -14,4 +14,16 @@ npx --no-install lint-staged # 2. Unit tests. `npm test` rebuilds dist/ first so it matches src/ — without # the rebuild, test:fast runs against a stale build and can pass-or-fail on # code that isn't what's being committed. -npm test --silent 2>&1 | tail -20 +# Captured to a file, not piped into tail: a pipeline's exit status is the +# LAST command's, so `npm test | tail` reported tail's success and let a red +# suite commit silently (sh has no pipefail; set -e never saw the failure). +test_log="$(mktemp)" +if npm test --silent >"$test_log" 2>&1; then + tail -20 "$test_log" + rm -f "$test_log" +else + tail -40 "$test_log" + rm -f "$test_log" + echo "pre-commit: npm test failed — commit aborted" >&2 + exit 1 +fi From 7731480102b4df0809b611a94962376de5d10454 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 11:17:36 +0000 Subject: [PATCH 05/11] feat(lint): observe-only media-unregistered sidecar note (pyCapCut#13) --- src/lint.ts | 34 ++++++++++ src/store.ts | 32 ++++++++- test/lint-media-unregistered.test.mjs | 98 +++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 test/lint-media-unregistered.test.mjs diff --git a/src/lint.ts b/src/lint.ts index fd7bd78..cd785ff 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -6,6 +6,7 @@ import { extractStyleRanges, extractText, findMaterial, getTracksByType } from " import { type Category, listEnum, type Namespace } from "./enums.js"; import { copyAssetDeduped, effectCatalogue, filterCatalogue } from "./factory.js"; import { ffprobeAvailable, isVfr, probeMedia } from "./probe.js"; +import { assessMediaRegistrationAt } from "./store.js"; import { rangesLookDoubled, repairDoubledRanges } from "./text-offsets.js"; import { allUserEnumIds } from "./user-enums.js"; import { atLeast } from "./version.js"; @@ -615,6 +616,39 @@ export function lintDraft(draft: Draft, opts: LintOptions = DEFAULT_LINT_OPTIONS } } + // Unregistered-media sidecar note (pyCapCut#13), observe-only: newer builds + // (reported on CapCut International 9.1.0, macOS) show timeline media as + // "file inaccessible" and prompt per-clip relinking when + // draft_meta_info.json's draft_materials registers nothing. The registration + // WRITE stays deliberately out of scope until a real entry shape is captured + // (src/store.ts rationale) — so this is info-severity: it names the hazard + // where CI pipelines will actually see it and asks for the one artifact the + // write can be built from. It can never fail an exit code. + // Gated on media that actually exists on disk: absent media is + // missing-file's finding, and the 9.1.0 symptom is precisely media that IS + // there and still shows inaccessible in the app. + const presentLocalMedia = (["videos", "audios"] as const).some((kind) => + (draft.materials?.[kind] ?? []).some((mat) => { + const p = (mat as { path?: unknown }).path; + return typeof p === "string" && p.length > 0 && !/^https?:\/\//i.test(p) && fileExists(p); + }), + ); + if (opts.draftDir && presentLocalMedia) { + const registration = assessMediaRegistrationAt(draft, opts.draftDir); + // missing-file stays diagnose's finding: a folder with no sidecar at all + // is the ordinary tool-built shape (`register` exists for it), not the + // pyCapCut#13 shape where the sidecar is present and registers nothing. + if (registration && registration.draft_materials !== "missing-file") { + issues.push({ + severity: "info", + code: "media-unregistered", + message: registration.note, + fixable: false, + suggested_command: `capcut fixture ${opts.draftDir} --out `, + }); + } + } + return issues; } diff --git a/src/store.ts b/src/store.ts index 39bfe5e..fe8d4bb 100644 --- a/src/store.ts +++ b/src/store.ts @@ -924,11 +924,24 @@ function draftMaterialsAllEmpty(value: unknown): boolean { * is not provably empty. */ function assessMediaRegistration(store: DraftStore): MediaRegistrationNote | null { - const referenced = store.canonical.draft ? referencedLocalMedia(store.canonical.draft) : 0; - if (referenced === 0) return null; const meta = store.candidates.find((candidate) => candidate.name === "draft_meta_info.json"); + return assessMediaRegistrationRaw(store.canonical.draft, { + exists: meta?.exists ?? false, + raw: meta?.raw ?? null, + }); +} + +/** The sidecar-note core, decoupled from store discovery so `lint` can run the + * same observation from a draft + project dir (pyCapCut#13 reports the symptom + * landing in CI-shaped pipelines, where diagnose is never run). */ +export function assessMediaRegistrationRaw( + draft: Draft | null, + meta: { exists: boolean; raw: string | null }, +): MediaRegistrationNote | null { + const referenced = draft ? referencedLocalMedia(draft) : 0; + if (referenced === 0) return null; let state: MediaRegistrationNote["draft_materials"]; - if (!meta?.exists) { + if (!meta.exists) { state = "missing-file"; } else { if (meta.raw === null) return null; @@ -964,6 +977,19 @@ function assessMediaRegistration(store: DraftStore): MediaRegistrationNote | nul }; } +/** File-reading form of the sidecar note for callers that hold a draft and its + * project directory rather than a discovered store (lint's media-unregistered + * check). Unreadable sidecars return null, same as the store form. */ +export function assessMediaRegistrationAt(draft: Draft, projectDir: string): MediaRegistrationNote | null { + const metaPath = join(projectDir, "draft_meta_info.json"); + if (!existsSync(metaPath)) return assessMediaRegistrationRaw(draft, { exists: false, raw: null }); + try { + return assessMediaRegistrationRaw(draft, { exists: true, raw: stripBom(readFileSync(metaPath, "utf-8")) }); + } catch { + return null; + } +} + export function diagnoseDraftStore(input: string): DraftStoreReport { const store = discoverDraftStore(input); const running = editorProcesses(); diff --git a/test/lint-media-unregistered.test.mjs b/test/lint-media-unregistered.test.mjs new file mode 100644 index 0000000..5e7b2da --- /dev/null +++ b/test/lint-media-unregistered.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +// Minimal draft referencing one local media file that exists inside the draft +// dir — the pyCapCut#13 situation is about the SIDECAR, not the media itself. +function draft(dir, { withMedia = true } = {}) { + if (withMedia) writeFileSync(join(dir, "clip.mp4"), "stub"); + return { + id: "guid-meta", + name: "meta-draft", + 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" }, + tracks: [ + { + id: "T-main", + type: "video", + name: "video", + attribute: 0, + segments: withMedia + ? [ + { + id: "SEG-1", + material_id: "V1", + target_timerange: { start: 0, duration: 1_000_000 }, + source_timerange: { start: 0, duration: 1_000_000 }, + }, + ] + : [], + }, + ], + materials: { + videos: withMedia ? [{ id: "V1", type: "video", path: join(dir, "clip.mp4"), duration: 1_000_000 }] : [], + texts: [], + speeds: [], + }, + }; +} + +function fixture({ meta, withMedia = true } = {}) { + const dir = mkdtempSync(join(tmpdir(), "capcut-lint-meta-")); + writeFileSync(join(dir, "draft_content.json"), JSON.stringify(draft(dir, { withMedia }), null, 2)); + if (meta !== undefined) writeFileSync(join(dir, "draft_meta_info.json"), JSON.stringify(meta, null, 2)); + return { dir, path: join(dir, "draft_content.json"), cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +describe("lint media-unregistered (pyCapCut#13, observe-only)", () => { + it("fires as info when draft_materials registers nothing, without failing the exit code", () => { + const f = fixture({ meta: { draft_materials: [] } }); + after(f.cleanup); + const r = spawnCli(["lint", f.path, "--no-probe"]); + assert.equal(r.status, 0, `info severity must never fail CI — stderr: ${r.stderr}`); + const issue = r.json.issues.find((i) => i.code === "media-unregistered"); + assert.ok(issue, "the empty sidecar must be surfaced"); + assert.equal(issue.severity, "info"); + assert.equal(issue.fixable, false, "the registration write is deliberately out of scope"); + assert.match(issue.suggested_command, /capcut fixture/); + }); + + it("stays silent when draft_materials is not provably empty", () => { + const f = fixture({ meta: { draft_materials: [{ type: 0, value: [{ id: "m1" }] }] } }); + after(f.cleanup); + const r = spawnCli(["lint", f.path, "--no-probe"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(!r.json.issues.some((i) => i.code === "media-unregistered")); + }); + + it("stays silent when there is no draft_meta_info.json at all (diagnose's finding, not lint's)", () => { + const f = fixture({}); + after(f.cleanup); + const r = spawnCli(["lint", f.path, "--no-probe"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(!r.json.issues.some((i) => i.code === "media-unregistered")); + }); + + it("fires on a sidecar that carries no draft_materials key", () => { + const f = fixture({ meta: { other_key: 1 } }); + after(f.cleanup); + const r = spawnCli(["lint", f.path, "--no-probe"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + const issue = r.json.issues.find((i) => i.code === "media-unregistered"); + assert.ok(issue); + assert.match(issue.message, /no `draft_materials` key/); + }); + + it("never fires when the timeline references no local media", () => { + const f = fixture({ meta: { draft_materials: [] }, withMedia: false }); + after(f.cleanup); + const r = spawnCli(["lint", f.path, "--no-probe"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(!r.json.issues.some((i) => i.code === "media-unregistered")); + }); +}); From 3483b509408fe652d0be302fe44b4b9d45b63d19 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 11:20:58 +0000 Subject: [PATCH 06/11] feat(import): --clone-style copies the draft's existing caption look without a segment id --- src/command-specs.ts | 11 +++++ src/index.ts | 28 ++++++++++++ test/import-clone-style.test.mjs | 78 ++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 test/import-clone-style.test.mjs diff --git a/src/command-specs.ts b/src/command-specs.ts index ec5d61b..210f0de 100644 --- a/src/command-specs.ts +++ b/src/command-specs.ts @@ -89,6 +89,13 @@ const TRACK_NAME = option("track_name", ["--track-name"], "string", "Target trac const OUT = option("out", ["--out"], "path", "Output path."); const FFPROBE = option("ffprobe_cmd", ["--ffprobe-cmd"], "path", "ffprobe binary."); const STYLE_REF = option("style_ref", ["--style-ref"], "id", "Copy styling from this text segment."); +const CLONE_STYLE = option( + "clone_style", + ["--clone-style"], + "boolean", + "Copy styling from the draft's newest existing caption (target track first, any text track as fallback) — " + + "--style-ref without having to look the segment id up. An explicit --style-ref wins.", +); const PRESET = option( "preset", ["--preset"], @@ -458,6 +465,7 @@ const optionsByCommand: Record = { "import-srt": [ TRACK_NAME, STYLE_REF, + CLONE_STYLE, option("time_offset", ["--time-offset"], "time", "Shift imported cues."), ...TEXT_STYLE, ...KEYWORD_EMPHASIS, @@ -465,6 +473,7 @@ const optionsByCommand: Record = { "import-ass": [ TRACK_NAME, STYLE_REF, + CLONE_STYLE, option("time_offset", ["--time-offset"], "time", "Shift imported cues."), ...TEXT_STYLE, ], @@ -720,6 +729,7 @@ optionsByCommand["image-anim"] = optionsByCommand["text-anim"]; // --nested -> sync-timelines (v0.21 nested Timelines/ repair) // --pip -> lint (v0.21 PIP + mask validation report) // --kind -> catalogue (v0.21 cross-category lookup); --limit also scopes there +// --clone-style -> import-srt, import-ass (v0.21 id-free style preservation) // --stage -> add-video, add-audio, replace-media, quickstart, relink (v0.21 media staging) // Everywhere else they fall through to the positional stream verbatim, matching // pre-release behaviour where these tokens were unknown and preserved. @@ -728,6 +738,7 @@ export const RELEASE_SCOPED_FLAGS: ReadonlySet = new Set([ "--apply", "--bind", "--catalogue", + "--clone-style", "--color-cycle", "--data", "--easing", diff --git a/src/index.ts b/src/index.ts index 8da54e2..95bfde9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -811,6 +811,8 @@ interface Flags { pip?: boolean; // catalogue kind?: string; + // import-srt / import-ass + cloneStyle?: boolean; // keyframe easing?: string; // Phase 1 decorators @@ -1363,6 +1365,8 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } { flags.pip = true; } else if (a === "--kind" && i + 1 < args.length) { flags.kind = args[++i]; + } else if (a === "--clone-style") { + flags.cloneStyle = true; } else if (a === "--sync") { flags.sync = true; } else if (a === "--add") { @@ -3314,6 +3318,30 @@ async function importCuesToDraft( const { addText, copyTextStyle } = await import("./factory.js"); const offsetUs = flags.timeOffset ? parseTimeInput(flags.timeOffset) : 0; + // --clone-style (fork parity): keep the draft's existing caption look + // without hunting for a segment id first. Resolves to the newest text + // segment on the target track (falling back to any text track) and then + // rides the --style-ref machinery unchanged; an explicit --style-ref wins. + if (flags.cloneStyle && !flags.styleRef) { + const textSegments = (trackFilter?: string): Segment[] => + draft.tracks + .filter((t) => t.type === "text" && (trackFilter === undefined || t.name === trackFilter)) + .flatMap((t) => t.segments); + const targetTrack = flags.trackName ?? "subtitle"; + const preferred = textSegments(targetTrack); + const pool = preferred.length > 0 ? preferred : textSegments(); + if (pool.length === 0) { + die( + "--clone-style needs an existing text segment to copy from, and this draft has none. " + + "Style one caption first (add-text / text-style), or pass --style-ref .", + ); + } + const source = pool.reduce((a, b) => + (b.target_timerange?.start ?? 0) >= (a.target_timerange?.start ?? 0) ? b : a, + ); + flags.styleRef = source.id; + } + // Resolve the style-ref segment once, before writing anything, so a bad ref // fails fast instead of halfway through a 200-cue import. if (flags.styleRef) { diff --git a/test/import-clone-style.test.mjs b/test/import-clone-style.test.mjs new file mode 100644 index 0000000..23cd97b --- /dev/null +++ b/test/import-clone-style.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +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"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +const SRT = "1\n00:00:03,000 --> 00:00:04,000\nHello clone\n"; + +function minimalDraft() { + return { + id: "guid-clone", + name: "clone-draft", + duration: 2_000_000, + fps: 30, + canvas_config: { width: 1080, height: 1920, ratio: "9:16" }, + platform: { app_source: "cc", app_version: "8.7.0", os: "windows" }, + tracks: [], + materials: { videos: [], audios: [], texts: [], speeds: [] }, + }; +} + +function fixture() { + const dir = mkdtempSync(join(tmpdir(), "capcut-clone-style-")); + writeFileSync(join(dir, "draft_content.json"), JSON.stringify(minimalDraft(), null, 2)); + return { dir, path: join(dir, "draft_content.json"), cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +function textMaterials(path) { + const draft = JSON.parse(readFileSync(path, "utf-8")); + return draft.materials.texts.map((m) => ({ id: m.id, content: JSON.parse(m.content) })); +} + +describe("import-srt --clone-style (id-free style preservation)", () => { + it("copies the newest existing caption's styling onto every imported cue", () => { + const f = fixture(); + after(f.cleanup); + + const styled = spawnCli(["add-text", f.path, "0", "1s", "Styled seed", "--font-size", "77"]); + assert.equal(styled.status, 0, `stderr: ${styled.stderr}`); + + const r = spawnCli(["import-srt", f.path, "-", "--clone-style"], { input: SRT }); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + + const mats = textMaterials(f.path); + const seed = mats.find((m) => JSON.stringify(m.content).includes("Styled seed")); + const imported = mats.find((m) => JSON.stringify(m.content).includes("Hello clone")); + assert.ok(seed && imported, "both the seed and the imported caption must exist"); + assert.equal( + imported.content.styles?.[0]?.size, + seed.content.styles?.[0]?.size, + "the imported cue must carry the seed caption's font size", + ); + assert.equal(imported.content.styles?.[0]?.size, 77); + }); + + it("an explicit --style-ref wins over --clone-style", () => { + const f = fixture(); + after(f.cleanup); + spawnCli(["add-text", f.path, "0", "1s", "Old style", "--font-size", "30"]); + const newer = spawnCli(["add-text", f.path, "1s", "1s", "New style", "--font-size", "90"]); + assert.equal(newer.status, 0, `stderr: ${newer.stderr}`); + const oldSegId = JSON.parse(readFileSync(f.path, "utf-8")).tracks.flatMap((t) => t.segments)[0].id; + + const r = spawnCli(["import-srt", f.path, "-", "--clone-style", "--style-ref", oldSegId], { input: SRT }); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + const imported = textMaterials(f.path).find((m) => JSON.stringify(m.content).includes("Hello clone")); + assert.equal(imported.content.styles?.[0]?.size, 30, "--style-ref must win"); + }); + + it("fails fast with guidance when the draft has no text segment to clone from", () => { + const f = fixture(); + after(f.cleanup); + const r = spawnCli(["import-srt", f.path, "-", "--clone-style"], { input: SRT }); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /existing text segment/); + }); +}); From d850b15200ee66f4156702b526c0564c9736baef Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 11:31:39 +0000 Subject: [PATCH 07/11] feat: relink --stage, fixture --check, tagged write refusals - relink --stage copies each relinked video/audio into assets// and points the material at the copy, so the repaired draft is portable (pyJianYingDraft#177) - fixture --check scans the finished bundle (SANITIZE_REPORT.json and README included) for residual home paths, emails, device ids and the account name, with file:line findings and a non-zero exit; a bundle dir as the argument re-checks without rebuilding (#50, #59) - every write refusal now names its gate - refused [editor-open] / [version-boundary] / [draft-changed-on-disk] / [mirror-newer] - so the next pasted stderr line is unambiguous about which gate fired (#50 side finding) --- src/command-specs.ts | 26 ++++++-- src/draft.ts | 14 ++++- src/fixture.ts | 102 +++++++++++++++++++++++++++++- src/index.ts | 92 ++++++++++++++++++++++----- test/fixture-check.test.mjs | 73 ++++++++++++++++++++++ test/refusal-gate-ids.test.mjs | 38 +++++++++++ test/relink-stage.test.mjs | 111 +++++++++++++++++++++++++++++++++ 7 files changed, 433 insertions(+), 23 deletions(-) create mode 100644 test/fixture-check.test.mjs create mode 100644 test/refusal-gate-ids.test.mjs create mode 100644 test/relink-stage.test.mjs diff --git a/src/command-specs.ts b/src/command-specs.ts index 210f0de..23029e8 100644 --- a/src/command-specs.ts +++ b/src/command-specs.ts @@ -214,7 +214,7 @@ const usages = { prune: "capcut prune ", register: "capcut register [--apply] [--drafts ]", rename: "capcut rename [--drafts ]", - relink: "capcut relink (--dir | --from --to )", + relink: "capcut relink (--dir | --from --to ) [--stage]", timeline: "capcut timeline [--cols ]", projects: "capcut projects [query] [--drafts ] [--names]", diff: "capcut diff ", @@ -228,7 +228,7 @@ const usages = { "capcut harvest-enums [ | --sync | --add ] [--apply] [--catalogue ]", doctor: "capcut doctor", diagnose: "capcut diagnose [--bundle ]", - fixture: "capcut fixture --out ", + fixture: "capcut fixture --out [--check]", "sync-timelines": "capcut sync-timelines [--nested] [--apply]", restore: "capcut restore [--step | --list]", serve: "capcut serve [--queue ] [options]", @@ -566,6 +566,14 @@ const optionsByCommand: Record = { option("dir", ["--dir"], "path", "Directory containing replacement files."), option("from", ["--from"], "path", "Old path prefix."), option("to", ["--to"], "path", "New path prefix."), + option( + "stage", + ["--stage"], + "boolean", + "Copy each file this run relinks into the draft's assets// and point the material at the copy, so the " + + "repaired draft is portable (video/audio only; skipped under --dry-run — a copy is a side effect no draft " + + "write rolls back).", + ), ], timeline: [option("cols", ["--cols"], "number", "Timeline columns.", { default: 60 })], projects: [ @@ -574,7 +582,17 @@ const optionsByCommand: Record = { ], concat: [OUT], diagnose: [option("bundle", ["--bundle"], "path", "Write a redacted JSON diagnostic bundle.")], - fixture: [option("out", ["--out"], "path", "Output directory for the sanitized bundle.")], + fixture: [ + option("out", ["--out"], "path", "Output directory for the sanitized bundle."), + option( + "check", + ["--check"], + "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.", + ), + ], "sync-timelines": [ option( "apply", @@ -730,7 +748,7 @@ optionsByCommand["image-anim"] = optionsByCommand["text-anim"]; // --pip -> lint (v0.21 PIP + mask validation report) // --kind -> catalogue (v0.21 cross-category lookup); --limit also scopes there // --clone-style -> import-srt, import-ass (v0.21 id-free style preservation) -// --stage -> add-video, add-audio, replace-media, quickstart, relink (v0.21 media staging) +// --stage -> relink (v0.21 stage relinked media into the draft) // Everywhere else they fall through to the positional stream verbatim, matching // pre-release behaviour where these tokens were unknown and preserved. export const RELEASE_SCOPED_FLAGS: ReadonlySet = new Set([ diff --git a/src/draft.ts b/src/draft.ts index ce589df..e5042e8 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -278,8 +278,12 @@ export function assertTargetsUnchangedOnDisk(targets: DraftCandidate[]): void { // that is otherwise untouched does not read as a concurrent change. const current = stripBom(readFileSync(target.path, "utf-8")); if (current !== target.raw) { + // The [gate-id] prefix is load-bearing (issue #50): refusal reports + // arrive paraphrased ("it said something was running"), and two of the + // three gates mention --force-write, so a stable greppable tag is what + // makes the next quoted stderr line unambiguous about WHICH gate fired. throw new Error( - `Draft changed on disk after it was loaded: ${target.name}. ` + + `refused [draft-changed-on-disk]: Draft changed on disk after it was loaded: ${target.name}. ` + "Reload and retry, or pass --force-write to overwrite intentionally.", ); } @@ -363,8 +367,10 @@ export function saveDraft( if (!forceWrite && isManagedDraftPath(filePath)) { const running = editorProcesses(); if (running.length > 0) { + // Tagged like the other write gates (issue #50: a report of this exact + // message could not be told apart from the version boundary's refusal). throw new Error( - `${running.join(" / ")} is running. Close the editor before writing this managed draft, ` + + `refused [editor-open]: ${running.join(" / ")} is running. Close the editor before writing this managed draft, ` + "or pass --force-write if you accept that the app may overwrite the change.", ); } @@ -383,7 +389,9 @@ export function saveDraft( // mirrors diverged. if (options.skipVersionGuard !== true) { const safety = assessWriteSafety(draft, store.version); - if (safety.action === "refuse" && !forceWrite) throw new Error(safety.reasons.join("\n")); + if (safety.action === "refuse" && !forceWrite) { + throw new Error(`refused [version-boundary]: ${safety.reasons.join("\n")}`); + } if (safety.action === "warn" || (safety.action === "refuse" && forceWrite)) { process.stderr.write(`WARNING: ${safety.reasons.join(" ")}\n`); } diff --git a/src/fixture.ts b/src/fixture.ts index a21cc47..aaa20ca 100644 --- a/src/fixture.ts +++ b/src/fixture.ts @@ -9,7 +9,7 @@ // CapCut 8.7 desktop) can only happen on Windows. import { createHash } from "node:crypto"; -import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { stripBom } from "./bom.js"; import { keyframePropertyTypes } from "./decorators.js"; @@ -687,3 +687,103 @@ export function buildNestedTimelinesEvidence(input: string): NestedTimelinesEvid root_vs_nested: comparisons, }; } + +import { userInfo } from "node:os"; + +// --- `fixture --check`: mechanical redaction verification ------------------- +// +// The bundle README has always said "review the files yourself before +// attaching" — and that manual burden is exactly what stalls contributions: +// the 9.2.8 reporter in issue #50 held the bundle back until confident nothing +// private leaked. This pass turns the review into a checkable gate: scan every +// text file in the finished bundle (SANITIZE_REPORT.json and README included — +// #59's lesson was that scrubbed values re-entered through the report) for the +// shapes the redactor exists to remove, and fail loudly with file:line +// pointers. A finding is not proof of a leak — it is a place a human must +// look — so the excerpt itself is never echoed, only the location and kind. + +export interface RedactionFinding { + file: string; + line: number; + kind: "home-path" | "email" | "device-key" | "username"; +} + +export interface RedactionCheck { + ok: boolean; + bundle_dir: string; + files_scanned: number; + findings: RedactionFinding[]; +} + +const CHECK_ALLOWED_EMAIL = "redacted@example.com"; +const CHECK_HOME_PATH = + /(?:\/Users\/[A-Za-z0-9._-]{2,}|\/home\/[A-Za-z0-9._-]{2,}|[A-Za-z]:\\+Users\\+[A-Za-z0-9._-]{2,})/; +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 +// "redacted" marker. Numeric tallies in SANITIZE_REPORT.json ("device_id": 2) +// carry no string value and never match. +const CHECK_DEVICE_KEY = /"(?:device_id|mac_address|hard_disk_id)"\s*:\s*"(?!redacted")[^"]{4,}"/; + +function checkTextFiles(dir: string, prefix = ""): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir).sort()) { + const full = join(dir, entry); + const rel = prefix.length === 0 ? entry : `${prefix}/${entry}`; + let stat: ReturnType; + try { + stat = statSync(full); + } catch { + continue; + } + if (stat.isDirectory()) { + files.push(...checkTextFiles(full, rel)); + continue; + } + if (/\.(json|md|txt|tmp)$/i.test(entry)) files.push(rel); + } + return files; +} + +export function verifyBundleRedaction(bundleDir: string): RedactionCheck { + const root = resolve(bundleDir); + const findings: RedactionFinding[] = []; + // The account name only matters when it is distinctive enough to identify — + // short names ("a", "dev") would flood the check with coincidences. + let username: string | null = null; + try { + const name = userInfo().username; + if (name.length >= 5) username = name; + } catch { + username = null; + } + const usernameRe = username + ? new RegExp(`(? email.toLowerCase() !== CHECK_ALLOWED_EMAIL)) { + findings.push({ file: rel, line: i + 1, kind: "email" }); + } + if (CHECK_DEVICE_KEY.test(line)) { + findings.push({ file: rel, line: i + 1, kind: "device-key" }); + } + } + } + return { ok: findings.length === 0, bundle_dir: root, files_scanned: files.length, findings }; +} diff --git a/src/index.ts b/src/index.ts index 95bfde9..0d5d666 100644 --- a/src/index.ts +++ b/src/index.ts @@ -813,6 +813,8 @@ interface Flags { kind?: string; // import-srt / import-ass cloneStyle?: boolean; + // relink + stage?: boolean; // keyframe easing?: string; // Phase 1 decorators @@ -1367,6 +1369,8 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } { flags.kind = args[++i]; } else if (a === "--clone-style") { flags.cloneStyle = true; + } else if (a === "--stage") { + flags.stage = true; } else if (a === "--sync") { flags.sync = true; } else if (a === "--add") { @@ -4149,13 +4153,13 @@ function cmdSyncTimelines(projectPath: string | undefined, flags: Flags): number const running = editorProcesses(); if (running.length > 0) { die( - `${running.join(" / ")} is running. Close the editor before repairing this draft, ` + + `refused [editor-open]: ${running.join(" / ")} is running. Close the editor before repairing this draft, ` + "or pass --force-write if you accept that the app may overwrite the change.", ); } if (plan.canonical_stale) { die( - `${staleCanonicalWarning()} Back up the project, review the plan (capcut sync-timelines ${projectPath}), ` + + `refused [mirror-newer]: ${staleCanonicalWarning()} Back up the project, review the plan (capcut sync-timelines ${projectPath}), ` + `and pass --force-write only if ${plan.canonical} is really the timeline you want to keep.`, ); } @@ -4383,22 +4387,25 @@ async function cmdPrune(draft: Draft, filePath: string, flags: Flags): Promise for each material whose path is missing, look for a file // with the same basename in and repoint to it. // --from

--to prefix-replace on every material path. -function cmdRelink(draft: Draft, filePath: string, flags: Flags): void { +async function cmdRelink(draft: Draft, filePath: string, flags: Flags): Promise { if (!flags.dir && !(flags.from && flags.to)) { - die("Usage: capcut relink --dir | --from --to "); + die("Usage: capcut relink (--dir | --from --to ) [--stage]"); } + const { copyAssetDeduped } = await import("./factory.js"); const dirIndex = new Map(); if (flags.dir) { if (!existsSync(flags.dir)) die(`--dir not found: ${flags.dir}`); for (const f of readdirSync(flags.dir)) dirIndex.set(path.basename(f), path.join(flags.dir as string, f)); } - const relinked: Array<{ id: string; from: string; to: string }> = []; + const draftDir = path.dirname(path.resolve(filePath)); + const relinked: Array<{ id: string; from: string; to: string; staged: boolean }> = []; let missing = 0; let ok = 0; - for (const arr of Object.values(draft.materials)) { + let staged = 0; + for (const [kind, arr] of Object.entries(draft.materials)) { if (!Array.isArray(arr)) continue; for (const m of arr) { - const mat = m as { id?: string; path?: unknown }; + const mat = m as { id?: string; path?: unknown; material_name?: unknown; name?: unknown }; if (typeof mat.path !== "string" || mat.path === "") continue; let p = mat.path; let changed = false; @@ -4413,8 +4420,39 @@ function cmdRelink(draft: Draft, filePath: string, flags: Flags): void { changed = true; } } + // --stage: copy the file this run just relinked into assets// and + // point the material at the copy — the repaired draft leaves portable + // (pyJianYingDraft#177: a draft whose media lives outside the project + // folder black-screens when the folder moves machines). Same + // copyAssetDeduped path add-video/add-audio use, so re-runs are no-ops. + // A file copy is a side effect no draft write rolls back, so --dry-run + // skips the copy and the plan keeps the resolved external path. + let didStage = false; + if ( + changed && + flags.stage && + !isDryRun() && + (kind === "videos" || kind === "audios") && + existsSync(p) && + !path.resolve(p).startsWith(draftDir + path.sep) + ) { + const assetKind = kind === "audios" ? "audio" : "video"; + const destPath = copyAssetDeduped( + p, + path.resolve(draftDir, "assets", assetKind), + assetKind === "audio" ? "audio.mp3" : "media", + ); + p = destPath; + didStage = true; + staged++; + // Keep the display-name fields tracking the staged file, the + // replace-media convention — only visible when de-collision renamed. + const filename = path.basename(destPath); + if ("material_name" in mat) mat.material_name = filename; + if ("name" in mat) mat.name = filename; + } if (changed && p !== mat.path) { - relinked.push({ id: mat.id ?? "", from: mat.path, to: p }); + relinked.push({ id: mat.id ?? "", from: mat.path, to: p, staged: didStage }); mat.path = p; } if (existsSync(p)) ok++; @@ -4422,7 +4460,7 @@ function cmdRelink(draft: Draft, filePath: string, flags: Flags): void { } } if (relinked.length > 0) saveDraft(filePath, draft); - out({ ok: true, relinked: relinked.length, still_missing: missing, present: ok, changes: relinked }, flags); + out({ ok: true, relinked: relinked.length, staged, still_missing: missing, present: ok, changes: relinked }, flags); } // `replace-media` swaps a segment's source file in place (placeholder > final), @@ -5220,17 +5258,41 @@ async function main(): Promise { // `fixture` reads raw (possibly modern-storage) files and writes a redacted bundle — no loadDraft. if (cmd === "fixture") { - if (!projectPath) die("Usage: capcut fixture --out

"); - if (!flags.out) die("Missing --out . Usage: capcut fixture --out "); - const { sanitizeDraftBundle } = await import("./fixture.js"); + const { sanitizeDraftBundle, verifyBundleRedaction } = await import("./fixture.js"); + const printCheck = (check: ReturnType): void => { + if (flags.quiet) return; + for (const finding of check.findings) { + process.stderr.write(`LEAK ${finding.kind} ${finding.file}:${finding.line}\n`); + } + process.stderr.write( + check.ok + ? `Redaction check passed (${check.files_scanned} files scanned).\n` + : `Redaction check FAILED: ${check.findings.length} finding(s) to review before sharing.\n`, + ); + }; + // Verify-only mode: the positional is a finished bundle, not a project — + // re-check it any time without rebuilding (issue #50's hesitant-reporter + // case: confidence, on demand, before attaching). + if (flags.check && !flags.out && projectPath && existsSync(path.join(projectPath, "SANITIZE_REPORT.json"))) { + const check = verifyBundleRedaction(projectPath); + out(check, flags); + printCheck(check); + process.exit(check.ok ? 0 : 1); + } + if (!projectPath) { + die("Usage: capcut fixture --out [--check] | capcut fixture --check"); + } + if (!flags.out) die("Missing --out . Usage: capcut fixture --out [--check]"); const report = sanitizeDraftBundle(projectPath, flags.out); - out(report, flags); + const check = flags.check ? verifyBundleRedaction(report.out_dir) : null; + out(check ? { ...report, ok: report.ok && check.ok, redaction_check: check } : 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.exit(0); + if (check) printCheck(check); + process.exit(check && !check.ok ? 1 : 0); } // `register` reads draft_content.json directly — no loadDraft: the draft may @@ -5411,7 +5473,7 @@ async function main(): Promise { await cmdPrune(draft, filePath, flags); break; case "relink": - cmdRelink(draft, filePath, flags); + await cmdRelink(draft, filePath, flags); break; case "replace-media": requireArgs(positional, 4, "capcut replace-media [--retime]"); diff --git a/test/fixture-check.test.mjs b/test/fixture-check.test.mjs new file mode 100644 index 0000000..f0cebd9 --- /dev/null +++ b/test/fixture-check.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +function project() { + const dir = mkdtempSync(join(tmpdir(), "capcut-fixture-check-")); + const draft = { + id: "guid-fixture", + name: "fixture-draft", + 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" }, + tracks: [{ id: "T1", type: "text", name: "text", attribute: 0, segments: [] }], + materials: { videos: [], audios: [], texts: [], speeds: [] }, + }; + writeFileSync(join(dir, "draft_content.json"), JSON.stringify(draft, null, 2)); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +describe("fixture --check (redaction verification)", () => { + it("builds a bundle and passes the check when nothing private survives", () => { + const p = project(); + after(p.cleanup); + const out = join(p.dir, "bundle"); + + const r = spawnCli(["fixture", p.dir, "--out", out, "--check"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.redaction_check.ok, true, JSON.stringify(r.json.redaction_check)); + assert.ok(r.json.redaction_check.files_scanned >= 2, "the bundle's own report and README must be scanned too"); + assert.match(r.stderr, /Redaction check passed/); + }); + + it("verify-only mode fails an existing bundle that leaks a home path, naming file and line", () => { + const p = project(); + after(p.cleanup); + const out = join(p.dir, "bundle"); + assert.equal(spawnCli(["fixture", p.dir, "--out", out]).status, 0); + writeFileSync(join(out, "leak.json"), JSON.stringify({ note: "/Users/hansmustermann/secret.mov" })); + + const r = spawnCli(["fixture", out, "--check"]); + assert.equal(r.status, 1, `a finding must fail the exit code — stderr: ${r.stderr}`); + assert.equal(r.json.ok, false); + const finding = r.json.findings.find((f) => f.file === "leak.json"); + assert.ok(finding, JSON.stringify(r.json.findings)); + assert.equal(finding.kind, "home-path"); + assert.equal(typeof finding.line, "number"); + assert.match(r.stderr, /LEAK home-path leak\.json/); + assert.ok(!r.stderr.includes("hansmustermann"), "the check must never echo the leaked value itself"); + }); + + it("flags real emails and unredacted device keys, allows the redactor's placeholder", () => { + const p = project(); + after(p.cleanup); + const out = join(p.dir, "bundle"); + assert.equal(spawnCli(["fixture", p.dir, "--out", out]).status, 0); + writeFileSync( + join(out, "extra.json"), + JSON.stringify({ contact: "someone@real-mail.com", device_id: "abcdef123456", ok: "redacted@example.com" }), + ); + writeFileSync(join(out, "clean.json"), JSON.stringify({ device_id: "redacted", mail: "redacted@example.com" })); + + const r = spawnCli(["fixture", out, "--check"]); + assert.equal(r.status, 1); + const kinds = r.json.findings.filter((f) => f.file === "extra.json").map((f) => f.kind); + assert.ok(kinds.includes("email"), JSON.stringify(r.json.findings)); + assert.ok(kinds.includes("device-key")); + assert.ok(!r.json.findings.some((f) => f.file === "clean.json"), "redacted placeholders must not be flagged"); + }); +}); diff --git a/test/refusal-gate-ids.test.mjs b/test/refusal-gate-ids.test.mjs new file mode 100644 index 0000000..77b7755 --- /dev/null +++ b/test/refusal-gate-ids.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +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"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +// A draft far beyond the registry's version evidence: the write-time version +// boundary must refuse it. +function beyondRangeFixture() { + const dir = mkdtempSync(join(tmpdir(), "capcut-gate-ids-")); + const draft = { + id: "guid-gate", + name: "gate-draft", + duration: 1_000_000, + fps: 30, + canvas_config: { width: 1080, height: 1920, ratio: "9:16" }, + platform: { app_source: "cc", app_version: "99.9.9", os: "windows" }, + tracks: [], + materials: { videos: [], audios: [], texts: [], speeds: [] }, + }; + writeFileSync(join(dir, "draft_content.json"), JSON.stringify(draft, null, 2)); + return { dir, path: join(dir, "draft_content.json"), cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +describe("write-refusal gate ids (issue #50 side finding)", () => { + it("the version boundary names its gate, so a pasted stderr line is unambiguous", () => { + const f = beyondRangeFixture(); + after(f.cleanup); + const before = readFileSync(f.path, "utf-8"); + + const r = spawnCli(["add-text", f.path, "0", "1s", "hello"]); + assert.notEqual(r.status, 0, "a beyond-range draft must refuse the write"); + assert.match(r.stderr, /refused \[version-boundary\]/); + assert.ok(!r.stderr.includes("[editor-open]"), "only the gate that fired may be named"); + assert.equal(readFileSync(f.path, "utf-8"), before, "refusal must not write"); + }); +}); diff --git a/test/relink-stage.test.mjs b/test/relink-stage.test.mjs new file mode 100644 index 0000000..3692a3f --- /dev/null +++ b/test/relink-stage.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +function draft(mediaPath, kind = "videos") { + const materials = { videos: [], audios: [], texts: [], speeds: [] }; + materials[kind] = [{ id: "M1", type: kind === "audios" ? "audio" : "video", path: mediaPath, duration: 1_000_000 }]; + return { + id: "guid-relink", + name: "relink-draft", + 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" }, + tracks: [ + { + id: "T1", + type: kind === "audios" ? "audio" : "video", + name: kind === "audios" ? "audio" : "video", + attribute: 0, + segments: [ + { + id: "S1", + material_id: "M1", + target_timerange: { start: 0, duration: 1_000_000 }, + source_timerange: { start: 0, duration: 1_000_000 }, + }, + ], + }, + ], + materials, + }; +} + +// A draft pointing at a media path that no longer exists, plus a replacement +// directory holding the real file — the classic moved-machines relink case. +function fixture({ kind = "videos" } = {}) { + const dir = mkdtempSync(join(tmpdir(), "capcut-relink-stage-")); + const mediaDir = mkdtempSync(join(tmpdir(), "capcut-relink-media-")); + const filename = kind === "audios" ? "sound.mp3" : "clip.mp4"; + writeFileSync(join(mediaDir, filename), "media-bytes"); + const gonePath = join(mediaDir, "gone-subdir", filename); // never exists + writeFileSync(join(dir, "draft_content.json"), JSON.stringify(draft(gonePath, kind), null, 2)); + return { + dir, + mediaDir, + filename, + path: join(dir, "draft_content.json"), + cleanup: () => { + rmSync(dir, { recursive: true, force: true }); + rmSync(mediaDir, { recursive: true, force: true }); + }, + }; +} + +describe("relink --stage (portable repair)", () => { + it("copies the relinked file into assets/video/ and points the material at the copy", () => { + const f = fixture(); + after(f.cleanup); + + const r = spawnCli(["relink", f.path, "--dir", f.mediaDir, "--stage"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.relinked, 1); + assert.equal(r.json.staged, 1); + assert.equal(r.json.changes[0].staged, true); + + const saved = JSON.parse(readFileSync(f.path, "utf-8")); + const staged = saved.materials.videos[0].path; + assert.ok(staged.startsWith(join(f.dir, "assets", "video")), `expected staged path, got ${staged}`); + assert.ok(existsSync(staged), "the staged copy must exist"); + assert.equal(readFileSync(staged, "utf-8"), "media-bytes"); + }); + + it("stages audio into assets/audio/", () => { + const f = fixture({ kind: "audios" }); + after(f.cleanup); + + const r = spawnCli(["relink", f.path, "--dir", f.mediaDir, "--stage"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.staged, 1); + const saved = JSON.parse(readFileSync(f.path, "utf-8")); + assert.ok(saved.materials.audios[0].path.startsWith(join(f.dir, "assets", "audio"))); + }); + + it("without --stage, relink only rewrites the path (previous behaviour intact)", () => { + const f = fixture(); + after(f.cleanup); + + const r = spawnCli(["relink", f.path, "--dir", f.mediaDir]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.relinked, 1); + assert.equal(r.json.staged, 0); + const saved = JSON.parse(readFileSync(f.path, "utf-8")); + assert.equal(saved.materials.videos[0].path, join(f.mediaDir, f.filename)); + assert.ok(!existsSync(join(f.dir, "assets")), "nothing may be copied without --stage"); + }); + + it("--dry-run --stage neither copies nor writes", () => { + const f = fixture(); + after(f.cleanup); + const before = readFileSync(f.path, "utf-8"); + + const r = spawnCli(["relink", f.path, "--dir", f.mediaDir, "--stage", "--dry-run"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(!existsSync(join(f.dir, "assets")), "dry-run must not copy"); + assert.equal(readFileSync(f.path, "utf-8"), before, "dry-run must not write the draft"); + }); +}); From d62752393dc27432d7bbf03629ed087526c4f95f Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 11:44:24 +0000 Subject: [PATCH 08/11] feat(catalogue): register the command in describe/completions and regenerate the reference --- docs/command-reference.json | 131 ++++++++++++++++++++++++++++++++++-- docs/command-reference.md | 7 +- src/index.ts | 2 + 3 files changed, 133 insertions(+), 7 deletions(-) diff --git a/docs/command-reference.json b/docs/command-reference.json index b8a83e6..e8fba78 100644 --- a/docs/command-reference.json +++ b/docs/command-reference.json @@ -1,6 +1,6 @@ { "name": "capcut-cli", - "version": "0.19.1", + "version": "0.21.0", "schema_version": 2, "description": "Edit CapCut/JianYing draft_content.json directly. JSON in, JSON out.", "global_flags": [ @@ -204,6 +204,15 @@ "required": false, "description": "Skip ffprobe media checks (VFR / unreadable media)." }, + { + "name": "pip", + "flags": [ + "--pip" + ], + "type": "boolean", + "required": false, + "description": "Validate the PIP + local-mask workflow (issue #78): report overlay / overlay-keyframe / mask-attachment counts and missing media by path, and fail the exit code on an orphaned (never-attached) mask." + }, { "name": "ffprobe_cmd", "flags": [ @@ -2883,6 +2892,15 @@ "required": false, "description": "Copy styling from this text segment." }, + { + "name": "clone_style", + "flags": [ + "--clone-style" + ], + "type": "boolean", + "required": false, + "description": "Copy styling from the draft's newest existing caption (target track first, any text track as fallback) — --style-ref without having to look the segment id up. An explicit --style-ref wins." + }, { "name": "time_offset", "flags": [ @@ -3217,6 +3235,15 @@ "required": false, "description": "Copy styling from this text segment." }, + { + "name": "clone_style", + "flags": [ + "--clone-style" + ], + "type": "boolean", + "required": false, + "description": "Copy styling from the draft's newest existing caption (target track first, any text track as fallback) — --style-ref without having to look the segment id up. An explicit --style-ref wins." + }, { "name": "time_offset", "flags": [ @@ -4045,7 +4072,7 @@ { "name": "relink", "summary": "Repair broken media paths (--dir or --from/--to).", - "usage": "capcut relink (--dir | --from --to )", + "usage": "capcut relink (--dir | --from --to ) [--stage]", "positionals": [ { "name": "project", @@ -4085,6 +4112,15 @@ "type": "path", "required": false, "description": "New path prefix." + }, + { + "name": "stage", + "flags": [ + "--stage" + ], + "type": "boolean", + "required": false, + "description": "Copy each file this run relinks into the draft's assets// and point the material at the copy, so the repaired draft is portable (video/audio only; skipped under --dry-run — a copy is a side effect no draft write rolls back)." } ], "mutates": true, @@ -4381,6 +4417,75 @@ "1": "invalid input, warning, or operation failure" } }, + { + "name": "catalogue", + "summary": "Find a resource id by name across every category, harvested entries included.", + "usage": "capcut catalogue [--kind ] [--limit ] [--jianying]", + "positionals": [ + { + "name": "query", + "type": "string", + "required": true + }, + { + "name": "category", + "type": "string", + "required": false + }, + { + "name": "n", + "type": "string", + "required": false + } + ], + "options": [ + { + "name": "kind", + "flags": [ + "--kind" + ], + "type": "enum", + "required": false, + "description": "Search only this category (default: all categories, filters and bubbles included).", + "values": [ + "transitions", + "masks", + "image_intros", + "image_outros", + "image_combos", + "text_intros", + "text_outros", + "text_loop_anims", + "scene_effects", + "character_effects", + "audio_effects", + "fonts", + "filters", + "bubbles" + ] + }, + { + "name": "limit", + "flags": [ + "--limit" + ], + "type": "number", + "required": false, + "description": "Keep only the N best matches.", + "default": 20 + } + ], + "mutates": false, + "prerequisites": [], + "output": { + "type": "array", + "description": "JSON by default; use -H where supported for human output." + }, + "exit_codes": { + "0": "success", + "1": "invalid input, warning, or operation failure" + } + }, { "name": "harvest-enums", "summary": "Learn store resource ids into the per-user catalogue: from one draft, the whole library (--sync), or by hand (--add).", @@ -4532,7 +4637,7 @@ { "name": "fixture", "summary": "Build a shareable, redacted compatibility bundle (timeline JSON only) for a version-support issue, including the mask-keyframe evidence report (#44).", - "usage": "capcut fixture --out ", + "usage": "capcut fixture --out [--check]", "positionals": [ { "name": "project", @@ -4549,6 +4654,15 @@ "type": "path", "required": false, "description": "Output directory for the sanitized bundle." + }, + { + "name": "check", + "flags": [ + "--check" + ], + "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." } ], "mutates": false, @@ -4565,7 +4679,7 @@ { "name": "sync-timelines", "summary": "Reconcile drifted timeline mirrors (template-2.tmp, draft_info.json) from a read-only draft_content.json (plan with mtimes by default; --apply rewrites only the drifted mirrors).", - "usage": "capcut sync-timelines [--apply]", + "usage": "capcut sync-timelines [--nested] [--apply]", "positionals": [ { "name": "project-dir", @@ -4582,6 +4696,15 @@ "type": "boolean", "required": false, "description": "Rewrite only the drifted mirror files from draft_content.json (default: print the plan only)." + }, + { + "name": "nested", + "flags": [ + "--nested" + ], + "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." } ], "mutates": true, diff --git a/docs/command-reference.md b/docs/command-reference.md index 58debae..1e59c3a 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -62,7 +62,7 @@ | `prune` | `capcut prune ` | yes | Remove materials no segment references. | | `register` | `capcut register [--apply] [--drafts ]` | yes | Repair an existing draft's registration metadata (draft_meta_info.json + root_meta_info.json entry) from a read-only draft_content.json so the CapCut app lists it (plan by default; --apply writes with .bak). | | `rename` | `capcut rename [--drafts ]` | yes | Rename a draft after creation: the folder on disk plus draft_name and every self-referential path in draft_meta_info.json and the store's root_meta_info.json entry, transactionally (refuses when the target folder exists). | -| `relink` | `capcut relink (--dir \| --from --to )` | yes | Repair broken media paths (--dir or --from/--to). | +| `relink` | `capcut relink (--dir \| --from --to ) [--stage]` | yes | Repair broken media paths (--dir or --from/--to). | | `replace-media` | `capcut replace-media [--retime]` | yes | Swap a segment's source file (placeholder > final) keeping its timing, effects, and keyframes. | | `timeline` | `capcut timeline [--cols ]` | no | Show the track/segment layout (JSON, or -H ASCII bars). | | `projects` | `capcut projects [query] [--drafts ] [--names]` | no | List CapCut/JianYing draft folders on disk. | @@ -72,11 +72,12 @@ | `describe` | `capcut describe` | no | Emit the full command surface as JSON (agent tool spec). | | `completions` | `capcut completions ` | no | Generate shell completions (bash|zsh|fish). | | `enums` | `capcut enums [--jianying]` | no | List enum slugs (transitions, masks, effects, ...) by category. | +| `catalogue` | `capcut catalogue [--kind ] [--limit ] [--jianying]` | no | Find a resource id by name across every category, harvested entries included. | | `harvest-enums` | `capcut harvest-enums [ \| --sync \| --add ] [--apply] [--catalogue ]` | no | Learn store resource ids into the per-user catalogue: from one draft, the whole library (--sync), or by hand (--add). | | `doctor` | `capcut doctor` | no | Environment preflight (Node, whisper, API key, project dir). | | `diagnose` | `capcut diagnose [--bundle ]` | no | Inspect canonical draft files, divergence, and editor-write safety. | -| `fixture` | `capcut fixture --out ` | no | Build a shareable, redacted compatibility bundle (timeline JSON only) for a version-support issue, including the mask-keyframe evidence report (#44). | -| `sync-timelines` | `capcut sync-timelines [--apply]` | yes | Reconcile drifted timeline mirrors (template-2.tmp, draft_info.json) from a read-only draft_content.json (plan with mtimes by default; --apply rewrites only the drifted mirrors). | +| `fixture` | `capcut fixture --out [--check]` | no | Build a shareable, redacted compatibility bundle (timeline JSON only) for a version-support issue, including the mask-keyframe evidence report (#44). | +| `sync-timelines` | `capcut sync-timelines [--nested] [--apply]` | yes | Reconcile drifted timeline mirrors (template-2.tmp, draft_info.json) from a read-only draft_content.json (plan with mtimes by default; --apply rewrites only the drifted mirrors). | | `restore` | `capcut restore [--step \| --list]` | yes | Undo writes from .bak / snapshot history (--step N, --list). | | `serve` | `capcut serve [--queue ] [options]` | no | Run a stateless JSONL job queue from stdin/--queue. | | `decrypt` | `capcut decrypt ` | no | Detect JianYing 6.0+ encryption and explain the workaround. | diff --git a/src/index.ts b/src/index.ts index 0d5d666..510c772 100644 --- a/src/index.ts +++ b/src/index.ts @@ -142,6 +142,7 @@ export const COMMANDS = [ "describe", "completions", "enums", + "catalogue", "harvest-enums", "doctor", "diagnose", @@ -4643,6 +4644,7 @@ const SUMMARIES: Record = { "add-sfx": "Add a sound effect on a dedicated track.", chroma: "Green-screen / chroma key a video segment, or --off.", enums: "List enum slugs (transitions, masks, effects, ...) by category.", + catalogue: "Find a resource id by name across every category, harvested entries included.", doctor: "Environment preflight (Node, whisper, API key, project dir).", diagnose: "Inspect canonical draft files, divergence, and editor-write safety.", "sync-timelines": From b9d3552a296d1b099a50a600746d584f12b07b9e Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 11:47:22 +0000 Subject: [PATCH 09/11] docs(zh): README parity with the #97 restructure, version-support + encryption translations, v0.21.0 highlights --- README.zh-CN.md | 59 ++++++++------- docs/jianying-encryption.zh-CN.md | 52 +++++++++++++ docs/version-support.zh-CN.md | 119 ++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 docs/jianying-encryption.zh-CN.md create mode 100644 docs/version-support.zh-CN.md diff --git a/README.zh-CN.md b/README.zh-CN.md index 23be2b9..2ae90bb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,47 +12,54 @@ [English](./README.md) | 中文 -> **隐私 — 若你曾运行过 `capcut fixture`,请升级到 0.18.0。** 在 0.17.2 及更早的所有版本中,该命令生成的分享包会原样带出 CapCut 写入草稿的 `device_id`、`mac_address` 与 `hard_disk_id`:脱敏器只处理了用户主目录路径和电子邮件地址,而 `SANITIZE_REPORT.json` 自身的 `source_dir` / `out_dir` 未经脱敏写入,又把用户名带了回来。由于文档建议的流程正是把该分享包附到公开 issue 上,照做即会公开一个稳定的设备 ID 与 MAC 地址——尽管文件名与报告都称其“已脱敏”。已在 0.18.0 修复([#59](https://github.com/renezander030/capcut-cli/issues/59))。**请将更早版本生成的任何分享包视为未脱敏。** `npm install -g capcut-cli@latest`。 +**在终端里创建和编辑真正的 CapCut / 剪映项目 —— 或者交给任何大模型 Agent 来做。** -> **安全提示 —— 请升级到 0.17.1 或更高版本。** 0.17.0 及更早版本在 `export --batch` 中把草稿文件夹名直接拼进要执行的自动化脚本,因此一个精心命名的文件夹可以在 macOS 与 Windows 上执行自己的命令。0.17.1 已修复,同时修复的还有:草稿的字幕颜色可注入 ffmpeg 滤镜参数(`render --burn-captions`)、`compile` 规格的 `name` 可写到草稿存储之外、每次草稿写入使用的临时文件名可被预测,以及 `serve` 把凭据值回显到自己的输出中。两处注入都需要一个并非你本人创建的草稿文件夹或草稿文件,因此影响范围是本地而非远程。升级:`npm install -g capcut-cli@latest`。详见 [更新日志](./CHANGELOG.md)。 +在 CapCut 中打开成果,每条轨道依然可编辑。capcut-cli 直接操作本地草稿存储:JSON 进、JSON 出,没有上传、没有 API、没有 MCP 服务,也没有 HTTP 守护进程。 -> **免责声明:** 本项目为独立的、社区维护的项目,与 CapCut、剪映或字节跳动有限公司(ByteDance Ltd.)**无任何隶属、赞助或背书关系**。"CapCut" 与 "剪映" 为字节跳动有限公司的商标,所有产品名称、徽标与品牌均归各自所有者所有,此处仅用于标识(指称性使用)目的。 +`原始录音` → `静音感知剪辑 + 样式化字幕` → `可编辑的 CapCut / 剪映草稿` -**任何大模型 Agent 都能驱动的剪映 / CapCut 命令行 —— 零依赖、无服务、CapCut + 剪映共用一个二进制。** +[**▶ 观看一个带字幕的成片示例(60 秒)**](./media/two-sisters-vietnam-short.mp4) -JSON 进、JSON 出:每个命令都直接读写本地草稿存储,不用 MCP 服务或 HTTP 守护进程。新版 CapCut 会自动检测并同步每个可读的时间线目标,不再假设只有 `draft_content.json` 是真源。这给任何模型(Claude、DeepSeek、GLM、Kimi)一个确定性边界,用于查看、构建、字幕、字幕烧录、翻译与长视频切短。 +## 安装并打开你的第一个可编辑草稿 -**三种用法:** +**前置要求:** Node ≥ 18(仅用内置模块,无原生依赖)。可选工具解锁特定命令:Whisper 用于 `caption`,FFmpeg 用于 `render`,ffprobe 用于自动读取媒体元数据,`ANTHROPIC_API_KEY` 用于 `translate`。 -- **命令行(CLI)** —— `npm install -g capcut-cli`,然后 `capcut ` -- **库(Library)** —— `import { loadDraft, lintDraft, saveDraft } from "capcut-cli"`(带类型、零依赖) -- **队列执行器** —— `capcut serve` 从 stdin 读取 JSONL 任务,对接 [n8n / Make / Coze](./examples/serve-automation.md) +```bash +npm install -g capcut-cli +``` -> **v0.20.0 新增:** 字幕样式现在可以双向跨越草稿边界——`export-ass` 写出 `[V4+ Styles]`、逐区间覆盖标签与 `--karaoke` 逐词计时,`import-ass` 则保留内联的粗体/斜体/颜色/字号区间,不再压平为纯文本,并有往返测试兜底。原始录音的处理链路就此闭环:`detect-silence` 找出静音段(`--pad` 留出余量,不会把词切在半个音节上),`tts` 可通过任意本地 TTS 工具(piper、`say`、espeak-ng)把文稿直接配音到音频轨上。`render` 补完了 0.19.0 的快速失败工作——音频滤镜链与视频链一样接受预检([#91](https://github.com/renezander030/capcut-cli/issues/91))——并新增 `--encoder` 以启用硬件编码器。另有 `harvest-enums --sync`/`--add` 支持整库扫描与手工登记,`diagnose` 现在能为悬而未决的存储布局问题采集脱敏证据([#50](https://github.com/renezander030/capcut-cli/issues/50))。没有删除任何命令,现有参数含义均未改变。详见[更新日志](./CHANGELOG.md)。 +```bash +capcut doctor +capcut quickstart my-first --video clip.mp4 --srt captions.srt +capcut info ./my-first/ -H +``` -> **v0.19.1 新增:** 此前每一处多区间文字高亮都被写到了文本末尾之外。`styles[].range` 存的是 UTF-16 码元(code unit)而非 UTF-16LE 字节,因此 `text-ranges`、`caption --karaoke`、`--highlight-words` 以及任何带 `text_ranges` 的预设,写入的偏移量都是应有值的两倍——普通的 `add-text` 看起来正常,只是因为整段区间会被裁回文本末尾([#85](https://github.com/renezander030/capcut-cli/issues/85),由 [@hillimited](https://github.com/hillimited) 在 38 个由 App 创建的草稿上实测得出)。所有读写该偏移量的位置均已修正,`lint --fix` 可修复旧版本写出的草稿(`text-range-doubled`)。详见[更新日志](./CHANGELOG.md)。 +**结果:** 一个真实的本地项目,视频和字幕都在可编辑的轨道上 —— 不是压平后的导出文件。在 CapCut 或剪映中打开它,进行审阅、调整与渲染。发布这一下点击,始终留给人来完成。 -## 安装 +有用的话,[给 capcut-cli 加个 Star](https://github.com/renezander030/capcut-cli),帮助更多剪辑师和 Agent 开发者发现它。 -**前置要求:** Node ≥ 18(仅用内置模块,无原生依赖)。可选工具解锁特定命令:Whisper 用于 `caption`,FFmpeg 用于 `render`,ffprobe 用于自动读取媒体元数据,`ANTHROPIC_API_KEY` 用于 `translate`。 +也可以从源码构建:`git clone https://github.com/renezander030/capcut-cli && cd capcut-cli && npm install && npm run build`(然后用 `npm link` 暴露出 `capcut`)。或者不安装,直接运行任意命令:`npx capcut-cli `。 -```bash -npm install -g capcut-cli # 或:npx capcut-cli -``` +> [!IMPORTANT] +> **请先升级,不要继续使用旧版本。** 0.17.2 及更早版本生成的 fixture 包,可能带有稳定的设备标识符,必须视为未脱敏处理([#59](https://github.com/renezander030/capcut-cli/issues/59))。0.17.0 及更早版本还存在本地命令/过滤器注入路径,以及不安全的临时文件与凭据输出行为。这些问题分别已在 0.18.0 与 0.17.1 中修复。运行 `npm install -g capcut-cli@latest`,完整说明见[更新日志](./CHANGELOG.md)。 -从源码构建:`git clone https://github.com/renezander030/capcut-cli && cd capcut-cli && npm install && npm run build`(再 `npm link` 暴露 `capcut`)。 +> **免责声明:** 本项目为独立的、社区维护的项目,与 CapCut、剪映或字节跳动有限公司(ByteDance Ltd.)**无任何隶属、赞助或背书关系**。"CapCut" 与 "剪映" 为字节跳动有限公司的商标,所有产品名称、徽标与品牌均归各自所有者所有,此处仅用于标识(指称性使用)目的。 -## 快速上手 +**任何大模型 Agent 都能驱动的剪映 / CapCut 命令行 —— 零依赖、无服务、CapCut + 剪映共用一个二进制。** -```bash -capcut doctor # 检查 Node、FFmpeg、whisper、草稿目录 -capcut quickstart my-first --video clip.mp4 # 创建 + 加素材 + lint,并打印“在 CapCut 中打开”的步骤 -capcut info ./my-first/ # 查看草稿(加 -H 显示表格) -``` +JSON 进、JSON 出:每个命令都直接读写本地草稿存储,不用 MCP 服务或 HTTP 守护进程。新版 CapCut 会自动检测并同步每个可读的时间线目标,不再假设只有 `draft_content.json` 是真源。这给任何模型(Claude、DeepSeek、GLM、Kimi)一个确定性边界,用于查看、构建、字幕、字幕烧录、翻译与长视频切短。 + +**三种用法:** -然后在 CapCut 中打开项目审阅并渲染。所有短视频平台都禁止自动上传,所以最后的发布按钮由你来点。 +- **命令行(CLI)** —— `npm install -g capcut-cli`,然后 `capcut ` +- **库(Library)** —— `import { loadDraft, lintDraft, saveDraft } from "capcut-cli"`(带类型、零依赖) +- **队列执行器** —— `capcut serve` 从 stdin 读取 JSONL 任务,对接 [n8n / Make / Coze](./examples/serve-automation.md) -**剪映(国内版)用户:** 版本须知(6.0+ 加密)、草稿目录位置与 `--jianying` 命名空间,见 **[剪映快速上手](./docs/quickstart.zh-CN.md)**。 +## 发布说明 + +> **v0.21.0 新增:** issue [#50](https://github.com/renezander030/capcut-cli/issues/50) 中 CapCut Mac 9.2.8 嵌套 Timelines 布局的报告有了修复路径——`sync-timelines --nested` 以显式选择的方式把根时间线复制进 `Timelines//` 文档(每个文档保留自己的 GUID,即讨论串中验证过的解决办法),`fixture --check` 会在你附上诊断包之前机械化地检查是否残留家目录路径、邮箱或设备 ID。每次写入拒绝现在都会标明触发的守卫(`refused [editor-open]` / `[version-boundary]` / `[draft-changed-on-disk]`),粘贴的 stderr 不再有歧义。另有 `catalogue `(按名称在全部内置与采集目录中查 resource_id)、`lint --pip`(画中画+蒙版工作流校验,[#78](https://github.com/renezander030/capcut-cli/issues/78))、`import-srt --clone-style`(无需先查段 ID 即沿用草稿现有字幕样式)、`relink --stage`(修复后的草稿可随文件夹迁移)、只观察不写入的 `media-unregistered` 提示(pyCapCut#13),以及 version-support 与 jianying-encryption 的中文文档。没有删除任何命令,现有参数含义均未改变。详见[更新日志](./CHANGELOG.md)。 + +> **v0.20.0 新增:** 字幕样式现在可以双向跨越草稿边界——`export-ass` 写出 `[V4+ Styles]`、逐区间覆盖标签与 `--karaoke` 逐词计时,`import-ass` 则保留内联的粗体/斜体/颜色/字号区间,不再压平为纯文本,并有往返测试兜底。原始录音的处理链路就此闭环:`detect-silence` 找出静音段(`--pad` 留出余量,不会把词切在半个音节上),`tts` 可通过任意本地 TTS 工具(piper、`say`、espeak-ng)把文稿直接配音到音频轨上。`render` 补完了 0.19.0 的快速失败工作——音频滤镜链与视频链一样接受预检([#91](https://github.com/renezander030/capcut-cli/issues/91))——并新增 `--encoder` 以启用硬件编码器。另有 `harvest-enums --sync`/`--add` 支持整库扫描与手工登记,`diagnose` 现在能为悬而未决的存储布局问题采集脱敏证据([#50](https://github.com/renezander030/capcut-cli/issues/50))。没有删除任何命令,现有参数含义均未改变。详见[更新日志](./CHANGELOG.md)。 ## 常用命令 @@ -95,7 +102,7 @@ CapCut / 剪映把每个项目存为本地 JSON。capcut-cli 加载这个存储 - [docs/command-reference.zh-CN.md](./docs/command-reference.zh-CN.md) —— 每个命令与参数([英文原版](./docs/command-reference.md)) - [docs/quickstart.zh-CN.md](./docs/quickstart.zh-CN.md) —— 剪映快速上手:版本须知、草稿目录、`--jianying` 命名空间 - [examples/](./examples/) —— 端到端示例(配音对齐、serve 自动化、批量字幕修正) -- [docs/version-support.md](./docs/version-support.md) · [docs/jianying-encryption.md](./docs/jianying-encryption.md) +- [docs/version-support.zh-CN.md](./docs/version-support.zh-CN.md)([英文原版](./docs/version-support.md))· [docs/jianying-encryption.zh-CN.md](./docs/jianying-encryption.zh-CN.md)([英文原版](./docs/jianying-encryption.md)) - [CHANGELOG.md](./CHANGELOG.md) · [Releases](https://github.com/renezander030/capcut-cli/releases) —— 更新内容 - [draftcat](https://github.com/renezander030/draftcat) —— 姊妹项目:受治理的 AI 流水线(Go, MIT),同样单二进制、无需 API diff --git a/docs/jianying-encryption.zh-CN.md b/docs/jianying-encryption.zh-CN.md new file mode 100644 index 0000000..3d76f82 --- /dev/null +++ b/docs/jianying-encryption.zh-CN.md @@ -0,0 +1,52 @@ +# 决策:剪映 6.0+ 草稿加密 + +> English version: [jianying-encryption.md](./jianying-encryption.md) + +状态:**已决定 —— 只检测,不解密**(重新评估的条件见下文)。 +范围:剪映(JianYing)6.0+ 引入的 `draft_content.json` 加密。 +相关:`capcut decrypt`、`docs/version-support.md`、pyJianYingDraft #142/#169/#174。 + +## 背景 + +剪映是 CapCut 的中国大陆版本。从 6.0+ 开始,它把 `draft_content.json` 存成 AES 加密的二进制内容,而不是明文 JSON。中文 README 是本仓库访问量最高的几个页面之一,所以剪映这条线有真实的需求。相邻项目 `pyJianYingDraft` 体量更大,也持续收到「直接帮忙解密」这样的请求。CapCut 国际版**不**加密 —— 只有剪映加密。 + +本仓库要回答的问题是:`capcut-cli` 要不要内置一个解密程序? + +## 决定 + +不内置。CLI 只**检测**加密并说明应对方案,不做解密。这在 `src/decrypt.ts`(`detectEncryption`)中实现,并通过 `capcut decrypt ` 暴露给用户。 + +这是一个有条件的兼容性决定,不是永久拒绝。只有当下面列出的每一条绊线都被清除时,它才会改变。 + +## 为什么 + +- **法律姿态。** 发布一个唯一目的就是破解厂商加密的程序,和读取一份已有文档的明文格式,属于完全不同的风险等级。社区已知的算法都是逆向工程得来的,并未获得授权。 +- **算法一直在变。** 密钥/算法在剪映的多个小版本之间发生过变化(见 pyJianYingDraft #142/#169/#174)。解密器会是一个不断移动的维护目标,每次剪映更新都可能导致它失效。 +- **本仓库的差异化定位是「本地、确定性、可被 Agent 驱动」。** 一个脆弱、法律地位灰色的解密器会与这个定位相冲突。检测 + 清晰的应对方案,能让信任的边界保持干净。 + +## 我们现在的做法(为什么这对大多数用户已经够用) + +`capcut decrypt` 会报告具体情况,并按优先顺序指向: + +1. 把剪映固定在 5.9.x 并阻止自动更新(最后一个被广泛使用的明文版本)。 +2. 改用 CapCut 国际版 —— 不加密。 +3. 如果草稿已经被加密,可以参考社区资料(pyJianYingDraft #142、duoec/duo-video)—— 这明确不在本工具的范围之内。 + +检测方法:如果一个 `draft_content.json` 不以 `{` 开头,也无法解析为 JSON,就会被判定为剪映 6.0+ 加密的情况;如果文件以 `{` 开头但解析失败,则会被判定为*损坏*,而不是加密,这样两种失败情况就不会被混为一谈。 + +## 收集 fixture(在不解密的前提下积累证据) + +不用碰解密,我们依然可以推进剪映这条线: + +- `capcut fixture --out ` 会生成一份脱敏、不含媒体文件的时间线文件包。对于一份*已加密*的草稿,这能捕获其信封/标记信息(大小、开头字节,通过 `diagnose` 获取),这样就能用真实样本而不是猜测来改进检测逻辑。 +- 明文/导出版的剪映变体(5.9.x,或 6.0+ 的「导出项目」)可以作为已提交的 fixture 加入进来,并接受正常命令套件的检验。 + +## 重新评估是否内置解密的绊线 + +只有当以下条件**全部**成立时才重新评估: + +1. 存在一个稳定的、有文档记录的算法,并且在至少连续两个剪映版本上都成立(而不是只针对单一版本的逆向工程)。 +2. 法律地位足够清晰,可以在 README 里毫不含糊地写明。 +3. 有维护者愿意承诺持续跟踪剪映的版本更新 —— 因为一个悄悄失效的解密器,比诚实地说「不支持」更糟糕。 + +在那之前:只检测、说明情况、收集 fixture。不解密。 diff --git a/docs/version-support.zh-CN.md b/docs/version-support.zh-CN.md new file mode 100644 index 0000000..1d8a333 --- /dev/null +++ b/docs/version-support.zh-CN.md @@ -0,0 +1,119 @@ +# 版本支持矩阵 + +> English version: [version-support.md](./version-support.md) + +CapCut 和剪映在不断演进一套没有文档记录的本地磁盘 schema。本矩阵刻意把有 fixture 实证支持的内容,与仅是预期兼容的内容分开列出。 + +运行 `capcut version ` 查看 schema 标志;运行 `capcut diagnose -H` 查看规范文件选择、时间线分歧与编辑器进程安全性。`capcut diagnose --bundle support.json` 会生成一份适合附到 issue 里的脱敏报告。 + +## 证据等级 + +- **fixture-tested** —— 由已提交的 fixture、经自动化测试验证过。 +- **synthetic-tested** —— 用一个最小化的版本/系统组合验证了某个已观察到的存储或 schema 行为;仍需要一份真实的、由应用创建的包。 +- **reported** —— 行为来自可复现的用户报告,但尚未有脱敏后的真实 fixture 佐证。 +- **expected-compatible** —— 根据 schema 检查推测兼容;不代表已在桌面应用中实测。 +- **known-broken** —— CLI 能检测到该不兼容情况,并给出应对方案或拒绝执行。 + +## CapCut(`platform.app_source == "cc"`) + +| 版本 | 证据 | 状态 | 说明 | +|---|---|---|---| +| 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 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 —— 见下方「写入时版本护栏」一节。 | + +不存在笼统的「6.x–9.x 已测试」这种说法。只有已提交 fixture 的版本才能标注这个标签。`capcut version` 的注册表与本表一致:6.2.8 报告为 `fixture-tested`,8.7.0 为 `synthetic-tested`,而 6.5.0/7.0.0/8.0.0/9.0.0 报告为 `untested` + `expected-compatible`,而非一个「已测试」的结论。 + +## 剪映(`platform.app_source == "lv"`) + +| 版本 | 证据 | 状态 | 说明 | +|---|---|---|---| +| 5.9.x | community-reported | expected-compatible | 最后一个被广泛使用的明文版本;目前没有已提交的、由应用创建的脱敏 fixture。 | +| 6.0+ | reported | known-broken for encrypted files | `capcut decrypt` 能检测到加密并说明应对方案;它本身不解密文件。明文/导出变体依然可以正常查看,但写入护栏会拒绝对明文变体的任何修改性写入,除非加 `--force-write` —— 因为 6.0+ 的应用已进入加密草稿时代,写回的明文可能被忽略或提示已损坏。 | + +## v0.11 存储与写入安全 + +`capcut-cli` 会检查项目目录中的以下文件: + +1. `draft_content.json` +2. `draft_info.json` +3. `draft_meta_info.json` +4. `template-2.tmp` + +它能识别位于根层级、或嵌套在浅层 object/字符串 JSON 信封中的时间线。对于 CapCut 8.7+,可读的 `template-2.tmp` / `draft_meta_info.json` 时间线优先;更早的版本仍保留 content/info 优先的顺序。每一个可读的时间线目标都会在一次原子保存中被同步。 + +写入使用同目录下的临时文件、fsync 与重命名。提交前,如果目标自加载以来发生了变化,CLI 会拒绝写入。检测到剪映 / CapCut 桌面端编辑器正在运行时,受管理的草稿路径同样会受到保护。`--force-write` 是显式的覆盖开关,不是默认的恢复路径。 + +## 写入时版本护栏 + +每一个修改性命令(`saveDraft` 路径,以及会直接写入镜像的 `sync-timelines --apply`)在写入前都会评估草稿的版本标记。有效版本取 `platform.app_version`、`last_modified_platform.app_version`,与最新的可读同级文件三者中的数值最大值,因此即便是由更新的应用构建写出的镜像,也一样会触发护栏。按顺序匹配第一条命中的规则;标记缺失或无法解析时,对应规则永远不会触发: + +| 条件 | 动作 | +|---|---| +| 剪映有效版本 >= 6.0(加密草稿时代) | 拒绝 | +| CapCut 有效版本超出已知范围(> 9.x) | 拒绝 | +| 顶层 `version` schema 整数 > 360000 | 拒绝 | +| 携带版本标记的未识别 `app_source`——或完全没有 `app_source`,但通过 `last_modified_platform` 或某个同级文件得到了有效应用版本 | 警告后写入 | +| 顶层 `version` schema 整数早于 360000 | 警告后写入 | +| 其余所有情况——包括没有任何标记的、由 CLI 创建的草稿 | 正常写入 | + +360000 这个 schema 整数边界,是在一份脱敏参考语料库中,所有已知真实 CapCut 8.x fixture 里观察到的共同常量。证据等级:**reported**——这些 fixture 并未提交在本仓库中,因此更大的数值只说明「这是一个本仓库尚无证据的世代」,而不是一个已验证的不兼容结论。 + +`--force-write` 可以覆盖拒绝,但 WARNING 仍会打印到 stderr,所以强制写入绝不会是无声的。`--dry-run` 永远不会被拦截(因为它本来就不写入任何内容),并且同样会打印 WARNING。拒绝信息的结尾都会附上收集 fixture 的行动号召:如果项目在你的应用里能正常打开,`capcut fixture --out ` 可以构建一个脱敏包,从而把这个版本推进到 fixture-tested。`restore` 与所有只读命令永远不会被拦截——恢复备份是逃生通道,不是风险点。护栏不会凭空发明版本标记:`capcut create` 的输出始终不带标记,永远不会被打上 `platform` 或 `version` 字段。 + +## 应用自动升级绊线 + +上面的护栏回答的是「这个版本是否超出了已有证据」。还有一种更早发生的失败模式:应用自我更新,重写了它打开的草稿,而在写入行为开始变得不一样之前,流水线里的任何环节都不会提示这一点(GuanYixuan/pyJianYingDraft#115、#178)。针对这一点,CLI 会为每个草稿存储记住它上一次看到的版本证据——与护栏检测的是同一组有效元组(有效应用版本、app source、顶层 schema 整数)——并在每次修改性写入时进行比较: + +- 状态保存在 CLI 自己的配置目录里:`~/.config/capcut-cli/app-versions.json`(遵循 `XDG_CONFIG_HOME`,`CAPCUT_CLI_APP_VERSIONS` 可覆盖路径)。不会往草稿里写入任何内容;写出的草稿始终保持字节级不变。 +- 第一次看到某个存储时会静默记录。之后一旦证据出现差异,修改性命令会在 stderr 打印一条 `WARNING`,指出旧值 -> 新值(例如 `app version 8.7.0 -> 10.5.0`),其 JSON 结果也会带上 `app_version_drift` 字段(`store_dir`、带 `seen_at` 的 `from`、`to`、`changes`),随后更新记录。 +- **只警告——绊线本身从不拒绝写入。** 拒绝逻辑始终由写入时版本护栏负责:在受支持范围内的漂移(比如 6.2.8 -> 8.7.0)只警告并写入;超出范围的漂移会警告,*并且*护栏会像之前一样拒绝写入。 +- `capcut version ` 会只读地报告 `app_version_drift`(它从不更新记录,所以这个漂移会一直可见,直到下一次修改性写入确认它为止)。`capcut doctor` 会重新检查每一个被跟踪的存储,并把漂移作为 warn 级别的 `app-upgrade` 检查项报告出来。 +- 状态文件损坏时会被当作空文件读取并打印 `WARNING`,下一次修改性写入会重建它——这与 `user-enums.json` 目录所遵循的健壮性规则相同。没有任何标记的、由 CLI 创建的草稿永远不会被跟踪。 + +## 固定应用版本 + +绊线能告诉你发生了一次升级,但无法阻止升级发生。无论 CapCut 还是剪映,都没有文档说明过一种受支持的、永久性的退出应用更新的方法,所以本节刻意只给出不依赖具体应用构建版本的保守措施。剪映那条注册表备注(「自动更新会破坏固定」)说的正是这个问题:一台固定在 5.9.x 的安装一旦自我更新,就会进入加密草稿时代,明文工具链也就不再能正常往返读写。 + +在两个系统上都成立的做法: + +- **保留你验证过的那个版本的安装包。** 一旦厂商推出新版本,官方渠道就很难再获取旧安装包了;把你测试过的确切构建版本归档,是唯一能挺过一切变化的固定方式。 +- **在更新后第一次启动应用之前,先备份草稿存储。** 更新后的应用在打开草稿时可能会就地迁移它;一旦迁移完成,旧版工具可能就无法再正常往返读写这个文件了。趁应用关闭时,把整个 `com.lveditor.draft` 文件夹复制一份——就是 `doctor` 检查的那些文件夹。 +- **让绊线和护栏尽早看到写入。** 在任何疑似更新之后运行 `capcut version ` / `capcut doctor`,并把流水线写入时出现的漂移 WARNING 当作停下来、先验证再批量操作的信号。 +- **让批量流水线操作存储的副本**,而不是正在使用的那一份,这样即便应用在运行过程中升级了,也不会有东西可供它在你眼皮底下迁移。 + +Windows: + +- 草稿存储位于 `%LOCALAPPDATA%\CapCut\User Data\Projects\com.lveditor.draft`(剪映:`%LOCALAPPDATA%\JianyingPro\...`);流水线依赖的是这个文件夹本身,而不是应用安装目录——这也是在放任一次更新触碰它之前,应该快照的对象。 +- 应用会自行管理更新,我们不知道有任何文档记录过的、能永久禁用更新的设置。社区讨论中有人建议对更新程序设置防火墙规则——这种做法不受官方支持、与具体构建版本强相关,还可能导致登录或特效下载失败,因此本文档不推荐任何具体规则。 + +macOS: + +- 草稿存储位于 `~/Movies/CapCut/User Data/Projects/com.lveditor.draft`(剪映:`~/Movies/JianyingPro/...`)。 +- 如果应用是从 **Mac App Store** 安装的,更新会遵循 App Store 自身的自动更新设置(App Store → 设置 → 自动更新)。关闭它可以防止无人值守的升级——之后只有你主动选择安装时才会更新。这是标准的 App Store 行为,不是 CapCut 的特有功能。 +- 如果应用是直接从厂商下载的,它会像 Windows 版一样自行管理更新,同样适用上面的保守建议:归档安装包、备份存储、写入前用 `capcut version` 验证。 + +## Schema 特性检测 + +`capcut version` 会报告: + +| 标志 | 含义 | +|---|---| +| `mask_field` | 旧版 `mask`、新版 `common_masks`,两者都有,或都没有。 | +| `has_text_ranges` | 至少有一个文本素材包含多样式区间。 | +| `has_audio_fades` | 存在 `materials.audio_fades[]`。 | +| `new_version_field` | 顶层 `new_version` 字段(如果存在)。 | +| `last_modified_platform` | 跨平台修改标记(如果存在)。 | + +## 报告一个损坏的版本 + +1. 关闭 CapCut/剪映。 +2. 运行 `capcut diagnose --bundle support.json`。 +3. 运行 `capcut version `。 +4. 提交 issue,附上应用版本、操作系统、具体命令、JSON 报错信息,以及 `support.json`。 +5. 如果可以,附上一份脱敏后的项目文件夹。运行 `capcut fixture --out ` 可以自动生成一份:它只拷贝时间线 JSON(不含媒体文件),会脱敏用户主目录路径和邮箱地址,并附带一份 README 和一份 diagnose 报告。分享前请先自行检查这些文件。 + +只有在脱敏后的 fixture 与回归测试都被提交之后,一个版本才会被标注为 **fixture-tested**。 From 23dddeeb905f212dffe3540e3f861bccbe5a52e5 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 11:50:50 +0000 Subject: [PATCH 10/11] =?UTF-8?q?release:=20v0.21.0=20=E2=80=94=20nine=20o?= =?UTF-8?q?pportunity-mined=20items=20(the=20#50=20repair=20path,=20portab?= =?UTF-8?q?ility,=20discovery)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 +- package-lock.json | 4 +- package.json | 2 +- 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 660cf5a..22ce1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,111 @@ All notable changes to capcut-cli are documented here. The format follows [Keep ## [Unreleased] +## [0.21.0] — 2026-08-28 + +One thread runs through most of this release: the issue-[#50](https://github.com/renezander030/capcut-cli/issues/50) +cluster finally got movement. The CapCut Mac 9.2.8 report confirmed the nested +`Timelines//` documents as the file the app actually reads on that build, +with a hand-verified workaround — this release mechanizes that workaround as an +explicit opt-in repair, makes the fixture bundle that issue is still waiting on +mechanically checkable for private data, and tags every write refusal with a +stable gate id so the next pasted stderr line cannot be misread. Around it: +ecosystem-mined portability and discovery work (`catalogue`, `relink --stage`, +the `media-unregistered` note), the PIP + local-mask validation issue +[#78](https://github.com/renezander030/capcut-cli/issues/78) asked for, id-free +caption styling on import, and Chinese docs parity. No command was removed. The +only existing-output changes are additive JSON fields, the `refused [gate-id]:` +prefix on refusal messages, and `sync-timelines`' applied summary naming the +actual canonical file instead of hardcoding `draft_content.json`. + +### Added + +- **`sync-timelines --nested` — the issue [#50](https://github.com/renezander030/capcut-cli/issues/50) repair, as an explicit opt-in** — on + CapCut International for Mac 9.2.8 the nested `Timelines//draft_info.json` is authoritative and + root-file writes are silently discarded; the reporter's hand-verified fix was copying the CLI-written root file over + the nested documents, keeping the timeline id. `--nested` does exactly that: the plan additionally covers every + `Timelines//` timeline document plus its same-directory `template-2.tmp`, in the same canonical → mirror + direction, behind the same newer-mirror refusal (`--force-write` to override), with each nested document keeping its + own GUID and comparing by id-normalized timeline hash. `Timelines/project.json` — the pointer that names the active + timeline — is never touched, and nothing changes without the flag: a default run that sees nested documents says so + and points at the opt-in instead of reporting "in sync" about files it never read. PR #51's canonical-read flip + stays rejected pending a field artifact; this changes which files an explicit repair can *write*, never which file + any command *reads*. Fork-proven demand: the most-diverged community fork shipped its own `sync-timelines` repair. +- **`lint --pip` — validation for the PIP + local-mask workflow ([#78](https://github.com/renezander030/capcut-cli/issues/78))** — the discussion-#43 build + (duplicate a clip onto an upper layer, mask the copy) has four ways to be silently wrong: the overlay never landed, + the mask never got attached, the keyframes did not write, the copy points at missing media. `--pip` reports the + counts (`overlays`, `overlay_keyframes`, `masks_attached`, `masks_orphaned`, `missing_media` — by path, not just a + count) in the JSON and `-H` outputs, and raises a `mask-orphaned` warning per never-attached mask so the exit code + fails CI exactly as the issue's acceptance criteria ask. Gated behind the flag deliberately: an ordinary draft + carrying an unreferenced mask is not necessarily damaged (the [#88](https://github.com/renezander030/capcut-cli/issues/88) lesson), but in a pipeline that just tried + to attach one it is precisely the failure being looked for. +- **`catalogue ` — name → resource_id in one call** — the ecosystem's most repeated resource pain is hand- + extracting effect ids from the app when a display name is all you have (pyJianYingDraft#174 has no extraction path + for encrypted-era resources; pyCapCut#12's static tables miss newer ids). `catalogue` searches every bundled table, + the filters/bubbles starter catalogues, and the `harvest-enums` user catalogue at once — exact matches first, then + prefix, then substring, `--kind ` to narrow, `--limit` to cap, and each row labelled `bundled` or `user`. + Pasting a resource id answers the reverse question ("what is this id?") — ids match exactly, never fuzzily. +- **`import-srt` / `import-ass` `--clone-style` — keep the draft's caption look without hunting a segment id** — + `--style-ref ` already copied styling from an existing segment, but agents and one-liners had to query `texts` + first to find the id. `--clone-style` resolves it: the newest text segment on the target track (any text track as + fallback), then rides the `--style-ref` machinery unchanged. An explicit `--style-ref` wins; a draft with no text + segment fails fast with guidance instead of importing unstyled cues. +- **`relink --stage` — portable repair** — `relink` rewrote paths but left the draft depending on files outside its + folder, which is exactly what black-screens a draft the moment the folder moves machines + (pyJianYingDraft#177's Mac-sandbox "content corrupted" case). With `--stage`, each video/audio file this run + relinks is copied into `assets//` via the same content-hash-deduplicating path `add-video`/`add-audio` use, + and the material points at the copy. Only files the run actually relinked are staged; `--dry-run` skips the copy — + a file copy is a side effect no draft write can roll back. +- **`lint` `media-unregistered` — observe-only sidecar note (pyCapCut#13)** — CapCut International 9.1.0 on macOS is + reported to show timeline media as "file inaccessible" and demand per-clip relinking when `draft_meta_info.json`'s + `draft_materials` registers nothing, even with valid paths in the timeline. The registration *write* stays + deliberately out of scope — no real entry shape has been captured yet — so lint now names the hazard where CI will + actually see it: info severity (never fails an exit code), only when the sidecar exists, provably registers + nothing, and the referenced media is actually present on disk, with the `capcut fixture` ask attached — the one + artifact the write could be built from. +- **`fixture --check` — mechanical redaction verification** — the bundle README has always ended with "review the + files yourself", and that burden is measurably what stalls contributions: the 9.2.8 reporter in + [#50](https://github.com/renezander030/capcut-cli/issues/50) held the bundle back until confident nothing private + leaked. `--check` scans every text file in the finished bundle — `SANITIZE_REPORT.json` and the README included, + the [#59](https://github.com/renezander030/capcut-cli/issues/59) lesson — for residual home-path shapes, emails + (the redactor's `redacted@example.com` placeholder excepted), unredacted `device_id`/`mac_address`/`hard_disk_id` + values, and the machine's account name, reporting `file:line` and `kind` per finding (never the leaked value + itself) and exiting non-zero on any. `capcut fixture --check` re-checks an existing bundle without + rebuilding. +- **Chinese docs parity** — `README.zh-CN.md` caught up with the English README's restructure (#97), and + `docs/version-support.zh-CN.md` + `docs/jianying-encryption.zh-CN.md` now exist alongside the quickstart and + command-reference translations — the version-support and encryption stories were previously invisible to the + project's largest user segment. + +### Changed + +- **Write refusals name their gate** — a refusal now reads `refused [editor-open]: …`, `refused [version-boundary]: …`, + `refused [draft-changed-on-disk]: …` (and `sync-timelines --apply`'s `refused [mirror-newer]: …`). Motivated by a + [#50](https://github.com/renezander030/capcut-cli/issues/50) side report where "CapCut is running" was quoted with + no CapCut process alive: two of the three write gates mention `--force-write`, so a paraphrased report could not + identify which gate fired. The tag is stable and greppable; the message text after it is unchanged. +- **`sync-timelines --apply`'s summary names the plan's canonical** — the applied line hardcoded + `Reconciled from draft_content.json:` even on the draft_info-primary layout, where the canonical is + `draft_info.json`. It now prints the file the repair actually read from. + +### Fixed + +- **The pre-commit test gate could pass on a red suite** — `npm test --silent 2>&1 | tail -20` reports the *pipeline's* + exit status, which is `tail`'s, so `set -e` never saw a test failure and a failing suite could commit silently + (plain `sh` has no `pipefail`). The hook now captures the run to a file and propagates the real status, printing the + last 40 lines on failure. +- **`action.yml` passed composite-action inputs through shell interpolation** — inputs are now passed via `env` + ([#94](https://github.com/renezander030/capcut-cli/issues/94)); shipped on master since 2026-08-23, first release + here. +- **9.x stores got nested-Timelines evidence with no explanation** — `diagnose` attached the redacted + `nested_evidence` block on ≥ 8.7 storage and `fixture` bundled the nested documents, but the human-readable + next-action and the `version` note were gated on the layout value, which stops at 8.7 by design — so a 9.x user got + a report carrying `Timelines/` evidence with no line of prose saying why it was collected + ([#95](https://github.com/renezander030/capcut-cli/issues/95), + [#96](https://github.com/renezander030/capcut-cli/pull/96)); shipped on master since 2026-08-23, first release + here. The note asserts neither the 7.x discard finding nor the 8.5.0 survival finding — both predate the 8.7 + storage change. + ## [0.20.0] — 2026-08-21 Two threads run through this release. Subtitles now carry their styling across diff --git a/README.md b/README.md index 06f1403..b361031 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,9 @@ JSON in, JSON out: every command reads and writes the local draft store directly ## Release notes -> **New in v0.20.0:** subtitles now carry their styling across the draft boundary in both directions — `export-ass` writes `[V4+ Styles]`, per-range override tags and `--karaoke` word timing, and `import-ass` keeps inline bold/italic/colour/size spans instead of flattening them to plain text, pinned by a round-trip test. The raw-recording pipeline closes: `detect-silence` finds dead air (with a `--pad` so no word gets clipped mid-syllable) and `tts` voices a script through any local TTS tool (piper, `say`, espeak-ng) straight onto an audio track. `render` finishes 0.19.0's fail-fast work — the audio filter chain is probed like the video chain ([#91](https://github.com/renezander030/capcut-cli/issues/91)) — and `--encoder` unlocks hardware encoders. Plus `harvest-enums --sync`/`--add` for the whole library, and `diagnose` now captures sanitized evidence for the open store-layout questions ([#50](https://github.com/renezander030/capcut-cli/issues/50)). No command was removed and no existing flag changed meaning. Full details in the [changelog](./CHANGELOG.md). +> **New in v0.21.0:** the CapCut Mac 9.2.8 nested-Timelines report ([#50](https://github.com/renezander030/capcut-cli/issues/50)) gets its repair path — `sync-timelines --nested` copies the root timeline into the `Timelines//` documents as an explicit opt-in, each document keeping its own GUID (the workaround verified in the thread), and `fixture --check` mechanically verifies a bundle leaks no home path, email or device id before you attach it. Every write refusal now names its gate (`refused [editor-open]` / `[version-boundary]` / `[draft-changed-on-disk]`), so a pasted stderr line is unambiguous. Plus `catalogue ` — name → resource_id across every bundled and harvested table, `lint --pip` for the PIP + local-mask workflow ([#78](https://github.com/renezander030/capcut-cli/issues/78)), `import-srt --clone-style` (keep the draft's caption look without hunting a segment id), `relink --stage` (the repaired draft leaves portable), an observe-only `media-unregistered` note (pyCapCut#13), and Chinese docs for version support and encryption. No command was removed and no existing flag changed meaning. Full details in the [changelog](./CHANGELOG.md). -> **New in v0.19.1:** every multi-range text highlight was being written past the end of the text it styled. `styles[].range` holds UTF-16 code units, not UTF-16LE bytes, so `text-ranges`, `caption --karaoke`, `--highlight-words` and any preset carrying `text_ranges` stored offsets twice as large as they should be — a plain `add-text` looked fine only because a full-span range clamps back to the end of the text ([#85](https://github.com/renezander030/capcut-cli/issues/85), measured by [@hillimited](https://github.com/hillimited) across 38 app-authored drafts). Fixed everywhere those offsets are read or written, and `lint --fix` repairs drafts written by earlier versions (`text-range-doubled`). Full details in the [changelog](./CHANGELOG.md). +> **New in v0.20.0:** subtitles now carry their styling across the draft boundary in both directions — `export-ass` writes `[V4+ Styles]`, per-range override tags and `--karaoke` word timing, and `import-ass` keeps inline bold/italic/colour/size spans instead of flattening them to plain text, pinned by a round-trip test. The raw-recording pipeline closes: `detect-silence` finds dead air (with a `--pad` so no word gets clipped mid-syllable) and `tts` voices a script through any local TTS tool (piper, `say`, espeak-ng) straight onto an audio track. `render` finishes 0.19.0's fail-fast work — the audio filter chain is probed like the video chain ([#91](https://github.com/renezander030/capcut-cli/issues/91)) — and `--encoder` unlocks hardware encoders. Plus `harvest-enums --sync`/`--add` for the whole library, and `diagnose` now captures sanitized evidence for the open store-layout questions ([#50](https://github.com/renezander030/capcut-cli/issues/50)). No command was removed and no existing flag changed meaning. Full details in the [changelog](./CHANGELOG.md). ## Commands diff --git a/package-lock.json b/package-lock.json index 4b151c8..d57d3cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "capcut-cli", - "version": "0.20.0", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "capcut-cli", - "version": "0.20.0", + "version": "0.21.0", "license": "MIT", "bin": { "capcut": "dist/index.js", diff --git a/package.json b/package.json index 7c71a64..84391bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "capcut-cli", - "version": "0.20.0", + "version": "0.21.0", "description": "Independent, unofficial CLI to create and edit CapCut projects — build drafts from scratch, add video/audio/text, subtitles, timing, speed, volume, templates, cut long-form to shorts. No API needed. Not affiliated with ByteDance.", "type": "module", "bin": { From ec49e10fab02d7a4341b65293baf645ea7270489 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 28 Aug 2026 11:57:20 +0000 Subject: [PATCH 11/11] fix(fixture): --check works on home-dir projects Two composed bugs, found by smoking a bundle built under /home instead of /tmp: the verify pass scanned report.out_dir, which is itself redacted (/home/USER/...) and so not a filesystem path; and the home-path scan flagged the redactor's own USER placeholder, which every correctly redacted home-dir bundle (and every Windows/macOS temp dir) contains. The check now scans the real --out path and captures the account segment so exactly the USER placeholder passes. --- src/fixture.ts | 12 +++++++++--- src/index.ts | 5 ++++- test/fixture-check.test.mjs | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/fixture.ts b/src/fixture.ts index aaa20ca..24dd080 100644 --- a/src/fixture.ts +++ b/src/fixture.ts @@ -716,8 +716,13 @@ export interface RedactionCheck { } const CHECK_ALLOWED_EMAIL = "redacted@example.com"; -const CHECK_HOME_PATH = - /(?:\/Users\/[A-Za-z0-9._-]{2,}|\/home\/[A-Za-z0-9._-]{2,}|[A-Za-z]:\\+Users\\+[A-Za-z0-9._-]{2,})/; +// The account segment is captured so the redactor's own placeholder can pass: +// redaction rewrites /home/ to /home/USER (same for /Users and +// 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_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 // "redacted" marker. Numeric tallies in SANITIZE_REPORT.json ("device_id": 2) @@ -771,7 +776,8 @@ export function verifyBundleRedaction(bundleDir: string): RedactionCheck { const lines = text.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; - if (CHECK_HOME_PATH.test(line)) { + const home = line.match(CHECK_HOME_PATH); + if (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 510c772..c5b85c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5286,7 +5286,10 @@ async function main(): Promise { } if (!flags.out) die("Missing --out . Usage: capcut fixture --out [--check]"); const report = sanitizeDraftBundle(projectPath, flags.out); - const check = flags.check ? verifyBundleRedaction(report.out_dir) : null; + // 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); if (!flags.quiet) { const total = Object.values(report.redaction_kinds).reduce((a, b) => a + b, 0); diff --git a/test/fixture-check.test.mjs b/test/fixture-check.test.mjs index f0cebd9..aa6b98c 100644 --- a/test/fixture-check.test.mjs +++ b/test/fixture-check.test.mjs @@ -70,4 +70,24 @@ describe("fixture --check (redaction verification)", () => { assert.ok(kinds.includes("device-key")); assert.ok(!r.json.findings.some((f) => f.file === "clean.json"), "redacted placeholders must not be flagged"); }); + + it("allows the redactor's own USER home-path placeholder but flags a real account name", () => { + const p = project(); + after(p.cleanup); + const out = join(p.dir, "bundle"); + assert.equal(spawnCli(["fixture", p.dir, "--out", out]).status, 0); + // What a correctly redacted report looks like when the project lives under + // a home directory (and what every Windows/macOS temp dir produces). + writeFileSync( + join(out, "placeholder.json"), + JSON.stringify({ a: "/home/USER/drafts/p", b: "/Users/USER/x", c: "C:\\Users\\USER\\y" }), + ); + const clean = spawnCli(["fixture", out, "--check"]); + assert.equal(clean.status, 0, `placeholder paths are the redactor working — stderr: ${clean.stderr}`); + + writeFileSync(join(out, "real.json"), JSON.stringify({ a: "/home/hansmustermann/drafts/p" })); + const dirty = spawnCli(["fixture", out, "--check"]); + assert.equal(dirty.status, 1); + assert.ok(dirty.json.findings.some((f) => f.file === "real.json" && f.kind === "home-path")); + }); });