@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,155 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The media capability's configuration — the thin, user-owned surface in `pithy.config.ts`. The adopter
|
|
8
|
+
* picks a storage backend per media type and toggles AI enrichment; the package owns everything else.
|
|
9
|
+
* Every AI model is a configurable parameter with the CMS default, so an adopter swaps a model without
|
|
10
|
+
* touching package code. Every field is `.describe()`d — the descriptions feed the self-documenting CLI.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Default Workers AI model for image-to-text (alt text and captions). */
|
|
14
|
+
export const DEFAULT_IMAGE_TO_TEXT_MODEL = "@cf/llava-hf/llava-1.5-7b-hf";
|
|
15
|
+
/** Default Workers AI model for speech-to-text transcription (audio and video). */
|
|
16
|
+
export const DEFAULT_TRANSCRIBE_MODEL = "@cf/openai/whisper-large-v3-turbo";
|
|
17
|
+
|
|
18
|
+
/** Image storage + optional alt-text/caption enrichment. */
|
|
19
|
+
export const MediaImageConfig = z
|
|
20
|
+
.object({
|
|
21
|
+
store: z
|
|
22
|
+
.enum(["r2", "cf-images"])
|
|
23
|
+
.default("cf-images")
|
|
24
|
+
.describe(
|
|
25
|
+
"Where image bytes live: `cf-images` (Cloudflare Images — variants and optimized delivery) or `r2` (raw ownership).",
|
|
26
|
+
),
|
|
27
|
+
imageToText: z
|
|
28
|
+
.boolean()
|
|
29
|
+
.default(false)
|
|
30
|
+
.describe("Generate alt text and a caption for each image with Workers AI. A no-op with no cost when off."),
|
|
31
|
+
model: z
|
|
32
|
+
.string()
|
|
33
|
+
.default(DEFAULT_IMAGE_TO_TEXT_MODEL)
|
|
34
|
+
.describe(
|
|
35
|
+
"The Workers AI vision model used for alt text and captions. Override to swap models with no code edits.",
|
|
36
|
+
),
|
|
37
|
+
})
|
|
38
|
+
.describe("Image storage backend and optional image-to-text enrichment.");
|
|
39
|
+
export type MediaImageConfig = z.output<typeof MediaImageConfig>;
|
|
40
|
+
|
|
41
|
+
/** Video storage + optional transcription enrichment. */
|
|
42
|
+
export const MediaVideoConfig = z
|
|
43
|
+
.object({
|
|
44
|
+
store: z
|
|
45
|
+
.enum(["r2", "cf-stream"])
|
|
46
|
+
.default("cf-stream")
|
|
47
|
+
.describe(
|
|
48
|
+
"Where video lives: `cf-stream` (Cloudflare Stream — encoding, HLS delivery, adaptive bitrate) or `r2` (raw files).",
|
|
49
|
+
),
|
|
50
|
+
transcribe: z
|
|
51
|
+
.boolean()
|
|
52
|
+
.default(false)
|
|
53
|
+
.describe("Transcribe each video's audio with Workers AI as a Workflow. A no-op with no cost when off."),
|
|
54
|
+
model: z
|
|
55
|
+
.string()
|
|
56
|
+
.default(DEFAULT_TRANSCRIBE_MODEL)
|
|
57
|
+
.describe(
|
|
58
|
+
"The Workers AI speech-to-text model used for transcription. Override to swap models with no code edits.",
|
|
59
|
+
),
|
|
60
|
+
})
|
|
61
|
+
.describe("Video storage backend and optional speech-to-text enrichment.");
|
|
62
|
+
export type MediaVideoConfig = z.output<typeof MediaVideoConfig>;
|
|
63
|
+
|
|
64
|
+
/** Audio storage (R2) + optional transcription enrichment. */
|
|
65
|
+
export const MediaAudioConfig = z
|
|
66
|
+
.object({
|
|
67
|
+
store: z.literal("r2").default("r2").describe("Audio is always stored in R2; the field is fixed for symmetry."),
|
|
68
|
+
transcribe: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.default(false)
|
|
71
|
+
.describe("Transcribe each audio file with Workers AI as a Workflow. A no-op with no cost when off."),
|
|
72
|
+
model: z
|
|
73
|
+
.string()
|
|
74
|
+
.default(DEFAULT_TRANSCRIBE_MODEL)
|
|
75
|
+
.describe(
|
|
76
|
+
"The Workers AI speech-to-text model used for transcription. Override to swap models with no code edits.",
|
|
77
|
+
),
|
|
78
|
+
})
|
|
79
|
+
.describe("Audio storage (R2) and optional speech-to-text enrichment.");
|
|
80
|
+
export type MediaAudioConfig = z.output<typeof MediaAudioConfig>;
|
|
81
|
+
|
|
82
|
+
/** Document storage (R2) + optional text extraction. */
|
|
83
|
+
export const MediaDocumentConfig = z
|
|
84
|
+
.object({
|
|
85
|
+
store: z
|
|
86
|
+
.literal("r2")
|
|
87
|
+
.default("r2")
|
|
88
|
+
.describe("Documents are always stored in R2; the field is fixed for symmetry."),
|
|
89
|
+
extractText: z
|
|
90
|
+
.boolean()
|
|
91
|
+
.default(false)
|
|
92
|
+
.describe(
|
|
93
|
+
"Extract text (markdown) from pdf/doc/docx with Workers AI as a Workflow. A no-op with no cost when off.",
|
|
94
|
+
),
|
|
95
|
+
})
|
|
96
|
+
.describe("Document storage (R2) and optional text extraction.");
|
|
97
|
+
export type MediaDocumentConfig = z.output<typeof MediaDocumentConfig>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Public delivery configuration — the non-secret account values needed to build consumer URLs for stored
|
|
101
|
+
* media (see `deliver/url.ts`). These are public identifiers (they appear in delivery URLs), so they live
|
|
102
|
+
* in config, not in `@pithy-sh/secrets`.
|
|
103
|
+
*/
|
|
104
|
+
export const MediaDelivery = z
|
|
105
|
+
.object({
|
|
106
|
+
imagesAccountHash: z
|
|
107
|
+
.string()
|
|
108
|
+
.optional()
|
|
109
|
+
.describe(
|
|
110
|
+
"The Cloudflare Images account hash, used to build `imagedelivery.net` URLs and to read image bytes for enrichment.",
|
|
111
|
+
),
|
|
112
|
+
streamCustomerCode: z
|
|
113
|
+
.string()
|
|
114
|
+
.optional()
|
|
115
|
+
.describe(
|
|
116
|
+
"The Cloudflare Stream customer subdomain code, used to build `customer-<code>.cloudflarestream.com` playback URLs.",
|
|
117
|
+
),
|
|
118
|
+
r2PublicBaseUrl: z
|
|
119
|
+
.string()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe(
|
|
122
|
+
"A public base URL for R2 objects when the bucket is served publicly; otherwise consumers use a presigned download URL.",
|
|
123
|
+
),
|
|
124
|
+
})
|
|
125
|
+
.describe("Public delivery configuration for building consumer media URLs.");
|
|
126
|
+
export type MediaDelivery = z.output<typeof MediaDelivery>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The full media configuration. `recordStore` chooses where records live (D1 by default; a KV opt-in for
|
|
130
|
+
* KV-only projects, with its query limitations documented). Each media type block picks a backend and
|
|
131
|
+
* toggles its enrichment.
|
|
132
|
+
*/
|
|
133
|
+
export const MediaConfig = z
|
|
134
|
+
.object({
|
|
135
|
+
recordStore: z
|
|
136
|
+
.enum(["d1", "kv"])
|
|
137
|
+
.default("d1")
|
|
138
|
+
.describe(
|
|
139
|
+
"Where media records live: `d1` (default — transcriptions and extracted text are queryable) or `kv` (key-lookup only; no text search over derived content). Duplicate detection always uses D1 either way.",
|
|
140
|
+
),
|
|
141
|
+
delivery: MediaDelivery.prefault({}).describe("Public delivery configuration for building consumer media URLs."),
|
|
142
|
+
kvMetadata: z
|
|
143
|
+
.array(z.string())
|
|
144
|
+
.default(["type", "status", "hasTranscription", "hasExtractedText"])
|
|
145
|
+
.describe(
|
|
146
|
+
"For `recordStore: 'kv'` only: which record fields to also store as KV metadata. Metadata rides free on a KV `list` (no per-value read), so `list` filters, sorts, and renders from it — put the fields your list views need here (e.g. an owning `userId` for owner-scoped lists). KV caps metadata at 1024 bytes serialized, so keep it to small scalar fields. `type` and `createdAt` are always included (list filters by type and sorts by recency). Ignored in D1 mode.",
|
|
147
|
+
),
|
|
148
|
+
images: MediaImageConfig.prefault({}).describe("Image storage and enrichment settings."),
|
|
149
|
+
video: MediaVideoConfig.prefault({}).describe("Video storage and enrichment settings."),
|
|
150
|
+
audio: MediaAudioConfig.prefault({}).describe("Audio storage and enrichment settings."),
|
|
151
|
+
documents: MediaDocumentConfig.prefault({}).describe("Document storage and enrichment settings."),
|
|
152
|
+
})
|
|
153
|
+
.describe("Configuration for the media capability — record store plus per-type backend and enrichment.");
|
|
154
|
+
export type MediaConfig = z.output<typeof MediaConfig>;
|
|
155
|
+
export type MediaConfigInput = z.input<typeof MediaConfig>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The closed enums the media capability's schemas and handlers share. Each is a described Zod enum
|
|
8
|
+
* (the object model's documentation) with its inferred type exported under the same name.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The four kinds of media the capability stores and enriches. The discriminator across every record. */
|
|
12
|
+
export const MediaType = z
|
|
13
|
+
.enum(["image", "video", "audio", "document"])
|
|
14
|
+
.describe("The kind of media: image, video, audio, or document. The discriminator on every record.");
|
|
15
|
+
export type MediaType = z.output<typeof MediaType>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A media record's lifecycle. A record is created `pending` when its upload URL is minted, flips to
|
|
19
|
+
* `stored` once the client confirms the bytes landed (finalize), and is `failed` if finalize could not
|
|
20
|
+
* verify the object.
|
|
21
|
+
*/
|
|
22
|
+
export const MediaStatus = z
|
|
23
|
+
.enum(["pending", "stored", "failed"])
|
|
24
|
+
.describe("A media record's lifecycle: `pending` (URL minted), `stored` (upload finalized), or `failed`.");
|
|
25
|
+
export type MediaStatus = z.output<typeof MediaStatus>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where a media object's bytes live. Chosen per media type by config: images to `r2` or `cf-images`,
|
|
29
|
+
* video to `r2` or `cf-stream`, audio and documents always to `r2`.
|
|
30
|
+
*/
|
|
31
|
+
export const StorageBackend = z
|
|
32
|
+
.enum(["r2", "cf-images", "cf-stream"])
|
|
33
|
+
.describe("The Cloudflare product a media object's bytes live in: `r2`, `cf-images`, or `cf-stream`.");
|
|
34
|
+
export type StorageBackend = z.output<typeof StorageBackend>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The document extensions Workers AI `toMarkdown` can extract text from. Extraction is only dispatched
|
|
38
|
+
* for these — a `.txt`, `.png`, or unknown extension is never enqueued (no wasted Workflow, no cost).
|
|
39
|
+
* Matches the CMS's supported set.
|
|
40
|
+
*/
|
|
41
|
+
export const EXTRACTABLE_EXTENSIONS = ["pdf", "doc", "docx"] as const;
|
|
42
|
+
|
|
43
|
+
/** The file extension of a filename, lowercased, or the empty string. */
|
|
44
|
+
export function fileExtension(filename: string): string {
|
|
45
|
+
if (typeof filename !== "string") return "";
|
|
46
|
+
const dot = filename.lastIndexOf(".");
|
|
47
|
+
return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether a document's filename is one text extraction supports (pdf/doc/docx). */
|
|
51
|
+
export function isExtractableDocument(filename: string): boolean {
|
|
52
|
+
return (EXTRACTABLE_EXTENSIONS as readonly string[]).includes(fileExtension(filename));
|
|
53
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
import { MediaAsset } from "./mediaAsset";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The adopter-extension mechanism, and the single reason one Zod schema can persist to either record
|
|
9
|
+
* store. An adopter extends a media type by passing a `z.ZodObject` of extra fields to `media({ extend })`
|
|
10
|
+
* — an owning `userId`, a tenant id, tags. From that one schema the mapping layer derives everything:
|
|
11
|
+
*
|
|
12
|
+
* - D1: {@link extensionColumns} turns each field into a real column (type + nullability), which the
|
|
13
|
+
* generated `0002_extend` migration adds to `pithy_media_assets`; the effective schema then reads and
|
|
14
|
+
* writes those columns through the same codecs as the base fields.
|
|
15
|
+
* - KV: the effective schema validates the extra fields as part of the stored value — no columns, no
|
|
16
|
+
* migration, no backend-specific work.
|
|
17
|
+
*
|
|
18
|
+
* The base fields are always guaranteed; an extension field that collides with a base column name is
|
|
19
|
+
* ignored, so an adopter can never redefine or drop a base field.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A derived SQLite column: its affinity and whether the source field is required. */
|
|
23
|
+
export interface SqliteColumn {
|
|
24
|
+
/** The SQLite storage affinity — `text` for strings/JSON, `integer` for numbers/booleans/dates. */
|
|
25
|
+
type: "text" | "integer";
|
|
26
|
+
/** Whether the column is `NOT NULL` — false when the field is optional, nullable, or nullish. */
|
|
27
|
+
notNull: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A derived column plus its (camelCase) name, for the extension migration. */
|
|
31
|
+
export interface ExtensionColumn extends SqliteColumn {
|
|
32
|
+
/** The column name — the extension field's key. `CamelCasePlugin` snake-cases it in the DDL. */
|
|
33
|
+
name: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ZodInternal {
|
|
37
|
+
def?: { type?: string; innerType?: z.ZodType; out?: z.ZodType };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read a schema's internal def, tolerating both the `_zod.def` and `.def` shapes across Zod builds. */
|
|
41
|
+
function defOf(schema: z.ZodType): ZodInternal["def"] {
|
|
42
|
+
const internal = schema as unknown as { _zod?: ZodInternal } & ZodInternal;
|
|
43
|
+
return internal._zod?.def ?? internal.def;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Unwrap `optional` / `nullable` / `default` wrappers to the underlying schema, tracking nullability. */
|
|
47
|
+
function unwrap(schema: z.ZodType): { inner: z.ZodType; notNull: boolean } {
|
|
48
|
+
let current = schema;
|
|
49
|
+
let notNull = true;
|
|
50
|
+
// A wrapper nests its target under `def.innerType`; walk until we reach a concrete type.
|
|
51
|
+
for (let guard = 0; guard < 16; guard++) {
|
|
52
|
+
const def = defOf(current);
|
|
53
|
+
const kind = def?.type;
|
|
54
|
+
if ((kind === "optional" || kind === "nullable" || kind === "default") && def?.innerType) {
|
|
55
|
+
if (kind !== "default") notNull = false;
|
|
56
|
+
current = def.innerType;
|
|
57
|
+
} else {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { inner: current, notNull };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Map a concrete (unwrapped) schema — primitive or codec — to a SQLite affinity. */
|
|
65
|
+
function affinityOf(schema: z.ZodType): "text" | "integer" {
|
|
66
|
+
const def = defOf(schema);
|
|
67
|
+
// A codec is a `pipe`; its stored form follows the OUTPUT type (a date/boolean stores as a number,
|
|
68
|
+
// a JSON payload stores as a string).
|
|
69
|
+
const target = def?.type === "pipe" && def.out ? defOf(def.out)?.type : def?.type;
|
|
70
|
+
switch (target) {
|
|
71
|
+
case "number":
|
|
72
|
+
case "boolean":
|
|
73
|
+
case "date":
|
|
74
|
+
case "bigint":
|
|
75
|
+
return "integer";
|
|
76
|
+
default:
|
|
77
|
+
return "text";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Derive the SQLite column (affinity + nullability) a single extension field maps to. */
|
|
82
|
+
export function sqliteColumnType(schema: z.ZodType): SqliteColumn {
|
|
83
|
+
const { inner, notNull } = unwrap(schema);
|
|
84
|
+
return { type: affinityOf(inner), notNull };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The base column names. An extension may not redefine them, and the create handler treats any body key
|
|
89
|
+
* that IS a base column as server-owned — never an extension field — so a client cannot smuggle a base
|
|
90
|
+
* field (id, status, storageKey, …) in through the extension bag.
|
|
91
|
+
*/
|
|
92
|
+
export const BASE_COLUMN_NAMES: ReadonlySet<string> = new Set(Object.keys(MediaAsset.shape));
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A nullable D1 column returns SQLite NULL when a record omitted the field, so its schema must accept
|
|
96
|
+
* null on read. A plain `.optional()` field (undefined-only) would throw on that null, breaking every D1
|
|
97
|
+
* read of a record that omitted it. Widen such a field to also accept null (`.nullable()`), keeping the
|
|
98
|
+
* D1 and KV paths in agreement — a not-null column is left untouched.
|
|
99
|
+
*/
|
|
100
|
+
function reconcileNullability(field: z.ZodType): z.ZodType {
|
|
101
|
+
const { notNull } = sqliteColumnType(field);
|
|
102
|
+
if (notNull) return field;
|
|
103
|
+
// Already accepts null? (a `.nullable()`/`.nullish()` field parses null fine) — leave it. Otherwise
|
|
104
|
+
// wrap so a stored NULL round-trips. `.nullable()` composes with an existing `.optional()`.
|
|
105
|
+
return field.safeParse(null).success ? field : field.nullable();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The effective record schema: the base {@link MediaAsset} with the adopter's extension fields merged in.
|
|
110
|
+
* Returns the base schema unchanged when no extension is given, so the common case allocates nothing.
|
|
111
|
+
*/
|
|
112
|
+
export function extendMediaAsset(extension?: z.ZodObject): z.ZodObject {
|
|
113
|
+
if (!extension) return MediaAsset;
|
|
114
|
+
// The base owns its columns — drop any colliding extension key before merging so a base field can
|
|
115
|
+
// never be redefined or weakened. Reconcile each remaining field's nullability with its D1 column.
|
|
116
|
+
const extra: Record<string, z.ZodType> = {};
|
|
117
|
+
for (const [key, field] of Object.entries(extension.shape)) {
|
|
118
|
+
if (!BASE_COLUMN_NAMES.has(key)) extra[key] = reconcileNullability(field as z.ZodType);
|
|
119
|
+
}
|
|
120
|
+
return MediaAsset.extend(extra);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The columns the `0002_extend` migration must add for `extension`, one per non-colliding field, in
|
|
125
|
+
* declaration order. Empty when no extension is given.
|
|
126
|
+
*/
|
|
127
|
+
export function extensionColumns(extension?: z.ZodObject): ExtensionColumn[] {
|
|
128
|
+
if (!extension) return [];
|
|
129
|
+
const columns: ExtensionColumn[] = [];
|
|
130
|
+
for (const [name, field] of Object.entries(extension.shape)) {
|
|
131
|
+
if (BASE_COLUMN_NAMES.has(name)) continue;
|
|
132
|
+
columns.push({ name, ...sqliteColumnType(field as z.ZodType) });
|
|
133
|
+
}
|
|
134
|
+
return columns;
|
|
135
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { MediaStatus, MediaType, StorageBackend } from "./enums";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One row in `pithy_media_assets` — the spine of the capability. Every uploaded file is one record: a
|
|
10
|
+
* single table with a `type` discriminator and nullable per-type derived columns, rather than a table
|
|
11
|
+
* per media type. `z.output` is the app shape (Dates, booleans); `z.input` is the SQLite row (ms-epoch,
|
|
12
|
+
* 0|1). All JS↔SQLite conversion runs through the core codecs — no raw `0/1`, epoch, or `new Date()` in
|
|
13
|
+
* query code.
|
|
14
|
+
*
|
|
15
|
+
* The base fields are guaranteed for every configured backend and type. An adopter adds their own fields
|
|
16
|
+
* (an owning `userId`, a tenant id, tags) by passing an extension schema to `media({ extend })`; the
|
|
17
|
+
* mapping layer persists those to either store from that one schema — see `data/extend.ts`.
|
|
18
|
+
*
|
|
19
|
+
* `id` is a text UUID, not an autoincrement integer: a media id is handed to clients and embedded in
|
|
20
|
+
* URLs, so it must not be enumerable (CLAUDE.md §Data layer — externally-exposed entities use a text id).
|
|
21
|
+
*/
|
|
22
|
+
export const MediaAsset = z
|
|
23
|
+
.object({
|
|
24
|
+
id: z
|
|
25
|
+
.string()
|
|
26
|
+
.describe("UUID primary key. Text, not autoincrement, so an externally-exposed media id cannot be enumerated."),
|
|
27
|
+
type: MediaType.describe("The kind of media this record holds; drives which derived fields apply."),
|
|
28
|
+
status: MediaStatus.describe("The record's lifecycle state — `pending`, `stored`, or `failed`."),
|
|
29
|
+
name: z.string().describe("A human-readable display name for the media, supplied by the adopter's app."),
|
|
30
|
+
filename: z.string().describe("The original client filename, retained for display and content-type inference."),
|
|
31
|
+
contentType: z.string().describe("The MIME type of the stored bytes (e.g. `image/png`, `audio/mpeg`)."),
|
|
32
|
+
size: z
|
|
33
|
+
.number()
|
|
34
|
+
.int()
|
|
35
|
+
.nullish()
|
|
36
|
+
.describe("The object's size in bytes; null until the upload is finalized with a known length."),
|
|
37
|
+
storageBackend: StorageBackend.describe("The Cloudflare product the bytes live in — resolved from config."),
|
|
38
|
+
storageKey: z
|
|
39
|
+
.string()
|
|
40
|
+
.describe("The backend-specific handle to the bytes: the R2 object key, the CF Images id, or the CF Stream uid."),
|
|
41
|
+
sha256: z
|
|
42
|
+
.string()
|
|
43
|
+
.nullish()
|
|
44
|
+
.describe("Client-computed lowercase-hex SHA-256 of the original file, for exact-match dedup; null if omitted."),
|
|
45
|
+
phash: z
|
|
46
|
+
.string()
|
|
47
|
+
.nullish()
|
|
48
|
+
.describe("Client-computed perceptual hash (16-hex blockhash) for near-duplicate images; null for non-images."),
|
|
49
|
+
width: z.number().int().nullish().describe("Original pixel width for images and video; null for audio/documents."),
|
|
50
|
+
height: z
|
|
51
|
+
.number()
|
|
52
|
+
.int()
|
|
53
|
+
.nullish()
|
|
54
|
+
.describe("Original pixel height for images and video; null for audio/documents."),
|
|
55
|
+
altText: z
|
|
56
|
+
.string()
|
|
57
|
+
.nullish()
|
|
58
|
+
.describe("Alt text for an image, written by the image-to-text enrichment when enabled; null otherwise."),
|
|
59
|
+
caption: z
|
|
60
|
+
.string()
|
|
61
|
+
.nullish()
|
|
62
|
+
.describe("A longer caption for an image, written by the image-to-text enrichment when enabled; null otherwise."),
|
|
63
|
+
transcription: z
|
|
64
|
+
.string()
|
|
65
|
+
.nullish()
|
|
66
|
+
.describe("The speech-to-text transcription for audio/video, written by the transcription Workflow; null until."),
|
|
67
|
+
hasTranscription: SQLiteBoolean.describe("Whether a transcription has been written to this record."),
|
|
68
|
+
extractedText: z
|
|
69
|
+
.string()
|
|
70
|
+
.nullish()
|
|
71
|
+
.describe("Extracted document text (markdown), written by the document-extraction Workflow; null until."),
|
|
72
|
+
hasExtractedText: SQLiteBoolean.describe("Whether extracted text has been written to this record."),
|
|
73
|
+
createdAt: SQLiteDate.describe("When the record was created (its upload URL was minted)."),
|
|
74
|
+
updatedAt: SQLiteDate.describe("When the record was last written."),
|
|
75
|
+
})
|
|
76
|
+
.describe("One media asset in `pithy_media_assets` — the record of a stored, tracked, enrichable file.");
|
|
77
|
+
export type MediaAsset = z.output<typeof MediaAsset>;
|
|
78
|
+
export type MediaAssetRow = z.input<typeof MediaAsset>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { MediaType } from "./enums";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One row in `pithy_media_hashes` — a dedup hash decoupled from the record store. Duplicate detection is
|
|
10
|
+
* a query workload (exact `sha256` lookup, and a bounded near-duplicate `phash` scan over images), which
|
|
11
|
+
* KV cannot do; so hashes always live in D1, regardless of whether records live in D1 or KV. This is why
|
|
12
|
+
* the media capability requires the `DB` binding even in `recordStore: 'kv'` mode.
|
|
13
|
+
*/
|
|
14
|
+
export const MediaHash = z
|
|
15
|
+
.object({
|
|
16
|
+
id: z.number().int().describe("Auto-increment primary key."),
|
|
17
|
+
mediaId: z.string().describe("The id of the media record this hash belongs to (one row per record)."),
|
|
18
|
+
mediaType: MediaType.describe("The media type — scopes the near-duplicate scan to images."),
|
|
19
|
+
sha256: z.string().describe("Lowercase-hex SHA-256 of the original file, for exact-match dedup."),
|
|
20
|
+
phash: z.string().nullish().describe("Perceptual hash (hex) for near-duplicate images; null for other types."),
|
|
21
|
+
createdAt: SQLiteDate.describe("When the hash row was written."),
|
|
22
|
+
})
|
|
23
|
+
.describe("One dedup hash in `pithy_media_hashes` — always D1, so dedup works whatever the record store.");
|
|
24
|
+
export type MediaHash = z.output<typeof MediaHash>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
6
|
+
import type { Kysely } from "kysely";
|
|
7
|
+
import type { z } from "zod";
|
|
8
|
+
import { MediaAsset } from "./mediaAsset";
|
|
9
|
+
import { MediaHash } from "./mediaHash";
|
|
10
|
+
|
|
11
|
+
/** The media record table. `CamelCasePlugin` snake-cases it to `pithy_media_assets` in the DDL. */
|
|
12
|
+
export const MEDIA_ASSETS_TABLE = "pithyMediaAssets";
|
|
13
|
+
/** The dedup hash table. `CamelCasePlugin` snake-cases it to `pithy_media_hashes`. */
|
|
14
|
+
export const MEDIA_HASHES_TABLE = "pithyMediaHashes";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The media tables map for a given effective schema. The hash table is always present (dedup is D1-only);
|
|
18
|
+
* the record table is present for the D1 record store and omitted for `recordStore: 'kv'`.
|
|
19
|
+
*/
|
|
20
|
+
export function mediaTables(
|
|
21
|
+
effective: z.ZodObject = MediaAsset,
|
|
22
|
+
options: { withAssets?: boolean } = {},
|
|
23
|
+
): Record<string, z.ZodObject> {
|
|
24
|
+
const tables: Record<string, z.ZodObject> = { [MEDIA_HASHES_TABLE]: MediaHash };
|
|
25
|
+
if (options.withAssets !== false) tables[MEDIA_ASSETS_TABLE] = effective;
|
|
26
|
+
return tables;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The typed Kysely database over the base media record table. Extension columns are read/written by schema. */
|
|
30
|
+
export type MediaTables = { [MEDIA_ASSETS_TABLE]: typeof MediaAsset };
|
|
31
|
+
export type MediaDatabase = Kysely<DatabaseSchema<MediaTables>>;
|
|
32
|
+
|
|
33
|
+
/** Build the record database from the `DB` binding (CamelCasePlugin installed). */
|
|
34
|
+
export function mediaDatabase(d1: D1Database, effective: z.ZodObject = MediaAsset): MediaDatabase {
|
|
35
|
+
return createDatabase(d1, { [MEDIA_ASSETS_TABLE]: effective }) as unknown as MediaDatabase;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The typed Kysely database over the dedup hash table. */
|
|
39
|
+
export type MediaHashTables = { [MEDIA_HASHES_TABLE]: typeof MediaHash };
|
|
40
|
+
export type MediaHashDatabase = Kysely<DatabaseSchema<MediaHashTables>>;
|
|
41
|
+
|
|
42
|
+
/** Build the hash database from the `DB` binding. */
|
|
43
|
+
export function mediaHashDatabase(d1: D1Database): MediaHashDatabase {
|
|
44
|
+
return createDatabase(d1, { [MEDIA_HASHES_TABLE]: MediaHash }) as unknown as MediaHashDatabase;
|
|
45
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MediaDelivery } from "../config/config";
|
|
5
|
+
import { MediaUnsupportedError } from "../error/errors";
|
|
6
|
+
import type { MediaRecord } from "../record/store";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Consumer URL builders — how an app turns a stored media record into a URL its clients can load. The URL
|
|
10
|
+
* differs by backend: Cloudflare Images serves from `imagedelivery.net` with named variants, Cloudflare
|
|
11
|
+
* Stream serves HLS/DASH/thumbnails from a per-account subdomain, and R2 is either a public base URL or a
|
|
12
|
+
* presigned download (async — see {@link MediaStorage.presignedDownloadUrl}). The account identifiers come
|
|
13
|
+
* from the public `delivery` config.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Build a Cloudflare Images delivery URL: `https://imagedelivery.net/<hash>/<imageId>/<variant>`. */
|
|
17
|
+
export function buildImageUrl(imageId: string, accountHash: string, variant = "public"): string {
|
|
18
|
+
return `https://imagedelivery.net/${accountHash}/${imageId}/${variant}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Build a Cloudflare Stream HLS manifest URL for a video uid. */
|
|
22
|
+
export function buildStreamHlsUrl(uid: string, customerCode: string): string {
|
|
23
|
+
return `https://customer-${customerCode}.cloudflarestream.com/${uid}/manifest/video.m3u8`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Build a Cloudflare Stream DASH manifest URL for a video uid. */
|
|
27
|
+
export function buildStreamDashUrl(uid: string, customerCode: string): string {
|
|
28
|
+
return `https://customer-${customerCode}.cloudflarestream.com/${uid}/manifest/video.mpd`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build a Cloudflare Stream still-thumbnail URL for a video uid. */
|
|
32
|
+
export function buildStreamThumbnailUrl(uid: string, customerCode: string): string {
|
|
33
|
+
return `https://customer-${customerCode}.cloudflarestream.com/${uid}/thumbnails/thumbnail.jpg`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build a Cloudflare Stream embed/iframe URL for a video uid. */
|
|
37
|
+
export function buildStreamIframeUrl(uid: string, customerCode: string): string {
|
|
38
|
+
return `https://customer-${customerCode}.cloudflarestream.com/${uid}/iframe`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the consumer URL for a record from the public delivery config. Synchronous for Cloudflare Images
|
|
43
|
+
* (a named variant, default `public`) and Cloudflare Stream (HLS by default). For an R2-backed record it
|
|
44
|
+
* returns the public base URL join when `r2PublicBaseUrl` is set; otherwise it throws — a private R2
|
|
45
|
+
* object needs a presigned download URL, which is async (`storage.presignedDownloadUrl(record)`).
|
|
46
|
+
*
|
|
47
|
+
* Throws `media/unsupported` when the delivery config lacks the identifier the backend needs, so a
|
|
48
|
+
* misconfiguration surfaces clearly instead of producing a broken URL.
|
|
49
|
+
*/
|
|
50
|
+
export function mediaUrl(
|
|
51
|
+
record: MediaRecord,
|
|
52
|
+
delivery: MediaDelivery,
|
|
53
|
+
options: { imageVariant?: string } = {},
|
|
54
|
+
): string {
|
|
55
|
+
switch (record.storageBackend) {
|
|
56
|
+
case "cf-images": {
|
|
57
|
+
if (!delivery.imagesAccountHash) {
|
|
58
|
+
throw new MediaUnsupportedError({ detail: "delivery.imagesAccountHash is required to build an image URL" });
|
|
59
|
+
}
|
|
60
|
+
return buildImageUrl(record.storageKey, delivery.imagesAccountHash, options.imageVariant ?? "public");
|
|
61
|
+
}
|
|
62
|
+
case "cf-stream": {
|
|
63
|
+
if (!delivery.streamCustomerCode) {
|
|
64
|
+
throw new MediaUnsupportedError({ detail: "delivery.streamCustomerCode is required to build a video URL" });
|
|
65
|
+
}
|
|
66
|
+
return buildStreamHlsUrl(record.storageKey, delivery.streamCustomerCode);
|
|
67
|
+
}
|
|
68
|
+
case "r2": {
|
|
69
|
+
if (!delivery.r2PublicBaseUrl) {
|
|
70
|
+
throw new MediaUnsupportedError({
|
|
71
|
+
detail: "delivery.r2PublicBaseUrl is unset; use storage.presignedDownloadUrl(record) for a private R2 object",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const base = delivery.r2PublicBaseUrl.replace(/\/$/, "");
|
|
75
|
+
return `${base}/${record.storageKey}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `@pithy-sh/media` throw sugar. The `media/*` codes live in core's closed `KitErrorPayload` union
|
|
9
|
+
* (CLAUDE.md §Errors: capabilities add their codes to the one union); these subclasses are the
|
|
10
|
+
* package-local vehicles that set one of those members — the same pattern as `@pithy-sh/email` and
|
|
11
|
+
* `@pithy-sh/turnstile`. Runtime code in this package throws one of these, never a plain `new Error`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Variable parts each subclass accepts; `code`/`status` are fixed by the subclass. */
|
|
15
|
+
interface MediaErrorArgs {
|
|
16
|
+
/** Override the public, safe-to-expose message. */
|
|
17
|
+
message?: string;
|
|
18
|
+
/** A remediation hint (CLI action line). */
|
|
19
|
+
action?: string;
|
|
20
|
+
/** Internal context for logs + audit. Never serialized to clients. */
|
|
21
|
+
detail?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
24
|
+
* unlike `action` and `detail` — these cross the boundary with `message`.
|
|
25
|
+
*/
|
|
26
|
+
params?: MessageParams;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A requested media record does not exist (or was soft-deleted). */
|
|
30
|
+
export class MediaNotFoundError extends PithyError {
|
|
31
|
+
constructor(args: MediaErrorArgs = {}, options?: { cause?: unknown }) {
|
|
32
|
+
super(
|
|
33
|
+
{
|
|
34
|
+
code: "media/not_found",
|
|
35
|
+
status: 404,
|
|
36
|
+
message: args.message ?? "That media does not exist.",
|
|
37
|
+
action: args.action,
|
|
38
|
+
detail: args.detail,
|
|
39
|
+
params: args.params,
|
|
40
|
+
},
|
|
41
|
+
options,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The media type, extension, or backend is not supported for the requested operation. */
|
|
47
|
+
export class MediaUnsupportedError extends PithyError {
|
|
48
|
+
constructor(args: MediaErrorArgs = {}, options?: { cause?: unknown }) {
|
|
49
|
+
super(
|
|
50
|
+
{
|
|
51
|
+
code: "media/unsupported",
|
|
52
|
+
status: 400,
|
|
53
|
+
message: args.message ?? "That media type or format is not supported for this operation.",
|
|
54
|
+
action: args.action,
|
|
55
|
+
detail: args.detail,
|
|
56
|
+
params: args.params,
|
|
57
|
+
},
|
|
58
|
+
options,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A storage operation failed — minting a direct-upload URL, or reading/deleting a stored object. */
|
|
64
|
+
export class MediaStorageError extends PithyError {
|
|
65
|
+
constructor(args: MediaErrorArgs = {}, options?: { cause?: unknown }) {
|
|
66
|
+
super(
|
|
67
|
+
{
|
|
68
|
+
code: "media/storage_failed",
|
|
69
|
+
status: 502,
|
|
70
|
+
message: args.message ?? "The storage backend could not complete the request.",
|
|
71
|
+
action: args.action,
|
|
72
|
+
detail: args.detail,
|
|
73
|
+
params: args.params,
|
|
74
|
+
},
|
|
75
|
+
options,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An AI enrichment step (image-to-text, transcription, or extraction) failed. */
|
|
81
|
+
export class MediaEnrichmentError extends PithyError {
|
|
82
|
+
constructor(args: MediaErrorArgs = {}, options?: { cause?: unknown }) {
|
|
83
|
+
super(
|
|
84
|
+
{
|
|
85
|
+
code: "media/enrichment_failed",
|
|
86
|
+
status: 500,
|
|
87
|
+
message: args.message ?? "Media enrichment could not complete.",
|
|
88
|
+
action: args.action,
|
|
89
|
+
detail: args.detail,
|
|
90
|
+
params: args.params,
|
|
91
|
+
},
|
|
92
|
+
options,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|