Skip to content

Repository files navigation

timecode

ci license: MIT

English | Espanol

timecode demo: measuring drop frame drift against the wall clock and explaining it, then refusing a timecode that cannot exist and saying why

SMPTE timecode arithmetic for video editors, with drop frame handled the way SMPTE ST 12-1 actually defines it.

Most timecode implementations get drop frame slightly wrong. The usual mistake is to believe that drop frame throws away picture, or to accept 00:01:00;00 as a real position when that label does not exist. This tool treats the label and the frame as two different things and keeps the arithmetic in exact integers, so the numbers it prints match the numbers a deck or an NLE prints.

What it does

timecode add   01:00:00:00 00:02:30:15 --fps 29.97df
timecode sub   01:00:00:00 00:02:30:15 --fps 29.97df
timecode conv  01:00:00;00 --from 29.97df --to 23.976
timecode frames 01:00:00:00 --fps 24
timecode at    86400 --fps 24
timecode range 01:00:00:00 01:00:10:00 --fps 25
timecode drift 01:00:00;00 --fps 29.97df
timecode validate 01:01:00;01 --fps 29.97df

Supported rates: 23.976, 24, 25, 29.97, 29.97df, 30, 50, 59.94, 59.94df, 60.

Every subcommand takes --json and prints one JSON object instead of the aligned block. --version prints the version.

A semicolon in a shell argument needs quoting, so write '01:00:00;00' or use the colon form, which is accepted at every rate.

The rules it follows

  • Drop frame drops the label, not the frame. No picture is discarded. Two frame numbers are skipped at the start of every minute except minutes divisible by ten. 29.97df skips two, 59.94df skips four.
  • One hour of 29.97df is 107892 frames, not 108000. One hour of 59.94df is 215784 frames.
  • 00:01:00;02 is frame 1800 and 00:10:00;00 is frame 17982.
  • Non-drop 29.97 is legal. It runs 3.6 seconds per hour behind wall clock time and the tool says so rather than pretending the problem does not exist.
  • A semicolon separates the frames field for drop frame and a colon for non-drop. Both are accepted on input and the correct one comes back out.
  • Negative results print with a leading minus sign. Sums past 24 hours wrap and report how many days came off.

Why drop frame is the way it is

When NTSC added colour in 1953 the new colour subcarrier had to share a channel with the existing luminance and sound carriers without producing a visible beat pattern on monochrome sets. The engineers solved it by slowing the whole system by a factor of 1000/1001, which moved 30.00 frames per second to 29.97 and put the subcarrier into an interleaved relationship with the sound carrier that neither the eye nor the ear could pick out. Timecode, which was standardised later, kept counting 30 labels every second even though only 29.97 frames were being recorded, so an hour of timecode took 3603.6 seconds of real time and a one hour programme overran the clock by 3.6 seconds. Drop frame timecode corrects the label count rather than the picture by skipping the labels ;00 and ;01 at the start of every minute except every tenth minute, which removes 108 labels per hour and leaves a residual error of 3.6 milliseconds per hour.

Install

go install github.com/keivanmalhani/timecode/cmd/timecode@latest

The package can also be used directly:

import "github.com/keivanmalhani/timecode"

There are no dependencies outside the standard library.

Real output

Everything below is pasted from a real run.

version

$ timecode --version
timecode 0.1.0

add

$ timecode add 01:00:00:00 00:02:30:15 --fps 29.97df
result  01:02:30;15
frames  112403
rate    29.97df, 30000/1001 = 29.970030 frames per second, drop frame, 2 labels skipped per dropping minute
a       01:00:00;00, frame 107892
b       00:02:30;15, frame 4511
real    3750.513433 s, 01:02:30.513433

Rolling over midnight:

