overc-clips 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overc_clips-0.1.0/LICENSE +29 -0
- overc_clips-0.1.0/PKG-INFO +120 -0
- overc_clips-0.1.0/README.md +94 -0
- overc_clips-0.1.0/overc_clips/__init__.py +0 -0
- overc_clips-0.1.0/overc_clips/cli.py +335 -0
- overc_clips-0.1.0/overc_clips/config.py +133 -0
- overc_clips-0.1.0/overc_clips/engine.py +375 -0
- overc_clips-0.1.0/overc_clips/history.py +303 -0
- overc_clips-0.1.0/overc_clips/importer.py +137 -0
- overc_clips-0.1.0/overc_clips/jobs.py +213 -0
- overc_clips-0.1.0/overc_clips/library.py +255 -0
- overc_clips-0.1.0/overc_clips/preflight.py +239 -0
- overc_clips-0.1.0/overc_clips/server.py +593 -0
- overc_clips-0.1.0/overc_clips/web/index.html +1657 -0
- overc_clips-0.1.0/overc_clips.egg-info/PKG-INFO +120 -0
- overc_clips-0.1.0/overc_clips.egg-info/SOURCES.txt +20 -0
- overc_clips-0.1.0/overc_clips.egg-info/dependency_links.txt +1 -0
- overc_clips-0.1.0/overc_clips.egg-info/entry_points.txt +2 -0
- overc_clips-0.1.0/overc_clips.egg-info/requires.txt +2 -0
- overc_clips-0.1.0/overc_clips.egg-info/top_level.txt +1 -0
- overc_clips-0.1.0/pyproject.toml +45 -0
- overc_clips-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Bruce Ashfield
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: overc-clips
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cut volleyball analysis clips from video, locally or hosted. Web UI, no command line required.
|
|
5
|
+
Author-email: Bruce Ashfield <bruce.ashfield@gmail.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://zedd.org/overc-clips/
|
|
8
|
+
Project-URL: Source, https://gitlab.com/bruce-ashfield/volleyball
|
|
9
|
+
Keywords: video,clips,volleyball,coaching,ffmpeg,yt-dlp
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Multimedia :: Video :: Conversion
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: yt-dlp>=2024.1.1
|
|
24
|
+
Requires-Dist: imageio-ffmpeg>=0.5
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# overc-clips
|
|
28
|
+
|
|
29
|
+
Cut analysis clips out of a YouTube video or a local file, through a web page,
|
|
30
|
+
with no command line required.
|
|
31
|
+
|
|
32
|
+
Built for volleyball coaches handing clips to video-review sites, but there is
|
|
33
|
+
nothing sport-specific in it. Point it at a video, mark the spans you want, and
|
|
34
|
+
it produces the clips.
|
|
35
|
+
|
|
36
|
+
It runs two ways from one codebase:
|
|
37
|
+
|
|
38
|
+
- **On your own machine.** It starts a small web server and prints an address.
|
|
39
|
+
Open that in a browser on the same machine, or on your phone if you are on the
|
|
40
|
+
same wifi. The video processing happens on your computer; the phone is only
|
|
41
|
+
the remote control.
|
|
42
|
+
- **Hosted**, like any other internal web app.
|
|
43
|
+
|
|
44
|
+
## What you need
|
|
45
|
+
|
|
46
|
+
**Python 3.10 or newer. That is all.** ffmpeg and yt-dlp come with it — the
|
|
47
|
+
install pulls a static ffmpeg build, so there is nothing to install separately
|
|
48
|
+
and nothing to put on your PATH.
|
|
49
|
+
|
|
50
|
+
Works on macOS, Linux and Windows.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install overc-clips
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
On current macOS and most Linux distributions that will refuse with a message
|
|
59
|
+
about an "externally managed environment" — that is the operating system
|
|
60
|
+
protecting its own Python, not an error in this package. Use a virtualenv:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python3 -m venv ~/.local/share/overc-clips/venv
|
|
64
|
+
~/.local/share/overc-clips/venv/bin/pip install overc-clips
|
|
65
|
+
~/.local/share/overc-clips/venv/bin/overc-clips serve
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
or install it with `pipx`, which does the same thing for you:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pipx install overc-clips
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Use it
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
overc-clips serve # start the web UI, print the address to visit
|
|
78
|
+
overc-clips doctor # what it found on this machine, and what is missing
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`serve` prints a local address and, if it can, a second one for other devices on
|
|
82
|
+
your network. Everything else happens in the browser: paste a video link, scrub
|
|
83
|
+
to the start and end of each clip, and make them.
|
|
84
|
+
|
|
85
|
+
Useful flags:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
overc-clips serve --outdir ~/Movies/clips # where clips are written
|
|
89
|
+
overc-clips serve --port 8080 # default
|
|
90
|
+
overc-clips serve --token secret # require a token, for a shared network
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Settings you would otherwise need a flag for — the output folder, how long clips
|
|
94
|
+
are kept, the maximum clip length — are in the web UI itself, so someone who was
|
|
95
|
+
handed a desktop shortcut can still change them.
|
|
96
|
+
|
|
97
|
+
## Clips
|
|
98
|
+
|
|
99
|
+
Clips are named after the source video and their timestamps, so two cuts from
|
|
100
|
+
one match never collide. Where each clip came from is written into the file as
|
|
101
|
+
mp4 metadata, which survives the file being moved or shared, and the built-in
|
|
102
|
+
library can group, search and file them.
|
|
103
|
+
|
|
104
|
+
Clips are deleted after 14 days by default, which the UI tells you on startup
|
|
105
|
+
and lets you change or switch off.
|
|
106
|
+
|
|
107
|
+
## Keeping it working
|
|
108
|
+
|
|
109
|
+
YouTube changes things regularly and an out-of-date yt-dlp is the usual cause of
|
|
110
|
+
a download that suddenly fails. Updating it is independent of this package:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install -U yt-dlp # in whichever environment you installed into
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`overc-clips doctor` reports the version it is using and where it came from.
|
|
117
|
+
|
|
118
|
+
## Licence
|
|
119
|
+
|
|
120
|
+
BSD 3-Clause. See `LICENSE`.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# overc-clips
|
|
2
|
+
|
|
3
|
+
Cut analysis clips out of a YouTube video or a local file, through a web page,
|
|
4
|
+
with no command line required.
|
|
5
|
+
|
|
6
|
+
Built for volleyball coaches handing clips to video-review sites, but there is
|
|
7
|
+
nothing sport-specific in it. Point it at a video, mark the spans you want, and
|
|
8
|
+
it produces the clips.
|
|
9
|
+
|
|
10
|
+
It runs two ways from one codebase:
|
|
11
|
+
|
|
12
|
+
- **On your own machine.** It starts a small web server and prints an address.
|
|
13
|
+
Open that in a browser on the same machine, or on your phone if you are on the
|
|
14
|
+
same wifi. The video processing happens on your computer; the phone is only
|
|
15
|
+
the remote control.
|
|
16
|
+
- **Hosted**, like any other internal web app.
|
|
17
|
+
|
|
18
|
+
## What you need
|
|
19
|
+
|
|
20
|
+
**Python 3.10 or newer. That is all.** ffmpeg and yt-dlp come with it — the
|
|
21
|
+
install pulls a static ffmpeg build, so there is nothing to install separately
|
|
22
|
+
and nothing to put on your PATH.
|
|
23
|
+
|
|
24
|
+
Works on macOS, Linux and Windows.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install overc-clips
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
On current macOS and most Linux distributions that will refuse with a message
|
|
33
|
+
about an "externally managed environment" — that is the operating system
|
|
34
|
+
protecting its own Python, not an error in this package. Use a virtualenv:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
python3 -m venv ~/.local/share/overc-clips/venv
|
|
38
|
+
~/.local/share/overc-clips/venv/bin/pip install overc-clips
|
|
39
|
+
~/.local/share/overc-clips/venv/bin/overc-clips serve
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
or install it with `pipx`, which does the same thing for you:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pipx install overc-clips
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Use it
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
overc-clips serve # start the web UI, print the address to visit
|
|
52
|
+
overc-clips doctor # what it found on this machine, and what is missing
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`serve` prints a local address and, if it can, a second one for other devices on
|
|
56
|
+
your network. Everything else happens in the browser: paste a video link, scrub
|
|
57
|
+
to the start and end of each clip, and make them.
|
|
58
|
+
|
|
59
|
+
Useful flags:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
overc-clips serve --outdir ~/Movies/clips # where clips are written
|
|
63
|
+
overc-clips serve --port 8080 # default
|
|
64
|
+
overc-clips serve --token secret # require a token, for a shared network
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Settings you would otherwise need a flag for — the output folder, how long clips
|
|
68
|
+
are kept, the maximum clip length — are in the web UI itself, so someone who was
|
|
69
|
+
handed a desktop shortcut can still change them.
|
|
70
|
+
|
|
71
|
+
## Clips
|
|
72
|
+
|
|
73
|
+
Clips are named after the source video and their timestamps, so two cuts from
|
|
74
|
+
one match never collide. Where each clip came from is written into the file as
|
|
75
|
+
mp4 metadata, which survives the file being moved or shared, and the built-in
|
|
76
|
+
library can group, search and file them.
|
|
77
|
+
|
|
78
|
+
Clips are deleted after 14 days by default, which the UI tells you on startup
|
|
79
|
+
and lets you change or switch off.
|
|
80
|
+
|
|
81
|
+
## Keeping it working
|
|
82
|
+
|
|
83
|
+
YouTube changes things regularly and an out-of-date yt-dlp is the usual cause of
|
|
84
|
+
a download that suddenly fails. Updating it is independent of this package:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install -U yt-dlp # in whichever environment you installed into
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`overc-clips doctor` reports the version it is using and where it came from.
|
|
91
|
+
|
|
92
|
+
## Licence
|
|
93
|
+
|
|
94
|
+
BSD 3-Clause. See `LICENSE`.
|
|
File without changes
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Command line for overc-clips. The web service calls the same engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import csv
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import engine, preflight
|
|
14
|
+
from .config import Config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _version() -> str:
|
|
18
|
+
try:
|
|
19
|
+
from importlib.metadata import version
|
|
20
|
+
return version("overc-clips")
|
|
21
|
+
except Exception:
|
|
22
|
+
return "0.1.0"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rows_from_csv(path: Path, need_url: bool) -> list[dict]:
|
|
26
|
+
with open(path, newline="", encoding="utf-8-sig") as fh:
|
|
27
|
+
rows = [r for r in csv.DictReader(fh)]
|
|
28
|
+
out = []
|
|
29
|
+
for r in rows:
|
|
30
|
+
r = { (k or "").strip().lower(): (v or "").strip() for k, v in r.items() }
|
|
31
|
+
if not any(r.values()):
|
|
32
|
+
continue
|
|
33
|
+
if need_url and not r.get("url"):
|
|
34
|
+
print(f"SKIP row with no url: {r}", file=sys.stderr)
|
|
35
|
+
continue
|
|
36
|
+
out.append(r)
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_doctor(a) -> int:
|
|
41
|
+
t = preflight.detect()
|
|
42
|
+
if a.json:
|
|
43
|
+
print(json.dumps(t.as_dict(), indent=2))
|
|
44
|
+
else:
|
|
45
|
+
print("overc-clips — tool check\n")
|
|
46
|
+
print(preflight.report(t))
|
|
47
|
+
return 0 if t.can_cut else 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _emit(plans, a, produced=None):
|
|
51
|
+
if a.json:
|
|
52
|
+
print(json.dumps({"dry_run": a.dry_run,
|
|
53
|
+
"clips": [c.as_dict() for c in plans],
|
|
54
|
+
"produced": [str(p) for p in (produced or [])]}, indent=2))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_cut(a) -> int:
|
|
58
|
+
t = preflight.detect()
|
|
59
|
+
if not t.can_cut:
|
|
60
|
+
print("ffmpeg not available — run `overc-clips doctor`", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
src = Path(a.input)
|
|
63
|
+
if not src.exists():
|
|
64
|
+
print(f"input not found: {src}", file=sys.stderr)
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
specs = []
|
|
68
|
+
if a.range:
|
|
69
|
+
s, e = a.range.split("-", 1)
|
|
70
|
+
specs.append({"name": a.name or f"clip_{s.replace(':','')}{e.replace(':','')}",
|
|
71
|
+
"start": s, "end": e})
|
|
72
|
+
else:
|
|
73
|
+
specs = _rows_from_csv(Path(a.csv), need_url=False)
|
|
74
|
+
|
|
75
|
+
plans, produced = [], []
|
|
76
|
+
for sp in specs:
|
|
77
|
+
try:
|
|
78
|
+
cs = engine.plan_clip(sp.get("name", ""), sp["start"], sp["end"])
|
|
79
|
+
except (engine.ClipError, KeyError) as ex:
|
|
80
|
+
print(f"SKIP {sp.get('name','?')}: {ex}", file=sys.stderr)
|
|
81
|
+
continue
|
|
82
|
+
plans += cs
|
|
83
|
+
if not a.json and not a.quiet:
|
|
84
|
+
print(f"{sp.get('name','?')}: {len(cs)} clip(s)")
|
|
85
|
+
for c in cs:
|
|
86
|
+
if a.dry_run:
|
|
87
|
+
if not a.json and not a.quiet:
|
|
88
|
+
print(f" -> {c.name} ({c.start_hms}, {c.seconds:.0f}s)")
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
p = engine.encode(src, c, Path(a.outdir), t, encoder=a.encoder,
|
|
92
|
+
accurate=a.accurate, copy=a.copy)
|
|
93
|
+
produced.append(p)
|
|
94
|
+
if not a.json and not a.quiet:
|
|
95
|
+
print(f" -> {p}")
|
|
96
|
+
except engine.ClipError as ex:
|
|
97
|
+
print(f" !! {ex}", file=sys.stderr)
|
|
98
|
+
_emit(plans, a, produced)
|
|
99
|
+
if not a.json and not a.quiet:
|
|
100
|
+
print(f"\n{len(plans)} clip(s) {'planned (dry run)' if a.dry_run else f'in {a.outdir}/'}")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_grab(a) -> int:
|
|
105
|
+
t = preflight.detect()
|
|
106
|
+
if not a.dry_run and not t.can_grab:
|
|
107
|
+
print("need ffmpeg + yt-dlp — run `overc-clips doctor`", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
specs = ([{"url": a.url, "name": a.name, "start": a.range.split("-")[0],
|
|
111
|
+
"end": a.range.split("-")[1]}] if a.url
|
|
112
|
+
else _rows_from_csv(Path(a.csv), need_url=True))
|
|
113
|
+
|
|
114
|
+
plans, produced = [], []
|
|
115
|
+
for sp in specs:
|
|
116
|
+
try:
|
|
117
|
+
cs = engine.plan_clip(sp.get("name", ""), sp["start"], sp["end"])
|
|
118
|
+
except (engine.ClipError, KeyError) as ex:
|
|
119
|
+
print(f"SKIP {sp.get('name','?')}: {ex}", file=sys.stderr)
|
|
120
|
+
continue
|
|
121
|
+
plans += cs
|
|
122
|
+
if not a.json and not a.quiet:
|
|
123
|
+
print(f"{cs[0].source_name}: {sp['start']}-{sp['end']} -> {len(cs)} clip(s)")
|
|
124
|
+
if a.dry_run:
|
|
125
|
+
continue
|
|
126
|
+
out = Path(a.outdir)
|
|
127
|
+
tmpdir = Path(tempfile.mkdtemp(prefix="overc-clips-"))
|
|
128
|
+
try:
|
|
129
|
+
span = engine.fetch_span(sp["url"], sp["start"], sp["end"],
|
|
130
|
+
tmpdir / "span", t,
|
|
131
|
+
log=lambda m: print(m, file=sys.stderr))
|
|
132
|
+
if len(cs) == 1:
|
|
133
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
dest = out / f"{cs[0].name}.mp4"
|
|
135
|
+
shutil.move(str(span), dest)
|
|
136
|
+
produced.append(dest)
|
|
137
|
+
if not a.json and not a.quiet:
|
|
138
|
+
print(f" -> {dest}")
|
|
139
|
+
else:
|
|
140
|
+
actual = engine.probe_duration(span, t) or cs[0].seconds * len(cs)
|
|
141
|
+
for c in engine.plan_clip(cs[0].source_name, "00:00:00",
|
|
142
|
+
engine.fmt_hms(actual)):
|
|
143
|
+
p = engine.encode(span, c, out, t, encoder=a.encoder,
|
|
144
|
+
accurate=a.accurate, copy=a.copy)
|
|
145
|
+
produced.append(p)
|
|
146
|
+
if not a.json and not a.quiet:
|
|
147
|
+
print(f" -> {p}")
|
|
148
|
+
except engine.ClipError as ex:
|
|
149
|
+
print(f" !! {ex}", file=sys.stderr)
|
|
150
|
+
finally:
|
|
151
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
152
|
+
_emit(plans, a, produced)
|
|
153
|
+
if not a.json and not a.quiet:
|
|
154
|
+
print(f"\n{len(plans)} clip(s) {'planned (dry run)' if a.dry_run else f'in {a.outdir}/'}")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_backfill(a) -> int:
|
|
159
|
+
"""Give existing clips their provenance back.
|
|
160
|
+
|
|
161
|
+
Three sources, cheapest first: what the index already holds, what history
|
|
162
|
+
knows about the video id, and what the file says about itself. Clips made
|
|
163
|
+
before any of this existed have none of the three and stay unknown, which
|
|
164
|
+
is honest -- nothing here guesses.
|
|
165
|
+
"""
|
|
166
|
+
from .history import History
|
|
167
|
+
from .library import Library
|
|
168
|
+
|
|
169
|
+
out = Path(a.outdir)
|
|
170
|
+
lib = Library(out)
|
|
171
|
+
hist = History(out / ".sources.json")
|
|
172
|
+
tools = preflight.detect()
|
|
173
|
+
titles = {(i.get("video_id") or i.get("url")): i.get("title", "")
|
|
174
|
+
for i in hist.list() if i.get("title")}
|
|
175
|
+
|
|
176
|
+
fixed = tagged = probed = 0
|
|
177
|
+
for c in lib.list():
|
|
178
|
+
rec = dict(lib._index.get(c["name"], {}))
|
|
179
|
+
before = dict(rec)
|
|
180
|
+
|
|
181
|
+
if not rec.get("title"):
|
|
182
|
+
key = rec.get("video_id") or rec.get("url") or ""
|
|
183
|
+
if key and titles.get(key):
|
|
184
|
+
rec["title"] = titles[key]
|
|
185
|
+
|
|
186
|
+
# Nothing known at all? Ask the file.
|
|
187
|
+
if not rec.get("title"):
|
|
188
|
+
got = engine.provenance_from_tags(
|
|
189
|
+
engine.read_metadata(out / c["name"], tools))
|
|
190
|
+
probed += 1
|
|
191
|
+
for k, v in got.items():
|
|
192
|
+
rec.setdefault(k, v)
|
|
193
|
+
|
|
194
|
+
if rec and rec != before:
|
|
195
|
+
lib.record(c["name"], **{k: v for k, v in rec.items() if k != "created"})
|
|
196
|
+
fixed += 1
|
|
197
|
+
print(f" {c['name']}\n title -> {rec.get('title','') or '(still unknown)'}")
|
|
198
|
+
|
|
199
|
+
if a.tag_files and rec.get("title"):
|
|
200
|
+
meta = engine.clip_metadata(rec.get("title", ""),
|
|
201
|
+
rec.get("url") or rec.get("path", ""),
|
|
202
|
+
rec.get("start", ""), rec.get("end", ""),
|
|
203
|
+
Path(c["name"]).stem)
|
|
204
|
+
if stamp(out / c["name"], meta, tools):
|
|
205
|
+
tagged += 1
|
|
206
|
+
|
|
207
|
+
print(f"\n{fixed} record(s) updated, {probed} file(s) probed"
|
|
208
|
+
+ (f", {tagged} file(s) tagged" if a.tag_files else ""))
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def stamp(path: Path, meta: dict, tools) -> bool:
|
|
213
|
+
"""Write tags into an existing clip without re-encoding it."""
|
|
214
|
+
import subprocess
|
|
215
|
+
tmp = path.with_suffix(".tagging.mp4")
|
|
216
|
+
r = subprocess.run([str(tools.ffmpeg), "-nostdin", "-hide_banner",
|
|
217
|
+
"-loglevel", "error", "-y", "-i", str(path), "-c", "copy",
|
|
218
|
+
*engine._meta_args(meta), "-movflags", "+faststart", str(tmp)],
|
|
219
|
+
capture_output=True, text=True)
|
|
220
|
+
if r.returncode != 0 or not tmp.exists():
|
|
221
|
+
tmp.unlink(missing_ok=True)
|
|
222
|
+
return False
|
|
223
|
+
# Keep the original timestamp: mtime is what retention ages on, and
|
|
224
|
+
# re-tagging is not the clip being made again.
|
|
225
|
+
st = path.stat()
|
|
226
|
+
tmp.replace(path)
|
|
227
|
+
os.utime(path, (st.st_atime, st.st_mtime))
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_update(a) -> int:
|
|
232
|
+
print("overc-clips — updating video tools\n")
|
|
233
|
+
r = preflight.update_tools()
|
|
234
|
+
if not r["ok"]:
|
|
235
|
+
print(f" FAILED: {r['error']}")
|
|
236
|
+
return 1
|
|
237
|
+
if r.get("changed"):
|
|
238
|
+
print(f" yt-dlp {r['before']} -> {r['after']}")
|
|
239
|
+
else:
|
|
240
|
+
print(f" yt-dlp {r['after'] or 'installed'} — already current")
|
|
241
|
+
print("\n Done.")
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cmd_serve(a) -> int:
|
|
246
|
+
from . import server
|
|
247
|
+
cfg = Config()
|
|
248
|
+
given = set(sys.argv[1:])
|
|
249
|
+
def pick(flag, attr, key):
|
|
250
|
+
return getattr(a, attr) if flag in given else cfg.data.get(key, getattr(a, attr))
|
|
251
|
+
a.port = int(pick("--port", "port", "port"))
|
|
252
|
+
a.host = pick("--host", "host", "host")
|
|
253
|
+
a.token = pick("--token", "token", "token")
|
|
254
|
+
a.keep_days = int(pick("--keep-days", "keep_days", "keep_days"))
|
|
255
|
+
if "--outdir" not in given and cfg.data.get("outdir"):
|
|
256
|
+
a.outdir = cfg.data["outdir"]
|
|
257
|
+
auto = sys.stdout.isatty() if a.open is None else a.open
|
|
258
|
+
server.serve(Path(a.outdir), host=a.host, port=a.port, token=a.token,
|
|
259
|
+
workers=a.workers, open_browser=auto, keep_days=a.keep_days,
|
|
260
|
+
config=cfg)
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main(argv=None) -> int:
|
|
265
|
+
ap = argparse.ArgumentParser(prog="overc-clips",
|
|
266
|
+
description="Cut analysis clips from video.")
|
|
267
|
+
ap.add_argument("--version", action="version",
|
|
268
|
+
version=f"overc-clips {_version()}")
|
|
269
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
270
|
+
|
|
271
|
+
d = sub.add_parser("doctor", help="what is installed, what is missing")
|
|
272
|
+
d.add_argument("--json", action="store_true")
|
|
273
|
+
d.set_defaults(fn=cmd_doctor)
|
|
274
|
+
|
|
275
|
+
common = dict(outdir="clips", encoder="sw")
|
|
276
|
+
for name, fn, helptext in (("cut", cmd_cut, "cut a local video"),
|
|
277
|
+
("grab", cmd_grab, "fetch spans from a URL")):
|
|
278
|
+
p = sub.add_parser(name, help=helptext)
|
|
279
|
+
if name == "cut":
|
|
280
|
+
p.add_argument("input")
|
|
281
|
+
g = p.add_mutually_exclusive_group(required=True)
|
|
282
|
+
g.add_argument("--range", help="HH:MM:SS-HH:MM:SS")
|
|
283
|
+
g.add_argument("--csv", help="csv: name,start,end")
|
|
284
|
+
else:
|
|
285
|
+
p.add_argument("url", nargs="?")
|
|
286
|
+
p.add_argument("--range", help="HH:MM:SS-HH:MM:SS")
|
|
287
|
+
p.add_argument("--csv", help="csv: url,name,start,end")
|
|
288
|
+
p.add_argument("--name")
|
|
289
|
+
p.add_argument("--outdir", default=common["outdir"])
|
|
290
|
+
p.add_argument("--encoder", default=common["encoder"], choices=["sw", "hw"])
|
|
291
|
+
p.add_argument("--dry-run", action="store_true")
|
|
292
|
+
p.add_argument("--json", action="store_true")
|
|
293
|
+
p.add_argument("--accurate", action="store_true")
|
|
294
|
+
p.add_argument("--copy", action="store_true")
|
|
295
|
+
p.add_argument("--quiet", action="store_true")
|
|
296
|
+
p.set_defaults(fn=fn)
|
|
297
|
+
|
|
298
|
+
bf = sub.add_parser("backfill",
|
|
299
|
+
help="restore provenance for clips already on disk")
|
|
300
|
+
bf.add_argument("--outdir", default="clips")
|
|
301
|
+
bf.add_argument("--tag-files", action="store_true",
|
|
302
|
+
help="also write the tags into the clips (no re-encode)")
|
|
303
|
+
bf.set_defaults(fn=cmd_backfill)
|
|
304
|
+
|
|
305
|
+
up = sub.add_parser("update",
|
|
306
|
+
help="update yt-dlp (the usual fix when downloads fail)")
|
|
307
|
+
up.set_defaults(fn=cmd_update)
|
|
308
|
+
sv = sub.add_parser("serve", help="run the web UI")
|
|
309
|
+
sv.add_argument("--host", default="0.0.0.0",
|
|
310
|
+
help="0.0.0.0 lets a phone on the same wifi reach it")
|
|
311
|
+
sv.add_argument("--port", type=int, default=8080)
|
|
312
|
+
sv.add_argument("--outdir", default="clips")
|
|
313
|
+
sv.add_argument("--token", default="", help="require ?token=... (use when hosted)")
|
|
314
|
+
sv.add_argument("--workers", type=int, default=1)
|
|
315
|
+
sv.add_argument("--keep-days", type=int, default=14,
|
|
316
|
+
help="delete clips older than this many days (0 = keep forever)")
|
|
317
|
+
# Opening the browser is the point for someone who was handed a launcher,
|
|
318
|
+
# so it is the default when a person is watching. Redirected output means a
|
|
319
|
+
# service manager is running us, and popping a browser there is wrong.
|
|
320
|
+
sv.add_argument("--open", action="store_true", default=None,
|
|
321
|
+
help="open a browser on start (default when interactive)")
|
|
322
|
+
sv.add_argument("--no-open", dest="open", action="store_false",
|
|
323
|
+
help="never open a browser")
|
|
324
|
+
sv.set_defaults(fn=cmd_serve)
|
|
325
|
+
|
|
326
|
+
a = ap.parse_args(argv)
|
|
327
|
+
if a.cmd == "grab" and not a.url and not a.csv:
|
|
328
|
+
ap.error("grab needs a URL with --range, or --csv")
|
|
329
|
+
if a.cmd == "grab" and a.url and not a.range:
|
|
330
|
+
ap.error("grab with a URL needs --range")
|
|
331
|
+
return a.fn(a)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
sys.exit(main())
|