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.
Files changed (25) hide show
  1. {overc_clips-0.2.2 → overc_clips-0.2.4}/PKG-INFO +1 -1
  2. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/config.py +20 -1
  3. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/engine.py +62 -9
  4. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/jobs.py +34 -4
  5. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/server.py +1 -1
  6. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/web/index.html +112 -6
  7. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/PKG-INFO +1 -1
  8. {overc_clips-0.2.2 → overc_clips-0.2.4}/pyproject.toml +1 -1
  9. {overc_clips-0.2.2 → overc_clips-0.2.4}/LICENSE +0 -0
  10. {overc_clips-0.2.2 → overc_clips-0.2.4}/README.md +0 -0
  11. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/__init__.py +0 -0
  12. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/cli.py +0 -0
  13. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/history.py +0 -0
  14. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/importer.py +0 -0
  15. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/library.py +0 -0
  16. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/preflight.py +0 -0
  17. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/resources/overc.icns +0 -0
  18. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/resources/overc.png +0 -0
  19. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips/tray.py +0 -0
  20. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/SOURCES.txt +0 -0
  21. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/dependency_links.txt +0 -0
  22. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/entry_points.txt +0 -0
  23. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/requires.txt +0 -0
  24. {overc_clips-0.2.2 → overc_clips-0.2.4}/overc_clips.egg-info/top_level.txt +0 -0
  25. {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.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", "4M", "-allow_sw", "1"]
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", "20"]
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
- FORMAT_SELECTOR = ("bv*[height<=720][ext=mp4]+ba[ext=m4a]/"
247
- "bv*[height<=720]+ba/b[height<=720]")
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", FORMAT_SELECTOR, "--merge-output-format", "mp4",
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) -> Path:
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", FORMAT_SELECTOR,
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, copy=job.copy,
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
- shutil.move(str(span), dest)
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, accurate=job.accurate,
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>Updates</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>Default quality</span>
609
- <select id="setEncoder">
610
- <option value="sw">Better (slower)</option>
611
- <option value="hw">Faster (hardware)</option>
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.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.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