loopsmith 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Pratt
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.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: loopsmith
3
+ Version: 0.1.0
4
+ Summary: Find the longest seamlessly-loopable segment in a video via normalized cross-correlation.
5
+ Author-email: John Pratt <john@john-pratt.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jpratt9/loopsmith
8
+ Project-URL: Repository, https://github.com/jpratt9/loopsmith
9
+ Project-URL: Issues, https://github.com/jpratt9/loopsmith/issues
10
+ Keywords: video,loop,seamless-loop,gif,ncc,opencv,ffmpeg
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Video
16
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: opencv-python>=4.8
21
+ Requires-Dist: numpy>=1.24
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # loopsmith
27
+
28
+ Find the longest seamlessly-loopable segment in a video.
29
+
30
+ Given a clip, `loopsmith` figures out the longest stretch you can pull out and
31
+ play on repeat without a visible jump — the point where some later frame looks
32
+ almost exactly like an earlier one, so playback can snap back to the start with
33
+ no seam. Handy for turning B-roll, animations, or background footage into clean
34
+ loops (looping wallpapers, GIFs, video backdrops, and so on).
35
+
36
+ ## How it works
37
+
38
+ The core of it is a single matrix multiply.
39
+
40
+ 1. Sample every Nth frame, shrink each to a 160px thumbnail, convert to
41
+ grayscale, and z-score normalize it (subtract the mean, divide by the
42
+ standard deviation).
43
+ 2. Stack the normalized frames into a matrix and multiply it by its own
44
+ transpose. For z-scored vectors, each dot product (divided by the pixel
45
+ count) *is* the normalized cross-correlation between two frames — a
46
+ similarity score from -1 to 1 where 1 means "visually identical." So one
47
+ `mat @ mat.T` produces the score for every pair of frames at once.
48
+ 3. For each pair of frames, the gap between them is a candidate loop length.
49
+ Among all pairs whose similarity clears a threshold, the one with the
50
+ largest gap wins: those two near-identical frames are the in- and out-points
51
+ of the longest seam-free loop.
52
+
53
+ Because the expensive step is one BLAS matmul, it stays quick even though it's
54
+ effectively comparing every frame against every other frame.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install loopsmith
60
+ ```
61
+
62
+ From source, for development (includes the test deps):
63
+
64
+ ```bash
65
+ pip install -e ".[dev]"
66
+ ```
67
+
68
+ Needs Python 3.9+, OpenCV, and NumPy — the last two are pulled in automatically.
69
+
70
+ ## Usage (CLI)
71
+
72
+ ```bash
73
+ # Analyze a single video
74
+ loopsmith clip.mp4
75
+
76
+ # Batch every .mp4/.mov in a directory
77
+ loopsmith ./footage/
78
+
79
+ # Loosen or tighten the similarity threshold (default 0.85)
80
+ loopsmith clip.mp4 --threshold 0.80
81
+
82
+ # Sample every 5th frame instead of every 3rd (faster, coarser)
83
+ loopsmith clip.mp4 --downsample 5
84
+
85
+ # Detailed report: top 10 loops by similarity and by length
86
+ loopsmith clip.mp4 --detail
87
+
88
+ # Find the best loop closest to a target length, in seconds
89
+ loopsmith clip.mp4 --detail --target-length 6
90
+ ```
91
+
92
+ Batch mode prints the best loop per file as a table; `--detail` (single file)
93
+ prints the top candidates plus a yes/no "is this clip loopable" verdict.
94
+
95
+ ## Usage (library)
96
+
97
+ ```python
98
+ from loopsmith import extract_frames, find_best_loop
99
+
100
+ rows, frame_indices, fps, total = extract_frames("clip.mp4", downsample=3)
101
+ loop = find_best_loop(rows, frame_indices, threshold=0.85)
102
+ if loop:
103
+ start_frame, end_frame, ncc = loop
104
+ seconds = (end_frame - start_frame) / fps
105
+ print(f"Loop {start_frame}->{end_frame} ({seconds:.1f}s) at {ncc:.1%} similarity")
106
+ ```
107
+
108
+ Other helpers: `find_best_for_target()` (the loop closest to a target
109
+ duration), `find_top_loops()` (ranked candidates), and `analyze_video()` (a
110
+ one-call summary dict).
111
+
112
+ ## Notes & limitations
113
+
114
+ - **Visual only.** It compares frames, not audio. A visually seamless loop can
115
+ still have an audible seam — check the sound separately if that matters.
116
+ - **Cut resolution.** In/out points are accurate to within `--downsample`
117
+ frames, since that's the sampling step. Lower it for tighter cuts at the cost
118
+ of speed.
119
+ - **Scales as O(N²) in sampled frames.** It builds an N×N similarity matrix, so
120
+ for long videos raise `--downsample` to keep time and memory in check.
121
+ - **It reports, it doesn't cut.** Output is frame indices / timestamps and
122
+ similarity scores; trimming the actual clip (e.g. with ffmpeg) is up to you.
123
+
124
+ ## License
125
+
126
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,101 @@
1
+ # loopsmith
2
+
3
+ Find the longest seamlessly-loopable segment in a video.
4
+
5
+ Given a clip, `loopsmith` figures out the longest stretch you can pull out and
6
+ play on repeat without a visible jump — the point where some later frame looks
7
+ almost exactly like an earlier one, so playback can snap back to the start with
8
+ no seam. Handy for turning B-roll, animations, or background footage into clean
9
+ loops (looping wallpapers, GIFs, video backdrops, and so on).
10
+
11
+ ## How it works
12
+
13
+ The core of it is a single matrix multiply.
14
+
15
+ 1. Sample every Nth frame, shrink each to a 160px thumbnail, convert to
16
+ grayscale, and z-score normalize it (subtract the mean, divide by the
17
+ standard deviation).
18
+ 2. Stack the normalized frames into a matrix and multiply it by its own
19
+ transpose. For z-scored vectors, each dot product (divided by the pixel
20
+ count) *is* the normalized cross-correlation between two frames — a
21
+ similarity score from -1 to 1 where 1 means "visually identical." So one
22
+ `mat @ mat.T` produces the score for every pair of frames at once.
23
+ 3. For each pair of frames, the gap between them is a candidate loop length.
24
+ Among all pairs whose similarity clears a threshold, the one with the
25
+ largest gap wins: those two near-identical frames are the in- and out-points
26
+ of the longest seam-free loop.
27
+
28
+ Because the expensive step is one BLAS matmul, it stays quick even though it's
29
+ effectively comparing every frame against every other frame.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install loopsmith
35
+ ```
36
+
37
+ From source, for development (includes the test deps):
38
+
39
+ ```bash
40
+ pip install -e ".[dev]"
41
+ ```
42
+
43
+ Needs Python 3.9+, OpenCV, and NumPy — the last two are pulled in automatically.
44
+
45
+ ## Usage (CLI)
46
+
47
+ ```bash
48
+ # Analyze a single video
49
+ loopsmith clip.mp4
50
+
51
+ # Batch every .mp4/.mov in a directory
52
+ loopsmith ./footage/
53
+
54
+ # Loosen or tighten the similarity threshold (default 0.85)
55
+ loopsmith clip.mp4 --threshold 0.80
56
+
57
+ # Sample every 5th frame instead of every 3rd (faster, coarser)
58
+ loopsmith clip.mp4 --downsample 5
59
+
60
+ # Detailed report: top 10 loops by similarity and by length
61
+ loopsmith clip.mp4 --detail
62
+
63
+ # Find the best loop closest to a target length, in seconds
64
+ loopsmith clip.mp4 --detail --target-length 6
65
+ ```
66
+
67
+ Batch mode prints the best loop per file as a table; `--detail` (single file)
68
+ prints the top candidates plus a yes/no "is this clip loopable" verdict.
69
+
70
+ ## Usage (library)
71
+
72
+ ```python
73
+ from loopsmith import extract_frames, find_best_loop
74
+
75
+ rows, frame_indices, fps, total = extract_frames("clip.mp4", downsample=3)
76
+ loop = find_best_loop(rows, frame_indices, threshold=0.85)
77
+ if loop:
78
+ start_frame, end_frame, ncc = loop
79
+ seconds = (end_frame - start_frame) / fps
80
+ print(f"Loop {start_frame}->{end_frame} ({seconds:.1f}s) at {ncc:.1%} similarity")
81
+ ```
82
+
83
+ Other helpers: `find_best_for_target()` (the loop closest to a target
84
+ duration), `find_top_loops()` (ranked candidates), and `analyze_video()` (a
85
+ one-call summary dict).
86
+
87
+ ## Notes & limitations
88
+
89
+ - **Visual only.** It compares frames, not audio. A visually seamless loop can
90
+ still have an audible seam — check the sound separately if that matters.
91
+ - **Cut resolution.** In/out points are accurate to within `--downsample`
92
+ frames, since that's the sampling step. Lower it for tighter cuts at the cost
93
+ of speed.
94
+ - **Scales as O(N²) in sampled frames.** It builds an N×N similarity matrix, so
95
+ for long videos raise `--downsample` to keep time and memory in check.
96
+ - **It reports, it doesn't cut.** Output is frame indices / timestamps and
97
+ similarity scores; trimming the actual clip (e.g. with ffmpeg) is up to you.
98
+
99
+ ## License
100
+
101
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: loopsmith
3
+ Version: 0.1.0
4
+ Summary: Find the longest seamlessly-loopable segment in a video via normalized cross-correlation.
5
+ Author-email: John Pratt <john@john-pratt.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jpratt9/loopsmith
8
+ Project-URL: Repository, https://github.com/jpratt9/loopsmith
9
+ Project-URL: Issues, https://github.com/jpratt9/loopsmith/issues
10
+ Keywords: video,loop,seamless-loop,gif,ncc,opencv,ffmpeg
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Video
16
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: opencv-python>=4.8
21
+ Requires-Dist: numpy>=1.24
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # loopsmith
27
+
28
+ Find the longest seamlessly-loopable segment in a video.
29
+
30
+ Given a clip, `loopsmith` figures out the longest stretch you can pull out and
31
+ play on repeat without a visible jump — the point where some later frame looks
32
+ almost exactly like an earlier one, so playback can snap back to the start with
33
+ no seam. Handy for turning B-roll, animations, or background footage into clean
34
+ loops (looping wallpapers, GIFs, video backdrops, and so on).
35
+
36
+ ## How it works
37
+
38
+ The core of it is a single matrix multiply.
39
+
40
+ 1. Sample every Nth frame, shrink each to a 160px thumbnail, convert to
41
+ grayscale, and z-score normalize it (subtract the mean, divide by the
42
+ standard deviation).
43
+ 2. Stack the normalized frames into a matrix and multiply it by its own
44
+ transpose. For z-scored vectors, each dot product (divided by the pixel
45
+ count) *is* the normalized cross-correlation between two frames — a
46
+ similarity score from -1 to 1 where 1 means "visually identical." So one
47
+ `mat @ mat.T` produces the score for every pair of frames at once.
48
+ 3. For each pair of frames, the gap between them is a candidate loop length.
49
+ Among all pairs whose similarity clears a threshold, the one with the
50
+ largest gap wins: those two near-identical frames are the in- and out-points
51
+ of the longest seam-free loop.
52
+
53
+ Because the expensive step is one BLAS matmul, it stays quick even though it's
54
+ effectively comparing every frame against every other frame.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install loopsmith
60
+ ```
61
+
62
+ From source, for development (includes the test deps):
63
+
64
+ ```bash
65
+ pip install -e ".[dev]"
66
+ ```
67
+
68
+ Needs Python 3.9+, OpenCV, and NumPy — the last two are pulled in automatically.
69
+
70
+ ## Usage (CLI)
71
+
72
+ ```bash
73
+ # Analyze a single video
74
+ loopsmith clip.mp4
75
+
76
+ # Batch every .mp4/.mov in a directory
77
+ loopsmith ./footage/
78
+
79
+ # Loosen or tighten the similarity threshold (default 0.85)
80
+ loopsmith clip.mp4 --threshold 0.80
81
+
82
+ # Sample every 5th frame instead of every 3rd (faster, coarser)
83
+ loopsmith clip.mp4 --downsample 5
84
+
85
+ # Detailed report: top 10 loops by similarity and by length
86
+ loopsmith clip.mp4 --detail
87
+
88
+ # Find the best loop closest to a target length, in seconds
89
+ loopsmith clip.mp4 --detail --target-length 6
90
+ ```
91
+
92
+ Batch mode prints the best loop per file as a table; `--detail` (single file)
93
+ prints the top candidates plus a yes/no "is this clip loopable" verdict.
94
+
95
+ ## Usage (library)
96
+
97
+ ```python
98
+ from loopsmith import extract_frames, find_best_loop
99
+
100
+ rows, frame_indices, fps, total = extract_frames("clip.mp4", downsample=3)
101
+ loop = find_best_loop(rows, frame_indices, threshold=0.85)
102
+ if loop:
103
+ start_frame, end_frame, ncc = loop
104
+ seconds = (end_frame - start_frame) / fps
105
+ print(f"Loop {start_frame}->{end_frame} ({seconds:.1f}s) at {ncc:.1%} similarity")
106
+ ```
107
+
108
+ Other helpers: `find_best_for_target()` (the loop closest to a target
109
+ duration), `find_top_loops()` (ranked candidates), and `analyze_video()` (a
110
+ one-call summary dict).
111
+
112
+ ## Notes & limitations
113
+
114
+ - **Visual only.** It compares frames, not audio. A visually seamless loop can
115
+ still have an audible seam — check the sound separately if that matters.
116
+ - **Cut resolution.** In/out points are accurate to within `--downsample`
117
+ frames, since that's the sampling step. Lower it for tighter cuts at the cost
118
+ of speed.
119
+ - **Scales as O(N²) in sampled frames.** It builds an N×N similarity matrix, so
120
+ for long videos raise `--downsample` to keep time and memory in check.
121
+ - **It reports, it doesn't cut.** Output is frame indices / timestamps and
122
+ similarity scores; trimming the actual clip (e.g. with ffmpeg) is up to you.
123
+
124
+ ## License
125
+
126
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ loopsmith.py
4
+ pyproject.toml
5
+ loopsmith.egg-info/PKG-INFO
6
+ loopsmith.egg-info/SOURCES.txt
7
+ loopsmith.egg-info/dependency_links.txt
8
+ loopsmith.egg-info/entry_points.txt
9
+ loopsmith.egg-info/requires.txt
10
+ loopsmith.egg-info/top_level.txt
11
+ tests/test_loopsmith.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ loopsmith = loopsmith:main
@@ -0,0 +1,5 @@
1
+ opencv-python>=4.8
2
+ numpy>=1.24
3
+
4
+ [dev]
5
+ pytest>=8.0
@@ -0,0 +1 @@
1
+ loopsmith
@@ -0,0 +1,290 @@
1
+ """Find the longest seamlessly-loopable segment in a video using NCC.
2
+
3
+ Uses vectorized N^2 pairwise Normalized Cross-Correlation (a single matrix
4
+ multiply) to find the largest frame gap whose endpoints match above a
5
+ similarity threshold -- i.e. the longest sub-clip you can loop without a
6
+ visible seam.
7
+
8
+ CLI:
9
+ loopsmith video.mp4
10
+ loopsmith path/to/videos/ # batch every .mp4/.mov in a dir
11
+ loopsmith video.mp4 --threshold 0.80
12
+ loopsmith video.mp4 --downsample 5
13
+ loopsmith video.mp4 --detail # top-10 by similarity and length
14
+ loopsmith video.mp4 --detail --target-length 6
15
+ """
16
+
17
+ import argparse
18
+ import glob
19
+ import os
20
+ import time
21
+
22
+ import cv2
23
+ import numpy as np
24
+
25
+ THUMB = 160 # scale longest edge to this for comparison
26
+ DEFAULT_THRESHOLD = 0.85
27
+ DEFAULT_DOWNSAMPLE = 3 # every Nth frame
28
+
29
+
30
+ def extract_frames(video_path, downsample):
31
+ """Extract downsampled, normalized grayscale frames from a video.
32
+
33
+ Returns:
34
+ Tuple of (list of flattened normalized frames, list of original frame indices, fps).
35
+ """
36
+ cap = cv2.VideoCapture(video_path)
37
+ fps = cap.get(cv2.CAP_PROP_FPS)
38
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
39
+
40
+ rows = []
41
+ frame_indices = []
42
+ for i in range(0, total, downsample):
43
+ cap.set(cv2.CAP_PROP_POS_FRAMES, i)
44
+ ret, frame = cap.read()
45
+ if not ret:
46
+ break
47
+ h, w = frame.shape[:2]
48
+ scale = THUMB / max(h, w)
49
+ nw, nh = int(w * scale), int(h * scale)
50
+ g = cv2.cvtColor(cv2.resize(frame, (nw, nh)), cv2.COLOR_BGR2GRAY).astype(np.float32)
51
+ std = g.std()
52
+ if std < 1.0:
53
+ continue
54
+ normed = ((g - g.mean()) / std).flatten()
55
+ rows.append(normed)
56
+ frame_indices.append(i)
57
+ cap.release()
58
+
59
+ return rows, frame_indices, fps, total
60
+
61
+
62
+ def compute_ncc_matrix(rows, frame_indices):
63
+ """Compute all pairwise NCCs via matrix multiplication.
64
+
65
+ Returns:
66
+ Tuple of (ncc_matrix, gaps_matrix, frame_indices array).
67
+ """
68
+ mat = np.stack(rows) # (N, pixels)
69
+ ncc_matrix = mat @ mat.T / mat.shape[1] # (N, N)
70
+ idx_arr = np.array(frame_indices)
71
+ gaps = idx_arr[None, :] - idx_arr[:, None] # (N, N)
72
+ return ncc_matrix, gaps
73
+
74
+
75
+ def find_best_loop(rows, frame_indices, threshold):
76
+ """Find the largest frame gap with NCC above threshold.
77
+
78
+ Returns:
79
+ Tuple of (best_start_frame, best_end_frame, best_ncc) or None if no loop found.
80
+ """
81
+ n = len(rows)
82
+ if n < 2:
83
+ return None
84
+
85
+ ncc_matrix, gaps = compute_ncc_matrix(rows, frame_indices)
86
+ mask = (ncc_matrix >= threshold) & (gaps > 0)
87
+
88
+ if not mask.any():
89
+ return None
90
+
91
+ qualified_gaps = np.where(mask, gaps, 0)
92
+ flat_idx = np.argmax(qualified_gaps)
93
+ a, b = divmod(flat_idx, n)
94
+
95
+ return frame_indices[a], frame_indices[b], float(ncc_matrix[a, b])
96
+
97
+
98
+ def find_best_for_target(rows, frame_indices, fps, target_seconds, min_ncc=0.90):
99
+ """Find the best loop closest to target_seconds among high-NCC pairs.
100
+
101
+ Filters to pairs with NCC >= min_ncc and duration >= 1s, then picks
102
+ the one closest to the target duration.
103
+
104
+ Returns:
105
+ Tuple of (start_frame, end_frame, gap_seconds, ncc) or None.
106
+ """
107
+ n = len(rows)
108
+ if n < 2:
109
+ return None
110
+
111
+ ncc_matrix, gaps = compute_ncc_matrix(rows, frame_indices)
112
+
113
+ mask = gaps > 0
114
+ ai, bi = np.where(mask)
115
+ nccs = ncc_matrix[ai, bi]
116
+ durations = gaps[ai, bi].astype(float) / fps
117
+
118
+ # Filter: NCC >= min_ncc AND duration >= 1s
119
+ valid = (nccs >= min_ncc) & (durations >= 1.0)
120
+ if not valid.any():
121
+ return None
122
+
123
+ ai, bi, nccs, durations = ai[valid], bi[valid], nccs[valid], durations[valid]
124
+
125
+ # Pick closest to target duration
126
+ best_k = np.argmin(np.abs(durations - target_seconds))
127
+ return (
128
+ frame_indices[ai[best_k]],
129
+ frame_indices[bi[best_k]],
130
+ float(durations[best_k]),
131
+ float(nccs[best_k]),
132
+ )
133
+
134
+
135
+ def find_top_loops(rows, frame_indices, fps, top_n=10):
136
+ """Find top loops ranked by NCC and by gap length.
137
+
138
+ Returns:
139
+ Tuple of (by_ncc, by_gap) where each is a list of
140
+ (start_frame, end_frame, gap_seconds, ncc) tuples.
141
+ """
142
+ n = len(rows)
143
+ if n < 2:
144
+ return [], []
145
+
146
+ ncc_matrix, gaps = compute_ncc_matrix(rows, frame_indices)
147
+
148
+ # Upper triangle only (j > i)
149
+ mask = gaps > 0
150
+ ai, bi = np.where(mask)
151
+ nccs = ncc_matrix[ai, bi]
152
+ gap_vals = gaps[ai, bi]
153
+
154
+ # Top by NCC
155
+ ncc_order = np.argsort(nccs)[::-1][:top_n]
156
+ by_ncc = [
157
+ (frame_indices[ai[k]], frame_indices[bi[k]], float(gap_vals[k]) / fps, float(nccs[k]))
158
+ for k in ncc_order
159
+ ]
160
+
161
+ # Top by gap (longest first), with minimum NCC > 0.5 to filter garbage
162
+ valid = nccs > 0.5
163
+ if valid.any():
164
+ valid_idx = np.where(valid)[0]
165
+ gap_order = np.argsort(gap_vals[valid_idx])[::-1][:top_n]
166
+ by_gap = [
167
+ (frame_indices[ai[valid_idx[k]]], frame_indices[bi[valid_idx[k]]],
168
+ float(gap_vals[valid_idx[k]]) / fps, float(nccs[valid_idx[k]]))
169
+ for k in gap_order
170
+ ]
171
+ else:
172
+ by_gap = []
173
+
174
+ return by_ncc, by_gap
175
+
176
+
177
+ def analyze_video(video_path, threshold, downsample):
178
+ """Analyze a single video for loop segments.
179
+
180
+ Returns:
181
+ Dict with video info and loop detection results.
182
+ """
183
+ rows, frame_indices, fps, total = extract_frames(video_path, downsample)
184
+ dur = total / fps if fps > 0 else 0
185
+
186
+ result = {
187
+ "file": os.path.basename(video_path),
188
+ "total_frames": total,
189
+ "fps": fps,
190
+ "duration": dur,
191
+ "loop": None,
192
+ }
193
+
194
+ loop = find_best_loop(rows, frame_indices, threshold)
195
+ if loop:
196
+ start, end, ncc = loop
197
+ result["loop"] = {
198
+ "start_frame": start,
199
+ "end_frame": end,
200
+ "duration": (end - start) / fps,
201
+ "ncc": ncc,
202
+ }
203
+
204
+ return result
205
+
206
+
207
+ def main():
208
+ parser = argparse.ArgumentParser(description="Detect loop segments in video files")
209
+ parser.add_argument("path", help="Video file or directory of videos")
210
+ parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD,
211
+ help=f"NCC threshold (default: {DEFAULT_THRESHOLD})")
212
+ parser.add_argument("--downsample", type=int, default=DEFAULT_DOWNSAMPLE,
213
+ help=f"Extract every Nth frame (default: {DEFAULT_DOWNSAMPLE})")
214
+ parser.add_argument("--detail", action="store_true",
215
+ help="Show top 10 loops by NCC and by duration (single file only)")
216
+ parser.add_argument("--target-length", type=float, default=None,
217
+ help="Find best NCC loop closest to this duration in seconds")
218
+ args = parser.parse_args()
219
+
220
+ if os.path.isdir(args.path):
221
+ videos = sorted(
222
+ glob.glob(os.path.join(args.path, "*.mp4"))
223
+ + glob.glob(os.path.join(args.path, "*.mov"))
224
+ )
225
+ else:
226
+ videos = [args.path]
227
+
228
+ if args.detail and len(videos) == 1:
229
+ path = videos[0]
230
+ print(f"Analyzing: {os.path.basename(path)}")
231
+ t0 = time.time()
232
+ rows, frame_indices, fps, total = extract_frames(path, args.downsample)
233
+ dur = total / fps if fps > 0 else 0
234
+ print(f"Frames: {total}, FPS: {fps:.0f}, Duration: {dur:.1f}s, Sampled: {len(rows)}")
235
+
236
+ by_ncc, by_gap = find_top_loops(rows, frame_indices, fps)
237
+
238
+ print(f"\nTop 10 by NCC (highest similarity):")
239
+ print(f" {'Start':>6} {'End':>6} {'Duration':>9} {'NCC':>7}")
240
+ for start, end, gap_s, ncc in by_ncc:
241
+ print(f" f{start:>5} f{end:>5} {gap_s:>8.1f}s {ncc:>6.1%}")
242
+
243
+ print(f"\nTop 10 by duration (longest loops with NCC > 50%):")
244
+ print(f" {'Start':>6} {'End':>6} {'Duration':>9} {'NCC':>7}")
245
+ for start, end, gap_s, ncc in by_gap:
246
+ print(f" f{start:>5} f{end:>5} {gap_s:>8.1f}s {ncc:>6.1%}")
247
+
248
+ if args.target_length:
249
+ result = find_best_for_target(rows, frame_indices, fps, args.target_length)
250
+ if result:
251
+ print(f"\nBest loop near {args.target_length:.0f}s: f{result[0]}->f{result[1]} ({result[2]:.1f}s) NCC={result[3]:.1%}")
252
+ else:
253
+ print(f"\nNo loop found near {args.target_length:.0f}s")
254
+
255
+ # Check if highest NCC pair (>= 1s) is loopable
256
+ best = find_best_for_target(rows, frame_indices, fps, target_seconds=dur)
257
+ if best and best[3] >= 0.97:
258
+ raw_loopable = abs(best[2] - dur) <= 0.5
259
+ print(f"\nLoopable: YES (best NCC={best[3]:.1%}, {best[2]:.1f}s)")
260
+ if raw_loopable:
261
+ print(f"Original clip is loopable as-is (within 0.5s of {dur:.1f}s)")
262
+ else:
263
+ ncc_str = f"{best[3]:.1%}" if best else "N/A"
264
+ print(f"\nLoopable: NO (best NCC={ncc_str})")
265
+
266
+ print(f"\nDone in {time.time() - t0:.1f}s")
267
+ return
268
+
269
+ print(f"{'File':<55} {'Dur':>5} {'Best Loop':>22} {'NCC':>7} {'Loop?'}")
270
+ print("-" * 100)
271
+
272
+ t0 = time.time()
273
+ for path in videos:
274
+ result = analyze_video(path, args.threshold, args.downsample)
275
+ name = result["file"]
276
+ dur = result["duration"]
277
+
278
+ if result["loop"]:
279
+ lp = result["loop"]
280
+ loop_str = f"f{lp['start_frame']}->{lp['end_frame']} ({lp['duration']:.1f}s)"
281
+ tag = "YES" if lp["ncc"] >= 0.90 else "~"
282
+ print(f"{name:<55} {dur:>4.1f}s {loop_str:>22} {lp['ncc']:>6.1%} {tag}")
283
+ else:
284
+ print(f"{name:<55} {dur:>4.1f}s {'none found':>22} no")
285
+
286
+ print(f"\nDone in {time.time() - t0:.1f}s")
287
+
288
+
289
+ if __name__ == "__main__":
290
+ main()
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "loopsmith"
7
+ version = "0.1.0"
8
+ description = "Find the longest seamlessly-loopable segment in a video via normalized cross-correlation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "John Pratt", email = "john@john-pratt.com" }]
14
+ keywords = ["video", "loop", "seamless-loop", "gif", "ncc", "opencv", "ffmpeg"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Multimedia :: Video",
21
+ "Topic :: Scientific/Engineering :: Image Recognition",
22
+ ]
23
+ dependencies = [
24
+ "opencv-python>=4.8",
25
+ "numpy>=1.24",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8.0"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/jpratt9/loopsmith"
33
+ Repository = "https://github.com/jpratt9/loopsmith"
34
+ Issues = "https://github.com/jpratt9/loopsmith/issues"
35
+
36
+ [project.scripts]
37
+ loopsmith = "loopsmith:main"
38
+
39
+ [tool.setuptools]
40
+ py-modules = ["loopsmith"]
41
+
42
+ [tool.pytest.ini_options]
43
+ pythonpath = ["."]
44
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,197 @@
1
+ """Tests for the loopsmith module."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from loopsmith import (
9
+ compute_ncc_matrix,
10
+ find_best_for_target,
11
+ find_best_loop,
12
+ find_top_loops,
13
+ analyze_video,
14
+ )
15
+
16
+
17
+ def _make_frame(value, size=100):
18
+ """Create a normalized flat frame with a known pattern."""
19
+ frame = np.random.RandomState(value).randn(size).astype(np.float32)
20
+ frame = (frame - frame.mean()) / frame.std()
21
+ return frame
22
+
23
+
24
+ class TestComputeNccMatrix:
25
+ def test_self_similarity_is_one(self):
26
+ rows = [_make_frame(1), _make_frame(2)]
27
+ ncc_matrix, gaps = compute_ncc_matrix(rows, [0, 10])
28
+ assert ncc_matrix[0, 0] == pytest.approx(1.0, abs=0.01)
29
+ assert ncc_matrix[1, 1] == pytest.approx(1.0, abs=0.01)
30
+
31
+ def test_gaps_computed_correctly(self):
32
+ rows = [_make_frame(1), _make_frame(2), _make_frame(3)]
33
+ ncc_matrix, gaps = compute_ncc_matrix(rows, [0, 30, 90])
34
+ assert gaps[0, 1] == 30
35
+ assert gaps[0, 2] == 90
36
+ assert gaps[1, 2] == 60
37
+ assert gaps[1, 0] == -30
38
+
39
+ def test_identical_frames_have_high_ncc(self):
40
+ frame = _make_frame(42)
41
+ rows = [frame.copy(), frame.copy()]
42
+ ncc_matrix, _ = compute_ncc_matrix(rows, [0, 100])
43
+ assert ncc_matrix[0, 1] == pytest.approx(1.0, abs=0.01)
44
+
45
+ def test_different_frames_have_low_ncc(self):
46
+ rows = [_make_frame(1), _make_frame(999)]
47
+ ncc_matrix, _ = compute_ncc_matrix(rows, [0, 100])
48
+ assert ncc_matrix[0, 1] < 0.5
49
+
50
+
51
+ class TestFindBestLoop:
52
+ def test_finds_loop_above_threshold(self):
53
+ frame = _make_frame(42)
54
+ rows = [frame.copy(), _make_frame(2), frame.copy()]
55
+ indices = [0, 30, 90]
56
+ result = find_best_loop(rows, indices, threshold=0.90)
57
+ assert result is not None
58
+ start, end, ncc = result
59
+ assert start == 0
60
+ assert end == 90
61
+ assert ncc > 0.90
62
+
63
+ def test_returns_none_below_threshold(self):
64
+ rows = [_make_frame(1), _make_frame(2), _make_frame(3)]
65
+ indices = [0, 30, 60]
66
+ result = find_best_loop(rows, indices, threshold=0.99)
67
+ assert result is None
68
+
69
+ def test_returns_none_with_fewer_than_two_frames(self):
70
+ result = find_best_loop([_make_frame(1)], [0], threshold=0.5)
71
+ assert result is None
72
+
73
+ def test_picks_largest_gap(self):
74
+ frame = _make_frame(42)
75
+ rows = [frame.copy(), frame.copy(), _make_frame(99), frame.copy()]
76
+ indices = [0, 10, 50, 100]
77
+ result = find_best_loop(rows, indices, threshold=0.90)
78
+ assert result is not None
79
+ start, end, ncc = result
80
+ # Should pick f0->f100 (gap=100) over f0->f10 (gap=10)
81
+ assert end - start == 100
82
+
83
+
84
+ class TestFindBestForTarget:
85
+ def test_finds_closest_to_target(self):
86
+ frame = _make_frame(42)
87
+ # 3 identical frames at 0, 150, 300 -> gaps of 5s and 10s at 30fps
88
+ rows = [frame.copy(), frame.copy(), frame.copy()]
89
+ indices = [0, 150, 300]
90
+ result = find_best_for_target(rows, indices, fps=30.0, target_seconds=9.0)
91
+ assert result is not None
92
+ start, end, dur, ncc = result
93
+ # Should pick the 10s pair (closest to 9s target)
94
+ assert dur == pytest.approx(10.0, abs=0.1)
95
+ assert ncc > 0.99
96
+
97
+ def test_picks_closest_duration_among_top_ncc(self):
98
+ frame = _make_frame(42)
99
+ # 4 identical frames at different gaps: 2s, 5s, 8s, 10s at 30fps
100
+ rows = [frame.copy(), frame.copy(), frame.copy(), frame.copy()]
101
+ indices = [0, 60, 150, 300]
102
+ # Target 7s -> should pick 8s (f0->f240 isn't available, but f60->f300 = 8s)
103
+ result = find_best_for_target(rows, indices, fps=30.0, target_seconds=7.0)
104
+ assert result is not None
105
+ # All pairs have ~100% NCC, so it picks purely by closest duration
106
+ assert result[3] > 0.9
107
+
108
+ def test_skips_short_gaps(self):
109
+ frame = _make_frame(42)
110
+ # Two identical frames only 10 frames apart at 30fps = 0.33s (< 1s threshold)
111
+ rows = [frame.copy(), frame.copy()]
112
+ indices = [0, 10]
113
+ result = find_best_for_target(rows, indices, fps=30.0, target_seconds=5.0)
114
+ assert result is None
115
+
116
+ def test_returns_none_with_fewer_than_two_frames(self):
117
+ result = find_best_for_target([_make_frame(1)], [0], fps=30.0, target_seconds=5.0)
118
+ assert result is None
119
+
120
+ def test_returns_none_when_no_high_ncc_pairs(self):
121
+ # Two different frames -- NCC will be low, below min_ncc threshold
122
+ rows = [_make_frame(1), _make_frame(2)]
123
+ indices = [0, 300]
124
+ result = find_best_for_target(rows, indices, fps=30.0, target_seconds=10.0)
125
+ assert result is None
126
+
127
+
128
+ class TestFindTopLoops:
129
+ def test_returns_top_by_ncc(self):
130
+ frame_a = _make_frame(1)
131
+ frame_b = _make_frame(2)
132
+ rows = [frame_a.copy(), frame_b.copy(), frame_a.copy()]
133
+ indices = [0, 30, 60]
134
+ by_ncc, by_gap = find_top_loops(rows, indices, fps=30.0, top_n=5)
135
+ assert len(by_ncc) > 0
136
+ # First entry should have highest NCC
137
+ assert by_ncc[0][3] >= by_ncc[-1][3]
138
+
139
+ def test_returns_top_by_gap(self):
140
+ frame = _make_frame(42)
141
+ rows = [frame.copy(), _make_frame(2), frame.copy()]
142
+ indices = [0, 30, 90]
143
+ by_ncc, by_gap = find_top_loops(rows, indices, fps=30.0, top_n=5)
144
+ assert len(by_gap) > 0
145
+ # First entry should have largest gap
146
+ assert by_gap[0][2] >= by_gap[-1][2]
147
+
148
+ def test_empty_with_fewer_than_two_frames(self):
149
+ by_ncc, by_gap = find_top_loops([_make_frame(1)], [0], fps=30.0)
150
+ assert by_ncc == []
151
+ assert by_gap == []
152
+
153
+ def test_gap_duration_is_in_seconds(self):
154
+ frame = _make_frame(42)
155
+ rows = [frame.copy(), frame.copy()]
156
+ indices = [0, 90]
157
+ by_ncc, _ = find_top_loops(rows, indices, fps=30.0)
158
+ # 90 frames at 30fps = 3.0 seconds
159
+ assert by_ncc[0][2] == pytest.approx(3.0, abs=0.01)
160
+
161
+
162
+ class TestAnalyzeVideo:
163
+ @patch("loopsmith.extract_frames")
164
+ def test_returns_loop_info(self, mock_extract):
165
+ frame = _make_frame(42)
166
+ mock_extract.return_value = (
167
+ [frame.copy(), _make_frame(2), frame.copy()],
168
+ [0, 30, 90],
169
+ 30.0,
170
+ 100,
171
+ )
172
+ result = analyze_video("/fake/video.mp4", threshold=0.85, downsample=3)
173
+ assert result["file"] == "video.mp4"
174
+ assert result["total_frames"] == 100
175
+ assert result["fps"] == 30.0
176
+ assert result["loop"] is not None
177
+ assert result["loop"]["start_frame"] == 0
178
+ assert result["loop"]["end_frame"] == 90
179
+ assert result["loop"]["ncc"] > 0.90
180
+
181
+ @patch("loopsmith.extract_frames")
182
+ def test_returns_none_when_no_loop(self, mock_extract):
183
+ mock_extract.return_value = (
184
+ [_make_frame(1), _make_frame(2), _make_frame(3)],
185
+ [0, 30, 60],
186
+ 30.0,
187
+ 100,
188
+ )
189
+ result = analyze_video("/fake/video.mp4", threshold=0.99, downsample=3)
190
+ assert result["loop"] is None
191
+
192
+ @patch("loopsmith.extract_frames")
193
+ def test_handles_zero_fps(self, mock_extract):
194
+ mock_extract.return_value = ([], [], 0.0, 0)
195
+ result = analyze_video("/fake/video.mp4", threshold=0.85, downsample=3)
196
+ assert result["duration"] == 0
197
+ assert result["loop"] is None