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:
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'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 })