@hypersocial/decks-cli 0.2.1 → 0.3.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 -4
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +297 -13
- package/skills/hyperdecks/chat.md +49 -0
- package/src/check.mjs +110 -240
- package/src/checkRules.mjs +558 -0
- package/src/commands.mjs +200 -10
- package/src/files.mjs +35 -0
- package/src/guide.mjs +55 -0
- package/src/hosted.mjs +74 -0
- package/src/importer.mjs +1 -1
- package/src/init.mjs +605 -0
- package/src/layouts.mjs +71 -0
- package/src/narrate.mjs +4 -13
- package/src/ops.mjs +101 -7
- package/src/runtime.mjs +73 -14
- package/src/scaffold.mjs +23 -22
- package/src/source.mjs +69 -0
- package/src/voice.mjs +11 -0
- package/templates/first-deck/character.json +26 -0
- package/templates/first-deck/self.json +26 -0
- package/templates/layouts/_deck.html +102 -0
- package/templates/layouts/chart.html +39 -0
- package/templates/layouts/closer.html +28 -0
- package/templates/layouts/compare.html +33 -0
- package/templates/layouts/features.html +35 -0
- package/templates/layouts/hero.html +35 -0
- package/templates/layouts/number.html +29 -0
- package/templates/layouts/product.html +57 -0
- package/templates/layouts/quote.html +31 -0
- package/templates/layouts/team.html +37 -0
- package/templates/layouts/timeline.html +39 -0
- package/templates/narrated-deck.html +57 -11
- package/templates/script.md +11 -3
package/src/check.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
|
|
1
|
+
// hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
|
|
2
2
|
//
|
|
3
3
|
// The narrated-deck integrity gate, ported from the Netflix presentation's
|
|
4
4
|
// scripts/check.mjs and its variant builders. Every rule returns its message from
|
|
@@ -6,171 +6,103 @@
|
|
|
6
6
|
// The rules are pure (htmlErrors, manifestErrors, timelineErrors); checkDeck does
|
|
7
7
|
// the I/O: it reads the deck folder, fetches the manifest the player names and
|
|
8
8
|
// every clip's audio and timeline. `fetch` and `exists` are injectable for tests.
|
|
9
|
+
// The rules themselves live in checkRules.mjs (no Node imports, shared with the
|
|
10
|
+
// hosted check); this file adds the folder I/O and the Chromium measurements.
|
|
9
11
|
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
10
12
|
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
export function slides(html) {
|
|
56
|
-
return [...uncommented(html).matchAll(/(<section\b[^>]*\sclass="(?:[^"]*\s)?slide(?:\s[^"]*)?"[^>]*>)([\s\S]*?)<\/section>/g)].map(([, tag, body]) => {
|
|
57
|
-
const heading = /(<h[12]\b[^>]*>)([\s\S]*?)<\/h[12]>/.exec(body);
|
|
58
|
-
return { id: attr(tag, "id") ?? "", tag, body, title: heading ? textOf(heading[2]) : "", titleTag: heading?.[1] ?? "" };
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function playerScript(html) {
|
|
63
|
-
const tag = [...uncommented(html).matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t));
|
|
64
|
-
return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Relative references: src, href and poster (either quote), srcset candidates and
|
|
68
|
-
// CSS url(…) in style attributes and <style> blocks; everything that is not a URL
|
|
69
|
-
// scheme, protocol-relative or a fragment. Query strings and fragments dropped.
|
|
70
|
-
export function localRefs(html) {
|
|
71
|
-
const doc = uncommented(html);
|
|
72
|
-
const raw = [
|
|
73
|
-
...[...doc.matchAll(/\s(?:src|href|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
74
|
-
...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
|
|
75
|
-
...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
|
|
76
|
-
];
|
|
77
|
-
const refs = raw.map((r) => decode(String(r ?? "")).trim().split("#")[0].split("?")[0]).filter(Boolean);
|
|
78
|
-
// Folder links (href="/", "./") are navigation, not files.
|
|
79
|
-
return [...new Set(refs.filter((r) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(r) && !r.endsWith("/")))];
|
|
13
|
+
import { PHONE, PRESENTER_FOOTPRINT, checkSources, overlapWarnings, phoneOverflowErrors, slideStyles, stageSize, MESSAGES } from "./checkRules.mjs";
|
|
14
|
+
|
|
15
|
+
export * from "./checkRules.mjs";
|
|
16
|
+
|
|
17
|
+
// Lay each slide out in Chromium at the deck's stage size with JavaScript off
|
|
18
|
+
// (no player, no deck scripts), put the style's footprint in its slot, and
|
|
19
|
+
// measure it against the title's text. Needs `hyperdecks preview setup`.
|
|
20
|
+
export async function measureLayout(deckDir, styles, { width = 1600, height = 900 } = {}) {
|
|
21
|
+
return withDeckPage(deckDir, { width, height }, (page) => page.evaluate(({ styles, footprint, width, height }) => {
|
|
22
|
+
const box = (r) => ({ x: r.left, y: r.top, width: r.width, height: r.height });
|
|
23
|
+
const sections = [...document.querySelectorAll("section.slide")];
|
|
24
|
+
const host = sections[0]?.parentElement ?? document.body;
|
|
25
|
+
Object.assign(host.style, { position: "relative", width: `${width}px`, height: `${height}px`, overflow: "hidden", margin: "0" });
|
|
26
|
+
document.body.style.margin = "0";
|
|
27
|
+
return sections.map((slide, i) => {
|
|
28
|
+
sections.forEach((s) => { s.style.display = s === slide ? "" : "none"; Object.assign(s.style, { position: "absolute", inset: "0" }); });
|
|
29
|
+
const { id, style } = styles[i] ?? { id: slide.id, style: "bubble" };
|
|
30
|
+
const size = footprint[style];
|
|
31
|
+
const probe = document.createElement("div");
|
|
32
|
+
Object.assign(probe.style, { width: `${size.width}px`, height: `${size.height}px`, flex: "none" });
|
|
33
|
+
const slot = slide.querySelector(".narration-slot");
|
|
34
|
+
let presenter = null;
|
|
35
|
+
if (style === "corner" || !slot) {
|
|
36
|
+
const right = size.right ?? 56, bottom = size.bottom ?? 56;
|
|
37
|
+
presenter = { x: width - right - size.width, y: height - bottom - size.height, width: size.width, height: size.height };
|
|
38
|
+
} else {
|
|
39
|
+
slot.append(probe);
|
|
40
|
+
presenter = box(probe.getBoundingClientRect());
|
|
41
|
+
probe.remove();
|
|
42
|
+
}
|
|
43
|
+
const heading = slide.querySelector("h1, h2");
|
|
44
|
+
let title = null;
|
|
45
|
+
if (heading) {
|
|
46
|
+
const range = document.createRange();
|
|
47
|
+
range.selectNodeContents(heading);
|
|
48
|
+
const rects = [...range.getClientRects()].filter((r) => r.width && r.height);
|
|
49
|
+
if (rects.length) {
|
|
50
|
+
const [x0, y0] = [Math.min(...rects.map((r) => r.left)), Math.min(...rects.map((r) => r.top))];
|
|
51
|
+
title = { x: x0, y: y0, width: Math.max(...rects.map((r) => r.right)) - x0, height: Math.max(...rects.map((r) => r.bottom)) - y0 };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { id, style, presenter, title };
|
|
55
|
+
});
|
|
56
|
+
}, { styles, footprint: PRESENTER_FOOTPRINT, width, height }));
|
|
80
57
|
}
|
|
81
58
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
59
|
+
// Lay each slide out as the framed player shows it on a phone: the deck's own
|
|
60
|
+
// phone rules at a 390×844 viewport, its mobile titles, inside the card, with
|
|
61
|
+
// JavaScript off. Returns { id, overflow } per slide: how far its content runs
|
|
62
|
+
// past the slide's bottom (≤ 0 when it fits).
|
|
63
|
+
export async function measurePhoneFit(deckDir) {
|
|
64
|
+
return withDeckPage(deckDir, { width: PHONE.width, height: PHONE.height }, (page) => page.evaluate((card) => {
|
|
65
|
+
for (const n of document.querySelectorAll("[data-title-mobile]")) n.textContent = n.dataset.titleMobile;
|
|
66
|
+
const sections = [...document.querySelectorAll("section.slide")];
|
|
67
|
+
const host = sections[0]?.parentElement ?? document.body;
|
|
68
|
+
Object.assign(host.style, { position: "relative", width: `${card.width}px`, height: `${card.height}px`, overflow: "hidden", margin: "0" });
|
|
69
|
+
document.body.style.margin = "0";
|
|
70
|
+
return sections.map((slide) => {
|
|
71
|
+
sections.forEach((s) => { s.style.display = s === slide ? "" : "none"; Object.assign(s.style, { position: "absolute", inset: "0", overflow: "hidden" }); });
|
|
72
|
+
return { id: slide.id, overflow: slide.scrollHeight - slide.clientHeight };
|
|
73
|
+
});
|
|
74
|
+
}, PHONE.card));
|
|
86
75
|
}
|
|
87
76
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
77
|
+
// Open the deck's index.html in Chromium at `viewport` with JavaScript off and
|
|
78
|
+
// hand the page to `measure`. Needs `hyperdecks preview setup`.
|
|
79
|
+
async function withDeckPage(deckDir, viewport, measure) {
|
|
80
|
+
const { chromium } = await import("playwright");
|
|
81
|
+
const { serveSource, routeCaptureRequest } = await import("./preview-capture.mjs");
|
|
82
|
+
const { mkdtemp, rm } = await import("node:fs/promises");
|
|
83
|
+
const { tmpdir } = await import("node:os");
|
|
84
|
+
// The same isolation as preview capture: a throwaway HOME, the sandbox on
|
|
85
|
+
// Linux, and its request filter (no loopback, private or metadata hosts).
|
|
86
|
+
const temp = await mkdtemp(join(tmpdir(), "hyperdecks-layout-"));
|
|
87
|
+
let browser, server;
|
|
88
|
+
try {
|
|
89
|
+
try {
|
|
90
|
+
browser = await chromium.launch({ headless: true, timeout: 30000, chromiumSandbox: process.platform === "linux", env: { ...(process.platform === "win32" ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: temp, HOME: temp } });
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error("check --layout needs Chromium: run `hyperdecks preview setup`");
|
|
103
93
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
return errs;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// The manifest against the deck: order, total and cap. Clip text is checked for
|
|
120
|
-
// forbidden phrases too, since that is what the audience hears.
|
|
121
|
-
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
122
|
-
const isStr = (v) => typeof v === "string" && v.trim().length > 0;
|
|
123
|
-
|
|
124
|
-
// Fields every clip needs before its other rules mean anything.
|
|
125
|
-
export function clipFieldErrors(clip, i) {
|
|
126
|
-
const id = isStr(clip?.id) ? clip.id : `#${i + 1}`;
|
|
127
|
-
const errs = [];
|
|
128
|
-
if (!isStr(clip?.id)) errs.push(MESSAGES.clipField(id, "needs an id"));
|
|
129
|
-
if (!(isNum(clip?.duration) && clip.duration > 0)) errs.push(MESSAGES.clipField(id, "duration must be a positive number of seconds"));
|
|
130
|
-
if (!(isNum(clip?.hold) && clip.hold >= 0)) errs.push(MESSAGES.clipField(id, "hold must be a number of seconds, 0 or more"));
|
|
131
|
-
if (!isStr(clip?.text)) errs.push(MESSAGES.clipField(id, "needs its text"));
|
|
132
|
-
for (const k of ["audio", "timeline"]) if (!isStr(clip?.[k])) errs.push(MESSAGES.clipField(id, `needs an ${k} URL`));
|
|
133
|
-
return errs;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function manifestErrors(manifest, slideIds, { maxDuration = DEFAULT_MAX_DURATION, forbid = [] } = {}) {
|
|
137
|
-
const clips = Array.isArray(manifest?.clips) ? manifest.clips : [];
|
|
138
|
-
const clipIds = clips.map((c) => c?.id ?? "");
|
|
139
|
-
const errs = clips.flatMap(clipFieldErrors);
|
|
140
|
-
if (slideIds.length !== clipIds.length || slideIds.some((id, i) => id !== clipIds[i])) errs.push(MESSAGES.order(slideIds, clipIds));
|
|
141
|
-
const sum = round(clips.reduce((n, c) => n + (Number(c?.duration) || 0) + (Number(c?.hold) || 0), 0));
|
|
142
|
-
const declared = Number(manifest?.totalDuration);
|
|
143
|
-
if (!(Math.abs(sum - declared) < 0.01)) errs.push(MESSAGES.total(sum, manifest?.totalDuration));
|
|
144
|
-
if (Number.isFinite(declared) && declared > maxDuration) errs.push(MESSAGES.cap(round(declared), maxDuration));
|
|
145
|
-
for (const phrase of phraseList(forbid)) {
|
|
146
|
-
for (const c of clips) if (String(c?.text ?? "").toLowerCase().includes(phrase.toLowerCase())) errs.push(MESSAGES.forbidden(phrase, `clip ${c.id}`));
|
|
94
|
+
server = await serveSource({ file: join(deckDir, "index.html") });
|
|
95
|
+
const context = await browser.newContext({ viewport, javaScriptEnabled: false, serviceWorkers: "block" });
|
|
96
|
+
await context.route("**/*", (route) => routeCaptureRequest(route, server.url));
|
|
97
|
+
const page = await context.newPage();
|
|
98
|
+
await page.goto(server.url, { waitUntil: "load", timeout: 30000 });
|
|
99
|
+
await page.evaluate(() => document.fonts.ready);
|
|
100
|
+
return await measure(page);
|
|
101
|
+
} finally {
|
|
102
|
+
await server?.close().catch(() => {});
|
|
103
|
+
await browser?.close().catch(() => {});
|
|
104
|
+
await rm(temp, { recursive: true, force: true }).catch(() => {});
|
|
147
105
|
}
|
|
148
|
-
return errs;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// One clip's timeline: caption timing, coverage of the clip text, and length.
|
|
152
|
-
export function timelineErrors(clip, timeline) {
|
|
153
|
-
const errs = [];
|
|
154
|
-
if (!Array.isArray(timeline?.captions) || !timeline.captions.length) return [MESSAGES.noCaptions(clip.id)];
|
|
155
|
-
const captions = timeline.captions.map((c) => (c && typeof c === "object" ? c : {}));
|
|
156
|
-
const duration = Number(clip.duration) || 0;
|
|
157
|
-
captions.forEach((c, i) => {
|
|
158
|
-
if (!(c.start >= 0 && c.end > c.start && c.end <= duration + 0.1)) errs.push(MESSAGES.timing(clip.id, i, c.start, c.end, duration));
|
|
159
|
-
});
|
|
160
|
-
const spoken = captions.flatMap((c) => (Array.isArray(c.words) ? c.words.map((w) => w?.text ?? "") : [])).join(" ");
|
|
161
|
-
if (normalizeWords(spoken) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
|
|
162
|
-
captions.forEach((c, i) => {
|
|
163
|
-
const text = String(c.text ?? "");
|
|
164
|
-
if (text.length < CAPTION_MIN_CHARS || text.length > CAPTION_MAX_CHARS) errs.push(MESSAGES.captionLength(clip.id, i, text));
|
|
165
|
-
});
|
|
166
|
-
return errs;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// The deck's own cap: `maxTotalSeconds` in script.md's front matter.
|
|
170
|
-
export function scriptCap(script) {
|
|
171
|
-
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(script)?.[1] ?? "";
|
|
172
|
-
const m = /^maxTotalSeconds:\s*([\d.]+)\s*$/m.exec(front);
|
|
173
|
-
return m ? Number(m[1]) : null;
|
|
174
106
|
}
|
|
175
107
|
|
|
176
108
|
// "<slug>" means ./decks/<slug> unless it names a folder itself.
|
|
@@ -206,100 +138,37 @@ export function deckFileResolver(deckDir) {
|
|
|
206
138
|
};
|
|
207
139
|
}
|
|
208
140
|
|
|
209
|
-
async function fetchCheck(fetchImpl, url, want) {
|
|
210
|
-
try {
|
|
211
|
-
const r = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
212
|
-
if (!r.ok) {
|
|
213
|
-
await r.body?.cancel?.();
|
|
214
|
-
return { why: `returned HTTP ${r.status}` };
|
|
215
|
-
}
|
|
216
|
-
const type = (r.headers.get("content-type") || "").split(";")[0].trim();
|
|
217
|
-
if (!want.test(type)) {
|
|
218
|
-
await r.body?.cancel?.();
|
|
219
|
-
return { why: `is ${type || "untyped"}, expected ${want === AUDIO ? "audio/*" : "JSON"}` };
|
|
220
|
-
}
|
|
221
|
-
return { response: r };
|
|
222
|
-
} catch (e) {
|
|
223
|
-
return { why: `could not be fetched (${e.message})` };
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
const AUDIO = /^audio\//;
|
|
227
|
-
const FETCH_TIMEOUT_MS = 30_000;
|
|
228
|
-
const JSON_TYPE = /json/;
|
|
229
|
-
|
|
230
141
|
// Run every rule on a deck folder. Returns { deckDir, slug, slides, totalDuration, errors }.
|
|
231
|
-
export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd } = {}) {
|
|
142
|
+
export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd, measure = measureLayout, measureFit = measurePhoneFit } = {}) {
|
|
232
143
|
const deckDir = resolveDeckDir(dirArg, cwd);
|
|
233
144
|
const html = readFileSync(join(deckDir, "index.html"), "utf8");
|
|
234
145
|
const scriptPath = join(deckDir, "script.md");
|
|
235
146
|
const script = existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : "";
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (player && !player.manifest) errors.push(MESSAGES.noManifest());
|
|
248
|
-
if (player?.manifest) {
|
|
249
|
-
const got = await fetchCheck(fetchImpl, player.manifest, JSON_TYPE);
|
|
250
|
-
if (got.why) errors.push(MESSAGES.manifestUnavailable(player.manifest, got.why));
|
|
251
|
-
else {
|
|
252
|
-
try {
|
|
253
|
-
manifest = await got.response.json();
|
|
254
|
-
} catch (e) {
|
|
255
|
-
errors.push(MESSAGES.manifestUnavailable(player.manifest, `not JSON: ${e.message}`));
|
|
256
|
-
}
|
|
257
|
-
if (manifest !== null && !(manifest && typeof manifest === "object" && Array.isArray(manifest.clips) && manifest.clips.length)) {
|
|
258
|
-
errors.push(MESSAGES.manifestUnavailable(player.manifest, "not a manifest: needs a non-empty clips array"));
|
|
259
|
-
manifest = null;
|
|
260
|
-
} else if (manifest === null && !errors.some((e) => e.startsWith(`manifest ${player.manifest}`))) {
|
|
261
|
-
errors.push(MESSAGES.manifestUnavailable(player.manifest, "not a manifest: needs a non-empty clips array"));
|
|
262
|
-
}
|
|
147
|
+
const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir) }, { fetch: fetchImpl });
|
|
148
|
+
const { errors, warnings } = result;
|
|
149
|
+
// A presenter over the title is a warning, not a failure: only a person can
|
|
150
|
+
// judge it. A slide that overflows the phone card is an error. Both need a
|
|
151
|
+
// browser, so they run only with --layout.
|
|
152
|
+
if (opts.layout) {
|
|
153
|
+
const { width: w, height: h } = stageSize(html);
|
|
154
|
+
try {
|
|
155
|
+
warnings.push(...overlapWarnings(await measure(deckDir, slideStyles(html, manifest), { width: w, height: h }), `${w}×${h}`));
|
|
156
|
+
} catch (e) {
|
|
157
|
+
warnings.push(MESSAGES.layoutUnchecked(e.message));
|
|
263
158
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
let audioUrl, timelineUrl;
|
|
270
|
-
try {
|
|
271
|
-
audioUrl = new URL(clip.audio, player.manifest).href;
|
|
272
|
-
timelineUrl = new URL(clip.timeline, player.manifest).href;
|
|
273
|
-
} catch {
|
|
274
|
-
errors.push(MESSAGES.clipField(clip.id, "audio and timeline must be URLs"));
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
const audio = await fetchCheck(fetchImpl, audioUrl, AUDIO);
|
|
278
|
-
if (audio.why) errors.push(MESSAGES.url(clip.id, "audio", audioUrl, audio.why));
|
|
279
|
-
else await audio.response.body?.cancel?.().catch(() => {});
|
|
280
|
-
const tl = await fetchCheck(fetchImpl, timelineUrl, JSON_TYPE);
|
|
281
|
-
if (tl.why) {
|
|
282
|
-
errors.push(MESSAGES.url(clip.id, "timeline", timelineUrl, tl.why));
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
let timeline;
|
|
286
|
-
try {
|
|
287
|
-
timeline = await tl.response.json();
|
|
288
|
-
} catch (e) {
|
|
289
|
-
errors.push(MESSAGES.url(clip.id, "timeline", timelineUrl, `is not JSON (${e.message})`));
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
errors.push(...timelineErrors(clip, timeline));
|
|
159
|
+
// A slide that scrolls on a phone fails: the player never scrolls a slide.
|
|
160
|
+
try {
|
|
161
|
+
errors.push(...phoneOverflowErrors(await measureFit(deckDir)));
|
|
162
|
+
} catch (e) {
|
|
163
|
+
warnings.push(MESSAGES.layoutUnchecked(e.message));
|
|
293
164
|
}
|
|
294
165
|
}
|
|
295
166
|
return {
|
|
296
167
|
deckDir,
|
|
297
168
|
slug: opts.slug || basename(deckDir),
|
|
298
|
-
|
|
299
|
-
totalDuration: manifest ? Number(manifest.totalDuration) : null,
|
|
300
|
-
manifestUrl: player?.manifest ?? null,
|
|
169
|
+
...result,
|
|
301
170
|
passed: errors.length === 0,
|
|
302
|
-
|
|
171
|
+
warnings: [...new Set(warnings)],
|
|
303
172
|
};
|
|
304
173
|
}
|
|
305
174
|
|
|
@@ -311,6 +180,7 @@ export async function check(pos, opts = {}, { log = (line) => console.log(line),
|
|
|
311
180
|
log(`✗ ${result.errors.length} issue(s):`);
|
|
312
181
|
for (const e of result.errors) log(` - ${e}`);
|
|
313
182
|
} else log("✓ check passed");
|
|
183
|
+
for (const w of result.warnings) log(` ⚠ ${w}`);
|
|
314
184
|
}
|
|
315
185
|
return result;
|
|
316
186
|
}
|