@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.
- package/LICENSE +235 -0
- package/README.md +66 -2
- package/dist/chunk-D3KOHAVH.js +140 -0
- package/dist/chunk-VF4KBW3I.js +459 -0
- package/dist/cloudflare-kZ7J3LQ2.d.ts +19 -0
- package/dist/codecs-RKUOPKWR.js +198 -0
- package/dist/index.d.ts +530 -0
- package/dist/index.js +4008 -0
- package/dist/transcode/container.d.ts +10 -0
- package/dist/transcode/container.js +23 -0
- package/dist/wasm-modules-55Q2QP7Q.js +22 -0
- package/package.json +52 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,4008 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JOB_TICKET_HEADER,
|
|
3
|
+
KV_JOB_TTL_SECONDS,
|
|
4
|
+
KvPrefix,
|
|
5
|
+
MaxResolution,
|
|
6
|
+
RESOURCE_SECURITY_HEADER,
|
|
7
|
+
RUN_SCRIPT_COMMAND,
|
|
8
|
+
SERVICE_SUBJECT_PREFIX,
|
|
9
|
+
SERVICE_TOKEN_TYPE,
|
|
10
|
+
TranscodeLaunchErrorCode,
|
|
11
|
+
TranscodeRunnerName,
|
|
12
|
+
UPLOAD_TOKEN_TTL_SECONDS,
|
|
13
|
+
areLocalesAllowed,
|
|
14
|
+
base64UrlDecode,
|
|
15
|
+
contentDispositionAttachment,
|
|
16
|
+
contentDispositionInline,
|
|
17
|
+
createCloudflareContainerRunner,
|
|
18
|
+
createObjectEncryption,
|
|
19
|
+
deletePrefix,
|
|
20
|
+
encryptForStorage,
|
|
21
|
+
envWithScript,
|
|
22
|
+
getRequestState,
|
|
23
|
+
hashKey,
|
|
24
|
+
isAuthenticated,
|
|
25
|
+
isBearerAuthorized,
|
|
26
|
+
isContentTypeAllowed,
|
|
27
|
+
isServiceToken,
|
|
28
|
+
isTranscodeWebhookAuthorized,
|
|
29
|
+
launchErrorCode,
|
|
30
|
+
loadStorageKey,
|
|
31
|
+
notConfigured,
|
|
32
|
+
parseRunnerName,
|
|
33
|
+
parseSecurityHeader,
|
|
34
|
+
sanitizeFilename,
|
|
35
|
+
shouldEncryptAtRest,
|
|
36
|
+
signJobTicket,
|
|
37
|
+
signViewerToken,
|
|
38
|
+
storageKey,
|
|
39
|
+
storagePrefix,
|
|
40
|
+
updateRequestState,
|
|
41
|
+
verifyJobTicket,
|
|
42
|
+
verifyViewerToken
|
|
43
|
+
} from "./chunk-VF4KBW3I.js";
|
|
44
|
+
import {
|
|
45
|
+
ImageTransformsMode,
|
|
46
|
+
WORKER_DECODABLE_TYPES,
|
|
47
|
+
fitsWorkerBudget,
|
|
48
|
+
isUpscale,
|
|
49
|
+
isVariantKey,
|
|
50
|
+
megapixels,
|
|
51
|
+
mimeOfFormat,
|
|
52
|
+
outputQuality,
|
|
53
|
+
parseMaxMegapixels,
|
|
54
|
+
planResize,
|
|
55
|
+
resolveOutputFormat,
|
|
56
|
+
resolveRequestedFormat,
|
|
57
|
+
resolveTransformsMode,
|
|
58
|
+
variantPrefix,
|
|
59
|
+
variantStorageKey
|
|
60
|
+
} from "./chunk-D3KOHAVH.js";
|
|
61
|
+
|
|
62
|
+
// src/index.ts
|
|
63
|
+
import {
|
|
64
|
+
EXPIRES_AT_HEADER as EXPIRES_AT_HEADER2,
|
|
65
|
+
REPR_DIGEST_HEADER as REPR_DIGEST_HEADER2,
|
|
66
|
+
SESSION_HEADER as SESSION_HEADER2,
|
|
67
|
+
SHA256_HEADER as SHA256_HEADER3,
|
|
68
|
+
SHA256_VERIFIED_HEADER as SHA256_VERIFIED_HEADER2
|
|
69
|
+
} from "@devultur/core";
|
|
70
|
+
import { Hono as Hono10 } from "hono";
|
|
71
|
+
import { cors } from "hono/cors";
|
|
72
|
+
import { logger } from "hono/logger";
|
|
73
|
+
|
|
74
|
+
// src/plugin.ts
|
|
75
|
+
var PHASES = ["beforeAuth", "auth", "afterAuth"];
|
|
76
|
+
function assertComposable(plugins) {
|
|
77
|
+
const names = /* @__PURE__ */ new Set();
|
|
78
|
+
for (const plugin of plugins) {
|
|
79
|
+
if (names.has(plugin.name)) throw new Error(`createWorker: two plugins are named "${plugin.name}"`);
|
|
80
|
+
names.add(plugin.name);
|
|
81
|
+
}
|
|
82
|
+
if (!plugins.some((plugin) => (plugin.middleware?.auth?.length ?? 0) > 0)) {
|
|
83
|
+
throw new Error("createWorker: no plugin authenticates requests. Add workerSecretAuth() or your own auth plugin.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function composePlugins(plugins) {
|
|
87
|
+
assertComposable(plugins);
|
|
88
|
+
return {
|
|
89
|
+
middleware: PHASES.flatMap((phase) => plugins.flatMap((plugin) => plugin.middleware?.[phase] ?? [])),
|
|
90
|
+
routes: plugins.flatMap((plugin) => plugin.routes ?? []),
|
|
91
|
+
hooks: {
|
|
92
|
+
issueCredential: plugins.flatMap((plugin) => plugin.issueCredential ? [plugin.issueCredential] : []),
|
|
93
|
+
beforeLinkRedeem: plugins.flatMap((plugin) => plugin.beforeLinkRedeem ? [plugin.beforeLinkRedeem] : [])
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/plugin-hooks.ts
|
|
99
|
+
var PLUGIN_HOOKS_VARIABLE = "pluginHooks";
|
|
100
|
+
var NO_HOOKS = { issueCredential: [], beforeLinkRedeem: [] };
|
|
101
|
+
function getPluginHooks(context) {
|
|
102
|
+
return context.get(PLUGIN_HOOKS_VARIABLE) ?? NO_HOOKS;
|
|
103
|
+
}
|
|
104
|
+
var earliest = (current, next) => current === void 0 ? next : next === void 0 ? current : Math.min(current, next);
|
|
105
|
+
async function decideCredential(context, request) {
|
|
106
|
+
let expiresAt;
|
|
107
|
+
let claims;
|
|
108
|
+
for (const hook of getPluginHooks(context).issueCredential) {
|
|
109
|
+
const decision = await hook(context, request);
|
|
110
|
+
if (!decision.allow) return decision;
|
|
111
|
+
expiresAt = earliest(expiresAt, decision.expiresAt);
|
|
112
|
+
if (decision.claims) claims = { ...claims, ...decision.claims };
|
|
113
|
+
}
|
|
114
|
+
return { allow: true, ...expiresAt !== void 0 ? { expiresAt } : {}, ...claims ? { claims } : {} };
|
|
115
|
+
}
|
|
116
|
+
async function gateLinkRedemption(context, link) {
|
|
117
|
+
for (const hook of getPluginHooks(context).beforeLinkRedeem) {
|
|
118
|
+
const refusal = await hook(context, link);
|
|
119
|
+
if (refusal) return refusal;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
function cappedExpiry(requested, cap, toUnit) {
|
|
124
|
+
return cap === void 0 ? requested : Math.min(requested, toUnit(cap));
|
|
125
|
+
}
|
|
126
|
+
function withPluginClaims(claims, decision) {
|
|
127
|
+
return { ...decision.claims, ...claims };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/plugins/credential-policy.ts
|
|
131
|
+
function credentialPolicy(options = {}) {
|
|
132
|
+
return {
|
|
133
|
+
name: "credential-policy",
|
|
134
|
+
issueCredential: (_context, request) => {
|
|
135
|
+
const maxTtlSeconds = options.maxTtlSeconds?.[request.kind];
|
|
136
|
+
if (maxTtlSeconds === void 0) return { allow: true };
|
|
137
|
+
return { allow: true, expiresAt: Date.now() + maxTtlSeconds * 1e3 };
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/plugins/download-links.ts
|
|
143
|
+
import { Hono as Hono2 } from "hono";
|
|
144
|
+
|
|
145
|
+
// src/routes/download.ts
|
|
146
|
+
import { contentDispositionAttachment as contentDispositionAttachment2, relativeMediaPath, TokenScope } from "@devultur/core";
|
|
147
|
+
import { Hono } from "hono";
|
|
148
|
+
|
|
149
|
+
// src/download-link.ts
|
|
150
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
151
|
+
function recordContext(record) {
|
|
152
|
+
if (record.context) return record.context;
|
|
153
|
+
const legacy = record.callerContext;
|
|
154
|
+
return isRecord(legacy) ? legacy : void 0;
|
|
155
|
+
}
|
|
156
|
+
var DownloadLinkPath = {
|
|
157
|
+
CREATE: "/create",
|
|
158
|
+
REDEEM: "/redeem",
|
|
159
|
+
/** Reads the link without consuming a use (same 404/410 as redemption): the `beforeLinkRedeem` hook decides first. */
|
|
160
|
+
PEEK: "/peek"
|
|
161
|
+
};
|
|
162
|
+
var RECORD_KEY = "link";
|
|
163
|
+
var linkGone = (code, error) => Response.json({ error, code }, { status: 410 });
|
|
164
|
+
var DownloadLink = class {
|
|
165
|
+
state;
|
|
166
|
+
storage;
|
|
167
|
+
constructor(state) {
|
|
168
|
+
this.state = state;
|
|
169
|
+
this.storage = state.storage;
|
|
170
|
+
}
|
|
171
|
+
async fetch(request) {
|
|
172
|
+
const { pathname } = new URL(request.url);
|
|
173
|
+
if (request.method !== "POST") return new Response(null, { status: 405 });
|
|
174
|
+
if (pathname === DownloadLinkPath.CREATE) return this.state.blockConcurrencyWhile(() => this.create(request));
|
|
175
|
+
if (pathname === DownloadLinkPath.REDEEM) return this.state.blockConcurrencyWhile(() => this.redeem());
|
|
176
|
+
if (pathname === DownloadLinkPath.PEEK) return this.state.blockConcurrencyWhile(() => this.peek());
|
|
177
|
+
return new Response(null, { status: 404 });
|
|
178
|
+
}
|
|
179
|
+
async create(request) {
|
|
180
|
+
if (await this.storage.get(RECORD_KEY)) {
|
|
181
|
+
return Response.json({ error: "Link id already in use", code: "link_exists" }, { status: 409 });
|
|
182
|
+
}
|
|
183
|
+
const record = await request.json();
|
|
184
|
+
await this.storage.put(RECORD_KEY, { ...record, uses: 0 });
|
|
185
|
+
await this.storage.setAlarm(record.expiresAt);
|
|
186
|
+
return Response.json({ ok: true }, { status: 201 });
|
|
187
|
+
}
|
|
188
|
+
/** The record if it still has a use left, or the response explaining why not: 404 if it never existed (or the alarm already
|
|
189
|
+
* cleared it), 410 if it expired or was already used. */
|
|
190
|
+
async usableRecord() {
|
|
191
|
+
const record = await this.storage.get(RECORD_KEY);
|
|
192
|
+
if (!record) return Response.json({ error: "Download link not found", code: "link_not_found" }, { status: 404 });
|
|
193
|
+
if (record.expiresAt <= Date.now()) return linkGone("link_expired", "This download link has expired");
|
|
194
|
+
if (record.uses >= record.maxUses) return linkGone("link_used", "This download link was already used");
|
|
195
|
+
return record;
|
|
196
|
+
}
|
|
197
|
+
async peek() {
|
|
198
|
+
const record = await this.usableRecord();
|
|
199
|
+
return record instanceof Response ? record : Response.json(record);
|
|
200
|
+
}
|
|
201
|
+
/** Consumes ONE use. */
|
|
202
|
+
async redeem() {
|
|
203
|
+
const record = await this.usableRecord();
|
|
204
|
+
if (record instanceof Response) return record;
|
|
205
|
+
const consumed = { ...record, uses: record.uses + 1 };
|
|
206
|
+
await this.storage.put(RECORD_KEY, consumed);
|
|
207
|
+
return Response.json(consumed);
|
|
208
|
+
}
|
|
209
|
+
async alarm() {
|
|
210
|
+
await this.storage.deleteAll();
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// src/route-result.ts
|
|
215
|
+
var ROUTE_RESULT_VARIABLE = "routeResult";
|
|
216
|
+
function getRouteResult(context) {
|
|
217
|
+
return context.get(ROUTE_RESULT_VARIABLE) ?? {};
|
|
218
|
+
}
|
|
219
|
+
function addRouteResult(context, result) {
|
|
220
|
+
context.set(ROUTE_RESULT_VARIABLE, { ...getRouteResult(context), ...result });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/routes/object-response.ts
|
|
224
|
+
import {
|
|
225
|
+
AES_BLOCK_BYTES,
|
|
226
|
+
createDecryptStream,
|
|
227
|
+
decodeEncryptionMeta,
|
|
228
|
+
decryptChunk,
|
|
229
|
+
EXPIRES_AT_HEADER,
|
|
230
|
+
REPR_DIGEST_HEADER,
|
|
231
|
+
reprDigest,
|
|
232
|
+
SHA256_HEADER as SHA256_HEADER2,
|
|
233
|
+
SHA256_VERIFIED_HEADER
|
|
234
|
+
} from "@devultur/core";
|
|
235
|
+
|
|
236
|
+
// src/routes/upload-integrity.ts
|
|
237
|
+
import { normalizeSha256, SHA256_HEADER, sha256Hex } from "@devultur/core";
|
|
238
|
+
var ObjectMeta = {
|
|
239
|
+
SHA256: "sha256",
|
|
240
|
+
/** `"true"` = the worker hashed the bytes; `"false"` = sha256 declared by the client (multipart). */
|
|
241
|
+
SHA256_VERIFIED: "sha256Verified",
|
|
242
|
+
/** Epoch ms. From then on the gate responds 410 (and an external sweeper may delete it). */
|
|
243
|
+
EXPIRES_AT: "expiresAt"
|
|
244
|
+
};
|
|
245
|
+
var WORKER_METADATA_KEYS = /* @__PURE__ */ new Set(["security", "enc", ObjectMeta.EXPIRES_AT.toLowerCase(), "derivedfrom"]);
|
|
246
|
+
function isWorkerMetadataKey(key) {
|
|
247
|
+
const lowerKey = key.toLowerCase();
|
|
248
|
+
return lowerKey.startsWith(ObjectMeta.SHA256) || WORKER_METADATA_KEYS.has(lowerKey);
|
|
249
|
+
}
|
|
250
|
+
function allowedUploadMetadata(metadata) {
|
|
251
|
+
if (!metadata) return {};
|
|
252
|
+
return Object.fromEntries(Object.entries(metadata).filter(([key]) => !isWorkerMetadataKey(key)));
|
|
253
|
+
}
|
|
254
|
+
function targetFailure(context, failure3) {
|
|
255
|
+
return context.json({ error: failure3.error, code: failure3.code }, failure3.status);
|
|
256
|
+
}
|
|
257
|
+
function alreadyExists(context, key) {
|
|
258
|
+
return context.json(
|
|
259
|
+
{ error: `${key} already exists; pass overwrite: true to replace it`, code: "already_exists", key },
|
|
260
|
+
409
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
function sha256Mismatch(context, expected, actual) {
|
|
264
|
+
return context.json(
|
|
265
|
+
{ error: "The uploaded bytes do not match the declared sha256", code: "sha256_mismatch", expected, actual },
|
|
266
|
+
400
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
function headerSha256(context) {
|
|
270
|
+
const raw = context.req.header(SHA256_HEADER);
|
|
271
|
+
if (raw === void 0) return void 0;
|
|
272
|
+
return normalizeSha256(raw);
|
|
273
|
+
}
|
|
274
|
+
async function digestBody(body, declared, header) {
|
|
275
|
+
if (header === null) return { invalid: true };
|
|
276
|
+
const actual = await sha256Hex(body);
|
|
277
|
+
const expected = declared ?? header;
|
|
278
|
+
if (expected && expected !== actual) return { mismatch: { expected, actual } };
|
|
279
|
+
return { sha256: actual };
|
|
280
|
+
}
|
|
281
|
+
function integrityMetadata(sha256, isVerified, expiresAt) {
|
|
282
|
+
return {
|
|
283
|
+
...sha256 ? { [ObjectMeta.SHA256]: sha256, [ObjectMeta.SHA256_VERIFIED]: String(isVerified) } : {},
|
|
284
|
+
...expiresAt ? { [ObjectMeta.EXPIRES_AT]: String(expiresAt) } : {}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function createOnlyCondition() {
|
|
288
|
+
return new Headers({ "If-None-Match": "*" });
|
|
289
|
+
}
|
|
290
|
+
async function discardStaleVariants(bucket, pending) {
|
|
291
|
+
if (!pending?.overwrite) return;
|
|
292
|
+
await deletePrefix(bucket, variantPrefix(pending.storageKey));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/routes/object-response.ts
|
|
296
|
+
function isObjectExpired(customMetadata, now = Date.now()) {
|
|
297
|
+
const expiresAt = Number(customMetadata?.[ObjectMeta.EXPIRES_AT]);
|
|
298
|
+
return Number.isFinite(expiresAt) && expiresAt > 0 && expiresAt <= now;
|
|
299
|
+
}
|
|
300
|
+
function expiredResponse() {
|
|
301
|
+
return Response.json({ error: "This file has expired", code: "expired" }, { status: 410 });
|
|
302
|
+
}
|
|
303
|
+
function applyIntegrityHeaders(headers, customMetadata) {
|
|
304
|
+
const sha256 = customMetadata?.[ObjectMeta.SHA256];
|
|
305
|
+
if (sha256) {
|
|
306
|
+
headers.set(SHA256_HEADER2, sha256);
|
|
307
|
+
headers.set(REPR_DIGEST_HEADER, reprDigest(sha256));
|
|
308
|
+
headers.set(SHA256_VERIFIED_HEADER, customMetadata?.[ObjectMeta.SHA256_VERIFIED] === "true" ? "true" : "false");
|
|
309
|
+
}
|
|
310
|
+
const expiresAt = customMetadata?.[ObjectMeta.EXPIRES_AT];
|
|
311
|
+
if (expiresAt) headers.set(EXPIRES_AT_HEADER, expiresAt);
|
|
312
|
+
}
|
|
313
|
+
function parseRange(range, totalSize) {
|
|
314
|
+
const match = range.match(/bytes=(\d*)-(\d*)/);
|
|
315
|
+
if (!match || !match[1] && !match[2]) return [0, totalSize - 1];
|
|
316
|
+
if (!match[1]) {
|
|
317
|
+
const suffixLength = Number.parseInt(match[2], 10);
|
|
318
|
+
if (suffixLength === 0) return null;
|
|
319
|
+
return [Math.max(0, totalSize - suffixLength), totalSize - 1];
|
|
320
|
+
}
|
|
321
|
+
const start = Number.parseInt(match[1], 10);
|
|
322
|
+
if (start >= totalSize) return null;
|
|
323
|
+
const end = match[2] ? Math.min(Number.parseInt(match[2], 10), totalSize - 1) : totalSize - 1;
|
|
324
|
+
return end < start ? null : [start, end];
|
|
325
|
+
}
|
|
326
|
+
var notFound = () => Response.json({ error: "Not found" }, { status: 404 });
|
|
327
|
+
async function rangeResponse(input, cryptoKey, iv) {
|
|
328
|
+
const { bucket, fullKey, object, headers } = input;
|
|
329
|
+
const byteRange = parseRange(input.range ?? "", object.size);
|
|
330
|
+
if (!byteRange) {
|
|
331
|
+
return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${object.size}` } });
|
|
332
|
+
}
|
|
333
|
+
const [start, end] = byteRange;
|
|
334
|
+
headers.set("Content-Range", `bytes ${start}-${end}/${object.size}`);
|
|
335
|
+
headers.set("Content-Length", String(end - start + 1));
|
|
336
|
+
if (cryptoKey && iv) {
|
|
337
|
+
const firstBlock = Math.floor(start / AES_BLOCK_BYTES);
|
|
338
|
+
const alignedOffset = firstBlock * AES_BLOCK_BYTES;
|
|
339
|
+
const cipherPart = await bucket.get(fullKey, { range: { offset: alignedOffset, length: end - alignedOffset + 1 } });
|
|
340
|
+
if (!cipherPart) return notFound();
|
|
341
|
+
const decrypted = await decryptChunk(await cipherPart.arrayBuffer(), cryptoKey, iv, firstBlock);
|
|
342
|
+
const leading = start - alignedOffset;
|
|
343
|
+
return new Response(decrypted.subarray(leading, leading + (end - start + 1)), { status: 206, headers });
|
|
344
|
+
}
|
|
345
|
+
const partial = await bucket.get(fullKey, { range: { offset: start, length: end - start + 1 } });
|
|
346
|
+
if (!partial) return notFound();
|
|
347
|
+
return new Response(partial.body, { status: 206, headers });
|
|
348
|
+
}
|
|
349
|
+
async function streamObject(input) {
|
|
350
|
+
const { object, headers } = input;
|
|
351
|
+
headers.set("Content-Length", String(object.size));
|
|
352
|
+
headers.set("ETag", object.httpEtag);
|
|
353
|
+
headers.set("Accept-Ranges", "bytes");
|
|
354
|
+
applyIntegrityHeaders(headers, object.customMetadata);
|
|
355
|
+
const enc = decodeEncryptionMeta(object.customMetadata?.enc);
|
|
356
|
+
const loaded = enc ? await loadStorageKey(input.keysBucket, input.fullKey, enc.keyId) : null;
|
|
357
|
+
if (enc && !loaded) {
|
|
358
|
+
return { response: Response.json({ error: "Decryption key unavailable" }, { status: 500 }), kind: "other" };
|
|
359
|
+
}
|
|
360
|
+
const cryptoKey = loaded?.key ?? null;
|
|
361
|
+
const iv = enc?.iv ?? null;
|
|
362
|
+
if (input.range && object.size > 0) {
|
|
363
|
+
const response = await rangeResponse(input, cryptoKey, iv);
|
|
364
|
+
return { response, kind: !enc && response.status === 206 ? "plain-range" : "other" };
|
|
365
|
+
}
|
|
366
|
+
if (cryptoKey && iv && object.body) {
|
|
367
|
+
return {
|
|
368
|
+
response: new Response(object.body.pipeThrough(createDecryptStream(cryptoKey, iv)), { status: 200, headers }),
|
|
369
|
+
kind: "other"
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return { response: new Response(object.body, { status: 200, headers }), kind: "plain-full" };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// src/routes/download.ts
|
|
376
|
+
var DEFAULT_LINK_TTL_SECONDS = 15 * 60;
|
|
377
|
+
var MAX_LINK_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
378
|
+
var MAX_LINK_USES = 100;
|
|
379
|
+
var LINK_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/;
|
|
380
|
+
function newLinkId() {
|
|
381
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
382
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
383
|
+
}
|
|
384
|
+
function linkStub(namespace, linkId) {
|
|
385
|
+
return namespace.get(namespace.idFromName(linkId));
|
|
386
|
+
}
|
|
387
|
+
var isIntegerIn = (value, max) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= max;
|
|
388
|
+
function parseLinkRequest(body) {
|
|
389
|
+
const key = typeof body.key === "string" ? body.key : "";
|
|
390
|
+
if (relativeMediaPath(key) === null) {
|
|
391
|
+
return { error: "key must be a visibility-prefixed media key", code: "invalid_key" };
|
|
392
|
+
}
|
|
393
|
+
const expiresIn = body.expiresIn ?? DEFAULT_LINK_TTL_SECONDS;
|
|
394
|
+
if (!isIntegerIn(expiresIn, MAX_LINK_TTL_SECONDS)) {
|
|
395
|
+
return { error: `expiresIn must be 1..${MAX_LINK_TTL_SECONDS} seconds`, code: "invalid_expires_in" };
|
|
396
|
+
}
|
|
397
|
+
const maxUses = body.maxUses ?? 1;
|
|
398
|
+
if (!isIntegerIn(maxUses, MAX_LINK_USES)) {
|
|
399
|
+
return { error: `maxUses must be 1..${MAX_LINK_USES}`, code: "invalid_max_uses" };
|
|
400
|
+
}
|
|
401
|
+
const filename = typeof body.filename === "string" && body.filename ? body.filename : void 0;
|
|
402
|
+
return { key, expiresIn, maxUses, filename };
|
|
403
|
+
}
|
|
404
|
+
async function handleDownloadLink(context) {
|
|
405
|
+
const namespace = context.env.DOWNLOAD_LINKS;
|
|
406
|
+
if (!namespace) return context.json({ error: "Download links are not configured", code: "not_configured" }, 501);
|
|
407
|
+
const parsed = parseLinkRequest(await context.req.json().catch(() => null) ?? {});
|
|
408
|
+
if ("error" in parsed) return context.json(parsed, 400);
|
|
409
|
+
const fullKey = storageKey(context, parsed.key);
|
|
410
|
+
const object = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
411
|
+
if (!object) return context.json({ error: "Not found" }, 404);
|
|
412
|
+
if (isObjectExpired(object.customMetadata)) return expiredResponse();
|
|
413
|
+
const objectExpiresAt = Number(object.customMetadata?.expiresAt) || Infinity;
|
|
414
|
+
const requestedExpiresAt = Math.min(Date.now() + parsed.expiresIn * 1e3, objectExpiresAt);
|
|
415
|
+
const decision = await decideCredential(context, {
|
|
416
|
+
kind: "download-link",
|
|
417
|
+
scopes: [TokenScope.MEDIA_READ],
|
|
418
|
+
path: parsed.key,
|
|
419
|
+
expiresAt: requestedExpiresAt
|
|
420
|
+
});
|
|
421
|
+
if (!decision.allow) return decision.response;
|
|
422
|
+
const expiresAt = cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs);
|
|
423
|
+
const linkId = newLinkId();
|
|
424
|
+
const scope = getRequestState(context);
|
|
425
|
+
const record = {
|
|
426
|
+
storageKey: fullKey,
|
|
427
|
+
key: parsed.key,
|
|
428
|
+
expiresAt,
|
|
429
|
+
maxUses: parsed.maxUses,
|
|
430
|
+
...parsed.filename ? { filename: parsed.filename } : {},
|
|
431
|
+
storagePrefix: scope.storagePrefix,
|
|
432
|
+
...scope.context ? { context: scope.context } : {}
|
|
433
|
+
};
|
|
434
|
+
const created = await linkStub(namespace, linkId).fetch(`https://download-link${DownloadLinkPath.CREATE}`, {
|
|
435
|
+
method: "POST",
|
|
436
|
+
body: JSON.stringify(record)
|
|
437
|
+
});
|
|
438
|
+
if (!created.ok) return context.json({ error: "Could not create the download link" }, 500);
|
|
439
|
+
const origin = new URL(context.req.url).origin;
|
|
440
|
+
return context.json({ url: `${origin}/v1/download/${linkId}`, expiresAt, maxUses: parsed.maxUses });
|
|
441
|
+
}
|
|
442
|
+
function attachmentDisposition(record, storedDisposition) {
|
|
443
|
+
if (record.filename) return contentDispositionAttachment2(record.filename);
|
|
444
|
+
const fallback2 = storedDisposition ?? contentDispositionInline(record.key.split("/").pop() || "download");
|
|
445
|
+
return fallback2.startsWith("inline") ? `attachment${fallback2.slice("inline".length)}` : fallback2;
|
|
446
|
+
}
|
|
447
|
+
var callLink = (stub, path) => stub.fetch(`https://download-link${path}`, { method: "POST" });
|
|
448
|
+
var relayLinkFailure = (response) => new Response(response.body, { status: response.status, headers: response.headers });
|
|
449
|
+
function linkInfoOf(record) {
|
|
450
|
+
const context = recordContext(record);
|
|
451
|
+
return { storagePrefix: record.storagePrefix, key: record.key, ...context ? { context } : {} };
|
|
452
|
+
}
|
|
453
|
+
async function gateRedemption(context, stub) {
|
|
454
|
+
if (getPluginHooks(context).beforeLinkRedeem.length === 0) return null;
|
|
455
|
+
const peeked = await callLink(stub, DownloadLinkPath.PEEK);
|
|
456
|
+
if (!peeked.ok) return relayLinkFailure(peeked);
|
|
457
|
+
return gateLinkRedemption(context, linkInfoOf(await peeked.json()));
|
|
458
|
+
}
|
|
459
|
+
var download = new Hono();
|
|
460
|
+
download.get("/:linkId", async (context) => {
|
|
461
|
+
if (context.req.method === "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET" } });
|
|
462
|
+
const linkId = context.req.param("linkId");
|
|
463
|
+
const namespace = context.env.DOWNLOAD_LINKS;
|
|
464
|
+
if (!namespace || !LINK_ID_PATTERN.test(linkId)) return context.json({ error: "Not found" }, 404);
|
|
465
|
+
const stub = linkStub(namespace, linkId);
|
|
466
|
+
const blocked = await gateRedemption(context, stub);
|
|
467
|
+
if (blocked) return blocked;
|
|
468
|
+
const redeemed = await callLink(stub, DownloadLinkPath.REDEEM);
|
|
469
|
+
if (!redeemed.ok) return relayLinkFailure(redeemed);
|
|
470
|
+
const record = await redeemed.json();
|
|
471
|
+
const object = await context.env.MEDIA_BUCKET.get(record.storageKey);
|
|
472
|
+
if (!object) return context.json({ error: "Not found" }, 404);
|
|
473
|
+
if (isObjectExpired(object.customMetadata)) return expiredResponse();
|
|
474
|
+
addRouteResult(context, { redeemedLink: linkInfoOf(record) });
|
|
475
|
+
const headers = new Headers({
|
|
476
|
+
"Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
|
|
477
|
+
"Cache-Control": "private, no-store",
|
|
478
|
+
"Content-Disposition": attachmentDisposition(record, object.httpMetadata?.contentDisposition)
|
|
479
|
+
});
|
|
480
|
+
const { response } = await streamObject({
|
|
481
|
+
bucket: context.env.MEDIA_BUCKET,
|
|
482
|
+
keysBucket: context.env.KEYS_BUCKET,
|
|
483
|
+
fullKey: record.storageKey,
|
|
484
|
+
object,
|
|
485
|
+
headers,
|
|
486
|
+
range: context.req.header("Range")
|
|
487
|
+
});
|
|
488
|
+
return response;
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// src/plugins/download-links.ts
|
|
492
|
+
function downloadLinks() {
|
|
493
|
+
const issue = new Hono2().post("/", handleDownloadLink);
|
|
494
|
+
return {
|
|
495
|
+
name: "download-links",
|
|
496
|
+
routes: [
|
|
497
|
+
{ path: "/v1/token/download", app: issue },
|
|
498
|
+
{ path: "/v1/download", app: download }
|
|
499
|
+
]
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/plugins/static-config.ts
|
|
504
|
+
function staticConfig(config) {
|
|
505
|
+
const applyConfig = async (context, next) => {
|
|
506
|
+
updateRequestState(context, { config });
|
|
507
|
+
await next();
|
|
508
|
+
};
|
|
509
|
+
return { name: "static-config", middleware: { beforeAuth: [applyConfig] } };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/routes/token-upload.ts
|
|
513
|
+
import { assetRelativePath as assetRelativePath2, normalizeAssetPrefix } from "@devultur/core";
|
|
514
|
+
|
|
515
|
+
// src/token-claims.ts
|
|
516
|
+
function audienceClaim(storagePrefix2) {
|
|
517
|
+
const prefix = storagePrefix2.replace(/\/$/, "");
|
|
518
|
+
return prefix ? { aud: prefix } : {};
|
|
519
|
+
}
|
|
520
|
+
function scopeClaim(scopes) {
|
|
521
|
+
return { scope: scopes.join(" ") };
|
|
522
|
+
}
|
|
523
|
+
function hasInvalidScope(scopes) {
|
|
524
|
+
return scopes.some((scope) => typeof scope !== "string" || scope === "" || /\s/.test(scope));
|
|
525
|
+
}
|
|
526
|
+
function readTokenScopes(payload) {
|
|
527
|
+
if (payload.scope === void 0) return void 0;
|
|
528
|
+
if (typeof payload.scope !== "string") return [];
|
|
529
|
+
return payload.scope.split(" ").filter(Boolean);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/routes/upload-target.ts
|
|
533
|
+
import {
|
|
534
|
+
assetRelativePath,
|
|
535
|
+
DEFAULT_ASSET_CACHE_CONTROL,
|
|
536
|
+
filePath,
|
|
537
|
+
generateId,
|
|
538
|
+
imagePath,
|
|
539
|
+
isWithinPathPrefix as isWithinPathPrefix2,
|
|
540
|
+
MediaType,
|
|
541
|
+
matchCacheRule,
|
|
542
|
+
normalizeAssetPath,
|
|
543
|
+
normalizeSha256 as normalizeSha2562,
|
|
544
|
+
resolveCacheControl,
|
|
545
|
+
videoRawPath
|
|
546
|
+
} from "@devultur/core";
|
|
547
|
+
|
|
548
|
+
// src/routes/viewer-token-claims.ts
|
|
549
|
+
import { coversScope, isWithinPathPrefix, relativeMediaPath as relativeMediaPath2, TokenScope as TokenScope2 } from "@devultur/core";
|
|
550
|
+
var TOKEN_REJECTED_CODE = "token_rejected";
|
|
551
|
+
function isOtherAudience(payload, storagePrefix2) {
|
|
552
|
+
if (payload.aud === void 0) return false;
|
|
553
|
+
return payload.aud !== (storagePrefix2 ?? "").replace(/\/$/, "");
|
|
554
|
+
}
|
|
555
|
+
function isOutsideResource(payload, key) {
|
|
556
|
+
if (payload.res === void 0) return false;
|
|
557
|
+
if (typeof payload.res !== "string") return true;
|
|
558
|
+
const relativePath = relativeMediaPath2(key);
|
|
559
|
+
return relativePath === null || !isWithinPathPrefix(relativePath, payload.res);
|
|
560
|
+
}
|
|
561
|
+
function lacksReadScope(payload) {
|
|
562
|
+
const scopes = readTokenScopes(payload);
|
|
563
|
+
return scopes !== void 0 && !coversScope(scopes, TokenScope2.MEDIA_READ);
|
|
564
|
+
}
|
|
565
|
+
function rejectionReason(payload, target) {
|
|
566
|
+
if (isOtherAudience(payload, target.storagePrefix)) return "audience";
|
|
567
|
+
if (isOutsideResource(payload, target.key)) return "resource";
|
|
568
|
+
if (lacksReadScope(payload)) return "scope";
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
function viewerTokenClaimRejection(payload, target) {
|
|
572
|
+
const reason = rejectionReason(payload, target);
|
|
573
|
+
if (!reason) return null;
|
|
574
|
+
return { status: 403, error: "Token rejected", code: TOKEN_REJECTED_CODE, reason };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// src/routes/upload-target.ts
|
|
578
|
+
var MAX_FILE_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
579
|
+
var MAX_FILES_COUNTER_TTL_SECONDS = 24 * 60 * 60;
|
|
580
|
+
var MAX_FILES_PREFIX = "upload-token-files";
|
|
581
|
+
var failure = (status, code, error) => ({
|
|
582
|
+
status,
|
|
583
|
+
code,
|
|
584
|
+
error
|
|
585
|
+
});
|
|
586
|
+
function isUploadTargetError(value) {
|
|
587
|
+
return typeof value === "object" && value !== null && "code" in value && "status" in value;
|
|
588
|
+
}
|
|
589
|
+
function generatedKindPath(mediaType, id, filename) {
|
|
590
|
+
const safeFilename = sanitizeFilename(filename);
|
|
591
|
+
if (mediaType === MediaType.VIDEO) return videoRawPath(id, safeFilename);
|
|
592
|
+
if (mediaType === MediaType.IMAGE) return imagePath(id, safeFilename);
|
|
593
|
+
return filePath(id, safeFilename);
|
|
594
|
+
}
|
|
595
|
+
function tokenRejected(reason, path) {
|
|
596
|
+
console.warn(`[upload] token rejected (${reason}) for path ${String(path)}`);
|
|
597
|
+
return failure(403, TOKEN_REJECTED_CODE, "Token rejected");
|
|
598
|
+
}
|
|
599
|
+
function assetTarget(rawPath, visibility, grant) {
|
|
600
|
+
const assetPath = normalizeAssetPath(rawPath);
|
|
601
|
+
if (!assetPath) {
|
|
602
|
+
return failure(400, "invalid_path", "path must be a relative path of [A-Za-z0-9._-] segments, without `..`");
|
|
603
|
+
}
|
|
604
|
+
const relativePath = assetRelativePath(assetPath);
|
|
605
|
+
if (grant && !isWithinPathPrefix2(relativePath, grant.pathPrefix)) return tokenRejected("resource", relativePath);
|
|
606
|
+
const key = `${visibility}/${relativePath}`;
|
|
607
|
+
return { key, id: key, visibility, isAsset: true, assetPath };
|
|
608
|
+
}
|
|
609
|
+
function resolveUploadTarget(body, mediaType, visibility, grant) {
|
|
610
|
+
if (grant && body.path === void 0) {
|
|
611
|
+
return failure(400, "path_required", "an upload token can only create files at an explicit `path`");
|
|
612
|
+
}
|
|
613
|
+
if (grant && body.visibility && body.visibility !== grant.visibility) return tokenRejected("visibility", body.path);
|
|
614
|
+
if (body.path !== void 0) return assetTarget(body.path, grant?.visibility ?? visibility, grant);
|
|
615
|
+
const id = generateId();
|
|
616
|
+
return { key: `${visibility}/${generatedKindPath(mediaType, id, body.filename)}`, id, visibility, isAsset: false };
|
|
617
|
+
}
|
|
618
|
+
function resolveExpiresAt(requested, grant, now, maxTtlSeconds) {
|
|
619
|
+
const hasRequest = requested !== void 0 && requested !== null;
|
|
620
|
+
const isValid = typeof requested === "number" && Number.isInteger(requested) && requested > 0;
|
|
621
|
+
if (hasRequest && (!isValid || requested > MAX_FILE_TTL_SECONDS)) {
|
|
622
|
+
return failure(400, "invalid_expires_in", "expiresIn must be a positive integer number of seconds (max 1 year)");
|
|
623
|
+
}
|
|
624
|
+
const candidates = [hasRequest ? requested : void 0, grant?.fileExpiresIn, maxTtlSeconds];
|
|
625
|
+
const ttls = candidates.filter((ttl) => typeof ttl === "number" && ttl > 0);
|
|
626
|
+
return ttls.length > 0 ? now + Math.min(...ttls) * 1e3 : void 0;
|
|
627
|
+
}
|
|
628
|
+
function resolveDeclaredSha256(raw) {
|
|
629
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
630
|
+
return normalizeSha2562(raw) ?? failure(400, "invalid_sha256", "sha256 must be a 64-character hex string");
|
|
631
|
+
}
|
|
632
|
+
function effectiveMaxBytes(ruleMax, grant) {
|
|
633
|
+
const limits = [ruleMax, grant?.maxBytes].filter((limit) => typeof limit === "number");
|
|
634
|
+
return limits.length > 0 ? Math.min(...limits) : void 0;
|
|
635
|
+
}
|
|
636
|
+
async function claimTokenFileSlot(kv, grant) {
|
|
637
|
+
if (!grant.maxFiles) return null;
|
|
638
|
+
const counterKey = `${MAX_FILES_PREFIX}:${grant.tokenId}`;
|
|
639
|
+
const used = Number(await kv.get(counterKey) ?? "0");
|
|
640
|
+
if (used >= grant.maxFiles) {
|
|
641
|
+
return failure(403, "max_files_reached", `the upload token already created ${grant.maxFiles} files`);
|
|
642
|
+
}
|
|
643
|
+
await kv.put(counterKey, String(used + 1), { expirationTtl: MAX_FILES_COUNTER_TTL_SECONDS });
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
function resolveObjectCacheControl(requested, target, rules) {
|
|
647
|
+
if (!target.isAsset || !target.assetPath) return void 0;
|
|
648
|
+
if (requested !== void 0 && requested !== null) {
|
|
649
|
+
return resolveCacheControl(requested) ?? failure(400, "invalid_cache_control", "cacheControl must be immutable, revalidate, no-cache or a valid header");
|
|
650
|
+
}
|
|
651
|
+
return matchCacheRule(rules, target.assetPath)?.cacheControl ?? DEFAULT_ASSET_CACHE_CONTROL;
|
|
652
|
+
}
|
|
653
|
+
function inferMediaType(contentType) {
|
|
654
|
+
if (contentType.startsWith("video/")) return MediaType.VIDEO;
|
|
655
|
+
if (contentType.startsWith("image/")) return MediaType.IMAGE;
|
|
656
|
+
if (contentType.startsWith("application/")) return MediaType.FILE;
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/routes/token-upload.ts
|
|
661
|
+
var UPLOAD_TOKEN_TYPE = "upload";
|
|
662
|
+
var MEDIA_WRITE_SCOPE = "media:write";
|
|
663
|
+
var DEFAULT_UPLOAD_TOKEN_TTL_SECONDS = 60 * 60;
|
|
664
|
+
var MAX_UPLOAD_TOKEN_TTL_SECONDS = 24 * 60 * 60;
|
|
665
|
+
var isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
666
|
+
function optionalPositive(value, max = Number.MAX_SAFE_INTEGER) {
|
|
667
|
+
if (value === void 0 || value === null) return void 0;
|
|
668
|
+
return isPositiveInteger(value) && value <= max ? value : null;
|
|
669
|
+
}
|
|
670
|
+
function parseVisibility(value) {
|
|
671
|
+
if (value === void 0 || value === null) return "private";
|
|
672
|
+
return value === "public" || value === "private" ? value : null;
|
|
673
|
+
}
|
|
674
|
+
function uploadGrantFromClaims(payload) {
|
|
675
|
+
if (payload.typ !== UPLOAD_TOKEN_TYPE || typeof payload.res !== "string" || typeof payload.jti !== "string") {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
if (payload.visibility !== "public" && payload.visibility !== "private") return null;
|
|
679
|
+
return {
|
|
680
|
+
tokenId: payload.jti,
|
|
681
|
+
pathPrefix: payload.res,
|
|
682
|
+
visibility: payload.visibility,
|
|
683
|
+
...isPositiveInteger(payload.max_bytes) ? { maxBytes: payload.max_bytes } : {},
|
|
684
|
+
...isPositiveInteger(payload.max_files) ? { maxFiles: payload.max_files } : {},
|
|
685
|
+
...isPositiveInteger(payload.file_ttl) ? { fileExpiresIn: payload.file_ttl } : {}
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
var badRequest = (context, code, error) => context.json({ error, code }, 400);
|
|
689
|
+
async function handleUploadToken(context) {
|
|
690
|
+
const body = await context.req.json().catch(() => null) ?? {};
|
|
691
|
+
const prefix = normalizeAssetPrefix(body.prefix);
|
|
692
|
+
if (!prefix) return badRequest(context, "invalid_prefix", "prefix must be a relative path like `exports/inst-1/`");
|
|
693
|
+
const visibility = parseVisibility(body.visibility);
|
|
694
|
+
if (!visibility) return badRequest(context, "invalid_visibility", "visibility must be `public` or `private`");
|
|
695
|
+
const expiresIn = optionalPositive(body.expiresIn, MAX_UPLOAD_TOKEN_TTL_SECONDS);
|
|
696
|
+
const maxBytes = optionalPositive(body.maxBytes);
|
|
697
|
+
const maxFiles = optionalPositive(body.maxFiles);
|
|
698
|
+
const fileExpiresIn = optionalPositive(body.fileExpiresIn, MAX_FILE_TTL_SECONDS);
|
|
699
|
+
if (expiresIn === null) return badRequest(context, "invalid_expires_in", "expiresIn must be 1..86400 seconds");
|
|
700
|
+
if (maxBytes === null || maxFiles === null) {
|
|
701
|
+
return badRequest(context, "invalid_limits", "maxBytes and maxFiles must be positive integers");
|
|
702
|
+
}
|
|
703
|
+
if (fileExpiresIn === null) {
|
|
704
|
+
return badRequest(context, "invalid_file_expires_in", "fileExpiresIn must be a positive number of seconds");
|
|
705
|
+
}
|
|
706
|
+
const pathPrefix = assetRelativePath2(prefix);
|
|
707
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
708
|
+
const requestedExp = now + (expiresIn ?? DEFAULT_UPLOAD_TOKEN_TTL_SECONDS);
|
|
709
|
+
const decision = await decideCredential(context, {
|
|
710
|
+
kind: "upload",
|
|
711
|
+
scopes: [MEDIA_WRITE_SCOPE],
|
|
712
|
+
path: pathPrefix,
|
|
713
|
+
expiresAt: requestedExp * 1e3
|
|
714
|
+
});
|
|
715
|
+
if (!decision.allow) return decision.response;
|
|
716
|
+
const expiresAt = cappedExpiry(requestedExp, decision.expiresAt, (epochMs) => Math.floor(epochMs / 1e3));
|
|
717
|
+
const payload = withPluginClaims(
|
|
718
|
+
{
|
|
719
|
+
typ: UPLOAD_TOKEN_TYPE,
|
|
720
|
+
jti: crypto.randomUUID(),
|
|
721
|
+
res: pathPrefix,
|
|
722
|
+
visibility,
|
|
723
|
+
...scopeClaim([MEDIA_WRITE_SCOPE]),
|
|
724
|
+
...audienceClaim(getRequestState(context).storagePrefix),
|
|
725
|
+
iat: now,
|
|
726
|
+
exp: expiresAt,
|
|
727
|
+
...maxBytes ? { max_bytes: maxBytes } : {},
|
|
728
|
+
...maxFiles ? { max_files: maxFiles } : {},
|
|
729
|
+
...fileExpiresIn ? { file_ttl: fileExpiresIn } : {}
|
|
730
|
+
},
|
|
731
|
+
decision
|
|
732
|
+
);
|
|
733
|
+
const token2 = await signViewerToken(payload, context.env.DELIVERY_SIGN_SECRET);
|
|
734
|
+
return context.json({
|
|
735
|
+
token: token2,
|
|
736
|
+
prefix,
|
|
737
|
+
visibility,
|
|
738
|
+
expiresAt,
|
|
739
|
+
...maxBytes ? { maxBytes } : {},
|
|
740
|
+
...maxFiles ? { maxFiles } : {},
|
|
741
|
+
...fileExpiresIn ? { fileExpiresIn } : {}
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/plugins/worker-secret-auth.ts
|
|
746
|
+
var WorkerAuthErrorCode = {
|
|
747
|
+
SECRET_MISSING: "worker_auth_secret_missing",
|
|
748
|
+
UNAUTHORIZED: "unauthorized"
|
|
749
|
+
};
|
|
750
|
+
var MEDIA_PREFIX = "/v1/media/";
|
|
751
|
+
var DOWNLOAD_PREFIX = "/v1/download/";
|
|
752
|
+
var WEBHOOKS_PREFIX = "/v1/webhooks/";
|
|
753
|
+
var UPLOAD_CREATE_PATH = "/v1/upload";
|
|
754
|
+
var UPLOAD_URL_PREFIX = "/v1/upload/";
|
|
755
|
+
var isReadMethod = (method) => method === "GET" || method === "HEAD";
|
|
756
|
+
function isOpenRoute(method, path) {
|
|
757
|
+
if (method === "OPTIONS") return true;
|
|
758
|
+
if (path === "/health") return isReadMethod(method);
|
|
759
|
+
if (path === "/v1/metrics/playback") return method === "POST";
|
|
760
|
+
if (path.startsWith(MEDIA_PREFIX) || path.startsWith(DOWNLOAD_PREFIX)) return isReadMethod(method);
|
|
761
|
+
return path.startsWith(WEBHOOKS_PREFIX) && method === "POST";
|
|
762
|
+
}
|
|
763
|
+
function isUploadUrl(path) {
|
|
764
|
+
return path.startsWith(UPLOAD_URL_PREFIX);
|
|
765
|
+
}
|
|
766
|
+
async function uploadGrantFromBearer(context, authHeader) {
|
|
767
|
+
const token2 = authHeader?.match(/^Bearer\s+(\S+)$/i)?.[1];
|
|
768
|
+
const signSecret = context.env.DELIVERY_SIGN_SECRET;
|
|
769
|
+
if (!token2 || !signSecret) return null;
|
|
770
|
+
try {
|
|
771
|
+
const payload = await verifyViewerToken(token2, signSecret);
|
|
772
|
+
return payload ? uploadGrantFromClaims(payload) : null;
|
|
773
|
+
} catch {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function rejectUnauthenticated(context, isSecretMissing) {
|
|
778
|
+
if (isSecretMissing) {
|
|
779
|
+
return context.json(
|
|
780
|
+
{ error: "WORKER_AUTH_SECRET is not configured on the Worker", code: WorkerAuthErrorCode.SECRET_MISSING },
|
|
781
|
+
503
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
return context.json({ error: "Unauthorized", code: WorkerAuthErrorCode.UNAUTHORIZED }, 401);
|
|
785
|
+
}
|
|
786
|
+
function workerSecretMiddleware() {
|
|
787
|
+
return async (context, next) => {
|
|
788
|
+
const { method, path } = context.req;
|
|
789
|
+
const authHeader = context.req.header("Authorization");
|
|
790
|
+
const secret = context.env.WORKER_AUTH_SECRET;
|
|
791
|
+
const hasWorkerSecret = isBearerAuthorized(secret, authHeader);
|
|
792
|
+
updateRequestState(context, {
|
|
793
|
+
runnerCredential: secret ?? "",
|
|
794
|
+
...hasWorkerSecret ? { access: { authenticated: true } } : {}
|
|
795
|
+
});
|
|
796
|
+
if (isOpenRoute(method, path)) {
|
|
797
|
+
if (hasWorkerSecret && path.startsWith(MEDIA_PREFIX))
|
|
798
|
+
updateRequestState(context, { access: { trustedRead: true } });
|
|
799
|
+
await next();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (isUploadUrl(path) || hasWorkerSecret) {
|
|
803
|
+
await next();
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (!secret) return rejectUnauthenticated(context, true);
|
|
807
|
+
const grant = method === "POST" && path === UPLOAD_CREATE_PATH ? await uploadGrantFromBearer(context, authHeader) : null;
|
|
808
|
+
if (!grant) return rejectUnauthenticated(context, false);
|
|
809
|
+
updateRequestState(context, { access: { authenticated: true, uploadGrant: grant } });
|
|
810
|
+
await next();
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
function workerSecretAuth() {
|
|
814
|
+
return { name: "worker-secret-auth", middleware: { auth: [workerSecretMiddleware()] } };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/plugins/index.ts
|
|
818
|
+
function defaultPlugins(config = {}) {
|
|
819
|
+
return [staticConfig(config), workerSecretAuth(), credentialPolicy(), downloadLinks()];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// src/routes/assets.ts
|
|
823
|
+
import { ASSET_NAMESPACE, normalizeAssetPrefix as normalizeAssetPrefix2 } from "@devultur/core";
|
|
824
|
+
import { Hono as Hono3 } from "hono";
|
|
825
|
+
var MAX_ASSET_LIST_LIMIT = 1e3;
|
|
826
|
+
var VISIBILITY_ORDER = ["private", "public"];
|
|
827
|
+
function parseListPrefix(raw) {
|
|
828
|
+
if (!raw?.trim()) return "";
|
|
829
|
+
return normalizeAssetPrefix2(raw);
|
|
830
|
+
}
|
|
831
|
+
function parseListCursor(raw, visibilities) {
|
|
832
|
+
if (!raw) return { visibility: visibilities[0] };
|
|
833
|
+
const separator = raw.indexOf(":");
|
|
834
|
+
const visibility = raw.slice(0, separator);
|
|
835
|
+
if (separator < 0 || !visibilities.includes(visibility)) return null;
|
|
836
|
+
const cursor = raw.slice(separator + 1);
|
|
837
|
+
return { visibility, ...cursor ? { cursor } : {} };
|
|
838
|
+
}
|
|
839
|
+
function parseLimit(raw) {
|
|
840
|
+
const limit = Number(raw);
|
|
841
|
+
if (!Number.isInteger(limit) || limit <= 0) return MAX_ASSET_LIST_LIMIT;
|
|
842
|
+
return Math.min(limit, MAX_ASSET_LIST_LIMIT);
|
|
843
|
+
}
|
|
844
|
+
function toEntry(object, storageRoot, visibility) {
|
|
845
|
+
const key = object.key.slice(storageRoot.length);
|
|
846
|
+
const sha256 = object.customMetadata?.[ObjectMeta.SHA256] ?? null;
|
|
847
|
+
return {
|
|
848
|
+
key,
|
|
849
|
+
path: key.slice(`${visibility}/${ASSET_NAMESPACE}/`.length),
|
|
850
|
+
size: object.size,
|
|
851
|
+
etag: object.httpEtag,
|
|
852
|
+
sha256,
|
|
853
|
+
sha256Verified: Boolean(sha256) && object.customMetadata?.[ObjectMeta.SHA256_VERIFIED] === "true",
|
|
854
|
+
uploaded: object.uploaded.toISOString(),
|
|
855
|
+
cacheControl: object.httpMetadata?.cacheControl ?? null,
|
|
856
|
+
visibility
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
function nextCursor(listing, position, visibilities) {
|
|
860
|
+
if (listing.truncated) return `${position.visibility}:${listing.cursor}`;
|
|
861
|
+
const next = visibilities[visibilities.indexOf(position.visibility) + 1];
|
|
862
|
+
return next ? `${next}:` : null;
|
|
863
|
+
}
|
|
864
|
+
var assets = new Hono3();
|
|
865
|
+
assets.get("/", async (context) => {
|
|
866
|
+
const prefix = parseListPrefix(context.req.query("prefix"));
|
|
867
|
+
if (prefix === null) {
|
|
868
|
+
return context.json(
|
|
869
|
+
{ error: "prefix must be a relative path of [A-Za-z0-9._-] segments", code: "invalid_prefix" },
|
|
870
|
+
400
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
const requested = context.req.query("visibility");
|
|
874
|
+
const visibilities = VISIBILITY_ORDER.filter((visibility) => !requested || visibility === requested);
|
|
875
|
+
const position = visibilities.length > 0 ? parseListCursor(context.req.query("cursor"), visibilities) : null;
|
|
876
|
+
if (!position) return context.json({ error: "Invalid cursor or visibility", code: "invalid_cursor" }, 400);
|
|
877
|
+
const storageRoot = storagePrefix(context, "");
|
|
878
|
+
const listOptions = {
|
|
879
|
+
prefix: `${storageRoot}${position.visibility}/${ASSET_NAMESPACE}/${prefix}`,
|
|
880
|
+
limit: parseLimit(context.req.query("limit")),
|
|
881
|
+
...position.cursor ? { cursor: position.cursor } : {},
|
|
882
|
+
include: ["httpMetadata", "customMetadata"]
|
|
883
|
+
};
|
|
884
|
+
const listing = await context.env.MEDIA_BUCKET.list(listOptions);
|
|
885
|
+
const objects = listing.objects.filter((object) => !isObjectExpired(object.customMetadata)).map((object) => toEntry(object, storageRoot, position.visibility));
|
|
886
|
+
return context.json({ objects, cursor: nextCursor(listing, position, visibilities) });
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// src/routes/captions.ts
|
|
890
|
+
import {
|
|
891
|
+
captionPath,
|
|
892
|
+
createDecryptStream as createDecryptStream2,
|
|
893
|
+
decodeEncryptionMeta as decodeEncryptionMeta2,
|
|
894
|
+
extractId,
|
|
895
|
+
resolveSecurity,
|
|
896
|
+
visibilityFromKey
|
|
897
|
+
} from "@devultur/core";
|
|
898
|
+
import { Hono as Hono5 } from "hono";
|
|
899
|
+
|
|
900
|
+
// src/routes/captions-errors.ts
|
|
901
|
+
var CaptionsErrorCode = {
|
|
902
|
+
/** The provider responded 429: retriable later. */
|
|
903
|
+
PROVIDER_RATE_LIMITED: "captions_provider_rate_limited",
|
|
904
|
+
/** The provider responded with some other non-2xx. */
|
|
905
|
+
PROVIDER_ERROR: "captions_provider_error",
|
|
906
|
+
/** Something on our end failed (VTT storage, parsing, …). */
|
|
907
|
+
FAILED: "captions_failed"
|
|
908
|
+
};
|
|
909
|
+
var CaptionsProviderError = class extends Error {
|
|
910
|
+
constructor(provider, status, body) {
|
|
911
|
+
super(`${provider} responded ${status}`);
|
|
912
|
+
this.provider = provider;
|
|
913
|
+
this.status = status;
|
|
914
|
+
this.body = body;
|
|
915
|
+
this.name = "CaptionsProviderError";
|
|
916
|
+
}
|
|
917
|
+
provider;
|
|
918
|
+
status;
|
|
919
|
+
body;
|
|
920
|
+
};
|
|
921
|
+
function toCaptionsFailure(error) {
|
|
922
|
+
if (!(error instanceof CaptionsProviderError)) {
|
|
923
|
+
return {
|
|
924
|
+
status: 500,
|
|
925
|
+
code: CaptionsErrorCode.FAILED,
|
|
926
|
+
error: "Captions generation failed",
|
|
927
|
+
reason: "internal",
|
|
928
|
+
retriable: true
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
if (error.status === 429) {
|
|
932
|
+
return {
|
|
933
|
+
status: 503,
|
|
934
|
+
code: CaptionsErrorCode.PROVIDER_RATE_LIMITED,
|
|
935
|
+
error: "Captions provider is rate limited, try again later",
|
|
936
|
+
reason: "capacity",
|
|
937
|
+
retriable: true
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
status: 500,
|
|
942
|
+
code: CaptionsErrorCode.PROVIDER_ERROR,
|
|
943
|
+
error: "Captions provider failed",
|
|
944
|
+
reason: "internal",
|
|
945
|
+
retriable: error.status >= 500
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
function logCaptionsError(context, error) {
|
|
949
|
+
if (error instanceof CaptionsProviderError) {
|
|
950
|
+
console.error(`[captions] ${context}: ${error.provider} ${error.status}:`, error.body);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
console.error(`[captions] ${context}:`, error);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// src/routes/webhooks.ts
|
|
957
|
+
import { Hono as Hono4 } from "hono";
|
|
958
|
+
|
|
959
|
+
// src/transcode/metrics.ts
|
|
960
|
+
var OPERATOR_EVENT_FIELDS = ["encode", "posterKey", "posterFormats"];
|
|
961
|
+
var isNonNegativeNumber = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
962
|
+
var optionalString = (value) => typeof value === "string" && value.length > 0 ? value : void 0;
|
|
963
|
+
function readEncodeParams(value) {
|
|
964
|
+
if (!Array.isArray(value)) return void 0;
|
|
965
|
+
const params = value.filter((entry) => {
|
|
966
|
+
if (typeof entry !== "object" || entry === null) return false;
|
|
967
|
+
const { rung, crf, maxrateKbps } = entry;
|
|
968
|
+
return typeof rung === "string" && isNonNegativeNumber(crf) && isNonNegativeNumber(maxrateKbps);
|
|
969
|
+
});
|
|
970
|
+
return params.length > 0 ? params.map(({ rung, crf, maxrateKbps }) => ({ rung, crf, maxrateKbps })) : void 0;
|
|
971
|
+
}
|
|
972
|
+
function perTitleMetrics(encode) {
|
|
973
|
+
const isScore = isNonNegativeNumber(encode.complexity) && encode.complexity <= 1;
|
|
974
|
+
const encodeParams = readEncodeParams(encode.params);
|
|
975
|
+
return {
|
|
976
|
+
...isScore ? { complexity: encode.complexity } : {},
|
|
977
|
+
...isNonNegativeNumber(encode.probeSeconds) ? { probeSeconds: encode.probeSeconds } : {},
|
|
978
|
+
...encodeParams ? { encodeParams } : {}
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
982
|
+
function parseEncodeMetrics(event) {
|
|
983
|
+
if (!isRecord2(event) || event.event !== "transcode.complete" || !isRecord2(event.encode)) return null;
|
|
984
|
+
const { encode } = event;
|
|
985
|
+
if (!isNonNegativeNumber(encode.seconds) || !isNonNegativeNumber(event.duration)) return null;
|
|
986
|
+
return {
|
|
987
|
+
encodeSeconds: encode.seconds,
|
|
988
|
+
cpus: isNonNegativeNumber(encode.cpus) ? encode.cpus : 0,
|
|
989
|
+
durationSeconds: event.duration,
|
|
990
|
+
topRung: optionalString(encode.topRung),
|
|
991
|
+
runner: optionalString(encode.runner),
|
|
992
|
+
...perTitleMetrics(encode)
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
function vcpuSecondsPerVideoMinute(metrics) {
|
|
996
|
+
if (metrics.cpus <= 0 || metrics.durationSeconds <= 0) return null;
|
|
997
|
+
return metrics.encodeSeconds * metrics.cpus / (metrics.durationSeconds / 60);
|
|
998
|
+
}
|
|
999
|
+
function withoutOperatorFields(event) {
|
|
1000
|
+
const clientEvent = { ...event };
|
|
1001
|
+
for (const field of OPERATOR_EVENT_FIELDS) delete clientEvent[field];
|
|
1002
|
+
return clientEvent;
|
|
1003
|
+
}
|
|
1004
|
+
function logEncodeMetrics(videoId, metrics) {
|
|
1005
|
+
const perMinute = vcpuSecondsPerVideoMinute(metrics);
|
|
1006
|
+
console.log(
|
|
1007
|
+
"[transcode-metrics]",
|
|
1008
|
+
JSON.stringify({
|
|
1009
|
+
videoId,
|
|
1010
|
+
...metrics,
|
|
1011
|
+
vcpuSecondsPerVideoMinute: perMinute === null ? null : Math.round(perMinute * 10) / 10
|
|
1012
|
+
})
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// src/transcode/outputs-report.ts
|
|
1017
|
+
import {
|
|
1018
|
+
buildTranscodeOutputUrls
|
|
1019
|
+
} from "@devultur/core";
|
|
1020
|
+
var isOutputKey = (value) => typeof value === "string" && /^(public|private)\/video\/[^/]+\/(hls|mp4|thumbnails)\/[^/]+$/.test(value);
|
|
1021
|
+
var isPosterFormat = (value) => value === "jpg" || value === "avif";
|
|
1022
|
+
var isPixelSize = (value) => Number.isSafeInteger(value) && value > 0;
|
|
1023
|
+
function readRendition(raw) {
|
|
1024
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
1025
|
+
const { variant, kind, rung, key, width, height } = raw;
|
|
1026
|
+
if (typeof variant !== "string" || typeof rung !== "string" || !isOutputKey(key)) return null;
|
|
1027
|
+
if (kind !== "hls" && kind !== "mp4") return null;
|
|
1028
|
+
const size = isPixelSize(width) && isPixelSize(height) ? { width, height } : {};
|
|
1029
|
+
return { variant, kind, rung, key, ...size };
|
|
1030
|
+
}
|
|
1031
|
+
var STORYBOARD_KEY = /^(public|private)\/video\/[^/]+\/storyboard\/storyboard\.vtt$/;
|
|
1032
|
+
function readStoryboardKey(event) {
|
|
1033
|
+
if (typeof event !== "object" || event === null) return null;
|
|
1034
|
+
const { storyboardKey } = event;
|
|
1035
|
+
return typeof storyboardKey === "string" && STORYBOARD_KEY.test(storyboardKey) ? storyboardKey : null;
|
|
1036
|
+
}
|
|
1037
|
+
function withClientStoryboard(event) {
|
|
1038
|
+
if (!("storyboardKey" in event)) return event;
|
|
1039
|
+
const { storyboardKey: _dropped, ...rest } = event;
|
|
1040
|
+
const storyboardKey = readStoryboardKey(event);
|
|
1041
|
+
if (!storyboardKey || !Array.isArray(event.renditions)) return rest;
|
|
1042
|
+
return { ...rest, storyboardKey };
|
|
1043
|
+
}
|
|
1044
|
+
function readTranscodeOutputKeys(event) {
|
|
1045
|
+
if (typeof event !== "object" || event === null) return null;
|
|
1046
|
+
const raw = event;
|
|
1047
|
+
if (raw.event !== "transcode.complete" || !Array.isArray(raw.renditions)) return null;
|
|
1048
|
+
const renditions = raw.renditions.map(readRendition).filter((rendition) => rendition !== null);
|
|
1049
|
+
const posterFormats = Array.isArray(raw.posterFormats) ? raw.posterFormats.filter(isPosterFormat) : void 0;
|
|
1050
|
+
return {
|
|
1051
|
+
renditions,
|
|
1052
|
+
...isOutputKey(raw.posterKey) ? { posterKey: raw.posterKey } : {},
|
|
1053
|
+
...posterFormats?.length ? { posterFormats } : {},
|
|
1054
|
+
...typeof raw.hasAudio === "boolean" ? { hasAudio: raw.hasAudio } : {}
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
function readTranscodeOutputs(event, baseUrl) {
|
|
1058
|
+
const keys = readTranscodeOutputKeys(event);
|
|
1059
|
+
return keys ? buildTranscodeOutputUrls(baseUrl, keys) : null;
|
|
1060
|
+
}
|
|
1061
|
+
function withOutputUrls(event, baseUrl) {
|
|
1062
|
+
const outputs = readTranscodeOutputs(event, baseUrl);
|
|
1063
|
+
if (!outputs) return event;
|
|
1064
|
+
return { ...event, ...outputs };
|
|
1065
|
+
}
|
|
1066
|
+
var outputBaseKey = (videoId) => `${KvPrefix.TRANSCODE_META}:output-base:${videoId}`;
|
|
1067
|
+
function saveOutputBaseUrl(kv, videoId, baseUrl) {
|
|
1068
|
+
return kv.put(outputBaseKey(videoId), baseUrl, { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1069
|
+
}
|
|
1070
|
+
async function loadOutputBaseUrl(kv, videoId, fallbackUrl) {
|
|
1071
|
+
if (typeof videoId !== "string" || !videoId) return fallbackUrl;
|
|
1072
|
+
return await kv.get(outputBaseKey(videoId)) ?? fallbackUrl;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// src/transcode/raw-retention.ts
|
|
1076
|
+
import { isVideoRawKey } from "@devultur/core";
|
|
1077
|
+
var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
|
|
1078
|
+
async function rememberRawDisposal(kv, videoId, disposal) {
|
|
1079
|
+
await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1080
|
+
}
|
|
1081
|
+
async function disposeRawAfterTranscode(env, videoId) {
|
|
1082
|
+
const disposal = await env.MEDIA_KV.get(disposalKey(videoId), "json");
|
|
1083
|
+
if (!disposal?.deleteOnComplete || !disposal.storageKey) return false;
|
|
1084
|
+
await env.MEDIA_BUCKET.delete(disposal.storageKey);
|
|
1085
|
+
if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
var ORIGINAL_NOT_RETAINED = {
|
|
1089
|
+
error: "The original of this video is no longer retained, so it can't be re-encoded. Upload it again, or set `config.transcode.deleteOriginalOnSuccess` to false for future videos.",
|
|
1090
|
+
code: "original_not_retained"
|
|
1091
|
+
};
|
|
1092
|
+
var PRIOR_OUTPUTS_LIST_LIMIT = 100;
|
|
1093
|
+
async function hasPriorTranscodeOutputs(bucket, videoFolder) {
|
|
1094
|
+
const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
|
|
1095
|
+
return listing.objects.some((object) => !isVideoRawKey(object.key));
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// src/transcode/status.ts
|
|
1099
|
+
import { TranscodeStatus } from "@devultur/core";
|
|
1100
|
+
var statusKey = (videoId) => `${KvPrefix.TRANSCODE_STATUS}:${videoId}`;
|
|
1101
|
+
function statusChangeFor(event) {
|
|
1102
|
+
switch (event.event) {
|
|
1103
|
+
case "transcode.started":
|
|
1104
|
+
return { status: TranscodeStatus.RUNNING };
|
|
1105
|
+
case "transcode.progress":
|
|
1106
|
+
return event.phase === "downloading" ? { status: TranscodeStatus.RUNNING } : null;
|
|
1107
|
+
case "transcode.complete":
|
|
1108
|
+
return { status: TranscodeStatus.COMPLETED };
|
|
1109
|
+
case "transcode.failed":
|
|
1110
|
+
return {
|
|
1111
|
+
status: TranscodeStatus.FAILED,
|
|
1112
|
+
...typeof event.error === "string" ? { error: event.error } : {},
|
|
1113
|
+
...typeof event.reason === "string" ? { reason: event.reason } : {},
|
|
1114
|
+
...typeof event.retriable === "boolean" ? { retriable: event.retriable } : {}
|
|
1115
|
+
};
|
|
1116
|
+
default:
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
async function readStatus(kv, videoId) {
|
|
1121
|
+
return kv.get(statusKey(videoId), "json");
|
|
1122
|
+
}
|
|
1123
|
+
async function writeQueuedStatus(kv, videoId, job) {
|
|
1124
|
+
const record = { status: TranscodeStatus.QUEUED, ...job, updatedAt: Date.now() };
|
|
1125
|
+
await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1126
|
+
}
|
|
1127
|
+
async function applyStatusEvent(kv, event) {
|
|
1128
|
+
const change = statusChangeFor(event);
|
|
1129
|
+
const videoId = typeof event.videoId === "string" ? event.videoId : "";
|
|
1130
|
+
if (!change || !videoId) return;
|
|
1131
|
+
const current = await readStatus(kv, videoId);
|
|
1132
|
+
if (!current) return;
|
|
1133
|
+
if (current.status === TranscodeStatus.COMPLETED) return;
|
|
1134
|
+
const record = {
|
|
1135
|
+
jobRef: current.jobRef,
|
|
1136
|
+
...current.runner ? { runner: current.runner } : {},
|
|
1137
|
+
...change,
|
|
1138
|
+
updatedAt: Date.now()
|
|
1139
|
+
};
|
|
1140
|
+
await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// src/routes/webhooks.ts
|
|
1144
|
+
var webhooks = new Hono4();
|
|
1145
|
+
webhooks.post("/transcode", async (context) => {
|
|
1146
|
+
const webhookSecret = context.env.TRANSCODE_WEBHOOK_SECRET;
|
|
1147
|
+
if (!webhookSecret) {
|
|
1148
|
+
return context.json(
|
|
1149
|
+
{ error: "TRANSCODE_WEBHOOK_SECRET is not configured on the Worker", code: "webhook_secret_missing" },
|
|
1150
|
+
503
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
if (!isTranscodeWebhookAuthorized(webhookSecret, context.req.header("Authorization"))) {
|
|
1154
|
+
return context.json({ error: "Unauthorized" }, 401);
|
|
1155
|
+
}
|
|
1156
|
+
const body = await context.req.json();
|
|
1157
|
+
await publishTranscodeJob(context, body);
|
|
1158
|
+
if (body.event === "transcode.complete" && body.videoId) {
|
|
1159
|
+
if (await disposeRawAfterTranscode(context.env, body.videoId)) addRouteResult(context, { originalDeleted: true });
|
|
1160
|
+
await context.env.MEDIA_KV.put(
|
|
1161
|
+
`${KvPrefix.TRANSCODE_META}:${body.videoId}`,
|
|
1162
|
+
JSON.stringify({ duration: body.duration }),
|
|
1163
|
+
{ expirationTtl: KV_JOB_TTL_SECONDS }
|
|
1164
|
+
);
|
|
1165
|
+
const metrics = parseEncodeMetrics(body);
|
|
1166
|
+
if (metrics) logEncodeMetrics(body.videoId, metrics);
|
|
1167
|
+
}
|
|
1168
|
+
await applyStatusEvent(context.env.MEDIA_KV, body);
|
|
1169
|
+
const webhookData = await getWebhookData(context.env.MEDIA_KV, body.videoId);
|
|
1170
|
+
const outputBaseUrl = await loadOutputBaseUrl(context.env.MEDIA_KV, body.videoId, new URL(context.req.url).origin);
|
|
1171
|
+
const clientEvent = withClientStoryboard(withoutOperatorFields(withOutputUrls(body, outputBaseUrl)));
|
|
1172
|
+
await forwardToClientWebhook(clientEvent, webhookData);
|
|
1173
|
+
return context.json({ received: true });
|
|
1174
|
+
});
|
|
1175
|
+
async function publishTranscodeJob(context, body) {
|
|
1176
|
+
const ticket = context.req.header(JOB_TICKET_HEADER);
|
|
1177
|
+
if (!ticket || body.event !== "transcode.complete" || typeof body.videoId !== "string") return;
|
|
1178
|
+
const job = await verifyJobTicket(context.env.DELIVERY_SIGN_SECRET, ticket, body.videoId);
|
|
1179
|
+
if (job) {
|
|
1180
|
+
addRouteResult(context, { transcodeJob: job });
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
console.warn(`[transcode] callback for ${body.videoId} carries a job ticket that does not verify`);
|
|
1184
|
+
}
|
|
1185
|
+
function clientWebhookKvKey(videoId) {
|
|
1186
|
+
return `${KvPrefix.CAPTION_JOB}:webhook:${videoId}`;
|
|
1187
|
+
}
|
|
1188
|
+
function clientWebhookFor(scope, url) {
|
|
1189
|
+
return { url, credential: scope.webhookCredential ?? scope.runnerCredential ?? "" };
|
|
1190
|
+
}
|
|
1191
|
+
async function getWebhookData(kv, videoId) {
|
|
1192
|
+
const raw = await kv.get(clientWebhookKvKey(videoId));
|
|
1193
|
+
if (!raw) return null;
|
|
1194
|
+
try {
|
|
1195
|
+
const stored = JSON.parse(raw);
|
|
1196
|
+
if (typeof stored.url !== "string") return null;
|
|
1197
|
+
return { url: stored.url, credential: typeof stored.credential === "string" ? stored.credential : "" };
|
|
1198
|
+
} catch {
|
|
1199
|
+
return { url: raw, credential: "" };
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
async function forwardToClientWebhook(event, webhookData) {
|
|
1203
|
+
if (!webhookData?.url) return;
|
|
1204
|
+
try {
|
|
1205
|
+
const response = await fetch(webhookData.url, {
|
|
1206
|
+
method: "POST",
|
|
1207
|
+
headers: {
|
|
1208
|
+
"Content-Type": "application/json",
|
|
1209
|
+
...webhookData.credential ? { Authorization: `Bearer ${webhookData.credential}` } : {}
|
|
1210
|
+
},
|
|
1211
|
+
body: JSON.stringify(event)
|
|
1212
|
+
});
|
|
1213
|
+
if (!response.ok) {
|
|
1214
|
+
const responseBody = await response.text();
|
|
1215
|
+
console.error("[webhook] forward failed:", response.status, webhookData.url, responseBody);
|
|
1216
|
+
}
|
|
1217
|
+
} catch (err) {
|
|
1218
|
+
console.error("[webhook] forward error:", webhookData.url, err);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/routes/captions.ts
|
|
1223
|
+
var GROQ_API = "https://api.groq.com/openai/v1";
|
|
1224
|
+
var MISTRAL_API = "https://api.mistral.ai/v1";
|
|
1225
|
+
var LANGUAGE_NAMES = {
|
|
1226
|
+
en: "English",
|
|
1227
|
+
es: "Spanish",
|
|
1228
|
+
fr: "French",
|
|
1229
|
+
pt: "Portuguese",
|
|
1230
|
+
de: "German",
|
|
1231
|
+
it: "Italian",
|
|
1232
|
+
ja: "Japanese",
|
|
1233
|
+
ko: "Korean",
|
|
1234
|
+
zh: "Chinese",
|
|
1235
|
+
ar: "Arabic",
|
|
1236
|
+
hi: "Hindi",
|
|
1237
|
+
ru: "Russian",
|
|
1238
|
+
nl: "Dutch",
|
|
1239
|
+
sv: "Swedish",
|
|
1240
|
+
pl: "Polish",
|
|
1241
|
+
tr: "Turkish",
|
|
1242
|
+
vi: "Vietnamese",
|
|
1243
|
+
th: "Thai",
|
|
1244
|
+
uk: "Ukrainian",
|
|
1245
|
+
id: "Indonesian"
|
|
1246
|
+
};
|
|
1247
|
+
function getLanguageName(code) {
|
|
1248
|
+
return LANGUAGE_NAMES[code] ?? code;
|
|
1249
|
+
}
|
|
1250
|
+
function formatTimestamp(seconds) {
|
|
1251
|
+
const h = Math.floor(seconds / 3600);
|
|
1252
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
1253
|
+
const s = Math.floor(seconds % 60);
|
|
1254
|
+
const ms = Math.floor(seconds % 1 * 1e3);
|
|
1255
|
+
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(3, "0")}`;
|
|
1256
|
+
}
|
|
1257
|
+
function wordsToSentenceVtt(words) {
|
|
1258
|
+
const cues = [];
|
|
1259
|
+
let sentenceWords = [];
|
|
1260
|
+
for (const word of words) {
|
|
1261
|
+
sentenceWords.push(word);
|
|
1262
|
+
if (/[.!?]$/.test(word.word)) {
|
|
1263
|
+
cues.push({
|
|
1264
|
+
start: sentenceWords[0].start,
|
|
1265
|
+
end: word.end,
|
|
1266
|
+
text: sentenceWords.map((w) => w.word).join(" ")
|
|
1267
|
+
});
|
|
1268
|
+
sentenceWords = [];
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (sentenceWords.length > 0) {
|
|
1272
|
+
cues.push({
|
|
1273
|
+
start: sentenceWords[0].start,
|
|
1274
|
+
end: sentenceWords[sentenceWords.length - 1].end,
|
|
1275
|
+
text: sentenceWords.map((w) => w.word).join(" ")
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
const lines = ["WEBVTT", ""];
|
|
1279
|
+
for (const cue of cues) {
|
|
1280
|
+
lines.push(`${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}`);
|
|
1281
|
+
lines.push(cue.text.trim());
|
|
1282
|
+
lines.push("");
|
|
1283
|
+
}
|
|
1284
|
+
return lines.join("\n");
|
|
1285
|
+
}
|
|
1286
|
+
function findSourceLocale(detectedLang, locales) {
|
|
1287
|
+
return locales.find((l) => l.split("-")[0] === detectedLang) ?? locales[0];
|
|
1288
|
+
}
|
|
1289
|
+
function cleanVttResponse(response) {
|
|
1290
|
+
let vtt = response.trim();
|
|
1291
|
+
if (vtt.startsWith("```")) {
|
|
1292
|
+
vtt = vtt.replace(/^```(?:vtt|webvtt)?\n?/, "").replace(/\n?```$/, "");
|
|
1293
|
+
}
|
|
1294
|
+
if (!vtt.startsWith("WEBVTT")) {
|
|
1295
|
+
vtt = `WEBVTT
|
|
1296
|
+
|
|
1297
|
+
${vtt}`;
|
|
1298
|
+
}
|
|
1299
|
+
return vtt;
|
|
1300
|
+
}
|
|
1301
|
+
async function transcribeWithGroq(providerKey, audioBody, filename) {
|
|
1302
|
+
const blob = new Blob([await new Response(audioBody).arrayBuffer()]);
|
|
1303
|
+
const formData = new FormData();
|
|
1304
|
+
formData.append("file", blob, filename);
|
|
1305
|
+
formData.append("model", "whisper-large-v3-turbo");
|
|
1306
|
+
formData.append("response_format", "verbose_json");
|
|
1307
|
+
formData.append("timestamp_granularities[]", "word");
|
|
1308
|
+
const response = await fetch(`${GROQ_API}/audio/transcriptions`, {
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
headers: { Authorization: `Bearer ${providerKey}` },
|
|
1311
|
+
body: formData
|
|
1312
|
+
});
|
|
1313
|
+
if (!response.ok) {
|
|
1314
|
+
throw new CaptionsProviderError("groq", response.status, await response.text());
|
|
1315
|
+
}
|
|
1316
|
+
return await response.json();
|
|
1317
|
+
}
|
|
1318
|
+
async function translateVtt(providerKey, sourceVtt, targetLocale) {
|
|
1319
|
+
const targetLang = targetLocale.split("-")[0];
|
|
1320
|
+
const targetName = getLanguageName(targetLang);
|
|
1321
|
+
const response = await fetch(`${MISTRAL_API}/chat/completions`, {
|
|
1322
|
+
method: "POST",
|
|
1323
|
+
headers: {
|
|
1324
|
+
Authorization: `Bearer ${providerKey}`,
|
|
1325
|
+
"Content-Type": "application/json"
|
|
1326
|
+
},
|
|
1327
|
+
body: JSON.stringify({
|
|
1328
|
+
model: "mistral-small-latest",
|
|
1329
|
+
messages: [
|
|
1330
|
+
{
|
|
1331
|
+
role: "user",
|
|
1332
|
+
content: [
|
|
1333
|
+
`Translate the following WebVTT subtitle file to ${targetName} (${targetLocale}).`,
|
|
1334
|
+
"Rules:",
|
|
1335
|
+
"- Keep the WEBVTT header and ALL timestamps exactly as-is",
|
|
1336
|
+
"- Only translate the spoken text between timestamp lines",
|
|
1337
|
+
"- Do not add or remove any cues",
|
|
1338
|
+
"- Return ONLY the complete translated VTT file, no commentary",
|
|
1339
|
+
"",
|
|
1340
|
+
sourceVtt
|
|
1341
|
+
].join("\n")
|
|
1342
|
+
}
|
|
1343
|
+
]
|
|
1344
|
+
})
|
|
1345
|
+
});
|
|
1346
|
+
if (!response.ok) {
|
|
1347
|
+
throw new CaptionsProviderError("mistral", response.status, await response.text());
|
|
1348
|
+
}
|
|
1349
|
+
const result = await response.json();
|
|
1350
|
+
return cleanVttResponse(result.choices[0].message.content);
|
|
1351
|
+
}
|
|
1352
|
+
async function resolveCaptionsWebhook(context, videoId, callbackUrl) {
|
|
1353
|
+
if (callbackUrl) return clientWebhookFor(getRequestState(context), callbackUrl);
|
|
1354
|
+
const stored = await getWebhookData(context.env.MEDIA_KV, videoId);
|
|
1355
|
+
return stored;
|
|
1356
|
+
}
|
|
1357
|
+
async function transcodedDuration(kv, videoId) {
|
|
1358
|
+
const meta = await kv.get(`${KvPrefix.TRANSCODE_META}:${videoId}`, "json");
|
|
1359
|
+
return meta?.duration ?? void 0;
|
|
1360
|
+
}
|
|
1361
|
+
var captions = new Hono5();
|
|
1362
|
+
captions.post("/", async (context) => {
|
|
1363
|
+
const body = await context.req.json();
|
|
1364
|
+
if (!body.key || !body.locales?.length) {
|
|
1365
|
+
return context.json({ error: "key and locales are required" }, 400);
|
|
1366
|
+
}
|
|
1367
|
+
const scope = getRequestState(context);
|
|
1368
|
+
if (!areLocalesAllowed(body.locales, scope.config?.captionsLocales)) {
|
|
1369
|
+
return context.json(
|
|
1370
|
+
{ error: "One or more requested locales are not allowed by this Worker's config", code: "locale_not_allowed" },
|
|
1371
|
+
400
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
const fullKey = storageKey(context, body.key);
|
|
1375
|
+
const file = await context.env.MEDIA_BUCKET.get(fullKey);
|
|
1376
|
+
if (!file) {
|
|
1377
|
+
return context.json({ error: "Media not found in storage" }, 404);
|
|
1378
|
+
}
|
|
1379
|
+
const videoId = extractId(body.key);
|
|
1380
|
+
if (!videoId || videoId === body.key) {
|
|
1381
|
+
return context.json({ error: "Invalid key format \u2014 expected {type}/{id}/..." }, 400);
|
|
1382
|
+
}
|
|
1383
|
+
const filename = body.key.split("/").pop() ?? "audio.mp4";
|
|
1384
|
+
const webhookData = await resolveCaptionsWebhook(context, videoId, body.callbackUrl);
|
|
1385
|
+
const rawEnc = decodeEncryptionMeta2(file.customMetadata?.enc);
|
|
1386
|
+
let audioBody = file.body;
|
|
1387
|
+
if (rawEnc) {
|
|
1388
|
+
const loaded = await loadStorageKey(context.env.KEYS_BUCKET, fullKey, rawEnc.keyId);
|
|
1389
|
+
if (!loaded) {
|
|
1390
|
+
return context.json({ error: "Decryption key unavailable" }, 500);
|
|
1391
|
+
}
|
|
1392
|
+
audioBody = file.body.pipeThrough(createDecryptStream2(loaded.key, rawEnc.iv));
|
|
1393
|
+
}
|
|
1394
|
+
const visibility = visibilityFromKey(body.key) ?? "private";
|
|
1395
|
+
const captionKey = (locale) => storageKey(context, `${visibility}/${captionPath(videoId, locale)}`);
|
|
1396
|
+
const resourceOverride = parseSecurityHeader(file.customMetadata?.security);
|
|
1397
|
+
const captionPolicy = resolveSecurity(resourceOverride, scope.config?.security);
|
|
1398
|
+
const encryptCaptions = captionPolicy.encrypt && visibility === "private" && Boolean(context.env.KEYS_BUCKET);
|
|
1399
|
+
const putCaption = async (locale, vtt) => {
|
|
1400
|
+
const objectKey = captionKey(locale);
|
|
1401
|
+
const metadata = {};
|
|
1402
|
+
if (resourceOverride) metadata.security = JSON.stringify(resourceOverride);
|
|
1403
|
+
let bodyToStore = vtt;
|
|
1404
|
+
if (encryptCaptions && context.env.KEYS_BUCKET) {
|
|
1405
|
+
const result = await encryptForStorage(
|
|
1406
|
+
context.env.KEYS_BUCKET,
|
|
1407
|
+
objectKey,
|
|
1408
|
+
new TextEncoder().encode(vtt),
|
|
1409
|
+
metadata
|
|
1410
|
+
);
|
|
1411
|
+
bodyToStore = result.body;
|
|
1412
|
+
metadata.enc = result.enc;
|
|
1413
|
+
}
|
|
1414
|
+
await context.env.MEDIA_BUCKET.put(objectKey, bodyToStore, {
|
|
1415
|
+
httpMetadata: { contentType: "text/vtt" },
|
|
1416
|
+
...Object.keys(metadata).length > 0 ? { customMetadata: metadata } : {}
|
|
1417
|
+
});
|
|
1418
|
+
};
|
|
1419
|
+
try {
|
|
1420
|
+
const transcript = await transcribeWithGroq(context.env.GROQ_API_KEY, audioBody, filename);
|
|
1421
|
+
const sourceVtt = wordsToSentenceVtt(transcript.words);
|
|
1422
|
+
const detectedLang = transcript.language.slice(0, 2);
|
|
1423
|
+
const sourceLocale = findSourceLocale(detectedLang, body.locales);
|
|
1424
|
+
const jobId = crypto.randomUUID();
|
|
1425
|
+
const completedLocales = [];
|
|
1426
|
+
await putCaption(sourceLocale, sourceVtt);
|
|
1427
|
+
completedLocales.push(sourceLocale);
|
|
1428
|
+
const duplicateLocales = body.locales.filter((l) => l !== sourceLocale && l.split("-")[0] === detectedLang);
|
|
1429
|
+
for (const locale of duplicateLocales) {
|
|
1430
|
+
await putCaption(locale, sourceVtt);
|
|
1431
|
+
completedLocales.push(locale);
|
|
1432
|
+
}
|
|
1433
|
+
const translationLocales = body.locales.filter((l) => l !== sourceLocale && l.split("-")[0] !== detectedLang);
|
|
1434
|
+
const translationResults = await Promise.allSettled(
|
|
1435
|
+
translationLocales.map(async (locale) => {
|
|
1436
|
+
const translatedVtt = await translateVtt(context.env.MISTRAL_API_KEY, sourceVtt, locale);
|
|
1437
|
+
await putCaption(locale, translatedVtt);
|
|
1438
|
+
return locale;
|
|
1439
|
+
})
|
|
1440
|
+
);
|
|
1441
|
+
const translatedLocales = [];
|
|
1442
|
+
for (const [index, result] of translationResults.entries()) {
|
|
1443
|
+
if (result.status === "rejected") {
|
|
1444
|
+
logCaptionsError(`translation to ${translationLocales[index]} failed for ${videoId}`, result.reason);
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
completedLocales.push(result.value);
|
|
1448
|
+
translatedLocales.push(result.value);
|
|
1449
|
+
}
|
|
1450
|
+
const jobMeta = { videoId, locales: body.locales, completedLocales, status: "completed" };
|
|
1451
|
+
await context.env.MEDIA_KV.put(`${KvPrefix.CAPTION_GROUP}:${jobId}`, JSON.stringify(jobMeta), {
|
|
1452
|
+
expirationTtl: KV_JOB_TTL_SECONDS
|
|
1453
|
+
});
|
|
1454
|
+
await forwardToClientWebhook({ event: "captions.complete", videoId, locales: completedLocales }, webhookData);
|
|
1455
|
+
return context.json({
|
|
1456
|
+
transcriptId: jobId,
|
|
1457
|
+
inputKey: body.key,
|
|
1458
|
+
locales: body.locales,
|
|
1459
|
+
completedLocales,
|
|
1460
|
+
// Job detail for the caller: source language, effective translations and the audio's duration (the
|
|
1461
|
+
// transcription's; without it, the one the last transcode reported, kept in KV for 24 h).
|
|
1462
|
+
sourceLocale,
|
|
1463
|
+
translatedLocales,
|
|
1464
|
+
durationSeconds: transcript.duration ?? await transcodedDuration(context.env.MEDIA_KV, videoId),
|
|
1465
|
+
status: "completed"
|
|
1466
|
+
});
|
|
1467
|
+
} catch (err) {
|
|
1468
|
+
logCaptionsError(`job failed for ${videoId}`, err);
|
|
1469
|
+
const failure3 = toCaptionsFailure(err);
|
|
1470
|
+
await forwardToClientWebhook(
|
|
1471
|
+
{
|
|
1472
|
+
event: "captions.failed",
|
|
1473
|
+
videoId,
|
|
1474
|
+
error: failure3.error,
|
|
1475
|
+
reason: failure3.reason,
|
|
1476
|
+
retriable: failure3.retriable
|
|
1477
|
+
},
|
|
1478
|
+
webhookData
|
|
1479
|
+
);
|
|
1480
|
+
return context.json({ error: failure3.error, code: failure3.code }, failure3.status);
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
captions.get("/:transcriptId", async (context) => {
|
|
1484
|
+
const transcriptId = context.req.param("transcriptId");
|
|
1485
|
+
const job = await context.env.MEDIA_KV.get(`${KvPrefix.CAPTION_GROUP}:${transcriptId}`, "json");
|
|
1486
|
+
if (!job) {
|
|
1487
|
+
return context.json({ error: "Caption job not found" }, 404);
|
|
1488
|
+
}
|
|
1489
|
+
return context.json({
|
|
1490
|
+
transcriptId,
|
|
1491
|
+
status: job.status,
|
|
1492
|
+
// The ones actually generated; falls back to `locales` (requested) only for old jobs missing the field.
|
|
1493
|
+
completedLocales: job.completedLocales ?? job.locales,
|
|
1494
|
+
error: job.error
|
|
1495
|
+
});
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
// src/routes/media.ts
|
|
1499
|
+
import {
|
|
1500
|
+
computeFingerprint,
|
|
1501
|
+
decodeEncryptionMeta as decodeEncryptionMeta4,
|
|
1502
|
+
GENERATED_PRIVATE_CACHE_CONTROL,
|
|
1503
|
+
GENERATED_PUBLIC_CACHE_CONTROL,
|
|
1504
|
+
resolveSecurity as resolveSecurity2,
|
|
1505
|
+
SESSION_HEADER,
|
|
1506
|
+
toPrivateCacheControl,
|
|
1507
|
+
videoBasePath,
|
|
1508
|
+
visibilityFromKey as visibilityFromKey2
|
|
1509
|
+
} from "@devultur/core";
|
|
1510
|
+
import { Hono as Hono6 } from "hono";
|
|
1511
|
+
|
|
1512
|
+
// src/image/cloudflare.ts
|
|
1513
|
+
var IMAGE_RESIZING_VIA = "image-resizing";
|
|
1514
|
+
var ORIGIN_MESSAGE_PREFIX = "image-origin:";
|
|
1515
|
+
var ORIGIN_CREDENTIAL_PREFIX = "Bearer dvo.";
|
|
1516
|
+
var CLOUDFLARE_FIT = {
|
|
1517
|
+
fill: "squeeze",
|
|
1518
|
+
cover: "cover",
|
|
1519
|
+
contain: "contain",
|
|
1520
|
+
"scale-down": "scale-down",
|
|
1521
|
+
pad: "pad"
|
|
1522
|
+
};
|
|
1523
|
+
function imageOriginUrl(requestUrl) {
|
|
1524
|
+
return new URL(`${requestUrl.origin}${requestUrl.pathname}`);
|
|
1525
|
+
}
|
|
1526
|
+
async function originSigningKey(secret, usage) {
|
|
1527
|
+
const keyBytes = new TextEncoder().encode(secret);
|
|
1528
|
+
return crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, [usage]);
|
|
1529
|
+
}
|
|
1530
|
+
var originMessage = (scope, url) => new TextEncoder().encode(`${ORIGIN_MESSAGE_PREFIX}${scope}
|
|
1531
|
+
${url.host}${url.pathname}`);
|
|
1532
|
+
var base64Url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1533
|
+
async function imageOriginAuthorization(secret, origin) {
|
|
1534
|
+
const key = await originSigningKey(secret, "sign");
|
|
1535
|
+
const signature = await crypto.subtle.sign("HMAC", key, originMessage(origin.scope, origin.url));
|
|
1536
|
+
const encodedScope = base64Url(new TextEncoder().encode(origin.scope));
|
|
1537
|
+
return `${ORIGIN_CREDENTIAL_PREFIX}${encodedScope}.${base64Url(new Uint8Array(signature))}`;
|
|
1538
|
+
}
|
|
1539
|
+
async function readImageOriginScope(request, secret) {
|
|
1540
|
+
if (request.method !== "GET" || !request.headers.get("via")?.includes(IMAGE_RESIZING_VIA)) return null;
|
|
1541
|
+
const authorization = request.headers.get("Authorization");
|
|
1542
|
+
if (!secret || !authorization?.startsWith(ORIGIN_CREDENTIAL_PREFIX)) return null;
|
|
1543
|
+
const [encodedScope, encodedSignature] = authorization.slice(ORIGIN_CREDENTIAL_PREFIX.length).split(".");
|
|
1544
|
+
if (encodedScope === void 0 || !encodedSignature) return null;
|
|
1545
|
+
try {
|
|
1546
|
+
const scope = new TextDecoder().decode(base64UrlDecode(encodedScope));
|
|
1547
|
+
const key = await originSigningKey(secret, "verify");
|
|
1548
|
+
const message = originMessage(scope, new URL(request.url));
|
|
1549
|
+
const isValid = await crypto.subtle.verify("HMAC", key, base64UrlDecode(encodedSignature), message);
|
|
1550
|
+
return isValid ? scope : null;
|
|
1551
|
+
} catch {
|
|
1552
|
+
return null;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
async function isImageOriginRequest(request, secret) {
|
|
1556
|
+
return await readImageOriginScope(request, secret) !== null;
|
|
1557
|
+
}
|
|
1558
|
+
function cloudflareImageOptions(params, format) {
|
|
1559
|
+
const options = { "origin-auth": "share-publicly" };
|
|
1560
|
+
if (params.width) options.width = params.width;
|
|
1561
|
+
if (params.height) options.height = params.height;
|
|
1562
|
+
if (params.fit) options.fit = CLOUDFLARE_FIT[params.fit];
|
|
1563
|
+
if (params.quality) options.quality = params.quality;
|
|
1564
|
+
if (format) options.format = format;
|
|
1565
|
+
return options;
|
|
1566
|
+
}
|
|
1567
|
+
async function fetchCloudflareImage(origin, secret, image) {
|
|
1568
|
+
return fetch(origin.url.toString(), {
|
|
1569
|
+
headers: { Authorization: await imageOriginAuthorization(secret, origin) },
|
|
1570
|
+
cf: { image }
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
function cloudflareOutputFormat(params, accept, sourceType) {
|
|
1574
|
+
const requested = resolveRequestedFormat(params, accept);
|
|
1575
|
+
if (requested === "source") return null;
|
|
1576
|
+
if (params.format !== "auto") return resolveOutputFormat(requested, sourceType);
|
|
1577
|
+
if (requested === "avif" && sourceType === "image/jpeg") return "avif";
|
|
1578
|
+
if (accept?.includes("image/webp")) return "webp";
|
|
1579
|
+
if (sourceType === "image/gif") return null;
|
|
1580
|
+
return resolveOutputFormat("baseline", sourceType);
|
|
1581
|
+
}
|
|
1582
|
+
async function serveCloudflareTransform(transform) {
|
|
1583
|
+
const { params, accept, sourceType } = transform;
|
|
1584
|
+
const format = cloudflareOutputFormat(params, accept, sourceType);
|
|
1585
|
+
const image = cloudflareImageOptions(params, format);
|
|
1586
|
+
const transformed = await fetchCloudflareImage(transform.origin, transform.secret, image);
|
|
1587
|
+
const headers = new Headers(transformed.headers);
|
|
1588
|
+
headers.set("Cache-Control", transform.cacheControl);
|
|
1589
|
+
if (params.format === "auto") headers.set("Vary", "Accept");
|
|
1590
|
+
if (transform.disposition) headers.set("Content-Disposition", transform.disposition);
|
|
1591
|
+
return new Response(transformed.body, { status: transformed.status, headers });
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// src/image/params.ts
|
|
1595
|
+
var IMAGE_CONTENT_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp", "image/avif", "image/gif"]);
|
|
1596
|
+
var ImageFit = {
|
|
1597
|
+
COVER: "cover",
|
|
1598
|
+
CONTAIN: "contain",
|
|
1599
|
+
FILL: "fill",
|
|
1600
|
+
SCALE_DOWN: "scale-down",
|
|
1601
|
+
PAD: "pad"
|
|
1602
|
+
};
|
|
1603
|
+
var VALID_FITS = new Set(Object.values(ImageFit));
|
|
1604
|
+
var VALID_FORMATS = /* @__PURE__ */ new Set(["webp", "avif", "jpeg", "png", "auto"]);
|
|
1605
|
+
var MAX_DIMENSION = 4096;
|
|
1606
|
+
var MIN_QUALITY = 1;
|
|
1607
|
+
var MAX_QUALITY = 100;
|
|
1608
|
+
var TRANSFORM_QUERY_PARAMS = ["w", "width", "h", "height", "fit", "format", "q", "quality"];
|
|
1609
|
+
function parseTransformParams(url) {
|
|
1610
|
+
const width = url.searchParams.get("w") ?? url.searchParams.get("width");
|
|
1611
|
+
const height = url.searchParams.get("h") ?? url.searchParams.get("height");
|
|
1612
|
+
const fit = url.searchParams.get("fit");
|
|
1613
|
+
const format = url.searchParams.get("format");
|
|
1614
|
+
const quality = url.searchParams.get("q") ?? url.searchParams.get("quality");
|
|
1615
|
+
if (!width && !height && !fit && !format && !quality) return null;
|
|
1616
|
+
const params = {};
|
|
1617
|
+
if (width) {
|
|
1618
|
+
const parsed = Number.parseInt(width, 10);
|
|
1619
|
+
if (parsed > 0 && parsed <= MAX_DIMENSION) params.width = parsed;
|
|
1620
|
+
}
|
|
1621
|
+
if (height) {
|
|
1622
|
+
const parsed = Number.parseInt(height, 10);
|
|
1623
|
+
if (parsed > 0 && parsed <= MAX_DIMENSION) params.height = parsed;
|
|
1624
|
+
}
|
|
1625
|
+
if (fit && VALID_FITS.has(fit)) params.fit = fit;
|
|
1626
|
+
if (format && VALID_FORMATS.has(format)) params.format = format;
|
|
1627
|
+
if (quality) {
|
|
1628
|
+
const parsed = Number.parseInt(quality, 10);
|
|
1629
|
+
if (parsed >= MIN_QUALITY && parsed <= MAX_QUALITY) params.quality = parsed;
|
|
1630
|
+
}
|
|
1631
|
+
return Object.keys(params).length > 0 ? params : null;
|
|
1632
|
+
}
|
|
1633
|
+
function hasTransformQuery(url) {
|
|
1634
|
+
return TRANSFORM_QUERY_PARAMS.some((param) => url.searchParams.has(param));
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// src/image/dimensions.ts
|
|
1638
|
+
function readImageDimensions(bytes, contentType) {
|
|
1639
|
+
if (contentType === "image/jpeg") return readJpeg(bytes);
|
|
1640
|
+
if (contentType === "image/png") return readPng(bytes);
|
|
1641
|
+
if (contentType === "image/webp") return readWebp(bytes);
|
|
1642
|
+
if (contentType === "image/avif") return readAvif(bytes);
|
|
1643
|
+
return null;
|
|
1644
|
+
}
|
|
1645
|
+
var uint16BE = (bytes, offset) => bytes[offset] << 8 | bytes[offset + 1];
|
|
1646
|
+
var uint32BE = (bytes, offset) => (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
|
|
1647
|
+
var uint16LE = (bytes, offset) => bytes[offset] | bytes[offset + 1] << 8;
|
|
1648
|
+
var uint24LE = (bytes, offset) => bytes[offset] | bytes[offset + 1] << 8 | bytes[offset + 2] << 16;
|
|
1649
|
+
var isStartOfFrame = (marker) => marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
|
|
1650
|
+
function readJpeg(bytes) {
|
|
1651
|
+
if (bytes[0] !== 255 || bytes[1] !== 216) return null;
|
|
1652
|
+
let offset = 2;
|
|
1653
|
+
while (offset + 9 < bytes.length) {
|
|
1654
|
+
if (bytes[offset] !== 255) return null;
|
|
1655
|
+
const marker = bytes[offset + 1];
|
|
1656
|
+
if (isStartOfFrame(marker)) {
|
|
1657
|
+
return { height: uint16BE(bytes, offset + 5), width: uint16BE(bytes, offset + 7) };
|
|
1658
|
+
}
|
|
1659
|
+
offset += 2 + uint16BE(bytes, offset + 2);
|
|
1660
|
+
}
|
|
1661
|
+
return null;
|
|
1662
|
+
}
|
|
1663
|
+
function readPng(bytes) {
|
|
1664
|
+
const isPng = bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71;
|
|
1665
|
+
if (!isPng || bytes.length < 24) return null;
|
|
1666
|
+
return { width: uint32BE(bytes, 16), height: uint32BE(bytes, 20) };
|
|
1667
|
+
}
|
|
1668
|
+
function readWebp(bytes) {
|
|
1669
|
+
if (bytes.length < 30) return null;
|
|
1670
|
+
const chunk = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
|
|
1671
|
+
if (chunk === "VP8X") return { width: uint24LE(bytes, 24) + 1, height: uint24LE(bytes, 27) + 1 };
|
|
1672
|
+
if (chunk === "VP8 ") return { width: uint16LE(bytes, 26) & 16383, height: uint16LE(bytes, 28) & 16383 };
|
|
1673
|
+
if (chunk === "VP8L") {
|
|
1674
|
+
const bits = bytes[21] | bytes[22] << 8 | bytes[23] << 16 | bytes[24] << 24;
|
|
1675
|
+
return { width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
|
|
1676
|
+
}
|
|
1677
|
+
return null;
|
|
1678
|
+
}
|
|
1679
|
+
function readAvif(bytes) {
|
|
1680
|
+
const limit = Math.min(bytes.length - 20, 64 * 1024);
|
|
1681
|
+
for (let offset = 4; offset < limit; offset += 1) {
|
|
1682
|
+
const isIspe = bytes[offset] === 105 && bytes[offset + 1] === 115 && bytes[offset + 2] === 112 && bytes[offset + 3] === 101;
|
|
1683
|
+
if (isIspe) return { width: uint32BE(bytes, offset + 8), height: uint32BE(bytes, offset + 12) };
|
|
1684
|
+
}
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1687
|
+
var fourCC = (bytes, offset) => String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
|
|
1688
|
+
function isAnimatedImage(bytes, contentType) {
|
|
1689
|
+
if (contentType === "image/gif") return true;
|
|
1690
|
+
if (contentType === "image/webp")
|
|
1691
|
+
return bytes.length > 20 && fourCC(bytes, 12) === "VP8X" && (bytes[20] & 2) !== 0;
|
|
1692
|
+
if (contentType === "image/png") return hasPngAnimationControl(bytes);
|
|
1693
|
+
if (contentType === "image/avif") return hasAvifSequenceBrand(bytes);
|
|
1694
|
+
return false;
|
|
1695
|
+
}
|
|
1696
|
+
function hasPngAnimationControl(bytes) {
|
|
1697
|
+
let offset = 8;
|
|
1698
|
+
while (offset + 8 <= bytes.length) {
|
|
1699
|
+
const chunk = fourCC(bytes, offset + 4);
|
|
1700
|
+
if (chunk === "acTL") return true;
|
|
1701
|
+
if (chunk === "IDAT") return false;
|
|
1702
|
+
offset += 12 + uint32BE(bytes, offset);
|
|
1703
|
+
}
|
|
1704
|
+
return false;
|
|
1705
|
+
}
|
|
1706
|
+
function hasAvifSequenceBrand(bytes) {
|
|
1707
|
+
if (bytes.length < 16 || fourCC(bytes, 4) !== "ftyp") return false;
|
|
1708
|
+
const boxEnd = Math.min(uint32BE(bytes, 0), bytes.length);
|
|
1709
|
+
for (let offset = 8; offset + 4 <= boxEnd; offset += 4) {
|
|
1710
|
+
if (offset === 12) continue;
|
|
1711
|
+
if (fourCC(bytes, offset) === "avis") return true;
|
|
1712
|
+
}
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// src/image/storage.ts
|
|
1717
|
+
import { decodeEncryptionMeta as decodeEncryptionMeta3, decryptChunk as decryptChunk2 } from "@devultur/core";
|
|
1718
|
+
var HEADER_PROBE_BYTES = 256 * 1024;
|
|
1719
|
+
var passThrough = async (bytes) => bytes;
|
|
1720
|
+
var DerivedKind = {
|
|
1721
|
+
VARIANT: "variant",
|
|
1722
|
+
MEZZANINE: "mezzanine"
|
|
1723
|
+
};
|
|
1724
|
+
async function loadDecryptor(env, objectKey, customMetadata) {
|
|
1725
|
+
const enc = decodeEncryptionMeta3(customMetadata?.enc);
|
|
1726
|
+
if (!enc) return passThrough;
|
|
1727
|
+
const loaded = await loadStorageKey(env.KEYS_BUCKET, objectKey, enc.keyId);
|
|
1728
|
+
if (!loaded) return null;
|
|
1729
|
+
return (bytes) => decryptChunk2(bytes, loaded.key, enc.iv, 0);
|
|
1730
|
+
}
|
|
1731
|
+
async function readHeaderBytes(env, objectKey, object, decrypt) {
|
|
1732
|
+
const length = Math.min(object.size, HEADER_PROBE_BYTES);
|
|
1733
|
+
if (length === 0) return null;
|
|
1734
|
+
const part = await env.MEDIA_BUCKET.get(objectKey, { range: { offset: 0, length } });
|
|
1735
|
+
if (!part) return null;
|
|
1736
|
+
return decrypt(new Uint8Array(await part.arrayBuffer()));
|
|
1737
|
+
}
|
|
1738
|
+
async function readWholeObject(object, decrypt) {
|
|
1739
|
+
return decrypt(new Uint8Array(await object.arrayBuffer()));
|
|
1740
|
+
}
|
|
1741
|
+
async function storeDerived(env, storageKey2, body, contentType, original, kind) {
|
|
1742
|
+
const security = original.customMetadata?.security;
|
|
1743
|
+
const customMetadata = { derivedFrom: kind };
|
|
1744
|
+
if (security) customMetadata.security = security;
|
|
1745
|
+
let storedBody = body;
|
|
1746
|
+
if (decodeEncryptionMeta3(original.customMetadata?.enc)) {
|
|
1747
|
+
if (!env.KEYS_BUCKET) throw new Error("Encrypted original without KEYS_BUCKET");
|
|
1748
|
+
const encrypted = await encryptForStorage(env.KEYS_BUCKET, storageKey2, body, security ? { security } : void 0);
|
|
1749
|
+
storedBody = encrypted.body;
|
|
1750
|
+
customMetadata.enc = encrypted.enc;
|
|
1751
|
+
}
|
|
1752
|
+
await env.MEDIA_BUCKET.put(storageKey2, storedBody, {
|
|
1753
|
+
storageClass: "Standard",
|
|
1754
|
+
httpMetadata: { contentType },
|
|
1755
|
+
customMetadata
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// src/image/mezzanine.ts
|
|
1760
|
+
var MEZZANINE_QUALITY = 90;
|
|
1761
|
+
var MEZZANINE_PIXEL_HEADROOM = 0.98;
|
|
1762
|
+
function mezzanineFormat(sourceType) {
|
|
1763
|
+
return sourceType === "image/jpeg" ? "jpeg" : "webp";
|
|
1764
|
+
}
|
|
1765
|
+
function mezzanineKey(fullKey, sourceType) {
|
|
1766
|
+
return `${variantPrefix(fullKey)}mezzanine.${mezzanineFormat(sourceType)}`;
|
|
1767
|
+
}
|
|
1768
|
+
function mezzanineLongSide(original, maxMegapixels) {
|
|
1769
|
+
const budgetPixels = maxMegapixels * 1e6 * MEZZANINE_PIXEL_HEADROOM;
|
|
1770
|
+
const scale = Math.min(1, Math.sqrt(budgetPixels / (original.width * original.height)));
|
|
1771
|
+
return Math.max(1, Math.floor(Math.max(original.width, original.height) * scale));
|
|
1772
|
+
}
|
|
1773
|
+
var isLandscape = (size) => size.width >= size.height;
|
|
1774
|
+
function canServeFromMezzanine(original, mezzanine, params) {
|
|
1775
|
+
const oriented = isLandscape(original) === isLandscape(mezzanine) ? original : { width: original.height, height: original.width };
|
|
1776
|
+
const fromOriginal = planResize(oriented, params);
|
|
1777
|
+
const fromMezzanine = planResize(mezzanine, params);
|
|
1778
|
+
if (isUpscale(mezzanine, fromMezzanine)) return false;
|
|
1779
|
+
const isSameWidth = Math.abs(fromOriginal.width - fromMezzanine.width) <= 1;
|
|
1780
|
+
return isSameWidth && Math.abs(fromOriginal.height - fromMezzanine.height) <= 1;
|
|
1781
|
+
}
|
|
1782
|
+
var inFlight = /* @__PURE__ */ new Map();
|
|
1783
|
+
function resolveMezzanine(request) {
|
|
1784
|
+
const key = mezzanineKey(request.fullKey, request.original.httpMetadata?.contentType ?? "");
|
|
1785
|
+
const pending = inFlight.get(key);
|
|
1786
|
+
if (pending) return pending;
|
|
1787
|
+
const run = loadOrCreate(request, key).finally(() => inFlight.delete(key));
|
|
1788
|
+
inFlight.set(key, run);
|
|
1789
|
+
return run;
|
|
1790
|
+
}
|
|
1791
|
+
async function loadOrCreate(request, key) {
|
|
1792
|
+
const stored = await readStoredMezzanine(request.env, key, request.maxMegapixels);
|
|
1793
|
+
if (stored) return { kind: "mezzanine", source: stored, created: false };
|
|
1794
|
+
return createMezzanine(request, key);
|
|
1795
|
+
}
|
|
1796
|
+
async function readStoredMezzanine(env, key, maxMegapixels) {
|
|
1797
|
+
const object = await env.MEDIA_BUCKET.get(key);
|
|
1798
|
+
if (!object) return null;
|
|
1799
|
+
const decrypt = await loadDecryptor(env, key, object.customMetadata);
|
|
1800
|
+
if (!decrypt) return null;
|
|
1801
|
+
const bytes = await decrypt(new Uint8Array(await object.arrayBuffer()));
|
|
1802
|
+
return validMezzanine(bytes, object.httpMetadata?.contentType ?? "", maxMegapixels);
|
|
1803
|
+
}
|
|
1804
|
+
async function createMezzanine(request, key) {
|
|
1805
|
+
const { env, original, originalDimensions, maxMegapixels } = request;
|
|
1806
|
+
const format = mezzanineFormat(original.httpMetadata?.contentType ?? "");
|
|
1807
|
+
const longSide = mezzanineLongSide(originalDimensions, maxMegapixels);
|
|
1808
|
+
const response = await fetchCloudflareImage(request.origin, env.DELIVERY_SIGN_SECRET, {
|
|
1809
|
+
width: longSide,
|
|
1810
|
+
height: longSide,
|
|
1811
|
+
fit: "scale-down",
|
|
1812
|
+
format,
|
|
1813
|
+
quality: MEZZANINE_QUALITY,
|
|
1814
|
+
"origin-auth": "share-publicly"
|
|
1815
|
+
});
|
|
1816
|
+
if (!response.ok) return { kind: "fallback", reason: `mezzanine_status:${response.status}` };
|
|
1817
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
1818
|
+
const declaredLength = response.headers.get("content-length");
|
|
1819
|
+
if (declaredLength && Number(declaredLength) !== bytes.byteLength) {
|
|
1820
|
+
return { kind: "fallback", reason: "mezzanine_truncated" };
|
|
1821
|
+
}
|
|
1822
|
+
const contentType = mimeOfFormat(format);
|
|
1823
|
+
if (!response.headers.get("content-type")?.startsWith(contentType)) {
|
|
1824
|
+
return { kind: "fallback", reason: "mezzanine_type" };
|
|
1825
|
+
}
|
|
1826
|
+
const source = validMezzanine(bytes, contentType, maxMegapixels);
|
|
1827
|
+
if (!source) return { kind: "fallback", reason: "mezzanine_invalid" };
|
|
1828
|
+
await storeDerived(env, key, bytes.buffer, contentType, original, DerivedKind.MEZZANINE);
|
|
1829
|
+
console.log(`[image-transforms] mezzanine created ${source.dimensions.width}x${source.dimensions.height}`);
|
|
1830
|
+
return { kind: "mezzanine", source, created: true };
|
|
1831
|
+
}
|
|
1832
|
+
function validMezzanine(bytes, contentType, maxMegapixels) {
|
|
1833
|
+
const dimensions = readImageDimensions(bytes, contentType);
|
|
1834
|
+
if (!dimensions || megapixels(dimensions) > maxMegapixels) return null;
|
|
1835
|
+
if (isAnimatedImage(bytes, contentType)) return null;
|
|
1836
|
+
return { bytes, contentType, dimensions };
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// src/image/worker-transform.ts
|
|
1840
|
+
var MAX_SOURCE_BYTES = 25 * 1024 * 1024;
|
|
1841
|
+
var fallback = (reason) => ({ kind: "fallback", reason });
|
|
1842
|
+
function workerVariantKey(fullKey, params, accept) {
|
|
1843
|
+
return variantStorageKey(fullKey, params, resolveRequestedFormat(params, accept));
|
|
1844
|
+
}
|
|
1845
|
+
async function resolveWorkerVariant(request) {
|
|
1846
|
+
const storageKey2 = workerVariantKey(request.fullKey, request.params, request.accept);
|
|
1847
|
+
const existing = await request.env.MEDIA_BUCKET.get(storageKey2);
|
|
1848
|
+
if (existing) return { kind: "variant", storageKey: storageKey2, object: existing, generated: false };
|
|
1849
|
+
try {
|
|
1850
|
+
return await generateVariant(request, storageKey2);
|
|
1851
|
+
} catch (error) {
|
|
1852
|
+
console.error("[image-transforms] worker path failed:", error);
|
|
1853
|
+
return fallback(`error:${error instanceof Error ? error.name : "unknown"}`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
async function generateVariant(request, storageKey2) {
|
|
1857
|
+
const { env, fullKey, original, params } = request;
|
|
1858
|
+
const sourceType = original.httpMetadata?.contentType ?? "";
|
|
1859
|
+
if (!WORKER_DECODABLE_TYPES.has(sourceType)) return fallback(`type:${sourceType}`);
|
|
1860
|
+
const decrypt = await loadDecryptor(env, fullKey, original.customMetadata);
|
|
1861
|
+
if (!decrypt) return fallback("decryption_key_unavailable");
|
|
1862
|
+
const header = await readHeaderBytes(env, fullKey, original, decrypt);
|
|
1863
|
+
const dimensions = header ? readImageDimensions(header, sourceType) : null;
|
|
1864
|
+
if (!header || !dimensions) return fallback("unreadable_header");
|
|
1865
|
+
if (isAnimatedImage(header, sourceType)) return fallback("animated");
|
|
1866
|
+
const maxMegapixels = parseMaxMegapixels(env.IMAGE_TRANSFORMS_MAX_MEGAPIXELS);
|
|
1867
|
+
const isWithinBudget = megapixels(dimensions) <= maxMegapixels && original.size <= MAX_SOURCE_BYTES;
|
|
1868
|
+
const source = isWithinBudget ? { bytes: await readWholeObject(original, decrypt), contentType: sourceType, dimensions } : await mezzanineSource(request, dimensions, maxMegapixels);
|
|
1869
|
+
if ("reason" in source) return source;
|
|
1870
|
+
const plan = planResize(source.dimensions, params);
|
|
1871
|
+
if (!fitsWorkerBudget(source.dimensions, maxMegapixels, plan)) return fallback("megapixels");
|
|
1872
|
+
const format = resolveOutputFormat(resolveRequestedFormat(params, request.accept), sourceType);
|
|
1873
|
+
const output = await oneAtATime(() => runCodecs(source, params, format));
|
|
1874
|
+
await storeDerived(env, storageKey2, output.body, output.contentType, original, DerivedKind.VARIANT);
|
|
1875
|
+
const stored = await env.MEDIA_BUCKET.get(storageKey2);
|
|
1876
|
+
if (!stored) return fallback("variant_write_lost");
|
|
1877
|
+
return { kind: "variant", storageKey: storageKey2, object: stored, generated: true };
|
|
1878
|
+
}
|
|
1879
|
+
async function mezzanineSource(request, originalDimensions, maxMegapixels) {
|
|
1880
|
+
const { env, fullKey, original, origin, params } = request;
|
|
1881
|
+
const mezzanine = await resolveMezzanine({ env, fullKey, original, originalDimensions, origin, maxMegapixels });
|
|
1882
|
+
if (mezzanine.kind === "fallback") return mezzanine;
|
|
1883
|
+
if (!canServeFromMezzanine(originalDimensions, mezzanine.source.dimensions, params)) {
|
|
1884
|
+
return fallback("beyond_mezzanine");
|
|
1885
|
+
}
|
|
1886
|
+
return mezzanine.source;
|
|
1887
|
+
}
|
|
1888
|
+
var transformQueue = Promise.resolve();
|
|
1889
|
+
function oneAtATime(task) {
|
|
1890
|
+
const run = transformQueue.then(task, task);
|
|
1891
|
+
transformQueue = run.catch(() => void 0);
|
|
1892
|
+
return run;
|
|
1893
|
+
}
|
|
1894
|
+
async function runCodecs(source, params, format) {
|
|
1895
|
+
const [{ prepareCodecs, transformImage }, { codecWasmModules }] = await Promise.all([
|
|
1896
|
+
import("./codecs-RKUOPKWR.js"),
|
|
1897
|
+
import("./wasm-modules-55Q2QP7Q.js")
|
|
1898
|
+
]);
|
|
1899
|
+
await prepareCodecs(codecWasmModules, source.contentType, format);
|
|
1900
|
+
const headerPixels = source.dimensions.width * source.dimensions.height;
|
|
1901
|
+
return transformImage(
|
|
1902
|
+
source.bytes.buffer,
|
|
1903
|
+
source.contentType,
|
|
1904
|
+
(decoded) => planResize(decoded, params),
|
|
1905
|
+
{ format, quality: outputQuality(params, format) },
|
|
1906
|
+
headerPixels
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// src/routes/edge-cache.ts
|
|
1911
|
+
import { edgeTtlSeconds, isAssetKey, sha256Hex as sha256Hex2 } from "@devultur/core";
|
|
1912
|
+
var MAX_EDGE_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
1913
|
+
var MAX_EDGE_CACHE_FILL_BYTES = 100 * 1024 * 1024;
|
|
1914
|
+
var ORIGINAL_CACHE_CONTROL_HEADER = "x-devultur-cache-control";
|
|
1915
|
+
var CACHE_TAG_HEADER = "Cache-Tag";
|
|
1916
|
+
var TAG_HASH_LENGTH = 40;
|
|
1917
|
+
function publicEdgeCache() {
|
|
1918
|
+
if (typeof caches === "undefined") return null;
|
|
1919
|
+
return caches.default;
|
|
1920
|
+
}
|
|
1921
|
+
function purgeUnitOfKey(virtualKey) {
|
|
1922
|
+
if (isAssetKey(virtualKey)) return virtualKey;
|
|
1923
|
+
return virtualKey.split("/").slice(0, 3).join("/");
|
|
1924
|
+
}
|
|
1925
|
+
async function edgeCacheTag(storagePrefix2, virtualKey) {
|
|
1926
|
+
const digest = await sha256Hex2(new TextEncoder().encode(`${storagePrefix2}${purgeUnitOfKey(virtualKey)}`));
|
|
1927
|
+
return `dv-o-${digest.slice(0, TAG_HASH_LENGTH)}`;
|
|
1928
|
+
}
|
|
1929
|
+
function prefixEdgeCacheTag(storagePrefix2) {
|
|
1930
|
+
const prefix = storagePrefix2.replace(/\/$/, "");
|
|
1931
|
+
return prefix ? `dv-p-${prefix}` : null;
|
|
1932
|
+
}
|
|
1933
|
+
async function edgeCacheTags(storagePrefix2, virtualKey) {
|
|
1934
|
+
const prefixTag = prefixEdgeCacheTag(storagePrefix2);
|
|
1935
|
+
const objectTag = await edgeCacheTag(storagePrefix2, virtualKey);
|
|
1936
|
+
return prefixTag ? `${objectTag},${prefixTag}` : objectTag;
|
|
1937
|
+
}
|
|
1938
|
+
function responseFromEdgeHit(hit, options) {
|
|
1939
|
+
const headers = new Headers(hit.headers);
|
|
1940
|
+
if (options.isFormatNegotiated) headers.set("Vary", "Accept");
|
|
1941
|
+
const original = headers.get(ORIGINAL_CACHE_CONTROL_HEADER);
|
|
1942
|
+
if (original) headers.set("Cache-Control", original);
|
|
1943
|
+
headers.delete(ORIGINAL_CACHE_CONTROL_HEADER);
|
|
1944
|
+
headers.delete(CACHE_TAG_HEADER);
|
|
1945
|
+
return new Response(hit.body, { status: hit.status, headers });
|
|
1946
|
+
}
|
|
1947
|
+
function edgeTtlFor(cacheControl) {
|
|
1948
|
+
return edgeTtlSeconds(cacheControl, MAX_EDGE_TTL_SECONDS);
|
|
1949
|
+
}
|
|
1950
|
+
async function putEdgeCopy(target, body, headers) {
|
|
1951
|
+
const ttl = edgeTtlFor(target.cacheControl);
|
|
1952
|
+
const copyHeaders = new Headers(headers);
|
|
1953
|
+
copyHeaders.delete("Content-Range");
|
|
1954
|
+
copyHeaders.delete("Vary");
|
|
1955
|
+
copyHeaders.set(ORIGINAL_CACHE_CONTROL_HEADER, target.cacheControl);
|
|
1956
|
+
copyHeaders.set("Cache-Control", `public, max-age=${ttl}`);
|
|
1957
|
+
copyHeaders.set(CACHE_TAG_HEADER, await edgeCacheTags(target.storagePrefix, target.virtualKey));
|
|
1958
|
+
await target.cache.put(target.cacheKey, new Response(body, { status: 200, headers: copyHeaders }));
|
|
1959
|
+
}
|
|
1960
|
+
async function fillEdgeCache(target, bucket, fullKey, headers) {
|
|
1961
|
+
const full = await bucket.get(fullKey);
|
|
1962
|
+
if (!full) return;
|
|
1963
|
+
const fullHeaders = new Headers(headers);
|
|
1964
|
+
fullHeaders.set("Content-Length", String(full.size));
|
|
1965
|
+
await putEdgeCopy(target, full.body, fullHeaders);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// src/routes/storyboard-vtt.ts
|
|
1969
|
+
import { createDecryptStream as createDecryptStream3 } from "@devultur/core";
|
|
1970
|
+
var STORYBOARD_VTT = /(?:^|\/)video\/[^/]+\/storyboard\/[^/]+\.vtt$/;
|
|
1971
|
+
var SPRITE_LINE = /^(sprite-\d+\.jpg)(#xywh=\d+,\d+,\d+,\d+)?$/gm;
|
|
1972
|
+
function isStoryboardVttKey(key) {
|
|
1973
|
+
return STORYBOARD_VTT.test(key);
|
|
1974
|
+
}
|
|
1975
|
+
function rewriteStoryboardVtt(vttText, token2) {
|
|
1976
|
+
return vttText.replace(SPRITE_LINE, (_line, file, fragment = "") => `${file}?token=${token2}${fragment}`);
|
|
1977
|
+
}
|
|
1978
|
+
async function storyboardVttResponse(body, token2, decryption) {
|
|
1979
|
+
const plain = decryption ? body.pipeThrough(createDecryptStream3(decryption.key, decryption.iv)) : body;
|
|
1980
|
+
const vttText = await new Response(plain).text();
|
|
1981
|
+
return new Response(rewriteStoryboardVtt(vttText, token2), {
|
|
1982
|
+
headers: { "Content-Type": "text/vtt; charset=utf-8", "Cache-Control": "no-cache" }
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// src/routes/media.ts
|
|
1987
|
+
async function verifyDeliveryToken(token2, secret) {
|
|
1988
|
+
const payload = await verifyViewerToken(token2, secret);
|
|
1989
|
+
return payload && payload.typ !== "upload" ? payload : null;
|
|
1990
|
+
}
|
|
1991
|
+
function downloadDisposition(storedDisposition, key, filename) {
|
|
1992
|
+
if (filename) return contentDispositionAttachment(filename);
|
|
1993
|
+
if (storedDisposition?.startsWith("inline")) return `attachment${storedDisposition.slice("inline".length)}`;
|
|
1994
|
+
return contentDispositionAttachment(key.split("/").pop() || "download");
|
|
1995
|
+
}
|
|
1996
|
+
var media = new Hono6();
|
|
1997
|
+
media.get("/:key{.+}", async (context) => {
|
|
1998
|
+
const key = context.req.param("key");
|
|
1999
|
+
const fullKey = storageKey(context, key);
|
|
2000
|
+
const requestUrl = new URL(context.req.url);
|
|
2001
|
+
if (isVariantKey(key)) {
|
|
2002
|
+
return context.json({ error: "Not found" }, 404);
|
|
2003
|
+
}
|
|
2004
|
+
const callerScope = storagePrefix(context, "");
|
|
2005
|
+
const originScope = await readImageOriginScope(context.req.raw, context.env.DELIVERY_SIGN_SECRET);
|
|
2006
|
+
const isOriginRequest = originScope !== null && originScope === callerScope;
|
|
2007
|
+
const origin = { url: imageOriginUrl(requestUrl), scope: callerScope };
|
|
2008
|
+
const visibility = visibilityFromKey2(key) ?? "private";
|
|
2009
|
+
const isPrivate = visibility === "private";
|
|
2010
|
+
const isOwnerAccess = getRequestState(context).access?.trustedRead === true;
|
|
2011
|
+
const token2 = isOwnerAccess ? void 0 : context.req.query("token") ?? context.req.header("Authorization")?.replace(/^Bearer\s+/i, "");
|
|
2012
|
+
let tokenPayload = null;
|
|
2013
|
+
if (isPrivate && !isOriginRequest && !isOwnerAccess) {
|
|
2014
|
+
tokenPayload = await verifyDeliveryToken(token2, context.env.DELIVERY_SIGN_SECRET);
|
|
2015
|
+
if (!tokenPayload) {
|
|
2016
|
+
return context.json({ error: "Unauthorized" }, 401);
|
|
2017
|
+
}
|
|
2018
|
+
const claimRejection = viewerTokenClaimRejection(tokenPayload, {
|
|
2019
|
+
storagePrefix: getRequestState(context).storagePrefix,
|
|
2020
|
+
key
|
|
2021
|
+
});
|
|
2022
|
+
if (claimRejection) {
|
|
2023
|
+
console.warn(`[delivery] token rejected (${claimRejection.reason}) for ${key}`);
|
|
2024
|
+
return context.json({ error: claimRejection.error, code: claimRejection.code }, claimRejection.status);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
if (key.includes("/hls/") && key.endsWith(".key") && !isOwnerAccess) {
|
|
2028
|
+
return serveEncryptionKey(context, key, tokenPayload);
|
|
2029
|
+
}
|
|
2030
|
+
const transformParams = isOriginRequest ? null : parseTransformParams(requestUrl);
|
|
2031
|
+
const isFormatNegotiated = transformParams?.format === "auto";
|
|
2032
|
+
const workerParams = resolveTransformsMode(context.env.IMAGE_TRANSFORMS) === ImageTransformsMode.WORKER ? transformParams : null;
|
|
2033
|
+
const accept = context.req.header("Accept");
|
|
2034
|
+
const edgeCacheKey = workerParams ? `${requestUrl.origin}/__variant/${workerVariantKey(fullKey, workerParams, accept)}` : requestUrl.toString();
|
|
2035
|
+
const isUncachedTransform = hasTransformQuery(requestUrl) && !workerParams;
|
|
2036
|
+
const edgeCache = isPrivate || isOriginRequest || isUncachedTransform ? null : publicEdgeCache();
|
|
2037
|
+
if (edgeCache) {
|
|
2038
|
+
const cacheRequest = workerParams ? new Request(edgeCacheKey, context.req.raw) : context.req.raw;
|
|
2039
|
+
const hit = await edgeCache.match(cacheRequest);
|
|
2040
|
+
if (hit) return responseFromEdgeHit(hit, { isFormatNegotiated });
|
|
2041
|
+
}
|
|
2042
|
+
let object = await context.env.MEDIA_BUCKET.get(fullKey);
|
|
2043
|
+
let servedKey = fullKey;
|
|
2044
|
+
if (!object) {
|
|
2045
|
+
return context.json({ error: "Not found" }, 404);
|
|
2046
|
+
}
|
|
2047
|
+
if (isObjectExpired(object.customMetadata)) return expiredResponse();
|
|
2048
|
+
if (tokenPayload && await bindingMismatch(context, tokenPayload, object.customMetadata)) {
|
|
2049
|
+
return context.json({ error: "Session mismatch", code: "session_mismatch" }, 401);
|
|
2050
|
+
}
|
|
2051
|
+
let contentType = object.httpMetadata?.contentType ?? "application/octet-stream";
|
|
2052
|
+
const isPlaylist = key.endsWith(".m3u8");
|
|
2053
|
+
const cacheControl = servedCacheControl(object.httpMetadata?.cacheControl, isPrivate);
|
|
2054
|
+
const downloadParam = requestUrl.searchParams.get("download");
|
|
2055
|
+
const isDownload = downloadParam !== null;
|
|
2056
|
+
if (isPlaylist && token2) {
|
|
2057
|
+
const playlistText = await object.text();
|
|
2058
|
+
const rewritten = playlistText.replace(/^([^#\s].+\.(?:ts|m3u8))$/gm, `$1?token=${token2}`).replace(/(#EXT-X-KEY:[^\n]*URI=")([^"]+)(")/g, `$1$2?token=${token2}$3`);
|
|
2059
|
+
return new Response(rewritten, {
|
|
2060
|
+
headers: {
|
|
2061
|
+
"Content-Type": "application/vnd.apple.mpegurl",
|
|
2062
|
+
"Cache-Control": "no-cache"
|
|
2063
|
+
}
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
const imageParams = IMAGE_CONTENT_TYPES.has(contentType) ? transformParams : null;
|
|
2067
|
+
const disposition = isDownload ? downloadDisposition(object.httpMetadata?.contentDisposition, key, downloadParam ?? "") : void 0;
|
|
2068
|
+
let isServingVariant = false;
|
|
2069
|
+
if (imageParams && workerParams) {
|
|
2070
|
+
const variant = await resolveWorkerVariant({
|
|
2071
|
+
env: context.env,
|
|
2072
|
+
fullKey,
|
|
2073
|
+
original: object,
|
|
2074
|
+
params: workerParams,
|
|
2075
|
+
accept,
|
|
2076
|
+
origin
|
|
2077
|
+
});
|
|
2078
|
+
if (variant.kind === "variant") {
|
|
2079
|
+
object = variant.object;
|
|
2080
|
+
servedKey = variant.storageKey;
|
|
2081
|
+
contentType = object.httpMetadata?.contentType ?? contentType;
|
|
2082
|
+
isServingVariant = true;
|
|
2083
|
+
} else {
|
|
2084
|
+
console.log(`[image-transforms] fallback to cloudflare: ${variant.reason}`);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
if (imageParams && !isServingVariant) {
|
|
2088
|
+
return serveCloudflareTransform({
|
|
2089
|
+
origin,
|
|
2090
|
+
secret: context.env.DELIVERY_SIGN_SECRET,
|
|
2091
|
+
params: imageParams,
|
|
2092
|
+
accept,
|
|
2093
|
+
sourceType: contentType,
|
|
2094
|
+
cacheControl,
|
|
2095
|
+
disposition
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
if (token2 && isStoryboardVttKey(key) && object.body) {
|
|
2099
|
+
const enc = decodeEncryptionMeta4(object.customMetadata?.enc);
|
|
2100
|
+
const loaded = enc ? await loadStorageKey(context.env.KEYS_BUCKET, fullKey, enc.keyId) : null;
|
|
2101
|
+
if (enc && !loaded) return context.json({ error: "Decryption key unavailable" }, 500);
|
|
2102
|
+
const decryption = enc && loaded ? { key: loaded.key, iv: enc.iv } : null;
|
|
2103
|
+
return storyboardVttResponse(object.body, token2, decryption);
|
|
2104
|
+
}
|
|
2105
|
+
const headers = new Headers();
|
|
2106
|
+
headers.set("Content-Type", contentType);
|
|
2107
|
+
headers.set("Cache-Control", cacheControl);
|
|
2108
|
+
if (isServingVariant && isFormatNegotiated) headers.set("Vary", "Accept");
|
|
2109
|
+
if (disposition) {
|
|
2110
|
+
headers.set("Content-Disposition", disposition);
|
|
2111
|
+
} else if (object.httpMetadata?.contentDisposition) {
|
|
2112
|
+
headers.set("Content-Disposition", object.httpMetadata.contentDisposition);
|
|
2113
|
+
}
|
|
2114
|
+
const { response, kind } = await streamObject({
|
|
2115
|
+
bucket: context.env.MEDIA_BUCKET,
|
|
2116
|
+
keysBucket: context.env.KEYS_BUCKET,
|
|
2117
|
+
fullKey: servedKey,
|
|
2118
|
+
object,
|
|
2119
|
+
headers,
|
|
2120
|
+
range: context.req.header("Range")
|
|
2121
|
+
});
|
|
2122
|
+
if (!edgeCache || edgeTtlFor(cacheControl) <= 0) return response;
|
|
2123
|
+
const target = {
|
|
2124
|
+
cache: edgeCache,
|
|
2125
|
+
cacheKey: edgeCacheKey,
|
|
2126
|
+
storagePrefix: getRequestState(context).storagePrefix,
|
|
2127
|
+
virtualKey: key,
|
|
2128
|
+
cacheControl
|
|
2129
|
+
};
|
|
2130
|
+
if (kind === "plain-range" && object.size <= MAX_EDGE_CACHE_FILL_BYTES) {
|
|
2131
|
+
context.executionCtx.waitUntil(fillEdgeCache(target, context.env.MEDIA_BUCKET, servedKey, response.headers));
|
|
2132
|
+
}
|
|
2133
|
+
if (kind === "plain-full") {
|
|
2134
|
+
context.executionCtx.waitUntil(putEdgeCopy(target, response.clone().body, headers));
|
|
2135
|
+
}
|
|
2136
|
+
return response;
|
|
2137
|
+
});
|
|
2138
|
+
function servedCacheControl(stored, isPrivate) {
|
|
2139
|
+
if (!stored) return isPrivate ? GENERATED_PRIVATE_CACHE_CONTROL : GENERATED_PUBLIC_CACHE_CONTROL;
|
|
2140
|
+
return isPrivate ? toPrivateCacheControl(stored) : stored;
|
|
2141
|
+
}
|
|
2142
|
+
media.delete("/videos/:videoId", async (context) => {
|
|
2143
|
+
const videoId = context.req.param("videoId");
|
|
2144
|
+
const visibilityParam = context.req.query("visibility");
|
|
2145
|
+
const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
|
|
2146
|
+
let deletedCount = 0;
|
|
2147
|
+
let deletedKeyCount = 0;
|
|
2148
|
+
for (const visibility of visibilities) {
|
|
2149
|
+
const prefix = storagePrefix(context, `${visibility}/${videoBasePath(videoId)}/`);
|
|
2150
|
+
deletedCount += await deletePrefix(context.env.MEDIA_BUCKET, prefix);
|
|
2151
|
+
if (visibility === "private" && context.env.KEYS_BUCKET) {
|
|
2152
|
+
deletedKeyCount += await deletePrefix(context.env.KEYS_BUCKET, prefix);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
return context.json({ videoId, deletedCount, deletedKeyCount });
|
|
2156
|
+
});
|
|
2157
|
+
media.delete("/:key{.+}", async (context) => {
|
|
2158
|
+
const key = context.req.param("key");
|
|
2159
|
+
const fullKey = storageKey(context, key);
|
|
2160
|
+
if (isVariantKey(key)) {
|
|
2161
|
+
return context.json({ error: "Not found" }, 404);
|
|
2162
|
+
}
|
|
2163
|
+
const exists = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
2164
|
+
if (!exists) {
|
|
2165
|
+
return context.json({ error: "Not found" }, 404);
|
|
2166
|
+
}
|
|
2167
|
+
await context.env.MEDIA_BUCKET.delete(fullKey);
|
|
2168
|
+
await deletePrefix(context.env.MEDIA_BUCKET, variantPrefix(fullKey));
|
|
2169
|
+
if (context.env.KEYS_BUCKET) await deletePrefix(context.env.KEYS_BUCKET, `${fullKey}/`);
|
|
2170
|
+
return new Response(null, { status: 204 });
|
|
2171
|
+
});
|
|
2172
|
+
function parseSecurityMeta(raw) {
|
|
2173
|
+
if (!raw) return void 0;
|
|
2174
|
+
try {
|
|
2175
|
+
return JSON.parse(raw);
|
|
2176
|
+
} catch {
|
|
2177
|
+
return void 0;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
async function bindingMismatch(context, payload, customMetadata) {
|
|
2181
|
+
const policy = resolveSecurity2(
|
|
2182
|
+
parseSecurityMeta(customMetadata?.security),
|
|
2183
|
+
payload.bind
|
|
2184
|
+
);
|
|
2185
|
+
if (!policy.sessionBinding) return false;
|
|
2186
|
+
const userId = payload.sub;
|
|
2187
|
+
const nonce = payload.nonce;
|
|
2188
|
+
if (!userId || !nonce) return false;
|
|
2189
|
+
const sessionSecret = context.req.header(SESSION_HEADER) ?? "";
|
|
2190
|
+
if (policy.ipBinding) {
|
|
2191
|
+
const ip = context.req.header("CF-Connecting-IP") ?? context.req.header("X-Forwarded-For") ?? "unknown";
|
|
2192
|
+
return await computeFingerprint(userId, sessionSecret, nonce, ip) !== payload.fpip;
|
|
2193
|
+
}
|
|
2194
|
+
return await computeFingerprint(userId, sessionSecret, nonce) !== payload.fp;
|
|
2195
|
+
}
|
|
2196
|
+
async function serveEncryptionKey(context, key, tokenPayload) {
|
|
2197
|
+
if (!tokenPayload) {
|
|
2198
|
+
return context.json({ error: "Unauthorized" }, 401);
|
|
2199
|
+
}
|
|
2200
|
+
const keyObjectPath = storageKey(context, key.replace(/\.key$/, ""));
|
|
2201
|
+
const stored = context.env.KEYS_BUCKET ? await context.env.KEYS_BUCKET.get(keyObjectPath) : null;
|
|
2202
|
+
if (!stored) {
|
|
2203
|
+
return context.json({ error: "Not found" }, 404);
|
|
2204
|
+
}
|
|
2205
|
+
if (await bindingMismatch(context, tokenPayload, stored.customMetadata)) {
|
|
2206
|
+
return context.json({ error: "Session mismatch", code: "session_mismatch" }, 401);
|
|
2207
|
+
}
|
|
2208
|
+
return new Response(stored.body, {
|
|
2209
|
+
headers: {
|
|
2210
|
+
"Content-Type": "application/octet-stream",
|
|
2211
|
+
"Content-Length": String(stored.size),
|
|
2212
|
+
// The key must never be cached by a CDN/proxy: it's the secret that protects the stream.
|
|
2213
|
+
"Cache-Control": "private, no-store"
|
|
2214
|
+
}
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// src/routes/token.ts
|
|
2219
|
+
import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope3 } from "@devultur/core";
|
|
2220
|
+
import { Hono as Hono7 } from "hono";
|
|
2221
|
+
var DEFAULT_VIEWER_TOKEN_TTL_SECONDS = 7200;
|
|
2222
|
+
var token = new Hono7();
|
|
2223
|
+
token.post("/", async (context) => {
|
|
2224
|
+
const body = await context.req.json();
|
|
2225
|
+
if (!body.userId) {
|
|
2226
|
+
return context.json({ error: "userId is required" }, 400);
|
|
2227
|
+
}
|
|
2228
|
+
if (typeof body.userId === "string" && body.userId.startsWith(SERVICE_SUBJECT_PREFIX)) {
|
|
2229
|
+
return context.json(
|
|
2230
|
+
{ error: `userId must not start with "${SERVICE_SUBJECT_PREFIX}"`, code: "reserved_user_id" },
|
|
2231
|
+
400
|
|
2232
|
+
);
|
|
2233
|
+
}
|
|
2234
|
+
const clientIp = context.req.header("CF-Connecting-IP") ?? context.req.header("X-Forwarded-For") ?? "unknown";
|
|
2235
|
+
const requestedScopes = body.scopes ?? [TokenScope3.MEDIA_READ];
|
|
2236
|
+
if (!Array.isArray(requestedScopes) || hasInvalidScope(requestedScopes)) {
|
|
2237
|
+
return context.json({ error: "scopes must be non-empty strings without spaces", code: "invalid_scopes" }, 400);
|
|
2238
|
+
}
|
|
2239
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2240
|
+
const requestedExp = now + (body.expiresIn ?? DEFAULT_VIEWER_TOKEN_TTL_SECONDS);
|
|
2241
|
+
const decision = await decideCredential(context, {
|
|
2242
|
+
kind: "viewer",
|
|
2243
|
+
scopes: requestedScopes,
|
|
2244
|
+
expiresAt: requestedExp * 1e3
|
|
2245
|
+
});
|
|
2246
|
+
if (!decision.allow) return decision.response;
|
|
2247
|
+
const exp = cappedExpiry(requestedExp, decision.expiresAt, (epochMs) => Math.floor(epochMs / 1e3));
|
|
2248
|
+
const expiresIn = exp - now;
|
|
2249
|
+
const sessionSecret = body.sessionSecret ?? "";
|
|
2250
|
+
const nonce = crypto.randomUUID();
|
|
2251
|
+
const fp = await computeFingerprint2(body.userId, sessionSecret, nonce);
|
|
2252
|
+
const fpip = await computeFingerprint2(body.userId, sessionSecret, nonce, clientIp);
|
|
2253
|
+
const scope = getRequestState(context);
|
|
2254
|
+
const security = scope.config?.security ?? DEFAULT_SECURITY;
|
|
2255
|
+
const payload = withPluginClaims(
|
|
2256
|
+
{
|
|
2257
|
+
sub: body.userId,
|
|
2258
|
+
ip: clientIp,
|
|
2259
|
+
nonce,
|
|
2260
|
+
fp,
|
|
2261
|
+
fpip,
|
|
2262
|
+
// Current binding policy → the gate knows what it can require without re-reading config.
|
|
2263
|
+
bind: { sessionBinding: security.sessionBinding ?? true, ipBinding: security.ipBinding ?? false },
|
|
2264
|
+
...scopeClaim(requestedScopes),
|
|
2265
|
+
// The storage prefix it was issued under: the delivery gate rejects the token under any other.
|
|
2266
|
+
...audienceClaim(scope.storagePrefix),
|
|
2267
|
+
iat: now,
|
|
2268
|
+
exp
|
|
2269
|
+
},
|
|
2270
|
+
decision
|
|
2271
|
+
);
|
|
2272
|
+
const jwt = await signViewerToken(payload, context.env.DELIVERY_SIGN_SECRET);
|
|
2273
|
+
return context.json({ token: jwt, expiresIn, expiresAt: exp });
|
|
2274
|
+
});
|
|
2275
|
+
token.post("/upload", handleUploadToken);
|
|
2276
|
+
|
|
2277
|
+
// src/routes/transcode.ts
|
|
2278
|
+
import {
|
|
2279
|
+
extractId as extractId2,
|
|
2280
|
+
generateId as generateId2,
|
|
2281
|
+
resolveSecurity as resolveSecurity3,
|
|
2282
|
+
resolveTranscodePreset,
|
|
2283
|
+
TokenScope as TokenScope4,
|
|
2284
|
+
TranscodeStatus as TranscodeStatus2,
|
|
2285
|
+
videoBasePath as videoBasePath2,
|
|
2286
|
+
videoHlsPath,
|
|
2287
|
+
videoMp4Dir,
|
|
2288
|
+
videoPosterPath,
|
|
2289
|
+
videoStoryboardDir,
|
|
2290
|
+
videoThumbnailPath,
|
|
2291
|
+
visibilityFromKey as visibilityFromKey3
|
|
2292
|
+
} from "@devultur/core";
|
|
2293
|
+
import { Hono as Hono8 } from "hono";
|
|
2294
|
+
|
|
2295
|
+
// src/transcode/plan.ts
|
|
2296
|
+
import { MAIN_VARIANT } from "@devultur/core";
|
|
2297
|
+
|
|
2298
|
+
// src/transcode-ladder.ts
|
|
2299
|
+
var RENDITION_LADDER = [
|
|
2300
|
+
{ name: "360p", shortSide: 360, crf: 28, audioBitrate: "96k", maxrateKbps: 800, h264Level: "3.1" },
|
|
2301
|
+
{ name: "720p", shortSide: 720, crf: 23, audioBitrate: "128k", maxrateKbps: 3e3, h264Level: "3.2" },
|
|
2302
|
+
{ name: "1080p", shortSide: 1080, crf: 20, audioBitrate: "192k", maxrateKbps: 6e3, h264Level: "4.2" },
|
|
2303
|
+
{ name: "4k", shortSide: 2160, crf: 18, audioBitrate: "256k", maxrateKbps: 16e3, h264Level: "5.2" }
|
|
2304
|
+
];
|
|
2305
|
+
function videoCodecOf(rendition) {
|
|
2306
|
+
const levelHex = Math.round(Number(rendition.h264Level) * 10).toString(16).padStart(2, "0");
|
|
2307
|
+
return `avc1.6400${levelHex}`;
|
|
2308
|
+
}
|
|
2309
|
+
var AUDIO_CODEC = "mp4a.40.2";
|
|
2310
|
+
var h264Args = (rendition) => `-profile:v high -level:v ${rendition.h264Level}`;
|
|
2311
|
+
var SEGMENT_SECONDS = 2;
|
|
2312
|
+
var MAX_RESOLUTION_SHORT_SIDE = {
|
|
2313
|
+
[MaxResolution.P720]: 720,
|
|
2314
|
+
[MaxResolution.P1080]: 1080,
|
|
2315
|
+
[MaxResolution.P4K]: 2160
|
|
2316
|
+
};
|
|
2317
|
+
function renditionsFor(maxResolution) {
|
|
2318
|
+
const cap = MAX_RESOLUTION_SHORT_SIDE[maxResolution];
|
|
2319
|
+
return RENDITION_LADDER.filter((rendition) => rendition.shortSide <= cap);
|
|
2320
|
+
}
|
|
2321
|
+
function clampRungsToCap(names, maxResolution) {
|
|
2322
|
+
const allowed = renditionsFor(maxResolution);
|
|
2323
|
+
const withinCap = allowed.filter((rendition) => names.includes(rendition.name));
|
|
2324
|
+
if (withinCap.length > 0) return withinCap;
|
|
2325
|
+
const highestAllowed = allowed[allowed.length - 1];
|
|
2326
|
+
return highestAllowed ? [highestAllowed] : [];
|
|
2327
|
+
}
|
|
2328
|
+
function presetToMaxResolution(preset) {
|
|
2329
|
+
return preset === "hls-adaptive" ? MaxResolution.P1080 : MaxResolution.P720;
|
|
2330
|
+
}
|
|
2331
|
+
function scaleFilter(rendition) {
|
|
2332
|
+
const side = rendition.shortSide;
|
|
2333
|
+
return `scale=${side}:${side}:force_original_aspect_ratio=increase:force_divisible_by=2`;
|
|
2334
|
+
}
|
|
2335
|
+
var CROP_FILTERS = {
|
|
2336
|
+
"9:16-center": "crop=w=min(iw\\,ih*9/16):h=min(ih\\,iw*16/9)",
|
|
2337
|
+
"1:1-center": "crop=w=min(iw\\,ih):h=min(iw\\,ih)"
|
|
2338
|
+
};
|
|
2339
|
+
function cropFilter(crop) {
|
|
2340
|
+
return CROP_FILTERS[crop];
|
|
2341
|
+
}
|
|
2342
|
+
var SOURCE_SIZE_FILTER = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
|
|
2343
|
+
function videoFilter(rendition, crop, atSourceSize) {
|
|
2344
|
+
const scale = atSourceSize ? SOURCE_SIZE_FILTER : scaleFilter(rendition);
|
|
2345
|
+
return crop ? `${cropFilter(crop)},${scale}` : scale;
|
|
2346
|
+
}
|
|
2347
|
+
function crfRateArgs(rendition, perTitle) {
|
|
2348
|
+
if (!perTitle) {
|
|
2349
|
+
return `-crf ${rendition.crf} -maxrate ${rendition.maxrateKbps}k -bufsize ${rendition.maxrateKbps * 2}k`;
|
|
2350
|
+
}
|
|
2351
|
+
const tag = rendition.name.toUpperCase();
|
|
2352
|
+
return `-crf $PT_CRF_${tag} -maxrate \${PT_MAXRATE_${tag}}k -bufsize \${PT_BUFSIZE_${tag}}k`;
|
|
2353
|
+
}
|
|
2354
|
+
function audioArgs(rendition, stripAudio) {
|
|
2355
|
+
return stripAudio ? "-an" : `-c:a aac -b:a ${rendition.audioBitrate}`;
|
|
2356
|
+
}
|
|
2357
|
+
function streamMaps(stripAudio) {
|
|
2358
|
+
return stripAudio ? "-map 0:v" : "-map 0:v -map 0:a?";
|
|
2359
|
+
}
|
|
2360
|
+
function renditionArgs(rendition, hlsKeyOption, options = {}) {
|
|
2361
|
+
const segmentPattern = `/tmp/output/${rendition.name}_%03d.ts`;
|
|
2362
|
+
const filter = videoFilter(rendition, options.crop, options.atSourceSize);
|
|
2363
|
+
return [
|
|
2364
|
+
streamMaps(options.stripAudio),
|
|
2365
|
+
`-vf ${filter}`,
|
|
2366
|
+
`-c:v libx264 -preset fast ${h264Args(rendition)} ${crfRateArgs(rendition, options.perTitle)}`,
|
|
2367
|
+
// Always 8-bit 4:2:0: a 10-bit/4:2:2 source (iPhone HDR, cameras) would produce High10/High 4:2:2, which
|
|
2368
|
+
// almost no browser decodes.
|
|
2369
|
+
"-pix_fmt yuv420p",
|
|
2370
|
+
`-force_key_frames expr:gte(t,n_forced*${SEGMENT_SECONDS}) -sc_threshold 0`,
|
|
2371
|
+
audioArgs(rendition, options.stripAudio),
|
|
2372
|
+
`-f hls ${hlsKeyOption}-hls_time ${SEGMENT_SECONDS} -hls_playlist_type vod`,
|
|
2373
|
+
`-hls_segment_filename ${segmentPattern} /tmp/output/${rendition.name}.m3u8`,
|
|
2374
|
+
// A scaled frame outside /tmp/output (not uploaded): gives the master's real RESOLUTION even when the
|
|
2375
|
+
// segments are encrypted (ffprobe can't read an AES-128 .ts).
|
|
2376
|
+
`-map 0:v:0 -frames:v 1 -vf ${filter} -update 1 /tmp/probe/${rendition.name}.jpg`
|
|
2377
|
+
].join(" ");
|
|
2378
|
+
}
|
|
2379
|
+
function mp4RateArgs(rendition, options) {
|
|
2380
|
+
const { bitrateKbps } = options;
|
|
2381
|
+
if (!bitrateKbps) return crfRateArgs(rendition, options.perTitle);
|
|
2382
|
+
return `-b:v ${bitrateKbps}k -maxrate ${Math.round(bitrateKbps * 1.5)}k -bufsize ${bitrateKbps * 2}k`;
|
|
2383
|
+
}
|
|
2384
|
+
function mp4FileName(variant, rendition) {
|
|
2385
|
+
return `${variant}-${rendition.name}.mp4`;
|
|
2386
|
+
}
|
|
2387
|
+
function mp4Args(variant, rendition, options = {}) {
|
|
2388
|
+
return [
|
|
2389
|
+
options.stripAudio ? "-map 0:v:0" : "-map 0:v:0 -map 0:a:0?",
|
|
2390
|
+
`-vf ${videoFilter(rendition, options.crop, options.atSourceSize)}`,
|
|
2391
|
+
`-c:v libx264 -preset fast ${h264Args(rendition)} ${mp4RateArgs(rendition, options)}`,
|
|
2392
|
+
"-pix_fmt yuv420p",
|
|
2393
|
+
audioArgs(rendition, options.stripAudio),
|
|
2394
|
+
`-movflags +faststart /tmp/mp4/${mp4FileName(variant, rendition)}`
|
|
2395
|
+
].join(" ");
|
|
2396
|
+
}
|
|
2397
|
+
var SEGMENT_RATES_AWK = [
|
|
2398
|
+
'/^#EXTINF:/ { sub(/^#EXTINF:/, ""); sub(/,.*/, ""); duration = $0 + 0; next }',
|
|
2399
|
+
'/^[^#]/ && duration > 0 { cmd = "wc -c < " dir "/" $0; cmd | getline bytes; close(cmd);',
|
|
2400
|
+
"bits = bytes * 8; total += bits; elapsed += duration; rate = bits / duration;",
|
|
2401
|
+
"if (rate > peak) peak = rate; duration = 0 }",
|
|
2402
|
+
'END { if (elapsed <= 0) exit 1; printf "BANDWIDTH=%d,AVERAGE-BANDWIDTH=%d\\n", peak + 1, total / elapsed + 1 }'
|
|
2403
|
+
].join(" ");
|
|
2404
|
+
function videoCodecCase() {
|
|
2405
|
+
const branches = RENDITION_LADDER.map((rendition) => `${rendition.name}) VIDEO_CODEC=${videoCodecOf(rendition)} ;;`);
|
|
2406
|
+
return ` case "$NAME" in ${branches.join(" ")} esac`;
|
|
2407
|
+
}
|
|
2408
|
+
function masterPlaylistScript() {
|
|
2409
|
+
return [
|
|
2410
|
+
'MASTER="#EXTM3U\\n#EXT-X-INDEPENDENT-SEGMENTS\\n"',
|
|
2411
|
+
"for NAME in $RENDITIONS; do",
|
|
2412
|
+
videoCodecCase(),
|
|
2413
|
+
` CODECS="$VIDEO_CODEC"; if [ "$HAS_AUDIO" = true ]; then CODECS="$CODECS,${AUDIO_CODEC}"; fi`,
|
|
2414
|
+
` RATES=$(awk -v dir=/tmp/output '${SEGMENT_RATES_AWK}' "/tmp/output/$NAME.m3u8")`,
|
|
2415
|
+
' RESOLUTION=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "/tmp/probe/$NAME.jpg")',
|
|
2416
|
+
' echo "[transcode] $NAME $RESOLUTION $RATES"',
|
|
2417
|
+
' MASTER="$MASTER#EXT-X-STREAM-INF:$RATES,RESOLUTION=$RESOLUTION,CODECS=\\"$CODECS\\"\\n$NAME.m3u8\\n"',
|
|
2418
|
+
"done",
|
|
2419
|
+
'printf "%b" "$MASTER" > /tmp/output/playlist.m3u8'
|
|
2420
|
+
].join("\n");
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// src/transcode/plan.ts
|
|
2424
|
+
var DEFAULT_POSTER_FORMATS = ["jpg", "avif"];
|
|
2425
|
+
function planMp4(output, maxResolution) {
|
|
2426
|
+
if (!output) return [];
|
|
2427
|
+
return clampRungsToCap(output.rungs, maxResolution).map((rendition) => {
|
|
2428
|
+
const bitrateKbps = output.bitrate?.[rendition.name];
|
|
2429
|
+
return bitrateKbps ? { rendition, bitrateKbps } : { rendition };
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
function planHls(output, maxResolution) {
|
|
2433
|
+
if (!output) return [];
|
|
2434
|
+
if (!output.rungs) return renditionsFor(maxResolution);
|
|
2435
|
+
return clampRungsToCap(output.rungs, maxResolution);
|
|
2436
|
+
}
|
|
2437
|
+
function planMainVariant(spec, maxResolution) {
|
|
2438
|
+
if (!spec.hls && !spec.mp4) return null;
|
|
2439
|
+
return {
|
|
2440
|
+
name: MAIN_VARIANT,
|
|
2441
|
+
...spec.crop ? { crop: spec.crop } : {},
|
|
2442
|
+
hls: planHls(spec.hls, maxResolution),
|
|
2443
|
+
mp4: planMp4(spec.mp4, maxResolution)
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
function planTranscode(spec, maxResolution, options) {
|
|
2447
|
+
const main = planMainVariant(spec, maxResolution);
|
|
2448
|
+
const extraVariants = (spec.variants ?? []).map((variant) => ({
|
|
2449
|
+
name: variant.name,
|
|
2450
|
+
...variant.crop ? { crop: variant.crop } : {},
|
|
2451
|
+
hls: [],
|
|
2452
|
+
mp4: planMp4(variant.mp4, maxResolution)
|
|
2453
|
+
}));
|
|
2454
|
+
return {
|
|
2455
|
+
variants: main ? [main, ...extraVariants] : extraVariants,
|
|
2456
|
+
stripAudio: spec.audio === "strip",
|
|
2457
|
+
posterAt: spec.poster?.at ?? "auto",
|
|
2458
|
+
posterFormats: spec.poster?.formats ?? DEFAULT_POSTER_FORMATS,
|
|
2459
|
+
reportsOutputs: options.reportsOutputs,
|
|
2460
|
+
perTitle: spec.perTitle ?? true,
|
|
2461
|
+
storyboard: spec.storyboard ?? true
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
function legacyPlan(maxResolution) {
|
|
2465
|
+
return planTranscode({ hls: {} }, maxResolution, { reportsOutputs: false });
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// src/transcode/retry.ts
|
|
2469
|
+
var DEFAULT_MAX_ATTEMPTS = 5;
|
|
2470
|
+
var DEFAULT_BASE_DELAY_MS = 500;
|
|
2471
|
+
var DEFAULT_MAX_DELAY_MS = 8e3;
|
|
2472
|
+
var MAX_RETRY_AFTER_MS = 3e4;
|
|
2473
|
+
var defaultSleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
2474
|
+
function isRetryableStatus(status) {
|
|
2475
|
+
return status === 429 || status >= 500;
|
|
2476
|
+
}
|
|
2477
|
+
function parseRetryAfterMs(header, now = Date.now()) {
|
|
2478
|
+
if (!header) return null;
|
|
2479
|
+
const trimmed = header.trim();
|
|
2480
|
+
if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1e3;
|
|
2481
|
+
const date = Date.parse(trimmed);
|
|
2482
|
+
if (Number.isNaN(date)) return null;
|
|
2483
|
+
return Math.max(0, date - now);
|
|
2484
|
+
}
|
|
2485
|
+
function backoffDelayMs(attempt, options = {}) {
|
|
2486
|
+
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
2487
|
+
const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
2488
|
+
const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
|
|
2489
|
+
return Math.floor((options.random ?? Math.random)() * ceiling);
|
|
2490
|
+
}
|
|
2491
|
+
function delayFor(attempt, response, options) {
|
|
2492
|
+
const retryAfterMs = parseRetryAfterMs(response?.headers.get("Retry-After") ?? null, (options.now ?? Date.now)());
|
|
2493
|
+
if (retryAfterMs !== null) return Math.min(retryAfterMs, MAX_RETRY_AFTER_MS);
|
|
2494
|
+
return backoffDelayMs(attempt, options);
|
|
2495
|
+
}
|
|
2496
|
+
async function fetchWithRetry(send, options = {}) {
|
|
2497
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
2498
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
2499
|
+
for (let attempt = 0; ; attempt++) {
|
|
2500
|
+
const isLastAttempt = attempt === maxAttempts - 1;
|
|
2501
|
+
let response;
|
|
2502
|
+
try {
|
|
2503
|
+
response = await send();
|
|
2504
|
+
} catch (error) {
|
|
2505
|
+
if (isLastAttempt) throw error;
|
|
2506
|
+
await sleep(delayFor(attempt, null, options));
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
if (!isRetryableStatus(response.status) || isLastAttempt) return response;
|
|
2510
|
+
await response.body?.cancel();
|
|
2511
|
+
await sleep(delayFor(attempt, response, options));
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// src/transcode/fly.ts
|
|
2516
|
+
var FLY_API_BASE = "https://api.machines.dev/v1";
|
|
2517
|
+
var DEFAULT_FLY_GUEST = { cpu_kind: "performance", cpus: 4, memory_mb: 8192 };
|
|
2518
|
+
function positiveInt(raw, fallback2) {
|
|
2519
|
+
const parsed = Number(raw);
|
|
2520
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback2;
|
|
2521
|
+
}
|
|
2522
|
+
function flyGuestFromEnv(env) {
|
|
2523
|
+
return {
|
|
2524
|
+
cpu_kind: env.FLY_TRANSCODE_CPU_KIND?.trim() || DEFAULT_FLY_GUEST.cpu_kind,
|
|
2525
|
+
cpus: positiveInt(env.FLY_TRANSCODE_CPUS, DEFAULT_FLY_GUEST.cpus),
|
|
2526
|
+
memory_mb: positiveInt(env.FLY_TRANSCODE_MEMORY_MB, DEFAULT_FLY_GUEST.memory_mb)
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
function flyMachinePayload(request) {
|
|
2530
|
+
return JSON.stringify({
|
|
2531
|
+
config: {
|
|
2532
|
+
image: request.image,
|
|
2533
|
+
guest: request.guest,
|
|
2534
|
+
auto_destroy: true,
|
|
2535
|
+
restart: { policy: "no" },
|
|
2536
|
+
env: request.env,
|
|
2537
|
+
init: { cmd: [`echo '${btoa(request.script)}' | base64 -d | sh`] }
|
|
2538
|
+
},
|
|
2539
|
+
...request.region ? { region: request.region } : {}
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
async function resumeFlyApp(appName, apiToken) {
|
|
2543
|
+
const headers = { Authorization: `Bearer ${apiToken}` };
|
|
2544
|
+
const response = await fetch(`${FLY_API_BASE}/apps/${appName}`, { method: "GET", headers });
|
|
2545
|
+
if (!response.ok) return false;
|
|
2546
|
+
const app = await response.json();
|
|
2547
|
+
if (app.status !== "suspended") return true;
|
|
2548
|
+
const resumeResponse = await fetch(`${FLY_API_BASE}/apps/${appName}`, {
|
|
2549
|
+
method: "PUT",
|
|
2550
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
2551
|
+
body: JSON.stringify({ status: "deployed" })
|
|
2552
|
+
});
|
|
2553
|
+
if (!resumeResponse.ok) return false;
|
|
2554
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
2555
|
+
return true;
|
|
2556
|
+
}
|
|
2557
|
+
function postMachine(request, retry) {
|
|
2558
|
+
const body = flyMachinePayload(request);
|
|
2559
|
+
return fetchWithRetry(
|
|
2560
|
+
() => fetch(`${FLY_API_BASE}/apps/${request.appName}/machines`, {
|
|
2561
|
+
method: "POST",
|
|
2562
|
+
headers: { Authorization: `Bearer ${request.apiToken}`, "Content-Type": "application/json" },
|
|
2563
|
+
body
|
|
2564
|
+
}),
|
|
2565
|
+
retry
|
|
2566
|
+
);
|
|
2567
|
+
}
|
|
2568
|
+
async function failure2(response, error) {
|
|
2569
|
+
const detail = await response.text();
|
|
2570
|
+
const status = response.status === 429 || response.status >= 500 ? 503 : 500;
|
|
2571
|
+
console.error(`[transcode] fly: ${error} (status ${response.status}):`, detail);
|
|
2572
|
+
return { ok: false, status, error, code: launchErrorCode(status), detail };
|
|
2573
|
+
}
|
|
2574
|
+
async function createFlyMachine(request, retry) {
|
|
2575
|
+
const response = await postMachine(request, retry);
|
|
2576
|
+
if (response.ok) return { ok: true, machine: await response.json() };
|
|
2577
|
+
const errorBody = await response.text();
|
|
2578
|
+
const isSuspended = response.status === 412 || errorBody.includes("suspended");
|
|
2579
|
+
if (!isSuspended) {
|
|
2580
|
+
return failure2(new Response(errorBody, { status: response.status }), "Failed to create transcode job");
|
|
2581
|
+
}
|
|
2582
|
+
if (!await resumeFlyApp(request.appName, request.apiToken)) {
|
|
2583
|
+
console.error("[transcode] fly: failed to resume suspended app:", errorBody);
|
|
2584
|
+
return {
|
|
2585
|
+
ok: false,
|
|
2586
|
+
status: 503,
|
|
2587
|
+
error: "Failed to resume transcode app",
|
|
2588
|
+
code: TranscodeLaunchErrorCode.UNAVAILABLE,
|
|
2589
|
+
detail: errorBody
|
|
2590
|
+
};
|
|
2591
|
+
}
|
|
2592
|
+
const retried = await postMachine(request, retry);
|
|
2593
|
+
if (retried.ok) return { ok: true, machine: await retried.json() };
|
|
2594
|
+
return failure2(retried, "Failed to create transcode job after resume");
|
|
2595
|
+
}
|
|
2596
|
+
function createFlyRunner(env, retry) {
|
|
2597
|
+
const guest = flyGuestFromEnv(env);
|
|
2598
|
+
const appName = env.FLY_APP_NAME ?? "";
|
|
2599
|
+
const apiToken = env.FLY_API_TOKEN ?? "";
|
|
2600
|
+
const region = env.FLY_REGION?.trim() || void 0;
|
|
2601
|
+
async function launch(job) {
|
|
2602
|
+
if (!appName || !apiToken || !env.FLY_TRANSCODE_IMAGE) {
|
|
2603
|
+
const detail = "FLY_APP_NAME, FLY_API_TOKEN and FLY_TRANSCODE_IMAGE are required for TRANSCODE_RUNNER=fly";
|
|
2604
|
+
return notConfigured(detail);
|
|
2605
|
+
}
|
|
2606
|
+
const result = await createFlyMachine(
|
|
2607
|
+
{ appName, apiToken, image: env.FLY_TRANSCODE_IMAGE, guest, env: job.env, script: job.script, region },
|
|
2608
|
+
retry
|
|
2609
|
+
);
|
|
2610
|
+
if (!result.ok) return result;
|
|
2611
|
+
return { ok: true, jobRef: result.machine.id };
|
|
2612
|
+
}
|
|
2613
|
+
async function cancel(jobRef) {
|
|
2614
|
+
await fetch(`${FLY_API_BASE}/apps/${appName}/machines/${jobRef}?force=true`, {
|
|
2615
|
+
method: "DELETE",
|
|
2616
|
+
headers: { Authorization: `Bearer ${apiToken}` }
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
return { name: TranscodeRunnerName.FLY, cpus: guest.cpus, launch, cancel };
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// src/transcode/http.ts
|
|
2623
|
+
var HTTP_RUNNER_PROTOCOL_VERSION = 1;
|
|
2624
|
+
var JOB_REF_PREFIX = "http-";
|
|
2625
|
+
function cpusFrom(raw) {
|
|
2626
|
+
const parsed = Number(raw);
|
|
2627
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
2628
|
+
}
|
|
2629
|
+
async function jobRefFrom(response, videoId) {
|
|
2630
|
+
const body = await response.json().catch(() => null);
|
|
2631
|
+
return typeof body?.jobId === "string" && body.jobId ? body.jobId : `${JOB_REF_PREFIX}${videoId}`;
|
|
2632
|
+
}
|
|
2633
|
+
function createHttpRunner(env, retry) {
|
|
2634
|
+
const url = env.TRANSCODE_RUNNER_URL?.trim().replace(/\/$/, "") ?? "";
|
|
2635
|
+
const secret = env.TRANSCODE_RUNNER_SECRET ?? "";
|
|
2636
|
+
const headers = { Authorization: `Bearer ${secret}`, "Content-Type": "application/json" };
|
|
2637
|
+
async function launch(job) {
|
|
2638
|
+
if (!url || !secret) {
|
|
2639
|
+
const detail2 = "TRANSCODE_RUNNER_URL and TRANSCODE_RUNNER_SECRET are required for TRANSCODE_RUNNER=http";
|
|
2640
|
+
return notConfigured(detail2);
|
|
2641
|
+
}
|
|
2642
|
+
const request = {
|
|
2643
|
+
version: HTTP_RUNNER_PROTOCOL_VERSION,
|
|
2644
|
+
videoId: job.videoId,
|
|
2645
|
+
command: ["sh", "-c", RUN_SCRIPT_COMMAND],
|
|
2646
|
+
env: envWithScript(job)
|
|
2647
|
+
};
|
|
2648
|
+
const body = JSON.stringify(request);
|
|
2649
|
+
let response;
|
|
2650
|
+
try {
|
|
2651
|
+
response = await fetchWithRetry(() => fetch(url, { method: "POST", headers, body }), retry);
|
|
2652
|
+
} catch (error) {
|
|
2653
|
+
console.error("[transcode] http runner unreachable:", error);
|
|
2654
|
+
return {
|
|
2655
|
+
ok: false,
|
|
2656
|
+
status: 503,
|
|
2657
|
+
error: "Transcode runner unreachable",
|
|
2658
|
+
code: TranscodeLaunchErrorCode.UNAVAILABLE,
|
|
2659
|
+
detail: String(error)
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
if (response.ok) return { ok: true, jobRef: await jobRefFrom(response, job.videoId) };
|
|
2663
|
+
const detail = await response.text();
|
|
2664
|
+
console.error(`[transcode] http runner rejected the job (status ${response.status}):`, detail);
|
|
2665
|
+
const status = isRetryableStatus(response.status) ? 503 : 500;
|
|
2666
|
+
return { ok: false, status, error: "Transcode runner rejected the job", code: launchErrorCode(status), detail };
|
|
2667
|
+
}
|
|
2668
|
+
async function cancel(jobRef) {
|
|
2669
|
+
await fetch(`${url}/${encodeURIComponent(jobRef)}`, { method: "DELETE", headers });
|
|
2670
|
+
}
|
|
2671
|
+
return { name: "http", cpus: cpusFrom(env.TRANSCODE_RUNNER_CPUS), launch, cancel };
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// src/transcode/runners.ts
|
|
2675
|
+
function createRunner(env) {
|
|
2676
|
+
switch (parseRunnerName(env.TRANSCODE_RUNNER)) {
|
|
2677
|
+
case TranscodeRunnerName.FLY:
|
|
2678
|
+
return createFlyRunner(env);
|
|
2679
|
+
case TranscodeRunnerName.CLOUDFLARE:
|
|
2680
|
+
return createCloudflareContainerRunner(env);
|
|
2681
|
+
case TranscodeRunnerName.HTTP:
|
|
2682
|
+
return createHttpRunner(env);
|
|
2683
|
+
default:
|
|
2684
|
+
return null;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// src/transcode/per-title.ts
|
|
2689
|
+
var PER_TITLE = {
|
|
2690
|
+
/** Shorter clips aren't probed: the savings are minimal and the probe would cost more than the encode. */
|
|
2691
|
+
minDurationSeconds: 10,
|
|
2692
|
+
sampleSeconds: 2,
|
|
2693
|
+
maxSamples: 4,
|
|
2694
|
+
/** One sample every this many seconds of video (up to `maxSamples`). */
|
|
2695
|
+
secondsPerSample: 15,
|
|
2696
|
+
probeShortSide: 360,
|
|
2697
|
+
probeCrf: 23,
|
|
2698
|
+
probePreset: "veryfast",
|
|
2699
|
+
/** Probe kbps (360p, CRF 23) at the simple end (score 0) and the complex end (score 1); logarithmic scale. */
|
|
2700
|
+
simpleKbps: 100,
|
|
2701
|
+
complexKbps: 800,
|
|
2702
|
+
minMaxrateFactor: 0.5,
|
|
2703
|
+
maxCrfBoost: 2
|
|
2704
|
+
};
|
|
2705
|
+
var rateTag = (rendition) => rendition.name.toUpperCase();
|
|
2706
|
+
function crfRungs(plan) {
|
|
2707
|
+
const names = /* @__PURE__ */ new Set();
|
|
2708
|
+
for (const variant of plan.variants) {
|
|
2709
|
+
for (const rendition of variant.hls) names.add(rendition.name);
|
|
2710
|
+
for (const output of variant.mp4) {
|
|
2711
|
+
if (!output.bitrateKbps) names.add(output.rendition.name);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
return RENDITION_LADDER.filter((rendition) => names.has(rendition.name));
|
|
2715
|
+
}
|
|
2716
|
+
var PROBE_FILE = "/tmp/probe/per-title.h264";
|
|
2717
|
+
function probeEncode() {
|
|
2718
|
+
const side = PER_TITLE.probeShortSide;
|
|
2719
|
+
return [
|
|
2720
|
+
`ffmpeg -nostdin -v error -y -ss "$PROBE_AT" -t ${PER_TITLE.sampleSeconds} -i /tmp/input -map 0:v:0 -an`,
|
|
2721
|
+
`-vf scale=${side}:${side}:force_original_aspect_ratio=increase:force_divisible_by=2`,
|
|
2722
|
+
`-c:v libx264 -preset ${PER_TITLE.probePreset} -crf ${PER_TITLE.probeCrf} -pix_fmt yuv420p -f h264 ${PROBE_FILE}`
|
|
2723
|
+
].join(" ");
|
|
2724
|
+
}
|
|
2725
|
+
function probeScript() {
|
|
2726
|
+
const { minDurationSeconds, secondsPerSample, maxSamples, sampleSeconds } = PER_TITLE;
|
|
2727
|
+
const span = Math.log(PER_TITLE.complexKbps / PER_TITLE.simpleKbps);
|
|
2728
|
+
return [
|
|
2729
|
+
`PROBE_SAMPLES=$(awk -v d="$DURATION" 'BEGIN { if (!(d >= ${minDurationSeconds})) { print 0; exit } n = int(d / ${secondsPerSample}); if (n < 1) n = 1; if (n > ${maxSamples}) n = ${maxSamples}; print n }')`,
|
|
2730
|
+
"PROBE_BYTES=0",
|
|
2731
|
+
"PROBE_DONE=0",
|
|
2732
|
+
'while [ "$PROBE_DONE" -lt "$PROBE_SAMPLES" ]; do',
|
|
2733
|
+
// Center of each equal-length segment, minus half a sample: always within the video.
|
|
2734
|
+
` PROBE_AT=$(awk -v d="$DURATION" -v n="$PROBE_SAMPLES" -v i="$PROBE_DONE" 'BEGIN { t = (i + 0.5) * d / n - ${sampleSeconds / 2}; if (t < 0) t = 0; printf "%.3f", t }')`,
|
|
2735
|
+
` if ! ${probeEncode()}; then PROBE_BYTES=0; break; fi`,
|
|
2736
|
+
` PROBE_BYTES=$((PROBE_BYTES + $(wc -c < ${PROBE_FILE})))`,
|
|
2737
|
+
" PROBE_DONE=$((PROBE_DONE + 1))",
|
|
2738
|
+
"done",
|
|
2739
|
+
'if [ "$PROBE_BYTES" -gt 0 ]; then',
|
|
2740
|
+
` PROBE_KBPS=$(awk -v b="$PROBE_BYTES" -v n="$PROBE_SAMPLES" 'BEGIN { printf "%.1f", b * 8 / 1000 / (n * ${sampleSeconds}) }')`,
|
|
2741
|
+
` PT_SCORE=$(awk -v k="$PROBE_KBPS" 'BEGIN { if (k < 1) k = 1; s = log(k / ${PER_TITLE.simpleKbps}) / ${span}; if (s < 0) s = 0; if (s > 1) s = 1; printf "%.2f", s }')`,
|
|
2742
|
+
"fi"
|
|
2743
|
+
];
|
|
2744
|
+
}
|
|
2745
|
+
function rateVarsScript() {
|
|
2746
|
+
const { minMaxrateFactor, maxCrfBoost } = PER_TITLE;
|
|
2747
|
+
return [
|
|
2748
|
+
"PT_BOOST=0",
|
|
2749
|
+
`if [ "$PT_SCORE" != null ]; then PT_BOOST=$(awk -v s="$PT_SCORE" 'BEGIN { printf "%d", int(${maxCrfBoost} * (1 - s) + 0.5) }'); fi`,
|
|
2750
|
+
...RENDITION_LADDER.flatMap((rendition) => {
|
|
2751
|
+
const tag = rateTag(rendition);
|
|
2752
|
+
return [
|
|
2753
|
+
`PT_CRF_${tag}=$((${rendition.crf} + PT_BOOST))`,
|
|
2754
|
+
`PT_MAXRATE_${tag}=${rendition.maxrateKbps}`,
|
|
2755
|
+
`if [ "$PT_SCORE" != null ]; then PT_MAXRATE_${tag}=$(awk -v s="$PT_SCORE" 'BEGIN { printf "%d", int(${rendition.maxrateKbps} * (${minMaxrateFactor} + ${1 - minMaxrateFactor} * s) + 0.5) }'); fi`,
|
|
2756
|
+
`PT_BUFSIZE_${tag}=$((PT_MAXRATE_${tag} * 2))`
|
|
2757
|
+
];
|
|
2758
|
+
})
|
|
2759
|
+
];
|
|
2760
|
+
}
|
|
2761
|
+
function perTitleScript(plan) {
|
|
2762
|
+
return [
|
|
2763
|
+
"PT_SCORE=null",
|
|
2764
|
+
"PROBE_START=$(date +%s)",
|
|
2765
|
+
...plan.perTitle ? probeScript() : [],
|
|
2766
|
+
"PROBE_SECONDS=$(( $(date +%s) - PROBE_START ))",
|
|
2767
|
+
...rateVarsScript(),
|
|
2768
|
+
'echo "[transcode] per-title score $PT_SCORE (probe $PROBE_SECONDS s)"'
|
|
2769
|
+
].join("\n");
|
|
2770
|
+
}
|
|
2771
|
+
function encodeParamsJson(plan) {
|
|
2772
|
+
const entries = crfRungs(plan).map((rendition) => {
|
|
2773
|
+
const tag = rateTag(rendition);
|
|
2774
|
+
return `{\\"rung\\":\\"${rendition.name}\\",\\"crf\\":$PT_CRF_${tag},\\"maxrateKbps\\":$PT_MAXRATE_${tag}}`;
|
|
2775
|
+
});
|
|
2776
|
+
return `[${entries.join(",")}]`;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
// src/transcode/storyboard-script.ts
|
|
2780
|
+
var STORYBOARD = {
|
|
2781
|
+
thumbWidth: 160,
|
|
2782
|
+
columns: 10,
|
|
2783
|
+
rows: 10,
|
|
2784
|
+
minIntervalSeconds: 2,
|
|
2785
|
+
maxThumbnails: 300,
|
|
2786
|
+
jpegQuality: 5,
|
|
2787
|
+
vttFile: "storyboard.vtt"
|
|
2788
|
+
};
|
|
2789
|
+
var PER_SHEET = STORYBOARD.columns * STORYBOARD.rows;
|
|
2790
|
+
var DIR = "/tmp/storyboard";
|
|
2791
|
+
function storyboardFilter(plan) {
|
|
2792
|
+
const sample = `fps=1/$SB_INTERVAL,scale=${STORYBOARD.thumbWidth}:-2,tile=${STORYBOARD.columns}x${STORYBOARD.rows}`;
|
|
2793
|
+
const crop = plan.variants[0]?.crop;
|
|
2794
|
+
return crop ? `${cropFilter(crop)},${sample}` : sample;
|
|
2795
|
+
}
|
|
2796
|
+
function storyboardOutputScript(plan) {
|
|
2797
|
+
if (!plan.storyboard) return [];
|
|
2798
|
+
const { maxThumbnails, minIntervalSeconds } = STORYBOARD;
|
|
2799
|
+
const args = `-map 0:v:0 -vf ${storyboardFilter(plan)} -q:v ${STORYBOARD.jpegQuality} -start_number 0 ${DIR}/sprite-%d.jpg`;
|
|
2800
|
+
return [
|
|
2801
|
+
`SB_INTERVAL=$(awk -v d="$DURATION" 'BEGIN { d += 0; i = int(d / ${maxThumbnails}); if (i * ${maxThumbnails} < d) i++; if (i < ${minIntervalSeconds}) i = ${minIntervalSeconds}; print i }')`,
|
|
2802
|
+
`FF_OUTPUTS="$FF_OUTPUTS ${args}"`
|
|
2803
|
+
];
|
|
2804
|
+
}
|
|
2805
|
+
var VTT_AWK = [
|
|
2806
|
+
'function ts(t, h, m) { h = int(t / 3600); m = int((t - h * 3600) / 60); return sprintf("%02d:%02d:%06.3f", h, m, t - h * 3600 - m * 60) }',
|
|
2807
|
+
"BEGIN {",
|
|
2808
|
+
' print "WEBVTT"',
|
|
2809
|
+
" n = int(d / i); if (n * i < d) n++; if (n > sheets * per) n = sheets * per",
|
|
2810
|
+
" for (k = 0; k < n; k++) {",
|
|
2811
|
+
" start = k * i; end = (k + 1) * i; if (end > d) end = d",
|
|
2812
|
+
" pos = k % per",
|
|
2813
|
+
' print ""; print ts(start) " --> " ts(end)',
|
|
2814
|
+
' printf "sprite-%d.jpg#xywh=%d,%d,%d,%d\\n", int(k / per), (pos % cols) * w, int(pos / cols) * h, w, h',
|
|
2815
|
+
" }",
|
|
2816
|
+
"}"
|
|
2817
|
+
].join("\n");
|
|
2818
|
+
function storyboardVttScript(plan, storyboardKey) {
|
|
2819
|
+
if (!plan.storyboard) return 'STORYBOARD_KEY=""';
|
|
2820
|
+
return [
|
|
2821
|
+
'STORYBOARD_KEY=""',
|
|
2822
|
+
`if [ -s ${DIR}/sprite-0.jpg ]; then`,
|
|
2823
|
+
` SB_DIMS=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 ${DIR}/sprite-0.jpg | head -1)`,
|
|
2824
|
+
` SB_TILE_W=$(( $(echo "$SB_DIMS" | cut -d, -f1) / ${STORYBOARD.columns} ))`,
|
|
2825
|
+
` SB_TILE_H=$(( $(echo "$SB_DIMS" | cut -d, -f2) / ${STORYBOARD.rows} ))`,
|
|
2826
|
+
` SB_SHEETS=$(ls ${DIR}/sprite-*.jpg | wc -l)`,
|
|
2827
|
+
` awk -v d="$DURATION" -v i="$SB_INTERVAL" -v w="$SB_TILE_W" -v h="$SB_TILE_H" -v sheets="$SB_SHEETS" -v per=${PER_SHEET} -v cols=${STORYBOARD.columns} '${VTT_AWK}' > ${DIR}/${STORYBOARD.vttFile}`,
|
|
2828
|
+
` STORYBOARD_KEY="${storyboardKey}"`,
|
|
2829
|
+
` echo "[transcode] storyboard: $SB_SHEETS sheet(s), every $SB_INTERVAL s, tile \${SB_TILE_W}x$SB_TILE_H"`,
|
|
2830
|
+
"fi"
|
|
2831
|
+
].join("\n");
|
|
2832
|
+
}
|
|
2833
|
+
function storyboardUploadScript(plan, storyboardPrefix, put) {
|
|
2834
|
+
if (!plan.storyboard) return "";
|
|
2835
|
+
return [
|
|
2836
|
+
'if [ -n "$STORYBOARD_KEY" ]; then',
|
|
2837
|
+
` for f in ${DIR}/*; do`,
|
|
2838
|
+
' FILENAME=$(basename "$f")',
|
|
2839
|
+
' CT="image/jpeg"',
|
|
2840
|
+
' case "$FILENAME" in *.vtt) CT="text/vtt" ;; esac',
|
|
2841
|
+
` ${put} "$WORKER_URL/v1/upload/${storyboardPrefix}$FILENAME" -H "Content-Type: $CT" --data-binary "@$f"`,
|
|
2842
|
+
" done",
|
|
2843
|
+
"fi"
|
|
2844
|
+
].join("\n");
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
// src/transcode/plan-script.ts
|
|
2848
|
+
var NO_KEYS = { playlistKey: "", mp4Prefix: "" };
|
|
2849
|
+
function shortSideScript(prefix, dimsCommand) {
|
|
2850
|
+
return [
|
|
2851
|
+
`${prefix}_DIMS=$(${dimsCommand} | head -1)`,
|
|
2852
|
+
`${prefix}_W=$(echo "$${prefix}_DIMS" | cut -s -d, -f1)`,
|
|
2853
|
+
`${prefix}_H=$(echo "$${prefix}_DIMS" | cut -s -d, -f2)`,
|
|
2854
|
+
`case "$${prefix}_W" in ""|*[!0-9]*) ${prefix}_W=0 ;; esac`,
|
|
2855
|
+
`case "$${prefix}_H" in ""|*[!0-9]*) ${prefix}_H=0 ;; esac`,
|
|
2856
|
+
`${prefix}_SHORT=$${prefix}_W`,
|
|
2857
|
+
`if [ "$${prefix}_H" -lt "$${prefix}_SHORT" ]; then ${prefix}_SHORT=$${prefix}_H; fi`
|
|
2858
|
+
];
|
|
2859
|
+
}
|
|
2860
|
+
var FRAME_DIMS = (file) => `ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 ${file}`;
|
|
2861
|
+
function variantShortSide(variant) {
|
|
2862
|
+
if (!variant.crop) return ["V_SHORT=$SRC_SHORT"];
|
|
2863
|
+
const probe = `/tmp/probe/crop-${variant.name}.jpg`;
|
|
2864
|
+
return [
|
|
2865
|
+
`ffmpeg -nostdin -v error -y -i /tmp/input -frames:v 1 -vf "${cropFilter(variant.crop)}" -update 1 ${probe}`,
|
|
2866
|
+
...shortSideScript("CROP", FRAME_DIMS(probe)),
|
|
2867
|
+
"V_SHORT=$CROP_SHORT"
|
|
2868
|
+
];
|
|
2869
|
+
}
|
|
2870
|
+
function markRung(rendition, atSourceSize) {
|
|
2871
|
+
const side = atSourceSize ? "$V_EVEN" : String(rendition.shortSide);
|
|
2872
|
+
return [
|
|
2873
|
+
` if [ ${side} -gt "$TOP_SHORT" ]; then TOP_SHORT=${side}; TOP_RES="${rendition.name}"; fi`,
|
|
2874
|
+
` if [ ${side} -gt "$V_TOP" ]; then V_TOP=${side}; fi`
|
|
2875
|
+
];
|
|
2876
|
+
}
|
|
2877
|
+
var RECORD_RENDITION_FUNCTION = 'record_rendition() { echo "$1 $2" >> /tmp/outputs.list; }';
|
|
2878
|
+
function recordRendition(probeFile, variant, kind, rung, key) {
|
|
2879
|
+
const json = `{\\"variant\\":\\"${variant}\\",\\"kind\\":\\"${kind}\\",\\"rung\\":\\"${rung}\\",\\"key\\":\\"${key}\\"`;
|
|
2880
|
+
return `record_rendition "${probeFile}" "${json}"`;
|
|
2881
|
+
}
|
|
2882
|
+
function rungBlocks(rungs, doneFlag, block) {
|
|
2883
|
+
const lowest = rungs[0];
|
|
2884
|
+
if (!lowest) return [];
|
|
2885
|
+
return [
|
|
2886
|
+
`${doneFlag}=""`,
|
|
2887
|
+
...rungs.flatMap((rendition) => [
|
|
2888
|
+
`if [ "$V_SHORT" -ge ${rendition.shortSide} ]; then`,
|
|
2889
|
+
...block(rendition, false),
|
|
2890
|
+
` ${doneFlag}=1`,
|
|
2891
|
+
"fi"
|
|
2892
|
+
]),
|
|
2893
|
+
`if [ -z "$${doneFlag}" ]; then`,
|
|
2894
|
+
...block(lowest, true),
|
|
2895
|
+
"fi"
|
|
2896
|
+
];
|
|
2897
|
+
}
|
|
2898
|
+
function hlsScript(variant, plan, hlsKeyOption, keys) {
|
|
2899
|
+
if (variant.hls.length === 0) return [];
|
|
2900
|
+
return [
|
|
2901
|
+
'HLS_TOP=""',
|
|
2902
|
+
...rungBlocks(variant.hls, "HLS_DONE", (rendition, atSourceSize) => [
|
|
2903
|
+
` FF_OUTPUTS="$FF_OUTPUTS ${renditionArgs(rendition, hlsKeyOption, { crop: variant.crop, stripAudio: plan.stripAudio, atSourceSize, perTitle: plan.perTitle })}"`,
|
|
2904
|
+
` RENDITIONS="$RENDITIONS ${rendition.name}"`,
|
|
2905
|
+
` HLS_TOP="${rendition.name}"`,
|
|
2906
|
+
...markRung(rendition, atSourceSize)
|
|
2907
|
+
]),
|
|
2908
|
+
// The ladder is ONE output, measured by its highest rung (its probe frame has the real size).
|
|
2909
|
+
recordRendition("/tmp/probe/$HLS_TOP.jpg", variant.name, "hls", "$HLS_TOP", keys.playlistKey)
|
|
2910
|
+
];
|
|
2911
|
+
}
|
|
2912
|
+
function mp4Script(variant, plan, keys) {
|
|
2913
|
+
const bitrates = new Map(variant.mp4.map((output) => [output.rendition.name, output.bitrateKbps]));
|
|
2914
|
+
const rungs = variant.mp4.map((output) => output.rendition);
|
|
2915
|
+
return rungBlocks(rungs, "MP4_DONE", (rendition, atSourceSize) => {
|
|
2916
|
+
const options = {
|
|
2917
|
+
crop: variant.crop,
|
|
2918
|
+
stripAudio: plan.stripAudio,
|
|
2919
|
+
atSourceSize,
|
|
2920
|
+
perTitle: plan.perTitle,
|
|
2921
|
+
// At source size the requested rung's bitrate is overkill: constant quality with the rung's cap instead.
|
|
2922
|
+
bitrateKbps: atSourceSize ? void 0 : bitrates.get(rendition.name)
|
|
2923
|
+
};
|
|
2924
|
+
const key = `${keys.mp4Prefix}${mp4FileName(variant.name, rendition)}`;
|
|
2925
|
+
return [
|
|
2926
|
+
` FF_OUTPUTS="$FF_OUTPUTS ${mp4Args(variant.name, rendition, options)}"`,
|
|
2927
|
+
` ${recordRendition(`/tmp/mp4/${mp4FileName(variant.name, rendition)}`, variant.name, "mp4", rendition.name, key)}`,
|
|
2928
|
+
...markRung(rendition, atSourceSize)
|
|
2929
|
+
];
|
|
2930
|
+
});
|
|
2931
|
+
}
|
|
2932
|
+
function variantScript(variant, index, plan, hlsKeyOption, keys) {
|
|
2933
|
+
return [
|
|
2934
|
+
`# frame ${variant.name}`,
|
|
2935
|
+
...variantShortSide(variant),
|
|
2936
|
+
// Even short side: the one that will have an output at source size.
|
|
2937
|
+
"V_EVEN=$((V_SHORT / 2 * 2))",
|
|
2938
|
+
"V_TOP=0",
|
|
2939
|
+
...hlsScript(variant, plan, hlsKeyOption, keys),
|
|
2940
|
+
...mp4Script(variant, plan, keys),
|
|
2941
|
+
...index === 0 ? ["POSTER_SHORT=$V_TOP"] : [],
|
|
2942
|
+
`echo "[transcode] ${variant.name}: short $V_SHORT top $V_TOP"`
|
|
2943
|
+
];
|
|
2944
|
+
}
|
|
2945
|
+
function planSetupScript(plan, hlsKeyOption, keys = NO_KEYS) {
|
|
2946
|
+
if (plan.variants.length === 0) throw new Error("planSetupScript: plan has no outputs");
|
|
2947
|
+
return [
|
|
2948
|
+
// `width,height` of the video stream. The short side doesn't change with rotation (phone metadata).
|
|
2949
|
+
...shortSideScript("SRC", FRAME_DIMS("/tmp/input")),
|
|
2950
|
+
'FF_OUTPUTS=""',
|
|
2951
|
+
'RENDITIONS=""',
|
|
2952
|
+
'TOP_RES=""',
|
|
2953
|
+
"TOP_SHORT=0",
|
|
2954
|
+
RECORD_RENDITION_FUNCTION,
|
|
2955
|
+
": > /tmp/outputs.list",
|
|
2956
|
+
"POSTER_SHORT=0",
|
|
2957
|
+
...plan.variants.flatMap((variant, index) => variantScript(variant, index, plan, hlsKeyOption, keys)),
|
|
2958
|
+
// Storyboard: one more output from the same ffmpeg (shares the decode); doesn't count as a video output.
|
|
2959
|
+
...storyboardOutputScript(plan),
|
|
2960
|
+
'echo "[transcode] source $SRC_DIMS (short $SRC_SHORT) top $TOP_RES (short $TOP_SHORT) outputs $(wc -l < /tmp/outputs.list)"'
|
|
2961
|
+
].join("\n");
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
// src/transcode/poster-script.ts
|
|
2965
|
+
var AUTO_OFFSET_AWK = "t = d * 0.1; if (t > 5) t = 5";
|
|
2966
|
+
function posterSeekScript(at) {
|
|
2967
|
+
if (at === "first") return 'POSTER_SEEK=""';
|
|
2968
|
+
const offset = at === "auto" ? `BEGIN { ${AUTO_OFFSET_AWK}; printf "%.3f", t }` : (
|
|
2969
|
+
// A fixed second past the end falls back to `auto` (otherwise ffmpeg wouldn't produce any frame).
|
|
2970
|
+
`BEGIN { if (${at} < d) t = ${at}; else { ${AUTO_OFFSET_AWK} } printf "%.3f", t }`
|
|
2971
|
+
);
|
|
2972
|
+
return [`POSTER_AT=$(awk -v d="$DURATION" '${offset}')`, 'POSTER_SEEK="-ss $POSTER_AT"'].join("\n");
|
|
2973
|
+
}
|
|
2974
|
+
function posterFilter(plan) {
|
|
2975
|
+
const scale = "scale=$POSTER_SHORT:$POSTER_SHORT:force_original_aspect_ratio=increase:force_divisible_by=2";
|
|
2976
|
+
const crop = plan.variants[0]?.crop;
|
|
2977
|
+
return crop ? `${cropFilter(crop)},${scale}` : scale;
|
|
2978
|
+
}
|
|
2979
|
+
function posterScript(plan) {
|
|
2980
|
+
const grabPoster = (seek) => `ffmpeg -nostdin -v error -y ${seek}-i /tmp/input -frames:v 1 -vf "${posterFilter(plan)}" -q:v 2 -update 1 /tmp/poster.jpg`;
|
|
2981
|
+
return [
|
|
2982
|
+
posterSeekScript(plan.posterAt),
|
|
2983
|
+
'if [ "$POSTER_SHORT" -le 0 ] 2>/dev/null; then POSTER_SHORT=720; fi',
|
|
2984
|
+
// `-ss` before `-i`: keyframe seek + decode up to the instant (precise and fast).
|
|
2985
|
+
`${grabPoster("$POSTER_SEEK ")} || true`,
|
|
2986
|
+
// No frame at that instant (misreported duration) → the first frame, never an empty poster.
|
|
2987
|
+
`[ -s /tmp/poster.jpg ] || ${grabPoster("")}`,
|
|
2988
|
+
'ffmpeg -nostdin -v error -y -i /tmp/poster.jpg -vf "scale=640:-2" -q:v 2 -update 1 /tmp/thumbnail.jpg'
|
|
2989
|
+
].join("\n");
|
|
2990
|
+
}
|
|
2991
|
+
function hasAudioScript(plan) {
|
|
2992
|
+
if (plan.stripAudio) return "HAS_AUDIO=false";
|
|
2993
|
+
return [
|
|
2994
|
+
"AUDIO_STREAM=$(ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 /tmp/input | head -1)",
|
|
2995
|
+
'if [ -n "$AUDIO_STREAM" ]; then HAS_AUDIO=true; else HAS_AUDIO=false; fi'
|
|
2996
|
+
].join("\n");
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
// src/transcode/upload-script.ts
|
|
3000
|
+
var DERIVED_MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
|
|
3001
|
+
var AUTH = '-H "Authorization: Bearer $UPLOAD_CREDENTIAL"';
|
|
3002
|
+
var SECURITY = `-H "${RESOURCE_SECURITY_HEADER}: $RESOURCE_SECURITY"`;
|
|
3003
|
+
var jsonString = (field) => `sed -n 's/.*"${field}":"\\([^"]*\\)".*/\\1/p'`;
|
|
3004
|
+
var jsonNumber = (field) => `sed -n 's/.*"${field}":\\([0-9]*\\).*/\\1/p'`;
|
|
3005
|
+
function uploadAssetFunction(thresholdBytes = DERIVED_MULTIPART_THRESHOLD_BYTES) {
|
|
3006
|
+
return [
|
|
3007
|
+
"upload_asset() {",
|
|
3008
|
+
' ASSET_FILE="$1"; ASSET_KEY="$2"; ASSET_TYPE="$3"',
|
|
3009
|
+
` ASSET_SIZE=$(wc -c < "$ASSET_FILE" | tr -d ' ')`,
|
|
3010
|
+
` if [ "$ASSET_SIZE" -le ${thresholdBytes} ]; then`,
|
|
3011
|
+
` curl -sS -X PUT "$WORKER_URL/v1/upload/$ASSET_KEY" ${AUTH} ${SECURITY} -H "Content-Type: $ASSET_TYPE" --data-binary "@$ASSET_FILE"`,
|
|
3012
|
+
" return 0",
|
|
3013
|
+
" fi",
|
|
3014
|
+
' echo "[transcode] multipart upload of $ASSET_KEY ($ASSET_SIZE bytes)"',
|
|
3015
|
+
` CREATED=$(curl -sS --fail -X POST "$WORKER_URL/v1/upload/$ASSET_KEY?multipart=create" ${AUTH} ${SECURITY} -H "Content-Type: application/json" -d "{\\"size\\":$ASSET_SIZE,\\"contentType\\":\\"$ASSET_TYPE\\"}")`,
|
|
3016
|
+
` UPLOAD_ID=$(printf '%s' "$CREATED" | ${jsonString("uploadId")})`,
|
|
3017
|
+
` PART_SIZE=$(printf '%s' "$CREATED" | ${jsonNumber("partSize")})`,
|
|
3018
|
+
` PART_COUNT=$(printf '%s' "$CREATED" | ${jsonNumber("partCount")})`,
|
|
3019
|
+
' if [ -z "$UPLOAD_ID" ] || [ -z "$PART_SIZE" ] || [ -z "$PART_COUNT" ]; then return 1; fi',
|
|
3020
|
+
// R2's uploadId can contain `+`, `/` or `=`: it's URL-encoded in the query.
|
|
3021
|
+
` UPLOAD_ID_QUERY=$(printf '%s' "$UPLOAD_ID" | sed -e 's/%/%25/g' -e 's/+/%2B/g' -e 's/\\//%2F/g' -e 's/=/%3D/g')`,
|
|
3022
|
+
' PARTS=""',
|
|
3023
|
+
" PART=1",
|
|
3024
|
+
' while [ "$PART" -le "$PART_COUNT" ]; do',
|
|
3025
|
+
// Each part is exactly `partSize` (the last one, the remainder): the worker rejects any other size.
|
|
3026
|
+
` PART_RESPONSE=$(dd if="$ASSET_FILE" bs="$PART_SIZE" skip=$((PART - 1)) count=1 2>/dev/null | curl -sS --fail -X PUT "$WORKER_URL/v1/upload/$ASSET_KEY?uploadId=$UPLOAD_ID_QUERY&partNumber=$PART" ${AUTH} -H "Content-Type: application/octet-stream" --data-binary @-)`,
|
|
3027
|
+
` ETAG=$(printf '%s' "$PART_RESPONSE" | sed -n 's/.*"etag":"\\(.*\\)"}.*/\\1/p')`,
|
|
3028
|
+
' if [ -z "$ETAG" ]; then return 1; fi',
|
|
3029
|
+
' PARTS="$PARTS,{\\"partNumber\\":$PART,\\"etag\\":\\"$ETAG\\"}"',
|
|
3030
|
+
" PART=$((PART + 1))",
|
|
3031
|
+
" done",
|
|
3032
|
+
` PARTS=$(printf '%s' "$PARTS" | cut -c2-)`,
|
|
3033
|
+
` curl -sS --fail -X POST "$WORKER_URL/v1/upload/$ASSET_KEY?multipart=complete" ${AUTH} -H "Content-Type: application/json" -d "{\\"uploadId\\":\\"$UPLOAD_ID\\",\\"parts\\":[$PARTS]}" > /dev/null`,
|
|
3034
|
+
"}"
|
|
3035
|
+
].join("\n");
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
// src/transcode/script.ts
|
|
3039
|
+
function encodeJson(plan) {
|
|
3040
|
+
const fields = [
|
|
3041
|
+
'\\"seconds\\":$ENCODE_SECONDS',
|
|
3042
|
+
'\\"cpus\\":$TRANSCODE_CPUS',
|
|
3043
|
+
'\\"topRung\\":\\"$TOP_RES\\"',
|
|
3044
|
+
'\\"runner\\":\\"$TRANSCODE_RUNNER\\"',
|
|
3045
|
+
'\\"complexity\\":$PT_SCORE',
|
|
3046
|
+
'\\"probeSeconds\\":$PROBE_SECONDS',
|
|
3047
|
+
`\\"params\\":${encodeParamsJson(plan)}`
|
|
3048
|
+
];
|
|
3049
|
+
return `\\"encode\\":{${fields.join(",")}}`;
|
|
3050
|
+
}
|
|
3051
|
+
function renditionsScript() {
|
|
3052
|
+
return [
|
|
3053
|
+
'OUTPUTS_JSON=""',
|
|
3054
|
+
"while read -r O_PROBE O_JSON; do",
|
|
3055
|
+
' O_DIMS=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "$O_PROBE" | head -1)',
|
|
3056
|
+
' O_W=$(echo "$O_DIMS" | cut -s -d, -f1)',
|
|
3057
|
+
' O_H=$(echo "$O_DIMS" | cut -s -d, -f2)',
|
|
3058
|
+
' case "$O_W" in ""|*[!0-9]*) O_W=0 ;; esac',
|
|
3059
|
+
' case "$O_H" in ""|*[!0-9]*) O_H=0 ;; esac',
|
|
3060
|
+
' OUTPUTS_JSON="$OUTPUTS_JSON,$O_JSON,\\"width\\":$O_W,\\"height\\":$O_H}"',
|
|
3061
|
+
"done < /tmp/outputs.list",
|
|
3062
|
+
`OUTPUTS_JSON=$(printf '%s' "$OUTPUTS_JSON" | cut -c2-)`
|
|
3063
|
+
];
|
|
3064
|
+
}
|
|
3065
|
+
function outputsReportJson(plan, posterKey) {
|
|
3066
|
+
if (!plan.reportsOutputs) return "";
|
|
3067
|
+
const formats = plan.posterFormats.map((format) => `\\"${format}\\"`).join(",");
|
|
3068
|
+
return [
|
|
3069
|
+
"",
|
|
3070
|
+
'\\"renditions\\":[$OUTPUTS_JSON]',
|
|
3071
|
+
`\\"posterKey\\":\\"${posterKey}\\"`,
|
|
3072
|
+
`\\"posterFormats\\":[${formats}]`,
|
|
3073
|
+
'\\"hasAudio\\":$HAS_AUDIO'
|
|
3074
|
+
].join(",");
|
|
3075
|
+
}
|
|
3076
|
+
function buildScript(options) {
|
|
3077
|
+
const hlsKeyOption = options.encrypt ? "-hls_key_info_file /tmp/enc.keyinfo " : "";
|
|
3078
|
+
const plan = options.plan ?? legacyPlan(options.maxResolution);
|
|
3079
|
+
const playlistKey = `${options.outputPrefix}playlist.m3u8`;
|
|
3080
|
+
const mp4Prefix = options.mp4Prefix ?? options.outputPrefix.replace(/hls\/$/, "mp4/");
|
|
3081
|
+
const posterKey = options.posterKey ?? options.thumbnailKey.replace(/[^/]+$/, "poster.jpg");
|
|
3082
|
+
const storyboardPrefix = options.storyboardPrefix ?? options.outputPrefix.replace(/hls\/$/, "storyboard/");
|
|
3083
|
+
const putAsset = `curl -sS -X PUT -H "Authorization: Bearer $UPLOAD_CREDENTIAL" -H "${RESOURCE_SECURITY_HEADER}: $RESOURCE_SECURITY"`;
|
|
3084
|
+
const ladderSetup = planSetupScript(plan, hlsKeyOption, { playlistKey, mp4Prefix });
|
|
3085
|
+
const callbackHeaders = `-H "Content-Type: application/json" -H "Authorization: Bearer $CALLBACK_SECRET" -H "${JOB_TICKET_HEADER}: $JOB_TICKET"`;
|
|
3086
|
+
const sendEvent = `curl -sS -X POST "$CALLBACK_URL" ${callbackHeaders} -d`;
|
|
3087
|
+
const sendCompletion = `curl -sS --fail --retry 5 --retry-delay 3 --retry-connrefused -X POST "$CALLBACK_URL" ${callbackHeaders} -d`;
|
|
3088
|
+
return [
|
|
3089
|
+
"set -e",
|
|
3090
|
+
uploadAssetFunction(options.multipartThresholdBytes),
|
|
3091
|
+
// With `set -e`, any failing command aborts the script and the machine self-destructs
|
|
3092
|
+
// WITHOUT sending `transcode.complete` → the client would be stuck in "transcoding". This trap
|
|
3093
|
+
// emits `transcode.failed` on abnormal exit so the webhook still arrives. `STAGE`
|
|
3094
|
+
// gives context on where it failed; `COMPLETED` keeps it from firing after a successful finish.
|
|
3095
|
+
"STAGE=starting",
|
|
3096
|
+
// Failure categorized by stage: `reason` + `retriable` (unquoted JSON bool) → the client decides
|
|
3097
|
+
// the message and whether to retry. downloading=invalid/non-retriable input; encoding=unsupported codec;
|
|
3098
|
+
// starting=capacity (the machine didn't start, retriable); everything else=internal, retriable.
|
|
3099
|
+
`notify_failure() { ec=$?; [ "$ec" -eq 0 ] && return 0; [ -n "$COMPLETED" ] && return 0; case "$STAGE" in downloading) R=invalid_input; T=false ;; encoding) R=unsupported_codec; T=false ;; starting) R=capacity; T=true ;; *) R=internal; T=true ;; esac; ${sendEvent} "{\\"event\\":\\"transcode.failed\\",\\"videoId\\":\\"${options.videoId}\\",\\"error\\":\\"transcode failed at $STAGE (exit $ec)\\",\\"reason\\":\\"$R\\",\\"retriable\\":$T}" || true; }`,
|
|
3100
|
+
"trap notify_failure EXIT",
|
|
3101
|
+
"echo '[transcode] starting'",
|
|
3102
|
+
// /tmp/probe holds one frame per rendition (for the master's RESOLUTION); not uploaded.
|
|
3103
|
+
"mkdir -p /tmp/output /tmp/probe /tmp/mp4 /tmp/storyboard",
|
|
3104
|
+
// L4: keyfile (16 bytes from KEY_B64) + keyinfo for ffmpeg. keyinfo line 1 = URI that gets
|
|
3105
|
+
// baked into the playlist (`$KEY_ID.key`, relative → resolves against the hls/ folder on delivery);
|
|
3106
|
+
// line 2 = local path to the keyfile ffmpeg uses to encrypt. Outside /tmp/output → not uploaded.
|
|
3107
|
+
...options.encrypt ? [
|
|
3108
|
+
'echo "$KEY_B64" | base64 -d > /tmp/enc.keyfile',
|
|
3109
|
+
`printf '%s\\n%s\\n' "$KEY_ID.key" "/tmp/enc.keyfile" > /tmp/enc.keyinfo`
|
|
3110
|
+
] : [],
|
|
3111
|
+
"STAGE=downloading",
|
|
3112
|
+
// "Machine alive" heartbeat: the first progress event (phase=downloading) moves the video out of
|
|
3113
|
+
// "queued" within seconds, BEFORE downloading the input (which can take a while). Without this the
|
|
3114
|
+
// status takes ~as long as the download.
|
|
3115
|
+
`${sendEvent} "{\\"event\\":\\"transcode.progress\\",\\"videoId\\":\\"${options.videoId}\\",\\"currentSegment\\":0,\\"totalSegments\\":100,\\"phase\\":\\"downloading\\"}"`,
|
|
3116
|
+
'echo "[transcode] downloading input from R2"',
|
|
3117
|
+
// The input (raw, private) is downloaded with the service download token, not the upload credential: the
|
|
3118
|
+
// delivery gate requires a valid viewer token for private media. The worker decrypts the raw if it's encrypted.
|
|
3119
|
+
'curl -sS -o /tmp/input "$WORKER_URL/v1/media/$INPUT_KEY" -H "Authorization: Bearer $DOWNLOAD_TOKEN"',
|
|
3120
|
+
'echo "[transcode] input downloaded: $(du -h /tmp/input | cut -f1)"',
|
|
3121
|
+
"DURATION=$(ffprobe -v error -show_entries format=duration -of csv=p=0 /tmp/input)",
|
|
3122
|
+
`TOTAL_SEGMENTS=$(echo "$DURATION / ${SEGMENT_SECONDS}" | bc)`,
|
|
3123
|
+
"TOTAL_SEGMENTS=$((TOTAL_SEGMENTS + 1))",
|
|
3124
|
+
`${sendEvent} "{\\"event\\":\\"transcode.started\\",\\"videoId\\":\\"${options.videoId}\\",\\"totalSegments\\":$TOTAL_SEGMENTS,\\"duration\\":$DURATION}"`,
|
|
3125
|
+
// Complexity probe (per-title encode) BEFORE setup: the ffmpeg args read its `PT_*` values.
|
|
3126
|
+
"STAGE=probing",
|
|
3127
|
+
perTitleScript(plan),
|
|
3128
|
+
ladderSetup,
|
|
3129
|
+
"STAGE=encoding",
|
|
3130
|
+
'echo "[transcode] running ffmpeg hls encode"',
|
|
3131
|
+
// `set -f`: FF_OUTPUTS is expanded unquoted (intentional word-splitting) and carries a `*` in
|
|
3132
|
+
// -force_key_frames's `expr:` → without noglob the shell could expand it against the filesystem.
|
|
3133
|
+
"set -f",
|
|
3134
|
+
// Measurement: encode wall clock (ffmpeg only, no download/upload). × runner vCPU = vCPU-s.
|
|
3135
|
+
"ENCODE_START=$(date +%s)",
|
|
3136
|
+
"ffmpeg -nostdin -progress /tmp/ffmpeg_progress -i /tmp/input $FF_OUTPUTS &",
|
|
3137
|
+
"set +f",
|
|
3138
|
+
"FFMPEG_PID=$!",
|
|
3139
|
+
"LAST_PCT=0",
|
|
3140
|
+
"while kill -0 $FFMPEG_PID 2>/dev/null; do",
|
|
3141
|
+
" sleep 5",
|
|
3142
|
+
" if [ -f /tmp/ffmpeg_progress ]; then",
|
|
3143
|
+
' CURRENT_US=$(grep "out_time_us" /tmp/ffmpeg_progress | tail -1 | cut -d= -f2)',
|
|
3144
|
+
' if [ -n "$CURRENT_US" ] && [ "$CURRENT_US" -gt 0 ] 2>/dev/null; then',
|
|
3145
|
+
' DURATION_US=$(echo "$DURATION * 1000000" | bc | cut -d. -f1)',
|
|
3146
|
+
" PCT=$(( CURRENT_US * 100 / DURATION_US ))",
|
|
3147
|
+
' if [ "$PCT" -gt "$LAST_PCT" ] && [ "$PCT" -le 100 ]; then',
|
|
3148
|
+
` ${sendEvent} "{\\"event\\":\\"transcode.progress\\",\\"videoId\\":\\"${options.videoId}\\",\\"currentSegment\\":$PCT,\\"totalSegments\\":100,\\"phase\\":\\"encoding\\"}"`,
|
|
3149
|
+
" LAST_PCT=$PCT",
|
|
3150
|
+
" fi",
|
|
3151
|
+
" fi",
|
|
3152
|
+
" fi",
|
|
3153
|
+
"done",
|
|
3154
|
+
"wait $FFMPEG_PID",
|
|
3155
|
+
"ENCODE_SECONDS=$(( $(date +%s) - ENCODE_START ))",
|
|
3156
|
+
// Sanitize: these go raw into the event's JSON (number / slug), a weird env value shouldn't break it.
|
|
3157
|
+
'case "$TRANSCODE_CPUS" in ""|*[!0-9]*) TRANSCODE_CPUS=0 ;; esac',
|
|
3158
|
+
'case "$TRANSCODE_RUNNER" in ""|*[!a-z0-9-]*) TRANSCODE_RUNNER=unknown ;; esac',
|
|
3159
|
+
'echo "[transcode] encode took $ENCODE_SECONDS seconds on $TRANSCODE_CPUS vCPU ($TRANSCODE_RUNNER)"',
|
|
3160
|
+
...renditionsScript(),
|
|
3161
|
+
// The master's CODECS needs to know if the output has audio.
|
|
3162
|
+
hasAudioScript(plan),
|
|
3163
|
+
// Without an HLS ladder (MP4-only preset) there's no master to write.
|
|
3164
|
+
'if [ -n "$RENDITIONS" ]; then',
|
|
3165
|
+
'echo "[transcode] generating master playlist"',
|
|
3166
|
+
masterPlaylistScript(),
|
|
3167
|
+
"fi",
|
|
3168
|
+
storyboardVttScript(plan, `${storyboardPrefix}${STORYBOARD.vttFile}`),
|
|
3169
|
+
"STAGE=thumbnail",
|
|
3170
|
+
'echo "[transcode] generating poster and thumbnail"',
|
|
3171
|
+
// Poster and thumbnail are generated OUTSIDE /tmp/output so they upload to their own path (`thumbnails/…`,
|
|
3172
|
+
// where delivery serves them from) instead of sitting in the segment loop's hls/ folder.
|
|
3173
|
+
posterScript(plan),
|
|
3174
|
+
"STAGE=uploading",
|
|
3175
|
+
'echo "[transcode] uploading segments"',
|
|
3176
|
+
"CURRENT=0",
|
|
3177
|
+
// Counts the files to upload from hls/ (.ts segments + media playlists + master) and mp4/. Poster and
|
|
3178
|
+
// thumbnail upload separately to their own path. CURRENT and TOTAL line up with any plan.
|
|
3179
|
+
"TOTAL=$(ls /tmp/output/* /tmp/mp4/* 2>/dev/null | wc -l)",
|
|
3180
|
+
"for f in /tmp/output/*; do",
|
|
3181
|
+
// Empty folder (MP4-only preset): the glob stays literal.
|
|
3182
|
+
' [ -e "$f" ] || continue',
|
|
3183
|
+
' FILENAME=$(basename "$f")',
|
|
3184
|
+
' CT="application/octet-stream"',
|
|
3185
|
+
' case "$FILENAME" in',
|
|
3186
|
+
' *.m3u8) CT="application/vnd.apple.mpegurl" ;;',
|
|
3187
|
+
' *.ts) CT="video/mp2t" ;;',
|
|
3188
|
+
" esac",
|
|
3189
|
+
' echo "[transcode] uploading $FILENAME ($(du -h "$f" | cut -f1))"',
|
|
3190
|
+
` curl -sS -X PUT "$WORKER_URL/v1/upload/${options.outputPrefix}$FILENAME" -H "Authorization: Bearer $UPLOAD_CREDENTIAL" -H "${RESOURCE_SECURITY_HEADER}: $RESOURCE_SECURITY" -H "Content-Type: $CT" --data-binary "@$f"`,
|
|
3191
|
+
" CURRENT=$((CURRENT + 1))",
|
|
3192
|
+
` ${sendEvent} "{\\"event\\":\\"transcode.progress\\",\\"videoId\\":\\"${options.videoId}\\",\\"currentSegment\\":$CURRENT,\\"totalSegments\\":$TOTAL,\\"phase\\":\\"uploading\\"}"`,
|
|
3193
|
+
"done",
|
|
3194
|
+
// Standalone MP4s → `mp4/`: single PUT up to the threshold, multipart above it (a 200 MB hero doesn't fit in a
|
|
3195
|
+
// buffered PUT). A private one is encrypted at rest on either path and served with Range.
|
|
3196
|
+
"for f in /tmp/mp4/*; do",
|
|
3197
|
+
' [ -e "$f" ] || continue',
|
|
3198
|
+
' FILENAME=$(basename "$f")',
|
|
3199
|
+
' echo "[transcode] uploading $FILENAME ($(du -h "$f" | cut -f1))"',
|
|
3200
|
+
` upload_asset "$f" "${mp4Prefix}$FILENAME" "video/mp4"`,
|
|
3201
|
+
" CURRENT=$((CURRENT + 1))",
|
|
3202
|
+
` ${sendEvent} "{\\"event\\":\\"transcode.progress\\",\\"videoId\\":\\"${options.videoId}\\",\\"currentSegment\\":$CURRENT,\\"totalSegments\\":$TOTAL,\\"phase\\":\\"uploading\\"}"`,
|
|
3203
|
+
"done",
|
|
3204
|
+
storyboardUploadScript(plan, storyboardPrefix, putAsset),
|
|
3205
|
+
'echo "[transcode] uploading poster"',
|
|
3206
|
+
`curl -sS -X PUT "$WORKER_URL/v1/upload/${posterKey}" -H "Authorization: Bearer $UPLOAD_CREDENTIAL" -H "${RESOURCE_SECURITY_HEADER}: $RESOURCE_SECURITY" -H "Content-Type: image/jpeg" --data-binary "@/tmp/poster.jpg"`,
|
|
3207
|
+
// Thumbnail → its own path (`{visibility}/video/{id}/thumbnails/thumbnail.jpg`), with the propagated
|
|
3208
|
+
// binding override. Encrypted at rest if the policy requires it (it's a non-HLS private asset).
|
|
3209
|
+
'echo "[transcode] uploading thumbnail"',
|
|
3210
|
+
`curl -sS -X PUT "$WORKER_URL/v1/upload/${options.thumbnailKey}" -H "Authorization: Bearer $UPLOAD_CREDENTIAL" -H "${RESOURCE_SECURITY_HEADER}: $RESOURCE_SECURITY" -H "Content-Type: image/jpeg" --data-binary "@/tmp/thumbnail.jpg"`,
|
|
3211
|
+
'echo "[transcode] sending completion"',
|
|
3212
|
+
// MP4-only preset: no playlist to announce.
|
|
3213
|
+
`PLAYLIST_KEY="${playlistKey}"`,
|
|
3214
|
+
'[ -n "$RENDITIONS" ] || PLAYLIST_KEY=""',
|
|
3215
|
+
`${sendCompletion} "{\\"event\\":\\"transcode.complete\\",\\"videoId\\":\\"${options.videoId}\\",\\"duration\\":$DURATION,\\"playlistKey\\":\\"$PLAYLIST_KEY\\",\\"thumbnailKey\\":\\"${options.thumbnailKey}\\",\\"storyboardKey\\":\\"$STORYBOARD_KEY\\",${encodeJson(plan)}${outputsReportJson(plan, posterKey)}}"`,
|
|
3216
|
+
"COMPLETED=1",
|
|
3217
|
+
'echo "[transcode] done"'
|
|
3218
|
+
].join("\n");
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
// src/routes/transcode.ts
|
|
3222
|
+
function parseSecurityMeta2(raw) {
|
|
3223
|
+
if (!raw) return void 0;
|
|
3224
|
+
try {
|
|
3225
|
+
return JSON.parse(raw);
|
|
3226
|
+
} catch {
|
|
3227
|
+
return void 0;
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
var SERVICE_TOKEN_TTL_SECONDS = 3600;
|
|
3231
|
+
var isVideoId = (videoId, key) => Boolean(videoId) && videoId !== key;
|
|
3232
|
+
var transcode = new Hono8();
|
|
3233
|
+
transcode.post("/", async (context) => {
|
|
3234
|
+
const body = await context.req.json();
|
|
3235
|
+
if (!body.key) {
|
|
3236
|
+
return context.json({ error: "key is required" }, 400);
|
|
3237
|
+
}
|
|
3238
|
+
const videoId = extractId2(body.key);
|
|
3239
|
+
const visibility = visibilityFromKey3(body.key) ?? "private";
|
|
3240
|
+
const outputPrefix = `${visibility}/${videoHlsPath(videoId)}/`;
|
|
3241
|
+
const thumbnailKey = `${visibility}/${videoThumbnailPath(videoId)}`;
|
|
3242
|
+
const fullKey = storageKey(context, body.key);
|
|
3243
|
+
const object = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
3244
|
+
if (!object) {
|
|
3245
|
+
const videoFolder = storageKey(context, `${visibility}/${videoBasePath2(videoId)}/`);
|
|
3246
|
+
const isKnownVideo = isVideoId(videoId, body.key) && await hasPriorTranscodeOutputs(context.env.MEDIA_BUCKET, videoFolder);
|
|
3247
|
+
if (isKnownVideo) return context.json(ORIGINAL_NOT_RETAINED, 410);
|
|
3248
|
+
return context.json({ error: "Media not found in storage" }, 404);
|
|
3249
|
+
}
|
|
3250
|
+
if (!isVideoId(videoId, body.key)) {
|
|
3251
|
+
return context.json({ error: "Invalid key format" }, 400);
|
|
3252
|
+
}
|
|
3253
|
+
const scope = getRequestState(context);
|
|
3254
|
+
const transcodeConfig = scope.config?.transcode;
|
|
3255
|
+
const resolved = resolveTranscodePreset(body.preset, transcodeConfig?.presets);
|
|
3256
|
+
if (!resolved.ok) {
|
|
3257
|
+
return context.json({ error: "Invalid preset", details: resolved.errors }, 400);
|
|
3258
|
+
}
|
|
3259
|
+
const preset = resolved.name;
|
|
3260
|
+
const fallbackMaxResolution = resolved.source === "legacy" ? presetToMaxResolution(preset) : MaxResolution.P4K;
|
|
3261
|
+
const maxResolution = transcodeConfig?.maxResolution ?? fallbackMaxResolution;
|
|
3262
|
+
const plan = planTranscode(resolved.spec, maxResolution, { reportsOutputs: resolved.source !== "legacy" });
|
|
3263
|
+
const posterKey = `${visibility}/${videoPosterPath(videoId)}`;
|
|
3264
|
+
const mp4Prefix = `${visibility}/${videoMp4Dir(videoId)}/`;
|
|
3265
|
+
const storyboardPrefix = `${visibility}/${videoStoryboardDir(videoId)}/`;
|
|
3266
|
+
const workerBaseUrl = new URL(context.req.url).origin;
|
|
3267
|
+
const callbackUrl = `${workerBaseUrl}/v1/webhooks/transcode`;
|
|
3268
|
+
const rawOverride = parseSecurityMeta2(object.customMetadata?.security);
|
|
3269
|
+
const resourceSecurityHeader = rawOverride ? JSON.stringify(rawOverride) : "";
|
|
3270
|
+
const keyMetadata = rawOverride ? { security: resourceSecurityHeader } : void 0;
|
|
3271
|
+
const policy = resolveSecurity3(rawOverride, scope.config?.security);
|
|
3272
|
+
const shouldEncrypt = policy.encrypt && visibility === "private" && Boolean(context.env.KEYS_BUCKET);
|
|
3273
|
+
let encryptKeyB64 = "";
|
|
3274
|
+
let encryptKeyId = "";
|
|
3275
|
+
if (shouldEncrypt && context.env.KEYS_BUCKET) {
|
|
3276
|
+
const rawKey = crypto.getRandomValues(new Uint8Array(16));
|
|
3277
|
+
encryptKeyId = generateId2();
|
|
3278
|
+
const keyPath = storageKey(context, `${outputPrefix}${encryptKeyId}`);
|
|
3279
|
+
await context.env.KEYS_BUCKET.put(keyPath, rawKey, {
|
|
3280
|
+
...keyMetadata ? { customMetadata: keyMetadata } : {}
|
|
3281
|
+
});
|
|
3282
|
+
encryptKeyB64 = btoa(String.fromCharCode(...rawKey));
|
|
3283
|
+
}
|
|
3284
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
3285
|
+
const requestedExp = nowSeconds + SERVICE_TOKEN_TTL_SECONDS;
|
|
3286
|
+
const decision = await decideCredential(context, {
|
|
3287
|
+
kind: "service",
|
|
3288
|
+
scopes: [TokenScope4.MEDIA_READ],
|
|
3289
|
+
path: body.key,
|
|
3290
|
+
expiresAt: requestedExp * 1e3
|
|
3291
|
+
});
|
|
3292
|
+
if (!decision.allow) return decision.response;
|
|
3293
|
+
const downloadToken = await signViewerToken(
|
|
3294
|
+
withPluginClaims(
|
|
3295
|
+
{
|
|
3296
|
+
sub: `${SERVICE_SUBJECT_PREFIX}${videoId}`,
|
|
3297
|
+
typ: SERVICE_TOKEN_TYPE,
|
|
3298
|
+
...scopeClaim([TokenScope4.MEDIA_READ]),
|
|
3299
|
+
...audienceClaim(scope.storagePrefix),
|
|
3300
|
+
bind: { sessionBinding: false, ipBinding: false },
|
|
3301
|
+
iat: nowSeconds,
|
|
3302
|
+
exp: cappedExpiry(requestedExp, decision.expiresAt, (epochMs) => Math.floor(epochMs / 1e3))
|
|
3303
|
+
},
|
|
3304
|
+
decision
|
|
3305
|
+
),
|
|
3306
|
+
context.env.DELIVERY_SIGN_SECRET
|
|
3307
|
+
);
|
|
3308
|
+
if (body.callbackUrl) {
|
|
3309
|
+
await context.env.MEDIA_KV.put(
|
|
3310
|
+
clientWebhookKvKey(videoId),
|
|
3311
|
+
JSON.stringify(clientWebhookFor(scope, body.callbackUrl)),
|
|
3312
|
+
{ expirationTtl: KV_JOB_TTL_SECONDS }
|
|
3313
|
+
);
|
|
3314
|
+
}
|
|
3315
|
+
const transcodeScript = buildScript({
|
|
3316
|
+
videoId,
|
|
3317
|
+
maxResolution,
|
|
3318
|
+
plan,
|
|
3319
|
+
outputPrefix,
|
|
3320
|
+
thumbnailKey,
|
|
3321
|
+
posterKey,
|
|
3322
|
+
mp4Prefix,
|
|
3323
|
+
storyboardPrefix,
|
|
3324
|
+
encrypt: shouldEncrypt
|
|
3325
|
+
});
|
|
3326
|
+
const { ticket: jobTicket } = await signJobTicket(context.env.DELIVERY_SIGN_SECRET, {
|
|
3327
|
+
videoId,
|
|
3328
|
+
...scope.context ? { context: scope.context } : {}
|
|
3329
|
+
});
|
|
3330
|
+
const runner = createRunner(context.env);
|
|
3331
|
+
if (!runner) {
|
|
3332
|
+
console.error(`[transcode] unknown TRANSCODE_RUNNER "${context.env.TRANSCODE_RUNNER}"`);
|
|
3333
|
+
return context.json({ error: "Invalid TRANSCODE_RUNNER", code: TranscodeLaunchErrorCode.NOT_CONFIGURED }, 500);
|
|
3334
|
+
}
|
|
3335
|
+
const launch = await runner.launch({
|
|
3336
|
+
videoId,
|
|
3337
|
+
script: transcodeScript,
|
|
3338
|
+
env: {
|
|
3339
|
+
INPUT_KEY: body.key,
|
|
3340
|
+
VIDEO_ID: videoId,
|
|
3341
|
+
OUTPUT_PREFIX: outputPrefix,
|
|
3342
|
+
THUMBNAIL_KEY: thumbnailKey,
|
|
3343
|
+
WORKER_URL: workerBaseUrl,
|
|
3344
|
+
UPLOAD_CREDENTIAL: scope.runnerCredential ?? "",
|
|
3345
|
+
CALLBACK_URL: callbackUrl,
|
|
3346
|
+
CALLBACK_SECRET: context.env.TRANSCODE_WEBHOOK_SECRET ?? "",
|
|
3347
|
+
JOB_TICKET: jobTicket,
|
|
3348
|
+
PRESET: preset,
|
|
3349
|
+
// Service token to download the raw (private → the gate requires a viewer token; public ignores it).
|
|
3350
|
+
DOWNLOAD_TOKEN: downloadToken,
|
|
3351
|
+
// L4: HLS encryption material (empty if the video isn't encrypted). The script writes the keyfile
|
|
3352
|
+
// from KEY_B64 and bakes `#EXT-X-KEY:URI="$KEY_ID.key"`. The key already lives in the keys bucket.
|
|
3353
|
+
KEY_B64: encryptKeyB64,
|
|
3354
|
+
KEY_ID: encryptKeyId,
|
|
3355
|
+
// Resource binding override (empty if there isn't one) → the script propagates it as a header on
|
|
3356
|
+
// every derived asset upload so the gate ties the video per-resource, same as the raw.
|
|
3357
|
+
RESOURCE_SECURITY: resourceSecurityHeader,
|
|
3358
|
+
// Measurement: the script reports `encodeSeconds` and this vCPU count in `transcode.complete`.
|
|
3359
|
+
TRANSCODE_CPUS: String(runner.cpus),
|
|
3360
|
+
TRANSCODE_RUNNER: runner.name
|
|
3361
|
+
}
|
|
3362
|
+
});
|
|
3363
|
+
if (!launch.ok) {
|
|
3364
|
+
console.error(`[transcode] ${runner.name} launch failed (${launch.code}):`, launch.detail);
|
|
3365
|
+
return context.json({ error: launch.error, code: launch.code }, launch.status);
|
|
3366
|
+
}
|
|
3367
|
+
const jobId = launch.jobRef;
|
|
3368
|
+
await Promise.all([
|
|
3369
|
+
context.env.MEDIA_KV.put(
|
|
3370
|
+
`${KvPrefix.TRANSCODE_JOB}:${jobId}`,
|
|
3371
|
+
JSON.stringify({ videoId, inputKey: body.key, outputPrefix, thumbnailKey, preset, runner: runner.name }),
|
|
3372
|
+
{ expirationTtl: KV_JOB_TTL_SECONDS }
|
|
3373
|
+
),
|
|
3374
|
+
writeQueuedStatus(context.env.MEDIA_KV, videoId, { jobRef: jobId, runner: runner.name }),
|
|
3375
|
+
// Webhook URLs from `outputBaseUrl` (or the request's origin), fixed when the job is queued.
|
|
3376
|
+
saveOutputBaseUrl(context.env.MEDIA_KV, videoId, transcodeConfig?.outputBaseUrl ?? workerBaseUrl),
|
|
3377
|
+
rememberRawDisposal(context.env.MEDIA_KV, videoId, {
|
|
3378
|
+
storageKey: fullKey,
|
|
3379
|
+
deleteOnComplete: transcodeConfig?.deleteOriginalOnSuccess === true
|
|
3380
|
+
})
|
|
3381
|
+
]);
|
|
3382
|
+
return context.json({
|
|
3383
|
+
jobId,
|
|
3384
|
+
videoId,
|
|
3385
|
+
inputKey: body.key,
|
|
3386
|
+
outputPrefix,
|
|
3387
|
+
thumbnailKey,
|
|
3388
|
+
preset,
|
|
3389
|
+
status: TranscodeStatus2.QUEUED
|
|
3390
|
+
});
|
|
3391
|
+
});
|
|
3392
|
+
transcode.get("/:jobId", async (context) => {
|
|
3393
|
+
const jobId = context.req.param("jobId");
|
|
3394
|
+
const kv = context.env.MEDIA_KV;
|
|
3395
|
+
const jobMeta = await kv.get(`${KvPrefix.TRANSCODE_JOB}:${jobId}`, "json");
|
|
3396
|
+
if (!jobMeta?.videoId) return context.json({ error: "Job not found" }, 404);
|
|
3397
|
+
const [record, durationMeta] = await Promise.all([
|
|
3398
|
+
readStatus(kv, jobMeta.videoId),
|
|
3399
|
+
kv.get(`${KvPrefix.TRANSCODE_META}:${jobMeta.videoId}`, "json")
|
|
3400
|
+
]);
|
|
3401
|
+
const ownRecord = record?.jobRef === jobId ? record : null;
|
|
3402
|
+
const status = ownRecord?.status ?? (durationMeta ? TranscodeStatus2.COMPLETED : TranscodeStatus2.QUEUED);
|
|
3403
|
+
if (status === TranscodeStatus2.FAILED) {
|
|
3404
|
+
const { error, reason, retriable } = ownRecord ?? {};
|
|
3405
|
+
return context.json({ jobId, status, error, reason, retriable });
|
|
3406
|
+
}
|
|
3407
|
+
if (status !== TranscodeStatus2.COMPLETED) return context.json({ jobId, status });
|
|
3408
|
+
return context.json({
|
|
3409
|
+
jobId,
|
|
3410
|
+
status,
|
|
3411
|
+
duration: durationMeta?.duration ?? null,
|
|
3412
|
+
playlistUrl: jobMeta.outputPrefix ? `${jobMeta.outputPrefix}playlist.m3u8` : null,
|
|
3413
|
+
thumbnailKey: jobMeta.thumbnailKey ?? null
|
|
3414
|
+
});
|
|
3415
|
+
});
|
|
3416
|
+
|
|
3417
|
+
// src/routes/upload.ts
|
|
3418
|
+
import {
|
|
3419
|
+
decodeEncryptionMeta as decodeEncryptionMeta6,
|
|
3420
|
+
MediaType as MediaType2,
|
|
3421
|
+
resolveSecurity as resolveSecurity5,
|
|
3422
|
+
resolveVisibility,
|
|
3423
|
+
UploadRequestKind as UploadRequestKind2
|
|
3424
|
+
} from "@devultur/core";
|
|
3425
|
+
import { Hono as Hono9 } from "hono";
|
|
3426
|
+
|
|
3427
|
+
// src/routes/upload-multipart.ts
|
|
3428
|
+
import {
|
|
3429
|
+
AES_BLOCK_BYTES as AES_BLOCK_BYTES2,
|
|
3430
|
+
classifyUploadRequest,
|
|
3431
|
+
counterForBlock,
|
|
3432
|
+
decodeEncryptionMeta as decodeEncryptionMeta5,
|
|
3433
|
+
encryptBytes,
|
|
3434
|
+
MULTIPART_PART_SIZE_BYTES,
|
|
3435
|
+
MULTIPART_QUERY,
|
|
3436
|
+
multipartPartCount,
|
|
3437
|
+
multipartPartRange,
|
|
3438
|
+
resolveSecurity as resolveSecurity4,
|
|
3439
|
+
UploadRequestKind
|
|
3440
|
+
} from "@devultur/core";
|
|
3441
|
+
|
|
3442
|
+
// src/routes/derived-upload.ts
|
|
3443
|
+
var DERIVED_ASSET_KEY = /^(public|private)\/video\/[A-Za-z0-9-]+\/(hls|mp4|thumbnails|storyboard)\/[^/]+$/;
|
|
3444
|
+
var DERIVED_CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
3445
|
+
"video/mp4",
|
|
3446
|
+
"video/mp2t",
|
|
3447
|
+
"image/jpeg",
|
|
3448
|
+
"application/vnd.apple.mpegurl",
|
|
3449
|
+
"text/vtt"
|
|
3450
|
+
]);
|
|
3451
|
+
function isDerivedAssetKey(key) {
|
|
3452
|
+
return DERIVED_ASSET_KEY.test(key);
|
|
3453
|
+
}
|
|
3454
|
+
async function derivedAssetPending(context, key) {
|
|
3455
|
+
if (!isDerivedAssetKey(key) || !isAuthenticated(context)) return null;
|
|
3456
|
+
const body = await context.req.json().catch(() => null);
|
|
3457
|
+
const declared = typeof body?.contentType === "string" ? body.contentType : "";
|
|
3458
|
+
const security = parseSecurityHeader(context.req.header(RESOURCE_SECURITY_HEADER));
|
|
3459
|
+
return {
|
|
3460
|
+
contentType: DERIVED_CONTENT_TYPES.has(declared) ? declared : "application/octet-stream",
|
|
3461
|
+
storageKey: storageKey(context, key),
|
|
3462
|
+
...security ? { security } : {}
|
|
3463
|
+
};
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
// src/routes/upload-pending.ts
|
|
3467
|
+
var UPLOAD_PENDING_QUERY = "u";
|
|
3468
|
+
var PENDING_ID_BYTES = 16;
|
|
3469
|
+
var PENDING_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/;
|
|
3470
|
+
function newUploadPendingId() {
|
|
3471
|
+
const bytes = crypto.getRandomValues(new Uint8Array(PENDING_ID_BYTES));
|
|
3472
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
3473
|
+
}
|
|
3474
|
+
function uploadPendingKvKey(id) {
|
|
3475
|
+
return `${KvPrefix.UPLOAD_PENDING}:${id}`;
|
|
3476
|
+
}
|
|
3477
|
+
function uploadUrlFor(key, id) {
|
|
3478
|
+
return `/v1/upload/${key}?${UPLOAD_PENDING_QUERY}=${id}`;
|
|
3479
|
+
}
|
|
3480
|
+
function uploadPendingIdOf(uploadUrl) {
|
|
3481
|
+
const id = new URL(uploadUrl, "http://localhost").searchParams.get(UPLOAD_PENDING_QUERY);
|
|
3482
|
+
if (id === null) return void 0;
|
|
3483
|
+
return PENDING_ID_PATTERN.test(id) ? id : null;
|
|
3484
|
+
}
|
|
3485
|
+
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3486
|
+
function withLegacyFieldNames(pending) {
|
|
3487
|
+
const { callerContext, objectMetadata, ...current } = pending;
|
|
3488
|
+
return {
|
|
3489
|
+
...current,
|
|
3490
|
+
...current.context === void 0 && isRecord3(callerContext) ? { context: callerContext } : {},
|
|
3491
|
+
...current.uploadMetadata === void 0 && isRecord3(objectMetadata) ? { uploadMetadata: objectMetadata } : {}
|
|
3492
|
+
};
|
|
3493
|
+
}
|
|
3494
|
+
function parsePending(raw) {
|
|
3495
|
+
if (!raw) return null;
|
|
3496
|
+
try {
|
|
3497
|
+
const pending = JSON.parse(raw);
|
|
3498
|
+
return pending?.storageKey ? withLegacyFieldNames(pending) : null;
|
|
3499
|
+
} catch {
|
|
3500
|
+
return null;
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
async function readUploadPendingRaw(kv, requestUrl, key) {
|
|
3504
|
+
const id = uploadPendingIdOf(requestUrl);
|
|
3505
|
+
if (id === void 0 || id === null) return id;
|
|
3506
|
+
const raw = await kv.get(uploadPendingKvKey(id));
|
|
3507
|
+
const pending = parsePending(raw);
|
|
3508
|
+
if (!raw || !pending || pending.key !== key) return null;
|
|
3509
|
+
return { id, raw };
|
|
3510
|
+
}
|
|
3511
|
+
function derivedSessionKvKey(context, key) {
|
|
3512
|
+
if (!isAuthenticated(context) || !isDerivedAssetKey(key)) return null;
|
|
3513
|
+
return `${KvPrefix.UPLOAD_PENDING}:${storageKey(context, key)}`;
|
|
3514
|
+
}
|
|
3515
|
+
function isUnderCallerPrefix(context, pending) {
|
|
3516
|
+
const prefix = getRequestState(context).storagePrefix;
|
|
3517
|
+
if (!isAuthenticated(context) || !prefix) return true;
|
|
3518
|
+
return pending.storageKey.startsWith(prefix);
|
|
3519
|
+
}
|
|
3520
|
+
async function lookupUploadPending(context, key) {
|
|
3521
|
+
const kv = context.env.MEDIA_KV;
|
|
3522
|
+
const capability = await readUploadPendingRaw(kv, context.req.url, key);
|
|
3523
|
+
if (capability === null) return null;
|
|
3524
|
+
if (capability) {
|
|
3525
|
+
const pending = parsePending(capability.raw);
|
|
3526
|
+
if (!pending || !isUnderCallerPrefix(context, pending)) return null;
|
|
3527
|
+
return { pending, kvKey: uploadPendingKvKey(capability.id) };
|
|
3528
|
+
}
|
|
3529
|
+
const sessionKvKey = derivedSessionKvKey(context, key);
|
|
3530
|
+
const session = sessionKvKey ? parsePending(await kv.get(sessionKvKey)) : null;
|
|
3531
|
+
return session && sessionKvKey ? { pending: session, kvKey: sessionKvKey } : void 0;
|
|
3532
|
+
}
|
|
3533
|
+
var uploadNotFound = (context) => context.json({ error: "Upload not found or expired", code: "upload_not_found" }, 404);
|
|
3534
|
+
function pendingUploadInfo(key, pending) {
|
|
3535
|
+
return {
|
|
3536
|
+
key,
|
|
3537
|
+
storageKey: pending.storageKey,
|
|
3538
|
+
contentType: pending.contentType,
|
|
3539
|
+
...pending.originalFilename ? { filename: pending.originalFilename } : {},
|
|
3540
|
+
isMultipart: Boolean(pending.multipart),
|
|
3541
|
+
...typeof pending.expiresAt === "number" ? { expiresAt: pending.expiresAt } : {},
|
|
3542
|
+
isOverwrite: Boolean(pending.isAsset && pending.overwrite),
|
|
3543
|
+
...pending.context ? { context: pending.context } : {}
|
|
3544
|
+
};
|
|
3545
|
+
}
|
|
3546
|
+
var UPLOAD_URL_PREFIX2 = "/v1/upload/";
|
|
3547
|
+
async function getPendingUpload(context) {
|
|
3548
|
+
const { path } = context.req;
|
|
3549
|
+
if (!path.startsWith(UPLOAD_URL_PREFIX2) || path.length === UPLOAD_URL_PREFIX2.length) return void 0;
|
|
3550
|
+
const key = path.slice(UPLOAD_URL_PREFIX2.length);
|
|
3551
|
+
const found = await readUploadPendingRaw(context.env.MEDIA_KV, context.req.url, key);
|
|
3552
|
+
if (!found) return found;
|
|
3553
|
+
const pending = parsePending(found.raw);
|
|
3554
|
+
return pending ? pendingUploadInfo(key, pending) : null;
|
|
3555
|
+
}
|
|
3556
|
+
async function cancelPendingUpload(context, uploadUrl) {
|
|
3557
|
+
const id = uploadPendingIdOf(uploadUrl);
|
|
3558
|
+
if (id) await context.env.MEDIA_KV.delete(uploadPendingKvKey(id));
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
// src/routes/upload-multipart.ts
|
|
3562
|
+
var MULTIPART_PENDING_TTL_SECONDS = 24 * 60 * 60;
|
|
3563
|
+
var MAX_PART_COUNT = 1e4;
|
|
3564
|
+
async function readMultipartAt(context, kvKey) {
|
|
3565
|
+
const raw = await context.env.MEDIA_KV.get(kvKey);
|
|
3566
|
+
try {
|
|
3567
|
+
return raw ? JSON.parse(raw).multipart : void 0;
|
|
3568
|
+
} catch {
|
|
3569
|
+
return void 0;
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
async function readActiveMultipart(context, key, uploadId) {
|
|
3573
|
+
const resolved = await lookupUploadPending(context, key);
|
|
3574
|
+
const multipart = resolved?.pending.multipart;
|
|
3575
|
+
if (!resolved || !multipart || !uploadId || multipart.uploadId !== uploadId) return null;
|
|
3576
|
+
return { ...resolved, multipart };
|
|
3577
|
+
}
|
|
3578
|
+
function uploadRequestKindOf(context) {
|
|
3579
|
+
return classifyUploadRequest(context.req.method, new URL(context.req.url).searchParams);
|
|
3580
|
+
}
|
|
3581
|
+
var invalidMultipartRequest = (context) => context.json({ error: "uploadId and partNumber are required for multipart calls", code: "invalid_multipart" }, 400);
|
|
3582
|
+
function expectedPartBytes(multipart, partNumber) {
|
|
3583
|
+
const partCount = multipartPartCount(multipart.size, multipart.partSize);
|
|
3584
|
+
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > partCount) return null;
|
|
3585
|
+
const { start, end } = multipartPartRange(partNumber, multipart.size, multipart.partSize);
|
|
3586
|
+
return end - start;
|
|
3587
|
+
}
|
|
3588
|
+
function normalizeCompletedParts(multipart, parts) {
|
|
3589
|
+
if (!Array.isArray(parts)) return null;
|
|
3590
|
+
const partCount = multipartPartCount(multipart.size, multipart.partSize);
|
|
3591
|
+
if (parts.length !== partCount) return null;
|
|
3592
|
+
const sorted = parts.map((part) => part).filter((part) => Number.isInteger(part.partNumber) && typeof part.etag === "string").sort((a, b) => a.partNumber - b.partNumber);
|
|
3593
|
+
const isComplete = sorted.length === partCount && sorted.every((part, index) => part.partNumber === index + 1);
|
|
3594
|
+
return isComplete ? sorted.map((part) => ({ partNumber: part.partNumber, etag: part.etag })) : null;
|
|
3595
|
+
}
|
|
3596
|
+
async function handleMultipartCreate(context, key, target) {
|
|
3597
|
+
const { pending, kvKey } = target;
|
|
3598
|
+
const body = await context.req.json().catch(() => null);
|
|
3599
|
+
const size = Number(body?.size);
|
|
3600
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
3601
|
+
return context.json({ error: "size must be a positive integer", code: "invalid_size" }, 400);
|
|
3602
|
+
}
|
|
3603
|
+
const declaredSha256 = resolveDeclaredSha256(body?.sha256 ?? pending.sha256);
|
|
3604
|
+
if (isUploadTargetError(declaredSha256)) return targetFailure(context, declaredSha256);
|
|
3605
|
+
if (pending.maxSizeBytes != null && size > pending.maxSizeBytes) {
|
|
3606
|
+
return context.json(
|
|
3607
|
+
{
|
|
3608
|
+
error: "File exceeds the allowed size for this media type",
|
|
3609
|
+
code: "file_too_large",
|
|
3610
|
+
limit: pending.maxSizeBytes
|
|
3611
|
+
},
|
|
3612
|
+
413
|
|
3613
|
+
);
|
|
3614
|
+
}
|
|
3615
|
+
const partSize = MULTIPART_PART_SIZE_BYTES;
|
|
3616
|
+
const partCount = multipartPartCount(size, partSize);
|
|
3617
|
+
if (partCount > MAX_PART_COUNT) {
|
|
3618
|
+
return context.json({ error: "File is too large for a multipart upload", code: "file_too_large" }, 413);
|
|
3619
|
+
}
|
|
3620
|
+
if (pending.multipart) {
|
|
3621
|
+
return context.json({ uploadId: pending.multipart.uploadId, partSize: pending.multipart.partSize, partCount });
|
|
3622
|
+
}
|
|
3623
|
+
if (await isTakenAsset(context, pending)) return alreadyExists(context, key);
|
|
3624
|
+
const customMetadata = {
|
|
3625
|
+
...allowedUploadMetadata(pending.uploadMetadata),
|
|
3626
|
+
...integrityMetadata(declaredSha256, false, pending.expiresAt)
|
|
3627
|
+
};
|
|
3628
|
+
if (pending.security) customMetadata.security = JSON.stringify(pending.security);
|
|
3629
|
+
const policy = resolveSecurity4(pending.security, getRequestState(context).config?.security);
|
|
3630
|
+
let enc;
|
|
3631
|
+
if (context.env.KEYS_BUCKET && shouldEncryptAtRest(policy, key, true)) {
|
|
3632
|
+
({ enc } = await createObjectEncryption(context.env.KEYS_BUCKET, pending.storageKey, { ...customMetadata }));
|
|
3633
|
+
customMetadata.enc = enc;
|
|
3634
|
+
}
|
|
3635
|
+
const multipartUpload = await context.env.MEDIA_BUCKET.createMultipartUpload(pending.storageKey, {
|
|
3636
|
+
// Video raw: the class the POST resolved from the config's retention setting (see upload.ts).
|
|
3637
|
+
storageClass: pending.storageClass ?? "Standard",
|
|
3638
|
+
httpMetadata: {
|
|
3639
|
+
contentType: pending.contentType,
|
|
3640
|
+
...pending.originalFilename ? { contentDisposition: contentDispositionInline(pending.originalFilename) } : {},
|
|
3641
|
+
...pending.cacheControl ? { cacheControl: pending.cacheControl } : {}
|
|
3642
|
+
},
|
|
3643
|
+
...Object.keys(customMetadata).length > 0 ? { customMetadata } : {}
|
|
3644
|
+
});
|
|
3645
|
+
const multipart = {
|
|
3646
|
+
uploadId: multipartUpload.uploadId,
|
|
3647
|
+
size,
|
|
3648
|
+
partSize,
|
|
3649
|
+
...enc ? { enc } : {},
|
|
3650
|
+
...declaredSha256 ? { sha256: declaredSha256 } : {}
|
|
3651
|
+
};
|
|
3652
|
+
await context.env.MEDIA_KV.put(kvKey, JSON.stringify({ ...pending, multipart }), {
|
|
3653
|
+
expirationTtl: MULTIPART_PENDING_TTL_SECONDS
|
|
3654
|
+
});
|
|
3655
|
+
const winner = await readMultipartAt(context, kvKey);
|
|
3656
|
+
if (winner && winner.uploadId !== multipart.uploadId) {
|
|
3657
|
+
await multipartUpload.abort();
|
|
3658
|
+
return context.json({ uploadId: winner.uploadId, partSize: winner.partSize, partCount });
|
|
3659
|
+
}
|
|
3660
|
+
return context.json({ uploadId: multipart.uploadId, partSize, partCount });
|
|
3661
|
+
}
|
|
3662
|
+
async function encryptPart(context, pending, multipart, partNumber, body) {
|
|
3663
|
+
if (!multipart.enc) return body;
|
|
3664
|
+
const meta = decodeEncryptionMeta5(multipart.enc);
|
|
3665
|
+
const material = meta ? await loadStorageKey(context.env.KEYS_BUCKET, pending.storageKey, meta.keyId) : null;
|
|
3666
|
+
if (!meta || !material) return null;
|
|
3667
|
+
const startBlock = (partNumber - 1) * multipart.partSize / AES_BLOCK_BYTES2;
|
|
3668
|
+
return encryptBytes(body, material.key, counterForBlock(meta.iv, startBlock));
|
|
3669
|
+
}
|
|
3670
|
+
async function handleMultipartPart(context) {
|
|
3671
|
+
const key = context.req.param("key") ?? "";
|
|
3672
|
+
const active = await readActiveMultipart(context, key, context.req.query(MULTIPART_QUERY.UPLOAD_ID));
|
|
3673
|
+
if (!active) return uploadNotFound(context);
|
|
3674
|
+
const { pending, multipart } = active;
|
|
3675
|
+
const partNumber = Number(context.req.query(MULTIPART_QUERY.PART_NUMBER));
|
|
3676
|
+
const expectedBytes = expectedPartBytes(multipart, partNumber);
|
|
3677
|
+
if (expectedBytes == null) return context.json({ error: "Invalid partNumber", code: "invalid_part" }, 400);
|
|
3678
|
+
const declaredBytes = Number(context.req.header("content-length") ?? "0");
|
|
3679
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > expectedBytes) {
|
|
3680
|
+
return context.json({ error: "Part size does not match the upload plan", code: "part_size_mismatch" }, 400);
|
|
3681
|
+
}
|
|
3682
|
+
const body = await context.req.arrayBuffer();
|
|
3683
|
+
if (body.byteLength !== expectedBytes) {
|
|
3684
|
+
return context.json({ error: "Part size does not match the upload plan", code: "part_size_mismatch" }, 400);
|
|
3685
|
+
}
|
|
3686
|
+
const partChecksumFailure = await checkPartSha256(context, multipart, body);
|
|
3687
|
+
if (partChecksumFailure) return partChecksumFailure;
|
|
3688
|
+
const storedPart = await encryptPart(context, pending, multipart, partNumber, body);
|
|
3689
|
+
if (!storedPart) return context.json({ error: "Encryption key not found for this upload" }, 500);
|
|
3690
|
+
const multipartUpload = context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId);
|
|
3691
|
+
const uploaded = await multipartUpload.uploadPart(partNumber, storedPart);
|
|
3692
|
+
return context.json({ partNumber: uploaded.partNumber, etag: uploaded.etag });
|
|
3693
|
+
}
|
|
3694
|
+
async function handleMultipartComplete(context, key) {
|
|
3695
|
+
const body = await context.req.json().catch(() => null);
|
|
3696
|
+
const active = await readActiveMultipart(context, key, body?.uploadId);
|
|
3697
|
+
if (!active) return uploadNotFound(context);
|
|
3698
|
+
const { pending, multipart, kvKey } = active;
|
|
3699
|
+
const parts = normalizeCompletedParts(multipart, body?.parts);
|
|
3700
|
+
if (!parts) return context.json({ error: "parts must list every part exactly once", code: "invalid_parts" }, 400);
|
|
3701
|
+
if (await isTakenAsset(context, pending)) return alreadyExists(context, key);
|
|
3702
|
+
const multipartUpload = context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId);
|
|
3703
|
+
const object = await multipartUpload.complete(parts);
|
|
3704
|
+
await context.env.MEDIA_KV.delete(kvKey);
|
|
3705
|
+
await discardStaleVariants(context.env.MEDIA_BUCKET, pending);
|
|
3706
|
+
const integrity = multipart.sha256 ? { sha256: multipart.sha256, sha256Verified: false } : {};
|
|
3707
|
+
return context.json({ key, size: object.size, ...integrity });
|
|
3708
|
+
}
|
|
3709
|
+
async function isTakenAsset(context, pending) {
|
|
3710
|
+
if (!pending.isAsset || pending.overwrite) return false;
|
|
3711
|
+
return Boolean(await context.env.MEDIA_BUCKET.head(pending.storageKey));
|
|
3712
|
+
}
|
|
3713
|
+
async function checkPartSha256(context, multipart, body) {
|
|
3714
|
+
const partSha256 = headerSha256(context);
|
|
3715
|
+
if (partSha256 === null) {
|
|
3716
|
+
return context.json({ error: "x-devultur-sha256 must be a 64-character hex string", code: "invalid_sha256" }, 400);
|
|
3717
|
+
}
|
|
3718
|
+
if (multipart.sha256 && !partSha256) {
|
|
3719
|
+
return context.json(
|
|
3720
|
+
{
|
|
3721
|
+
error: "Each part must carry its x-devultur-sha256 when the upload declares one",
|
|
3722
|
+
code: "part_sha256_required"
|
|
3723
|
+
},
|
|
3724
|
+
400
|
|
3725
|
+
);
|
|
3726
|
+
}
|
|
3727
|
+
if (!partSha256) return null;
|
|
3728
|
+
const digest = await digestBody(body, void 0, partSha256);
|
|
3729
|
+
if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
|
|
3730
|
+
return null;
|
|
3731
|
+
}
|
|
3732
|
+
async function handleMultipartPost(context) {
|
|
3733
|
+
const key = context.req.param("key") ?? "";
|
|
3734
|
+
const kind = uploadRequestKindOf(context);
|
|
3735
|
+
if (kind === UploadRequestKind.MULTIPART_COMPLETE) return handleMultipartComplete(context, key);
|
|
3736
|
+
if (kind === UploadRequestKind.INVALID) return invalidMultipartRequest(context);
|
|
3737
|
+
if (kind !== UploadRequestKind.MULTIPART_CREATE) return context.json({ error: "Not found" }, 404);
|
|
3738
|
+
const resolved = await lookupUploadPending(context, key);
|
|
3739
|
+
if (resolved === null) return uploadNotFound(context);
|
|
3740
|
+
if (resolved) return handleMultipartCreate(context, key, resolved);
|
|
3741
|
+
const derived = await derivedAssetPending(context, key);
|
|
3742
|
+
const derivedKvKey = derivedSessionKvKey(context, key);
|
|
3743
|
+
if (!derived || !derivedKvKey) return uploadNotFound(context);
|
|
3744
|
+
return handleMultipartCreate(context, key, { pending: derived, kvKey: derivedKvKey });
|
|
3745
|
+
}
|
|
3746
|
+
async function handleMultipartAbort(context) {
|
|
3747
|
+
if (uploadRequestKindOf(context) !== UploadRequestKind.MULTIPART_ABORT) return invalidMultipartRequest(context);
|
|
3748
|
+
const key = context.req.param("key") ?? "";
|
|
3749
|
+
const active = await readActiveMultipart(context, key, context.req.query(MULTIPART_QUERY.UPLOAD_ID));
|
|
3750
|
+
if (!active) return uploadNotFound(context);
|
|
3751
|
+
await context.env.MEDIA_BUCKET.resumeMultipartUpload(active.pending.storageKey, active.multipart.uploadId).abort();
|
|
3752
|
+
await context.env.MEDIA_KV.delete(active.kvKey);
|
|
3753
|
+
return context.json({ aborted: true });
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
// src/routes/upload.ts
|
|
3757
|
+
var upload = new Hono9();
|
|
3758
|
+
upload.post("/", async (context) => {
|
|
3759
|
+
const body = await context.req.json();
|
|
3760
|
+
const isAssetRequest = body.path !== void 0;
|
|
3761
|
+
const filename = body.filename ?? (typeof body.path === "string" ? body.path.split("/").pop() : void 0);
|
|
3762
|
+
if (!filename || !body.contentType) {
|
|
3763
|
+
return context.json({ error: "filename and contentType are required" }, 400);
|
|
3764
|
+
}
|
|
3765
|
+
const inferredType = inferMediaType(body.contentType);
|
|
3766
|
+
if (body.mediaType && inferredType && body.mediaType !== inferredType) {
|
|
3767
|
+
return context.json(
|
|
3768
|
+
{
|
|
3769
|
+
error: `mediaType "${body.mediaType}" does not match content type ${body.contentType}`,
|
|
3770
|
+
code: "media_type_mismatch"
|
|
3771
|
+
},
|
|
3772
|
+
400
|
|
3773
|
+
);
|
|
3774
|
+
}
|
|
3775
|
+
const mediaType = body.mediaType ?? inferredType ?? (isAssetRequest ? MediaType2.FILE : null);
|
|
3776
|
+
if (!mediaType) {
|
|
3777
|
+
return context.json({ error: `Unsupported content type: ${body.contentType}` }, 400);
|
|
3778
|
+
}
|
|
3779
|
+
const scope = getRequestState(context);
|
|
3780
|
+
const uploadRule = scope.config?.uploadRules?.[mediaType];
|
|
3781
|
+
if (!isContentTypeAllowed(body.contentType, uploadRule?.accept)) {
|
|
3782
|
+
return context.json(
|
|
3783
|
+
{ error: `Content type ${body.contentType} is not allowed`, code: "content_type_not_allowed" },
|
|
3784
|
+
415
|
|
3785
|
+
);
|
|
3786
|
+
}
|
|
3787
|
+
const grant = scope.access?.uploadGrant;
|
|
3788
|
+
const target = resolveUploadTarget(
|
|
3789
|
+
body,
|
|
3790
|
+
mediaType,
|
|
3791
|
+
resolveVisibility(body.visibility, uploadRule?.visibility),
|
|
3792
|
+
grant
|
|
3793
|
+
);
|
|
3794
|
+
if (isUploadTargetError(target)) return targetFailure(context, target);
|
|
3795
|
+
const expiresAt = resolveExpiresAt(body.expiresIn, grant, Date.now(), scope.config?.maxFileLifetimeSeconds);
|
|
3796
|
+
if (isUploadTargetError(expiresAt)) return targetFailure(context, expiresAt);
|
|
3797
|
+
const sha256 = resolveDeclaredSha256(body.sha256);
|
|
3798
|
+
if (isUploadTargetError(sha256)) return targetFailure(context, sha256);
|
|
3799
|
+
const cacheControl = resolveObjectCacheControl(body.cacheControl, target, scope.config?.cacheRules);
|
|
3800
|
+
if (isUploadTargetError(cacheControl)) return targetFailure(context, cacheControl);
|
|
3801
|
+
const maxSizeBytes = effectiveMaxBytes(uploadRule?.maxSizeBytes, grant);
|
|
3802
|
+
const declaredSize = Number(body.size);
|
|
3803
|
+
if (maxSizeBytes != null && Number.isFinite(declaredSize) && declaredSize > maxSizeBytes) {
|
|
3804
|
+
return context.json({ error: "File exceeds the allowed size", code: "file_too_large", limit: maxSizeBytes }, 413);
|
|
3805
|
+
}
|
|
3806
|
+
const virtualKey = target.key;
|
|
3807
|
+
const fullKey = storageKey(context, virtualKey);
|
|
3808
|
+
const shouldOverwrite = body.overwrite === true;
|
|
3809
|
+
if (target.isAsset && !shouldOverwrite && await context.env.MEDIA_BUCKET.head(fullKey)) {
|
|
3810
|
+
return alreadyExists(context, virtualKey);
|
|
3811
|
+
}
|
|
3812
|
+
if (grant) {
|
|
3813
|
+
const exhausted = await claimTokenFileSlot(context.env.MEDIA_KV, grant);
|
|
3814
|
+
if (exhausted) return targetFailure(context, exhausted);
|
|
3815
|
+
}
|
|
3816
|
+
const resolvedSecurity = resolveSecurity5(body.security, scope.config?.security);
|
|
3817
|
+
const pending = {
|
|
3818
|
+
contentType: body.contentType,
|
|
3819
|
+
storageKey: fullKey,
|
|
3820
|
+
key: virtualKey,
|
|
3821
|
+
originalFilename: filename,
|
|
3822
|
+
security: resolvedSecurity,
|
|
3823
|
+
...maxSizeBytes != null ? { maxSizeBytes } : {},
|
|
3824
|
+
...scope.context ? { context: scope.context } : {},
|
|
3825
|
+
// Only the video raw: image/file and the transcode assets always go in Standard.
|
|
3826
|
+
...mediaType === MediaType2.VIDEO && scope.config?.originalStorageClass ? { storageClass: scope.config.originalStorageClass } : {},
|
|
3827
|
+
...sha256 ? { sha256 } : {},
|
|
3828
|
+
...expiresAt ? { expiresAt } : {},
|
|
3829
|
+
...target.isAsset ? { isAsset: true, overwrite: shouldOverwrite } : {},
|
|
3830
|
+
...cacheControl ? { cacheControl } : {},
|
|
3831
|
+
...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {}
|
|
3832
|
+
};
|
|
3833
|
+
const pendingId = newUploadPendingId();
|
|
3834
|
+
await context.env.MEDIA_KV.put(uploadPendingKvKey(pendingId), JSON.stringify(pending), {
|
|
3835
|
+
expirationTtl: UPLOAD_TOKEN_TTL_SECONDS
|
|
3836
|
+
});
|
|
3837
|
+
return context.json({
|
|
3838
|
+
key: virtualKey,
|
|
3839
|
+
uploadUrl: uploadUrlFor(virtualKey, pendingId),
|
|
3840
|
+
id: target.id,
|
|
3841
|
+
kind: mediaType,
|
|
3842
|
+
visibility: target.visibility,
|
|
3843
|
+
...expiresAt ? { expiresAt } : {}
|
|
3844
|
+
});
|
|
3845
|
+
});
|
|
3846
|
+
upload.post("/:key{.+}", handleMultipartPost);
|
|
3847
|
+
upload.delete("/:key{.+}", handleMultipartAbort);
|
|
3848
|
+
upload.put("/:key{.+}", async (context) => {
|
|
3849
|
+
const kind = uploadRequestKindOf(context);
|
|
3850
|
+
if (kind === UploadRequestKind2.INVALID) return invalidMultipartRequest(context);
|
|
3851
|
+
if (kind === UploadRequestKind2.MULTIPART_PART) return handleMultipartPart(context);
|
|
3852
|
+
const key = context.req.param("key");
|
|
3853
|
+
const resolved = await lookupUploadPending(context, key);
|
|
3854
|
+
if (resolved === null || !resolved && !isAuthenticated(context)) return uploadNotFound(context);
|
|
3855
|
+
const pending = resolved?.pending ?? null;
|
|
3856
|
+
const finalStorageKey = pending?.storageKey ?? storageKey(context, key);
|
|
3857
|
+
const expectedContentType = pending?.contentType ?? null;
|
|
3858
|
+
const originalFilename = pending?.originalFilename ?? null;
|
|
3859
|
+
const maxSizeBytes = pending?.maxSizeBytes ?? null;
|
|
3860
|
+
const storageClass = pending?.storageClass ?? "Standard";
|
|
3861
|
+
const resourceSecurity = pending?.security ?? parseSecurityHeader(context.req.header(RESOURCE_SECURITY_HEADER));
|
|
3862
|
+
const contentType = context.req.header("Content-Type") ?? "application/octet-stream";
|
|
3863
|
+
if (expectedContentType && contentType !== expectedContentType) {
|
|
3864
|
+
return context.json({ error: `Expected Content-Type ${expectedContentType}, got ${contentType}` }, 400);
|
|
3865
|
+
}
|
|
3866
|
+
const declaredSize = Number(context.req.header("content-length") ?? "0");
|
|
3867
|
+
if (maxSizeBytes != null && Number.isFinite(declaredSize) && declaredSize > maxSizeBytes) {
|
|
3868
|
+
return context.json(
|
|
3869
|
+
{ error: "File exceeds the allowed size for this media type", code: "file_too_large", limit: maxSizeBytes },
|
|
3870
|
+
413
|
|
3871
|
+
);
|
|
3872
|
+
}
|
|
3873
|
+
const body = await context.req.arrayBuffer();
|
|
3874
|
+
if (maxSizeBytes != null && body.byteLength > maxSizeBytes) {
|
|
3875
|
+
return context.json(
|
|
3876
|
+
{ error: "File exceeds the allowed size for this media type", code: "file_too_large", limit: maxSizeBytes },
|
|
3877
|
+
413
|
|
3878
|
+
);
|
|
3879
|
+
}
|
|
3880
|
+
const digest = await digestBody(body, pending?.sha256, headerSha256(context));
|
|
3881
|
+
if ("invalid" in digest) {
|
|
3882
|
+
return context.json({ error: "x-devultur-sha256 must be a 64-character hex string", code: "invalid_sha256" }, 400);
|
|
3883
|
+
}
|
|
3884
|
+
if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
|
|
3885
|
+
const policy = resolveSecurity5(resourceSecurity, getRequestState(context).config?.security);
|
|
3886
|
+
const shouldEncrypt = shouldEncryptAtRest(policy, key, Boolean(context.env.KEYS_BUCKET));
|
|
3887
|
+
const customMetadata = {
|
|
3888
|
+
...allowedUploadMetadata(pending?.uploadMetadata ?? getRequestState(context).config?.uploadMetadata),
|
|
3889
|
+
...integrityMetadata(digest.sha256, true, pending?.expiresAt)
|
|
3890
|
+
};
|
|
3891
|
+
if (resourceSecurity) customMetadata.security = JSON.stringify(resourceSecurity);
|
|
3892
|
+
let storedBody = body;
|
|
3893
|
+
if (shouldEncrypt && context.env.KEYS_BUCKET) {
|
|
3894
|
+
const result = await encryptForStorage(context.env.KEYS_BUCKET, finalStorageKey, body, customMetadata);
|
|
3895
|
+
storedBody = result.body;
|
|
3896
|
+
customMetadata.enc = result.enc;
|
|
3897
|
+
}
|
|
3898
|
+
const isCreateOnly = Boolean(pending?.isAsset && !pending.overwrite);
|
|
3899
|
+
const stored = await context.env.MEDIA_BUCKET.put(finalStorageKey, storedBody, {
|
|
3900
|
+
...isCreateOnly ? { onlyIf: createOnlyCondition() } : {},
|
|
3901
|
+
// Standard except for the video raw when the config retains it ≥30 days (Infrequent Access, see
|
|
3902
|
+
// `RAW_STORAGE_CLASS`): IA has a 30-day minimum and charges retrieval ($0.01/GB) on every read — the
|
|
3903
|
+
// transcode's read, and the direct MP4 delivery if it isn't transcoded. With retention `off` the raw lives
|
|
3904
|
+
// for minutes.
|
|
3905
|
+
storageClass,
|
|
3906
|
+
httpMetadata: {
|
|
3907
|
+
contentType,
|
|
3908
|
+
...originalFilename ? { contentDisposition: contentDispositionInline(originalFilename) } : {},
|
|
3909
|
+
...pending?.cacheControl ? { cacheControl: pending.cacheControl } : {}
|
|
3910
|
+
},
|
|
3911
|
+
...Object.keys(customMetadata).length > 0 ? { customMetadata } : {}
|
|
3912
|
+
});
|
|
3913
|
+
if (isCreateOnly && !stored) {
|
|
3914
|
+
await discardObjectKey(context.env.KEYS_BUCKET, finalStorageKey, customMetadata.enc);
|
|
3915
|
+
return alreadyExists(context, key);
|
|
3916
|
+
}
|
|
3917
|
+
if (resolved) await context.env.MEDIA_KV.delete(resolved.kvKey);
|
|
3918
|
+
await discardStaleVariants(context.env.MEDIA_BUCKET, pending);
|
|
3919
|
+
return context.json({ key, size: body.byteLength, sha256: digest.sha256, sha256Verified: true });
|
|
3920
|
+
});
|
|
3921
|
+
async function discardObjectKey(keysBucket, objectStorageKey, enc) {
|
|
3922
|
+
const meta = decodeEncryptionMeta6(enc);
|
|
3923
|
+
if (keysBucket && meta) await keysBucket.delete(`${objectStorageKey}/${meta.keyId}`);
|
|
3924
|
+
}
|
|
3925
|
+
|
|
3926
|
+
// src/index.ts
|
|
3927
|
+
function createWorker(options) {
|
|
3928
|
+
const { middleware, routes, hooks } = composePlugins(options?.plugins ?? defaultPlugins());
|
|
3929
|
+
const app = new Hono10();
|
|
3930
|
+
app.use("*", logger());
|
|
3931
|
+
app.use(
|
|
3932
|
+
"*",
|
|
3933
|
+
cors({
|
|
3934
|
+
origin: "*",
|
|
3935
|
+
allowMethods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
3936
|
+
allowHeaders: ["Content-Type", "Authorization", "Range", SESSION_HEADER2, SHA256_HEADER3],
|
|
3937
|
+
exposeHeaders: [SHA256_HEADER3, SHA256_VERIFIED_HEADER2, REPR_DIGEST_HEADER2, EXPIRES_AT_HEADER2, "Content-Range"]
|
|
3938
|
+
})
|
|
3939
|
+
);
|
|
3940
|
+
app.use("*", async (context, next) => {
|
|
3941
|
+
context.set(PLUGIN_HOOKS_VARIABLE, hooks);
|
|
3942
|
+
await next();
|
|
3943
|
+
});
|
|
3944
|
+
for (const handler of middleware) app.use("*", handler);
|
|
3945
|
+
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
3946
|
+
app.post("/v1/metrics/playback", (c) => c.body(null, 204));
|
|
3947
|
+
app.route("/v1/webhooks", webhooks);
|
|
3948
|
+
app.route("/v1/upload", upload);
|
|
3949
|
+
app.route("/v1/media", media);
|
|
3950
|
+
app.route("/v1/assets", assets);
|
|
3951
|
+
app.route("/v1/transcode", transcode);
|
|
3952
|
+
app.route("/v1/captions", captions);
|
|
3953
|
+
app.route("/v1/token", token);
|
|
3954
|
+
for (const route of routes) app.route(route.path, route.app);
|
|
3955
|
+
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
|
3956
|
+
app.onError((err, c) => {
|
|
3957
|
+
console.error("Unhandled error:", err);
|
|
3958
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
3959
|
+
});
|
|
3960
|
+
return app;
|
|
3961
|
+
}
|
|
3962
|
+
export {
|
|
3963
|
+
DownloadLink,
|
|
3964
|
+
MAX_ASSET_LIST_LIMIT,
|
|
3965
|
+
MAX_UPLOAD_TOKEN_TTL_SECONDS,
|
|
3966
|
+
MaxResolution,
|
|
3967
|
+
ObjectMeta,
|
|
3968
|
+
TOKEN_REJECTED_CODE,
|
|
3969
|
+
UPLOAD_TOKEN_TYPE,
|
|
3970
|
+
WorkerAuthErrorCode,
|
|
3971
|
+
cancelPendingUpload,
|
|
3972
|
+
createWorker,
|
|
3973
|
+
credentialPolicy,
|
|
3974
|
+
defaultPlugins,
|
|
3975
|
+
deletePrefix,
|
|
3976
|
+
downloadLinks,
|
|
3977
|
+
edgeCacheTag,
|
|
3978
|
+
getPendingUpload,
|
|
3979
|
+
getRequestState,
|
|
3980
|
+
getRouteResult,
|
|
3981
|
+
hashKey,
|
|
3982
|
+
imageOriginAuthorization,
|
|
3983
|
+
isAuthenticated,
|
|
3984
|
+
isImageOriginRequest,
|
|
3985
|
+
isObjectExpired,
|
|
3986
|
+
isOpenRoute,
|
|
3987
|
+
isServiceToken,
|
|
3988
|
+
isUploadUrl,
|
|
3989
|
+
isVariantKey,
|
|
3990
|
+
logEncodeMetrics,
|
|
3991
|
+
parseEncodeMetrics,
|
|
3992
|
+
parseTransformParams,
|
|
3993
|
+
readImageOriginScope,
|
|
3994
|
+
readStoryboardKey,
|
|
3995
|
+
readTokenScopes,
|
|
3996
|
+
readTranscodeOutputKeys,
|
|
3997
|
+
readTranscodeOutputs,
|
|
3998
|
+
servedCacheControl,
|
|
3999
|
+
staticConfig,
|
|
4000
|
+
storageKey,
|
|
4001
|
+
updateRequestState,
|
|
4002
|
+
uploadGrantFromClaims,
|
|
4003
|
+
variantPrefix,
|
|
4004
|
+
vcpuSecondsPerVideoMinute,
|
|
4005
|
+
verifyViewerToken,
|
|
4006
|
+
withOutputUrls,
|
|
4007
|
+
workerSecretAuth
|
|
4008
|
+
};
|