sanity-plugin-r2-video 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.
@@ -0,0 +1,48 @@
1
+ import { VideoCodec, AudioCodec } from 'mediabunny';
2
+
3
+ /** Encoding settings, resolved by the caller so this file holds no defaults. */
4
+ type TranscodeOptions = {
5
+ heights: number[];
6
+ videoCodec: VideoCodec;
7
+ audioCodec: AudioCodec;
8
+ quality: number;
9
+ preferBitrate: boolean;
10
+ nativeTopTier: boolean;
11
+ };
12
+ /** What the main thread sends in. */
13
+ type TranscodeRequest = {
14
+ file: File;
15
+ keepAudio: boolean;
16
+ options: TranscodeOptions;
17
+ /**
18
+ * Encode only the tallest tier and stop. Used to show what the current
19
+ * settings actually produce before committing to the whole ladder — the top
20
+ * tier is the one that varies most, and the one most likely to surprise.
21
+ */
22
+ topTierOnly?: boolean;
23
+ };
24
+ /** One finished rendition, still as raw bytes. */
25
+ type TranscodedRendition = {
26
+ width: number;
27
+ height: number;
28
+ data: ArrayBuffer;
29
+ };
30
+ /** What the worker sends back, one message at a time. */
31
+ type TranscodeMessage = {
32
+ type: "progress";
33
+ progress: number;
34
+ label: string;
35
+ } | {
36
+ type: "result";
37
+ duration: number;
38
+ hasAudio: boolean;
39
+ poster: Blob;
40
+ posterWidth: number;
41
+ posterHeight: number;
42
+ renditions: TranscodedRendition[];
43
+ } | {
44
+ type: "error";
45
+ message: string;
46
+ };
47
+
48
+ export type { TranscodeMessage, TranscodeOptions, TranscodeRequest, TranscodedRendition };
@@ -0,0 +1,132 @@
1
+ import { Input, BlobSource, ALL_FORMATS, CanvasSink, Quality, Output, BufferTarget, Mp4OutputFormat, Conversion } from 'mediabunny';
2
+
3
+ // studio/transcode.worker.ts
4
+ var resolveHeights = (sourceHeight, heights) => {
5
+ const ordered = [...heights].sort((a, b) => a - b);
6
+ const fitting = ordered.filter((height) => height <= sourceHeight);
7
+ const chosen = fitting.length > 0 ? fitting : ordered.slice(0, 1);
8
+ return chosen.reverse();
9
+ };
10
+ var resolveWidth = (height, aspectRatio) => {
11
+ return Math.round(height * aspectRatio / 2) * 2;
12
+ };
13
+ var post = (message, transfer = []) => {
14
+ self.postMessage(message, transfer);
15
+ };
16
+ var extractPoster = async (track) => {
17
+ const frame = await new CanvasSink(track).getCanvas(0);
18
+ if (!frame) {
19
+ throw new Error("Could not read a first frame from that video.");
20
+ }
21
+ const { canvas } = frame;
22
+ if (!(canvas instanceof OffscreenCanvas)) {
23
+ throw new Error("Expected an OffscreenCanvas while running in a worker.");
24
+ }
25
+ return {
26
+ poster: await canvas.convertToBlob({ type: "image/jpeg", quality: 0.92 }),
27
+ posterWidth: canvas.width,
28
+ posterHeight: canvas.height
29
+ };
30
+ };
31
+ var encodeRendition = async (source, width, height, hasAudio, isNative, options, progressed) => {
32
+ const quality = new Quality({
33
+ quality: options.quality,
34
+ preferBitrate: options.preferBitrate
35
+ });
36
+ const output = new Output({
37
+ // Fast Start puts the metadata at the front so the browser can start
38
+ // playing before the whole file has arrived
39
+ format: new Mp4OutputFormat({ fastStart: "in-memory" }),
40
+ target: new BufferTarget()
41
+ });
42
+ const conversion = await Conversion.init({
43
+ input: new Input({ formats: ALL_FORMATS, source: new BlobSource(source) }),
44
+ output,
45
+ video: isNative ? {} : {
46
+ width,
47
+ height,
48
+ fit: "contain",
49
+ codec: options.videoCodec,
50
+ quality
51
+ },
52
+ audio: hasAudio ? { codec: options.audioCodec, quality } : { discard: true }
53
+ });
54
+ conversion.onProgress = progressed;
55
+ await conversion.execute();
56
+ const { buffer } = output.target;
57
+ if (!buffer) {
58
+ throw new Error(`Encoding produced no data at ${height}p.`);
59
+ }
60
+ return buffer;
61
+ };
62
+ var transcode = async ({
63
+ file,
64
+ keepAudio,
65
+ options,
66
+ topTierOnly
67
+ }) => {
68
+ const input = new Input({
69
+ formats: ALL_FORMATS,
70
+ source: new BlobSource(file)
71
+ });
72
+ const track = await input.getPrimaryVideoTrack();
73
+ if (!track) {
74
+ throw new Error("That file has no video track.");
75
+ }
76
+ const sourceWidth = await track.getDisplayWidth();
77
+ const sourceHeight = await track.getDisplayHeight();
78
+ const duration = await input.computeDuration();
79
+ const audioTrack = await input.getPrimaryAudioTrack();
80
+ const hasAudio = keepAudio && audioTrack !== null;
81
+ const sourceCodec = await track.getCodec();
82
+ const { poster, posterWidth, posterHeight } = await extractPoster(track);
83
+ const full = resolveHeights(sourceHeight, options.heights);
84
+ const heights = topTierOnly ? full.slice(0, 1) : full;
85
+ const aspectRatio = sourceWidth / sourceHeight;
86
+ const renditions = [];
87
+ let ladderSource = file;
88
+ for (const [index, height] of heights.entries()) {
89
+ const width = resolveWidth(height, aspectRatio);
90
+ const isNative = options.nativeTopTier && index === 0 && height === sourceHeight && sourceCodec === options.videoCodec;
91
+ const data = await encodeRendition(
92
+ ladderSource,
93
+ width,
94
+ height,
95
+ hasAudio,
96
+ isNative,
97
+ options,
98
+ (progress) => {
99
+ post({
100
+ type: "progress",
101
+ progress: (index + progress) / heights.length,
102
+ label: `${height}p`
103
+ });
104
+ }
105
+ );
106
+ renditions.push({ width, height, data });
107
+ if (index === 0) {
108
+ ladderSource = new Blob([data], { type: "video/mp4" });
109
+ }
110
+ }
111
+ return {
112
+ duration,
113
+ hasAudio,
114
+ poster,
115
+ posterWidth,
116
+ posterHeight,
117
+ renditions
118
+ };
119
+ };
120
+ self.addEventListener("message", (event) => {
121
+ transcode(event.data).then((result) => {
122
+ post(
123
+ { type: "result", ...result },
124
+ result.renditions.map((rendition) => rendition.data)
125
+ );
126
+ }).catch((error) => {
127
+ post({
128
+ type: "error",
129
+ message: error instanceof Error ? error.message : String(error)
130
+ });
131
+ });
132
+ });
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "sanity-plugin-r2-video",
3
+ "version": "0.1.0",
4
+ "description": "Video for Sanity Studio, encoded in the browser and stored in Cloudflare R2 as a ladder of plain MP4s.",
5
+ "keywords": [
6
+ "sanity",
7
+ "sanity-plugin",
8
+ "cloudflare",
9
+ "r2",
10
+ "video",
11
+ "webcodecs",
12
+ "mediabunny"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Paulo Ferreira Jorge",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/PauloMFJ/sanity-plugin-r2-video.git"
19
+ },
20
+ "homepage": "https://github.com/PauloMFJ/sanity-plugin-r2-video#readme",
21
+ "bugs": "https://github.com/PauloMFJ/sanity-plugin-r2-video/issues",
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "exports": {
25
+ "./studio": {
26
+ "types": "./dist/studio/index.d.ts",
27
+ "default": "./dist/studio/index.js"
28
+ },
29
+ "./storage": {
30
+ "types": "./dist/storage.d.ts",
31
+ "default": "./dist/storage.js"
32
+ },
33
+ "./worker": "./worker/index.ts",
34
+ "./worker/tsconfig.json": "./worker/tsconfig.json",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "bin": {
38
+ "sanity-plugin-r2-video": "./bin/cli.js"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "worker/index.ts",
43
+ "worker/tsconfig.json",
44
+ "bin"
45
+ ],
46
+ "engines": {
47
+ "node": ">=20"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "scripts": {
53
+ "build": "tsup",
54
+ "type-check": "tsc --noEmit -p studio/tsconfig.json && tsc --noEmit -p worker/tsconfig.check.json",
55
+ "prepublishOnly": "pnpm run type-check && pnpm run build",
56
+ "setup:worker": "node bin/cli.js setup worker"
57
+ },
58
+ "dependencies": {
59
+ "mediabunny": "^1.55.4"
60
+ },
61
+ "peerDependencies": {
62
+ "@cloudflare/workers-types": "^5",
63
+ "@sanity/icons": "^5",
64
+ "@sanity/ui": "^4",
65
+ "react": "^19",
66
+ "react-dom": "^19",
67
+ "sanity": "^6",
68
+ "styled-components": "^6"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "@cloudflare/workers-types": {
72
+ "optional": true
73
+ }
74
+ },
75
+ "devDependencies": {
76
+ "@cloudflare/workers-types": "^5.0.0",
77
+ "@sanity/icons": "^5.0.0",
78
+ "@sanity/ui": "^4.0.0",
79
+ "@types/react": "^19.0.0",
80
+ "@types/react-dom": "^19.0.0",
81
+ "react": "^19.0.0",
82
+ "react-dom": "^19.0.0",
83
+ "sanity": "^6.0.0",
84
+ "styled-components": "^6.0.0",
85
+ "tsup": "^8.5.0",
86
+ "typescript": "^5.9.0"
87
+ }
88
+ }
@@ -0,0 +1,181 @@
1
+ type Env = {
2
+ BUCKET: R2Bucket;
3
+ UPLOAD_TOKEN: string;
4
+ ALLOWED_ORIGINS: string;
5
+ };
6
+
7
+ /** Keys are generated by the plugin, but this Worker never trusts that. */
8
+ const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_-]*\.mp4$/;
9
+
10
+ /** Maximum size of a single upload. Currently 100 MB. */
11
+ const MAX_BYTES = 100 * 1024 * 1024;
12
+
13
+ const isAllowedOrigin = (origin: string | null, env: Env): origin is string => {
14
+ if (!origin) return false;
15
+
16
+ return env.ALLOWED_ORIGINS.split(",")
17
+ .map((allowed) => allowed.trim())
18
+ .includes(origin);
19
+ };
20
+
21
+ /** Create CORS headers for allowed origins, or empty for disallowed ones. */
22
+ const corsHeaders = (
23
+ origin: string | null,
24
+ env: Env,
25
+ ): Record<string, string> => {
26
+ if (!isAllowedOrigin(origin, env)) return {};
27
+
28
+ return {
29
+ "access-control-allow-origin": origin,
30
+ "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
31
+ "access-control-allow-headers": "authorization, content-type, x-object-key",
32
+ "access-control-max-age": "86400",
33
+ vary: "Origin",
34
+ };
35
+ };
36
+
37
+ /** Checks the `Authorization` header against the Worker's secret. */
38
+ const isAuthorized = (request: Request, env: Env) => {
39
+ const header = request.headers.get("authorization");
40
+ return header === `Bearer ${env.UPLOAD_TOKEN}`;
41
+ };
42
+
43
+ /** Rejects traversal, absolute paths and anything that isn't an MP4. */
44
+ const isAllowedKey = (key: unknown): key is string => {
45
+ if (typeof key !== "string" || !KEY_PATTERN.test(key)) return false;
46
+
47
+ return !key.includes("..");
48
+ };
49
+
50
+ /** Return a JSON response with the given body, status and headers. */
51
+ const jsonResponse = (body: unknown, status: number, headers: HeadersInit) => {
52
+ return new Response(JSON.stringify(body), {
53
+ status,
54
+ headers: { ...headers, "content-type": "application/json" },
55
+ });
56
+ };
57
+
58
+ /** Store one rendition in the bucket, keyed by the `x-object-key` header. */
59
+ const storeObject = async (
60
+ request: Request,
61
+ env: Env,
62
+ headers: HeadersInit,
63
+ ) => {
64
+ const key = request.headers.get("x-object-key");
65
+
66
+ if (!isAllowedKey(key)) {
67
+ return jsonResponse({ error: "Invalid object key." }, 400, headers);
68
+ }
69
+
70
+ const body = await request.arrayBuffer();
71
+ if (body.byteLength === 0) {
72
+ return jsonResponse({ error: "Empty body." }, 400, headers);
73
+ }
74
+
75
+ if (body.byteLength > MAX_BYTES) {
76
+ return jsonResponse(
77
+ { error: "Object exceeds the size limit." },
78
+ 413,
79
+ headers,
80
+ );
81
+ }
82
+
83
+ await env.BUCKET.put(key, body, {
84
+ httpMetadata: {
85
+ contentType: "video/mp4",
86
+ cacheControl: "public, max-age=31536000, immutable",
87
+ },
88
+ });
89
+
90
+ // Only the key and size — the Studio builds URLs from its own configured
91
+ // origin, so this Worker never needs to know one
92
+ return jsonResponse({ key, size: body.byteLength }, 200, headers);
93
+ };
94
+
95
+ /**
96
+ * Every key in the bucket, one page at a time. Used by the orphan sweep to work
97
+ * out what storage holds that no document references.
98
+ */
99
+ const listKeys = async (request: Request, env: Env, headers: HeadersInit) => {
100
+ const cursor = new URL(request.url).searchParams.get("cursor");
101
+
102
+ const listed = await env.BUCKET.list({
103
+ limit: 1000,
104
+ cursor: cursor ?? undefined,
105
+ });
106
+
107
+ return jsonResponse(
108
+ {
109
+ keys: listed.objects.map((object) => object.key),
110
+ cursor: listed.truncated ? listed.cursor : null,
111
+ },
112
+ 200,
113
+ headers,
114
+ );
115
+ };
116
+
117
+ /** Remove a given batch of keys from the bucket. */
118
+ const deleteKeys = async (request: Request, env: Env, headers: HeadersInit) => {
119
+ const payload: unknown = await request.json();
120
+ if (typeof payload !== "object" || payload === null) {
121
+ return jsonResponse({ error: "Expected an object." }, 400, headers);
122
+ }
123
+
124
+ const keys = Reflect.get(payload, "keys");
125
+ if (!Array.isArray(keys) || keys.some((key) => !isAllowedKey(key))) {
126
+ return jsonResponse({ error: "Invalid object keys." }, 400, headers);
127
+ }
128
+
129
+ await env.BUCKET.delete(keys);
130
+
131
+ return jsonResponse({ deleted: keys.length }, 200, headers);
132
+ };
133
+
134
+ /**
135
+ * Route requests.
136
+ *
137
+ * - `POST /upload` stores one rendition, keyed by the `x-object-key` header.
138
+ * - `GET /files` lists every key, paginated, for the orphan sweep.
139
+ * - `DELETE /files` removes a batch of keys.
140
+ */
141
+ const fetch = async (request: Request, env: Env): Promise<Response> => {
142
+ const origin = request.headers.get("origin");
143
+
144
+ // Resolved before any rejection, because a browser can only read the reason
145
+ // off a response that already carries them
146
+ const headers = corsHeaders(origin, env);
147
+
148
+ if (request.method === "OPTIONS") {
149
+ return new Response(null, { status: 204, headers });
150
+ }
151
+
152
+ // Both checked before anything reads the body, so an unauthorized caller
153
+ // never reaches the bucket
154
+ if (!isAllowedOrigin(origin, env)) {
155
+ return jsonResponse({ error: "Origin not allowed." }, 403, {});
156
+ }
157
+
158
+ if (!isAuthorized(request, env)) {
159
+ return jsonResponse({ error: "Unauthorized." }, 401, headers);
160
+ }
161
+
162
+ const { pathname } = new URL(request.url);
163
+
164
+ if (request.method === "POST" && pathname === "/upload") {
165
+ return storeObject(request, env, headers);
166
+ }
167
+
168
+ if (request.method === "GET" && pathname === "/files") {
169
+ return listKeys(request, env, headers);
170
+ }
171
+
172
+ if (request.method === "DELETE" && pathname === "/files") {
173
+ return deleteKeys(request, env, headers);
174
+ }
175
+
176
+ return jsonResponse({ error: "Not found." }, 404, headers);
177
+ };
178
+
179
+ // Module Worker format: Cloudflare calls 'fetch' off the default export, so a
180
+ // bare function here would deploy cleanly and fail on the first request
181
+ export default { fetch };
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig.json",
3
+ "compilerOptions": {
4
+ "target": "esnext",
5
+ "module": "preserve",
6
+ "moduleDetection": "force",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "types": ["@cloudflare/workers-types"]
10
+ }
11
+ }