Skip to content
22 changes: 4 additions & 18 deletions src/app-versions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { join, resolve } from "node:path";
import { writeFileAtomic } from "./atomic-write.js";
import { stripBom } from "./bom.js";
import type { Draft } from "./draft.js";
import { discoverDraftStore } from "./store.js";
Expand Down Expand Up @@ -142,21 +143,6 @@ export function assessAppVersionDrift(
return { drift: driftBetween(storeDir, stores[storeDir], evidence), error };
}

// Same atomic write the user-enums catalogue uses: temp + fsync + rename,
// 0600, parent dir created on demand.
function writeAtomicLocal(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
const temp = `${path}.capcut-cli-${process.pid}-${Date.now()}.tmp`;
const fd = openSync(temp, "w", 0o600);
try {
writeSync(fd, content, undefined, "utf-8");
fsyncSync(fd);
} finally {
closeSync(fd);
}
renameSync(temp, path);
}

// Drift found by the most recent mutating write, for the CLI layer to stamp
// into the command's JSON result (the isDryRun/out pattern). Consumed once.
let pendingDrift: AppVersionDrift | null = null;
Expand Down Expand Up @@ -192,7 +178,7 @@ export function trackAppVersion(
stores: { ...stores, [storeDir]: { ...evidence, seen_at: new Date().toISOString() } },
};
try {
writeAtomicLocal(statePath, `${JSON.stringify(file, null, 2)}\n`);
writeFileAtomic(statePath, `${JSON.stringify(file, null, 2)}\n`);
} catch (writeError) {
const message = writeError instanceof Error ? writeError.message : String(writeError);
return { drift, error: error ?? `app-version state could not be written: ${message} (${statePath})` };
Expand Down
51 changes: 51 additions & 0 deletions src/atomic-write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { randomBytes } from "node:crypto";
import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, writeSync } from "node:fs";
import { dirname } from "node:path";

function writeAndSync(path: string, content: string): void {
// "wx" is O_CREAT|O_EXCL: it creates the file or fails, and never follows an
// existing symlink. See writeTemp for why that matters.
const fd = openSync(path, "wx", 0o600);
try {
writeSync(fd, content, undefined, "utf-8");
fsyncSync(fd);
} finally {
closeSync(fd);
}
}