$ timecode add 23:59:59;29 00:00:00;01 --fps 29.97df
result    00:00:00;00
frames    2589408
rate      29.97df, 30000/1001 = 29.970030 frames per second, drop frame, 2 labels skipped per dropping minute
a         23:59:59;29, frame 2589407
b         00:00:00;01, frame 1
real      86399.913600 s, 23:59:59.913600
rollover  the sum ran past 24:00:00:00 to 24:00:00;00, so 1 whole day came off

sub

$ timecode sub 01:00:00;00 00:02:30;15 --fps 29.97df
result  00:57:29;15
frames  103381
rate    29.97df, 30000/1001 = 29.970030 frames per second, drop frame, 2 labels skipped per dropping minute
a       01:00:00;00, frame 107892
b       00:02:30;15, frame 4511
real    3449.479366 s, 00:57:29.479366

Going negative:

$ timecode sub 00:00:01:00 00:00:02:00 --fps 25
result  -00:00:01:00
frames  -25
rate    25, 25 frames per second exactly
a       00:00:01:00, frame 25
b       00:00:02:00, frame 50
real    -1.000000 s, -00:00:01.000000
note    the result is before 00:00:00:00 and is printed with a minus sign;
        the same position read inside one day is 23:59:59:00

conv

$ timecode conv 01:00:00;00 --from 29.97df --to 23.976
source rate    29.97df, 30000/1001 = 29.970030 frames per second, drop frame, 2 labels skipped per dropping minute
source         01:00:00;00
source frames  107892
source real    3599.996400 s, 00:59:59.996400
target rate    23.976, 24000/1001 = 23.976024 frames per second, non drop
target         00:59:56:10
target frames  86314
target real    3600.013083 s, 01:00:00.013083
difference     0.016683 s
note           the frame count was rounded to the nearest whole frame at
               23.976, so the target lands 0.016683 s from the source instant

The same instant relabelled between drop and non-drop at the same real rate, which is why an hour of non-drop 29.97 shows up as 01:00:03;18 in drop frame:

$ timecode conv 01:00:00:00 --from 29.97 --to 29.97df
source rate    29.97, 30000/1001 = 29.970030 frames per second, non drop
source         01:00:00:00
source frames  108000
source real    3603.600000 s, 01:00:03.600000
target rate    29.97df, 30000/1001 = 29.970030 frames per second, drop frame, 2 labels skipped per dropping minute
target         01:00:03;18
target frames  108000
target real    3603.600000 s, 01:00:03.600000
difference     0.000000 s
note           the two rates land on the same instant, so nothing was rounded

frames

$ timecode frames 01:00:00:00 --fps 24
frames    86400
timecode  01:00:00:00
rate      24, 24 frames per second exactly
labels    86400 counted at 24 per second
real      3600.000000 s, 01:00:00.000000
$ timecode frames 00:01:00;02 --fps 29.97df
frames    1800
timecode  00:01:00;02
rate      29.97df, 30000/1001 = 29.970030 frames per second, drop frame, 2 labels skipped per dropping minute
labels    1802 counted at 30 per second
dropped   2 labels skipped before this point
real      60.060000 s, 00:01:00.060000

at

$ timecode at 86400 --fps 24
timecode  01:00:00:00
frames    86400
rate      24, 24 frames per second exactly
labels    86400 counted at 24 per second
real      3600.000000 s, 01:00:00.000000
$ timecode at 107892 --fps 29.97df
timecode  01:00:00;00
frames    107892
rate      29.97df, 30000/1001 = 29.970030 frames per second, drop frame, 2 labels skipped per dropping minute
labels    108000 counted at 30 per second
dropped   108 labels skipped before this point
real      3599.996400 s, 00:59:59.996400

range

$ timecode range 01:00:00:00 01:00:10:00 --fps 25
frames     250
inclusive  251 frames when both ends are kept
seconds    10.000000
duration   00:00:10:00
real       00:00:10.000000
rate       25, 25 frames per second exactly
from       01:00:00:00, frame 90000
to         01:00:10:00, frame 90250

drift

