@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,192 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
5
+ import { NonRetryableError } from "cloudflare:workflows";
6
+ import type { D1Database, KVNamespace, R2Bucket } from "@cloudflare/workers-types";
7
+ import { CloudflareStreamManager } from "@pithy-sh/cloudflare/src/media/streamManager";
8
+ import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
9
+ import { workflowHostEntry } from "@pithy-sh/core/src/workflow/hostEntry";
10
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
11
+ import { configureSharedSecrets, sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
12
+ import { z } from "zod";
13
+ import type { MediaAi } from "../ai/enrich";
14
+ import { MediaConfig } from "../config/config";
15
+ import { MediaAsset } from "../data/mediaAsset";
16
+ import { MediaEnrichmentError } from "../error/errors";
17
+ import { resolveRecordStore } from "../record/resolve";
18
+ import type { MediaRecord } from "../record/store";
19
+ import { MEDIA_STORAGE_SECRET, mediaSecretsRegistry } from "../secret/registry";
20
+ import {
21
+ type EnrichDeps,
22
+ runAudioTranscription,
23
+ runDocumentExtraction,
24
+ runImageToText,
25
+ runVideoTranscription,
26
+ } from "./enrich";
27
+ import { fetchVideoAudio } from "./hls";
28
+ import { mediaWorkflowRetry } from "./retryPolicy";
29
+ import { MEDIA_CAPABILITY } from "./specs";
30
+
31
+ /**
32
+ * The prebuilt media worker. `pithy media provision` deploys one per environment; the adopter authors no
33
+ * code for it. It hosts the four enrichment Workflows — image-to-text, audio transcription, video
34
+ * transcription, and document extraction — each a thin durable shell around the tested orchestration in
35
+ * `enrich.ts`. The app worker holds their bindings and starts an instance on finalize.
36
+ *
37
+ * This module imports `cloudflare:workers`, so it runs only in the Workers runtime (excluded from the node
38
+ * meta-test). The worker reads records with a passthrough schema so an adopter's extension fields survive
39
+ * the derived-field write-back untouched, without the worker needing the adopter's extension code.
40
+ *
41
+ * **The default export is what makes this an ES module** (#426). It exports four Workflow classes and has no
42
+ * cron, so until now it exported no default — and wrangler infers a worker's module format from exactly
43
+ * that, so the build read it as a service worker and refused `cloudflare:workers` outright. The host did not
44
+ * build, `pithy dev` carried on past it, and every finalize that dispatched an enrichment enriched nothing.
45
+ * The refusal it exports is the honest body for a host with no request surface; see
46
+ * `@pithy-sh/core/src/workflow/hostEntry`.
47
+ */
48
+
49
+ /** The media worker's env: the record bindings, the R2 bucket, the AI binding, the secrets, and config. */
50
+ export interface MediaWorkerEnv extends SecretsStoreEnv {
51
+ /** The app database the media table lives in (D1 record store). */
52
+ DB: D1Database;
53
+ /** The KV namespace media records live in (KV record store). */
54
+ MEDIA: KVNamespace;
55
+ /** The R2 bucket media objects are read from. */
56
+ MEDIA_BUCKET: R2Bucket;
57
+ /** The Workers AI binding. */
58
+ AI: MediaAi;
59
+ /** The resolved media config as a JSON string, filled at provision. */
60
+ MEDIA_CONFIG?: string;
61
+ /**
62
+ * The project name, stamped alongside `ENVIRONMENT` at provision. Enrichment only reads and updates
63
+ * existing assets, so nothing here mints one — but the var is part of every host's identity, and it
64
+ * is what any future create path in this worker would stamp ownership from.
65
+ */
66
+ PROJECT?: string;
67
+ }
68
+
69
+ // A standalone worker, not assembled by `createBackend`, so wire the shared secrets accessor directly.
70
+ configureSharedSecrets({ registry: mediaSecretsRegistry });
71
+
72
+ /** The passthrough record schema: base fields decode through codecs, extension fields ride along raw. */
73
+ const LooseAsset = MediaAsset.catchall(z.unknown());
74
+
75
+ /** Read a stored object's raw bytes: R2 through the binding, Cloudflare Images through its delivery URL. */
76
+ async function readObjectBytes(
77
+ env: MediaWorkerEnv,
78
+ imagesAccountHash: string | undefined,
79
+ record: MediaRecord,
80
+ ): Promise<Uint8Array> {
81
+ if (record.storageBackend === "r2") {
82
+ const object = await env.MEDIA_BUCKET.get(record.storageKey);
83
+ if (!object) throw new MediaEnrichmentError({ detail: `R2 object missing: ${record.storageKey}` });
84
+ return new Uint8Array(await object.arrayBuffer());
85
+ }
86
+ if (record.storageBackend === "cf-images") {
87
+ if (!imagesAccountHash) {
88
+ throw new MediaEnrichmentError({
89
+ detail: "delivery.imagesAccountHash is required to read image bytes for enrichment",
90
+ });
91
+ }
92
+ const url = `https://imagedelivery.net/${imagesAccountHash}/${record.storageKey}/public`;
93
+ const response = await fetch(url);
94
+ if (!response.ok) throw new MediaEnrichmentError({ detail: `image fetch failed (${response.status})` });
95
+ return new Uint8Array(await response.arrayBuffer());
96
+ }
97
+ throw new MediaEnrichmentError({ detail: `cannot read bytes for backend ${record.storageBackend}` });
98
+ }
99
+
100
+ /**
101
+ * Assemble the enrichment dependencies from the worker env. `transcribeKind` picks which speech-to-text
102
+ * model config supplies — audio and video are configured independently. A job that does not transcribe
103
+ * (image-to-text, document extraction) omits it and never reads the value; the audio model is the inert
104
+ * default rather than a claim about what the job does.
105
+ */
106
+ async function buildDeps(env: MediaWorkerEnv, transcribeKind: "audio" | "video" = "audio"): Promise<EnrichDeps> {
107
+ // Parsed once per run and threaded through — the models, the record store, and the delivery hash all
108
+ // come from this one config.
109
+ const config = MediaConfig.parse(env.MEDIA_CONFIG ? JSON.parse(env.MEDIA_CONFIG) : {});
110
+ const store = resolveRecordStore(env, config, LooseAsset);
111
+ const secrets = await sharedSecretsStore(env, mediaSecretsRegistry);
112
+ const credentials = secrets.get(MEDIA_STORAGE_SECRET);
113
+ const imagesAccountHash = config.delivery.imagesAccountHash;
114
+ const streamManager = new CloudflareStreamManager({
115
+ apiToken: credentials.apiToken,
116
+ accountId: credentials.accountId,
117
+ });
118
+ return {
119
+ store,
120
+ ai: env.AI,
121
+ models: {
122
+ imageToText: config.images.model,
123
+ transcribe: transcribeKind === "video" ? config.video.model : config.audio.model,
124
+ },
125
+ readBytes: (record) => readObjectBytes(env, imagesAccountHash, record),
126
+ readDocument: async (record) => ({
127
+ name: record.filename,
128
+ blob: new Blob([await readObjectBytes(env, imagesAccountHash, record)]),
129
+ }),
130
+ fetchVideoAudio: async (record) => {
131
+ const details = (await streamManager.getVideoDetails(record.storageKey)) as { playback?: { hls?: string } };
132
+ const hls = details.playback?.hls;
133
+ if (!hls) throw new MediaEnrichmentError({ detail: `video ${record.storageKey} has no HLS playback URL yet` });
134
+ return fetchVideoAudio(hls);
135
+ },
136
+ };
137
+ }
138
+
139
+ /**
140
+ * The four enrichment Workflows all run their one step under {@link mediaWorkflowRetry}: an
141
+ * unreachable model, an unreachable Stream API, and a video whose asset is still encoding re-drive;
142
+ * a record that is gone, a type enrichment cannot read, and a model answer the schema refuses fail at
143
+ * once. See `retryPolicy.ts` — the asymmetry matters more here than anywhere else in the kit, because
144
+ * nothing restarts an enrichment and a missing caption looks exactly like a caption nobody wanted.
145
+ */
146
+
147
+ /** Image → alt text and caption. Does not transcribe. */
148
+ export class MediaImageToTextWorkflow extends WorkflowEntrypoint<MediaWorkerEnv, { id: string }> {
149
+ override async run(event: WorkflowEvent<{ id: string }>, step: WorkflowStep): Promise<void> {
150
+ const deps = await buildDeps(this.env);
151
+ await classifiedSteps(step, mediaWorkflowRetry, NonRetryableError).do(`image-to-text-${event.payload.id}`, () =>
152
+ runImageToText(deps, event.payload.id),
153
+ );
154
+ }
155
+ }
156
+
157
+ /** Audio → transcription. */
158
+ export class MediaAudioTranscribeWorkflow extends WorkflowEntrypoint<MediaWorkerEnv, { id: string }> {
159
+ override async run(event: WorkflowEvent<{ id: string }>, step: WorkflowStep): Promise<void> {
160
+ const deps = await buildDeps(this.env, "audio");
161
+ await classifiedSteps(step, mediaWorkflowRetry, NonRetryableError).do(`transcribe-audio-${event.payload.id}`, () =>
162
+ runAudioTranscription(deps, event.payload.id),
163
+ );
164
+ }
165
+ }
166
+
167
+ /** Video → transcription (Stream readiness + HLS batching). */
168
+ export class MediaVideoTranscribeWorkflow extends WorkflowEntrypoint<MediaWorkerEnv, { id: string }> {
169
+ override async run(event: WorkflowEvent<{ id: string }>, step: WorkflowStep): Promise<void> {
170
+ const deps = await buildDeps(this.env, "video");
171
+ await classifiedSteps(step, mediaWorkflowRetry, NonRetryableError).do(`transcribe-video-${event.payload.id}`, () =>
172
+ runVideoTranscription(deps, event.payload.id),
173
+ );
174
+ }
175
+ }
176
+
177
+ /** Document → extracted text. Does not transcribe. */
178
+ export class MediaDocExtractWorkflow extends WorkflowEntrypoint<MediaWorkerEnv, { id: string }> {
179
+ override async run(event: WorkflowEvent<{ id: string }>, step: WorkflowStep): Promise<void> {
180
+ const deps = await buildDeps(this.env);
181
+ await classifiedSteps(step, mediaWorkflowRetry, NonRetryableError).do(`extract-document-${event.payload.id}`, () =>
182
+ runDocumentExtraction(deps, event.payload.id),
183
+ );
184
+ }
185
+ }
186
+
187
+ /**
188
+ * The module's default export, and therefore its format. See `hostEntry` for why a Workflow host needs one
189
+ * at all, and why this one refuses rather than being empty: nothing reaches this worker over HTTP — the
190
+ * media routes live in the app worker, which starts an instance on the matching binding at finalize.
191
+ */
192
+ export default workflowHostEntry(MEDIA_CAPABILITY);
@@ -0,0 +1,73 @@
1
+ {
2
+ // The prebuilt media enrichment worker. Like the email worker, this is a TEMPLATE, not a wrangler
3
+ // env-stanza file: staging and prod are separate workers. `pithy media provision` resolves this
4
+ // into one complete config per environment — filling the `<...>` placeholders — and deploys each with
5
+ // `wrangler deploy --config <resolved>`. The adopter authors none of it. It hosts the four enrichment
6
+ // Workflows; the app worker holds their bindings and starts an instance on finalize.
7
+ // Resolved per project and env → <project>-staging-media / <project>-prod-media. Worker script
8
+ // names are account-scoped, so the project segment is what stops a second Pithy project's deploy
9
+ // overwriting this one's running worker instead of colliding with it.
10
+ "name": "pithy-media",
11
+ "main": "./worker.ts",
12
+ // The compatibility date every Worker in this repository runs on. Stated once in the repository
13
+ // root's `compatibility.ts` and copied here because JSONC cannot import it —
14
+ // `cli/src/ci/compatibilityDates.test.ts` fails on any Worker older than it.
15
+ "compatibility_date": "2026-06-01",
16
+ "compatibility_flags": ["nodejs_compat"],
17
+
18
+ // No public URL. The media routes live in the app worker; this worker is reached only by Workflow
19
+ // dispatch.
20
+ "workers_dev": false,
21
+
22
+ // The app database (D1 record store) and the secrets database, read for the storage credentials.
23
+ "d1_databases": [
24
+ { "binding": "DB", "database_name": "pithy-app", "database_id": "<filled-at-provision>" },
25
+ { "binding": "SECRETS", "database_name": "pithy-secrets", "database_id": "<filled-at-provision>" }
26
+ ],
27
+
28
+ // The KV namespace (KV record store) and the R2 bucket the enrichment reads bytes from.
29
+ "kv_namespaces": [{ "binding": "MEDIA", "id": "<filled-at-provision>" }],
30
+ "r2_buckets": [{ "binding": "MEDIA_BUCKET", "bucket_name": "<filled-at-provision>" }],
31
+
32
+ // The Workers AI binding — image-to-text, whisper transcription, and toMarkdown all run through it.
33
+ "ai": { "binding": "AI" },
34
+
35
+ // The master key for decrypting the storage credentials, read through the secretsStore accessor.
36
+ "secrets_store_secrets": [
37
+ {
38
+ "binding": "SECRETS_ENCRYPTION_KEYS",
39
+ "store_id": "<filled-at-provision>",
40
+ "secret_name": "<filled-at-provision>"
41
+ }
42
+ ],
43
+
44
+ // The four enrichment Workflows this worker hosts. Each `class_name` matches an exported
45
+ // WorkflowEntrypoint subclass; each `binding` is what the app worker dispatches to on finalize.
46
+ // Rewritten at provision from `workflows/specs.ts`, so the deployed names carry the project and the
47
+ // environment — the names below are the template reading as a complete config, nothing more.
48
+ "workflows": [
49
+ { "binding": "MEDIA_IMAGE_TO_TEXT", "name": "pithy-media-image-to-text", "class_name": "MediaImageToTextWorkflow" },
50
+ {
51
+ "binding": "MEDIA_AUDIO_TRANSCRIBE",
52
+ "name": "pithy-media-audio-transcribe",
53
+ "class_name": "MediaAudioTranscribeWorkflow"
54
+ },
55
+ {
56
+ "binding": "MEDIA_VIDEO_TRANSCRIBE",
57
+ "name": "pithy-media-video-transcribe",
58
+ "class_name": "MediaVideoTranscribeWorkflow"
59
+ },
60
+ { "binding": "MEDIA_DOC_EXTRACT", "name": "pithy-media-doc-extract", "class_name": "MediaDocExtractWorkflow" }
61
+ ],
62
+
63
+ "vars": {
64
+ // The resolved MediaConfig as one JSON blob, filled at provision from the app's media() config. The
65
+ // worker parses and validates it; absent falls back to the defaults.
66
+ "MEDIA_CONFIG": "<filled-at-provision>",
67
+ "ENVIRONMENT": "<filled-at-provision>",
68
+ // Who this worker is. Cloudflare Images and Stream are account-flat and key an asset by a
69
+ // Cloudflare-minted id, so no name can scope them — the ownership metadata stamped on each asset
70
+ // is the only thing that says which project owns it, and it is read from here.
71
+ "PROJECT": "<filled-at-provision>"
72
+ }
73
+ }