@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/README.md
CHANGED
|
@@ -259,6 +259,8 @@ Generate locally, select an existing same-deck recipient share, then explicitly
|
|
|
259
259
|
|
|
260
260
|
Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a play/watch badge on GIF, PNG and silent MP4. `--duration` accepts 1–10 seconds, `--width` 240–1200 pixels (odd widths round up for MP4), `--delay` 0–10000 milliseconds (default 1000), and `--label "Watch · 2:41"` supplies an explicit label; no whole-deck narration duration is inferred. Aspect comes from `--aspect-ratio W:H`, `<meta name="preview-aspect-ratio" content="16:9">`, or the first slide/canvas bounds; otherwise 16:9. Output height rounds to an even pixel. Fonts get up to 5 seconds to settle. Inspect readiness warnings/poster for slow modules or media and increase the bounded delay if needed.
|
|
261
261
|
|
|
262
|
+
To show the slide that sells the deck instead of its opening, name it and a moment of its narration: `hyperdecks preview create my-deck --scene chars --start 3.2 --share EXISTING_TOKEN --out ./preview-chars --upload --json`. `--scene` is the slide's id (its `<section class="slide" id>`; an unknown id fails and lists the deck's ids); `--start` is seconds into that slide's narration (default 0; it must fall inside the slide's clip plus its hold). The GIF then starts on exactly that moment: the guest speaking then, with its mouth and caption bubble, and every cue fired by then. It records frame by frame on the player's page clock (as `export --video` does), silently, after the slide's character heads finish loading; `--delay` still adds settle time first. Private character heads use the same in-memory, deck-scoped view grant; nothing is written to disk. `manifest.json` gets a `moment` saying which slide, which guest and which caption the first frame shows. Needs a deck on player/v1.
|
|
263
|
+
|
|
262
264
|
`--json` prints one line on stdout, identical to `<out>/manifest.json`:
|
|
263
265
|
|
|
264
266
|
```json
|
package/package.json
CHANGED
|
@@ -150,6 +150,8 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
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
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
|
+
| Give a deck to another account (only when asked; confirm the email) | `hyperdecks deck transfer <slug> --to <email>` (`deck transfers cancel <slug>` takes it back) | `transfer_deck` (`cancel: true`) |
|
|
154
|
+
| Transfers waiting on you, and accepting one (ask who narrates and, for each guest that needsPick, a voice and avatar of theirs) | `hyperdecks deck transfers`, then `deck transfers accept <id> --narrator <name\|dash\|none\|default> [--guest <n>=<voice>/<avatar>]` (or `decline <id>`) | `list_transfers`, then `accept_transfer` (`decline: true`) |
|
|
153
155
|
| Present or check your own deck (owner link) | `hyperdecks deck link <slug>` (`deck open` opens it) | `ownerUrl` from `list_decks` or `check_deck` |
|
|
154
156
|
| Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
|
|
155
157
|
| All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
|
|
@@ -308,6 +310,20 @@ its `##` heading.
|
|
|
308
310
|
| `glass` | dark or busy slides: head and captions on one frosted panel |
|
|
309
311
|
| `corner` | the slide is the point (a product shot, a chart, a demo): small head bottom-right, subtitles along the bottom |
|
|
310
312
|
|
|
313
|
+
**Loading and buffering.** A deck opens on its loading card (the title and
|
|
314
|
+
the presenter's still), and a line still loading shows as a small waiting
|
|
315
|
+
pill. Once the deck has spoken, a line that stalls on the network for more
|
|
316
|
+
than 0.3 s makes the slide play a short
|
|
317
|
+
"signal" glitch in the deck's accents (`--accent-2` / `--accent-3`, else
|
|
318
|
+
`--accent`; set `--hd-signal-1` / `--hd-signal-2` to choose others): the
|
|
319
|
+
title's colour splits, thin scanlines and a slice pass over the slide, and a
|
|
320
|
+
`SIGNAL // BUFFERING` tag sits in the top-left corner.
|
|
321
|
+
Body text never moves; it calms to scanlines after 6 s and stops the moment
|
|
322
|
+
sound plays. It never shows in thumbnails, exports, present mode or while the
|
|
323
|
+
owner presents, and reduced motion gets a still dim and the tag. Where it
|
|
324
|
+
doesn't fit the deck, turn it off with `data-loading="calm"` on the player tag
|
|
325
|
+
(or on `<html>`, or `"loading": "calm"` in the manifest).
|
|
326
|
+
|
|
311
327
|
Size it per slide with `data-presenter-size="s|m|l|xl"` on the `<section>`
|
|
312
328
|
(0.7×, 1×, 1.3×, 1.6× the style's head; on the player tag it is the deck's
|
|
313
329
|
default): `xl` on a slide with empty space, `s` on a busy product slide. Phones
|
|
@@ -431,8 +447,8 @@ slide that overflows a 390×844 phone.
|
|
|
431
447
|
|
|
432
448
|
#### House style (preferences)
|
|
433
449
|
|
|
434
|
-
|
|
435
|
-
|
|
450
|
+
The owner's house style, not rules: follow it by default; depart from it
|
|
451
|
+
when the brief asks.
|
|
436
452
|
|
|
437
453
|
**One idea per slide.** The title states the idea as a sentence a listener
|
|
438
454
|
could repeat ("Narration renders in two minutes", not "Speed"). Everything
|
|
@@ -451,16 +467,15 @@ new sizes:
|
|
|
451
467
|
| `--t-body` | 24px | 15px | list items, card text |
|
|
452
468
|
| `--t-label` | 17px | 12px | kickers, sources, captions (mono, uppercase) |
|
|
453
469
|
|
|
454
|
-
Nothing on a slide below 17px at stage size (a screenshot's frame chrome aside). A title over
|
|
455
|
-
`data-title-mobile
|
|
470
|
+
Nothing on a slide below 17px at stage size (a screenshot's frame chrome aside). A title over
|
|
471
|
+
28 characters needs `data-title-mobile`, written as a real sentence.
|
|
456
472
|
|
|
457
473
|
**Colour and contrast.** Three colours do the work: `--ink`, `--paper` and one
|
|
458
|
-
`--accent` (
|
|
459
|
-
|
|
474
|
+
`--accent` (`--accent-2` only for a chart's second series or a placeholder
|
|
475
|
+
face). Text is ink on paper or accent, or paper on ink: never accent
|
|
460
476
|
on paper, never grey on a colour. Alternate slide grounds (`on-accent`,
|
|
461
477
|
plain, `on-ink`) so the slide changes read, and keep every number, source and
|
|
462
|
-
label at full contrast.
|
|
463
|
-
layout reads them.
|
|
478
|
+
label at full contrast. Set the palette and fonts once, in `:root`.
|
|
464
479
|
|
|
465
480
|
**Start from the brand.** The account keeps a brand: colours, fonts, a logo,
|
|
466
481
|
a presenter style and a design.md (voice, do and don't). `deck new` and
|
|
@@ -468,16 +483,14 @@ a presenter style and a design.md (voice, do and don't). `deck new` and
|
|
|
468
483
|
it first (`hyperdecks brand show --json`, `get_brand`) and write in its voice.
|
|
469
484
|
An explicit request for this deck wins over the brand; `--no-brand`
|
|
470
485
|
(`brand: false`) starts from the template's look. No brand yet? Offer to
|
|
471
|
-
import it
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
Every brand save is kept in its history: `brand history` (`brand_history`)
|
|
480
|
-
lists the last changes and `brand undo [<version>]` (`undo_brand`) goes back.
|
|
486
|
+
import it: `hyperdecks brand import <url|file.pptx|file.pdf|deck-slug>`
|
|
487
|
+
(`import_brand`) reads the real colours, fonts and logo (a PowerPoint's theme
|
|
488
|
+
exactly) and returns a preview link of a sample slide and a token. Show them
|
|
489
|
+
the preview; save with `brand set --draft <token>` (`set_brand { draft }`)
|
|
490
|
+
only after they say yes.
|
|
491
|
+
To change it in words, `brand revise "<change>"` (`revise_brand`) gives a
|
|
492
|
+
draft and preview the same way; `brand history` (`brand_history`) lists past
|
|
493
|
+
saves and `brand undo [<version>]` (`undo_brand`) goes back.
|
|
481
494
|
|
|
482
495
|
**Fonts: pick a pairing.** Fonts load from Google Fonts. Use one of the
|
|
483
496
|
curated pairings below (display for titles, body for text, mono for kickers
|
|
@@ -489,13 +502,19 @@ saves one.
|
|
|
489
502
|
**Textures, icons, pictures:** the `look` topic (one texture, one icon set
|
|
490
503
|
from `search_icons` or `generate_icon_set`, a picture on every other slide).
|
|
491
504
|
|
|
492
|
-
**Pick the layout from what the slide says
|
|
493
|
-
`templates/layouts/` opens with its slot
|
|
494
|
-
|
|
505
|
+
**Pick the layout from what the slide says** (each file in
|
|
506
|
+
`templates/layouts/` opens with its slot, presenter and cues).
|
|
507
|
+
|
|
508
|
+
**The cover never puts text on the picture.** The title and lead sit on a
|
|
509
|
+
solid ground, never on a picture or one faded
|
|
510
|
+
with a gradient `mask-image` (`check` warns).
|
|
495
511
|
|
|
496
512
|
| Layout | Use it for | Presenter, moves |
|
|
497
513
|
|---|---|---|
|
|
498
|
-
| `hero` | the
|
|
514
|
+
| `hero` | the default cover, a section opener: title on the ground, the real subject framed beside it (no `.art`: the presenter is the visual) | stage; `[wave]`, `[look: h1]` |
|
|
515
|
+
| `cover-band` | the picture is the story: full bleed, title on a panel | as `hero` |
|
|
516
|
+
| `cover-poster` | a wide scene: picture on top, title below | as `hero` |
|
|
517
|
+
| `cover-type` | no real picture yet: type only, presenter large | as `hero` |
|
|
499
518
|
| `number` | one number with its source: proof, traction, a delta | stage; `[look: #<id>-figure]` as it is said |
|
|
500
519
|
| `product` | a screenshot in a browser frame (`.frame.card` for a phone) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
|
|
501
520
|
| `split` | the workhorse: a real picture (phone, laptop, 16:9) beside sticker, title, three ticks | stage, bottom-left; `[look: #<id>-frame]`, `[go: #<id>-t1]` … `t3` |
|
|
@@ -631,7 +650,7 @@ an app icon or a face), `.ticks`, `.panel`, `.pill`, `.frame.card` (a phone),
|
|
|
631
650
|
`<concrete subject>, <setting>, <medium: e.g. editorial photo, soft daylight>,
|
|
632
651
|
palette <ink hex> <accent hex> <paper hex>, no text, no logos`
|
|
633
652
|
(`generate_image { brand: true }` adds the palette and the "no text" for
|
|
634
|
-
you). Generate at the slide's shape (
|
|
653
|
+
you). Generate at the slide's shape (16:9; near-square for the hero), in one batch.
|
|
635
654
|
|
|
636
655
|
**Slide recipes.** Opener: `on-accent` + `bg-corner`, the logo
|
|
637
656
|
or a few app-icon stickers, one display line. Product or feature: `split`
|
|
@@ -764,6 +783,8 @@ you do).
|
|
|
764
783
|
clip that says "Minimoji" but is transcribed as "Moji" needs
|
|
765
784
|
`normalize: moji=minimoji`, and with a second entry
|
|
766
785
|
`normalize: moji=minimoji, dash=D.A.S.H.` (one line, not a YAML list).
|
|
786
|
+
British and American spellings of a word (centre/center, colour/color,
|
|
787
|
+
organise/organize, travelled/traveled) already pass: no entry needed.
|
|
767
788
|
- Write a narrator's name as it is spoken when the two differ, or leave it to
|
|
768
789
|
the narrator's spoken name: `narrator list` shows it, and narrate reads
|
|
769
790
|
"D.A.S.H." as "Dash" (captions keep what you wrote).
|
|
@@ -825,7 +846,8 @@ with the player's `hyperdecks.ai` script tag. Messages and what to do:
|
|
|
825
846
|
| `forbidden phrase` | remove it from the page, script or narration |
|
|
826
847
|
| `slide <id>: needs a data-note` / ⚠ `no data-note` | add the source (required on a `data-kind="pitch"` deck and with `--strict`) |
|
|
827
848
|
| `slide <id>: title … needs a data-title-mobile` | add a shorter title |
|
|
828
|
-
| `slide <id>: the hero's art` / `the product screenshot` / `the chart … is still the layout's placeholder` | make the real file (`gen-image`, `screenshot <url>`, `chart <spec.json>`) and swap the element for the `<img>` the message names; or delete the hero's `<div class="art">` for no art |
|
|
849
|
+
| `slide <id>: the hero's art` / `the cover's picture` / `the product screenshot` / `the chart … is still the layout's placeholder` | make the real file (`gen-image`, `screenshot <url>`, `chart <spec.json>`) and swap the element for the `<img>` the message names; or delete the hero's `<div class="art">` for no art (on `cover-band` / `cover-poster`, switch to `cover-type` instead) |
|
|
850
|
+
| ⚠ `slide <id>: the … cover fades its picture into the ground with a mask-image` | the old hero's look: the title sits on picture and colour mixed. Move the deck's opening to a current cover (`hero`, `cover-band`, `cover-poster`, `cover-type`), or drop the mask and keep the title off the picture |
|
|
829
851
|
| `… presenter style "…" is not one of` | use stage, bubble, glass or corner |
|
|
830
852
|
| `script.md line N: [go: …] matches nothing in slide <id>` | fix the selector, or add the element to that slide |
|
|
831
853
|
| `… transition "…" is not one of` | use drift, cut, push, wipe, zoom `<selector>`, morph or fly |
|
|
@@ -992,6 +1014,8 @@ Generate locally, select an existing same-deck recipient share, then explicitly
|
|
|
992
1014
|
|
|
993
1015
|
Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a play/watch badge on GIF, PNG and silent MP4. `--duration` accepts 1–10 seconds, `--width` 240–1200 pixels (odd widths round up for MP4), `--delay` 0–10000 milliseconds (default 1000), and `--label "Watch · 2:41"` supplies an explicit label; no whole-deck narration duration is inferred. Aspect comes from `--aspect-ratio W:H`, `<meta name="preview-aspect-ratio" content="16:9">`, or the first slide/canvas bounds; otherwise 16:9. Output height rounds to an even pixel. Fonts get up to 5 seconds to settle. Inspect readiness warnings/poster for slow modules or media and increase the bounded delay if needed.
|
|
994
1016
|
|
|
1017
|
+
To show the slide that sells the deck instead of its opening, name it and a moment of its narration: `hyperdecks preview create my-deck --scene chars --start 3.2 --share EXISTING_TOKEN --out ./preview-chars --upload --json`. `--scene` is the slide's id (its `<section class="slide" id>`; an unknown id fails and lists the deck's ids); `--start` is seconds into that slide's narration (default 0; it must fall inside the slide's clip plus its hold). The GIF then starts on exactly that moment: the guest speaking then, with its mouth and caption bubble, and every cue fired by then. It records frame by frame on the player's page clock (as `export --video` does), silently, after the slide's character heads finish loading; `--delay` still adds settle time first. Private character heads use the same in-memory, deck-scoped view grant; nothing is written to disk. `manifest.json` gets a `moment` saying which slide, which guest and which caption the first frame shows. Needs a deck on player/v1.
|
|
1018
|
+
|
|
995
1019
|
`--json` prints one line on stdout, identical to `<out>/manifest.json`:
|
|
996
1020
|
|
|
997
1021
|
```json
|
|
@@ -46,9 +46,11 @@ makes the hello deck; continue from step 5 below.
|
|
|
46
46
|
`upload_deck_asset` for a picture the user gave you (a URL, or a file they
|
|
47
47
|
uploaded). Each returns a hosted URL: put that URL in the HTML. A hosted deck
|
|
48
48
|
has no folder, so a relative path like `img/a.png` is an error. The
|
|
49
|
-
layouts' stand-ins (
|
|
49
|
+
layouts' stand-ins (the covers' `<div class="art">`, product's
|
|
50
50
|
`<div class="shot">`, chart's placeholder `<svg>`) fail `check_deck` until
|
|
51
51
|
you swap each for an `<img>` of its real file, or delete the hero's art.
|
|
52
|
+
Open on a cover (`hero`, `cover-band`, `cover-poster` or `cover-type`,
|
|
53
|
+
deck_guide topic layouts) and never put the title or lead on the picture.
|
|
52
54
|
Icons: one set per deck (`look` topic): `search_icons { query }` for real
|
|
53
55
|
line icons as inline `<svg>` in `currentColor`, or one `generate_icon_set`.
|
|
54
56
|
For motion, `generate_video` (poll `check_video_job`): `loop: true` for a
|
|
@@ -126,6 +128,19 @@ makes the hello deck; continue from step 5 below.
|
|
|
126
128
|
worker it answers with the `hyperdecks export <slug> --video` command
|
|
127
129
|
instead; give that to the user to run.
|
|
128
130
|
|
|
131
|
+
## Giving a deck to another account
|
|
132
|
+
|
|
133
|
+
Like moving a GitHub repo. Only when the person asks: confirm the
|
|
134
|
+
recipient's email, then `transfer_deck { slug, to }`. They get an email and
|
|
135
|
+
have 7 days; nothing moves until they accept, and `transfer_deck { slug,
|
|
136
|
+
cancel: true }` takes it back. On the other side, `list_transfers` shows what
|
|
137
|
+
waits. Before `accept_transfer`, ask who narrates it for them when
|
|
138
|
+
`needsNarrator` (`list_narrators`: theirs, dash, none or default), and a
|
|
139
|
+
voice and avatar of theirs for each guest with `needsPick`; the sender's
|
|
140
|
+
voice and face never move. Accepting moves the deck with its share links
|
|
141
|
+
(links already sent keep working), recipients and stats, and starts narrating
|
|
142
|
+
their picks; `release_deck` puts them on the links.
|
|
143
|
+
|
|
129
144
|
## Rules that matter
|
|
130
145
|
|
|
131
146
|
- Every slide is `<section class="slide" id="…" data-note="<source of its facts>">`,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Pack management never changes avatar IDs or the user's default presenter.
|
|
2
|
+
// Publication is a distinct command; there is no public flag on create/import.
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
import { client } from "./runtime.mjs";
|
|
5
|
+
|
|
6
|
+
const ref = (name) => makeFunctionReference(`avatarPacks:${name}`);
|
|
7
|
+
const PACK_USAGE = "usage: avatar pack list | create <name> [--private-only] [--description text] | get <pack-id> | add <pack-id> <avatar-id> | grant <pack-id> --account <id>|--email <email>|--team <id> | grants <pack-id> | revoke <grant-id> | publish <pack-id> | unpublish <pack-id> | invitations | claim | catalog";
|
|
8
|
+
const TEAM_USAGE = "usage: avatar team list | create <name> | members <team-id> | add <team-id> --account <id>|--email <email> | remove <membership-id>";
|
|
9
|
+
const text = (value, label) => {
|
|
10
|
+
if (typeof value !== "string" || !value.trim() || value.startsWith("--")) throw new Error(`${label} requires a value`);
|
|
11
|
+
return value.trim();
|
|
12
|
+
};
|
|
13
|
+
function exactly(pos, count, usage) {
|
|
14
|
+
if (pos.length !== count || pos.some(value => !value)) throw new Error(usage);
|
|
15
|
+
return pos;
|
|
16
|
+
}
|
|
17
|
+
function recipient(opts, allowTeam) {
|
|
18
|
+
const names = allowTeam ? ["account", "email", "team"] : ["account", "email"];
|
|
19
|
+
const selected = names.filter(name => Object.hasOwn(opts, name));
|
|
20
|
+
if (selected.length !== 1 || (!allowTeam && Object.hasOwn(opts, "team"))) throw new Error(`choose exactly one of ${names.map(name => `--${name}`).join(", ")}`);
|
|
21
|
+
const key = selected[0], value = text(opts[key], `--${key}`);
|
|
22
|
+
return { [{ account: "accountId", email: "email", team: "teamId" }[key]]: value };
|
|
23
|
+
}
|
|
24
|
+
function output(result, opts, log) {
|
|
25
|
+
if (opts.json) log(JSON.stringify(result, null, 2));
|
|
26
|
+
else if (Array.isArray(result)) {
|
|
27
|
+
if (!result.length) log("(none)");
|
|
28
|
+
for (const item of result) log(`${item.id ?? item._id}\t${item.name ?? item.granteeId ?? item.memberId ?? item.email ?? item.teamId ?? ""}${item.visibility ? `\t${item.visibility}` : ""}${item.access ? ` (${item.access})` : ""}${item.privateOnly ? " · permanently private-only" : ""}${item.status ? `\t${item.status}` : ""}`);
|
|
29
|
+
} else log(JSON.stringify(result, null, 2));
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function avatarPacksCommand(pos = [], opts = {}, { log = console.log } = {}) {
|
|
34
|
+
const [action = "list", ...args] = pos;
|
|
35
|
+
let method = "query", apiName, payload;
|
|
36
|
+
switch (action) {
|
|
37
|
+
case "list": exactly(args, 0, PACK_USAGE); apiName = "list"; payload = {}; break;
|
|
38
|
+
case "create":
|
|
39
|
+
if (!args.length) throw new Error(PACK_USAGE);
|
|
40
|
+
if (Object.hasOwn(opts, "public") || Object.hasOwn(opts, "publish")) throw new Error("packs are created private; use the separate publish command for an eligible pack");
|
|
41
|
+
apiName = "create"; method = "mutation";
|
|
42
|
+
payload = { name: text(args.join(" "), "pack name"), privateOnly: opts["private-only"] === true };
|
|
43
|
+
if (Object.hasOwn(opts, "description")) payload.description = text(opts.description, "--description");
|
|
44
|
+
break;
|
|
45
|
+
case "get": case "grants":
|
|
46
|
+
exactly(args, 1, PACK_USAGE); apiName = action; payload = { packId: args[0] }; break;
|
|
47
|
+
case "add":
|
|
48
|
+
exactly(args, 2, PACK_USAGE); apiName = "addAvatar"; method = "mutation";
|
|
49
|
+
payload = { packId: args[0], avatarId: args[1] }; break;
|
|
50
|
+
case "grant":
|
|
51
|
+
exactly(args, 1, PACK_USAGE); apiName = "grant"; method = "mutation";
|
|
52
|
+
payload = { packId: args[0], ...recipient(opts, true) }; break;
|
|
53
|
+
case "revoke":
|
|
54
|
+
exactly(args, 1, PACK_USAGE); apiName = "revoke"; method = "mutation"; payload = { grantId: args[0] }; break;
|
|
55
|
+
case "publish": case "unpublish":
|
|
56
|
+
exactly(args, 1, PACK_USAGE); apiName = action; method = "mutation"; payload = { packId: args[0] }; break;
|
|
57
|
+
case "invitations": case "claim":
|
|
58
|
+
exactly(args, 0, PACK_USAGE); apiName = action === "claim" ? "claimInvitations" : "invitations";
|
|
59
|
+
method = action === "claim" ? "mutation" : "query"; payload = {}; break;
|
|
60
|
+
case "catalog":
|
|
61
|
+
exactly(args, 0, PACK_USAGE); apiName = "publicCatalog";
|
|
62
|
+
payload = { paginationOpts: { numItems: 32, cursor: Object.hasOwn(opts, "cursor") ? text(opts.cursor, "--cursor") : null } }; break;
|
|
63
|
+
default: throw new Error(PACK_USAGE);
|
|
64
|
+
}
|
|
65
|
+
const result = await (await client(opts))[method](ref(apiName), payload);
|
|
66
|
+
if (action === "get" && !result) throw new Error("pack not found or unavailable to your account");
|
|
67
|
+
if (!opts.json && action === "create") log(result.privateOnly ? "Created privately. The private-only lock is permanent; this pack cannot be published." : "Created privately. Only an explicit publish command makes this pack public.");
|
|
68
|
+
return output(result, opts, log);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function avatarTeamsCommand(pos = [], opts = {}, { log = console.log } = {}) {
|
|
72
|
+
const [action = "list", ...args] = pos;
|
|
73
|
+
let method = "query", apiName, payload;
|
|
74
|
+
switch (action) {
|
|
75
|
+
case "list": exactly(args, 0, TEAM_USAGE); apiName = "teams"; payload = {}; break;
|
|
76
|
+
case "create":
|
|
77
|
+
if (!args.length) throw new Error(TEAM_USAGE);
|
|
78
|
+
apiName = "createTeam"; method = "mutation"; payload = { name: text(args.join(" "), "team name") }; break;
|
|
79
|
+
case "members":
|
|
80
|
+
exactly(args, 1, TEAM_USAGE); apiName = "teamMembers"; payload = { teamId: args[0] }; break;
|
|
81
|
+
case "add":
|
|
82
|
+
exactly(args, 1, TEAM_USAGE); apiName = "addTeamMember"; method = "mutation";
|
|
83
|
+
payload = { teamId: args[0], ...recipient(opts, false) }; break;
|
|
84
|
+
case "remove":
|
|
85
|
+
exactly(args, 1, TEAM_USAGE); apiName = "removeTeamMember"; method = "mutation"; payload = { membershipId: args[0] }; break;
|
|
86
|
+
default: throw new Error(TEAM_USAGE);
|
|
87
|
+
}
|
|
88
|
+
return output(await (await client(opts))[method](ref(apiName), payload), opts, log);
|
|
89
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Ephemeral HTML transform for play/capture/export. Never write its output to
|
|
2
|
+
// source push, release source, disk caches, or logs: it contains a view grant.
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
const ISSUE_OWNER_VIEW = makeFunctionReference("avatarAccess:issueOwnerView");
|
|
5
|
+
const VIEW_META = /<meta\b[^>]*\bname\s*=\s*(?:"hd-avatar-view"|'hd-avatar-view'|hd-avatar-view(?=\s|\/?>))[^>]*>/gi;
|
|
6
|
+
const PRIVATE_REFS = /\/avatar\/private\/|\bdata-hd-avatar\b/i;
|
|
7
|
+
|
|
8
|
+
export async function withAvatarView(client, slug, html, scope = "draft") {
|
|
9
|
+
if (typeof html !== "string") throw new Error("avatar preview requires HTML text");
|
|
10
|
+
if (scope !== "draft" && scope !== "released") throw new Error("avatar preview scope must be draft or released");
|
|
11
|
+
const hadView = /<meta\b[^>]*\bname\s*=\s*(?:"hd-avatar-view"|'hd-avatar-view'|hd-avatar-view(?=\s|\/?>))/i.test(html);
|
|
12
|
+
const needsView = hadView || PRIVATE_REFS.test(html);
|
|
13
|
+
// Always request a session: guest URLs may live only in the narration
|
|
14
|
+
// manifest. Offline ordinary playback remains usable without a grant;
|
|
15
|
+
// protected endpoints still reject private models without authorization.
|
|
16
|
+
const clean = html.replace(VIEW_META, "");
|
|
17
|
+
if (!needsView && !/\bdata-(?:manifest|hd-speaker)\b/i.test(html)) return clean;
|
|
18
|
+
let grant;
|
|
19
|
+
try {
|
|
20
|
+
if (typeof slug !== "string" || !slug.trim()) throw new Error("a deck slug is required");
|
|
21
|
+
grant = await client.mutation(ISSUE_OWNER_VIEW, { slug, scope });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (needsView) throw new Error("Cannot authorize private avatars for this preview. Sign in as the deck owner, verify pack access, and push the current deck source before retrying.", { cause: error });
|
|
24
|
+
return clean;
|
|
25
|
+
}
|
|
26
|
+
if (!grant) {
|
|
27
|
+
if (needsView) throw new Error("No current private-avatar view grant. Push the deck source and script so their avatar IDs are registered, then retry.");
|
|
28
|
+
return clean;
|
|
29
|
+
}
|
|
30
|
+
if (!/^[a-f0-9]{64}$/.test(grant.token) || !Number.isFinite(grant.expiresAt) || grant.expiresAt <= Date.now()) throw new Error("The server returned an invalid or expired avatar view grant");
|
|
31
|
+
const meta = `<meta name="hd-avatar-view" content="${grant.token}">`;
|
|
32
|
+
// Put the grant before any player script; the HTML string stays in memory.
|
|
33
|
+
return /<head\b[^>]*>/i.test(clean) ? clean.replace(/<head\b[^>]*>/i, match => `${match}\n${meta}`) : `${meta}\n${clean}`;
|
|
34
|
+
}
|
package/src/brand.mjs
CHANGED
|
@@ -118,6 +118,21 @@ export function cleanBrand(brand) {
|
|
|
118
118
|
return { name, colors, fonts, logoUrl, presenterStyle, hasDesignMd };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// The templates' own ink, paper and accent (layouts/_deck.html, narrated-deck.html), for a brand that sets only some colours.
|
|
122
|
+
const TEMPLATE_INK = "#0b0b0f", TEMPLATE_PAPER = "#fafaf7", TEMPLATE_ACCENT = "#ffda1b";
|
|
123
|
+
// WCAG relative luminance and contrast ratio of two #rgb / #rrggbb colours.
|
|
124
|
+
function luminance(hex) {
|
|
125
|
+
const h = hex.replace("#", ""), full = h.length === 3 ? [...h].map((c) => c + c).join("") : h.slice(0, 6);
|
|
126
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
|
127
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
128
|
+
}
|
|
129
|
+
export const contrast = (a, b) => { const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
|
|
130
|
+
// Which of ink and paper the text on the accent is: ink unless it falls under 4.5:1 and paper reads better.
|
|
131
|
+
export function accentInk(accent, ink, paper) {
|
|
132
|
+
const onInk = contrast(accent, ink);
|
|
133
|
+
return onInk < 4.5 && contrast(accent, paper) > onInk ? "paper" : "ink";
|
|
134
|
+
}
|
|
135
|
+
|
|
121
136
|
const escapeAttr = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
122
137
|
const fontStack = (family, role) => `"${family}", ${FALLBACK[role]}`;
|
|
123
138
|
const LOGO_CSS = `
|
|
@@ -141,6 +156,10 @@ export function applyBrand(html, brand) {
|
|
|
141
156
|
block = decl.test(block) ? block.replace(decl, (_, k) => `${k}${value}`) : block.replace(/\s*\}$/, (end) => ` ${name}: ${value};${end}`);
|
|
142
157
|
};
|
|
143
158
|
for (const [k, v] of Object.entries(b.colors)) set(CSS_VAR[k], v);
|
|
159
|
+
// Text on an accent ground reads --accent-ink (ink by default): a dark accent
|
|
160
|
+
// that ink cannot be read on takes the paper instead, so a cover on the accent stays legible.
|
|
161
|
+
const { accent = TEMPLATE_ACCENT, ink = TEMPLATE_INK, paper = TEMPLATE_PAPER } = b.colors;
|
|
162
|
+
if ((b.colors.accent || b.colors.ink || b.colors.paper) && accentInk(accent, ink, paper) === "paper") set("--accent-ink", "var(--paper)");
|
|
144
163
|
if (b.fonts.body) set("--font", fontStack(b.fonts.body, "body"));
|
|
145
164
|
if (b.fonts.mono) set("--mono", fontStack(b.fonts.mono, "mono"));
|
|
146
165
|
if (b.fonts.display) {
|
package/src/checkRules.mjs
CHANGED
|
@@ -72,6 +72,7 @@ export const MESSAGES = {
|
|
|
72
72
|
transitionStale: (where, script, manifest) => `${where}: script.md says transition "${script}" but the manifest has "${manifest}": run \`hyperdecks narrate\` so the player gets it`,
|
|
73
73
|
flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
|
|
74
74
|
// Warnings from the look topic (deck_guide look): the deck passes, but a person should look.
|
|
75
|
+
coverFade: (where, layout) => `${where}: the ${layout} cover fades its picture into the ground with a mask-image, so the title sits on a mix of picture and colour that is hard to read: keep the text off the picture (layouts hero, cover-band, cover-poster or cover-type; deck_guide topic layouts)`,
|
|
75
76
|
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)`,
|
|
76
77
|
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)`,
|
|
77
78
|
mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
|
|
@@ -316,6 +317,19 @@ export function slides(html) {
|
|
|
316
317
|
});
|
|
317
318
|
}
|
|
318
319
|
|
|
320
|
+
// Each slide's titles written short for phones: { id, titles: [{ desktop, phone }] },
|
|
321
|
+
// only where the phone text differs. The player swaps data-title-mobile in below
|
|
322
|
+
// 650px, so review must not read the shorter phone title as cut off.
|
|
323
|
+
export function mobileTitles(html) {
|
|
324
|
+
return slides(html).map(({ id, body }) => ({
|
|
325
|
+
id,
|
|
326
|
+
titles: [...body.matchAll(/<([a-z][\w-]*)\b([^>]*\sdata-title-mobile\s*=[^>]*)>([\s\S]*?)<\/\1>/gi)]
|
|
327
|
+
.map(([, , attrs, inner]) => ({ desktop: textOf(inner), phone: attr(attrs, "data-title-mobile") }))
|
|
328
|
+
.filter((t) => t.phone != null && decode(t.phone).trim() && decode(t.phone).trim() !== t.desktop)
|
|
329
|
+
.map((t) => ({ desktop: t.desktop.slice(0, 200), phone: decode(t.phone).trim().slice(0, 200) })),
|
|
330
|
+
})).filter((s) => s.id && s.titles.length);
|
|
331
|
+
}
|
|
332
|
+
|
|
319
333
|
// The player tag also carries the deck's declarations: data-silent (no
|
|
320
334
|
// narration, on purpose) and data-kind="pitch" (every slide sources its facts).
|
|
321
335
|
// The check reads what the deck says, never guesses from its content.
|
|
@@ -535,6 +549,14 @@ const PLACEHOLDERS = [
|
|
|
535
549
|
layout: "l-hero", tag: "div", cls: "art", what: "the hero's art", img: '<img class="art" src="…" alt="">, or delete the div for no art (the presenter is the visual)',
|
|
536
550
|
how: { hosted: "make a picture of the real subject with generate_image", cli: "make a picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
|
|
537
551
|
},
|
|
552
|
+
{
|
|
553
|
+
layout: "l-cover-band", tag: "div", cls: "art", what: "the cover's picture", img: '<img class="art" src="…" alt="">, or use the cover-type layout for no picture',
|
|
554
|
+
how: { hosted: "make a wide picture of the real subject with generate_image", cli: "make a wide picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
layout: "l-cover-poster", tag: "div", cls: "art", what: "the cover's picture", img: '<img class="art" src="…" alt="">, or use the cover-type layout for no picture',
|
|
558
|
+
how: { hosted: "make a wide picture of the real subject with generate_image", cli: "make a wide picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
|
|
559
|
+
},
|
|
538
560
|
{
|
|
539
561
|
layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
|
|
540
562
|
how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
|
|
@@ -814,10 +836,27 @@ export function lookWarnings(html) {
|
|
|
814
836
|
if (iconImg(tag)) sets.add("image icons");
|
|
815
837
|
}
|
|
816
838
|
if (sets.size > 1) out.push(MESSAGES.mixedIcons([...sets].sort()));
|
|
817
|
-
out.push(...textureWarnings(all, html));
|
|
839
|
+
out.push(...textureWarnings(all, html), ...coverFadeWarnings(all, html));
|
|
818
840
|
return out;
|
|
819
841
|
}
|
|
820
842
|
|
|
843
|
+
// Covers never put text on the picture. Where the text overlaps a picture can only be
|
|
844
|
+
// measured by rendering, so this reads the one source pattern that always does: a cover
|
|
845
|
+
// layout whose CSS fades its picture with a gradient mask-image (the old hero's look,
|
|
846
|
+
// still inlined in decks made from it). A deck's own picture and text placement beyond
|
|
847
|
+
// that is left to the layouts and the review.
|
|
848
|
+
export const COVER_LAYOUTS = ["l-hero", "l-cover-band", "l-cover-poster", "l-cover-type"];
|
|
849
|
+
function coverFadeWarnings(all, html) {
|
|
850
|
+
const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
|
|
851
|
+
const faded = new Set();
|
|
852
|
+
for (const [, sel, body] of css.matchAll(/([^{}]*)\{([^{}]*)\}/g)) {
|
|
853
|
+
if (!/(?:^|;|\s)(?:-webkit-)?mask(?:-image)?\s*:[^;]*gradient\(/i.test(body)) continue;
|
|
854
|
+
for (const l of COVER_LAYOUTS) if (new RegExp(`\\.${l}(?![\\w-])`).test(sel)) faded.add(l);
|
|
855
|
+
}
|
|
856
|
+
if (!faded.size) return [];
|
|
857
|
+
return all.flatMap((s, i) => classes(s.tag).filter((c) => faded.has(c)).map((c) => MESSAGES.coverFade(`slide ${s.id || i + 1}`, c.slice(2))));
|
|
858
|
+
}
|
|
859
|
+
|
|
821
860
|
// Backgrounds (the look topic): the kit's textures are confined to the object they
|
|
822
861
|
// back. The retired whole-slide textures warn rather than vanish silently (a deck made
|
|
823
862
|
// before keeps their rules in its own inlined shell, where they still paint). Read from each slide's class attribute; a texture on a layout whose shell
|
|
@@ -832,10 +871,11 @@ const RETIRED_TEXTURES = {
|
|
|
832
871
|
const SIGNATURES = ["bg-corner", "bg-mark"];
|
|
833
872
|
export const MAX_SIGNATURE_SLIDES = 3;
|
|
834
873
|
const BUSY_LAYOUTS = ["l-number", "l-features", "l-team", "l-timeline", "l-compare"]; // full-width content: nothing to back
|
|
874
|
+
const PICTURE_COVERS = ["l-cover-band", "l-cover-poster"]; // the picture is the ground: a texture would only sit under it
|
|
835
875
|
export const TEXTURE_OFF = {
|
|
836
|
-
"bg-corner": ["l-split", "l-product", "l-chart", "l-quote", ...BUSY_LAYOUTS],
|
|
837
|
-
"bg-stage": ["l-split", "l-chart", "l-quote", "l-closer", ...BUSY_LAYOUTS],
|
|
838
|
-
"bg-mark": ["l-split", "l-product", "l-chart", "l-phones", ...BUSY_LAYOUTS],
|
|
876
|
+
"bg-corner": ["l-split", "l-product", "l-chart", "l-quote", ...BUSY_LAYOUTS, ...PICTURE_COVERS],
|
|
877
|
+
"bg-stage": ["l-split", "l-chart", "l-quote", "l-closer", ...BUSY_LAYOUTS, ...PICTURE_COVERS, "l-cover-type"],
|
|
878
|
+
"bg-mark": ["l-split", "l-product", "l-chart", "l-phones", ...BUSY_LAYOUTS, ...PICTURE_COVERS],
|
|
839
879
|
};
|
|
840
880
|
function textureWarnings(all, html) {
|
|
841
881
|
const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
|
package/src/commands.mjs
CHANGED
|
@@ -173,6 +173,65 @@ async function deckForward(pos, opts) {
|
|
|
173
173
|
console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
const TRANSFER_USAGE = "usage: deck transfer <slug> --to <email>";
|
|
177
|
+
async function deckTransfer(pos, opts) {
|
|
178
|
+
const [slug] = pos;
|
|
179
|
+
if (!slug || !opts.to) throw new Error(TRANSFER_USAGE);
|
|
180
|
+
const out = await ops.transferDeck(opts, slug, opts.to);
|
|
181
|
+
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
182
|
+
console.log(`OK ${out.existing ? "already offered" : "offered"} ${slug} to ${out.to}, until ${out.expiresAt.slice(0, 10)}`);
|
|
183
|
+
console.log(" Nothing moves until they accept (signed in to Hyperdecks with that email). Cancel: deck transfers cancel " + slug);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const TRANSFERS_USAGE = "usage: deck transfers [accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]... [--no-narrate] | decline <id> | cancel <slug>]";
|
|
187
|
+
// "1=Ana/Ana Face,2=stock:liam" → [{ guest: 1, voice: "Ana", avatar: "Ana Face" }, …]
|
|
188
|
+
export function parseGuestPicks(spec) {
|
|
189
|
+
if (!spec) return [];
|
|
190
|
+
return String(spec).split(",").map((part) => {
|
|
191
|
+
const m = /^\s*(\d+)\s*=\s*([^/]+?)\s*(?:\/\s*(.+?)\s*)?$/.exec(part);
|
|
192
|
+
if (!m) throw new Error(`--guest ${part}: write <n>=<voice> or <n>=<voice>/<avatar>`);
|
|
193
|
+
return { guest: Number(m[1]), voice: m[2], ...(m[3] ? { avatar: m[3] } : {}) };
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function deckTransfers(pos, opts) {
|
|
198
|
+
const [action, arg] = pos;
|
|
199
|
+
if (action === "accept") {
|
|
200
|
+
if (!arg) throw new Error(TRANSFERS_USAGE);
|
|
201
|
+
const out = await ops.acceptTransfer(opts, arg, { narrator: opts.narrator, guests: parseGuestPicks(opts.guest), renarrate: !opts["no-narrate"] });
|
|
202
|
+
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
203
|
+
console.log(`OK ${out.slug} is yours, with its share links and stats; links already sent keep working`);
|
|
204
|
+
if (out.narration?.jobId) console.log(` narrating your picks: job ${out.narration.jobId} (narrate status), then release ${out.slug}`);
|
|
205
|
+
else if (out.narration?.error) console.log(` ⚠ narration did not start: ${out.narration.error}\n ${out.narration.note}`);
|
|
206
|
+
else if (out.narration?.note) console.log(` ${out.narration.note}`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (action === "decline") {
|
|
210
|
+
if (!arg) throw new Error(TRANSFERS_USAGE);
|
|
211
|
+
const out = await ops.declineTransfer(opts, arg);
|
|
212
|
+
return void console.log(opts.json ? JSON.stringify(out) : `OK declined ${out.slug}`);
|
|
213
|
+
}
|
|
214
|
+
if (action === "cancel") {
|
|
215
|
+
if (!arg) throw new Error(TRANSFERS_USAGE);
|
|
216
|
+
const out = await ops.cancelTransfer(opts, arg);
|
|
217
|
+
return void console.log(opts.json ? JSON.stringify(out) : `OK cancelled the transfer of ${out.slug} to ${out.to}`);
|
|
218
|
+
}
|
|
219
|
+
if (action) throw new Error(TRANSFERS_USAGE);
|
|
220
|
+
const out = await ops.listTransfers(opts);
|
|
221
|
+
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
222
|
+
if (out.emailUnverified) console.log("⚠ your account's email is not verified, so transfers sent to it can't be shown or accepted");
|
|
223
|
+
if (!out.incoming.length && !out.outgoing.length) return void console.log("No transfers waiting.");
|
|
224
|
+
for (const t of out.incoming) {
|
|
225
|
+
console.log(`← ${t.slug} "${t.title}" from ${t.from ?? "someone"} (until ${t.expiresAt.slice(0, 10)}) id ${t.id}`);
|
|
226
|
+
if (t.needsNarrator) console.log(" narrated in the sender's narrator or voice: pick yours with --narrator <name|dash|none|default>");
|
|
227
|
+
for (const g of t.guests) {
|
|
228
|
+
console.log(` guest ${g.guest} ${g.name} (${g.selector}): voice ${g.voice}${g.avatar ? `, avatar ${g.avatar}` : ""}${g.needsPick ? " → pick with --guest " + g.guest + "=<voice>/<avatar>" : ""}`);
|
|
229
|
+
}
|
|
230
|
+
console.log(` accept: deck transfers accept ${t.id}${t.needsNarrator ? " --narrator <…>" : ""}${t.guests.filter((g) => g.needsPick).map((g) => ` --guest ${g.guest}=<voice>/<avatar>`).join("")}`);
|
|
231
|
+
}
|
|
232
|
+
for (const t of out.outgoing) console.log(`→ ${t.slug} "${t.title}" to ${t.to} (until ${t.expiresAt.slice(0, 10)}); cancel: deck transfers cancel ${t.slug}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
176
235
|
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]";
|
|
177
236
|
const VIEWS = { slides: "slides", scroll: "scroll", default: null };
|
|
178
237
|
const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
|
|
@@ -363,12 +422,20 @@ commands:
|
|
|
363
422
|
login [--token <pat>] [--broker <url>] [--dev]
|
|
364
423
|
logout [--dev]
|
|
365
424
|
whoami [--json] [--dev]
|
|
366
|
-
deck list|stats|open|link|rm|forward|view
|
|
425
|
+
deck list|stats|open|link|rm|forward|view|transfer|transfers
|
|
367
426
|
deck link <slug> [--json]
|
|
368
427
|
the owner link: present or check your own deck (only you, signed in; untracked;
|
|
369
428
|
Present or H hides your owner tools). Use it instead of a QA link. deck open opens it.
|
|
370
429
|
deck forward on|off <slug>
|
|
371
430
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
431
|
+
deck transfer <slug> --to <email>
|
|
432
|
+
give the deck to another Hyperdecks account (like a GitHub repo transfer). Nothing moves until
|
|
433
|
+
they accept, within 7 days; then its share links, recipients and stats are theirs, not yours.
|
|
434
|
+
deck transfers [accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]... [--no-narrate]
|
|
435
|
+
| decline <id> | cancel <slug>]
|
|
436
|
+
transfers waiting on you and the ones you sent. Accepting picks your narrator and a voice and
|
|
437
|
+
avatar for each guest in the sender's own voice or face, then narrates those lines as you;
|
|
438
|
+
the deck plays as released until you release it.
|
|
372
439
|
deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
|
|
373
440
|
how the deck opens in the player (default: as its HTML says, data-view or slides) and
|
|
374
441
|
whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
|
|
@@ -471,6 +538,9 @@ commands:
|
|
|
471
538
|
|
|
472
539
|
preview create <slug> --out <directory> [--share <token>] [--upload] [--json]
|
|
473
540
|
preview create --file <index.html> --out <directory> [--duration 4] [--width 600] [--delay 1000] [--label "Watch"] [--aspect-ratio 16:9]
|
|
541
|
+
preview create <slug> --out <directory> --scene <slide-id> [--start <seconds>] [--share <token> --upload]
|
|
542
|
+
the email GIF of one slide, from <seconds> into its narration (default 0): the guest
|
|
543
|
+
speaking then, its caption bubble and every cue fired by then; needs a player/v1 deck
|
|
474
544
|
preview setup install isolated Chromium (Linux: see README for system dependencies)
|
|
475
545
|
|
|
476
546
|
narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
|
|
@@ -483,6 +553,9 @@ commands:
|
|
|
483
553
|
voice rm <name|id> delete a voice, also at the provider
|
|
484
554
|
voice hear <voice> | voice hear --narrator <name|id>
|
|
485
555
|
a short sample of the voice: its clip URL and the line it says
|
|
556
|
+
avatar pack [list|create|get|add|grant|grants|revoke|publish|unpublish|invitations|claim|catalog]
|
|
557
|
+
owner-managed packs; create <name> --private-only permanently prevents publication
|
|
558
|
+
avatar team [list|create|members|add|remove] owner-managed account groups for pack grants
|
|
486
559
|
avatar [--json] your avatars (who presents decks in your voice) and the default
|
|
487
560
|
avatar new [--species <s>] pick a species, then an avatar (s shuffles); --json or an agent gets a pick link
|
|
488
561
|
avatar from-photo [file] [--save-to-minimoji]
|
|
@@ -805,7 +878,8 @@ function commandTable(pos, rest, opts) {
|
|
|
805
878
|
"preview create": async () => {
|
|
806
879
|
const { createPreview } = await import("./preview.mjs");
|
|
807
880
|
const result = await createPreview(rest, opts);
|
|
808
|
-
|
|
881
|
+
const shows = result.moment ? `Shows ${result.moment.scene} at ${result.moment.start}s${result.moment.guest ? `: ${result.moment.guest.caption}` : ""}\n` : "";
|
|
882
|
+
console.log(opts.json ? JSON.stringify(result) : `Preview saved: ${result.manifestPath}\n${shows}${result.email.sendable ? result.email.path : "Insert the GIF or PNG manually; upload with an existing share for linked email HTML."}`);
|
|
809
883
|
},
|
|
810
884
|
init: async () => {
|
|
811
885
|
const { init } = await import("./init.mjs");
|
|
@@ -913,6 +987,8 @@ function commandTable(pos, rest, opts) {
|
|
|
913
987
|
const out = await avatarList(opts);
|
|
914
988
|
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
915
989
|
},
|
|
990
|
+
"avatar pack": async () => (await import("./avatar-packs.mjs")).avatarPacksCommand(rest, opts),
|
|
991
|
+
"avatar team": async () => (await import("./avatar-packs.mjs")).avatarTeamsCommand(rest, opts),
|
|
916
992
|
"avatar new": async () => {
|
|
917
993
|
const { avatarNew } = await import("./avatar.mjs");
|
|
918
994
|
const { askLine } = await import("./init.mjs");
|
|
@@ -1019,6 +1095,8 @@ function commandTable(pos, rest, opts) {
|
|
|
1019
1095
|
"deck rm": () => deckRemove(rest, opts),
|
|
1020
1096
|
"deck forward": () => deckForward(rest, opts),
|
|
1021
1097
|
"deck view": () => deckView(rest, opts),
|
|
1098
|
+
"deck transfer": () => deckTransfer(rest, opts),
|
|
1099
|
+
"deck transfers": () => deckTransfers(rest, opts),
|
|
1022
1100
|
"share new": () => shareNew(rest, opts),
|
|
1023
1101
|
"share list": () => shareList(rest, opts),
|
|
1024
1102
|
"share on": () => shareToggle(rest, opts, true),
|
package/src/deckPlayer.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// runs its own runtime (a legacy copy-and-swap deck.js, a plain HTML export)
|
|
6
6
|
// gets none of them.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// A copy of a page our routes served names a pinned build (/player/v1/b/<hash>/player.js).
|
|
9
|
+
export const loadsPlayer = (html) => /\/player\/v1\/(?:b\/[0-9a-f]+\/)?player\.js\b/.test(String(html ?? ""));
|
|
9
10
|
|
|
10
11
|
const SCRIPT_SRC = /<script\b[^>]*\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>/gi;
|
|
11
12
|
const MAX_SCRIPT_NAME = 120;
|