From 9d13dbdeb780934d8ec1d3cc10a53e71bcadeeb4 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:19:22 +0400 Subject: [PATCH 1/2] feat(time): add frame-grid helpers Expose framesFor and quantizeToFrame through the public library entry point so callers can opt into CapCut's nearest-frame duration rounding without changing any existing command writes.\n\nCover the measured issue rows, sub-frame behavior, fractional rates, and the documented 30 fps fallback.\n\nCloses #76.\n\nAI-assisted: OpenAI Codex helped implement and validate this change; the contributor reviewed the final diff. --- CHANGELOG.md | 9 ++++ docs/draft-schema/01-tracks-and-segments.md | 13 +++++ package.json | 1 + src/lib.ts | 1 + src/time.ts | 14 ++++++ test/frame-grid.test.mjs | 54 +++++++++++++++++++++ 6 files changed, 92 insertions(+) create mode 100644 test/frame-grid.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 14519a7..dcfa6c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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; 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..90963e8 100644 --- a/docs/draft-schema/01-tracks-and-segments.md +++ b/docs/draft-schema/01-tracks-and-segments.md @@ -134,6 +134,19 @@ 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. 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..c83c690 100644 --- a/src/time.ts +++ b/src/time.ts @@ -1,5 +1,19 @@ 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; +} + +export function framesFor(us: number, fps?: number): number { + return Math.round((us / US_PER_SEC) * frameRate(fps)); +} + +export function quantizeToFrame(us: number, fps?: number): number { + const rate = frameRate(fps); + return Math.round((framesFor(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..fb415d9 --- /dev/null +++ b/test/frame-grid.test.mjs @@ -0,0 +1,54 @@ +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("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); + } + }); +}); From a9173158d8c14e658a71bd7d940731525433fa97 Mon Sep 17 00:00:00 2001 From: Floze <88098863+floze-the-genius@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:10:54 +0400 Subject: [PATCH 2/2] fix(time): preserve short positive durations Floor positive sub-half-frame durations at one frame while preserving zero and signed negative rounding. Document and test the boundary explicitly. AI-assisted: OpenAI Codex helped implement and validate this maintainer-requested follow-up; the contributor reviewed the final diff. --- CHANGELOG.md | 5 +++-- docs/draft-schema/01-tracks-and-segments.md | 5 ++++- src/time.ts | 11 +++++++++-- test/frame-grid.test.mjs | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcfa6c3..18821f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,9 @@ All notable changes to capcut-cli are documented here. The format follows [Keep ([#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; existing commands remain unchanged and callers - opt in through the public library API. + 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 diff --git a/docs/draft-schema/01-tracks-and-segments.md b/docs/draft-schema/01-tracks-and-segments.md index 90963e8..6858797 100644 --- a/docs/draft-schema/01-tracks-and-segments.md +++ b/docs/draft-schema/01-tracks-and-segments.md @@ -145,7 +145,10 @@ const onGridDurationUs = quantizeToFrame(durationUs, draft.fps); ``` Both helpers fall back to 30 fps when the supplied rate is missing, non-numeric, -zero, or negative. They are opt-in and do not change what any CLI command writes. +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 diff --git a/src/time.ts b/src/time.ts index c83c690..16cb540 100644 --- a/src/time.ts +++ b/src/time.ts @@ -6,13 +6,20 @@ 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 Math.round((us / US_PER_SEC) * frameRate(fps)); + 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((framesFor(us, rate) * US_PER_SEC) / rate); + return Math.round((framesAtRate(us, rate) * US_PER_SEC) / rate); } export function secondsToUs(s: number): number { diff --git a/test/frame-grid.test.mjs b/test/frame-grid.test.mjs index fb415d9..b9f29a2 100644 --- a/test/frame-grid.test.mjs +++ b/test/frame-grid.test.mjs @@ -30,6 +30,24 @@ describe("frame-grid helpers", () => { 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);