@hypersocial/decks-cli 0.9.7 → 0.9.9

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 CHANGED
@@ -64,7 +64,7 @@ share list <slug> [--json]
64
64
  share on|off <token>
65
65
  share mute|unmute <token>
66
66
  share rm <token>
67
- share url <token>
67
+ share url <token> [--backup]
68
68
 
69
69
  recipient archive|unarchive <name>
70
70
  import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live|archived]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -149,7 +149,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
149
149
  | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
150
150
  | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
151
151
  | Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
152
- | Slides or scroll, viewer toggle | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off]` | `set_deck_view` |
152
+ | Slides or scroll, viewer toggle, pace, AI-readable version | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default] [--ai-readable on\|off]` | `set_deck_view` |
153
153
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
154
154
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
155
155
  | What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
@@ -169,7 +169,15 @@ HTML says. Viewers get a slides/scroll toggle in the player, remembered per
169
169
  link, unless you turn it off (`--toggle off`, `viewToggle: false`). A viewer
170
170
  who already switched keeps their own choice while the toggle is on; turn the
171
171
  toggle off to make every viewer see the deck's view.
172
- `deck list --json` / `list_decks` and `deck stats` / `deck_stats` show both.
172
+ Pace is the third: `--pace auto` (`pace: "auto"`, the default) plays itself,
173
+ `slide` waits for Next (the Continue pill, →, a swipe) after each slide's
174
+ narration, and `step` also stops the narration before each `[reveal]` cue,
175
+ so a slide's reveals are the steps the viewer clicks through: group what
176
+ belongs together at one word, so each stop is a meaningful step. Viewers can
177
+ switch pace (and slides/scroll) in the player's Preferences, in its top bar
178
+ (remembered per link); a video export always plays straight through. Pace
179
+ waits apply side to side (slides) only: scroll keeps its own flow.
180
+ `deck list --json` / `list_decks` and `deck stats` / `deck_stats` show all three.
173
181
 
174
182
  ### 1. Ground it in the user's own material
175
183
 
@@ -258,6 +266,18 @@ The deck contract (`check` enforces or warns on each line):
258
266
  <!-- topic: presenter -->
259
267
  #### Which presenter style
260
268
 
269
+ **Who presents is set at release; leave the tag alone.** `deck new --narrated`
270
+ writes `data-presenter="none"` on the player tag, and `release` (and
271
+ `release_deck`) puts the narrator's face on the copy it publishes: your own
272
+ narrator's avatar, or D.A.S.H.'s live head for a deck in D.A.S.H.'s voice. It
273
+ says `presented by …` only when the published page has that face; otherwise
274
+ it warns `releasing captions only` with the reason. To pick the face by hand,
275
+ set both attributes: `data-presenter="head" data-avatar="…"`. D.A.S.H.'s is
276
+ `data-presenter="head" data-avatar="https://hyperdecks.ai/player/v1/dash-avatar.json"`
277
+ (it loads from any host). `data-presenter="head"` with no `data-avatar` plays
278
+ captions only, and so does a `data-avatar` beside `data-presenter="none"`.
279
+ `check` warns on both, and `release` leaves a hand-set `data-avatar` alone.
280
+
261
281
  Set the deck's on the player tag (`data-presenter-style="stage"`) or in
262
282
  script.md's front matter (`presenterStyle: stage`); override one slide with
263
283
  `data-presenter-style` on its `<section>` or `presenter-style: corner` under
