diff --git a/src/scripto/gui/viewmodel.py b/src/scripto/gui/viewmodel.py index d09bce5..ce10b57 100644 --- a/src/scripto/gui/viewmodel.py +++ b/src/scripto/gui/viewmodel.py @@ -120,9 +120,8 @@ def __init__( self.log_lines: list[str] = [] - # ETA bookkeeping: wall time of completed files this batch - self._durations: list[float] = [] - self._active_since: float | None = None + # ETA bookkeeping: when this batch started (see _snapshot) + self._batch_started: float | None = None # History-translation queue: one worker, jobs survive any dialog. self.translation_jobs: list[TranslationJob] = [] @@ -185,8 +184,7 @@ def start_batch( self._stop.clear() self._final_stats = None self._finished_flag = False - self._durations = [] - self._active_since = None + self._batch_started = time.monotonic() self._id_map = {n: ids[n - 1] for n in range(1, len(ids) + 1)} config = self.config.load() @@ -260,7 +258,6 @@ def drain(self) -> DrainResult: row = self._row_for_job(int(event.subject.split(":", 1)[1])) if row is None: continue - self._track_eta(row, event.status) row.status = event.status row.error = event.detail row.error_key = event.detail_key @@ -298,14 +295,6 @@ def _row_for_job(self, job_id: int) -> FileRow | None: mapped = getattr(self, "_id_map", {}).get(job_id, job_id) return self.rows.get(mapped) - def _track_eta(self, row: FileRow, new_status: str) -> None: - now = time.monotonic() - if new_status == JobStatus.TRANSCRIBING.value: - self._active_since = now - elif new_status == JobStatus.DONE.value and self._active_since is not None: - self._durations.append(now - self._active_since) - self._active_since = None - def _snapshot(self) -> Snapshot: rows = list(self.rows.values()) terminal = { @@ -315,15 +304,25 @@ def _snapshot(self) -> Snapshot: done = sum(1 for r in rows if r.status in terminal) active = next( (r for r in rows if r.status in ( - JobStatus.TRANSCRIBING.value, JobStatus.EXTRACTING.value, - JobStatus.TRANSLATING.value, + JobStatus.DOWNLOADING.value, JobStatus.TRANSCRIBING.value, + JobStatus.EXTRACTING.value, JobStatus.TRANSLATING.value, )), None, ) + # Wall-clock throughput, not the average of per-file transcribe times. + # Timing files individually measured only the stage we happened to + # watch and charged every remaining file a full transcription — with + # a half-subtitled library, where most files are skipped in + # milliseconds, that produced estimates in the thousands of minutes. + # Elapsed-over-finished counts everything the batch actually spends + # (iCloud downloads, extraction stalls, the one-time model load, + # translation running alongside) and lets cheap files pull the + # average down by themselves. eta = None - if self.running and self._durations: + if self.running and done and self._batch_started is not None: remaining = len(rows) - done - eta = sum(self._durations) / len(self._durations) * remaining + elapsed = time.monotonic() - self._batch_started + eta = elapsed / done * remaining return Snapshot( running=self.running, done=done, diff --git a/tests/test_viewmodel.py b/tests/test_viewmodel.py index 572cfa2..c3a987e 100644 --- a/tests/test_viewmodel.py +++ b/tests/test_viewmodel.py @@ -55,21 +55,48 @@ def test_drain_applies_status_and_progress(tmp_path): assert any("boom" in line for line in result.log_lines) -def test_eta_from_completed_durations(tmp_path): - vm = make_vm(tmp_path) - make_media(tmp_path, 3) +def _running_batch(vm, tmp_path, count: int, *, elapsed: float): + make_media(tmp_path, count) vm.scan_inputs(str(tmp_path)) vm.running = True + vm._batch_started = time.monotonic() - elapsed vm._id_map = {i: rid for i, rid in enumerate(vm.row_order, start=1)} - vm.bus.emit(StatusEvent(subject="job:1", status="transcribing")) - vm.drain() - time.sleep(0.05) + +def test_eta_extrapolates_wall_clock_throughput(tmp_path): + vm = make_vm(tmp_path) + _running_batch(vm, tmp_path, 4, elapsed=60.0) + vm.bus.emit(StatusEvent(subject="job:1", status="done")) - result = vm.drain() - snap = result.snapshot - assert snap.done == 1 and snap.total == 3 - assert snap.eta_sec is not None and snap.eta_sec > 0 + snap = vm.drain().snapshot + assert snap.done == 1 and snap.total == 4 + assert 170 < snap.eta_sec < 195 # a minute bought one file, three to go + + +def test_eta_treats_instantly_skipped_files_as_cheap(tmp_path): + """The shape that produced a 3000-minute estimate: most files skip. + + Timing transcriptions individually and multiplying by the files left + charged every already-subtitled file a full transcription. Wall-clock + throughput lets them cost what they actually cost. + """ + vm = make_vm(tmp_path) + _running_batch(vm, tmp_path, 10, elapsed=5.0) + + for job_id in range(1, 6): # five files already had subtitles + vm.bus.emit(StatusEvent(subject=f"job:{job_id}", status="skipped")) + snap = vm.drain().snapshot + assert snap.done == 5 + assert snap.eta_sec < 10 # seconds, not five full transcriptions + + +def test_eta_waits_for_the_first_finished_file(tmp_path): + vm = make_vm(tmp_path) + _running_batch(vm, tmp_path, 3, elapsed=30.0) + vm.bus.emit(StatusEvent(subject="job:1", status="downloading")) + snap = vm.drain().snapshot + assert snap.eta_sec is None # nothing to extrapolate from yet + assert snap.current_status == "downloading" # but the row is shown as busy def test_batch_runs_via_fake_pipeline(tmp_path, monkeypatch):