Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions src/scripto/gui/viewmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down
47 changes: 37 additions & 10 deletions tests/test_viewmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading