@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.
- package/README.md +20 -2
- package/package.json +3 -2
- package/src/api.js +81 -2
- package/src/capabilities.js +1 -1
- package/src/cli.js +51 -3
- package/src/constants.js +6 -0
- package/src/content/components.js +180 -2
- package/src/content/free-form-section-registry.json +16 -1
- package/src/content/markdown.js +12 -2
- package/src/content/media-presentation.js +123 -0
- package/src/content/tiptap-prosemirror.ts +12 -64
- package/src/content/video-embed-url.js +104 -0
- package/src/content/vocabulary.js +9 -32
- package/src/field-schema.json +227 -0
- package/src/field-validation.js +721 -0
- package/src/fixture-contract.js +14 -3
- package/src/fixture-init.js +18 -0
- package/src/forms-api.js +192 -0
- package/src/forms-contract.js +274 -0
- package/src/forms-workspace.js +100 -0
- package/src/image-metadata.js +1 -1
- package/src/reference-help.js +168 -5
- package/src/session.js +22 -1
- package/src/settings-catalog.js +3 -0
- package/src/transport.js +77 -3
- package/src/typed-pages.js +83 -13
- package/src/verbs/forms-pull.js +67 -0
- package/src/verbs/forms-push.js +227 -0
- package/src/verbs/forms-validate.js +36 -0
- package/src/verbs/index.js +11 -0
- package/src/verbs/media-upload.js +352 -28
- package/src/verbs/pages-push.js +97 -2
- package/src/verbs/pull.js +8 -0
- package/src/verbs/validate.js +94 -2
- package/src/video-metadata.js +90 -0
- package/src/video-prepare.js +136 -0
- package/src/workspace.js +113 -1
|
@@ -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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
192
|
-
|
|
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
|
|
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
|
}
|
package/src/verbs/pages-push.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import {
|
|
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);
|