@taprootio/site-authoring 0.12.7 → 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.
@@ -1,5 +1,16 @@
1
- import { createPage, listSitePages, PAGE_STATUS_DELETED, updatePage, withRefusalGuidance } from "../api.js";
1
+ import {
2
+ createPage,
3
+ importVideoEmbedPoster,
4
+ listSitePages,
5
+ listVideos,
6
+ IMAGE_PROCESSING_STATE_COMPLETE,
7
+ PAGE_STATUS_DELETED,
8
+ posterFromImage,
9
+ updatePage,
10
+ withRefusalGuidance,
11
+ } from "../api.js";
2
12
  import { VERB_PAGES_PUSH } from "../constants.js";
13
+ import { waitForProcessing } from "./media-upload.js";
3
14
  import { SiteAuthoringError } from "../errors.js";
4
15
  import { RUNTIME_MIRROR_PATH_REASON, isRuntimeMirrorPath } from "../reserved-paths.js";
5
16
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
@@ -8,6 +19,8 @@ import {
8
19
  contentDocuments,
9
20
  COVER_IMAGE_UNUSED_MESSAGE,
10
21
  documentTemplate,
22
+ placedVideoIds,
23
+ posterlessVideoEmbeds,
11
24
  FRONT_MATTER_KEYS,
12
25
  PAGE_TEMPLATES,
13
26
  requireTemplateName,
@@ -708,9 +721,84 @@ export async function validateWorkspacePageDocument({
708
721
  }
709
722
  }
710
723
 
724
+ /**
725
+ * Refuses a push that places a video the site's library does not hold; every
726
+ * library video is ready to play. Pages that will not be sent are not asked about.
727
+ */
728
+ async function requirePlacedVideosExist(client, siteId, planned) {
729
+ const placements = new Map();
730
+ for (const page of planned) {
731
+ if (page.unchanged) continue;
732
+ for (const videoId of placedVideoIds(page.document)) {
733
+ if (!placements.has(videoId)) placements.set(videoId, page.file);
734
+ }
735
+ }
736
+ if (placements.size === 0) return;
737
+
738
+ const library = await listVideos(client, siteId);
739
+ const knownIds = new Set(library.videos.map((video) => video.videoId));
740
+ for (const [videoId, file] of placements) {
741
+ const known = knownIds.has(videoId);
742
+ if (!known && library.truncated) {
743
+ throw documentError(
744
+ "pages.video_library_unverifiable",
745
+ `'${file}' places video ${videoId}, which is not among the first ${library.videos.length} videos of this `
746
+ + "site's library, and the library is larger than one push can check. Reduce the library or place the video "
747
+ + "from the editor.",
748
+ file,
749
+ );
750
+ }
751
+ if (!known) {
752
+ throw documentError(
753
+ "pages.video_unknown",
754
+ `'${file}' places video ${videoId}, which is not in this site's video library. Upload it with `
755
+ + "'taproot-site media upload' and use the videoId it reports.",
756
+ file,
757
+ );
758
+ }
759
+ }
760
+ }
761
+
762
+ /**
763
+ * Gives each `video-embed` block that has no poster the provider's thumbnail,
764
+ * copied into the site's images by the server (TR01109). One request per provider
765
+ * and id for the whole push. A refusal leaves that block without a poster, since
766
+ * an embed plays without one, and the push goes on; pages that will not be sent
767
+ * are not touched. Offline `validate` never reaches this.
768
+ */
769
+ async function copyVideoEmbedPosters(client, siteId, planned, { onProgress, now }) {
770
+ const posters = new Map();
771
+ for (const page of planned) {
772
+ if (page.unchanged) continue;
773
+ for (const embed of posterlessVideoEmbeds(page.document)) {
774
+ const key = `${embed.provider}:${embed.videoId}`;
775
+ if (!posters.has(key)) {
776
+ onProgress(`Copying the ${embed.provider} thumbnail for video ${embed.videoId} into the site's images.`);
777
+ try {
778
+ const imported = await importVideoEmbedPoster(client, siteId, embed.provider, embed.videoId);
779
+ // The copy is processed like an upload; a poster that cannot be delivered is no poster.
780
+ // It is an embed poster, which no library lists, so its state is read by id.
781
+ const observed = await waitForProcessing(client, siteId, [imported.imageId], { onProgress, now, byId: true });
782
+ const image = observed.get(imported.imageId);
783
+ if (image?.processingState !== IMAGE_PROCESSING_STATE_COMPLETE) {
784
+ throw new SiteAuthoringError("media.processing_failed", "Taproot could not process the copied thumbnail.");
785
+ }
786
+ posters.set(key, posterFromImage(image));
787
+ } catch (error) {
788
+ if (!(error instanceof SiteAuthoringError)) throw error;
789
+ onProgress(`'${page.file}' keeps no poster for ${embed.provider} video ${embed.videoId}: ${error.message}`);
790
+ posters.set(key, undefined);
791
+ }
792
+ }
793
+ const poster = posters.get(key);
794
+ if (poster !== undefined) embed.setPoster(poster);
795
+ }
796
+ }
797
+ }
798
+
711
799
  export async function pagesPush(invocation) {
712
800
  const session = await openSession(invocation);
713
- const { client, config, siteId, onProgress } = session;
801
+ const { client, config, siteId, now, onProgress } = session;
714
802
  // One advisory line before this verb does any work, and only when the
715
803
  // exchange said the platform is paused. It changes nothing else: the write
716
804
  // still runs and its refusal still classifies as platform_paused (TR00692).
@@ -1001,6 +1089,13 @@ export async function pagesPush(invocation) {
1001
1089
  });
1002
1090
  }
