diff --git a/src/app-versions.ts b/src/app-versions.ts index 94fda6c..bb83e2a 100644 --- a/src/app-versions.ts +++ b/src/app-versions.ts @@ -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"; @@ -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; @@ -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})` }; diff --git a/src/atomic-write.ts b/src/atomic-write.ts new file mode 100644 index 0000000..8f0d7f4 --- /dev/null +++ b/src/atomic-write.ts @@ -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); +} diff --git a/src/bom.ts b/src/bom.ts index 9bfe055..a83d3bf 100644 --- a/src/bom.ts +++ b/src/bom.ts @@ -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` @@ -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 | null { + try { + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : 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 | null } { + const raw = readTextIfPresent(path); + return { raw, obj: raw === null ? null : parseJsonObject(raw) }; +} diff --git a/src/caption.ts b/src/caption.ts index 3e2e1a6..f73ec05 100644 --- a/src/caption.ts +++ b/src/caption.ts @@ -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"; @@ -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); diff --git a/src/chroma.ts b/src/chroma.ts index 2976391..ea7cf50 100644 --- a/src/chroma.ts +++ b/src/chroma.ts @@ -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" @@ -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)`, @@ -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) ?? []; diff --git a/src/decorators.ts b/src/decorators.ts index de0444d..3fc49d5 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -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"; @@ -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)) { @@ -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; @@ -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); @@ -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>; @@ -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})`); } @@ -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); @@ -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); diff --git a/src/draft.ts b/src/draft.ts index 4957a88..84cc2be 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -1,12 +1,9 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { appendFileSync, - closeSync, existsSync, - fsyncSync, linkSync, mkdirSync, - openSync, readdirSync, readFileSync, realpathSync, @@ -14,10 +11,10 @@ import { 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, @@ -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 { diff --git a/src/factory.ts b/src/factory.ts index b9e1e7d..c68b66f 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -17,13 +17,14 @@ import { writeFileSync, } from "node:fs"; import { basename, dirname, resolve } from "node:path"; -import { stripBom } from "./bom.js"; +import { readJsonObjectFile, readTextIfPresent, stripBom } from "./bom.js"; import { uuidHex } from "./decorators.js"; import { detectEncryption } from "./decrypt.js"; import type { Draft, Segment, Timerange, Track } from "./draft.js"; import { findMaterialGlobal, findSegment, loadedDraftProjectDir, makeTrack, writeAtomic } from "./draft.js"; import { findEnum, type Namespace } from "./enums.js"; import { PHOTO_META_DURATION_US, registerMediumInSidecar } from "./materials-register.js"; +import { requireSegment } from "./segment-lookup.js"; import { isManagedDraftPath, parseCandidate } from "./store.js"; import { storedTextLength } from "./text-offsets.js"; import { atLeast, versionTuple } from "./version.js"; @@ -389,16 +390,6 @@ function seedOutranks(a: StoreSeed, b: StoreSeed): boolean { return a.mtimeMs > b.mtimeMs; } -/** - * Newest `platform.app_version` across the drafts already in a drafts ROOT, or - * null when the folder holds no readable draft. store.ts's highestVersion does - * the same job across one project's siblings; `init` has no project to discover - * yet, so it scans the store instead. - */ -export function detectStoreAppVersion(draftsDir: string): string | null { - return scanStore(draftsDir).newestVersion; -} - /** True when the store's projects come from a materially newer app than the template declares (#67's gate). */ export function storeOutgrowsTemplate(templateVersion: string | null, storeVersion: string | null): boolean { if (!templateVersion || !storeVersion) return false; @@ -1055,17 +1046,8 @@ export function planDraftRegistration( // Target 1: the per-folder draft_meta_info.json sidecar. const metaPath = resolve(projectDir, "draft_meta_info.json"); - const metaRaw = existsSync(metaPath) ? stripBom(readFileSync(metaPath, "utf-8")) : null; - let metaParsed: Record | null = null; - if (metaRaw !== null) { - try { - const parsed = JSON.parse(metaRaw) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) - metaParsed = parsed as Record; - } catch { - // Unparseable sidecar: metaParsed stays null and is treated as corrupt below. - } - } + // An unparseable sidecar leaves metaParsed null and is treated as corrupt below. + const { raw: metaRaw, obj: metaParsed } = readJsonObjectFile(metaPath); if (storeRoot === null) { // Without a known store root there is no draft_root_path to derive and no // index to update — report only, never write (the contract for drafts @@ -1150,7 +1132,7 @@ export function planDraftRegistration( // Target 2: the draft's entry in the store's root_meta_info.json index. const indexPath = resolve(storeRoot, "root_meta_info.json"); - const indexRaw = existsSync(indexPath) ? stripBom(readFileSync(indexPath, "utf-8")) : null; + const { raw: indexRaw, obj: index } = readJsonObjectFile(indexPath); if (indexRaw === null) { // No index yet (fresh CapCut / custom --drafts dir): create a minimal // one, exactly like init's registerDraftInIndex. @@ -1169,13 +1151,7 @@ export function planDraftRegistration( previous: null, }); } else { - let index: Record | null = null; - try { - const parsed = JSON.parse(indexRaw) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) index = parsed as Record; - } catch { - // Unreadable index — leave it untouched rather than corrupt it (it lists every draft). - } + // Unreadable index — leave it untouched rather than corrupt it (it lists every draft). if (index === null) { targets.push({ file: "root_meta_info.json", @@ -1270,6 +1246,25 @@ export function planDraftRegistration( }; } +/** + * The sync-timelines optimistic-concurrency rule for register and rename: + * refuse when a planned file's text is no longer what the plan read + * (`previous`, null for a file that was absent). + */ +function assertPlannedFilesUnchanged( + writes: Array<{ file: string; path: string; previous: string | null }>, + command: "register" | "rename", +): void { + for (const write of writes) { + if (readTextIfPresent(write.path) !== write.previous) { + throw new Error( + `Draft changed on disk after it was planned: ${write.file}. ` + + `Re-run ${command}, or pass --force-write to overwrite intentionally.`, + ); + } + } +} + /** * Perform the writes a registration plan prepared: temp+fsync+rename per file, * with a `.bak` of the pre-plan content for every file that already existed. @@ -1280,17 +1275,7 @@ export function applyDraftRegistration( result: RegistrationResult, options: { forceWrite?: boolean } = {}, ): { applied: string[]; backups: string[] } { - if (!options.forceWrite) { - for (const write of result.writes) { - const current = existsSync(write.path) ? stripBom(readFileSync(write.path, "utf-8")) : null; - if (current !== write.previous) { - throw new Error( - `Draft changed on disk after it was planned: ${write.file}. ` + - "Re-run register, or pass --force-write to overwrite intentionally.", - ); - } - } - } + if (!options.forceWrite) assertPlannedFilesUnchanged(result.writes, "register"); const applied: string[] = []; const backups: string[] = []; for (const write of result.writes) { @@ -1437,22 +1422,12 @@ export function planDraftRename( // proceeding would leave the app a sidecar whose recorded name/paths can // never be updated. register --apply repairs it (with a .bak) first. const metaPath = resolve(projectDir, "draft_meta_info.json"); - const metaRaw = existsSync(metaPath) ? stripBom(readFileSync(metaPath, "utf-8")) : null; - let metaParsed: Record | null = null; - if (metaRaw !== null) { - try { - const parsed = JSON.parse(metaRaw) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) - metaParsed = parsed as Record; - } catch { - // metaParsed stays null → refused below. - } - if (metaParsed === null) { - throw new Error( - "rename must update draft_meta_info.json but it does not parse as JSON. " + - `Repair it first — capcut register ${projectDir} --apply rewrites the sidecar with a .bak — then re-run rename.`, - ); - } + const { raw: metaRaw, obj: metaParsed } = readJsonObjectFile(metaPath); + if (metaRaw !== null && metaParsed === null) { + throw new Error( + "rename must update draft_meta_info.json but it does not parse as JSON. " + + `Repair it first — capcut register ${projectDir} --apply rewrites the sidecar with a .bak — then re-run rename.`, + ); } const oldName = @@ -1511,7 +1486,7 @@ export function planDraftRename( }); } else { const indexPath = resolve(storeRoot, "root_meta_info.json"); - const indexRaw = existsSync(indexPath) ? stripBom(readFileSync(indexPath, "utf-8")) : null; + const { raw: indexRaw, obj: index } = readJsonObjectFile(indexPath); if (indexRaw === null) { targets.push({ file: "root_meta_info.json", @@ -1522,13 +1497,7 @@ export function planDraftRename( updated_fields: [], }); } else { - let index: Record | null = null; - try { - const parsed = JSON.parse(indexRaw) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) index = parsed as Record; - } catch { - // Unreadable index — refused below rather than renamed around (it lists every draft). - } + // Unreadable index — refused below rather than renamed around (it lists every draft). if (index === null) { throw new Error( "rename must update the draft's entry in root_meta_info.json, but the index does not parse as JSON; " + @@ -1619,15 +1588,10 @@ export function applyDraftRename( ): { renamed: boolean; applied: string[]; backups: string[] } { const { plan, writes } = result; if (!options.forceWrite) { - for (const write of writes) { - const current = existsSync(write.readPath) ? stripBom(readFileSync(write.readPath, "utf-8")) : null; - if (current !== write.previous) { - throw new Error( - `Draft changed on disk after it was planned: ${write.file}. ` + - "Re-run rename, or pass --force-write to overwrite intentionally.", - ); - } - } + assertPlannedFilesUnchanged( + writes.map((write) => ({ ...write, path: write.readPath })), + "rename", + ); } if (existsSync(plan.new_path)) { throw new Error(`Refusing to rename: the target folder already exists: ${plan.new_path}`); @@ -2361,8 +2325,7 @@ export interface RemoveSegmentResult { * fresh init draft). */ export function removeSegment(draft: Draft, segId: string, opts: RemoveSegmentOptions = {}): RemoveSegmentResult { - const found = findSegment(draft, segId); - if (!found) throw new Error(`Segment not found: ${segId}`); + const found = requireSegment(draft, segId); const { track, segment, index } = found; const durationBefore = draft.duration; const removedStart = segment.target_timerange.start; @@ -2450,22 +2413,7 @@ export interface Template { } export function saveTemplate(draft: Draft, segId: string, name: string, outPath: string): Template { - const shortId = segId.toLowerCase(); - let foundSeg: Segment | null = null; - let foundTrack: Track | null = null; - - for (const track of draft.tracks) { - for (const seg of track.segments) { - if (seg.id === segId || seg.id.toLowerCase().startsWith(shortId)) { - foundSeg = seg; - foundTrack = track; - break; - } - } - if (foundSeg) break; - } - - if (!foundSeg || !foundTrack) throw new Error(`Segment not found: ${segId}`); + const { segment: foundSeg, track: foundTrack } = requireSegment(draft, segId); // Resolve primary material const mat = findMaterialGlobal(draft, foundSeg.material_id); @@ -2634,8 +2582,7 @@ export function duplicateSegment( segId: string, opts: DuplicateSegmentOptions = {}, ): DuplicateSegmentResult { - const found = findSegment(draft, segId); - if (!found) throw new Error(`Segment not found: ${segId}`); + const found = requireSegment(draft, segId); const { track: sourceTrack, segment: sourceSeg } = found; const { start, duration } = sourceSeg.target_timerange; @@ -2974,8 +2921,7 @@ export function addEffect( // full segment id so the written reference never dangles. let bindSegmentId = ""; if (opts.bindSegmentId) { - const bound = findSegment(draft, opts.bindSegmentId); - if (!bound) throw new Error(`Segment not found: ${opts.bindSegmentId}`); + const bound = requireSegment(draft, opts.bindSegmentId); bindSegmentId = bound.segment.id; } @@ -3100,8 +3046,7 @@ function findCropMaterial( segmentId: string, label = "crop", ): { seg: Segment; mat: Record & { id: string; type?: string } } { - const found = findSegment(draft, segmentId); - if (!found) throw new Error(`Segment not found: ${segmentId}`); + const found = requireSegment(draft, segmentId); const seg = found.segment; const videos = (draft.materials.videos ?? []) as Array & { id: string; type?: string }>; const mat = videos.find((v) => v.id === seg.material_id); @@ -3201,8 +3146,7 @@ export function setAudioFade( if (fadeIn <= 0 && fadeOut <= 0) { throw new Error(`audio-fade requires at least one of --in or --out (> 0)`); } - const found = findSegment(draft, segmentId); - if (!found) throw new Error(`Segment not found: ${segmentId}`); + const found = requireSegment(draft, segmentId); if (found.track.type !== "audio") { throw new Error(`audio-fade only applies to audio segments (track type: ${found.track.type})`); } diff --git a/src/ffmpeg-pass.ts b/src/ffmpeg-pass.ts new file mode 100644 index 0000000..73b1130 --- /dev/null +++ b/src/ffmpeg-pass.ts @@ -0,0 +1,61 @@ +import { spawnSync } from "node:child_process"; + +export interface FfmpegPassOptions { + /** Command name every error starts with, e.g. "detect-scenes". */ + label: string; + /** What the pass does, for the timeout message, e.g. "scene detection". */ + what: string; + cmd: string; + mediaPath: string; + args: string[]; + timeoutMs: number; + maxBuffer: number; + /** What to try after a timeout. */ + timeoutHint: string; + /** Which option emits less output after a buffer overflow. */ + bufferHint: string; +} + +/** + * Run one ffmpeg analysis pass (a filter that prints to stderr, output to the + * null muxer) and return its stderr. Every failure throws an actionable error: + * a missing binary says how to install or point at one, while a tripped + * timeout or buffer — ffmpeg ran — says so instead of blaming the install. + */ +export function runFfmpegAnalysis(o: FfmpegPassOptions): string { + let r: ReturnType; + try { + r = spawnSync(o.cmd, o.args, { encoding: "utf-8", timeout: o.timeoutMs, maxBuffer: o.maxBuffer }); + } catch (e) { + throw new Error( + `${o.label}: ffmpeg is unavailable at '${o.cmd}'. Install ffmpeg or pass --ffmpeg-cmd . (${ + e instanceof Error ? e.message : String(e) + })`, + ); + } + if (r.error) { + // spawnSync reports its own limits via r.error, not by throwing — a tripped + // timeout/buffer means ffmpeg RAN, so "install ffmpeg" would be a lie. + const code = (r.error as NodeJS.ErrnoException).code; + if (code === "ETIMEDOUT") { + throw new Error( + `${o.label}: ${o.what} timed out after ${o.timeoutMs / 1000}s on ${o.mediaPath}. ${o.timeoutHint}`, + ); + } + if (code === "ENOBUFS") { + const mib = o.maxBuffer / (1024 * 1024); + const cap = mib >= 1 ? `${Math.round(mib)} MiB` : `${o.maxBuffer}-byte`; + throw new Error(`${o.label}: ffmpeg output exceeded the ${cap} buffer on ${o.mediaPath}. ${o.bufferHint}`); + } + throw new Error( + `${o.label}: ffmpeg is unavailable at '${o.cmd}'. Install ffmpeg or pass --ffmpeg-cmd . (${ + code ?? r.error.message + })`, + ); + } + const stderr = typeof r.stderr === "string" ? r.stderr : ""; + if (r.status !== 0) { + throw new Error(`${o.label}: ffmpeg failed on ${o.mediaPath}.\n${stderr.slice(-600)}`); + } + return stderr; +} diff --git a/src/index.ts b/src/index.ts index 40e3178..dcaff8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { trackAppVersion, } from "./app-versions.js"; import type { AssEvent, AssStyleDef } from "./ass.js"; -import { stripBom } from "./bom.js"; +import { parseJsonObject, stripBom } from "./bom.js"; import { buildCommandSpecs, commandDeclaresFlag, @@ -1205,6 +1205,216 @@ function fishCompletion(): string { .join("\n"); } +// Flags that only set one field, by value kind: a boolean switch, or a flag +// that consumes the next token as text or a number. Flags with validation, +// several values or command-scoped meaning stay in parseFlags's chain. +type FlagKey = { + [K in keyof Flags]-?: [NonNullable] extends [T] ? ([T] extends [NonNullable] ? K : never) : never; +}[keyof Flags]; +const BOOLEAN_FLAGS = new Map>([ + ["--off", "off"], + ["--materials", "materials"], + ["--from-store", "fromStore"], + ["--ripple", "ripple"], + ["--word-reveal", "wordReveal"], + ["--soft-captions", "softCaptions"], + ["--strict", "strict"], + ["--verify", "verify"], + ["--invert", "invert"], + ["--vertical", "vertical"], + ["--shadow", "shadow"], + ["--full", "full"], + ["--bubbles", "bubbles"], + ["--jianying", "jianying"], + ["--force-license", "forceLicense"], + ["--progress", "progress"], + ["--no-check-paths", "noCheckPaths"], + ["--fix", "fix"], + ["--karaoke", "karaoke"], + ["--no-probe", "noProbe"], + ["--retime", "retime"], + ["--dry-run", "dryRun"], + ["--fail-fast", "failFast"], + ["--recursive", "recursive"], + ["--list", "list"], + ["--names", "names"], + ["--burn-captions", "burnCaptions"], + ["--all-video-tracks", "allVideoTracks"], + ["--force-write", "forceWrite"], + ["--continue-on-error", "continueOnError"], + ["--check", "check"], + ["--apply", "apply"], + ["--nested", "nested"], + ["--pip", "pip"], + ["--frame-grid", "frameGrid"], + ["--clone-style", "cloneStyle"], + ["--stage", "stage"], + ["--sync", "sync"], + ["--add", "add"], + ["--json", "json"], + ["--reset", "reset"], + ["--new-track", "newTrack"], + ["--keep-track", "keepTrack"], + ["--keep-materials", "keepMaterials"], +]); +const STRING_FLAGS = new Map>([ + ["--out", "out"], + ["--color", "color"], + ["--track-name", "trackName"], + ["--template", "template"], + ["--drafts", "drafts"], + ["--duration", "duration"], + ["--like", "like"], + ["--captions", "captions"], + ["--script", "script"], + ["--catalogue", "catalogue"], + ["--shadow-color", "shadowColor"], + ["--border-color", "borderColor"], + ["--bg-color", "bgColor"], + ["--intro", "intro"], + ["--outro", "outro"], + ["--intro-duration", "introDuration"], + ["--outro-duration", "outroDuration"], + ["--combo", "combo"], + ["--combo-duration", "comboDuration"], + ["--resource-id", "resourceId"], + ["--params", "params"], + ["--bind", "bind"], + ["--style-ref", "styleRef"], + ["--time-offset", "timeOffset"], + ["--fade-out", "fadeOut"], + ["--time", "time"], + ["--bubble", "bubble"], + ["--effect-id", "effectId"], + ["--styles", "styles"], + ["--preset", "preset"], + ["--audio", "audio"], + ["--from-segment", "fromSegment"], + ["--words", "words"], + ["--whisper-cmd", "whisperCmd"], + ["--whisper-model", "whisperModel"], + ["--language", "language"], + ["--highlight-words", "highlightWords"], + ["--keyword-color", "keywordColor"], + ["--color-cycle", "colorCycle"], + ["--text", "text"], + ["--text-file", "textFile"], + ["--tts-cmd", "ttsCmd"], + ["--lexicon", "lexicon"], + ["--ffprobe-cmd", "ffprobeCmd"], + ["--video", "video"], + ["--srt", "srt"], + ["--to", "to"], + ["--api-key", "apiKey"], + ["--model", "model"], + ["--app", "app"], + ["--queue", "queue"], + ["--dir", "dir"], + ["--ffmpeg-cmd", "ffmpegCmd"], + ["--encoder", "encoder"], + ["--video-bitrate", "videoBitrate"], + ["--bundle", "bundle"], + ["--data", "data"], + ["--into", "into"], + ["--kind", "kind"], + ["--ratio", "ratio"], + ["--rect", "rect"], +]); +const FLOAT_FLAGS = new Map>([ + ["--font-size", "fontSize"], + ["--x", "x"], + ["--y", "y"], + ["--width", "width"], + ["--height", "height"], + ["--volume", "volume"], + ["--min-script-match", "minScriptMatch"], + ["--window", "window"], + ["--similarity", "similarity"], + ["--center-x", "centerX"], + ["--center-y", "centerY"], + ["--size", "size"], + ["--rotation", "rotation"], + ["--feather", "feather"], + ["--rect-width", "rectWidth"], + ["--round-corner", "roundCorner"], + ["--alpha", "alpha"], + ["--fixed-width", "fixedWidth"], + ["--fixed-height", "fixedHeight"], + ["--shadow-alpha", "shadowAlpha"], + ["--shadow-angle", "shadowAngle"], + ["--shadow-distance", "shadowDistance"], + ["--shadow-smoothing", "shadowSmoothing"], + ["--border-width", "borderWidth"], + ["--border-alpha", "borderAlpha"], + ["--bg-alpha", "bgAlpha"], + ["--bg-round-radius", "bgRoundRadius"], + ["--bg-width", "bgWidth"], + ["--bg-height", "bgHeight"], + ["--bg-h-offset", "bgHOffset"], + ["--bg-v-offset", "bgVOffset"], + ["--scale", "scale"], + ["--max-cue-secs", "maxCueSecs"], + ["--min-gap-ms", "minGapMs"], + ["--max-cps", "maxCps"], + ["--safe-area", "safeArea"], + ["--max-gap-ms", "maxGapMs"], + ["--keyword-size", "keywordSize"], + ["--intensity", "intensity"], + ["--fps", "fps"], + ["--crf", "crf"], + ["--threshold", "threshold"], + ["--min-gap", "minGap"], + ["--threshold-db", "thresholdDb"], + ["--min-silence", "minSilence"], + ["--pad", "pad"], +]); +const INTEGER_FLAGS = new Map>([ + ["--align", "align"], + ["--min-words", "minWords"], + ["--bg-style", "bgStyle"], + ["--max-chars", "maxChars"], + ["--max-words", "maxWords"], + ["--step", "step"], + ["--cols", "cols"], + ["--limit", "limit"], +]); +const NUMBER_FLAGS = new Map>([ + ["--audio-stream", "audioStream"], + ["--workers", "workers"], + ["--retries", "retries"], + ["--timeout", "timeoutMs"], + ["--backoff-ms", "backoffMs"], + ["--max-buffer-mb", "maxBufferMb"], +]); + +/** + * Apply args[i] when it is a table flag (and, for a value flag, a value token + * follows). Returns false otherwise, leaving args[i] to parseFlags's chain — + * a value flag at the end of the line falls through exactly as before. + */ +function applyTableFlag(flags: Flags, args: string[], i: number): boolean { + const a = args[i]; + const flag = BOOLEAN_FLAGS.get(a); + if (flag) { + flags[flag] = true; + return true; + } + if (i + 1 >= args.length) return false; + const value = args[i + 1]; + const text = STRING_FLAGS.get(a); + if (text) flags[text] = value; + else { + const float = FLOAT_FLAGS.get(a); + const integer = INTEGER_FLAGS.get(a); + const number = NUMBER_FLAGS.get(a); + if (float) flags[float] = parseFloat(value); + else if (integer) flags[integer] = parseInt(value, 10); + else if (number) flags[number] = Number(value); + else return false; + } + return true; +} + function parseFlags(args: string[]): { positional: string[]; flags: Flags } { const positional: string[] = []; const flags: Flags = { human: false, quiet: false, batch: false }; @@ -1221,6 +1431,10 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } { positional.push(a); continue; } + if (applyTableFlag(flags, args, i)) { + if (!BOOLEAN_FLAGS.has(a)) i++; + continue; + } if (a === "-H" || a === "--human") flags.human = true; else if (a === "-v" || a === "--version") flags.version = true; else if (a === "-q" || a === "--quiet") flags.quiet = true; @@ -1236,242 +1450,28 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } { flags.easing = args[++i]; } else if ((a === "--track" || a === "--type") && i + 1 < args.length) { flags.track = args[++i]; - } else if (a === "--out" && i + 1 < args.length) { - flags.out = args[++i]; - } else if (a === "--font-size" && i + 1 < args.length) { - flags.fontSize = parseFloat(args[++i]); - } else if (a === "--color" && i + 1 < args.length) { - flags.color = args[++i]; - } else if (a === "--align" && i + 1 < args.length) { - flags.align = parseInt(args[++i], 10); - } else if (a === "--x" && i + 1 < args.length) { - flags.x = parseFloat(args[++i]); - } else if (a === "--y" && i + 1 < args.length) { - flags.y = parseFloat(args[++i]); - } else if (a === "--track-name" && i + 1 < args.length) { - flags.trackName = args[++i]; - } else if (a === "--width" && i + 1 < args.length) { - flags.width = parseFloat(args[++i]); - } else if (a === "--height" && i + 1 < args.length) { - flags.height = parseFloat(args[++i]); - } else if (a === "--volume" && i + 1 < args.length) { - flags.volume = parseFloat(args[++i]); - } else if (a === "--template" && i + 1 < args.length) { - flags.template = args[++i]; - } else if (a === "--drafts" && i + 1 < args.length) { - flags.drafts = args[++i]; - } else if (a === "--duration" && i + 1 < args.length) { - flags.duration = args[++i]; - } else if (a === "--off") { - flags.off = true; - } else if (a === "--materials") { - flags.materials = true; - } else if (a === "--like" && i + 1 < args.length) { - flags.like = args[++i]; - } else if (a === "--from-store") { - flags.fromStore = true; } else if (a === "--from" && command === "shift-all" && i + 1 < args.length) { flags.fromTime = args[++i]; - } else if (a === "--ripple") { - flags.ripple = true; - } else if (a === "--captions" && i + 1 < args.length) { - flags.captions = args[++i]; - } else if (a === "--script" && i + 1 < args.length) { - flags.script = args[++i]; - } else if (a === "--word-reveal") { - flags.wordReveal = true; - } else if (a === "--min-script-match" && i + 1 < args.length) { - flags.minScriptMatch = parseFloat(args[++i]); - } else if (a === "--audio-stream" && i + 1 < args.length) { - flags.audioStream = Number(args[++i]); - } else if (a === "--window" && i + 1 < args.length) { - flags.window = parseFloat(args[++i]); - } else if (a === "--similarity" && i + 1 < args.length) { - flags.similarity = parseFloat(args[++i]); - } else if (a === "--min-words" && i + 1 < args.length) { - flags.minWords = parseInt(args[++i], 10); - } else if (a === "--soft-captions") { - flags.softCaptions = true; - } else if (a === "--strict") { - flags.strict = true; - } else if (a === "--verify") { - flags.verify = true; - } else if (a === "--center-x" && i + 1 < args.length) { - flags.centerX = parseFloat(args[++i]); - } else if (a === "--center-y" && i + 1 < args.length) { - flags.centerY = parseFloat(args[++i]); - } else if (a === "--size" && i + 1 < args.length) { - flags.size = parseFloat(args[++i]); - } else if (a === "--rotation" && i + 1 < args.length) { - flags.rotation = parseFloat(args[++i]); - } else if (a === "--feather" && i + 1 < args.length) { - flags.feather = parseFloat(args[++i]); - } else if (a === "--invert") { - flags.invert = true; - } else if (a === "--rect-width" && i + 1 < args.length) { - flags.rectWidth = parseFloat(args[++i]); - } else if (a === "--round-corner" && i + 1 < args.length) { - flags.roundCorner = parseFloat(args[++i]); } else if (a === "--mask-field" && i + 1 < args.length) { const field = args[++i]; if (!["masks", "common_mask", "common_masks"].includes(field)) { throw new Error("--mask-field must be masks|common_mask|common_masks"); } flags.maskField = field as Flags["maskField"]; - } else if (a === "--catalogue" && i + 1 < args.length) { - flags.catalogue = args[++i]; - } else if (a === "--alpha" && i + 1 < args.length) { - flags.alpha = parseFloat(args[++i]); - } else if (a === "--vertical") { - flags.vertical = true; - } else if (a === "--fixed-width" && i + 1 < args.length) { - flags.fixedWidth = parseFloat(args[++i]); - } else if (a === "--fixed-height" && i + 1 < args.length) { - flags.fixedHeight = parseFloat(args[++i]); - } else if (a === "--shadow") { - flags.shadow = true; } else if (a === "--no-shadow") { flags.shadow = false; - } else if (a === "--shadow-alpha" && i + 1 < args.length) { - flags.shadowAlpha = parseFloat(args[++i]); - } else if (a === "--shadow-angle" && i + 1 < args.length) { - flags.shadowAngle = parseFloat(args[++i]); - } else if (a === "--shadow-color" && i + 1 < args.length) { - flags.shadowColor = args[++i]; - } else if (a === "--shadow-distance" && i + 1 < args.length) { - flags.shadowDistance = parseFloat(args[++i]); - } else if (a === "--shadow-smoothing" && i + 1 < args.length) { - flags.shadowSmoothing = parseFloat(args[++i]); - } else if (a === "--border-width" && i + 1 < args.length) { - flags.borderWidth = parseFloat(args[++i]); - } else if (a === "--border-color" && i + 1 < args.length) { - flags.borderColor = args[++i]; - } else if (a === "--border-alpha" && i + 1 < args.length) { - flags.borderAlpha = parseFloat(args[++i]); - } else if (a === "--bg-color" && i + 1 < args.length) { - flags.bgColor = args[++i]; - } else if (a === "--bg-alpha" && i + 1 < args.length) { - flags.bgAlpha = parseFloat(args[++i]); - } else if (a === "--bg-style" && i + 1 < args.length) { - flags.bgStyle = parseInt(args[++i], 10); - } else if (a === "--bg-round-radius" && i + 1 < args.length) { - flags.bgRoundRadius = parseFloat(args[++i]); - } else if (a === "--bg-width" && i + 1 < args.length) { - flags.bgWidth = parseFloat(args[++i]); - } else if (a === "--bg-height" && i + 1 < args.length) { - flags.bgHeight = parseFloat(args[++i]); - } else if (a === "--bg-h-offset" && i + 1 < args.length) { - flags.bgHOffset = parseFloat(args[++i]); - } else if (a === "--bg-v-offset" && i + 1 < args.length) { - flags.bgVOffset = parseFloat(args[++i]); - } else if (a === "--intro" && i + 1 < args.length) { - flags.intro = args[++i]; - } else if (a === "--outro" && i + 1 < args.length) { - flags.outro = args[++i]; - } else if (a === "--intro-duration" && i + 1 < args.length) { - flags.introDuration = args[++i]; - } else if (a === "--outro-duration" && i + 1 < args.length) { - flags.outroDuration = args[++i]; - } else if (a === "--combo" && i + 1 < args.length) { - flags.combo = args[++i]; - } else if (a === "--combo-duration" && i + 1 < args.length) { - flags.comboDuration = args[++i]; - } else if (a === "--scale" && i + 1 < args.length) { - flags.scale = parseFloat(args[++i]); - } else if (a === "--resource-id" && i + 1 < args.length) { - flags.resourceId = args[++i]; - } else if (a === "--params" && i + 1 < args.length) { - flags.params = args[++i]; - } else if (a === "--bind" && i + 1 < args.length) { - flags.bind = args[++i]; - } else if (a === "--full") { - flags.full = true; - } else if (a === "--style-ref" && i + 1 < args.length) { - flags.styleRef = args[++i]; - } else if (a === "--time-offset" && i + 1 < args.length) { - flags.timeOffset = args[++i]; } else if (a === "--font" && i + 1 < args.length) { // Accepted and ignored: no command ever read it. The value token stays // consumed so the positional stream still parses exactly as it did. i++; } else if ((a === "--in" || a === "--fade-in") && i + 1 < args.length) { flags.fadeIn = args[++i]; - } else if (a === "--fade-out" && i + 1 < args.length) { - flags.fadeOut = args[++i]; - } else if (a === "--time" && i + 1 < args.length) { - flags.time = args[++i]; - } else if (a === "--bubble" && i + 1 < args.length) { - flags.bubble = args[++i]; - } else if (a === "--effect-id" && i + 1 < args.length) { - flags.effectId = args[++i]; - } else if (a === "--bubbles") { - flags.bubbles = true; - } else if (a === "--jianying") { - flags.jianying = true; - } else if (a === "--styles" && i + 1 < args.length) { - flags.styles = args[++i]; - } else if (a === "--preset" && i + 1 < args.length) { - flags.preset = args[++i]; - } else if (a === "--force-license") { - flags.forceLicense = true; - } else if (a === "--max-chars" && i + 1 < args.length) { - flags.maxChars = parseInt(args[++i], 10); - } else if (a === "--max-cue-secs" && i + 1 < args.length) { - flags.maxCueSecs = parseFloat(args[++i]); - } else if (a === "--min-gap-ms" && i + 1 < args.length) { - flags.minGapMs = parseFloat(args[++i]); - } else if (a === "--max-cps" && i + 1 < args.length) { - flags.maxCps = parseFloat(args[++i]); - } else if (a === "--safe-area" && i + 1 < args.length) { - flags.safeArea = parseFloat(args[++i]); - } else if (a === "--progress") { - flags.progress = true; - } else if (a === "--no-check-paths") { - flags.noCheckPaths = true; - } else if (a === "--fix") { - flags.fix = true; - } else if (a === "--audio" && i + 1 < args.length) { - flags.audio = args[++i]; - } else if (a === "--from-segment" && i + 1 < args.length) { - flags.fromSegment = args[++i]; - } else if (a === "--words" && i + 1 < args.length) { - flags.words = args[++i]; - } else if (a === "--whisper-cmd" && i + 1 < args.length) { - flags.whisperCmd = args[++i]; } else if (a === "--whisper-engine" && i + 1 < args.length) { const engine = args[++i]; if (!["auto", "openai", "whisper-cpp", "faster-whisper"].includes(engine)) { throw new Error("--whisper-engine must be auto|openai|whisper-cpp|faster-whisper"); } flags.whisperEngine = engine as Flags["whisperEngine"]; - } else if (a === "--whisper-model" && i + 1 < args.length) { - flags.whisperModel = args[++i]; - } else if (a === "--language" && i + 1 < args.length) { - flags.language = args[++i]; - } else if (a === "--karaoke") { - flags.karaoke = true; - } else if (a === "--max-words" && i + 1 < args.length) { - flags.maxWords = parseInt(args[++i], 10); - } else if (a === "--max-gap-ms" && i + 1 < args.length) { - flags.maxGapMs = parseFloat(args[++i]); - } else if (a === "--highlight-words" && i + 1 < args.length) { - flags.highlightWords = args[++i]; - } else if (a === "--keyword-color" && i + 1 < args.length) { - flags.keywordColor = args[++i]; - } else if (a === "--keyword-size" && i + 1 < args.length) { - flags.keywordSize = parseFloat(args[++i]); - } else if (a === "--color-cycle" && i + 1 < args.length) { - flags.colorCycle = args[++i]; - } else if (a === "--no-probe") { - flags.noProbe = true; - } else if (a === "--text" && i + 1 < args.length) { - flags.text = args[++i]; - } else if (a === "--text-file" && i + 1 < args.length) { - flags.textFile = args[++i]; - } else if (a === "--tts-cmd" && i + 1 < args.length) { - flags.ttsCmd = args[++i]; - } else if (a === "--lexicon" && i + 1 < args.length) { - flags.lexicon = args[++i]; } else if (a === "--granularity" && i + 1 < args.length) { const granularity = args[++i]; if (!["line", "word"].includes(granularity)) { @@ -1484,80 +1484,8 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } { throw new Error("--format must be srt|vtt"); } flags.format = format as Flags["format"]; - } else if (a === "--ffprobe-cmd" && i + 1 < args.length) { - flags.ffprobeCmd = args[++i]; - } else if (a === "--video" && i + 1 < args.length) { - flags.video = args[++i]; - } else if (a === "--srt" && i + 1 < args.length) { - flags.srt = args[++i]; - } else if (a === "--retime") { - flags.retime = true; - } else if (a === "--to" && i + 1 < args.length) { - flags.to = args[++i]; } else if (a === "--from" && i + 1 < args.length) { flags.from = args[++i]; - } else if (a === "--api-key" && i + 1 < args.length) { - flags.apiKey = args[++i]; - } else if (a === "--model" && i + 1 < args.length) { - flags.model = args[++i]; - } else if (a === "--dry-run") { - flags.dryRun = true; - } else if (a === "--intensity" && i + 1 < args.length) { - flags.intensity = parseFloat(args[++i]); - } else if (a === "--app" && i + 1 < args.length) { - flags.app = args[++i]; - } else if (a === "--queue" && i + 1 < args.length) { - flags.queue = args[++i]; - } else if (a === "--fail-fast") { - flags.failFast = true; - } else if (a === "--workers" && i + 1 < args.length) { - flags.workers = Number(args[++i]); - } else if (a === "--retries" && i + 1 < args.length) { - flags.retries = Number(args[++i]); - } else if (a === "--timeout" && i + 1 < args.length) { - flags.timeoutMs = Number(args[++i]); - } else if (a === "--backoff-ms" && i + 1 < args.length) { - flags.backoffMs = Number(args[++i]); - } else if (a === "--max-buffer-mb" && i + 1 < args.length) { - flags.maxBufferMb = Number(args[++i]); - } else if (a === "--recursive") { - flags.recursive = true; - } else if (a === "--dir" && i + 1 < args.length) { - flags.dir = args[++i]; - } else if (a === "--step" && i + 1 < args.length) { - flags.step = parseInt(args[++i], 10); - } else if (a === "--list") { - flags.list = true; - } else if (a === "--cols" && i + 1 < args.length) { - flags.cols = parseInt(args[++i], 10); - } else if (a === "--names") { - flags.names = true; - } else if (a === "--fps" && i + 1 < args.length) { - flags.fps = parseFloat(args[++i]); - } else if (a === "--ffmpeg-cmd" && i + 1 < args.length) { - flags.ffmpegCmd = args[++i]; - } else if (a === "--encoder" && i + 1 < args.length) { - flags.encoder = args[++i]; - } else if (a === "--crf" && i + 1 < args.length) { - flags.crf = parseFloat(args[++i]); - } else if (a === "--video-bitrate" && i + 1 < args.length) { - flags.videoBitrate = args[++i]; - } else if (a === "--burn-captions") { - flags.burnCaptions = true; - } else if (a === "--all-video-tracks") { - flags.allVideoTracks = true; - } else if (a === "--force-write") { - flags.forceWrite = true; - } else if (a === "--bundle" && i + 1 < args.length) { - flags.bundle = args[++i]; - } else if (a === "--continue-on-error") { - flags.continueOnError = true; - } else if (a === "--data" && i + 1 < args.length) { - flags.data = args[++i]; - } else if (a === "--into" && i + 1 < args.length) { - flags.into = args[++i]; - } else if (a === "--check") { - flags.check = true; } else if (a === "--plan" && command === "batch") { const file = args[++i]; if (!file || file.startsWith("-")) die("--plan requires a plan file path (batch --plan )"); @@ -1568,50 +1496,6 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } { flags.applyPlan = file; } else if (a === "--plan") { flags.plan = true; - } else if (a === "--apply") { - flags.apply = true; - } else if (a === "--nested") { - flags.nested = true; - } else if (a === "--pip") { - flags.pip = true; - } else if (a === "--frame-grid") { - flags.frameGrid = true; - } else if (a === "--kind" && i + 1 < args.length) { - 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") { - flags.add = true; - } else if (a === "--threshold" && i + 1 < args.length) { - flags.threshold = parseFloat(args[++i]); - } else if (a === "--min-gap" && i + 1 < args.length) { - flags.minGap = parseFloat(args[++i]); - } else if (a === "--limit" && i + 1 < args.length) { - flags.limit = parseInt(args[++i], 10); - } else if (a === "--json") { - flags.json = true; - } else if (a === "--threshold-db" && i + 1 < args.length) { - flags.thresholdDb = parseFloat(args[++i]); - } else if (a === "--min-silence" && i + 1 < args.length) { - flags.minSilence = parseFloat(args[++i]); - } else if (a === "--pad" && i + 1 < args.length) { - flags.pad = parseFloat(args[++i]); - } else if (a === "--ratio" && i + 1 < args.length) { - flags.ratio = args[++i]; - } else if (a === "--rect" && i + 1 < args.length) { - flags.rect = args[++i]; - } else if (a === "--reset") { - flags.reset = true; - } else if (a === "--new-track") { - flags.newTrack = true; - } else if (a === "--keep-track") { - flags.keepTrack = true; - } else if (a === "--keep-materials") { - flags.keepMaterials = true; } else { const hit = ENUM_FLAG_MAP.find((f) => f.flag === a); if (hit) { @@ -4806,15 +4690,7 @@ async function addMaterialsToRegistrationPlan( const sidecarWrite = result.writes.find((w) => w.file === "draft_meta_info.json"); const onDiskRaw = existsSync(sidecarPath) ? stripBom(readFileSync(sidecarPath, "utf-8")) : null; const baseRaw = sidecarWrite ? sidecarWrite.content : onDiskRaw; - let base: Record | null = null; - if (baseRaw !== null) { - try { - const parsed = JSON.parse(baseRaw) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed as Record; - } catch { - base = null; - } - } + const base = baseRaw === null ? null : parseJsonObject(baseRaw); const { target, fixed } = planDraftMaterials(draft, base, { sidecarPath, projectDir: plan.project_dir }); if (fixed) { const content = JSON.stringify(fixed, null, 0); diff --git a/src/materials-register.ts b/src/materials-register.ts index 5d9b8bc..9b2b319 100644 --- a/src/materials-register.ts +++ b/src/materials-register.ts @@ -28,7 +28,7 @@ import { randomUUID } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { basename, isAbsolute, resolve } from "node:path"; -import { stripBom } from "./bom.js"; +import { parseJsonObject, stripBom } from "./bom.js"; import { type Draft, writeAtomic } from "./draft.js"; export type MetaMaterialKind = "video" | "photo" | "music"; @@ -165,8 +165,7 @@ export function readSidecar(projectDir: string): Record | null const sidecarPath = resolve(projectDir, "draft_meta_info.json"); if (!existsSync(sidecarPath)) return null; try { - const parsed = JSON.parse(stripBom(readFileSync(sidecarPath, "utf-8"))) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : null; + return parseJsonObject(stripBom(readFileSync(sidecarPath, "utf-8"))); } catch { return null; } @@ -286,15 +285,13 @@ export function planChangedMediaRegistration( if (changed.size === 0) return null; const sidecarPath = resolve(projectDir, "draft_meta_info.json"); let raw: string; - let sidecar: Record; try { raw = readFileSync(sidecarPath, "utf-8"); - const parsed: unknown = JSON.parse(stripBom(raw)); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - sidecar = parsed as Record; } catch { return null; } + const sidecar = parseJsonObject(stripBom(raw)); + if (sidecar === null) return null; const byPath = new Map(); for (const entry of registeredEntries(sidecar.draft_materials)) { diff --git a/src/matting.ts b/src/matting.ts index bee03c2..4be23a3 100644 --- a/src/matting.ts +++ b/src/matting.ts @@ -22,7 +22,7 @@ // real thing the moment a matted app draft is shared. import type { Draft, MaterialVideo, Segment } from "./draft.js"; -import { findSegment } from "./draft.js"; +import { requireSegment } from "./segment-lookup.js"; export const MATTING_OFF = 0; export const MATTING_SMART_PORTRAIT = 3; @@ -73,8 +73,7 @@ function resolveVideoMaterial( draft: Draft, segId: string, ): { segment: Segment; material: MaterialVideo; shared: string[] } { - 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( `Smart matting only applies to video/photo segments (segment ${segId} is on a ${found.track.type} track)`, @@ -118,10 +117,3 @@ export function clearMatting(draft: Draft, segId: string): MattingResult { shared_segments: shared, }; } - -/** Read the matting state of a segment's material without writing. */ -export function mattingState(draft: Draft, segId: string): { segmentId: string; materialId: string; flag: number } { - const { segment, material } = resolveVideoMaterial(draft, segId); - const flag = isRecord(material.matting) && typeof material.matting.flag === "number" ? material.matting.flag : 0; - return { segmentId: segment.id, materialId: material.id, flag }; -} diff --git a/src/replace.ts b/src/replace.ts index c225f0b..9a522c9 100644 --- a/src/replace.ts +++ b/src/replace.ts @@ -8,9 +8,10 @@ import { existsSync, statSync } from "node:fs"; import { basename, resolve } from "node:path"; -import { type Draft, findMaterialGlobal, findSegment } from "./draft.js"; +import { type Draft, findMaterialGlobal } from "./draft.js"; import { copyAssetDeduped, planAssetCopy } from "./factory.js"; import { probeMedia } from "./probe.js"; +import { requireSegment } from "./segment-lookup.js"; import { draftProjectDir } from "./store.js"; export interface ReplaceMediaOptions { @@ -53,8 +54,7 @@ export function replaceMedia(draft: Draft, filePath: string, opts: ReplaceMediaO if (!existsSync(opts.newPath)) throw new Error(`Replacement file not found: ${opts.newPath}`); if (!statSync(opts.newPath).isFile()) throw new Error(`Replacement media must be a regular file: ${opts.newPath}`); - const hit = findSegment(draft, opts.segmentId); - if (!hit) throw new Error(`Segment not found: ${opts.segmentId}`); + const hit = requireSegment(draft, opts.segmentId); const materialId = hit.segment.material_id; if (!materialId) throw new Error(`Segment ${opts.segmentId} has no material_id to replace.`); diff --git a/src/scenes.ts b/src/scenes.ts index f50f99c..dbb60d5 100644 --- a/src/scenes.ts +++ b/src/scenes.ts @@ -1,5 +1,5 @@ -import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { runFfmpegAnalysis } from "./ffmpeg-pass.js"; import { probeMedia } from "./probe.js"; /** @@ -187,44 +187,17 @@ export function detectScenes(videoPath: string, opts: SceneDetectOptions = {}): "null", "-", ]; - let r: ReturnType; - try { - r = spawnSync(cmd, args, { encoding: "utf-8", timeout: timeoutMs, maxBuffer }); - } catch (e) { - throw new Error( - `detect-scenes: ffmpeg is unavailable at '${cmd}'. Install ffmpeg or pass --ffmpeg-cmd . (${ - e instanceof Error ? e.message : String(e) - })`, - ); - } - if (r.error) { - // spawnSync reports its own limits via r.error, not by throwing — a tripped - // timeout/buffer means ffmpeg RAN, so "install ffmpeg" would be a lie. - const code = (r.error as NodeJS.ErrnoException).code; - if (code === "ETIMEDOUT") { - throw new Error( - `detect-scenes: scene detection timed out after ${timeoutMs / 1000}s on ${videoPath}. ` + - "Try a shorter or lower-resolution input.", - ); - } - if (code === "ENOBUFS") { - const mib = maxBuffer / (1024 * 1024); - const cap = mib >= 1 ? `${Math.round(mib)} MiB` : `${maxBuffer}-byte`; - throw new Error( - `detect-scenes: ffmpeg output exceeded the ${cap} buffer on ${videoPath}. ` + - "Raise --threshold to emit fewer cut candidates.", - ); - } - throw new Error( - `detect-scenes: ffmpeg is unavailable at '${cmd}'. Install ffmpeg or pass --ffmpeg-cmd . (${ - code ?? r.error.message - })`, - ); - } - const stderr = typeof r.stderr === "string" ? r.stderr : ""; - if (r.status !== 0) { - throw new Error(`detect-scenes: ffmpeg failed on ${videoPath}.\n${stderr.slice(-600)}`); - } + const stderr = runFfmpegAnalysis({ + label: "detect-scenes", + what: "scene detection", + cmd, + mediaPath: videoPath, + args, + timeoutMs, + maxBuffer, + timeoutHint: "Try a shorter or lower-resolution input.", + bufferHint: "Raise --threshold to emit fewer cut candidates.", + }); // Segment ends must come from the VIDEO stream's duration. The container // header ("Duration:") is the LONGEST stream — when the audio track outlasts // the video (common in screen recordings), it would point the final segment diff --git a/src/segment-lookup.ts b/src/segment-lookup.ts new file mode 100644 index 0000000..c4aabe2 --- /dev/null +++ b/src/segment-lookup.ts @@ -0,0 +1,13 @@ +import type { Draft, Segment, Track } from "./draft.js"; +import { findSegment } from "./draft.js"; + +/** + * The segment an editing operation targets, by exact id or case-insensitive id + * prefix (first match in track order), or the library's standard + * `Segment not found: ` error. + */ +export function requireSegment(draft: Draft, id: string): { track: Track; segment: Segment; index: number } { + const found = findSegment(draft, id); + if (!found) throw new Error(`Segment not found: ${id}`); + return found; +} diff --git a/src/silence.ts b/src/silence.ts index edbbff6..f58fdcc 100644 --- a/src/silence.ts +++ b/src/silence.ts @@ -1,5 +1,5 @@ -import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { runFfmpegAnalysis } from "./ffmpeg-pass.js"; import { probeMedia } from "./probe.js"; import { parseFfmpegDuration, type SceneSegment } from "./scenes.js"; @@ -193,44 +193,17 @@ export function detectSilence(mediaPath: string, opts: SilenceDetectOptions = {} "null", "-", ]; - let r: ReturnType; - try { - r = spawnSync(cmd, args, { encoding: "utf-8", timeout: timeoutMs, maxBuffer }); - } catch (e) { - throw new Error( - `detect-silence: ffmpeg is unavailable at '${cmd}'. Install ffmpeg or pass --ffmpeg-cmd . (${ - e instanceof Error ? e.message : String(e) - })`, - ); - } - if (r.error) { - // spawnSync reports its own limits via r.error, not by throwing — a tripped - // timeout/buffer means ffmpeg RAN, so "install ffmpeg" would be a lie. - const code = (r.error as NodeJS.ErrnoException).code; - if (code === "ETIMEDOUT") { - throw new Error( - `detect-silence: silence detection timed out after ${timeoutMs / 1000}s on ${mediaPath}. ` + - "Try a shorter input.", - ); - } - if (code === "ENOBUFS") { - const mib = maxBuffer / (1024 * 1024); - const cap = mib >= 1 ? `${Math.round(mib)} MiB` : `${maxBuffer}-byte`; - throw new Error( - `detect-silence: ffmpeg output exceeded the ${cap} buffer on ${mediaPath}. ` + - "Raise --min-silence to report fewer silence spans.", - ); - } - throw new Error( - `detect-silence: ffmpeg is unavailable at '${cmd}'. Install ffmpeg or pass --ffmpeg-cmd . (${ - code ?? r.error.message - })`, - ); - } - const stderr = typeof r.stderr === "string" ? r.stderr : ""; - if (r.status !== 0) { - throw new Error(`detect-silence: ffmpeg failed on ${mediaPath}.\n${stderr.slice(-600)}`); - } + const stderr = runFfmpegAnalysis({ + label: "detect-silence", + what: "silence detection", + cmd, + mediaPath: mediaPath, + args, + timeoutMs, + maxBuffer, + timeoutHint: "Try a shorter input.", + bufferHint: "Raise --min-silence to report fewer silence spans.", + }); // Keep segments must span the whole file, so the bound is the CONTAINER // duration (silence applies to the file timeline `cut` operates on, not one // stream). ffprobe reads the precise metadata; the centisecond-rounded diff --git a/src/user-enums.ts b/src/user-enums.ts index 21827ca..41197f8 100644 --- a/src/user-enums.ts +++ b/src/user-enums.ts @@ -1,16 +1,7 @@ -import { - closeSync, - existsSync, - fsyncSync, - mkdirSync, - openSync, - readFileSync, - renameSync, - statSync, - writeSync, -} from "node:fs"; +import { existsSync, readFileSync, statSync } 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 type { Category, EnumEntry } from "./enums.js"; @@ -275,19 +266,6 @@ export function harvestDraft(draft: Draft, knownIds: Set): HarvestResult return { found, known, candidates }; } -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); -} - /** Merge candidates into the catalogue file (created if absent). Existing * entries win: a candidate whose id pair is already present is a duplicate. */ export function mergeUserEnums( @@ -312,7 +290,7 @@ export function mergeUserEnums( } if (added > 0) { const file: UserEnumsFile = { version: 1, entries: merged }; - writeAtomicLocal(path, `${JSON.stringify(file, null, 2)}\n`); + writeFileAtomic(path, `${JSON.stringify(file, null, 2)}\n`); clearUserEnumsCache(); } return { added, duplicates, total: merged.length }; diff --git a/test/atomic-temp.test.mjs b/test/atomic-temp.test.mjs index a4fbb9d..4fdf9f4 100644 --- a/test/atomic-temp.test.mjs +++ b/test/atomic-temp.test.mjs @@ -1,8 +1,19 @@ import assert from "node:assert/strict"; -import { lstatSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, describe, it } from "node:test"; +import { writeFileAtomic } from "../dist/atomic-write.js"; import { writeAtomic } from "../dist/draft.js"; // Every mutating command stages its write in a temp file next to the target, @@ -78,3 +89,44 @@ describe("atomic write: temp files are unpredictable and exclusively created", ( assert.equal(readFileSync(victim, "utf-8"), "untouched"); }); }); + +// The user-enums catalogue and the app-version tracker save through +// writeFileAtomic: the same staging as a draft save, plus the parent folder. +describe("atomic state-file write: parent folder, private mode, no planted temp", () => { + const dirs = []; + const realNow = Date.now; + after(() => { + Date.now = realNow; + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + }); + + it("creates the missing parent folder and leaves only the target behind", () => { + const dir = mkdtempSync(join(tmpdir(), "capcut-atomic-state-")); + dirs.push(dir); + const target = join(dir, "config", "capcut-cli", "user-enums.json"); + writeFileAtomic(target, '{"version":1}\n'); + assert.equal(readFileSync(target, "utf-8"), '{"version":1}\n'); + assert.deepEqual(readdirSync(join(dir, "config", "capcut-cli")), ["user-enums.json"]); + if (process.platform !== "win32") assert.equal(statSync(target).mode & 0o777, 0o600); + }); + + it("never writes through a symlink planted at the predictable temp path", () => { + const dir = mkdtempSync(join(tmpdir(), "capcut-atomic-state-")); + dirs.push(dir); + mkdirSync(join(dir, "state")); + const victim = join(dir, "victim.txt"); + writeFileSync(victim, "untouched"); + const target = join(dir, "state", "app-versions.json"); + for (const path of legacyTempPaths(target)) symlinkSync(victim, path); + + Date.now = () => FROZEN_MS; + try { + writeFileAtomic(target, "tracked"); + } finally { + Date.now = realNow; + } + + assert.equal(readFileSync(victim, "utf-8"), "untouched"); + assert.equal(readFileSync(target, "utf-8"), "tracked"); + }); +}); diff --git a/test/detect-ffmpeg-errors.test.mjs b/test/detect-ffmpeg-errors.test.mjs new file mode 100644 index 0000000..b345408 --- /dev/null +++ b/test/detect-ffmpeg-errors.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { detectScenes } from "../dist/scenes.js"; +import { detectSilence } from "../dist/silence.js"; + +// detect-scenes and detect-silence run the same kind of ffmpeg analysis pass; +// each failure mode keeps its own command label and actionable hint. +const isWindows = process.platform === "win32"; + +const DETECTORS = [ + { + run: detectScenes, + label: "detect-scenes", + what: "scene detection", + timeoutHint: "Try a shorter or lower-resolution input.", + bufferHint: "Raise --threshold to emit fewer cut candidates.", + }, + { + run: detectSilence, + label: "detect-silence", + what: "silence detection", + timeoutHint: "Try a shorter input.", + bufferHint: "Raise --min-silence to report fewer silence spans.", + }, +]; + +describe("detect-scenes / detect-silence: ffmpeg failure messages", { skip: isWindows }, () => { + const dir = mkdtempSync(join(tmpdir(), "capcut-ffmpeg-pass-")); + after(() => rmSync(dir, { recursive: true, force: true })); + const media = join(dir, "clip.mp4"); + writeFileSync(media, ""); + function fake(name, body) { + const path = join(dir, name); + writeFileSync(path, `#!/bin/sh\n${body}\n`); + chmodSync(path, 0o755); + return path; + } + const slow = fake("slow-ffmpeg", "sleep 3"); + const noisy = fake("noisy-ffmpeg", "head -c 200000 /dev/zero"); + const failing = fake("failing-ffmpeg", "echo boom >&2; exit 3"); + + for (const d of DETECTORS) { + it(`${d.label}: unavailable, timeout, buffer and exit failures`, () => { + assert.throws(() => d.run(media, { ffmpegCmd: "/nonexistent/ffmpeg" }), { + message: `${d.label}: ffmpeg is unavailable at '/nonexistent/ffmpeg'. Install ffmpeg or pass --ffmpeg-cmd . (ENOENT)`, + }); + assert.throws(() => d.run(media, { ffmpegCmd: slow, timeoutMs: 250 }), { + message: `${d.label}: ${d.what} timed out after 0.25s on ${media}. ${d.timeoutHint}`, + }); + assert.throws(() => d.run(media, { ffmpegCmd: noisy, maxBufferBytes: 1024 }), { + message: `${d.label}: ffmpeg output exceeded the 1024-byte buffer on ${media}. ${d.bufferHint}`, + }); + assert.throws(() => d.run(media, { ffmpegCmd: failing }), { + message: `${d.label}: ffmpeg failed on ${media}.\nboom\n`, + }); + }); + } +}); diff --git a/test/json-object-file.test.mjs b/test/json-object-file.test.mjs new file mode 100644 index 0000000..be00eee --- /dev/null +++ b/test/json-object-file.test.mjs @@ -0,0 +1,38 @@ +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 { parseJsonObject, readJsonObjectFile, readTextIfPresent } from "../dist/bom.js"; + +// register, rename and media registration all read CapCut metadata files the +// same way: "missing" (raw null) and "corrupt" (raw set, obj null) are +// different outcomes for them, and a BOM never makes a file corrupt. +describe("readJsonObjectFile: missing vs corrupt metadata", () => { + const dir = mkdtempSync(join(tmpdir(), "capcut-json-object-")); + after(() => rmSync(dir, { recursive: true, force: true })); + + it("reports an absent file as raw null and obj null", () => { + assert.deepEqual(readJsonObjectFile(join(dir, "absent.json")), { raw: null, obj: null }); + assert.equal(readTextIfPresent(join(dir, "absent.json")), null); + }); + + it("strips a BOM from raw and parses the object", () => { + const path = join(dir, "bom.json"); + writeFileSync(path, '{"draft_id":"A"}'); + assert.deepEqual(readJsonObjectFile(path), { raw: '{"draft_id":"A"}', obj: { draft_id: "A" } }); + }); + + it("keeps raw but no obj for an array, a scalar or broken JSON", () => { + for (const [name, text] of [ + ["array.json", "[1,2]"], + ["scalar.json", "42"], + ["broken.json", "{not json"], + ]) { + const path = join(dir, name); + writeFileSync(path, text); + assert.deepEqual(readJsonObjectFile(path), { raw: text, obj: null }, name); + } + assert.equal(parseJsonObject("null"), null); + }); +}); diff --git a/test/rename.test.mjs b/test/rename.test.mjs index 5ebe929..93d6dbb 100644 --- a/test/rename.test.mjs +++ b/test/rename.test.mjs @@ -287,3 +287,47 @@ describe("rename", () => { assert.ok(!existsSync(join(loose, "root_meta_info.json")), "no index may be invented outside a store"); }); }); + +// register and rename plan their metadata writes from one read and apply them +// later; a file edited in between (CapCut saving, another tool) must stop the +// apply instead of being overwritten, unless the caller forces the write. +describe("register/rename apply: optimistic concurrency", () => { + it("rename refuses when the sidecar changed after planning, and leaves the folder in place", async () => { + const { planDraftRename, applyDraftRename } = await import("../dist/factory.js"); + const f = storeFixture(); + after(f.cleanup); + const plan = planDraftRename(f.dir, "Better Name", { draftsDir: f.root }); + writeFileSync(join(f.dir, "draft_meta_info.json"), JSON.stringify({ edited: true })); + assert.throws( + () => applyDraftRename(plan), + /^Error: Draft changed on disk after it was planned: draft_meta_info\.json\. Re-run rename, or pass --force-write to overwrite intentionally\.$/, + ); + assert.ok(existsSync(f.dir), "the folder moved despite the refusal"); + assert.ok(!existsSync(f.newDir)); + }); + + it("rename with forceWrite proceeds over the edited sidecar", async () => { + const { planDraftRename, applyDraftRename } = await import("../dist/factory.js"); + const f = storeFixture(); + after(f.cleanup); + const plan = planDraftRename(f.dir, "Better Name", { draftsDir: f.root }); + writeFileSync(join(f.dir, "draft_meta_info.json"), JSON.stringify({ edited: true })); + assert.equal(applyDraftRename(plan, { forceWrite: true }).renamed, true); + assert.ok(existsSync(f.newDir)); + }); + + it("register refuses when a file it planned to create appeared after planning", async () => { + const { planDraftRegistration, applyDraftRegistration } = await import("../dist/factory.js"); + const f = storeFixture({ withMeta: false }); + after(f.cleanup); + const plan = planDraftRegistration(f.dir, { draftsDir: f.root }); + const created = plan.writes.find((w) => w.file === "draft_meta_info.json"); + assert.ok(created && created.previous === null, "fixture should plan a new sidecar"); + writeFileSync(join(f.dir, "draft_meta_info.json"), "{}"); + assert.throws( + () => applyDraftRegistration(plan), + /^Error: Draft changed on disk after it was planned: draft_meta_info\.json\. Re-run register, or pass --force-write to overwrite intentionally\.$/, + ); + assert.equal(readFileSync(join(f.dir, "draft_meta_info.json"), "utf-8"), "{}"); + }); +});