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 +21 -0
- preroll-0.0.1/PKG-INFO +46 -0
- preroll-0.0.1/README.md +31 -0
- preroll-0.0.1/pyproject.toml +26 -0
- preroll-0.0.1/src/preroll/__init__.py +17 -0
- preroll-0.0.1/src/preroll/timecode.py +106 -0
- preroll-0.0.1/tests/test_timecode.py +67 -0
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
|
preroll-0.0.1/README.md
ADDED
|
@@ -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
|