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,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()
|