beatfit 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.
- beatfit-0.1.0/.gitignore +8 -0
- beatfit-0.1.0/LICENSE +21 -0
- beatfit-0.1.0/PKG-INFO +145 -0
- beatfit-0.1.0/README.md +129 -0
- beatfit-0.1.0/pyproject.toml +41 -0
- beatfit-0.1.0/src/beatfit/__init__.py +23 -0
- beatfit-0.1.0/src/beatfit/ffmpeg.py +98 -0
- beatfit-0.1.0/src/beatfit/fit.py +547 -0
- beatfit-0.1.0/src/beatfit/ramp.py +118 -0
- beatfit-0.1.0/tests/test_ffmpeg.py +95 -0
- beatfit-0.1.0/tests/test_fit.py +246 -0
- beatfit-0.1.0/uv.lock +174 -0
beatfit-0.1.0/.gitignore
ADDED
beatfit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eddie Zhou
|
|
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.
|
beatfit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: beatfit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Retime video clips so chosen moments land on the beats of a song.
|
|
5
|
+
Project-URL: Homepage, https://github.com/TwelveNights/beatfit
|
|
6
|
+
Project-URL: Issues, https://github.com/TwelveNights/beatfit/issues
|
|
7
|
+
Author: Eddie Zhou
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: beat,editing,ffmpeg,montage,music,retiming,video
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
13
|
+
Classifier: Topic :: Multimedia :: Video
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# beatfit
|
|
18
|
+
|
|
19
|
+
Retime a video clip to fill part of a song so that chosen moments in it land on beats.
|
|
20
|
+
|
|
21
|
+
You give it the moments (as source frames), the song's beat times and the stretch of the
|
|
22
|
+
song the clip has to fill. It returns the clip as a list of constant-speed pieces.
|
|
23
|
+
|
|
24
|
+
Footage plays in real time unless the gap between two moments is longer than 5.5 s. Then
|
|
25
|
+
the middle of the gap is sped up to at most 1.5x, with short eased ramps and about a second
|
|
26
|
+
of real time either side of each moment. The lead-in is picked so the first moment falls on
|
|
27
|
+
a beat, a downbeat if possible, and each sped-up gap gets whatever length puts the next
|
|
28
|
+
moment on a beat. If a gap is still too long, its sped-up middle gets a jump cut. With the
|
|
29
|
+
default settings that happens to every gap long enough to speed up; raise `max_gap_out` to
|
|
30
|
+
speed up without cutting. Ranges you mark as protected are never sped up or cut.
|
|
31
|
+
|
|
32
|
+
The result fills the requested number of frames exactly and never plays slower than real
|
|
33
|
+
time or faster than `max_speed`.
|
|
34
|
+
|
|
35
|
+
beatfit doesn't analyse audio. Use a beat tracker such as
|
|
36
|
+
[beat_this](https://github.com/CPJKU/beat_this), [All-In-One](https://github.com/mir-aidj/all-in-one)
|
|
37
|
+
or [librosa](https://librosa.org) for beats and downbeats. It also doesn't decide which clip
|
|
38
|
+
goes where in the song; `fit_clip` returns `None` when a clip can't fill a slot, so you can
|
|
39
|
+
try clips against slots and keep the ones that fit.
|
|
40
|
+
|
|
41
|
+
Pure Python with no dependencies, 3.10 or newer.
|
|
42
|
+
|
|
43
|
+
## Example
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from beatfit import Clip, Event, Params, fit_clip
|
|
47
|
+
|
|
48
|
+
fps = 60
|
|
49
|
+
beats = [0.5 + 0.5 * k for k in range(200)] # 120 BPM, from your beat tracker (seconds)
|
|
50
|
+
downbeats = beats[::4]
|
|
51
|
+
|
|
52
|
+
# moments at 12.0 s, 13.1 s and 31.5 s of the recording (source frames at `fps`)
|
|
53
|
+
clip = Clip(
|
|
54
|
+
events=[Event(12 * fps), Event(round(13.1 * fps)), Event(round(31.5 * fps), protect=(4.0, 1.0))],
|
|
55
|
+
src_start=0, # usable footage
|
|
56
|
+
src_end=40 * fps,
|
|
57
|
+
)
|
|
58
|
+
start = round(downbeats[4] * fps) # fill bars 4-12 of the song
|
|
59
|
+
frames = round(downbeats[12] * fps) - start
|
|
60
|
+
fit = fit_clip(clip, start, frames, beats, downbeats, Params(fps=fps))
|
|
61
|
+
|
|
62
|
+
for p in fit.pieces: # constant-speed pieces: source frames -> output frames
|
|
63
|
+
print(p.src, p.src_frames, p.out_frames, round(p.speed, 3), "jump" if p.jump else "")
|
|
64
|
+
print(fit.event_out, fit.event_on_beat, fit.event_err_ms)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
To render it with ffmpeg (video only):
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import subprocess
|
|
71
|
+
from beatfit.ffmpeg import command
|
|
72
|
+
|
|
73
|
+
subprocess.run(command("recording.mp4", fit.pieces, fps, "clip.mp4"), check=True)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`beatfit.ffmpeg.filter_graph` and `input_args` return the parts of that command, for use
|
|
77
|
+
in a larger filter graph.
|
|
78
|
+
|
|
79
|
+
## Concepts
|
|
80
|
+
|
|
81
|
+
All times are integer frames at `Params.fps` (default 60) on two timelines: source (the
|
|
82
|
+
recording) and output (the song). Beats are in seconds on the output timeline. A source
|
|
83
|
+
frame is `1 / fps` seconds whatever the recording's own frame rate, so a 30 fps recording
|
|
84
|
+
works with `fps=60`.
|
|
85
|
+
|
|
86
|
+
A `Clip` has `events` in increasing source order and the usable source range
|
|
87
|
+
`[src_start, src_end)`. Optional fields:
|
|
88
|
+
|
|
89
|
+
| Field | Meaning |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `lead_from` | Start the lead-in here, e.g. where the tension begins. A long wait is sped up after a moment of real time. |
|
|
92
|
+
| `lead` | `Lead(pref_min, pref_max, max_realtime, hold)`: preferred lead-in length in seconds, the longest lead-in played entirely in real time, and the real time kept before the first event when the lead-in is longer than that. |
|
|
93
|
+
| `tail_min` | Frames of real time after the last event, e.g. for a reaction. The tail then ends on a bar line, up to `tail_slack_bars` later. |
|
|
94
|
+
| `protect` | Source ranges `[start, end)` that always play in real time. |
|
|
95
|
+
|
|
96
|
+
`Event.protect = (before, after)` protects that many seconds around one event.
|
|
97
|
+
`Event.soft` marks an event whose timing is approximate, so putting it on a beat is
|
|
98
|
+
optional.
|
|
99
|
+
|
|
100
|
+
A protected range between two events is always shown in real time, and the dead time on
|
|
101
|
+
either side of it can still be sped up or cut. A range around the first event is shown from
|
|
102
|
+
its start: the clip never begins inside it, and the lead-in stays in real time as long as
|
|
103
|
+
the range needs. A range earlier than that is shown only if the lead-in reaches back to it.
|
|
104
|
+
A clip can end before a range around its last event is over.
|
|
105
|
+
|
|
106
|
+
`Params` holds the other settings: real time around events (`pre_hold`, `post_hold`), the
|
|
107
|
+
speed cap (`max_speed`), the shortest gap that gets sped up (`speed_gap`), the longest
|
|
108
|
+
sped-up stretch before a jump cut (`max_gap_out`), lead-in and tail limits, and
|
|
109
|
+
`beats_per_bar`. `last=True` allows a longer tail for the final clip of a song, and
|
|
110
|
+
`music_end` penalises events that would land after the music stops.
|
|
111
|
+
|
|
112
|
+
A `Fit` has the `pieces`; each event's output frame relative to the slot (`event_out`);
|
|
113
|
+
whether it is on a beat or downbeat and how far it is from the nearest beat
|
|
114
|
+
(`event_err_ms`); and `max_speed`, `jumps`, `lead_out`, `tail_out` and `cost`. `locate`
|
|
115
|
+
and `source_at` convert between the two timelines.
|
|
116
|
+
|
|
117
|
+
## How it works
|
|
118
|
+
|
|
119
|
+
A dynamic program over (event, landing frame), where landing frames are the beats inside
|
|
120
|
+
the slot. Gaps shorter than `speed_gap` play in real time, so the event after one lands
|
|
121
|
+
wherever that puts it; the solver favours lead-ins that bring those events close to a beat.
|
|
122
|
+
A speed-up is up to three eased steps on each side of a constant plateau, so renderers and
|
|
123
|
+
editors only ever see constant speeds.
|
|
124
|
+
|
|
125
|
+
## Limits
|
|
126
|
+
|
|
127
|
+
- It fits one clip into one slot. Choosing and ordering clips is up to you.
|
|
128
|
+
- Times are whole frames, so an event lands within half a frame of its beat (8 ms at 60 fps).
|
|
129
|
+
- The ffmpeg helper renders video only. Retiming or muting the audio is up to you.
|
|
130
|
+
- It was written for game highlight montages at 60 fps. Other footage and tempos have only
|
|
131
|
+
been tested with synthetic data.
|
|
132
|
+
|
|
133
|
+
## Related work
|
|
134
|
+
|
|
135
|
+
- [visbeat](https://github.com/abedavis/visbeat) (Davis & Agrawala, "Visual Rhythm and
|
|
136
|
+
Beat", SIGGRAPH 2018) warps video so visible motion lines up with musical beats. beatfit
|
|
137
|
+
takes the moments as given and keeps real time around them.
|
|
138
|
+
- [mugen](https://github.com/scherroman/mugen) and similar tools cut video to the beat.
|
|
139
|
+
They put cuts on beats rather than moving moments inside a clip.
|
|
140
|
+
- [otio-music-arrangement](https://pypi.org/project/otio-music-arrangement/) builds
|
|
141
|
+
beat-aligned OpenTimelineIO timelines from placeholder clips.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
beatfit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# beatfit
|
|
2
|
+
|
|
3
|
+
Retime a video clip to fill part of a song so that chosen moments in it land on beats.
|
|
4
|
+
|
|
5
|
+
You give it the moments (as source frames), the song's beat times and the stretch of the
|
|
6
|
+
song the clip has to fill. It returns the clip as a list of constant-speed pieces.
|
|
7
|
+
|
|
8
|
+
Footage plays in real time unless the gap between two moments is longer than 5.5 s. Then
|
|
9
|
+
the middle of the gap is sped up to at most 1.5x, with short eased ramps and about a second
|
|
10
|
+
of real time either side of each moment. The lead-in is picked so the first moment falls on
|
|
11
|
+
a beat, a downbeat if possible, and each sped-up gap gets whatever length puts the next
|
|
12
|
+
moment on a beat. If a gap is still too long, its sped-up middle gets a jump cut. With the
|
|
13
|
+
default settings that happens to every gap long enough to speed up; raise `max_gap_out` to
|
|
14
|
+
speed up without cutting. Ranges you mark as protected are never sped up or cut.
|
|
15
|
+
|
|
16
|
+
The result fills the requested number of frames exactly and never plays slower than real
|
|
17
|
+
time or faster than `max_speed`.
|
|
18
|
+
|
|
19
|
+
beatfit doesn't analyse audio. Use a beat tracker such as
|
|
20
|
+
[beat_this](https://github.com/CPJKU/beat_this), [All-In-One](https://github.com/mir-aidj/all-in-one)
|
|
21
|
+
or [librosa](https://librosa.org) for beats and downbeats. It also doesn't decide which clip
|
|
22
|
+
goes where in the song; `fit_clip` returns `None` when a clip can't fill a slot, so you can
|
|
23
|
+
try clips against slots and keep the ones that fit.
|
|
24
|
+
|
|
25
|
+
Pure Python with no dependencies, 3.10 or newer.
|
|
26
|
+
|
|
27
|
+
## Example
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from beatfit import Clip, Event, Params, fit_clip
|
|
31
|
+
|
|
32
|
+
fps = 60
|
|
33
|
+
beats = [0.5 + 0.5 * k for k in range(200)] # 120 BPM, from your beat tracker (seconds)
|
|
34
|
+
downbeats = beats[::4]
|
|
35
|
+
|
|
36
|
+
# moments at 12.0 s, 13.1 s and 31.5 s of the recording (source frames at `fps`)
|
|
37
|
+
clip = Clip(
|
|
38
|
+
events=[Event(12 * fps), Event(round(13.1 * fps)), Event(round(31.5 * fps), protect=(4.0, 1.0))],
|
|
39
|
+
src_start=0, # usable footage
|
|
40
|
+
src_end=40 * fps,
|
|
41
|
+
)
|
|
42
|
+
start = round(downbeats[4] * fps) # fill bars 4-12 of the song
|
|
43
|
+
frames = round(downbeats[12] * fps) - start
|
|
44
|
+
fit = fit_clip(clip, start, frames, beats, downbeats, Params(fps=fps))
|
|
45
|
+
|
|
46
|
+
for p in fit.pieces: # constant-speed pieces: source frames -> output frames
|
|
47
|
+
print(p.src, p.src_frames, p.out_frames, round(p.speed, 3), "jump" if p.jump else "")
|
|
48
|
+
print(fit.event_out, fit.event_on_beat, fit.event_err_ms)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
To render it with ffmpeg (video only):
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import subprocess
|
|
55
|
+
from beatfit.ffmpeg import command
|
|
56
|
+
|
|
57
|
+
subprocess.run(command("recording.mp4", fit.pieces, fps, "clip.mp4"), check=True)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`beatfit.ffmpeg.filter_graph` and `input_args` return the parts of that command, for use
|
|
61
|
+
in a larger filter graph.
|
|
62
|
+
|
|
63
|
+
## Concepts
|
|
64
|
+
|
|
65
|
+
All times are integer frames at `Params.fps` (default 60) on two timelines: source (the
|
|
66
|
+
recording) and output (the song). Beats are in seconds on the output timeline. A source
|
|
67
|
+
frame is `1 / fps` seconds whatever the recording's own frame rate, so a 30 fps recording
|
|
68
|
+
works with `fps=60`.
|
|
69
|
+
|
|
70
|
+
A `Clip` has `events` in increasing source order and the usable source range
|
|
71
|
+
`[src_start, src_end)`. Optional fields:
|
|
72
|
+
|
|
73
|
+
| Field | Meaning |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `lead_from` | Start the lead-in here, e.g. where the tension begins. A long wait is sped up after a moment of real time. |
|
|
76
|
+
| `lead` | `Lead(pref_min, pref_max, max_realtime, hold)`: preferred lead-in length in seconds, the longest lead-in played entirely in real time, and the real time kept before the first event when the lead-in is longer than that. |
|
|
77
|
+
| `tail_min` | Frames of real time after the last event, e.g. for a reaction. The tail then ends on a bar line, up to `tail_slack_bars` later. |
|
|
78
|
+
| `protect` | Source ranges `[start, end)` that always play in real time. |
|
|
79
|
+
|
|
80
|
+
`Event.protect = (before, after)` protects that many seconds around one event.
|
|
81
|
+
`Event.soft` marks an event whose timing is approximate, so putting it on a beat is
|
|
82
|
+
optional.
|
|
83
|
+
|
|
84
|
+
A protected range between two events is always shown in real time, and the dead time on
|
|
85
|
+
either side of it can still be sped up or cut. A range around the first event is shown from
|
|
86
|
+
its start: the clip never begins inside it, and the lead-in stays in real time as long as
|
|
87
|
+
the range needs. A range earlier than that is shown only if the lead-in reaches back to it.
|
|
88
|
+
A clip can end before a range around its last event is over.
|
|
89
|
+
|
|
90
|
+
`Params` holds the other settings: real time around events (`pre_hold`, `post_hold`), the
|
|
91
|
+
speed cap (`max_speed`), the shortest gap that gets sped up (`speed_gap`), the longest
|
|
92
|
+
sped-up stretch before a jump cut (`max_gap_out`), lead-in and tail limits, and
|
|
93
|
+
`beats_per_bar`. `last=True` allows a longer tail for the final clip of a song, and
|
|
94
|
+
`music_end` penalises events that would land after the music stops.
|
|
95
|
+
|
|
96
|
+
A `Fit` has the `pieces`; each event's output frame relative to the slot (`event_out`);
|
|
97
|
+
whether it is on a beat or downbeat and how far it is from the nearest beat
|
|
98
|
+
(`event_err_ms`); and `max_speed`, `jumps`, `lead_out`, `tail_out` and `cost`. `locate`
|
|
99
|
+
and `source_at` convert between the two timelines.
|
|
100
|
+
|
|
101
|
+
## How it works
|
|
102
|
+
|
|
103
|
+
A dynamic program over (event, landing frame), where landing frames are the beats inside
|
|
104
|
+
the slot. Gaps shorter than `speed_gap` play in real time, so the event after one lands
|
|
105
|
+
wherever that puts it; the solver favours lead-ins that bring those events close to a beat.
|
|
106
|
+
A speed-up is up to three eased steps on each side of a constant plateau, so renderers and
|
|
107
|
+
editors only ever see constant speeds.
|
|
108
|
+
|
|
109
|
+
## Limits
|
|
110
|
+
|
|
111
|
+
- It fits one clip into one slot. Choosing and ordering clips is up to you.
|
|
112
|
+
- Times are whole frames, so an event lands within half a frame of its beat (8 ms at 60 fps).
|
|
113
|
+
- The ffmpeg helper renders video only. Retiming or muting the audio is up to you.
|
|
114
|
+
- It was written for game highlight montages at 60 fps. Other footage and tempos have only
|
|
115
|
+
been tested with synthetic data.
|
|
116
|
+
|
|
117
|
+
## Related work
|
|
118
|
+
|
|
119
|
+
- [visbeat](https://github.com/abedavis/visbeat) (Davis & Agrawala, "Visual Rhythm and
|
|
120
|
+
Beat", SIGGRAPH 2018) warps video so visible motion lines up with musical beats. beatfit
|
|
121
|
+
takes the moments as given and keeps real time around them.
|
|
122
|
+
- [mugen](https://github.com/scherroman/mugen) and similar tools cut video to the beat.
|
|
123
|
+
They put cuts on beats rather than moving moments inside a clip.
|
|
124
|
+
- [otio-music-arrangement](https://pypi.org/project/otio-music-arrangement/) builds
|
|
125
|
+
beat-aligned OpenTimelineIO timelines from placeholder clips.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "beatfit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Retime video clips so chosen moments land on the beats of a song."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Eddie Zhou" }]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = []
|
|
11
|
+
keywords = ["video", "music", "beat", "montage", "editing", "retiming", "ffmpeg"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Multimedia :: Video",
|
|
15
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/TwelveNights/beatfit"
|
|
20
|
+
Issues = "https://github.com/TwelveNights/beatfit/issues"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling>=1.27"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/beatfit"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 130
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["pytest>=8"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
# int() around round()/floor() is deliberate: inputs may be numpy floats (beats from a tracker)
|
|
40
|
+
# zip(xs, xs[1:]) for successive pairs is fine
|
|
41
|
+
ignore = ["RUF046", "RUF007"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Retime video clips so chosen moments land on the beats of a song."""
|
|
2
|
+
|
|
3
|
+
from .fit import Clip, Event, Fit, Lead, Params, Piece, fit_clip, locate, protected, realtime_ok, source_at
|
|
4
|
+
from .ramp import max_src, min_out, ramp
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Clip",
|
|
10
|
+
"Event",
|
|
11
|
+
"Fit",
|
|
12
|
+
"Lead",
|
|
13
|
+
"Params",
|
|
14
|
+
"Piece",
|
|
15
|
+
"fit_clip",
|
|
16
|
+
"locate",
|
|
17
|
+
"max_src",
|
|
18
|
+
"min_out",
|
|
19
|
+
"protected",
|
|
20
|
+
"ramp",
|
|
21
|
+
"realtime_ok",
|
|
22
|
+
"source_at",
|
|
23
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Render a fit with ffmpeg: one input per continuous stretch (split at jump cuts), each
|
|
2
|
+
retimed with a piecewise-linear ``setpts``, resampled to a constant frame rate and
|
|
3
|
+
concatenated. Video only: retiming audio is left to the caller."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from .fit import Piece
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Segment:
|
|
14
|
+
"""A continuous stretch of source (no jump cut inside)."""
|
|
15
|
+
|
|
16
|
+
pieces: list[Piece]
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def src(self) -> int:
|
|
20
|
+
return self.pieces[0].src
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def src_frames(self) -> int:
|
|
24
|
+
return sum(p.src_frames for p in self.pieces)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def out_frames(self) -> int:
|
|
28
|
+
return sum(p.out_frames for p in self.pieces)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def segments(pieces: list[Piece]) -> list[Segment]:
|
|
32
|
+
"""Split pieces at jump cuts (and anywhere else the source is not continuous)."""
|
|
33
|
+
out: list[Segment] = []
|
|
34
|
+
for pc in pieces:
|
|
35
|
+
if out and not pc.jump and out[-1].pieces[-1].src_end == pc.src:
|
|
36
|
+
out[-1].pieces.append(pc)
|
|
37
|
+
else:
|
|
38
|
+
out.append(Segment([pc]))
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _num(x: float) -> str:
|
|
43
|
+
return f"{x:.9f}".rstrip("0").rstrip(".") if x != int(x) else str(int(x))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def setpts_expr(pieces: list[Piece], fps: float) -> str:
|
|
47
|
+
"""Expression mapping segment-relative source time ``T`` (seconds) to output seconds."""
|
|
48
|
+
terms = []
|
|
49
|
+
a = 0
|
|
50
|
+
for p in pieces:
|
|
51
|
+
terms.append(f"clip(T-{_num(a / fps)},0,{_num(p.src_frames / fps)})*{_num(p.out_frames / p.src_frames)}")
|
|
52
|
+
a += p.src_frames
|
|
53
|
+
return "+".join(terms)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def filter_graph(pieces: list[Piece], fps: float, out_label: str = "v") -> str:
|
|
57
|
+
"""``-filter_complex`` graph for the inputs made by :func:`input_args` (input k is
|
|
58
|
+
segment k), ending in ``[out_label]``."""
|
|
59
|
+
segs = segments(pieces)
|
|
60
|
+
chains = []
|
|
61
|
+
for k, seg in enumerate(segs):
|
|
62
|
+
ch = f"[{k}:v:0]setpts=PTS-STARTPTS"
|
|
63
|
+
if any(p.src_frames != p.out_frames for p in seg.pieces):
|
|
64
|
+
ch += f",setpts='({setpts_expr(seg.pieces, fps)})/TB'"
|
|
65
|
+
# a constant frame rate, padded and trimmed to exactly the planned number of frames
|
|
66
|
+
ch += f",fps=fps={_num(fps)},tpad=stop_mode=clone:stop=8,trim=end_frame={seg.out_frames},setpts=PTS-STARTPTS[s{k}]"
|
|
67
|
+
chains.append(ch)
|
|
68
|
+
chains.append("".join(f"[s{k}]" for k in range(len(segs))) + f"concat=n={len(segs)}:v=1:a=0[{out_label}]")
|
|
69
|
+
return ";\n".join(chains)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def input_args(source: str, pieces: list[Piece], fps: float) -> list[str]:
|
|
73
|
+
"""``-ss``/``-t``/``-i`` arguments, one input per segment. Source frames are taken to be
|
|
74
|
+
``1 / fps`` seconds from the start of the file, whatever its own frame rate."""
|
|
75
|
+
args: list[str] = []
|
|
76
|
+
for seg in segments(pieces):
|
|
77
|
+
args += ["-ss", _num(seg.src / fps), "-t", _num((seg.src_frames + 2) / fps), "-i", source]
|
|
78
|
+
return args
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def command(
|
|
82
|
+
source: str, pieces: list[Piece], fps: float, output: str, ffmpeg: str = "ffmpeg", encode: list[str] | None = None
|
|
83
|
+
) -> list[str]:
|
|
84
|
+
"""A complete ffmpeg command rendering the pieces of ``source`` to ``output`` (no audio)."""
|
|
85
|
+
enc = encode if encode is not None else ["-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p"]
|
|
86
|
+
return [
|
|
87
|
+
ffmpeg,
|
|
88
|
+
"-hide_banner",
|
|
89
|
+
"-y",
|
|
90
|
+
*input_args(source, pieces, fps),
|
|
91
|
+
"-filter_complex",
|
|
92
|
+
filter_graph(pieces, fps),
|
|
93
|
+
"-map",
|
|
94
|
+
"[v]",
|
|
95
|
+
"-an",
|
|
96
|
+
*enc,
|
|
97
|
+
output,
|
|
98
|
+
]
|