preroll 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
preroll-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 preroll
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
preroll-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.5
2
+ Name: preroll
3
+ Version: 0.0.1
4
+ Summary: Tools for editing video podcasts. This early release holds the timecode helpers.
5
+ Author: preroll
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: drop-frame,editing,podcast,smpte,timecode,video
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Multimedia :: Video
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ # preroll
17
+
18
+ Tools for editing video podcasts.
19
+
20
+ This is an early release. It holds one part: timecode helpers that convert between frame counts, seconds and SMPTE
21
+ timecode, with drop-frame timecode for 29.97 and 59.94 frames a second. More tools follow.
22
+
23
+ ## Install
24
+
25
+ ```
26
+ pip install preroll
27
+ ```
28
+
29
+ ## Use
30
+
31
+ ```python
32
+ from preroll import frames_to_timecode, timecode_to_frames, frames_to_seconds
33
+
34
+ frames_to_timecode(1800, 29.97) # '00:01:00;02' (drop-frame is the default at 29.97)
35
+ frames_to_timecode(1800, 29.97, drop_frame=False) # '00:01:00:00'
36
+ timecode_to_frames("01:00:00;00", 29.97) # 107892
37
+ timecode_to_frames("00:00:10:00", "24000/1001") # 240
38
+ frames_to_seconds(240, "24000/1001") # 10.01
39
+ ```
40
+
41
+ A frame rate can be a number (`24`, `25`, `29.97`, `23.976`) or an exact fraction as text (`"30000/1001"`). The
42
+ common NTSC rates given as decimals (23.976, 29.97, 59.94) are read as their exact fractions.
43
+
44
+ ## Licence
45
+
46
+ MIT
@@ -0,0 +1,31 @@
1
+ # preroll
2
+
3
+ Tools for editing video podcasts.
4
+
5
+ This is an early release. It holds one part: timecode helpers that convert between frame counts, seconds and SMPTE
6
+ timecode, with drop-frame timecode for 29.97 and 59.94 frames a second. More tools follow.
7
+
8
+ ## Install
9
+
10
+ ```
11
+ pip install preroll
12
+ ```
13
+
14
+ ## Use
15
+
16
+ ```python
17
+ from preroll import frames_to_timecode, timecode_to_frames, frames_to_seconds
18
+
19
+ frames_to_timecode(1800, 29.97) # '00:01:00;02' (drop-frame is the default at 29.97)
20
+ frames_to_timecode(1800, 29.97, drop_frame=False) # '00:01:00:00'
21
+ timecode_to_frames("01:00:00;00", 29.97) # 107892
22
+ timecode_to_frames("00:00:10:00", "24000/1001") # 240
23
+ frames_to_seconds(240, "24000/1001") # 10.01
24
+ ```
25
+
26
+ A frame rate can be a number (`24`, `25`, `29.97`, `23.976`) or an exact fraction as text (`"30000/1001"`). The
27
+ common NTSC rates given as decimals (23.976, 29.97, 59.94) are read as their exact fractions.
28
+
29
+ ## Licence
30
+
31
+ MIT
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "preroll"
7
+ version = "0.0.1"
8
+ description = "Tools for editing video podcasts. This early release holds the timecode helpers."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "preroll" }]
14
+ keywords = ["video", "podcast", "editing", "timecode", "smpte", "drop-frame"]
15
+ classifiers = [
16
+ "Development Status :: 2 - Pre-Alpha",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Multimedia :: Video",
20
+ ]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/preroll"]
24
+
25
+ [tool.hatch.build.targets.sdist]
26
+ include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,17 @@
1
+ """preroll: tools for editing video podcasts. This early release holds the timecode helpers."""
2
+ from .timecode import (
3
+ frame_rate,
4
+ frames_to_seconds,
5
+ frames_to_timecode,
6
+ seconds_to_frames,
7
+ timecode_to_frames,
8
+ )
9
+
10
+ __version__ = "0.0.1"
11
+ __all__ = [
12
+ "frame_rate",
13
+ "frames_to_seconds",
14
+ "frames_to_timecode",
15
+ "seconds_to_frames",
16
+ "timecode_to_frames",
17
+ ]
@@ -0,0 +1,106 @@
1
+ """Frame counts, seconds and SMPTE timecode.
2
+
3
+ A timecode is HH:MM:SS:FF. Drop-frame timecode (29.97 and 59.94 frames a second) is written with a semicolon before
4
+ the frames, HH:MM:SS;FF. It skips frame numbers, never frames: the first 2 numbers (4 at 59.94) of each minute are
5
+ left out, except in each tenth minute, so that the timecode keeps step with the clock.
6
+ """
7
+ from fractions import Fraction
8
+
9
+ _NTSC = {
10
+ Fraction("23.976"): Fraction(24000, 1001),
11
+ Fraction("23.98"): Fraction(24000, 1001),
12
+ Fraction("29.97"): Fraction(30000, 1001),
13
+ Fraction("47.952"): Fraction(48000, 1001),
14
+ Fraction("59.94"): Fraction(60000, 1001),
15
+ Fraction("119.88"): Fraction(120000, 1001),
16
+ }
17
+
18
+
19
+ def frame_rate(fps):
20
+ """The exact frame rate as a Fraction. fps: a number (24, 25, 29.97), a Fraction, or text ("30000/1001",
21
+ "23.976"). The NTSC rates given as decimals are read as their exact values (29.97 is 30000/1001)."""
22
+ if isinstance(fps, bool):
23
+ raise TypeError("frame rate must be a number, a Fraction or text")
24
+ try:
25
+ rate = Fraction(str(fps)) if isinstance(fps, float) else Fraction(fps)
26
+ except (TypeError, ValueError, ZeroDivisionError):
27
+ raise ValueError(f"not a frame rate: {fps!r}") from None
28
+ rate = _NTSC.get(rate, rate)
29
+ if rate <= 0:
30
+ raise ValueError(f"frame rate must be above 0, got {fps!r}")
31
+ return rate
32
+
33
+
34
+ def _base(rate):
35
+ """The whole number of frame numbers in one timecode second (30 for 29.97)."""
36
+ return int(rate + Fraction(1, 2))
37
+
38
+
39
+ def _drop(rate, drop_frame):
40
+ """Frame numbers skipped each minute: 2 at 29.97, 4 at 59.94, 0 for non-drop."""
41
+ can = rate in (Fraction(30000, 1001), Fraction(60000, 1001))
42
+ if drop_frame is None:
43
+ drop_frame = can
44
+ if drop_frame and not can:
45
+ raise ValueError("drop-frame timecode exists only for 29.97 and 59.94 frames a second")
46
+ return 2 * (_base(rate) // 30) if drop_frame else 0
47
+
48
+
49
+ def frames_to_timecode(frames, fps, drop_frame=None):
50
+ """The timecode of a frame count (frame 0 is 00:00:00:00). drop_frame: None uses drop-frame at 29.97 and 59.94
51
+ and non-drop at every other rate."""
52
+ if isinstance(frames, bool) or not isinstance(frames, int):
53
+ raise TypeError("frames must be a whole number")
54
+ if frames < 0:
55
+ raise ValueError("frames must be 0 or more")
56
+ rate = frame_rate(fps)
57
+ base, drop = _base(rate), _drop(rate, drop_frame)
58
+ n = frames
59
+ if drop:
60
+ per_min = base * 60 - drop
61
+ per_10min = base * 600 - 9 * drop
62
+ tens, rest = divmod(frames, per_10min)
63
+ n += 9 * drop * tens
64
+ if rest >= base * 60:
65
+ n += drop * ((rest - base * 60) // per_min + 1)
66
+ ff = n % base
67
+ ss = n // base % 60
68
+ mm = n // (base * 60) % 60
69
+ hh = n // (base * 3600)
70
+ return f"{hh:02d}:{mm:02d}:{ss:02d}{';' if drop else ':'}{ff:02d}"
71
+
72
+
73
+ def timecode_to_frames(timecode, fps, drop_frame=None):
74
+ """The frame count of a timecode. drop_frame: None reads the timecode's own mark (a semicolon or a full stop
75
+ before the frames means drop-frame)."""
76
+ if not isinstance(timecode, str):
77
+ raise TypeError("timecode must be text, for example '00:01:00;02'")
78
+ text = timecode.strip()
79
+ marked = len(text) > 3 and text[-3] in ";."
80
+ parts = text.replace(";", ":").replace(".", ":").split(":")
81
+ if len(parts) != 4 or not all(p.isdigit() for p in parts):
82
+ raise ValueError(f"not a timecode: {timecode!r} (expected HH:MM:SS:FF)")
83
+ hh, mm, ss, ff = (int(p) for p in parts)
84
+ rate = frame_rate(fps)
85
+ base = _base(rate)
86
+ drop = _drop(rate, marked if drop_frame is None else drop_frame)
87
+ if mm > 59 or ss > 59 or ff >= base:
88
+ raise ValueError(f"not a timecode at {base} frames a second: {timecode!r}")
89
+ if drop and ss == 0 and ff < drop and mm % 10:
90
+ raise ValueError(f"drop-frame timecode has no {timecode!r}: frame numbers 0 to {drop - 1} are skipped "
91
+ "at the start of each minute, except each tenth minute")
92
+ minutes = 60 * hh + mm
93
+ return (minutes * 60 + ss) * base + ff - drop * (minutes - minutes // 10)
94
+
95
+
96
+ def frames_to_seconds(frames, fps):
97
+ """The real time of a frame count, in seconds (240 frames at 23.976 are 10.01 s)."""
98
+ return float(frames / frame_rate(fps))
99
+
100
+
101
+ def seconds_to_frames(seconds, fps):
102
+ """The frame that is on screen at a time in seconds (the last frame that starts at or before it)."""
103
+ if seconds < 0:
104
+ raise ValueError("seconds must be 0 or more")
105
+ value = Fraction(str(seconds)) if isinstance(seconds, float) else Fraction(seconds)
106
+ return int(value * frame_rate(fps))
@@ -0,0 +1,67 @@
1
+ from fractions import Fraction
2
+
3
+ import pytest
4
+
5
+ from preroll import (frame_rate, frames_to_seconds, frames_to_timecode, seconds_to_frames,
6
+ timecode_to_frames)
7
+
8
+
9
+ def test_frame_rate_reads_ntsc_decimals_as_exact_fractions():
10
+ assert frame_rate(29.97) == Fraction(30000, 1001)
11
+ assert frame_rate("23.976") == Fraction(24000, 1001)
12
+ assert frame_rate("24000/1001") == Fraction(24000, 1001)
13
+ assert frame_rate(25) == 25
14
+ with pytest.raises(ValueError):
15
+ frame_rate(0)
16
+ with pytest.raises(ValueError):
17
+ frame_rate("fast")
18
+
19
+
20
+ def test_non_drop_timecode():
21
+ assert frames_to_timecode(0, 24) == "00:00:00:00"
22
+ assert frames_to_timecode(24, 23.976) == "00:00:01:00"
23
+ assert frames_to_timecode(25 * 3600 + 7, 25) == "01:00:00:07"
24
+ assert timecode_to_frames("00:00:10:00", "24000/1001") == 240
25
+ assert frames_to_timecode(1800, 29.97, drop_frame=False) == "00:01:00:00"
26
+
27
+
28
+ def test_drop_frame_known_values():
29
+ assert frames_to_timecode(1799, 29.97) == "00:00:59;29"
30
+ assert frames_to_timecode(1800, 29.97) == "00:01:00;02"
31
+ assert frames_to_timecode(17981, 29.97) == "00:09:59;29"
32
+ assert frames_to_timecode(17982, 29.97) == "00:10:00;00"
33
+ assert frames_to_timecode(107892, 29.97) == "01:00:00;00"
34
+ assert frames_to_timecode(3600, 59.94) == "00:01:00;04"
35
+ assert timecode_to_frames("01:00:00;00", 29.97) == 107892
36
+ assert timecode_to_frames("00:01:00;02", "30000/1001") == 1800
37
+
38
+
39
+ def test_drop_frame_mark_and_skipped_numbers():
40
+ assert timecode_to_frames("00:01:00:02", 29.97) == 1802 # no mark: read as non-drop
41
+ assert timecode_to_frames("00:01:00:02", 29.97, drop_frame=True) == 1800
42
+ with pytest.raises(ValueError):
43
+ timecode_to_frames("00:01:00;00", 29.97) # a skipped frame number
44
+ with pytest.raises(ValueError):
45
+ frames_to_timecode(10, 25, drop_frame=True) # no drop-frame at 25
46
+ with pytest.raises(ValueError):
47
+ timecode_to_frames("00:00:00:30", 29.97)
48
+
49
+
50
+ @pytest.mark.parametrize("fps, drop", [(24, False), (25, False), (23.976, False), (29.97, True), (29.97, False),
51
+ (59.94, True), (30, False), (60, False)])
52
+ def test_round_trip(fps, drop):
53
+ seen = None
54
+ for frames in list(range(0, 40000)) + [107891, 107892, 1078920, 2589407]:
55
+ tc = frames_to_timecode(frames, fps, drop_frame=drop)
56
+ assert timecode_to_frames(tc, fps, drop_frame=drop) == frames
57
+ if frames < 40000:
58
+ assert seen is None or tc > seen # timecodes rise with the frames
59
+ seen = tc
60
+
61
+
62
+ def test_seconds():
63
+ assert frames_to_seconds(240, "24000/1001") == pytest.approx(10.01)
64
+ assert seconds_to_frames(10.01, 23.976) == 240
65
+ assert seconds_to_frames(1.0, 25) == 25
66
+ assert seconds_to_frames(0.999, 25) == 24
67
+ assert frames_to_seconds(107892, 29.97) == pytest.approx(3599.9964) # one hour of drop-frame timecode