@valbuild/server 0.140.2 → 0.141.1

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.
@@ -20,6 +20,7 @@ var net = require('node:net');
20
20
  var sizeOf = require('image-size');
21
21
  var zodValidationError = require('zod-validation-error');
22
22
  var os = require('os');
23
+ var buffer = require('buffer');
23
24
  var sucrase = require('sucrase');
24
25
  var http$1 = require('http');
25
26
  var https$1 = require('https');
@@ -2497,6 +2498,24 @@ function defineExternal(definition) {
2497
2498
  throw new Error("defineExternal is not implemented yet: phase 0 lands the contract, the registry that executes it arrives with the read endpoints.");
2498
2499
  }
2499
2500
 
2501
+ /**
2502
+ * Does saving this commit upload a file to Val's remote host?
2503
+ *
2504
+ * The question `/save` asks before it reads remote credentials. It used to ask
2505
+ * the SCHEMAS instead -- "is any of them remote?" -- and with
2506
+ * `files: { remote: true }` every media schema is, so every save needed a
2507
+ * `val login` token or an api key: a corrected typo included. Only a remote
2508
+ * FILE is uploaded, so only a commit carrying one needs credentials.
2509
+ */
2510
+ function commitCarriesRemoteFiles(preparedCommit) {
2511
+ return Object.values(preparedCommit.patchedBinaryFilesDescriptors).some(descriptor => descriptor.remote);
2512
+ }
2513
+
2514
+ /** Does this commit carry any file at all, remote or local? */
2515
+ function commitCarriesFiles(preparedCommit) {
2516
+ return Object.keys(preparedCommit.patchedBinaryFilesDescriptors).length > 0;
2517
+ }
2518
+
2500
2519
  const JwtPayloadSchema = z.z.object({
2501
2520
  sub: z.z.string(),
2502
2521
  exp: z.z.number(),
@@ -3218,6 +3237,291 @@ function readableError(error) {
3218
3237
  }
3219
3238
  }
3220
3239
 
3240
+ /**
3241
+ * The `Range` request header, for the one route that serves bytes a player
3242
+ * seeks in: `/files`.
3243
+ *
3244
+ * A `<video>` element asks for ranges as a matter of course, and Safari will
3245
+ * not play a video at all from a server that does not answer them. An HLS
3246
+ * stream whose segments are byte ranges of one file (`#EXT-X-BYTERANGE`) asks
3247
+ * for nothing else.
3248
+ *
3249
+ * Only a single range is honoured. A multi-range request (`bytes=0-1,5-6`) is
3250
+ * answered with the whole file, which RFC 9110 permits, and nothing that plays
3251
+ * media sends one. A header that does not parse is ignored the same way.
3252
+ */
3253
+
3254
+ function parseRangeHeader(header, size) {
3255
+ if (!header) {
3256
+ return {
3257
+ kind: "none"
3258
+ };
3259
+ }
3260
+ const match = /^\s*bytes\s*=\s*(.*)$/i.exec(header);
3261
+ if (!match) {
3262
+ return {
3263
+ kind: "none"
3264
+ };
3265
+ }
3266
+ const spec = match[1].trim();
3267
+ if (spec.includes(",")) {
3268
+ return {
3269
+ kind: "none"
3270
+ };
3271
+ }
3272
+ const suffix = /^-(\d+)$/.exec(spec);
3273
+ if (suffix) {
3274
+ const length = Number(suffix[1]);
3275
+ if (length === 0 || size === 0) {
3276
+ return {
3277
+ kind: "unsatisfiable"
3278
+ };
3279
+ }
3280
+ return {
3281
+ kind: "range",
3282
+ start: Math.max(0, size - length),
3283
+ end: size - 1
3284
+ };
3285
+ }
3286
+ const bounded = /^(\d+)-(\d*)$/.exec(spec);
3287
+ if (!bounded) {
3288
+ return {
3289
+ kind: "none"
3290
+ };
3291
+ }
3292
+ const start = Number(bounded[1]);
3293
+ const end = bounded[2] === "" ? undefined : Number(bounded[2]);
3294
+ if (end !== undefined && end < start) {
3295
+ // Syntactically invalid (RFC 9110 §14.1.1): ignored, not refused.
3296
+ return {
3297
+ kind: "none"
3298
+ };
3299
+ }
3300
+ if (start >= size) {
3301
+ return {
3302
+ kind: "unsatisfiable"
3303
+ };
3304
+ }
3305
+ return {
3306
+ kind: "range",
3307
+ start,
3308
+ end: Math.min(end ?? size - 1, size - 1)
3309
+ };
3310
+ }
3311
+
3312
+ /**
3313
+ * The little of HLS (RFC 8216) the server has to read: the variants of a
3314
+ * master playlist, the segment durations of a media playlist, and every URI a
3315
+ * playlist names.
3316
+ *
3317
+ * Deliberately a line scanner rather than a parser. The playlists Val reads are
3318
+ * the ones the Studio wrote, and the questions asked of them are small — "how
3319
+ * big is the largest rendition", "how long is it", "where does each URI
3320
+ * point" — so a full model of the format would be code nothing exercises.
3321
+ */
3322
+
3323
+ /** One `#EXT-X-STREAM-INF` entry of a master playlist. */
3324
+
3325
+ /** Lines of a playlist, with Windows line endings tolerated. */
3326
+ function linesOf(text) {
3327
+ return text.split(/\r?\n/);
3328
+ }
3329
+
3330
+ /**
3331
+ * The attributes of a tag, e.g. the part after `#EXT-X-STREAM-INF:`.
3332
+ *
3333
+ * Quoted values may contain commas (`CODECS="avc1.64001f,mp4a.40.2"`), which
3334
+ * is why this is not a `split(",")`. Quotes are removed from the values.
3335
+ */
3336
+ function parseHlsAttributes(list) {
3337
+ const attributes = {};
3338
+ const re = /([A-Z0-9-]+)=("[^"]*"|[^,]*)/g;
3339
+ let match;
3340
+ while ((match = re.exec(list)) !== null) {
3341
+ const [, name, rawValue] = match;
3342
+ attributes[name] = rawValue.startsWith('"') && rawValue.endsWith('"') ? rawValue.slice(1, -1) : rawValue.trim();
3343
+ }
3344
+ return attributes;
3345
+ }
3346
+
3347
+ /** Whether a playlist is a master (multivariant) playlist. */
3348
+ function isHlsMasterPlaylist(text) {
3349
+ return linesOf(text).some(line => line.trim().startsWith("#EXT-X-STREAM-INF:"));
3350
+ }
3351
+
3352
+ /** The variants of a master playlist, in the order they are listed. */
3353
+ function parseHlsMasterVariants(text) {
3354
+ const variants = [];
3355
+ let pending = null;
3356
+ for (const rawLine of linesOf(text)) {
3357
+ const line = rawLine.trim();
3358
+ if (line === "") {
3359
+ continue;
3360
+ }
3361
+ if (line.startsWith("#EXT-X-STREAM-INF:")) {
3362
+ const attributes = parseHlsAttributes(line.slice("#EXT-X-STREAM-INF:".length));
3363
+ const variant = {};
3364
+ const bandwidth = Number(attributes["BANDWIDTH"]);
3365
+ if (attributes["BANDWIDTH"] !== undefined && Number.isFinite(bandwidth)) {
3366
+ variant.bandwidth = bandwidth;
3367
+ }
3368
+ const resolution = /^(\d+)x(\d+)$/.exec(attributes["RESOLUTION"] ?? "");
3369
+ if (resolution) {
3370
+ variant.width = Number(resolution[1]);
3371
+ variant.height = Number(resolution[2]);
3372
+ }
3373
+ pending = variant;
3374
+ continue;
3375
+ }
3376
+ if (line.startsWith("#")) {
3377
+ continue;
3378
+ }
3379
+ // The URI line that follows a STREAM-INF tag belongs to it.
3380
+ if (pending) {
3381
+ variants.push({
3382
+ ...pending,
3383
+ uri: line
3384
+ });
3385
+ pending = null;
3386
+ }
3387
+ }
3388
+ return variants;
3389
+ }
3390
+
3391
+ /**
3392
+ * The variant whose dimensions describe the video: the largest picture, and
3393
+ * among equally large ones the highest bandwidth. `undefined` when no variant
3394
+ * declares a `RESOLUTION`.
3395
+ */
3396
+ function largestHlsVariant(variants) {
3397
+ let best;
3398
+ for (const variant of variants) {
3399
+ const {
3400
+ width,
3401
+ height
3402
+ } = variant;
3403
+ if (width === undefined || height === undefined) {
3404
+ continue;
3405
+ }
3406
+ if (!best || width * height > best.width * best.height || width * height === best.width * best.height && (variant.bandwidth ?? 0) > (best.bandwidth ?? 0)) {
3407
+ best = {
3408
+ ...variant,
3409
+ width,
3410
+ height
3411
+ };
3412
+ }
3413
+ }
3414
+ return best;
3415
+ }
3416
+
3417
+ /**
3418
+ * The sum of a media playlist's `#EXTINF` durations, in seconds, or
3419
+ * `undefined` when it has none (a master playlist, or not a playlist).
3420
+ */
3421
+ function sumHlsSegmentDurations(text) {
3422
+ let total = 0;
3423
+ let found = false;
3424
+ for (const rawLine of linesOf(text)) {
3425
+ const line = rawLine.trim();
3426
+ if (!line.startsWith("#EXTINF:")) {
3427
+ continue;
3428
+ }
3429
+ const seconds = Number.parseFloat(line.slice("#EXTINF:".length));
3430
+ if (Number.isFinite(seconds)) {
3431
+ total += seconds;
3432
+ found = true;
3433
+ }
3434
+ }
3435
+ return found ? total : undefined;
3436
+ }
3437
+
3438
+ /**
3439
+ * Rewrite every URI a playlist names, leaving everything else byte for byte.
3440
+ *
3441
+ * URIs are the non-comment lines (segments, variant playlists) and the `URI`
3442
+ * attribute of tags such as `#EXT-X-MAP` and `#EXT-X-MEDIA`. Line endings are
3443
+ * kept as they were.
3444
+ */
3445
+ function mapHlsUris(text, map) {
3446
+ return text.split(/(\r?\n)/).map(part => {
3447
+ if (part === "\n" || part === "\r\n") {
3448
+ return part;
3449
+ }
3450
+ const trimmed = part.trim();
3451
+ if (trimmed === "") {
3452
+ return part;
3453
+ }
3454
+ if (trimmed.startsWith("#")) {
3455
+ // Only an attribute that is exactly `URI` — preceded by the tag's
3456
+ // colon or a comma — and not, say, a `KEYFORMAT-URI` lookalike.
3457
+ return part.replace(/([:,]\s*)URI="([^"]*)"/g, (_match, before, uri) => `${before}URI="${map(uri)}"`);
3458
+ }
3459
+ const leading = part.slice(0, part.indexOf(trimmed));
3460
+ const trailing = part.slice(part.indexOf(trimmed) + trimmed.length);
3461
+ return `${leading}${map(trimmed)}${trailing}`;
3462
+ }).join("");
3463
+ }
3464
+
3465
+ /**
3466
+ * An HLS playlist served as a DRAFT, with every URI in it pointed at the draft
3467
+ * endpoint.
3468
+ *
3469
+ * Why this exists: a draft is served from `/api/val/files<path>?patch_id=…`,
3470
+ * and a player resolves the playlist's URIs against that URL. A RELATIVE URI
3471
+ * (`720p.m3u8`, how a local stream is written so it works wherever the
3472
+ * directory is served from) resolves to `/api/val/files/public/val/x/720p.m3u8`
3473
+ * — the query string is dropped by URL resolution, so the `patch_id` is gone,
3474
+ * and the endpoint looks for a PUBLISHED file that does not exist yet. An
3475
+ * ABSOLUTE remote ref (how a remote stream is written, because the content host
3476
+ * addresses files by hash) points at the content host, which has nothing until
3477
+ * publish. Either way the master plays and nothing it names does.
3478
+ *
3479
+ * So each URI is rewritten to the URL `Internal.mediaUrl` gives the same file
3480
+ * as a draft. Every file of one upload is in the same patch, so they share the
3481
+ * `patch_id` the playlist itself was requested with.
3482
+ *
3483
+ * What is NOT rewritten, and why:
3484
+ * - an absolute URL that is not a Val remote ref (another host's stream): it
3485
+ * was never in a patch;
3486
+ * - a root-relative URI (`/val/x/720p.mp4`): the Studio never writes one, and
3487
+ * it already names a published URL, so it is not this function's to guess at.
3488
+ *
3489
+ * Pure, and the whole rule: the `/files` route calls it and nothing else.
3490
+ */
3491
+ function rewriteDraftPlaylist(playlist, options) {
3492
+ return mapHlsUris(playlist, uri => rewriteUri(uri, options));
3493
+ }
3494
+ function rewriteUri(uri, {
3495
+ playlistPath,
3496
+ patchId,
3497
+ remote
3498
+ }) {
3499
+ if (core.Internal.remote.splitRemoteRef(uri).status === "success") {
3500
+ return core.Internal.mediaUrl({
3501
+ path: uri,
3502
+ patch_id: patchId
3503
+ });
3504
+ }
3505
+ if (/^[a-z][a-z0-9+.-]*:/i.test(uri) || uri.startsWith("/")) {
3506
+ // Another scheme (https:, data:, …), protocol-relative or root-relative.
3507
+ return uri;
3508
+ }
3509
+ // The query and fragment of a relative URI address the published file; the
3510
+ // draft endpoint has its own query, and fragments do not reach a server.
3511
+ const relative = uri.split(/[?#]/)[0];
3512
+ if (relative === "") {
3513
+ return uri;
3514
+ }
3515
+ // `join` from an absolute directory stays absolute: `..` stops at the root.
3516
+ const resolved = path__namespace["default"].posix.join(path__namespace["default"].posix.dirname(playlistPath), relative);
3517
+ return `/api/val/files${resolved}?patch_id=${patchId}${remote ? "&remote=true" : ""}`;
3518
+ }
3519
+
3520
+ /** Whether a served file is an HLS playlist, by its extension. */
3521
+ function isHlsPlaylistPath(filePath) {
3522
+ return filePath.split("?")[0].toLowerCase().endsWith(".m3u8");
3523
+ }
3524
+
3221
3525
  /* eslint-disable @typescript-eslint/no-unused-vars */
3222
3526
  const jsonOps = new patch.JSONOps();
3223
3527
  /**
@@ -4041,9 +4345,20 @@ class ValOps {
4041
4345
  // when constructing the url we use the patch id (and the file path)
4042
4346
  // to fetch the right file
4043
4347
  // NOTE: overwrite and use last patch_id if multiple patches modify the same file
4044
- fileFixOps[op.path.join("/")] = [{
4348
+ //
4349
+ // Keyed by where the patch_id LANDS — the field's path plus
4350
+ // `nestedFilePath` — not by the field's path alone. A video's
4351
+ // poster and caption files are file ops on the video's own path
4352
+ // with `nestedFilePath` ["poster"] / ["captions", "0"]; keyed by
4353
+ // `op.path` they overwrote the video's entry (and each other),
4354
+ // so only one of the files got a patch_id and the rest were
4355
+ // looked for at a published URL that holds nothing. JSON rather
4356
+ // than a "/" join because a segment can contain "/" (a gallery
4357
+ // key is a file path).
4358
+ const patchIdPath = op.path.concat(...(op.nestedFilePath || [])).concat("patch_id");
4359
+ fileFixOps[JSON.stringify(patchIdPath)] = [{
4045
4360
  op: "add",
4046
- path: op.path.concat(...(op.nestedFilePath || [])).concat("patch_id"),
4361
+ path: patchIdPath,
4047
4362
  value: patchId
4048
4363
  }];
4049
4364
  }
@@ -4241,7 +4556,7 @@ class ValOps {
4241
4556
  };
4242
4557
  if (validationErrors) {
4243
4558
  for (const validationError of validationErrors) {
4244
- var _validationError$fixe, _validationError$fixe2, _validationError$fixe3, _validationError$fixe4, _validationError$fixe5, _validationError$fixe6;
4559
+ var _validationError$fixe, _validationError$fixe2, _validationError$fixe3, _validationError$fixe4, _validationError$fixe5, _validationError$fixe6, _validationError$fixe7, _validationError$fixe8;
4245
4560
  if (isOnlyFileCheckValidationError(validationError)) {
4246
4561
  if (files[sourcePath]) {
4247
4562
  addError({
@@ -4261,7 +4576,7 @@ class ValOps {
4261
4576
  addError(resolved.error);
4262
4577
  }
4263
4578
  // resolved.status === "resolved" → drop silently
4264
- } else if ((_validationError$fixe3 = validationError.fixes) !== null && _validationError$fixe3 !== void 0 && _validationError$fixe3.includes("images:check-unique-folder") || (_validationError$fixe4 = validationError.fixes) !== null && _validationError$fixe4 !== void 0 && _validationError$fixe4.includes("files:check-unique-folder")) {
4579
+ } else if ((_validationError$fixe3 = validationError.fixes) !== null && _validationError$fixe3 !== void 0 && _validationError$fixe3.includes("images:check-unique-folder") || (_validationError$fixe4 = validationError.fixes) !== null && _validationError$fixe4 !== void 0 && _validationError$fixe4.includes("files:check-unique-folder") || (_validationError$fixe5 = validationError.fixes) !== null && _validationError$fixe5 !== void 0 && _validationError$fixe5.includes("videos:check-unique-folder")) {
4265
4580
  const TYPE_ERROR_MESSAGE = `This is most likely a Val version mismatch or Val bug.`;
4266
4581
  if (!validationError.value || typeof validationError.value !== "object") {
4267
4582
  addError({
@@ -4286,7 +4601,7 @@ class ValOps {
4286
4601
  // If conflictingModules is empty, directory is unique — silently drop the error.
4287
4602
  }
4288
4603
  }
4289
- } else if ((_validationError$fixe5 = validationError.fixes) !== null && _validationError$fixe5 !== void 0 && _validationError$fixe5.includes("images:check-all-files") || (_validationError$fixe6 = validationError.fixes) !== null && _validationError$fixe6 !== void 0 && _validationError$fixe6.includes("files:check-all-files")) ; else {
4604
+ } else if ((_validationError$fixe6 = validationError.fixes) !== null && _validationError$fixe6 !== void 0 && _validationError$fixe6.includes("images:check-all-files") || (_validationError$fixe7 = validationError.fixes) !== null && _validationError$fixe7 !== void 0 && _validationError$fixe7.includes("files:check-all-files") || (_validationError$fixe8 = validationError.fixes) !== null && _validationError$fixe8 !== void 0 && _validationError$fixe8.includes("videos:check-all-files")) ; else {
4290
4605
  addError(validationError);
4291
4606
  }
4292
4607
  }
@@ -5391,6 +5706,19 @@ class ValOps {
5391
5706
  async saveBinaryFileFromPatch(filePath, parentRef, patchId, bytes, mimeType, type, metadata) {
5392
5707
  return this.saveBase64EncodedBinaryFileFromPatch(filePath, parentRef, patchId, `data:${mimeType};base64,${bytes.toString("base64")}`, type, metadata);
5393
5708
  }
5709
+ /**
5710
+ * A file's bytes as something a `Range` request can be answered from
5711
+ * without reading the rest: a video is seeked in by many small ranges, and
5712
+ * each used to load the whole file.
5713
+ *
5714
+ * This default holds the whole file, which is all a mode that only gets
5715
+ * whole files can do (`ValOpsHttp`: the content service answers with the
5716
+ * file in a JSON body). `ValOpsFS` reads only the asked-for range off disk.
5717
+ */
5718
+ async openBinaryFile(filePath, fromPatch) {
5719
+ const buffer = fromPatch ? await this.getBase64EncodedBinaryFileFromPatch(filePath, fromPatch.patchId, fromPatch.remote) : await this.getBinaryFile(filePath);
5720
+ return buffer === null ? null : bufferReader(buffer);
5721
+ }
5394
5722
 
5395
5723
  // #region history
5396
5724
  //
@@ -5441,8 +5769,8 @@ class ValOps {
5441
5769
  // #endregion history
5442
5770
  }
5443
5771
  function isOnlyFileCheckValidationError(validationError) {
5444
- var _validationError$fixe7;
5445
- if ((_validationError$fixe7 = validationError.fixes) !== null && _validationError$fixe7 !== void 0 && _validationError$fixe7.every(f => f === "file:check-metadata" || f === "image:check-metadata")) {
5772
+ var _validationError$fixe9;
5773
+ if ((_validationError$fixe9 = validationError.fixes) !== null && _validationError$fixe9 !== void 0 && _validationError$fixe9.every(f => f === "file:check-metadata" || f === "image:check-metadata")) {
5446
5774
  return true;
5447
5775
  }
5448
5776
  return false;
@@ -5632,6 +5960,15 @@ function bufferFromDataUrl(dataUrl) {
5632
5960
  }
5633
5961
  }
5634
5962
 
5963
+ /** A file of known size whose bytes are read a range at a time. */
5964
+
5965
+ function bufferReader(buffer) {
5966
+ return {
5967
+ size: buffer.length,
5968
+ read: async (start, end) => buffer.subarray(start, end + 1)
5969
+ };
5970
+ }
5971
+
5635
5972
  /**
5636
5973
  * The `*.val.json` files backing a `.jsonValues()` module's entries, as paths
5637
5974
  * relative to the project root.
@@ -8066,6 +8403,26 @@ class ValOpsFS extends ValOps {
8066
8403
  errors
8067
8404
  };
8068
8405
  }
8406
+ async openBinaryFile(filePath, fromPatch) {
8407
+ let absPath;
8408
+ if (fromPatch) {
8409
+ const patchesDir = this.getPatchesDir();
8410
+ absPath = this.wherePatchFileIs(patchBinaryFile(patchesDir, fromPatch.patchId, filePath), stagedPatchBinaryFile(patchesDir, fromPatch.patchId, filePath));
8411
+ } else {
8412
+ absPath = path__namespace["default"].join(this.rootDir, ...filePath.split("/"));
8413
+ if (!this.host.fileExists(absPath)) {
8414
+ absPath = null;
8415
+ }
8416
+ }
8417
+ if (absPath === null) {
8418
+ return null;
8419
+ }
8420
+ const path = absPath;
8421
+ return {
8422
+ size: this.host.binaryFileSize(path),
8423
+ read: async (start, end) => this.host.readBinaryFileRange(path, start, end)
8424
+ };
8425
+ }
8069
8426
  async getBinaryFile(filePath) {
8070
8427
  const absPath = path__namespace["default"].join(this.rootDir, ...filePath.split("/"));
8071
8428
  if (!this.host.fileExists(absPath)) {
@@ -8188,6 +8545,30 @@ class FSOpsHost {
8188
8545
  readBinaryFile(path) {
8189
8546
  return fs__default["default"].readFileSync(path);
8190
8547
  }
8548
+ binaryFileSize(path) {
8549
+ return fs__default["default"].statSync(path).size;
8550
+ }
8551
+
8552
+ /** Bytes `start` to `end` inclusive, and only those, read off disk. */
8553
+ async readBinaryFileRange(path, start, end) {
8554
+ const handle = await fs__default["default"].promises.open(path, "r");
8555
+ try {
8556
+ const buffer$1 = buffer.Buffer.alloc(Math.max(0, end - start + 1));
8557
+ let filled = 0;
8558
+ while (filled < buffer$1.length) {
8559
+ const {
8560
+ bytesRead
8561
+ } = await handle.read(buffer$1, filled, buffer$1.length - filled, start + filled);
8562
+ if (bytesRead === 0) {
8563
+ break;
8564
+ }
8565
+ filled += bytesRead;
8566
+ }
8567
+ return buffer$1.subarray(0, filled);
8568
+ } finally {
8569
+ await handle.close();
8570
+ }
8571
+ }
8191
8572
  readUtf8File(path) {
8192
8573
  return fs__default["default"].readFileSync(path, "utf-8");
8193
8574
  }
@@ -12329,6 +12710,15 @@ const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
12329
12710
  */
12330
12711
  const VAL_APP_ENV = "app";
12331
12712
 
12713
+ /**
12714
+ * What the Val app says when a project does not store its media remotely.
12715
+ *
12716
+ * Exported so that `val publish` -- which publishes to the app, and so is
12717
+ * the last place to catch this before a build goes out -- refuses with the
12718
+ * same sentence the deployed server would.
12719
+ */
12720
+ const APP_MODE_REQUIRES_REMOTE_FILES = "This project runs in the Val app, which stores every image, file and " + "video on Val's remote content host -- there is no repository for an " + "upload to go into. Add `files: { remote: true }` to `initVal` in " + "val.config.ts, so that `s.image()`, `s.file()`, `s.video()` and the " + "sets are remote everywhere this project is read. Images already in the " + "project then fail validation until they are uploaded: `npx val validate " + "--fix` does that.";
12721
+
12332
12722
  /** Which mode the environment SAYS this is, and which variable said so. */
12333
12723
 
12334
12724
  /**
@@ -12475,7 +12865,7 @@ async function initHandlerOptions(route, opts, config) {
12475
12865
  valContentUrl
12476
12866
  });
12477
12867
  if (isProxyMode) {
12478
- var _opts$versions, _opts$versions2;
12868
+ var _config$files, _opts$versions, _opts$versions2;
12479
12869
  /*
12480
12870
  * Why this app is in http mode, in the message that says what is missing.
12481
12871
  *
@@ -12528,6 +12918,27 @@ async function initHandlerOptions(route, opts, config) {
12528
12918
  if (!maybeValProject) {
12529
12919
  throw new Error("Proxy mode does not work unless the 'project' option in val.config is defined or the VAL_PROJECT env var is set." + because);
12530
12920
  }
12921
+ /*
12922
+ * The Val app has no repository for an uploaded file to go into, so every
12923
+ * image, file and video in it is remote -- and the project has to SAY so,
12924
+ * in `val.config.ts`, rather than this server deciding it.
12925
+ *
12926
+ * Deciding it here would be easy and wrong. The schemas are built when
12927
+ * `val.config.ts` and the modules are evaluated, before this function
12928
+ * sees anything, and the same file is evaluated by `val validate`, the VS
12929
+ * Code extension and `pnpm dev`. A switch only the app could see would
12930
+ * leave every one of those reading the project's media as local: every
12931
+ * remote image reported as a mistake, and `val validate --fix` downloading
12932
+ * it back into the repository.
12933
+ *
12934
+ * So this refuses rather than covers for it. Without the check, the
12935
+ * failure is quieter and later: a local upload that the app has nowhere
12936
+ * to put, found at a publish.
12937
+ */
12938
+ // `config?.`: `initVal()` with no argument hands back no config at all.
12939
+ if (isAppEnv && (config === null || config === void 0 || (_config$files = config.files) === null || _config$files === void 0 ? void 0 : _config$files.remote) !== true) {
12940
+ throw new Error(APP_MODE_REQUIRES_REMOTE_FILES);
12941
+ }
12531
12942
  const coreVersion = (_opts$versions = opts.versions) === null || _opts$versions === void 0 ? void 0 : _opts$versions.core;
12532
12943
  if (!coreVersion) {
12533
12944
  throw new Error("Could not determine version of @valbuild/core");
@@ -12860,6 +13271,12 @@ async function resolveRemoteFileAuth(options) {
12860
13271
 
12861
13272
  /** What a publish reports back, in either shape. */
12862
13273
 
13274
+ /**
13275
+ * Where a local file a publish commits is written. Fixed: it was configurable
13276
+ * (`files.directory` in val.config.ts), nobody configured it, and the schemas
13277
+ * have their own `dir` for any collection that wants somewhere else.
13278
+ */
13279
+ const FILES_DIRECTORY = "/public/val";
12863
13280
  const ValServer = (valModules, options, callbacks) => {
12864
13281
  var _options$git;
12865
13282
  const AIContentBlock = z.z.union([z.z.object({
@@ -14876,7 +15293,6 @@ const ValServer = (valModules, options, callbacks) => {
14876
15293
  },
14877
15294
  "/publish-job-prepare": {
14878
15295
  POST: async req => {
14879
- var _options$config$files2;
14880
15296
  const auth = getAuth(req.cookies);
14881
15297
  if (auth.error) {
14882
15298
  return {
@@ -14923,7 +15339,6 @@ const ValServer = (valModules, options, callbacks) => {
14923
15339
  * push either way.
14924
15340
  */
14925
15341
  if (serverOps.sourceMode() === "connected") {
14926
- var _options$config$files;
14927
15342
  const jobPatches = await serverOps.fetchPatches({
14928
15343
  patchIds,
14929
15344
  excludePatchOps: false
@@ -14976,7 +15391,7 @@ const ValServer = (valModules, options, callbacks) => {
14976
15391
  const deploymentFiles = commit === undefined ? await serverOps.embeddedValSourceShas() : null;
14977
15392
  const sent = await serverOps.prepareJob(jobId, {
14978
15393
  tab,
14979
- filesDirectory: ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val",
15394
+ filesDirectory: FILES_DIRECTORY,
14980
15395
  ...split.archive,
14981
15396
  ...(commit !== undefined ? {
14982
15397
  gitCommit: commit
@@ -15005,7 +15420,7 @@ const ValServer = (valModules, options, callbacks) => {
15005
15420
  } = built;
15006
15421
  const sent = await serverOps.prepareJob(jobId, {
15007
15422
  tab,
15008
- filesDirectory: ((_options$config$files2 = options.config.files) === null || _options$config$files2 === void 0 ? void 0 : _options$config$files2.directory) || "/public/val",
15423
+ filesDirectory: FILES_DIRECTORY,
15009
15424
  ...split.archive
15010
15425
  });
15011
15426
  return jobPrepareAnswer(sent, {
@@ -15335,7 +15750,13 @@ const ValServer = (valModules, options, callbacks) => {
15335
15750
  * tree; a store with no working tree does only the push.
15336
15751
  */
15337
15752
  if (serverOps instanceof ValOpsMemory) {
15338
- const isRemoteRequired = getIsRemoteRequired(await serverOps.getSchemas());
15753
+ /*
15754
+ * A commit with no files uploads nothing and needs no credentials;
15755
+ * see `commitCarriesRemoteFiles`. One with a LOCAL file still goes
15756
+ * through `uploadRemoteFiles` where the project is remote, because
15757
+ * that is where a local file is refused rather than dropped.
15758
+ */
15759
+ const isRemoteRequired = commitCarriesRemoteFiles(preparedCommit) || commitCarriesFiles(preparedCommit) && getIsRemoteRequired(await serverOps.getSchemas());
15339
15760
  if (isRemoteRequired) {
15340
15761
  const authRes = await getRemoteFileAuth();
15341
15762
  if (authRes.status !== 200) {
@@ -15358,7 +15779,9 @@ const ValServer = (valModules, options, callbacks) => {
15358
15779
  }
15359
15780
  if (serverOps instanceof ValOpsFS) {
15360
15781
  var _remoteFileAuthRes;
15361
- const isRemoteRequired = getIsRemoteRequired(await serverOps.getSchemas());
15782
+ // Credentials when THIS save uploads a remote file, not whenever
15783
+ // the project has a remote schema. See `commitCarriesRemoteFiles`.
15784
+ const isRemoteRequired = commitCarriesRemoteFiles(preparedCommit);
15362
15785
  let mode;
15363
15786
  let remoteFileAuthRes;
15364
15787
  if (isRemoteRequired) {
@@ -15493,17 +15916,14 @@ const ValServer = (valModules, options, callbacks) => {
15493
15916
  */
15494
15917
  const managedBranch = serverOps.sourceMode() === "managed" ? serverOps.projectBranch() : null;
15495
15918
  const committedBinaries = serverOps.sourceMode() === "managed" ? await readCommittedBinaryFiles(serverOps, preparedCommit.patchedBinaryFilesDescriptors) : null;
15496
- const commitToGit = () => {
15497
- var _options$config$files3;
15498
- return serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files3 = options.config.files) === null || _options$config$files3 === void 0 ? void 0 : _options$config$files3.directory) || "/public/val", undefined,
15499
- /*
15500
- * Forwarded verbatim, and only the client can decide it: the
15501
- * content API closes the group it is named without checking
15502
- * that the commit shipped all of it, and whether it did needs
15503
- * the patch sets, which live in the browser.
15504
- */
15505
- body.patchGroupId);
15506
- };
15919
+ const commitToGit = () => serverOps.commit(preparedCommit, message, auth.id, FILES_DIRECTORY, undefined,
15920
+ /*
15921
+ * Forwarded verbatim, and only the client can decide it: the
15922
+ * content API closes the group it is named without checking
15923
+ * that the commit shipped all of it, and whether it did needs
15924
+ * the patch sets, which live in the browser.
15925
+ */
15926
+ body.patchGroupId);
15507
15927
  /*
15508
15928
  * A host may publish somewhere other than the repository.
15509
15929
  *
@@ -16490,34 +16910,18 @@ const ValServer = (valModules, options, callbacks) => {
16490
16910
  // nothing fetches it backend-to-backend. Neither half of the argument
16491
16911
  // above transfers. See architecture/media.md, "Why /files has no auth,
16492
16912
  // and /history/files does".
16493
- let fileBuffer;
16494
- let mimeType;
16495
16913
  const remote = query.remote === "true";
16496
- if (query.patch_id) {
16497
- fileBuffer = await serverOps.getBase64EncodedBinaryFileFromPatch(filePath, query.patch_id, remote);
16498
- mimeType = core.Internal.filenameToMimeType(filePath);
16499
- } else {
16500
- if (serverOps instanceof ValOpsHttp && remote) {
16501
- console.error(`Remote file: ${filePath} requested without patch id. This is most likely a bug in Val.`);
16502
- }
16503
- fileBuffer = await serverOps.getBinaryFile(filePath);
16914
+ const patchId = query.patch_id;
16915
+ if (!patchId && serverOps instanceof ValOpsHttp && remote) {
16916
+ console.error(`Remote file: ${filePath} requested without patch id. This is most likely a bug in Val.`);
16504
16917
  }
16505
- if (fileBuffer) {
16506
- return {
16507
- status: 200,
16508
- headers: {
16509
- // TODO: we could use ETag and return 304 instead
16510
- "Content-Type": mimeType || "application/octet-stream",
16511
- // TODO: a file requested with a patch_id is immutable for that
16512
- // patch, so it could be served "public, max-age=20000, immutable"
16513
- // instead. There used to be a `cacheControl` variable here for
16514
- // that, but its only assignment was commented out, so every
16515
- // response has always taken the revalidate branch.
16516
- "Cache-Control": "public, max-age=0, must-revalidate"
16517
- },
16518
- body: bufferToReadableStream(fileBuffer)
16519
- };
16520
- } else {
16918
+ // Opened, not read: a video is seeked in by many small ranges, and
16919
+ // only the asked-for bytes are read where the mode can (fs).
16920
+ const opened = await serverOps.openBinaryFile(filePath, patchId ? {
16921
+ patchId: patchId,
16922
+ remote
16923
+ } : null);
16924
+ if (!opened) {
16521
16925
  return {
16522
16926
  status: 404,
16523
16927
  json: {
@@ -16525,6 +16929,62 @@ const ValServer = (valModules, options, callbacks) => {
16525
16929
  }
16526
16930
  };
16527
16931
  }
16932
+ // A DRAFT playlist names files that are drafts too, at URLs that only
16933
+ // resolve once published. See `rewriteDraftPlaylist`. Playlists are
16934
+ // small, so one is read whole to be rewritten.
16935
+ const file = patchId && isHlsPlaylistPath(filePath) ? bufferReader(Buffer.from(rewriteDraftPlaylist((await opened.read(0, opened.size - 1)).toString("utf-8"), {
16936
+ playlistPath: filePath,
16937
+ patchId,
16938
+ remote
16939
+ }), "utf-8")) : opened;
16940
+ const headers = {
16941
+ // TODO: we could use ETag and return 304 instead
16942
+ // From the extension, published or draft: a `<video>` and an HLS
16943
+ // player both decide what they are holding by it, and it used to be
16944
+ // set for drafts only.
16945
+ "Content-Type": core.Internal.filenameToMimeType(filePath) || "application/octet-stream",
16946
+ // TODO: a file requested with a patch_id is immutable for that
16947
+ // patch, so it could be served "public, max-age=20000, immutable"
16948
+ // instead. There used to be a `cacheControl` variable here for
16949
+ // that, but its only assignment was commented out, so every
16950
+ // response has always taken the revalidate branch.
16951
+ "Cache-Control": "public, max-age=0, must-revalidate",
16952
+ "Accept-Ranges": "bytes"
16953
+ };
16954
+ const range = parseRangeHeader(req.headers.range, file.size);
16955
+ if (range.kind === "unsatisfiable") {
16956
+ return {
16957
+ status: 416,
16958
+ headers: {
16959
+ ...headers,
16960
+ "Content-Range": `bytes */${file.size}`
16961
+ },
16962
+ json: {
16963
+ message: `Range not satisfiable: ${req.headers.range}`
16964
+ }
16965
+ };
16966
+ }
16967
+ if (range.kind === "range") {
16968
+ const slice = await file.read(range.start, range.end);
16969
+ return {
16970
+ status: 206,
16971
+ headers: {
16972
+ ...headers,
16973
+ "Content-Range": `bytes ${range.start}-${range.end}/${file.size}`,
16974
+ "Content-Length": String(slice.length)
16975
+ },
16976
+ body: bufferToReadableStream(slice)
16977
+ };
16978
+ }
16979
+ const body = file.size === 0 ? Buffer.alloc(0) : await file.read(0, file.size - 1);
16980
+ return {
16981
+ status: 200,
16982
+ headers: {
16983
+ ...headers,
16984
+ "Content-Length": String(body.length)
16985
+ },
16986
+ body: bufferToReadableStream(body)
16987
+ };
16528
16988
  }
16529
16989
  }
16530
16990
  };
@@ -17872,301 +18332,2692 @@ class ValFSHost {
17872
18332
  }
17873
18333
  }
17874
18334
 
17875
- async function extractImageMetadata(filename, input) {
17876
- const imageSize = sizeOf__default["default"](new Uint8Array(input));
17877
- let mimeType = null;
17878
- if (imageSize.type) {
17879
- const possibleMimeType = `image/${imageSize.type}`;
17880
- if (core.Internal.MIME_TYPES_TO_EXT[possibleMimeType]) {
17881
- mimeType = possibleMimeType;
17882
- }
17883
- const filenameBasedLookup = core.Internal.filenameToMimeType(filename);
17884
- if (filenameBasedLookup) {
17885
- mimeType = filenameBasedLookup;
17886
- }
17887
- }
17888
- if (!mimeType) {
17889
- mimeType = "application/octet-stream";
17890
- }
17891
- let {
17892
- width,
17893
- height
17894
- } = imageSize;
17895
- if (!width || !height) {
17896
- width = 0;
17897
- height = 0;
17898
- }
18335
+ /**
18336
+ * The size and length of an ISO base media file (`.mp4`, `.m4v`, `.mov`),
18337
+ * read from its headers. Nothing is decoded.
18338
+ *
18339
+ * Hand-written rather than a media library, deliberately: `@valbuild/server`
18340
+ * is loaded by every app that runs Val, and the four numbers wanted here are
18341
+ * three small boxes away from the start of the `moov` box:
18342
+ *
18343
+ * moov > mvhd timescale and duration (version 0 and 1)
18344
+ * moov > mvex > mehd duration of a FRAGMENTED file, whose mvhd says 0
18345
+ * moov > trak > tkhd width and height (16.16 fixed point) and the
18346
+ * display matrix, for a rotated phone video
18347
+ * moov > trak > mdia > hdlr `vide`: which trak is the picture
18348
+ *
18349
+ * Only the box headers are read on the way to `moov`, and then `moov` itself,
18350
+ * so a multi-GB file costs a few small reads: `mdat` is seeked past, never
18351
+ * loaded. That is what {@link ByteSource} is for.
18352
+ */
18353
+
18354
+ /** Random access to a file's bytes, so a large `mdat` can be skipped. */
18355
+
18356
+ function bufferByteSource(buffer) {
17899
18357
  return {
17900
- width,
17901
- height,
17902
- mimeType
18358
+ size: buffer.length,
18359
+ read: (offset, length) => buffer.subarray(offset, offset + length)
17903
18360
  };
17904
18361
  }
17905
- async function extractFileMetadata(filename, _input // TODO: use buffer to determine mimetype
17906
- ) {
17907
- let mimeType = core.Internal.filenameToMimeType(filename);
17908
- if (!mimeType) {
17909
- mimeType = "application/octet-stream";
18362
+
18363
+ /**
18364
+ * Run `read` against a file on disk, reading only what it asks for. The file
18365
+ * is closed afterwards whatever happens.
18366
+ */
18367
+ function withFileByteSource(absolutePath, read) {
18368
+ const fd = fs__default["default"].openSync(absolutePath, "r");
18369
+ try {
18370
+ const size = fs__default["default"].fstatSync(fd).size;
18371
+ return read({
18372
+ size,
18373
+ read: (offset, length) => {
18374
+ const wanted = Math.max(0, Math.min(length, size - offset));
18375
+ const buffer = Buffer.alloc(wanted);
18376
+ let filled = 0;
18377
+ while (filled < wanted) {
18378
+ const n = fs__default["default"].readSync(fd, buffer, filled, wanted - filled, offset + filled);
18379
+ if (n === 0) {
18380
+ break;
18381
+ }
18382
+ filled += n;
18383
+ }
18384
+ return buffer.subarray(0, filled);
18385
+ }
18386
+ });
18387
+ } finally {
18388
+ fs__default["default"].closeSync(fd);
17910
18389
  }
17911
- return {
17912
- mimeType
17913
- };
17914
18390
  }
17915
-
17916
18391
  /**
17917
- * A gallery entry's key, read as what it actually is.
17918
- *
17919
- * A gallery is a record keyed by its entries' file paths. A REMOTE gallery keys
17920
- * an uploaded entry by the remote URL instead — which encodes that same path —
17921
- * so every entry has a local path, and the interesting question is whether the
17922
- * bytes are expected to be at it.
17923
- *
17924
- * **They are not, for a remote entry added the ordinary way.** `saveOrUploadFiles`
17925
- * uploads the remote descriptors to the content host and copies only the local
17926
- * ones into the working tree, so an image added through the Studio (or over MCP)
17927
- * and published has no file in the repo, by design — putting one there is what
17928
- * remote storage exists to avoid. A remote entry CAN have one, because
17929
- * `val validate --fix` promotes a local file to a remote ref and leaves the file
17930
- * where it was; so "remote" means "do not require it", never "there is not one".
17931
- *
17932
- * One copy, in its own file, because this used to be two: a `remoteKeyToLocalPath`
17933
- * in `fixHandlers` and a `galleryKeyToLocalPath` in `createFixPatch`, each
17934
- * normalising the key correctly and each then treating the result as a file that
17935
- * must exist. That agreement is what made a published remote image report as
17936
- * missing from both sides at once.
18392
+ * A `moov` larger than this is not a header worth reading into memory. A
18393
+ * two-hour film's is a few MB; this is far past any real one.
17937
18394
  */
18395
+ const MAX_MOOV_SIZE = 64 * 1024 * 1024;
17938
18396
 
17939
18397
  /**
17940
- * A key that names somewhere else, whether or not it is a ref we can read.
17941
- *
17942
- * Case-insensitive, because a scheme is (RFC 3986) — and because
17943
- * `splitRemoteRef`'s pattern is not, so `HTTPS://…` is precisely one of the
17944
- * spellings that arrives here unparsed and must not be mistaken for a path.
18398
+ * Whatever of {@link IsoBmffMetadata} the file declares. A field that cannot be
18399
+ * read is absent; nothing here throws on a file that is not ISO BMFF.
18400
+ */
18401
+ function readIsoBmffMetadata(source) {
18402
+ const moov = findTopLevelBox(source, "moov");
18403
+ if (!moov) {
18404
+ return {};
18405
+ }
18406
+ const metadata = {};
18407
+ const mvhd = childBox(moov, "mvhd");
18408
+ const timescaleAndDuration = mvhd ? readMvhd(mvhd) : undefined;
18409
+ if (timescaleAndDuration) {
18410
+ const {
18411
+ timescale,
18412
+ duration
18413
+ } = timescaleAndDuration;
18414
+ if (duration > 0) {
18415
+ metadata.duration = duration / timescale;
18416
+ } else {
18417
+ // A fragmented file (CMAF) says 0 in mvhd and puts the length in mehd,
18418
+ // in the same timescale.
18419
+ const mehd = childBox(childBox(moov, "mvex"), "mehd");
18420
+ const fragmentDuration = mehd ? readMehd(mehd) : undefined;
18421
+ if (fragmentDuration !== undefined && fragmentDuration > 0) {
18422
+ metadata.duration = fragmentDuration / timescale;
18423
+ }
18424
+ }
18425
+ }
18426
+ for (const trak of childBoxes(moov, "trak")) {
18427
+ const hdlr = childBox(childBox(trak, "mdia"), "hdlr");
18428
+ if (!hdlr || hdlr.length < 12 || hdlr.toString("latin1", 8, 12) !== "vide") {
18429
+ continue;
18430
+ }
18431
+ const tkhd = childBox(trak, "tkhd");
18432
+ const size = tkhd ? readTkhd(tkhd) : undefined;
18433
+ if (size && size.width > 0 && size.height > 0) {
18434
+ metadata.width = size.width;
18435
+ metadata.height = size.height;
18436
+ break;
18437
+ }
18438
+ }
18439
+ return metadata;
18440
+ }
18441
+
18442
+ /** The payload (after the header) of the first top-level box of a type. */
18443
+ function findTopLevelBox(source, type) {
18444
+ let offset = 0;
18445
+ while (offset + 8 <= source.size) {
18446
+ const header = source.read(offset, 16);
18447
+ if (header.length < 8) {
18448
+ return undefined;
18449
+ }
18450
+ let size = header.readUInt32BE(0);
18451
+ const boxType = header.toString("latin1", 4, 8);
18452
+ let headerSize = 8;
18453
+ if (size === 1) {
18454
+ if (header.length < 16) {
18455
+ return undefined;
18456
+ }
18457
+ size = Number(header.readBigUInt64BE(8));
18458
+ headerSize = 16;
18459
+ } else if (size === 0) {
18460
+ size = source.size - offset;
18461
+ }
18462
+ if (size < headerSize) {
18463
+ return undefined;
18464
+ }
18465
+ if (boxType === type) {
18466
+ if (size - headerSize > MAX_MOOV_SIZE) {
18467
+ return undefined;
18468
+ }
18469
+ const payload = source.read(offset + headerSize, size - headerSize);
18470
+ return payload.length === size - headerSize ? payload : undefined;
18471
+ }
18472
+ offset += size;
18473
+ }
18474
+ return undefined;
18475
+ }
18476
+
18477
+ /** Each child box of a container payload, as `[type, payload]`. */
18478
+ function* boxesIn(payload) {
18479
+ let offset = 0;
18480
+ while (offset + 8 <= payload.length) {
18481
+ let size = payload.readUInt32BE(offset);
18482
+ const type = payload.toString("latin1", offset + 4, offset + 8);
18483
+ let headerSize = 8;
18484
+ if (size === 1) {
18485
+ if (offset + 16 > payload.length) {
18486
+ return;
18487
+ }
18488
+ size = Number(payload.readBigUInt64BE(offset + 8));
18489
+ headerSize = 16;
18490
+ } else if (size === 0) {
18491
+ size = payload.length - offset;
18492
+ }
18493
+ if (size < headerSize || offset + size > payload.length) {
18494
+ return;
18495
+ }
18496
+ yield [type, payload.subarray(offset + headerSize, offset + size)];
18497
+ offset += size;
18498
+ }
18499
+ }
18500
+ function childBoxes(payload, type) {
18501
+ if (!payload) {
18502
+ return [];
18503
+ }
18504
+ const found = [];
18505
+ for (const [boxType, child] of boxesIn(payload)) {
18506
+ if (boxType === type) {
18507
+ found.push(child);
18508
+ }
18509
+ }
18510
+ return found;
18511
+ }
18512
+ function childBox(payload, type) {
18513
+ return childBoxes(payload, type)[0];
18514
+ }
18515
+
18516
+ /** "Unknown" in a version 1 box. Not a literal: `0x…n` needs an ES2020 target. */
18517
+ const ALL_ONES_64 = BigInt("0xffffffffffffffff");
18518
+ function readMvhd(mvhd) {
18519
+ const version = mvhd[0];
18520
+ if (version === 1) {
18521
+ if (mvhd.length < 32) return undefined;
18522
+ const timescale = mvhd.readUInt32BE(20);
18523
+ const duration = mvhd.readBigUInt64BE(24);
18524
+ if (timescale === 0 || duration === ALL_ONES_64) {
18525
+ return undefined;
18526
+ }
18527
+ return {
18528
+ timescale,
18529
+ duration: Number(duration)
18530
+ };
18531
+ }
18532
+ if (mvhd.length < 20) return undefined;
18533
+ const timescale = mvhd.readUInt32BE(12);
18534
+ const duration = mvhd.readUInt32BE(16);
18535
+ // All ones means "unknown" in a version 0 box.
18536
+ if (timescale === 0 || duration === 0xffffffff) {
18537
+ return undefined;
18538
+ }
18539
+ return {
18540
+ timescale,
18541
+ duration
18542
+ };
18543
+ }
18544
+ function readMehd(mehd) {
18545
+ if (mehd[0] === 1) {
18546
+ return mehd.length >= 12 ? Number(mehd.readBigUInt64BE(4)) : undefined;
18547
+ }
18548
+ return mehd.length >= 8 ? mehd.readUInt32BE(4) : undefined;
18549
+ }
18550
+
18551
+ /**
18552
+ * The size a track is SHOWN at: tkhd's width and height, swapped when the
18553
+ * display matrix turns the picture a quarter turn — a phone records upright
18554
+ * video sideways and says so here, and a page lays it out as it is shown.
18555
+ */
18556
+ function readTkhd(tkhd) {
18557
+ // Past the version-specific times and ids, then 16 bytes of reserved,
18558
+ // layer, alternate group, volume and reserved, then the 36-byte matrix.
18559
+ const matrixAt = tkhd[0] === 1 ? 4 + 32 + 16 : 4 + 20 + 16;
18560
+ const widthAt = matrixAt + 36;
18561
+ if (tkhd.length < widthAt + 8) {
18562
+ return undefined;
18563
+ }
18564
+ const a = tkhd.readInt32BE(matrixAt);
18565
+ const b = tkhd.readInt32BE(matrixAt + 4);
18566
+ const c = tkhd.readInt32BE(matrixAt + 12);
18567
+ const d = tkhd.readInt32BE(matrixAt + 16);
18568
+ const width = Math.round(tkhd.readUInt32BE(widthAt) / 0x10000);
18569
+ const height = Math.round(tkhd.readUInt32BE(widthAt + 4) / 0x10000);
18570
+ const quarterTurn = a === 0 && d === 0 && b !== 0 && c !== 0;
18571
+ return quarterTurn ? {
18572
+ width: height,
18573
+ height: width
18574
+ } : {
18575
+ width,
18576
+ height
18577
+ };
18578
+ }
18579
+
18580
+ /**
18581
+ * The size and length of a Matroska file (`.webm`, and `.mkv`, which is the
18582
+ * same container), read from its headers. Nothing is decoded.
18583
+ *
18584
+ * Hand-written for the same reason `isoBmff.ts` is: `@valbuild/server` is
18585
+ * loaded by every app that runs Val, and what is wanted here is a handful of
18586
+ * integers two levels into the file:
18587
+ *
18588
+ * EBML the header; a file without one is not Matroska
18589
+ * Segment > Info TimecodeScale and Duration
18590
+ * Segment > Tracks > TrackEntry TrackType 1 (video) >
18591
+ * Video PixelWidth / PixelHeight, and DisplayWidth /
18592
+ * DisplayHeight when they are in pixels
18593
+ *
18594
+ * Matroska is EBML: every element is a variable-length ID, a variable-length
18595
+ * size and a payload. Only element HEADERS are read on the way through the
18596
+ * Segment, and then the payloads of Info and Tracks, which are a few hundred
18597
+ * bytes. The Clusters — the frames, which are the file — are never read: the
18598
+ * walk stops at the first one.
18599
+ *
18600
+ * That is allowed because the spec (RFC 9559, section 6.3) requires the first
18601
+ * Info and Tracks to come before the first Cluster, or to be named by a
18602
+ * SeekHead that does. A file written by a browser's MediaRecorder, or by
18603
+ * anything else that streams, does not know its own length when it starts:
18604
+ * its Segment and Clusters have the "unknown" size, and its Info has no
18605
+ * Duration. The Segment is then read as running to the end of the file, and
18606
+ * the duration is left out rather than counted from the Clusters — a caller
18607
+ * reports it, as it does for a fragmented mp4.
18608
+ */
18609
+
18610
+ const EBML_HEADER = 0x1a45dfa3;
18611
+ const SEGMENT = 0x18538067;
18612
+ const SEEK_HEAD = 0x114d9b74;
18613
+ const SEEK = 0x4dbb;
18614
+ const SEEK_ID = 0x53ab;
18615
+ const SEEK_POSITION = 0x53ac;
18616
+ const INFO = 0x1549a966;
18617
+ const TIMECODE_SCALE = 0x2ad7b1;
18618
+ const DURATION = 0x4489;
18619
+ const TRACKS = 0x1654ae6b;
18620
+ const TRACK_ENTRY = 0xae;
18621
+ const TRACK_TYPE = 0x83;
18622
+ const VIDEO = 0xe0;
18623
+ const PIXEL_WIDTH = 0xb0;
18624
+ const PIXEL_HEIGHT = 0xba;
18625
+ const DISPLAY_WIDTH = 0x54b0;
18626
+ const DISPLAY_HEIGHT = 0x54ba;
18627
+ const DISPLAY_UNIT = 0x54b2;
18628
+ const CLUSTER = 0x1f43b675;
18629
+ const TRACK_TYPE_VIDEO = 1;
18630
+ const DISPLAY_UNIT_PIXELS = 0;
18631
+ /** Nanoseconds per tick, when Info does not say. */
18632
+ const DEFAULT_TIMECODE_SCALE = 1_000_000;
18633
+
18634
+ /**
18635
+ * Info, Tracks or a SeekHead larger than this is not a header worth reading
18636
+ * into memory. Tracks is the big one, for its codec private data, and that is
18637
+ * kilobytes.
18638
+ */
18639
+ const MAX_HEADER_ELEMENT_SIZE = 16 * 1024 * 1024;
18640
+
18641
+ /**
18642
+ * Whatever of {@link EbmlMetadata} the file declares. A field that cannot be
18643
+ * read is absent; nothing here throws on a file that is not Matroska.
18644
+ */
18645
+ function readEbmlMetadata(source) {
18646
+ const header = readElementHeader(source, 0);
18647
+ if (!header || header.id !== EBML_HEADER || header.size === undefined) {
18648
+ return {};
18649
+ }
18650
+ const segment = findSegment(source, header.dataStart + header.size);
18651
+ if (!segment) {
18652
+ return {};
18653
+ }
18654
+ const segmentStart = segment.dataStart;
18655
+ const segmentEnd = segment.size === undefined ? source.size : Math.min(source.size, segmentStart + segment.size);
18656
+ let info;
18657
+ let tracks;
18658
+ const seekPositions = new Map();
18659
+ let offset = segmentStart;
18660
+ while (offset < segmentEnd && !(info && tracks)) {
18661
+ const element = readElementHeader(source, offset);
18662
+ if (!element || element.id === CLUSTER) {
18663
+ break;
18664
+ }
18665
+ if (element.id === INFO && !info) {
18666
+ info = readPayload(source, element);
18667
+ } else if (element.id === TRACKS && !tracks) {
18668
+ tracks = readPayload(source, element);
18669
+ } else if (element.id === SEEK_HEAD) {
18670
+ const seekHead = readPayload(source, element);
18671
+ if (seekHead) {
18672
+ readSeekHead(seekHead, seekPositions);
18673
+ }
18674
+ }
18675
+ if (element.size === undefined) {
18676
+ // Only a Segment or a Cluster may have an unknown size, and an element
18677
+ // whose end is unknown cannot be stepped over.
18678
+ break;
18679
+ }
18680
+ offset = element.dataStart + element.size;
18681
+ }
18682
+ // Not before the first Cluster: the SeekHead says where.
18683
+ if (!info) {
18684
+ info = readSought(source, segmentStart, seekPositions, INFO);
18685
+ }
18686
+ if (!tracks) {
18687
+ tracks = readSought(source, segmentStart, seekPositions, TRACKS);
18688
+ }
18689
+ const metadata = {};
18690
+ const duration = info ? readDuration(info) : undefined;
18691
+ if (duration !== undefined) {
18692
+ metadata.duration = duration;
18693
+ }
18694
+ const size = tracks ? readVideoSize(tracks) : undefined;
18695
+ if (size) {
18696
+ metadata.width = size.width;
18697
+ metadata.height = size.height;
18698
+ }
18699
+ return metadata;
18700
+ }
18701
+ /** The first Segment among the top-level elements after the EBML header. */
18702
+ function findSegment(source, from) {
18703
+ let offset = from;
18704
+ while (offset < source.size) {
18705
+ const element = readElementHeader(source, offset);
18706
+ if (!element) {
18707
+ return undefined;
18708
+ }
18709
+ if (element.id === SEGMENT) {
18710
+ return element;
18711
+ }
18712
+ if (element.size === undefined) {
18713
+ return undefined;
18714
+ }
18715
+ offset = element.dataStart + element.size;
18716
+ }
18717
+ return undefined;
18718
+ }
18719
+ function readElementHeader(source, offset) {
18720
+ // At most 4 bytes of ID and 8 of size.
18721
+ const bytes = source.read(offset, 12);
18722
+ const id = readVint(bytes, 0, 4);
18723
+ if (!id) {
18724
+ return undefined;
18725
+ }
18726
+ const size = readVint(bytes, id.length, 8);
18727
+ if (!size) {
18728
+ return undefined;
18729
+ }
18730
+ return {
18731
+ id: id.raw,
18732
+ size: size.unknown ? undefined : size.value,
18733
+ dataStart: offset + id.length + size.length
18734
+ };
18735
+ }
18736
+ function readPayload(source, element) {
18737
+ const {
18738
+ size
18739
+ } = element;
18740
+ if (size === undefined || size > MAX_HEADER_ELEMENT_SIZE) {
18741
+ return undefined;
18742
+ }
18743
+ const payload = source.read(element.dataStart, size);
18744
+ return payload.length === size ? payload : undefined;
18745
+ }
18746
+ /**
18747
+ * An EBML variable-length integer: the number of leading zero bits of the
18748
+ * first byte, plus one, is its length in bytes. Built with arithmetic rather
18749
+ * than shifts, which are 32-bit and signed in JavaScript.
18750
+ */
18751
+ function readVint(bytes, offset, maxLength) {
18752
+ if (offset >= bytes.length) {
18753
+ return undefined;
18754
+ }
18755
+ const first = bytes[offset];
18756
+ if (first === 0) {
18757
+ return undefined;
18758
+ }
18759
+ const length = Math.clz32(first) - 23;
18760
+ if (length > maxLength || offset + length > bytes.length) {
18761
+ return undefined;
18762
+ }
18763
+ const valueMask = 0xff >> length;
18764
+ let raw = first;
18765
+ let value = first & valueMask;
18766
+ let unknown = value === valueMask;
18767
+ for (let i = 1; i < length; i++) {
18768
+ const byte = bytes[offset + i];
18769
+ raw = raw * 256 + byte;
18770
+ value = value * 256 + byte;
18771
+ unknown = unknown && byte === 0xff;
18772
+ }
18773
+ return {
18774
+ length,
18775
+ raw,
18776
+ value,
18777
+ unknown
18778
+ };
18779
+ }
18780
+
18781
+ /** Each child element of a master element's payload, as `[id, payload]`. */
18782
+ function* elementsIn(payload) {
18783
+ let offset = 0;
18784
+ while (offset < payload.length) {
18785
+ const id = readVint(payload, offset, 4);
18786
+ if (!id) {
18787
+ return;
18788
+ }
18789
+ const size = readVint(payload, offset + id.length, 8);
18790
+ if (!size || size.unknown) {
18791
+ return;
18792
+ }
18793
+ const dataStart = offset + id.length + size.length;
18794
+ const end = dataStart + size.value;
18795
+ if (end > payload.length) {
18796
+ return;
18797
+ }
18798
+ yield [id.raw, payload.subarray(dataStart, end)];
18799
+ offset = end;
18800
+ }
18801
+ }
18802
+ function child(payload, id) {
18803
+ for (const [childId, data] of elementsIn(payload)) {
18804
+ if (childId === id) {
18805
+ return data;
18806
+ }
18807
+ }
18808
+ return undefined;
18809
+ }
18810
+ function readUint(data) {
18811
+ if (!data || data.length > 8) {
18812
+ return undefined;
18813
+ }
18814
+ let value = 0;
18815
+ for (const byte of data) {
18816
+ value = value * 256 + byte;
18817
+ }
18818
+ return value;
18819
+ }
18820
+ function readFloat(data) {
18821
+ if ((data === null || data === void 0 ? void 0 : data.length) === 4) {
18822
+ return data.readFloatBE(0);
18823
+ }
18824
+ if ((data === null || data === void 0 ? void 0 : data.length) === 8) {
18825
+ return data.readDoubleBE(0);
18826
+ }
18827
+ return undefined;
18828
+ }
18829
+
18830
+ /** Where each top-level element a SeekHead names is, from the Segment's data. */
18831
+ function readSeekHead(seekHead, into) {
18832
+ for (const [id, seek] of elementsIn(seekHead)) {
18833
+ if (id !== SEEK) {
18834
+ continue;
18835
+ }
18836
+ const seekId = child(seek, SEEK_ID);
18837
+ const position = readUint(child(seek, SEEK_POSITION));
18838
+ if (seekId && seekId.length <= 4 && position !== undefined) {
18839
+ const target = readUint(seekId);
18840
+ if (target !== undefined && !into.has(target)) {
18841
+ into.set(target, position);
18842
+ }
18843
+ }
18844
+ }
18845
+ }
18846
+ function readSought(source, segmentStart, seekPositions, id) {
18847
+ const position = seekPositions.get(id);
18848
+ if (position === undefined) {
18849
+ return undefined;
18850
+ }
18851
+ const element = readElementHeader(source, segmentStart + position);
18852
+ return (element === null || element === void 0 ? void 0 : element.id) === id ? readPayload(source, element) : undefined;
18853
+ }
18854
+
18855
+ /**
18856
+ * Info's Duration is a float in ticks of TimecodeScale nanoseconds. Absent in
18857
+ * a file written as it was recorded, and then absent here too.
18858
+ */
18859
+ function readDuration(info) {
18860
+ const duration = readFloat(child(info, DURATION));
18861
+ if (duration === undefined || !Number.isFinite(duration) || duration <= 0) {
18862
+ return undefined;
18863
+ }
18864
+ const scale = readUint(child(info, TIMECODE_SCALE)) ?? DEFAULT_TIMECODE_SCALE;
18865
+ if (scale <= 0) {
18866
+ return undefined;
18867
+ }
18868
+ return duration * scale / 1e9;
18869
+ }
18870
+
18871
+ /**
18872
+ * The size the first video track is SHOWN at. DisplayWidth and DisplayHeight
18873
+ * are what a non-square-pixel (anamorphic) video is laid out at, so they win
18874
+ * over the stored PixelWidth and PixelHeight — but only when DisplayUnit says
18875
+ * they are pixels. The other units (centimetres, inches, a bare aspect ratio)
18876
+ * are not a size a page can use, so the pixel size is reported instead.
18877
+ */
18878
+ function readVideoSize(tracks) {
18879
+ for (const [id, entry] of elementsIn(tracks)) {
18880
+ if (id !== TRACK_ENTRY || readUint(child(entry, TRACK_TYPE)) !== TRACK_TYPE_VIDEO) {
18881
+ continue;
18882
+ }
18883
+ const video = child(entry, VIDEO);
18884
+ if (!video) {
18885
+ continue;
18886
+ }
18887
+ const pixelWidth = readUint(child(video, PIXEL_WIDTH));
18888
+ const pixelHeight = readUint(child(video, PIXEL_HEIGHT));
18889
+ const displayUnit = readUint(child(video, DISPLAY_UNIT)) ?? DISPLAY_UNIT_PIXELS;
18890
+ if (displayUnit === DISPLAY_UNIT_PIXELS) {
18891
+ const displayWidth = readUint(child(video, DISPLAY_WIDTH));
18892
+ const displayHeight = readUint(child(video, DISPLAY_HEIGHT));
18893
+ if (displayWidth && displayHeight) {
18894
+ return {
18895
+ width: displayWidth,
18896
+ height: displayHeight
18897
+ };
18898
+ }
18899
+ }
18900
+ if (pixelWidth && pixelHeight) {
18901
+ return {
18902
+ width: pixelWidth,
18903
+ height: pixelHeight
18904
+ };
18905
+ }
18906
+ }
18907
+ return undefined;
18908
+ }
18909
+
18910
+ async function extractImageMetadata(filename, input) {
18911
+ const imageSize = sizeOf__default["default"](new Uint8Array(input));
18912
+ let mimeType = null;
18913
+ if (imageSize.type) {
18914
+ const possibleMimeType = `image/${imageSize.type}`;
18915
+ if (core.Internal.MIME_TYPES_TO_EXT[possibleMimeType]) {
18916
+ mimeType = possibleMimeType;
18917
+ }
18918
+ const filenameBasedLookup = core.Internal.filenameToMimeType(filename);
18919
+ if (filenameBasedLookup) {
18920
+ mimeType = filenameBasedLookup;
18921
+ }
18922
+ }
18923
+ if (!mimeType) {
18924
+ mimeType = "application/octet-stream";
18925
+ }
18926
+ let {
18927
+ width,
18928
+ height
18929
+ } = imageSize;
18930
+ if (!width || !height) {
18931
+ width = 0;
18932
+ height = 0;
18933
+ }
18934
+ return {
18935
+ width,
18936
+ height,
18937
+ mimeType
18938
+ };
18939
+ }
18940
+ async function extractFileMetadata(filename, _input // TODO: use buffer to determine mimetype
18941
+ ) {
18942
+ let mimeType = core.Internal.filenameToMimeType(filename);
18943
+ if (!mimeType) {
18944
+ mimeType = "application/octet-stream";
18945
+ }
18946
+ return {
18947
+ mimeType
18948
+ };
18949
+ }
18950
+
18951
+ /**
18952
+ * What `val validate --fix` writes into a video: `mimeType`, `width`, `height`
18953
+ * and `duration`, read from the file. A field that cannot be read is left out
18954
+ * rather than guessed, and the caller reports it — see
18955
+ * {@link unreadableVideoMetadataMessage}.
18956
+ *
18957
+ * `mimeType` always comes from the file EXTENSION: validation compares the two,
18958
+ * so any other answer is one validation rejects. The rest depends on the kind
18959
+ * of file:
18960
+ *
18961
+ * - **ISO BMFF** (`.mp4`, `.m4v`, `.mov`): read from the `moov` box by a small
18962
+ * parser of our own (`isoBmff.ts`). No media library: this package is loaded
18963
+ * by every app that runs Val.
18964
+ * - **WebM / Matroska** (`.webm`, `.mkv`): read from the Segment's Info and Tracks by another
18965
+ * (`ebml.ts`). A WebM recorded in a browser does not declare its length, and
18966
+ * then `duration` is left out — the frames are not counted to find it.
18967
+ * - **An HLS master playlist** (`.m3u8`): the dimensions are the largest
18968
+ * rendition's `RESOLUTION`, and the duration is the sum of the `#EXTINF`s of
18969
+ * the first media playlist it names, read from disk beside it — which is why
18970
+ * `filename` must be the playlist's path on disk.
18971
+ * - **Anything else**: the mime type and nothing more. The Studio reads those
18972
+ * in the browser when they are uploaded.
18973
+ */
18974
+ async function extractVideoMetadata(filename, input, readFile = readFileOrUndefined$2) {
18975
+ if (isHlsPath(filename)) {
18976
+ return extractHlsMetadata(filename, input.toString("utf-8"), readFile);
18977
+ }
18978
+ return extractFromByteSource(filename, bufferByteSource(input));
18979
+ }
18980
+
18981
+ /**
18982
+ * {@link extractVideoMetadata} for a file on disk, reading only the headers
18983
+ * it needs: a video's frames (an mp4's `mdat`, a WebM's Clusters) are most of
18984
+ * the file, and are skipped, not loaded.
18985
+ */
18986
+ async function extractVideoMetadataFromFile(absolutePath) {
18987
+ if (isHlsPath(absolutePath)) {
18988
+ return extractVideoMetadata(absolutePath, fs__default["default"].readFileSync(absolutePath));
18989
+ }
18990
+ return withFileByteSource(absolutePath, source => extractFromByteSource(absolutePath, source));
18991
+ }
18992
+ const ISO_BMFF_EXTENSIONS = [".mp4", ".m4v", ".mov"];
18993
+ const EBML_EXTENSIONS = [".webm", ".mkv"];
18994
+
18995
+ /** The extensions whose size and length Val reads itself. */
18996
+ const READABLE_VIDEO_EXTENSIONS = [...ISO_BMFF_EXTENSIONS, ...EBML_EXTENSIONS, ".m3u8"];
18997
+
18998
+ /**
18999
+ * What to tell someone whose video's `fields` could not be read. Names the
19000
+ * way out, because "could not read" alone leaves them nowhere to go.
19001
+ */
19002
+ function unreadableVideoMetadataMessage(fileRef, fields) {
19003
+ const extension = extensionOf(fileRef);
19004
+ if (!canReadVideoMetadata(fileRef)) {
19005
+ return `Val cannot read the size and length of a ${extension || "file without an extension"} file on the command line. Upload it again in the Val Studio, or add ${listOf(fields)} by hand.`;
19006
+ }
19007
+ return `Val could not read the ${listOf(fields)} of ${fileRef}. Upload it again in the Val Studio, or add ${listOf(fields)} by hand.`;
19008
+ }
19009
+
19010
+ /** Whether Val reads this video's size and length itself, by its extension. */
19011
+ function canReadVideoMetadata(fileRef) {
19012
+ return READABLE_VIDEO_EXTENSIONS.includes(extensionOf(fileRef));
19013
+ }
19014
+ function extensionOf(fileRef) {
19015
+ return path__namespace["default"].extname(fileRef.split("?")[0]).toLowerCase();
19016
+ }
19017
+ function listOf(fields) {
19018
+ return fields.length <= 1 ? fields.join("") : `${fields.slice(0, -1).join(", ")} and ${fields[fields.length - 1]}`;
19019
+ }
19020
+ function isHlsPath(filename) {
19021
+ return path__namespace["default"].extname(filename).toLowerCase() === ".m3u8";
19022
+ }
19023
+ function extractFromByteSource(filename, source) {
19024
+ const metadata = {};
19025
+ const mimeType = core.Internal.filenameToMimeType(filename);
19026
+ if (mimeType) {
19027
+ metadata.mimeType = mimeType;
19028
+ }
19029
+ const extension = extensionOf(filename);
19030
+ const read = ISO_BMFF_EXTENSIONS.includes(extension) ? readIsoBmffMetadata(source) : EBML_EXTENSIONS.includes(extension) ? readEbmlMetadata(source) : undefined;
19031
+ if (!read) {
19032
+ return metadata;
19033
+ }
19034
+ if (read.width !== undefined && read.height !== undefined) {
19035
+ metadata.width = read.width;
19036
+ metadata.height = read.height;
19037
+ }
19038
+ const duration = roundSeconds(read.duration);
19039
+ if (duration !== undefined) {
19040
+ metadata.duration = duration;
19041
+ }
19042
+ return metadata;
19043
+ }
19044
+
19045
+ /**
19046
+ * An HLS stream's metadata from its master playlist's text, and — for the
19047
+ * duration — the first media playlist it names, which `readMediaPlaylist`
19048
+ * fetches by the URI the master gives (relative to it, or a remote ref). The
19049
+ * one implementation for a stream on disk and one on Val Remote.
19050
+ */
19051
+ async function readHlsMetadata(text, readMediaPlaylist) {
19052
+ const metadata = {
19053
+ mimeType: core.Internal.media.HLS_MIME_TYPE
19054
+ };
19055
+ if (!isHlsMasterPlaylist(text)) {
19056
+ // A media playlist on its own: it knows how long it is, not how big.
19057
+ const duration = roundSeconds(sumHlsSegmentDurations(text));
19058
+ if (duration !== undefined) {
19059
+ metadata.duration = duration;
19060
+ }
19061
+ return metadata;
19062
+ }
19063
+ const variants = parseHlsMasterVariants(text);
19064
+ const largest = largestHlsVariant(variants);
19065
+ if (largest) {
19066
+ metadata.width = largest.width;
19067
+ metadata.height = largest.height;
19068
+ }
19069
+ // Every rendition is the same length, so the first one listed is as good as
19070
+ // any.
19071
+ const first = variants[0];
19072
+ if (first) {
19073
+ const mediaPlaylist = await readMediaPlaylist(first.uri);
19074
+ if (mediaPlaylist !== undefined) {
19075
+ const duration = roundSeconds(sumHlsSegmentDurations(mediaPlaylist));
19076
+ if (duration !== undefined) {
19077
+ metadata.duration = duration;
19078
+ }
19079
+ }
19080
+ }
19081
+ return metadata;
19082
+ }
19083
+ function extractHlsMetadata(filename, text, readFile) {
19084
+ // Only a relative URI can be read from disk: the Studio writes relative
19085
+ // URIs into a local stream.
19086
+ return readHlsMetadata(text, async uri => {
19087
+ var _readFile;
19088
+ return isRelativeUri(uri) ? (_readFile = readFile(path__namespace["default"].resolve(path__namespace["default"].dirname(filename), uri.split(/[?#]/)[0]))) === null || _readFile === void 0 ? void 0 : _readFile.toString("utf-8") : undefined;
19089
+ });
19090
+ }
19091
+ function isRelativeUri(uri) {
19092
+ return !/^[a-z][a-z0-9+.-]*:/i.test(uri) && !uri.startsWith("/");
19093
+ }
19094
+
19095
+ /**
19096
+ * Seconds to the millisecond. A duration summed from `#EXTINF`s or computed
19097
+ * from timestamps carries float noise (`5.999999999`), and this value is
19098
+ * written into a `.val.ts` someone reads.
19099
+ */
19100
+ function roundSeconds(seconds) {
19101
+ if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) {
19102
+ return undefined;
19103
+ }
19104
+ return Math.round(seconds * 1000) / 1000;
19105
+ }
19106
+ function readFileOrUndefined$2(absolutePath) {
19107
+ try {
19108
+ return fs__default["default"].readFileSync(absolutePath);
19109
+ } catch {
19110
+ return undefined;
19111
+ }
19112
+ }
19113
+
19114
+ /**
19115
+ * A remote video's size and length, read over HTTP without downloading it.
19116
+ *
19117
+ * The parsers (`isoBmff.ts`, `ebml.ts`) are synchronous and ask a
19118
+ * {@link ByteSource} for the few header bytes they need. Here that source is
19119
+ * a cache of byte ranges fetched with `Range` requests: when the parser asks
19120
+ * for bytes the cache does not hold, the read throws {@link NeedBytes}, the
19121
+ * missing range is fetched, and the parser runs again from the start. Parsing
19122
+ * headers is microseconds and fetching is milliseconds, so re-running is the
19123
+ * cheap side of that trade — and it keeps ONE parser for files on disk and
19124
+ * files on Val Remote, rather than an async copy of each.
19125
+ *
19126
+ * An mp4 with `moov` first costs one request; one with `moov` after `mdat`
19127
+ * costs about three (the start, the box header past `mdat`, and `moov`
19128
+ * itself), however large the file. An HLS stream is its master playlist and
19129
+ * one media playlist.
19130
+ */
19131
+
19132
+ /** How much to fetch past what was asked for: box headers come in runs. */
19133
+ const READ_AHEAD = 64 * 1024;
19134
+ /** A parser that needs more fetches than this is not reading headers. */
19135
+ const MAX_FETCHES = 16;
19136
+ /** Thrown by a cached read that needs bytes not fetched yet. */
19137
+ class NeedBytes {
19138
+ constructor(offset, length) {
19139
+ this.offset = offset;
19140
+ this.length = length;
19141
+ }
19142
+ }
19143
+
19144
+ /**
19145
+ * Read `url`'s metadata. `nameForType` is the file name its type is read off —
19146
+ * for a remote ref, the `/public/...` path inside it, since the ref's own URL
19147
+ * ends in the same name but is not where a reader would look for it.
19148
+ */
19149
+ async function extractVideoMetadataFromUrl(url, nameForType, fetchImpl = fetch) {
19150
+ if (nameForType.split("?")[0].toLowerCase().endsWith(".m3u8")) {
19151
+ const master = await fetchText(url, fetchImpl);
19152
+ return readHlsMetadata(master, uri => fetchText(new URL(uri, url).toString(), fetchImpl).catch(() => undefined));
19153
+ }
19154
+ const cache = new RangeCache(url, fetchImpl);
19155
+ for (let fetches = 0;; fetches++) {
19156
+ const source = await cache.source();
19157
+ try {
19158
+ return extractFromByteSource(nameForType, source);
19159
+ } catch (err) {
19160
+ if (!(err instanceof NeedBytes)) {
19161
+ throw err;
19162
+ }
19163
+ if (fetches >= MAX_FETCHES) {
19164
+ throw new Error(`Reading the headers of ${url} took more than ${MAX_FETCHES} requests`, {
19165
+ cause: err
19166
+ });
19167
+ }
19168
+ await cache.fetch(err.offset, err.length);
19169
+ }
19170
+ }
19171
+ }
19172
+
19173
+ /** The local `/public/...` path a remote ref names, for its file type. */
19174
+ function nameForTypeOf(ref) {
19175
+ const split = core.Internal.remote.splitRemoteRef(ref);
19176
+ return split.status === "success" ? `/${split.filePath}` : ref;
19177
+ }
19178
+ async function fetchText(url, fetchImpl) {
19179
+ const res = await fetchImpl(url, {
19180
+ headers: {}
19181
+ });
19182
+ if (!res.ok) {
19183
+ throw new Error(`Could not read ${url}: HTTP ${res.status}`);
19184
+ }
19185
+ return res.text();
19186
+ }
19187
+ class RangeCache {
19188
+ /** Fetched ranges, as [start, bytes]. Few, so a list is enough. */
19189
+ chunks = [];
19190
+ size;
19191
+ constructor(url, fetchImpl) {
19192
+ this.url = url;
19193
+ this.fetchImpl = fetchImpl;
19194
+ }
19195
+
19196
+ /** A source over what is cached, after fetching the start if nothing is. */
19197
+ async source() {
19198
+ if (this.size === undefined) {
19199
+ await this.fetch(0, READ_AHEAD);
19200
+ }
19201
+ const size = this.size ?? 0;
19202
+ return {
19203
+ size,
19204
+ read: (offset, length) => {
19205
+ const end = Math.min(offset + length, size);
19206
+ if (end <= offset) {
19207
+ return Buffer.alloc(0);
19208
+ }
19209
+ for (const [start, bytes] of this.chunks) {
19210
+ if (start <= offset && end <= start + bytes.length) {
19211
+ return bytes.subarray(offset - start, end - start);
19212
+ }
19213
+ }
19214
+ throw new NeedBytes(offset, end - offset);
19215
+ }
19216
+ };
19217
+ }
19218
+ async fetch(offset, length) {
19219
+ const last = offset + Math.max(length, READ_AHEAD) - 1;
19220
+ const res = await this.fetchImpl(this.url, {
19221
+ headers: {
19222
+ Range: `bytes=${offset}-${last}`
19223
+ }
19224
+ });
19225
+ if (res.status === 200) {
19226
+ // The server ignored the range and sent the whole file. That answers
19227
+ // every read there will be.
19228
+ const whole = Buffer.from(await res.arrayBuffer());
19229
+ this.chunks = [[0, whole]];
19230
+ this.size = whole.length;
19231
+ return;
19232
+ }
19233
+ if (res.status !== 206) {
19234
+ throw new Error(`Could not read ${this.url}: HTTP ${res.status}`);
19235
+ }
19236
+ // `bytes 0-65535/1234567`: where the bytes start, and how big the file is.
19237
+ const range = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(res.headers.get("content-range") ?? "");
19238
+ if (!range) {
19239
+ throw new Error(`Could not read ${this.url}: a partial answer without a Content-Range`);
19240
+ }
19241
+ const bytes = Buffer.from(await res.arrayBuffer());
19242
+ if (range[3] !== "*") {
19243
+ this.size = Number(range[3]);
19244
+ } else if (this.size === undefined) {
19245
+ throw new Error(`Could not read ${this.url}: its size is not known`);
19246
+ }
19247
+ this.chunks.push([Number(range[1]), bytes]);
19248
+ }
19249
+ }
19250
+
19251
+ /**
19252
+ * A gallery entry's key, read as what it actually is.
19253
+ *
19254
+ * A gallery is a record keyed by its entries' file paths. A REMOTE gallery keys
19255
+ * an uploaded entry by the remote URL instead — which encodes that same path —
19256
+ * so every entry has a local path, and the interesting question is whether the
19257
+ * bytes are expected to be at it.
19258
+ *
19259
+ * **They are not, for a remote entry added the ordinary way.** `saveOrUploadFiles`
19260
+ * uploads the remote descriptors to the content host and copies only the local
19261
+ * ones into the working tree, so an image added through the Studio (or over MCP)
19262
+ * and published has no file in the repo, by design — putting one there is what
19263
+ * remote storage exists to avoid. A remote entry CAN have one, because
19264
+ * `val validate --fix` promotes a local file to a remote ref and leaves the file
19265
+ * where it was; so "remote" means "do not require it", never "there is not one".
19266
+ *
19267
+ * One copy, in its own file, because this used to be two: a `remoteKeyToLocalPath`
19268
+ * in `fixHandlers` and a `galleryKeyToLocalPath` in `createFixPatch`, each
19269
+ * normalising the key correctly and each then treating the result as a file that
19270
+ * must exist. That agreement is what made a published remote image report as
19271
+ * missing from both sides at once.
19272
+ */
19273
+
19274
+ /**
19275
+ * A key that names somewhere else, whether or not it is a ref we can read.
19276
+ *
19277
+ * Case-insensitive, because a scheme is (RFC 3986) — and because
19278
+ * `splitRemoteRef`'s pattern is not, so `HTTPS://…` is precisely one of the
19279
+ * spellings that arrives here unparsed and must not be mistaken for a path.
17945
19280
  */
17946
19281
  function isRemoteUrl(key) {
17947
19282
  return /^https?:\/\//i.test(key);
17948
19283
  }
17949
- function galleryEntryOf(key) {
17950
- const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
17951
- if (remoteRefRes.status === "success") {
19284
+ function galleryEntryOf(key) {
19285
+ const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
19286
+ if (remoteRefRes.status === "success") {
19287
+ return {
19288
+ localPath: `/${remoteRefRes.filePath}`,
19289
+ remote: true
19290
+ };
19291
+ }
19292
+ if (isRemoteUrl(key)) {
19293
+ // A URL that is not a ref this version can read: truncated by a hand edit,
19294
+ // a path outside `public/`, a `..` segment — or written by a core version
19295
+ // whose format `splitRemoteRef` rejects.
19296
+ //
19297
+ // Still not a path in the working tree, so the on-disk checks must not run
19298
+ // against it. They would find nothing at `<projectRoot>/https://…`, report
19299
+ // the entry as missing, and `--fix` would DELETE it — the exact failure
19300
+ // this module exists to prevent, arriving at the one moment the key is the
19301
+ // only remaining record of where the bytes went.
19302
+ //
19303
+ // Nothing is being hidden by this: a malformed key is already reported, by
19304
+ // the record schema's own "Invalid remote URL format". That is an error for
19305
+ // a person to look at, not a file for `--fix` to go missing.
19306
+ return {
19307
+ localPath: key,
19308
+ remote: true
19309
+ };
19310
+ }
19311
+ return {
19312
+ localPath: key,
19313
+ remote: false
19314
+ };
19315
+ }
19316
+
19317
+ /**
19318
+ * The media path a validation error is about, if it is about media at all.
19319
+ *
19320
+ * There is no schema in hand here — a `ValidationError` carries only the value
19321
+ * it flagged — so the shape is all there is to go on.
19322
+ */
19323
+ function getValidationErrorFileRef(validationError) {
19324
+ const maybePath = validationError.value && typeof validationError.value === "object" && "path" in validationError.value && typeof validationError.value.path === "string" ? validationError.value.path : undefined;
19325
+ if (!maybePath) {
19326
+ return null;
19327
+ }
19328
+ return maybePath;
19329
+ }
19330
+
19331
+ const textEncoder$1 = new TextEncoder();
19332
+ async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
19333
+ const remoteRefDataRes = core.Internal.remote.splitRemoteRef(ref);
19334
+ if (remoteRefDataRes.status === "error") {
19335
+ return remoteRefDataRes;
19336
+ }
19337
+ const fileHash = remoteRefDataRes.fileHash;
19338
+ if (!fileHash) {
19339
+ return {
19340
+ status: "error",
19341
+ error: `File hash is missing in remote ref: ${ref}`
19342
+ };
19343
+ }
19344
+ const relativeFilePath = remoteRefDataRes.filePath;
19345
+ if (!relativeFilePath) {
19346
+ return {
19347
+ status: "error",
19348
+ error: `File path is missing in remote ref: ${ref}`
19349
+ };
19350
+ }
19351
+ if (!relativeFilePath.startsWith("public/")) {
19352
+ return {
19353
+ status: "error",
19354
+ error: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`
19355
+ };
19356
+ }
19357
+ const coreVersion = core.Internal.VERSION.core || "unknown";
19358
+ const fileExt = getFileExt(relativeFilePath);
19359
+ const currentValidationHash = core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, metadata,
19360
+ // TODO: validate
19361
+ fileHash, textEncoder$1);
19362
+ const validationHashFromRemoteRefValues = core.Internal.remote.getValidationHash(remoteRefDataRes.version, schema, getFileExt(remoteRefDataRes.filePath), metadata,
19363
+ // TODO: validate
19364
+ fileHash, textEncoder$1);
19365
+ if (
19366
+ // Current validation hash is the same as the remote validation hash
19367
+ // We assume the uploaded file exists and is correct
19368
+ currentValidationHash === remoteRefDataRes.validationHash &&
19369
+ // This can happen if the version or fileExt has been tampered with
19370
+ // (or if the way it is computed has changed)
19371
+ validationHashFromRemoteRefValues === remoteRefDataRes.validationHash) {
19372
+ return {
19373
+ status: "success"
19374
+ };
19375
+ }
19376
+ // Validation hash does not match, so we need to re-validate the remote content
19377
+ const fileBufferRes = await getFileBufferFromRemote(ref, fileExt, remoteRefDataRes.fileHash, projectRoot);
19378
+
19379
+ // We could not download the remote file for some reason (for example, the file hash is wrong)
19380
+ if (fileBufferRes.status === "error") {
19381
+ return fileBufferRes;
19382
+ }
19383
+
19384
+ // At this point we have the file buffer and we know the correct file hash
19385
+ // We must create a new ref since something has changed
19386
+
19387
+ let currentMetadata;
19388
+ try {
19389
+ if (schema.type === "image") {
19390
+ currentMetadata = await extractImageMetadata(remoteRefDataRes.filePath, fileBufferRes.fileBuffer);
19391
+ } else if (schema.type === "file") {
19392
+ currentMetadata = await extractFileMetadata(remoteRefDataRes.filePath, fileBufferRes.fileBuffer);
19393
+ } else {
19394
+ const exhaustiveCheck = schema;
19395
+ return {
19396
+ status: "error",
19397
+ error: `Unknown schema type: ${JSON.stringify(exhaustiveCheck)}`
19398
+ };
19399
+ }
19400
+ } catch (err) {
19401
+ return {
19402
+ status: "error",
19403
+ error: `Failed to extract metadata from remote file: ${err instanceof Error ? err.message : "Unknown error"}. Cached file: ${fileBufferRes.cachedFilePath}`
19404
+ };
19405
+ }
19406
+ const updatedMetadata = {
19407
+ ...metadata,
19408
+ ...currentMetadata
19409
+ };
19410
+ const nextValidationHash = core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, updatedMetadata, fileBufferRes.fileHash, textEncoder$1);
19411
+ if (!updatedMetadata.mimeType) {
19412
+ return {
19413
+ status: "error",
19414
+ error: `MIME type is missing in metadata: ${JSON.stringify(updatedMetadata)}`
19415
+ };
19416
+ }
19417
+ const newFileExt = core.Internal.mimeTypeToFileExt(updatedMetadata.mimeType);
19418
+ const newFilePath = relativeFilePath.slice(0, -fileExt.length) + newFileExt;
19419
+ return {
19420
+ status: "fix-required",
19421
+ metadata: updatedMetadata,
19422
+ ref: core.Internal.remote.createRemoteRef(remoteHost, {
19423
+ publicProjectId: remoteRefDataRes.projectId,
19424
+ coreVersion,
19425
+ bucket: remoteRefDataRes.bucket,
19426
+ validationHash: nextValidationHash,
19427
+ fileHash: fileBufferRes.fileHash,
19428
+ filePath: newFilePath
19429
+ })
19430
+ };
19431
+ }
19432
+ function getCachedRemoteFileDir(projectRoot) {
19433
+ // store in projectRoot/.val/remote-file-cache
19434
+ const remoteFileCacheDir = path__namespace["default"].join(projectRoot, ".val", "remote-file-cache");
19435
+ return remoteFileCacheDir;
19436
+ }
19437
+ function getCachedRemoteFilePath(fileExt, currentFileHash, remoteFileCacheDir) {
19438
+ const remoteFilePath = path__namespace["default"].join(remoteFileCacheDir, currentFileHash + "." + fileExt);
19439
+ return remoteFilePath;
19440
+ }
19441
+ async function getFileBufferFromRemote(ref, fileExt, currentFileHash, projectRoot) {
19442
+ const remoteFileCacheDir = getCachedRemoteFileDir(projectRoot);
19443
+ const remoteFilePath = getCachedRemoteFilePath(fileExt, currentFileHash, remoteFileCacheDir);
19444
+ try {
19445
+ const fileBuffer = await fs__default["default"].promises.readFile(remoteFilePath);
19446
+ const computedFileHash = core.Internal.remote.getFileHash(fileBuffer);
19447
+ if (computedFileHash !== currentFileHash) {
19448
+ await fs__default["default"].promises.unlink(remoteFilePath);
19449
+ throw Error(`Cached file hash does not match the expected hash: ${computedFileHash} !== ${currentFileHash}`);
19450
+ }
19451
+ } catch {
19452
+ try {
19453
+ await fs__default["default"].promises.mkdir(remoteFileCacheDir, {
19454
+ recursive: true
19455
+ });
19456
+ await downloadFileFromRemote(ref, remoteFilePath);
19457
+ } catch (err) {
19458
+ try {
19459
+ // try to delete in case of partial download
19460
+ await fs__default["default"].promises.unlink(remoteFilePath);
19461
+ } catch {
19462
+ //
19463
+ }
19464
+ return {
19465
+ status: "error",
19466
+ error: `Failed to download remote file: ${err instanceof Error ? err.message : "Unknown error"}`
19467
+ };
19468
+ }
19469
+ }
19470
+ const fileBuffer = await fs__default["default"].promises.readFile(remoteFilePath);
19471
+ const computedFileHash = core.Internal.remote.getFileHash(fileBuffer);
19472
+ return {
19473
+ status: "success",
19474
+ fileBuffer,
19475
+ fileHash: computedFileHash,
19476
+ cachedFilePath: remoteFilePath
19477
+ };
19478
+ }
19479
+ async function downloadFileFromRemote(ref, filePath) {
19480
+ return new Promise((resolve, reject) => {
19481
+ const url = new URL(ref);
19482
+ const client = url.protocol === "https:" ? https__default$1["default"] : http__default$1["default"];
19483
+ const request = client.get(url, response => {
19484
+ if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
19485
+ // Handle redirects
19486
+ return downloadFileFromRemote(response.headers.location, filePath).then(resolve).catch(reject);
19487
+ }
19488
+ if (response.statusCode == 404) {
19489
+ return reject(new Error(`${ref}. File not found. The URL is most likely incorrect.`));
19490
+ }
19491
+ if (response.statusCode !== 200) {
19492
+ return reject(new Error(`${ref}. Failed to download file. HTTP Status: ${response.statusCode}`));
19493
+ }
19494
+ const writeStream = fs__default["default"].createWriteStream(filePath);
19495
+ response.pipe(writeStream);
19496
+ writeStream.on("finish", () => resolve({
19497
+ status: "success",
19498
+ filePath
19499
+ }));
19500
+ writeStream.on("error", err => reject(new Error(`${ref}. Error writing file: ${err.message}`)));
19501
+ });
19502
+ request.on("error", err => reject(new Error(`${ref}. Download error: ${err.message}`)));
19503
+ request.end();
19504
+ });
19505
+ }
19506
+
19507
+ const textEncoder = new TextEncoder();
19508
+
19509
+ /**
19510
+ * Everything an upload to Val Remote needs before it has any bytes: the
19511
+ * developer's token, the project's public id and a bucket. Opened once per
19512
+ * fix, so a video's dozen files go up under one session and into one bucket
19513
+ * rather than a bucket each.
19514
+ */
19515
+
19516
+ async function openRemoteUploadSession(ctx) {
19517
+ const fail = errorMessage => ({
19518
+ success: false,
19519
+ result: {
19520
+ success: false,
19521
+ errorMessage
19522
+ }
19523
+ });
19524
+ const patFile = getPersonalAccessTokenPath(ctx.projectRoot);
19525
+ if (!ctx.fs.fileExists(patFile)) {
19526
+ return fail(`File: ${path__namespace["default"].join(ctx.projectRoot, ctx.file)} has remote files that are not uploaded and you are not logged in.\n\nFix this error by logging in:\n\t"npx val login"\n`);
19527
+ }
19528
+ const patFileContent = ctx.fs.readFile(patFile);
19529
+ if (patFileContent === undefined) {
19530
+ return fail(`Could not read personal access token file at ${patFile}`);
19531
+ }
19532
+ const parsedPatFile = parsePersonalAccessTokenFile(patFileContent);
19533
+ if (!parsedPatFile.success) {
19534
+ return fail(`Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`);
19535
+ }
19536
+ const {
19537
+ pat
19538
+ } = parsedPatFile.data;
19539
+ const projectName = ctx.project;
19540
+ let publicProjectId = ctx.publicProjectId;
19541
+ let remoteFileBuckets = ctx.remoteFileBuckets;
19542
+ let remoteFilesCounter = ctx.remoteFilesCounter;
19543
+ if (!publicProjectId || !remoteFileBuckets) {
19544
+ if (!projectName) {
19545
+ return fail("Project name not found. Add project name to val.config or set the VAL_PROJECT environment variable");
19546
+ }
19547
+ const settingsRes = await ctx.remote.getSettings(projectName, {
19548
+ pat
19549
+ });
19550
+ if (!settingsRes.success) {
19551
+ return fail(`Could not get public project id: ${settingsRes.message}.`);
19552
+ }
19553
+ publicProjectId = settingsRes.data.publicProjectId;
19554
+ remoteFileBuckets = settingsRes.data.remoteFileBuckets.map(b => b.bucket);
19555
+ }
19556
+ if (!publicProjectId) {
19557
+ return fail("Could not get public project id");
19558
+ }
19559
+ if (!projectName) {
19560
+ return fail(`Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`);
19561
+ }
19562
+ remoteFilesCounter += 1;
19563
+ const bucket = remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
19564
+ if (!bucket) {
19565
+ return fail(`Internal error: could not allocate a bucket for the remote file located at ${ctx.sourcePath}`);
19566
+ }
19567
+ return {
19568
+ success: true,
19569
+ session: {
19570
+ pat,
19571
+ projectName,
19572
+ publicProjectId,
19573
+ bucket,
19574
+ remoteFileBuckets,
19575
+ remoteFilesCounter
19576
+ }
19577
+ };
19578
+ }
19579
+
19580
+ /**
19581
+ * Upload one file's bytes and return the ref they are served at.
19582
+ *
19583
+ * `relativeFilePath` is the file's path under the project (`public/...`),
19584
+ * which the ref encodes; the bytes need not be the bytes on disk there — a
19585
+ * video's playlists are uploaded REWRITTEN, naming the refs of what they list.
19586
+ */
19587
+ async function uploadBytesToRemote(ctx, session, relativeFilePath, fileBuffer, metadata, schema) {
19588
+ if (!relativeFilePath.startsWith("public/")) {
19589
+ return {
19590
+ success: false,
19591
+ error: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`
19592
+ };
19593
+ }
19594
+ const fileHash = core.Internal.remote.getFileHash(fileBuffer);
19595
+ const coreVersion = core.Internal.VERSION.core || "unknown";
19596
+ const fileExt = getFileExt(relativeFilePath);
19597
+ const ref = core.Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
19598
+ publicProjectId: session.publicProjectId,
19599
+ coreVersion,
19600
+ bucket: session.bucket,
19601
+ validationHash: core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, metadata, fileHash, textEncoder),
19602
+ fileHash,
19603
+ filePath: relativeFilePath
19604
+ });
19605
+ const remoteFileUpload = await ctx.remote.uploadFile(session.projectName, session.bucket, fileHash, fileExt, fileBuffer, {
19606
+ pat: session.pat
19607
+ });
19608
+ if (!remoteFileUpload.success) {
19609
+ return {
19610
+ success: false,
19611
+ error: `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`
19612
+ };
19613
+ }
19614
+ return {
19615
+ success: true,
19616
+ ref
19617
+ };
19618
+ }
19619
+
19620
+ /**
19621
+ * Every file a video value names, as refs (`/public/…`, or a remote URL as
19622
+ * written): the video, its poster, each caption track — and, for a LOCAL HLS
19623
+ * stream, every file its playlists name, followed from the master down through
19624
+ * each media playlist to the segment files, init sections and audio tracks.
19625
+ *
19626
+ * Followed rather than "everything in the master's directory": the Studio
19627
+ * gives each stream a directory of its own, but nothing stops a hand-placed
19628
+ * `/public/val/clip.m3u8`, and the directory of THAT one is every file in
19629
+ * `/public/val`. A file in a stream's directory that no playlist names is not
19630
+ * used by the stream either, so calling it unused is right.
19631
+ *
19632
+ * A remote stream's playlists are on the content host, so its master is all
19633
+ * that is named here.
19634
+ *
19635
+ * The one place that answers this, so `list-unused-files` and anything else
19636
+ * asking "is this file used" agree on what a video holds.
19637
+ */
19638
+ function filesOfVideo(video, options) {
19639
+ if (!isObject(video) || typeof video.path !== "string") {
19640
+ return [];
19641
+ }
19642
+ const refs = new Set([video.path]);
19643
+ if (isObject(video.poster) && typeof video.poster.path === "string") {
19644
+ refs.add(video.poster.path);
19645
+ }
19646
+ if (Array.isArray(video.captions)) {
19647
+ for (const track of video.captions) {
19648
+ if (isObject(track) && typeof track.path === "string") {
19649
+ refs.add(track.path);
19650
+ }
19651
+ }
19652
+ }
19653
+ const mimeType = typeof video.mimeType === "string" ? video.mimeType : undefined;
19654
+ if (!core.Internal.isRemoteMediaPath(video.path) && core.Internal.media.isHlsVideo({
19655
+ path: video.path,
19656
+ mimeType
19657
+ })) {
19658
+ const readFile = options.readFile ?? readFileOrUndefined$1;
19659
+ const pending = [video.path.split("?")[0]];
19660
+ const visited = new Set();
19661
+ while (pending.length > 0) {
19662
+ var _readFile;
19663
+ const playlist = pending.pop();
19664
+ if (visited.has(playlist)) {
19665
+ continue;
19666
+ }
19667
+ visited.add(playlist);
19668
+ const text = (_readFile = readFile(path__namespace["default"].join(options.projectRoot, ...playlist.split("/")))) === null || _readFile === void 0 ? void 0 : _readFile.toString("utf-8");
19669
+ if (text === undefined) {
19670
+ continue;
19671
+ }
19672
+ mapHlsUris(text, uri => {
19673
+ const ref = localRefOf(uri, playlist);
19674
+ if (ref !== undefined) {
19675
+ refs.add(ref);
19676
+ if (ref.toLowerCase().endsWith(".m3u8")) {
19677
+ pending.push(ref);
19678
+ }
19679
+ }
19680
+ return uri;
19681
+ });
19682
+ }
19683
+ }
19684
+ return [...refs];
19685
+ }
19686
+
19687
+ /** A playlist URI as a local ref, when it is relative; otherwise undefined. */
19688
+ function localRefOf(uri, playlist) {
19689
+ if (/^[a-z][a-z0-9+.-]*:/i.test(uri) || uri.startsWith("/")) {
19690
+ return undefined;
19691
+ }
19692
+ const relative = uri.split(/[?#]/)[0];
19693
+ if (relative === "") {
19694
+ return undefined;
19695
+ }
19696
+ return path__namespace["default"].posix.join(path__namespace["default"].posix.dirname(playlist), relative);
19697
+ }
19698
+ function isObject(value) {
19699
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19700
+ }
19701
+ function readFileOrUndefined$1(absolutePath) {
19702
+ try {
19703
+ return fs__default["default"].readFileSync(absolutePath);
19704
+ } catch {
19705
+ return undefined;
19706
+ }
19707
+ }
19708
+
19709
+ /**
19710
+ * `video:upload-remote` and `video:download-remote`: move a video between the
19711
+ * repository and Val Remote.
19712
+ *
19713
+ * A video is the one media value that names several files — the video, its
19714
+ * poster, its caption tracks, and for an HLS stream every playlist and segment
19715
+ * its master names — so one fix moves all of them, and the patch then rewrites
19716
+ * every `path` the value holds. Moving only some would leave a video that is
19717
+ * half on each side, which validation rejects and no page can play.
19718
+ *
19719
+ * HLS is the part that is not a copy. The content host serves a file by its
19720
+ * HASH, so a relative name in a playlist resolves to nothing there: on the way
19721
+ * UP every playlist is rewritten to name the refs of what it lists, bottom-up
19722
+ * (a playlist's ref is the hash of its rewritten bytes, so what it names must
19723
+ * be uploaded first). On the way DOWN the same playlists are rewritten back to
19724
+ * relative names, which is what lets a published stream be plain static files.
19725
+ */
19726
+ function isRecord$2(value) {
19727
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19728
+ }
19729
+ function isPlaylist$1(ref) {
19730
+ return ref.split("?")[0].toLowerCase().endsWith(".m3u8");
19731
+ }
19732
+
19733
+ /** The paths a video value holds, each with where in the value it sits. */
19734
+ function videoPathsOf(video) {
19735
+ if (!isRecord$2(video) || typeof video.path !== "string") {
19736
+ return [];
19737
+ }
19738
+ const found = [{
19739
+ at: ["path"],
19740
+ path: video.path
19741
+ }];
19742
+ if (isRecord$2(video.poster) && typeof video.poster.path === "string") {
19743
+ found.push({
19744
+ at: ["poster", "path"],
19745
+ path: video.poster.path
19746
+ });
19747
+ }
19748
+ if (Array.isArray(video.captions)) {
19749
+ video.captions.forEach((track, index) => {
19750
+ if (isRecord$2(track) && typeof track.path === "string") {
19751
+ found.push({
19752
+ at: ["captions", String(index), "path"],
19753
+ path: track.path
19754
+ });
19755
+ }
19756
+ });
19757
+ }
19758
+ return found;
19759
+ }
19760
+
19761
+ /**
19762
+ * The patch that points a video at where its files now are: one `add` per
19763
+ * path that moved, so the authored fields beside them — the description, the
19764
+ * times, the caption labels — are never rewritten.
19765
+ */
19766
+ function rewriteVideoPathsPatch(sourcePath, video, moved) {
19767
+ const base = patch.sourceToPatchPath(sourcePath);
19768
+ const patch$1 = [];
19769
+ for (const {
19770
+ at,
19771
+ path: current
19772
+ } of videoPathsOf(video)) {
19773
+ const to = moved[current];
19774
+ if (to !== undefined && to !== current) {
19775
+ patch$1.push({
19776
+ op: "add",
19777
+ path: base.concat(at),
19778
+ value: to
19779
+ });
19780
+ }
19781
+ }
19782
+ return patch$1;
19783
+ }
19784
+
19785
+ /** A relative playlist URI as a `/public/...` ref, or undefined if it is not one. */
19786
+ function resolvePlaylistUri(uri, playlist) {
19787
+ if (/^[a-z][a-z0-9+.-]*:/i.test(uri) || uri.startsWith("/")) {
19788
+ return undefined;
19789
+ }
19790
+ const relative = uri.split(/[?#]/)[0];
19791
+ if (relative === "") {
19792
+ return undefined;
19793
+ }
19794
+ return path__namespace["default"].posix.join(path__namespace["default"].posix.dirname(playlist), relative);
19795
+ }
19796
+ function playlistUris(text) {
19797
+ const uris = [];
19798
+ mapHlsUris(text, uri => {
19799
+ if (!uris.includes(uri)) uris.push(uri);
19800
+ return uri;
19801
+ });
19802
+ return uris;
19803
+ }
19804
+
19805
+ /** What a file is to the video that names it, which decides its hash basis. */
19806
+
19807
+ /**
19808
+ * What a file's validation hash is computed from — the same as the Studio
19809
+ * computes for an upload (`createVideoPatch`), so a video moved by the CLI and
19810
+ * one uploaded in the Studio carry refs of the same kind.
19811
+ */
19812
+ function metadataOf(localRef, role, video) {
19813
+ const mimeType = core.Internal.filenameToMimeType(localRef);
19814
+ if (role === "video") {
19815
+ return {
19816
+ mimeType,
19817
+ width: video.width,
19818
+ height: video.height
19819
+ };
19820
+ }
19821
+ if (role === "poster" && isRecord$2(video.poster)) {
19822
+ return {
19823
+ mimeType: video.poster.mimeType ?? mimeType,
19824
+ width: video.poster.width,
19825
+ height: video.poster.height
19826
+ };
19827
+ }
19828
+ return {
19829
+ mimeType
19830
+ };
19831
+ }
19832
+
19833
+ /**
19834
+ * Places a video's local files on Val Remote, each once, and remembers where.
19835
+ *
19836
+ * The one implementation of "upload a video" for every fix that does it: a
19837
+ * video field (`video:upload-remote`), and an entry of an `s.videoset()`
19838
+ * (`videos:upload-remote`). What differs between the two is which files go up
19839
+ * and what their refs are hashed against, so those are the arguments.
19840
+ *
19841
+ * A playlist goes up AFTER everything it names, rewritten to name their refs:
19842
+ * the content host serves a file by its hash, so a relative name in a playlist
19843
+ * resolves to nothing there — and a playlist's own ref is the hash of its
19844
+ * rewritten bytes, so what it names must have refs first.
19845
+ */
19846
+ function createVideoUploader(ctx, session, schema, metadataFor) {
19847
+ const moved = new Map();
19848
+ const events = [];
19849
+ const put = async (localRef, role) => {
19850
+ const already = moved.get(localRef);
19851
+ if (already !== undefined) {
19852
+ return already;
19853
+ }
19854
+ const absolute = path__namespace["default"].join(ctx.projectRoot, localRef);
19855
+ const buffer = ctx.fs.readBuffer(absolute);
19856
+ if (buffer === undefined) {
19857
+ throw new Error(`Error reading file: ${absolute}`);
19858
+ }
19859
+ let bytes = buffer;
19860
+ if (isPlaylist$1(localRef)) {
19861
+ // Bottom-up: what this playlist names goes up first, so the refs it is
19862
+ // rewritten to exist — and so its own hash is of the rewritten bytes.
19863
+ const text = buffer.toString("utf-8");
19864
+ const refs = new Map();
19865
+ for (const uri of playlistUris(text)) {
19866
+ const target = resolvePlaylistUri(uri, localRef);
19867
+ if (target !== undefined) {
19868
+ refs.set(uri, await put(target, "stream"));
19869
+ }
19870
+ }
19871
+ bytes = Buffer.from(mapHlsUris(text, uri => refs.get(uri) ?? uri), "utf-8");
19872
+ }
19873
+ const uploaded = await uploadBytesToRemote(ctx, session, localRef.replace(/^\//, ""), bytes, metadataFor(localRef, role), schema);
19874
+ if (!uploaded.success) {
19875
+ throw new Error(uploaded.error);
19876
+ }
19877
+ moved.set(localRef, uploaded.ref);
19878
+ events.push({
19879
+ type: "remote-uploaded",
19880
+ ref: uploaded.ref
19881
+ });
19882
+ return uploaded.ref;
19883
+ };
19884
+ return {
19885
+ put,
19886
+ moved,
19887
+ events
19888
+ };
19889
+ }
19890
+
19891
+ /**
19892
+ * The first of `refs` — and of every file an HLS stream among them names —
19893
+ * that is not on disk, as an absolute path.
19894
+ *
19895
+ * Asked before anything goes up: a missing segment found after the master's
19896
+ * siblings were uploaded leaves bytes on the content host that nothing names.
19897
+ */
19898
+ function firstMissingVideoFile(ctx, refs) {
19899
+ for (const ref of refs) {
19900
+ const files = filesOfVideo({
19901
+ path: ref.path,
19902
+ mimeType: ref.mimeType
19903
+ }, {
19904
+ projectRoot: ctx.projectRoot,
19905
+ readFile: absolute => ctx.fs.readBuffer(absolute)
19906
+ });
19907
+ for (const file of files) {
19908
+ const absolute = path__namespace["default"].join(ctx.projectRoot, file);
19909
+ if (!ctx.fs.fileExists(absolute)) {
19910
+ return absolute;
19911
+ }
19912
+ }
19913
+ }
19914
+ return undefined;
19915
+ }
19916
+ function resolveVideo(ctx) {
19917
+ var _resolved$schema;
19918
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
19919
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
19920
+ return {
19921
+ success: false,
19922
+ result: {
19923
+ success: false,
19924
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
19925
+ }
19926
+ };
19927
+ }
19928
+ const resolved = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
19929
+ const video = resolved.source;
19930
+ if (((_resolved$schema = resolved.schema) === null || _resolved$schema === void 0 ? void 0 : _resolved$schema.type) !== "video" || !isRecord$2(video) || typeof video.path !== "string") {
19931
+ return {
19932
+ success: false,
19933
+ result: {
19934
+ success: false,
19935
+ errorMessage: `Expected a video at ${ctx.sourcePath}`
19936
+ }
19937
+ };
19938
+ }
19939
+ return {
19940
+ success: true,
19941
+ video,
19942
+ schema: resolved.schema
19943
+ };
19944
+ }
19945
+
19946
+ /**
19947
+ * The paths of a video FIELD that its fix moves.
19948
+ *
19949
+ * All of them for a video of its own. For a field picked from an
19950
+ * `s.videoset()` (`referencedModule`), only the poster and the captions: the
19951
+ * video file is the set's — its key there — and moving it from here would
19952
+ * leave the field naming a file the set does not have. The set's own fix
19953
+ * (`videos:upload-remote`) moves it, and rewrites the fields that name it.
19954
+ */
19955
+ function movablePathsOf(video, schema) {
19956
+ const setBacked = schema.referencedModule !== undefined;
19957
+ return videoPathsOf(video).filter(({
19958
+ at
19959
+ }) => !setBacked || at[0] !== "path");
19960
+ }
19961
+ async function handleVideoUploadRemote(ctx) {
19962
+ if (!ctx.fix) {
19963
+ return {
19964
+ success: false,
19965
+ errorMessage: `Video needs to be uploaded to Val Remote (use --fix to upload)`
19966
+ };
19967
+ }
19968
+ const resolved = resolveVideo(ctx);
19969
+ if (!resolved.success) {
19970
+ return resolved.result;
19971
+ }
19972
+ const {
19973
+ video,
19974
+ schema
19975
+ } = resolved;
19976
+ if (ctx.remoteFiles[ctx.sourcePath]) {
19977
+ return {
19978
+ success: true,
19979
+ shouldApplyPatch: true,
19980
+ events: [{
19981
+ type: "remote-already-uploaded",
19982
+ filePath: String(video.path)
19983
+ }]
19984
+ };
19985
+ }
19986
+ const toUpload = movablePathsOf(video, schema).filter(({
19987
+ path: ref
19988
+ }) => !core.Internal.isRemoteMediaPath(ref));
19989
+ // Every file is checked before any goes up: a missing caption found after
19990
+ // the video was uploaded leaves bytes on the content host that nothing names.
19991
+ const missing = firstMissingVideoFile(ctx, toUpload.map(({
19992
+ at,
19993
+ path: ref
19994
+ }) => ({
19995
+ path: ref,
19996
+ ...(at[0] === "path" && typeof video.mimeType === "string" ? {
19997
+ mimeType: video.mimeType
19998
+ } : {})
19999
+ })));
20000
+ if (missing !== undefined) {
20001
+ return {
20002
+ success: false,
20003
+ errorMessage: `File ${missing} does not exist`
20004
+ };
20005
+ }
20006
+ const opened = await openRemoteUploadSession(ctx);
20007
+ if (!opened.success) {
20008
+ return opened.result;
20009
+ }
20010
+ const {
20011
+ session
20012
+ } = opened;
20013
+ const uploader = createVideoUploader(ctx, session, schema, (localRef, role) => metadataOf(localRef, role, video));
20014
+ try {
20015
+ for (const {
20016
+ at,
20017
+ path: ref
20018
+ } of toUpload) {
20019
+ await uploader.put(ref, at[0] === "poster" ? "poster" : at[0] === "captions" ? "caption" : "video");
20020
+ }
20021
+ } catch (err) {
20022
+ return {
20023
+ success: false,
20024
+ errorMessage: err instanceof Error ? err.message : String(err)
20025
+ };
20026
+ }
20027
+ const refs = Object.fromEntries(uploader.moved);
20028
+ ctx.remoteFiles[ctx.sourcePath] = {
20029
+ ref: refs[String(video.path)] ?? String(video.path),
20030
+ refs
20031
+ };
20032
+ return {
20033
+ success: true,
20034
+ shouldApplyPatch: true,
20035
+ publicProjectId: session.publicProjectId,
20036
+ remoteFileBuckets: session.remoteFileBuckets,
20037
+ remoteFilesCounter: session.remoteFilesCounter,
20038
+ events: uploader.events
20039
+ };
20040
+ }
20041
+ async function handleVideoDownloadRemote(ctx) {
20042
+ if (!ctx.fix) {
17952
20043
  return {
17953
- localPath: `/${remoteRefRes.filePath}`,
17954
- remote: true
20044
+ success: false,
20045
+ errorMessage: `Video ${ctx.sourcePath} needs to be downloaded (use --fix to download)`
17955
20046
  };
17956
20047
  }
17957
- if (isRemoteUrl(key)) {
17958
- // A URL that is not a ref this version can read: truncated by a hand edit,
17959
- // a path outside `public/`, a `..` segment — or written by a core version
17960
- // whose format `splitRemoteRef` rejects.
17961
- //
17962
- // Still not a path in the working tree, so the on-disk checks must not run
17963
- // against it. They would find nothing at `<projectRoot>/https://…`, report
17964
- // the entry as missing, and `--fix` would DELETE it — the exact failure
17965
- // this module exists to prevent, arriving at the one moment the key is the
17966
- // only remaining record of where the bytes went.
17967
- //
17968
- // Nothing is being hidden by this: a malformed key is already reported, by
17969
- // the record schema's own "Invalid remote URL format". That is an error for
17970
- // a person to look at, not a file for `--fix` to go missing.
20048
+ const resolved = resolveVideo(ctx);
20049
+ if (!resolved.success) {
20050
+ return resolved.result;
20051
+ }
20052
+ const {
20053
+ video,
20054
+ schema
20055
+ } = resolved;
20056
+ const moved = new Map();
20057
+ const fetchTo = async ref => {
20058
+ const already = moved.get(ref);
20059
+ if (already !== undefined) {
20060
+ return already;
20061
+ }
20062
+ const split = core.Internal.remote.splitRemoteRef(ref);
20063
+ if (split.status === "error") {
20064
+ throw new Error(`Not a Val Remote ref: ${ref} (${split.error})`);
20065
+ }
20066
+ if (!split.filePath.startsWith("public/")) {
20067
+ throw new Error(`Cannot download ${ref}: its file path must start with public/ (got ${split.filePath})`);
20068
+ }
20069
+ const localRef = `/${split.filePath}`;
20070
+ const absolute = path__namespace["default"].join(ctx.projectRoot, split.filePath);
20071
+ await fs__default["default"].promises.mkdir(path__namespace["default"].dirname(absolute), {
20072
+ recursive: true
20073
+ });
20074
+ const res = await downloadFileFromRemote(ref, absolute);
20075
+ if (res.status === "error") {
20076
+ throw new Error(res.error);
20077
+ }
20078
+ moved.set(ref, localRef);
20079
+ if (isPlaylist$1(localRef)) {
20080
+ // Back to relative names: a local stream is plain static files, and the
20081
+ // draft endpoint rewrites relative names, not refs.
20082
+ const text = fs__default["default"].readFileSync(absolute, "utf-8");
20083
+ const local = new Map();
20084
+ for (const uri of playlistUris(text)) {
20085
+ if (core.Internal.remote.splitRemoteRef(uri).status === "success") {
20086
+ const target = await fetchTo(uri);
20087
+ local.set(uri, path__namespace["default"].posix.relative(path__namespace["default"].posix.dirname(localRef), target));
20088
+ }
20089
+ }
20090
+ fs__default["default"].writeFileSync(absolute, mapHlsUris(text, uri => local.get(uri) ?? uri), "utf-8");
20091
+ }
20092
+ return localRef;
20093
+ };
20094
+ try {
20095
+ for (const {
20096
+ path: ref
20097
+ } of movablePathsOf(video, schema)) {
20098
+ if (core.Internal.isRemoteMediaPath(ref)) {
20099
+ await fetchTo(ref);
20100
+ }
20101
+ }
20102
+ } catch (err) {
20103
+ return {
20104
+ success: false,
20105
+ errorMessage: err instanceof Error ? err.message : String(err)
20106
+ };
20107
+ }
20108
+ const refs = Object.fromEntries(moved);
20109
+ ctx.remoteFiles[ctx.sourcePath] = {
20110
+ ref: refs[String(video.path)] ?? String(video.path),
20111
+ refs
20112
+ };
20113
+ return {
20114
+ success: true,
20115
+ shouldApplyPatch: true,
20116
+ events: [{
20117
+ type: "remote-downloading",
20118
+ sourcePath: ctx.sourcePath
20119
+ }]
20120
+ };
20121
+ }
20122
+
20123
+ /**
20124
+ * What is out of step between a gallery's entries and its directory.
20125
+ *
20126
+ * Two questions, and they treat a remote entry differently — which is the whole
20127
+ * reason this is separate from the handler around it:
20128
+ *
20129
+ * - **Missing**: an entry with no bytes at its local path. Asked of LOCAL
20130
+ * entries only. A remote entry's bytes live on the content host, and nothing
20131
+ * puts a copy in the working tree: `saveOrUploadFiles` uploads the remote
20132
+ * descriptors and copies only the local ones into the tree, so a remote entry
20133
+ * added through the Studio (or over MCP) has no local file by design, and
20134
+ * demanding one would mean committing remote bytes to git — which is what
20135
+ * remote storage exists to avoid. Whether those bytes really are on the host
20136
+ * is a different check, `image:check-remote`, which already runs for exactly
20137
+ * these entries.
20138
+ * - **Untracked**: a file in the directory that no entry claims. Asked of every
20139
+ * entry, remote included, and that is why they are normalised to their local
20140
+ * path: `val validate --fix` promotes a local file to a remote ref and leaves
20141
+ * the file where it was, so a remote entry can perfectly well have one.
20142
+ *
20143
+ * An entry can claim more than its key. A video set's HLS stream is keyed by
20144
+ * its master playlist, and the media playlists and segments it names are the
20145
+ * same entry — so `filesOfEntry` says what else an entry holds, and those are
20146
+ * not untracked either.
20147
+ *
20148
+ * Its own module rather than a part of `fixHandlers.ts`, so the video set's
20149
+ * fixes can use it without importing the handler registry they are part of.
20150
+ */
20151
+ function checkGalleryFiles(input) {
20152
+ const {
20153
+ dir,
20154
+ projectRoot,
20155
+ fs,
20156
+ filesOfEntry
20157
+ } = input;
20158
+ const entries = input.entryKeys.map(key => ({
20159
+ key,
20160
+ entry: galleryEntryOf(key)
20161
+ }));
20162
+ const trackedFiles = new Set();
20163
+ for (const {
20164
+ key,
20165
+ entry
20166
+ } of entries) {
20167
+ trackedFiles.add(entry.localPath);
20168
+ for (const file of (filesOfEntry === null || filesOfEntry === void 0 ? void 0 : filesOfEntry(entry, key)) ?? []) {
20169
+ trackedFiles.add(file);
20170
+ }
20171
+ }
20172
+ const missingTrackedFiles = entries.filter(({
20173
+ entry
20174
+ }) => !entry.remote && !fs.fileExists(path__namespace["default"].join(projectRoot, entry.localPath))).map(({
20175
+ entry
20176
+ }) => entry.localPath);
20177
+ return {
20178
+ missingTrackedFiles,
20179
+ untrackedFiles: filesInDirectory({
20180
+ dir,
20181
+ projectRoot,
20182
+ fs
20183
+ }).filter(f => !trackedFiles.has(f))
20184
+ };
20185
+ }
20186
+
20187
+ /**
20188
+ * The LOCAL entries whose key is there but some of what they hold is not: a
20189
+ * stream missing a segment plays up to the gap and then stops. Separate from
20190
+ * `checkGalleryFiles`' "missing" because a fix does not remove these — the
20191
+ * entry still names a video someone uploaded and may have the rest of.
20192
+ */
20193
+ function incompleteGalleryEntries(input) {
20194
+ const exists = file => input.fs.fileExists(path__namespace["default"].join(input.projectRoot, file));
20195
+ const incomplete = [];
20196
+ for (const key of input.entryKeys) {
20197
+ const entry = galleryEntryOf(key);
20198
+ if (entry.remote || !exists(entry.localPath)) {
20199
+ continue;
20200
+ }
20201
+ const missing = input.filesOfEntry(entry, key).filter(file => file !== entry.localPath && !exists(file));
20202
+ if (missing.length > 0) {
20203
+ incomplete.push({
20204
+ key,
20205
+ missing
20206
+ });
20207
+ }
20208
+ }
20209
+ return incomplete;
20210
+ }
20211
+
20212
+ /** Every file under `dir`, as `/public/…` refs; none for a directory not there. */
20213
+ function filesInDirectory({
20214
+ dir,
20215
+ projectRoot,
20216
+ fs
20217
+ }) {
20218
+ const filesInDir = [];
20219
+ try {
20220
+ const found = fs.readDirectory(path__namespace["default"].join(projectRoot, dir), undefined, undefined, ["**/*"]);
20221
+ for (const entry of found) {
20222
+ filesInDir.push("/" + path__namespace["default"].relative(projectRoot, entry).split(path__namespace["default"].sep).join("/"));
20223
+ }
20224
+ } catch {
20225
+ // directory doesn't exist — no untracked files possible
20226
+ }
20227
+ return filesInDir;
20228
+ }
20229
+
20230
+ /**
20231
+ * The fixes of an `s.videoset()` — the video twin of an image gallery.
20232
+ *
20233
+ * A set is a record keyed by each video's file path (an HLS stream by its
20234
+ * master playlist), so most of this is the gallery fixes again with the key as
20235
+ * the file. Three things are not, and they are why this is a module of its own
20236
+ * rather than another branch in `fixHandlers.ts`:
20237
+ *
20238
+ * - **An entry is several files.** A stream's key is its master playlist, and
20239
+ * the media playlists and segments it names belong to the same entry. They
20240
+ * are tracked by it, uploaded with it, and none of them is an entry itself.
20241
+ * - **The set's directory holds files that are not entries.** A field picked
20242
+ * from the set (`s.video(videosVal)`) keeps its own poster and caption
20243
+ * tracks, and the Studio puts them where the set keeps its videos. A file a
20244
+ * video field names is accounted for, not untracked.
20245
+ * - **Fields elsewhere name an entry by its key.** Uploading an entry renames
20246
+ * its key to the remote ref, and every `s.video(videosVal)` whose `path` was
20247
+ * the old key would then name a video the set does not have. So the upload
20248
+ * rewrites them too (`otherModulePatches`).
20249
+ *
20250
+ * Nothing here imports `fixHandlers.ts` at runtime: that module registers
20251
+ * these handlers, and a cycle would hand it undefined ones.
20252
+ */
20253
+
20254
+ /** The fields of a set entry that are read from the bytes, in the order written. */
20255
+ const VIDEO_METADATA_FIELDS$1 = ["mimeType", "width", "height", "duration"];
20256
+
20257
+ /** What of a `Service` the project-wide walks need. */
20258
+
20259
+ function isRecord$1(value) {
20260
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20261
+ }
20262
+ function isPlaylist(ref) {
20263
+ return ref.split("?")[0].toLowerCase().endsWith(".m3u8");
20264
+ }
20265
+ function readFileOrUndefined(absolutePath) {
20266
+ try {
20267
+ return fs__default["default"].readFileSync(absolutePath);
20268
+ } catch {
20269
+ return undefined;
20270
+ }
20271
+ }
20272
+
20273
+ /** See `Internal.videosetEntryVideoSchema`: the Studio hashes against it too. */
20274
+ function videosetEntryVideoSchema(record) {
20275
+ return core.Internal.videosetEntryVideoSchema(record);
20276
+ }
20277
+
20278
+ /** The key of the set entry at `sourcePath`: its last module path segment. */
20279
+ function entryKeyOf(sourcePath) {
20280
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
20281
+ const parts = core.Internal.splitModulePath(modulePath);
20282
+ return parts[parts.length - 1];
20283
+ }
20284
+
20285
+ /**
20286
+ * The patch path of `sourcePath`. Not `sourceToPatchPath`: a set's keys are
20287
+ * file paths, and their dots do not survive its `split(".")`.
20288
+ */
20289
+ function patchPathOf(sourcePath) {
20290
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
20291
+ return core.Internal.splitModulePath(modulePath);
20292
+ }
20293
+
20294
+ /**
20295
+ * Every file a set entry holds: its key's file and, for an HLS stream, every
20296
+ * playlist and segment its master names — read from the LOCAL copy, which a
20297
+ * remote entry promoted by `--fix` still has, and which is the only place a
20298
+ * stream's tree can be followed from at all.
20299
+ */
20300
+ function filesOfVideosetEntry(entry, metadata, options) {
20301
+ const fields = isRecord$1(metadata) ? metadata : {};
20302
+ const mimeType = typeof fields.mimeType === "string" ? fields.mimeType : undefined;
20303
+ // The entry's poster and captions are its defaults' files: the set holds
20304
+ // them as it holds the video, so they are tracked by the entry — a remote
20305
+ // one by the local path it was uploaded from, as the key is.
20306
+ return filesOfVideo({
20307
+ path: entry.localPath,
20308
+ mimeType,
20309
+ poster: fields.poster,
20310
+ captions: fields.captions
20311
+ }, options).map(ref => galleryEntryOf(ref).localPath);
20312
+ }
20313
+
20314
+ /**
20315
+ * An entry with every file it names that `moved` has a new ref for pointed
20316
+ * at that ref: the poster and the captions, which a remote set keeps on the
20317
+ * content host with the video.
20318
+ */
20319
+ function withMovedFiles(entry, moved) {
20320
+ const movedPath = file => isRecord$1(file) && typeof file.path === "string" && moved.has(file.path) ? {
20321
+ ...file,
20322
+ path: moved.get(file.path)
20323
+ } : file;
20324
+ return {
20325
+ ...entry,
20326
+ ...(entry.poster !== undefined ? {
20327
+ poster: movedPath(entry.poster)
20328
+ } : {}),
20329
+ ...(Array.isArray(entry.captions) ? {
20330
+ captions: entry.captions.map(movedPath)
20331
+ } : {})
20332
+ };
20333
+ }
20334
+
20335
+ /** The entry's poster and caption files that are still local. */
20336
+ function localFilesOfEntry(entry) {
20337
+ const files = [];
20338
+ if (isRecord$1(entry.poster) && typeof entry.poster.path === "string") {
20339
+ files.push({
20340
+ path: entry.poster.path,
20341
+ role: "poster"
20342
+ });
20343
+ }
20344
+ if (Array.isArray(entry.captions)) {
20345
+ for (const track of entry.captions) {
20346
+ if (isRecord$1(track) && typeof track.path === "string") {
20347
+ files.push({
20348
+ path: track.path,
20349
+ role: "caption"
20350
+ });
20351
+ }
20352
+ }
20353
+ }
20354
+ return files.filter(file => !core.Internal.isRemoteMediaPath(file.path));
20355
+ }
20356
+
20357
+ /**
20358
+ * The untracked files of a set's directory, sorted into the ones `--fix` can
20359
+ * make entries of and the ones a person has to decide about.
20360
+ *
20361
+ * - `videos`: an untracked stream (keyed by its master playlist — the media
20362
+ * playlists and segments it names are part of it and listed nowhere), and a
20363
+ * progressive video the set accepts.
20364
+ * - `others`: everything else — a poster or caption no video field names, a
20365
+ * media playlist without a master, a video of a type the set does not
20366
+ * accept. `--fix` does not add these: none of them is a video the set could
20367
+ * hold, and deleting someone's file is not a fix.
20368
+ */
20369
+ function classifyUntrackedVideosetFiles({
20370
+ untracked,
20371
+ accept,
20372
+ projectRoot,
20373
+ readFile = readFileOrUndefined
20374
+ }) {
20375
+ const masters = untracked.filter(file => {
20376
+ var _readFile;
20377
+ if (!isPlaylist(file)) {
20378
+ return false;
20379
+ }
20380
+ const text = (_readFile = readFile(path__namespace["default"].join(projectRoot, file))) === null || _readFile === void 0 ? void 0 : _readFile.toString("utf-8");
20381
+ return text !== undefined && isHlsMasterPlaylist(text);
20382
+ });
20383
+ const partOfAStream = new Set();
20384
+ for (const master of masters) {
20385
+ for (const file of filesOfVideo({
20386
+ path: master
20387
+ }, {
20388
+ projectRoot,
20389
+ readFile
20390
+ })) {
20391
+ if (file !== master) {
20392
+ partOfAStream.add(file);
20393
+ }
20394
+ }
20395
+ }
20396
+ const videos = [];
20397
+ const others = [];
20398
+ for (const file of untracked) {
20399
+ if (partOfAStream.has(file)) {
20400
+ continue;
20401
+ }
20402
+ if (masters.includes(file)) {
20403
+ videos.push(file);
20404
+ continue;
20405
+ }
20406
+ const mimeType = core.Internal.filenameToMimeType(file);
20407
+ if (mimeType !== undefined && mimeType.startsWith("video/") && core.Internal.mimeTypeMatchesAccept(mimeType, accept)) {
20408
+ videos.push(file);
20409
+ } else {
20410
+ others.push(file);
20411
+ }
20412
+ }
20413
+ return {
20414
+ videos,
20415
+ others
20416
+ };
20417
+ }
20418
+
20419
+ /** Whether a serialized schema has a video field anywhere in it. */
20420
+ function hasVideoField(schema) {
20421
+ if (Array.isArray(schema)) {
20422
+ return schema.some(hasVideoField);
20423
+ }
20424
+ if (!isRecord$1(schema)) {
20425
+ return false;
20426
+ }
20427
+ if (schema.type === "video") {
20428
+ return true;
20429
+ }
20430
+ return Object.values(schema).some(hasVideoField);
20431
+ }
20432
+ /**
20433
+ * Every video field in the project, with its value.
20434
+ *
20435
+ * Validated reads for the modules that have one, because only those come back
20436
+ * with their `.jsonValues()` entries loaded — an unvalidated read hands back
20437
+ * each entry as a marker, and a field inside one would be invisible. Modules
20438
+ * without a video are read once, unvalidated, to find that out.
20439
+ */
20440
+ async function videoFieldsOf(service) {
20441
+ const found = [];
20442
+ for (const moduleFilePath of service.getModuleFilePaths()) {
20443
+ const shallow = await service.get(moduleFilePath, "", {
20444
+ validate: false
20445
+ });
20446
+ if (!hasVideoField(shallow.schema)) {
20447
+ continue;
20448
+ }
20449
+ const loaded = await service.get(moduleFilePath, "", {
20450
+ validate: true
20451
+ });
20452
+ if (loaded.source === undefined || !loaded.schema) {
20453
+ continue;
20454
+ }
20455
+ internal.traverseSchemaSource(loaded.source, loaded.schema, moduleFilePath, ({
20456
+ source,
20457
+ schema,
20458
+ path: sourcePath
20459
+ }) => {
20460
+ if (schema.type === "video") {
20461
+ found.push({
20462
+ moduleFilePath,
20463
+ sourcePath,
20464
+ source,
20465
+ schema
20466
+ });
20467
+ }
20468
+ });
20469
+ }
20470
+ return found;
20471
+ }
20472
+
20473
+ /**
20474
+ * The files video fields name — a video of its own, and the poster and
20475
+ * captions of every field — as `/public/…` refs, a remote one by the local
20476
+ * path it was uploaded from.
20477
+ */
20478
+ async function filesNamedByVideoFields(service, options) {
20479
+ const files = new Set();
20480
+ for (const field of await videoFieldsOf(service)) {
20481
+ for (const ref of filesOfVideo(field.source, options)) {
20482
+ // A remote ref by the path it encodes: `--fix` uploads a poster and
20483
+ // leaves the file where it was, exactly as it does a set's entry, and
20484
+ // that file is the field's — not something the set forgot.
20485
+ files.add(galleryEntryOf(ref).localPath);
20486
+ }
20487
+ }
20488
+ return files;
20489
+ }
20490
+
20491
+ /**
20492
+ * The patches that point every `s.video(set)` field at a set entry's new key.
20493
+ *
20494
+ * One `add` on each field's `path`, so the description, poster, times and
20495
+ * captions beside it are never rewritten. A field of another set, or of no
20496
+ * set, is left alone even when its path is the same string: it names its own
20497
+ * file.
20498
+ */
20499
+ async function videosetReferencePatches(service, setModuleFilePath, renamed) {
20500
+ const byModule = new Map();
20501
+ for (const field of await videoFieldsOf(service)) {
20502
+ if (field.schema.referencedModule !== setModuleFilePath) {
20503
+ continue;
20504
+ }
20505
+ const value = field.source;
20506
+ const current = isRecord$1(value) ? value.path : undefined;
20507
+ if (typeof current !== "string" || !(current in renamed)) {
20508
+ continue;
20509
+ }
20510
+ const patch = byModule.get(field.moduleFilePath) ?? [];
20511
+ patch.push({
20512
+ op: "add",
20513
+ path: patchPathOf(field.sourcePath).concat("path"),
20514
+ value: renamed[current]
20515
+ });
20516
+ byModule.set(field.moduleFilePath, patch);
20517
+ }
20518
+ return [...byModule].map(([moduleFilePath, patch]) => ({
20519
+ moduleFilePath,
20520
+ patch
20521
+ }));
20522
+ }
20523
+
20524
+ /** The record at `sourcePath` and its entries, or why it could not be read. */
20525
+ function resolveVideoset(module, sourcePath) {
20526
+ var _resolved$schema;
20527
+ if (module.source === undefined || !module.schema) {
20528
+ return {
20529
+ success: false,
20530
+ errorMessage: `Could not resolve source or schema for ${sourcePath}`
20531
+ };
20532
+ }
20533
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
20534
+ const resolved = core.Internal.resolvePath(modulePath, module.source, module.schema);
20535
+ if (((_resolved$schema = resolved.schema) === null || _resolved$schema === void 0 ? void 0 : _resolved$schema.type) !== "record" || resolved.schema.mediaType !== "videos") {
20536
+ var _resolved$schema2;
20537
+ return {
20538
+ success: false,
20539
+ errorMessage: `Expected a video set at ${sourcePath}, got ${((_resolved$schema2 = resolved.schema) === null || _resolved$schema2 === void 0 ? void 0 : _resolved$schema2.type) ?? "nothing"}`
20540
+ };
20541
+ }
20542
+ return {
20543
+ success: true,
20544
+ record: resolved.schema,
20545
+ entries: isRecord$1(resolved.source) ? resolved.source : {}
20546
+ };
20547
+ }
20548
+
20549
+ /**
20550
+ * `videos:add-metadata`: the entry's KEY is its file, and it must be on disk
20551
+ * (or on Val Remote, read over HTTP) and a kind Val can read the size and
20552
+ * length of here — the same preconditions as `video:add-metadata`, asked of
20553
+ * the key instead of `path`.
20554
+ */
20555
+ async function handleVideosetMetadata(ctx) {
20556
+ const key = entryKeyOf(ctx.sourcePath);
20557
+ if (key === undefined) {
17971
20558
  return {
17972
- localPath: key,
17973
- remote: true
20559
+ success: false,
20560
+ errorMessage: `Expected a video set entry at ${ctx.sourcePath}`
20561
+ };
20562
+ }
20563
+ const absolute = path__namespace["default"].join(ctx.projectRoot, key);
20564
+ if (!galleryEntryOf(key).remote && !ctx.fs.fileExists(absolute)) {
20565
+ return {
20566
+ success: false,
20567
+ errorMessage: `File ${absolute} does not exist`
20568
+ };
20569
+ }
20570
+ const entry = ctx.validationError.value;
20571
+ const missing = ["width", "height", "duration"].filter(field => !isRecord$1(entry) || entry[field] === undefined);
20572
+ if (missing.length > 0 && !canReadVideoMetadata(key)) {
20573
+ return {
20574
+ success: false,
20575
+ errorMessage: unreadableVideoMetadataMessage(key, missing)
17974
20576
  };
17975
20577
  }
17976
20578
  return {
17977
- localPath: key,
17978
- remote: false
20579
+ success: true,
20580
+ shouldApplyPatch: true
17979
20581
  };
17980
20582
  }
17981
20583
 
17982
20584
  /**
17983
- * The media path a validation error is about, if it is about media at all.
17984
- *
17985
- * There is no schema in hand here — a `ValidationError` carries only the value
17986
- * it flagged — so the shape is all there is to go on.
20585
+ * `videos:check-remote`: a key that is wrong as a key — a remote URL in a set
20586
+ * that is not `.remote()`, one that does not parse, one outside the set's
20587
+ * directory. There is nothing to look up and nothing to move, so the error is
20588
+ * reported as it is, for a person: only they know which of the set's options
20589
+ * or the key is the mistake.
17987
20590
  */
17988
- function getValidationErrorFileRef(validationError) {
17989
- const maybePath = validationError.value && typeof validationError.value === "object" && "path" in validationError.value && typeof validationError.value.path === "string" ? validationError.value.path : undefined;
17990
- if (!maybePath) {
17991
- return null;
17992
- }
17993
- return maybePath;
20591
+ async function handleVideosetCheckRemote(ctx) {
20592
+ return {
20593
+ success: false,
20594
+ errorMessage: ctx.validationError.message
20595
+ };
17994
20596
  }
17995
20597
 
17996
- const textEncoder$1 = new TextEncoder();
17997
- async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
17998
- const remoteRefDataRes = core.Internal.remote.splitRemoteRef(ref);
17999
- if (remoteRefDataRes.status === "error") {
18000
- return remoteRefDataRes;
20598
+ /**
20599
+ * `videos:upload-remote`: an entry of a `.remote()` set still keyed by a local
20600
+ * path. Every file it holds goes up — a stream bottom-up, as the Studio places
20601
+ * one — and `createFixPatch` renames the key to the ref, the same way
20602
+ * `images:upload-remote` does. The fields that name the entry by its old key
20603
+ * are rewritten too: without that they name a video the set no longer has.
20604
+ *
20605
+ * The entry's poster and captions — its defaults' files — go up with it, and
20606
+ * are what is left to do for an entry whose key is already remote: the key
20607
+ * stays, and the entry is written back with their refs.
20608
+ */
20609
+ async function handleVideosetUploadRemote(ctx) {
20610
+ if (!ctx.fix) {
20611
+ return {
20612
+ success: false,
20613
+ // No sourcePath in the message: the reported location already points at it.
20614
+ errorMessage: `Video needs to be uploaded to Val Remote (use --fix to upload)`
20615
+ };
18001
20616
  }
18002
- const fileHash = remoteRefDataRes.fileHash;
18003
- if (!fileHash) {
20617
+ const key = ctx.validationError.value;
20618
+ if (typeof key !== "string") {
18004
20619
  return {
18005
- status: "error",
18006
- error: `File hash is missing in remote ref: ${ref}`
20620
+ success: false,
20621
+ errorMessage: `Expected a local file path for the video set entry at ${ctx.sourcePath}`
18007
20622
  };
18008
20623
  }
18009
- const relativeFilePath = remoteRefDataRes.filePath;
18010
- if (!relativeFilePath) {
20624
+ if (ctx.remoteFiles[ctx.sourcePath]) {
18011
20625
  return {
18012
- status: "error",
18013
- error: `File path is missing in remote ref: ${ref}`
20626
+ success: true,
20627
+ shouldApplyPatch: true,
20628
+ events: [{
20629
+ type: "remote-already-uploaded",
20630
+ filePath: key
20631
+ }]
18014
20632
  };
18015
20633
  }
18016
- if (!relativeFilePath.startsWith("public/")) {
20634
+ const set = resolveVideoset(ctx.valModule, core.Internal.parentOfSourcePath(ctx.sourcePath));
20635
+ if (!set.success) {
20636
+ return set;
20637
+ }
20638
+ const entry = set.entries[key];
20639
+ if (!isRecord$1(entry)) {
18017
20640
  return {
18018
- status: "error",
18019
- error: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`
20641
+ success: false,
20642
+ errorMessage: `The video set has no entry '${key}' at ${ctx.sourcePath}`
18020
20643
  };
18021
20644
  }
18022
- const coreVersion = core.Internal.VERSION.core || "unknown";
18023
- const fileExt = getFileExt(relativeFilePath);
18024
- const currentValidationHash = core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, metadata,
18025
- // TODO: validate
18026
- fileHash, textEncoder$1);
18027
- const validationHashFromRemoteRefValues = core.Internal.remote.getValidationHash(remoteRefDataRes.version, schema, getFileExt(remoteRefDataRes.filePath), metadata,
18028
- // TODO: validate
18029
- fileHash, textEncoder$1);
18030
- if (
18031
- // Current validation hash is the same as the remote validation hash
18032
- // We assume the uploaded file exists and is correct
18033
- currentValidationHash === remoteRefDataRes.validationHash &&
18034
- // This can happen if the version or fileExt has been tampered with
18035
- // (or if the way it is computed has changed)
18036
- validationHashFromRemoteRefValues === remoteRefDataRes.validationHash) {
20645
+ // The key is local (the entry goes up whole), or the key is already on the
20646
+ // content host and only the entry's poster or captions are still here.
20647
+ const keyIsLocal = !core.Internal.isRemoteMediaPath(key);
20648
+ const entryFiles = localFilesOfEntry(entry);
20649
+ const missing = firstMissingVideoFile(ctx, [...(keyIsLocal ? [{
20650
+ path: key,
20651
+ ...(typeof entry.mimeType === "string" ? {
20652
+ mimeType: entry.mimeType
20653
+ } : {})
20654
+ }] : []), ...entryFiles.map(file => ({
20655
+ path: file.path
20656
+ }))]);
20657
+ if (missing !== undefined) {
18037
20658
  return {
18038
- status: "success"
20659
+ success: false,
20660
+ errorMessage: `File ${missing} does not exist`
18039
20661
  };
18040
20662
  }
18041
- // Validation hash does not match, so we need to re-validate the remote content
18042
- const fileBufferRes = await getFileBufferFromRemote(ref, fileExt, remoteRefDataRes.fileHash, projectRoot);
18043
-
18044
- // We could not download the remote file for some reason (for example, the file hash is wrong)
18045
- if (fileBufferRes.status === "error") {
18046
- return fileBufferRes;
20663
+ const opened = await openRemoteUploadSession(ctx);
20664
+ if (!opened.success) {
20665
+ return opened.result;
18047
20666
  }
18048
-
18049
- // At this point we have the file buffer and we know the correct file hash
18050
- // We must create a new ref since something has changed
18051
-
18052
- let currentMetadata;
20667
+ const {
20668
+ session
20669
+ } = opened;
20670
+ const uploader = createVideoUploader(ctx, session, videosetEntryVideoSchema(set.record), (localRef, role) => metadataOf(localRef, role, entry));
20671
+ let ref;
18053
20672
  try {
18054
- if (schema.type === "image") {
18055
- currentMetadata = await extractImageMetadata(remoteRefDataRes.filePath, fileBufferRes.fileBuffer);
18056
- } else if (schema.type === "file") {
18057
- currentMetadata = await extractFileMetadata(remoteRefDataRes.filePath, fileBufferRes.fileBuffer);
18058
- } else {
18059
- const exhaustiveCheck = schema;
18060
- return {
18061
- status: "error",
18062
- error: `Unknown schema type: ${JSON.stringify(exhaustiveCheck)}`
18063
- };
20673
+ ref = keyIsLocal ? await uploader.put(key, "video") : key;
20674
+ for (const file of entryFiles) {
20675
+ await uploader.put(file.path, file.role);
18064
20676
  }
18065
20677
  } catch (err) {
18066
20678
  return {
18067
- status: "error",
18068
- error: `Failed to extract metadata from remote file: ${err instanceof Error ? err.message : "Unknown error"}. Cached file: ${fileBufferRes.cachedFilePath}`
20679
+ success: false,
20680
+ errorMessage: err instanceof Error ? err.message : String(err)
18069
20681
  };
18070
20682
  }
18071
- const updatedMetadata = {
18072
- ...metadata,
18073
- ...currentMetadata
20683
+ ctx.remoteFiles[ctx.sourcePath] = {
20684
+ ref,
20685
+ metadata: withMovedFiles(entry, uploader.moved),
20686
+ refs: Object.fromEntries(uploader.moved)
18074
20687
  };
18075
- const nextValidationHash = core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, updatedMetadata, fileBufferRes.fileHash, textEncoder$1);
18076
- if (!updatedMetadata.mimeType) {
20688
+ const otherModulePatches = ref === key ? [] : await videosetReferencePatches(ctx.service, ctx.moduleFilePath, {
20689
+ [key]: ref
20690
+ });
20691
+ return {
20692
+ success: true,
20693
+ shouldApplyPatch: true,
20694
+ publicProjectId: session.publicProjectId,
20695
+ remoteFileBuckets: session.remoteFileBuckets,
20696
+ remoteFilesCounter: session.remoteFilesCounter,
20697
+ events: uploader.events,
20698
+ ...(otherModulePatches.length > 0 ? {
20699
+ otherModulePatches
20700
+ } : {})
20701
+ };
20702
+ }
20703
+
20704
+ /**
20705
+ * `videos:check-all-files`: the set's entries against its directory.
20706
+ *
20707
+ * What only this handler can answer is which untracked files are accounted
20708
+ * for ELSEWHERE — a poster or caption a video field names — because that needs
20709
+ * every module in the project. Those are fine. An untracked file that is not
20710
+ * a video and that no field names is for a person to look at, and is reported
20711
+ * here. Everything `--fix` can do something about — an entry whose file has
20712
+ * gone, a video in the directory the set does not list — is left to
20713
+ * `createFixPatch` (see {@link videosetCheckAllFilesPatch}), which reports it
20714
+ * per entry, or repairs it.
20715
+ */
20716
+ async function handleVideosetCheckAllFiles(ctx) {
20717
+ const value = ctx.validationError.value;
20718
+ const dir = isRecord$1(value) ? value.dir : undefined;
20719
+ if (typeof dir !== "string") {
18077
20720
  return {
18078
- status: "error",
18079
- error: `MIME type is missing in metadata: ${JSON.stringify(updatedMetadata)}`
20721
+ success: false,
20722
+ errorMessage: `Unexpected value in check-all-files for ${ctx.sourcePath}`
18080
20723
  };
18081
20724
  }
18082
- const newFileExt = core.Internal.mimeTypeToFileExt(updatedMetadata.mimeType);
18083
- const newFilePath = relativeFilePath.slice(0, -fileExt.length) + newFileExt;
18084
- return {
18085
- status: "fix-required",
18086
- metadata: updatedMetadata,
18087
- ref: core.Internal.remote.createRemoteRef(remoteHost, {
18088
- publicProjectId: remoteRefDataRes.projectId,
18089
- coreVersion,
18090
- bucket: remoteRefDataRes.bucket,
18091
- validationHash: nextValidationHash,
18092
- fileHash: fileBufferRes.fileHash,
18093
- filePath: newFilePath
20725
+ const set = resolveVideoset(ctx.valModule, ctx.sourcePath);
20726
+ if (!set.success) {
20727
+ return set;
20728
+ }
20729
+ const readFile = absolute => ctx.fs.readBuffer(absolute);
20730
+ const incompleteEntries = incompleteGalleryEntries({
20731
+ entryKeys: Object.keys(set.entries),
20732
+ projectRoot: ctx.projectRoot,
20733
+ fs: ctx.fs,
20734
+ filesOfEntry: (entry, key) => filesOfVideosetEntry(entry, set.entries[key], {
20735
+ projectRoot: ctx.projectRoot,
20736
+ readFile
18094
20737
  })
20738
+ });
20739
+ const {
20740
+ untrackedFiles
20741
+ } = checkGalleryFiles({
20742
+ entryKeys: Object.keys(set.entries),
20743
+ dir,
20744
+ projectRoot: ctx.projectRoot,
20745
+ fs: ctx.fs,
20746
+ filesOfEntry: (entry, key) => filesOfVideosetEntry(entry, set.entries[key], {
20747
+ projectRoot: ctx.projectRoot,
20748
+ readFile
20749
+ })
20750
+ });
20751
+ if (incompleteEntries.length > 0) {
20752
+ return {
20753
+ success: false,
20754
+ errorMessage: incompleteMessage(incompleteEntries)
20755
+ };
20756
+ }
20757
+ if (untrackedFiles.length === 0) {
20758
+ return {
20759
+ success: true,
20760
+ shouldApplyPatch: true
20761
+ };
20762
+ }
20763
+ const named = await filesNamedByVideoFields(ctx.service, {
20764
+ projectRoot: ctx.projectRoot,
20765
+ readFile
20766
+ });
20767
+ const {
20768
+ others
20769
+ } = classifyUntrackedVideosetFiles({
20770
+ untracked: untrackedFiles.filter(file => !named.has(file)),
20771
+ accept: set.record.accept ?? "video/*",
20772
+ projectRoot: ctx.projectRoot,
20773
+ readFile
20774
+ });
20775
+ if (others.length > 0) {
20776
+ return {
20777
+ success: false,
20778
+ errorMessage: `Video set in ${ctx.moduleFilePath} has files in '${dir}' that are not in the set, and that no video field uses: ${others.join(", ")}. They are not videos the set accepts, so --fix will not add them: move them out of the directory or remove them.`
20779
+ };
20780
+ }
20781
+ return {
20782
+ success: true,
20783
+ shouldApplyPatch: true
18095
20784
  };
18096
20785
  }
18097
- function getCachedRemoteFileDir(projectRoot) {
18098
- // store in projectRoot/.val/remote-file-cache
18099
- const remoteFileCacheDir = path__namespace["default"].join(projectRoot, ".val", "remote-file-cache");
18100
- return remoteFileCacheDir;
18101
- }
18102
- function getCachedRemoteFilePath(fileExt, currentFileHash, remoteFileCacheDir) {
18103
- const remoteFilePath = path__namespace["default"].join(remoteFileCacheDir, currentFileHash + "." + fileExt);
18104
- return remoteFilePath;
18105
- }
18106
- async function getFileBufferFromRemote(ref, fileExt, currentFileHash, projectRoot) {
18107
- const remoteFileCacheDir = getCachedRemoteFileDir(projectRoot);
18108
- const remoteFilePath = getCachedRemoteFilePath(fileExt, currentFileHash, remoteFileCacheDir);
18109
- try {
18110
- const fileBuffer = await fs__default["default"].promises.readFile(remoteFilePath);
18111
- const computedFileHash = core.Internal.remote.getFileHash(fileBuffer);
18112
- if (computedFileHash !== currentFileHash) {
18113
- await fs__default["default"].promises.unlink(remoteFilePath);
18114
- throw Error(`Cached file hash does not match the expected hash: ${computedFileHash} !== ${currentFileHash}`);
20786
+
20787
+ /**
20788
+ * The `createFixPatch` half of `videos:check-all-files`: drop the entries
20789
+ * whose file has gone, and add the videos in the directory that the set does
20790
+ * not list — with what is read from their bytes, or not at all.
20791
+ *
20792
+ * What is stored for an entry that IS on disk is not compared against its
20793
+ * bytes, unlike an image gallery's. The Studio reads a video's size and length
20794
+ * in the browser from the file as it was picked, and that is not what a header
20795
+ * parse of what is on disk says: a stream's largest rendition is not the
20796
+ * upload's size whenever the upload was larger than the largest rendition, and
20797
+ * two readers round a duration differently. Correcting one from the other
20798
+ * would rewrite every streamed video in the project, every run. A missing
20799
+ * value is `videos:add-metadata`'s, and is reported on the entry.
20800
+ */
20801
+ async function videosetCheckAllFilesPatch({
20802
+ projectRoot,
20803
+ apply,
20804
+ sourcePath,
20805
+ validationError,
20806
+ moduleSource,
20807
+ moduleSchema
20808
+ }) {
20809
+ const patch$1 = [];
20810
+ const remainingErrors = [];
20811
+ const fail = message => ({
20812
+ patch: patch$1,
20813
+ remainingErrors: [{
20814
+ ...validationError,
20815
+ message,
20816
+ fixes: undefined
20817
+ }]
20818
+ });
20819
+ const value = validationError.value;
20820
+ const dir = isRecord$1(value) ? value.dir : undefined;
20821
+ if (typeof dir !== "string") {
20822
+ return fail(`Unexpected value in check-all-files for ${sourcePath}`);
20823
+ }
20824
+ if (moduleSource === undefined || moduleSchema === undefined) {
20825
+ return fail("Unexpected error while checking a video set (no module source or schema)");
20826
+ }
20827
+ const set = resolveVideoset({
20828
+ source: moduleSource,
20829
+ schema: moduleSchema
20830
+ }, sourcePath);
20831
+ if (!set.success) {
20832
+ return fail(set.errorMessage);
20833
+ }
20834
+ const recordPath = patchPathOf(sourcePath);
20835
+ const incompleteEntries = incompleteGalleryEntries({
20836
+ entryKeys: Object.keys(set.entries),
20837
+ projectRoot,
20838
+ fs: ts__default["default"].sys,
20839
+ filesOfEntry: (entry, key) => filesOfVideosetEntry(entry, set.entries[key], {
20840
+ projectRoot
20841
+ })
20842
+ });
20843
+ const {
20844
+ missingTrackedFiles,
20845
+ untrackedFiles
20846
+ } = checkGalleryFiles({
20847
+ entryKeys: Object.keys(set.entries),
20848
+ dir,
20849
+ projectRoot,
20850
+ // The host `createDefaultValFSHost` gives the handlers, and so the same
20851
+ // listing the handler half of this check saw.
20852
+ fs: ts__default["default"].sys,
20853
+ filesOfEntry: (entry, key) => filesOfVideosetEntry(entry, set.entries[key], {
20854
+ projectRoot
20855
+ })
20856
+ });
20857
+ if (incompleteEntries.length > 0) {
20858
+ remainingErrors.push({
20859
+ ...validationError,
20860
+ message: incompleteMessage(incompleteEntries),
20861
+ fixes: undefined
20862
+ });
20863
+ }
20864
+ for (const missing of missingTrackedFiles) {
20865
+ if (apply) {
20866
+ const removePath = recordPath.concat(missing);
20867
+ if (patch.isNotRoot(removePath)) {
20868
+ patch$1.push({
20869
+ op: "remove",
20870
+ path: removePath
20871
+ });
20872
+ }
20873
+ } else {
20874
+ remainingErrors.push({
20875
+ ...validationError,
20876
+ message: `Video '${missing}' is in the set, but its file does not exist. Use --fix to remove it from the set.`,
20877
+ sourcePath: core.Internal.createValPathOfItem(sourcePath, missing),
20878
+ keyError: true
20879
+ });
18115
20880
  }
18116
- } catch {
20881
+ }
20882
+ const {
20883
+ videos
20884
+ } = classifyUntrackedVideosetFiles({
20885
+ untracked: untrackedFiles,
20886
+ accept: set.record.accept ?? "video/*",
20887
+ projectRoot
20888
+ });
20889
+ for (const file of videos) {
20890
+ let metadata;
18117
20891
  try {
18118
- await fs__default["default"].promises.mkdir(remoteFileCacheDir, {
18119
- recursive: true
20892
+ metadata = await extractVideoMetadataFromFile(path__namespace["default"].join(projectRoot, file));
20893
+ } catch (err) {
20894
+ remainingErrors.push({
20895
+ ...validationError,
20896
+ message: `Video '${file}' is in '${dir}' but not in the set, and its metadata could not be read: ${err instanceof Error ? err.message : String(err)}`,
20897
+ fixes: undefined
20898
+ });
20899
+ continue;
20900
+ }
20901
+ const unreadable = VIDEO_METADATA_FIELDS$1.filter(field => metadata[field] === undefined);
20902
+ if (unreadable.length > 0) {
20903
+ // All or nothing, as for `video:add-metadata`: an entry with a mime type
20904
+ // and no size is no closer to valid, and reads as one someone finished.
20905
+ remainingErrors.push({
20906
+ ...validationError,
20907
+ message: `Video '${file}' is in '${dir}' but not in the set. ${unreadableVideoMetadataMessage(file, unreadable)}`,
20908
+ fixes: undefined
20909
+ });
20910
+ continue;
20911
+ }
20912
+ if (!apply) {
20913
+ remainingErrors.push({
20914
+ ...validationError,
20915
+ message: `Video '${file}' is in '${dir}' but not in the set. Use --fix to add it.`
20916
+ });
20917
+ continue;
20918
+ }
20919
+ const entry = {};
20920
+ for (const field of VIDEO_METADATA_FIELDS$1) {
20921
+ const read = metadata[field];
20922
+ if (read !== undefined) {
20923
+ entry[field] = read;
20924
+ }
20925
+ }
20926
+ // What an upload writes when nobody has described the video yet.
20927
+ entry.alt = null;
20928
+ const addPath = recordPath.concat(file);
20929
+ if (patch.isNotRoot(addPath)) {
20930
+ patch$1.push({
20931
+ op: "add",
20932
+ path: addPath,
20933
+ value: entry
20934
+ });
20935
+ }
20936
+ }
20937
+ return {
20938
+ patch: patch$1,
20939
+ remainingErrors
20940
+ };
20941
+ }
20942
+
20943
+ /**
20944
+ * The `createFixPatch` half of `videos:add-metadata`: read what is missing
20945
+ * from the file at the entry's KEY, and write only that, one op per field —
20946
+ * `alt` and anything already there are someone's, and stay.
20947
+ */
20948
+ async function videosetAddMetadataPatch({
20949
+ projectRoot,
20950
+ sourcePath,
20951
+ validationError,
20952
+ moduleSource,
20953
+ moduleSchema
20954
+ }) {
20955
+ const fail = message => ({
20956
+ patch: [],
20957
+ remainingErrors: [{
20958
+ ...validationError,
20959
+ message,
20960
+ fixes: undefined
20961
+ }]
20962
+ });
20963
+ const key = entryKeyOf(sourcePath);
20964
+ if (key === undefined) {
20965
+ return fail(`Expected a video set entry at ${sourcePath}`);
20966
+ }
20967
+ let current = validationError.value;
20968
+ if (moduleSource !== undefined && moduleSchema !== undefined) {
20969
+ const parent = resolveVideoset({
20970
+ source: moduleSource,
20971
+ schema: moduleSchema
20972
+ }, core.Internal.parentOfSourcePath(sourcePath));
20973
+ if (!parent.success) {
20974
+ return fail(`Could not add video metadata: ${parent.errorMessage}`);
20975
+ }
20976
+ current = parent.entries[key];
20977
+ }
20978
+ if (!isRecord$1(current)) {
20979
+ return fail("Video set entry is not an object!");
20980
+ }
20981
+ let metadata;
20982
+ try {
20983
+ metadata = galleryEntryOf(key).remote ? await extractVideoMetadataFromUrl(key, nameForTypeOf(key)) : await extractVideoMetadataFromFile(path__namespace["default"].join(projectRoot, key));
20984
+ } catch (err) {
20985
+ return fail(`Failed to read video metadata from ${key}: ${err instanceof Error ? err.message : String(err)}`);
20986
+ }
20987
+ const entry = current;
20988
+ const missing = VIDEO_METADATA_FIELDS$1.filter(field => entry[field] === undefined);
20989
+ const unreadable = missing.filter(field => metadata[field] === undefined);
20990
+ if (unreadable.length > 0) {
20991
+ return fail(unreadableVideoMetadataMessage(key, unreadable));
20992
+ }
20993
+ const entryPath = patchPathOf(sourcePath);
20994
+ const patch = [];
20995
+ for (const field of missing) {
20996
+ const read = metadata[field];
20997
+ if (read !== undefined) {
20998
+ patch.push({
20999
+ op: "add",
21000
+ path: entryPath.concat(field),
21001
+ value: read
18120
21002
  });
18121
- await downloadFileFromRemote(ref, remoteFilePath);
18122
- } catch (err) {
18123
- try {
18124
- // try to delete in case of partial download
18125
- await fs__default["default"].promises.unlink(remoteFilePath);
18126
- } catch {
18127
- //
18128
- }
18129
- return {
18130
- status: "error",
18131
- error: `Failed to download remote file: ${err instanceof Error ? err.message : "Unknown error"}`
18132
- };
18133
21003
  }
18134
21004
  }
18135
- const fileBuffer = await fs__default["default"].promises.readFile(remoteFilePath);
18136
- const computedFileHash = core.Internal.remote.getFileHash(fileBuffer);
18137
21005
  return {
18138
- status: "success",
18139
- fileBuffer,
18140
- fileHash: computedFileHash,
18141
- cachedFilePath: remoteFilePath
21006
+ patch,
21007
+ remainingErrors: []
18142
21008
  };
18143
21009
  }
18144
- async function downloadFileFromRemote(ref, filePath) {
18145
- return new Promise((resolve, reject) => {
18146
- const url = new URL(ref);
18147
- const client = url.protocol === "https:" ? https__default$1["default"] : http__default$1["default"];
18148
- const request = client.get(url, response => {
18149
- if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
18150
- // Handle redirects
18151
- return downloadFileFromRemote(response.headers.location, filePath).then(resolve).catch(reject);
18152
- }
18153
- if (response.statusCode == 404) {
18154
- return reject(new Error(`${ref}. File not found. The URL is most likely incorrect.`));
18155
- }
18156
- if (response.statusCode !== 200) {
18157
- return reject(new Error(`${ref}. Failed to download file. HTTP Status: ${response.statusCode}`));
18158
- }
18159
- const writeStream = fs__default["default"].createWriteStream(filePath);
18160
- response.pipe(writeStream);
18161
- writeStream.on("finish", () => resolve({
18162
- status: "success",
18163
- filePath
18164
- }));
18165
- writeStream.on("error", err => reject(new Error(`${ref}. Error writing file: ${err.message}`)));
18166
- });
18167
- request.on("error", err => reject(new Error(`${ref}. Download error: ${err.message}`)));
18168
- request.end();
18169
- });
21010
+
21011
+ /**
21012
+ * What to say about streams whose master is there and some of whose files are
21013
+ * not. Not fixable: the entry still names a video someone uploaded, and only
21014
+ * they have the rest of it.
21015
+ */
21016
+ function incompleteMessage(incomplete) {
21017
+ return incomplete.map(({
21018
+ key,
21019
+ missing
21020
+ }) => `Video '${key}' is missing ${missing.length === 1 ? "a file it names" : `${missing.length} files it names`}: ${missing.join(", ")}. It stops playing where they are. Upload it again, or put the files back.`).join(" ");
18170
21021
  }
18171
21022
 
18172
21023
  // A remaining error may optionally carry a more specific `sourcePath` than the
@@ -18332,6 +21183,86 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
18332
21183
  value: fileMetadata.mimeType ?? null
18333
21184
  });
18334
21185
  }
21186
+ } else if (fix === "video:add-metadata") {
21187
+ // Only the fields that are MISSING, one op each: a video's `alt`,
21188
+ // `poster`, `captions` and times are authored and sit in the same
21189
+ // object, and a field already there is not re-read (see the comment on
21190
+ // the fix code in core — reading a video is not cheap).
21191
+ const current = currentVideoValue(sourcePath, validationError, moduleSource, moduleSchema);
21192
+ if (current.status === "error") {
21193
+ remainingErrors.push({
21194
+ ...validationError,
21195
+ message: current.message,
21196
+ fixes: undefined
21197
+ });
21198
+ continue;
21199
+ }
21200
+ const fileRef = current.value.path;
21201
+ if (typeof fileRef !== "string") {
21202
+ remainingErrors.push({
21203
+ ...validationError,
21204
+ message: `Cannot read video metadata: the video has no path`,
21205
+ fixes: undefined
21206
+ });
21207
+ continue;
21208
+ }
21209
+ let metadata;
21210
+ try {
21211
+ metadata = await getVideoMetadata(config.projectRoot, fileRef);
21212
+ } catch (err) {
21213
+ remainingErrors.push({
21214
+ ...validationError,
21215
+ message: `Failed to read video metadata from ${fileRef}: ${err instanceof Error ? err.message : String(err)}`,
21216
+ fixes: undefined
21217
+ });
21218
+ continue;
21219
+ }
21220
+ const missing = VIDEO_METADATA_FIELDS.filter(field => current.value[field] === undefined);
21221
+ const unreadable = missing.filter(field => metadata[field] === undefined);
21222
+ if (unreadable.length > 0) {
21223
+ // All or nothing: a video with a mime type but no size is no closer
21224
+ // to valid, and a half-written value reads as one someone finished.
21225
+ remainingErrors.push({
21226
+ ...validationError,
21227
+ message: unreadableVideoMetadataMessage(fileRef, unreadable),
21228
+ fixes: undefined
21229
+ });
21230
+ continue;
21231
+ }
21232
+ const patchPath = patch.sourceToPatchPath(sourcePath);
21233
+ for (const field of missing) {
21234
+ const value = metadata[field];
21235
+ if (value !== undefined) {
21236
+ patch$1.push({
21237
+ op: "add",
21238
+ path: patchPath.concat(field),
21239
+ value
21240
+ });
21241
+ }
21242
+ }
21243
+ } else if (fix === "videos:add-metadata") {
21244
+ // A set entry's twin of `video:add-metadata`: the file is the entry's
21245
+ // KEY, and only what is missing is written.
21246
+ const fixed = await videosetAddMetadataPatch({
21247
+ projectRoot: config.projectRoot,
21248
+ sourcePath,
21249
+ validationError,
21250
+ moduleSource,
21251
+ moduleSchema
21252
+ });
21253
+ patch$1.push(...fixed.patch);
21254
+ remainingErrors.push(...fixed.remainingErrors);
21255
+ } else if (fix === "videos:check-all-files") {
21256
+ const fixed = await videosetCheckAllFilesPatch({
21257
+ projectRoot: config.projectRoot,
21258
+ apply,
21259
+ sourcePath,
21260
+ validationError,
21261
+ moduleSource,
21262
+ moduleSchema
21263
+ });
21264
+ patch$1.push(...fixed.patch);
21265
+ remainingErrors.push(...fixed.remainingErrors);
18335
21266
  } else if (fix === "image:upload-remote" || fix === "file:upload-remote") {
18336
21267
  const remoteFile = remoteFiles[sourcePath];
18337
21268
  let metadata = remoteFile.metadata;
@@ -18361,10 +21292,30 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
18361
21292
  path: patch.sourceToPatchPath(sourcePath)
18362
21293
  });
18363
21294
  }
18364
- } else if (fix === "images:upload-remote" || fix === "files:upload-remote") {
21295
+ } else if (fix === "video:upload-remote" || fix === "video:download-remote") {
21296
+ var _remoteFiles$sourcePa;
21297
+ // The handler moved the files (`videoRemote.ts`); what is left is to
21298
+ // point each path the value holds at where its file now is. Read from
21299
+ // the module rather than the error, which carries the value as it was
21300
+ // validated.
21301
+ const moved = (_remoteFiles$sourcePa = remoteFiles[sourcePath]) === null || _remoteFiles$sourcePa === void 0 ? void 0 : _remoteFiles$sourcePa.refs;
21302
+ if (!moved) {
21303
+ remainingErrors.push({
21304
+ ...validationError,
21305
+ message: fix === "video:upload-remote" ? "Cannot point the video at Val Remote: its files were not uploaded" : "Cannot point the video at its local files: they were not downloaded",
21306
+ fixes: undefined
21307
+ });
21308
+ continue;
21309
+ }
21310
+ const current = moduleSource !== undefined && moduleSchema !== undefined ? core.Internal.resolvePath(core.Internal.splitModuleFilePathAndModulePath(sourcePath)[1], moduleSource, moduleSchema).source : validationError.value;
21311
+ patch$1.push(...rewriteVideoPathsPatch(sourcePath, current, moved));
21312
+ } else if (fix === "images:upload-remote" || fix === "files:upload-remote" || fix === "videos:upload-remote") {
18365
21313
  // Gallery entry: the record is keyed by the file path, so uploading to
18366
21314
  // remote means renaming the key from the local path to the remote URL
18367
- // (remove the old key, add the new one with the same metadata).
21315
+ // (remove the old key, add the new one with the same metadata). A video
21316
+ // set's entry is renamed to its MASTER playlist's ref for a stream: the
21317
+ // playlists and segments it names went up with it, and are named by the
21318
+ // uploaded master, not by the set.
18368
21319
  const remoteFile = remoteFiles[sourcePath];
18369
21320
  if (!remoteFile) {
18370
21321
  remainingErrors.push({
@@ -18723,6 +21674,52 @@ function getRemoteValueFromValidationError(v) {
18723
21674
  metadata
18724
21675
  };
18725
21676
  }
21677
+
21678
+ /** The fields of a video that are read from its bytes, in the order written. */
21679
+ const VIDEO_METADATA_FIELDS = ["mimeType", "width", "height", "duration"];
21680
+
21681
+ /**
21682
+ * The video a `video:add-metadata` error is about, read from the module at its
21683
+ * path when the module is at hand — the schema there decides that it IS a
21684
+ * video — and from the error's own value otherwise.
21685
+ */
21686
+ function currentVideoValue(sourcePath, validationError, moduleSource, moduleSchema) {
21687
+ if (moduleSource !== undefined && moduleSchema !== undefined) {
21688
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
21689
+ const resolved = core.Internal.resolvePath(modulePath, moduleSource, moduleSchema);
21690
+ if (resolved.schema.type !== "video") {
21691
+ return {
21692
+ status: "error",
21693
+ message: `Could not add video metadata: schema at ${sourcePath} is '${resolved.schema.type}', not a video`
21694
+ };
21695
+ }
21696
+ const value = mediaValueOf(resolved.source);
21697
+ return value ? {
21698
+ status: "ok",
21699
+ value
21700
+ } : {
21701
+ status: "error",
21702
+ message: "Video is not an object!"
21703
+ };
21704
+ }
21705
+ const value = mediaValueOf(validationError.value);
21706
+ return value ? {
21707
+ status: "ok",
21708
+ value
21709
+ } : {
21710
+ status: "error",
21711
+ message: "Video is not an object!"
21712
+ };
21713
+ }
21714
+ async function getVideoMetadata(projectRoot, fileRef) {
21715
+ // Not read into memory: only the headers are, and a video is mostly not
21716
+ // headers. A remote video's headers are read over HTTP, with Range
21717
+ // requests, so it is not downloaded either.
21718
+ if (core.Internal.isRemoteMediaPath(fileRef)) {
21719
+ return extractVideoMetadataFromUrl(fileRef, nameForTypeOf(fileRef));
21720
+ }
21721
+ return extractVideoMetadataFromFile(path__namespace["default"].join(projectRoot, fileRef));
21722
+ }
18726
21723
  async function getImageMetadata(projectRoot, validationError) {
18727
21724
  const fileRef = getValidationErrorFileRef(validationError);
18728
21725
  if (!fileRef) {
@@ -18896,10 +21893,17 @@ function formatOpsError(error, sourceFile) {
18896
21893
  * What stays in the CLI is the driver: the `runValidation` async generator that
18897
21894
  * walks every module, decides what to report, and renders it to a terminal.
18898
21895
  */
18899
- const textEncoder = new TextEncoder();
18900
21896
 
18901
21897
  // Types for handler system
18902
21898
 
21899
+ /**
21900
+ * Where a fix moved a media value's file. `refs` is for a value that names
21901
+ * SEVERAL files — a video's poster, captions and stream — keyed by the path
21902
+ * the value held, so the patch can rewrite each one where it is.
21903
+ */
21904
+
21905
+ /** A patch, and the module it is for. */
21906
+
18903
21907
  // Handler functions
18904
21908
  async function handleFileMetadata(ctx) {
18905
21909
  var _fileSource$source;
@@ -18933,6 +21937,53 @@ async function handleFileMetadata(ctx) {
18933
21937
  };
18934
21938
  }
18935
21939
 
21940
+ /**
21941
+ * `video:add-metadata`: the bytes must be on disk, or on Val Remote (read
21942
+ * over HTTP, headers only — see `remoteVideoMetadata.ts`), and they must be a
21943
+ * kind of file Val can read the size and length of here: mp4/mov boxes,
21944
+ * WebM/Matroska headers and HLS playlists. Anything else is refused with what
21945
+ * to do instead, unless all that is missing is the mime type, which the
21946
+ * extension answers.
21947
+ */
21948
+ async function handleVideoMetadata(ctx) {
21949
+ const exists = isRemoteVideoAt(ctx) ? {
21950
+ success: true,
21951
+ shouldApplyPatch: true
21952
+ } : await handleFileMetadata(ctx);
21953
+ if (!exists.success) {
21954
+ return exists;
21955
+ }
21956
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
21957
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
21958
+ return exists;
21959
+ }
21960
+ const video = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema).source;
21961
+ if (!isRecord(video) || typeof video.path !== "string") {
21962
+ return exists;
21963
+ }
21964
+ const missing = ["width", "height", "duration"].filter(field => video[field] === undefined);
21965
+ if (missing.length > 0 && !canReadVideoMetadata(video.path)) {
21966
+ return {
21967
+ success: false,
21968
+ errorMessage: unreadableVideoMetadataMessage(video.path, missing)
21969
+ };
21970
+ }
21971
+ return exists;
21972
+ }
21973
+
21974
+ /** Whether the video at the error's path is on Val Remote. */
21975
+ function isRemoteVideoAt(ctx) {
21976
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
21977
+ return false;
21978
+ }
21979
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
21980
+ const video = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema).source;
21981
+ return isRecord(video) && typeof video.path === "string" && core.Internal.isRemoteMediaPath(video.path);
21982
+ }
21983
+ function isRecord(value) {
21984
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21985
+ }
21986
+
18936
21987
  // Shared upload core used by both the single-field (handleRemoteFileUpload)
18937
21988
  // and gallery (handleRemoteGalleryFileUpload) handlers. The two differ only in
18938
21989
  // how they derive the local file ref, metadata and serialized image/file
@@ -18945,30 +21996,6 @@ async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
18945
21996
  errorMessage: `File ${filePath} does not exist`
18946
21997
  };
18947
21998
  }
18948
- const patFile = getPersonalAccessTokenPath(ctx.projectRoot);
18949
- if (!ctx.fs.fileExists(patFile)) {
18950
- return {
18951
- success: false,
18952
- errorMessage: `File: ${path__namespace["default"].join(ctx.projectRoot, ctx.file)} has remote images that are not uploaded and you are not logged in.\n\nFix this error by logging in:\n\t"npx val login"\n`
18953
- };
18954
- }
18955
- const patFileContent = ctx.fs.readFile(patFile);
18956
- if (patFileContent === undefined) {
18957
- return {
18958
- success: false,
18959
- errorMessage: `Could not read personal access token file at ${patFile}`
18960
- };
18961
- }
18962
- const parsedPatFile = parsePersonalAccessTokenFile(patFileContent);
18963
- if (!parsedPatFile.success) {
18964
- return {
18965
- success: false,
18966
- errorMessage: `Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`
18967
- };
18968
- }
18969
- const {
18970
- pat
18971
- } = parsedPatFile.data;
18972
21999
  if (ctx.remoteFiles[ctx.sourcePath]) {
18973
22000
  return {
18974
22001
  success: true,
@@ -18978,49 +22005,13 @@ async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
18978
22005
  }]
18979
22006
  };
18980
22007
  }
18981
- const projectName = ctx.project;
18982
- let publicProjectId = ctx.publicProjectId;
18983
- let remoteFileBuckets = ctx.remoteFileBuckets;
18984
- let remoteFilesCounter = ctx.remoteFilesCounter;
18985
- if (!publicProjectId || !remoteFileBuckets) {
18986
- if (!projectName) {
18987
- return {
18988
- success: false,
18989
- errorMessage: "Project name not found. Add project name to val.config or set the VAL_PROJECT environment variable"
18990
- };
18991
- }
18992
- const settingsRes = await ctx.remote.getSettings(projectName, {
18993
- pat
18994
- });
18995
- if (!settingsRes.success) {
18996
- return {
18997
- success: false,
18998
- errorMessage: `Could not get public project id: ${settingsRes.message}.`
18999
- };
19000
- }
19001
- publicProjectId = settingsRes.data.publicProjectId;
19002
- remoteFileBuckets = settingsRes.data.remoteFileBuckets.map(b => b.bucket);
19003
- }
19004
- if (!publicProjectId) {
19005
- return {
19006
- success: false,
19007
- errorMessage: "Could not get public project id"
19008
- };
19009
- }
19010
- if (!projectName) {
19011
- return {
19012
- success: false,
19013
- errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`
19014
- };
19015
- }
19016
- remoteFilesCounter += 1;
19017
- const bucket = remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
19018
- if (!bucket) {
19019
- return {
19020
- success: false,
19021
- errorMessage: `Internal error: could not allocate a bucket for the remote file located at ${ctx.sourcePath}`
19022
- };
22008
+ const opened = await openRemoteUploadSession(ctx);
22009
+ if (!opened.success) {
22010
+ return opened.result;
19023
22011
  }
22012
+ const {
22013
+ session
22014
+ } = opened;
19024
22015
  const fileBuffer = ctx.fs.readBuffer(filePath);
19025
22016
  if (fileBuffer === undefined) {
19026
22017
  return {
@@ -19029,32 +22020,16 @@ async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
19029
22020
  };
19030
22021
  }
19031
22022
  const relativeFilePath = path__namespace["default"].relative(ctx.projectRoot, filePath).split(path__namespace["default"].sep).join("/");
19032
- if (!relativeFilePath.startsWith("public/")) {
19033
- return {
19034
- success: false,
19035
- errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`
19036
- };
19037
- }
19038
- const fileHash = core.Internal.remote.getFileHash(fileBuffer);
19039
- const coreVersion = core.Internal.VERSION.core || "unknown";
19040
- const fileExt = getFileExt(filePath);
19041
- const ref = core.Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
19042
- publicProjectId,
19043
- coreVersion,
19044
- bucket,
19045
- validationHash: core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, metadata, fileHash, textEncoder),
19046
- fileHash,
19047
- filePath: relativeFilePath
19048
- });
19049
- const remoteFileUpload = await ctx.remote.uploadFile(projectName, bucket, fileHash, fileExt, fileBuffer, {
19050
- pat
19051
- });
19052
- if (!remoteFileUpload.success) {
22023
+ const uploaded = await uploadBytesToRemote(ctx, session, relativeFilePath, fileBuffer, metadata, schema);
22024
+ if (!uploaded.success) {
19053
22025
  return {
19054
22026
  success: false,
19055
- errorMessage: `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`
22027
+ errorMessage: uploaded.error
19056
22028
  };
19057
22029
  }
22030
+ const {
22031
+ ref
22032
+ } = uploaded;
19058
22033
  ctx.remoteFiles[ctx.sourcePath] = {
19059
22034
  ref,
19060
22035
  metadata
@@ -19062,9 +22037,9 @@ async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
19062
22037
  return {
19063
22038
  success: true,
19064
22039
  shouldApplyPatch: true,
19065
- publicProjectId,
19066
- remoteFileBuckets,
19067
- remoteFilesCounter,
22040
+ publicProjectId: session.publicProjectId,
22041
+ remoteFileBuckets: session.remoteFileBuckets,
22042
+ remoteFilesCounter: session.remoteFilesCounter,
19068
22043
  events: [{
19069
22044
  type: "remote-uploading",
19070
22045
  ref
@@ -19265,50 +22240,6 @@ async function handleUniqueFolderCheck(ctx) {
19265
22240
  success: true
19266
22241
  };
19267
22242
  }
19268
-
19269
- /**
19270
- * What is out of step between a gallery's entries and its directory.
19271
- *
19272
- * Two questions, and they treat a remote entry differently — which is the whole
19273
- * reason this is separate from the handler around it:
19274
- *
19275
- * - **Missing**: an entry with no bytes at its local path. Asked of LOCAL
19276
- * entries only. A remote entry's bytes live on the content host, and nothing
19277
- * puts a copy in the working tree: `saveOrUploadFiles` uploads the remote
19278
- * descriptors and copies only the local ones into the tree, so a remote entry
19279
- * added through the Studio (or over MCP) has no local file by design, and
19280
- * demanding one would mean committing remote bytes to git — which is what
19281
- * remote storage exists to avoid. Whether those bytes really are on the host
19282
- * is a different check, `image:check-remote`, which already runs for exactly
19283
- * these entries.
19284
- * - **Untracked**: a file in the directory that no entry claims. Asked of every
19285
- * entry, remote included, and that is why they are normalised to their local
19286
- * path: `val validate --fix` promotes a local file to a remote ref and leaves
19287
- * the file where it was, so a remote entry can perfectly well have one.
19288
- */
19289
- function checkGalleryFiles(input) {
19290
- const {
19291
- dir,
19292
- projectRoot,
19293
- fs
19294
- } = input;
19295
- const entries = input.entryKeys.map(galleryEntryOf);
19296
- const trackedFiles = new Set(entries.map(entry => entry.localPath));
19297
- const missingTrackedFiles = entries.filter(entry => !entry.remote && !fs.fileExists(path__namespace["default"].join(projectRoot, entry.localPath))).map(entry => entry.localPath);
19298
- const filesInDir = [];
19299
- try {
19300
- const found = fs.readDirectory(path__namespace["default"].join(projectRoot, dir), undefined, undefined, ["**/*"]);
19301
- for (const entry of found) {
19302
- filesInDir.push("/" + path__namespace["default"].relative(projectRoot, entry).split(path__namespace["default"].sep).join("/"));
19303
- }
19304
- } catch {
19305
- // directory doesn't exist — no untracked files possible
19306
- }
19307
- return {
19308
- missingTrackedFiles,
19309
- untrackedFiles: filesInDir.filter(f => !trackedFiles.has(f))
19310
- };
19311
- }
19312
22243
  async function handleCheckAllFiles(ctx) {
19313
22244
  const value = ctx.validationError.value;
19314
22245
  if (!value || typeof value.dir !== "string") {
@@ -19455,12 +22386,20 @@ const currentFixHandlers = {
19455
22386
  "image:add-metadata": handleFileMetadata,
19456
22387
  "file:check-metadata": handleFileMetadata,
19457
22388
  "file:add-metadata": handleFileMetadata,
22389
+ "video:add-metadata": handleVideoMetadata,
19458
22390
  "image:upload-remote": handleRemoteFileUpload,
19459
22391
  "file:upload-remote": handleRemoteFileUpload,
19460
22392
  "images:upload-remote": handleRemoteGalleryFileUpload,
19461
22393
  "files:upload-remote": handleRemoteGalleryFileUpload,
19462
22394
  "image:download-remote": handleRemoteFileDownload,
19463
22395
  "file:download-remote": handleRemoteFileDownload,
22396
+ "video:upload-remote": handleVideoUploadRemote,
22397
+ "video:download-remote": handleVideoDownloadRemote,
22398
+ "videos:add-metadata": handleVideosetMetadata,
22399
+ "videos:upload-remote": handleVideosetUploadRemote,
22400
+ "videos:check-remote": handleVideosetCheckRemote,
22401
+ "videos:check-unique-folder": handleUniqueFolderCheck,
22402
+ "videos:check-all-files": handleVideosetCheckAllFiles,
19464
22403
  "image:check-remote": handleRemoteFileCheck,
19465
22404
  "images:check-remote": handleRemoteFileCheck,
19466
22405
  "file:check-remote": handleRemoteFileCheck,
@@ -19656,20 +22595,15 @@ function createPrettierFormatter(prettier, options) {
19656
22595
  }
19657
22596
 
19658
22597
  /**
19659
- * NOTE: this is intentionally NOT `SharedValConfig` from `@valbuild/shared`.
19660
- * That schema requires `files.directory` to be exactly `/public/val`, whereas
19661
- * this one accepts any path beneath `/public`. Unifying them would change which
19662
- * configs are accepted, so they are kept separate on purpose.
22598
+ * NOTE: this is intentionally NOT `SharedValConfig` from `@valbuild/shared`,
22599
+ * which leaves `files` out: `files.remote` decides how the schemas are built,
22600
+ * so it is read where `val.config.ts` is, not shared with the client.
19663
22601
  */
19664
22602
  const ValConfigSchema = z__default["default"].object({
19665
22603
  project: z__default["default"].string().optional(),
19666
22604
  root: z__default["default"].string().optional(),
19667
22605
  files: z__default["default"].object({
19668
- directory: z__default["default"].string().refine(val => (val === "/public" || val.startsWith("/public/") && !val.endsWith("/")) &&
19669
- // Reject path traversal so the directory cannot escape /public
19670
- !val.split("/").some(segment => segment === "." || segment === ".."), {
19671
- message: "files.directory must start with '/public', must not end with '/' and must not contain '.' or '..' segments"
19672
- })
22606
+ remote: z__default["default"].boolean().optional()
19673
22607
  }).optional(),
19674
22608
  gitCommit: z__default["default"].string().optional(),
19675
22609
  gitBranch: z__default["default"].string().optional(),
@@ -20158,7 +23092,7 @@ async function replaySnapshot(snapshotDir) {
20158
23092
  const serverOps = new ValOpsFS(process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST, root, valModules,
20159
23093
  // The snapshot's own val.config, as evaluated by loadValModules - no need to
20160
23094
  // re-read it, and this way the replay uses exactly the config the snapshot
20161
- // carries (files.directory in particular).
23095
+ // carries.
20162
23096
  {
20163
23097
  config: valModules.config
20164
23098
  });
@@ -20241,6 +23175,7 @@ Object.defineProperty(exports, 'hasRemoteFileSchema', {
20241
23175
  enumerable: true,
20242
23176
  get: function () { return core.hasRemoteFileSchema; }
20243
23177
  });
23178
+ exports.APP_MODE_REQUIRES_REMOTE_FILES = APP_MODE_REQUIRES_REMOTE_FILES;
20244
23179
  exports.DEFAULT_LOGIN_EXPIRES_IN_SECONDS = DEFAULT_LOGIN_EXPIRES_IN_SECONDS;
20245
23180
  exports.DEFAULT_LOGIN_HOST = DEFAULT_LOGIN_HOST;
20246
23181
  exports.DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = DEFAULT_LOGIN_POLL_INTERVAL_SECONDS;
@@ -20280,6 +23215,10 @@ exports.evalValConfigFile = evalValConfigFile;
20280
23215
  exports.extractFileMetadata = extractFileMetadata;
20281
23216
  exports.extractImageMetadata = extractImageMetadata;
20282
23217
  exports.extractJsonValuesEntry = extractJsonValuesEntry;
23218
+ exports.extractVideoMetadata = extractVideoMetadata;
23219
+ exports.extractVideoMetadataFromFile = extractVideoMetadataFromFile;
23220
+ exports.filesOfVideo = filesOfVideo;
23221
+ exports.filesOfVideosetEntry = filesOfVideosetEntry;
20283
23222
  exports.findAndEvalValConfigFile = findAndEvalValConfigFile;
20284
23223
  exports.findDefaultExport = findDefaultExport;
20285
23224
  exports.findJsonEntryFilePath = findJsonEntryFilePath;
@@ -20304,6 +23243,10 @@ exports.handleRemoteFileDownload = handleRemoteFileDownload;
20304
23243
  exports.handleRemoteFileUpload = handleRemoteFileUpload;
20305
23244
  exports.handleRemoteGalleryFileUpload = handleRemoteGalleryFileUpload;
20306
23245
  exports.handleUniqueFolderCheck = handleUniqueFolderCheck;
23246
+ exports.handleVideosetCheckAllFiles = handleVideosetCheckAllFiles;
23247
+ exports.handleVideosetCheckRemote = handleVideosetCheckRemote;
23248
+ exports.handleVideosetMetadata = handleVideosetMetadata;
23249
+ exports.handleVideosetUploadRemote = handleVideosetUploadRemote;
20307
23250
  exports.initHandlerOptions = initHandlerOptions;
20308
23251
  exports.isExternalResult = isExternalResult;
20309
23252
  exports.loadValModules = loadValModules;
@@ -20322,3 +23265,4 @@ exports.startValLogin = startValLogin;
20322
23265
  exports.uploadRemoteFile = uploadRemoteFile;
20323
23266
  exports.validateMetadata = validateMetadata;
20324
23267
  exports.verifyJwt = verifyJwt;
23268
+ exports.videosetEntryVideoSchema = videosetEntryVideoSchema;