@pithy-sh/media 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/package.json +52 -0
  4. package/pithy.manifest.json +85 -0
  5. package/src/ai/enrich.ts +187 -0
  6. package/src/ai/videoBatching.ts +130 -0
  7. package/src/capability.ts +129 -0
  8. package/src/cloudflare-test.d.ts +15 -0
  9. package/src/config/config.ts +155 -0
  10. package/src/data/enums.ts +53 -0
  11. package/src/data/extend.ts +135 -0
  12. package/src/data/mediaAsset.ts +78 -0
  13. package/src/data/mediaHash.ts +24 -0
  14. package/src/data/tables.ts +45 -0
  15. package/src/deliver/url.ts +78 -0
  16. package/src/error/errors.ts +95 -0
  17. package/src/hash/duplicates.ts +129 -0
  18. package/src/hash/sha256.ts +34 -0
  19. package/src/http/dispatch.ts +86 -0
  20. package/src/http/guard.ts +25 -0
  21. package/src/http/handlers.ts +205 -0
  22. package/src/http/routes.ts +119 -0
  23. package/src/http/schemas.ts +90 -0
  24. package/src/index.ts +39 -0
  25. package/src/migrations/0001_init.ts +104 -0
  26. package/src/migrations/extend.ts +40 -0
  27. package/src/provision/provisionMedia.ts +226 -0
  28. package/src/provision/resolveMediaConfig.ts +78 -0
  29. package/src/record/d1Store.ts +95 -0
  30. package/src/record/hashStore.ts +104 -0
  31. package/src/record/kvStore.ts +147 -0
  32. package/src/record/resolve.ts +34 -0
  33. package/src/record/store.ts +50 -0
  34. package/src/secret/registry.ts +85 -0
  35. package/src/storage/backend.ts +29 -0
  36. package/src/storage/cloudflare.ts +77 -0
  37. package/src/storage/minter.ts +57 -0
  38. package/src/storage/resolve.ts +78 -0
  39. package/src/storage/storage.ts +130 -0
  40. package/src/version.generated.ts +16 -0
  41. package/src/workflows/enrich.ts +116 -0
  42. package/src/workflows/hls.ts +99 -0
  43. package/src/workflows/retryPolicy.ts +56 -0
  44. package/src/workflows/specs.ts +75 -0
  45. package/src/workflows/worker.ts +192 -0
  46. package/src/workflows/wrangler.jsonc +73 -0
