Archilyzer · Source

archilyzer

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

commit e1d09191132bc320e585be29739a356e3ff7c81d
parent fa073c74a242bd7a71c5d7605966e8ca64269f73
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 26 Aug 2026 10:45:45 -0400

operations: transcode is a registry entry, with the first `appliesTo`

The one per-video media operation the catalog did not know. Registered as an
external descriptor after download: group media, shortLabel "Transcode",
costBasis "one ffmpeg pass per video, on the CPU", lane { TRANSCRIPTION_QUEUE,
cpu } (where the channel page already runs it — transcodeDefaultQueueKey),
dependsOn ["download"], dispatch external, no runner. /operations/transcode
then renders through slice 2's no-console panel with no further work: runner
null, sweepLaneIdFor null, band absent — the case that panel was built for.
An unknown id still 404s.

ExternalOperation / OperationDescriptor gain `appliesTo?(config)`, and
`operationApplies(id, config)` reads it (absent → true, unknown id → true, for
the reason operationLabel returns the id). Transcode's is
`handling === "transcribe" && !!audioFormat`, which replaces the three verbatim
copies of that gate in channels/[slug]/page.tsx, channels/[slug]/videos/
page.tsx and stageStatus.ts. Config-scoped on purpose: it is a fact about the
channel's configuration, not any video's state.

Deliberately NOT the band. buildOperationBands is snapshot-only and pure
(noCorpusWalkInRenderPaths), and ChannelSnapshot carries no handling or
audioFormat, so a band could not tell a channel that never transcodes from
one that has finished. The honest route is the snapshot writer recording a
transcode population — a snapshot-shape change that belongs with unified-ops
step 1. EXTERNAL_BAND_IDS stays two; the IA doc's transcode row says so.

Accepted side effect: settings/page.tsx offers every catalog id as a worker
tag, so "transcode" is offerable beside "download".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/operations.test.ts | 26++++++++++++++++++++++++++
Mcommon/lib/operations.ts | 39+++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/[slug]/lib/stageStatus.ts | 4++--
Meditor/app/channels/[slug]/page.tsx | 4++--
Meditor/app/channels/[slug]/videos/page.tsx | 4++--
Mplans/editor-operations-ia.md | 2+-
7 files changed, 73 insertions(+), 7 deletions(-)

