@taprootio/site-authoring 0.12.6 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,8 @@ import {
15
15
  } from "../fixture-contract.js";
16
16
  import { initializeFixture } from "../fixture-init.js";
17
17
  import { appearanceManifestEntry, FOOTER_SETTINGS_FILE, footerManifestEntry } from "../footer-workspace.js";
18
+ import { readWorkspaceForms } from "../forms-workspace.js";
19
+ import { FORMS_DIRECTORY } from "../forms-contract.js";
18
20
  import {
19
21
  isRedirectMapRevision,
20
22
  normalizeRedirectPath,
@@ -41,7 +43,13 @@ import { validateFooterWorkspaceDocument } from "./footer-push.js";
41
43
  import { validateNavigationWorkspaceDocument } from "./nav-push.js";
42
44
  import { validateWorkspacePageDocument, validateWorkspacePageSource } from "./pages-push.js";
43
45
  import { validateThemeWorkspace } from "./theme-push.js";
44
- import { documentImageIds, documentTemplate, isAuthorableTemplateType, PAGE_TEMPLATES } from "../typed-pages.js";
46
+ import {
47
+ documentImageIds,
48
+ documentTemplate,
49
+ isAuthorableTemplateType,
50
+ PAGE_TEMPLATES,
51
+ placedVideoIds,
52
+ } from "../typed-pages.js";
45
53
 
46
54
  const MAXIMUM_REPORTED = 200;
47
55
  const FIXTURE_ROOT_KEYS = new Set(FIXTURE_ROOT_FIELDS);
@@ -192,6 +200,25 @@ function validateFixtureManifest(manifest) {
192
200
  if (imageIds.size !== manifest.fixture.imageIds.length) {
193
201
  fail("fixture.image_ids_invalid", "fixture.imageIds must not contain duplicates.", "fixture.imageIds");
194
202
  }
203
+ // Optional: a fixture that places no video omits the list. Whatever is listed
204
+ // is a `ready` video a page may place, the offline stand-in for the library
205
+ // read a live push makes.
206
+ const declaredVideoIds = manifest.fixture.videoIds ?? [];
207
+ if (
208
+ !Array.isArray(declaredVideoIds)
209
+ || declaredVideoIds.length > WORKSPACE_LIMITS.files
210
+ || declaredVideoIds.some((value) => !isCanonicalUuid(value))
211
+ ) {
212
+ fail(
213
+ "fixture.video_ids_invalid",
214
+ "fixture.videoIds must be a bounded list of deterministic canonical UUIDs.",
215
+ "fixture.videoIds",
216
+ );
217
+ }
218
+ const videoIds = new Set(declaredVideoIds);
219
+ if (videoIds.size !== declaredVideoIds.length) {
220
+ fail("fixture.video_ids_invalid", "fixture.videoIds must not contain duplicates.", "fixture.videoIds");
221
+ }
195
222
  if (
196
223
  !Array.isArray(manifest.fixture.deliveryOrigins)
197
224
  || manifest.fixture.deliveryOrigins.length > FIXTURE_MAXIMUM_DELIVERY_ORIGINS
@@ -315,7 +342,7 @@ function validateFixtureManifest(manifest) {
315
342
  files.add(entry.file);
316
343
  paths.add(pagePath);
317
344
  }
318
- return { manifest, imageIds, deliveryOrigins, pageIds, resourceIds, files, settingsEntityIds };
345
+ return { manifest, imageIds, videoIds, deliveryOrigins, pageIds, resourceIds, files, settingsEntityIds };
319
346
  }
320
347
 
321
348
  function validateDeliveryUrl(value, field, deliveryOrigins) {
@@ -405,6 +432,57 @@ function headerWidthHints(headerWidth, rootBandPages) {
405
432
  }];
406
433
  }
407
434
 
435
+ /**
436
+ * The fixture's forms, when it carries any. The manifest binding and the files
437
+ * must name the same keys: a form file the manifest does not mention would be
438
+ * validated by no one, and a key with no file would be a baseline for nothing.
439
+ * Returns undefined for a fixture with no forms.
440
+ */
441
+ async function validateFixtureForms(fixtureRoot, bound, onProgress) {
442
+ const files = await readWorkspaceForms(fixtureRoot);
443
+ if (bound === undefined) {
444
+ if (files.length > 0) {
445
+ fail(
446
+ "fixture.forms_unbound",
447
+ "The fixture carries form files but its manifest does not bind them in 'forms'.",
448
+ "forms",
449
+ );
450
+ }
451
+ return undefined;
452
+ }
453
+ const items = isPlainObject(bound) && isPlainObject(bound.items) ? bound.items : undefined;
454
+ const keys = items === undefined ? [] : Object.keys(items).sort();
455
+ if (
456
+ items === undefined
457
+ || Object.keys(bound).length !== 1
458
+ || keys.length === 0
459
+ || keys.some((key) =>
460
+ !isPlainObject(items[key])
461
+ || Object.keys(items[key]).sort().join() !== "id,version"
462
+ || !isCanonicalUuid(items[key].id)
463
+ || !Number.isSafeInteger(items[key].version)
464
+ || items[key].version < 1
465
+ )
466
+ ) {
467
+ fail(
468
+ "fixture.forms_invalid",
469
+ "The fixture manifest's 'forms' must be { items: { <key>: { id, version } } } with at least one form, "
470
+ + "a canonical UUID id, and a positive version.",
471
+ "forms",
472
+ );
473
+ }
474
+ const fileKeys = files.map((form) => form.key);
475
+ if (fileKeys.join() !== keys.join()) {
476
+ fail(
477
+ "fixture.forms_mismatch",
478
+ `The fixture manifest binds forms [${keys.join(", ")}] but ${FORMS_DIRECTORY}/ holds [${fileKeys.join(", ")}].`,
479
+ "forms",
480
+ );
481
+ }
482
+ onProgress(`Validated ${files.length} form file${files.length === 1 ? "" : "s"} against the shared field schema.`);
483
+ return files;
484
+ }
485
+
408
486
  export async function validateFixture(invocation = {}) {
409
487
  if (invocation.init) return await initializeFixture(invocation, validateFixture);
410
488
  const onProgress = typeof invocation.onProgress === "function" ? invocation.onProgress : () => {};
@@ -416,6 +494,7 @@ export async function validateFixture(invocation = {}) {
416
494
  const {
417
495
  manifest,
418
496
  imageIds,
497
+ videoIds,
419
498
  deliveryOrigins,
420
499
  pageIds,
421
500
  resourceIds,
@@ -521,6 +600,14 @@ export async function validateFixture(invocation = {}) {
521
600
  file,
522
601
  );
523
602
  }
603
+ const unknownVideo = [...placedVideoIds(page.document)].find((videoId) => !videoIds.has(videoId));
604
+ if (unknownVideo !== undefined) {
605
+ fail(
606
+ "fixture.video_reference_unknown",
607
+ `Fixture video '${unknownVideo}' is not declared in fixture.videoIds, so it is not a ready video of this site.`,
608
+ file,
609
+ );
610
+ }
524
611
  validatedPages.push({ file, path: page.pagePath });
525
612
  if (pageUsesRootBand(page.document)) rootBandPages.push(file);
526
613
  }
@@ -597,6 +684,8 @@ export async function validateFixture(invocation = {}) {
597
684
  },
598
685
  );
599
686
 
687
+ const forms = await validateFixtureForms(fixtureRoot, manifest.forms, onProgress);
688
+
600
689
  const reportedPages = boundedList(validatedPages, MAXIMUM_REPORTED);
601
690
  onProgress(
602
691
  `Validated ${validatedPages.length} page(s), ${navigation.items} navigation item(s), two themes, appearance, and footer without credentials or mutation.`,
@@ -607,6 +696,7 @@ export async function validateFixture(invocation = {}) {
607
696
  contractVersion: FIXTURE_CONTRACT_VERSION,
608
697
  manifest: FIXTURE_MANIFEST_FILE_NAME,
609
698
  imageIds: imageIds.size,
699
+ videoIds: videoIds.size,
610
700
  deliveryOrigins: deliveryOrigins.size,
611
701
  },
612
702
  validated: {
@@ -620,6 +710,7 @@ export async function validateFixture(invocation = {}) {
620
710
  entries: redirects.entries.length,
621
711
  gone: redirects.entries.filter((entry) => entry.kind === REDIRECT_KIND_GONE).length,
622
712
  },
713
+ ...(forms === undefined ? {} : { forms: { count: forms.length } }),
623
714
  themes: 2,
624
715
  appearanceSettings: presentation.scalarOperations.length,
625
716
  footer: true,
@@ -636,6 +727,7 @@ export async function validateFixture(invocation = {}) {
636
727
  "navigation shape and local page-resource references",
637
728
  "redirect-map shape, normalization, chains, loops, and fixture-local path occupancy",
638
729
  "complete theme, appearance, header, brand, and footer semantics",
730
+ "form files, when the manifest binds any, against the shared field schema",
639
731
  "fixture-local image identities and reserved delivery origins",
640
732
  ],
641
733
  doesNotProve: [
@@ -0,0 +1,90 @@
1
+ import { SiteAuthoringError } from "./errors.js";
2
+
3
+ /**
4
+ * Container sniffing for video uploads in `media upload`.
5
+ *
6
+ * Like the image sniffer this reads a fixed, bounded prefix and nothing else:
7
+ * it names the container (MP4, MOV or WebM) so `video-prepare.js` can read the
8
+ * file, and leaves the real validation to that read and to the API, which
9
+ * accepts only an H.264 and AAC MP4. A file that is not one of the three is not
10
+ * claimed here, so the caller falls through to the image path and its own
11
+ * refusal.
12
+ */
13
+
14
+ export const VIDEO_CONTENT_TYPES = Object.freeze({
15
+ mp4: "video/mp4",
16
+ quicktime: "video/quicktime",
17
+ webm: "video/webm",
18
+ });
19
+
20
+ /** The extensions a directory walk treats as video candidates; the container decides. */
21
+ export const VIDEO_EXTENSIONS = Object.freeze([".mp4", ".mov", ".webm"]);
22
+
23
+ /**
24
+ * The major brands of an ISO base media file that are an MP4 video. The `ftyp`
25
+ * box is shared with HEIC, HEIF, AVIF, M4A and 3GP, none of which are videos
26
+ * this upload accepts, so the brand decides rather than the box alone.
27
+ */
28
+ const MP4_BRANDS = Object.freeze([
29
+ "isom",
30
+ "iso2",
31
+ "iso3",
32
+ "iso4",
33
+ "iso5",
34
+ "iso6",
35
+ "mp41",
36
+ "mp42",
37
+ "avc1",
38
+ "dash",
39
+ "M4V ",
40
+ "M4VH",
41
+ "M4VP",
42
+ "MSNV",
43
+ "f4v ",
44
+ ]);
45
+ const QUICKTIME_BRAND = "qt ";
46
+
47
+ const EBML_MAGIC = [0x1a, 0x45, 0xdf, 0xa3];
48
+ // The EBML header carries its DocType within the first few dozen bytes.
49
+ const EBML_DOCTYPE_WINDOW = 64;
50
+
51
+ function startsWithAscii(bytes, text, offset) {
52
+ if (bytes.byteLength < offset + text.length) return false;
53
+ for (let index = 0; index < text.length; index += 1) {
54
+ if (bytes[offset + index] !== text.charCodeAt(index)) return false;
55
+ }
56
+ return true;
57
+ }
58
+
59
+ function containsAscii(bytes, text, limit) {
60
+ const end = Math.min(bytes.byteLength, limit) - text.length;
61
+ for (let offset = 0; offset <= end; offset += 1) {
62
+ if (startsWithAscii(bytes, text, offset)) return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * The video content type for a file's leading bytes, or `undefined` when they
69
+ * are not an MP4, MOV or WebM container (an ISO base media file with another
70
+ * brand, such as HEIC or M4A, is not one). A Matroska file shares WebM's EBML
71
+ * header but is not accepted, so the DocType decides.
72
+ */
73
+ export function sniffVideoContentType(header) {
74
+ if (header.byteLength >= 12 && startsWithAscii(header, "ftyp", 4)) {
75
+ if (startsWithAscii(header, QUICKTIME_BRAND, 8)) return VIDEO_CONTENT_TYPES.quicktime;
76
+ return MP4_BRANDS.some((brand) => startsWithAscii(header, brand, 8)) ? VIDEO_CONTENT_TYPES.mp4 : undefined;
77
+ }
78
+ if (header.byteLength >= 4 && EBML_MAGIC.every((byte, index) => header[index] === byte)) {
79
+ return containsAscii(header, "webm", EBML_DOCTYPE_WINDOW) ? VIDEO_CONTENT_TYPES.webm : undefined;
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ export function videoTooLarge(fileName, maximumBytes) {
85
+ return new SiteAuthoringError(
86
+ "media.video_too_large",
87
+ `'${fileName}' is larger than the ${Math.round(maximumBytes / (1024 * 1024 * 1024))} GiB a single video upload accepts.`,
88
+ { field: fileName },
89
+ );
90
+ }
@@ -0,0 +1,136 @@
1
+ import { open } from "node:fs/promises";
2
+
3
+ import { ALL_FORMATS, BufferTarget, Conversion, FilePathSource, Input, Mp4OutputFormat, Output } from "mediabunny";
4
+
5
+ /**
6
+ * Prepares a video for `media upload` the way the Videos page does: read the container
7
+ * and codecs, rewrite an H.264 and AAC file that is not an MP4 with its index first as
8
+ * one (copying the tracks, never re-encoding), and declare the real codec strings so
9
+ * the server decides what is accepted. Anything that is not H.264 and AAC is left
10
+ * alone and declared as it is, so the server's refusal names what the file actually is.
11
+ */
12
+
13
+ /** The most any licence's upload cap allows; a larger file is described, not remuxed. */
14
+ export const DEFAULT_MAX_REMUX_BYTES = 250 * 1024 * 1024;
15
+
16
+ const MAX_TOP_LEVEL_BOXES = 64;
17
+
18
+ export function isH264(codec) {
19
+ return /^avc[13]/iu.test(codec);
20
+ }
21
+
22
+ /** No audio track (an empty string) is allowed. */
23
+ export function isAac(codec) {
24
+ return codec === "" || /^mp4a\.(40\.(1|2|3|4|5|29)|6[678])$/iu.test(codec);
25
+ }
26
+
27
+ export function mp4Name(name) {
28
+ return /\.mp4$/iu.test(name) ? name : `${name.replace(/\.[^./\\]+$/u, "")}.mp4`;
29
+ }
30
+
31
+ /** Whether `moov` precedes `mdat` among a file's top-level boxes, read with a few small reads. */
32
+ export async function indexComesFirst(filePath) {
33
+ const handle = await open(filePath, "r");
34
+ try {
35
+ const { size: fileSize } = await handle.stat();
36
+ const header = Buffer.alloc(16);
37
+ let offset = 0;
38
+ for (let boxes = 0; boxes < MAX_TOP_LEVEL_BOXES && offset < fileSize; boxes += 1) {
39
+ const { bytesRead } = await handle.read(header, 0, 16, offset);
40
+ if (bytesRead < 8) return false;
41
+ const type = header.toString("latin1", 4, 8);
42
+ if (type === "moov") return true;
43
+ if (type === "mdat") return false;
44
+ let size = header.readUInt32BE(0);
45
+ if (size === 1 && bytesRead >= 16) size = Number(header.readBigUInt64BE(8));
46
+ else if (size === 0) return false;
47
+ if (!Number.isFinite(size) || size < 8) return false;
48
+ offset += size;
49
+ }
50
+ return false;
51
+ } finally {
52
+ await handle.close();
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {{ filePath: string, byteLength: number, maxRemuxBytes?: number }} file
58
+ * @returns {Promise<{ videoCodec: string, audioCodec: string, remuxed: Buffer | null }>}
59
+ * `remuxed` is the rewritten MP4 when one was needed and possible, else null (upload the file as it is).
60
+ */
61
+ export async function prepareVideoFile({ filePath, byteLength, maxRemuxBytes = DEFAULT_MAX_REMUX_BYTES }) {
62
+ const input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS });
63
+ try {
64
+ const format = await input.getFormat();
65
+ const video = await input.getPrimaryVideoTrack();
66
+ const audio = await input.getPrimaryAudioTrack();
67
+ // The server refuses a file if any track is not H.264 or AAC, so the first track that is
68
+ // not is the one declared and named. A track whose codec mediabunny cannot name is
69
+ // "unknown", not absent: an absent audio track is fine, a silent copy of one that exists is not.
70
+ const videoCodec = await declaredCodec(await input.getVideoTracks(), isH264);
71
+ const audioCodec = await declaredCodec(await input.getAudioTracks(), isAac);
72
+ const described = { videoCodec, audioCodec, remuxed: null };
73
+
74
+ // A remux keeps only the primary tracks, so they are what must be accepted for it to help.
75
+ const primaryVideo = video ? await codecOf(video) : "";
76
+ const primaryAudio = audio ? await codecOf(audio) : "";
77
+ if (!isH264(primaryVideo) || !isAac(primaryAudio)) return described;
78
+
79
+ const everyTrackAccepted = isH264(videoCodec) && isAac(audioCodec);
80
+ if (everyTrackAccepted && format.mimeType === "video/mp4" && await indexComesFirst(filePath)) return described;
81
+ if (byteLength > maxRemuxBytes) return described;
82
+
83
+ try {
84
+ const remuxed = await remux(input, { video: video?.id, audio: audio?.id });
85
+ // The rewrite holds only the primaries, so those are the codecs the server will see.
86
+ return remuxed ? { videoCodec: primaryVideo, audioCodec: primaryAudio, remuxed } : described;
87
+ } catch {
88
+ // A remux that fails leaves the real codecs on the original, so the server's
89
+ // structure refusal names the real problem.
90
+ return described;
91
+ }
92
+ } catch {
93
+ // An unreadable file is declared with no codecs; the server answers in its own words.
94
+ return { videoCodec: "", audioCodec: "", remuxed: null };
95
+ } finally {
96
+ input.dispose();
97
+ }
98
+ }
99
+
100
+ async function codecOf(track) {
101
+ return (await track.getCodecParameterString()) ?? "unknown";
102
+ }
103
+
104
+ /** The first codec that is not accepted, else the first track's, else none. */
105
+ async function declaredCodec(tracks, accepts) {
106
+ let first = "";
107
+ for (const track of tracks) {
108
+ const codec = await codecOf(track);
109
+ if (!accepts(codec)) return codec;
110
+ first ||= codec;
111
+ }
112
+ return first;
113
+ }
114
+
115
+ async function remux(input, primary) {
116
+ const target = new BufferTarget();
117
+ const output = new Output({ format: new Mp4OutputFormat({ fastStart: "in-memory" }), target });
118
+ // Copy only: a track that cannot be copied is dropped, never re-encoded.
119
+ const conversion = await Conversion.init({
120
+ input,
121
+ output,
122
+ copy: { mode: "forced", shiftTolerance: Number.POSITIVE_INFINITY },
123
+ // Only the primary picture and sound go in; an extra track is dropped, not copied.
124
+ video: (track) => (track.id === primary.video ? {} : { discard: true }),
125
+ audio: (track) => (track.id === primary.audio ? {} : { discard: true }),
126
+ });
127
+ const kept = conversion.utilizedTracks;
128
+ if (
129
+ !conversion.isValid
130
+ || !kept.some((track) => track.isVideoTrack())
131
+ || (primary.audio !== undefined && !kept.some((track) => track.isAudioTrack()))
132
+ ) return null;
133
+
134
+ await conversion.execute();
135
+ return target.buffer ? Buffer.from(target.buffer) : null;
136
+ }
package/src/workspace.js CHANGED
@@ -270,6 +270,10 @@ export const WORKSPACE_LIMITS = Object.freeze({
270
270
  settingsBytes: 256 * 1024,
271
271
  manifestBytes: 8 * 1024 * 1024,
272
272
  mediaBytes: 32 * 1024 * 1024,
273
+ // A video is streamed unless it must be remuxed: this mirrors the API's own
274
+ // single-PUT ceiling (`VideoUploadLimits.MaxSingleUploadMb`, 5 GiB). The licence's
275
+ // upload cap, which the server enforces, is far lower.
276
+ videoBytes: 5 * 1024 * 1024 * 1024,
273
277
  // Pull holds the bodies of the pages this workspace already tracks until it
274
278
  // knows whether any of them conflict, because a refusal has to leave `pages/`
275
279
  // untouched. Generous for any real authoring workspace, and an explicit
@@ -760,6 +764,106 @@ export async function readWorkspaceFile(workspaceDir, relativePath, maximumBytes
760
764
  }
761
765
  }
762
766
 
767
+ /** How much of a file's start `openWorkspaceFileStream` hands back for container sniffing. */
768
+ const STREAM_HEADER_BYTES = 4096;
769
+
770
+ /**
771
+ * Opens one workspace file for streaming under the same discipline as
772
+ * `readWorkspaceFile` — no link anywhere on the way, a regular file, a byte
773
+ * bound — without reading it into memory. Returns its size, a bounded prefix
774
+ * for container sniffing, and a factory for a fresh read stream, because an
775
+ * upload that is retried reads the file again from the start. The caller must
776
+ * `close()`.
777
+ */
778
+ export async function openWorkspaceFileStream(workspaceDir, relativePath, maximumBytes) {
779
+ const filePath = resolveWorkspacePath(workspaceDir, relativePath);
780
+ if (!await requireRealDirectoryChain(workspaceDir, relativePath)) {
781
+ throw new SiteAuthoringError(
782
+ "workspace.file_missing",
783
+ `Workspace file '${relativePath}' does not exist.`,
784
+ { field: relativePath },
785
+ );
786
+ }
787
+ let handle;
788
+ try {
789
+ handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
790
+ const stats = await handle.stat({ bigint: true });
791
+ if (!stats.isFile()) {
792
+ throw new SiteAuthoringError(
793
+ "workspace.not_regular",
794
+ `Workspace file '${relativePath}' is not a regular file.`,
795
+ { field: relativePath },
796
+ );
797
+ }
798
+ if (stats.size > BigInt(maximumBytes)) {
799
+ throw new SiteAuthoringError(
800
+ "workspace.file_too_large",
801
+ `Workspace file '${relativePath}' exceeds ${maximumBytes} bytes.`,
802
+ { field: relativePath },
803
+ );
804
+ }
805
+ const byteLength = Number(stats.size);
806
+ const modifiedMilliseconds = Math.floor(Number(stats.mtimeMs));
807
+ const header = Buffer.allocUnsafe(Math.min(byteLength, STREAM_HEADER_BYTES));
808
+ let total = 0;
809
+ while (total < header.byteLength) {
810
+ const { bytesRead } = await handle.read(header, total, header.byteLength - total, total);
811
+ if (bytesRead === 0) break;
812
+ total += bytesRead;
813
+ }
814
+ const opened = handle;
815
+ return {
816
+ byteLength,
817
+ modifiedMilliseconds,
818
+ header: header.subarray(0, total),
819
+ // Exactly the size that was inspected. A file that shrinks under the
820
+ // upload fails the signed length check; one that grows would not, since
821
+ // only its first `byteLength` bytes are read, so `verifyUnchanged` is how
822
+ // the caller proves what it sent is still the file on disk.
823
+ stream: () => opened.createReadStream({ start: 0, end: byteLength - 1, autoClose: false }),
824
+ verifyUnchanged: async () => {
825
+ // The held handle catches an in-place append or rewrite; the path is
826
+ // checked too, because an editor that saves by renaming a new file over
827
+ // the old one leaves the handle looking untouched.
828
+ const current = await opened.stat({ bigint: true });
829
+ const onDisk = await lstat(filePath, { bigint: true }).catch(() => undefined);
830
+ if (
831
+ current.size !== stats.size
832
+ || current.mtimeMs !== stats.mtimeMs
833
+ || onDisk === undefined
834
+ || onDisk.ino !== stats.ino
835
+ || onDisk.dev !== stats.dev
836
+ ) {
837
+ throw new SiteAuthoringError(
838
+ "workspace.file_changed",
839
+ `Workspace file '${relativePath}' changed while it was being read, so what was sent is not the file on disk. `
840
+ + "Wait for whatever is writing it to finish and run the command again.",
841
+ { field: relativePath },
842
+ );
843
+ }
844
+ },
845
+ close: async () => {
846
+ await opened.close().catch(() => {});
847
+ },
848
+ };
849
+ } catch (error) {
850
+ await handle?.close().catch(() => {});
851
+ if (error instanceof SiteAuthoringError) throw error;
852
+ if (error && typeof error === "object" && error.code === "ENOENT") {
853
+ throw new SiteAuthoringError(
854
+ "workspace.file_missing",
855
+ `Workspace file '${relativePath}' does not exist.`,
856
+ { field: relativePath },
857
+ );
858
+ }
859
+ throw new SiteAuthoringError(
860
+ "workspace.unreadable",
861
+ `Could not read workspace file '${relativePath}'.`,
862
+ { field: relativePath },
863
+ );
864
+ }
865
+ }
866
+
763
867
  export async function readWorkspaceJson(workspaceDir, relativePath, maximumBytes) {
764
868
  const bytes = await readWorkspaceFile(workspaceDir, relativePath, maximumBytes);
765
869
  try {
@@ -1180,7 +1284,7 @@ export async function readMediaManifest(workspaceDir, expectedSiteId) {
1180
1284
  { field: MEDIA_MANIFEST_FILE_NAME },
1181
1285
  );
1182
1286
  }
1183
- return { mediaManifestVersion: MEDIA_MANIFEST_VERSION, siteId: expectedSiteId, media: {} };
1287
+ return { mediaManifestVersion: MEDIA_MANIFEST_VERSION, siteId: expectedSiteId, media: {}, videos: {} };
1184
1288
  }
1185
1289
  const parsed = requireManifestObject(
1186
1290
  await readWorkspaceJson(workspaceDir, MEDIA_MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes),
@@ -1209,6 +1313,14 @@ export async function readMediaManifest(workspaceDir, expectedSiteId) {
1209
1313
  "media map",
1210
1314
  MEDIA_MANIFEST_FILE_NAME,
1211
1315
  ),
1316
+ // Videos uploaded from this workspace, by file: absent in a manifest
1317
+ // written before video upload, which reads as none.
1318
+ videos: requireManifestObject(
1319
+ parsed.videos ?? {},
1320
+ "workspace.media_manifest_invalid",
1321
+ "video map",
1322
+ MEDIA_MANIFEST_FILE_NAME,
1323
+ ),
1212
1324
  };
1213
1325
  }
1214
1326