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.
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/bin/cli.js +172 -0
- package/dist/chunk-D5VS4ED7.js +6 -0
- package/dist/storage.d.ts +15 -0
- package/dist/storage.js +1 -0
- package/dist/studio/index.d.ts +395 -0
- package/dist/studio/index.js +1836 -0
- package/dist/studio/transcode.worker.d.ts +48 -0
- package/dist/studio/transcode.worker.js +132 -0
- package/package.json +88 -0
- package/worker/index.ts +181 -0
- package/worker/tsconfig.json +11 -0
|
@@ -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
|
+
}
|
package/worker/index.ts
ADDED
|
@@ -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 };
|