@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.
@@ -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
- const name = new URL(route.request().url()).pathname.replace(/^.*\/player\/v1\//, '');
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
- const server = await serveSource({ html: deck.html });
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, in a sensible deck order; `--layouts` takes any order and repeats.
8
- export const LAYOUTS = ["hero", "number", "product", "split", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -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 a quiet loader in --hd-ground-ink; then the",
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
- // The block's pieces besides its style, on any player host: the two preloads and
53
- // the frame flag (each kept once), and its own comment (matched only as a whole
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
- const deduped = trimPieces(rawHead, 1);
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: deduped !== rawHead ? "deduped" : "refreshed" };
117
+ return { html: splice(fresh), fixed: trimPieces(rawHead, 1) !== rawHead ? "deduped" : "refreshed" };
103
118
  }
@@ -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
- const page = withNarrator(html, presenter, who);
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 });
@@ -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 width = config.width % 2 ? config.width + 1 : config.width;
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 input = ['-framerate', String(fps), '-i', join(temp, 'frame-%04d.png')];
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 {