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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
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
@@ -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
+ ]