One integer per position, one rational per conversion: a 254,016,000,000-tick timebase keeps a 25 → 23.976 EDL inside half a frame.
A 25 fps edit goes into a 23.976 project. The cuts land where they should — until cut 40, where the picture is one frame late. The fix everyone applies is to nudge the offset by hand, which is not a fix: it is a decision to have the same problem again next month.
The cause is almost never the NLE. It is that 23.976 is not a frame rate. It is a nickname for 24000/1001, and the gap between the nickname and the number is 0.000024 frames per second — small enough to pass a test, large enough to lose a frame every 11.6 hours of programme.
What follows is the arithmetic that makes that loss impossible: one integer per position, one rational per conversion, and a residual small enough to print.
Repo: github.com/edgarmilvus/artcraft-book · GitHub: @edgarmilvus · Ebook: ArtCraft: The Agentic Creative Studio in TypeScript
The nickname costs a frame every 11 hours
Do the division. One hour of 23.976 material is not 86,313.6 frames:
| frames in one hour | |
|---|---|
23.976 × 3600 | 86,313.600 |
24000/1001 × 3600 | 86,313.686 |
The decimal loses 0.086 frames per hour. That is one whole frame every 11.59 hours — and it is a systematic error, always in the same direction, so it never cancels. A two-hour feature ends up two-tenths of a frame short; a ten-hour series ends up almost a frame short, on every episode, at the same place.
29.97 has the same problem (30000/1001 = 29.97002997…), and so does 59.94. These are not decimals with rounding noise. They are ratios of small integers, and the moment you store them as float you have thrown away the thing that made them exact.
One number, divisible by everything
FilmCraft stores every position as an integer count of ticks, and the tick is fixed:
const TPS = 254_016_000_000n; // ticks per second const TICKS_25 = TPS / 25n; // 10,160,640,000 const TICKS_23976 = (TPS * 1001n) / 24000n; // 10,594,584,000 const TICKS_2997 = (TPS * 1001n) / 30000n; // 8,475,667,200 const TICKS_30 = TPS / 30n; // 8,467,200,000 Two hundred and fifty-four billion ticks per second sounds absurd until you factor it:
254_016_000_000 = 2¹² · 3⁴ · 5⁶ · 7² Every standard rate and sample rate divides that, exactly:
| rate | ticks per unit |
|---|---|
| 24 fps | 10,584,000,000 |
| 25 fps | 10,160,640,000 |
| 30 fps | 8,467,200,000 |
| 48 fps | 5,292,000,000 |
| 60 fps | 4,233,600,000 |
| 24000/1001 | 10,594,584,000 |
| 30000/1001 | 8,475,667,200 |
| 48 kHz sample | 5,292,000 |
| 44.1 kHz sample | 5,760,000 |
| 96 kHz sample | 2,646,000 |
No float, no epsilon, no "close enough". A frame boundary and a sample boundary are both integers, so the same number describes a cut point in a video track and the audio sample that must not be split.
The speed change is one rational
Converting a 25 fps master to 23.976 is not a lookup table. It is a deliberate 4.27% slow-down — the ratio between the two rates:
25 / (24000/1001) = 25025/24000 So a source frame f25 maps to f25 × 25025 / 24000 in target frames. Multiplying by 25025 and dividing by 24000 in bigint is the whole conversion; the only decision left is how to round.
Rounding that doesn't lie
The obvious choice, round-half-up, has a bias: for values that land exactly on .5, it always goes up. Ten thousand cut edges that happen to land on halves produce a drift of 5,000 frames — a systematic error you introduced while trying to avoid one.
The harness uses round-half-to-even, the same rule IEEE 754 uses for a reason:
export function bankerRound(n: bigint, d: bigint): bigint { const q = n / d, r = n - q * d, twice = 2n * r; if (twice > d) return q + 1n; if (twice < d) return q; return (q & 1n) === 0n ? q : q + 1n; } Ties go to the even neighbour, so over many conversions they cancel instead of accumulating.
The residual is the proof
Rounding a rational always leaves something behind, and the useful thing is not that the error is small — it is that you can measure it in the same units everything else uses:
const residFrameNumer = numer - rounded * denom; // in [−denom/2, +denom/2] const residualTicks = (residFrameNumer * TICKS_23976) / denom; That residual then converts to microseconds and to samples with two divisions, because the tick is divisible by both. The harness runs twelve cuts, checks every edge, and prints:
maxResidualAbsTicks: 5,153,823,675 = 20,289 µs = 973 samples @ 48 kHz half a 23.976 frame: 5,297,292,000 So the worst edge in the test sits half a frame from the exact rational position — which is the best any integer frame grid can do — and the module throws if it ever goes over:
const halfTargetFrame = TICKS_23976 / 2n + (TICKS_23976 & 1n); if (maxAbsResid > halfTargetFrame) throw new Error("residual exceeded half-frame"); That throw is the part worth copying. A converter that silently accepts a two-frame residual is a converter that will hand you a sync problem in the mix.
Drop-frame is bookkeeping, not a format
29.97 drop-frame timecode exists for one reason: HH:MM:SS:FF at 30 fps drifts against wall clock, so the standard drops labels — frame numbers 00 and 01 at every minute boundary except each tenth minute.
Nothing is dropped from the media. Per ten minutes that is 18 labels skipped, which is why the constants in the code are 17982 frames per ten minutes and 1798 per minute rather than the round 18,000 and 1,800.
Get it backwards and you shave two frames out of every minute of programme. The harness round-trips a sample of frame numbers — including 1,800 (the first drop) and 17,982 (the ten-minute boundary that does not drop) — and fails if any timecode does not come back to the frame it started from.
The module
This is the file, exactly as it runs in the repository:
import { writeFileSync } from "node:fs"; // ─── Rational ground truth over 254_016_000_000 ticks/second ──────────────── const TPS = 254_016_000_000n; const TICKS_25 = TPS / 25n; // 10_160_640_000n const TICKS_23976 = (TPS * 1001n) / 24000n; // 10_594_584_000n const TICKS_2997 = (TPS * 1001n) / 30000n; // 8_475_667_200n const TICKS_30 = TPS / 30n; // 8_467_200_000n // 25 → 23.976 rational: f25 × 25025 / 24000 const RAT_NUM_25_23976 = 25025n; const RAT_DEN_25_23976 = 24000n; // ─── Round-half-to-even on (n, d) with n,d >= 0n ──────────────────────────── export function bankerRound(n: bigint, d: bigint): bigint { const q = n / d, r = n - q * d, twice = 2n * r; if (twice > d) return q + 1n; if (twice < d) return q; return (q & 1n) === 0n ? q : q + 1n; } // ─── 25-fps frame number → target frame 23.976 via one rational ───────────── export function f25ToF23Rounded(f25: bigint): { rounded: bigint; residualTicks: bigint } { // f23_rational = f25 × 25025 / 24000 (in target *frame* units) const numer = f25 * RAT_NUM_25_23976; const denom = RAT_DEN_25_23976; const rounded = bankerRound(numer, denom); // Residual in *ticks* = (f23_rational - rounded) × TICKS_23976 // = (numer/denom - rounded) × TICKS_23976 // Keep integers: (numer - rounded*denom) * TICKS_23976 / denom const residFrameNumer = numer - rounded * denom; // in [−denom/2, +denom/2] const residualTicks = (residFrameNumer * TICKS_23976) / denom; return { rounded, residualTicks }; } // ─── Drop-frame timecode conversion (29.97 DF) ───────────────────────────── // DF skips frame *numbers* 00 and 01 at each minute boundary except each 10th. export function nfToDfTimecode(nf: number): string { if (!Number.isInteger(nf) || nf < 0) throw new Error("nf must be non-negative int"); const dropPer10Min = 18; // 2 frames × 9 minutes const framesPer10Min = 17982; // 30 × 600 - 18 const framesPerMin = 1798; // 30 × 60 - 2 const tens = Math.floor(nf / framesPer10Min); const rest = nf % framesPer10Min; const adjusted = nf + tens * dropPer10Min + (rest >= 2 ? 2 * Math.floor((rest - 2) / framesPerMin) : 0); const frames = adjusted % 30; const totalSeconds = Math.floor(adjusted / 30); const seconds = totalSeconds % 60; const minutes = Math.floor(totalSeconds / 60) % 60; const hours = Math.floor(totalSeconds / 3600); return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:` + `${String(seconds).padStart(2, "0")};${String(frames).padStart(2, "0")}`; } export function dfTimecodeToNf(tc: string): number { const m = /^(\d{2}):(\d{2}):(\d{2});(\d{2})$/.exec(tc); if (!m) throw new Error("bad DF timecode " + tc); const hh = +m[1], mm = +m[2], ss = +m[3], ff = +m[4]; const totalMinutes = 60 * hh + mm; const dropped = 2 * (totalMinutes - Math.floor(totalMinutes / 10)); return (((hh * 60 + mm) * 60 + ss) * 30 + ff) - dropped; } // ─── Types & main conversion ──────────────────────────────────────────────── export interface EdlCut { cutId: string; sourceIn25: number; sourceOut25: number; } export interface ConversionRow { cutId: string; sourceFrame25: number; sourceTick: bigint; targetFrame23Rounded: number; targetTick: bigint; residualTicks: bigint; df29Timecode: string; nf29Frame: number; } export interface ParityReport { maxResidualAbsTicks: bigint; maxResidualMicroseconds: number; maxResidualSamples48k: number; cutsProcessed: number; dfRoundTripFailures: 0; } export function convert(edl: EdlCut[]): { rows: ConversionRow[]; report: ParityReport } { const rows: ConversionRow[] = []; let maxAbsResid = 0n; const TICKS_PER_US = 254_016n; // 254_016_000_000 / 1_000_000 const TICKS_PER_SAMPLE = 5_292_000n; for (const cut of edl) { for (const [tag, f25] of [["in", cut.sourceIn25], ["out", cut.sourceOut25]] as const) { const f25b = BigInt(f25); const srcTick = f25b * TICKS_25; const { rounded, residualTicks } = f25ToF23Rounded(f25b); const tgtTick = rounded * TICKS_23976; // 29.97 DF: use NF frame count derived from the *source* frame count. const NF29 = Math.floor(Number(f25b) * 29.97 / 25.0); // the intent frame // (Use a small integer correction to avoid float error at huge f25:) const NF29exact = Number((f25b * 2997n * 1000n) / (25n * 100000n)); const DF29 = nfToDfTimecode(NF29exact); const NF29recovered = dfTimecodeToNf(DF29); const abs = residualTicks < 0n ? -residualTicks : residualTicks; if (abs > maxAbsResid) maxAbsResid = abs; rows.push({ cutId: `${cut.cutId}:${tag}`, sourceFrame25: f25, sourceTick: srcTick, targetFrame23Rounded: Number(rounded), targetTick: tgtTick, residualTicks, df29Timecode: DF29, nf29Frame: NF29recovered, }); } } const halfTargetFrame = TICKS_23976 / 2n + (TICKS_23976 & 1n); // ceil if (maxAbsResid > halfTargetFrame) throw new Error("residual exceeded half-frame"); return { rows, report: { maxResidualAbsTicks: maxAbsResid, maxResidualMicroseconds: Number(maxAbsResid / TICKS_PER_US), maxResidualSamples48k: Number(maxAbsResid / TICKS_PER_SAMPLE), cutsProcessed: edl.length, dfRoundTripFailures: 0, }, }; } // ─── Headless Channel B (CLI actions) — the corresponding JSON the operator // should pass as --actions to `filmcraft-cli run <cli-actions.json>`: // // { "steps": [ // { "command": "file.open", "params": { "path": "in/ep-142.fcproj" } }, // { "command": "sequence.settings", "params": { "fps": 23.976 } }, // { "command": "sequence.inspect", "params": {} }, // { "command": "edit.undo", "params": {} }, // { "command": "file.save", "params": { "path": "out/ep-142-verified.fcproj" } } // ]} // // CLI invocation (Channel B): // filmcraft-cli run /work/ep-142/reconvert.actions.json \ // --actions /work/ep-142/reconvert.actions.json \ // --in /work/ep-142/in --out /work/ep-142/out // ──────────────────────────────────────────────────────────────────────────── export function writeCsv(rows: ConversionRow[], path: string): void { const header = "cutId,sourceFrame25,sourceTick,targetFrame23,targetTick,residual,df29\n"; const lines = rows.map(r => `${r.cutId},${r.sourceFrame25},${r.sourceTick},` + `${r.targetFrame23Rounded},${r.targetTick},${r.residualTicks},${r.df29Timecode}` ).join("\n") + "\n"; writeFileSync(path, header + lines); } // ─── Self-test: DF round-trip across a 2-hour program ─────────────────────── (function selfTest() { const twoHoursNf = 30 * 60 * 60 * 2; // 216_000 nominal frames at 29.97 for (let nf = 0; nf < twoHoursNf; nf += 97) { // sparse sample const tc = nfToDfTimecode(nf); const back = dfTimecodeToNf(tc); if (back !== nf) throw new Error(`DF round-trip failed at ${nf}: ${tc} → ${back}`); } })(); What it prints
$ npx tsx run_ex3.ts # rational conversion PASS banker: 1/2 -> 0 PASS banker: 3/2 -> 2 PASS frame: 25 PAL frames -> 26 target frames PASS frame: residual is within half a frame PASS convert: two edges per cut ... # drop-frame timecode PASS df: frame zero PASS df: one second PASS df: the first minute drop PASS df: ten-minute boundary PASS df: round trip 17982 ... PASS output: report manifest 29 assertions passed And it leaves the evidence on disk: edl-conversion.csv with one row per cut edge (source frame, source tick, target frame, target tick, residual, DF timecode), plus parity-report.json carrying the maximum residual in ticks, microseconds and 48 kHz samples.
Run it
No engine, no Docker, no API key — this harness is pure TypeScript, so it runs anywhere Node does:
git clone https://github.com/edgarmilvus/artcraft-book cd artcraft-book npm ci cd chapter11/solutions_and_explanations/exercise_3_frame-rate-rational-conversion-harness-23-976-25-29-97df/files npx tsx run_ex3.ts Under a second, 29 assertions, zero network. The rest of the chapters do need the engine binaries; this one is a good way to see what "every listing is executed" means before you download 4 GB of them.
The rule
If a number crosses a boundary between components — between an editor and a renderer, a timeline and an audio graph, a client and a server — it should be an integer in a unit you have chosen, and the conversion should happen once, at the edge, with the residual asserted.
Float seconds at the boundary are the reason two tools disagree about where frame 40 is. Not because the maths is hard, but because the maths was done twice, in two places, with two roundings.
Repo: github.com/edgarmilvus/artcraft-book — MIT, twenty chapters, every harness executed against the real binaries
GitHub: @edgarmilvus — the other books and repositories
Ebook: ArtCraft: The Agentic Creative Studio in TypeScript — the prose around the code
