fix: clip pad ±3s + OSD-clock alignment at cut points (single-frame OCR)
ci / smoke (push) Canceled after 0s
ci / smoke (push) Canceled after 0s
This commit is contained in:
1 parent
110d557dbf
commit
14f5ed4aac
7 files changed
+314
-12
No files matched your search
@@ -31,6 +31,8 @@ RTSP_URL=rtsp://user:pass@192.168.192.209:8554/camera_stream_640
|
|||||||
# --- Batch clip download (motionEye; dashboard /api/batch-clip only) ---
|
# --- Batch clip download (motionEye; dashboard /api/batch-clip only) ---
|
||||||
MOTIONEYE_URL=http://192.168.192.253:8765 # motionEye base URL for batch clip download; empty disables
|
MOTIONEYE_URL=http://192.168.192.253:8765 # motionEye base URL for batch clip download; empty disables
|
||||||
MOTIONEYE_CAMERA_ID=2 # motionEye camera id (1 = default camera)
|
MOTIONEYE_CAMERA_ID=2 # motionEye camera id (1 = default camera)
|
||||||
|
MOTIONEYE_CLIP_PAD=3 # seconds trimmed before/after the batch window (float)
|
||||||
|
MOTIONEYE_OSD_ALIGN=1 # 1 = OCR-correct OSD clock drift at cut points, 0 = filename-based offsets only
|
||||||
|
|
||||||
# --- Deprecated: model pipeline mode now lives in config.yaml models.active_mode.
|
# --- Deprecated: model pipeline mode now lives in config.yaml models.active_mode.
|
||||||
# Dashboard switches persist there (atomic write, comments preserved) and apply
|
# Dashboard switches persist there (atomic write, comments preserved) and apply
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ version tags are cut in this repo — POC stage, `main` is the release line).
|
|||||||
- History `Klip` button → office-only `GET /api/batch-clip/<date>/<batch_number>`
|
- History `Klip` button → office-only `GET /api/batch-clip/<date>/<batch_number>`
|
||||||
trims+concatenates motionEye recordings to the batch window (`src/clip.py`),
|
trims+concatenates motionEye recordings to the batch window (`src/clip.py`),
|
||||||
download `b<n>-<date>-<HH-MM-SS>.mp4`.
|
download `b<n>-<date>-<HH-MM-SS>.mp4`.
|
||||||
|
- `MOTIONEYE_CLIP_PAD` (default ±3 s window padding) + OSD-clock alignment via
|
||||||
|
single-frame OCR at each cut point (`MOTIONEYE_OSD_ALIGN`, default on).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- OCR engine default **`rapid`** (was `tesseract`) across `config.yaml`,
|
- OCR engine default **`rapid`** (was `tesseract`) across `config.yaml`,
|
||||||
|
|||||||
+19
-1
@@ -1575,10 +1575,25 @@ def api_batch_clip(date, batch_number):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
camera_id = 2
|
camera_id = 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
pad_seconds = float((os.getenv("MOTIONEYE_CLIP_PAD") or "").strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pad_seconds = 3.0
|
||||||
|
_align_raw = os.getenv("MOTIONEYE_OSD_ALIGN")
|
||||||
|
align_osd = (
|
||||||
|
True if _align_raw is None
|
||||||
|
else _align_raw.strip().lower() not in ("0", "false", "no", "")
|
||||||
|
)
|
||||||
|
|
||||||
if not str(batch_number).isdigit():
|
if not str(batch_number).isdigit():
|
||||||
return jsonify({"success": False, "error": f"batch_number tidak valid: {batch_number}"}), 400
|
return jsonify({"success": False, "error": f"batch_number tidak valid: {batch_number}"}), 400
|
||||||
batch_number = int(batch_number)
|
batch_number = int(batch_number)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[CLIP] batch={date}/{batch_number} pad={pad_seconds} align={align_osd}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -1614,7 +1629,10 @@ def api_batch_clip(date, batch_number):
|
|||||||
return jsonify({"success": False, "error": f"Modul klip belum tersedia: {e}"}), 503
|
return jsonify({"success": False, "error": f"Modul klip belum tersedia: {e}"}), 503
|
||||||
|
|
||||||
try:
|
try:
|
||||||
clip_batch(motion_url, camera_id, str(start_iso), str(end_iso), tmp_path)
|
clip_batch(
|
||||||
|
motion_url, camera_id, str(start_iso), str(end_iso), tmp_path,
|
||||||
|
pad_seconds=pad_seconds, align_osd=align_osd,
|
||||||
|
)
|
||||||
except ClipError as ce:
|
except ClipError as ce:
|
||||||
_unlink_quiet(tmp_path)
|
_unlink_quiet(tmp_path)
|
||||||
msg = str(ce)
|
msg = str(ce)
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ when set.
|
|||||||
| `RTSP_URL` | — | Camera stream URL (env-only, never in YAML) |
|
| `RTSP_URL` | — | Camera stream URL (env-only, never in YAML) |
|
||||||
| `MOTIONEYE_URL` | `""` | motionEye base URL for batch clip download (empty disables the endpoint) |
|
| `MOTIONEYE_URL` | `""` | motionEye base URL for batch clip download (empty disables the endpoint) |
|
||||||
| `MOTIONEYE_CAMERA_ID` | `2` | motionEye camera id used for batch clip download |
|
| `MOTIONEYE_CAMERA_ID` | `2` | motionEye camera id used for batch clip download |
|
||||||
|
| `MOTIONEYE_CLIP_PAD` | `3` | Seconds padded before/after the batch window when trimming the clip (float) |
|
||||||
|
| `MOTIONEYE_OSD_ALIGN` | `1` | `1` = OCR-correct burned-in OSD clock drift at cut points, `0` = filename-based offsets only |
|
||||||
| `MODEL_PATH` | — (deprecated) | Single-file v4 override, folded into `models.paths` |
|
| `MODEL_PATH` | — (deprecated) | Single-file v4 override, folded into `models.paths` |
|
||||||
| `MODEL_MODE` | — (deprecated) | One-run override of `models.active_mode` (warned) |
|
| `MODEL_MODE` | — (deprecated) | One-run override of `models.active_mode` (warned) |
|
||||||
| `BATCH_MERGE_THRESHOLD_SECONDS` | `300` | Merge window for adjacent batches |
|
| `BATCH_MERGE_THRESHOLD_SECONDS` | `300` | Merge window for adjacent batches |
|
||||||
|
|||||||
@@ -103,6 +103,19 @@ under the hood) and returns the file as an attachment.
|
|||||||
- URL keys (`.env`): `MOTIONEYE_URL` (base URL; empty → 404
|
- URL keys (`.env`): `MOTIONEYE_URL` (base URL; empty → 404
|
||||||
`motionEye belum dikonfigurasi (MOTIONEYE_URL)`), `MOTIONEYE_CAMERA_ID`
|
`motionEye belum dikonfigurasi (MOTIONEYE_URL)`), `MOTIONEYE_CAMERA_ID`
|
||||||
(default `2`).
|
(default `2`).
|
||||||
|
- `MOTIONEYE_CLIP_PAD` — float seconds padded before/after the batch window
|
||||||
|
at both cut points (default `3` → ±3 s; empty/invalid falls back to `3.0`).
|
||||||
|
- `MOTIONEYE_OSD_ALIGN` — OSD-clock alignment, default **on** (unset = on;
|
||||||
|
`0` / `false` / `no` / empty = off). Why: the camera's burned-in OSD clock
|
||||||
|
and the recording clock drift — up to ~+22 s inside a long clip — because
|
||||||
|
of dropped frames, so filename-based offsets cut at the wrong media time.
|
||||||
|
When on, `clip_batch` OCRs **1 frame** at each cut point (~2–6 s extra per
|
||||||
|
clip, result cached per clip+second) and corrects the cut times. `0` →
|
||||||
|
filename-based offsets only. If OCR fails, it silently falls back to
|
||||||
|
filename-based offsets; both cases log one
|
||||||
|
`[CLIP] batch=... pad=... align=...` line at request start (plus any
|
||||||
|
`[CLIP]` alignment detail lines) — check with `journalctl -u
|
||||||
|
karung-counter-dashboard`.
|
||||||
- motionEye HTTP API used: `GET /movie/<id>/list/` (recording segments in the
|
- motionEye HTTP API used: `GET /movie/<id>/list/` (recording segments in the
|
||||||
window) and `GET /movie/<id>/download<path>` (segment bytes).
|
window) and `GET /movie/<id>/download<path>` (segment bytes).
|
||||||
- Requires `ffmpeg` / `ffprobe` on PATH — both already installed on the Jetson.
|
- Requires `ffmpeg` / `ffprobe` on PATH — both already installed on the Jetson.
|
||||||
|
|||||||
+121
-9
@@ -12,6 +12,12 @@ import urllib.request
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
_PATH_RE = re.compile(r"(\d{4}-\d{2}-\d{2})/(\d{2})-(\d{2})-(\d{2})\.mp4$")
|
_PATH_RE = re.compile(r"(\d{4}-\d{2}-\d{2})/(\d{2})-(\d{2})-(\d{2})\.mp4$")
|
||||||
|
# OSD clock burned into pixels: "2026-09-26 08:52:47" (OCR sometimes glues date+time)
|
||||||
|
_OSD_RE = re.compile(r"(\d{4}-\d{2}-\d{2})\s*(\d{1,2}:\d{2}:\d{2})")
|
||||||
|
_OSD_CACHE = {} # (url, round(media_offset s)) -> datetime
|
||||||
|
_OCR = None # lazy rapidocr singleton
|
||||||
|
_OCR_FAILED = False
|
||||||
|
_FRAME_W, _FRAME_H = 960, 540
|
||||||
_CANDIDATE_BACK = timedelta(minutes=90)
|
_CANDIDATE_BACK = timedelta(minutes=90)
|
||||||
_OVERLAP_MIN = 0.5
|
_OVERLAP_MIN = 0.5
|
||||||
_NO_CLIP_MSG = (
|
_NO_CLIP_MSG = (
|
||||||
@@ -130,6 +136,91 @@ def _fmt(seconds: float) -> str:
|
|||||||
return "{:.3f}".format(seconds)
|
return "{:.3f}".format(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def osd_time_at(url: str, media_offset: float, timeout: int = 45):
|
||||||
|
"""Extract ONE frame at media_offset, OCR the burned-in OSD clock.
|
||||||
|
|
||||||
|
Returns naive datetime or None (never raises). Lazy rapidocr singleton.
|
||||||
|
"""
|
||||||
|
global _OCR, _OCR_FAILED
|
||||||
|
if _OCR_FAILED:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg", "-v", "error",
|
||||||
|
"-ss", _fmt(media_offset),
|
||||||
|
"-i", url,
|
||||||
|
"-frames:v", "1",
|
||||||
|
"-s", "{}x{}".format(_FRAME_W, _FRAME_H),
|
||||||
|
"-f", "rawvideo", "-pix_fmt", "bgr24", "-",
|
||||||
|
],
|
||||||
|
capture_output=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
need = _FRAME_W * _FRAME_H * 3
|
||||||
|
if proc.returncode != 0 or len(proc.stdout) < need:
|
||||||
|
return None
|
||||||
|
import numpy as np # CI-safe: numpy is a test dep
|
||||||
|
|
||||||
|
frame = np.frombuffer(proc.stdout[:need], np.uint8).reshape(
|
||||||
|
_FRAME_H, _FRAME_W, 3
|
||||||
|
)
|
||||||
|
if _OCR is None:
|
||||||
|
try:
|
||||||
|
from rapidocr_onnxruntime import RapidOCR # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
_OCR_FAILED = True
|
||||||
|
print("[CLIP] rapidocr_onnxruntime missing — OSD align off "
|
||||||
|
"(filename-based offsets).", flush=True)
|
||||||
|
return None
|
||||||
|
_OCR = RapidOCR()
|
||||||
|
result, _ = _OCR(frame)
|
||||||
|
text = " ".join(str(item[1]) for item in (result or []))
|
||||||
|
match = _OSD_RE.search(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return datetime.strptime(
|
||||||
|
"{} {}".format(match.group(1), match.group(2)), "%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _align_offset(url: str, cur: float, desired: datetime,
|
||||||
|
duration: float, label: str) -> float:
|
||||||
|
"""Correct one cut offset so the OSD at that frame reads `desired`.
|
||||||
|
|
||||||
|
Max 2 probes (frame+OCR each); OCR failure keeps the filename-based
|
||||||
|
offset. Media time drifts vs wall clock inside a clip (dropped frames),
|
||||||
|
so the correction is measured AT the cut point, not assumed constant.
|
||||||
|
"""
|
||||||
|
original = cur
|
||||||
|
corrected = False
|
||||||
|
last_osd = None
|
||||||
|
for _ in range(2):
|
||||||
|
key = (url, int(round(cur)))
|
||||||
|
osd = _OSD_CACHE.get(key)
|
||||||
|
if osd is None:
|
||||||
|
osd = osd_time_at(url, cur)
|
||||||
|
if osd is not None:
|
||||||
|
_OSD_CACHE[key] = osd
|
||||||
|
if osd is None:
|
||||||
|
break
|
||||||
|
last_osd = osd
|
||||||
|
delta = (osd - desired).total_seconds()
|
||||||
|
if abs(delta) <= 1.0:
|
||||||
|
break
|
||||||
|
if corrected and osd.date() != desired.date():
|
||||||
|
break # suspicious day jump — keep the one correction already made
|
||||||
|
new = min(max(cur - delta, 0.0), duration)
|
||||||
|
if new == cur:
|
||||||
|
break
|
||||||
|
cur = new
|
||||||
|
corrected = True
|
||||||
|
print("[CLIP] align {}: {:.1f} -> {:.1f} (osd={}, desired={})".format(
|
||||||
|
label, original, cur, last_osd, desired), flush=True)
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
def _piece_argv(clip_url: str, off0: float, off1: float, piece: str) -> list:
|
def _piece_argv(clip_url: str, off0: float, off1: float, piece: str) -> list:
|
||||||
return [
|
return [
|
||||||
"ffmpeg", "-y",
|
"ffmpeg", "-y",
|
||||||
@@ -166,11 +257,23 @@ def _run_ffmpeg(args: list, timeout: int):
|
|||||||
|
|
||||||
|
|
||||||
def clip_batch(motion_url: str, camera_id: int, start_iso: str, end_iso: str,
|
def clip_batch(motion_url: str, camera_id: int, start_iso: str, end_iso: str,
|
||||||
out_path: str, timeout: int = 180) -> None:
|
out_path: str, timeout: int = 180,
|
||||||
"""List → cover → trim pieces → concat into out_path. Raises ClipError only."""
|
pad_seconds: float = 3.0, align_osd: bool = True) -> None:
|
||||||
|
"""List → cover → trim pieces → concat into out_path. Raises ClipError only.
|
||||||
|
|
||||||
|
pad_seconds widens the target window (trim a little before/after the
|
||||||
|
batch). align_osd OCR-corrects the burned-in OSD clock at the cut
|
||||||
|
points (media-vs-wall drift; ~1-2 s per probe, cached).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
start = datetime.fromisoformat(start_iso)
|
start = datetime.fromisoformat(start_iso)
|
||||||
end = datetime.fromisoformat(end_iso)
|
end = datetime.fromisoformat(end_iso)
|
||||||
|
try:
|
||||||
|
pad = float(pad_seconds)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pad = 3.0
|
||||||
|
window_start = start - timedelta(seconds=pad)
|
||||||
|
window_end = end + timedelta(seconds=pad)
|
||||||
out_path = os.path.abspath(out_path)
|
out_path = os.path.abspath(out_path)
|
||||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||||
tmpdir = None
|
tmpdir = None
|
||||||
@@ -180,20 +283,29 @@ def clip_batch(motion_url: str, camera_id: int, start_iso: str, end_iso: str,
|
|||||||
def probe(path):
|
def probe(path):
|
||||||
return probe_duration(_download_url(motion_url, camera_id, path))
|
return probe_duration(_download_url(motion_url, camera_id, path))
|
||||||
|
|
||||||
clips = find_clips(movies, start, end, probe)
|
clips = find_clips(movies, window_start, window_end, probe)
|
||||||
if not clips:
|
if not clips:
|
||||||
raise ClipError(_NO_CLIP_MSG)
|
raise ClipError(_NO_CLIP_MSG)
|
||||||
tmpdir = tempfile.mkdtemp(dir=os.path.dirname(out_path))
|
tmpdir = tempfile.mkdtemp(dir=os.path.dirname(out_path))
|
||||||
pieces = []
|
pieces = []
|
||||||
for index, clip in enumerate(clips):
|
for index, clip in enumerate(clips):
|
||||||
piece = os.path.join(tmpdir, "piece_{:03d}.mp4".format(index))
|
piece = os.path.join(tmpdir, "piece_{:03d}.mp4".format(index))
|
||||||
|
clip_url = _download_url(motion_url, camera_id, clip["path"])
|
||||||
|
off0, off1 = clip["off0"], clip["off1"]
|
||||||
|
if align_osd:
|
||||||
|
orig0, orig1 = off0, off1
|
||||||
|
if index == 0:
|
||||||
|
off0 = _align_offset(clip_url, off0, window_start,
|
||||||
|
clip["duration"], "start")
|
||||||
|
if index == len(clips) - 1:
|
||||||
|
off1 = _align_offset(clip_url, off1, window_end,
|
||||||
|
clip["duration"], "end")
|
||||||
|
if off1 - off0 < 1.0:
|
||||||
|
off0, off1 = orig0, orig1
|
||||||
|
print("[CLIP] align rejected (span < 1s); "
|
||||||
|
"filename-based offsets kept.", flush=True)
|
||||||
_run_ffmpeg(
|
_run_ffmpeg(
|
||||||
_piece_argv(
|
_piece_argv(clip_url, off0, off1, piece),
|
||||||
_download_url(motion_url, camera_id, clip["path"]),
|
|
||||||
clip["off0"],
|
|
||||||
clip["off1"],
|
|
||||||
piece,
|
|
||||||
),
|
|
||||||
timeout,
|
timeout,
|
||||||
)
|
)
|
||||||
pieces.append(piece)
|
pieces.append(piece)
|
||||||
|
|||||||
+155
-2
@@ -201,7 +201,8 @@ def test_clip_batch_single_piece_argv(monkeypatch, tmp_path):
|
|||||||
calls, captured = [], {}
|
calls, captured = [], {}
|
||||||
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||||
out = tmp_path / "sub" / "batch.mp4"
|
out = tmp_path / "sub" / "batch.mp4"
|
||||||
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50", str(out))
|
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50", str(out),
|
||||||
|
pad_seconds=0, align_osd=False)
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
args, timeout = calls[0]
|
args, timeout = calls[0]
|
||||||
assert timeout == 180
|
assert timeout == 180
|
||||||
@@ -229,7 +230,8 @@ def test_clip_batch_two_pieces_trim_and_concat(monkeypatch, tmp_path):
|
|||||||
calls, captured = [], {}
|
calls, captured = [], {}
|
||||||
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||||
out = tmp_path / "batch.mp4"
|
out = tmp_path / "batch.mp4"
|
||||||
clip.clip_batch(BASE, 1, START_ISO, END_ISO, str(out), timeout=60)
|
clip.clip_batch(BASE, 1, START_ISO, END_ISO, str(out), timeout=60,
|
||||||
|
pad_seconds=0, align_osd=False)
|
||||||
assert len(calls) == 3
|
assert len(calls) == 3
|
||||||
(piece_a, timeout_a), (piece_b, _), (concat_args, timeout_b) = calls
|
(piece_a, timeout_a), (piece_b, _), (concat_args, timeout_b) = calls
|
||||||
assert timeout_a == timeout_b == 60
|
assert timeout_a == timeout_b == 60
|
||||||
@@ -273,3 +275,154 @@ def test_clip_batch_bad_iso_raises_cliperror(tmp_path):
|
|||||||
with pytest.raises(ClipError):
|
with pytest.raises(ClipError):
|
||||||
clip.clip_batch(BASE, 1, "bukan-tanggal", "2026-09-26T10:00:00",
|
clip.clip_batch(BASE, 1, "bukan-tanggal", "2026-09-26T10:00:00",
|
||||||
str(tmp_path / "out.mp4"))
|
str(tmp_path / "out.mp4"))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- pad + OSD alignment
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_osd_cache():
|
||||||
|
clip._OSD_CACHE.clear()
|
||||||
|
yield
|
||||||
|
clip._OSD_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _one_movie(monkeypatch, start_iso, duration):
|
||||||
|
movies = [{"path": CLIP_A, "start": dt("2026-09-26 09:00:00")}]
|
||||||
|
patch_batch_env(monkeypatch, movies, duration=duration)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pad_extends_selection_window(monkeypatch, tmp_path):
|
||||||
|
# clip 09:00:00 + 5 s ends exactly at batch start → pad 0 finds nothing
|
||||||
|
movies = [{"path": CLIP_A, "start": dt("2026-09-26 09:00:00")}]
|
||||||
|
patch_batch_env(monkeypatch, movies, duration=5.0)
|
||||||
|
calls, captured = [], {}
|
||||||
|
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||||
|
with pytest.raises(ClipError, match="Tidak ada rekaman"):
|
||||||
|
clip.clip_batch(BASE, 1, "2026-09-26T09:00:05", "2026-09-26T09:00:10",
|
||||||
|
str(tmp_path / "a.mp4"), pad_seconds=0, align_osd=False)
|
||||||
|
clip.clip_batch(BASE, 1, "2026-09-26T09:00:05", "2026-09-26T09:00:10",
|
||||||
|
str(tmp_path / "b.mp4"), pad_seconds=3, align_osd=False)
|
||||||
|
args = calls[0][0]
|
||||||
|
assert args[args.index("-ss") + 1] == "2.000" # window_start 09:00:02
|
||||||
|
assert args[args.index("-to") + 1] == "5.000" # clip ends 09:00:05
|
||||||
|
|
||||||
|
|
||||||
|
def test_osd_alignment_corrects_start_offset(monkeypatch, tmp_path):
|
||||||
|
_one_movie(monkeypatch, None, 600.0)
|
||||||
|
calls, captured = [], {}
|
||||||
|
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||||
|
# batch 09:01:10→09:02:00 (pad 0): filename off0=70, off1=120.
|
||||||
|
# OSD at media 70 reads desired+14 → shift to 56; at 56 reads desired.
|
||||||
|
reads = {
|
||||||
|
70: dt("2026-09-26 09:01:24"),
|
||||||
|
56: dt("2026-09-26 09:01:10"),
|
||||||
|
120: dt("2026-09-26 09:02:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_osd(url, media_offset, timeout=45):
|
||||||
|
return reads.get(int(round(media_offset)))
|
||||||
|
|
||||||
|
monkeypatch.setattr(clip, "osd_time_at", fake_osd)
|
||||||
|
out = tmp_path / "out.mp4"
|
||||||
|
clip.clip_batch(BASE, 1, "2026-09-26T09:01:10", "2026-09-26T09:02:00",
|
||||||
|
str(out), pad_seconds=0, align_osd=True)
|
||||||
|
args = calls[0][0]
|
||||||
|
assert args[args.index("-ss") + 1] == "56.000"
|
||||||
|
assert args[args.index("-to") + 1] == "120.000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_disabled_never_probes_osd(monkeypatch, tmp_path):
|
||||||
|
_one_movie(monkeypatch, None, 600.0)
|
||||||
|
calls, captured = [], {}
|
||||||
|
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise AssertionError("osd_time_at must not be called")
|
||||||
|
|
||||||
|
monkeypatch.setattr(clip, "osd_time_at", boom)
|
||||||
|
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50",
|
||||||
|
str(tmp_path / "o.mp4"), pad_seconds=0, align_osd=False)
|
||||||
|
args = calls[0][0]
|
||||||
|
assert args[args.index("-ss") + 1] == "10.000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_osd_failure_keeps_filename_offsets(monkeypatch, tmp_path):
|
||||||
|
_one_movie(monkeypatch, None, 600.0)
|
||||||
|
calls, captured = [], {}
|
||||||
|
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||||
|
monkeypatch.setattr(clip, "osd_time_at", lambda *a, **k: None)
|
||||||
|
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50",
|
||||||
|
str(tmp_path / "o.mp4"), pad_seconds=0, align_osd=True)
|
||||||
|
args = calls[0][0]
|
||||||
|
assert args[args.index("-ss") + 1] == "10.000"
|
||||||
|
assert args[args.index("-to") + 1] == "50.000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_osd_alignment_probe_iteration_cap(monkeypatch, tmp_path):
|
||||||
|
_one_movie(monkeypatch, None, 600.0)
|
||||||
|
calls, captured = [], {}
|
||||||
|
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||||
|
probes = []
|
||||||
|
|
||||||
|
def always_off(url, media_offset, timeout=45):
|
||||||
|
probes.append(int(round(media_offset)))
|
||||||
|
return dt("2026-09-26 09:01:24") # always +14 vs desired 09:01:10
|
||||||
|
|
||||||
|
monkeypatch.setattr(clip, "osd_time_at", always_off)
|
||||||
|
clip.clip_batch(BASE, 1, "2026-09-26T09:01:10", "2026-09-26T09:02:00",
|
||||||
|
str(tmp_path / "o.mp4"), pad_seconds=0, align_osd=True)
|
||||||
|
# start align: at most 2 probes; end align: 2 more (same cap)
|
||||||
|
assert len(probes) <= 4
|
||||||
|
start_probes = [p for p in probes if p in (70, 56, 42)]
|
||||||
|
assert len(start_probes) <= 2
|
||||||
|
args = calls[0][0]
|
||||||
|
# after two corrections 70 -> 56 -> 42
|
||||||
|
assert args[args.index("-ss") + 1] == "42.000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_osd_date_mismatch_corrects_once_then_stops(monkeypatch, tmp_path):
|
||||||
|
# batch spans New Year's Eve; OSD read lands on the wrong calendar day
|
||||||
|
movies = [{"path": "/2026-12-31/23-00-00.mp4",
|
||||||
|
"start": dt("2026-12-31 23:00:00")}]
|
||||||
|
monkeypatch.setattr(clip, "list_movies", lambda *a, **k: list(movies))
|
||||||
|
monkeypatch.setattr(clip, "probe_duration", lambda url, timeout=30: 7200.0)
|
||||||
|
calls, captured = [], {}
|
||||||
|
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||||
|
probes = []
|
||||||
|
|
||||||
|
def wrong_day(url, media_offset, timeout=45):
|
||||||
|
off = int(round(media_offset))
|
||||||
|
probes.append(off)
|
||||||
|
if off == 1924:
|
||||||
|
return dt("2027-01-01 00:32:04") # +1 h vs desired Dec31 23:32:04
|
||||||
|
if off == 0:
|
||||||
|
return dt("2027-01-01 00:31:50") # still Jan 1 after correction
|
||||||
|
return None # end-offset probes do nothing
|
||||||
|
|
||||||
|
monkeypatch.setattr(clip, "osd_time_at", wrong_day)
|
||||||
|
clip.clip_batch(BASE, 1, "2026-12-31T23:32:04", "2026-12-31T23:35:00",
|
||||||
|
str(tmp_path / "o.mp4"), pad_seconds=0, align_osd=True)
|
||||||
|
# start align: probe at 1924 → corrects (clamped to 0), probe at 0 has
|
||||||
|
# wrong date again → rejected by date check (2 probes max).
|
||||||
|
assert probes[:2] == [1924, 0]
|
||||||
|
args = calls[0][0]
|
||||||
|
assert args[args.index("-ss") + 1] == "0.000"
|
||||||
|
assert args[args.index("-to") + 1] == "2100.000" # end offset untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_alignment_span_collapse_falls_back(monkeypatch, tmp_path):
|
||||||
|
_one_movie(monkeypatch, None, 600.0)
|
||||||
|
calls, captured = [], {}
|
||||||
|
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||||
|
|
||||||
|
def collapse(url, media_offset, timeout=45):
|
||||||
|
# readings that would push off1 before off0
|
||||||
|
return dt("2026-09-26 09:02:59") # end desired 09:00:50 → huge shift
|
||||||
|
|
||||||
|
monkeypatch.setattr(clip, "osd_time_at", collapse)
|
||||||
|
clip.clip_batch(BASE, 1, "2026-09-26T09:00:10", "2026-09-26T09:00:50",
|
||||||
|
str(tmp_path / "o.mp4"), pad_seconds=0, align_osd=True)
|
||||||
|
args = calls[0][0]
|
||||||
|
# fallback keeps filename-based offsets
|
||||||
|
assert args[args.index("-ss") + 1] == "10.000"
|
||||||
|
assert args[args.index("-to") + 1] == "50.000"
|
||||||
Reference in new issue
Block a user