1003
1091
 
1092
+ // A page that places a video names it by id, and only the library knows
1093
+ // whether that id is one of this site's videos. Asked once for the whole
1094
+ // push, before anything is written, so a page cannot be sent that
1095
+ // publishing would then refuse.
1096
+ await requirePlacedVideosExist(client, siteId, planned);
1097
+ await copyVideoEmbedPosters(client, siteId, planned, { onProgress, now });
1098
+
1004
1099
  // Phase two: send. The manifest is written back even if a later page fails,
1005
1100
  // so a retry updates the pages this run created rather than duplicating them.
1006
1101
  const applied = [];
@@ -43,7 +43,13 @@ import { validateFooterWorkspaceDocument } from "./footer-push.js";
43
43
  import { validateNavigationWorkspaceDocument } from "./nav-push.js";
44
44
  import { validateWorkspacePageDocument, validateWorkspacePageSource } from "./pages-push.js";
45
45
  import { validateThemeWorkspace } from "./theme-push.js";
46
- 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";
47
53
 
48
54
  const MAXIMUM_REPORTED = 200;
49
55
  const FIXTURE_ROOT_KEYS = new Set(FIXTURE_ROOT_FIELDS);
@@ -194,6 +200,25 @@ function validateFixtureManifest(manifest) {
194
200
  if (imageIds.size !== manifest.fixture.imageIds.length) {
195
201
  fail("fixture.image_ids_invalid", "fixture.imageIds must not contain duplicates.", "fixture.imageIds");
196
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
+ }
197
222
  if (
198
223
  !Array.isArray(manifest.fixture.deliveryOrigins)
199
224
  || manifest.fixture.deliveryOrigins.length > FIXTURE_MAXIMUM_DELIVERY_ORIGINS
@@ -317,7 +342,7 @@ function validateFixtureManifest(manifest) {
317
342
  files.add(entry.file);
318
343
  paths.add(pagePath);
319
344
  }
320
- return { manifest, imageIds, deliveryOrigins, pageIds, resourceIds, files, settingsEntityIds };
345
+ return { manifest, imageIds, videoIds, deliveryOrigins, pageIds, resourceIds, files, settingsEntityIds };
321
346
  }
322
347
 
323
348
  function validateDeliveryUrl(value, field, deliveryOrigins) {
@@ -469,6 +494,7 @@ export async function validateFixture(invocation = {}) {
469
494
  const {
470
495
  manifest,
471
496
  imageIds,
497
+ videoIds,
472
498
  deliveryOrigins,
473
499
  pageIds,
474
500
  resourceIds,
@@ -574,6 +600,14 @@ export async function validateFixture(invocation = {}) {
574
600
  file,
575
601
  );
576
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
+ }
577
611
  validatedPages.push({ file, path: page.pagePath });
578
612
  if (pageUsesRootBand(page.document)) rootBandPages.push(file);
579
613
  }
@@ -662,6 +696,7 @@ export async function validateFixture(invocation = {}) {
662
696
  contractVersion: FIXTURE_CONTRACT_VERSION,
663
697
  manifest: FIXTURE_MANIFEST_FILE_NAME,
664
698
  imageIds: imageIds.size,
699
+ videoIds: videoIds.size,
665
700
  deliveryOrigins: deliveryOrigins.size,
666
701
  },
667
702
  validated: {
@@ -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