diff --git a/CHANGELOG.md b/CHANGELOG.md index 14519a7..18821f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to capcut-cli are documented here. The format follows [Keep ## [Unreleased] +### Added + +- **Frame-grid helpers let library callers match CapCut's duration rounding** + ([#76](https://github.com/renezander030/capcut-cli/issues/76)). + `quantizeToFrame(us, fps)` returns the nearest on-grid microsecond duration, + while `framesFor(us, fps)` exposes the frame count. Both fall back to 30 fps + for missing or invalid rates, floor positive durations at one frame, and keep + negative durations signed; existing commands remain unchanged and callers opt + in through the public library API. + ### Documentation - **The mask-keyframe section records what the encoding search has already ruled out** ([#44](https://github.com/renezander030/capcut-cli/issues/44)). `docs/draft-schema/03-keyframes-and-animations.md` said no capture exists in the neighbouring ecosystem tools without naming what had been checked, so anyone picking the issue up starts that search from zero. It now names the negative result: `pyJianYingDraft`'s `KeyframeProperty` enum — the upstream `src/enums.json` is extracted from — carries the same eleven properties this CLI exposes and nothing for mask geometry, so there is no encoding to borrow and the ground truth has to come from an app-authored capture. No behaviour change; the CLI still declines to write mask keyframes. diff --git a/docs/draft-schema/01-tracks-and-segments.md b/docs/draft-schema/01-tracks-and-segments.md index e696bee..6858797 100644 --- a/docs/draft-schema/01-tracks-and-segments.md +++ b/docs/draft-schema/01-tracks-and-segments.md @@ -134,6 +134,22 @@ quantised = Math.round(us / frame) * frame Round durations rather than truncating them — CapCut rounds to nearest, so truncating drifts a frame short over a long timeline. +The public library exports the same calculation so callers do not have to +reimplement it: + +```js +import { framesFor, quantizeToFrame } from "capcut-cli"; + +const frames = framesFor(durationUs, draft.fps); +const onGridDurationUs = quantizeToFrame(durationUs, draft.fps); +``` + +Both helpers fall back to 30 fps when the supplied rate is missing, non-numeric, +zero, or negative. A positive duration that would round to zero is floored to one +frame, while zero stays zero. Negative durations keep their signed nearest-frame +result; the helpers do not clamp or reject them. They are opt-in and do not change +what any CLI command writes. + ## Special segments ### Audio segments diff --git a/package.json b/package.json index 9ea13c3..07d7305 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dist/lint.d.ts", "dist/runner.d.ts", "dist/store.d.ts", + "dist/time.d.ts", "dist/version.d.ts", "docs", "examples", diff --git a/src/lib.ts b/src/lib.ts index 43e0909..28979ab 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -47,5 +47,6 @@ export { } from "./lint.js"; export type { RunCommandRequest, RunCommandResult } from "./runner.js"; export { runCommand } from "./runner.js"; +export { framesFor, quantizeToFrame } from "./time.js"; export type { AppSource, VersionInfo } from "./version.js"; export { detectVersion } from "./version.js"; diff --git a/src/time.ts b/src/time.ts index 2e680ab..16cb540 100644 --- a/src/time.ts +++ b/src/time.ts @@ -1,5 +1,26 @@ const US_PER_SEC = 1_000_000; const US_PER_MS = 1_000; +const DEFAULT_FPS = 30; + +function frameRate(fps?: number): number { + return typeof fps === "number" && Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_FPS; +} + +function framesAtRate(us: number, rate: number): number { + const frames = Math.round((us / US_PER_SEC) * rate); + return us > 0 && frames === 0 ? 1 : frames; +} + +/** Positive durations use at least one frame; negative durations keep their signed rounding. */ +export function framesFor(us: number, fps?: number): number { + return framesAtRate(us, frameRate(fps)); +} + +/** Quantize to the nearest frame without collapsing a positive duration to zero. */ +export function quantizeToFrame(us: number, fps?: number): number { + const rate = frameRate(fps); + return Math.round((framesAtRate(us, rate) * US_PER_SEC) / rate); +} export function secondsToUs(s: number): number { return Math.round(s * US_PER_SEC); diff --git a/test/frame-grid.test.mjs b/test/frame-grid.test.mjs new file mode 100644 index 0000000..b9f29a2 --- /dev/null +++ b/test/frame-grid.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LIB = pathToFileURL(join(__dirname, "..", "dist", "lib.js")).href; + +describe("frame-grid helpers", () => { + it("maps every measured 30 fps duration to its observed frame", async () => { + const { framesFor, quantizeToFrame } = await import(LIB); + const samples = [ + { written: 2_767_000, frames: 83, quantized: [2_766_666, 2_766_667] }, + { written: 3_133_000, frames: 94, quantized: [3_133_333, 3_133_334] }, + { written: 334_000, frames: 10, quantized: [333_333] }, + { written: 866_000, frames: 26, quantized: [866_667] }, + { written: 867_000, frames: 26, quantized: [866_667] }, + ]; + + for (const sample of samples) { + assert.equal(framesFor(sample.written, 30), sample.frames); + assert.ok(sample.quantized.includes(quantizeToFrame(sample.written, 30))); + } + }); + + it("keeps a 33 ms sub-frame duration on one frame", async () => { + const { framesFor, quantizeToFrame } = await import(LIB); + + assert.equal(framesFor(33_000, 30), 1); + assert.equal(quantizeToFrame(33_000, 30), 33_333); + }); + + it("floors positive sub-half-frame durations to one frame", async () => { + const { framesFor, quantizeToFrame } = await import(LIB); + + for (const durationUs of [1_000, 16_000]) { + assert.equal(framesFor(durationUs, 30), 1); + assert.equal(quantizeToFrame(durationUs, 30), 33_333); + } + assert.equal(framesFor(0, 30), 0); + assert.equal(quantizeToFrame(0, 30), 0); + }); + + it("keeps negative durations on their signed nearest frame", async () => { + const { framesFor, quantizeToFrame } = await import(LIB); + + assert.equal(framesFor(-100_000, 30), -3); + assert.equal(quantizeToFrame(-100_000, 30), -100_000); + }); + + it("handles fractional frame rates with finite non-negative results", async () => { + const { framesFor, quantizeToFrame } = await import(LIB); + + for (const fps of [29.97, 23.976]) { + assert.ok(Number.isFinite(framesFor(1_000_000, fps))); + assert.ok(framesFor(1_000_000, fps) >= 0); + assert.ok(Number.isFinite(quantizeToFrame(1_000_000, fps))); + assert.ok(quantizeToFrame(1_000_000, fps) >= 0); + } + }); + + it("falls back to 30 fps for invalid or missing rates", async () => { + const { framesFor, quantizeToFrame } = await import(LIB); + const expectedFrames = framesFor(866_000, 30); + const expectedDuration = quantizeToFrame(866_000, 30); + + for (const fps of [0, -30, undefined, "30", Number.NaN]) { + assert.equal(framesFor(866_000, fps), expectedFrames); + assert.equal(quantizeToFrame(866_000, fps), expectedDuration); + } + }); +});