$ timecode drift 01:00:00;00 --fps 29.97df
timecode    01:00:00;00
rate        29.97df, 30000/1001 = 29.970030 frames per second, drop frame, 2 labels skipped per dropping minute
labels      ((1*60 + 0)*60 + 0)*30 + 0 = 108000 labels counted at 30 per second
dropped     2 labels * (60 minutes - 6 minutes divisible by ten) = 108 labels never used
frames      108000 - 108 = 107892 frames
label time  108000 / 30 = 3600.000000 s
real time   107892 * 1001 / 30000 = 3599.996400 s
drift       3600.000000 - 3599.996400 = 0.003600 s
meaning     at this point the timecode label is 0.003600 s ahead of the
            wall clock
per hour    0.003600 s of drift for every hour of labels
per day     0.086400 s of drift for every day of labels
why         NTSC colour television slowed the whole number rate by a
            factor of 1000/1001 so that the colour subcarrier would not
            beat visibly against the sound carrier, which turned 30 frames
            per second into 30000/1001 frames per second. Timecode kept
            counting 30 labels every second even though fewer frames were
            being recorded, so an hour of labels took 3603.6 seconds of
            real time. Drop frame corrects the label count rather than the
            picture, skipping two labels at the start of every minute
            except every tenth minute and removing 108 labels from every
            hour. That correction overshoots by a small amount, which is
            why an hour of 29.97df is 3.6 milliseconds short of an hour on
            the clock.

The same point at non-drop 29.97, which is legal and drifts 3.6 seconds per hour:

$ timecode drift 01:00:00:00 --fps 29.97
timecode    01:00:00:00
rate        29.97, 30000/1001 = 29.970030 frames per second, non drop
labels      ((1*60 + 0)*60 + 0)*30 + 0 = 108000 labels counted at 30 per second
dropped     none, this rate does not use drop frame labelling
frames      108000 labels
label time  108000 / 30 = 3600.000000 s
real time   108000 * 1001 / 30000 = 3603.600000 s
drift       3600.000000 - 3603.600000 = -3.600000 s
meaning     at this point the timecode label is 3.600000 s behind the wall
            clock
per hour    -3.600000 s of drift for every hour of labels
per day     -86.400000 s of drift for every day of labels
why         NTSC colour television slowed the whole number rate by a
            factor of 1000/1001 so that the colour subcarrier would not
            beat visibly against the sound carrier, which turned 30 frames
            per second into 30000/1001 frames per second. Timecode still
            counts 30 labels every second, so the label count falls behind
            the clock by 3.6 seconds for every hour of labels. Non drop
            29.97 is legal and is the usual choice for work that never has
            to match a broadcast clock, because the label count then
            matches the frame count exactly. Use 29.97df when the running
            time on screen has to agree with the clock.

At an exact rate there is nothing to explain:

$ timecode drift 01:00:00:00 --fps 25
timecode    01:00:00:00
rate        25, 25 frames per second exactly
labels      ((1*60 + 0)*60 + 0)*25 + 0 = 90000 labels counted at 25 per second
dropped     none, this rate does not use drop frame labelling
frames      90000 labels
label time  90000 / 25 = 3600.000000 s
real time   90000 * 1 / 25 = 3600.000000 s
drift       3600.000000 - 3600.000000 = 0.000000 s
meaning     at this point the timecode label and the wall clock agree
            exactly
per hour    0.000000 s of drift for every hour of labels
per day     0.000000 s of drift for every day of labels
why         25 is a whole number of frames per second, so one second of
            labels is one second on the clock and the two never separate.
            There is no drift to correct and no drop frame form of this
            rate exists.

validate

validate exits 1 when the label does not exist:

$ timecode validate 01:01:00;01 --fps 29.97df
valid   no
input   01:01:00;01
rate    29.97df, 30000/1001 = 29.970030 frames per second, drop frame, 2 labels skipped per dropping minute
reason  frames 00 and 01 do not exist at 01:01:00 because drop frame
        skips two labels at the start of every minute except every
        tenth minute; the first legal frame here is 02
