@hypersocial/decks-cli 0.6.0 → 0.8.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 +7 -1
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +64 -9
- package/skills/hyperdecks/chat.md +26 -9
- package/src/avatar.mjs +1 -1
- package/src/brandCli.mjs +86 -2
- package/src/brandImport.mjs +991 -0
- package/src/brandPreview.mjs +31 -0
- package/src/checkRules.mjs +39 -11
- package/src/commands.mjs +49 -11
- package/src/importer.mjs +1 -1
- package/src/ops.mjs +58 -12
- package/src/play.mjs +119 -0
- package/src/presenter.mjs +14 -7
- package/src/preview-capture.mjs +5 -3
- package/src/runtime.mjs +10 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The page a brand import (brandImport.mjs, convex/brandDrafts.ts) is shown on
|
|
2
|
+
// before it is saved: a hero and a features slide from the layout library, in
|
|
3
|
+
// the draft's colours, fonts and logo (applyBrand, the same code new decks
|
|
4
|
+
// use). Served at /p/brand/<token> (app/p/brand/[token]/route.ts), and with
|
|
5
|
+
// { saved: true } for the saved brand on the dashboard's Brand tab
|
|
6
|
+
// (app/dashboard/brand/sample/route.ts).
|
|
7
|
+
import { applyBrand } from "./brand.mjs";
|
|
8
|
+
import { assembleLayoutDeck } from "./layouts.mjs";
|
|
9
|
+
|
|
10
|
+
const SAMPLE_CSS = ".hd-embedded :is(.hd-gate, .hd-controls, .hd-chapters, .hd-presenter, .hd-slide-note, .hd-note, .hd-flash) { display: none !important; }";
|
|
11
|
+
// The slides a sample shows, each whole in its own 16:9 frame.
|
|
12
|
+
export const SAMPLE_SLIDES = ["hero", "features"];
|
|
13
|
+
|
|
14
|
+
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
15
|
+
|
|
16
|
+
export function brandPreviewHtml(draft, { saved = false } = {}) {
|
|
17
|
+
const name = (draft?.name ?? "").trim() || "Your brand";
|
|
18
|
+
let html = assembleLayoutDeck({ slug: "brand-preview", title: name, layouts: SAMPLE_SLIDES })["index.html"];
|
|
19
|
+
// The hero's art slot is a stand-in a real deck fills; the preview has no art.
|
|
20
|
+
html = html.replace(/\s*<div class="art" role="presentation"><\/div>/, "");
|
|
21
|
+
html = html.replace(/(<div class="kicker">)brand-preview(<\/div>)/, saved ? "$1Your brand$2" : "$1Brand preview$2");
|
|
22
|
+
html = html.replace(
|
|
23
|
+
"One sentence on what this deck is about.",
|
|
24
|
+
saved ? "Your colours, fonts and logo on a sample slide. Every new deck starts from this." : "Your colours, fonts and logo on a sample slide. Nothing is saved until you say yes.",
|
|
25
|
+
);
|
|
26
|
+
// Framed on the Brand page (one iframe per slide, #scene=hero / #scene=features),
|
|
27
|
+
// the sample is a picture of the slide: no play gate, controls or presenter.
|
|
28
|
+
html = html.replace("</head>", () => `<style data-brand-sample>${SAMPLE_CSS}</style>\n</head>`);
|
|
29
|
+
html = html.replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtml(name)} · ${saved ? "brand" : "brand preview"}</title>`);
|
|
30
|
+
return applyBrand(html, { name: draft?.name ?? undefined, colors: draft?.colors ?? {}, fonts: draft?.fonts ?? {}, logoUrl: draft?.logoUrl ?? undefined });
|
|
31
|
+
}
|
package/src/checkRules.mjs
CHANGED
|
@@ -41,6 +41,8 @@ export const MESSAGES = {
|
|
|
41
41
|
mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
|
|
42
42
|
slideId: (n) => `slide ${n} has no id`,
|
|
43
43
|
selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
|
|
44
|
+
gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
|
|
45
|
+
gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
|
|
44
46
|
presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
|
|
45
47
|
transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
|
|
46
48
|
zoomTarget: (id, target, prev) => `slide ${id}: zoom target "${target}" matches nothing on the slide before it (${prev})`,
|
|
@@ -217,8 +219,33 @@ export function selectorMatches(body, selector) {
|
|
|
217
219
|
});
|
|
218
220
|
}
|
|
219
221
|
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
+
// The Minimoji head's gestures, for [gesture: <id>]: lib/deckScript.ts GESTURES
|
|
223
|
+
// (the published CLI cannot import it; a test keeps the two equal).
|
|
224
|
+
export const GESTURES = [
|
|
225
|
+
"big-double-laugh", "boiling-point", "bright-tilt", "buoyant-pop", "chuckle-bob", "clipped-huff", "comfort-tilt",
|
|
226
|
+
"curious-glance", "deep-snore", "delighted-sway", "dodge-glance", "double-take", "dream-twitch", "final-flop",
|
|
227
|
+
"fold-inward", "freeze-and-shiver", "hard-recoil", "head-shake", "knowing-nod", "lean-out-laugh", "lightbulb",
|
|
228
|
+
"look-away", "low-simmer", "nervous-scan", "nod-on-the-beat", "one-sided-lean", "other-side", "party-bounce",
|
|
229
|
+
"ponder-tilt", "pop-back", "pressure-build", "quiet-sigh", "radiant-lift", "shaky-sniff", "sharp-glare",
|
|
230
|
+
"side-eye-hmm", "sideways-flinch", "sleepy-sway", "slow-droop", "snap-and-fume", "sob-heave", "soft-reset",
|
|
231
|
+
"spark-salute", "sunny-nod", "thinking-it-over", "tiny-aftershock", "tiny-double-take", "tiny-wince", "uneasy-sway",
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
// [gesture: <id>] in script.md must name one of the head's gestures.
|
|
235
|
+
export function cueGestureErrors(script = "") {
|
|
236
|
+
const errs = [];
|
|
237
|
+
script.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
|
|
238
|
+
for (const [, colon, raw] of line.matchAll(/\[\s*gesture\b\s*(:?)\s*([^\]]*?)\s*\]/gi)) {
|
|
239
|
+
if (!GESTURES.includes(raw.toLowerCase())) errs.push(MESSAGES.gesture(i + 1, raw));
|
|
240
|
+
else if (!colon) errs.push(MESSAGES.gestureColon(i + 1, raw));
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return errs;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// [go: <selector>] and [look: <selector>] (or [look <selector>]) in script.md
|
|
247
|
+
// must match something in their slide ([go: slot] returns home, and [look
|
|
248
|
+
// viewer], [look away] and [look back] need no element).
|
|
222
249
|
export function cueSelectorErrors(html, script = "") {
|
|
223
250
|
const bodies = new Map(slides(html).map((s) => [s.id, s.body]));
|
|
224
251
|
const errs = [];
|
|
@@ -227,8 +254,9 @@ export function cueSelectorErrors(html, script = "") {
|
|
|
227
254
|
const heading = /^##\s+(\S+)/.exec(line);
|
|
228
255
|
if (heading) { id = heading[1]; return; }
|
|
229
256
|
if (!id || !bodies.has(id)) return;
|
|
230
|
-
for (const [,
|
|
231
|
-
|
|
257
|
+
for (const [, colon, spaced, raw] of line.matchAll(/\[\s*(?:(go|look)\s*:\s*|(look)\s+)([^\]]*?)\s*\]/gi)) {
|
|
258
|
+
const cue = colon ?? spaced;
|
|
259
|
+
if (!raw || raw === "slot" || (cue.toLowerCase() === "look" && /^(viewer|away|back)$/i.test(raw))) continue;
|
|
232
260
|
if (selectorMatches(bodies.get(id), raw) === false) errs.push(MESSAGES.selector(i + 1, cue.toLowerCase(), raw, id));
|
|
233
261
|
}
|
|
234
262
|
});
|
|
@@ -252,13 +280,13 @@ export function playerScript(html) {
|
|
|
252
280
|
return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
|
|
253
281
|
}
|
|
254
282
|
|
|
255
|
-
// Relative references: src, href, poster
|
|
283
|
+
// Relative references: src, href, poster, data-presenter-image and data-alpha-stacked (either quote), srcset candidates and
|
|
256
284
|
// CSS url(…) in style attributes and <style> blocks; everything that is not a URL
|
|
257
285
|
// scheme, protocol-relative or a fragment. Query strings and fragments dropped.
|
|
258
286
|
export function localRefs(html) {
|
|
259
287
|
const doc = uncommented(html);
|
|
260
288
|
const raw = [
|
|
261
|
-
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
289
|
+
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
262
290
|
...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
|
|
263
291
|
...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
|
|
264
292
|
];
|
|
@@ -306,7 +334,7 @@ export function placeholderErrors(html, { hosted = false } = {}) {
|
|
|
306
334
|
// Everything checkable from the deck's own files. `exists(ref)` answers whether a
|
|
307
335
|
// local reference resolves inside the deck folder: true, false or "outside".
|
|
308
336
|
export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false } = {}) {
|
|
309
|
-
const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...placeholderErrors(html, { hosted })];
|
|
337
|
+
const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...cueGestureErrors(script), ...placeholderErrors(html, { hosted })];
|
|
310
338
|
const player = playerScript(html);
|
|
311
339
|
if (!player) errs.push(MESSAGES.noPlayer());
|
|
312
340
|
else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
|
|
@@ -386,9 +414,9 @@ export function timelineErrors(clip, timeline) {
|
|
|
386
414
|
captions.forEach((c, i) => {
|
|
387
415
|
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));
|
|
388
416
|
});
|
|
389
|
-
// Caption text carries the script's spelling; the timed words
|
|
390
|
-
// transcript's (which may spell a name differently, as the
|
|
391
|
-
// pronunciation map allows), so coverage is checked on the text.
|
|
417
|
+
// Caption text carries the script's spelling; the timed words of an older
|
|
418
|
+
// clip carry the transcript's (which may spell a name differently, as the
|
|
419
|
+
// narrate guard's pronunciation map allows), so coverage is checked on the text.
|
|
392
420
|
const covered = captions.map((c) => String(c.text ?? "")).join(" ");
|
|
393
421
|
const timed = captions.every((c) => Array.isArray(c.words) && c.words.length);
|
|
394
422
|
if (!timed || normalizeWords(covered) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
|
|
@@ -487,7 +515,7 @@ export function stageSize(html) {
|
|
|
487
515
|
export function absoluteRefs(html) {
|
|
488
516
|
const doc = uncommented(html);
|
|
489
517
|
const raw = [
|
|
490
|
-
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
518
|
+
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
491
519
|
...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
|
|
492
520
|
...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
|
|
493
521
|
];
|
package/src/commands.mjs
CHANGED
|
@@ -322,6 +322,9 @@ commands:
|
|
|
322
322
|
file an image, video or font under the deck; prints its hosted URL
|
|
323
323
|
guide [workflow|format|script|design|layouts|presenter|transitions|check]
|
|
324
324
|
the skill, one topic at a time (what deck_guide serves)
|
|
325
|
+
play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
|
|
326
|
+
play the folder as it is, committed or not, with its real face and voice, on 127.0.0.1
|
|
327
|
+
until Ctrl-C; nothing is written, uploaded or shared, and changed lines need narrate first
|
|
325
328
|
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
|
|
326
329
|
on a committed deck: check → narrate → import → share new per --to
|
|
327
330
|
share new|list|on|off|forward|mute|unmute|rm|url
|
|
@@ -342,8 +345,10 @@ commands:
|
|
|
342
345
|
[--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
|
|
343
346
|
default model gpt-image-2.5; --seed applies to nano-banana-2.1
|
|
344
347
|
image-job <jobId> [--json]
|
|
345
|
-
gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
|
|
348
|
+
gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]
|
|
346
349
|
a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
|
|
350
|
+
--loop starts and ends on one frame (no jump at the seam); --transparent cuts the subject
|
|
351
|
+
out as a WebM + a stacked-alpha MP4 for Safari (prints both URLs and the <video> markup)
|
|
347
352
|
video-job <jobId> [--json]
|
|
348
353
|
screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]
|
|
349
354
|
a PNG of a public web page (private and local addresses are refused)
|
|
@@ -356,7 +361,16 @@ commands:
|
|
|
356
361
|
brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
|
|
357
362
|
brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
|
|
358
363
|
[--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
|
|
359
|
-
[--presenter-style s] [--design <file.md|->] [--name "<company>"]
|
|
364
|
+
[--presenter-style s] [--design <file.md|->] [--name "<company>"] [--draft <token>]
|
|
365
|
+
brand import <url|file|slug> [--json]
|
|
366
|
+
read a brand from your website, a .pptx / .potx / .pdf or one of your decks: shows its
|
|
367
|
+
colours, fonts and logo and a preview link; save it with brand set --draft <token>
|
|
368
|
+
brand revise "<change>" [--json]
|
|
369
|
+
a draft of your brand with that change ("darker accent, serif titles") and a
|
|
370
|
+
preview link; save it with brand set --draft <token>
|
|
371
|
+
brand history the brand's last changes, from the dashboard, chat or here
|
|
372
|
+
brand undo [<version>]
|
|
373
|
+
undo the last change, or go back to a version from brand history
|
|
360
374
|
brand edit edit design.md (voice, do and don't) in $EDITOR
|
|
361
375
|
brand fonts the curated font pairings (display + body + mono)
|
|
362
376
|
brand clear forget the brand; new decks get the template look
|
|
@@ -388,7 +402,7 @@ commands:
|
|
|
388
402
|
avatar connect import your avatar from Minimoji with a code you enter on minimoji.com
|
|
389
403
|
avatar default <name|id|dash> make it the default
|
|
390
404
|
avatar remove <name|id> remove it; decks already shared keep working, captions only
|
|
391
|
-
deck avatar <slug> <name|id|none|default> the deck's own presenter (
|
|
405
|
+
deck avatar <slug> <name|id|none|default> the deck's own presenter (play and the draft link now; share links at its next release)
|
|
392
406
|
narrator [--json] who narrates your decks (a face plus a voice) and the default
|
|
393
407
|
narrator new a link where you pick a face and a voice; it becomes the default
|
|
394
408
|
narrator default <name|id|dash> make it the default
|
|
@@ -472,7 +486,7 @@ async function imageJob(pos, opts) {
|
|
|
472
486
|
async function genVideo(pos, opts) {
|
|
473
487
|
const prompt = pos.join(" ").trim() || opts.prompt;
|
|
474
488
|
if (!prompt) {
|
|
475
|
-
throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]');
|
|
489
|
+
throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]');
|
|
476
490
|
}
|
|
477
491
|
const queued = await ops.generateVideo(opts, {
|
|
478
492
|
prompt,
|
|
@@ -480,12 +494,15 @@ async function genVideo(pos, opts) {
|
|
|
480
494
|
seconds: opts.seconds,
|
|
481
495
|
aspect: opts.aspect,
|
|
482
496
|
model: opts.model,
|
|
497
|
+
loop: opts.loop,
|
|
498
|
+
transparent: opts.transparent,
|
|
483
499
|
slug: opts.slug,
|
|
484
500
|
name: opts.name,
|
|
485
501
|
});
|
|
486
502
|
// A clip takes a minute or more; poll so gen-video stays one blocking command.
|
|
487
503
|
// Progress goes to stderr so stdout stays the URL (or --json).
|
|
488
|
-
|
|
504
|
+
const kind = [queued.loop && "looping", queued.transparent && "transparent"].filter(Boolean).join(" ");
|
|
505
|
+
process.stderr.write(`queued a ${queued.seconds}s ${kind ? `${kind} ` : ""}${queued.model} clip — job ${queued.jobId}\n`);
|
|
489
506
|
const job = await ops.pollVideoJob(opts, queued.jobId, {
|
|
490
507
|
onProgress: (j, ms) => process.stderr.write(` ${j.status} ${Math.round(ms / 1000)}s…\r`),
|
|
491
508
|
});
|
|
@@ -493,13 +510,21 @@ async function genVideo(pos, opts) {
|
|
|
493
510
|
const url = job.video?.url;
|
|
494
511
|
if (!url) throw new Error(`video job ${queued.jobId} finished without a URL; check it with: video-job ${queued.jobId}`);
|
|
495
512
|
// Print the paid clip's URL before downloading, so a failed download never hides it.
|
|
513
|
+
// A transparent clip prints both files (WebM, then Safari's stacked MP4) and the markup.
|
|
496
514
|
console.log(opts.json ? JSON.stringify(job, null, 2) : url);
|
|
515
|
+
if (!opts.json && job.video?.stacked?.url) console.log(job.video.stacked.url);
|
|
516
|
+
if (!opts.json && job.transparent && job.markup) process.stderr.write(`markup: ${job.markup}\n`);
|
|
497
517
|
if (job.creditsRemaining != null) process.stderr.write(`credits left: ${job.creditsRemaining}\n`);
|
|
498
518
|
if (opts.download) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
519
|
+
// A transparent clip is two files: the WebM where asked, the stacked MP4 beside it.
|
|
520
|
+
const files = [[url, opts.download]];
|
|
521
|
+
if (job.video?.stacked?.url) files.push([job.video.stacked.url, `${String(opts.download).replace(/\.[^./]+$/, "")}-stacked.mp4`]);
|
|
522
|
+
for (const [from, to] of files) {
|
|
523
|
+
const res = await fetch(from);
|
|
524
|
+
if (!res.ok) throw new Error(`download of ${from} failed (${res.status})`);
|
|
525
|
+
writeFileSync(to, Buffer.from(await res.arrayBuffer()));
|
|
526
|
+
process.stderr.write(`saved to ${to}\n`);
|
|
527
|
+
}
|
|
503
528
|
}
|
|
504
529
|
}
|
|
505
530
|
|
|
@@ -514,6 +539,8 @@ async function videoJob(pos, opts) {
|
|
|
514
539
|
console.log(`status: ${job.status}`);
|
|
515
540
|
if (job.error) console.log(`error: ${job.error}`);
|
|
516
541
|
if (job.video?.url) console.log(job.video.url);
|
|
542
|
+
if (job.video?.stacked?.url) console.log(job.video.stacked.url);
|
|
543
|
+
if (job.markup) console.log(`markup: ${job.markup}`);
|
|
517
544
|
}
|
|
518
545
|
|
|
519
546
|
async function screenshot(pos, opts) {
|
|
@@ -675,6 +702,13 @@ function commandTable(pos, rest, opts) {
|
|
|
675
702
|
const out = await release(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
|
|
676
703
|
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
677
704
|
},
|
|
705
|
+
play: async () => {
|
|
706
|
+
const { play } = await import("./play.mjs");
|
|
707
|
+
await play(pos.slice(1), opts, opts.json ? {
|
|
708
|
+
log: (line) => console.error(line),
|
|
709
|
+
started: ({ url, slug, deckDir, presenter, who }) => console.log(JSON.stringify({ url, slug, deckDir, presenter, who })),
|
|
710
|
+
} : {});
|
|
711
|
+
},
|
|
678
712
|
narrate: async () => {
|
|
679
713
|
const { narrate } = await import("./narrate.mjs");
|
|
680
714
|
const result = await narrate(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
|
|
@@ -756,7 +790,7 @@ function commandTable(pos, rest, opts) {
|
|
|
756
790
|
"deck avatar": async () => {
|
|
757
791
|
if (rest.length < 2) throw new Error("usage: deck avatar <slug> <name|id|none|default>");
|
|
758
792
|
const out = await ops.setDeckAvatar(opts, rest[0], rest.slice(1).join(" "));
|
|
759
|
-
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (
|
|
793
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (play and the draft link now; share links at its next release)`);
|
|
760
794
|
},
|
|
761
795
|
narrator: async () => {
|
|
762
796
|
const { narratorList } = await import("./narrator.mjs");
|
|
@@ -801,7 +835,7 @@ function commandTable(pos, rest, opts) {
|
|
|
801
835
|
"deck narrator": async () => {
|
|
802
836
|
if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
|
|
803
837
|
const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
|
|
804
|
-
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (
|
|
838
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
|
|
805
839
|
},
|
|
806
840
|
"preview setup": async () => {
|
|
807
841
|
const { setupCapture } = await import("./preview-capture.mjs");
|
|
@@ -853,6 +887,10 @@ function commandTable(pos, rest, opts) {
|
|
|
853
887
|
"brand edit": async () => (await import("./brandCli.mjs")).brandEdit(opts),
|
|
854
888
|
"brand fonts": async () => (await import("./brandCli.mjs")).brandFonts(opts),
|
|
855
889
|
"brand clear": async () => (await import("./brandCli.mjs")).brandClear(opts),
|
|
890
|
+
"brand import": async () => (await import("./brandCli.mjs")).brandImport(rest[0], opts),
|
|
891
|
+
"brand revise": async () => (await import("./brandCli.mjs")).brandRevise(rest, opts),
|
|
892
|
+
"brand history": async () => (await import("./brandCli.mjs")).brandHistory(opts),
|
|
893
|
+
"brand undo": async () => (await import("./brandCli.mjs")).brandUndo(rest[0], opts),
|
|
856
894
|
review: () => review(pos.slice(1), opts),
|
|
857
895
|
"review-job": () => reviewJob(pos.slice(1), opts),
|
|
858
896
|
poster: () => poster(pos.slice(1), opts),
|
package/src/importer.mjs
CHANGED
|
@@ -158,7 +158,7 @@ export function rewriteTextAssetUrls(text, sourcePath, assets, uploadedByName) {
|
|
|
158
158
|
export function rewriteHtmlAssetUrls(html, htmlEntry, assets, uploadedByName) {
|
|
159
159
|
const assetMap = buildAssetUrlMap(assets, uploadedByName, htmlEntry);
|
|
160
160
|
let out = html.replace(
|
|
161
|
-
/\b(src|href|poster|data-presenter-image)\s*=\s*(["'])([^"']+)\2/gi,
|
|
161
|
+
/\b(src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(["'])([^"']+)\2/gi,
|
|
162
162
|
(match, attr, quote, raw) => {
|
|
163
163
|
const url = resolveLocalUrl(raw, assetMap);
|
|
164
164
|
return url ? `${attr}=${quote}${url}${quote}` : match;
|
package/src/ops.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { zipSync } from "fflate";
|
|
2
2
|
import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
|
|
3
|
+
import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
|
|
3
4
|
import { api, client, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
4
5
|
import {
|
|
5
6
|
bytesFromText,
|
|
@@ -120,8 +121,9 @@ export async function pollImageJob(opts, jobId, { intervalMs = 3000, timeoutMs =
|
|
|
120
121
|
|
|
121
122
|
// Generate a short clip via fal (MiniMax H3 Max by default, Kling v3 with
|
|
122
123
|
// model "kling-v3": text→video, or image→video when an image URL is given as the
|
|
123
|
-
// first frame
|
|
124
|
-
//
|
|
124
|
+
// first frame; loop starts and ends on one frame, transparent mattes the background
|
|
125
|
+
// out). Always a background job: returns
|
|
126
|
+
// { jobId, status, seconds, aspect?, loop, transparent, creditsRemaining? } without waiting, so the MCP
|
|
125
127
|
// tool never blocks; the CLI polls with pollVideoJob.
|
|
126
128
|
export async function generateVideo(opts, fields = {}) {
|
|
127
129
|
if (!fields.prompt) throw new Error("prompt is required");
|
|
@@ -131,6 +133,8 @@ export async function generateVideo(opts, fields = {}) {
|
|
|
131
133
|
if (fields.seconds != null && fields.seconds !== "") args.seconds = Number(fields.seconds);
|
|
132
134
|
if (fields.aspect) args.aspect = String(fields.aspect);
|
|
133
135
|
if (fields.model) args.model = String(fields.model);
|
|
136
|
+
if (fields.loop === true || fields.loop === "true") args.loop = true;
|
|
137
|
+
if (fields.transparent === true || fields.transparent === "true") args.transparent = true;
|
|
134
138
|
if (fields.slug) args.slug = String(fields.slug);
|
|
135
139
|
if (fields.name) args.name = String(fields.name);
|
|
136
140
|
return (await client(opts)).action(api.videogen.generateVideo, args);
|
|
@@ -232,11 +236,11 @@ export async function checkVideoJob(opts, jobId) {
|
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
// Poll a video job until it finishes. Returns the job (its `video` holds the hosted
|
|
235
|
-
// URL); throws on job error or timeout.
|
|
236
|
-
// minutes, so the 16-minute default only trips if the job row itself stalls. A few
|
|
237
|
-
// failed checks in a row (network, Convex hiccup) are tolerated: the job keeps
|
|
239
|
+
// URL); throws on job error or timeout. A few failed checks in a row (network, Convex hiccup) are tolerated: the job keeps
|
|
238
240
|
// running server-side either way, so giving up early would only lose the result.
|
|
239
|
-
|
|
241
|
+
// 30 minutes: a transparent clip can take the render's 12 minutes, then up to 14
|
|
242
|
+
// more to matte and store it (convex/videogen.ts gives the matte its own clock).
|
|
243
|
+
export async function pollVideoJob(opts, jobId, { intervalMs = 5000, timeoutMs = 1_800_000, maxErrors = 5, onProgress } = {}) {
|
|
240
244
|
const c = await client(opts);
|
|
241
245
|
const start = Date.now();
|
|
242
246
|
const resume = `check it later with: video-job ${jobId}`;
|
|
@@ -747,7 +751,7 @@ function designServeUrl(projectId, token, path) {
|
|
|
747
751
|
}
|
|
748
752
|
|
|
749
753
|
// Pull every local asset path a deck file references (HTML or CSS): the
|
|
750
|
-
// ext-resource-dependency manifest, src/href/poster/data-presenter-image, srcset, <script>/<link>,
|
|
754
|
+
// ext-resource-dependency manifest, src/href/poster/data-presenter-image/data-alpha-stacked, srcset, <script>/<link>,
|
|
751
755
|
// and CSS url(). Mirrors the importer's rewrite surface so we fetch the same set
|
|
752
756
|
// it will later rewrite. External/data/blob refs are skipped.
|
|
753
757
|
function extractDesignRefs(text) {
|
|
@@ -759,7 +763,7 @@ function extractDesignRefs(text) {
|
|
|
759
763
|
if (clean) out.add(clean);
|
|
760
764
|
};
|
|
761
765
|
for (const m of text.matchAll(/ext-resource-dependency"\s+content="([^"]+)"/gi)) add(m[1]);
|
|
762
|
-
for (const m of text.matchAll(/\b(?:src|href|poster|data-presenter-image)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
|
|
766
|
+
for (const m of text.matchAll(/\b(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
|
|
763
767
|
for (const m of text.matchAll(/\bsrcset\s*=\s*["']([^"']+)["']/gi)) {
|
|
764
768
|
for (const part of m[1].split(",")) add(part.trim().split(/\s+/)[0]);
|
|
765
769
|
}
|
|
@@ -960,6 +964,11 @@ export async function presenterFor(opts, slug, voice) {
|
|
|
960
964
|
return (await client(opts)).query(api.avatars.presenterFor, { slug, ...(voice ? { voice: String(voice) } : {}) });
|
|
961
965
|
}
|
|
962
966
|
|
|
967
|
+
// The same answer as presenterFor plus who presents in words: { presenter, who }.
|
|
968
|
+
export async function presenterWithReason(opts, slug, voice) {
|
|
969
|
+
return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}) });
|
|
970
|
+
}
|
|
971
|
+
|
|
963
972
|
// ---- narrators (convex/narrators.ts) ------------------------------------------
|
|
964
973
|
// Who narrates your decks: a face plus a voice. D.A.S.H. is built in. As with
|
|
965
974
|
// avatars, an agent never adds one: `narrator new` hands back a link.
|
|
@@ -1097,8 +1106,10 @@ export async function getBrand(opts = {}) {
|
|
|
1097
1106
|
// Merge into the saved brand: a field left out keeps its value, "" clears it.
|
|
1098
1107
|
// fontPairing names one of FONT_PAIRINGS and fills the three fonts (an explicit
|
|
1099
1108
|
// font still wins). logoUrl is fetched into our storage; logoBytes (with
|
|
1100
|
-
// logoType) is a file the CLI read. clearLogo removes the logo.
|
|
1101
|
-
|
|
1109
|
+
// logoType) is a file the CLI read. clearLogo removes the logo. draft is a
|
|
1110
|
+
// brand import's token (importBrand): its colours, fonts, name and logo,
|
|
1111
|
+
// saved after the person has seen the preview; other fields still win.
|
|
1112
|
+
export async function setBrand(opts, { name, designMd, colors, fonts, fontPairing, presenterStyle, logoUrl, logoBytes, logoType, clearLogo, draft } = {}) {
|
|
1102
1113
|
const c = await client(opts);
|
|
1103
1114
|
let fontSet = fonts;
|
|
1104
1115
|
if (fontPairing) {
|
|
@@ -1115,9 +1126,9 @@ export async function setBrand(opts, { name, designMd, colors, fonts, fontPairin
|
|
|
1115
1126
|
};
|
|
1116
1127
|
known(colors, COLOR_KEYS, "colour");
|
|
1117
1128
|
known(fontSet, FONT_ROLES, "font role");
|
|
1118
|
-
const fields = { name, designMd, colors, fonts: fontSet, presenterStyle };
|
|
1129
|
+
const fields = { name, designMd, colors, fonts: fontSet, presenterStyle, draft: draft ? String(draft).trim() : undefined };
|
|
1119
1130
|
const defined = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && v !== null));
|
|
1120
|
-
if (!Object.keys(defined).length && !logoUrl && !logoBytes && !clearLogo) throw new Error("nothing to set: pass colors, fonts, fontPairing, logoUrl, designMd, name or presenterStyle");
|
|
1131
|
+
if (!Object.keys(defined).length && !logoUrl && !logoBytes && !clearLogo) throw new Error("nothing to set: pass draft, colors, fonts, fontPairing, logoUrl, designMd, name or presenterStyle");
|
|
1121
1132
|
let out = Object.keys(defined).length ? await c.mutation(api.brand.set, defined) : null;
|
|
1122
1133
|
try {
|
|
1123
1134
|
if (clearLogo) out = await c.mutation(api.brand.clear, { logoOnly: true });
|
|
@@ -1134,6 +1145,41 @@ export async function setBrand(opts, { name, designMd, colors, fonts, fontPairin
|
|
|
1134
1145
|
return out ?? getBrand(opts);
|
|
1135
1146
|
}
|
|
1136
1147
|
|
|
1148
|
+
// Read a brand from a website (url), one of your decks (slug) or a .pptx /
|
|
1149
|
+
// .potx / .pdf (bytes + fileName, read here with brandImport.mjs) into a
|
|
1150
|
+
// draft. Returns { token, colors, fonts, logoUrl, notes, previewUrl, … };
|
|
1151
|
+
// nothing is saved until setBrand({ draft: token }).
|
|
1152
|
+
export async function importBrand(opts, { url, slug, bytes, fileName } = {}) {
|
|
1153
|
+
const given = [url, slug, bytes].filter((x) => x !== undefined && x !== null && x !== "");
|
|
1154
|
+
if (given.length !== 1) throw new Error("import a brand from one of: a website url, a deck slug or a .pptx / .potx / .pdf file");
|
|
1155
|
+
const c = await client(opts);
|
|
1156
|
+
if (url) return c.action(api.brandNode.importFromUrl, { url: String(url) });
|
|
1157
|
+
if (slug) return c.action(api.brandNode.importFromDeck, { slug: String(slug) });
|
|
1158
|
+
const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
1159
|
+
if (data.byteLength > MAX_IMPORT_FILE_BYTES) throw new Error(`${fileName ?? "the file"} is over ${MAX_IMPORT_FILE_BYTES / 1024 / 1024} MB`);
|
|
1160
|
+
return c.action(api.brandNode.createDraft, draftArgs(extractFile(data, fileName)));
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Revise the brand with a prompt ("darker accent, serif titles"): a draft
|
|
1164
|
+
// like importBrand's, with a previewUrl; nothing is saved until
|
|
1165
|
+
// setBrand({ draft: token }).
|
|
1166
|
+
export async function reviseBrand(opts, { instruction } = {}) {
|
|
1167
|
+
const ask = String(instruction ?? "").trim();
|
|
1168
|
+
if (!ask) throw new Error('say what to change, e.g. "make the accent darker and use a serif for titles"');
|
|
1169
|
+
return (await client(opts)).action(api.brandRevise.revise, { instruction: ask });
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// The brand's last changes, newest first (the first is the brand now).
|
|
1173
|
+
export async function brandHistory(opts = {}) {
|
|
1174
|
+
return { versions: await (await client(opts)).query(api.brand.history, {}) };
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Undo the brand's last change, or go back to one version from brandHistory.
|
|
1178
|
+
export async function undoBrand(opts = {}, { version } = {}) {
|
|
1179
|
+
const c = await client(opts);
|
|
1180
|
+
return version ? c.mutation(api.brand.restore, { version: String(version) }) : c.mutation(api.brand.undo, {});
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1137
1183
|
export async function clearBrand(opts = {}) {
|
|
1138
1184
|
await (await client(opts)).mutation(api.brand.clear, {});
|
|
1139
1185
|
return { cleared: true };
|
package/src/play.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// hyperdecks play <slug|deck-dir> [--voice v] [--slug s] [--port n] [--no-open] [--json]
|
|
2
|
+
//
|
|
3
|
+
// Plays the working folder as it is, committed or not, the way it will play once
|
|
4
|
+
// released: the owner's face goes on the player tag of the page it serves, in
|
|
5
|
+
// memory only. It writes no file, uploads nothing, creates no share and renders
|
|
6
|
+
// no audio, so it plays the manifest index.html already points at; changed lines
|
|
7
|
+
// need `narrate` first. The server listens on 127.0.0.1 behind a random path until
|
|
8
|
+
// Ctrl-C. The deck's script.md and evidence.md are not served (preview-capture.mjs
|
|
9
|
+
// serves only the file types a page loads).
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { basename, join, relative } from "node:path";
|
|
12
|
+
import { playerScript, sectionIds } from "./checkRules.mjs";
|
|
13
|
+
import { resolveDeckDir } from "./check.mjs";
|
|
14
|
+
import * as ops from "./ops.mjs";
|
|
15
|
+
import { playsCaptionsOnly, presenterLine, withAvatar } from "./presenter.mjs";
|
|
16
|
+
import { serveSource } from "./preview-capture.mjs";
|
|
17
|
+
import { openBrowser } from "./runtime.mjs";
|
|
18
|
+
|
|
19
|
+
const USAGE = "usage: play <slug|deck-dir> [--voice <name|id>] [--slug <slug>] [--port <n>] [--no-open]";
|
|
20
|
+
|
|
21
|
+
function portOf(value) {
|
|
22
|
+
if (value === undefined) return 0;
|
|
23
|
+
const port = Number(value);
|
|
24
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`--port must be a port number (got ${value})`);
|
|
25
|
+
return port;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A path `narrate` and `import` take: the argument to play may be a slug.
|
|
29
|
+
function pathFrom(cwd, deckDir) {
|
|
30
|
+
const rel = relative(cwd ?? process.cwd(), deckDir);
|
|
31
|
+
return !rel ? "." : rel.startsWith("..") ? deckDir : rel;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The face to add, and { who } in words. Never stops the play: without an answer
|
|
35
|
+
// the page plays as written, as a release does when this lookup fails.
|
|
36
|
+
async function lookUpPresenter(opts, { slug, path, html, log }) {
|
|
37
|
+
const narrate = `hyperdecks narrate ${path}`;
|
|
38
|
+
const lead = playsCaptionsOnly(html) ? "captions only" : "playing as written";
|
|
39
|
+
if (!playerScript(html)?.manifest) {
|
|
40
|
+
log(`⚠ no narration yet, so this plays silent: run \`${narrate}\` first`);
|
|
41
|
+
return { presenter: null, who: null };
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const found = await ops.presenterWithReason(opts, slug, opts.voice);
|
|
45
|
+
if (!found.who) log(`⚠ ${lead}: no finished narration on this account for "${slug}": run \`${narrate}\` first`);
|
|
46
|
+
return found;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
const why = ops.errorText(e);
|
|
49
|
+
if (/deck not found/.test(why)) log(`⚠ ${lead}: "${slug}" is not on this account yet, so it has no presenter. Register it (\`hyperdecks import ${join(path, "index.html")} --slug ${slug} --status draft\`), then run \`${narrate}\``);
|
|
50
|
+
else if (/forbidden/.test(why)) log(`⚠ ${lead}: the deck "${slug}" belongs to another account. Log in as its owner, or pass --slug with yours`);
|
|
51
|
+
else log(`⚠ ${lead}: could not ask who presents (${why}). Your avatar is not the problem; try again, and if it keeps failing this CLI may be newer than the backend`);
|
|
52
|
+
return { presenter: null, who: null };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Says what the served page does, not what the lookup answered.
|
|
57
|
+
function sayWhoPresents({ html, page, presenter, who, log }) {
|
|
58
|
+
if (presenter && page === html) log("⚠ the player tag already names a presenter, so it plays as written");
|
|
59
|
+
else if (who?.face && !presenter && playsCaptionsOnly(html)) log(`⚠ captions only: the page names no presenter, and a face for voice "${who.voice}" is not added here`);
|
|
60
|
+
else if (who) log(presenterLine(who));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A warning only. An error with text is the backend's answer (the local script
|
|
64
|
+
// differs from the hosted one, a line that won't compile), so it is shown; no
|
|
65
|
+
// script.md, or a backend that could not say, is not worth a line.
|
|
66
|
+
async function warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log }) {
|
|
67
|
+
try {
|
|
68
|
+
const script = readFileSync(join(deckDir, "script.md"), "utf8");
|
|
69
|
+
const plan = await ops.previewNarration(opts, { slug, script, voice: who.voice, sectionIds: sectionIds(html) });
|
|
70
|
+
const changed = plan.slides.filter((s) => s.render).map((s) => s.id);
|
|
71
|
+
if (changed.length) log(`⚠ script.md changed since the last narration (${changed.join(", ")}); this plays the old audio. Run \`hyperdecks narrate ${path}\` to hear the new lines`);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (typeof e?.data === "string") log(`⚠ could not compare script.md with the last narration: ${e.data}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Starts serving and returns; the caller closes it. `play` is the command.
|
|
78
|
+
export async function startPlay(pos, opts = {}, { log = (line) => console.log(line), open = openBrowser, cwd } = {}) {
|
|
79
|
+
const [deckDirArg] = pos;
|
|
80
|
+
if (!deckDirArg) throw new Error(USAGE);
|
|
81
|
+
for (const k of ["voice", "slug", "port"]) if (k in opts && typeof opts[k] !== "string") throw new Error(`--${k} requires a value`);
|
|
82
|
+
const port = portOf(opts.port);
|
|
83
|
+
const deckDir = resolveDeckDir(deckDirArg, cwd);
|
|
84
|
+
const path = pathFrom(cwd, deckDir);
|
|
85
|
+
const slug = opts.slug || basename(deckDir);
|
|
86
|
+
const file = join(deckDir, "index.html");
|
|
87
|
+
const html = readFileSync(file, "utf8");
|
|
88
|
+
|
|
89
|
+
const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
|
|
90
|
+
const page = presenter ? withAvatar(html, presenter) : html;
|
|
91
|
+
sayWhoPresents({ html, page, presenter, who, log });
|
|
92
|
+
// --voice names the voice it was narrated in, so there is nothing to compare it to.
|
|
93
|
+
if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });
|
|
94
|
+
|
|
95
|
+
let server;
|
|
96
|
+
try {
|
|
97
|
+
server = await serveSource({ file, html: page, port });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
throw new Error(e?.code === "EADDRINUSE" ? `port ${port} is in use; pick another with --port` : e?.message || String(e));
|
|
100
|
+
}
|
|
101
|
+
log(`▶ playing ${slug}: ${server.url}\n Ctrl-C to stop. Nothing is written, uploaded or shared.`);
|
|
102
|
+
if (!opts["no-open"]) open(server.url);
|
|
103
|
+
return { url: server.url, close: server.close, deckDir, slug, presenter: page === html ? null : presenter, who: who ?? null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function play(pos, opts = {}, io = {}) {
|
|
107
|
+
const session = await startPlay(pos, opts, io);
|
|
108
|
+
await new Promise((stopped) => {
|
|
109
|
+
const stop = () => {
|
|
110
|
+
process.off("SIGINT", stop);
|
|
111
|
+
process.off("SIGTERM", stop);
|
|
112
|
+
stopped();
|
|
113
|
+
};
|
|
114
|
+
process.on("SIGINT", stop);
|
|
115
|
+
process.on("SIGTERM", stop);
|
|
116
|
+
io.started?.(session);
|
|
117
|
+
});
|
|
118
|
+
await session.close();
|
|
119
|
+
}
|
package/src/presenter.mjs
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
// The released deck's presenter (roadmap: avatars-pick-make-from-a-selfie-or-
|
|
2
2
|
// import-your-minimoji). A deck narrated in the owner's own voice is written
|
|
3
3
|
// captions-only (data-presenter="none"); at release, when the owner has an
|
|
4
|
-
// avatar, its player tag gets that face instead. Shared by
|
|
5
|
-
// (convex/deckSources.ts
|
|
6
|
-
// rewrite the tag the same way. A tag that already names a presenter (D.A.S.H.
|
|
4
|
+
// avatar, its player tag gets that face instead. Shared by both releases
|
|
5
|
+
// (convex/deckSources.ts, release.mjs), `hyperdecks play` and the /p draft
|
|
6
|
+
// preview, so all rewrite the tag the same way; only a release stores it. A tag that already names a presenter (D.A.S.H.
|
|
7
7
|
// on a character-voice deck, or one set by hand) is left alone.
|
|
8
8
|
|
|
9
9
|
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
|
|
10
10
|
|
|
11
11
|
const attr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """);
|
|
12
12
|
|
|
13
|
+
// Whether this tag plays captions only: data-presenter="none", or no presenter
|
|
14
|
+
// at all (the player's default when there is no data-presenter-image either).
|
|
15
|
+
const noPresenter = (tag) => /\sdata-presenter="none"/.test(tag) || (!/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag));
|
|
16
|
+
|
|
17
|
+
// Whether the page's player tag names nobody, so without a face it has captions only.
|
|
18
|
+
export function playsCaptionsOnly(html) {
|
|
19
|
+
const tag = PLAYER_TAG.exec(html)?.[0];
|
|
20
|
+
return !!tag && !/\sdata-avatar=/.test(tag) && noPresenter(tag);
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
// html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
|
|
14
24
|
// the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
|
|
15
25
|
// or html unchanged when there is no such tag or it already has a presenter.
|
|
16
26
|
export function withAvatar(html, { avatarUrl, imageUrl }) {
|
|
17
27
|
const tag = PLAYER_TAG.exec(html)?.[0];
|
|
18
28
|
if (!tag || /\sdata-avatar=/.test(tag)) return html;
|
|
19
|
-
|
|
20
|
-
// default when there is no data-presenter-image either).
|
|
29
|
+
if (!noPresenter(tag)) return html;
|
|
21
30
|
const none = /\sdata-presenter="none"/.test(tag);
|
|
22
|
-
const unset = !/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag);
|
|
23
|
-
if (!none && !unset) return html;
|
|
24
31
|
const image = imageUrl && !/\sdata-presenter-image=/.test(tag) ? ` data-presenter-image="${attr(imageUrl)}"` : "";
|
|
25
32
|
const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
|
|
26
33
|
const next = none ? tag.replace(/\sdata-presenter="none"/, face) : tag.replace(/>$/, `${face}>`);
|
package/src/preview-capture.mjs
CHANGED
|
@@ -19,7 +19,9 @@ export async function setupCapture(pos, opts) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// Capability URL + realpath containment; captured code gets no general file server.
|
|
22
|
-
|
|
22
|
+
// `html` alone serves that page; with `file` it replaces the page and the file's
|
|
23
|
+
// folder still serves the rest (play's in-memory copy with the face on it).
|
|
24
|
+
export async function serveSource({ file, html, port = 0 }) {
|
|
23
25
|
const root = file ? await realpath(dirname(file)) : null;
|
|
24
26
|
const prefix = '/' + randomUUID() + '/';
|
|
25
27
|
const entry = prefix + (file ? encodeURIComponent(resolve(file).split(sep).pop()) : 'index.html');
|
|
@@ -27,7 +29,7 @@ export async function serveSource({ file, html }) {
|
|
|
27
29
|
try {
|
|
28
30
|
const path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
|
29
31
|
if (req.method !== 'GET' || !path.startsWith(prefix)) { res.writeHead(404).end(); return; }
|
|
30
|
-
if (
|
|
32
|
+
if (html != null && path === entry) { res.writeHead(200, { 'Content-Type':'text/html; charset=utf-8', 'Cache-Control':'no-store' }).end(html); return; }
|
|
31
33
|
const rel = path.slice(prefix.length);
|
|
32
34
|
if (!root || rel.split('/').some(s => s.startsWith('.') || s.includes('\\')) || !MIME[extname(rel).toLowerCase()]) { res.writeHead(404).end(); return; }
|
|
33
35
|
const full = await realpath(resolve(root, rel));
|
|
@@ -36,7 +38,7 @@ export async function serveSource({ file, html }) {
|
|
|
36
38
|
res.end(await readFile(full));
|
|
37
39
|
} catch { res.writeHead(404).end(); }
|
|
38
40
|
});
|
|
39
|
-
await new Promise((ok, fail) => { server.once('error', fail); server.listen(
|
|
41
|
+
await new Promise((ok, fail) => { server.once('error', fail); server.listen(port, '127.0.0.1', ok); });
|
|
40
42
|
const url = `http://127.0.0.1:${server.address().port}${entry}`;
|
|
41
43
|
return { url, close: () => new Promise((ok, fail) => { server.close(e => e ? fail(e) : ok()); server.closeAllConnections(); }) };
|
|
42
44
|
}
|