/**
* Create the scratch file a temp+fsync+rename write stages into, next to its
* target, and return its path.
*
* The name carries crypto randomness and the open is exclusive. A predictable
* name (pid + clock alone) is a write-through primitive: anyone able to create
* files in the draft folder can pre-place a symlink at the path we are about
* to open and redirect every draft save to a file of their choosing. O_EXCL is
* the guarantee — it refuses a path that already exists, symlink included —
* and the randomness keeps a legitimate concurrent writer from colliding with
* us in the first place. A collision just draws another name; the bytes
* written are unchanged.
*/
export function writeTemp(target: string, content: string): string {
for (let attempt = 0; attempt < 8; attempt++) {
const temp = `${target}.capcut-cli-${process.pid}-${Date.now()}-${randomBytes(8).toString("hex")}.tmp`;
try {
writeAndSync(temp, content);
return temp;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
}
throw new Error(`Could not create a temp file next to ${target} after 8 attempts.`);
}

/**
* Atomically replace (or create) a small state file outside a draft: the user
* enums catalogue and the app-version tracker. Same temp+fsync+rename as a
* draft save, plus the parent directory created on demand.
*/
export function writeFileAtomic(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
renameSync(writeTemp(path, content), path);
}
28 changes: 28 additions & 0 deletions src/bom.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { existsSync, readFileSync } from "node:fs";

/**
* Lenient text reads: strip a leading UTF-8 BOM (U+FEFF). Windows PowerShell's
* `Set-Content` (and some editors) prepend one to text files; `JSON.parse`
Expand All @@ -9,3 +11,29 @@
export function stripBom(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}

/** A text file BOM-stripped, or null when it does not exist; other read errors propagate. */
export function readTextIfPresent(path: string): string | null {
return existsSync(path) ? stripBom(readFileSync(path, "utf-8")) : null;
}

/** `raw` parsed as JSON when it is a plain object; null for an array, a scalar or a parse error. */
export function parseJsonObject(raw: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}

/**
* A JSON metadata file as BOM-stripped text (`raw`, null when the file is
* absent) and as a plain object (`obj`, null when absent or not an object).
* Callers tell "missing" from "corrupt" by which of the two is null; read
* errors other than absence propagate.
*/
export function readJsonObjectFile(path: string): { raw: string | null; obj: Record<string, unknown> | null } {
const raw = readTextIfPresent(path);
return { raw, obj: raw === null ? null : parseJsonObject(raw) };
}
4 changes: 2 additions & 2 deletions src/caption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { Draft, Segment, Track } from "./draft.js";
import { findSegment } from "./draft.js";
import { captionStyleFromPreset, type TextStylePreset } from "./preset.js";
import { type CaptionScript, captionScript, groupingDefaults, wordSeparator } from "./script.js";
import { requireSegment } from "./segment-lookup.js";
import { parseSrt } from "./srt.js";
import { storedTextLength } from "./text-offsets.js";

Expand Down Expand Up @@ -430,8 +431,7 @@ function resolveAudio(draft: Draft, opts: CaptionOptions): string {
return opts.audio;
}
if (opts.fromSegment) {
const found = findSegment(draft, opts.fromSegment);
if (!found) throw new Error(`Segment not found: ${opts.fromSegment}`);
const found = requireSegment(draft, opts.fromSegment);
if (found.track.type !== "audio")
throw new Error(`--from-segment must be on an audio track (got ${found.track.type})`);
const mat = draft.materials.audios.find((m) => m.id === found.segment.material_id);
Expand Down
8 changes: 3 additions & 5 deletions src/chroma.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID as uuid } from "node:crypto";
import type { Draft, Segment } from "./draft.js";
import { findSegment } from "./draft.js";
import { requireSegment } from "./segment-lookup.js";

export interface ChromaOptions {
color: string; // hex like "#00FF00"
Expand All @@ -13,8 +13,7 @@ export function setChroma(
segId: string,
opts: ChromaOptions,
): { ok: true; segmentId: string; materialId: string; color: string; intensity: number; shadow: number } {
const found = findSegment(draft, segId);
if (!found) throw new Error(`Segment not found: ${segId}`);
const found = requireSegment(draft, segId);
if (found.track.type !== "video") {
throw new Error(
`Chroma key can only be applied to video segments (segment ${segId} is on a ${found.track.type} track)`,
Expand Down Expand Up @@ -46,8 +45,7 @@ export function setChroma(
}

export function removeChroma(draft: Draft, segId: string): { ok: true; segmentId: string; removed: string[] } {
const found = findSegment(draft, segId);
if (!found) throw new Error(`Segment not found: ${segId}`);
const found = requireSegment(draft, segId);
const seg = found.segment as Segment & { extra_material_refs?: string[] };
const removed: string[] = [];
const chromas = (draft.materials.chromas as Array<{ id: string }> | undefined) ?? [];
Expand Down
24 changes: 9 additions & 15 deletions src/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import type { Draft, MaterialText, Segment } from "./draft.js";
import { findMaterial, findSegment } from "./draft.js";
import { findMaterial } from "./draft.js";
import { findEnum, type Namespace, slugsFor } from "./enums.js";
import { requireSegment } from "./segment-lookup.js";
import { storedTextLength, toStoredOffset } from "./text-offsets.js";
import { atLeast } from "./version.js";

Expand Down Expand Up @@ -334,8 +335,7 @@ export function addKeyframes(
holdKeyframes: number;
} {
resolveEasing(easing); // fail fast even when every keyframe overrides it
const found = findSegment(draft, segmentId);
if (!found) throw new Error(`Segment not found: ${segmentId}`);
const found = requireSegment(draft, segmentId);
const seg = found.segment as Segment & { common_keyframes?: unknown };

if (!Array.isArray(seg.common_keyframes)) {
Expand Down Expand Up @@ -416,8 +416,7 @@ export function addTransition(
const hint = namespace === "jianying" ? " --jianying" : "";
throw new Error(`Unknown transition: ${slug}. Run 'capcut enums --transitions${hint}' for the full list.`);
}
const found = findSegment(draft, segmentId);
if (!found) throw new Error(`Segment not found: ${segmentId}`);
const found = requireSegment(draft, segmentId);
const seg = found.segment;
draft.materials.transitions ??= [];
const transitions = draft.materials.transitions;
Expand Down Expand Up @@ -518,8 +517,7 @@ export function addMask(
const hint = namespace === "jianying" ? " --jianying" : "";
throw new Error(`Unknown mask: ${slug}. Run 'capcut enums --masks${hint}' for the full list.`);
}
const found = findSegment(draft, segmentId);
if (!found) throw new Error(`Segment not found: ${segmentId}`);
const found = requireSegment(draft, segmentId);
const seg = found.segment;

const field = maskTargetField(draft, opts.field);
Expand Down Expand Up @@ -584,8 +582,7 @@ export function setBgBlur(
segmentId: string,
level: 1 | 2 | 3 | 4 | "off",
): { segmentId: string; canvas_id: string | null; blur: number | null } {
const found = findSegment(draft, segmentId);
if (!found) throw new Error(`Segment not found: ${segmentId}`);
const found = requireSegment(draft, segmentId);
const seg = found.segment;

draft.materials.canvases ??= [] as Array<Record<string, unknown>>;
Expand Down Expand Up @@ -633,8 +630,7 @@ export function requireTextMaterial(
segmentId: string,
label?: string,
): { seg: Segment; text: MaterialText } {
const found = findSegment(draft, segmentId);
if (!found) throw new Error(`Segment not found: ${segmentId}`);
const found = requireSegment(draft, segmentId);
if (label !== undefined && found.track.type !== "text") {
throw new Error(`${label} only applies to text segments (track type: ${found.track.type})`);
}
Expand Down Expand Up @@ -943,8 +939,7 @@ export function addImageAnim(
throw new Error("at least one of --intro, --outro, --combo is required");
}

const found = findSegment(draft, segmentId);
if (!found) throw new Error(`Segment not found: ${segmentId}`);
const found = requireSegment(draft, segmentId);
const seg = found.segment;

const container = ensureAnimContainer(draft, seg);
Expand Down Expand Up @@ -1036,8 +1031,7 @@ export function addTextAnim(
} {
if (!opts.intro && !opts.outro) throw new Error("at least one of --intro or --outro is required");

const found = findSegment(draft, segmentId);
if (!found) throw new Error(`Segment not found: ${segmentId}`);
const found = requireSegment(draft, segmentId);
const seg = found.segment;

const container = ensureAnimContainer(draft, seg);
Expand Down
45 changes: 2 additions & 43 deletions src/draft.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import {
appendFileSync,
closeSync,
existsSync,
fsyncSync,
linkSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
unlinkSync,
writeFileSync,
writeSync,
} from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { appVersionEvidence, formatAppVersionDriftWarning, trackAppVersion } from "./app-versions.js";
import { writeTemp } from "./atomic-write.js";
import { stripBom } from "./bom.js";
import {
assertActiveTimelineUnchanged,
Expand Down Expand Up @@ -622,44 +619,6 @@ export function saveDraft(
});
}

function writeAndSync(path: string, content: string): void {
// "wx" is O_CREAT|O_EXCL: it creates the file or fails, and never follows an
// existing symlink. See writeTemp for why that matters.
const fd = openSync(path, "wx", 0o600);
try {
writeSync(fd, content, undefined, "utf-8");
fsyncSync(fd);
} finally {
closeSync(fd);
}
}

/**
* Create the scratch file a temp+fsync+rename write stages into, next to its
* target, and return its path.
*
* The name carries crypto randomness and the open is exclusive. A predictable
* name (pid + clock alone) is a write-through primitive: anyone able to create
* files in the draft folder can pre-place a symlink at the path we are about
* to open and redirect every draft save to a file of their choosing. O_EXCL is
* the guarantee — it refuses a path that already exists, symlink included —
* and the randomness keeps a legitimate concurrent writer from colliding with
* us in the first place. A collision just draws another name; the bytes
* written are unchanged.
*/
function writeTemp(target: string, content: string): string {
for (let attempt = 0; attempt < 8; attempt++) {
const temp = `${target}.capcut-cli-${process.pid}-${Date.now()}-${randomBytes(8).toString("hex")}.tmp`;
try {
writeAndSync(temp, content);
return temp;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
}
throw new Error(`Could not create a temp file next to ${target} after 8 attempts.`);
}

// Exported for factory.ts (register): the same temp+fsync+rename write the
// draft save path uses, for metadata files outside commitDraftTargets.
export function writeAtomic(path: string, content: string): void {
Expand Down
Loading
Loading