@devultur/self-host 0.0.0-stage → 1.0.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,459 @@
1
+ // src/request-state.ts
2
+ var REQUEST_STATE_VARIABLE = "requestState";
3
+ var INITIAL_STATE = { storagePrefix: "" };
4
+ function getRequestState(context) {
5
+ return context.get(REQUEST_STATE_VARIABLE) ?? INITIAL_STATE;
6
+ }
7
+ function isAuthenticated(context) {
8
+ return getRequestState(context).access?.authenticated === true;
9
+ }
10
+ function mergeConfig(current, patch) {
11
+ if (!patch) return current;
12
+ const transcode = patch.transcode ? { ...current?.transcode, ...patch.transcode } : current?.transcode;
13
+ return { ...current, ...patch, ...transcode ? { transcode } : {} };
14
+ }
15
+ function mergeRecord(current, patch) {
16
+ return patch ? { ...current, ...patch } : current;
17
+ }
18
+ function updateRequestState(context, patch) {
19
+ const current = getRequestState(context);
20
+ const config = mergeConfig(current.config, patch.config);
21
+ const access = mergeRecord(current.access, patch.access);
22
+ const pluginContext = mergeRecord(current.context, patch.context);
23
+ const next = {
24
+ ...current,
25
+ ...patch,
26
+ ...config ? { config } : {},
27
+ ...access ? { access } : {},
28
+ ...pluginContext ? { context: pluginContext } : {}
29
+ };
30
+ context.set(REQUEST_STATE_VARIABLE, next);
31
+ return next;
32
+ }
33
+
34
+ // src/types.ts
35
+ var HashAlgorithm = {
36
+ SHA_256: "SHA-256"
37
+ };
38
+ var HmacAlgorithm = {
39
+ NAME: "HMAC",
40
+ HASH: HashAlgorithm.SHA_256
41
+ };
42
+ var MaxResolution = {
43
+ P720: "720p",
44
+ P1080: "1080p",
45
+ P4K: "4k"
46
+ };
47
+
48
+ // src/helpers.ts
49
+ import { visibilityFromKey } from "@devultur/core";
50
+
51
+ // src/bearer.ts
52
+ var encoder = new TextEncoder();
53
+ function isConstantTimeEqual(received, expected) {
54
+ const receivedBytes = encoder.encode(received);
55
+ const expectedBytes = encoder.encode(expected);
56
+ let difference = receivedBytes.length ^ expectedBytes.length;
57
+ for (let index = 0; index < expectedBytes.length; index++) {
58
+ difference |= (receivedBytes[index] ?? 0) ^ expectedBytes[index];
59
+ }
60
+ return difference === 0;
61
+ }
62
+ function isBearerAuthorized(secret, authHeader) {
63
+ if (!secret || !authHeader) return false;
64
+ return isConstantTimeEqual(authHeader, `Bearer ${secret}`);
65
+ }
66
+
67
+ // src/legacy-claims.ts
68
+ var LEGACY_CLAIM_NAMES = {
69
+ pid: "aud",
70
+ ppx: "res",
71
+ pfx: "res",
72
+ vis: "visibility",
73
+ mxb: "max_bytes",
74
+ mxf: "max_files",
75
+ fttl: "file_ttl",
76
+ ctx: "context"
77
+ };
78
+ var LEGACY_SCOPES_CLAIM = "scopes";
79
+ function withCurrentClaimNames(payload) {
80
+ const current = { ...payload };
81
+ for (const [legacy, name] of Object.entries(LEGACY_CLAIM_NAMES)) {
82
+ if (!(legacy in current)) continue;
83
+ if (current[name] === void 0) current[name] = current[legacy];
84
+ delete current[legacy];
85
+ }
86
+ if (LEGACY_SCOPES_CLAIM in current) {
87
+ const scopes = current[LEGACY_SCOPES_CLAIM];
88
+ const scope = Array.isArray(scopes) ? scopes.filter((entry) => typeof entry === "string").join(" ") : "";
89
+ if (current.scope === void 0) current.scope = scope;
90
+ delete current[LEGACY_SCOPES_CLAIM];
91
+ }
92
+ return current;
93
+ }
94
+
95
+ // src/helpers.ts
96
+ import { contentDispositionAttachment, contentDispositionInline, sanitizeFilename } from "@devultur/core";
97
+ import {
98
+ encodeEncryptionMeta,
99
+ encryptBytes,
100
+ exportEncryptionKey,
101
+ generateEncryptionKey,
102
+ generateId,
103
+ generateIv,
104
+ importEncryptionKey
105
+ } from "@devultur/core";
106
+ var RESOURCE_SECURITY_HEADER = "X-Devultur-Resource-Security";
107
+ function parseSecurityHeader(raw) {
108
+ if (!raw) return void 0;
109
+ try {
110
+ return JSON.parse(raw);
111
+ } catch {
112
+ return void 0;
113
+ }
114
+ }
115
+ function isContentTypeAllowed(contentType, accept) {
116
+ if (!accept || accept.length === 0) return true;
117
+ const type = contentType.toLowerCase().split(";")[0].trim();
118
+ return accept.some((entry) => {
119
+ const rule = entry.toLowerCase().trim();
120
+ if (rule === "*" || rule === "*/*") return true;
121
+ if (rule.endsWith("/*")) return type.startsWith(`${rule.slice(0, -1)}`);
122
+ return type === rule;
123
+ });
124
+ }
125
+ function areLocalesAllowed(requested, allowed) {
126
+ if (!allowed || allowed.length === 0) return true;
127
+ const allowedSet = new Set(allowed.map((locale) => locale.toLowerCase()));
128
+ return requested.every((locale) => allowedSet.has(locale.toLowerCase()));
129
+ }
130
+ function storageKey(context, key) {
131
+ return `${getRequestState(context).storagePrefix}${key}`;
132
+ }
133
+ function storagePrefix(context, prefix) {
134
+ return `${getRequestState(context).storagePrefix}${prefix}`;
135
+ }
136
+ function isTranscodeWebhookAuthorized(configuredSecret, authHeader) {
137
+ return isBearerAuthorized(configuredSecret, authHeader);
138
+ }
139
+ function base64UrlDecode(value) {
140
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
141
+ const binary = atob(base64);
142
+ const bytes = new Uint8Array(binary.length);
143
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
144
+ return bytes;
145
+ }
146
+ var SERVICE_SUBJECT_PREFIX = "transcode:";
147
+ var SERVICE_TOKEN_TYPE = "transcode";
148
+ function isServiceToken(payload) {
149
+ return payload?.typ === SERVICE_TOKEN_TYPE;
150
+ }
151
+ async function verifyViewerToken(token, secret) {
152
+ if (!token) return null;
153
+ const parts = token.split(".");
154
+ if (parts.length !== 3) return null;
155
+ const [headerB64, payloadB64, signatureB64] = parts;
156
+ const encoder2 = new TextEncoder();
157
+ const key = await crypto.subtle.importKey("raw", encoder2.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
158
+ "verify"
159
+ ]);
160
+ const valid = await crypto.subtle.verify(
161
+ "HMAC",
162
+ key,
163
+ base64UrlDecode(signatureB64),
164
+ encoder2.encode(`${headerB64}.${payloadB64}`)
165
+ );
166
+ if (!valid) return null;
167
+ let payload;
168
+ try {
169
+ payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
170
+ } catch {
171
+ return null;
172
+ }
173
+ const now = Math.floor(Date.now() / 1e3);
174
+ if (typeof payload.exp === "number" && payload.exp < now) return null;
175
+ return withCurrentClaimNames(payload);
176
+ }
177
+ async function createObjectEncryption(keysBucket, objectStorageKey, keyMetadata) {
178
+ const cryptoKey = await generateEncryptionKey();
179
+ const iv = generateIv();
180
+ const keyId = generateId();
181
+ await keysBucket.put(`${objectStorageKey}/${keyId}`, await exportEncryptionKey(cryptoKey), {
182
+ ...keyMetadata && Object.keys(keyMetadata).length > 0 ? { customMetadata: keyMetadata } : {}
183
+ });
184
+ return { cryptoKey, iv, enc: encodeEncryptionMeta(iv, keyId) };
185
+ }
186
+ async function encryptForStorage(keysBucket, objectStorageKey, body, keyMetadata) {
187
+ const { cryptoKey, iv, enc } = await createObjectEncryption(keysBucket, objectStorageKey, keyMetadata);
188
+ return { body: await encryptBytes(body, cryptoKey, iv), enc };
189
+ }
190
+ function shouldEncryptAtRest(policy, key, hasKeysBucket) {
191
+ const isHlsAsset = key.endsWith(".ts") || key.endsWith(".m3u8");
192
+ return Boolean(policy.encrypt) && visibilityFromKey(key) === "private" && !isHlsAsset && hasKeysBucket;
193
+ }
194
+ async function loadStorageKey(keysBucket, objectStorageKey, keyId) {
195
+ if (!keysBucket) return null;
196
+ const stored = await keysBucket.get(`${objectStorageKey}/${keyId}`);
197
+ if (!stored) return null;
198
+ return { key: await importEncryptionKey(await stored.arrayBuffer()), metadata: stored.customMetadata };
199
+ }
200
+ var base64Url = (value) => btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
201
+ async function signViewerToken(payload, secret) {
202
+ const encoder2 = new TextEncoder();
203
+ const header = base64Url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
204
+ const payloadB64 = base64Url(JSON.stringify(payload));
205
+ const signingInput = `${header}.${payloadB64}`;
206
+ const key = await crypto.subtle.importKey("raw", encoder2.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
207
+ "sign"
208
+ ]);
209
+ const signature = await crypto.subtle.sign("HMAC", key, encoder2.encode(signingInput));
210
+ const signatureB64 = base64Url(String.fromCharCode(...new Uint8Array(signature)));
211
+ return `${signingInput}.${signatureB64}`;
212
+ }
213
+ async function hashKey(key) {
214
+ const encoder2 = new TextEncoder();
215
+ const data = encoder2.encode(key);
216
+ const hashBuffer = await crypto.subtle.digest(HashAlgorithm.SHA_256, data);
217
+ return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
218
+ }
219
+ async function deletePrefix(bucket, prefix) {
220
+ let deletedCount = 0;
221
+ let cursor;
222
+ do {
223
+ const listed = await bucket.list({ prefix, cursor });
224
+ if (listed.objects.length > 0) {
225
+ await bucket.delete(listed.objects.map((obj) => obj.key));
226
+ deletedCount += listed.objects.length;
227
+ }
228
+ cursor = listed.truncated ? listed.cursor : void 0;
229
+ } while (cursor);
230
+ return deletedCount;
231
+ }
232
+ var KV_JOB_TTL_SECONDS = 86400;
233
+ var UPLOAD_TOKEN_TTL_SECONDS = 3600;
234
+ var KvPrefix = {
235
+ UPLOAD_PENDING: "upload-pending",
236
+ TRANSCODE_JOB: "transcode",
237
+ TRANSCODE_META: "transcode-meta",
238
+ TRANSCODE_STATUS: "transcode-status",
239
+ // What to do with the raw once the transcode finishes successfully (by videoId: the webhook doesn't carry the job). See
240
+ // transcode/raw-retention.ts.
241
+ TRANSCODE_RAW: "transcode-raw",
242
+ CAPTION_JOB: "caption",
243
+ CAPTION_GROUP: "caption-group",
244
+ CAPTION_STORED: "caption-stored"
245
+ };
246
+
247
+ // src/transcode/job-ticket.ts
248
+ import { generateId as generateId2 } from "@devultur/core";
249
+ var JOB_TICKET_HEADER = "X-Devultur-Job";
250
+ var JOB_TICKET_TYPE = "transcode-job";
251
+ var JOB_TICKET_KEY_SUFFIX = ":transcode-job-ticket";
252
+ var JOB_TICKET_TTL_SECONDS = 2 * 24 * 60 * 60;
253
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
254
+ var ticketKey = (secret) => `${secret}${JOB_TICKET_KEY_SUFFIX}`;
255
+ async function signJobTicket(secret, job, nowMs = Date.now()) {
256
+ const runId = generateId2();
257
+ const issuedAt = Math.floor(nowMs / 1e3);
258
+ const ticket = await signViewerToken(
259
+ {
260
+ typ: JOB_TICKET_TYPE,
261
+ sub: job.videoId,
262
+ jti: runId,
263
+ iat: issuedAt,
264
+ exp: issuedAt + JOB_TICKET_TTL_SECONDS,
265
+ ...job.context ? { context: job.context } : {}
266
+ },
267
+ ticketKey(secret)
268
+ );
269
+ return { ticket, runId };
270
+ }
271
+ async function verifyJobTicket(secret, ticket, videoId) {
272
+ const claims = await verifyViewerToken(ticket, ticketKey(secret));
273
+ if (claims?.typ !== JOB_TICKET_TYPE || claims.sub !== videoId) return null;
274
+ if (typeof claims.jti !== "string" || typeof claims.iat !== "number") return null;
275
+ return {
276
+ runId: claims.jti,
277
+ videoId,
278
+ startedAt: claims.iat * 1e3,
279
+ ...isRecord(claims.context) ? { context: claims.context } : {}
280
+ };
281
+ }
282
+
283
+ // src/transcode/runner.ts
284
+ var TranscodeRunnerName = {
285
+ FLY: "fly",
286
+ CLOUDFLARE: "cloudflare",
287
+ HTTP: "http"
288
+ };
289
+ var SCRIPT_ENV_VAR = "TRANSCODE_SCRIPT_B64";
290
+ var RUN_SCRIPT_COMMAND = `echo "$${SCRIPT_ENV_VAR}" | base64 -d | sh`;
291
+ var TranscodeLaunchErrorCode = {
292
+ NOT_CONFIGURED: "transcode_not_configured",
293
+ /** No capacity right now (429/5xx exhausted, runner down): retriable (503). */
294
+ UNAVAILABLE: "transcode_runner_unavailable",
295
+ /** The provider rejected the job (4xx): retrying won't fix it (500). */
296
+ LAUNCH_FAILED: "transcode_launch_failed"
297
+ };
298
+ function notConfigured(detail) {
299
+ return {
300
+ ok: false,
301
+ status: 500,
302
+ error: "Transcode runner not configured",
303
+ code: TranscodeLaunchErrorCode.NOT_CONFIGURED,
304
+ detail
305
+ };
306
+ }
307
+ function launchErrorCode(status) {
308
+ return status === 503 ? TranscodeLaunchErrorCode.UNAVAILABLE : TranscodeLaunchErrorCode.LAUNCH_FAILED;
309
+ }
310
+ function envWithScript(job) {
311
+ return { ...job.env, [SCRIPT_ENV_VAR]: btoa(job.script) };
312
+ }
313
+ function parseRunnerName(raw) {
314
+ const value = raw?.trim().toLowerCase();
315
+ if (!value) return TranscodeRunnerName.CLOUDFLARE;
316
+ const names = Object.values(TranscodeRunnerName);
317
+ return names.includes(value) ? value : null;
318
+ }
319
+
320
+ // src/transcode/cloudflare.ts
321
+ var CONTAINER_IMAGE_NAME = "transcode";
322
+ var DEFAULT_CF_INSTANCE = "standard-4";
323
+ var JOB_REF_PREFIX = "cf-";
324
+ var INSTANCE_VCPUS = {
325
+ lite: 1 / 16,
326
+ basic: 1 / 4,
327
+ "standard-1": 1 / 2,
328
+ "standard-2": 1,
329
+ "standard-3": 2,
330
+ "standard-4": 4
331
+ };
332
+ function createCloudflareContainerRunner(env) {
333
+ const instance = env.CF_TRANSCODE_INSTANCE?.trim() || DEFAULT_CF_INSTANCE;
334
+ const namespace = env.TRANSCODE_CONTAINER;
335
+ const stubFor = (videoId) => namespace?.get(namespace.idFromName(videoId));
336
+ async function launch(job) {
337
+ const stub = stubFor(job.videoId);
338
+ if (!stub) return notConfigured("TRANSCODE_CONTAINER binding is required for TRANSCODE_RUNNER=cloudflare");
339
+ if (!(instance in INSTANCE_VCPUS)) return notConfigured(`Unknown CF_TRANSCODE_INSTANCE "${instance}"`);
340
+ try {
341
+ const result = await stub.launch({ env: envWithScript(job), instance });
342
+ if (result.alreadyRunning) console.warn(`[transcode] cloudflare: ${job.videoId} already running, reusing it`);
343
+ return { ok: true, jobRef: `${JOB_REF_PREFIX}${job.videoId}` };
344
+ } catch (error) {
345
+ console.error("[transcode] cloudflare: container launch failed:", error);
346
+ return {
347
+ ok: false,
348
+ status: 503,
349
+ error: "Failed to start transcode container",
350
+ code: TranscodeLaunchErrorCode.UNAVAILABLE,
351
+ detail: String(error)
352
+ };
353
+ }
354
+ }
355
+ async function cancel(jobRef) {
356
+ await stubFor(jobRef.replace(JOB_REF_PREFIX, ""))?.cancel();
357
+ }
358
+ return { name: "cloudflare", cpus: INSTANCE_VCPUS[instance] ?? 0, launch, cancel };
359
+ }
360
+ function startTranscodeContainer(container, request) {
361
+ if (!container) throw new Error("Containers are not configured for this Durable Object (see [[containers]])");
362
+ if (container.running) return { alreadyRunning: true, exited: null };
363
+ const image = container.images?.[CONTAINER_IMAGE_NAME];
364
+ if (!image) throw new Error(`Missing [containers.images.${CONTAINER_IMAGE_NAME}] in the wrangler config`);
365
+ container.start({
366
+ image,
367
+ instance: request.instance,
368
+ // Downloads the raw and uploads the renditions over HTTP to the Worker → needs internet egress.
369
+ enableInternet: true,
370
+ entrypoint: ["sh", "-c", RUN_SCRIPT_COMMAND],
371
+ env: request.env
372
+ });
373
+ return { alreadyRunning: false, exited: container.monitor() };
374
+ }
375
+ function exitCodeOf(error) {
376
+ const exitCode = error?.exitCode;
377
+ return typeof exitCode === "number" ? exitCode : void 0;
378
+ }
379
+ function isUnreportedCrash(error) {
380
+ const exitCode = exitCodeOf(error);
381
+ return exitCode === void 0 || exitCode >= 128;
382
+ }
383
+ async function reportContainerCrash(env, error) {
384
+ if (!env.CALLBACK_URL || !env.VIDEO_ID) return;
385
+ const exitCode = exitCodeOf(error);
386
+ await fetch(env.CALLBACK_URL, {
387
+ method: "POST",
388
+ headers: {
389
+ "Content-Type": "application/json",
390
+ Authorization: `Bearer ${env.CALLBACK_SECRET ?? ""}`,
391
+ ...env.JOB_TICKET ? { [JOB_TICKET_HEADER]: env.JOB_TICKET } : {}
392
+ },
393
+ body: JSON.stringify({
394
+ event: "transcode.failed",
395
+ videoId: env.VIDEO_ID,
396
+ // The runtime text doesn't travel: this event gets forwarded to the client's webhook. It was already logged in watchContainer.
397
+ error: `transcode container stopped (${exitCode === void 0 ? "unexpectedly" : `exit ${exitCode}`})`,
398
+ reason: "internal",
399
+ retriable: true
400
+ })
401
+ });
402
+ }
403
+ async function watchContainer(exited, env) {
404
+ try {
405
+ await exited;
406
+ } catch (error) {
407
+ if (!isUnreportedCrash(error)) return;
408
+ console.error("[transcode] cloudflare: container crashed:", error);
409
+ await reportContainerCrash(env, error).catch(
410
+ (reportError) => console.error("[transcode] cloudflare: crash report failed:", reportError)
411
+ );
412
+ }
413
+ }
414
+
415
+ export {
416
+ isBearerAuthorized,
417
+ getRequestState,
418
+ isAuthenticated,
419
+ updateRequestState,
420
+ MaxResolution,
421
+ RESOURCE_SECURITY_HEADER,
422
+ parseSecurityHeader,
423
+ isContentTypeAllowed,
424
+ areLocalesAllowed,
425
+ storageKey,
426
+ storagePrefix,
427
+ isTranscodeWebhookAuthorized,
428
+ base64UrlDecode,
429
+ SERVICE_SUBJECT_PREFIX,
430
+ SERVICE_TOKEN_TYPE,
431
+ isServiceToken,
432
+ verifyViewerToken,
433
+ createObjectEncryption,
434
+ encryptForStorage,
435
+ shouldEncryptAtRest,
436
+ loadStorageKey,
437
+ signViewerToken,
438
+ hashKey,
439
+ deletePrefix,
440
+ KV_JOB_TTL_SECONDS,
441
+ UPLOAD_TOKEN_TTL_SECONDS,
442
+ KvPrefix,
443
+ contentDispositionAttachment,
444
+ contentDispositionInline,
445
+ sanitizeFilename,
446
+ JOB_TICKET_HEADER,
447
+ signJobTicket,
448
+ verifyJobTicket,
449
+ TranscodeRunnerName,
450
+ RUN_SCRIPT_COMMAND,
451
+ TranscodeLaunchErrorCode,
452
+ notConfigured,
453
+ launchErrorCode,
454
+ envWithScript,
455
+ parseRunnerName,
456
+ createCloudflareContainerRunner,
457
+ startTranscodeContainer,
458
+ watchContainer
459
+ };
@@ -0,0 +1,19 @@
1
+ interface ContainerLaunchRequest {
2
+ env: Record<string, string>;
3
+ instance: string;
4
+ }
5
+ interface ContainerLaunchResult {
6
+ alreadyRunning: boolean;
7
+ }
8
+ /** RPC surface of the DO (what the Worker calls on the stub). */
9
+ interface TranscodeContainerRpc {
10
+ launch(request: ContainerLaunchRequest): Promise<ContainerLaunchResult>;
11
+ cancel(): Promise<void>;
12
+ }
13
+ /** `TRANSCODE_CONTAINER` binding (DurableObjectNamespace<TranscodeContainer>), typed by what's actually used. */
14
+ interface TranscodeContainerNamespace {
15
+ idFromName(name: string): DurableObjectId;
16
+ get(id: DurableObjectId): TranscodeContainerRpc;
17
+ }
18
+
19
+ export type { ContainerLaunchRequest as C, TranscodeContainerNamespace as T, ContainerLaunchResult as a };
@@ -0,0 +1,198 @@
1
+ import {
2
+ mimeOfFormat
3
+ } from "./chunk-D3KOHAVH.js";
4
+
5
+ // src/image/codecs.ts
6
+ import avifDecode, { init as initAvifDecode } from "@jsquash/avif/decode";
7
+ import avifEncode, { init as initAvifEncode } from "@jsquash/avif/encode";
8
+ import jpegDecode, { init as initJpegDecode } from "@jsquash/jpeg/decode";
9
+ import jpegEncode, { init as initJpegEncode } from "@jsquash/jpeg/encode";
10
+ import pngDecode, { init as initPngDecode } from "@jsquash/png/decode";
11
+ import pngEncode, { init as initPngEncode } from "@jsquash/png/encode";
12
+ import resize, { initResize } from "@jsquash/resize";
13
+ import webpDecode, { init as initWebpDecode } from "@jsquash/webp/decode";
14
+ import webpEncode, { init as initWebpEncode } from "@jsquash/webp/encode";
15
+
16
+ // src/image/area-resize.ts
17
+ function axisCoverage(sourceSize, targetSize) {
18
+ const scale = sourceSize / targetSize;
19
+ const first = new Int32Array(sourceSize);
20
+ const split = new Float32Array(sourceSize);
21
+ for (let index = 0; index < sourceSize; index += 1) {
22
+ const target = Math.floor(index / scale);
23
+ const boundary = (target + 1) * scale;
24
+ first[index] = target;
25
+ split[index] = boundary >= index + 1 ? 1 : boundary - index;
26
+ }
27
+ return { first, split };
28
+ }
29
+ function areaDownscale(source, width, height, region = { x: 0, y: 0, width: source.width, height: source.height }) {
30
+ const horizontal = axisCoverage(region.width, width);
31
+ const vertical = axisCoverage(region.height, height);
32
+ const output = new Uint8ClampedArray(width * height * 4);
33
+ const rowSum = new Float32Array(width * 4);
34
+ let currentRow = new Float32Array(width * 4);
35
+ let nextRow = new Float32Array(width * 4);
36
+ const normalizer = 1 / (region.width / width * (region.height / height));
37
+ for (let sourceY = 0; sourceY < region.height; sourceY += 1) {
38
+ sumRowHorizontally(source, region, sourceY, horizontal, rowSum);
39
+ const weight = vertical.split[sourceY];
40
+ for (let index = 0; index < rowSum.length; index += 1) {
41
+ currentRow[index] += rowSum[index] * weight;
42
+ nextRow[index] += rowSum[index] * (1 - weight);
43
+ }
44
+ const isRowDone = sourceY === region.height - 1 || vertical.first[sourceY + 1] !== vertical.first[sourceY];
45
+ if (!isRowDone) continue;
46
+ writeRow(output, vertical.first[sourceY], currentRow, normalizer);
47
+ [currentRow, nextRow] = [nextRow, currentRow];
48
+ nextRow.fill(0);
49
+ }
50
+ return output;
51
+ }
52
+ function sumRowHorizontally(source, region, sourceY, horizontal, rowSum) {
53
+ rowSum.fill(0);
54
+ const pixels = source.data;
55
+ const lastTarget = rowSum.length - 4;
56
+ let offset = ((region.y + sourceY) * source.width + region.x) * 4;
57
+ for (let sourceX = 0; sourceX < region.width; sourceX += 1, offset += 4) {
58
+ const target = horizontal.first[sourceX] * 4;
59
+ const alpha = pixels[offset + 3];
60
+ const weight = horizontal.split[sourceX] * alpha;
61
+ addPixel(rowSum, target, pixels, offset, weight);
62
+ if (weight < alpha && target < lastTarget) addPixel(rowSum, target + 4, pixels, offset, alpha - weight);
63
+ }
64
+ }
65
+ function addPixel(row, target, pixels, offset, weight) {
66
+ row[target] += pixels[offset] * weight;
67
+ row[target + 1] += pixels[offset + 1] * weight;
68
+ row[target + 2] += pixels[offset + 2] * weight;
69
+ row[target + 3] += weight;
70
+ }
71
+ function writeRow(output, targetY, row, normalizer) {
72
+ const start = targetY * row.length;
73
+ for (let index = 0; index < row.length; index += 4) {
74
+ const alphaSum = row[index + 3];
75
+ const inverse = alphaSum > 0 ? 1 / alphaSum : 0;
76
+ output[start + index] = row[index] * inverse;
77
+ output[start + index + 1] = row[index + 1] * inverse;
78
+ output[start + index + 2] = row[index + 2] * inverse;
79
+ output[start + index + 3] = alphaSum * normalizer;
80
+ }
81
+ }
82
+
83
+ // src/image/codecs.ts
84
+ var AVIF_DEFAULT_SPEED = 8;
85
+ var ImageDataShim = class {
86
+ constructor(data, width, height) {
87
+ this.data = data;
88
+ this.width = width;
89
+ this.height = height;
90
+ }
91
+ data;
92
+ width;
93
+ height;
94
+ colorSpace = "srgb";
95
+ };
96
+ function ensureImageData() {
97
+ const scope = globalThis;
98
+ if (!scope.ImageData) scope.ImageData = ImageDataShim;
99
+ }
100
+ var asModuleInit = (init) => init;
101
+ var EMSCRIPTEN_DECODERS = {
102
+ "image/jpeg": [initJpegDecode, "jpegDec"],
103
+ "image/webp": [initWebpDecode, "webpDec"],
104
+ "image/avif": [initAvifDecode, "avifDec"]
105
+ };
106
+ var EMSCRIPTEN_ENCODERS = {
107
+ jpeg: [initJpegEncode, "jpegEnc"],
108
+ webp: [initWebpEncode, "webpEnc"],
109
+ avif: [initAvifEncode, "avifEnc"]
110
+ };
111
+ var bindgenReady = null;
112
+ async function prepareCodecs(wasm, sourceType, format) {
113
+ ensureImageData();
114
+ bindgenReady ??= Promise.all([
115
+ initResize(wasm.resize),
116
+ asModuleInit(initPngDecode)(wasm.pngCodec),
117
+ asModuleInit(initPngEncode)(wasm.pngCodec)
118
+ ]);
119
+ const decoder = EMSCRIPTEN_DECODERS[sourceType];
120
+ const encoder = EMSCRIPTEN_ENCODERS[format];
121
+ await Promise.all([
122
+ bindgenReady,
123
+ decoder ? asModuleInit(decoder[0])(wasm[decoder[1]]) : null,
124
+ encoder ? asModuleInit(encoder[0])(wasm[encoder[1]]) : null
125
+ ]);
126
+ }
127
+ async function decodeImage(bytes, contentType) {
128
+ if (contentType === "image/jpeg") return jpegDecode(bytes, { preserveOrientation: true });
129
+ if (contentType === "image/png") return pngDecode(bytes);
130
+ if (contentType === "image/webp") return webpDecode(bytes);
131
+ if (contentType === "image/avif") {
132
+ const decoded = await avifDecode(bytes);
133
+ if (!decoded) throw new Error("AVIF decode failed");
134
+ return decoded;
135
+ }
136
+ throw new Error(`Unsupported source type: ${contentType}`);
137
+ }
138
+ var DEFAULT_RESIZE_OPTIONS = { method: "lanczos3", linearRGB: false };
139
+ async function resizeImage(image, plan, options = DEFAULT_RESIZE_OPTIONS, strategy = "area") {
140
+ const region = plan.crop ?? { x: 0, y: 0, width: image.width, height: image.height };
141
+ const isSameSize = region.width === plan.width && region.height === plan.height;
142
+ const isDownscale = plan.width <= region.width && plan.height <= region.height;
143
+ const scaled = strategy === "area" && isDownscale && !isSameSize ? makeImageData(areaDownscale(image, plan.width, plan.height, region), plan.width, plan.height) : await resizeWithWasm(image, plan, options);
144
+ return plan.canvas ? padImage(scaled, plan.canvas) : scaled;
145
+ }
146
+ async function resizeWithWasm(image, plan, options) {
147
+ const cropped = plan.crop ? cropImage(image, plan.crop) : image;
148
+ if (cropped.width === plan.width && cropped.height === plan.height) return cropped;
149
+ return resize(cropped, { width: plan.width, height: plan.height, premultiply: true, ...options });
150
+ }
151
+ async function encodeImage(image, target) {
152
+ const isFast = (target.effort ?? "fast") === "fast";
153
+ if (target.format === "jpeg") return jpegEncode(image, { quality: target.quality, progressive: !isFast });
154
+ if (target.format === "webp") return webpEncode(image, { quality: target.quality, method: isFast ? 2 : 4 });
155
+ if (target.format === "png") return pngEncode(image);
156
+ return avifEncode(image, { quality: target.quality, speed: target.avifSpeed ?? AVIF_DEFAULT_SPEED });
157
+ }
158
+ async function transformImage(bytes, contentType, planFor, target, maxDecodedPixels = Number.POSITIVE_INFINITY) {
159
+ const decoded = await decodeImage(bytes, contentType);
160
+ if (decoded.width * decoded.height > maxDecodedPixels) {
161
+ throw new Error(`Decoded ${decoded.width}x${decoded.height} exceeds the header-checked budget`);
162
+ }
163
+ const resized = await resizeImage(decoded, planFor(decoded));
164
+ const body = await encodeImage(resized, target);
165
+ return { body, contentType: mimeOfFormat(target.format), width: resized.width, height: resized.height };
166
+ }
167
+ function makeImageData(data, width, height) {
168
+ const Ctor = globalThis.ImageData;
169
+ return new Ctor(data, width, height);
170
+ }
171
+ function cropImage(image, crop) {
172
+ const source = new Uint32Array(image.data.buffer, image.data.byteOffset, image.width * image.height);
173
+ const target = new Uint32Array(crop.width * crop.height);
174
+ for (let row = 0; row < crop.height; row += 1) {
175
+ const start = (row + crop.y) * image.width + crop.x;
176
+ target.set(source.subarray(start, start + crop.width), row * crop.width);
177
+ }
178
+ return makeImageData(new Uint8ClampedArray(target.buffer), crop.width, crop.height);
179
+ }
180
+ function padImage(image, canvas) {
181
+ const target = new Uint32Array(canvas.width * canvas.height).fill(4294967295);
182
+ const source = new Uint32Array(image.data.buffer, image.data.byteOffset, image.width * image.height);
183
+ const offsetX = Math.floor((canvas.width - image.width) / 2);
184
+ const offsetY = Math.floor((canvas.height - image.height) / 2);
185
+ for (let row = 0; row < image.height; row += 1) {
186
+ target.set(source.subarray(row * image.width, (row + 1) * image.width), (row + offsetY) * canvas.width + offsetX);
187
+ }
188
+ return makeImageData(new Uint8ClampedArray(target.buffer), canvas.width, canvas.height);
189
+ }
190
+ export {
191
+ AVIF_DEFAULT_SPEED,
192
+ DEFAULT_RESIZE_OPTIONS,
193
+ decodeImage,
194
+ encodeImage,
195
+ prepareCodecs,
196
+ resizeImage,
197
+ transformImage
198
+ };