OpenScrub 1.0.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.
- openscrub-1.0.0.dist-info/METADATA +607 -0
- openscrub-1.0.0.dist-info/RECORD +8 -0
- openscrub-1.0.0.dist-info/WHEEL +4 -0
- openscrub-1.0.0.dist-info/entry_points.txt +3 -0
- openscrub-1.0.0.dist-info/licenses/LICENSE +190 -0
- openscrub.py +2445 -0
- openscrub_web.py +1859 -0
- zones_ui.py +380 -0
openscrub_web.py
ADDED
|
@@ -0,0 +1,1859 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
openscrub_web.py — browser interface for openscrub, usable across your LAN.
|
|
4
|
+
|
|
5
|
+
Run on an always-on machine:
|
|
6
|
+
python openscrub_web.py # port 8384, prints access URL
|
|
7
|
+
python openscrub_web.py --port 9000 --token mysecret
|
|
8
|
+
|
|
9
|
+
Then from any device on the LAN (laptop, phone), open the printed URL.
|
|
10
|
+
Workflow: upload a recording (or give a server-side path) -> scan runs on
|
|
11
|
+
the server (live preview + log) -> review every detection with thumbnails,
|
|
12
|
+
uncheck false positives, draw missed regions -> render -> download.
|
|
13
|
+
|
|
14
|
+
Security model: a single access token (printed at startup or set with
|
|
15
|
+
--token) is required for every request; it's carried in a cookie after the
|
|
16
|
+
first visit. This is *LAN-grade* protection for a trusted home/office
|
|
17
|
+
network — do not expose this port to the internet. Uploaded videos and
|
|
18
|
+
audit reports contain PHI; the jobs folder inherits that sensitivity.
|
|
19
|
+
|
|
20
|
+
Jobs are processed one at a time (FIFO) so concurrent requests don't fight
|
|
21
|
+
over the GPU.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import queue
|
|
28
|
+
import secrets
|
|
29
|
+
import sys
|
|
30
|
+
import socket
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
import uuid
|
|
34
|
+
|
|
35
|
+
import cv2
|
|
36
|
+
from flask import (Flask, Response, abort, jsonify, redirect, request,
|
|
37
|
+
send_file)
|
|
38
|
+
|
|
39
|
+
import openscrub
|
|
40
|
+
import zones_ui
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Anchor to the script's own folder, NOT the process working directory —
|
|
44
|
+
# double-clicking a .py on Windows can launch with cwd=C:\Windows\system32
|
|
45
|
+
JOBS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
46
|
+
"openscrub_jobs")
|
|
47
|
+
# One-time migration from the pre-rebrand job store: carries over all prior
|
|
48
|
+
# jobs, reviews, and the learned-words allowlist.
|
|
49
|
+
_legacy_jobs = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
50
|
+
"phi_blur_jobs")
|
|
51
|
+
if os.path.isdir(_legacy_jobs) and not os.path.isdir(JOBS_DIR):
|
|
52
|
+
try:
|
|
53
|
+
os.rename(_legacy_jobs, JOBS_DIR)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass # e.g. cross-device or perms: fall back to fresh dir
|
|
56
|
+
ZONES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
57
|
+
"zones.json")
|
|
58
|
+
CERT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "certs")
|
|
59
|
+
CUSTOM_CERT = os.path.join(CERT_DIR, "custom_cert.pem")
|
|
60
|
+
CUSTOM_KEY = os.path.join(CERT_DIR, "custom_key.pem")
|
|
61
|
+
AUTO_CERT = os.path.join(CERT_DIR, "auto_cert.pem")
|
|
62
|
+
AUTO_KEY = os.path.join(CERT_DIR, "auto_key.pem")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ensure_self_signed():
|
|
66
|
+
"""Generate a long-lived self-signed certificate once, with SANs for
|
|
67
|
+
localhost and this machine's LAN IP so browsers tie it to the URL used."""
|
|
68
|
+
if os.path.exists(AUTO_CERT) and os.path.exists(AUTO_KEY):
|
|
69
|
+
return
|
|
70
|
+
from cryptography import x509
|
|
71
|
+
from cryptography.x509.oid import NameOID
|
|
72
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
73
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
74
|
+
import datetime, ipaddress
|
|
75
|
+
os.makedirs(CERT_DIR, exist_ok=True)
|
|
76
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
77
|
+
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "openscrub")])
|
|
78
|
+
sans = [x509.DNSName("localhost"),
|
|
79
|
+
x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]
|
|
80
|
+
try:
|
|
81
|
+
sans.append(x509.IPAddress(ipaddress.ip_address(lan_ip())))
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
85
|
+
cert = (x509.CertificateBuilder()
|
|
86
|
+
.subject_name(name).issuer_name(name)
|
|
87
|
+
.public_key(key.public_key())
|
|
88
|
+
.serial_number(x509.random_serial_number())
|
|
89
|
+
.not_valid_before(now - datetime.timedelta(days=1))
|
|
90
|
+
.not_valid_after(now + datetime.timedelta(days=3650))
|
|
91
|
+
.add_extension(x509.SubjectAlternativeName(sans), critical=False)
|
|
92
|
+
.sign(key, hashes.SHA256()))
|
|
93
|
+
with open(AUTO_KEY, "wb") as f:
|
|
94
|
+
f.write(key.private_bytes(
|
|
95
|
+
serialization.Encoding.PEM,
|
|
96
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
97
|
+
serialization.NoEncryption()))
|
|
98
|
+
with open(AUTO_CERT, "wb") as f:
|
|
99
|
+
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
|
100
|
+
try:
|
|
101
|
+
os.chmod(AUTO_KEY, 0o600)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def active_cert_pair():
|
|
107
|
+
if os.path.exists(CUSTOM_CERT) and os.path.exists(CUSTOM_KEY):
|
|
108
|
+
return CUSTOM_CERT, CUSTOM_KEY, "custom"
|
|
109
|
+
ensure_self_signed()
|
|
110
|
+
return AUTO_CERT, AUTO_KEY, "self-signed"
|
|
111
|
+
app = Flask(__name__)
|
|
112
|
+
app.config["MAX_CONTENT_LENGTH"] = 8 * 1024 ** 3 # 8 GB uploads
|
|
113
|
+
|
|
114
|
+
TOKEN = None
|
|
115
|
+
JOBS = {} # id -> job dict
|
|
116
|
+
JOBS_LOCK = threading.Lock()
|
|
117
|
+
WORK_Q = queue.Queue()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ----------------------------------------------------------------------------
|
|
121
|
+
# Job model
|
|
122
|
+
# ----------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def new_job(video_path, options, name):
|
|
125
|
+
jid = uuid.uuid4().hex[:12]
|
|
126
|
+
jdir = os.path.join(JOBS_DIR, jid)
|
|
127
|
+
os.makedirs(jdir, exist_ok=True)
|
|
128
|
+
job = {
|
|
129
|
+
"id": jid, "dir": jdir, "video": video_path, "name": name,
|
|
130
|
+
"options": options, "phase": "queued", "progress": 0.0,
|
|
131
|
+
"log": [], "error": None, "output": None, "created": time.time(),
|
|
132
|
+
"cancel": threading.Event(), "stats": {},
|
|
133
|
+
}
|
|
134
|
+
with JOBS_LOCK:
|
|
135
|
+
JOBS[jid] = job
|
|
136
|
+
return job
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def job_public(job):
|
|
140
|
+
return {k: job[k] for k in ("id", "name", "phase", "progress", "error",
|
|
141
|
+
"stats")} | {
|
|
142
|
+
"log_tail": job["log"][-30:],
|
|
143
|
+
"log_len": len(job["log"]),
|
|
144
|
+
"has_output": bool(job["output"] and os.path.exists(job["output"])),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class WebCallbacks(openscrub.Callbacks):
|
|
149
|
+
wants_frames = True
|
|
150
|
+
|
|
151
|
+
def __init__(self, job):
|
|
152
|
+
self.job = job
|
|
153
|
+
self._last_frame = 0.0
|
|
154
|
+
|
|
155
|
+
def log(self, msg):
|
|
156
|
+
self.job["log"].append(msg)
|
|
157
|
+
|
|
158
|
+
def progress(self, stage, cur, total):
|
|
159
|
+
base, span = (0, 0.5) if stage == "scan" else (0.5, 0.5)
|
|
160
|
+
self.job["progress"] = round(base + span * cur / max(total, 1), 3)
|
|
161
|
+
|
|
162
|
+
def scan_frame(self, frame, t, found):
|
|
163
|
+
now = time.time()
|
|
164
|
+
if now - self._last_frame < 0.4: # throttle disk writes
|
|
165
|
+
return
|
|
166
|
+
self._last_frame = now
|
|
167
|
+
h, w = frame.shape[:2]
|
|
168
|
+
scale = 720 / w
|
|
169
|
+
small = cv2.resize(frame, (720, int(h * scale)))
|
|
170
|
+
cv2.imwrite(os.path.join(self.job["dir"], "preview.jpg"), small,
|
|
171
|
+
[cv2.IMWRITE_JPEG_QUALITY, 70])
|
|
172
|
+
|
|
173
|
+
def cancelled(self):
|
|
174
|
+
return self.job["cancel"].is_set()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_args(job, for_render=False):
|
|
178
|
+
o = job["options"]
|
|
179
|
+
jdir = job["dir"]
|
|
180
|
+
argv = [job["video"],
|
|
181
|
+
"--engine", o.get("engine", "auto"),
|
|
182
|
+
"--device", o.get("device", "auto"),
|
|
183
|
+
"--encoder", o.get("encoder", "auto"),
|
|
184
|
+
"--mode", o.get("mode", "blur"),
|
|
185
|
+
"--mode-map", o.get("mode_map", ""),
|
|
186
|
+
"--sample-interval", str(o.get("sample_interval", 0.5)),
|
|
187
|
+
"--scan-trigger", str(o.get("scan_trigger", 60)),
|
|
188
|
+
"--pad", str(o.get("pad", 8)),
|
|
189
|
+
"--bridge-gap", str(o.get("bridge_gap", 4.0)),
|
|
190
|
+
"--mrn-regex", o.get("mrn_regex", openscrub.RE_MRN_DEFAULT),
|
|
191
|
+
"--face-expand", str(o.get("face_expand", 0.15)),
|
|
192
|
+
"--face-threshold", str(o.get("face_threshold", 0.6)),
|
|
193
|
+
"--plate-threshold", str(o.get("plate_threshold", 0.35)),
|
|
194
|
+
"--detect-scale", str(o.get("detect_scale", 1.0)),
|
|
195
|
+
"--skip-start", str(o.get("skip_start", 0)),
|
|
196
|
+
"--skip-end", str(o.get("skip_end", 0)),
|
|
197
|
+
"--categories", o.get("categories",
|
|
198
|
+
"name,dob,phone,ssn,mrn,email,address,card,apikey,ipaddr,plate,face"),
|
|
199
|
+
"-o", os.path.join(jdir, "output."
|
|
200
|
+
+ (o.get("out_format", "mp4")
|
|
201
|
+
if o.get("out_format") in ("mp4", "mov", "mkv")
|
|
202
|
+
else "mp4")),
|
|
203
|
+
"--report", os.path.join(jdir, "report.json")]
|
|
204
|
+
if o.get("no_memory"):
|
|
205
|
+
argv.append("--no-memory")
|
|
206
|
+
if o.get("no_ner"):
|
|
207
|
+
argv.append("--no-ner")
|
|
208
|
+
if o.get("preview_mode"):
|
|
209
|
+
argv.append("--preview")
|
|
210
|
+
if o.get("dense_faces"):
|
|
211
|
+
argv.append("--dense-faces")
|
|
212
|
+
if o.get("draw_scores"):
|
|
213
|
+
argv.append("--draw-scores")
|
|
214
|
+
if o.get("use_zones", True) and os.path.exists(ZONES_PATH):
|
|
215
|
+
try:
|
|
216
|
+
with open(ZONES_PATH, encoding="utf-8") as f:
|
|
217
|
+
if json.load(f):
|
|
218
|
+
argv += ["--zones", ZONES_PATH]
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
server_allow = os.path.join(JOBS_DIR, "allowlist.txt")
|
|
222
|
+
for kind in ("allow_names", "extra_names"):
|
|
223
|
+
text = (o.get(kind) or "").strip()
|
|
224
|
+
if kind == "allow_names" and os.path.exists(server_allow):
|
|
225
|
+
text = (text + "\n" + open(server_allow, encoding="utf-8").read()).strip()
|
|
226
|
+
if text:
|
|
227
|
+
p = os.path.join(jdir, kind + ".txt")
|
|
228
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
229
|
+
f.write(text)
|
|
230
|
+
argv += ["--" + kind.replace("_", "-"), p]
|
|
231
|
+
for line in (o.get("ignore_regions") or "").strip().splitlines():
|
|
232
|
+
line = line.strip()
|
|
233
|
+
if line:
|
|
234
|
+
argv += ["--ignore-region", line]
|
|
235
|
+
if for_render:
|
|
236
|
+
argv += ["--from-report", os.path.join(jdir, "report.json")]
|
|
237
|
+
parser = openscrub.build_parser()
|
|
238
|
+
return openscrub._prep_args(parser.parse_args(argv), parser)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def worker():
|
|
242
|
+
while True:
|
|
243
|
+
jid, action = WORK_Q.get()
|
|
244
|
+
with JOBS_LOCK:
|
|
245
|
+
job = JOBS.get(jid)
|
|
246
|
+
if job is None:
|
|
247
|
+
continue
|
|
248
|
+
cb = WebCallbacks(job)
|
|
249
|
+
try:
|
|
250
|
+
if action == "scan":
|
|
251
|
+
job["phase"] = "scanning"
|
|
252
|
+
args = build_args(job)
|
|
253
|
+
state = openscrub.run_scan(args, cb)
|
|
254
|
+
openscrub.write_report(os.path.join(job["dir"], "report.json"),
|
|
255
|
+
args, state)
|
|
256
|
+
job["stats"] = state["stats"]
|
|
257
|
+
def _pregen(jid=jid):
|
|
258
|
+
"""One pass through the video with a single decoder:
|
|
259
|
+
thumbnails come out in seconds instead of minutes.
|
|
260
|
+
Sorting by reference time keeps seeks short and forward-
|
|
261
|
+
only, which is what codecs are fast at."""
|
|
262
|
+
try:
|
|
263
|
+
jb = JOBS.get(jid)
|
|
264
|
+
doc = _load_job_report(jb)
|
|
265
|
+
rs = doc["render_state"]
|
|
266
|
+
todo = []
|
|
267
|
+
for k, d in enumerate(doc["detections"]):
|
|
268
|
+
cch = os.path.join(jb["dir"], f"thumb2_{k}.jpg")
|
|
269
|
+
if os.path.exists(cch):
|
|
270
|
+
continue
|
|
271
|
+
tref = min(max(d.get("last_seen", d["t_start"]),
|
|
272
|
+
d["t_start"]), d["t_end"])
|
|
273
|
+
todo.append((tref, k, d, cch))
|
|
274
|
+
todo.sort()
|
|
275
|
+
cap = cv2.VideoCapture(jb["video"])
|
|
276
|
+
cur = -1
|
|
277
|
+
frame = None
|
|
278
|
+
for tref, k, d, cch in todo:
|
|
279
|
+
fidx = min(int(tref * rs["fps"]),
|
|
280
|
+
len(rs["cum"]) - 1)
|
|
281
|
+
if fidx != cur:
|
|
282
|
+
if 0 <= fidx - cur <= 12:
|
|
283
|
+
while cur < fidx: # cheap forward decode
|
|
284
|
+
ok, frame = cap.read()
|
|
285
|
+
if not ok:
|
|
286
|
+
frame = None
|
|
287
|
+
break
|
|
288
|
+
cur += 1
|
|
289
|
+
else:
|
|
290
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, fidx)
|
|
291
|
+
ok, frame = cap.read()
|
|
292
|
+
cur = fidx if ok else cur
|
|
293
|
+
if not ok:
|
|
294
|
+
frame = None
|
|
295
|
+
if frame is None:
|
|
296
|
+
continue
|
|
297
|
+
ox, oy = rs["cum"][fidx]
|
|
298
|
+
h, wd = frame.shape[:2]
|
|
299
|
+
pad = 24
|
|
300
|
+
x1 = max(0, int(d["cbox"][0] + ox) - pad)
|
|
301
|
+
y1 = max(0, int(d["cbox"][1] + oy) - pad)
|
|
302
|
+
x2 = min(wd, int(d["cbox"][2] + ox) + pad)
|
|
303
|
+
y2 = min(h, int(d["cbox"][3] + oy) + pad)
|
|
304
|
+
crop = frame[y1:y2, x1:x2].copy()
|
|
305
|
+
if crop.size == 0:
|
|
306
|
+
continue
|
|
307
|
+
inner = crop[pad:-pad or None, pad:-pad or None]
|
|
308
|
+
if not inner.size or float(inner.std()) <= 8:
|
|
309
|
+
continue # blank: leave for the on-demand
|
|
310
|
+
# path, which tries other moments
|
|
311
|
+
cv2.rectangle(crop, (pad, pad),
|
|
312
|
+
(crop.shape[1] - pad,
|
|
313
|
+
crop.shape[0] - pad),
|
|
314
|
+
(0, 0, 255), 2)
|
|
315
|
+
cv2.imwrite(cch, crop,
|
|
316
|
+
[cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
317
|
+
cap.release()
|
|
318
|
+
jb["log"].append("thumbnails pre-generated "
|
|
319
|
+
f"({len(todo)})")
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
threading.Thread(target=_pregen, daemon=True).start()
|
|
323
|
+
try:
|
|
324
|
+
prov = _load_job_report(job).get("provenance", {})
|
|
325
|
+
if (prov.get("vfr_normalized")
|
|
326
|
+
and os.path.exists(prov.get("input", ""))):
|
|
327
|
+
job["video"] = prov["input"]
|
|
328
|
+
job["log"].append(
|
|
329
|
+
"using CFR-normalized file for previews/thumbnails")
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
if job["options"].get("skip_review"):
|
|
333
|
+
job["log"].append("review skipped — rendering all detections")
|
|
334
|
+
job["phase"] = "review"
|
|
335
|
+
WORK_Q.put((jid, "render"))
|
|
336
|
+
else:
|
|
337
|
+
job["phase"] = "review"
|
|
338
|
+
job["progress"] = 0.5
|
|
339
|
+
elif action == "render":
|
|
340
|
+
job["phase"] = "rendering"
|
|
341
|
+
args = build_args(job, for_render=True)
|
|
342
|
+
res = openscrub.run_pipeline(args, cb)
|
|
343
|
+
job["output"] = res["output"]
|
|
344
|
+
# refresh report with output hash
|
|
345
|
+
dets, rstate, prov = openscrub.load_report(
|
|
346
|
+
os.path.join(job["dir"], "report.json"))
|
|
347
|
+
job["phase"] = "done"
|
|
348
|
+
job["progress"] = 1.0
|
|
349
|
+
except openscrub.PipelineCancelled:
|
|
350
|
+
job["phase"] = "cancelled"
|
|
351
|
+
job["error"] = "cancelled by user"
|
|
352
|
+
except Exception as e:
|
|
353
|
+
job["phase"] = "error"
|
|
354
|
+
job["error"] = str(e)
|
|
355
|
+
job["log"].append(f"ERROR: {e}")
|
|
356
|
+
finally:
|
|
357
|
+
WORK_Q.task_done()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ----------------------------------------------------------------------------
|
|
361
|
+
# Auth
|
|
362
|
+
# ----------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
@app.before_request
|
|
365
|
+
def check_token():
|
|
366
|
+
if TOKEN is None: # open mode: anyone on the network is trusted
|
|
367
|
+
return
|
|
368
|
+
tok = (request.args.get("token") or request.cookies.get("phiblur_token")
|
|
369
|
+
or request.headers.get("X-Token"))
|
|
370
|
+
if tok != TOKEN:
|
|
371
|
+
abort(401, "missing or wrong access token — open the URL printed "
|
|
372
|
+
"at server startup (includes ?token=...)")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@app.after_request
|
|
376
|
+
def set_cookie(resp):
|
|
377
|
+
if TOKEN is not None and request.args.get("token") == TOKEN:
|
|
378
|
+
resp.set_cookie("phiblur_token", TOKEN, max_age=90 * 86400,
|
|
379
|
+
samesite="Lax")
|
|
380
|
+
return resp
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ----------------------------------------------------------------------------
|
|
384
|
+
# API
|
|
385
|
+
# ----------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
@app.route("/api/jobs", methods=["POST"])
|
|
388
|
+
def create_job():
|
|
389
|
+
options = json.loads(request.form.get("options", "{}"))
|
|
390
|
+
jobs = []
|
|
391
|
+
files = request.files.getlist("video")
|
|
392
|
+
if files and files[0].filename:
|
|
393
|
+
for f in files:
|
|
394
|
+
name = os.path.basename(f.filename)
|
|
395
|
+
job = new_job("", options, name)
|
|
396
|
+
path = os.path.join(job["dir"], "input" + os.path.splitext(name)[1])
|
|
397
|
+
f.save(path)
|
|
398
|
+
job["video"] = path
|
|
399
|
+
WORK_Q.put((job["id"], "scan"))
|
|
400
|
+
jobs.append(job["id"])
|
|
401
|
+
elif request.form.get("server_path"):
|
|
402
|
+
p = request.form["server_path"].strip()
|
|
403
|
+
if not os.path.exists(p):
|
|
404
|
+
return jsonify({"error": f"server path not found: {p}"}), 400
|
|
405
|
+
job = new_job(p, options, os.path.basename(p))
|
|
406
|
+
WORK_Q.put((job["id"], "scan"))
|
|
407
|
+
jobs.append(job["id"])
|
|
408
|
+
else:
|
|
409
|
+
return jsonify({"error": "no file uploaded and no server path"}), 400
|
|
410
|
+
return jsonify({"jobs": jobs})
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@app.route("/api/jobs")
|
|
414
|
+
def list_jobs():
|
|
415
|
+
with JOBS_LOCK:
|
|
416
|
+
js = sorted(JOBS.values(), key=lambda j: -j["created"])
|
|
417
|
+
return jsonify([job_public(j) for j in js])
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@app.route("/api/jobs/<jid>")
|
|
421
|
+
def job_status(jid):
|
|
422
|
+
job = JOBS.get(jid) or abort(404)
|
|
423
|
+
return jsonify(job_public(job))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@app.route("/api/jobs/<jid>/log")
|
|
427
|
+
def job_log(jid):
|
|
428
|
+
job = JOBS.get(jid) or abort(404)
|
|
429
|
+
frm = max(0, int(request.args.get("from", 0)))
|
|
430
|
+
return jsonify({"from": frm, "lines": job["log"][frm:],
|
|
431
|
+
"len": len(job["log"])})
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@app.route("/api/jobs/<jid>/preview.jpg")
|
|
435
|
+
def job_preview(jid):
|
|
436
|
+
job = JOBS.get(jid) or abort(404)
|
|
437
|
+
p = os.path.join(job["dir"], "preview.jpg")
|
|
438
|
+
if not os.path.exists(p):
|
|
439
|
+
abort(404)
|
|
440
|
+
return send_file(p, mimetype="image/jpeg", max_age=0)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@app.route("/api/jobs/<jid>/cancel", methods=["POST"])
|
|
444
|
+
def job_cancel(jid):
|
|
445
|
+
job = JOBS.get(jid) or abort(404)
|
|
446
|
+
job["cancel"].set()
|
|
447
|
+
return jsonify({"ok": True})
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _load_job_report(job):
|
|
451
|
+
with open(os.path.join(job["dir"], "report.json"), encoding="utf-8") as f:
|
|
452
|
+
return json.load(f)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _frame_at(job, t, rstate):
|
|
456
|
+
cap = cv2.VideoCapture(job["video"])
|
|
457
|
+
fps = rstate["fps"]
|
|
458
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, int(t * fps))
|
|
459
|
+
ok, frame = cap.read()
|
|
460
|
+
cap.release()
|
|
461
|
+
return frame if ok else None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@app.route("/api/jobs/<jid>/mediainfo")
|
|
465
|
+
def job_mediainfo(jid):
|
|
466
|
+
"""Basic video properties straight from the file — available in every
|
|
467
|
+
job phase, no scan report required."""
|
|
468
|
+
job = JOBS.get(jid) or abort(404)
|
|
469
|
+
cap = cv2.VideoCapture(job["video"])
|
|
470
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
|
471
|
+
n = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0
|
|
472
|
+
w_ = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
473
|
+
h_ = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
474
|
+
cap.release()
|
|
475
|
+
return jsonify({"duration": n / fps if fps else 0, "fps": fps,
|
|
476
|
+
"width": w_, "height": h_})
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@app.route("/api/jobs/<jid>/detections")
|
|
480
|
+
def job_detections(jid):
|
|
481
|
+
job = JOBS.get(jid) or abort(404)
|
|
482
|
+
doc = _load_job_report(job)
|
|
483
|
+
items = []
|
|
484
|
+
for i, d in enumerate(doc["detections"]):
|
|
485
|
+
items.append({"i": i, "category": d["category"], "text": d["text"],
|
|
486
|
+
"t_start": d["t_start"], "t_end": round(d["t_end"], 2),
|
|
487
|
+
"enabled": d.get("enabled", True),
|
|
488
|
+
"zone_dropped": bool(d.get("zone_dropped"))})
|
|
489
|
+
return jsonify({"detections": items,
|
|
490
|
+
"fps": doc["render_state"]["fps"],
|
|
491
|
+
"duration": len(doc["render_state"]["cum"]) /
|
|
492
|
+
doc["render_state"]["fps"]})
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@app.route("/api/jobs/<jid>/detframe/<int:i>")
|
|
496
|
+
def job_detframe(jid, i):
|
|
497
|
+
"""Full video frame at a real sighting of detection i, with its box
|
|
498
|
+
outlined — the review lightbox."""
|
|
499
|
+
job = JOBS.get(jid) or abort(404)
|
|
500
|
+
doc = _load_job_report(job)
|
|
501
|
+
d = doc["detections"][i]
|
|
502
|
+
rs = doc["render_state"]
|
|
503
|
+
tref = min(max(d.get("last_seen", d["t_start"]), d["t_start"]), d["t_end"])
|
|
504
|
+
fidx = min(int(tref * rs["fps"]), len(rs["cum"]) - 1)
|
|
505
|
+
ox, oy = rs["cum"][fidx]
|
|
506
|
+
frame = _frame_at(job, tref, rs)
|
|
507
|
+
if frame is None:
|
|
508
|
+
abort(404)
|
|
509
|
+
x1, y1 = int(d["cbox"][0] + ox), int(d["cbox"][1] + oy)
|
|
510
|
+
x2, y2 = int(d["cbox"][2] + ox), int(d["cbox"][3] + oy)
|
|
511
|
+
cv2.rectangle(frame, (x1 - 4, y1 - 4), (x2 + 4, y2 + 4), (0, 0, 255), 3)
|
|
512
|
+
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 84])
|
|
513
|
+
from flask import Response
|
|
514
|
+
return Response(buf.tobytes(), mimetype="image/jpeg")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@app.route("/api/jobs/<jid>/boxes_at")
|
|
518
|
+
def job_boxes_at(jid):
|
|
519
|
+
"""Active blur boxes (screen coords) at time t, plus the scroll offset so
|
|
520
|
+
the client can convert edited boxes back to content coordinates."""
|
|
521
|
+
job = JOBS.get(jid) or abort(404)
|
|
522
|
+
doc = _load_job_report(job)
|
|
523
|
+
rs = doc["render_state"]
|
|
524
|
+
t = float(request.args.get("t", 0))
|
|
525
|
+
fidx = min(int(t * rs["fps"]), len(rs["cum"]) - 1)
|
|
526
|
+
ox, oy = rs["cum"][fidx]
|
|
527
|
+
boxes = []
|
|
528
|
+
want_all = request.args.get("all") == "1"
|
|
529
|
+
for i, d in enumerate(doc["detections"]):
|
|
530
|
+
if not (d["t_start"] - 0.01 <= t <= d["t_end"] + 0.01):
|
|
531
|
+
continue
|
|
532
|
+
if not want_all and not d.get("enabled", True):
|
|
533
|
+
continue
|
|
534
|
+
if True:
|
|
535
|
+
boxes.append({"i": i, "category": d["category"],
|
|
536
|
+
"enabled": d.get("enabled", True),
|
|
537
|
+
"text": d.get("text", ""),
|
|
538
|
+
"t_start": d["t_start"], "t_end": d["t_end"],
|
|
539
|
+
"box": [d["cbox"][0] + ox, d["cbox"][1] + oy,
|
|
540
|
+
d["cbox"][2] + ox, d["cbox"][3] + oy]})
|
|
541
|
+
return jsonify({"t": t, "ox": ox, "oy": oy, "boxes": boxes})
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@app.route("/api/jobs/<jid>/thumb/<int:i>")
|
|
545
|
+
def job_thumb(jid, i):
|
|
546
|
+
job = JOBS.get(jid) or abort(404)
|
|
547
|
+
cache = os.path.join(job["dir"], f"thumb2_{i}.jpg")
|
|
548
|
+
if not os.path.exists(cache):
|
|
549
|
+
doc = _load_job_report(job)
|
|
550
|
+
d = doc["detections"][i]
|
|
551
|
+
rs = doc["render_state"]
|
|
552
|
+
# cut the thumbnail at a moment the detector actually SAW the object.
|
|
553
|
+
# t_start can be earlier than the object's appearance (backtracking
|
|
554
|
+
# deliberately moves it to just before onset) — a frame there shows
|
|
555
|
+
# nothing and made review pictures mismatch their text.
|
|
556
|
+
ls = min(max(d.get("last_seen", d["t_start"]), d["t_start"]),
|
|
557
|
+
d["t_end"])
|
|
558
|
+
mid = (d["t_start"] + d["t_end"]) / 2
|
|
559
|
+
crop = None
|
|
560
|
+
pad = 24
|
|
561
|
+
for tref in (ls, mid, min(d["t_start"] + 0.2, d["t_end"])):
|
|
562
|
+
fidx = min(int(tref * rs["fps"]), len(rs["cum"]) - 1)
|
|
563
|
+
ox, oy = rs["cum"][fidx]
|
|
564
|
+
frame = _frame_at(job, tref, rs)
|
|
565
|
+
if frame is None:
|
|
566
|
+
continue
|
|
567
|
+
h, w = frame.shape[:2]
|
|
568
|
+
x1 = max(0, int(d["cbox"][0] + ox) - pad)
|
|
569
|
+
y1 = max(0, int(d["cbox"][1] + oy) - pad)
|
|
570
|
+
x2 = min(w, int(d["cbox"][2] + ox) + pad)
|
|
571
|
+
y2 = min(h, int(d["cbox"][3] + oy) + pad)
|
|
572
|
+
c = frame[y1:y2, x1:x2]
|
|
573
|
+
if c.size == 0:
|
|
574
|
+
continue
|
|
575
|
+
crop = c
|
|
576
|
+
inner = c[pad:-pad or None, pad:-pad or None]
|
|
577
|
+
if inner.size and float(inner.std()) > 8:
|
|
578
|
+
break # crop shows real content — use this moment
|
|
579
|
+
if crop is None:
|
|
580
|
+
abort(404)
|
|
581
|
+
cv2.rectangle(crop, (pad, pad), (crop.shape[1] - pad,
|
|
582
|
+
crop.shape[0] - pad), (0, 0, 255), 2)
|
|
583
|
+
cv2.imwrite(cache, crop, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
584
|
+
return send_file(cache, mimetype="image/jpeg", max_age=3600)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@app.route("/api/jobs/<jid>/frame_at")
|
|
588
|
+
def job_frame_at(jid):
|
|
589
|
+
job = JOBS.get(jid) or abort(404)
|
|
590
|
+
t = float(request.args.get("t", 0))
|
|
591
|
+
path = (job["output"] if request.args.get("src") == "output"
|
|
592
|
+
and job.get("output") else job["video"])
|
|
593
|
+
cap = cv2.VideoCapture(path)
|
|
594
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
|
595
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, int(t * fps))
|
|
596
|
+
ok, frame = cap.read()
|
|
597
|
+
cap.release()
|
|
598
|
+
frame = frame if ok else None
|
|
599
|
+
if frame is None:
|
|
600
|
+
abort(404)
|
|
601
|
+
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 82])
|
|
602
|
+
return Response(buf.tobytes(), mimetype="image/jpeg")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
@app.route("/api/jobs/<jid>/detections", methods=["POST"])
|
|
606
|
+
def job_save_edits(jid):
|
|
607
|
+
job = JOBS.get(jid) or abort(404)
|
|
608
|
+
edits = request.get_json(force=True)
|
|
609
|
+
path = os.path.join(job["dir"], "report.json")
|
|
610
|
+
doc = _load_job_report(job)
|
|
611
|
+
enabled = edits.get("enabled", {})
|
|
612
|
+
for i, d in enumerate(doc["detections"]):
|
|
613
|
+
if str(i) in enabled:
|
|
614
|
+
d["enabled"] = bool(enabled[str(i)])
|
|
615
|
+
for i, box in (edits.get("box_overrides") or {}).items():
|
|
616
|
+
i = int(i)
|
|
617
|
+
if 0 <= i < len(doc["detections"]) and len(box) == 4:
|
|
618
|
+
doc["detections"][i]["cbox"] = [float(v) for v in box]
|
|
619
|
+
for i, span in (edits.get("time_overrides") or {}).items():
|
|
620
|
+
i = int(i)
|
|
621
|
+
if 0 <= i < len(doc["detections"]) and len(span) == 2:
|
|
622
|
+
t0, t1 = float(span[0]), float(span[1])
|
|
623
|
+
if t1 > t0 >= 0:
|
|
624
|
+
doc["detections"][i]["t_start"] = t0
|
|
625
|
+
doc["detections"][i]["t_end"] = t1
|
|
626
|
+
doc["detections"][i]["last_seen"] = min(
|
|
627
|
+
max(doc["detections"][i].get("last_seen", t0), t0), t1)
|
|
628
|
+
rs = doc["render_state"]
|
|
629
|
+
for m in edits.get("manual", []):
|
|
630
|
+
t0 = float(m["t_start"]); t1 = float(m["t_end"])
|
|
631
|
+
tref = float(m.get("t_ref", t0))
|
|
632
|
+
fidx = min(int(tref * rs["fps"]), len(rs["cum"]) - 1)
|
|
633
|
+
ox, oy = rs["cum"][fidx]
|
|
634
|
+
b = m["screen_box"]
|
|
635
|
+
doc["detections"].append({
|
|
636
|
+
"t_start": t0, "t_end": t1,
|
|
637
|
+
"cbox": [int(b[0] - ox), int(b[1] - oy),
|
|
638
|
+
int(b[2] - ox), int(b[3] - oy)],
|
|
639
|
+
"category": "manual", "text": "user-drawn", "confidence": 1.0,
|
|
640
|
+
"aoff": [ox, oy], "last_seen": t0, "enabled": True})
|
|
641
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
642
|
+
json.dump(doc, f, indent=1)
|
|
643
|
+
# suggest allowlisting any string whose EVERY instance was disabled —
|
|
644
|
+
# the review just taught us it's a recurring false positive
|
|
645
|
+
per = {}
|
|
646
|
+
for d in doc["detections"]:
|
|
647
|
+
if d["category"] in ("name",):
|
|
648
|
+
per.setdefault(d["text"], []).append(d.get("enabled", True))
|
|
649
|
+
suggest = sorted(t for t, states in per.items()
|
|
650
|
+
if len(states) >= 2 and not any(states))
|
|
651
|
+
return jsonify({"ok": True, "total": len(doc["detections"]),
|
|
652
|
+
"suggest_allowlist": suggest})
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@app.route("/api/plate_models")
|
|
656
|
+
def plate_models():
|
|
657
|
+
"""Registry entries + whether each is already installed + active model."""
|
|
658
|
+
import openscrub as engine
|
|
659
|
+
models = engine.load_plate_registry()
|
|
660
|
+
mdir = os.path.join(os.path.dirname(os.path.abspath(engine.__file__)), "models")
|
|
661
|
+
out = []
|
|
662
|
+
for m in models:
|
|
663
|
+
path = os.path.join(mdir, "%s.onnx" % m.get("id"))
|
|
664
|
+
out.append({
|
|
665
|
+
"id": m.get("id"), "label": m.get("label"),
|
|
666
|
+
"license": m.get("license"), "source_url": m.get("source_url"),
|
|
667
|
+
"notes": m.get("notes"), "recommended": bool(m.get("recommended")),
|
|
668
|
+
"verified": m.get("download_url") not in (None, "", "TODO_VERIFY"),
|
|
669
|
+
"pinned": bool(m.get("sha256")) and m.get("sha256") != "TODO_VERIFY",
|
|
670
|
+
"installed": os.path.exists(path),
|
|
671
|
+
"attribution": m.get("attribution", ""),
|
|
672
|
+
})
|
|
673
|
+
# is any model active (i.e. would PlateDetector find one)?
|
|
674
|
+
active = None
|
|
675
|
+
for cand in (os.environ.get("OPENSCRUB_PLATE_MODEL"),
|
|
676
|
+
os.path.join(mdir, "plate_yolov8.onnx")):
|
|
677
|
+
if cand and os.path.exists(cand):
|
|
678
|
+
active = cand; break
|
|
679
|
+
if not active:
|
|
680
|
+
for m in models:
|
|
681
|
+
path = os.path.join(mdir, "%s.onnx" % m.get("id"))
|
|
682
|
+
if os.path.exists(path):
|
|
683
|
+
active = path; break
|
|
684
|
+
return jsonify({"models": out, "active": os.path.basename(active) if active else None})
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
_plate_dl = {"state": "idle", "pct": 0, "error": "", "id": ""}
|
|
688
|
+
|
|
689
|
+
@app.route("/api/plate_models/<mid>/download", methods=["POST"])
|
|
690
|
+
def plate_model_download(mid):
|
|
691
|
+
"""Download+verify a registry model in a background thread."""
|
|
692
|
+
import openscrub as engine
|
|
693
|
+
entry = next((m for m in engine.load_plate_registry() if m.get("id") == mid), None)
|
|
694
|
+
if entry is None:
|
|
695
|
+
return jsonify({"error": "unknown model id"}), 404
|
|
696
|
+
if _plate_dl["state"] == "downloading":
|
|
697
|
+
return jsonify({"error": "a download is already in progress"}), 409
|
|
698
|
+
_plate_dl.update(state="downloading", pct=0, error="", id=mid)
|
|
699
|
+
def work():
|
|
700
|
+
try:
|
|
701
|
+
engine.download_plate_model(
|
|
702
|
+
entry, progress=lambda f: _plate_dl.update(pct=int(f * 100)))
|
|
703
|
+
_plate_dl.update(state="done", pct=100)
|
|
704
|
+
except Exception as e:
|
|
705
|
+
_plate_dl.update(state="error", error=str(e))
|
|
706
|
+
threading.Thread(target=work, daemon=True).start()
|
|
707
|
+
return jsonify({"ok": True})
|
|
708
|
+
|
|
709
|
+
@app.route("/api/plate_models/download_status")
|
|
710
|
+
def plate_model_download_status():
|
|
711
|
+
return jsonify(_plate_dl)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@app.route("/api/allowlist", methods=["GET", "POST"])
|
|
715
|
+
def allowlist():
|
|
716
|
+
path = os.path.join(JOBS_DIR, "allowlist.txt")
|
|
717
|
+
if request.method == "POST":
|
|
718
|
+
body = request.get_json(force=True)
|
|
719
|
+
existing = []
|
|
720
|
+
if os.path.exists(path):
|
|
721
|
+
existing = [l.strip() for l in open(path, encoding="utf-8")
|
|
722
|
+
if l.strip()]
|
|
723
|
+
for wd in body.get("words", []):
|
|
724
|
+
wd = wd.strip()
|
|
725
|
+
if wd and wd not in existing:
|
|
726
|
+
existing.append(wd)
|
|
727
|
+
removes = {r.strip() for r in body.get("remove", [])}
|
|
728
|
+
if removes:
|
|
729
|
+
existing = [wd for wd in existing if wd not in removes]
|
|
730
|
+
if body.get("clear"):
|
|
731
|
+
existing = []
|
|
732
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
733
|
+
f.write("".join(wd + chr(10) for wd in existing))
|
|
734
|
+
return jsonify({"ok": True, "count": len(existing)})
|
|
735
|
+
words = []
|
|
736
|
+
if os.path.exists(path):
|
|
737
|
+
words = [l.strip() for l in open(path, encoding="utf-8") if l.strip()]
|
|
738
|
+
return jsonify({"words": words, "path": path})
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
@app.route("/api/jobs/<jid>/render", methods=["POST"])
|
|
742
|
+
def job_render(jid):
|
|
743
|
+
job = JOBS.get(jid) or abort(404)
|
|
744
|
+
if job["phase"] in ("rendering", "queued_render"):
|
|
745
|
+
return jsonify({"ok": True, "note": "render already in progress"})
|
|
746
|
+
if job["phase"] not in ("review", "done", "error", "cancelled"):
|
|
747
|
+
return jsonify({"error": f"cannot render from phase {job['phase']}"}), 400
|
|
748
|
+
job["phase"] = "queued_render"
|
|
749
|
+
job["cancel"].clear()
|
|
750
|
+
job["error"] = None
|
|
751
|
+
WORK_Q.put((jid, "render"))
|
|
752
|
+
return jsonify({"ok": True})
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
@app.route("/api/jobs/<jid>/download")
|
|
756
|
+
def job_download(jid):
|
|
757
|
+
job = JOBS.get(jid) or abort(404)
|
|
758
|
+
if not job["output"] or not os.path.exists(job["output"]):
|
|
759
|
+
abort(404)
|
|
760
|
+
return send_file(job["output"], as_attachment=True,
|
|
761
|
+
download_name=os.path.splitext(job["name"])[0]
|
|
762
|
+
+ "_redacted.mp4")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@app.route("/api/jobs/<jid>/report")
|
|
766
|
+
def job_report(jid):
|
|
767
|
+
job = JOBS.get(jid) or abort(404)
|
|
768
|
+
return send_file(os.path.join(job["dir"], "report.json"),
|
|
769
|
+
as_attachment=True,
|
|
770
|
+
download_name=os.path.splitext(job["name"])[0]
|
|
771
|
+
+ "_audit.json")
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
# ----------------------------------------------------------------------------
|
|
775
|
+
# UI
|
|
776
|
+
# ----------------------------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
PAGE = """<!doctype html><html><head><meta charset="utf-8">
|
|
779
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
780
|
+
<title>OpenScrub</title>
|
|
781
|
+
<link rel="icon" href="data:image/png;base64,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"><style>
|
|
782
|
+
:root{--bg:#f5f6f8;--card:#fff;--acc:#2563eb;--red:#dc2626}
|
|
783
|
+
*{box-sizing:border-box}body{font:15px/1.45 system-ui,Segoe UI,Roboto,sans-serif;
|
|
784
|
+
margin:0;background:var(--bg);color:#1f2937}
|
|
785
|
+
header{background:#0F172A;color:#fff;padding:20px 16px 14px;
|
|
786
|
+
display:flex;flex-direction:column;align-items:center;gap:8px;
|
|
787
|
+
display:flex;flex-direction:column;align-items:center;gap:6px}
|
|
788
|
+
header img{height:88px;max-width:92vw;object-fit:contain}
|
|
789
|
+
header .brand{font-size:34px;font-weight:800;letter-spacing:-1px;line-height:1}
|
|
790
|
+
header .brand .box{border:2.5px solid #E53935;border-radius:3px;padding:0 4px;margin:0 1px;display:inline-block}
|
|
791
|
+
header .brand .fuzz{filter:blur(2.6px);opacity:.75}
|
|
792
|
+
header small{color:#9ca3af;font-weight:400;letter-spacing:.3px}
|
|
793
|
+
main{max-width:1100px;margin:0 auto;padding:12px}
|
|
794
|
+
.card{background:var(--card);border-radius:10px;padding:14px;margin-bottom:12px;
|
|
795
|
+
box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
796
|
+
h2{margin:0 0 10px;font-size:17px}
|
|
797
|
+
label{display:block;margin:6px 0 2px;font-size:13px;color:#4b5563}
|
|
798
|
+
input[type=text],input[type=number],select,textarea{width:100%;padding:7px;
|
|
799
|
+
border:1px solid #d1d5db;border-radius:6px;font:inherit}
|
|
800
|
+
textarea{height:64px}
|
|
801
|
+
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
|
802
|
+
.grid3{display:grid;grid-template-columns:repeat(auto-fill,minmax(175px,1fr));gap:9px}
|
|
803
|
+
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
|
804
|
+
button{background:var(--acc);color:#fff;border:0;border-radius:7px;
|
|
805
|
+
padding:9px 16px;font:inherit;cursor:pointer}
|
|
806
|
+
button.sec{background:#6b7280}button.danger{background:var(--red)}
|
|
807
|
+
button:disabled{opacity:.5}
|
|
808
|
+
progress{width:100%;height:14px}
|
|
809
|
+
.det{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:6px;
|
|
810
|
+
text-align:center;font-size:12px}
|
|
811
|
+
.det img{max-width:100%;border-radius:5px}
|
|
812
|
+
.det.off{opacity:.45}
|
|
813
|
+
.det.selected{outline:2px dashed #2563eb;outline-offset:2px}
|
|
814
|
+
.blurbtn{width:100%;padding:8px 0;margin-top:4px;font-weight:700;
|
|
815
|
+
font-size:13px;border-radius:8px;letter-spacing:.3px;
|
|
816
|
+
transition:background .15s,transform .05s}
|
|
817
|
+
.blurbtn:active{transform:scale(.97)}
|
|
818
|
+
.blurbtn.blur{background:#dc2626}
|
|
819
|
+
.blurbtn.keep{background:#16a34a}
|
|
820
|
+
.marq{position:absolute;border:1.5px dashed #2563eb;
|
|
821
|
+
background:rgba(37,99,235,.08);z-index:50;pointer-events:none}
|
|
822
|
+
.selmenu{position:absolute;z-index:60;background:#fff;border-radius:10px;
|
|
823
|
+
box-shadow:0 8px 28px rgba(0,0,0,.28);padding:7px 10px;display:flex;
|
|
824
|
+
gap:8px;align-items:center;font-size:12.5px;color:#374151}
|
|
825
|
+
.selmenu button{padding:7px 16px;font-weight:700}
|
|
826
|
+
.selmenu .mkeep{background:#16a34a}
|
|
827
|
+
.selmenu .mblur{background:#dc2626}
|
|
828
|
+
.badge{display:inline-block;background:#e5e7eb;border-radius:5px;
|
|
829
|
+
padding:1px 6px;font-size:11px;margin-left:4px}
|
|
830
|
+
#log{background:#0f172a;color:#cbd5e1;font:12px/1.4 ui-monospace,Consolas,monospace;
|
|
831
|
+
padding:10px;border-radius:8px;height:180px;overflow:auto;white-space:pre-wrap}
|
|
832
|
+
#preview img,#framewrap{max-width:100%;border-radius:8px}
|
|
833
|
+
#framewrap{position:relative;display:inline-block}
|
|
834
|
+
#framewrap canvas{position:absolute;left:0;top:0;cursor:crosshair}
|
|
835
|
+
.joblist a{color:var(--acc);text-decoration:none}
|
|
836
|
+
.warn{background:#fef3c7;border:1px solid #fcd34d;border-radius:8px;
|
|
837
|
+
padding:8px 12px;font-size:13px}
|
|
838
|
+
.chk{display:flex;gap:12px;flex-wrap:wrap}
|
|
839
|
+
.chk label{display:flex;gap:4px;align-items:center;margin:0}
|
|
840
|
+
.plist{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px}
|
|
841
|
+
.pw{background:#f0fdf4;color:#15803d;border:1px solid #bbf7d0;border-radius:7px;
|
|
842
|
+
padding:2px 7px;font-size:12px;font-weight:600;display:flex;gap:6px;
|
|
843
|
+
align-items:center}
|
|
844
|
+
.pw span{cursor:pointer;color:#16a34a;font-weight:700}
|
|
845
|
+
.pw span:hover{color:#14532d}
|
|
846
|
+
.pnote{font-size:11.5px;color:#15803d;margin-top:4px}
|
|
847
|
+
.qm{display:inline-flex;align-items:center;justify-content:center;
|
|
848
|
+
width:15px;height:15px;border-radius:50%;background:#94a3b8;color:#fff;
|
|
849
|
+
font-size:10.5px;font-weight:700;cursor:help;margin-left:5px;
|
|
850
|
+
position:relative;vertical-align:middle;user-select:none}
|
|
851
|
+
.qm:hover::after,.qm.open::after{content:attr(data-tip);position:absolute;
|
|
852
|
+
left:50%;transform:translateX(-50%);bottom:135%;background:#111827;
|
|
853
|
+
color:#e5e7eb;padding:9px 11px;border-radius:9px;width:250px;
|
|
854
|
+
font-size:12px;font-weight:400;line-height:1.4;z-index:90;
|
|
855
|
+
box-shadow:0 8px 24px rgba(0,0,0,.35);white-space:normal;text-align:left}
|
|
856
|
+
@media(max-width:640px){.grid2{grid-template-columns:1fr}}
|
|
857
|
+
.catrow{display:inline-flex;align-items:center;gap:5px}
|
|
858
|
+
.catrow select.catmode{font-size:11px;padding:1px 3px;border-radius:5px;
|
|
859
|
+
border:1px solid #d1d5db;color:#6b7280;background:#fff}
|
|
860
|
+
</style></head><body>
|
|
861
|
+
<header><img src="data:image/png;base64,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" alt="OpenScrub">
|
|
862
|
+
<div class="brand">Open<span class="box"><span class="fuzz">Scr</span></span>ub</div>
|
|
863
|
+
<small>Local video redaction — review before you trust</small></header>
|
|
864
|
+
<main>
|
|
865
|
+
<div class="card" id="newjob">
|
|
866
|
+
<h2>Job Settings</h2>
|
|
867
|
+
<label>Upload video(s) <span style="font-weight:400;color:#6b7280">— accepts
|
|
868
|
+
MP4, MOV, MKV, AVI, M4V, WebM, WMV (anything ffmpeg can read)</span></label>
|
|
869
|
+
<input type="file" id="file" accept="video/*" multiple>
|
|
870
|
+
<label>…or path on the server</label>
|
|
871
|
+
<input type="text" id="spath" placeholder="C:\\recordings\\demo.mp4">
|
|
872
|
+
<label>Output format<span class="qm" data-tip="Container for the redacted file. The video inside is re-encoded H.264 either way; MP4 is right for DaVinci Resolve and most tools.">?</span></label>
|
|
873
|
+
<select id="outfmt" style="max-width:340px">
|
|
874
|
+
<option value="mp4">MP4 — H.264 (recommended)</option>
|
|
875
|
+
<option value="mov">MOV — QuickTime container</option>
|
|
876
|
+
<option value="mkv">MKV — Matroska container</option></select>
|
|
877
|
+
<div class="grid2">
|
|
878
|
+
<div><label>OCR engine<span class="qm" data-tip="Reads on-screen text. Paddle is the most accurate (GPU-capable); Tesseract is the lighter fallback. Auto picks Paddle when installed.">?</span></label><select id="engine">
|
|
879
|
+
<option>auto</option><option>paddle</option><option>tesseract</option></select></div>
|
|
880
|
+
<div><label>OCR device<span class="qm" data-tip="Where PaddleOCR runs. GPU is far faster when CUDA is set up; CPU always works.">?</span></label><select id="device">
|
|
881
|
+
<option>auto</option><option>gpu</option><option>cpu</option></select></div>
|
|
882
|
+
<div><label>Encoder<span class="qm" data-tip="Encoder for the final render. NVENC uses the GPU's dedicated encode chip (fast, frees the CPU); x264 is CPU-only. Auto tests NVENC and falls back safely.">?</span></label><select id="encoder">
|
|
883
|
+
<option>auto</option><option value="nvenc">NVENC (GPU)</option>
|
|
884
|
+
<option value="x264">x264 (CPU)</option></select></div>
|
|
885
|
+
<div><label>Redaction (default)<span class="qm" data-tip="Default style for every category. blur = Gaussian blur; readable structure is destroyed but a blur is, in principle, partially reversible — short high-contrast strings (an SSN or MRN in a fixed font) are the most vulnerable. box = solid black; pixels are set to zero, so nothing is recoverable. Override per category below.">?</span></label><select id="mode" onchange="renderCats()">
|
|
886
|
+
<option>blur</option><option>box</option><option>mosaic</option></select></div>
|
|
887
|
+
<div><label>Sample interval (s)<span class="qm" data-tip="How often a full OCR scan runs. Lower catches short-lived PHI sooner but scans take longer. Backtracking automatically closes most of the gap between scans, so 0.5 is a good default.">?</span></label><input type="number" id="si" value="0.5" step="0.1"></div>
|
|
888
|
+
<div><label>Scan trigger (px)<span class="qm" data-tip="An extra scan fires every time the page scrolls this many pixels, so scrolled-in content is read promptly. Lower = more scans while scrolling.">?</span></label><input type="number" id="st" value="60"></div>
|
|
889
|
+
<div><label>Blur buffer (px)<span class="qm" data-tip="Pixels of blur beyond the tightly-cropped word or face. 8 is a safe default; 3 is a tight cosmetic look. During scrolling a small drift allowance is added automatically.">?</span></label><input type="number" id="pad" value="8"></div>
|
|
890
|
+
<div><label>Face expand (0-1)<span class="qm" data-tip="Enlarges detected face boxes by this fraction before the blur buffer, covering hairline and ears. 0 = raw detector box (may leave identifiable edges).">?</span></label><input type="number" id="fex" value="0.15" step="0.05"></div>
|
|
891
|
+
<div><label>Face threshold<span class="qm" data-tip="Face detector confidence cutoff (0-1). Lower catches more faces but risks false positives on face-like patterns; higher is stricter. Default 0.6. Tune with preview + show scores.">?</span></label><input type="number" id="fthr" value="0.6" step="0.05" min="0" max="1"></div>
|
|
892
|
+
<div><label>Detection scale<span class="qm" data-tip="Runs FACE detection on a downscaled copy of each frame for speed (0.2-1.0; e.g. 0.5 = half resolution). Output quality is unaffected — only the detection pass is faster. 1.0 = full resolution (off). Helpful with dense faces on high-res video.">?</span></label><input type="number" id="dscale" value="1.0" step="0.1" min="0.2" max="1"></div>
|
|
893
|
+
<div><label>Bridge gap (s)<span class="qm" data-tip="If the same PHI is seen, missed for a bit, then seen again, gaps up to this long stay blurred — unless scans prove it actually disappeared. Raise to 8-10 for mostly static screens.">?</span></label><input type="number" id="bg" value="4" step="0.5"></div>
|
|
894
|
+
<div><label>Skip first (s) — delay detection<span class="qm" data-tip="Detection window start. NOTHING in the first N seconds is detected or blurred — use only when PHI cannot appear during the intro.">?</span></label>
|
|
895
|
+
<input type="number" id="skipstart" value="0" min="0" step="1"></div>
|
|
896
|
+
<div><label>Skip last (s) — stop before end<span class="qm" data-tip="Detection window end. Nothing in the last N seconds is detected or blurred.">?</span></label>
|
|
897
|
+
<input type="number" id="skipend" value="0" min="0" step="1"></div>
|
|
898
|
+
<div><label>MRN regex<span class="qm" data-tip="Pattern for medical record numbers. Default matches 7 digits starting with 1, or MM followed by 10 digits. Anchors work — punctuation around the number is handled.">?</span></label><input type="text" id="mrnrx"
|
|
899
|
+
value="(^(?:1\\d{6}|MM\\d{10})$)"></div>
|
|
900
|
+
</div>
|
|
901
|
+
<label>Categories<span class="qm" data-tip="Which PHI types the detectors look for. Unchecking a category means it is never blurred.">?</span></label>
|
|
902
|
+
<div class="chk" id="cats"></div>
|
|
903
|
+
<div class="row" style="margin:10px 0 4px">
|
|
904
|
+
<a href="zones"><button type="button" class="sec">⬚ Detection zones…</button></a>
|
|
905
|
+
<span id="zstat" class="badge">none configured</span></div>
|
|
906
|
+
<div class="grid2">
|
|
907
|
+
<div><label>Allow names (keep visible)<span class="qm" data-tip="Words never treated as PHI — provider names, app names. One per line. Green chips below are learned automatically from your reviews and always apply too.">?</span></label><textarea id="allow"
|
|
908
|
+
placeholder="e.g. provider or app names to always keep visible one per line"></textarea>
|
|
909
|
+
<div id="persistlist" class="plist"></div>
|
|
910
|
+
<div id="persistnote" class="pnote" style="display:none"></div>
|
|
911
|
+
<button type="button" class="sec" id="clearpersist" style="display:none;margin-top:5px;
|
|
912
|
+
padding:4px 10px;font-size:12px" onclick="clearPersist()">Clear all learned words</button></div>
|
|
913
|
+
<div><label>Always blur (extra names)<span class="qm" data-tip="Words always blurred even if the detectors would miss them (unusual spellings, nicknames). One per line.">?</span></label><textarea id="extra"></textarea></div>
|
|
914
|
+
</div>
|
|
915
|
+
<label>Ignore regions (x1,y1,x2,y2 per line — e.g. taskbar clock)<span class="qm" data-tip="Screen rectangles that are never scanned at all — taskbar clock, a webcam overlay of yourself. Pixel coordinates of the recording.">?</span></label>
|
|
916
|
+
<textarea id="ign" style="height:40px"></textarea>
|
|
917
|
+
<div class="row" style="margin-top:8px">
|
|
918
|
+
<label style="margin:0"><input type="checkbox" id="nomem"> disable memory<span class="qm" data-tip="Memory recalls confirmed PHI on later frames even when OCR misreads it. Disabling cuts false positives but weakens recall — usually leave off.">?</span></label>
|
|
919
|
+
<label style="margin:0"><input type="checkbox" id="pmode"> preview mode (boxes only)<span class="qm" data-tip="Draws red outline boxes instead of blurring — a fast way to check coverage before committing to a render.">?</span></label>
|
|
920
|
+
<label style="margin:0"><input type="checkbox" id="skiprev"> skip review (blur everything found, render immediately)<span class="qm" data-tip="No human check: every detection is blurred and the render starts right away. Use only with settings you already trust.">?</span></label>
|
|
921
|
+
<label style="margin:0"><input type="checkbox" id="usezones" checked> apply detection zones<span class="qm" data-tip="Restricts each category to the zones drawn in the zone editor. Outside a category's zones NOTHING is blurred, even if detected.">?</span></label>
|
|
922
|
+
<div id="platepanel" style="display:none;margin:8px 0;padding:10px 12px;border:1px solid #e5e7eb;border-radius:10px;background:#fafaf9">
|
|
923
|
+
<div style="font-weight:700;font-size:13px;margin-bottom:2px">License-plate model <span class="qm" data-tip="Plate detection needs a model file (not bundled). Pick a curated open-source model to download it — the file is SHA-256 verified before use. Each entry shows its software license.">?</span></div>
|
|
924
|
+
<div id="platestatus" style="font-size:12px;color:#6b7280;margin-bottom:6px">checking…</div>
|
|
925
|
+
<div id="platelist"></div>
|
|
926
|
+
</div>
|
|
927
|
+
<label style="margin:0"><input type="checkbox" id="drawscores"> show face scores (preview)<span class="qm" data-tip="In preview mode, labels each face box with its detection confidence so you can pick a good Face threshold for your footage.">?</span></label>
|
|
928
|
+
<label style="margin:0"><input type="checkbox" id="densefaces"> dense faces (every frame)<span class="qm" data-tip="Runs the face detector on EVERY frame instead of at scan intervals, so fast-moving faces stay covered (e.g. someone walking through a webcam feed). Slower to render — pair it with a face detection zone to keep it fast. Leave off for static screens where faces don't move.">?</span></label>
|
|
929
|
+
<button onclick="startJob()">Start scan</button>
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
932
|
+
|
|
933
|
+
<div class="card"><h2>Jobs</h2><div class="joblist" id="jobs">loading…</div></div>
|
|
934
|
+
<div id="detail"></div>
|
|
935
|
+
</main>
|
|
936
|
+
<footer style="text-align:center;color:#9ca3af;font-size:12px;padding:18px 12px 26px">
|
|
937
|
+
OpenScrub v4.2.0 · <a href="license" style="color:#6b7280">Apache-2.0 license</a>
|
|
938
|
+
· best-effort redaction — always review output before sharing PHI</footer>
|
|
939
|
+
<script>
|
|
940
|
+
const CATS=["name","dob","phone","ssn","mrn","email","address","card","apikey","ipaddr","plate","face"];
|
|
941
|
+
const CATMODE={}; // category -> "" (default) | "blur" | "box"
|
|
942
|
+
function renderCats(){
|
|
943
|
+
const gm=document.getElementById("mode").value;
|
|
944
|
+
document.getElementById("cats").innerHTML=CATS.map(c=>{
|
|
945
|
+
const on=CATMODE[c]===undefined?true:CATMODE[c]!=="__off";
|
|
946
|
+
const m=(CATMODE[c]&&CATMODE[c]!=="__off")?CATMODE[c]:"";
|
|
947
|
+
return `<span class="catrow">
|
|
948
|
+
<label><input type="checkbox" class="cat" value="${c}" ${on?"checked":""}
|
|
949
|
+
onchange="onCatToggle('${c}',this.checked)">${c}</label>
|
|
950
|
+
<select class="catmode" data-c="${c}" onchange="CATMODE['${c}']=this.value||''"
|
|
951
|
+
${on?"":"disabled"} title="redaction style for ${c}">
|
|
952
|
+
<option value="">${gm} (default)</option>
|
|
953
|
+
<option value="blur" ${m==="blur"?"selected":""}>blur</option>
|
|
954
|
+
<option value="box" ${m==="box"?"selected":""}>box</option>
|
|
955
|
+
<option value="mosaic" ${m==="mosaic"?"selected":""}>mosaic</option>
|
|
956
|
+
</select></span>`;
|
|
957
|
+
}).join("");
|
|
958
|
+
}
|
|
959
|
+
function onCatToggle(c,on){
|
|
960
|
+
CATMODE[c]=on?"":"__off";
|
|
961
|
+
const sel=document.querySelector(`.catmode[data-c="${c}"]`);
|
|
962
|
+
if(sel)sel.disabled=!on;
|
|
963
|
+
}
|
|
964
|
+
renderCats();
|
|
965
|
+
|
|
966
|
+
async function refreshPlatePanel(){
|
|
967
|
+
const on=[...document.querySelectorAll(".cat:checked")].some(e=>e.value==="plate");
|
|
968
|
+
const pp=document.getElementById("platepanel");
|
|
969
|
+
pp.style.display=on?"block":"none";
|
|
970
|
+
if(!on)return;
|
|
971
|
+
try{
|
|
972
|
+
const r=await fetch("/api/plate_models"); const d=await r.json();
|
|
973
|
+
const st=document.getElementById("platestatus");
|
|
974
|
+
st.textContent=d.active?("Active model: "+d.active):"No model installed — plate detection is INACTIVE until you install or provide one.";
|
|
975
|
+
st.style.color=d.active?"#15803d":"#b45309";
|
|
976
|
+
document.getElementById("platelist").innerHTML=d.models.map(m=>`
|
|
977
|
+
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-top:1px solid #eee">
|
|
978
|
+
<div style="flex:1;min-width:0">
|
|
979
|
+
<div style="font-size:12.5px;font-weight:600">${m.label} ${m.recommended?'<span style="color:#15803d;font-size:11px">★ recommended</span>':''}</div>
|
|
980
|
+
<div style="font-size:11px;color:#6b7280">${m.notes||""}</div>
|
|
981
|
+
</div>
|
|
982
|
+
<a href="${m.source_url}" target="_blank" style="font-size:11px;background:#eef2ff;color:#3730a3;border-radius:5px;padding:2px 7px;text-decoration:none" title="${m.attribution||''}">${m.license}</a>
|
|
983
|
+
${m.installed?'<span style="font-size:11px;color:#15803d;font-weight:700">installed</span>'
|
|
984
|
+
:(m.verified?`<button onclick="dlPlate('${m.id}',this)" title="${m.pinned?'SHA-256 verified against pinned hash':'first download: hash will be computed and pinned'}" style="font-size:11.5px;padding:3px 10px">Download</button>`
|
|
985
|
+
:'<span style="font-size:11px;color:#9ca3af" title="registry entry has no download URL">unavailable</span>')}
|
|
986
|
+
</div>`).join("");
|
|
987
|
+
}catch(e){document.getElementById("platestatus").textContent="model registry unavailable";}
|
|
988
|
+
}
|
|
989
|
+
async function dlPlate(id,btn){
|
|
990
|
+
btn.disabled=true;btn.textContent="0%";
|
|
991
|
+
const r=await fetch(`/api/plate_models/${id}/download`,{method:"POST"});
|
|
992
|
+
if(!r.ok){const d=await r.json();btn.textContent="error";btn.title=d.error||"";return;}
|
|
993
|
+
const t=setInterval(async()=>{
|
|
994
|
+
const s=await(await fetch("/api/plate_models/download_status")).json();
|
|
995
|
+
if(s.state==="downloading"){btn.textContent=(s.pct||0)+"%";}
|
|
996
|
+
else{clearInterval(t);
|
|
997
|
+
if(s.state==="done"){refreshPlatePanel();}
|
|
998
|
+
else{btn.disabled=false;btn.textContent="retry";btn.title=s.error||"download failed";}}
|
|
999
|
+
},500);
|
|
1000
|
+
}
|
|
1001
|
+
document.getElementById("cats").addEventListener("change",refreshPlatePanel);
|
|
1002
|
+
refreshPlatePanel();
|
|
1003
|
+
let CUR=null, EN={}, MAN=[], DUR=0, POLL=null, PHIST=[], LOGN=0, SHELLPH=null, JOBSJSON="";
|
|
1004
|
+
|
|
1005
|
+
function opts(){return{
|
|
1006
|
+
engine:engine.value,device:device.value,encoder:encoder.value,mode:mode.value,
|
|
1007
|
+
sample_interval:+si.value,scan_trigger:+st.value,pad:+pad.value,bridge_gap:+bg.value,
|
|
1008
|
+
mrn_regex:mrnrx.value,face_expand:+fex.value,skip_review:skiprev.checked,use_zones:usezones.checked,
|
|
1009
|
+
skip_start:+skipstart.value,skip_end:+skipend.value,out_format:outfmt.value,
|
|
1010
|
+
categories:[...document.querySelectorAll(".cat:checked")].map(e=>e.value).join(","),
|
|
1011
|
+
mode_map:Object.entries(CATMODE).filter(([c,m])=>m==="blur"||m==="box")
|
|
1012
|
+
.map(([c,m])=>`${c}=${m}`).join(","),
|
|
1013
|
+
allow_names:allow.value,extra_names:extra.value,ignore_regions:ign.value,
|
|
1014
|
+
no_memory:nomem.checked,preview_mode:pmode.checked,
|
|
1015
|
+
dense_faces:densefaces.checked,face_threshold:+fthr.value,
|
|
1016
|
+
detect_scale:+dscale.value,draw_scores:drawscores.checked}}
|
|
1017
|
+
|
|
1018
|
+
async function startJob(){
|
|
1019
|
+
const fd=new FormData();
|
|
1020
|
+
for(const f of file.files) fd.append("video",f);
|
|
1021
|
+
fd.append("server_path",spath.value);
|
|
1022
|
+
fd.append("options",JSON.stringify(opts()));
|
|
1023
|
+
const r=await fetch("api/jobs",{method:"POST",body:fd});
|
|
1024
|
+
const j=await r.json();
|
|
1025
|
+
if(j.error){alert(j.error);return}
|
|
1026
|
+
file.value="";spath.value="";
|
|
1027
|
+
loadJobs(); if(j.jobs.length)openJob(j.jobs[0]);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function loadJobs(){
|
|
1031
|
+
const js=await (await fetch("api/jobs")).json();
|
|
1032
|
+
const s=JSON.stringify(js);
|
|
1033
|
+
if(s===JOBSJSON)return;
|
|
1034
|
+
JOBSJSON=s;
|
|
1035
|
+
jobs.innerHTML=js.length?js.map(j=>
|
|
1036
|
+
`<div class="row" style="justify-content:space-between;padding:4px 0">
|
|
1037
|
+
<a href="#" onclick="openJob('${j.id}');return false">${j.name}</a>
|
|
1038
|
+
<span class="badge">${j.phase}</span></div>`).join(""):"none yet";
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function openJob(id){
|
|
1042
|
+
CUR=id; EN={}; MAN=[]; PHIST=[]; LOGN=0; SHELLPH=null;
|
|
1043
|
+
detail.innerHTML="";
|
|
1044
|
+
if(POLL)clearInterval(POLL);
|
|
1045
|
+
POLL=setInterval(refresh,900); refresh();
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function shellHtml(j){
|
|
1049
|
+
return `<div class="card" id="jobcard"><h2>${j.name}
|
|
1050
|
+
<span class="badge" id="ph"></span><small id="eta"></small></h2>
|
|
1051
|
+
<progress id="prog" value="0" max="1"></progress>
|
|
1052
|
+
<div class="row" id="actions" style="margin-top:8px"></div>
|
|
1053
|
+
<div style="margin-top:8px;text-align:center"><img id="pimg"
|
|
1054
|
+
style="max-width:100%;border-radius:8px;display:none;margin:0 auto"></div>
|
|
1055
|
+
<div id="pcap" style="font-size:12px;color:#6b7280"></div>
|
|
1056
|
+
<div id="log" style="margin-top:8px"></div>
|
|
1057
|
+
<div class="warn" id="warnbox" style="display:none;margin-top:8px"></div>
|
|
1058
|
+
</div>`;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function refresh(){
|
|
1062
|
+
if(!CUR)return;
|
|
1063
|
+
const j=await (await fetch("api/jobs/"+CUR)).json();
|
|
1064
|
+
if(SHELLPH===null){
|
|
1065
|
+
detail.insertAdjacentHTML("afterbegin",shellHtml(j));SHELLPH="";
|
|
1066
|
+
const im=document.getElementById("pimg");
|
|
1067
|
+
im.onload=()=>{im.style.display="block";}; // show only once a real frame arrives
|
|
1068
|
+
im.onerror=()=>{im.style.display="none";}; // 404 before first preview: stay hidden
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// progress + ETA (updated in place — nothing is rebuilt)
|
|
1072
|
+
PHIST.push([Date.now(),j.progress]); if(PHIST.length>40)PHIST.shift();
|
|
1073
|
+
let eta="";
|
|
1074
|
+
if(j.progress>0&&j.progress<1&&PHIST.length>5){
|
|
1075
|
+
const[t0,p0]=PHIST[0],[t1,p1]=PHIST[PHIST.length-1];
|
|
1076
|
+
if(p1>p0){const rem=(1-p1)*(t1-t0)/(p1-p0)/1000;
|
|
1077
|
+
eta=` ~${rem>90?Math.round(rem/60)+" min":Math.round(rem)+" s"} left`;}}
|
|
1078
|
+
document.getElementById("ph").textContent=j.phase;
|
|
1079
|
+
document.getElementById("eta").textContent=eta;
|
|
1080
|
+
document.getElementById("prog").value=j.progress;
|
|
1081
|
+
|
|
1082
|
+
// incremental log append with stick-to-bottom (respects manual scroll-up)
|
|
1083
|
+
const lg=document.getElementById("log");
|
|
1084
|
+
const stick=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-40;
|
|
1085
|
+
const nl=await (await fetch(`api/jobs/${CUR}/log?from=${LOGN}`)).json();
|
|
1086
|
+
if(LOGN===0&&nl.len>400){LOGN=nl.len-400;nl.lines=nl.lines.slice(-400);}
|
|
1087
|
+
if(nl.lines.length){
|
|
1088
|
+
const NL=String.fromCharCode(10);
|
|
1089
|
+
lg.textContent+=(lg.textContent?NL:"")+nl.lines.join(NL);
|
|
1090
|
+
LOGN=nl.len;
|
|
1091
|
+
if(stick)lg.scrollTop=lg.scrollHeight;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// live preview: swap src in place (browser keeps old frame until new loads)
|
|
1095
|
+
const img=document.getElementById("pimg");
|
|
1096
|
+
if(j.phase=="scanning"){
|
|
1097
|
+
img.src=`api/jobs/${CUR}/preview.jpg?x=${Date.now()}`;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const wb=document.getElementById("warnbox");
|
|
1101
|
+
if(j.error){wb.style.display="block";wb.textContent=j.error;}
|
|
1102
|
+
else wb.style.display="none";
|
|
1103
|
+
|
|
1104
|
+
// action buttons rebuilt ONLY when the phase changes
|
|
1105
|
+
if(SHELLPH!==j.phase){
|
|
1106
|
+
SHELLPH=j.phase;
|
|
1107
|
+
let a="";
|
|
1108
|
+
if(j.phase=="scanning"||j.phase=="rendering")
|
|
1109
|
+
a=`<button class="danger" onclick="cancelJob()">Cancel</button>`;
|
|
1110
|
+
if(j.phase=="review")
|
|
1111
|
+
a=`<button onclick="loadReview()">Open review</button>`;
|
|
1112
|
+
if(j.phase=="done"&&j.has_output)
|
|
1113
|
+
a=`<a href="api/jobs/${CUR}/download"><button>Download redacted video</button></a>
|
|
1114
|
+
<a href="api/jobs/${CUR}/report"><button class="sec">Download audit report</button></a>
|
|
1115
|
+
<button class="sec" onclick="loadReview()">Re-open review</button>
|
|
1116
|
+
<button class="sec" onclick="loadCompare()">Compare before/after</button>
|
|
1117
|
+
<button class="sec" onclick="loadBoxEdit()">Preview & edit blur boxes</button>
|
|
1118
|
+
<div class="warn" style="width:100%">Reminder: scrub the result before
|
|
1119
|
+
sharing. This tool is best-effort, not a compliance guarantee.</div>
|
|
1120
|
+
<div id="compare" style="width:100%"></div>
|
|
1121
|
+
<div id="beslot_done" style="width:100%"></div>`;
|
|
1122
|
+
document.getElementById("actions").innerHTML=a;
|
|
1123
|
+
if(j.phase!="scanning")img.style.display="none";
|
|
1124
|
+
}
|
|
1125
|
+
loadJobs();
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
let BE={t:0,ox:0,oy:0,boxes:[],sel:-1,ov:{},dis:{},times:{},adds:[],
|
|
1129
|
+
add:false,addAnchor:null,drag:null,scale:1,dur:10};
|
|
1130
|
+
async function loadBoxEdit(){
|
|
1131
|
+
const mi=await (await fetch(`api/jobs/${CUR}/mediainfo`)).json();
|
|
1132
|
+
// in review: the slot right under the Preview button; on done: the actions slot
|
|
1133
|
+
let c=document.getElementById("beslot")||document.getElementById("beslot_done");
|
|
1134
|
+
if(!c){c=document.createElement("div");c.id="beslot";c.style.width="100%";
|
|
1135
|
+
(document.getElementById("review")||document.getElementById("jobcard")||detail)
|
|
1136
|
+
.appendChild(c);}
|
|
1137
|
+
BE={t:0,ox:0,oy:0,boxes:[],sel:-1,ov:{},dis:{},times:{},adds:[],
|
|
1138
|
+
add:false,addAnchor:null,drag:null,scale:1,dur:mi.duration};
|
|
1139
|
+
c.innerHTML=`<div class="row" style="margin-top:8px">t=
|
|
1140
|
+
<input type="range" id="beT" min="0" max="${Math.max(0.2,mi.duration-0.1).toFixed(1)}"
|
|
1141
|
+
step="0.1" value="0" style="flex:1" oninput="beScrub()"><span id="beL">0s</span></div>
|
|
1142
|
+
<div style="position:relative;display:inline-block;max-width:100%">
|
|
1143
|
+
<img id="beImg" style="max-width:100%;border-radius:8px">
|
|
1144
|
+
<canvas id="beCv" style="position:absolute;left:0;top:0;touch-action:none"></canvas></div>
|
|
1145
|
+
<div class="row" style="margin-top:6px">
|
|
1146
|
+
<button class="tog" id="beAdd" onclick="beAddMode()">+ Add box</button>
|
|
1147
|
+
<button class="danger" id="beRm" style="display:none" onclick="beRemove()">Remove blur</button>
|
|
1148
|
+
<span id="beTimes" style="display:none;align-items:center;gap:4px;font-size:13px">
|
|
1149
|
+
from <input type="number" id="beT0" step="0.1" min="0" style="width:74px">
|
|
1150
|
+
to <input type="number" id="beT1" step="0.1" style="width:74px"> s
|
|
1151
|
+
<button class="sec" onclick="beApplyTimes()">Apply times</button></span>
|
|
1152
|
+
<button onclick="beSave()">Save changes & re-render</button></div>
|
|
1153
|
+
<p style="font-size:12px;color:#6b7280;margin:4px 0 0">Click a red box to
|
|
1154
|
+
select — drag inside to move, a corner to resize, edit its from/to times,
|
|
1155
|
+
or remove its blur. + Add box: drag a new region, then set how long it
|
|
1156
|
+
lasts. New boxes show orange.</p>`;
|
|
1157
|
+
const img=document.getElementById("beImg");
|
|
1158
|
+
img.onload=()=>{const cv=document.getElementById("beCv");
|
|
1159
|
+
cv.width=img.clientWidth;cv.height=img.clientHeight;
|
|
1160
|
+
BE.scale=img.clientWidth/(img.naturalWidth||1);beDraw();};
|
|
1161
|
+
beHook();beScrub();
|
|
1162
|
+
}
|
|
1163
|
+
async function beScrub(){
|
|
1164
|
+
BE.sel=-1;document.getElementById("beRm").style.display="none";
|
|
1165
|
+
document.getElementById("beTimes").style.display="none";
|
|
1166
|
+
BE.t=+document.getElementById("beT").value;
|
|
1167
|
+
document.getElementById("beL").textContent=BE.t.toFixed(1)+"s";
|
|
1168
|
+
document.getElementById("beImg").src=`api/jobs/${CUR}/frame_at?t=${BE.t}`;
|
|
1169
|
+
const d=await (await fetch(`api/jobs/${CUR}/boxes_at?t=${BE.t}&all=1`)).json();
|
|
1170
|
+
BE.ox=d.ox;BE.oy=d.oy;BE.raw=d.boxes;
|
|
1171
|
+
beFilter();beDraw();
|
|
1172
|
+
}
|
|
1173
|
+
function beFilter(){
|
|
1174
|
+
BE.boxes=(BE.raw||[]).filter(b=>{
|
|
1175
|
+
const on=(b.i in EN)?EN[b.i]:b.enabled; // live: review toggles apply here
|
|
1176
|
+
return on;
|
|
1177
|
+
}).map(b=>{
|
|
1178
|
+
let bb=b;
|
|
1179
|
+
if(BE.ov[b.i]){const o=BE.ov[b.i];
|
|
1180
|
+
bb={...b,box:[o[0]+BE.ox,o[1]+BE.oy,o[2]+BE.ox,o[3]+BE.oy]};}
|
|
1181
|
+
if(BE.times[b.i]){bb={...bb,t_start:BE.times[b.i][0],t_end:BE.times[b.i][1]};}
|
|
1182
|
+
return bb;
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
function beDraw(){
|
|
1186
|
+
const cv=document.getElementById("beCv");if(!cv)return;
|
|
1187
|
+
const x=cv.getContext("2d"),s=BE.scale;
|
|
1188
|
+
x.clearRect(0,0,cv.width,cv.height);
|
|
1189
|
+
for(const a of BE.adds){
|
|
1190
|
+
if(BE.t<a.t0-0.01||BE.t>a.t1+0.01)continue;
|
|
1191
|
+
const[a1,b1,a2,b2]=a.box.map(v=>v*s);
|
|
1192
|
+
x.fillStyle="rgba(245,158,11,.25)";x.strokeStyle="#f59e0b";x.lineWidth=2;
|
|
1193
|
+
x.fillRect(a1,b1,a2-a1,b2-b1);x.strokeRect(a1,b1,a2-a1,b2-b1);
|
|
1194
|
+
}
|
|
1195
|
+
if(BE.add&&BE.addAnchor&&BE.addFloat){
|
|
1196
|
+
const[p,q]=[BE.addAnchor,BE.addFloat];
|
|
1197
|
+
x.strokeStyle="#f59e0b";x.setLineDash([6,4]);x.lineWidth=2;
|
|
1198
|
+
x.strokeRect(Math.min(p[0],q[0])*s,Math.min(p[1],q[1])*s,
|
|
1199
|
+
Math.abs(q[0]-p[0])*s,Math.abs(q[1]-p[1])*s);x.setLineDash([]);
|
|
1200
|
+
}
|
|
1201
|
+
for(const b of BE.boxes){
|
|
1202
|
+
const[a1,b1,a2,b2]=b.box.map(v=>v*s);
|
|
1203
|
+
x.fillStyle=b.i===BE.sel?"rgba(37,99,235,.25)":"rgba(220,38,38,.22)";
|
|
1204
|
+
x.strokeStyle=b.i===BE.sel?"#2563eb":"#dc2626";x.lineWidth=2;
|
|
1205
|
+
x.fillRect(a1,b1,a2-a1,b2-b1);x.strokeRect(a1,b1,a2-a1,b2-b1);
|
|
1206
|
+
if(b.i===BE.sel)for(const[hx,hy] of [[a1,b1],[a2,b1],[a1,b2],[a2,b2]]){
|
|
1207
|
+
x.fillStyle="#fff";x.fillRect(hx-5,hy-5,10,10);
|
|
1208
|
+
x.strokeRect(hx-5,hy-5,10,10);}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
function beAddMode(){
|
|
1212
|
+
BE.add=!BE.add;BE.addAnchor=null;BE.sel=-1;
|
|
1213
|
+
document.getElementById("beAdd").classList.toggle("on",BE.add);
|
|
1214
|
+
document.getElementById("beRm").style.display="none";
|
|
1215
|
+
document.getElementById("beTimes").style.display="none";
|
|
1216
|
+
beDraw();
|
|
1217
|
+
}
|
|
1218
|
+
function beApplyTimes(){
|
|
1219
|
+
if(BE.sel<0)return;
|
|
1220
|
+
const t0=+document.getElementById("beT0").value,
|
|
1221
|
+
t1=+document.getElementById("beT1").value;
|
|
1222
|
+
if(!(t1>t0&&t0>=0)){alert("'to' must be after 'from'.");return;}
|
|
1223
|
+
BE.times[BE.sel]=[t0,Math.min(t1,BE.dur)];
|
|
1224
|
+
const b=BE.boxes.find(x=>x.i===BE.sel);
|
|
1225
|
+
if(b){b.t_start=t0;b.t_end=t1;}
|
|
1226
|
+
beDraw();
|
|
1227
|
+
}
|
|
1228
|
+
function beShowTimes(b){
|
|
1229
|
+
const el=document.getElementById("beTimes");
|
|
1230
|
+
el.style.display="inline-flex";
|
|
1231
|
+
document.getElementById("beT0").value=b.t_start.toFixed(1);
|
|
1232
|
+
document.getElementById("beT1").value=b.t_end.toFixed(1);
|
|
1233
|
+
}
|
|
1234
|
+
function beHook(){
|
|
1235
|
+
const cv=document.getElementById("beCv");
|
|
1236
|
+
const pt=e=>{const r=cv.getBoundingClientRect();
|
|
1237
|
+
return[(e.clientX-r.left)/BE.scale,(e.clientY-r.top)/BE.scale];};
|
|
1238
|
+
cv.addEventListener("pointerdown",e=>{
|
|
1239
|
+
const p=pt(e);cv.setPointerCapture(e.pointerId);
|
|
1240
|
+
if(BE.add){
|
|
1241
|
+
if(!BE.addAnchor){BE.addAnchor=p;BE.addFloat=p;}
|
|
1242
|
+
else{
|
|
1243
|
+
const a=BE.addAnchor,q=p;BE.addAnchor=null;BE.addFloat=null;
|
|
1244
|
+
const box=[Math.min(a[0],q[0]),Math.min(a[1],q[1]),
|
|
1245
|
+
Math.max(a[0],q[0]),Math.max(a[1],q[1])];
|
|
1246
|
+
if(box[2]-box[0]>6&&box[3]-box[1]>6){
|
|
1247
|
+
const t0=Math.max(0,+(BE.t-0.5).toFixed(1)),
|
|
1248
|
+
t1=Math.min(BE.dur,+(BE.t+3).toFixed(1));
|
|
1249
|
+
const fr=prompt("Blur this region FROM (seconds):",t0);
|
|
1250
|
+
if(fr===null){beDraw();return;}
|
|
1251
|
+
const to=prompt("...TO (seconds):",t1);
|
|
1252
|
+
if(to===null){beDraw();return;}
|
|
1253
|
+
BE.adds.push({box,t0:Math.max(0,+fr||0),
|
|
1254
|
+
t1:Math.min(BE.dur,+to||t1),tref:BE.t});
|
|
1255
|
+
}
|
|
1256
|
+
beDraw();
|
|
1257
|
+
}
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const sel=BE.boxes.find(b=>b.i===BE.sel);
|
|
1261
|
+
if(sel){const[a1,b1,a2,b2]=sel.box,hs=9/BE.scale;
|
|
1262
|
+
const corners=[[a1,b1,0],[a2,b1,1],[a1,b2,2],[a2,b2,3]];
|
|
1263
|
+
for(const[hx,hy,ci] of corners)
|
|
1264
|
+
if(Math.abs(p[0]-hx)<hs&&Math.abs(p[1]-hy)<hs){
|
|
1265
|
+
BE.drag={kind:"corner",ci};return;}
|
|
1266
|
+
if(p[0]>=a1&&p[0]<=a2&&p[1]>=b1&&p[1]<=b2){
|
|
1267
|
+
BE.drag={kind:"move",off:[p[0]-a1,p[1]-b1]};return;}
|
|
1268
|
+
}
|
|
1269
|
+
const hit=[...BE.boxes].reverse().find(b=>{
|
|
1270
|
+
const[a1,b1,a2,b2]=b.box;
|
|
1271
|
+
return p[0]>=a1&&p[0]<=a2&&p[1]>=b1&&p[1]<=b2;});
|
|
1272
|
+
BE.sel=hit?hit.i:-1;
|
|
1273
|
+
document.getElementById("beRm").style.display=hit?"inline-block":"none";
|
|
1274
|
+
if(hit)beShowTimes(hit);
|
|
1275
|
+
else document.getElementById("beTimes").style.display="none";
|
|
1276
|
+
beDraw();
|
|
1277
|
+
});
|
|
1278
|
+
cv.addEventListener("pointermove",e=>{
|
|
1279
|
+
if(BE.add&&BE.addAnchor){BE.addFloat=pt(e);beDraw();return;}
|
|
1280
|
+
if(!BE.drag||BE.sel<0)return;
|
|
1281
|
+
const p=pt(e),b=BE.boxes.find(x=>x.i===BE.sel);if(!b)return;
|
|
1282
|
+
let[a1,b1,a2,b2]=b.box;
|
|
1283
|
+
if(BE.drag.kind==="move"){
|
|
1284
|
+
const w0=a2-a1,h0=b2-b1;a1=p[0]-BE.drag.off[0];b1=p[1]-BE.drag.off[1];
|
|
1285
|
+
a2=a1+w0;b2=b1+h0;
|
|
1286
|
+
}else{
|
|
1287
|
+
if(BE.drag.ci===0){a1=p[0];b1=p[1];}
|
|
1288
|
+
if(BE.drag.ci===1){a2=p[0];b1=p[1];}
|
|
1289
|
+
if(BE.drag.ci===2){a1=p[0];b2=p[1];}
|
|
1290
|
+
if(BE.drag.ci===3){a2=p[0];b2=p[1];}
|
|
1291
|
+
}
|
|
1292
|
+
b.box=[Math.min(a1,a2),Math.min(b1,b2),Math.max(a1,a2),Math.max(b1,b2)];
|
|
1293
|
+
BE.ov[BE.sel]=[b.box[0]-BE.ox,b.box[1]-BE.oy,b.box[2]-BE.ox,b.box[3]-BE.oy];
|
|
1294
|
+
beDraw();
|
|
1295
|
+
});
|
|
1296
|
+
cv.addEventListener("pointerup",()=>{BE.drag=null;});
|
|
1297
|
+
}
|
|
1298
|
+
function zoomDet(i){
|
|
1299
|
+
let ov=document.getElementById("lb");
|
|
1300
|
+
if(!ov){
|
|
1301
|
+
ov=document.createElement("div");ov.id="lb";
|
|
1302
|
+
ov.style.cssText="position:fixed;inset:0;background:rgba(0,0,0,.82);"+
|
|
1303
|
+
"z-index:100;display:flex;align-items:center;justify-content:center;"+
|
|
1304
|
+
"cursor:zoom-out;padding:18px";
|
|
1305
|
+
ov.onclick=()=>ov.remove();
|
|
1306
|
+
document.body.appendChild(ov);
|
|
1307
|
+
}
|
|
1308
|
+
ov.innerHTML=`<img src="api/jobs/${CUR}/detframe/${i}"
|
|
1309
|
+
style="max-width:96%;max-height:96%;border-radius:8px;
|
|
1310
|
+
box-shadow:0 12px 50px rgba(0,0,0,.6)">`;
|
|
1311
|
+
}
|
|
1312
|
+
function beRemove(){
|
|
1313
|
+
if(BE.sel<0)return;
|
|
1314
|
+
applyDet(BE.sel,false); // syncs thumbnail + refilters editor
|
|
1315
|
+
BE.sel=-1;
|
|
1316
|
+
document.getElementById("beRm").style.display="none";
|
|
1317
|
+
document.getElementById("beTimes").style.display="none";
|
|
1318
|
+
}
|
|
1319
|
+
async function beSave(){
|
|
1320
|
+
await saveAndRender();
|
|
1321
|
+
const c=document.querySelector("#beslot");
|
|
1322
|
+
if(c)c.innerHTML="rendering with your edits…";
|
|
1323
|
+
}
|
|
1324
|
+
async function cancelJob(){await fetch("api/jobs/"+CUR+"/cancel",{method:"POST"})}
|
|
1325
|
+
|
|
1326
|
+
async function loadCompare(){
|
|
1327
|
+
const d=await (await fetch(`api/jobs/${CUR}/detections`)).json();
|
|
1328
|
+
const c=document.getElementById("compare");
|
|
1329
|
+
c.innerHTML=`<div class="row" style="margin-top:8px">t=
|
|
1330
|
+
<input type="range" id="cmpT" min="0" max="${d.duration.toFixed(1)}" step="0.2"
|
|
1331
|
+
value="0" style="flex:1" oninput="cmpShow()">
|
|
1332
|
+
<span id="cmpLbl">0s</span></div>
|
|
1333
|
+
<div class="grid2"><div><b>Original</b><br><img id="cmpO" style="max-width:100%"></div>
|
|
1334
|
+
<div><b>Redacted</b><br><img id="cmpR" style="max-width:100%"></div></div>`;
|
|
1335
|
+
cmpShow();
|
|
1336
|
+
}
|
|
1337
|
+
function cmpShow(){
|
|
1338
|
+
const t=document.getElementById("cmpT").value;
|
|
1339
|
+
document.getElementById("cmpLbl").textContent=t+"s";
|
|
1340
|
+
document.getElementById("cmpO").src=`api/jobs/${CUR}/frame_at?t=${t}`;
|
|
1341
|
+
document.getElementById("cmpR").src=`api/jobs/${CUR}/frame_at?t=${t}&src=output`;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
async function loadReview(){
|
|
1345
|
+
const d=await (await fetch(`api/jobs/${CUR}/detections`)).json();
|
|
1346
|
+
DUR=d.duration;
|
|
1347
|
+
const groups={}; const zdrop=[];
|
|
1348
|
+
d.detections.forEach(x=>{
|
|
1349
|
+
EN[x.i]=x.enabled;
|
|
1350
|
+
if(x.zone_dropped){zdrop.push(x);return;}
|
|
1351
|
+
(groups[x.category]=groups[x.category]||[]).push(x);});
|
|
1352
|
+
let html=`<div class="card" id="review"><h2>Review — red = will be blurred,
|
|
1353
|
+
green = kept visible. Drag a box around several to bulk-set.</h2>
|
|
1354
|
+
<div class="row" style="margin:0 0 8px">
|
|
1355
|
+
<button class="sec" onclick="loadBoxEdit()">▶ Preview video & edit boxes
|
|
1356
|
+
(before rendering)</button></div>
|
|
1357
|
+
<div id="beslot"></div>
|
|
1358
|
+
<div id="detwrap" style="position:relative;user-select:none">`;
|
|
1359
|
+
if(zdrop.length){
|
|
1360
|
+
html+=`<details style="margin:4px 0 10px;border:1px dashed #f59e0b;border-radius:9px;padding:8px 10px;background:#fffbeb">
|
|
1361
|
+
<summary style="cursor:pointer;color:#92400e;font-weight:600">
|
|
1362
|
+
⚠ ${zdrop.length} detection(s) outside your zones — NOT blurred
|
|
1363
|
+
(mostly logos/labels by design; open only if something real was zoned out)
|
|
1364
|
+
</summary>
|
|
1365
|
+
<div class="row" style="margin:8px 0 4px">
|
|
1366
|
+
<button class="sec" onclick="setAllZd(true)">blur all of these</button>
|
|
1367
|
+
<button class="sec" onclick="setAllZd(false)">keep all</button></div>
|
|
1368
|
+
<div class="grid3">`;
|
|
1369
|
+
for(const x of zdrop){
|
|
1370
|
+
html+=`<div class="det ${EN[x.i]?'':'off'}" id="det${x.i}" data-cat="zdrop">
|
|
1371
|
+
<img src="api/jobs/${CUR}/thumb/${x.i}" loading="lazy"
|
|
1372
|
+
style="cursor:zoom-in" onclick="zoomDet(${x.i})" onerror="thumbRetry(this)">
|
|
1373
|
+
<div>"${x.text}" <br>${x.t_start.toFixed(1)}–${x.t_end.toFixed(1)}s</div>
|
|
1374
|
+
<button class="blurbtn ${EN[x.i]?"blur":"keep"}" id="bb${x.i}"
|
|
1375
|
+
onclick="toggleDet(${x.i})">${EN[x.i]?"Blur":"Keep"}</button>
|
|
1376
|
+
</div>`;
|
|
1377
|
+
}
|
|
1378
|
+
html+=`</div></details>`;
|
|
1379
|
+
}
|
|
1380
|
+
for(const[cat,items] of Object.entries(groups)){
|
|
1381
|
+
html+=`<h3 style="margin:10px 0 6px">${cat} <span class="badge">${items.length}</span>
|
|
1382
|
+
<button class="sec" style="padding:3px 8px;font-size:12px"
|
|
1383
|
+
onclick="setAll('${cat}',true)">all on</button>
|
|
1384
|
+
<button class="sec" style="padding:3px 8px;font-size:12px"
|
|
1385
|
+
onclick="setAll('${cat}',false)">all off</button></h3><div class="grid3">`;
|
|
1386
|
+
for(const x of items){
|
|
1387
|
+
html+=`<div class="det ${EN[x.i]?'':'off'}" id="det${x.i}" data-cat="${cat}">
|
|
1388
|
+
<img src="api/jobs/${CUR}/thumb/${x.i}"
|
|
1389
|
+
style="cursor:zoom-in" onclick="zoomDet(${x.i})"
|
|
1390
|
+
onerror="thumbRetry(this)">
|
|
1391
|
+
<div>"${x.text}" <br>${x.t_start.toFixed(1)}–${x.t_end.toFixed(1)}s</div>
|
|
1392
|
+
<button class="blurbtn ${EN[x.i]?"blur":"keep"}" id="bb${x.i}"
|
|
1393
|
+
onclick="toggleDet(${x.i})">${EN[x.i]?"Blur":"Keep"}</button>
|
|
1394
|
+
</div>`}
|
|
1395
|
+
html+=`</div>`}
|
|
1396
|
+
html+=`</div>
|
|
1397
|
+
<div class="row" style="margin-top:12px">
|
|
1398
|
+
<button onclick="saveAndRender()">Save edits & render</button>
|
|
1399
|
+
<span style="font-size:12px;color:#6b7280">missed something? use
|
|
1400
|
+
▶ Preview video & edit boxes above — its + Add box replaces the old
|
|
1401
|
+
add-region tool</span></div></div>`;
|
|
1402
|
+
const old=document.getElementById("review"); if(old)old.remove();
|
|
1403
|
+
detail.insertAdjacentHTML("beforeend",html);
|
|
1404
|
+
initMarquee();
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function applyDet(i,blur){
|
|
1408
|
+
EN[i]=blur;
|
|
1409
|
+
const b=document.getElementById("bb"+i),t=document.getElementById("det"+i);
|
|
1410
|
+
if(b){b.className="blurbtn "+(blur?"blur":"keep");
|
|
1411
|
+
b.textContent=blur?"Blur":"Keep";}
|
|
1412
|
+
if(t)t.classList.toggle("off",!blur);
|
|
1413
|
+
if(document.getElementById("beCv")){beFilter();beDraw();} // live sync
|
|
1414
|
+
}
|
|
1415
|
+
function toggleDet(i){applyDet(i,!EN[i]);}
|
|
1416
|
+
function setAll(cat,on){document.querySelectorAll(`.det[data-cat=${cat}]`)
|
|
1417
|
+
.forEach(el=>applyDet(+el.id.slice(3),on));}
|
|
1418
|
+
function thumbRetry(img){
|
|
1419
|
+
const n=+(img.dataset.r||0);
|
|
1420
|
+
if(n>=3)return;
|
|
1421
|
+
img.dataset.r=n+1;
|
|
1422
|
+
setTimeout(()=>{img.src=img.src.split("?r=")[0]+"?r="+Date.now();},
|
|
1423
|
+
600*(n+1));
|
|
1424
|
+
}
|
|
1425
|
+
function setAllZd(on){document.querySelectorAll(`.det[data-cat=zdrop]`)
|
|
1426
|
+
.forEach(el=>applyDet(+el.id.slice(3),on));}
|
|
1427
|
+
|
|
1428
|
+
let selSet=new Set(),marqEl=null,menuEl=null,mStart=null;
|
|
1429
|
+
function clearSel(){
|
|
1430
|
+
selSet.forEach(i=>{const t=document.getElementById("det"+i);
|
|
1431
|
+
if(t)t.classList.remove("selected");});
|
|
1432
|
+
selSet.clear();
|
|
1433
|
+
if(menuEl){menuEl.remove();menuEl=null;}
|
|
1434
|
+
}
|
|
1435
|
+
function initMarquee(){
|
|
1436
|
+
const wrap=document.getElementById("detwrap");
|
|
1437
|
+
if(!wrap||wrap.dataset.mq)return;
|
|
1438
|
+
wrap.dataset.mq=1;
|
|
1439
|
+
wrap.addEventListener("pointerdown",e=>{
|
|
1440
|
+
if(e.pointerType==="touch")return; // touch keeps normal scrolling
|
|
1441
|
+
if(e.target.closest("button"))return; // button clicks stay clicks
|
|
1442
|
+
clearSel();
|
|
1443
|
+
mStart=[e.pageX,e.pageY];
|
|
1444
|
+
marqEl=document.createElement("div");marqEl.className="marq";
|
|
1445
|
+
document.body.appendChild(marqEl);
|
|
1446
|
+
const move=ev=>{
|
|
1447
|
+
const x1=Math.min(mStart[0],ev.pageX),y1=Math.min(mStart[1],ev.pageY),
|
|
1448
|
+
x2=Math.max(mStart[0],ev.pageX),y2=Math.max(mStart[1],ev.pageY);
|
|
1449
|
+
Object.assign(marqEl.style,{left:x1+"px",top:y1+"px",
|
|
1450
|
+
width:(x2-x1)+"px",height:(y2-y1)+"px"});
|
|
1451
|
+
};
|
|
1452
|
+
const up=ev=>{
|
|
1453
|
+
document.removeEventListener("pointermove",move);
|
|
1454
|
+
document.removeEventListener("pointerup",up);
|
|
1455
|
+
const r=marqEl.getBoundingClientRect();
|
|
1456
|
+
marqEl.remove();marqEl=null;
|
|
1457
|
+
if(r.width<8&&r.height<8)return; // just a click — not a drag
|
|
1458
|
+
document.querySelectorAll("#detwrap .det").forEach(t=>{
|
|
1459
|
+
const b=t.getBoundingClientRect();
|
|
1460
|
+
if(b.left<r.right&&b.right>r.left&&b.top<r.bottom&&b.bottom>r.top){
|
|
1461
|
+
selSet.add(+t.id.slice(3));t.classList.add("selected");}});
|
|
1462
|
+
if(selSet.size)showSelMenu(ev.pageX,ev.pageY);
|
|
1463
|
+
};
|
|
1464
|
+
document.addEventListener("pointermove",move);
|
|
1465
|
+
document.addEventListener("pointerup",up);
|
|
1466
|
+
e.preventDefault();
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
function showSelMenu(x,y){
|
|
1470
|
+
menuEl=document.createElement("div");menuEl.className="selmenu";
|
|
1471
|
+
menuEl.innerHTML=`<span>${selSet.size} selected</span>
|
|
1472
|
+
<button class="mkeep" onclick="bulkSet(false)">Keep</button>
|
|
1473
|
+
<button class="mblur" onclick="bulkSet(true)">Blur</button>`;
|
|
1474
|
+
Object.assign(menuEl.style,{left:x+"px",top:(y+10)+"px"});
|
|
1475
|
+
document.body.appendChild(menuEl);
|
|
1476
|
+
}
|
|
1477
|
+
function bulkSet(blur){selSet.forEach(i=>applyDet(i,blur));clearSel();}
|
|
1478
|
+
document.addEventListener("pointerdown",e=>{
|
|
1479
|
+
if(menuEl&&!e.target.closest(".selmenu")
|
|
1480
|
+
&&!e.target.closest("#detwrap"))clearSel();});
|
|
1481
|
+
document.addEventListener("click",e=>{
|
|
1482
|
+
const q=e.target.closest(".qm");
|
|
1483
|
+
document.querySelectorAll(".qm.open").forEach(x=>{if(x!==q)x.classList.remove("open");});
|
|
1484
|
+
if(q)q.classList.toggle("open");});
|
|
1485
|
+
document.addEventListener("keydown",e=>{
|
|
1486
|
+
if(e.key==="Escape"){clearSel();
|
|
1487
|
+
const lb=document.getElementById("lb");if(lb)lb.remove();}});
|
|
1488
|
+
|
|
1489
|
+
async function saveAndRender(){
|
|
1490
|
+
const manual=[...MAN,...BE.adds.map(a=>({t_start:a.t0,t_end:a.t1,
|
|
1491
|
+
t_ref:a.tref,screen_box:a.box}))];
|
|
1492
|
+
const r=await (await fetch(`api/jobs/${CUR}/detections`,{method:"POST",
|
|
1493
|
+
headers:{"Content-Type":"application/json"},
|
|
1494
|
+
body:JSON.stringify({enabled:EN,manual,box_overrides:BE.ov,
|
|
1495
|
+
time_overrides:BE.times})})).json();
|
|
1496
|
+
BE.adds=[];BE.ov={};BE.times={};
|
|
1497
|
+
if(r.suggest_allowlist&&r.suggest_allowlist.length){
|
|
1498
|
+
if(confirm("You disabled every instance of: "+r.suggest_allowlist.join(", ")+". Add these to the permanent allow-list so future scans skip them?"))
|
|
1499
|
+
{await fetch("api/allowlist",{method:"POST",
|
|
1500
|
+
headers:{"Content-Type":"application/json"},
|
|
1501
|
+
body:JSON.stringify({words:r.suggest_allowlist})});
|
|
1502
|
+
loadPersist();}}
|
|
1503
|
+
MAN=[];
|
|
1504
|
+
await fetch(`api/jobs/${CUR}/render`,{method:"POST"});
|
|
1505
|
+
const rv=document.getElementById("review"); if(rv)rv.remove();
|
|
1506
|
+
}
|
|
1507
|
+
async function loadPersist(){
|
|
1508
|
+
const a=await (await fetch("api/allowlist")).json();
|
|
1509
|
+
const el=document.getElementById("persistlist"),
|
|
1510
|
+
nt=document.getElementById("persistnote");
|
|
1511
|
+
el.innerHTML=(a.words||[]).map(x=>
|
|
1512
|
+
`<div class="pw">${x}<span title="remove from allow-list"
|
|
1513
|
+
onclick="rmPersist('${x.replace(/'/g,"")}')">✕</span></div>`).join("");
|
|
1514
|
+
if((a.words||[]).length){
|
|
1515
|
+
nt.style.display="block";
|
|
1516
|
+
nt.textContent="Green words load automatically from "+a.path+
|
|
1517
|
+
" — marked safe during previous reviews. They are always applied in "+
|
|
1518
|
+
"addition to anything typed above. Click ✕ to remove one.";
|
|
1519
|
+
}else nt.style.display="none";
|
|
1520
|
+
document.getElementById("clearpersist").style.display=
|
|
1521
|
+
(a.words||[]).length?"inline-block":"none";
|
|
1522
|
+
}
|
|
1523
|
+
async function clearPersist(){
|
|
1524
|
+
if(!confirm("Remove ALL learned safe words? Future scans will flag them again until re-reviewed."))return;
|
|
1525
|
+
await fetch("api/allowlist",{method:"POST",
|
|
1526
|
+
headers:{"Content-Type":"application/json"},
|
|
1527
|
+
body:JSON.stringify({clear:true})});
|
|
1528
|
+
loadPersist();
|
|
1529
|
+
}
|
|
1530
|
+
async function rmPersist(word){
|
|
1531
|
+
await fetch("api/allowlist",{method:"POST",
|
|
1532
|
+
headers:{"Content-Type":"application/json"},
|
|
1533
|
+
body:JSON.stringify({remove:[word]})});
|
|
1534
|
+
loadPersist();
|
|
1535
|
+
}
|
|
1536
|
+
async function loadCertInfo(){
|
|
1537
|
+
try{
|
|
1538
|
+
const c=await (await fetch("api/certinfo")).json();
|
|
1539
|
+
document.getElementById("certinfo").textContent=
|
|
1540
|
+
`Current: ${c.mode} certificate — ${c.subject}, expires ${c.expires}`;
|
|
1541
|
+
}catch(e){
|
|
1542
|
+
document.getElementById("certinfo").textContent=
|
|
1543
|
+
"Running over plain HTTP (started with --http).";
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
async function uploadCerts(){
|
|
1547
|
+
const cf=document.getElementById("certfile").files[0],
|
|
1548
|
+
kf=document.getElementById("keyfile").files[0];
|
|
1549
|
+
if(!cf||!kf){alert("Choose both a certificate file and a key file.");return;}
|
|
1550
|
+
const fd=new FormData();fd.append("cert",cf);fd.append("key",kf);
|
|
1551
|
+
const r=await fetch("api/certs",{method:"POST",body:fd});
|
|
1552
|
+
alert(r.ok?(await r.json()).note:"Upload failed: "+await r.text());
|
|
1553
|
+
loadCertInfo();
|
|
1554
|
+
}
|
|
1555
|
+
async function removeCerts(){
|
|
1556
|
+
if(!confirm("Remove the custom certificate and return to self-signed on next restart?"))return;
|
|
1557
|
+
const r=await fetch("api/certs",{method:"DELETE"});
|
|
1558
|
+
alert((await r.json()).note);
|
|
1559
|
+
loadCertInfo();
|
|
1560
|
+
}
|
|
1561
|
+
async function zoneStatus(){
|
|
1562
|
+
const z=await (await fetch("api/zones")).json();
|
|
1563
|
+
const n=Object.keys(z).length;
|
|
1564
|
+
document.getElementById("zstat").textContent=
|
|
1565
|
+
n?(n+" class"+(n>1?"es":"")+" zoned — outside them nothing is blurred"):"none configured (full frame)";
|
|
1566
|
+
}
|
|
1567
|
+
zoneStatus();loadPersist();loadCertInfo();loadJobs();setInterval(loadJobs,5000);
|
|
1568
|
+
</script></body></html>"""
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
|
|
1572
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
@app.route("/license")
|
|
1576
|
+
def license_page():
|
|
1577
|
+
p = os.path.join(SCRIPT_DIR, "LICENSE")
|
|
1578
|
+
if os.path.exists(p):
|
|
1579
|
+
with open(p, encoding="utf-8") as f:
|
|
1580
|
+
body = f.read()
|
|
1581
|
+
else:
|
|
1582
|
+
body = ("OpenScrub is licensed under the Apache License 2.0.\n"
|
|
1583
|
+
"See https://www.apache.org/licenses/LICENSE-2.0")
|
|
1584
|
+
from flask import Response
|
|
1585
|
+
return Response(body, mimetype="text/plain")
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
@app.route("/logo.png")
|
|
1589
|
+
def logo():
|
|
1590
|
+
return send_file(os.path.join(ASSET_DIR, "logo.png"),
|
|
1591
|
+
mimetype="image/png", max_age=86400)
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
@app.route("/logo_dark.png")
|
|
1595
|
+
def logo_dark():
|
|
1596
|
+
return send_file(os.path.join(ASSET_DIR, "logo_dark.png"),
|
|
1597
|
+
mimetype="image/png", max_age=86400)
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
@app.route("/favicon.ico")
|
|
1601
|
+
def favicon():
|
|
1602
|
+
return send_file(os.path.join(ASSET_DIR, "openscrub.ico"),
|
|
1603
|
+
mimetype="image/x-icon", max_age=86400)
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
@app.route("/")
|
|
1607
|
+
def index():
|
|
1608
|
+
return PAGE
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
@app.route("/zones")
|
|
1612
|
+
def zones_page():
|
|
1613
|
+
return zones_ui.ZONES_PAGE
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
@app.route("/api/certinfo")
|
|
1617
|
+
def certinfo():
|
|
1618
|
+
cert_path, _, mode = active_cert_pair()
|
|
1619
|
+
from cryptography import x509
|
|
1620
|
+
with open(cert_path, "rb") as f:
|
|
1621
|
+
c = x509.load_pem_x509_certificate(f.read())
|
|
1622
|
+
return jsonify({
|
|
1623
|
+
"mode": mode,
|
|
1624
|
+
"subject": c.subject.rfc4514_string(),
|
|
1625
|
+
"issuer": c.issuer.rfc4514_string(),
|
|
1626
|
+
"expires": c.not_valid_after_utc.strftime("%Y-%m-%d"),
|
|
1627
|
+
})
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
@app.route("/api/certs", methods=["POST", "DELETE"])
|
|
1631
|
+
def upload_certs():
|
|
1632
|
+
if request.method == "DELETE":
|
|
1633
|
+
for p in (CUSTOM_CERT, CUSTOM_KEY):
|
|
1634
|
+
if os.path.exists(p):
|
|
1635
|
+
os.remove(p)
|
|
1636
|
+
return jsonify({"ok": True,
|
|
1637
|
+
"note": "custom certificate removed — restart the "
|
|
1638
|
+
"server to return to the self-signed one"})
|
|
1639
|
+
cert_f, key_f = request.files.get("cert"), request.files.get("key")
|
|
1640
|
+
if not cert_f or not key_f:
|
|
1641
|
+
abort(400, "both certificate and key PEM files are required")
|
|
1642
|
+
cert_pem, key_pem = cert_f.read(), key_f.read()
|
|
1643
|
+
from cryptography import x509
|
|
1644
|
+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
1645
|
+
try:
|
|
1646
|
+
cert = x509.load_pem_x509_certificate(cert_pem)
|
|
1647
|
+
key = load_pem_private_key(key_pem, password=None)
|
|
1648
|
+
if key.public_key().public_numbers() != cert.public_key().public_numbers():
|
|
1649
|
+
abort(400, "private key does not match the certificate")
|
|
1650
|
+
except ValueError as e:
|
|
1651
|
+
abort(400, f"could not parse PEM files: {e}")
|
|
1652
|
+
os.makedirs(CERT_DIR, exist_ok=True)
|
|
1653
|
+
with open(CUSTOM_CERT, "wb") as f:
|
|
1654
|
+
f.write(cert_pem)
|
|
1655
|
+
with open(CUSTOM_KEY, "wb") as f:
|
|
1656
|
+
f.write(key_pem)
|
|
1657
|
+
try:
|
|
1658
|
+
os.chmod(CUSTOM_KEY, 0o600)
|
|
1659
|
+
except Exception:
|
|
1660
|
+
pass
|
|
1661
|
+
return jsonify({"ok": True,
|
|
1662
|
+
"note": "certificate installed — restart the server to apply"})
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
@app.route("/api/zones", methods=["GET", "POST"])
|
|
1666
|
+
def api_zones():
|
|
1667
|
+
if request.method == "POST":
|
|
1668
|
+
data = request.get_json(force=True) or {}
|
|
1669
|
+
clean = {}
|
|
1670
|
+
for cat, rects in data.items():
|
|
1671
|
+
rs = []
|
|
1672
|
+
for r in rects or []:
|
|
1673
|
+
if len(r) == 4:
|
|
1674
|
+
x1, y1, x2, y2 = [max(0.0, min(1.0, float(v))) for v in r]
|
|
1675
|
+
if x2 - x1 > 0.004 and y2 - y1 > 0.004:
|
|
1676
|
+
rs.append([round(x1, 4), round(y1, 4),
|
|
1677
|
+
round(x2, 4), round(y2, 4)])
|
|
1678
|
+
if rs:
|
|
1679
|
+
clean[cat] = rs
|
|
1680
|
+
with open(ZONES_PATH, "w", encoding="utf-8") as f:
|
|
1681
|
+
json.dump(clean, f, indent=1)
|
|
1682
|
+
return jsonify({"ok": True, "classes": len(clean)})
|
|
1683
|
+
if os.path.exists(ZONES_PATH):
|
|
1684
|
+
with open(ZONES_PATH, encoding="utf-8") as f:
|
|
1685
|
+
return jsonify(json.load(f))
|
|
1686
|
+
return jsonify({})
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def lan_ip():
|
|
1690
|
+
try:
|
|
1691
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
1692
|
+
s.connect(("8.8.8.8", 80))
|
|
1693
|
+
ip = s.getsockname()[0]
|
|
1694
|
+
s.close()
|
|
1695
|
+
return ip
|
|
1696
|
+
except Exception:
|
|
1697
|
+
return "127.0.0.1"
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def rehydrate_jobs():
|
|
1701
|
+
"""Re-register jobs found on disk after a restart. A job with a report
|
|
1702
|
+
is a reviewable job; one with an output file is a finished one — either
|
|
1703
|
+
way the person's work survives the server going down."""
|
|
1704
|
+
n = 0
|
|
1705
|
+
for jid in sorted(os.listdir(JOBS_DIR)):
|
|
1706
|
+
jdir = os.path.join(JOBS_DIR, jid)
|
|
1707
|
+
rep = os.path.join(jdir, "report.json")
|
|
1708
|
+
if not os.path.isdir(jdir) or not os.path.exists(rep):
|
|
1709
|
+
continue
|
|
1710
|
+
if jid in JOBS:
|
|
1711
|
+
continue
|
|
1712
|
+
try:
|
|
1713
|
+
with open(rep, encoding="utf-8") as f:
|
|
1714
|
+
prov = json.load(f).get("provenance", {})
|
|
1715
|
+
video = prov.get("input")
|
|
1716
|
+
if not video or not os.path.exists(video):
|
|
1717
|
+
continue
|
|
1718
|
+
out = os.path.join(jdir, "output.mp4")
|
|
1719
|
+
has_out = os.path.exists(out)
|
|
1720
|
+
job = {
|
|
1721
|
+
"id": jid, "dir": jdir, "video": video,
|
|
1722
|
+
"name": os.path.basename(prov.get("original_input")
|
|
1723
|
+
or video),
|
|
1724
|
+
"options": prov.get("settings", {}) or {},
|
|
1725
|
+
"phase": "done" if has_out else "review",
|
|
1726
|
+
"progress": 1.0 if has_out else 0.5,
|
|
1727
|
+
"log": ["(recovered after server restart)"],
|
|
1728
|
+
"error": None, "output": out if has_out else None,
|
|
1729
|
+
"created": os.path.getmtime(jdir),
|
|
1730
|
+
"cancel": threading.Event(), "stats": {},
|
|
1731
|
+
}
|
|
1732
|
+
with JOBS_LOCK:
|
|
1733
|
+
JOBS[jid] = job
|
|
1734
|
+
n += 1
|
|
1735
|
+
except Exception:
|
|
1736
|
+
continue
|
|
1737
|
+
if n:
|
|
1738
|
+
print(f" recovered {n} job(s) from {JOBS_DIR}")
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def retention_sweep(days):
|
|
1742
|
+
import shutil as _sh
|
|
1743
|
+
cutoff = time.time() - days * 86400
|
|
1744
|
+
for jid in os.listdir(JOBS_DIR):
|
|
1745
|
+
p = os.path.join(JOBS_DIR, jid)
|
|
1746
|
+
if os.path.isdir(p) and os.path.getmtime(p) < cutoff:
|
|
1747
|
+
_sh.rmtree(p, ignore_errors=True)
|
|
1748
|
+
with JOBS_LOCK:
|
|
1749
|
+
JOBS.pop(jid, None)
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def retention_loop(days):
|
|
1753
|
+
while True:
|
|
1754
|
+
retention_sweep(days)
|
|
1755
|
+
time.sleep(6 * 3600)
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def main():
|
|
1759
|
+
global TOKEN
|
|
1760
|
+
ap = argparse.ArgumentParser()
|
|
1761
|
+
ap.add_argument("--port", type=int, default=8384)
|
|
1762
|
+
ap.add_argument("--host", default="0.0.0.0")
|
|
1763
|
+
ap.add_argument("--token", help="require this access token on every "
|
|
1764
|
+
"request (default: open — anyone on the network can use "
|
|
1765
|
+
"the server)")
|
|
1766
|
+
ap.add_argument("--http", action="store_true",
|
|
1767
|
+
help="serve plain HTTP (not recommended: video uploads "
|
|
1768
|
+
"and previews contain PHI and would cross the "
|
|
1769
|
+
"network unencrypted)")
|
|
1770
|
+
ap.add_argument("--retain-days", type=int, default=7,
|
|
1771
|
+
help="auto-delete job folders (which contain PHI) after "
|
|
1772
|
+
"this many days (default 7; 0 = keep forever)")
|
|
1773
|
+
args = ap.parse_args()
|
|
1774
|
+
TOKEN = args.token or None
|
|
1775
|
+
os.makedirs(JOBS_DIR, exist_ok=True)
|
|
1776
|
+
rehydrate_jobs()
|
|
1777
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
1778
|
+
if args.retain_days > 0:
|
|
1779
|
+
threading.Thread(target=retention_loop, args=(args.retain_days,),
|
|
1780
|
+
daemon=True).start()
|
|
1781
|
+
print("=" * 60)
|
|
1782
|
+
ssl_ctx = None
|
|
1783
|
+
scheme = "http"
|
|
1784
|
+
if not args.http:
|
|
1785
|
+
try:
|
|
1786
|
+
import cryptography # noqa: F401 — needed for cert generation
|
|
1787
|
+
except ImportError:
|
|
1788
|
+
print("=" * 60)
|
|
1789
|
+
print("HTTPS needs the 'cryptography' package, which isn't")
|
|
1790
|
+
print("installed. Fix it with ONE of these:")
|
|
1791
|
+
print()
|
|
1792
|
+
print(" pip install cryptography (recommended)")
|
|
1793
|
+
print(" python installer.py --yes (installs everything)")
|
|
1794
|
+
print()
|
|
1795
|
+
print("or start with --http to serve unencrypted")
|
|
1796
|
+
print("(not recommended: PHI would cross the network in plain text).")
|
|
1797
|
+
print("=" * 60)
|
|
1798
|
+
if os.name == "nt":
|
|
1799
|
+
input("Press Enter to close.")
|
|
1800
|
+
sys.exit(1)
|
|
1801
|
+
cert, key, mode = active_cert_pair()
|
|
1802
|
+
ssl_ctx = (cert, key)
|
|
1803
|
+
scheme = "https"
|
|
1804
|
+
print("openscrub web — open from any device on your LAN:")
|
|
1805
|
+
if TOKEN:
|
|
1806
|
+
print(f" {scheme}://{lan_ip()}:{args.port}/?token={TOKEN}")
|
|
1807
|
+
else:
|
|
1808
|
+
print(f" {scheme}://{lan_ip()}:{args.port}/")
|
|
1809
|
+
print(" (open access: no token — everyone on this network can use it;")
|
|
1810
|
+
print(" add --token <secret> if you ever want a gate back)")
|
|
1811
|
+
print("Jobs folder (contains PHI):", JOBS_DIR,
|
|
1812
|
+
f"— auto-deleted after {args.retain_days} day(s)" if args.retain_days else "— kept forever")
|
|
1813
|
+
print("Do NOT expose this port to the internet.")
|
|
1814
|
+
print("=" * 60)
|
|
1815
|
+
if ssl_ctx:
|
|
1816
|
+
if mode == "self-signed":
|
|
1817
|
+
print(" HTTPS with a self-signed certificate: your browser will")
|
|
1818
|
+
print(" warn once — choose Advanced -> Proceed, or install your")
|
|
1819
|
+
print(" own certificate at the bottom of the main page.")
|
|
1820
|
+
else:
|
|
1821
|
+
print(" HTTPS with your installed custom certificate.")
|
|
1822
|
+
else:
|
|
1823
|
+
print(" WARNING: plain HTTP — PHI crosses the network unencrypted.")
|
|
1824
|
+
_serve(args.host, args.port, ssl_ctx)
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def _serve(host, port, ssl_ctx):
|
|
1828
|
+
"""Run under cheroot (a production WSGI server that supports TLS and
|
|
1829
|
+
Windows) when available; otherwise fall back to Flask's built-in dev
|
|
1830
|
+
server, which works fine for a small LAN but prints a warning."""
|
|
1831
|
+
try:
|
|
1832
|
+
from cheroot.wsgi import Server
|
|
1833
|
+
except ImportError:
|
|
1834
|
+
print(" (running on Flask's built-in server — `pip install cheroot`")
|
|
1835
|
+
print(" to switch to a production server and silence its warning)")
|
|
1836
|
+
app.run(host=host, port=port, threaded=True, ssl_context=ssl_ctx)
|
|
1837
|
+
return
|
|
1838
|
+
server = Server((host, port), app, server_name="openscrub",
|
|
1839
|
+
numthreads=16)
|
|
1840
|
+
if ssl_ctx:
|
|
1841
|
+
from cheroot.ssl.builtin import BuiltinSSLAdapter
|
|
1842
|
+
server.ssl_adapter = BuiltinSSLAdapter(ssl_ctx[0], ssl_ctx[1])
|
|
1843
|
+
try:
|
|
1844
|
+
server.start()
|
|
1845
|
+
except KeyboardInterrupt:
|
|
1846
|
+
server.stop()
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
if __name__ == "__main__":
|
|
1850
|
+
try:
|
|
1851
|
+
main()
|
|
1852
|
+
except SystemExit:
|
|
1853
|
+
raise
|
|
1854
|
+
except BaseException:
|
|
1855
|
+
import traceback
|
|
1856
|
+
traceback.print_exc()
|
|
1857
|
+
if os.name == "nt":
|
|
1858
|
+
input("\nStartup failed — see the error above. Press Enter to close.")
|
|
1859
|
+
raise
|