@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.
- package/README.md +9 -1
- package/package.json +3 -2
- package/src/api.js +81 -2
- package/src/cli.js +1 -1
- package/src/constants.js +3 -0
- package/src/content/components.js +159 -2
- package/src/content/free-form-section-registry.json +11 -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 +11 -1
- package/src/field-validation.js +8 -2
- package/src/fixture-contract.js +13 -2
- package/src/fixture-init.js +3 -0
- package/src/image-metadata.js +1 -1
- package/src/reference-help.js +18 -4
- package/src/session.js +22 -1
- package/src/transport.js +77 -3
- package/src/typed-pages.js +83 -13
- package/src/verbs/media-upload.js +352 -28
- package/src/verbs/pages-push.js +97 -2
- package/src/verbs/validate.js +37 -2
- package/src/video-metadata.js +90 -0
- package/src/video-prepare.js +136 -0
- package/src/workspace.js +113 -1
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/validate.js
CHANGED
|
@@ -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 {
|
|
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
|
|