Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions docs/draft-schema/01-tracks-and-segments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
21 changes: 21 additions & 0 deletions src/time.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
72 changes: 72 additions & 0 deletions test/frame-grid.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
});