@hypersocial/decks-cli 0.9.8 → 0.9.10
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 +1 -0
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +94 -28
- package/skills/hyperdecks/chat.md +13 -2
- package/src/avatar.mjs +13 -2
- package/src/brand.mjs +38 -5
- package/src/checkRules.mjs +79 -2
- package/src/commands.mjs +53 -15
- package/src/draftFace.mjs +56 -0
- package/src/export-video.mjs +15 -10
- package/src/mediaPreviewMake.mjs +72 -0
- package/src/mediaPreviews.mjs +160 -0
- package/src/narrate.mjs +6 -1
- package/src/ops.mjs +70 -12
- package/src/play.mjs +2 -2
- package/src/presenter.mjs +73 -9
- package/src/release.mjs +83 -11
- package/src/runtime.mjs +1 -1
- package/src/scaffold.mjs +27 -6
- package/src/source.mjs +2 -2
- package/templates/layouts/_deck.html +71 -32
- package/templates/layouts/closer.html +1 -1
- package/templates/layouts/phones.html +5 -5
- package/templates/layouts/split.html +2 -2
- package/templates/narrated-deck.html +3 -1
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ whoami [--json]
|
|
|
58
58
|
deck list [--json]
|
|
59
59
|
deck stats <slug> [--json]
|
|
60
60
|
deck open <slug>
|
|
61
|
+
deck link <slug> [--json] # the owner link: present or check your own deck, untracked
|
|
61
62
|
|
|
62
63
|
share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify]
|
|
63
64
|
share list <slug> [--json]
|
package/package.json
CHANGED
|
@@ -146,14 +146,22 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
146
146
|
| PDF for a data room or attachment | `hyperdecks export <slug> --pdf [--notes] --out deck.pdf`, `hyperdecks export list` | `export_deck`, `list_exports` |
|
|
147
147
|
| Video (MP4) for YouTube, LinkedIn or an attachment | `hyperdecks export <slug\|deck-dir> --video [--size 1080p\|720p\|4k] [--shape 16:9\|9:16\|1:1] [--title-card] [--end-card] [--no-captions] [--no-avatar] [--upload] --out deck.mp4`: records the narrated player in real time (a 4-minute deck takes about 5), avatar, voice and captions, no player chrome; a slug records the released copy, a deck folder its working copy; `--upload` lists it with the deck's exports | `export_deck { kind: "mp4" }` renders it on our render worker (the dashboard's Export → Video); poll `check_export`, or `hyperdecks export status <exportId>` |
|
|
148
148
|
| Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
|
|
149
|
-
| One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
|
|
149
|
+
| One more link | `hyperdecks share new <slug> "<name>" [--no-forward] [--pace auto\|slide\|step]` | `create_share` (`allowForward`, `pace`) |
|
|
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, pace | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default]` | `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
|
+
| Present or check your own deck (owner link) | `hyperdecks deck link <slug>` (`deck open` opens it) | `ownerUrl` from `list_decks` or `check_deck` |
|
|
153
154
|
| Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
|
|
154
155
|
| All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
|
|
155
156
|
| What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
|
|
156
157
|
|
|
158
|
+
To present or check your own deck, use the owner link (`deck link`,
|
|
159
|
+
`ownerUrl`): the released deck as recipients get it, for the owner only
|
|
160
|
+
(signed in), with nothing tracked. Anyone else gets a sign-in notice, so a
|
|
161
|
+
leaked owner link shows nothing. Its toolbar's Present (or H) hides the owner
|
|
162
|
+
tools for a screen share. QA links are only for testing what a recipient sees;
|
|
163
|
+
never make one to present on a call.
|
|
164
|
+
|
|
157
165
|
Forwarding is on by default: a viewer can forward the deck to someone, which
|
|
158
166
|
makes a child link tracked on its own ("Dana via Peter"; `share list` shows it
|
|
159
167
|
indented under Peter's, `list_shares` gives `parentToken` and `forwardedBy`).
|
|
@@ -266,17 +274,27 @@ The deck contract (`check` enforces or warns on each line):
|
|
|
266
274
|
<!-- topic: presenter -->
|
|
267
275
|
#### Which presenter style
|
|
268
276
|
|
|
269
|
-
**
|
|
270
|
-
writes
|
|
271
|
-
`
|
|
272
|
-
narrator's
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
277
|
+
**The CLI writes the face into the tag; leave it alone.** `deck new --narrated`
|
|
278
|
+
writes the narrator's face on the player tag (`data-presenter="head"
|
|
279
|
+
data-avatar="…" data-presenter-from="narrator"`): the account's default
|
|
280
|
+
narrator's, else D.A.S.H.'s. `deck narrator <slug> <name>` and `narrate`
|
|
281
|
+
rewrite it to the narrator's face and print `presenter: …, written to
|
|
282
|
+
index.html`; `deck narrator <slug> none` (or `deck new --captions-only`)
|
|
283
|
+
writes captions only on purpose. So the file plays with its face however it is
|
|
284
|
+
opened, a static server included. `release` (and `release_deck`) follows the
|
|
285
|
+
narrator: a `data-presenter-from="narrator"` face is replaced by the current
|
|
286
|
+
narrator's (your own narrator's avatar, or D.A.S.H.'s live head for a deck in
|
|
287
|
+
D.A.S.H.'s voice). It says `presented by …` only when the published page has
|
|
288
|
+
that face; otherwise it warns `releasing captions only` with the reason. To pick
|
|
289
|
+
the face by hand, set both attributes and no `data-presenter-from`:
|
|
290
|
+
`data-presenter="head" data-avatar="…"`. D.A.S.H.'s is
|
|
276
291
|
`data-presenter="head" data-avatar="https://hyperdecks.ai/player/v1/dash-avatar.json"`
|
|
277
292
|
(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
|
-
|
|
293
|
+
captions only, and so does a `data-avatar` beside `data-presenter="none"`, and
|
|
294
|
+
so does a plain `data-presenter="none"` from an older scaffold.
|
|
295
|
+
`check` warns on all three, and `release` leaves a hand-set `data-avatar` alone.
|
|
296
|
+
A hosted draft (`new_deck`) stores the tag as the template wrote it; its draft
|
|
297
|
+
link and `release_deck` add the narrator's face, so `check_deck` does not warn on its plain none.
|
|
280
298
|
|
|
281
299
|
Set the deck's on the player tag (`data-presenter-style="stage"`) or in
|
|
282
300
|
script.md's front matter (`presenterStyle: stage`); override one slide with
|
|
@@ -492,7 +510,7 @@ suit it.
|
|
|
492
510
|
|
|
493
511
|
**Phones and backgrounds.** One phone: `product`'s `.frame.card`; a flow of
|
|
494
512
|
two to four: `phones` (`--tilt`, `--shadow` on a card). A few slides at most:
|
|
495
|
-
`bg-
|
|
513
|
+
`bg-stage` (never under text), a `.deco` of inked tiles.
|
|
496
514
|
|
|
497
515
|
Presenter style follows the deck (step 2): `stage` when the presenter carries
|
|
498
516
|
the story (the layouts keep the slot's corner clear); `corner` where a big
|
|
@@ -552,7 +570,8 @@ still.
|
|
|
552
570
|
- `[go]` when the narration walks through items (cards, milestones, people,
|
|
553
571
|
the action on the closer): one `go` per item at the word that starts it,
|
|
554
572
|
then `[go: slot]` before the slide ends. Never more than one `go` per
|
|
555
|
-
sentence; never a `go` on a slide in `corner` style.
|
|
573
|
+
sentence; never a `go` on a slide in `corner` style. A `go` keeps off
|
|
574
|
+
the picture; with no room it stays home.
|
|
556
575
|
- `[look]` for a glance: the title as it is read, a number as it is said, a
|
|
557
576
|
screenshot as the narration turns to it.
|
|
558
577
|
- `[reveal]` for the one detail that should not be read before it is said (a
|
|
@@ -564,8 +583,8 @@ still.
|
|
|
564
583
|
<!-- topic: look -->
|
|
565
584
|
#### Look: textures, icons, pictures
|
|
566
585
|
|
|
567
|
-
What the house decks do
|
|
568
|
-
|
|
586
|
+
What the house decks do. Decide these three once per deck, before writing
|
|
587
|
+
slides, and say them in your plan.
|
|
569
588
|
|
|
570
589
|
**1. A picture on at least every other slide**, the most real one you have:
|
|
571
590
|
|
|
@@ -579,18 +598,22 @@ once per deck, before writing slides, and say them in your plan.
|
|
|
579
598
|
No product to show (a plan, a strategy)? Make the slides visual with type
|
|
580
599
|
and the kit below: a `number` slide, stickers, ticks on a `panel`.
|
|
581
600
|
|
|
582
|
-
**2.
|
|
583
|
-
|
|
601
|
+
**2. Plain grounds; texture only behind objects.** Alternate plain,
|
|
602
|
+
`on-tint`, `on-tint-2`, `on-tint-3`, `on-accent`, `on-ink`. On the section:
|
|
584
603
|
|
|
585
604
|
| Class | Looks like | For |
|
|
586
605
|
|---|---|---|
|
|
587
|
-
| `bg-
|
|
588
|
-
| `bg-
|
|
589
|
-
| `bg-
|
|
590
|
-
| `bg-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
606
|
+
| `bg-pool` | a soft light behind the picture | any picture |
|
|
607
|
+
| `bg-corner` | a halftone quarter-disc, top-right | cover, closer |
|
|
608
|
+
| `bg-stage` | a dot plate under a mockup | product, phones |
|
|
609
|
+
| `bg-mark` | the slide number, huge, faint (`--mark:'04'`) | cover, opener, closer |
|
|
610
|
+
|
|
611
|
+
- One signature per deck (`bg-corner` or `bg-mark`), on three slides at
|
|
612
|
+
most: the cover, an opener or the ask, the closer.
|
|
613
|
+
- `bg-pool` and `bg-stage` are picture helpers: only beside an image.
|
|
614
|
+
- Nothing under text, a chart or a photo; the presenter's corner stays clear.
|
|
615
|
+
- Your own is fine in the deck's `<style>`: tokens only, behind the content
|
|
616
|
+
in an isolated slide, off with nothing to back.
|
|
594
617
|
|
|
595
618
|
**3. One icon set.** Pick one source and keep it for every icon in the deck:
|
|
596
619
|
Lucide (`search_icons`, same `strokeWidth` throughout); or one
|
|
@@ -610,7 +633,7 @@ palette <ink hex> <accent hex> <paper hex>, no text, no logos`
|
|
|
610
633
|
(`generate_image { brand: true }` adds the palette and the "no text" for
|
|
611
634
|
you). Generate at the slide's shape (wide for a hero), in one batch.
|
|
612
635
|
|
|
613
|
-
**Slide recipes.** Opener: `on-accent` + `bg-
|
|
636
|
+
**Slide recipes.** Opener: `on-accent` + `bg-corner`, the logo
|
|
614
637
|
or a few app-icon stickers, one display line. Product or feature: `split`
|
|
615
638
|
with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
|
|
616
639
|
`timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
|
|
@@ -692,6 +715,43 @@ you do).
|
|
|
692
715
|
| `[smile]` / `[think]` | `[mood: happy]` / `[mood: thinking]` |
|
|
693
716
|
| `[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) |
|
|
694
717
|
| `[custom: name]` | only the slide's controller sees it |
|
|
718
|
+
| `[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 |
|
|
719
|
+
- Guest speakers: other avatars that speak their own lines on a slide, no
|
|
720
|
+
slide JS. A `[speaker: <selector> voice=<voice> avatar=<face> name=<caption name>]`
|
|
721
|
+
line runs to the end of its paragraph; the narrator takes the next one:
|
|
722
|
+
|
|
723
|
+
```
|
|
724
|
+
## own
|
|
725
|
+
Let's hear them.
|
|
726
|
+
|
|
727
|
+
[speaker: #own-c1 voice=stock:sarah avatar=cat] Ooh, nice move!
|
|
728
|
+
|
|
729
|
+
[speaker: #own-c2 voice=matilda avatar=monkey name="Mono"] [reveal: #own-c2] Hola, ¿qué tal?
|
|
730
|
+
|
|
731
|
+
That's the idea.
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
`voice` is one of the user's voices, a built-in `stock:<id>` or a character
|
|
735
|
+
voice. `avatar` is a cast id (`cat`, `kitty`, `monkey`, `dog`, `rabbit`,
|
|
736
|
+
`penguin`, `ghost`, `alien`, `agent`…), one of the user's avatars or a
|
|
737
|
+
narrator's name; without one the guest speaks with captions only. `name`
|
|
738
|
+
labels its captions (default: the avatar, capitalised). The guest's head
|
|
739
|
+
fills the element the selector names, or the `[data-hd-speaker]` spot
|
|
740
|
+
inside it, so give each card a face box (e.g. 140×140). Narrate renders
|
|
741
|
+
each line in its own voice (cached, transcript-checked) into the slide's
|
|
742
|
+
timeline: the narrator pauses, the guests speak in turn with lip-sync and a
|
|
743
|
+
caption bubble by their element, its tail at their face, the spoken word
|
|
744
|
+
highlighted ("Cat: Ooh, nice move!"), only while they talk; the speaker's
|
|
745
|
+
element is spotlit and the other guests dim. The player draws that bubble:
|
|
746
|
+
don't put static speech bubbles or the guest's line on the slide too. The presenter
|
|
747
|
+
glances at whoever speaks, then the narrator goes on. A cue may open a
|
|
748
|
+
guest's line and fires as it starts. Inside a guest's line, `[nod]`,
|
|
749
|
+
`[shake]`, `[brow]` and `[gesture]` move the guest's head; the other cues
|
|
750
|
+
(`[reveal]`, `[go]`, `[mood]`, `[wave]`…) act on the slide and the presenter. Replay, Per step (each guest line is a
|
|
751
|
+
step), mute, You present and video export all include them.
|
|
752
|
+
- Audio and data files a slide uses itself (a sound effect, a chart's JSON):
|
|
753
|
+
`hyperdecks asset add` / `upload_deck_asset` take `.mp3`, `.m4a` and
|
|
754
|
+
`.json` as well as images, video and fonts.
|
|
695
755
|
- `voice: dash` narrates with D.A.S.H.'s own robot voice, a synthetic
|
|
696
756
|
character voice (not a clone of anyone) any account may use, for decks that
|
|
697
757
|
D.A.S.H. presents. It works once the deployment sets `CHARACTER_VOICE_DASH`.
|
|
@@ -803,6 +863,10 @@ browser (an agent reads the URL; `--json` prints it as one line).
|
|
|
803
863
|
`release --qa` is not a preview: it needs a commit, and it changes what every
|
|
804
864
|
link to this deck plays.
|
|
805
865
|
|
|
866
|
+
Watch a draft with `hyperdecks play`: it shows the face the deck will ship
|
|
867
|
+
with. A plain static server shows the file as written: the face named in the
|
|
868
|
+
tag (the narrator's, since `deck new`, `deck narrator` and `narrate` write it
|
|
869
|
+
there), or none for a tag that says `data-presenter="none"` (`check` warns).
|
|
806
870
|
For the layout pass, serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
|
|
807
871
|
and screenshot it with Playwright at 1440×900 and 390×844, every slide
|
|
808
872
|
(`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
|
|
@@ -867,8 +931,10 @@ hyperdecks engagement "<name>" --deck <slug> --json # MCP: recipient_engagemen
|
|
|
867
931
|
Then draft the email:
|
|
868
932
|
|
|
869
933
|
1. Lead with the slide they spent most time on (`summary.mostTime`) or
|
|
870
|
-
replayed (`replayedSlides`
|
|
871
|
-
|
|
934
|
+
replayed (`replayedSlides`; `slideReplays` are the ones they replayed on
|
|
935
|
+
purpose, most first, and `replayNote` says it in one line), and answer what
|
|
936
|
+
that slide raises. Each slide's `title` says what it is about. Watching it
|
|
937
|
+
again from the start (`watchedAgain`) is strong interest.
|
|
872
938
|
2. If they dropped off (`droppedOffOn`), offer the rest briefly, or a
|
|
873
939
|
2-minute call, rather than asking them to rewatch.
|
|
874
940
|
3. If they never opened it (`summary.visits` is 0), write a short nudge
|
|
@@ -889,7 +955,7 @@ the hosted MCP (`deck_guide`, `new_deck`, `get_deck_source`, `write_deck_source`
|
|
|
889
955
|
`upload_deck_asset`, `check_deck`, `release_deck`; see `chat.md`). Move between
|
|
890
956
|
the two with `hyperdecks source pull <slug> [dir]` (the hosted draft into a
|
|
891
957
|
folder) and `hyperdecks source push <slug> [dir]` (a folder's files back to the
|
|
892
|
-
draft), `hyperdecks asset add <slug> <file|url>` for an image, `hyperdecks guide
|
|
958
|
+
draft), `hyperdecks asset add <slug> <file|url>` for an image, video, audio (.mp3, .m4a), font or JSON file, `hyperdecks guide
|
|
893
959
|
[topic]` for these sections, and `--hosted` on `deck new`, `check` and `release`.
|
|
894
960
|
|
|
895
961
|
## 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
|
|
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
|
|
|
@@ -88,6 +90,11 @@ makes the hello deck; continue from step 5 below.
|
|
|
88
90
|
only, when nobody chose that, is refused: hand the person its Add narrator
|
|
89
91
|
link, or offer D.A.S.H., and pass `captionsOnly: true` only if they say so.
|
|
90
92
|
The result's `presenter` names who presents.
|
|
93
|
+
To present or check your own deck (on a call, say), give the user its
|
|
94
|
+
owner link, `ownerUrl` from `list_decks` or `check_deck`: only they can
|
|
95
|
+
open it, signed in, and nothing is tracked; Present (or H) hides their
|
|
96
|
+
owner tools for a screen share. QA links are only for testing what a
|
|
97
|
+
recipient sees, not for presenting.
|
|
91
98
|
10. Later: `list_shares` and `deck_stats` (both show the card),
|
|
92
99
|
`activity_overview`; `email_embed { token }` for the HTML that pastes into an
|
|
93
100
|
email as the deck's moving preview (it needs a recipient's link, not a QA
|
|
@@ -95,7 +102,11 @@ makes the hello deck; continue from step 5 below.
|
|
|
95
102
|
for how the deck opens, whether viewers may switch, and its pace (`auto`
|
|
96
103
|
plays itself, `slide` waits for Next after each slide, `step` also stops
|
|
97
104
|
before each `[reveal]`, so group a slide's reveals into meaningful steps)
|
|
98
|
-
(every link, next load, no re-release)
|
|
105
|
+
(every link, next load, no re-release). Any share link + `/md` (e.g.
|
|
106
|
+
https://decks.hypersocial.ai/v/<token>/md) returns the deck as markdown
|
|
107
|
+
(slide text, image descriptions and the narration), honouring the link's
|
|
108
|
+
gates; owners can turn it off with `set_deck_view { slug, aiReadable: false }`.
|
|
109
|
+
`set_share_notify` to mute a link's "opened" alerts;
|
|
99
110
|
`set_recipient_archived` to tidy the activity feed; `credits` for the
|
|
100
111
|
balance. `remove_deck` deletes a deck and breaks every link to it: confirm
|
|
101
112
|
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;
|
package/src/brand.mjs
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// only the tokens here touch the deck.
|
|
9
9
|
|
|
10
10
|
// Weights each role loads: the templates set body text at 500, titles at 700
|
|
11
|
-
// and labels in mono at 400/700. Every family
|
|
11
|
+
// and labels in mono at 400/700. Every pairing family has these on Google Fonts;
|
|
12
|
+
// a display family outside the pairings loads what displayFace says it has.
|
|
12
13
|
const ROLE_WEIGHTS = { display: "600;700", body: "400;500;700", mono: "400;700" };
|
|
13
14
|
|
|
14
15
|
// 16 pairings, display + body + mono, each a family Google Fonts serves at the
|
|
@@ -32,8 +33,35 @@ export const FONT_PAIRINGS = [
|
|
|
32
33
|
{ id: "grotesk", display: "Schibsted Grotesk", body: "Work Sans", mono: "Roboto Mono", use: "Marketplaces and media brands; confident and newsy." },
|
|
33
34
|
];
|
|
34
35
|
|
|
35
|
-
// Weights for families that do not have every role weight (load what exists)
|
|
36
|
-
|
|
36
|
+
// Weights for families that do not have every role weight (load what exists),
|
|
37
|
+
// including single-weight display faces a brand may name (Google Fonts refuses
|
|
38
|
+
// a stylesheet that asks for a weight the family lacks).
|
|
39
|
+
const SINGLE_WEIGHT = ["Bangers", "Bebas Neue", "Anton", "Luckiest Guy", "Permanent Marker", "Pacifico", "Lobster", "Abril Fatface", "Alfa Slab One", "Righteous", "Press Start 2P", "Black Ops One", "Bungee", "Russo One", "Shrikhand", "Monoton", "Creepster", "Special Elite"];
|
|
40
|
+
const FAMILY_WEIGHTS = {
|
|
41
|
+
"Space Mono": "400;700", "DM Mono": "400;500", "Libre Baskerville": "400;700", "Courier Prime": "400;700", "Syne": "600;700",
|
|
42
|
+
...Object.fromEntries(SINGLE_WEIGHT.map((f) => [f, "400"])),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// The template's titles (700, -.045em) were tuned for the pairings' sans
|
|
46
|
+
// display faces. Any other display face gets tracking 0; a face with a known
|
|
47
|
+
// weight table is set at its heaviest weight. A face we know nothing about stays
|
|
48
|
+
// at 700 and loads 400;700: Google Fonts' css2 serves whichever of the asked
|
|
49
|
+
// weights exist (it fails only when none do), and the templates' font-synthesis:
|
|
50
|
+
// none means a face without a 700 draws its real 400, never a faked bold.
|
|
51
|
+
const SERIF_DISPLAY = new Set(["Fraunces", "Playfair Display", "Newsreader", "Libre Baskerville"]);
|
|
52
|
+
const TUNED_DISPLAY = new Set(FONT_PAIRINGS.map((p) => p.display).filter((f) => !SERIF_DISPLAY.has(f)));
|
|
53
|
+
// Any family a pairing names (display or body) has the display weights.
|
|
54
|
+
const PAIRED_DISPLAY = new Set(FONT_PAIRINGS.flatMap((p) => [p.display, p.body]));
|
|
55
|
+
|
|
56
|
+
// { weights, weight, tracking } for a display family: the weights to load, and
|
|
57
|
+
// the --display-weight / --display-tracking to write (null: the template's).
|
|
58
|
+
export function displayFace(family) {
|
|
59
|
+
const f = String(family ?? "").trim();
|
|
60
|
+
const weights = FAMILY_WEIGHTS[f] ?? (PAIRED_DISPLAY.has(f) ? ROLE_WEIGHTS.display : "400;700");
|
|
61
|
+
if (TUNED_DISPLAY.has(f)) return { weights, weight: null, tracking: null };
|
|
62
|
+
const heaviest = Math.max(...weights.split(";").map(Number));
|
|
63
|
+
return { weights, weight: heaviest === 700 ? null : String(heaviest), tracking: "0" };
|
|
64
|
+
}
|
|
37
65
|
|
|
38
66
|
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
39
67
|
// A Google Fonts family name: letters, digits and spaces (e.g. "Source Sans 3").
|
|
@@ -54,7 +82,7 @@ export function findPairing(id) {
|
|
|
54
82
|
// The Google Fonts stylesheet URL for one family at the weights its roles use
|
|
55
83
|
// (a family that is both display and body loads both roles' weights).
|
|
56
84
|
export function googleFontsUrl(family, roles = "body") {
|
|
57
|
-
const wanted = [roles].flat().flatMap((r) => (ROLE_WEIGHTS[r] ?? "400;700").split(";"));
|
|
85
|
+
const wanted = [roles].flat().flatMap((r) => (r === "display" ? displayFace(family).weights : ROLE_WEIGHTS[r] ?? "400;700").split(";"));
|
|
58
86
|
const weights = FAMILY_WEIGHTS[family] ?? [...new Set(wanted)].sort().join(";");
|
|
59
87
|
return `https://fonts.googleapis.com/css2?family=${family.trim().replace(/ /g, "+")}:wght@${weights}&display=swap`;
|
|
60
88
|
}
|
|
@@ -115,7 +143,12 @@ export function applyBrand(html, brand) {
|
|
|
115
143
|
for (const [k, v] of Object.entries(b.colors)) set(CSS_VAR[k], v);
|
|
116
144
|
if (b.fonts.body) set("--font", fontStack(b.fonts.body, "body"));
|
|
117
145
|
if (b.fonts.mono) set("--mono", fontStack(b.fonts.mono, "mono"));
|
|
118
|
-
if (b.fonts.display)
|
|
146
|
+
if (b.fonts.display) {
|
|
147
|
+
set("--display", fontStack(b.fonts.display, "display"));
|
|
148
|
+
const face = displayFace(b.fonts.display);
|
|
149
|
+
if (face.weight) set("--display-weight", face.weight);
|
|
150
|
+
if (face.tracking) set("--display-tracking", face.tracking);
|
|
151
|
+
}
|
|
119
152
|
let out = html.slice(0, root.index) + block + html.slice(root.index + root[0].length);
|
|
120
153
|
|
|
121
154
|
const families = FONT_ROLES.filter((r) => b.fonts[r]).map((r) => [b.fonts[r], r]);
|
package/src/checkRules.mjs
CHANGED
|
@@ -37,6 +37,7 @@ export const MESSAGES = {
|
|
|
37
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",
|
|
38
38
|
clipField: (id, field) => `clip ${id}: ${field}`,
|
|
39
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`,
|
|
40
41
|
order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
|
|
41
42
|
total: (sum, declared) => `clip durations plus holds add up to ${sum}s but the manifest declares totalDuration ${declared}s`,
|
|
42
43
|
cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
|
|
@@ -62,6 +63,7 @@ export const MESSAGES = {
|
|
|
62
63
|
gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
|
|
63
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)`,
|
|
64
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`,
|
|
66
|
+
presenterCaptions: () => `the player tag plays captions only as written (data-presenter="none" or none set), so the file served as is (npx serve, python3 -m http.server, the screenshot pass) shows no face: run \`hyperdecks deck narrator <slug> <name>\` (or narrate again) to write the narrator's face into index.html, or watch it with \`hyperdecks play <deck>\`; release adds the face either way. Captions only on purpose: \`hyperdecks deck narrator <slug> none\``,
|
|
65
67
|
presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
|
|
66
68
|
presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
|
|
67
69
|
transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
|
|
@@ -73,6 +75,9 @@ export const MESSAGES = {
|
|
|
73
75
|
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)`,
|
|
74
76
|
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)`,
|
|
75
77
|
mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
|
|
78
|
+
retiredTexture: (where, cls, instead, inlined) => `${where}: ${cls} is a whole-slide texture the kit no longer ships ${inlined ? "(this deck still carries its rule, so it runs under the text)" : "(it ran under the text), so it paints nothing"}; ${instead} (deck_guide topic look)`,
|
|
79
|
+
tooManySignatures: (n) => `${n} slides carry a signature background (bg-corner or bg-mark); keep it to three at most: the cover, one opener or the ask, the closer (deck_guide topic look)`,
|
|
80
|
+
textureOff: (where, cls, layout) => `${where}: ${cls} does nothing on ${layout} (the shell switches it off there: nothing to back); remove it, or use bg-pool behind the picture (deck_guide topic look)`,
|
|
76
81
|
// A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
|
|
77
82
|
placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
|
|
78
83
|
// Warnings (check --layout): the deck still passes.
|
|
@@ -605,6 +610,23 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
|
|
|
605
610
|
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
606
611
|
const isStr = (v) => typeof v === "string" && v.trim().length > 0;
|
|
607
612
|
|
|
613
|
+
// Whether a guest's selector names something on its slide: an #id, or a
|
|
614
|
+
// .class or [attr] inside that <section>. Other selectors are taken on trust.
|
|
615
|
+
export function speakerOnSlide(html, slideId, selector) {
|
|
616
|
+
const page = uncommented(html);
|
|
617
|
+
const open = new RegExp(`<section\\b[^>]*\\sid\\s*=\\s*["']${slideId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'][^>]*>`, "i").exec(page);
|
|
618
|
+
if (!open) return false;
|
|
619
|
+
const end = page.indexOf("</section>", open.index);
|
|
620
|
+
const section = page.slice(open.index, end < 0 ? undefined : end);
|
|
621
|
+
const id = /^#([\w-]+)$/.exec(selector.trim());
|
|
622
|
+
if (id) return new RegExp(`\\sid\\s*=\\s*["']${id[1]}["']`).test(section);
|
|
623
|
+
const cls = /^\.([\w-]+)$/.exec(selector.trim());
|
|
624
|
+
if (cls) return new RegExp(`\\sclass\\s*=\\s*["'][^"']*\\b${cls[1]}\\b`).test(section);
|
|
625
|
+
const attr = /^\[([\w-]+)/.exec(selector.trim());
|
|
626
|
+
if (attr) return new RegExp(`\\s${attr[1]}(\\s*=|[\\s>])`).test(section);
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
|
|
608
630
|
// Fields every clip needs before its other rules mean anything.
|
|
609
631
|
export function clipFieldErrors(clip, i) {
|
|
610
632
|
const id = isStr(clip?.id) ? clip.id : `#${i + 1}`;
|
|
@@ -792,6 +814,44 @@ export function lookWarnings(html) {
|
|
|
792
814
|
if (iconImg(tag)) sets.add("image icons");
|
|
793
815
|
}
|
|
794
816
|
if (sets.size > 1) out.push(MESSAGES.mixedIcons([...sets].sort()));
|
|
817
|
+
out.push(...textureWarnings(all, html));
|
|
818
|
+
return out;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Backgrounds (the look topic): the kit's textures are confined to the object they
|
|
822
|
+
// back. The retired whole-slide textures warn rather than vanish silently (a deck made
|
|
823
|
+
// 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
|
|
824
|
+
// rule switches it off (TEXTURE_OFF, pinned to _deck.html by lib/deckLayouts.test.ts) did nothing, and a custom
|
|
825
|
+
// slide (no l-* class) is never judged on that. Commented-out slides do not count.
|
|
826
|
+
const RETIRED_TEXTURES = {
|
|
827
|
+
"bg-rays": "use a plain ground, or bg-corner on the cover and the closer",
|
|
828
|
+
"bg-lines": "use a plain ground, or bg-pool behind the picture",
|
|
829
|
+
"bg-halftone": "use bg-corner on the cover and the closer",
|
|
830
|
+
"bg-dots": "use bg-stage under the mockup or the phones",
|
|
831
|
+
};
|
|
832
|
+
const SIGNATURES = ["bg-corner", "bg-mark"];
|
|
833
|
+
export const MAX_SIGNATURE_SLIDES = 3;
|
|
834
|
+
const BUSY_LAYOUTS = ["l-number", "l-features", "l-team", "l-timeline", "l-compare"]; // full-width content: nothing to back
|
|
835
|
+
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],
|
|
839
|
+
};
|
|
840
|
+
function textureWarnings(all, html) {
|
|
841
|
+
const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
|
|
842
|
+
const inlined = (c) => new RegExp(`\\.${c}(?![\\w-])[^{}]*\\{`).test(css);
|
|
843
|
+
const out = [];
|
|
844
|
+
let signatures = 0;
|
|
845
|
+
all.forEach((s, i) => {
|
|
846
|
+
const where = `slide ${s.id || i + 1}`;
|
|
847
|
+
const cls = classes(s.tag);
|
|
848
|
+
for (const [c, instead] of Object.entries(RETIRED_TEXTURES)) if (cls.includes(c)) out.push(MESSAGES.retiredTexture(where, c, instead, inlined(c)));
|
|
849
|
+
if (SIGNATURES.some((c) => cls.includes(c))) signatures++;
|
|
850
|
+
const layout = cls.find((c) => /^l-[\w-]+$/.test(c));
|
|
851
|
+
if (!layout) return;
|
|
852
|
+
for (const [c, off] of Object.entries(TEXTURE_OFF)) if (cls.includes(c) && off.includes(layout)) out.push(MESSAGES.textureOff(where, c, layout));
|
|
853
|
+
});
|
|
854
|
+
if (signatures > MAX_SIGNATURE_SLIDES) out.push(MESSAGES.tooManySignatures(signatures));
|
|
795
855
|
return out;
|
|
796
856
|
}
|
|
797
857
|
|
|
@@ -937,12 +997,29 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
937
997
|
continue;
|
|
938
998
|
}
|
|
939
999
|
errors.push(...timelineErrors(clip, timeline));
|
|
1000
|
+
// Guest lines ([speaker: …]): each is audio of its own, and each speaker needs its element on the slide.
|
|
1001
|
+
for (const [g, guest] of (Array.isArray(clip.guests) ? clip.guests : []).entries()) {
|
|
1002
|
+
let url;
|
|
1003
|
+
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; }
|
|
1004
|
+
const got = await fetchCheck(fetchImpl, url, AUDIO);
|
|
1005
|
+
if (got.why) errors.push(MESSAGES.url(clip.id, `guest line ${g + 1} audio`, url, got.why));
|
|
1006
|
+
else await got.response.body?.cancel?.().catch(() => {});
|
|
1007
|
+
}
|
|
1008
|
+
for (const sp of Array.isArray(clip.speakers) ? clip.speakers : []) {
|
|
1009
|
+
if (sp?.selector && !speakerOnSlide(html, clip.id, sp.selector)) warnings.push(MESSAGES.speakerMissing(clip.id, sp.name ?? "a guest", sp.selector));
|
|
1010
|
+
}
|
|
940
1011
|
}
|
|
941
1012
|
}
|
|
942
1013
|
warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
|
|
943
1014
|
// A narrated deck's tag that shows no face and says otherwise (a silent deck has no presenter).
|
|
944
|
-
|
|
945
|
-
|
|
1015
|
+
// A hosted draft plays only through its draft link and release_deck, which add the face, so a plain none is no issue there.
|
|
1016
|
+
const found = silent ? null : presenterTagIssue(html);
|
|
1017
|
+
const tagIssue = found?.kind === "captions" && opts.hosted ? null : found;
|
|
1018
|
+
if (tagIssue) {
|
|
1019
|
+
warnings.push(tagIssue.kind === "kept" ? MESSAGES.presenterKept(tagIssue.presenter)
|
|
1020
|
+
: tagIssue.kind === "captions" ? MESSAGES.presenterCaptions()
|
|
1021
|
+
: MESSAGES.presenterUnfilled(tagIssue.presenter));
|
|
1022
|
+
}
|
|
946
1023
|
for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
|
|
947
1024
|
for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
|
|
948
1025
|
if (opts.hosted) {
|