$ echo $?
1

Drop frame only skips labels at second 00, and only in minutes that are not divisible by ten. 01:00:01;01 is one second and one frame into minute 60, which is a tenth minute anyway, so it is a real position and is accepted:

$ timecode validate 01:00:01;01 --fps 29.97df
valid     yes
input     01:00:01;01
timecode  01:00:01;01
frames    107923
rate      29.97df, 30000/1001 = 29.970030 frames per second, drop frame, 2 labels skipped per dropping minute

Field range errors are reported the same way:

$ timecode validate 01:00:00:60 --fps 25
valid   no
input   01:00:00:60
rate    25, 25 frames per second exactly
reason  frames must be 00 to 24 because 25 counts 25 labels per second

JSON

$ timecode add 01:00:00;00 00:02:30;15 --fps 29.97df --json
{
  "command": "add",
  "rate": "29.97df",
  "a": {
    "timecode": "01:00:00;00",
    "frames": 107892
  },
  "b": {
    "timecode": "00:02:30;15",
    "frames": 4511
  },
  "result": "01:02:30;15",
  "frames": 112403,
  "negative": false,
  "rollover_days": 0,
  "unwrapped": "01:02:30;15",
  "real_seconds": 3750.513433,
  "real_duration": "01:02:30.513433"
}
$ timecode range 01:00:00;00 01:00:10;00 --fps 29.97df --json
{
  "command": "range",
  "rate": "29.97df",
  "from": {
    "timecode": "01:00:00;00",
    "frames": 107892
  },
  "to": {
    "timecode": "01:00:10;00",
    "frames": 108192
  },
  "frames": 300,
  "frames_inclusive": 301,
  "seconds": 10.010000,
  "duration": "00:00:10;00",
  "real_duration": "00:00:10.010000"
}

Exit status

status meaning
0 the command succeeded
1 validate was given a timecode that does not exist
2 bad usage, bad rate, or bad timecode

Notes on the arithmetic

Nothing here is held as a floating point number. Frame counts and label counts are integers, and real elapsed time is carried as the fraction frames * den / num until the last step, where it becomes nanoseconds. That is why a 29.97df hour comes out as 3599.996400 seconds with no residue. The drift figure is computed as a single fraction, labelIndex/nominal - frames*den/num, for the same reason.

The hours field is limited to 00 to 23, which is what SMPTE defines. A duration longer than a day is carried as a frame count rather than as a timecode string.

What it will not do

  • It does not read or write timecode tracks in media files. There is no container parsing here at all.
  • It does not read or write EDL, AAF, XML or any other edit list format.
  • It does not do pulldown. Frame level cadence patterns such as 3:2 are a different problem from label arithmetic.
  • It does not know about user bits. It does not decode or generate LTC or VITC waveforms either.
  • It does not do audio sample counts or sample rate conversion.
  • It does not support arbitrary or user-defined frame rates. The ten rates listed above are the ones that exist in practice, and adding open-ended rates would mean guessing at drop frame rules that no standard defines.
  • It does not convert by preserving the frame count when the rates differ. conv converts by real elapsed time and rounds to the nearest frame, and it tells you how far the rounding moved the result.
  • It does not accept sloppy input. Every field has to be two digits with the hours between 00 and 23, and a drop frame label that SMPTE skips is refused rather than quietly corrected.

Development

go build ./...
go vet ./...
gofmt -l .
go test ./... -count=1

The test suite is table driven and covers 315 cases, including a walk over every frame of a whole day at 29.97df and 59.94df. That walk prints each frame count as a timecode and reads it back, which also proves the formatter never emits a label that drop frame skips, because the parser refuses those.

License

MIT. See LICENSE.

About

SMPTE timecode math that gets drop frame right, which is the part every quick script gets wrong. Add, subtract, convert between frame rates, and it explains exactly where the drift came from instead of just handing you the wrong number.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages