edgecut 0.1.0__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.
edgecut-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZeroMarker
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.
edgecut-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: edgecut
3
+ Version: 0.1.0
4
+ Summary: Frame-accurate H.264/HEVC clips that copy the video between keyframes and re-encode only the edges, using the FFmpeg CLI.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/ZeroMarker/edgecut
7
+ Project-URL: Repository, https://github.com/ZeroMarker/edgecut
8
+ Project-URL: Issues, https://github.com/ZeroMarker/edgecut/issues
9
+ Keywords: ffmpeg,video,cut,trim,h264,hevc,lossless
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Multimedia :: Video :: Conversion
12
+ Classifier: Environment :: Console
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # edgecut
19
+
20
+ Frame-accurate clips from H.264 and HEVC videos in seconds. edgecut copies the original video between keyframes and re-encodes only the few frames at each edge of the cut, so almost every frame of the clip is bit-identical to the source.
21
+
22
+ It is a single Python file with no dependencies beyond the standard library. All video work is done by the `ffmpeg` and `ffprobe` command-line tools.
23
+
24
+ ```sh
25
+ edgecut movie.mkv 1:02:03.5 1:04:10 clip.mp4
26
+ ```
27
+
28
+ ## Install
29
+
30
+ Requirements: Python 3.9+, and FFmpeg with libx264 (and libx265 for HEVC sources).
31
+
32
+ ```sh
33
+ uv tool install git+https://github.com/ZeroMarker/edgecut
34
+ # or
35
+ pipx install git+https://github.com/ZeroMarker/edgecut
36
+ ```
37
+
38
+ Or copy `edgecut.py` into your project.
39
+
40
+ ## Usage
41
+
42
+ ```
43
+ edgecut [-v] SOURCE START END OUTPUT
44
+ ```
45
+
46
+ - `START` and `END` are seconds, `MM:SS` or `HH:MM:SS` (fractions allowed). The clip holds every frame shown in [START, END) on the source timeline.
47
+ - `OUTPUT` is an MP4 file with the video tagged `avc1`/`hvc1`, `+faststart`, and timestamps starting at 0.
48
+ - `-v` prints the plan and every FFmpeg command.
49
+ - Exit status: `0` success, `2` source or range not supported (nothing was written; use a full re-encode), `1` a step or the output check failed.
50
+
51
+ From Python:
52
+
53
+ ```python
54
+ import tempfile, edgecut
55
+
56
+ with tempfile.TemporaryDirectory() as work:
57
+ try:
58
+ clip = edgecut.cut('movie.mp4', 3723.5, 3850.0, work) # returns work/clip.mp4
59
+ except edgecut.Unsupported: # raised before anything runs
60
+ ... # fall back to a full re-encode
61
+ except edgecut.CutFailed: # a step or the output check failed
62
+ ...
63
+ ```
64
+
65
+ `cut()` takes optional `run(argv, engine) -> (returncode, output)` and `event(status, **details)` callbacks, so an application can run the FFmpeg commands under its own logging and timeouts.
66
+
67
+ ## How it works
68
+
69
+ ```
70
+ source ──K0──────s──K1════════════════════K2──e────K3──
71
+ clip [head)[ body )[tail)
72
+ re- stream-copied re-
73
+ encoded encoded
74
+ ```
75
+
76
+ 1. **Plan** with ffprobe, without decoding. List the video packets from the keyframe before `start` to 3 s past `end` (with B-frames, frames shown just before `end` can come later in decode order). K1 is the first IDR keyframe at or after `start`, K2 the last at or before `end`; the NAL unit type of each candidate is checked (H.264 5, HEVC 19/20). The clip is rejected if it has fewer than two IDR keyframes or any packet between them is shown outside [K1, K2) (open GOP).
77
+ 2. **Body.** Stream-copy [K1, K2) to MPEG-TS with the source timestamps and in-band parameter sets.
78
+ 3. **Head and tail.** Re-encode [start, K1) and [K2, end) with libx264 (`veryfast`, CRF 16) or libx265 (`superfast`, CRF 18), without B-frames, matching the source pixel format, H.264 profile, colour properties and time base. Their DTS is shifted by the body's reorder delay, read from the written body because Matroska stores no DTS, so DTS keeps increasing across both joins.
79
+ 4. **Mux.** Concatenate the TS parts, add the source audio (AAC/MP3 copied, others encoded to AAC), shift the first frame to 0, and write MP4.
80
+ 5. **Check.** The frame count must equal the plan, the duration must be within 0.5 s of the clip, and FFmpeg must decode 2 s around the start and each join without errors.
81
+
82
+ Seeking only positions the demuxer at some earlier keyframe: Matroska seeks to a cue, and with `-copyts` FFmpeg's own `-ss` trimming is offset by the container start time. So every step selects frames and packets by source PTS: the `noise` bitstream filter's `drop` expression for copied packets, and `select`/`atrim` for decoded frames.
83
+
84
+ Only the clip's own packets are read, so the time does not grow with the length of the source. It is dominated by encoding the edges, so it follows the source's keyframe interval.
85
+
86
+ ## Results
87
+
88
+ On real 1080p–1440p sources (MP4 and MKV, clips of 28–140 s), compared with a frame-accurate decode of the same range:
89
+
90
+ - 3.4–16.5 s per clip (median 5.8 s) versus about 145 s for a full libx264 re-encode of the same clip.
91
+ - Same frame count as the reference; all copied frames bit-identical; re-encoded edge frames at 47–69 dB PSNR.
92
+ - Audio packets identical to the source; video and audio start at 0; timestamps within 0.011 ms of the source.
93
+ - Plays in Chromium without media errors, including seeks across the joins.
94
+
95
+ ## Limitations
96
+
97
+ - Video codecs: H.264 and HEVC with IDR keyframes. Open-GOP sources, other codecs, rotated video, and clips shorter than about two keyframe intervals raise `Unsupported`.
98
+ - Only the first video stream and the first audio stream are kept; subtitles, chapters and metadata are dropped.
99
+ - Not yet verified: HEVC output in browsers, and Safari/iOS playback across the joins, where parameter sets change in-band under the `avc1`/`hvc1` tags.
100
+
101
+ ## Tests
102
+
103
+ ```sh
104
+ python3 -m unittest discover -s tests -v
105
+ ```
106
+
107
+ The tests generate short videos with FFmpeg and compare every decoded frame of each clip with the source range: H.264 with B-frames, HEVC, 10-bit, Matroska, clips starting at 0, and start/end exactly on keyframes. They also check rejection of unsupported sources and errors from failed steps.
108
+
109
+ ## License
110
+
111
+ MIT
112
+
113
+ Release maintainers: see [RELEASING.md](RELEASING.md) for packaging checks and the
114
+ PyPI Trusted Publishing workflow.
@@ -0,0 +1,97 @@
1
+ # edgecut
2
+
3
+ Frame-accurate clips from H.264 and HEVC videos in seconds. edgecut copies the original video between keyframes and re-encodes only the few frames at each edge of the cut, so almost every frame of the clip is bit-identical to the source.
4
+
5
+ It is a single Python file with no dependencies beyond the standard library. All video work is done by the `ffmpeg` and `ffprobe` command-line tools.
6
+
7
+ ```sh
8
+ edgecut movie.mkv 1:02:03.5 1:04:10 clip.mp4
9
+ ```
10
+
11
+ ## Install
12
+
13
+ Requirements: Python 3.9+, and FFmpeg with libx264 (and libx265 for HEVC sources).
14
+
15
+ ```sh
16
+ uv tool install git+https://github.com/ZeroMarker/edgecut
17
+ # or
18
+ pipx install git+https://github.com/ZeroMarker/edgecut
19
+ ```
20
+
21
+ Or copy `edgecut.py` into your project.
22
+
23
+ ## Usage
24
+
25
+ ```
26
+ edgecut [-v] SOURCE START END OUTPUT
27
+ ```
28
+
29
+ - `START` and `END` are seconds, `MM:SS` or `HH:MM:SS` (fractions allowed). The clip holds every frame shown in [START, END) on the source timeline.
30
+ - `OUTPUT` is an MP4 file with the video tagged `avc1`/`hvc1`, `+faststart`, and timestamps starting at 0.
31
+ - `-v` prints the plan and every FFmpeg command.
32
+ - Exit status: `0` success, `2` source or range not supported (nothing was written; use a full re-encode), `1` a step or the output check failed.
33
+
34
+ From Python:
35
+
36
+ ```python
37
+ import tempfile, edgecut
38
+
39
+ with tempfile.TemporaryDirectory() as work:
40
+ try:
41
+ clip = edgecut.cut('movie.mp4', 3723.5, 3850.0, work) # returns work/clip.mp4
42
+ except edgecut.Unsupported: # raised before anything runs
43
+ ... # fall back to a full re-encode
44
+ except edgecut.CutFailed: # a step or the output check failed
45
+ ...
46
+ ```
47
+
48
+ `cut()` takes optional `run(argv, engine) -> (returncode, output)` and `event(status, **details)` callbacks, so an application can run the FFmpeg commands under its own logging and timeouts.
49
+
50
+ ## How it works
51
+
52
+ ```
53
+ source ──K0──────s──K1════════════════════K2──e────K3──
54
+ clip [head)[ body )[tail)
55
+ re- stream-copied re-
56
+ encoded encoded
57
+ ```
58
+
59
+ 1. **Plan** with ffprobe, without decoding. List the video packets from the keyframe before `start` to 3 s past `end` (with B-frames, frames shown just before `end` can come later in decode order). K1 is the first IDR keyframe at or after `start`, K2 the last at or before `end`; the NAL unit type of each candidate is checked (H.264 5, HEVC 19/20). The clip is rejected if it has fewer than two IDR keyframes or any packet between them is shown outside [K1, K2) (open GOP).
60
+ 2. **Body.** Stream-copy [K1, K2) to MPEG-TS with the source timestamps and in-band parameter sets.
61
+ 3. **Head and tail.** Re-encode [start, K1) and [K2, end) with libx264 (`veryfast`, CRF 16) or libx265 (`superfast`, CRF 18), without B-frames, matching the source pixel format, H.264 profile, colour properties and time base. Their DTS is shifted by the body's reorder delay, read from the written body because Matroska stores no DTS, so DTS keeps increasing across both joins.
62
+ 4. **Mux.** Concatenate the TS parts, add the source audio (AAC/MP3 copied, others encoded to AAC), shift the first frame to 0, and write MP4.
63
+ 5. **Check.** The frame count must equal the plan, the duration must be within 0.5 s of the clip, and FFmpeg must decode 2 s around the start and each join without errors.
64
+
65
+ Seeking only positions the demuxer at some earlier keyframe: Matroska seeks to a cue, and with `-copyts` FFmpeg's own `-ss` trimming is offset by the container start time. So every step selects frames and packets by source PTS: the `noise` bitstream filter's `drop` expression for copied packets, and `select`/`atrim` for decoded frames.
66
+
67
+ Only the clip's own packets are read, so the time does not grow with the length of the source. It is dominated by encoding the edges, so it follows the source's keyframe interval.
68
+
69
+ ## Results
70
+
71
+ On real 1080p–1440p sources (MP4 and MKV, clips of 28–140 s), compared with a frame-accurate decode of the same range:
72
+
73
+ - 3.4–16.5 s per clip (median 5.8 s) versus about 145 s for a full libx264 re-encode of the same clip.
74
+ - Same frame count as the reference; all copied frames bit-identical; re-encoded edge frames at 47–69 dB PSNR.
75
+ - Audio packets identical to the source; video and audio start at 0; timestamps within 0.011 ms of the source.
76
+ - Plays in Chromium without media errors, including seeks across the joins.
77
+
78
+ ## Limitations
79
+
80
+ - Video codecs: H.264 and HEVC with IDR keyframes. Open-GOP sources, other codecs, rotated video, and clips shorter than about two keyframe intervals raise `Unsupported`.
81
+ - Only the first video stream and the first audio stream are kept; subtitles, chapters and metadata are dropped.
82
+ - Not yet verified: HEVC output in browsers, and Safari/iOS playback across the joins, where parameter sets change in-band under the `avc1`/`hvc1` tags.
83
+
84
+ ## Tests
85
+
86
+ ```sh
87
+ python3 -m unittest discover -s tests -v
88
+ ```
89
+
90
+ The tests generate short videos with FFmpeg and compare every decoded frame of each clip with the source range: H.264 with B-frames, HEVC, 10-bit, Matroska, clips starting at 0, and start/end exactly on keyframes. They also check rejection of unsupported sources and errors from failed steps.
91
+
92
+ ## License
93
+
94
+ MIT
95
+
96
+ Release maintainers: see [RELEASING.md](RELEASING.md) for packaging checks and the
97
+ PyPI Trusted Publishing workflow.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: edgecut
3
+ Version: 0.1.0
4
+ Summary: Frame-accurate H.264/HEVC clips that copy the video between keyframes and re-encode only the edges, using the FFmpeg CLI.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/ZeroMarker/edgecut
7
+ Project-URL: Repository, https://github.com/ZeroMarker/edgecut
8
+ Project-URL: Issues, https://github.com/ZeroMarker/edgecut/issues
9
+ Keywords: ffmpeg,video,cut,trim,h264,hevc,lossless
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Multimedia :: Video :: Conversion
12
+ Classifier: Environment :: Console
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # edgecut
19
+
20
+ Frame-accurate clips from H.264 and HEVC videos in seconds. edgecut copies the original video between keyframes and re-encodes only the few frames at each edge of the cut, so almost every frame of the clip is bit-identical to the source.
21
+
22
+ It is a single Python file with no dependencies beyond the standard library. All video work is done by the `ffmpeg` and `ffprobe` command-line tools.
23
+
24
+ ```sh
25
+ edgecut movie.mkv 1:02:03.5 1:04:10 clip.mp4
26
+ ```
27
+
28
+ ## Install
29
+
30
+ Requirements: Python 3.9+, and FFmpeg with libx264 (and libx265 for HEVC sources).
31
+
32
+ ```sh
33
+ uv tool install git+https://github.com/ZeroMarker/edgecut
34
+ # or
35
+ pipx install git+https://github.com/ZeroMarker/edgecut
36
+ ```
37
+
38
+ Or copy `edgecut.py` into your project.
39
+
40
+ ## Usage
41
+
42
+ ```
43
+ edgecut [-v] SOURCE START END OUTPUT
44
+ ```
45
+
46
+ - `START` and `END` are seconds, `MM:SS` or `HH:MM:SS` (fractions allowed). The clip holds every frame shown in [START, END) on the source timeline.
47
+ - `OUTPUT` is an MP4 file with the video tagged `avc1`/`hvc1`, `+faststart`, and timestamps starting at 0.
48
+ - `-v` prints the plan and every FFmpeg command.
49
+ - Exit status: `0` success, `2` source or range not supported (nothing was written; use a full re-encode), `1` a step or the output check failed.
50
+
51
+ From Python:
52
+
53
+ ```python
54
+ import tempfile, edgecut
55
+
56
+ with tempfile.TemporaryDirectory() as work:
57
+ try:
58
+ clip = edgecut.cut('movie.mp4', 3723.5, 3850.0, work) # returns work/clip.mp4
59
+ except edgecut.Unsupported: # raised before anything runs
60
+ ... # fall back to a full re-encode
61
+ except edgecut.CutFailed: # a step or the output check failed
62
+ ...
63
+ ```
64
+
65
+ `cut()` takes optional `run(argv, engine) -> (returncode, output)` and `event(status, **details)` callbacks, so an application can run the FFmpeg commands under its own logging and timeouts.
66
+
67
+ ## How it works
68
+
69
+ ```
70
+ source ──K0──────s──K1════════════════════K2──e────K3──
71
+ clip [head)[ body )[tail)
72
+ re- stream-copied re-
73
+ encoded encoded
74
+ ```
75
+
76
+ 1. **Plan** with ffprobe, without decoding. List the video packets from the keyframe before `start` to 3 s past `end` (with B-frames, frames shown just before `end` can come later in decode order). K1 is the first IDR keyframe at or after `start`, K2 the last at or before `end`; the NAL unit type of each candidate is checked (H.264 5, HEVC 19/20). The clip is rejected if it has fewer than two IDR keyframes or any packet between them is shown outside [K1, K2) (open GOP).
77
+ 2. **Body.** Stream-copy [K1, K2) to MPEG-TS with the source timestamps and in-band parameter sets.
78
+ 3. **Head and tail.** Re-encode [start, K1) and [K2, end) with libx264 (`veryfast`, CRF 16) or libx265 (`superfast`, CRF 18), without B-frames, matching the source pixel format, H.264 profile, colour properties and time base. Their DTS is shifted by the body's reorder delay, read from the written body because Matroska stores no DTS, so DTS keeps increasing across both joins.
79
+ 4. **Mux.** Concatenate the TS parts, add the source audio (AAC/MP3 copied, others encoded to AAC), shift the first frame to 0, and write MP4.
80
+ 5. **Check.** The frame count must equal the plan, the duration must be within 0.5 s of the clip, and FFmpeg must decode 2 s around the start and each join without errors.
81
+
82
+ Seeking only positions the demuxer at some earlier keyframe: Matroska seeks to a cue, and with `-copyts` FFmpeg's own `-ss` trimming is offset by the container start time. So every step selects frames and packets by source PTS: the `noise` bitstream filter's `drop` expression for copied packets, and `select`/`atrim` for decoded frames.
83
+
84
+ Only the clip's own packets are read, so the time does not grow with the length of the source. It is dominated by encoding the edges, so it follows the source's keyframe interval.
85
+
86
+ ## Results
87
+
88
+ On real 1080p–1440p sources (MP4 and MKV, clips of 28–140 s), compared with a frame-accurate decode of the same range:
89
+
90
+ - 3.4–16.5 s per clip (median 5.8 s) versus about 145 s for a full libx264 re-encode of the same clip.
91
+ - Same frame count as the reference; all copied frames bit-identical; re-encoded edge frames at 47–69 dB PSNR.
92
+ - Audio packets identical to the source; video and audio start at 0; timestamps within 0.011 ms of the source.
93
+ - Plays in Chromium without media errors, including seeks across the joins.
94
+
95
+ ## Limitations
96
+
97
+ - Video codecs: H.264 and HEVC with IDR keyframes. Open-GOP sources, other codecs, rotated video, and clips shorter than about two keyframe intervals raise `Unsupported`.
98
+ - Only the first video stream and the first audio stream are kept; subtitles, chapters and metadata are dropped.
99
+ - Not yet verified: HEVC output in browsers, and Safari/iOS playback across the joins, where parameter sets change in-band under the `avc1`/`hvc1` tags.
100
+
101
+ ## Tests
102
+
103
+ ```sh
104
+ python3 -m unittest discover -s tests -v
105
+ ```
106
+
107
+ The tests generate short videos with FFmpeg and compare every decoded frame of each clip with the source range: H.264 with B-frames, HEVC, 10-bit, Matroska, clips starting at 0, and start/end exactly on keyframes. They also check rejection of unsupported sources and errors from failed steps.
108
+
109
+ ## License
110
+
111
+ MIT
112
+
113
+ Release maintainers: see [RELEASING.md](RELEASING.md) for packaging checks and the
114
+ PyPI Trusted Publishing workflow.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ edgecut.py
4
+ pyproject.toml
5
+ edgecut.egg-info/PKG-INFO
6
+ edgecut.egg-info/SOURCES.txt
7
+ edgecut.egg-info/dependency_links.txt
8
+ edgecut.egg-info/entry_points.txt
9
+ edgecut.egg-info/top_level.txt
10
+ tests/test_edgecut.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ edgecut = edgecut:main
@@ -0,0 +1 @@
1
+ edgecut
@@ -0,0 +1,336 @@
1
+ """edgecut: frame-accurate MP4 clips that re-encode only the edges of the cut.
2
+
3
+ A clip [start, end) of an H.264 or HEVC source is built from three parts on the
4
+ source timeline:
5
+
6
+ head [start, K1) re-encoded; K1 is the first IDR keyframe at or after start
7
+ body [K1, K2) stream-copied, packet for packet
8
+ tail [K2, end) re-encoded; K2 is the last IDR keyframe at or before end
9
+
10
+ IDR keyframes close their GOP, so the copied body never references a frame
11
+ outside it. Each part is written to MPEG-TS with the source timestamps and
12
+ in-band parameter sets, the parts are joined byte-wise, and the result is muxed
13
+ to MP4 with the source audio. Only the clip's own packets are probed, so the
14
+ cost does not grow with the length of the source file. Uses the FFmpeg CLI only.
15
+ """
16
+ import argparse
17
+ from fractions import Fraction
18
+ import json
19
+ from pathlib import Path
20
+ import re
21
+ import shlex
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+
27
+ __version__ = '0.1.0'
28
+ ENGINE = 'edgecut'
29
+
30
+ # codec: (encoder, extra encoder args, MP4 tag). The parts carry in-band
31
+ # parameter sets; the widely supported avc1/hvc1 tags are used anyway, as for
32
+ # FFmpeg's own MPEG-TS to MP4 remuxes (Safari rejects hev1).
33
+ CODECS = {
34
+ 'h264': ('libx264', ['-preset', 'veryfast', '-crf', '16', '-bf', '0'], 'avc1'),
35
+ 'hevc': ('libx265', ['-preset', 'superfast', '-crf', '18', '-x265-params', 'bframes=0:log-level=error'], 'hvc1'),
36
+ }
37
+ IDR_TYPES = {'h264': {5}, 'hevc': {19, 20}}
38
+ H264_PROFILES = {'Baseline': 'baseline', 'Constrained Baseline': 'baseline', 'Main': 'main', 'High': 'high',
39
+ 'High 10': 'high10', 'High 4:2:2': 'high422', 'High 4:4:4 Predictive': 'high444'}
40
+ COPY_AUDIO = {'aac', 'mp3'}
41
+ # Added to every intermediate timestamp so parts starting near zero never go
42
+ # negative in MPEG-TS (which cannot store negative timestamps).
43
+ TS_OFFSET = 10
44
+ EPSILON = Fraction(1, 1000)
45
+
46
+
47
+ class Unsupported(Exception):
48
+ """The source or range does not suit an edgecut; nothing was run."""
49
+
50
+
51
+ class CutFailed(Exception):
52
+ """An edgecut step failed or produced an implausible result."""
53
+
54
+
55
+ def seconds(value):
56
+ return f'{float(value):.6f}'
57
+
58
+
59
+ def ffprobe(args, timeout=120, error=Unsupported):
60
+ try:
61
+ result = subprocess.run(['ffprobe', '-v', 'error', *args, '-of', 'json'], stdin=subprocess.DEVNULL,
62
+ capture_output=True, timeout=timeout)
63
+ except subprocess.TimeoutExpired:
64
+ raise error(f'ffprobe took over {timeout} seconds') from None
65
+ if result.returncode:
66
+ raise error('ffprobe failed: ' + result.stderr.decode('utf-8', 'replace').strip()[-300:])
67
+ try:
68
+ return json.loads(result.stdout or b'{}')
69
+ except ValueError:
70
+ raise error('ffprobe returned invalid JSON') from None
71
+
72
+
73
+ def first_nal_type(source, codec, at, timeout=120):
74
+ """Type of the first picture NAL unit of the keyframe at `at` seconds."""
75
+ bsf = 'h264_mp4toannexb' if codec == 'h264' else 'hevc_mp4toannexb'
76
+ try:
77
+ data = subprocess.run(['ffmpeg', '-v', 'error', '-nostdin', '-ss', seconds(at + EPSILON), '-i', str(source),
78
+ '-map', '0:v:0', '-c', 'copy', '-copypriorss', '1', '-bsf:v', bsf, '-frames:v', '1',
79
+ '-f', codec, '-'], stdin=subprocess.DEVNULL, capture_output=True, timeout=timeout).stdout
80
+ except subprocess.TimeoutExpired:
81
+ raise Unsupported(f'reading the keyframe at {float(at)} took over {timeout} seconds') from None
82
+ position = 0
83
+ while (position := data.find(b'\x00\x00\x01', position) + 3) >= 3 and position < len(data):
84
+ header = data[position]
85
+ nal = header & 0x1f if codec == 'h264' else header >> 1 & 0x3f
86
+ if (nal in (1, 5)) if codec == 'h264' else nal < 32:
87
+ return nal
88
+ return None
89
+
90
+
91
+ def plan(source, start, end):
92
+ """Probe the source and decide the three parts, or raise Unsupported."""
93
+ info = ffprobe(['-show_entries', 'stream=index,codec_type,codec_name,profile,pix_fmt,time_base,color_range,'
94
+ 'color_space,color_transfer,color_primaries:stream_disposition=attached_pic:'
95
+ 'stream_side_data=rotation:format=duration', str(source)])
96
+ streams = info.get('streams', [])
97
+ video = next((s for s in streams if s.get('codec_type') == 'video'
98
+ and not s.get('disposition', {}).get('attached_pic')), None)
99
+ audio = next((s for s in streams if s.get('codec_type') == 'audio'), None)
100
+ if video is None:
101
+ raise Unsupported('no video stream')
102
+ codec = video.get('codec_name')
103
+ if codec not in CODECS:
104
+ raise Unsupported(f'unsupported video codec {codec}')
105
+ if any(side.get('rotation') for side in video.get('side_data_list', [])):
106
+ raise Unsupported('rotated video')
107
+ duration = float(info.get('format', {}).get('duration', 'nan'))
108
+ if not 0 <= start < end <= duration:
109
+ raise Unsupported(f'range is outside the source duration {duration}')
110
+ base = Fraction(video['time_base'])
111
+ # Read past the end: with B-frames, frames shown before `end` can follow a
112
+ # later-shown frame in decode order.
113
+ listed = ffprobe(['-select_streams', str(video['index']), '-read_intervals', f'{seconds(start)}%{seconds(end + 3)}',
114
+ '-show_entries', 'packet=pts,flags', str(source)], timeout=600)['packets']
115
+ # Packets in decode order. Only PTS is used: Matroska stores no DTS.
116
+ if any(p.get('pts') is None for p in listed):
117
+ raise Unsupported('packets without timestamps')
118
+ packets = [(Fraction(p['pts']) * base, 'K' in p.get('flags', '')) for p in listed]
119
+ s, e = Fraction(start), Fraction(end)
120
+ frames = sorted(pts for pts, _ in packets if s <= pts < e)
121
+ if not frames:
122
+ raise Unsupported('no frames in range')
123
+ keys = [i for i, (pts, key) in enumerate(packets) if key and s <= pts <= e]
124
+ idr = {}
125
+
126
+ def is_idr(i):
127
+ if i not in idr:
128
+ idr[i] = first_nal_type(source, codec, packets[i][0]) in IDR_TYPES[codec]
129
+ return idr[i]
130
+
131
+ first = next((i for i in keys if is_idr(i)), None)
132
+ last = next((i for i in reversed(keys) if first is not None and i > first and is_idr(i)), None)
133
+ if first is None or last is None or packets[first][0] >= e:
134
+ raise Unsupported('fewer than two IDR keyframes in the clip')
135
+ k1, k2 = packets[first][0], packets[last][0]
136
+ body = packets[first:last]
137
+ # The body is selected by PTS, so it must be exactly the packets between the keyframes.
138
+ if any(not k1 <= pts < k2 for pts, _ in body) or sum(k1 <= pts < k2 for pts, _ in packets) != len(body):
139
+ raise Unsupported('open GOP around the keyframes')
140
+ return {
141
+ 'codec': codec, 'video': video, 'audio': audio,
142
+ 'first_frame': frames[0], 'k1': k1, 'k2': k2,
143
+ 'head_frames': sum(1 for pts in frames if pts < k1),
144
+ 'body_frames': len(body),
145
+ 'tail_frames': sum(1 for pts in frames if pts >= k2),
146
+ 'start': s, 'end': e,
147
+ }
148
+
149
+
150
+ def encoder_args(p):
151
+ video, codec = p['video'], p['codec']
152
+ encoder, extra, _ = CODECS[codec]
153
+ args = ['-c:v', encoder, *extra, '-pix_fmt', video['pix_fmt']]
154
+ if codec == 'h264' and video.get('profile') in H264_PROFILES:
155
+ args += ['-profile:v', H264_PROFILES[video['profile']]]
156
+ for option, key in (('-color_range', 'color_range'), ('-colorspace', 'color_space'),
157
+ ('-color_trc', 'color_transfer'), ('-color_primaries', 'color_primaries')):
158
+ if video.get(key) not in (None, 'unknown', 'reserved'):
159
+ args += [option, video[key]]
160
+ return args
161
+
162
+
163
+ def ts_output(path, delay=0):
164
+ args = ['-fps_mode', 'passthrough', '-enc_time_base:v', '-1'] if delay is not None else []
165
+ if delay:
166
+ args += ['-bsf:v', f'setts=dts=DTS-{float(delay)!r}/TB']
167
+ return [*args, '-output_ts_offset', str(TS_OFFSET), '-mpegts_copyts', '1', '-muxdelay', '0',
168
+ '-muxpreload', '0', '-f', 'mpegts', str(path)]
169
+
170
+
171
+ def between(low, high):
172
+ """noise bitstream filter expression dropping packets outside [low, high)."""
173
+ return f'noise=drop=lt(pts*tb\\,{float(low - EPSILON)!r})+gte(pts*tb\\,{float(high - EPSILON)!r})'
174
+
175
+
176
+ # Seeking only positions the demuxer at some earlier keyframe (Matroska seeks to
177
+ # a cue, and with -copyts FFmpeg's own -ss trimming is offset by the container
178
+ # start time), so every step selects its frames or packets by source PTS.
179
+
180
+ def body_command(source, p, work):
181
+ return ['ffmpeg', '-nostdin', '-y', '-copyts', '-ss', seconds(p['k1']), '-to', seconds(p['k2'] + 1),
182
+ '-i', str(source), '-map', f"0:{p['video']['index']}", '-c', 'copy', '-copypriorss', '1',
183
+ '-bsf:v', between(p['k1'], p['k2']), *ts_output(work / 'body.ts', None)]
184
+
185
+
186
+ def edge_command(source, p, work, name, first, frames, delay):
187
+ """Re-encode `frames` frames starting at the frame shown at `first`."""
188
+ return ['ffmpeg', '-nostdin', '-y', '-copyts', '-noaccurate_seek', '-ss', seconds(first - EPSILON),
189
+ '-i', str(source), '-map', f"0:{p['video']['index']}",
190
+ '-vf', f'select=gte(t\\,{float(first - EPSILON)!r})', '-frames:v', str(frames), *encoder_args(p),
191
+ *ts_output(work / f'{name}.ts', delay)]
192
+
193
+
194
+ def body_timing(work):
195
+ """(first PTS - first DTS, last DTS) of the copied body, in source seconds."""
196
+ packets = ffprobe(['-show_entries', 'packet=pts_time,dts_time', str(work / 'body.ts')], error=CutFailed)['packets']
197
+ if not packets:
198
+ raise CutFailed('the copied body is empty')
199
+ first, last = packets[0], packets[-1]
200
+ return (Fraction(first['pts_time']) - Fraction(first['dts_time']),
201
+ Fraction(last['dts_time']) - TS_OFFSET)
202
+
203
+
204
+ def edge_delays(p, timing):
205
+ """DTS shifts for the re-encoded head and tail.
206
+
207
+ They have no B-frames, so their DTS equals PTS. Shifting the head by the
208
+ body's reorder delay ends it before the body's first DTS; the tail must
209
+ start after the body's last DTS.
210
+ """
211
+ delay, body_last_dts = timing
212
+ tail = delay if p['k2'] - delay > body_last_dts else (p['k2'] - body_last_dts) / 2
213
+ return delay, tail
214
+
215
+
216
+ def mux_command(source, p, work, parts):
217
+ command = ['ffmpeg', '-nostdin', '-y', '-copyts', '-itsoffset', str(-TS_OFFSET),
218
+ '-i', 'concat:' + '|'.join(str(work / f'{part}.ts') for part in parts)]
219
+ maps = ['-map', '0:v']
220
+ if p['audio']:
221
+ command += ['-noaccurate_seek', '-ss', seconds(p['start']), '-to', seconds(p['end'] + 1), '-i', str(source)]
222
+ maps += ['-map', f"1:{p['audio']['index']}"]
223
+ if p['audio']['codec_name'] in COPY_AUDIO:
224
+ maps += ['-c:a', 'copy', '-copypriorss:a', '1', '-bsf:a', between(p['start'], p['end'])]
225
+ else:
226
+ maps += ['-af', f"atrim=start={float(p['start'])!r}:end={float(p['end'])!r}", '-c:a', 'aac', '-b:a', '192k']
227
+ return [*command, *maps, '-c:v', 'copy', '-tag:v', CODECS[p['codec']][2], '-output_ts_offset',
228
+ seconds(-p['first_frame']), '-map_metadata', '-1', '-movflags', '+faststart', str(work / 'clip.mp4')]
229
+
230
+
231
+ def verify(p, output, run):
232
+ """Check frame count and duration, and decode around each join."""
233
+ info = ffprobe(['-count_packets', '-show_entries', 'stream=codec_type,nb_read_packets:format=duration',
234
+ str(output)], error=CutFailed)
235
+ count = next((int(s['nb_read_packets']) for s in info.get('streams', []) if s.get('codec_type') == 'video'), 0)
236
+ expected = p['head_frames'] + p['body_frames'] + p['tail_frames']
237
+ if count != expected:
238
+ raise CutFailed(f'output has {count} video frames instead of {expected}')
239
+ duration = float(info.get('format', {}).get('duration', 'nan'))
240
+ if not abs(duration - float(p['end'] - p['start'])) <= 0.5:
241
+ raise CutFailed(f"output lasts {duration} seconds instead of {float(p['end'] - p['start'])}")
242
+ joins = [0] + [float(k - p['first_frame']) for k, n in ((p['k1'], p['head_frames']), (p['k2'], p['tail_frames'])) if n]
243
+ command = ['ffmpeg', '-nostdin', '-v', 'error']
244
+ for join in joins:
245
+ command += ['-ss', seconds(max(0, join - 1)), '-t', '2', '-i', str(output)]
246
+ for index in range(len(joins)):
247
+ command += ['-map', f'{index}:v', '-f', 'null', '-']
248
+ returncode, text = run(command, ENGINE)
249
+ if returncode or text.strip():
250
+ raise CutFailed('decode errors near a join: ' + text.strip()[-300:])
251
+
252
+
253
+ def run_quietly(command, _engine=ENGINE):
254
+ """Default runner: run one command and return (returncode, stderr text)."""
255
+ result = subprocess.run(command, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors='replace')
256
+ return result.returncode, result.stderr
257
+
258
+
259
+ def cut(source, start, end, work, run=run_quietly, event=lambda status, **details: None):
260
+ """Write the clip [start, end) seconds of `source` to work/clip.mp4 and return its path.
261
+
262
+ `run(argv, engine)` runs and logs one command and returns (returncode,
263
+ output); `event(status, **details)` logs a step. Raises Unsupported before
264
+ running anything, or CutFailed afterwards.
265
+ """
266
+ source, work = Path(source), Path(work)
267
+ p = plan(source, start, end)
268
+ event('plan', engine=ENGINE, codec=p['codec'], k1=float(p['k1']), k2=float(p['k2']),
269
+ head_frames=p['head_frames'], body_frames=p['body_frames'], tail_frames=p['tail_frames'])
270
+ def step(name, command):
271
+ returncode, text = run(command, ENGINE)
272
+ if returncode:
273
+ raise CutFailed(f'{name} step exited with {returncode}: ' + text.strip()[-300:])
274
+
275
+ step('body', body_command(source, p, work))
276
+ head_delay, tail_delay = edge_delays(p, body_timing(work))
277
+ parts = ['body']
278
+ if p['head_frames']:
279
+ step('head', edge_command(source, p, work, 'head', p['first_frame'], p['head_frames'], head_delay))
280
+ parts.insert(0, 'head')
281
+ if p['tail_frames']:
282
+ step('tail', edge_command(source, p, work, 'tail', p['k2'], p['tail_frames'], tail_delay))
283
+ parts.append('tail')
284
+ step('mux', mux_command(source, p, work, parts))
285
+ output = work / 'clip.mp4'
286
+ verify(p, output, run)
287
+ return output
288
+
289
+
290
+ def parse_time(value):
291
+ """Seconds from '83.5', '1:23.5' or '0:01:23.5'."""
292
+ if not re.fullmatch(r'(\d+:){0,2}\d+(\.\d+)?', value):
293
+ raise argparse.ArgumentTypeError(f'invalid time {value!r}; use seconds, MM:SS or HH:MM:SS')
294
+ *rest, last = value.split(':')
295
+ if any(int(part) >= 60 for part in rest[1:]) or (rest and float(last) >= 60):
296
+ raise argparse.ArgumentTypeError(f'invalid time {value!r}')
297
+ total = 0.0
298
+ for part in rest:
299
+ total = total * 60 + int(part)
300
+ return total * 60 + float(last) if rest else float(last)
301
+
302
+
303
+ def main(argv=None):
304
+ parser = argparse.ArgumentParser(prog='edgecut', description='Cut a frame-accurate MP4 clip from an H.264 or HEVC '
305
+ 'video, copying the video between keyframes and re-encoding only the edges.')
306
+ parser.add_argument('source', type=Path)
307
+ parser.add_argument('start', type=parse_time, help='seconds, MM:SS or HH:MM:SS (inclusive)')
308
+ parser.add_argument('end', type=parse_time, help='seconds, MM:SS or HH:MM:SS (exclusive)')
309
+ parser.add_argument('output', type=Path, help='MP4 file to write')
310
+ parser.add_argument('-v', '--verbose', action='store_true', help='print the plan and every FFmpeg command')
311
+ parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
312
+ args = parser.parse_args(argv)
313
+
314
+ def run(command, engine=ENGINE):
315
+ if args.verbose:
316
+ print('+', shlex.join(command), file=sys.stderr)
317
+ return run_quietly(command, engine)
318
+
319
+ def event(status, **details):
320
+ if args.verbose:
321
+ print(status, json.dumps(details), file=sys.stderr)
322
+
323
+ try:
324
+ with tempfile.TemporaryDirectory(prefix='edgecut-') as work:
325
+ shutil.copyfile(cut(args.source, args.start, args.end, work, run, event), args.output)
326
+ except Unsupported as exc:
327
+ print(f'edgecut: not supported: {exc} (use a full re-encode instead)', file=sys.stderr)
328
+ return 2
329
+ except (CutFailed, OSError) as exc:
330
+ print(f'edgecut: failed: {exc}', file=sys.stderr)
331
+ return 1
332
+ return 0
333
+
334
+
335
+ if __name__ == '__main__':
336
+ sys.exit(main())
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "edgecut"
7
+ version = "0.1.0"
8
+ description = "Frame-accurate H.264/HEVC clips that copy the video between keyframes and re-encode only the edges, using the FFmpeg CLI."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ dependencies = []
14
+ keywords = ["ffmpeg", "video", "cut", "trim", "h264", "hevc", "lossless"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Multimedia :: Video :: Conversion",
18
+ "Environment :: Console",
19
+ ]
20
+
21
+ [project.scripts]
22
+ edgecut = "edgecut:main"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/ZeroMarker/edgecut"
26
+ Repository = "https://github.com/ZeroMarker/edgecut"
27
+ Issues = "https://github.com/ZeroMarker/edgecut/issues"
28
+
29
+ [tool.setuptools]
30
+ py-modules = ["edgecut"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,121 @@
1
+ """Cut generated videos and compare every decoded frame with the source range."""
2
+ import json
3
+ from pathlib import Path
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from unittest.mock import Mock, patch
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
12
+ import edgecut # noqa: E402
13
+
14
+ H264 = ('-c:v', 'libx264', '-g', '25', '-bf', '2')
15
+
16
+
17
+ def make_video(path, *codec_args, seconds=8):
18
+ """A test video with a keyframe every second, B-frames, and AAC audio."""
19
+ subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=s=160x90:r=25', '-f', 'lavfi',
20
+ '-i', 'sine=f=440:r=48000', '-t', str(seconds), *(codec_args or H264), '-c:a', 'aac', str(path)],
21
+ check=True)
22
+
23
+
24
+ def frame_hashes(path, start=None, end=None):
25
+ """MD5 of each decoded video frame, optionally only frames shown in [start, end)."""
26
+ select = ['-vf', f'select=gte(t\\,{start})*lt(t\\,{end})'] if start is not None else []
27
+ output = subprocess.check_output(['ffmpeg', '-v', 'error', '-copyts', '-i', str(path), '-map', '0:v', *select,
28
+ '-fps_mode', 'passthrough', '-f', 'framemd5', '-'], text=True)
29
+ return [line.rsplit(',', 1)[1].strip() for line in output.splitlines() if not line.startswith('#')]
30
+
31
+
32
+ @unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'FFmpeg and FFprobe required')
33
+ class EdgecutTests(unittest.TestCase):
34
+ def setUp(self):
35
+ temporary = tempfile.TemporaryDirectory()
36
+ self.addCleanup(temporary.cleanup)
37
+ self.directory = Path(temporary.name)
38
+
39
+ def cut(self, source, start, end):
40
+ events = []
41
+ work = self.directory / f'work-{len(list(self.directory.glob("work-*")))}'
42
+ work.mkdir()
43
+ output = edgecut.cut(source, start, end, work, event=lambda status, **d: events.append((status, d)))
44
+ return output, events
45
+
46
+ def assert_exact_clip(self, output, source, start, end, copied):
47
+ """Same frames as the source range; the copied ones bit-identical."""
48
+ expected, actual = frame_hashes(source, start, end), frame_hashes(output)
49
+ self.assertEqual(len(actual), len(expected))
50
+ self.assertGreaterEqual(sum(a == b for a, b in zip(actual, expected)), copied)
51
+ info = json.loads(subprocess.check_output(['ffprobe', '-v', 'error', '-show_entries',
52
+ 'stream=codec_type,start_time', '-of', 'json', str(output)]))
53
+ self.assertEqual({s['codec_type'] for s in info['streams']}, {'video', 'audio'})
54
+ self.assertTrue(all(abs(float(s['start_time'])) < 0.03 for s in info['streams']))
55
+
56
+ def test_copies_between_keyframes(self):
57
+ source = self.directory / 'source 日本語.mp4'
58
+ make_video(source)
59
+ output, events = self.cut(source, 0.37, 5.62)
60
+ plan = dict(events)['plan']
61
+ self.assertEqual((plan['k1'], plan['k2'], plan['body_frames']), (1.0, 5.0, 100))
62
+ self.assert_exact_clip(output, source, 0.37, 5.62, copied=100)
63
+
64
+ def test_matroska_hevc_10_bit_and_ranges_on_keyframes(self):
65
+ cases = [('matroska.mkv', H264, 0.37, 5.62, 100),
66
+ ('hevc.mp4', ('-c:v', 'libx265', '-x265-params',
67
+ 'keyint=25:min-keyint=25:no-open-gop=1:log-level=error'), 0.37, 5.62, 100),
68
+ ('10 bit.mp4', (*H264, '-pix_fmt', 'yuv420p10le'), 0.37, 5.62, 100),
69
+ ('on keyframes.mp4', H264, 1, 3, 50),
70
+ ('from zero.mp4', H264, 0, 2.3, 50)]
71
+ for name, codec_args, start, end, copied in cases:
72
+ with self.subTest(name):
73
+ source = self.directory / name
74
+ make_video(source, *codec_args)
75
+ self.assert_exact_clip(self.cut(source, start, end)[0], source, start, end, copied)
76
+
77
+ def test_unsuitable_sources_are_rejected_before_running(self):
78
+ open_gop = ('-c:v', 'libx265', '-x265-params', 'keyint=25:min-keyint=25:open-gop=1:log-level=error')
79
+ cases = [('xvid.avi', ('-c:v', 'mpeg4'), 0.37, 5.62, 'unsupported video codec mpeg4'),
80
+ ('open gop.mp4', open_gop, 0.37, 5.62, 'fewer than two IDR keyframes'),
81
+ ('short.mp4', H264, 1.2, 1.8, 'fewer than two IDR keyframes'),
82
+ ('past end.mp4', H264, 1, 9, 'range is outside the source duration')]
83
+ for name, codec_args, start, end, reason in cases:
84
+ with self.subTest(name):
85
+ source = self.directory / name
86
+ make_video(source, *codec_args)
87
+ run = Mock()
88
+ with self.assertRaisesRegex(edgecut.Unsupported, reason):
89
+ edgecut.cut(source, start, end, self.directory, run)
90
+ run.assert_not_called()
91
+
92
+ def test_failed_step_or_check_raises(self):
93
+ source = self.directory / 'source.mp4'
94
+ make_video(source)
95
+ with patch('edgecut.mux_command', return_value=['ffmpeg', '-v', 'error', '-i', '/nonexistent', '-f', 'null', '-']):
96
+ with self.assertRaisesRegex(edgecut.CutFailed, 'mux step exited'):
97
+ self.cut(source, 0.37, 5.62)
98
+ with patch('edgecut.verify', side_effect=edgecut.CutFailed('decode errors near a join')):
99
+ with self.assertRaisesRegex(edgecut.CutFailed, 'decode errors'):
100
+ self.cut(source, 0.37, 5.62)
101
+
102
+ def test_command_line(self):
103
+ source, output = self.directory / 'source.mp4', self.directory / 'clip.mp4'
104
+ make_video(source)
105
+ self.assertEqual(edgecut.main([str(source), '0:00.37', '5.62', str(output)]), 0)
106
+ self.assert_exact_clip(output, source, 0.37, 5.62, copied=100)
107
+ with patch('sys.stderr'):
108
+ self.assertEqual(edgecut.main([str(source), '1.2', '1.8', str(self.directory / 'short.mp4')]), 2)
109
+ self.assertFalse((self.directory / 'short.mp4').exists())
110
+
111
+ def test_parse_time(self):
112
+ self.assertEqual(edgecut.parse_time('83.5'), 83.5)
113
+ self.assertEqual(edgecut.parse_time('1:23.5'), 83.5)
114
+ self.assertEqual(edgecut.parse_time('1:01:23'), 3683)
115
+ for value in ['-1', '1:60', '1:61:00', 'abc', '1:2:3:4']:
116
+ with self.assertRaises(Exception):
117
+ edgecut.parse_time(value)
118
+
119
+
120
+ if __name__ == '__main__':
121
+ unittest.main()