@hypersocial/decks-cli 0.9.10 → 0.9.12
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 +2 -0
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +48 -24
- package/skills/hyperdecks/chat.md +16 -1
- package/src/avatar-packs.mjs +89 -0
- package/src/avatar-view.mjs +34 -0
- package/src/brand.mjs +19 -0
- package/src/checkRules.mjs +44 -4
- package/src/commands.mjs +80 -2
- package/src/deckPlayer.mjs +2 -1
- package/src/export-video.mjs +7 -2
- package/src/files.mjs +3 -0
- package/src/layouts.mjs +3 -2
- package/src/loadingBlock.mjs +22 -7
- package/src/opening.mjs +117 -0
- package/src/ops.mjs +106 -0
- package/src/play.mjs +4 -2
- package/src/preview-capture.mjs +27 -8
- package/src/preview-moment.mjs +243 -0
- package/src/preview.mjs +12 -2
- package/src/runtime.mjs +10 -3
- package/src/scaffold.mjs +1 -1
- package/templates/layouts/_deck.html +76 -7
- package/templates/layouts/closer.html +3 -1
- package/templates/layouts/cover-band.html +44 -0
- package/templates/layouts/cover-poster.html +42 -0
- package/templates/layouts/cover-type.html +31 -0
- package/templates/layouts/hero.html +27 -12
- package/templates/layouts/split.html +2 -2
- package/templates/narrated-deck.html +71 -2
package/src/export-video.mjs
CHANGED
|
@@ -424,7 +424,8 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
424
424
|
// A development checkout plays its own player/v1 (HYPERDECKS_PLAYER_DIR), not the released one.
|
|
425
425
|
if (config.playerDir) {
|
|
426
426
|
await context.route(/\/player\/v1\/[^?#]+/, async (route) => {
|
|
427
|
-
|
|
427
|
+
// A pinned build (/player/v1/b/<hash>/, lib/deckPlayer.ts) is the checkout's files too.
|
|
428
|
+
const name = new URL(route.request().url()).pathname.replace(/^.*\/player\/v1\/(b\/[^/]+\/)?/, '');
|
|
428
429
|
if (name.split('/').some((s) => s === '..' || s.startsWith('.'))) return route.abort();
|
|
429
430
|
const type = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png' }[extname(name)] ?? 'application/octet-stream';
|
|
430
431
|
try { await route.fulfill({ body: await readFile(join(config.playerDir, name)), contentType: type }); }
|
|
@@ -768,7 +769,11 @@ export async function exportVideo(arg, opts = {}, { log = (l) => process.stderr.
|
|
|
768
769
|
} else {
|
|
769
770
|
const deck = await ops.releasedDeck(opts, arg);
|
|
770
771
|
const { serveSource } = await import('./preview-capture.mjs');
|
|
771
|
-
|
|
772
|
+
// The worker imports recordVideo with already-scoped HTML and no CLI auth dependencies.
|
|
773
|
+
const { withAvatarView } = await import("./avatar-view.mjs");
|
|
774
|
+
const { client: avatarClient } = await import("./runtime.mjs");
|
|
775
|
+
const html = await withAvatarView({ mutation: async (ref, args) => (await avatarClient(opts)).mutation(ref, args) }, arg, deck.html, "released");
|
|
776
|
+
const server = await serveSource({ html });
|
|
772
777
|
source = { slug: arg, title: deck.title, url: server.url, close: server.close, presenter: null };
|
|
773
778
|
}
|
|
774
779
|
try {
|
package/src/files.mjs
CHANGED
|
@@ -13,6 +13,9 @@ const FILES = {
|
|
|
13
13
|
"templates/layouts/closer.html": () => new URL("../templates/layouts/closer.html", import.meta.url),
|
|
14
14
|
"templates/layouts/compare.html": () => new URL("../templates/layouts/compare.html", import.meta.url),
|
|
15
15
|
"templates/layouts/features.html": () => new URL("../templates/layouts/features.html", import.meta.url),
|
|
16
|
+
"templates/layouts/cover-band.html": () => new URL("../templates/layouts/cover-band.html", import.meta.url),
|
|
17
|
+
"templates/layouts/cover-poster.html": () => new URL("../templates/layouts/cover-poster.html", import.meta.url),
|
|
18
|
+
"templates/layouts/cover-type.html": () => new URL("../templates/layouts/cover-type.html", import.meta.url),
|
|
16
19
|
"templates/layouts/hero.html": () => new URL("../templates/layouts/hero.html", import.meta.url),
|
|
17
20
|
"templates/layouts/number.html": () => new URL("../templates/layouts/number.html", import.meta.url),
|
|
18
21
|
"templates/layouts/product.html": () => new URL("../templates/layouts/product.html", import.meta.url),
|
package/src/layouts.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
import { packageText } from "./files.mjs";
|
|
5
5
|
|
|
6
6
|
export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
|
|
7
|
-
// The library
|
|
8
|
-
|
|
7
|
+
// The library: the four covers first (hero is the default; a deck opens on one), then
|
|
8
|
+
// the rest in a sensible deck order. `--layouts` takes any order and repeats.
|
|
9
|
+
export const LAYOUTS = ["hero", "cover-band", "cover-poster", "cover-type", "number", "product", "split", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
|
|
9
10
|
const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features", "image-copy": "split", "image-text": "split" };
|
|
10
11
|
|
|
11
12
|
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
package/src/loadingBlock.mjs
CHANGED
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
// (lib/loadingBlock.test.ts keeps the three in step). A JS string, not a read of
|
|
5
5
|
// the template, because Convex (the hosted draft's write) cannot read package files.
|
|
6
6
|
// No Node imports: the CLI and Convex (and so the MCP route's writes) use it.
|
|
7
|
+
// The opening card (opening.mjs) is the loader: its rules in the style, and the
|
|
8
|
+
// <script data-hd-opening> after it that builds it once the page is parsed. A
|
|
9
|
+
// frame or ?thumb (data-hd-frame) gets the quiet dots instead; an export neither.
|
|
10
|
+
import { OPENING_CSS, OPENING_PAGE_SCRIPT } from "./opening.mjs";
|
|
11
|
+
|
|
7
12
|
export const LOADING_BLOCK = [
|
|
8
13
|
"<!-- Loading state (keep verbatim; `hyperdecks check` requires it). Until the",
|
|
9
14
|
" player marks body[data-hd-ready] the page shows only the slide card, at its",
|
|
10
15
|
" final size, in the deck's --hd-ground (its first slide's colour) on the",
|
|
11
|
-
" page's brand colour, with
|
|
16
|
+
" page's brand colour, with the opening card over it (the title and the",
|
|
17
|
+
" presenter's still), or in a frame quiet dots in --hd-ground-ink; then the",
|
|
12
18
|
" slides fade in. If the player never arrives, the slides show after 10 s",
|
|
13
19
|
" rather than never. Put data-layout=\"overlay\" on <html> for the overlay. -->",
|
|
14
20
|
"<link rel=\"modulepreload\" href=\"https://hyperdecks.ai/player/v1/player.js\">",
|
|
@@ -22,7 +28,7 @@ export const LOADING_BLOCK = [
|
|
|
22
28
|
" /* The card as the player draws it (player.css, framed): 16:9, scaled to fit",
|
|
23
29
|
" between the 64px masthead and the 56px dock, 24px in from the sides. */",
|
|
24
30
|
" body::before { inset: 64px 24px 56px; margin: auto; width: min(100vw - 48px, (100dvh - 120px) * 16 / 9); height: min(100dvh - 120px, (100vw - 48px) * 9 / 16); border-radius: calc(min(100vw - 48px, (100dvh - 120px) * 16 / 9) * .01125); background: var(--hd-ground, #fff); box-shadow: 0 0 0 1px color-mix(in srgb, var(--hd-ground-ink, #0b0b0f) 16%, transparent); }",
|
|
25
|
-
" body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }",
|
|
31
|
+
" [data-hd-frame] body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }",
|
|
26
32
|
" body[data-hd-ready]::before, body[data-hd-ready]::after { opacity: 0; animation: none; }",
|
|
27
33
|
" /* The overlay (embedded, ?thumb, or data-layout=\"overlay\"): the stage fills the window. */",
|
|
28
34
|
" :is([data-hd-frame], [data-layout=\"overlay\"]) body::before { inset: 0; width: min(100vw, 100dvh * 16 / 9); height: min(100dvh, 100vw * 9 / 16); border-radius: calc(min(100vw, 100dvh * 16 / 9) * .01125); box-shadow: none; }",
|
|
@@ -42,21 +48,28 @@ export const LOADING_BLOCK = [
|
|
|
42
48
|
" @keyframes hd-in { from { opacity: 0; } }",
|
|
43
49
|
" @keyframes hd-dots { from { opacity: .15; } to { opacity: .6; } }",
|
|
44
50
|
" @keyframes hd-fail-open { to { visibility: visible; overflow: auto; } }",
|
|
51
|
+
...OPENING_CSS.replace(/\n$/, "").split("\n"),
|
|
45
52
|
"</style>",
|
|
53
|
+
OPENING_PAGE_SCRIPT,
|
|
46
54
|
].join("\n");
|
|
47
55
|
|
|
48
56
|
const STYLE = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>[\s\S]*?<\/style>/i;
|
|
49
57
|
const HEAD = /<head\b[^>]*>[\s\S]*?<\/head>/i;
|
|
50
58
|
const PLAYER = /<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/;
|
|
51
59
|
const LOADING_STYLE = STYLE.exec(LOADING_BLOCK)[0];
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
const OPENING_SCRIPT = /<script data-hd-opening>[\s\S]*?<\/script>/;
|
|
61
|
+
const OPENING_SCRIPT_PIECE = new RegExp(`${OPENING_SCRIPT.source}[ \\t]*\\n?`, "g");
|
|
62
|
+
// The script with the line break before it (as the block writes it after the style).
|
|
63
|
+
const OPENING_SCRIPT_LINE = new RegExp(`\\n?[ \\t]*${OPENING_SCRIPT.source}`, "g");
|
|
64
|
+
// The block's pieces besides its style, on any player host: the two preloads,
|
|
65
|
+
// the frame flag and the card's <script data-hd-opening> (each kept once), and its own comment (matched only as a whole
|
|
54
66
|
// comment, so a person's comments in <head> are never touched).
|
|
55
67
|
const PIECES = [
|
|
56
68
|
/<link rel="modulepreload" href="[^"]*\/player\/v\d+\/player\.js">[ \t]*\n?/g,
|
|
57
69
|
/<link rel="preload" as="style" href="[^"]*\/player\/v\d+\/player\.css">[ \t]*\n?/g,
|
|
58
70
|
/<script>if \(self !== top \|\| [^<]*document\.documentElement\.dataset\.hdFrame = "";<\/script>[ \t]*\n?/g,
|
|
59
71
|
new RegExp(`${STYLE.source}[ \\t]*\\n?`, "gi"),
|
|
72
|
+
OPENING_SCRIPT_PIECE,
|
|
60
73
|
];
|
|
61
74
|
const BLOCK_COMMENT = /^<!-- Loading state \(keep verbatim/;
|
|
62
75
|
|
|
@@ -93,11 +106,13 @@ export function withLoadingBlock(html) {
|
|
|
93
106
|
const at = title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(cleaned)[0].length;
|
|
94
107
|
return { html: splice(`${cleaned.slice(0, at)}\n${LOADING_BLOCK}${cleaned.slice(at)}`), fixed: "inserted" };
|
|
95
108
|
}
|
|
96
|
-
|
|
109
|
+
// The card's script goes right after the style, as the template's (a block
|
|
110
|
+
// from before the card gets it; a stray copy elsewhere goes).
|
|
111
|
+
const deduped = trimPieces(rawHead, 1).split(/(<!--[\s\S]*?-->)/).map((part, i) => (i % 2 ? part : part.replace(OPENING_SCRIPT_LINE, ""))).join("");
|
|
97
112
|
// The one style left, outside comments, made the template's.
|
|
98
113
|
const blank = deduped.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length));
|
|
99
114
|
const style = STYLE.exec(blank);
|
|
100
|
-
const fresh = deduped.slice(0, style.index) + LOADING_STYLE + deduped.slice(style.index + style[0].length);
|
|
115
|
+
const fresh = deduped.slice(0, style.index) + LOADING_STYLE + "\n" + OPENING_PAGE_SCRIPT + deduped.slice(style.index + style[0].length);
|
|
101
116
|
if (fresh === rawHead) return { html, fixed: null };
|
|
102
|
-
return { html: splice(fresh), fixed:
|
|
117
|
+
return { html: splice(fresh), fixed: trimPieces(rawHead, 1) !== rawHead ? "deduped" : "refreshed" };
|
|
103
118
|
}
|
package/src/opening.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The opening card a deck shows while its player loads (#207): the first
|
|
2
|
+
// slide blurred and breathing where the slide will be, and a glass card with
|
|
3
|
+
// the presenter's still hopping, the title and the player's real boot steps
|
|
4
|
+
// (body[data-hd-boot], player.js). When the player marks body[data-hd-ready]
|
|
5
|
+
// the presenter jumps, the card fades and the slide sharpens.
|
|
6
|
+
//
|
|
7
|
+
// One card, two ways in: a route that serves the deck adds it with the deck's
|
|
8
|
+
// poster and narrator (lib/deckLoadingOverlay.ts), and every deck's own
|
|
9
|
+
// loading state (loadingBlock.mjs, the templates) builds it in the page from
|
|
10
|
+
// what the file has (its <title>, the player tag's data-presenter-image), so a
|
|
11
|
+
// raw deck file opens on it too. The page's own card steps aside when a route
|
|
12
|
+
// already added one. Never in a frame, a thumbnail (?thumb,
|
|
13
|
+
// data-hyperdecks-thumb) or an export (?export); a deck ready within FAST_MS
|
|
14
|
+
// never shows it; if the player never says ready it goes after FAIL_OPEN_MS.
|
|
15
|
+
// No Node imports: the CLI, Convex and the app all use it.
|
|
16
|
+
|
|
17
|
+
export const FAST_MS = 150;
|
|
18
|
+
export const FAIL_OPEN_MS = 10000;
|
|
19
|
+
|
|
20
|
+
// The boot steps player.js reports on body[data-hd-boot], in the order it
|
|
21
|
+
// reaches them: [progress %, copy]. {name} is the presenter's.
|
|
22
|
+
export const BOOT_STEPS = Object.freeze({
|
|
23
|
+
page: [10, "Opening the deck…"],
|
|
24
|
+
player: [38, "Loading the player…"],
|
|
25
|
+
presenter: [62, "Waking up {name}…"],
|
|
26
|
+
narration: [84, "Getting the narration…"],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// The card's rules (the route wraps them in <style data-hd-opening>; the
|
|
30
|
+
// loading block carries them in its <style data-hd-loading>).
|
|
31
|
+
export const OPENING_CSS = `#hd-open{position:fixed;inset:0;z-index:2147483000;pointer-events:none;visibility:visible!important;animation:none!important;opacity:0;font-family:var(--hd-ui-font,"Space Grotesk",system-ui,sans-serif);color:#0b0b0f}
|
|
32
|
+
#hd-open.is-shown{opacity:1;transition:opacity .2s ease-out}
|
|
33
|
+
#hd-open.is-gone{opacity:0;transition:opacity .45s ease-out}
|
|
34
|
+
#hd-open .hdo-slide{position:absolute;inset:64px 24px 56px;margin:auto;width:min(100vw - 48px,(100dvh - 120px)*16/9);height:min(100dvh - 120px,(100vw - 48px)*9/16);border-radius:calc(min(100vw - 48px,(100dvh - 120px)*16/9)*.01125);overflow:hidden;container-type:inline-size;background:var(--hd-ground,#fff)}
|
|
35
|
+
#hd-open .hdo-slide img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;filter:blur(2.2cqi) saturate(1.15);transform:scale(1.12);animation:hdo-breathe 2.4s ease-in-out infinite}
|
|
36
|
+
#hd-open .hdo-slide::after{content:"";position:absolute;inset:0;background:linear-gradient(100deg,transparent 30%,rgba(255,255,255,.3) 50%,transparent 70%);background-size:250% 100%;animation:hdo-shim 1.8s ease-in-out infinite;mix-blend-mode:soft-light}
|
|
37
|
+
#hd-open.is-done .hdo-slide img{animation:hdo-deblur .5s cubic-bezier(.2,.8,.2,1) .5s forwards}
|
|
38
|
+
#hd-open.is-done .hdo-slide::after{opacity:0;transition:opacity .3s}
|
|
39
|
+
.hdo-card{position:absolute;left:50%;top:50%;translate:-50% -50%;width:min(86vw,420px);display:grid;justify-items:center;gap:.6em;padding:1.3em 1.5em 1.2em;border-radius:22px;background:rgba(255,255,255,.74);-webkit-backdrop-filter:blur(14px) saturate(1.3);backdrop-filter:blur(14px) saturate(1.3);box-shadow:4px 4px 0 #0b0b0f;border:1.5px solid #0b0b0f;text-align:center;box-sizing:border-box}
|
|
40
|
+
#hd-open.is-done .hdo-card{opacity:0;transform:scale(.96);transition:opacity .25s .5s,transform .25s .5s}
|
|
41
|
+
.hdo-buddy{position:relative;width:92px;height:96px;margin-top:-62px;display:grid;justify-items:center}
|
|
42
|
+
.hdo-hop{width:86px;height:80px;transform-origin:50% 100%;animation:hdo-hop 1.15s cubic-bezier(.3,0,.4,1) infinite}
|
|
43
|
+
.hdo-look{width:100%;height:100%;animation:hdo-look 4.6s ease-in-out infinite}
|
|
44
|
+
.hdo-buddy img{width:100%;height:100%;object-fit:contain;display:block;filter:drop-shadow(0 3px 0 rgba(11,11,15,.18))}
|
|
45
|
+
.hdo-shadow{position:absolute;bottom:4px;width:46px;height:8px;border-radius:50%;background:rgba(11,11,15,.22);animation:hdo-shadow 1.15s cubic-bezier(.3,0,.4,1) infinite}
|
|
46
|
+
.hdo-think{position:absolute;right:-30px;top:-6px;display:flex;gap:3px;padding:5px 8px;border-radius:14px;background:#fff;border:1.5px solid #0b0b0f;box-shadow:2px 2px 0 #0b0b0f}
|
|
47
|
+
.hdo-think::after{content:"";position:absolute;left:4px;bottom:-7px;width:7px;height:7px;border-radius:50%;background:#fff;border:1.5px solid #0b0b0f}
|
|
48
|
+
.hdo-think i{width:5px;height:5px;border-radius:50%;background:#0b0b0f;animation:hdo-tdot 1s ease-in-out infinite}
|
|
49
|
+
.hdo-think i:nth-child(2){animation-delay:.15s}.hdo-think i:nth-child(3){animation-delay:.3s}
|
|
50
|
+
.hdo-spark{position:absolute;left:50%;top:40%;width:8px;height:8px;margin:-4px;opacity:0}
|
|
51
|
+
.hdo-spark::before{content:"";position:absolute;inset:0;background:#0b0b0f;clip-path:polygon(50% 0,62% 38%,100% 50%,62% 62%,50% 100%,38% 62%,0 50%,38% 38%)}
|
|
52
|
+
#hd-open.is-done .hdo-hop{animation:hdo-yay .55s cubic-bezier(.2,1.4,.4,1) forwards}
|
|
53
|
+
#hd-open.is-done :is(.hdo-look,.hdo-shadow){animation:none}
|
|
54
|
+
#hd-open.is-done .hdo-think{opacity:0;transition:opacity .15s}
|
|
55
|
+
#hd-open.is-done .hdo-spark{animation:hdo-spark .6s ease-out forwards}
|
|
56
|
+
.hdo-title{font-weight:700;font-size:18px;line-height:1.2;text-wrap:balance;overflow-wrap:anywhere}
|
|
57
|
+
.hdo-by{font:12.5px/1.3 var(--hd-ui-mono,"JetBrains Mono",ui-monospace,monospace);color:#3d3b35}
|
|
58
|
+
.hdo-bar{width:100%;height:5px;border-radius:999px;background:rgba(11,11,15,.12);overflow:hidden}
|
|
59
|
+
.hdo-bar i{display:block;height:100%;width:8%;background:#0b0b0f;border-radius:999px;transition:width .35s ease-out}
|
|
60
|
+
.hdo-step{font:12px/1.3 var(--hd-ui-mono,"JetBrains Mono",ui-monospace,monospace);color:#55534b;min-height:1.3em}
|
|
61
|
+
@keyframes hdo-breathe{50%{filter:blur(2.6cqi) saturate(1.25) brightness(1.05)}}
|
|
62
|
+
@keyframes hdo-shim{0%{background-position:120% 0}100%{background-position:-120% 0}}
|
|
63
|
+
@keyframes hdo-deblur{to{filter:blur(0);transform:none}}
|
|
64
|
+
@keyframes hdo-hop{0%,100%{transform:translateY(0) scale(1.06,.92)}12%{transform:translateY(0) scale(.95,1.06)}45%{transform:translateY(-14px) scale(1,1)}70%{transform:translateY(0) scale(1.04,.95)}82%{transform:translateY(0) scale(1,1)}}
|
|
65
|
+
@keyframes hdo-shadow{0%,100%{transform:scale(1.05);opacity:.9}45%{transform:scale(.62);opacity:.45}70%{transform:scale(1);opacity:.85}}
|
|
66
|
+
@keyframes hdo-look{0%,18%{transform:rotate(0)}24%,38%{transform:rotate(-9deg) translateX(-2px)}44%,58%{transform:rotate(8deg) translateX(2px)}64%,100%{transform:rotate(0)}}
|
|
67
|
+
@keyframes hdo-tdot{0%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-2px)}}
|
|
68
|
+
@keyframes hdo-yay{0%{transform:translateY(0) scale(1.08,.9)}40%{transform:translateY(-26px) scale(.94,1.08) rotate(-6deg)}100%{transform:translateY(-6px) scale(1) rotate(0)}}
|
|
69
|
+
@keyframes hdo-spark{0%{opacity:0;transform:translate(0,0) scale(.4)}30%{opacity:1}100%{opacity:0;transform:translate(var(--x),var(--y)) scale(1.2) rotate(90deg)}}
|
|
70
|
+
:is([data-hd-frame],[data-layout="overlay"]) #hd-open .hdo-slide{inset:0;width:min(100vw,100dvh*16/9);height:min(100dvh,100vw*9/16)}
|
|
71
|
+
@media (max-width:650px){#hd-open .hdo-slide{inset:52px 8px calc(56px + env(safe-area-inset-bottom));width:auto;height:auto;border-radius:14px}.hdo-card{width:min(84vw,320px);padding:1em 1.1em .95em}.hdo-title{font-size:15.5px}.hdo-buddy{transform:scale(.85);margin-top:-58px}}
|
|
72
|
+
@media (prefers-reduced-motion:reduce){#hd-open *,#hd-open *::before,#hd-open *::after{animation:none!important;transition:none!important}#hd-open .hdo-slide::after{display:none}#hd-open.is-done .hdo-card{opacity:1;transform:none}}
|
|
73
|
+
@media print{#hd-open{display:none!important}}
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
// Runs the card `o` (#hd-open): shows it after FAST_MS unless the deck is
|
|
77
|
+
// ready, follows data-hd-boot, and on data-hd-ready jumps, fades and removes
|
|
78
|
+
// itself. In a frame, a thumbnail or an export it removes itself at once.
|
|
79
|
+
export const OPENING_RUN = `function(o){var b=document.body,d=document.documentElement;if(!o||!b)return;
|
|
80
|
+
if(self!==top||/[?&](thumb|export)\\b/.test(location.search)||d.hasAttribute("data-hyperdecks-thumb")){o.remove();return}
|
|
81
|
+
var S=${JSON.stringify(BOOT_STEPS)},bar=o.querySelector(".hdo-bar i"),step=o.querySelector(".hdo-step"),who=o.getAttribute("data-name")||"the presenter",at=0,shown=false,done=false,mo;
|
|
82
|
+
var rm=window.matchMedia&&matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
83
|
+
function boot(){var s=S[b.getAttribute("data-hd-boot")];if(!s||s[0]<=at)return;at=s[0];bar.style.width=s[0]+"%";step.textContent=s[1].split("{name}").join(who)}
|
|
84
|
+
function finish(){if(done)return;done=true;if(mo)mo.disconnect();clearTimeout(t1);clearTimeout(t2);
|
|
85
|
+
if(!shown){o.remove();return}bar.style.width="100%";step.textContent="Ready";
|
|
86
|
+
o.classList.add("is-done");setTimeout(function(){o.classList.add("is-gone")},rm?0:500);setTimeout(function(){o.remove()},rm?500:1000)}
|
|
87
|
+
function check(){if(b.hasAttribute("data-hd-ready"))finish();else boot()}
|
|
88
|
+
var t1=setTimeout(function(){if(!done){shown=true;o.classList.add("is-shown")}},${FAST_MS});
|
|
89
|
+
var t2=setTimeout(finish,${FAIL_OPEN_MS});
|
|
90
|
+
step.textContent=S.page[1];
|
|
91
|
+
if(window.MutationObserver){mo=new MutationObserver(check);mo.observe(b,{attributes:true,attributeFilter:["data-hd-ready","data-hd-boot"]})}
|
|
92
|
+
check()}`;
|
|
93
|
+
|
|
94
|
+
// Five sparks around the presenter for the jump at ready.
|
|
95
|
+
export const OPENING_SPARKS = [[-40, -30], [42, -34], [-30, 10], [36, 6], [0, -46]]
|
|
96
|
+
.map(([x, y]) => `<span class="hdo-spark" style="--x:${x}px;--y:${y}px"></span>`).join("");
|
|
97
|
+
// The presenter's still in the card (its src set by whoever builds it).
|
|
98
|
+
export const OPENING_BUDDY = `<div class="hdo-buddy"><div class="hdo-hop"><div class="hdo-look"><img src="" alt=""></div></div><div class="hdo-shadow"></div><div class="hdo-think"><i></i><i></i><i></i></div>${OPENING_SPARKS}</div>`;
|
|
99
|
+
|
|
100
|
+
// The page's own card (the loading block's <script data-hd-opening>): once the
|
|
101
|
+
// page is parsed (readyState "interactive", before the player module runs),
|
|
102
|
+
// unless a route already added a card, the player is ready, or the page is
|
|
103
|
+
// framed, a thumbnail or an export, it puts a card at the top of <body> with
|
|
104
|
+
// the page's <title> and the player tag's presenter still (none when it
|
|
105
|
+
// presents nobody), with no poster and no narrator's name, and runs it.
|
|
106
|
+
export const OPENING_PAGE_SCRIPT = `<script data-hd-opening>(function(){var d=document;
|
|
107
|
+
if(self!==top||/[?&](thumb|export)\\b/.test(location.search))return;
|
|
108
|
+
function go(){if(d.readyState==="loading")return;d.removeEventListener("readystatechange",go);
|
|
109
|
+
var b=d.body,p=d.querySelector('script[src*="/player/v"][src*="/player.js"]');
|
|
110
|
+
if(!b||!p||d.getElementById("hd-open")||b.hasAttribute("data-hd-ready")||d.documentElement.hasAttribute("data-hyperdecks-thumb"))return;
|
|
111
|
+
var o=d.createElement("div"),img=p.getAttribute("data-presenter")!=="none"&&p.getAttribute("data-presenter-image");
|
|
112
|
+
o.id="hd-open";o.setAttribute("aria-hidden","true");
|
|
113
|
+
o.innerHTML='<div class="hdo-slide"></div><div class="hdo-card">'+(img?${JSON.stringify(OPENING_BUDDY)}:'')+'<div class="hdo-title"></div><div class="hdo-bar"><i></i></div><div class="hdo-step"></div></div>';
|
|
114
|
+
if(img){var i=o.querySelector(".hdo-buddy img");i.addEventListener("error",function(){var y=o.querySelector(".hdo-buddy");if(y)y.remove()});i.src=img}
|
|
115
|
+
o.querySelector(".hdo-title").textContent=(d.title||"").replace(/\\s+/g," ").trim();
|
|
116
|
+
b.insertBefore(o,b.firstChild);(${OPENING_RUN})(o)}
|
|
117
|
+
d.addEventListener("readystatechange",go);go()})();</script>`;
|
package/src/ops.mjs
CHANGED
|
@@ -1359,6 +1359,112 @@ export async function setDeckNarrator(opts, slug, ref) {
|
|
|
1359
1359
|
return (await client(opts)).mutation(api.narrators.setDeckNarrator, { slug, narrator: value });
|
|
1360
1360
|
}
|
|
1361
1361
|
|
|
1362
|
+
// ---- deck transfers (convex/deckTransfers.ts) ----------------------------------
|
|
1363
|
+
|
|
1364
|
+
// The tool descriptions, shared by the stdio and remote MCP servers.
|
|
1365
|
+
export const TRANSFER_DECK_DESCRIPTION =
|
|
1366
|
+
"Give a deck to another Hyperdecks account, like transferring a GitHub repo: they get an email and accept within 7 days " +
|
|
1367
|
+
"(list_transfers, accept_transfer); until then nothing changes. Accepting moves the deck with its share links (links already sent " +
|
|
1368
|
+
"keep working), recipients and stats; you keep nothing. Only when the person asked for it, and confirm the email first. cancel: true takes the offer back.";
|
|
1369
|
+
export const LIST_TRANSFERS_DESCRIPTION =
|
|
1370
|
+
"Deck transfers waiting on the user (incoming: id, deck, sender, whether they must pick a narrator, and guests, numbered, with needsPick " +
|
|
1371
|
+
"when a guest speaks in the sender's own voice or face) and the ones they sent (outgoing).";
|
|
1372
|
+
export const ACCEPT_TRANSFER_DESCRIPTION =
|
|
1373
|
+
"Accept (or decline: true) a deck transfer from list_transfers. Ask the person first: who narrates it for them (list_narrators: one of " +
|
|
1374
|
+
"theirs, dash, none or default; required when needsNarrator), and a voice (list_voices) and avatar (list_avatars) for each guest with " +
|
|
1375
|
+
"needsPick. The sender's voice and face never move. Then their picks are narrated as them (narration.jobId; poll check_narration_job); " +
|
|
1376
|
+
"the deck keeps playing as released until they release_deck.";
|
|
1377
|
+
|
|
1378
|
+
// Offer a deck to the Hyperdecks account signed in with `to`. Nothing moves
|
|
1379
|
+
// until they accept; the offer lasts 7 days.
|
|
1380
|
+
export async function transferDeck(opts, slug, to) {
|
|
1381
|
+
if (!slug || !to) throw new Error("usage: deck transfer <slug> --to <email>");
|
|
1382
|
+
const out = await (await client(opts)).mutation(api.deckTransfers.create, { slug, to: String(to) });
|
|
1383
|
+
return { ...out, expiresAt: new Date(out.expiresAt).toISOString() };
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
export async function cancelTransfer(opts, slug) {
|
|
1387
|
+
if (!slug) throw new Error("usage: deck transfers cancel <slug>");
|
|
1388
|
+
return (await client(opts)).mutation(api.deckTransfers.cancel, { slug });
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Transfers waiting on you (incoming, numbered guests to pick for) and the
|
|
1392
|
+
// ones you sent (outgoing). A guest needs a pick when it speaks in the
|
|
1393
|
+
// sender's own voice or face (needsPick).
|
|
1394
|
+
export async function listTransfers(opts = {}) {
|
|
1395
|
+
const out = await (await client(opts)).query(api.deckTransfers.list, {});
|
|
1396
|
+
const when = (ms) => new Date(ms).toISOString();
|
|
1397
|
+
return {
|
|
1398
|
+
incoming: out.incoming.map((t) => ({
|
|
1399
|
+
id: t.id,
|
|
1400
|
+
slug: t.slug,
|
|
1401
|
+
title: t.title,
|
|
1402
|
+
from: t.from,
|
|
1403
|
+
expiresAt: when(t.expiresAt),
|
|
1404
|
+
needsNarrator: t.needsNarrator,
|
|
1405
|
+
guests: t.guests.map((g, i) => ({
|
|
1406
|
+
guest: i + 1,
|
|
1407
|
+
name: g.name,
|
|
1408
|
+
selector: g.selector,
|
|
1409
|
+
voice: g.voice ?? "(the sender's own voice)",
|
|
1410
|
+
avatar: g.personalAvatar ? "(the sender's own avatar)" : g.avatar,
|
|
1411
|
+
needsPick: g.personalVoice || g.personalAvatar,
|
|
1412
|
+
})),
|
|
1413
|
+
})),
|
|
1414
|
+
emailUnverified: !!out.emailUnverified,
|
|
1415
|
+
outgoing: out.outgoing.map((t) => ({ id: t.id, slug: t.slug, title: t.title, to: t.to, expiresAt: when(t.expiresAt) })),
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Accept an incoming transfer. narrator: one of yours (name or id), dash, none
|
|
1420
|
+
// or default; required when the deck narrates in the sender's narrator or
|
|
1421
|
+
// voice. guests: [{ guest, voice, avatar? }], guest numbers from
|
|
1422
|
+
// listTransfers, one for each guest that needsPick. Then, unless
|
|
1423
|
+
// renarrate is false, narrates the rewritten draft as you (only the
|
|
1424
|
+
// re-picked lines render; the rest is cached). The live deck plays as
|
|
1425
|
+
// released until you release it.
|
|
1426
|
+
export async function acceptTransfer(opts, id, picks = {}) {
|
|
1427
|
+
const { narrator, guests, renarrate = true } = picks;
|
|
1428
|
+
if (!id) throw new Error("usage: deck transfers accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]");
|
|
1429
|
+
const c = await client(opts);
|
|
1430
|
+
// Guest numbers are positions in list's guests; only then is the list needed.
|
|
1431
|
+
let listed = null;
|
|
1432
|
+
if (guests?.length) {
|
|
1433
|
+
const all = await c.query(api.deckTransfers.list, {});
|
|
1434
|
+
listed = all.incoming.find((t) => t.id === id);
|
|
1435
|
+
if (!listed) throw new Error(`no transfer ${id} is waiting for you${all.emailUnverified ? " (your account's email is not verified)" : ""} (see deck transfers)`);
|
|
1436
|
+
}
|
|
1437
|
+
const speakers = (guests ?? []).map((p) => {
|
|
1438
|
+
const g = listed.guests[Number(p.guest) - 1];
|
|
1439
|
+
if (!g) throw new Error(`no guest ${p.guest} in ${listed.slug} (guests: 1..${listed.guests.length})`);
|
|
1440
|
+
if (!p.voice) throw new Error(`guest ${p.guest}: name a voice`);
|
|
1441
|
+
return { key: g.key, voice: String(p.voice), ...(p.avatar ? { avatar: String(p.avatar) } : {}) };
|
|
1442
|
+
});
|
|
1443
|
+
const narratorArg = narrator === undefined || narrator === null || narrator === "" ? undefined
|
|
1444
|
+
: ["none", "default", "dash"].includes(String(narrator).toLowerCase()) ? String(narrator).toLowerCase()
|
|
1445
|
+
: (await findNarrator(opts, narrator)).id;
|
|
1446
|
+
const out = await c.mutation(api.deckTransfers.accept, { id, ...(narratorArg !== undefined ? { narrator: narratorArg } : {}), speakers });
|
|
1447
|
+
const result = { ...out, narration: null };
|
|
1448
|
+
if (out.renarrate && renarrate) {
|
|
1449
|
+
try {
|
|
1450
|
+
const job = await narrateDeck(opts, { slug: out.slug, fromSource: true });
|
|
1451
|
+
result.narration = { jobId: job.jobId, note: "narrating the re-picked lines; then release the deck to put them on its links" };
|
|
1452
|
+
} catch (e) {
|
|
1453
|
+
result.narration = { error: errorText(e), note: "the deck is yours; narrate it again when this is fixed, then release" };
|
|
1454
|
+
}
|
|
1455
|
+
} else if (out.renarrate) {
|
|
1456
|
+
result.narration = { note: "narrate the deck, then release it, to put your voice and face on its links" };
|
|
1457
|
+
} else if (!out.hosted) {
|
|
1458
|
+
result.narration = { note: "this deck has no hosted script, so nothing was re-narrated" };
|
|
1459
|
+
}
|
|
1460
|
+
return result;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
export async function declineTransfer(opts, id) {
|
|
1464
|
+
if (!id) throw new Error("usage: deck transfers decline <id>");
|
|
1465
|
+
return (await client(opts)).mutation(api.deckTransfers.decline, { id });
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1362
1468
|
// The account's brand (convex/brand.ts): what new_deck / deck new start from.
|
|
1363
1469
|
export async function getBrand(opts = {}) {
|
|
1364
1470
|
return (await client(opts)).query(api.brand.get, {});
|
package/src/play.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withAvatarView } from "./avatar-view.mjs";
|
|
1
2
|
// hyperdecks play <slug|deck-dir> [--voice v] [--slug s] [--port n] [--no-open] [--json]
|
|
2
3
|
//
|
|
3
4
|
// Plays the working folder as it is, committed or not, the way it will play once
|
|
@@ -14,7 +15,7 @@ import { resolveDeckDir } from "./check.mjs";
|
|
|
14
15
|
import * as ops from "./ops.mjs";
|
|
15
16
|
import { playsCaptionsOnly, presenterLine, withNarrator } from "./presenter.mjs";
|
|
16
17
|
import { serveSource } from "./preview-capture.mjs";
|
|
17
|
-
import { openBrowser } from "./runtime.mjs";
|
|
18
|
+
import { openBrowser, client } from "./runtime.mjs";
|
|
18
19
|
|
|
19
20
|
const USAGE = "usage: play <slug|deck-dir> [--voice <name|id>] [--slug <slug>] [--port <n>] [--no-open]";
|
|
20
21
|
|
|
@@ -88,7 +89,8 @@ export async function startPlay(pos, opts = {}, { log = (line) => console.log(li
|
|
|
88
89
|
const html = readFileSync(file, "utf8");
|
|
89
90
|
|
|
90
91
|
const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
|
|
91
|
-
|
|
92
|
+
let page = withNarrator(html, presenter, who);
|
|
93
|
+
page = await withAvatarView({ mutation: async (ref, args) => (await client(opts)).mutation(ref, args) }, slug, page, "draft");
|
|
92
94
|
sayWhoPresents({ html, page, presenter, who, log });
|
|
93
95
|
// --voice names the voice it was narrated in, so there is nothing to compare it to.
|
|
94
96
|
if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });
|
package/src/preview-capture.mjs
CHANGED
|
@@ -255,11 +255,36 @@ async function encode(ffmpeg, args) {
|
|
|
255
255
|
try { await run(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-nostdin', ...args], { timeout: 90000, maxBuffer: 1024 * 1024, env: { PATH: dirname(ffmpeg), ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}) } }); }
|
|
256
256
|
catch (e) { throw new Error(`FFmpeg encoding failed (${e.killed ? '90s timeout' : 'check portable encoder installation'}); no global FFmpeg is used`); }
|
|
257
257
|
}
|
|
258
|
+
// The numbered frames in `dir` (frame-0000.png…) as the preview's PNG poster
|
|
259
|
+
// (the first frame), looping GIF and silent MP4 in `out`.
|
|
260
|
+
async function encodeOutputs(ffmpeg, { dir, out, width, height, fps, duration }) {
|
|
261
|
+
const input = ['-framerate', String(fps), '-i', join(dir, 'frame-%04d.png')];
|
|
262
|
+
const paths = Object.fromEntries(['png','gif','mp4'].map(t => [t, resolve(out, `preview.${t}`)]));
|
|
263
|
+
await encode(ffmpeg, ['-i', join(dir,'frame-0000.png'), '-vf', `scale=${width}:${height}`, '-frames:v','1', paths.png]);
|
|
264
|
+
await encode(ffmpeg, [...input, '-filter_complex', `scale=${width}:${height}:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse`, '-loop','0', paths.gif]);
|
|
265
|
+
await encode(ffmpeg, [...input, '-vf',`scale=${width}:${height}`, '-an','-c:v','libx264','-pix_fmt','yuv420p','-movflags','+faststart',paths.mp4]);
|
|
266
|
+
return Promise.all(['gif','png','mp4'].map(async type => ({ type, path: paths[type], contentType: type === 'mp4' ? 'video/mp4' : `image/${type}`, bytes: (await stat(paths[type])).size, width, height, ...(type === 'png' ? {} : { duration, fps, silent:true, ...(type === 'gif' ? { loop:0 } : {}) }) })));
|
|
267
|
+
}
|
|
268
|
+
// Even output dimensions for `width` at `aspect`, as the encoders need them.
|
|
269
|
+
const outputSize = (width, aspect) => { const w = width % 2 ? width + 1 : width; return { width: w, height: Math.round(w / aspect / 2) * 2 }; };
|
|
270
|
+
|
|
271
|
+
async function captureMoment(config, chromium, ffmpeg) {
|
|
272
|
+
const { recordMoment, withTempDir } = await import('./preview-moment.mjs');
|
|
273
|
+
const fps = 10, frames = Math.round(config.duration * fps);
|
|
274
|
+
return withTempDir(async dir => {
|
|
275
|
+
const recorded = await recordMoment({ ...config, frames, fps, playerDir: config.playerDir ?? process.env.HYPERDECKS_PLAYER_DIR ?? null }, { chromium, serveSource, dir });
|
|
276
|
+
const files = await encodeOutputs(ffmpeg, { dir, out: config.out, ...outputSize(config.width, recorded.aspect), fps, duration: frames / fps });
|
|
277
|
+
return { files, warnings: recorded.warnings, moment: recorded.moment };
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
258
281
|
export async function capturePreview(config) {
|
|
259
282
|
const { chromium } = await import('playwright');
|
|
260
283
|
let ffmpeg;
|
|
261
284
|
try { ffmpeg = (await import('@ffmpeg-installer/ffmpeg')).default.path; await run(ffmpeg, ['-version'], { timeout:10000 }); }
|
|
262
285
|
catch { throw new Error('Portable FFmpeg missing. Reinstall the CLI with optional dependencies and install scripts enabled (`npm install -g hyperdecks --include=optional`); pnpm users must approve @ffmpeg-installer platform build scripts.'); }
|
|
286
|
+
// --scene / --start: one moment of the narration, on the page clock (preview-moment.mjs).
|
|
287
|
+
if (config.scene != null || config.start != null) return captureMoment(config, chromium, ffmpeg);
|
|
263
288
|
let browser, server, temp, deadline, timedOut = false, phase = 'launch';
|
|
264
289
|
const warnings = [];
|
|
265
290
|
try {
|
|
@@ -309,8 +334,7 @@ export async function capturePreview(config) {
|
|
|
309
334
|
else if (busy > 1) warnings.push(`The deck kept its main thread busy for ${busy.toFixed(1)}s; script-driven motion may look choppy.`);
|
|
310
335
|
if (!config.aspect && !detected) warnings.push('Using 16:9 aspect ratio; use --aspect-ratio W:H or preview-aspect-ratio metadata for a different deck shape.');
|
|
311
336
|
const aspect = config.aspect ?? (detected >= 0.5 && detected <= 3 ? detected : 16/9);
|
|
312
|
-
const
|
|
313
|
-
const height = Math.round(width / aspect / 2) * 2;
|
|
337
|
+
const { width, height } = outputSize(config.width, aspect);
|
|
314
338
|
await page.setViewportSize({ width:1280, height:Math.round(1280/aspect) });
|
|
315
339
|
const fps = 10, frames = Math.round(config.duration * fps), duration = frames/fps;
|
|
316
340
|
// Screencast captures the live WebGL compositor and CSS motion without
|
|
@@ -339,14 +363,9 @@ export async function capturePreview(config) {
|
|
|
339
363
|
await context.close();
|
|
340
364
|
clearTimeout(deadline);
|
|
341
365
|
phase = 'encode';
|
|
342
|
-
const
|
|
343
|
-
const paths = Object.fromEntries(['png','gif','mp4'].map(t => [t, resolve(config.out, `preview.${t}`)]));
|
|
344
|
-
await encode(ffmpeg, ['-i', join(temp,'frame-0000.png'), '-vf', `scale=${width}:${height}`, '-frames:v','1', paths.png]);
|
|
345
|
-
await encode(ffmpeg, [...input, '-filter_complex', `scale=${width}:${height}:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse`, '-loop','0', paths.gif]);
|
|
346
|
-
await encode(ffmpeg, [...input, '-vf',`scale=${width}:${height}`, '-an','-c:v','libx264','-pix_fmt','yuv420p','-movflags','+faststart',paths.mp4]);
|
|
366
|
+
const files = await encodeOutputs(ffmpeg, { dir: temp, out: config.out, width, height, fps, duration });
|
|
347
367
|
if (blocked) warnings.push(`${blocked} navigation/telemetry/unsafe request(s) blocked; no recipient page was visited.`);
|
|
348
368
|
if (failed) warnings.push(`${failed} page/resource error(s); inspect the poster and increase --delay if necessary.`);
|
|
349
|
-
const files = await Promise.all(['gif','png','mp4'].map(async type => ({ type, path: paths[type], contentType: type === 'mp4' ? 'video/mp4' : `image/${type}`, bytes: (await stat(paths[type])).size, width, height, ...(type === 'png' ? {} : { duration, fps, silent:true, ...(type === 'gif' ? { loop:0 } : {}) }) })));
|
|
350
369
|
return { files, warnings };
|
|
351
370
|
} catch (e) { throw new Error(`Capture failed during ${phase}: ${timedOut ? '60s capture timeout; the renderer did not respond' : /Timeout/i.test(e.name) ? 'bounded capture timeout; inspect the deck or increase --delay' : e.message}`); }
|
|
352
371
|
finally {
|