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 +21 -0
- edgecut-0.1.0/PKG-INFO +114 -0
- edgecut-0.1.0/README.md +97 -0
- edgecut-0.1.0/edgecut.egg-info/PKG-INFO +114 -0
- edgecut-0.1.0/edgecut.egg-info/SOURCES.txt +10 -0
- edgecut-0.1.0/edgecut.egg-info/dependency_links.txt +1 -0
- edgecut-0.1.0/edgecut.egg-info/entry_points.txt +2 -0
- edgecut-0.1.0/edgecut.egg-info/top_level.txt +1 -0
- edgecut-0.1.0/edgecut.py +336 -0
- edgecut-0.1.0/pyproject.toml +30 -0
- edgecut-0.1.0/setup.cfg +4 -0
- edgecut-0.1.0/tests/test_edgecut.py +121 -0
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.
|
edgecut-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
edgecut
|
edgecut-0.1.0/edgecut.py
ADDED
|
@@ -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"]
|
edgecut-0.1.0/setup.cfg
ADDED
|
@@ -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()
|