diff --git a/common/lib/operations.test.ts b/common/lib/operations.test.ts @@ -9,6 +9,7 @@ import { backfillLaneOperations, resolveBackfillLaneOperations, orderByDependencies, + operationApplies, operationCatalog, operationCostBasis, operationGroup, @@ -1179,6 +1180,7 @@ test("the catalog covers every operation, dispatched here or not", () => { const ids = operationCatalog().map((o) => o.id); for (const id of [ "download", + "transcode", "transcription", "diarization", "attribution-diarized", @@ -1214,6 +1216,30 @@ test("`runner` names the auto-queue runner, and only for the two that have one", } }); +test("`appliesTo` is transcode's alone, and it reads the channel config", () => { + // A channel that never transcodes must not grow a dead Transcode stage, and + // the three surfaces that gate it used to each spell the rule themselves. + // Every other operation applies to every channel: absent, not `() => true`. + for (const op of operationCatalog()) { + assert.equal( + op.appliesTo !== undefined, + op.id === "transcode", + `${op.id} ${op.appliesTo ? "declares" : "lacks"} appliesTo`, + ); + } + const transcribing = { handling: "transcribe", audioFormat: "mp3" } as const; + assert.equal(operationApplies("transcode", transcribing), true); + assert.equal( + operationApplies("transcode", { handling: "youtube", audioFormat: "mp3" }), + false, + ); + assert.equal(operationApplies("transcode", { handling: "transcribe" }), false); + // No appliesTo means every channel — and so does an id the catalog does not + // know, for the reason operationLabel returns the id rather than throwing. + assert.equal(operationApplies("download", { handling: "youtube" }), true); + assert.equal(operationApplies("not-an-operation", { handling: "youtube" }), true); +}); + test("every catalogued operation declares a group and a cost basis", () => { // Both are read unconditionally by the UI — the transit line groups stations // by `group`, and every armed operation prints `costBasis` beside its diff --git a/common/lib/operations.ts b/common/lib/operations.ts @@ -73,6 +73,7 @@ // into an existing pass and `lane` gets the concurrent queue and the share. import type { Paths } from "./paths"; +import type { ChannelConfig } from "./channelConfig"; import type { AttributionSettings, DiarizationSettings, @@ -1252,6 +1253,14 @@ export type ExternalOperation = { // guessed from the id would hand transcode the transcription runner's // controls — a live Start button over the wrong lane. runner?: AutoQueueKind; + // Whether this operation applies to a channel AT ALL, off its config. Absent + // means every channel. The first (and so far only) taker is transcode: a + // youtube-handling channel or one with no audioFormat never has that work, + // and the three surfaces that gate the Transcode stage used to each spell + // `handling === "transcribe" && !!audioFormat` for themselves. Config-scoped + // on purpose — it is a fact about the channel's configuration, not about any + // video's state, which is what `state()` on a backfill operation answers. + appliesTo?: (config: ChannelConfig) => boolean; }; export const EXTERNAL_OPERATIONS: readonly ExternalOperation[] = [ @@ -1270,6 +1279,25 @@ export const EXTERNAL_OPERATIONS: readonly ExternalOperation[] = [ runner: "download", }, { + id: "transcode", + label: "Transcode", + hint: "Re-encoding kept media to the channel's audio format. Dispatched from the channel page's Transcode stage and the video page; a channel with no audioFormat never has this work.", + group: "media", + shortLabel: "Transcode", + costBasis: "one ffmpeg pass per video, on the CPU", + // Where the channel page already runs it (transcodeDefaultQueueKey is + // TRANSCRIPTION_QUEUE): it is CPU-bound, but sharing the transcription + // queue is what keeps an ffmpeg pass from racing whisper over the same + // media file. + lane: { queueKey: TRANSCRIPTION_QUEUE, contendsFor: "cpu" }, + dependsOn: ["download"], + dispatch: "external", + // No runner: nothing in the auto-queue feeds this. /operations/transcode + // is the "no console here" panel, on purpose. + appliesTo: (config) => + config.handling === "transcribe" && !!config.audioFormat, + }, + { id: "transcription", label: "Transcription", hint: "Turning audio into a transcript. Dispatched by the auto-transcribe runner across the worker pool.", @@ -1298,6 +1326,8 @@ export type OperationDescriptor = { // See ExternalOperation.runner. Absent for every backfill kind: those are // dispatched by the sweep and the arbiter, not by a runner. runner?: AutoQueueKind; + // See ExternalOperation.appliesTo. Absent means every channel. + appliesTo?: (config: ChannelConfig) => boolean; }; export function operationCatalog(): OperationDescriptor[] { @@ -1317,6 +1347,15 @@ export function operationCatalog(): OperationDescriptor[] { ]; } +// Whether an operation applies to a channel at all, off its config. True for an +// operation that declares no `appliesTo` — and true for an id the catalog does +// not know, for the same reason operationLabel returns the id: a dangling id is +// tolerated, and a gate that silently hid a stage on a typo would be worse than +// one that showed it. +export function operationApplies(id: string, config: ChannelConfig): boolean { + return operationCatalog().find((o) => o.id === id)?.appliesTo?.(config) ?? true; +} + // The label for a dependency id, from anywhere in the catalog. Returns the id // itself for something unknown rather than throwing — a dangling dependency is // already tolerated everywhere else here. diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - **The word "backfill" now means one thing: the shared queue.** It had been doing double duty — naming the queue that diarization, speaker attribution and the digest share, *and* standing in for each of those operations wherever a screen had no better word. The `/channels` group button that read **Backfill** is named after the operations it runs: **Speakers** once a speaker operation is switched on, **Derived data** while none is — the same derived label the channel page's stage already carried, so the button never claims work its lane is not doing. The `/actionable` section and every row of the channel page's Speakers card are labelled by the operations, not the queue, and the count column there says *reachable* rather than *to backfill*. *Pause backfill*, *Resume backfill* and *Start backfill sweep* keep their names, because those act on the queue itself. Under the hood the operation registry is `operations.ts` and its types say *Operation*, not *BackfillKind*; nothing stored on disk changed. +- **Transcode is in the operation registry, and `/operations/transcode` exists.** It had a state per video, a lane and a dependency, and was the one media operation the catalog did not know. It is registered as an operation nothing in the auto-queue drives — its page says so, names Download as what has to happen first, and points at the channel page's Transcode stage and the video page as what does dispatch it. The registry also now records *which channels an operation applies to*: a `youtube`-handling channel or one with no audio format never has transcode work, and the three screens that each spelled that rule for themselves read it from the registry instead. The Transcode stage and the `/channels` columns are unchanged; a transcode band on the pipelines rail waits until a channel's report can carry it. - **The channel page's "Backfill" card is the *Speakers* card, and it says what each operation costs.** "Backfill" is the name of a *queue*, and on this install that one word was standing in for three operations with different inputs, different costs and different reasons to be switched on — nobody can arm, pause or run "a backfill". The card is now named after the work it holds, one row per operation, each row carrying what one unit of it costs. Old links keep working: **`?stage=backfill` still opens the Speakers panel** rather than silently dropping you on the channel overview. - **A run's summary line reports everything that happened to it, wherever the run was started from.** Starting the same work from the channel card and from the corpus-wide sweep used to produce two differently-worded summaries: the card's mentioned videos that were *deferred* (over the length limit) and *blocked* (waiting on an earlier operation) but not the ones *skipped* mid-rewrite; the sweep's did the opposite. Both now print all of them, so "what did that run actually do" has one answer. The needs-re-acquiring count stays its own separate number and is never folded into the rest — on this corpus it is ~91× larger, and one summed "remaining" figure would be useless. - **A GPU-bound speaker run is now scheduled as GPU-bound.** When diarization is configured to run on the Vulkan backend it holds ~4.4 GB of the same card the transcription engine wants, and it *declared* that — but the dispatcher never asked, and scheduled it as if it only wanted CPU cores. It now stands aside for transcription the way it was always meant to. The same fix means a **metered** digest run is reserved on the metered lane rather than the local one, so the two digest lanes genuinely run side by side. diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts @@ -7,6 +7,7 @@ import { import type { JobRecord } from "yt-dlp-transcript-common/jobs/registry"; import { backfillLaneEntriesOf, + operationApplies, operationsGroupLabel, reachableOperationWork, type OperationGroup, @@ -203,8 +204,7 @@ export function computeStageStatuses( if (stage) runningByStage.add(stage); } - const transcodeApplies = - config.handling === "transcribe" && !!config.audioFormat; + const transcodeApplies = operationApplies("transcode", config); const downloadPending = undownloadedIds.length + diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -70,6 +70,7 @@ import { SpeakersStage } from "./components/stages/SpeakersStage"; import { getOperation, backfillLaneOperations, + operationApplies, operationsActionLabel, operationsGroupLabel, OPERATION_GROUP_ORDER, @@ -239,8 +240,7 @@ export default async function ChannelDetailPage({ const actionableDownloadedNoTranscriptIds = buckets.downloadedNoTranscript.filter( (id) => !excludedDownloadIds.has(id), ); - const transcodeApplies = - config.handling === "transcribe" && !!config.audioFormat; + const transcodeApplies = operationApplies("transcode", config); // Enabled lane backfills. A settings read, no I/O. // // backfillLaneOperations, still: this is the BACKFILL lane's list, and the snapshot diff --git a/editor/app/channels/[slug]/videos/page.tsx b/editor/app/channels/[slug]/videos/page.tsx @@ -4,6 +4,7 @@ import type { Dirent } from "node:fs"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; +import { operationApplies } from "yt-dlp-transcript-common/lib/operations"; import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot"; import { excludedDownloadIdSet, @@ -131,8 +132,7 @@ export default async function ChannelVideosPage({ (id) => !excludedDownloadIds.has(id), ); const failedTranscodingIds = await loadFailedTranscodings(paths, slug); - const transcodeApplies = - config.handling === "transcribe" && !!config.audioFormat; + const transcodeApplies = operationApplies("transcode", config); const channelDataDir = path.join(paths.channelsDir, slug, "data"); const channelDataDirIds = await readDataDirVideoIds(channelDataDir); diff --git a/plans/editor-operations-ia.md b/plans/editor-operations-ia.md @@ -57,7 +57,7 @@ and concludes the model was wrong. | **Sync** | An operation, but channel-scoped and cadence-triggered rather than per-video and backlog-driven. Add `scope: "video" \| "channel"` and `trigger: "backlog" \| "cadence"` to `OperationDescriptor`; it gets a board row and `/operations/sync` (today's `/scheduler`) and keeps its own runner. The keep-latest checks and the saved-video backup that ride the same heartbeat (`editor/app/scheduler/runTick.ts:25-27`) are **Storage chores**, not sync, and should be labelled as such. | | **Build / deploy** | **Not** operations. Per-site, no per-video state, no lane. They are the Site's *publish* verb → `/sites/[siteId]`. | | **Cleanup / saved-videos / relocate** | **Not** operations: they consume outputs rather than producing derived artifacts. Third noun, **Storage**, filed under Machine. The channel `cleanup` stage stays where it is. | -| **Transcode** | A per-video media operation that is simply **missing from the catalog** — it has a state per video, a lane and a dependency. Register it as an external descriptor, group `media`, `dependsOn: ["download"]`, with an `appliesTo?(channelConfig)` so a channel that never transcodes does not grow a dead row. Do it when the stage list becomes registry-derived (slice 2). | +| **Transcode** | **Registered 2026-08-26** (the vocabulary pass, commit 3): an external descriptor, group `media`, `lane: { TRANSCRIPTION_QUEUE, cpu }`, `dependsOn: ["download"]`, no runner, and the registry's first `appliesTo(config)` — `handling === "transcribe" && !!audioFormat`, exposed as `operationApplies(id, config)` and replacing the three verbatim copies of that gate. `/operations/transcode` is slice 2's no-console panel. **Its BAND waits**: `buildOperationBands` is snapshot-only and pure, and `ChannelSnapshot` carries no `handling`/`audioFormat`, so a band could not tell "never transcodes" from "finished". The honest route is the snapshot writer recording a transcode population per channel — a snapshot-shape change that belongs with unified-ops step 1. `EXTERNAL_BAND_IDS` stays two until then. | | **Social channels** | A channel whose operation set is `{fetch-posts}`. An explicit **non-goal** here: leave the short-circuit at `editor/app/channels/[slug]/page.tsx:156` alone. | | **The channel stage list** | Half-derived and that is correct. The middle (`download … backfill`) derives from `OPERATION_GROUP_ORDER`; the bookends (`configure`, `playlist` / `cleanup`, `diagnostics`, `danger`) are **channel chores**, not operations, and stay hand-listed. Say so in the code so the next reader does not "finish" the derivation. | | **Digest's two lanes** | A **registry defect**, not a noun problem. `common/controller/arbiter.ts` special-cased `DIGEST_KIND_ID` because digest runs on its own queue. **Resolved in slice 2** by giving digest its own `laneFor(settings)` and resolving every operation through `laneForOperation` — but NOT by "giving every kind a `laneFor` defaulting to `lane`": `laneFor` is optional and its PRESENCE is what `backfillBatch.ts` keys the GPU idle-only rule off, so a default would enrol every operation in that rule (FACTS.md, "laneFor? is OPTIONAL"). Add one only where the lane genuinely varies. |