@@ -0,0 +1,57 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The storage minting seams — the clean interfaces the media capability depends on. A test injects a
6
+ * fake minter; the real adapters live in `storage/cloudflare.ts`.
7
+ *
8
+ * {@link ImageMinter} and {@link VideoMinter} are adapted from the `@pithy-sh/cloudflare` managers,
9
+ * decoupled from those managers' SDK-typed returns. Every direct-upload URL is minted through that
10
+ * client — no hand-rolled `fetch` to the CF API (CLAUDE.md §Cloudflare access).
11
+ *
12
+ * {@link R2Minter} is different in kind, and deliberately so: it is not an SDK adapter but the narrow
13
+ * two-method slice of `@pithy-sh/storage`'s `ObjectStore` that media consumes. Media owns no R2
14
+ * mechanism — no bucket, no credential, no S3 client. Stating that slice as its own interface is what
15
+ * keeps `mediaStorage` testable with two async functions instead of a fifteen-method seam, and what
16
+ * lets the object plane change shape without reaching into media.
17
+ */
18
+
19
+ /** Mints a one-time Cloudflare Images direct-upload URL and deletes stored images. */
20
+ export interface ImageMinter {
21
+ /**
22
+ * Mint a direct-upload URL; CF assigns the image id, returned as the storage key.
23
+ *
24
+ * The bag is the caller's own; the adapter merges the ownership keys over it, so what reaches
25
+ * Cloudflare always names the owning project and environment (see `cloudflare.ts`).
26
+ */
27
+ mintDirectUpload(metadata?: Record<string, string>): Promise<{ id: string; uploadUrl: string }>;
28
+ /** Delete a stored image by its CF Images id. */
29
+ delete(id: string): Promise<void>;
30
+ }
31
+
32
+ /** Mints a Cloudflare Stream direct-upload URL and deletes stored videos. */
33
+ export interface VideoMinter {
34
+ /**
35
+ * Mint a direct-upload URL; CF assigns the video uid, returned as the storage key.
36
+ *
37
+ * Takes the same metadata bag as {@link ImageMinter} and for the same reason: Stream is
38
+ * account-flat and keys a video by a Cloudflare-minted uid, so the `meta` object the mint carries
39
+ * is the only place ownership can live. The adapter merges the project and environment over
40
+ * whatever the caller passes.
41
+ */
42
+ mintDirectUpload(metadata?: Record<string, string>): Promise<{ uid: string; uploadUrl: string }>;
43
+ /** Delete a stored video by its CF Stream uid. */
44
+ delete(uid: string): Promise<void>;
45
+ }
46
+
47
+ /**
48
+ * The presign contract media consumes from `@pithy-sh/storage`'s `ObjectStore`. Two methods, because
49
+ * two are all media needs: reads and deletes go through the `R2Bucket` binding instead, which costs no
50
+ * credential and no round trip (CLAUDE.md §Cloudflare access — bindings inside the Worker, S3 outside).
51
+ */
52
+ export interface R2Minter {
53
+ /** Presigned PUT URL for a client to upload bytes straight to R2. */
54
+ mintUpload(key: string, contentType: string, contentLength: number): Promise<string>;
55
+ /** Presigned GET URL for reading an object back (enrichment reads audio/documents this way). */
56
+ mintDownload(key: string): Promise<string>;
57
+ }
@@ -0,0 +1,78 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { R2Bucket } from "@cloudflare/workers-types";
5
+ import { CloudflareImageManager } from "@pithy-sh/cloudflare/src/media/imageManager";
6
+ import type { AssetOwner } from "@pithy-sh/cloudflare/src/media/ownership";
7
+ import { CloudflareStreamManager } from "@pithy-sh/cloudflare/src/media/streamManager";
8
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
9
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
10
+ import { sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
11
+ import { objectStore } from "@pithy-sh/storage/src/object/store";
12
+ import type { MediaConfig } from "../config/config";
13
+ import { MEDIA_R2_SECRET, MEDIA_STORAGE_SECRET, mediaSecretsRegistry } from "../secret/registry";
14
+ import { imageMinter, objectStoreMinter, videoMinter } from "./cloudflare";
15
+ import { type MediaStorage, mediaStorage } from "./storage";
16
+
17
+ /** The env the storage layer reads: the secrets bindings, the `MEDIA_BUCKET` R2 binding, and the owner. */
18
+ export type StorageEnv = SecretsStoreEnv & {
19
+ /** The R2 bucket binding media objects are read from and deleted through (bindings-first). */
20
+ MEDIA_BUCKET: R2Bucket;
21
+ /**
22
+ * The project name, stamped into every deployed Worker's vars at provision alongside `ENVIRONMENT`
23
+ * (`resolveWorkflowHost` in `@pithy-sh/core`). It is the owner written into the metadata of every
24
+ * Cloudflare Images and Stream asset this Worker mints — the only isolation those two account-flat
25
+ * stores have, since their assets are keyed by a Cloudflare-minted id rather than by a name we choose.
26
+ */
27
+ PROJECT?: string;
28
+ };
29
+
30
+ /**
31
+ * Who owns the assets this Worker is about to create.
32
+ *
33
+ * `ENVIRONMENT` absent means local dev — the same signal `@pithy-sh/secrets` keys its reader on — so
34
+ * it falls back to `dev`. `PROJECT` has no such fallback and is never guessed: CLAUDE.md §Resource
35
+ * naming forbids it for names anything must reproduce later, and a sweep is exactly that. Cloudflare
36
+ * Images and Stream have no local emulation, so even a `pithy dev` upload writes into the shared,
37
+ * account-wide store — an unstamped asset there is debris nobody can attribute or delete. Refusing to
38
+ * mint is the smaller failure.
39
+ */
40
+ export function assetOwner(env: StorageEnv): AssetOwner {
41
+ const project = env.PROJECT?.trim();
42
+ if (!project) {
43
+ throw new InternalError({
44
+ message: "This Worker cannot mint a Cloudflare Images or Stream upload: it does not know its project.",
45
+ action: 'Add "PROJECT" to the Worker\'s wrangler.jsonc vars, set to the root pithy.config.ts name.',
46
+ detail: "media storage env carries no PROJECT var; Images and Stream assets would be unattributable",
47
+ });
48
+ }
49
+ return { project, env: env.ENVIRONMENT ?? "dev" };
50
+ }
51
+
52
+ /**
53
+ * Resolve the storage seam from the request env.
54
+ *
55
+ * Images and Stream are media's own: the scoped CF API token is read once through the shared
56
+ * `@pithy-sh/secrets` accessor (never a raw binding) and the two managers are built from it. Both
57
+ * minters are constructed with {@link assetOwner}, so every asset they mint carries the project and
58
+ * environment that created it.
59
+ *
60
+ * R2 is not. `objectStore` from `@pithy-sh/storage` owns the bucket, the credential, and the S3 client;
61
+ * media points it at `MEDIA_BUCKET` under its own secret name and consumes two presign methods through
62
+ * the `R2Minter` port. Nothing here reads an access key. The store resolves that credential
63
+ * **lazily**, so a request that only reads or deletes — both of which stay on the bucket binding —
64
+ * never touches the secrets store at all.
65
+ */
66
+ export async function resolveStorage(env: StorageEnv, config: MediaConfig): Promise<MediaStorage> {
67
+ const store = await sharedSecretsStore(env, mediaSecretsRegistry);
68
+ const credentials = store.get(MEDIA_STORAGE_SECRET);
69
+ const clientConfig = { apiToken: credentials.apiToken, accountId: credentials.accountId };
70
+ const owner = assetOwner(env);
71
+ return mediaStorage({
72
+ image: imageMinter(new CloudflareImageManager(clientConfig), owner),
73
+ video: videoMinter(new CloudflareStreamManager(clientConfig), owner),
74
+ r2: objectStoreMinter(objectStore({ bucket: env.MEDIA_BUCKET, env, secretName: MEDIA_R2_SECRET })),
75
+ bucket: env.MEDIA_BUCKET,
76
+ config,
77
+ });
78
+ }
@@ -0,0 +1,130 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { R2Bucket } from "@cloudflare/workers-types";
5
+ import type { MediaConfig } from "../config/config";
6
+ import type { MediaType, StorageBackend } from "../data/enums";
7
+ import { MediaStorageError, MediaUnsupportedError } from "../error/errors";
8
+ import { backendForType, mediaR2Key } from "./backend";
9
+ import type { ImageMinter, R2Minter, VideoMinter } from "./minter";
10
+
11
+ /** The minters and bucket binding the storage layer needs, plus the resolved config. */
12
+ export interface StorageDeps {
13
+ /** Cloudflare Images minter. */
14
+ image: ImageMinter;
15
+ /** Cloudflare Stream minter. */
16
+ video: VideoMinter;
17
+ /** R2 presigned-URL minter. */
18
+ r2: R2Minter;
19
+ /** The R2 bucket binding, for reading and deleting objects (bindings-first). */
20
+ bucket: R2Bucket;
21
+ /** The resolved media config — drives per-type backend selection. */
22
+ config: MediaConfig;
23
+ }
24
+
25
+ /** What an upload needs: the target the client uploads to, and where the bytes will live. */
26
+ export interface UploadTarget {
27
+ /** The URL the client uploads the bytes straight to (bytes never proxy through the Worker). */
28
+ uploadUrl: string;
29
+ /** The backend the bytes live in. */
30
+ storageBackend: StorageBackend;
31
+ /** The backend-specific handle: the R2 object key, the CF Images id, or the CF Stream uid. */
32
+ storageKey: string;
33
+ }
34
+
35
+ /** Parameters for minting an upload URL. */
36
+ export interface MintParams {
37
+ /** The media type — drives backend selection. */
38
+ type: MediaType;
39
+ /** The record id, used to build a stable R2 key. */
40
+ id: string;
41
+ /** The MIME type, forwarded to the presigned R2 PUT. */
42
+ contentType: string;
43
+ /** The declared size in bytes, forwarded to the presigned R2 PUT; 0 when unknown. */
44
+ size?: number;
45
+ /**
46
+ * Small metadata forwarded to the Cloudflare Images or Stream direct upload — the caller's own keys
47
+ * (identity, album, whatever the adopter tracks). The minters merge the ownership stamp
48
+ * (`pithyProject`/`pithyEnv`) over it, so nothing here can omit or displace it.
49
+ */
50
+ metadata?: Record<string, string>;
51
+ }
52
+
53
+ /** A record's storage coordinates, enough to read or delete its object. */
54
+ export interface StorageLocation {
55
+ /** The backend the bytes live in. */
56
+ storageBackend: StorageBackend;
57
+ /** The backend-specific handle. */
58
+ storageKey: string;
59
+ }
60
+
61
+ /** The storage seam the routes and workflows use: mint an upload, read bytes, delete an object. */
62
+ export interface MediaStorage {
63
+ /** Mint a direct-upload URL and resolve where the bytes will live. */
64
+ mintUpload(params: MintParams): Promise<UploadTarget>;
65
+ /** Delete a stored object (best-effort cleanup when a record is deleted). */
66
+ deleteObject(location: StorageLocation): Promise<void>;
67
+ /** Read a stored R2 object's bytes (enrichment reads audio/documents through the binding). */
68
+ readR2Object(key: string): Promise<Uint8Array>;
69
+ /**
70
+ * A presigned, time-limited GET URL for a private R2-backed record — the consumer URL for audio and
71
+ * documents (and R2-stored images/video). Throws `media/unsupported` for a Cloudflare Images or Stream
72
+ * record, which have their own delivery URLs (see `deliver/url.ts`).
73
+ */
74
+ presignedDownloadUrl(location: StorageLocation): Promise<string>;
75
+ }
76
+
77
+ /** Build the storage seam over the injected minters, bucket binding, and config. */
78
+ export function mediaStorage(deps: StorageDeps): MediaStorage {
79
+ return {
80
+ async mintUpload(params) {
81
+ const backend = backendForType(params.type, deps.config);
82
+ try {
83
+ if (backend === "cf-images") {
84
+ const result = await deps.image.mintDirectUpload(params.metadata);
85
+ return { uploadUrl: result.uploadUrl, storageBackend: "cf-images", storageKey: result.id };
86
+ }
87
+ if (backend === "cf-stream") {
88
+ const result = await deps.video.mintDirectUpload(params.metadata);
89
+ return { uploadUrl: result.uploadUrl, storageBackend: "cf-stream", storageKey: result.uid };
90
+ }
91
+ const key = mediaR2Key(params.type, params.id);
92
+ const uploadUrl = await deps.r2.mintUpload(key, params.contentType, params.size ?? 0);
93
+ return { uploadUrl, storageBackend: "r2", storageKey: key };
94
+ } catch (error) {
95
+ throw new MediaStorageError({ detail: `mint upload failed for ${params.type}` }, { cause: error });
96
+ }
97
+ },
98
+
99
+ async deleteObject(location) {
100
+ try {
101
+ if (location.storageBackend === "cf-images") return await deps.image.delete(location.storageKey);
102
+ if (location.storageBackend === "cf-stream") return await deps.video.delete(location.storageKey);
103
+ await deps.bucket.delete(location.storageKey);
104
+ } catch (error) {
105
+ throw new MediaStorageError({ detail: `delete object failed for ${location.storageKey}` }, { cause: error });
106
+ }
107
+ },
108
+
109
+ async readR2Object(key) {
110
+ const object = await deps.bucket.get(key);
111
+ if (!object) {
112
+ throw new MediaStorageError({ detail: `R2 object not found: ${key}` });
113
+ }
114
+ return new Uint8Array(await object.arrayBuffer());
115
+ },
116
+
117
+ async presignedDownloadUrl(location) {
118
+ if (location.storageBackend !== "r2") {
119
+ throw new MediaUnsupportedError({
120
+ detail: `presignedDownloadUrl is R2-only; ${location.storageBackend} has its own delivery URL`,
121
+ });
122
+ }
123
+ try {
124
+ return await deps.r2.mintDownload(location.storageKey);
125
+ } catch (error) {
126
+ throw new MediaStorageError({ detail: `presign download failed for ${location.storageKey}` }, { cause: error });
127
+ }
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
5
+ //
6
+ // A Worker cannot read its own package.json, so this is how @pithy-sh/media knows its own version at
7
+ // runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
8
+ // which is what answers "should this project upgrade" and "is this customer exposed to what we just
9
+ // fixed". Those questions are only answerable per module, because a project composes some capabilities
10
+ // and not others.
11
+
12
+ /** This package's npm name — the join key against a release feed. */
13
+ export const PACKAGE_NAME = "@pithy-sh/media";
14
+
15
+ /** This package's version, stamped from its own package.json at generation time. */
16
+ export const PACKAGE_VERSION = "0.1.0";
@@ -0,0 +1,116 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { extractMarkdown, generateImageText, type MediaAi, transcribeAudioBytes } from "../ai/enrich";
5
+ import {
6
+ type AudioSegment,
7
+ deduplicateOverlappingTranscripts,
8
+ groupSegmentsForTranscription,
9
+ } from "../ai/videoBatching";
10
+ import type { MediaRecord, RecordStore } from "../record/store";
11
+
12
+ /**
13
+ * The enrichment orchestration — the pure, testable core each Workflow step runs. Every function reads a
14
+ * record, produces derived content with Workers AI, and writes it back through the record store. Byte
15
+ * access is an injected seam (`readBytes` / `readDocument` / `fetchVideoAudio`) so the orchestration is
16
+ * exercised against fakes; the Workflow worker wires the real reads over the R2 binding and the Stream
17
+ * manager. The AI model is always a parameter, defaulted from config.
18
+ */
19
+
20
+ /** One audio segment's bytes and duration, from a Stream HLS audio rendition. */
21
+ export interface AudioSegmentBytes {
22
+ /** The decoded segment bytes (a fragmented-MP4 media segment). */
23
+ bytes: Uint8Array;
24
+ /** The segment's duration in seconds, from the HLS `#EXTINF` tag. */
25
+ durationSec: number;
26
+ }
27
+
28
+ /** The audio a video's transcription reads: the shared fMP4 init segment plus the ordered media segments. */
29
+ export interface VideoAudioSource {
30
+ /** The fragmented-MP4 initialization segment — codec config, prepended to every group. */
31
+ init: Uint8Array;
32
+ /** The ordered audio segments. */
33
+ segments: AudioSegmentBytes[];
34
+ }
35
+
36
+ /** The dependencies the enrichment functions need, all injectable. */
37
+ export interface EnrichDeps {
38
+ /** The record store, bound to the effective schema. */
39
+ store: RecordStore;
40
+ /** The Workers AI binding. */
41
+ ai: MediaAi;
42
+ /** The configured models, per feature. */
43
+ models: { imageToText: string; transcribe: string };
44
+ /** Read a stored object's raw bytes (images from R2 or delivery URL; audio from R2). */
45
+ readBytes: (record: MediaRecord) => Promise<Uint8Array>;
46
+ /** Read a document as a Blob for `toMarkdown`. */
47
+ readDocument: (record: MediaRecord) => Promise<{ name: string; blob: Blob }>;
48
+ /** Fetch a video's HLS audio rendition as an init segment plus ordered media segments. */
49
+ fetchVideoAudio: (record: MediaRecord) => Promise<VideoAudioSource>;
50
+ }
51
+
52
+ /** Concatenate byte chunks into one buffer. */
53
+ function concatBytes(chunks: Uint8Array[]): Uint8Array {
54
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
55
+ const out = new Uint8Array(total);
56
+ let offset = 0;
57
+ for (const chunk of chunks) {
58
+ out.set(chunk, offset);
59
+ offset += chunk.length;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** Generate alt text and a caption for an image and write them to the record. */
65
+ export async function runImageToText(deps: EnrichDeps, id: string): Promise<void> {
66
+ const record = await deps.store.get(id);
67
+ if (record?.type !== "image") return;
68
+ const bytes = await deps.readBytes(record);
69
+ const { altText, caption } = await generateImageText(deps.ai, bytes, deps.models.imageToText);
70
+ await deps.store.patch(id, { altText, caption, updatedAt: new Date() });
71
+ }
72
+
73
+ /** Transcribe an audio file in one call and write the transcription to the record. */
74
+ export async function runAudioTranscription(deps: EnrichDeps, id: string): Promise<void> {
75
+ const record = await deps.store.get(id);
76
+ if (record?.type !== "audio") return;
77
+ const bytes = await deps.readBytes(record);
78
+ const transcription = await transcribeAudioBytes(deps.ai, bytes, deps.models.transcribe);
79
+ await deps.store.patch(id, { transcription, hasTranscription: true, updatedAt: new Date() });
80
+ }
81
+
82
+ /** Extract markdown text from a document and write it to the record. */
83
+ export async function runDocumentExtraction(deps: EnrichDeps, id: string): Promise<void> {
84
+ const record = await deps.store.get(id);
85
+ if (record?.type !== "document") return;
86
+ const file = await deps.readDocument(record);
87
+ const extractedText = await extractMarkdown(deps.ai, [file]);
88
+ await deps.store.patch(id, { extractedText, hasExtractedText: true, updatedAt: new Date() });
89
+ }
90
+
91
+ /**
92
+ * Transcribe a video's audio: fetch its HLS audio rendition, group the segments into ~30s overlapping
93
+ * batches (whisper has a per-call input limit), transcribe each batch with the shared fMP4 init segment
94
+ * prepended, then stitch the batches back with overlap-dedup. Writes the transcription to the record.
95
+ */
96
+ export async function runVideoTranscription(deps: EnrichDeps, id: string): Promise<void> {
97
+ const record = await deps.store.get(id);
98
+ if (record?.type !== "video") return;
99
+ const source = await deps.fetchVideoAudio(record);
100
+ const asSegments: AudioSegment[] = source.segments.map((segment, index) => ({
101
+ uri: String(index),
102
+ durationSec: segment.durationSec,
103
+ }));
104
+ const groups = groupSegmentsForTranscription(asSegments);
105
+ const transcripts: string[] = [];
106
+ for (const group of groups) {
107
+ const parts: Uint8Array[] = [source.init];
108
+ for (const segment of group.segments) {
109
+ const bytes = source.segments[Number(segment.uri)]?.bytes;
110
+ if (bytes) parts.push(bytes);
111
+ }
112
+ transcripts.push(await transcribeAudioBytes(deps.ai, concatBytes(parts), deps.models.transcribe));
113
+ }
114
+ const transcription = deduplicateOverlappingTranscripts(transcripts);
115
+ await deps.store.patch(id, { transcription, hasTranscription: true, updatedAt: new Date() });
116
+ }
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { MediaEnrichmentError } from "../error/errors";
5
+ import type { VideoAudioSource } from "./enrich";
6
+
7
+ /**
8
+ * Minimal HLS parsing for Cloudflare Stream's audio rendition. Stream serves a master manifest that
9
+ * points at an audio-only rendition; that rendition is a fragmented-MP4 playlist — a single `#EXT-X-MAP`
10
+ * init segment plus `#EXTINF`-timed media segments, each undecodable without the init. The two parsers
11
+ * are pure and unit-tested; {@link fetchVideoAudio} is the thin fetch orchestrator the Workflow injects.
12
+ */
13
+
14
+ /** Extract the audio rendition's playlist URI from a master manifest, or null if none is present. */
15
+ export function parseAudioRenditionUri(master: string): string | null {
16
+ for (const line of master.split(/\r?\n/)) {
17
+ if (line.startsWith("#EXT-X-MEDIA:") && /TYPE=AUDIO/.test(line)) {
18
+ const match = line.match(/URI="([^"]+)"/);
19
+ if (match?.[1]) return match[1];
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /** One media segment reference: its URI and duration in seconds. */
26
+ export interface MediaSegmentRef {
27
+ /** The segment URI, relative to the media playlist. */
28
+ uri: string;
29
+ /** The segment duration in seconds, from its `#EXTINF` tag. */
30
+ durationSec: number;
31
+ }
32
+
33
+ /** Parse an fMP4 media playlist into its init-segment URI and ordered, timed media segments. */
34
+ export function parseMediaPlaylist(playlist: string): { initUri: string | null; segments: MediaSegmentRef[] } {
35
+ const lines = playlist.split(/\r?\n/);
36
+ let initUri: string | null = null;
37
+ let pendingDuration: number | null = null;
38
+ const segments: MediaSegmentRef[] = [];
39
+ for (const raw of lines) {
40
+ const line = raw.trim();
41
+ if (line.startsWith("#EXT-X-MAP:")) {
42
+ const match = line.match(/URI="([^"]+)"/);
43
+ if (match?.[1]) initUri = match[1];
44
+ } else if (line.startsWith("#EXTINF:")) {
45
+ const value = Number.parseFloat(line.slice("#EXTINF:".length));
46
+ pendingDuration = Number.isFinite(value) ? value : 0;
47
+ } else if (line.length > 0 && !line.startsWith("#")) {
48
+ segments.push({ uri: line, durationSec: pendingDuration ?? 0 });
49
+ pendingDuration = null;
50
+ }
51
+ }
52
+ return { initUri, segments };
53
+ }
54
+
55
+ /** A minimal fetcher seam so {@link fetchVideoAudio} is injectable in tests. */
56
+ export type Fetcher = (
57
+ url: string,
58
+ ) => Promise<{ ok: boolean; status: number; text(): Promise<string>; arrayBuffer(): Promise<ArrayBuffer> }>;
59
+
60
+ /** Fetch and validate a URL, throwing a media enrichment error on a non-OK response. */
61
+ async function fetchOrThrow(
62
+ fetcher: Fetcher,
63
+ url: string,
64
+ ): Promise<{ text(): Promise<string>; arrayBuffer(): Promise<ArrayBuffer> }> {
65
+ const response = await fetcher(url);
66
+ if (!response.ok) {
67
+ throw new MediaEnrichmentError({ detail: `HLS fetch failed (${response.status}): ${url}` });
68
+ }
69
+ return response;
70
+ }
71
+
72
+ /**
73
+ * Fetch a Stream video's HLS audio rendition and assemble it into a {@link VideoAudioSource}: the init
74
+ * segment plus every media segment's bytes and duration, ready for {@link runVideoTranscription} to group.
75
+ */
76
+ export async function fetchVideoAudio(masterUrl: string, fetcher: Fetcher = globalFetcher): Promise<VideoAudioSource> {
77
+ const master = await (await fetchOrThrow(fetcher, masterUrl)).text();
78
+ const renditionUri = parseAudioRenditionUri(master);
79
+ if (!renditionUri) throw new MediaEnrichmentError({ detail: "no audio rendition in the HLS master manifest" });
80
+ const renditionUrl = new URL(renditionUri, masterUrl).toString();
81
+
82
+ const playlist = await (await fetchOrThrow(fetcher, renditionUrl)).text();
83
+ const { initUri, segments } = parseMediaPlaylist(playlist);
84
+ if (!initUri) throw new MediaEnrichmentError({ detail: "no init segment in the HLS audio playlist" });
85
+
86
+ const init = new Uint8Array(
87
+ await (await fetchOrThrow(fetcher, new URL(initUri, renditionUrl).toString())).arrayBuffer(),
88
+ );
89
+ const resolved = [];
90
+ for (const segment of segments) {
91
+ const url = new URL(segment.uri, renditionUrl).toString();
92
+ const bytes = new Uint8Array(await (await fetchOrThrow(fetcher, url)).arrayBuffer());
93
+ resolved.push({ bytes, durationSec: segment.durationSec });
94
+ }
95
+ return { init, segments: resolved };
96
+ }
97
+
98
+ /** The default fetcher — the Workers global `fetch`. */
99
+ const globalFetcher: Fetcher = (url) => fetch(url);
@@ -0,0 +1,56 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
5
+
6
+ /**
7
+ * **What an enrichment retries, and what it refuses to.**
8
+ *
9
+ * Enrichment is the one shape in this kit where terminal is *expensive*. The four Workflows are started
10
+ * once, on finalize, and nothing starts them again: a fault the step calls terminal is an asset that
11
+ * silently keeps no alt text and no transcription, and nobody finds out because a missing caption looks
12
+ * exactly like a caption nobody wanted. So the retryable list here is longer than most, and every entry
13
+ * on it is a service this capability does not control (pithy-sh/pithy#348).
14
+ *
15
+ * ## Retryable, and why
16
+ *
17
+ * - **`core/upstream_failed`** — Workers AI rejected the call. Raised at the one seam in `ai/enrich.ts`
18
+ * that wraps the binding, and *only* there: a model that answered in a shape the schema refuses is
19
+ * `media/enrichment_failed` and stays terminal. The split is by code rather than by phrasing because
20
+ * the step can only act on a code. A model that was overloaded for ten seconds answers on the second
21
+ * attempt.
22
+ * - **`cloudflare/request_failed`** — the Stream REST API, unreachable or answering 5xx. Video
23
+ * transcription asks Stream for an HLS playback URL before it fetches a single byte, and
24
+ * `cloudflareRequest` folds every transport failure into this one code.
25
+ * - **`media/enrichment_failed`** — **the deliberate exception, and the reason it earns its place is
26
+ * `fetchVideoAudio`.** A video whose Stream asset has not finished encoding has no HLS playback URL
27
+ * yet, and that is what the enrichment raises: not a refusal, a *not yet*. It is the single most
28
+ * likely failure of a video Workflow started the moment an upload finalizes, and it resolves on its
29
+ * own within a minute. The cost of admitting it is that the code's other producers — an R2 object that
30
+ * is genuinely missing, a manifest with no audio rendition — buy five cheap attempts before failing.
31
+ * That is a worse diagnosis and a bounded one; a transcription permanently lost to an encode still in
32
+ * progress is neither.
33
+ * - **A transient D1 fault** — the media table, when records live in D1. Classified in core by
34
+ * `withD1Retry`, never restated here.
35
+ *
36
+ * ## Terminal, and why
37
+ *
38
+ * - **`media/not_found`** — the record is gone. A Workflow instance outliving its row is ordinary, and
39
+ * the row does not come back over a backoff.
40
+ * - **`media/unsupported`** — a type or backend enrichment cannot read. A fact about the asset.
41
+ * - **`media/storage_failed`** — a mint, a delete, or a presign the storage plane refused. None of them
42
+ * run inside an enrichment step; one reaching here is a bug, and a bug should surface.
43
+ * - **`cloudflare/invalid_response`, `cloudflare/not_configured`** — a Stream response that did not
44
+ * match its schema, or a manager with no account id. A shape and a config; neither changes.
45
+ * - **`validation/invalid_input`** — a config or a payload the schema refuses.
46
+ */
47
+ export const mediaWorkflowRetry: WorkflowRetryPolicy = {
48
+ capability: "media",
49
+ retryable: {
50
+ "core/upstream_failed":
51
+ "Workers AI rejected the call rather than answering it; an overloaded model answers next time.",
52
+ "cloudflare/request_failed": "The Stream API could not be reached; the same read is idempotent and may reach it.",
53
+ "media/enrichment_failed":
54
+ "A video's Stream asset may still be encoding, so it has no HLS audio yet — a not-yet rather than a refusal.",
55
+ },
56
+ };
@@ -0,0 +1,75 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
5
+ import type { WorkflowRegistry, WorkflowSpecMap } from "@pithy-sh/core/src/workflow/spec";
6
+ import { z } from "zod";
7
+
8
+ /**
9
+ * The four enrichment jobs media owns, declared once.
10
+ *
11
+ * This map is the single description of media's durable work. `capability.ts` derives the four
12
+ * `workflow` bindings from it, `http/dispatch.ts` dispatches through it, and `pithy media provision`
13
+ * resolves the host worker's `workflows` array and per-environment names from it. Previously the same
14
+ * facts were written three times — in the capability's bindings, in the hand-rolled dispatcher, and in
15
+ * the committed `wrangler.jsonc` — and nothing kept them in agreement.
16
+ *
17
+ * Every job is `optional: true`. The Workflows live in the prebuilt media worker, deployed only by
18
+ * `pithy media provision`, so a project that has not provisioned must still boot and serve every
19
+ * non-enrichment route. An absent binding degrades to a logged skip, never a startup failure.
20
+ */
21
+
22
+ /** The capability name — the first segment of every media dispatch key and deployed workflow name. */
23
+ export const MEDIA_CAPABILITY = "media";
24
+
25
+ /** The parameters every enrichment job takes: which media record to enrich. */
26
+ export const MediaEnrichmentParams = z
27
+ .object({
28
+ id: z
29
+ .string()
30
+ .min(1)
31
+ .describe("The media record's id. The Workflow reads that record, enriches it, and writes the result back."),
32
+ })
33
+ .describe("The instance parameters of an enrichment Workflow — the one record it runs against.");
34
+ export type MediaEnrichmentParams = z.infer<typeof MediaEnrichmentParams>;
35
+
36
+ /** Media's durable jobs, keyed by job name. The key is the second segment of the `media/<job>` dispatch key. */
37
+ export const mediaWorkflows = {
38
+ "image-to-text": {
39
+ binding: "MEDIA_IMAGE_TO_TEXT",
40
+ className: "MediaImageToTextWorkflow",
41
+ params: MediaEnrichmentParams,
42
+ optional: true,
43
+ },
44
+ "audio-transcribe": {
45
+ binding: "MEDIA_AUDIO_TRANSCRIBE",
46
+ className: "MediaAudioTranscribeWorkflow",
47
+ params: MediaEnrichmentParams,
48
+ optional: true,
49
+ },
50
+ "video-transcribe": {
51
+ binding: "MEDIA_VIDEO_TRANSCRIBE",
52
+ className: "MediaVideoTranscribeWorkflow",
53
+ params: MediaEnrichmentParams,
54
+ optional: true,
55
+ },
56
+ "doc-extract": {
57
+ binding: "MEDIA_DOC_EXTRACT",
58
+ className: "MediaDocExtractWorkflow",
59
+ params: MediaEnrichmentParams,
60
+ optional: true,
61
+ },
62
+ } as const satisfies WorkflowSpecMap;
63
+
64
+ /**
65
+ * Media's jobs as a dispatch registry, keyed `media/<job>`. Built here rather than through
66
+ * `composeWorkflows` because the routes dispatch from inside the media capability, before any
67
+ * project-wide registry exists — and the key format comes from core's {@link workflowKey} either way,
68
+ * so the two cannot drift.
69
+ */
70
+ export const mediaWorkflowRegistry: WorkflowRegistry = Object.fromEntries(
71
+ Object.entries(mediaWorkflows).map(([job, spec]) => {
72
+ const key = workflowKey(MEDIA_CAPABILITY, job);
73
+ return [key, { key, capability: MEDIA_CAPABILITY, job, spec }];
74
+ }),
75
+ );