@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.
@@ -1,17 +1,25 @@
1
+ import { Readable } from "node:stream";
2
+
1
3
  import {
2
4
  confirmImageUpload,
5
+ confirmVideoUpload,
3
6
  IMAGE_OWNERSHIP_SCOPE_SITE,
4
7
  IMAGE_PROCESSING_STATE_COMPLETE,
5
8
  IMAGE_PROCESSING_STATE_FAILED,
6
9
  listSiteImages,
10
+ listVideos,
7
11
  normalizeImage,
8
12
  poll,
9
13
  requestImageUpload,
14
+ requestVideoUpload,
10
15
  withRefusalGuidance,
11
16
  } from "../api.js";
12
17
  import { LIMITS, VERB_MEDIA_UPLOAD } from "../constants.js";
13
- import { SiteAuthoringError } from "../errors.js";
18
+ import { SiteAuthoringError, sanitizeDiagnostic } from "../errors.js";
14
19
  import { contentHash, inspectImageBytes } from "../image-metadata.js";
20
+ import { ApiError } from "../transport.js";
21
+ import { sniffVideoContentType, VIDEO_EXTENSIONS, videoTooLarge } from "../video-metadata.js";
22
+ import { mp4Name, prepareVideoFile } from "../video-prepare.js";
15
23
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
16
24
  import {
17
25
  ensureWorkspaceRoot,
@@ -19,8 +27,10 @@ import {
19
27
  MEDIA_DIRECTORY,
20
28
  MEDIA_MANIFEST_FILE_NAME,
21
29
  MEDIA_MANIFEST_VERSION,
30
+ openWorkspaceFileStream,
22
31
  readMediaManifest,
23
32
  readWorkspaceFile,
33
+ resolveWorkspacePath,
24
34
  SAFE_MEDIA_SEGMENT,
25
35
  walkWorkspaceFiles,
26
36
  WORKSPACE_LIMITS,
@@ -30,6 +40,14 @@ import {
30
40
  /**
31
41
  * `media upload` — hash, request, PUT, confirm, then wait for processing.
32
42
  *
43
+ * A file whose container is MP4, MOV or WebM takes the video path instead. Taproot does
44
+ * not encode, so the file is read with mediabunny first: an H.264 and AAC file that is not
45
+ * an MP4 with its index first is rewritten as one (copying the tracks, never re-encoding),
46
+ * and the real codec strings are declared. `RequestVideoUpload`, a presigned PUT, and
47
+ * `ConfirmVideoUpload` follow; the video is ready when confirm returns, with nothing to
48
+ * wait for. The server decides what is accepted and its one-line refusal is printed as it
49
+ * came. The container is read from the file's bytes, not its extension.
50
+ *
33
51
  * The client computes the SHA-256 and the pixel dimensions before it asks for
34
52
  * anything, because `RequestImageUpload` needs all three: the hash drives the
35
53
  * dedup short-circuit, and the dimensions are recorded with the image. When the
@@ -56,7 +74,7 @@ import {
56
74
 
57
75
  const MAXIMUM_REPORTED = 200;
58
76
  const MAXIMUM_FILES = 500;
59
- const MEDIA_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
77
+ const MEDIA_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ...VIDEO_EXTENSIONS];
60
78
  const MEDIA_WALK_OPTIONS = Object.freeze({
61
79
  segmentPattern: SAFE_MEDIA_SEGMENT,
62
80
  segmentDescription: "letters, digits, '.', '_', '-', and '@'",
@@ -78,6 +96,14 @@ function requireUploadCapability(response, fileName) {
78
96
  return { url: response.presignedUrl, requiredHeaders: response.requiredHeaders };
79
97
  }
80
98
 
99
+ function videoComponent(videoId) {
100
+ const componentData = JSON.stringify({ videoId });
101
+ return {
102
+ markdown: `\`\`\`component:video\n${componentData}\n\`\`\``,
103
+ block: { type: "componentBlock", attrs: { componentType: "video", componentData } },
104
+ };
105
+ }
106
+
81
107
  function fileNameOf(relativePath) {
82
108
  return relativePath.slice(relativePath.lastIndexOf("/") + 1);
83
109
  }
@@ -134,35 +160,255 @@ async function resolveUploadTargets(workspaceDir, positionals) {
134
160
  return files;
135
161
  }
136
162
 
137
- async function waitForProcessing(client, siteId, imageIds, { onProgress, now }) {
138
- const pending = new Set(imageIds);
163
+ /**
164
+ * Waits until every uploaded item has reached a terminal state in its library
165
+ * listing, and returns what was last observed for each. One wait serves images
166
+ * and videos: only what is read, how an entry is identified and which states
167
+ * end the wait differ.
168
+ */
169
+ async function waitForTerminalStates(client, ids, {
170
+ read,
171
+ idOf,
172
+ stateOf,
173
+ terminalStates,
174
+ timeoutMilliseconds,
175
+ timeoutCode,
176
+ waitingMessage,
177
+ onProgress,
178
+ now,
179
+ }) {
180
+ const pending = new Set(ids);
139
181
  if (pending.size === 0) return new Map();
140
182
  const observed = new Map();
141
- return await poll({
142
- client,
143
- now,
144
- onProgress,
183
+ try {
184
+ return await poll({
185
+ client,
186
+ now,
187
+ onProgress,
188
+ timeoutMilliseconds,
189
+ // The poller's `{ deadline, now }` travels into every page of the listing,
190
+ // so the bound holds *within* one read of a large library and not only
191
+ // between reads.
192
+ read,
193
+ evaluate: (entries) => {
194
+ for (const entry of entries) {
195
+ if (pending.has(idOf(entry))) observed.set(idOf(entry), entry);
196
+ }
197
+ const waiting = [...pending].filter((id) => !terminalStates.includes(stateOf(observed.get(id))));
198
+ if (waiting.length === 0) return { done: true, value: observed };
199
+ return { done: false, progress: waitingMessage(waiting.length) };
200
+ },
201
+ timeoutCode,
202
+ });
203
+ } catch (error) {
204
+ // What was observed before the deadline travels with the refusal, so a
205
+ // caller can still record the items that did reach a terminal state.
206
+ if (error instanceof SiteAuthoringError) error.observed = observed;
207
+ throw error;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * `byId` reads the named images directly instead of paging the library, for an image
213
+ * the library never lists (a copied embed poster).
214
+ */
215
+ export function waitForProcessing(client, siteId, imageIds, { onProgress, now, byId = false }) {
216
+ return waitForTerminalStates(client, imageIds, {
217
+ read: async (requestOptions) =>
218
+ (await listSiteImages(client, siteId, requestOptions, byId ? { imageIds } : {})).images,
219
+ idOf: (image) => image.imageId,
220
+ stateOf: (image) => image?.processingState,
221
+ terminalStates: [IMAGE_PROCESSING_STATE_COMPLETE, IMAGE_PROCESSING_STATE_FAILED],
145
222
  timeoutMilliseconds: LIMITS.uploadMilliseconds,
146
- // The poller's `{ deadline, now }` travels into every page of the listing,
147
- // so the bound holds *within* one read of a large library and not only
148
- // between reads.
149
- read: async (requestOptions) => (await listSiteImages(client, siteId, requestOptions)).images,
150
- evaluate: (images) => {
151
- for (const image of images) {
152
- if (!pending.has(image.imageId)) continue;
153
- observed.set(image.imageId, image);
154
- }
155
- const waiting = [...pending].filter((imageId) => {
156
- const state = observed.get(imageId)?.processingState;
157
- return state !== IMAGE_PROCESSING_STATE_COMPLETE && state !== IMAGE_PROCESSING_STATE_FAILED;
158
- });
159
- if (waiting.length === 0) return { done: true, value: observed };
160
- return { done: false, progress: `Waiting for ${waiting.length} image(s) to finish processing.` };
161
- },
162
223
  timeoutCode: "media.processing_timeout",
224
+ waitingMessage: (count) => `Waiting for ${count} image(s) to finish processing.`,
225
+ onProgress,
226
+ now,
163
227
  });
164
228
  }
165
229
 
230
+ /** Where the server words a refusal of a video upload; the line is printed as it came. */
231
+ const VIDEO_REFUSAL_FIELDS = Object.freeze([
232
+ "VideoCodec",
233
+ "AudioCodec",
234
+ "FileName",
235
+ "SiteId",
236
+ "UploadId",
237
+ "ContentType",
238
+ "SizeBytes",
239
+ "Video",
240
+ "UpgradePrompt",
241
+ "VideoNotIncluded",
242
+ "VideoPendingDowngrade",
243
+ ]);
244
+
245
+ /**
246
+ * The server is the single authority for refusing a video, so its one-line message is shown
247
+ * rather than the generic "rejected the request field" the transport would give. Anything
248
+ * that is not such a refusal is rethrown unchanged.
249
+ */
250
+ function surfaceVideoRefusal(error, file) {
251
+ if (error instanceof ApiError) {
252
+ for (const field of VIDEO_REFUSAL_FIELDS) {
253
+ const description = error.descriptionFor?.(field);
254
+ if (typeof description === "string" && description !== "") {
255
+ return new SiteAuthoringError(
256
+ "media.video_refused",
257
+ `'${file}': ${sanitizeDiagnostic(description, "Taproot refused this video.")}`,
258
+ { field: file },
259
+ );
260
+ }
261
+ }
262
+ }
263
+ return error;
264
+ }
265
+
266
+ /**
267
+ * Uploads one video: prepare it, request, a PUT, confirm. Returns null when the file is not a
268
+ * video container, so the caller takes the image path. The file is opened once for the size
269
+ * and the prefix and held open across the PUT's retries, each of which streams it again from
270
+ * the start; a file that had to be remuxed is sent from memory (it is at most the largest
271
+ * licence cap).
272
+ *
273
+ * Unlike an image, a video has no content hash to short-circuit on, and a re-run would upload
274
+ * every file again, each a new library entry and a new quota reservation. So an unchanged file
275
+ * this workspace already uploaded (the recorded size and modification time match) is reused when
276
+ * the site's library still holds that video.
277
+ *
278
+ * The upload id is recorded before the bytes are sent, and confirming an upload id twice returns
279
+ * the same video. So a PUT or a confirm whose answer was lost is resumed by the next run (the
280
+ * confirm says whether the object arrived; if it did not, the file is sent again), not repeated
281
+ * as a second reservation. A 412 on a retried create-only PUT means it had already landed.
282
+ */
283
+ async function uploadVideoFile({
284
+ client,
285
+ config,
286
+ siteId,
287
+ file,
288
+ fileName,
289
+ recorded,
290
+ readLibrary,
291
+ recordPending,
292
+ onProgress,
293
+ }) {
294
+ // Opened with no size bound of its own: the video limit is checked below, once
295
+ // the container says this is a video, so an oversized video is named as one and
296
+ // not as a generic file that is too large.
297
+ const source = await openWorkspaceFileStream(config.workspaceDir, file, Number.MAX_SAFE_INTEGER);
298
+ try {
299
+ if (sniffVideoContentType(source.header) === undefined) return null;
300
+ if (source.byteLength > WORKSPACE_LIMITS.videoBytes) {
301
+ throw videoTooLarge(file, WORKSPACE_LIMITS.videoBytes);
302
+ }
303
+ const contentType = "video/mp4";
304
+ const base = {
305
+ contentType,
306
+ byteLength: source.byteLength,
307
+ modifiedMilliseconds: source.modifiedMilliseconds,
308
+ };
309
+ if (
310
+ recorded?.byteLength === source.byteLength
311
+ && recorded?.modifiedMilliseconds === source.modifiedMilliseconds
312
+ && typeof recorded?.videoId === "string"
313
+ ) {
314
+ if (recorded.pendingConfirm === true) {
315
+ // The recorded id is an upload that may never have become a video, so
316
+ // the library cannot be asked about it: confirming is the only check.
317
+ try {
318
+ const video = await confirmVideoUpload(client, siteId, recorded.videoId);
319
+ onProgress(`Confirmed the earlier upload of '${file}'; the video is ready.`);
320
+ return { video, deduplicated: false, ...base };
321
+ } catch (error) {
322
+ // A client-side refusal (the reservation expired, say) means that upload
323
+ // cannot be confirmed any more, so the file is sent again; a server
324
+ // error, a throttle or a lost connection is transient or ambiguous and
325
+ // is left for the next run.
326
+ const refused = error instanceof ApiError
327
+ && error.httpStatus >= 400
328
+ && error.httpStatus < 500
329
+ && ![408, 425, 429].includes(error.httpStatus);
330
+ if (!refused) throw error;
331
+ }
332
+ } else {
333
+ const { byId, truncated } = await readLibrary();
334
+ const existing = byId.get(recorded.videoId);
335
+ if (existing !== undefined) {
336
+ onProgress(`'${file}' is unchanged since it was uploaded as video ${existing.videoId}; skipping the upload.`);
337
+ return { video: existing, deduplicated: true, ...base };
338
+ }
339
+ // A listing cut short cannot say the video is gone, and uploading again
340
+ // would spend storage on a video that may well be there.
341
+ if (truncated) {
342
+ throw new SiteAuthoringError(
343
+ "media.video_library_unverifiable",
344
+ `'${file}' was uploaded as video ${recorded.videoId}, which is not among the first ${byId.size} videos of this `
345
+ + "site's library, and the library is larger than one run can check. Remove the file's entry from "
346
+ + `${MEDIA_MANIFEST_FILE_NAME} to upload it again, or leave it.`,
347
+ { field: file },
348
+ );
349
+ }
350
+ }
351
+ }
352
+ onProgress(`Checking '${file}' (${source.byteLength} bytes).`);
353
+ const prepared = await prepareVideoFile({
354
+ filePath: resolveWorkspacePath(config.workspaceDir, file),
355
+ byteLength: source.byteLength,
356
+ });
357
+ // The prepare step reopened the file by path, so prove it is still the file that was opened
358
+ // before anything is requested or sent.
359
+ await source.verifyUnchanged();
360
+ const sent = prepared.remuxed === null
361
+ ? { byteLength: source.byteLength, stream: source.stream }
362
+ : { byteLength: prepared.remuxed.byteLength, stream: () => Readable.from([prepared.remuxed]) };
363
+ if (prepared.remuxed !== null) {
364
+ onProgress(`Rewrote '${file}' as an MP4 with its index first, without re-encoding (${sent.byteLength} bytes).`);
365
+ }
366
+ let response;
367
+ try {
368
+ response = await requestVideoUpload(client, siteId, {
369
+ fileName: mp4Name(fileName),
370
+ contentType,
371
+ sizeBytes: sent.byteLength,
372
+ videoCodec: prepared.videoCodec,
373
+ audioCodec: prepared.audioCodec,
374
+ });
375
+ } catch (error) {
376
+ throw surfaceVideoRefusal(error, file);
377
+ }
378
+ const capability = requireUploadCapability(response, file);
379
+ // Recorded before the bytes are sent: a PUT can land while its answer is lost, and the
380
+ // next run must find this upload id and confirm it, not reserve and send the file again.
381
+ await recordPending(response.uploadId, base);
382
+ try {
383
+ await client.upload(capability, {
384
+ byteLength: sent.byteLength,
385
+ stream: sent.stream,
386
+ timeoutMilliseconds: LIMITS.videoUploadMilliseconds,
387
+ });
388
+ } catch (error) {
389
+ // The upload is create-only, so a retry after a PUT that landed is refused with 412: the
390
+ // object is in place, and confirm checks it.
391
+ if (!(error instanceof SiteAuthoringError && error.code === "upload.rejected" && error.status === "http:412")) {
392
+ throw error;
393
+ }
394
+ onProgress(`The upload of '${file}' had already landed; confirming it.`);
395
+ }
396
+ // The PUT streamed the opened length. If the file grew or was rewritten
397
+ // meanwhile, what Taproot holds is not the file on disk, so it is not confirmed.
398
+ await source.verifyUnchanged();
399
+ let video;
400
+ try {
401
+ video = await confirmVideoUpload(client, siteId, response.uploadId);
402
+ } catch (error) {
403
+ throw surfaceVideoRefusal(error, file);
404
+ }
405
+ onProgress(`Uploaded and confirmed '${file}'; the video is ready.`);
406
+ return { video, deduplicated: false, ...base };
407
+ } finally {
408
+ await source.close();
409
+ }
410
+ }
411
+
166
412
  export async function mediaUpload(invocation) {
167
413
  const session = await openSession(invocation);
168
414
  const { client, config, siteId, now, onProgress } = session;
@@ -188,8 +434,10 @@ export async function mediaUpload(invocation) {
188
434
  throw new SiteAuthoringError(
189
435
  "media.none_found",
190
436
  selected.length > 0
191
- ? `No PNG, JPEG, GIF, or WebP files were found under ${selected.map((value) => `'${value}'`).join(", ")}.`
192
- : `No PNG, JPEG, GIF, or WebP files were found under '${MEDIA_DIRECTORY}/' in the workspace.`,
437
+ ? `No PNG, JPEG, GIF, WebP, MP4, MOV, or WebM files were found under ${
438
+ selected.map((value) => `'${value}'`).join(", ")
439
+ }.`
440
+ : `No PNG, JPEG, GIF, WebP, MP4, MOV, or WebM files were found under '${MEDIA_DIRECTORY}/' in the workspace.`,
193
441
  { field: selected.length > 0 ? normalizePositional(selected[0]) : MEDIA_DIRECTORY },
194
442
  );
195
443
  }
@@ -202,11 +450,66 @@ export async function mediaUpload(invocation) {
202
450
  }
203
451
 
204
452
  const uploaded = [];
453
+ const uploadedVideos = [];
454
+ mediaManifest.videos ??= {};
455
+ // One read of the site's videos serves every unchanged file in the run, and
456
+ // only when there is a recorded upload to check.
457
+ let library;
458
+ const readLibrary = async () => {
459
+ if (library === undefined) {
460
+ const listing = await listVideos(client, siteId);
461
+ library = {
462
+ byId: new Map(listing.videos.map((video) => [video.videoId, video])),
463
+ truncated: listing.truncated,
464
+ };
465
+ }
466
+ return library;
467
+ };
205
468
 
206
469
  return await withRefusalGuidance(onProgress, "upload", async () => {
207
470
  try {
208
471
  for (const file of files) {
209
472
  const fileName = fileNameOf(file);
473
+ const videoUpload = await uploadVideoFile({
474
+ client,
475
+ config,
476
+ siteId,
477
+ file,
478
+ fileName,
479
+ recorded: mediaManifest.videos[file],
480
+ readLibrary,
481
+ recordPending: async (videoId, base) => {
482
+ mediaManifest.videos[file] = { videoId, ...base, pendingConfirm: true };
483
+ mediaManifest.mediaManifestVersion = MEDIA_MANIFEST_VERSION;
484
+ mediaManifest.siteId = siteId;
485
+ await writeMediaManifest(config.workspaceDir, mediaManifest);
486
+ },
487
+ onProgress,
488
+ });
489
+ if (videoUpload !== null) {
490
+ const { video, contentType, byteLength, modifiedMilliseconds, deduplicated } = videoUpload;
491
+ mediaManifest.videos[file] = {
492
+ videoId: video.videoId,
493
+ contentType,
494
+ byteLength,
495
+ modifiedMilliseconds,
496
+ };
497
+ uploadedVideos.push({
498
+ file,
499
+ videoId: video.videoId,
500
+ contentType,
501
+ byteLength,
502
+ deduplicated,
503
+ durationMilliseconds: video.durationMilliseconds,
504
+ });
505
+ // Recorded as soon as it is confirmed: a later original can take an
506
+ // hour to send, and an interrupt then must not forget this id, which a
507
+ // re-run would answer by uploading the video again.
508
+ mediaManifest.mediaManifestVersion = MEDIA_MANIFEST_VERSION;
509
+ mediaManifest.siteId = siteId;
510
+ await writeMediaManifest(config.workspaceDir, mediaManifest);
511
+ continue;
512
+ }
210
513
  const bytes = await readWorkspaceFile(config.workspaceDir, file, WORKSPACE_LIMITS.mediaBytes);
211
514
  const { contentType, width, height } = inspectImageBytes(bytes, file);
212
515
  const hash = contentHash(bytes);
@@ -260,7 +563,9 @@ export async function mediaUpload(invocation) {
260
563
  // with it, because an image id means nothing without one.
261
564
  mediaManifest.mediaManifestVersion = MEDIA_MANIFEST_VERSION;
262
565
  mediaManifest.siteId = siteId;
263
- if (uploaded.length > 0) await writeMediaManifest(config.workspaceDir, mediaManifest);
566
+ if (uploaded.length > 0 || uploadedVideos.length > 0) {
567
+ await writeMediaManifest(config.workspaceDir, mediaManifest);
568
+ }
264
569
  }
265
570
 
266
571
  onProgress("Waiting for image processing to finish.");
@@ -291,7 +596,6 @@ export async function mediaUpload(invocation) {
291
596
  manifestEntry.urls = image.responsiveUrls;
292
597
  }
293
598
  await writeMediaManifest(config.workspaceDir, mediaManifest);
294
-
295
599
  const reported = boundedList(
296
600
  uploaded.map((entry) => ({
297
601
  file: entry.file,
@@ -311,6 +615,21 @@ export async function mediaUpload(invocation) {
311
615
  })),
312
616
  MAXIMUM_REPORTED,
313
617
  );
618
+ const reportedVideos = boundedList(
619
+ uploadedVideos.map((entry) => ({
620
+ file: entry.file,
621
+ videoId: entry.videoId,
622
+ contentType: entry.contentType,
623
+ byteLength: entry.byteLength,
624
+ deduplicated: entry.deduplicated,
625
+ durationMilliseconds: entry.durationMilliseconds,
626
+ // Both forms that place it on a page, each accepted as written: a
627
+ // `component:video` fence for Markdown sources, and the ProseMirror
628
+ // block for `.pm.json` sources.
629
+ component: videoComponent(entry.videoId),
630
+ })),
631
+ MAXIMUM_REPORTED,
632
+ );
314
633
  return successResult(VERB_MEDIA_UPLOAD, siteId, {
315
634
  mediaManifestFile: MEDIA_MANIFEST_FILE_NAME,
316
635
  media: {
@@ -319,6 +638,11 @@ export async function mediaUpload(invocation) {
319
638
  items: reported.items,
320
639
  ...(reported.truncated ? { itemsTruncated: true } : {}),
321
640
  },
641
+ videos: {
642
+ total: uploadedVideos.length,
643
+ items: reportedVideos.items,
644
+ ...(reportedVideos.truncated ? { itemsTruncated: true } : {}),
645
+ },
322
646
  });
323
647
  });
324
648
  }
@@ -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 = [];
package/src/verbs/pull.js CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  workspaceContentHash,
64
64
  workspaceFileExists,
65
65
  workspaceFileNameForPage,
66
+ readManifest,
66
67
  writeManifest,
67
68
  writeWorkspaceFile,
68
69
  writeWorkspaceJson,
@@ -1103,6 +1104,12 @@ export async function pull(invocation) {
1103
1104
  await writeWorkspaceJson(config.workspaceDir, document.file, document.value);
1104
1105
  }
1105
1106
 
1107
+ // `forms pull` owns the forms baseline, and a plain pull does not read forms.
1108
+ // Carrying it forward keeps the next `forms push` fenced instead of refusing
1109
+ // it as never pulled. An unreadable old manifest has nothing to carry.
1110
+ const formsBaseline = (await readManifest(config.workspaceDir, siteId, { required: false }).catch(() =>
1111
+ undefined
1112
+ ))?.forms;
1106
1113
  const manifest = {
1107
1114
  manifestVersion: MANIFEST_VERSION,
1108
1115
  siteId,
@@ -1123,6 +1130,7 @@ export async function pull(invocation) {
1123
1130
  : {}
1124
1131
  ),
1125
1132
  ...(presentationRevision === undefined ? {} : { presentation: presentationManifestEntry(presentationRevision) }),
1133
+ ...(formsBaseline === undefined ? {} : { forms: formsBaseline }),
1126
1134
  pages: manifestPages,
1127
1135
  };
1128
1136
  await writeManifest(config.workspaceDir, manifest);