loopsmith 0.1.0__py3-none-any.whl

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,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,7 @@
1
+ loopsmith.py,sha256=txruTlZyV6Uc-ZCaHWQ-RHUvAQdSfoCqv6LDBb_yONM,9660
2
+ loopsmith-0.1.0.dist-info/licenses/LICENSE,sha256=yvXlAOPstWkenxnZFiBmsokfmb4Z3oGdABBS_ZdVr-E,1067
3
+ loopsmith-0.1.0.dist-info/METADATA,sha256=zpEk-xLaJrNxpKWBqfS_ztpeAawNLlh2qbd2Qh7U3js,4621
4
+ loopsmith-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ loopsmith-0.1.0.dist-info/entry_points.txt,sha256=VXWb_NejNwsD6r05S6EMeODVI_IqzkpJVVVvpLYuE6o,45
6
+ loopsmith-0.1.0.dist-info/top_level.txt,sha256=QjvLhoIiKDoWTdLTrQcTsBAplpOPtFdbNBC0w0cgD_c,10
7
+ loopsmith-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ loopsmith = loopsmith:main
@@ -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 @@
1
+ loopsmith
loopsmith.py ADDED
@@ -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()