@@ -596,6 +616,20 @@ with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
596
616
  `timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
597
617
  one action, stickers again.
598
618
 
619
+ **No presenter.** When nothing stands in the corner, no face (presenter
620
+ `none`) *and* no captions (`data-silent`, or an export with captions off), or
621
+ when a host calls `window.hyperdecks.setPresenterOff(true)`, the player puts
622
+ `hd-no-presenter` on `<html>` and hides every `.narration-slot`. Each layout
623
+ has a variant for that in its own `<style>` (`deck_guide layouts` says what
624
+ each does): compare and chart widen; features, team and timeline centre
625
+ vertically; split, product and phones centre their copy; number, quote,
626
+ closer and a hero without art re-centre. Type sizes stay. A deck with
627
+ captions keeps the slot (the bubble lives there). For a custom slide, keep it
628
+ compatible: the presenter's corner is the slide's `.narration-slot`, never a
629
+ hard-coded gutter (`padding-right: 400px`); if it needs one, override it in a
630
+ `:root.hd-no-presenter .l-yours …` rule (check warns on a right gutter until
631
+ you do).
632
+
599
633
  <!-- topic: script -->
600
634
  ### 3. Write slides and script
601
635
 
@@ -658,6 +692,43 @@ one action, stickers again.
658
692
  | `[smile]` / `[think]` | `[mood: happy]` / `[mood: thinking]` |
659
693
  | `[gesture: <id>]` | one of the head's tracks: `knowing-nod`, `ponder-tilt`, `lightbulb`, `side-eye-hmm`, `chuckle-bob`, `sunny-nod`, `double-take`… (check lists them all) |
660
694
  | `[custom: name]` | only the slide's controller sees it |
695
+ | `[after: reveal #id]` | any stage cue above, fired at the END of the word before it (reveal at the end of a phrase) instead of its start |
696
+ - Guest speakers: other avatars that speak their own lines on a slide, no
697
+ slide JS. A `[speaker: <selector> voice=<voice> avatar=<face> name=<caption name>]`
698
+ line runs to the end of its paragraph; the narrator takes the next one:
699
+
700
+ ```
701
+ ## own
702
+ Let's hear them.
703
+
704
+ [speaker: #own-c1 voice=stock:sarah avatar=cat] Ooh, nice move!
705
+
706
+ [speaker: #own-c2 voice=matilda avatar=monkey name="Mono"] [reveal: #own-c2] Hola, ¿qué tal?
707
+
708
+ That's the idea.
709
+ ```
710
+
711
+ `voice` is one of the user's voices, a built-in `stock:<id>` or a character
712
+ voice. `avatar` is a cast id (`cat`, `kitty`, `monkey`, `dog`, `rabbit`,
713
+ `penguin`, `ghost`, `alien`, `agent`…), one of the user's avatars or a
714
+ narrator's name; without one the guest speaks with captions only. `name`
715
+ labels its captions (default: the avatar, capitalised). The guest's head
716
+ fills the element the selector names, or the `[data-hd-speaker]` spot
717
+ inside it, so give each card a face box (e.g. 140×140). Narrate renders
718
+ each line in its own voice (cached, transcript-checked) into the slide's
719
+ timeline: the narrator pauses, the guests speak in turn with lip-sync and a
720
+ caption bubble by their element, its tail at their face, the spoken word
721
+ highlighted ("Cat: Ooh, nice move!"), only while they talk; the speaker's
722
+ element is spotlit and the other guests dim. The player draws that bubble:
723
+ don't put static speech bubbles or the guest's line on the slide too. The presenter
724
+ glances at whoever speaks, then the narrator goes on. A cue may open a
725
+ guest's line and fires as it starts. Inside a guest's line, `[nod]`,
726
+ `[shake]`, `[brow]` and `[gesture]` move the guest's head; the other cues
727
+ (`[reveal]`, `[go]`, `[mood]`, `[wave]`…) act on the slide and the presenter. Replay, Per step (each guest line is a
728
+ step), mute, You present and video export all include them.
729
+ - Audio and data files a slide uses itself (a sound effect, a chart's JSON):
730
+ `hyperdecks asset add` / `upload_deck_asset` take `.mp3`, `.m4a` and
731
+ `.json` as well as images, video and fonts.
661
732
  - `voice: dash` narrates with D.A.S.H.'s own robot voice, a synthetic
662
733
  character voice (not a clone of anyone) any account may use, for decks that
663
734
  D.A.S.H. presents. It works once the deployment sets `CHARACTER_VOICE_DASH`.
@@ -833,8 +904,10 @@ hyperdecks engagement "<name>" --deck <slug> --json # MCP: recipient_engagemen
833
904
  Then draft the email:
834
905
 
835
906
  1. Lead with the slide they spent most time on (`summary.mostTime`) or
836
- replayed (`replayedSlides`), and answer what that slide raises. Each
837
- slide's `title` says what it is about.
907
+ replayed (`replayedSlides`; `slideReplays` are the ones they replayed on
908
+ purpose, most first, and `replayNote` says it in one line), and answer what
909
+ that slide raises. Each slide's `title` says what it is about. Watching it
910
+ again from the start (`watchedAgain`) is strong interest.
838
911
  2. If they dropped off (`droppedOffOn`), offer the rest briefly, or a
839
912
  2-minute call, rather than asking them to rewatch.
840
913
  3. If they never opened it (`summary.visits` is 0), write a short nudge
@@ -855,7 +928,7 @@ the hosted MCP (`deck_guide`, `new_deck`, `get_deck_source`, `write_deck_source`
855
928
  `upload_deck_asset`, `check_deck`, `release_deck`; see `chat.md`). Move between
856
929
  the two with `hyperdecks source pull <slug> [dir]` (the hosted draft into a
857
930
  folder) and `hyperdecks source push <slug> [dir]` (a folder's files back to the
858
- draft), `hyperdecks asset add <slug> <file|url>` for an image, `hyperdecks guide
931
+ draft), `hyperdecks asset add <slug> <file|url>` for an image, video, audio (.mp3, .m4a), font or JSON file, `hyperdecks guide
859
932
  [topic]` for these sections, and `--hosted` on `deck new`, `check` and `release`.
860
933
 
861
934
  ## After you ship: say what cost you
@@ -15,7 +15,9 @@ narrators, credits) and the steps. No voice of their own: `clone_voice_link`
15
15
  in the chat, only after they agree to the consent statement, word for word.
16
16
  `hear_voice` plays a voice or a narrator in the chat. A face from a photo they
17
17
  attach: `avatar_from_photo { file }`, then `check_avatar_draft` until it hands
18
- back the link they approve it on. `rename_narrator` and `set_narrator_face`
18
+ back the link they approve it on (a pet photo comes back with an `offer`, say
19
+ "That looks like a cat. Make a cat avatar?": ask them, and on a yes
20
+ `new_avatar_link { species }`). `rename_narrator` and `set_narrator_face`
19
21
  change a narrator when they ask. Then `get_started { name, about, ask, voice }`
20
22
  makes the hello deck; continue from step 5 below.
21
23
 
@@ -91,9 +93,15 @@ makes the hello deck; continue from step 5 below.
91
93
  10. Later: `list_shares` and `deck_stats` (both show the card),
92
94
  `activity_overview`; `email_embed { token }` for the HTML that pastes into an
93
95
  email as the deck's moving preview (it needs a recipient's link, not a QA
94
- one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle }`
95
- for how the deck opens and whether viewers may switch (every link, next
96
- load, no re-release); `set_share_notify` to mute a link's "opened" alerts;
96
+ one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle, pace }`
97
+ for how the deck opens, whether viewers may switch, and its pace (`auto`
98
+ plays itself, `slide` waits for Next after each slide, `step` also stops
99
+ before each `[reveal]`, so group a slide's reveals into meaningful steps)
100
+ (every link, next load, no re-release). Any share link + `/md` (e.g.
101
+ https://decks.hypersocial.ai/v/<token>/md) returns the deck as markdown
102
+ (slide text, image descriptions and the narration), honouring the link's
103
+ gates; owners can turn it off with `set_deck_view { slug, aiReadable: false }`.
104
+ `set_share_notify` to mute a link's "opened" alerts;
97
105
  `set_recipient_archived` to tidy the activity feed; `credits` for the
98
106
  balance. `remove_deck` deletes a deck and breaks every link to it: confirm
99
107
  with the user first.
package/src/avatar.mjs CHANGED
@@ -59,7 +59,7 @@ export async function avatarNew(opts, { ask, log = console.log, interactive = in
59
59
 
60
60
  // A face from a photo file, or (no file) a selfie in the browser. Either way the
61
61
  // person approves it on the link before it is used.
62
- export async function avatarFromPhoto(file, opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
62
+ export async function avatarFromPhoto(file, opts, { ask, log = console.log, interactive = interactiveOf(opts) } = {}) {
63
63
  let photo = {};
64
64
  if (file) {
65
65
  const contentType = PHOTO_TYPES[extname(file).toLowerCase()];
@@ -67,8 +67,19 @@ export async function avatarFromPhoto(file, opts, { log = console.log, interacti
67
67
  photo = { bytes: readFileSync(file), contentType };
68
68
  }
69
69
  const out = await ops.avatarFromPhoto(opts, photo, { onProgress: (m) => !opts.json && log(m) });
70
- const url = out.reviewUrl ?? out.selfieUrl;
71
70
  if (opts.json) return out;
71
+ if (out.status === "failed" && out.suggestedSpecies) {
72
+ // No human face, but a pet: offer that species' faces instead.
73
+ log(out.offer);
74
+ if (!interactive || !ask) {
75
+ log(` hyperdecks avatar new --species ${out.suggestedSpecies}`);
76
+ return out;
77
+ }
78
+ const a = (await ask("Make one? [Y/n]: ")).trim().toLowerCase();
79
+ if (a && a !== "y" && a !== "yes") throw new Error(out.error || "no face was found in that photo");
80
+ return avatarNew({ ...opts, species: out.suggestedSpecies }, { ask, log, interactive });
81
+ }
82
+ const url = out.reviewUrl ?? out.selfieUrl;
72
83
  log(file ? `✓ Drew a cartoon of you. Check it before it's used:\n ${url}` : `No photo given. Take a selfie in your browser:\n ${url}`);
73
84
  if (opts["save-to-minimoji"]) log(" (tick \"Also save it to my Minimoji account\" there)");
74
85
  if (!interactive) return out;
@@ -5,6 +5,8 @@
5
5
  // the deck's index.html and script.md as text, with the manifest and clip fetches
6
6
  // injected.
7
7
 
8
+ import { DASH_AVATAR, presenterTagIssue } from "./presenter.mjs";
9
+
8
10
  export const PLAYER_V1 = "https://hyperdecks.ai/player/v1/player.js";
9
11
  // Decks released before the app moved to hyperdecks.ai load the same player
10
12
  // from the old host, which serves it forever; both pass.
@@ -35,6 +37,7 @@ export const MESSAGES = {
35
37
  noLoading: () => "no loading state: run `hyperdecks check <deck> --fix` (`check <slug> --hosted --fix`; over the MCP, write index.html again with write_deck_source), which needs a <head>…</head> and the player script; or copy the <style data-hd-loading> block and the lines above it from the narrated template into <head>; set --hd-ground to the first slide's colour",
36
38
  clipField: (id, field) => `clip ${id}: ${field}`,
37
39
  noCaptions: (id) => `clip ${id}: timeline has no captions`,
40
+ speakerMissing: (id, name, selector) => `slide ${id}: guest ${name} speaks from "${selector}", which is not on the slide, so its lines play with captions only and no face`,
38
41
  order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
39
42
  total: (sum, declared) => `clip durations plus holds add up to ${sum}s but the manifest declares totalDuration ${declared}s`,
40
43
  cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
@@ -58,6 +61,8 @@ export const MESSAGES = {
58
61
  selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
59
62
  gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
60
63
  gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
64
+ presenterUnfilled: (mode) => `the player tag says data-presenter="${mode}" but names no face (${mode === "head" ? "data-avatar" : "data-presenter-image"}), so as written it plays captions only. release puts the narrator's face on it; to set one by hand, D.A.S.H. is data-presenter="head" data-avatar="${DASH_AVATAR}" (deck_guide topic presenter)`,
65
+ presenterKept: (mode) => `the player tag has a data-avatar but data-presenter="${mode ?? "none"}", so it plays captions only and release leaves it that way: set data-presenter="head" to show that face, or remove data-avatar so release adds the narrator's`,
61
66
  presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
62
67
  presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
63
68
  transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
@@ -67,6 +72,7 @@ export const MESSAGES = {
67
72
  flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
68
73
  // Warnings from the look topic (deck_guide look): the deck passes, but a person should look.
69
74
  fewPictures: (shown, total) => `only ${shown} of ${total} slides show a picture (a screenshot, a recording, a photo, a chart); aim for a real one on every other slide (deck_guide topic look)`,
75
+ presenterGutter: (where, decl) => `${where}: ${decl} keeps a right gutter for the presenter; leave that corner to the slide's .narration-slot so a deck without a presenter can use it (deck_guide topic look)`,
70
76
  mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
71
77
  // A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
72
78
  placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
@@ -600,6 +606,23 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
600
606
  const isNum = (v) => typeof v === "number" && Number.isFinite(v);
601
607
  const isStr = (v) => typeof v === "string" && v.trim().length > 0;
602
608
 
609
+ // Whether a guest's selector names something on its slide: an #id, or a
610
+ // .class or [attr] inside that <section>. Other selectors are taken on trust.
611
+ export function speakerOnSlide(html, slideId, selector) {
612
+ const page = uncommented(html);
613
+ const open = new RegExp(`<section\\b[^>]*\\sid\\s*=\\s*["']${slideId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'][^>]*>`, "i").exec(page);
614
+ if (!open) return false;
615
+ const end = page.indexOf("</section>", open.index);
616
+ const section = page.slice(open.index, end < 0 ? undefined : end);
617
+ const id = /^#([\w-]+)$/.exec(selector.trim());
618
+ if (id) return new RegExp(`\\sid\\s*=\\s*["']${id[1]}["']`).test(section);
619
+ const cls = /^\.([\w-]+)$/.exec(selector.trim());
620
+ if (cls) return new RegExp(`\\sclass\\s*=\\s*["'][^"']*\\b${cls[1]}\\b`).test(section);
621
+ const attr = /^\[([\w-]+)/.exec(selector.trim());
622
+ if (attr) return new RegExp(`\\s${attr[1]}(\\s*=|[\\s>])`).test(section);
623
+ return true;
624
+ }
625
+
603
626
  // Fields every clip needs before its other rules mean anything.
604
627
  export function clipFieldErrors(clip, i) {
605
628
  const id = isStr(clip?.id) ? clip.id : `#${i + 1}`;
@@ -790,6 +813,68 @@ export function lookWarnings(html) {
790
813
  return out;
791
814
  }
792
815
 
816
+ // A wide right padding or margin (PRESENTER_GUTTER px or more, and wider than
817
+ // the left: a centred column is not a gutter) is a gutter held for the
818
+ // presenter outside its .narration-slot: without a presenter
819
+ // (:root.hd-no-presenter) it stays empty. Read from inline styles on a slide
820
+ // and its elements, and from the deck's <style> rules (by selector). Skipped:
821
+ // rules in a phone media query (max-width, no min-width), in @keyframes and
822
+ // the like, on the slot itself, the no-presenter rules, and a selector whose
823
+ // gutter a :root.hd-no-presenter rule overrides (the variant the guide asks for).
824
+ // One linear pass over the text: a malformed paste must not stall check.
825
+ export const PRESENTER_GUTTER = 240;
826
+ const sides = (prop, value) => {
827
+ const px = (v) => { const m = /^(\d+(?:\.\d+)?)(px|rem|em)$/i.exec(v ?? ""); return m ? Number(m[1]) * (m[2].toLowerCase() === "px" ? 1 : 16) : 0; };
828
+ const parts = value.trim().split(/\s+/);
829
+ if (/-(right|inline-end)$/i.test(prop)) return { right: px(parts[0]), left: 0 };
830
+ return { right: px(parts[1] ?? parts[0]), left: px(parts[3] ?? parts[1] ?? parts[0]) };
831
+ };
832
+ const gutters = (decls) => decls.split(";").map((d) => /^\s*((?:padding|margin)(?:-right|-inline-end)?)\s*:\s*([^!]+)/i.exec(d)).filter(Boolean)
833
+ .filter(([, prop, value]) => { const { right, left } = sides(prop, value); return right >= PRESENTER_GUTTER && right > left; })
834
+ .map(([, prop, value]) => `${prop.toLowerCase()}: ${value.trim()}`);
835
+ // Each style rule of a stylesheet: { selector, decls, at } where `at` lists the
836
+ // @-rule preludes around it. Comments drop out; braces nest.
837
+ function cssRules(css) {
838
+ const out = [], stack = [];
839
+ let start = 0, i = 0;
840
+ let text = "";
841
+ while (i < css.length) {
842
+ if (css.startsWith("/*", i)) { const end = css.indexOf("*/", i + 2); text += css.slice(start, i); i = start = end === -1 ? css.length : end + 2; continue; }
843
+ const ch = css[i];
844
+ if (ch === "{" || ch === "}") {
845
+ text += css.slice(start, i);
846
+ if (ch === "{") stack.push(text.trim());
847
+ else {
848
+ const head = stack.pop();
849
+ if (head !== undefined && !head.startsWith("@")) out.push({ selector: head, decls: text, at: stack.filter((h) => h.startsWith("@")) });
850
+ }
851
+ text = "";
852
+ start = i + 1;
853
+ }
854
+ i++;
855
+ }
856
+ return out;
857
+ }
858
+ const phoneOnly = (at) => at.some((h) => /^@media\b/i.test(h) && /max-width/i.test(h) && !/min-width/i.test(h));
859
+ const NO_PRESENTER = /^:root\.hd-no-presenter\s+/;
860
+ export function presenterGutterWarnings(html) {
861
+ const out = [];
862
+ for (const [i, s] of slides(html).entries()) {
863
+ const where = `slide ${s.id || i + 1}`;
864
+ for (const [, value] of `${s.tag}\n${uncommented(s.body)}`.matchAll(/\sstyle\s*=\s*("[^"]*"|'[^']*')/gi)) {
865
+ for (const decl of gutters(value.slice(1, -1))) out.push(MESSAGES.presenterGutter(where, decl));
866
+ }
867
+ }
868
+ const rules = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].flatMap(([, css]) => cssRules(css));
869
+ const overridden = new Set(rules.filter((r) => NO_PRESENTER.test(r.selector) && /(padding|margin)/i.test(r.decls)).map((r) => r.selector.replace(NO_PRESENTER, "")));
870
+ for (const r of rules) {
871
+ if (r.at.some((h) => !/^@(media|supports|layer|container)\b/i.test(h)) || phoneOnly(r.at)) continue;
872
+ if (NO_PRESENTER.test(r.selector) || /\.narration-slot\b/.test(r.selector) || overridden.has(r.selector)) continue;
873
+ for (const decl of gutters(r.decls)) out.push(MESSAGES.presenterGutter(`rule ${r.selector}`, decl));
874
+ }
875
+ return out;
876
+ }
877
+
793
878
  // Every rule on the deck's text. `exists(ref)` answers whether a local reference
794
879
  // resolves (true, false or "outside"); `hosted: true` means there is no folder,
795
880
  // so every local reference is an error naming upload_deck_asset, a hotlinked
@@ -809,7 +894,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
809
894
  const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted, strict });
810
895
  const warnings = [];
811
896
  if (!notesRequired(html, { strict })) for (const id of slidesWithoutNotes(html)) warnings.push(MESSAGES.noteSoft(id));
812
- warnings.push(...lookWarnings(html));
897
+ warnings.push(...lookWarnings(html), ...presenterGutterWarnings(html));
813
898
  // Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
814
899
  const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
815
900
  for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
@@ -870,9 +955,23 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
870
955
  continue;
871
956
  }
872
957
  errors.push(...timelineErrors(clip, timeline));
958
+ // Guest lines ([speaker: …]): each is audio of its own, and each speaker needs its element on the slide.
959
+ for (const [g, guest] of (Array.isArray(clip.guests) ? clip.guests : []).entries()) {
960
+ let url;
961
+ try { url = new URL(guest?.audio, player.manifest).href; } catch { errors.push(MESSAGES.clipField(clip.id, `guest line ${g + 1} needs an audio URL`)); continue; }
962
+ const got = await fetchCheck(fetchImpl, url, AUDIO);
963
+ if (got.why) errors.push(MESSAGES.url(clip.id, `guest line ${g + 1} audio`, url, got.why));
964
+ else await got.response.body?.cancel?.().catch(() => {});
965
+ }
966
+ for (const sp of Array.isArray(clip.speakers) ? clip.speakers : []) {
967
+ if (sp?.selector && !speakerOnSlide(html, clip.id, sp.selector)) warnings.push(MESSAGES.speakerMissing(clip.id, sp.name ?? "a guest", sp.selector));
968
+ }
873
969
  }
874
970
  }
875
971
  warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
972
+ // A narrated deck's tag that shows no face and says otherwise (a silent deck has no presenter).
973
+ const tagIssue = silent ? null : presenterTagIssue(html);
974
+ if (tagIssue) warnings.push(tagIssue.kind === "kept" ? MESSAGES.presenterKept(tagIssue.presenter) : MESSAGES.presenterUnfilled(tagIssue.presenter));
876
975
  for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
877
976
  for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
878
977
  if (opts.hosted) {
package/src/commands.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
  import {
4
+ backupLinkUrl,
4
5
  installHyperdecksSkill,
5
6
  login,
6
7
  logout,
7
8
  openBrowser,
9
+ linkBase,
8
10
  parse,
9
11
  target,
10
12
  VERSION,
@@ -60,7 +62,7 @@ async function deckStats(pos, opts) {
60
62
  console.log(` unique viewers: ${s.viewers}`);
61
63
  console.log(` avg engaged: ${fmtMs(s.avgMs)}`);
62
64
  console.log(` total engaged: ${fmtMs(s.totalMs)}`);
63
- if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
65
+ if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.pace ? `, pace ${s.pace}` : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
64
66
  if (s.recent?.length) {
65
67
  console.log(" recent:");
66
68
  for (const r of s.recent) {
@@ -85,10 +87,6 @@ async function deckRemove(pos, opts) {
85
87
  console.log(`OK removed deck ${slug}`);
86
88
  }
87
89
 
88
- function shareBase(opts) {
89
- return target(opts).shareBase;
90
- }
91
-
92
90
  async function shareNew(pos, opts) {
93
91
  const [slug, recipient] = pos;
94
92
  if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward]");
@@ -105,9 +103,13 @@ async function shareNew(pos, opts) {
105
103
  expires: opts.expires,
106
104
  limit: opts.limit,
107
105
  });
108
- const url = `${shareBase(opts)}/v/${token}`;
109
- if (opts.json) console.log(JSON.stringify({ token, url }, null, 2));
110
- else console.log(`OK share for "${recipient}" -> ${url}`);
106
+ const url = `${await linkBase(opts)}/v/${token}`;
107
+ const backupUrl = backupLinkUrl(url);
108
+ if (opts.json) console.log(JSON.stringify({ token, url, backupUrl }, null, 2));
109
+ else {
110
+ console.log(`OK share for "${recipient}" -> ${url}`);
111
+ if (backupUrl) console.log(` backup link (same link, other domain, if their network blocks it): ${backupUrl}`);
112
+ }
111
113
  }
112
114
 
113
115
  async function shareList(pos, opts) {
@@ -156,23 +158,32 @@ async function deckForward(pos, opts) {
156
158
  console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
157
159
  }
158
160
 
159
- const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off]";
161
+ const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]";
160
162
  const VIEWS = { slides: "slides", scroll: "scroll", default: null };
163
+ const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
161
164
  async function deckView(pos, opts) {
162
165
  const [slug, mode] = pos;
163
166
  const toggle = opts.toggle;
164
- if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle"))) throw new Error(VIEW_USAGE);
167
+ const pace = opts.pace;
168
+ const ai = opts["ai-readable"];
169
+ if (!slug || (mode === undefined && !["toggle", "pace", "ai-readable"].some((k) => Object.hasOwn(opts, k)))) throw new Error(VIEW_USAGE);
165
170
  if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
166
171
  // A trailing --toggle with no value is a mistake, not "leave it".
167
172
  if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
173
+ if (Object.hasOwn(opts, "pace") && !Object.hasOwn(PACES, pace)) throw new Error(VIEW_USAGE);
174
+ if (Object.hasOwn(opts, "ai-readable") && ai !== "on" && ai !== "off") throw new Error(VIEW_USAGE);
168
175
  const out = await ops.setDeckView(opts, slug, {
169
176
  view: mode === undefined ? undefined : VIEWS[mode],
170
177
  viewToggle: toggle === undefined ? undefined : toggle === "on",
178
+ pace: pace === undefined ? undefined : PACES[pace],
179
+ aiReadable: ai === undefined ? undefined : ai === "on",
171
180
  });
172
181
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
173
182
  const said = [];
174
183
  if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
175
184
  if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
185
+ if (pace !== undefined) said.push(pace === "default" ? "paces as its HTML says" : `pace ${pace}`);
186
+ if (ai !== undefined) said.push(`AI-readable version (/v/<token>/md) ${ai}`);
176
187
  if (out.applies === false) {
177
188
  console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
178
189
  console.warn(`warning: ${out.warning}`);
@@ -181,10 +192,14 @@ async function deckView(pos, opts) {
181
192
  console.log(`OK deck ${slug} ${said.join(", ")} (every link, on its next load)`);
182
193
  }
183
194
 
184
- function shareUrl(pos, opts) {
195
+ async function shareUrl(pos, opts) {
185
196
  const [token] = pos;
186
- if (!token) throw new Error("usage: share url <token>");
187
- console.log(`${shareBase(opts)}/v/${token}`);
197
+ if (!token) throw new Error("usage: share url <token> [--backup]");
198
+ const url = `${await linkBase(opts)}/v/${token}`;
199
+ console.log(url);
200
+ // Second line: the same link on the other domain (stdout stays one URL per line).
201
+ const backupUrl = backupLinkUrl(url);
202
+ if (backupUrl && opts.backup) console.log(backupUrl);
188
203
  }
189
204
 
190
205
  async function recipientArchive(pos, opts, archived) {
@@ -245,7 +260,7 @@ async function engagement(pos, opts) {
245
260
  console.log(` visits: ${s.visits} (last ${new Date(s.lastSeenAt).toISOString().slice(0, 16).replace("T", " ")}) · ${fmtMs(s.totalMs)} engaged`);
246
261
  console.log(` narration: ${s.narration.finished ? "finished" : s.narration.started ? "started" : "not started"} · heard ${pct(s.narration.heardPct)}${s.narration.telemetry === "v3" ? "" : " (playback detail unavailable)"}`);
247
262
  console.log(` reached: slide ${s.furthestSlide}${e.deck.slides ? ` of ${e.deck.slides}` : ""}${s.droppedOffOn ? ` · dropped off on ${slideName(s.droppedOffOn)}` : ""}`);
248
- console.log(` replayed: ${list(s.replayedSlides)}`);
263
+ console.log(` replayed: ${list(s.replayedSlides)}${s.replayNote ? ` · ${s.replayNote}` : ""}`);
249
264
  console.log(` skipped: ${list(s.skippedSlides)}`);
250
265
  console.log(` most time: ${s.mostTime.map((m) => `${slideName(m)} (${fmtMs(m.ms)})`).join(", ") || "none"}`);
251
266
  if (!s.slideData) console.log(" slide data unavailable (this deck reports time only)");
@@ -336,9 +351,10 @@ commands:
336
351
  deck list|stats|open|rm|forward|view
337
352
  deck forward on|off <slug>
338
353
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
339
- deck view <slug> [slides|scroll|default] [--toggle on|off]
354
+ deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
340
355
  how the deck opens in the player (default: as its HTML says, data-view or slides) and
341
356
  whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
357
+ --ai-readable: whether each link + /md serves the deck as markdown for AI assistants (on by default).
342
358
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
343
359
  scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
344
360
  in your saved brand's colours, fonts and logo (--no-brand: the template's look)
@@ -353,7 +369,7 @@ commands:
353
369
  source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
354
370
  move a hosted draft (index.html, script.md, evidence.md) to a folder and back
355
371
  asset add <slug> <file|url> [--name n]
356
- file an image, video or font under the deck; prints its hosted URL
372
+ file an image, video, audio (mp3, m4a), font or JSON under the deck; prints its URL
357
373
  guide [workflow|format|script|scripts|design|layouts|presenter|transitions|check]
358
374
  the skill, one topic at a time (what deck_guide serves)
359
375
  play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
@@ -441,7 +457,7 @@ commands:
441
457
 
442
458
  narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
443
459
  script.md → voiced clips, timelines and a manifest, rendered server-side; only changed slides re-render
444
- voice list [--json] your voices and their status
460
+ voice list [--json] your voices and their status, then the built-in voices anyone can narrate with
445
461
  voice clone <file...> --name <name>
446
462
  clone your voice from 1-3 mp3/m4a/wav recordings (30 s to 3 min in total, 10 MB each);
447
463
  asks you to type "I consent" before anything is uploaded
@@ -851,10 +867,12 @@ function commandTable(pos, rest, opts) {
851
867
  if (opts.json) console.log(JSON.stringify(out, null, 2));
852
868
  },
853
869
  "voice list": async () => {
854
- const voices = await ops.listVoices(opts);
870
+ const voices = await ops.listAllVoices(opts);
855
871
  if (opts.json) return void console.log(JSON.stringify(voices, null, 2));
856
- for (const x of voices) console.log(`${x.name}\t${x.status}\t${x.handle ?? "-"}\t${x.id}${x.error ? `\t${x.error}` : ""}`);
857
- if (!voices.length) console.log("(no voices; voice clone <file...> --name <name> makes one)");
872
+ const own = voices.filter((x) => !x.builtin);
873
+ for (const x of own) console.log(`${x.name}\t${x.status}\t${x.handle ?? "-"}\t${x.id}${x.error ? `\t${x.error}` : ""}`);
874
+ if (!own.length) console.log("(no voices of your own; voice clone <file...> --name <name> makes one)");
875
+ for (const x of voices.filter((x) => x.builtin)) console.log(`${x.name}\tbuilt in\t${x.description}\t${x.id}`);
858
876
  },
859
877
  "voice clone": async () => {
860
878
  const { voiceClone } = await import("./voice.mjs");
@@ -885,8 +903,11 @@ function commandTable(pos, rest, opts) {
885
903
  },
886
904
  "avatar from-photo": async () => {
887
905
  const { avatarFromPhoto } = await import("./avatar.mjs");
888
- const out = await avatarFromPhoto(rest[0], opts);
906
+ const { askLine } = await import("./init.mjs");
907
+ const out = await avatarFromPhoto(rest[0], opts, { ask: askLine });
889
908
  if (opts.json) console.log(JSON.stringify(out, null, 2));
909
+ // A pet photo is still a failed drawing: the offer is printed, the exit says so.
910
+ if (out?.status === "failed") process.exitCode = 1;
890
911
  },
891
912
  "avatar connect": async () => {
892
913
  const { avatarConnect } = await import("./avatar.mjs");
@@ -115,7 +115,8 @@ export function planTimeline(plan) {
115
115
  export function audioTimeline(log, markMs, lead = 0) {
116
116
  const audioOf = new Map((log.plan ?? []).map((s) => [s.id, s.audio]));
117
117
  return (log.clips ?? [])
118
- .map((c) => ({ id: c.id, audio: c.audio ?? audioOf.get(c.id) ?? null, offset: lead + Math.max(0, (c.at - markMs) / 1000) }))
118
+ // A guest line's entry names its own audio; `seek`: the slide's track picking up again after one.
119
+ .map((c) => ({ id: c.id, audio: c.audio ?? audioOf.get(c.id) ?? null, offset: lead + Math.max(0, (c.at - markMs) / 1000), ...(c.seek > 0 ? { seek: c.seek } : {}) }))
119
120
  .filter((c) => c.audio);
120
121
  }
121
122
 
@@ -125,7 +126,8 @@ export function audioTimeline(log, markMs, lead = 0) {
125
126
  export function timelineDrift(log, markMs) {
126
127
  const plan = planTimeline(log.plan ?? []);
127
128
  const startOf = new Map(plan.ids.map((id, i) => [id, plan.starts[i]]));
128
- return (log.clips ?? []).filter((c) => startOf.has(c.id)).map((c) => ({ id: c.id, drift: (c.at - markMs) / 1000 - startOf.get(c.id) }));
129
+ // Only each slide's own start: guest lines and resumes (entries with their own audio) are inside it.
130
+ return (log.clips ?? []).filter((c) => startOf.has(c.id) && !c.audio).map((c) => ({ id: c.id, drift: (c.at - markMs) / 1000 - startOf.get(c.id) }));
129
131
  }
130
132
 
131
133
  // ffmpeg args for the audio track, `seconds` long: silence up to the first
@@ -147,7 +149,9 @@ export function audioMixArgs({ inputs, seconds, out }) {
147
149
  }
148
150
  clips.forEach((c, n) => {
149
151
  const end = n + 1 < clips.length ? clips[n + 1].offset : seconds;
150
- parts.push(`[${n}:a]${fmt},apad,atrim=0:${Math.max(0.001, end - c.offset).toFixed(3)},asetpts=N/SR/TB[c${n}]`);
152
+ // `seek`: start that far into the file (a slide's track resuming after a guest line).
153
+ const from = c.seek > 0 ? `,atrim=start=${Number(c.seek).toFixed(3)},asetpts=PTS-STARTPTS` : '';
154
+ parts.push(`[${n}:a]${fmt}${from},apad,atrim=0:${Math.max(0.001, end - c.offset).toFixed(3)},asetpts=N/SR/TB[c${n}]`);
151
155
  labels.push(`[c${n}]`);
152
156
  });
153
157
  parts.push(`${labels.join('')}concat=n=${labels.length}:v=0:a=1[mix]`);
@@ -185,7 +189,7 @@ export const SEAM_SECONDS = 2;
185
189
  export const SEAM_FRAMES = SEAM_SECONDS * VIDEO_FPS;
186
190
  export const MAX_SPLIT_CLIPS = 200;
187
191
  export function splittableAudio(plan) {
188
- const audio = plan.map((s) => s.audio).filter(Boolean);
192
+ const audio = plan.flatMap((s) => [s.audio, ...(Array.isArray(s.guests) ? s.guests : [])]).filter(Boolean);
189
193
  return audio.length <= MAX_SPLIT_CLIPS && audio.every((a) => typeof a === 'string' && a.length <= MAX_SPLIT_AUDIO_URL && /^https?:\/\//i.test(a));
190
194
  }
191
195
 
@@ -223,7 +227,7 @@ export function joinTimeline(segments, fps = VIDEO_FPS) {
223
227
  let frames = 0;
224
228
  const clips = [];
225
229
  for (const s of segments) {
226
- for (const c of s.clips ?? []) clips.push({ id: c.id, audio: c.audio, offset: Math.max(0, frames / fps + (Number(c.offset) || 0)) });
230
+ for (const c of s.clips ?? []) clips.push({ id: c.id, audio: c.audio, offset: Math.max(0, frames / fps + (Number(c.offset) || 0)), ...(c.seek > 0 ? { seek: c.seek } : {}) });
227
231
  frames += s.frames;
228
232
  }
229
233
  return { clips, frames, seconds: frames / fps };
@@ -503,8 +507,9 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
503
507
  // A range needs only its own slides' clips.
504
508
  const audioFiles = new Map(), clipLengths = {};
505
509
  const wanted = (state.plan ?? []).filter((s) => inRange(s.id));
506
- for (const [n, url] of [...new Set(wanted.map((s) => s.audio).filter(Boolean))].entries()) {
507
- const id = wanted.find((s) => s.audio === url).id;
510
+ // A slide's guest lines are audio of their own.
511
+ for (const [n, url] of [...new Set(wanted.flatMap((s) => [s.audio, ...(Array.isArray(s.guests) ? s.guests : [])]).filter(Boolean))].entries()) {
512
+ const id = wanted.find((s) => s.audio === url || s.guests?.includes(url)).id;
508
513
  const path = clipPath(temp, n, url);
509
514
  await downloadClip(url, path, { id, entryUrl: pageUrl });
510
515
  audioFiles.set(url, path);
@@ -643,13 +648,13 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
643
648
  file: out, seconds: kept / VIDEO_FPS, width: geometry.width, height: geometry.height, bytes, slides: to - from + 1, clips: own.length, render, warnings,
644
649
  // frames: in the file. A clip's offset is from the file's first frame:
645
650
  // after a dropped seam, a slide's clip may start (just) before it.
646
- segment: { from, to, frames: kept, tail, clips: own.map((c) => ({ id: c.id, audio: c.audio, offset: c.offset - skip / VIDEO_FPS })) },
651
+ segment: { from, to, frames: kept, tail, clips: own.map((c) => ({ id: c.id, audio: c.audio, offset: c.offset - skip / VIDEO_FPS, ...(c.seek ? { seek: c.seek } : {}) })) },
647
652
  };
648
653
  }
649
654
  const inputs = clips.map((c) => {
650
655
  const path = audioFiles.get(c.audio);
651
656
  if (!path) throw new Error(`clip ${c.id} played audio the plan did not list (${c.audio})`);
652
- return { path, offset: c.offset };
657
+ return { path, offset: c.offset, seek: c.seek };
653
658
  });
654
659
  const audio = join(temp, 'audio.wav');
655
660
  await ffmpeg(ff, audioMixArgs({ inputs, seconds: total, out: audio }), 300000);
@@ -695,7 +700,7 @@ export async function joinVideo({ segments, out, origin = null }, { log = () =>
695
700
  }
696
701
  phase = 'audio';
697
702
  const audio = join(temp, 'audio.wav');
698
- await ffmpeg(ff, audioMixArgs({ inputs: timeline.clips.map((c) => ({ path: files.get(c.audio), offset: c.offset })), seconds: timeline.seconds, out: audio }), 300000);
703
+ await ffmpeg(ff, audioMixArgs({ inputs: timeline.clips.map((c) => ({ path: files.get(c.audio), offset: c.offset, seek: c.seek })), seconds: timeline.seconds, out: audio }), 300000);
699
704
  phase = 'join';
700
705
  const list = join(temp, 'segments.txt');
701
706
  await writeFile(list, concatList(segments.map((s) => s.file)));