Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 09285b2d12e06398f5ce598ef4d52aafa4716bb8
parent e487ca6aaf9095a7bfb9c151c1eeebc01c6d23da
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 24 Jun 2026 20:30:25 -0400

Pause Transcriptions: stop parakeet jobs between segments to free the GPU

The global pause (renamed "Pause all" -> "Pause Transcriptions") only stopped
handing out new worker slots; in-flight jobs ran their whole file, so the GPU
stayed busy. pauseAll() now also fires the existing partial-stop on any busy
partial-capable worker: parakeet finishes the current window, caches it, and
exits "paused" (a skip, not a failure), freeing the GPU within one segment and
resuming from cache on the next run. Other engines run their in-flight file to
completion as before.

The button is now shared (PauseTranscriptionsButton) and surfaced on both the
Workers page and the Active Jobs screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/jobs/workerPool.ts | 13++++++++++++-
Meditor/CHANGELOG.md | 1+
Meditor/app/jobs/active/page.tsx | 8+++++++-
Aeditor/app/jobs/components/PauseTranscriptionsButton.tsx | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/workers/components/WorkersView.tsx | 30++++++++----------------------
Meditor/e2e/parakeet-partial.spec.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/workers.spec.ts | 8++++----
7 files changed, 151 insertions(+), 28 deletions(-)

