@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +52 -0
- package/pithy.manifest.json +85 -0
- package/src/ai/enrich.ts +187 -0
- package/src/ai/videoBatching.ts +130 -0
- package/src/capability.ts +129 -0
- package/src/cloudflare-test.d.ts +15 -0
- package/src/config/config.ts +155 -0
- package/src/data/enums.ts +53 -0
- package/src/data/extend.ts +135 -0
- package/src/data/mediaAsset.ts +78 -0
- package/src/data/mediaHash.ts +24 -0
- package/src/data/tables.ts +45 -0
- package/src/deliver/url.ts +78 -0
- package/src/error/errors.ts +95 -0
- package/src/hash/duplicates.ts +129 -0
- package/src/hash/sha256.ts +34 -0
- package/src/http/dispatch.ts +86 -0
- package/src/http/guard.ts +25 -0
- package/src/http/handlers.ts +205 -0
- package/src/http/routes.ts +119 -0
- package/src/http/schemas.ts +90 -0
- package/src/index.ts +39 -0
- package/src/migrations/0001_init.ts +104 -0
- package/src/migrations/extend.ts +40 -0
- package/src/provision/provisionMedia.ts +226 -0
- package/src/provision/resolveMediaConfig.ts +78 -0
- package/src/record/d1Store.ts +95 -0
- package/src/record/hashStore.ts +104 -0
- package/src/record/kvStore.ts +147 -0
- package/src/record/resolve.ts +34 -0
- package/src/record/store.ts +50 -0
- package/src/secret/registry.ts +85 -0
- package/src/storage/backend.ts +29 -0
- package/src/storage/cloudflare.ts +77 -0
- package/src/storage/minter.ts +57 -0
- package/src/storage/resolve.ts +78 -0
- package/src/storage/storage.ts +130 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/enrich.ts +116 -0
- package/src/workflows/hls.ts +99 -0
- package/src/workflows/retryPolicy.ts +56 -0
- package/src/workflows/specs.ts +75 -0
- package/src/workflows/worker.ts +192 -0
- 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
|
+
}
|