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) ---
|
||||
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_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.
|
||||
# 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>`
|
||||
trims+concatenates motionEye recordings to the batch window (`src/clip.py`),
|
||||
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
|
||||
- OCR engine default **`rapid`** (was `tesseract`) across `config.yaml`,
|
||||
|
||||
+19
-1
@@ -1575,10 +1575,25 @@ def api_batch_clip(date, batch_number):
|
||||
except ValueError:
|
||||
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():
|
||||
return jsonify({"success": False, "error": f"batch_number tidak valid: {batch_number}"}), 400
|
||||
batch_number = int(batch_number)
|
||||
|
||||
print(
|
||||
f"[CLIP] batch={date}/{batch_number} pad={pad_seconds} align={align_osd}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
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
|
||||
|
||||
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:
|
||||
_unlink_quiet(tmp_path)
|
||||
msg = str(ce)
|
||||
|
||||
@@ -80,6 +80,8 @@ when set.
|
||||
| `RTSP_URL` | — | Camera stream URL (env-only, never in YAML) |
|
||||
| `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_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_MODE` | — (deprecated) | One-run override of `models.active_mode` (warned) |
|
||||
| `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
|
||||
`motionEye belum dikonfigurasi (MOTIONEYE_URL)`), `MOTIONEYE_CAMERA_ID`
|
||||
(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
|
||||
window) and `GET /movie/<id>/download<path>` (segment bytes).
|
||||
- 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
|
||||
|
||||
_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)
|
||||
_OVERLAP_MIN = 0.5
|
||||
_NO_CLIP_MSG = (
|
||||
@@ -130,6 +136,91 @@ def _fmt(seconds: float) -> str:
|
||||
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:
|
||||
return [
|
||||
"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,
|
||||
out_path: str, timeout: int = 180) -> None:
|
||||
"""List → cover → trim pieces → concat into out_path. Raises ClipError only."""
|
||||
out_path: str, timeout: int = 180,
|
||||
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:
|
||||
start = datetime.fromisoformat(start_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)
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
tmpdir = None
|
||||
@@ -180,20 +283,29 @@ def clip_batch(motion_url: str, camera_id: int, start_iso: str, end_iso: str,
|
||||
def probe(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:
|
||||
raise ClipError(_NO_CLIP_MSG)
|
||||
tmpdir = tempfile.mkdtemp(dir=os.path.dirname(out_path))
|
||||
pieces = []
|
||||
for index, clip in enumerate(clips):
|
||||
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(
|
||||
_piece_argv(
|
||||
_download_url(motion_url, camera_id, clip["path"]),
|
||||
clip["off0"],
|
||||
clip["off1"],
|
||||
piece,
|
||||
),
|
||||
_piece_argv(clip_url, off0, off1, piece),
|
||||
timeout,
|
||||
)
|
||||
pieces.append(piece)
|
||||
|
||||
+155
-2
@@ -201,7 +201,8 @@ def test_clip_batch_single_piece_argv(monkeypatch, tmp_path):
|
||||
calls, captured = [], {}
|
||||
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||
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
|
||||
args, timeout = calls[0]
|
||||
assert timeout == 180
|
||||
@@ -229,7 +230,8 @@ def test_clip_batch_two_pieces_trim_and_concat(monkeypatch, tmp_path):
|
||||
calls, captured = [], {}
|
||||
monkeypatch.setattr(clip, "_run_ffmpeg", fake_run_factory(calls, captured))
|
||||
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
|
||||
(piece_a, timeout_a), (piece_b, _), (concat_args, timeout_b) = calls
|
||||
assert timeout_a == timeout_b == 60
|
||||
@@ -273,3 +275,154 @@ def test_clip_batch_bad_iso_raises_cliperror(tmp_path):
|
||||
with pytest.raises(ClipError):
|
||||
clip.clip_batch(BASE, 1, "bukan-tanggal", "2026-09-26T10:00:00",
|
||||
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