diff --git a/common/jobs/workerPool.ts b/common/jobs/workerPool.ts @@ -203,7 +203,18 @@ export class WorkerPool { this.pausedSnapshot = new Map(); for (const [id, entry] of this.entries) { this.pausedSnapshot.set(id, entry.state); - entry.state = "disabled"; + // Gracefully stop a partial-capable in-flight job so the GPU frees + // between fragments (parakeet: finish the current window, cache it, exit + // "paused" — resumes from cache on the next run). Mirrors + // partialStopWorker: drain it so it ends disabled once the lease + // releases. Other engines just stop getting new work and run their + // in-flight file to completion. + if (entry.busy && entry.activeStop) { + entry.state = "draining"; + entry.activeStop(); + } else { + entry.state = "disabled"; + } } } diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **"Pause Transcriptions" now frees the GPU between parakeet segments instead of running the in-flight video to completion.** The global pause control (renamed from "Pause all" → **Pause Transcriptions** / **Resume Transcriptions**) used to only stop handing out new worker slots — any in-flight transcription kept running until its whole file was done, so the GPU stayed busy. Pausing now *also* sends a graceful between-segment stop to any partial-capable in-flight job: a **parakeet** worker finishes the current window, caches it (`win-NNNN.json`), and exits `paused` (a skip, not a failure), so the GPU frees within one segment and the video resumes from its cached windows on the next run — the same mechanism as the per-worker **Stop & keep progress** button, now wired into the global pause. Non-parakeet engines (whisper-cpp, chough) keep prior behavior: they stop taking new work but run their in-flight file to completion. The button is also surfaced on the **Active Jobs** screen (`/jobs/active`) next to **Drain all**, not just the Workers page. `resumeAll()` restores each worker's pre-pause state as before. See `common/jobs/workerPool.ts` (`pauseAll`), the new `editor/app/jobs/components/PauseTranscriptionsButton.tsx` (shared by both screens), `editor/app/workers/components/WorkersView.tsx`, and `editor/app/jobs/active/page.tsx`. - **"Drain all" (and the per-runner Drain) no longer hangs the auto-transcribe runner.** Draining could leave the runner stuck "draining" forever — appearing to freeze the app — whenever one of its per-video transcription units was *parked* in the worker pool waiting for a free slot at the moment drain fired (much more likely now that auto units yield slots to manual transcriptions). The runner forwarded only its hard-cancel signal — never the soft `drainSignal` — to a parked unit's `pool.acquire()`, so a soft drain could never unblock it: the unit's promise never settled, the runner's in-flight count never reached zero, and its drain loop spun indefinitely (hard **Cancel**/**Stop** always worked, since that signal *was* forwarded). The runner now threads `ctx.drainSignal` into each transcription unit, so a parked (not-yet-started) unit unblocks and is skipped on drain while a unit already transcribing finishes normally — correct drain semantics, and the runner finalizes promptly. See `common/controller/autoRunner.ts` (the `launchUnit` drainSignal wiring) and the new parked-unit drain regression test in `editor/e2e/auto-queue.spec.ts`. - **Manual transcriptions now preempt auto-queued ones — click "Transcribe missing" (or any non-auto transcribe) while auto-transcribe is running and yours goes next.** The auto-transcribe runner shares the global transcription **worker pool** with every manual transcribe (channel batch, bucket retry, single-video), so they already serialized — but the pool granted freed worker slots in plain arrival order, so a manual transcribe could wait behind the runner's next auto pick. The pool's waiter queue is now **priority-ordered**: a manual (foreground) acquire is served before any parked auto (background) one, with FIFO preserved within each class — the same foreground-vs-background priority the per-platform sync queue already uses, now applied to the pool too. The auto-runner acquires its per-video transcription slots at **background priority**, so a manual transcribe jumps ahead: the in-flight auto transcription is **not interrupted** (it finishes — "drains"), then every freed worker goes to the manual work until it's exhausted, then auto resumes. Worker N-way parallelism is untouched (only *parked* waiters are reordered). A future "auto-sync queues its own transcriptions" path gets the same yield for free by acquiring as background. See `common/jobs/workerPool.ts` (waiter priority), `common/controller/transcribeOne.ts`, `common/controller/transcribeOneFromQueue.ts`, and `common/controller/autoRunner.ts`. - **Auto-download now shares the same per-platform queue as a manual Sync, so you can click Sync while auto-download is running without risking a 429.** Each auto-download video is now launched as a real job on the channel's platform queue (e.g. `platform:youtube`) — the same queue Sync uses — instead of running out-of-band. The job registry serializes them, so the two never spawn yt-dlp against one platform at the same time (in either direction), and each auto-download unit appears as its own row in Active Jobs with progress. A manually clicked Sync **preempts** the runner's *queued* auto-download units on that platform (it doesn't interrupt one already downloading), and a Sync is **refused with a clear message** (showing remaining seconds) while that platform is in a rate-limit cooldown. A 429 hit by either path now records the shared cooldown, so manual and automatic downloads back off together. See `common/jobs/registry.ts` (queue priority), `common/controller/autoRunner.ts`, `common/jobs/downloadBackoff.ts`, and `common/jobs/streamCommand.ts`. diff --git a/editor/app/jobs/active/page.tsx b/editor/app/jobs/active/page.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; +import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; import { buildActiveJobsPayload } from "./buildActiveJobs"; import { ActiveJobsLive } from "../components/ActiveJobsLive"; import { DrainAllButton } from "../components/DrainAllButton"; +import { PauseTranscriptionsButton } from "../components/PauseTranscriptionsButton"; import { BookmarksMenu } from "../components/BookmarksMenu"; import { loadBookmarksView } from "../loadBookmarks"; @@ -14,11 +16,15 @@ export default async function ActiveJobsPage() { buildActiveJobsPayload(), loadBookmarksView(), ]); + const paused = getWorkerPool().isPaused(); return ( <div className="flex flex-col gap-4"> <div className="flex items-center justify-between"> <h1 className="text-2xl font-semibold">Active jobs</h1> - <DrainAllButton /> + <div className="flex items-center gap-2"> + <PauseTranscriptionsButton paused={paused} /> + <DrainAllButton /> + </div> </div> <BookmarksMenu bookmarks={bookmarks} missingSlugs={missingSlugs} /> <ActiveJobsLive initial={initial} /> diff --git a/editor/app/jobs/components/PauseTranscriptionsButton.tsx b/editor/app/jobs/components/PauseTranscriptionsButton.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + pauseAllWorkersAction, + resumeAllWorkersAction, +} from "../../workers/actions"; + +// Global pause/resume for transcription workers. Pausing stops handing out new +// work AND gracefully stops in-flight parakeet jobs after the current segment +// (cached for resume); other engines run their in-flight file to completion. +// Shared by the Workers page (live `paused` from polling, refetch on toggle) +// and the Active Jobs page (server-read `paused`, router.refresh on toggle). +export function PauseTranscriptionsButton({ + paused, + disabled, + onChange, +}: { + paused: boolean; + disabled?: boolean; + onChange?: () => void | Promise<void>; +}) { + const [pending, startTransition] = useTransition(); + const router = useRouter(); + + function toggle() { + startTransition(async () => { + await (paused ? resumeAllWorkersAction() : pauseAllWorkersAction()); + if (onChange) await onChange(); + else router.refresh(); + }); + } + + return paused ? ( + <button + type="button" + disabled={pending} + onClick={toggle} + className="px-3 py-1.5 rounded-md bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50" + > + Resume Transcriptions + </button> + ) : ( + <button + type="button" + disabled={pending || disabled} + onClick={toggle} + title="Stop taking new work; in-flight parakeet jobs finish the current segment, cache it, and resume on the next run" + className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50" + > + Pause Transcriptions + </button> + ); +} diff --git a/editor/app/workers/components/WorkersView.tsx b/editor/app/workers/components/WorkersView.tsx @@ -8,11 +8,10 @@ import { disableWorkerAction, drainWorkerAction, enableWorkerAction, - pauseAllWorkersAction, - resumeAllWorkersAction, setDefaultWorkersAction, stopWorkerPartialAction, } from "../actions"; +import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton"; export type WorkerTask = { id: string; @@ -117,28 +116,15 @@ export function WorkersView({ initial }: { initial: WorkersPayload }) { return ( <div className="flex flex-col gap-3"> <div className="flex items-center gap-3"> - {paused ? ( - <button - type="button" - disabled={pending} - onClick={() => run(resumeAllWorkersAction)} - className="px-3 py-1.5 rounded-md bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50" - > - Resume all workers - </button> - ) : ( - <button - type="button" - disabled={pending || workers.length === 0} - onClick={() => run(pauseAllWorkersAction)} - className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50" - > - Pause all - </button> - )} + <PauseTranscriptionsButton + paused={paused} + disabled={workers.length === 0} + onChange={refetch} + /> {paused && ( <span role="status" className="text-sm text-amber-700 dark:text-amber-300"> - All workers paused — batches are waiting. Resume restores each + Transcriptions paused — batches are waiting; parakeet jobs stop after + the current segment and resume on the next run. Resume restores each worker&apos;s previous state. </span> )} diff --git a/editor/e2e/parakeet-partial.spec.ts b/editor/e2e/parakeet-partial.spec.ts @@ -74,6 +74,70 @@ test("Stop & keep progress pauses a parakeet run, caches the window, and resumes expect(await pathExists(cachedWindow)).toBe(false); }); +test("Pause Transcriptions stops a parakeet run after the current segment, caches it, and resume completes it", async ({ + page, +}) => { + test.setTimeout(60_000); + await writeSettings({ + workers: [ + { id: "gpu", name: "GPU parakeet", kind: "local", enabled: true, priority: 0, appId: "parakeet", config: {} }, + ], + }); + await makeTranscribeChannel("gpause-chan", ["slowoppk1"]); + const dir = "test-transcripts/channels/gpause-chan/data/slowoppk1"; + const cachedWindow = `${dir}/.audio.mp3.parakeet/win-0000.json`; + const transcript = `${dir}/transcript.json`; + + await page.goto("/channels/gpause-chan"); + await page.getByRole("button", { name: "Transcribe missing" }).click(); + + // Wait until the parakeet worker is actually running, then hit the global + // "Pause Transcriptions" button — it should gracefully stop the in-flight + // parakeet job after the current segment (not let it run to completion). + await page.goto("/workers"); + const gpu = page.getByRole("listitem").filter({ hasText: "GPU parakeet" }); + await expect( + gpu.getByRole("button", { name: /stop GPU parakeet keep progress/i }), + ).toBeVisible({ timeout: 15_000 }); + await page.getByRole("button", { name: "Pause Transcriptions" }).click(); + await expect(page.getByRole("status")).toContainText(/paused/i); + + // The completed window is cached but no transcript is written yet. + await expect.poll(() => pathExists(cachedWindow), { timeout: 30_000 }).toBe(true); + expect(await pathExists(transcript)).toBe(false); + + // Resume restores the worker to enabled; re-running resumes from the cached + // window and completes the transcript. + await page.getByRole("button", { name: "Resume Transcriptions" }).click(); + await page.goto("/channels/gpause-chan"); + await page.getByRole("button", { name: "Transcribe missing" }).click(); + await expect.poll(() => pathExists(transcript), { timeout: 30_000 }).toBe(true); + const doc = await readJson<{ chunk_data?: unknown[] }>(transcript); + expect(doc.chunk_data?.length).toBe(2); + expect(await pathExists(cachedWindow)).toBe(false); +}); + +test("the Active Jobs screen has a Pause Transcriptions toggle", async ({ + page, +}) => { + await writeSettings({ + workers: [ + { id: "gpu", name: "GPU parakeet", kind: "local", enabled: true, priority: 0, appId: "parakeet", config: {} }, + ], + }); + await page.goto("/jobs/active"); + const pause = page.getByRole("button", { name: "Pause Transcriptions" }); + await expect(pause).toBeVisible(); + await pause.click(); + await expect( + page.getByRole("button", { name: "Resume Transcriptions" }), + ).toBeVisible({ timeout: 10_000 }); + await page.getByRole("button", { name: "Resume Transcriptions" }).click(); + await expect( + page.getByRole("button", { name: "Pause Transcriptions" }), + ).toBeVisible({ timeout: 10_000 }); +}); + test("Stop & keep progress on a force-killed parakeet run is NOT recorded as a failed transcription", async ({ page, }) => { diff --git a/editor/e2e/workers.spec.ts b/editor/e2e/workers.spec.ts @@ -90,12 +90,12 @@ test("pause all disables every worker; resume restores them", async ({ await gpu.getByRole("button", { name: "disable GPU" }).click(); await expect(gpu).toContainText("disabled"); - await page.getByRole("button", { name: "Pause all" }).click(); + await page.getByRole("button", { name: "Pause Transcriptions" }).click(); await expect(page.getByRole("status")).toContainText(/paused/i); const cpu = page.getByRole("listitem").filter({ hasText: "CPU" }); await expect(cpu).toContainText("disabled"); - await page.getByRole("button", { name: "Resume all workers" }).click(); + await page.getByRole("button", { name: "Resume Transcriptions" }).click(); // CPU returns to enabled (idle); GPU stays disabled — its pre-pause state. await expect(cpu).toContainText(/idle|busy/); await expect(gpu).toContainText("disabled"); @@ -127,7 +127,7 @@ test("pausing all workers pauses a running batch instead of failing it; resume c // Pause all workers. The in-flight one finishes (disable lets it complete) but // no new transcription starts — the batch parks, it does NOT fail. await page.goto("/workers"); - await page.getByRole("button", { name: "Pause all" }).click(); + await page.getByRole("button", { name: "Pause Transcriptions" }).click(); await expect(page.getByRole("status")).toContainText(/paused/i); // Give it time: at most the in-flight transcript lands, then progress stalls. @@ -147,7 +147,7 @@ test("pausing all workers pauses a running batch instead of failing it; resume c // Resume → the remaining videos transcribe and the batch finishes. await page.goto("/workers"); - await page.getByRole("button", { name: "Resume all workers" }).click(); + await page.getByRole("button", { name: "Resume Transcriptions" }).click(); await expect .poll(() => transcriptCount("pause-batch", ids), { timeout: 60_000 })