overc-clips 0.2.2__tar.gz → 0.2.4__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.
- {overc_clips-0.2.2 → overc_clips-0.2.4}/PKG-INFO +1 -1
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/config.py +20 -1
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/engine.py +62 -9
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/jobs.py +34 -4
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/server.py +1 -1
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/web/index.html +112 -6
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/PKG-INFO +1 -1
- {overc_clips-0.2.2 → overc_clips-0.2.4}/pyproject.toml +1 -1
- {overc_clips-0.2.2 → overc_clips-0.2.4}/LICENSE +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/README.md +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/__init__.py +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/cli.py +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/history.py +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/importer.py +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/library.py +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/preflight.py +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/resources/overc.icns +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/resources/overc.png +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/tray.py +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/SOURCES.txt +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/dependency_links.txt +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/entry_points.txt +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/requires.txt +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/top_level.txt +0 -0
- {overc_clips-0.2.2 → overc_clips-0.2.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: overc-clips
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Cut volleyball analysis clips from video, locally or hosted. Web UI, no command line required.
|
|
5
5
|
Author-email: Bruce Ashfield <bruce.ashfield@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-only
|
|
@@ -28,13 +28,18 @@ DEFAULTS = {
|
|
|
28
28
|
"clip_limit": 120, # live — seconds; 0 means the platform has no limit
|
|
29
29
|
"recent_limit": 12, # live — how many videos "recent" keeps
|
|
30
30
|
"autosave_points": False, # live — keep a running record of clip points
|
|
31
|
+
"player_size": "normal", # live — half | normal | double
|
|
32
|
+
"max_height": 720, # live — advanced: fetch at most this tall
|
|
33
|
+
"exact_cut": False, # live — advanced: decode to the exact frame
|
|
34
|
+
"quality": "balanced", # live — advanced: smaller | balanced | better
|
|
31
35
|
"port": 8080, # restart
|
|
32
36
|
"host": "0.0.0.0", # restart
|
|
33
37
|
"token": "", # restart
|
|
34
38
|
"outdir": "", # restart; empty means "decide at launch"
|
|
35
39
|
}
|
|
36
40
|
LIVE_KEYS = ("keep_days", "encoder", "clip_limit", "recent_limit",
|
|
37
|
-
"autosave_points"
|
|
41
|
+
"autosave_points", "player_size",
|
|
42
|
+
"max_height", "exact_cut", "quality")
|
|
38
43
|
RESTART_KEYS = ("port", "host", "token", "outdir")
|
|
39
44
|
|
|
40
45
|
|
|
@@ -93,6 +98,20 @@ class Config:
|
|
|
93
98
|
raise ValueError("a limit under 10s would split everything to nothing")
|
|
94
99
|
elif k == "autosave_points":
|
|
95
100
|
v = bool(v)
|
|
101
|
+
elif k == "max_height":
|
|
102
|
+
v = int(v)
|
|
103
|
+
if v not in (0, 480, 720, 1080, 1440, 2160):
|
|
104
|
+
raise ValueError("must be 480, 720, 1080, 1440, 2160, or 0 for best")
|
|
105
|
+
elif k == "exact_cut":
|
|
106
|
+
v = bool(v)
|
|
107
|
+
elif k == "quality":
|
|
108
|
+
v = str(v)
|
|
109
|
+
if v not in ("smaller", "balanced", "better"):
|
|
110
|
+
raise ValueError("must be smaller, balanced or better")
|
|
111
|
+
elif k == "player_size":
|
|
112
|
+
v = str(v)
|
|
113
|
+
if v not in ("half", "normal", "double"):
|
|
114
|
+
raise ValueError("must be half, normal or double")
|
|
96
115
|
elif k == "recent_limit":
|
|
97
116
|
v = int(v)
|
|
98
117
|
if not 1 <= v <= 200:
|
|
@@ -147,6 +147,29 @@ def plan_clip(name: str, start: str, end: str, ceiling: int = CEILING) -> list[C
|
|
|
147
147
|
]
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
def probe_vcodec(path: Path, tools) -> str:
|
|
151
|
+
"""The video codec in a file, or "" if it cannot be determined.
|
|
152
|
+
|
|
153
|
+
Uses ffprobe when there is one and parses `ffmpeg -i` when there is not --
|
|
154
|
+
the bundled ffmpeg ships without ffprobe, which is exactly the machine this
|
|
155
|
+
check matters on.
|
|
156
|
+
"""
|
|
157
|
+
if getattr(tools, "ffprobe", None):
|
|
158
|
+
r = subprocess.run(
|
|
159
|
+
[str(tools.ffprobe), "-v", "error", "-select_streams", "v:0",
|
|
160
|
+
"-show_entries", "stream=codec_name", "-of", "csv=p=0", str(path)],
|
|
161
|
+
capture_output=True, text=True)
|
|
162
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
163
|
+
return r.stdout.strip().splitlines()[0].strip().lower()
|
|
164
|
+
if getattr(tools, "ffmpeg", None):
|
|
165
|
+
r = subprocess.run([str(tools.ffmpeg), "-i", str(path)],
|
|
166
|
+
capture_output=True, text=True)
|
|
167
|
+
m = re.search(r"Stream #\d+:\d+.*?: Video: (\w+)", r.stderr or "")
|
|
168
|
+
if m:
|
|
169
|
+
return m.group(1).lower()
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
|
|
150
173
|
def probe_duration(path: Path, tools) -> float | None:
|
|
151
174
|
"""Seconds, or None if it cannot be determined.
|
|
152
175
|
|
|
@@ -198,6 +221,7 @@ def clip_metadata(title: str, source: str, start: str, end: str,
|
|
|
198
221
|
|
|
199
222
|
|
|
200
223
|
def encode(src: Path, clip: Clip, outdir: Path, tools, *, on_proc=None,
|
|
224
|
+
quality: str = "balanced",
|
|
201
225
|
encoder: str = "sw", accurate: bool = False, copy: bool = False,
|
|
202
226
|
limit: int | None = HARD_LIMIT, meta: dict | None = None) -> Path:
|
|
203
227
|
"""Produce one clip file and verify it against the hard limit."""
|
|
@@ -206,12 +230,13 @@ def encode(src: Path, clip: Clip, outdir: Path, tools, *, on_proc=None,
|
|
|
206
230
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
207
231
|
dest = outdir / f"{clip.name}.mp4"
|
|
208
232
|
|
|
233
|
+
q = QUALITY.get(quality, QUALITY["balanced"])
|
|
209
234
|
if copy:
|
|
210
235
|
vcodec = ["-c:v", "copy"]
|
|
211
236
|
elif encoder == "hw":
|
|
212
|
-
vcodec = ["-c:v", "h264_videotoolbox", "-b:v", "
|
|
237
|
+
vcodec = ["-c:v", "h264_videotoolbox", "-b:v", q["bitrate"], "-allow_sw", "1"]
|
|
213
238
|
elif encoder == "sw":
|
|
214
|
-
vcodec = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "
|
|
239
|
+
vcodec = ["-c:v", "libx264", "-preset", "veryfast", "-crf", q["crf"]]
|
|
215
240
|
else:
|
|
216
241
|
raise ClipError(f"encoder must be 'sw' or 'hw', got {encoder!r}")
|
|
217
242
|
|
|
@@ -243,8 +268,32 @@ def encode(src: Path, clip: Clip, outdir: Path, tools, *, on_proc=None,
|
|
|
243
268
|
# the DASH streams download fine -- verified both ways against the same video.
|
|
244
269
|
# Falling back to `b[...]` would quietly reintroduce progressive, so it is last
|
|
245
270
|
# and only reached when nothing else exists.
|
|
246
|
-
|
|
247
|
-
|
|
271
|
+
# avc1 first, explicitly. Asking only for ext=mp4 is not the same thing: yt-dlp
|
|
272
|
+
# will happily merge VP9 or AV1 into an mp4 container, and QuickTime and Safari
|
|
273
|
+
# decode neither -- which plays as a black picture with working sound. The
|
|
274
|
+
# later branches stay as a last resort, and anything they produce is re-encoded
|
|
275
|
+
# rather than trusted.
|
|
276
|
+
def format_selector(max_height: int = 720) -> str:
|
|
277
|
+
"""What to ask YouTube for. 0 means "best available"."""
|
|
278
|
+
h = f"[height<={max_height}]" if max_height else ""
|
|
279
|
+
return (f"bv*{h}[vcodec^=avc1]+ba[acodec^=mp4a]/"
|
|
280
|
+
f"bv*{h}[ext=mp4]+ba[ext=m4a]/"
|
|
281
|
+
f"bv*{h}+ba/b{h}")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
FORMAT_SELECTOR = format_selector() # the default, for callers with no opinion
|
|
285
|
+
|
|
286
|
+
# Quality is three named points rather than a CRF box: the number means nothing
|
|
287
|
+
# without knowing the scale runs backwards, and these are the only three
|
|
288
|
+
# answers anyone actually wants.
|
|
289
|
+
QUALITY = {
|
|
290
|
+
"smaller": {"crf": "26", "bitrate": "2M"},
|
|
291
|
+
"balanced": {"crf": "20", "bitrate": "4M"},
|
|
292
|
+
"better": {"crf": "17", "bitrate": "8M"},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# What a mac, an iPhone and every browser can play without thinking about it.
|
|
296
|
+
PLAYABLE_VCODECS = {"h264", "avc1"}
|
|
248
297
|
|
|
249
298
|
VIDEO_ID_RE = re.compile(
|
|
250
299
|
r"(?:youtube\.com/(?:watch\?(?:.*&)?v=|live/|shorts/)|youtu\.be/)([A-Za-z0-9_-]{11})")
|
|
@@ -280,7 +329,7 @@ def _ffmpeg_loc(tools) -> list[str]:
|
|
|
280
329
|
|
|
281
330
|
|
|
282
331
|
def fetch_full(url: str, cache_dir: Path, tools, *, log=lambda _m: None,
|
|
283
|
-
on_proc=None) -> Path:
|
|
332
|
+
on_proc=None, max_height: int = 720) -> Path:
|
|
284
333
|
"""Download the whole video once, natively, and keep it.
|
|
285
334
|
|
|
286
335
|
The ranged path below hands the CDN URL to ffmpeg, and YouTube answers
|
|
@@ -295,6 +344,7 @@ def fetch_full(url: str, cache_dir: Path, tools, *, log=lambda _m: None,
|
|
|
295
344
|
from . import preflight
|
|
296
345
|
vid = video_id(url) or sanitise(url)[:32]
|
|
297
346
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
fmt = format_selector(max_height)
|
|
298
348
|
dest = cache_dir / f"{vid}.mp4"
|
|
299
349
|
if dest.exists() and dest.stat().st_size > 0:
|
|
300
350
|
log(f"using cached download for {vid}")
|
|
@@ -302,7 +352,7 @@ def fetch_full(url: str, cache_dir: Path, tools, *, log=lambda _m: None,
|
|
|
302
352
|
|
|
303
353
|
log(f"downloading the full video once ({vid}) — this is the slow part")
|
|
304
354
|
cmd = [*preflight.ytdlp_cmd(tools),
|
|
305
|
-
"-f",
|
|
355
|
+
"-f", fmt, "--merge-output-format", "mp4",
|
|
306
356
|
*_ffmpeg_loc(tools),
|
|
307
357
|
"-o", str(cache_dir / f"{vid}.%(ext)s"), url]
|
|
308
358
|
r = _spawn(cmd, on_proc)
|
|
@@ -366,7 +416,8 @@ def provenance_from_tags(tags: dict) -> dict:
|
|
|
366
416
|
|
|
367
417
|
def fetch_span(url: str, start: str, end: str, dest_stem: Path, tools,
|
|
368
418
|
*, attempts: int = 2, cache_dir: Path | None = None,
|
|
369
|
-
log=lambda _m: None, on_proc=None, stop=None
|
|
419
|
+
log=lambda _m: None, on_proc=None, stop=None,
|
|
420
|
+
max_height: int = 720) -> Path:
|
|
370
421
|
"""Get just this span as dest_stem.mp4.
|
|
371
422
|
|
|
372
423
|
Fast path: ask yt-dlp for the range, which it fulfils via ffmpeg. That is a
|
|
@@ -378,6 +429,7 @@ def fetch_span(url: str, start: str, end: str, dest_stem: Path, tools,
|
|
|
378
429
|
if not tools.ytdlp:
|
|
379
430
|
raise ClipError("yt-dlp not available — run `overc-clips doctor`")
|
|
380
431
|
dest_stem.parent.mkdir(parents=True, exist_ok=True)
|
|
432
|
+
fmt = format_selector(max_height)
|
|
381
433
|
# --ffmpeg-location is what makes the ranged fetch work on a machine with
|
|
382
434
|
# no system ffmpeg. yt-dlp fulfils --download-sections through ffmpeg and
|
|
383
435
|
# finds it on PATH; the bundled copy is inside site-packages under a
|
|
@@ -388,7 +440,7 @@ def fetch_span(url: str, start: str, end: str, dest_stem: Path, tools,
|
|
|
388
440
|
*_ffmpeg_loc(tools),
|
|
389
441
|
"--download-sections", f"*{start}-{end}",
|
|
390
442
|
"--force-keyframes-at-cuts",
|
|
391
|
-
"-f",
|
|
443
|
+
"-f", fmt,
|
|
392
444
|
"--merge-output-format", "mp4",
|
|
393
445
|
"-o", f"{dest_stem}.%(ext)s", url]
|
|
394
446
|
last = ""
|
|
@@ -415,7 +467,8 @@ def fetch_span(url: str, start: str, end: str, dest_stem: Path, tools,
|
|
|
415
467
|
if stop and stop():
|
|
416
468
|
raise Cancelled("stopped")
|
|
417
469
|
log("ranged fetch unavailable for this video; falling back to full download")
|
|
418
|
-
full = fetch_full(url, cache_dir, tools, log=log, on_proc=on_proc
|
|
470
|
+
full = fetch_full(url, cache_dir, tools, log=log, on_proc=on_proc,
|
|
471
|
+
max_height=max_height)
|
|
419
472
|
span = Clip(dest_stem.name, parse_hms(start), parse_hms(end) - parse_hms(start))
|
|
420
473
|
out = encode(full, span, dest_stem.parent, tools, accurate=True)
|
|
421
474
|
return out.rename(Path(f"{dest_stem}.mp4")) if out.name != f"{dest_stem.name}.mp4" else out
|
|
@@ -81,8 +81,12 @@ def _hold(job: Job):
|
|
|
81
81
|
class Runner:
|
|
82
82
|
def __init__(self, outdir: Path, workers: int = 1, keep: int = 200,
|
|
83
83
|
cache_dir: Path | None = None, clip_limit: int = 120,
|
|
84
|
-
library=None):
|
|
84
|
+
library=None, config=None):
|
|
85
85
|
self.outdir = Path(outdir)
|
|
86
|
+
# Read through to config on each use rather than snapshotting: these
|
|
87
|
+
# are live settings, and a change should apply to the next job without
|
|
88
|
+
# a restart.
|
|
89
|
+
self.config = config
|
|
86
90
|
# Full downloads land here when the ranged fetch is refused. Shared
|
|
87
91
|
# across jobs so several clips from one match cost one download.
|
|
88
92
|
self.cache_dir = Path(cache_dir or self.outdir / ".cache")
|
|
@@ -194,6 +198,9 @@ class Runner:
|
|
|
194
198
|
if self.library.delete(job.replaces):
|
|
195
199
|
job.say(f"replaced {job.replaces}")
|
|
196
200
|
|
|
201
|
+
def _opt(self, key, default):
|
|
202
|
+
return (self.config.data.get(key, default) if self.config else default)
|
|
203
|
+
|
|
197
204
|
def _meta(self, job: Job, clip) -> dict:
|
|
198
205
|
return engine.clip_metadata(
|
|
199
206
|
job.title, job.url or job.path, clip.start_hms,
|
|
@@ -226,7 +233,9 @@ class Runner:
|
|
|
226
233
|
for c in plan:
|
|
227
234
|
p = engine.encode(src, c, out, self.tools, on_proc=_hold(job),
|
|
228
235
|
encoder=job.encoder,
|
|
229
|
-
accurate=job.accurate
|
|
236
|
+
accurate=job.accurate or self._opt("exact_cut", False),
|
|
237
|
+
quality=self._opt("quality", "balanced"),
|
|
238
|
+
copy=job.copy,
|
|
230
239
|
limit=self.clip_limit, meta=self._meta(job, c))
|
|
231
240
|
job.clips.append(p.name)
|
|
232
241
|
self._note(job, p.name, c)
|
|
@@ -241,11 +250,30 @@ class Runner:
|
|
|
241
250
|
span = engine.fetch_span(job.url, job.start, job.end, tmp / "span",
|
|
242
251
|
self.tools, cache_dir=self.cache_dir,
|
|
243
252
|
log=job.say, on_proc=_hold(job),
|
|
253
|
+
max_height=self._opt("max_height", 720),
|
|
244
254
|
stop=lambda: job.status == "cancelled")
|
|
245
255
|
job.say("fetched; producing clips")
|
|
246
256
|
if len(plan) == 1:
|
|
247
257
|
dest = out / f"{plan[0].name}.mp4"
|
|
248
|
-
|
|
258
|
+
# Moving the fetched span is the fast path, and only safe when
|
|
259
|
+
# what came down is actually playable. yt-dlp can merge VP9 or
|
|
260
|
+
# AV1 into an mp4 container when no avc1 stream is offered, and
|
|
261
|
+
# that plays as a black picture with sound -- a clip that looks
|
|
262
|
+
# produced and is not usable. Re-encode rather than hand that
|
|
263
|
+
# over.
|
|
264
|
+
vcodec = engine.probe_vcodec(span, self.tools)
|
|
265
|
+
if vcodec and vcodec not in engine.PLAYABLE_VCODECS:
|
|
266
|
+
job.say(f"source is {vcodec}; re-encoding so it plays anywhere")
|
|
267
|
+
whole = engine.Clip(plan[0].name, 0.0,
|
|
268
|
+
engine.probe_duration(span, self.tools) or plan[0].seconds)
|
|
269
|
+
produced = engine.encode(span, whole, out, self.tools,
|
|
270
|
+
on_proc=_hold(job), encoder=job.encoder,
|
|
271
|
+
quality=self._opt("quality", "balanced"),
|
|
272
|
+
limit=self.clip_limit,
|
|
273
|
+
meta=self._meta(job, plan[0]))
|
|
274
|
+
dest = produced
|
|
275
|
+
else:
|
|
276
|
+
shutil.move(str(span), dest)
|
|
249
277
|
job.clips.append(dest.name)
|
|
250
278
|
self._note(job, dest.name, plan[0])
|
|
251
279
|
job.say(f"-> {dest.name}")
|
|
@@ -258,7 +286,9 @@ class Runner:
|
|
|
258
286
|
for c in engine.plan_clip(plan[0].source_name, "00:00:00", dur,
|
|
259
287
|
ceiling=ceiling):
|
|
260
288
|
p = engine.encode(span, c, out, self.tools,
|
|
261
|
-
encoder=job.encoder,
|
|
289
|
+
encoder=job.encoder,
|
|
290
|
+
accurate=job.accurate or self._opt("exact_cut", False),
|
|
291
|
+
quality=self._opt("quality", "balanced"),
|
|
262
292
|
copy=job.copy, limit=self.clip_limit,
|
|
263
293
|
meta=self._meta(job, c))
|
|
264
294
|
job.clips.append(p.name)
|
|
@@ -574,7 +574,7 @@ def serve(outdir: Path, host: str = "0.0.0.0", port: int = 8080,
|
|
|
574
574
|
library = Library(Path(outdir), keep_days=keep_days)
|
|
575
575
|
runner = Runner(outdir, workers=workers,
|
|
576
576
|
clip_limit=config.data.get("clip_limit", 120),
|
|
577
|
-
library=library)
|
|
577
|
+
library=library, config=config)
|
|
578
578
|
history = History(Path(outdir) / ".sources.json",
|
|
579
579
|
limit=config.data.get("recent_limit", 12))
|
|
580
580
|
q = f"?token={token}" if token else ""
|
|
@@ -71,8 +71,19 @@
|
|
|
71
71
|
inside it; #player itself is disposable, because the IFrame API REPLACES
|
|
72
72
|
the element it is handed with an iframe -- which is why toggling
|
|
73
73
|
visibility on that node kept losing the player. */
|
|
74
|
+
/* The player is the biggest thing on the page and on a small laptop it
|
|
75
|
+
crowds out the controls under it. Sized by the width it is allowed, so the
|
|
76
|
+
16/9 box follows and nothing else has to move. */
|
|
74
77
|
#stage{position:relative;aspect-ratio:16/9;width:100%;background:#000;
|
|
75
|
-
border-radius:8px;overflow:hidden;margin:12px 0
|
|
78
|
+
border-radius:8px;overflow:hidden;margin:12px 0;
|
|
79
|
+
max-width:var(--stage-max, 100%)}
|
|
80
|
+
body[data-player="half"] #stage{--stage-max:52%}
|
|
81
|
+
body[data-player="normal"] #stage{--stage-max:100%}
|
|
82
|
+
body[data-player="double"] #stage{--stage-max:100%}
|
|
83
|
+
/* "Double" cannot be twice the column, so it widens the column itself --
|
|
84
|
+
the only way to make the player bigger once it already fills the width. */
|
|
85
|
+
body[data-player="double"] .wrap{max-width:1180px}
|
|
86
|
+
@media (max-width:700px){ body[data-player="half"] #stage{--stage-max:100%} }
|
|
76
87
|
#stage>#playerBox,#stage>#localPlayer{position:absolute;inset:0;width:100%;
|
|
77
88
|
height:100%;background:#000}
|
|
78
89
|
#playerBox iframe,#playerBox>div{width:100%;height:100%;border:0;display:block}
|
|
@@ -167,6 +178,20 @@
|
|
|
167
178
|
/* Deliberately smaller and quieter than .rec-x. They sit an inch apart and
|
|
168
179
|
the outer one discards the whole video and every point under it, so equal
|
|
169
180
|
visual weight would misstate what each does. */
|
|
181
|
+
/* The version, where it is actually wanted: beside the button that changes
|
|
182
|
+
it, and in the menu header. It was only at the foot of the settings panel
|
|
183
|
+
next to the licence, which is where you look for a licence. */
|
|
184
|
+
.adv{border-top:1px solid var(--line);padding-top:10px;margin-top:6px}
|
|
185
|
+
.adv summary{cursor:pointer;font-size:13px;color:var(--dim);padding:2px 0;
|
|
186
|
+
user-select:none}
|
|
187
|
+
.adv summary:hover{color:var(--accent)}
|
|
188
|
+
.adv[open] summary{margin-bottom:4px}
|
|
189
|
+
.sizes{display:inline-flex;gap:2px;margin-left:2px}
|
|
190
|
+
.sizes .sz{min-width:30px;padding:0 6px;line-height:1}
|
|
191
|
+
.sizes .sz.on{color:var(--accent);border-color:var(--accent);background:var(--bg)}
|
|
192
|
+
.verpill{flex:0 0 auto;font-size:11px;color:var(--dim);background:var(--bg);
|
|
193
|
+
border:1px solid var(--line);border-radius:999px;padding:2px 8px;
|
|
194
|
+
letter-spacing:.01em;white-space:nowrap}
|
|
170
195
|
.ptshead{display:flex;align-items:center;gap:6px;cursor:pointer;padding:3px 2px 5px;
|
|
171
196
|
user-select:none}
|
|
172
197
|
.ptshead:hover .rec-m{color:var(--accent)}
|
|
@@ -403,6 +428,11 @@
|
|
|
403
428
|
<option value="1" selected>1×</option><option value="1.5">1.5×</option>
|
|
404
429
|
<option value="2">2×</option>
|
|
405
430
|
</select>
|
|
431
|
+
<span class="sizes" role="group" aria-label="Video size">
|
|
432
|
+
<button class="t sz" data-size="half" title="Small video">▫</button>
|
|
433
|
+
<button class="t sz" data-size="normal" title="Normal video">▪</button>
|
|
434
|
+
<button class="t sz" data-size="double" title="Wide video">⬛</button>
|
|
435
|
+
</span>
|
|
406
436
|
</div>
|
|
407
437
|
<div class="setrow">
|
|
408
438
|
<button class="mark" id="markIn" title="Use the player's current position">Set start</button>
|
|
@@ -483,6 +513,7 @@
|
|
|
483
513
|
<div class="dhead">
|
|
484
514
|
<button class="dback" id="drawerBack" hidden title="Back to the menu">‹</button>
|
|
485
515
|
<strong id="drawerTitle">Menu</strong>
|
|
516
|
+
<span class="verpill mono" id="verTop">—</span>
|
|
486
517
|
<button class="dclose" id="drawerClose" title="Close">✕</button>
|
|
487
518
|
</div>
|
|
488
519
|
<div class="dbody">
|
|
@@ -560,7 +591,10 @@
|
|
|
560
591
|
<div id="updBox" class="set" hidden
|
|
561
592
|
style="align-items:flex-start;flex-direction:column;gap:6px;
|
|
562
593
|
border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:4px">
|
|
563
|
-
<span>
|
|
594
|
+
<span class="row" style="width:100%;align-items:baseline">
|
|
595
|
+
<span class="grow">Updates</span>
|
|
596
|
+
<span class="verpill mono" id="verNow">—</span>
|
|
597
|
+
</span>
|
|
564
598
|
<p class="muted" style="font-size:12px;margin:0">
|
|
565
599
|
An out-of-date downloader is the usual reason a video that worked
|
|
566
600
|
yesterday fails today. Safe while clips are being made.</p>
|
|
@@ -605,12 +639,14 @@
|
|
|
605
639
|
</select>
|
|
606
640
|
</label>
|
|
607
641
|
<label class="set">
|
|
608
|
-
<span>
|
|
609
|
-
<select id="
|
|
610
|
-
<option value="
|
|
611
|
-
<option value="
|
|
642
|
+
<span>Video size</span>
|
|
643
|
+
<select id="setPlayer">
|
|
644
|
+
<option value="half">Half width</option>
|
|
645
|
+
<option value="normal">Normal</option>
|
|
646
|
+
<option value="double">Wide</option>
|
|
612
647
|
</select>
|
|
613
648
|
</label>
|
|
649
|
+
|
|
614
650
|
<label class="set" style="align-items:flex-start;flex-direction:column;gap:5px">
|
|
615
651
|
<span>Save clips to <em id="outdirNote" class="warn-note" hidden>— restart to apply</em></span>
|
|
616
652
|
<span class="row" style="width:100%">
|
|
@@ -621,6 +657,44 @@
|
|
|
621
657
|
</label>
|
|
622
658
|
<p class="muted" id="aboutLine" style="font-size:12px;margin:8px 0 0;
|
|
623
659
|
border-top:1px solid var(--line);padding-top:10px"></p>
|
|
660
|
+
<details class="adv">
|
|
661
|
+
<summary>Advanced video options</summary>
|
|
662
|
+
<p class="muted" style="font-size:12px;margin:2px 0 10px">
|
|
663
|
+
The defaults suit most people. These change how clips are fetched and
|
|
664
|
+
encoded, and apply to the next clip you make.</p>
|
|
665
|
+
<label class="set">
|
|
666
|
+
<span>Fetch at most</span>
|
|
667
|
+
<select id="setHeight">
|
|
668
|
+
<option value="480">480p — smallest, fastest</option>
|
|
669
|
+
<option value="720">720p — default</option>
|
|
670
|
+
<option value="1080">1080p — more detail</option>
|
|
671
|
+
<option value="0">Best available</option>
|
|
672
|
+
</select>
|
|
673
|
+
</label>
|
|
674
|
+
<label class="set">
|
|
675
|
+
<span>Cutting</span>
|
|
676
|
+
<select id="setExact">
|
|
677
|
+
<option value="0">Fast — may start a second early</option>
|
|
678
|
+
<option value="1">Exact — slower, starts where you asked</option>
|
|
679
|
+
</select>
|
|
680
|
+
</label>
|
|
681
|
+
<label class="set">
|
|
682
|
+
<span>Encoding quality</span>
|
|
683
|
+
<select id="setQuality">
|
|
684
|
+
<option value="smaller">Smaller files</option>
|
|
685
|
+
<option value="balanced">Balanced</option>
|
|
686
|
+
<option value="better">Better picture</option>
|
|
687
|
+
</select>
|
|
688
|
+
</label>
|
|
689
|
+
<label class="set">
|
|
690
|
+
<span>Encoder</span>
|
|
691
|
+
<select id="setEncoder">
|
|
692
|
+
<option value="sw">Better (slower)</option>
|
|
693
|
+
<option value="hw">Faster (hardware)</option>
|
|
694
|
+
</select>
|
|
695
|
+
</label>
|
|
696
|
+
</details>
|
|
697
|
+
|
|
624
698
|
<p class="muted" style="font-size:12px;margin:2px 0 0">
|
|
625
699
|
Changes apply straight away, except the folder, which needs a restart.
|
|
626
700
|
Stored in <code id="cfgPath" class="mono"></code>.</p>
|
|
@@ -1430,6 +1504,11 @@ async function loadSettings(){
|
|
|
1430
1504
|
$('#outdirNote').hidden = !(d.values.outdir && liveOutdir &&
|
|
1431
1505
|
d.values.outdir !== liveOutdir);
|
|
1432
1506
|
$('#setEncoder').value = d.values.encoder;
|
|
1507
|
+
$('#setHeight').value = String(d.values.max_height ?? 720);
|
|
1508
|
+
$('#setExact').value = d.values.exact_cut ? '1' : '0';
|
|
1509
|
+
$('#setQuality').value = d.values.quality || 'balanced';
|
|
1510
|
+
applyPlayerSize(d.values.player_size || 'normal');
|
|
1511
|
+
$('#setPlayer').value = d.values.player_size || 'normal';
|
|
1433
1512
|
$('#encoder').value = d.values.encoder; // the main-flow picker follows it
|
|
1434
1513
|
$('#cfgPath').textContent = d.path;
|
|
1435
1514
|
}
|
|
@@ -1892,6 +1971,10 @@ fetch('/api/info').then(r=>r.json()).then(i=>{
|
|
|
1892
1971
|
$('#outdir').textContent = i.outdir;
|
|
1893
1972
|
// AGPL section 13: offer the source to whoever is using it, including
|
|
1894
1973
|
// someone on a phone who never touched this machine.
|
|
1974
|
+
const v = i.version ? `v${i.version}` : '';
|
|
1975
|
+
$('#verNow').textContent = v || '—';
|
|
1976
|
+
$('#verTop').textContent = v || '—';
|
|
1977
|
+
$('#verTop').hidden = !v;
|
|
1895
1978
|
$('#aboutLine').innerHTML =
|
|
1896
1979
|
`overc-clips ${esc(i.version || '')} · ${esc(i.license || '')} · ` +
|
|
1897
1980
|
`<a href="${esc(i.source_url || '')}" target="_blank" rel="noopener">source</a>`;
|
|
@@ -1929,6 +2012,27 @@ fetch('/api/info').then(r=>r.json()).then(i=>{
|
|
|
1929
2012
|
});
|
|
1930
2013
|
}).catch(()=>{});
|
|
1931
2014
|
|
|
2015
|
+
function applyPlayerSize(v){
|
|
2016
|
+
document.body.dataset.player = v;
|
|
2017
|
+
document.querySelectorAll('.sz').forEach(b => b.classList.toggle('on', b.dataset.size === v));
|
|
2018
|
+
const sel = $('#setPlayer');
|
|
2019
|
+
if(sel) sel.value = v;
|
|
2020
|
+
}
|
|
2021
|
+
// Beside the speed control on purpose: both are about watching, and both get
|
|
2022
|
+
// changed while hunting for a boundary. Going to Settings to make the video
|
|
2023
|
+
// smaller means leaving the thing you were looking at.
|
|
2024
|
+
document.querySelectorAll('.sz').forEach(b => b.onclick = () => {
|
|
2025
|
+
applyPlayerSize(b.dataset.size);
|
|
2026
|
+
saveSetting('player_size', b.dataset.size);
|
|
2027
|
+
});
|
|
2028
|
+
$('#setHeight').onchange = e => saveSetting('max_height', +e.target.value);
|
|
2029
|
+
$('#setExact').onchange = e => saveSetting('exact_cut', e.target.value === '1');
|
|
2030
|
+
$('#setQuality').onchange = e => saveSetting('quality', e.target.value);
|
|
2031
|
+
$('#setPlayer').onchange = e => {
|
|
2032
|
+
applyPlayerSize(e.target.value);
|
|
2033
|
+
saveSetting('player_size', e.target.value);
|
|
2034
|
+
};
|
|
2035
|
+
|
|
1932
2036
|
$('#doUpdate').onclick = async () => {
|
|
1933
2037
|
const b = $('#doUpdate'), m = $('#updMsg');
|
|
1934
2038
|
b.disabled = true; m.textContent = 'Updating — this can take a minute…';
|
|
@@ -1942,6 +2046,8 @@ $('#doUpdate').onclick = async () => {
|
|
|
1942
2046
|
? moved.map(p => `${p.name} ${p.before} → ${p.after}`).join(', ')
|
|
1943
2047
|
: 'Everything is already up to date.';
|
|
1944
2048
|
$('#updRestart').hidden = !r.restart_needed;
|
|
2049
|
+
const me = (r.packages||[]).find(p => p.name === 'overc-clips');
|
|
2050
|
+
if(me && me.after) $('#verNow').textContent = `v${me.after}`;
|
|
1945
2051
|
}
|
|
1946
2052
|
} catch { m.textContent = 'could not reach the server'; }
|
|
1947
2053
|
b.disabled = false;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: overc-clips
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Cut volleyball analysis clips from video, locally or hosted. Web UI, no command line required.
|
|
5
5
|
Author-email: Bruce Ashfield <bruce.ashfield@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-only
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "overc-clips"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "Cut volleyball analysis clips from video, locally or hosted. Web UI, no command line required."
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
readme = "README.md"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|