@hypersocial/decks-cli 0.6.0 → 0.8.0
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 +7 -1
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +64 -9
- package/skills/hyperdecks/chat.md +26 -9
- package/src/avatar.mjs +1 -1
- package/src/brandCli.mjs +86 -2
- package/src/brandImport.mjs +991 -0
- package/src/brandPreview.mjs +31 -0
- package/src/checkRules.mjs +39 -11
- package/src/commands.mjs +49 -11
- package/src/importer.mjs +1 -1
- package/src/ops.mjs +58 -12
- package/src/play.mjs +119 -0
- package/src/presenter.mjs +14 -7
- package/src/preview-capture.mjs +5 -3
- package/src/runtime.mjs +10 -2
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ voice list|clone|upload|rm
|
|
|
79
79
|
|
|
80
80
|
deck new <slug> "<title>" --narrated [--dir <path>]
|
|
81
81
|
check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
|
|
82
|
+
play <slug|deck-dir> [--voice <v>] [--slug <slug>] [--port <n>] [--no-open] [--json]
|
|
82
83
|
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
|
|
83
84
|
```
|
|
84
85
|
|
|
@@ -93,7 +94,12 @@ clip's audio and timeline resolve with the right type; captions are in time,
|
|
|
93
94
|
cover the clip text and run 4 to 85 characters; local `src`/`href` resolve inside
|
|
94
95
|
the folder; the player is the pinned `player/v1`; `--forbid` phrases appear
|
|
95
96
|
nowhere; every slide has a `data-note` and long titles a `data-title-mobile`.
|
|
96
|
-
`
|
|
97
|
+
`play` plays the folder as it is, committed or not, with the real face and voice:
|
|
98
|
+
the face goes on the served copy in memory, on 127.0.0.1 behind a random path, until
|
|
99
|
+
Ctrl-C. It writes no file, uploads nothing, creates no share and renders no audio, so
|
|
100
|
+
changed lines need `narrate` first. The hosted draft link `/p/<token>` shows the same face.
|
|
101
|
+
`release` refuses uncommitted changes in the deck folder (`--qa` too: a release
|
|
102
|
+
replaces what every link to the deck plays), runs check and narrate
|
|
97
103
|
(stopping for a commit if narration changed the folder), imports `index.html` with
|
|
98
104
|
the local files it references, and creates one share per `--to`. The agent
|
|
99
105
|
procedure is the "Author a narrated deck" section of the bundled skill.
|
package/package.json
CHANGED
|
@@ -66,7 +66,8 @@ returns a link; hand it to them, word for word, and wait.
|
|
|
66
66
|
The faces narrators wear; these commands still work as before.
|
|
67
67
|
|
|
68
68
|
A deck narrated in the user's own voice is presented by their default avatar
|
|
69
|
-
(a cartoon Minimoji, never a photo)
|
|
69
|
+
(a cartoon Minimoji, never a photo): in `play` and the draft link at once, on
|
|
70
|
+
share links from its next release. With none it plays
|
|
70
71
|
captions only. D.A.S.H. presents only in its own voice. **Never pick or approve
|
|
71
72
|
a face for the person.** Every command that needs a choice returns a link (and,
|
|
72
73
|
for an import, a code): hand it to them, word for word, and wait.
|
|
@@ -124,6 +125,9 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
124
125
|
| Step | CLI | MCP tool |
|
|
125
126
|
|---|---|---|
|
|
126
127
|
| Brand (once; every new deck starts from it) | `hyperdecks brand show` / `brand set` / `brand edit` | `get_brand` / `set_brand` |
|
|
128
|
+
| Import a brand (website, .pptx/.potx, .pdf, a deck; preview first) | `hyperdecks brand import <url\|file\|slug>` | `import_brand` |
|
|
129
|
+
| Revise the brand with a prompt (preview first) | `hyperdecks brand revise "<change>"` | `revise_brand` |
|
|
130
|
+
| Brand history / undo a brand change | `hyperdecks brand history` / `brand undo [<version>]` | `brand_history` / `undo_brand` |
|
|
127
131
|
| Scaffold | `hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer]` | `new_deck` (`layouts`) |
|
|
128
132
|
| Images | `hyperdecks gen-image "<prompt>" --slug <slug>` | `generate_image` |
|
|
129
133
|
| Video clips | `hyperdecks gen-video "<prompt>" --slug <slug>` | `generate_video` |
|
|
@@ -136,6 +140,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
136
140
|
| Who narrates (ask the person first; see *Narrators*) | `hyperdecks deck narrator <slug> <name\|none\|default>` | `list_narrators`, then `set_deck_narrator` |
|
|
137
141
|
| Narrate | `hyperdecks narrate decks/<slug>` | `narrate_deck` |
|
|
138
142
|
| Check | `hyperdecks check decks/<slug>` | `check_deck` |
|
|
143
|
+
| Watch a draft with its real face | `hyperdecks play decks/<slug>` | the `previewUrl` from `get_deck_source` |
|
|
139
144
|
| Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
|
|
140
145
|
| Share-link poster | `hyperdecks poster <slug>` | `generate_deck_poster` |
|
|
141
146
|
| Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
|
|
@@ -226,6 +231,9 @@ its `##` heading.
|
|
|
226
231
|
| `glass` | dark or busy slides: head and captions on one frosted panel |
|
|
227
232
|
| `corner` | the slide is the point (a product shot, a chart, a demo): small head bottom-right, subtitles along the bottom |
|
|
228
233
|
|
|
234
|
+
The live head's face follows script cues too (`[nod]`, `[look #card]`,
|
|
235
|
+
`[smile]`, `[gesture: lightbulb]`; see the script topic); a still face ignores them.
|
|
236
|
+
|
|
229
237
|
Below 650 px stage and glass become corner on their own. Run
|
|
230
238
|
`hyperdecks check <deck> --layout` to be warned about any slide whose presenter
|
|
231
239
|
would cover its title.
|
|
@@ -307,10 +315,17 @@ a presenter style and a design.md (voice, do and don't). `deck new` and
|
|
|
307
315
|
`new_deck` put its tokens in `:root` and its logo on the first slide, so read
|
|
308
316
|
it first (`hyperdecks brand show --json`, `get_brand`) and write in its voice.
|
|
309
317
|
An explicit request for this deck wins over the brand; `--no-brand`
|
|
310
|
-
(`brand: false`) starts from the template's look. No brand yet? Offer to
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
318
|
+
(`brand: false`) starts from the template's look. No brand yet? Offer to
|
|
319
|
+
import it from what they already have: `hyperdecks brand import
|
|
320
|
+
<url|file.pptx|file.pdf|deck-slug>` (`import_brand`) reads the real colours,
|
|
321
|
+
fonts and logo (a PowerPoint's theme exactly) and returns a preview link, a
|
|
322
|
+
sample slide in that brand, and a token. Always show them the preview and
|
|
323
|
+
ask; save with `brand set --draft <token>` (`set_brand { draft }`) only after
|
|
324
|
+
they say yes.
|
|
325
|
+
To change it in words ("darker accent, serif titles"), `hyperdecks brand
|
|
326
|
+
revise "<change>"` (`revise_brand`) gives a draft and preview the same way.
|
|
327
|
+
Every brand save is kept in its history: `brand history` (`brand_history`)
|
|
328
|
+
lists the last changes and `brand undo [<version>]` (`undo_brand`) goes back.
|
|
314
329
|
|
|
315
330
|
**Fonts: pick a pairing.** Fonts load from Google Fonts. Use one of the
|
|
316
331
|
curated pairings below (display for titles, body for text, mono for kickers
|
|
@@ -371,7 +386,26 @@ where there is room so slide changes move the presenter.
|
|
|
371
386
|
palette hexes, "no text"). `--model nano-banana-2.1` when the image must
|
|
372
387
|
carry legible type.
|
|
373
388
|
- Video: `gen-video` for a short loop behind a hero or a product moment, with
|
|
374
|
-
`[play: video]` at its word; never needed for a deck to work.
|
|
389
|
+
`[play: video]` at its word; never needed for a deck to work. A clip that
|
|
390
|
+
plays on repeat should be made with `--loop` (`loop: true`): it starts and
|
|
391
|
+
ends on the same frame, so there is no jump at the seam. Give it `--image`
|
|
392
|
+
to loop a still you already have; without one a keyframe is rendered first.
|
|
393
|
+
- Moving cut-outs: `--transparent` (`transparent: true`) mattes the
|
|
394
|
+
background out, the moving version of a `gen-image --transparent` PNG: a
|
|
395
|
+
spinning part, a product turning, over the slide's own background. Use it
|
|
396
|
+
with `--loop` and a subject on a plain background. It returns a VP9 WebM
|
|
397
|
+
with alpha (Chrome, Firefox, Edge) and a stacked-alpha MP4 that the hosted
|
|
398
|
+
player draws on a canvas in Safari and on iOS, which ignore WebM alpha. The
|
|
399
|
+
job's `markup` is the tag to paste, as is:
|
|
400
|
+
|
|
401
|
+
```html
|
|
402
|
+
<video muted loop playsinline autoplay crossorigin="anonymous"
|
|
403
|
+
src="(video.url)" data-alpha-stacked="(video.stacked.url)"></video>
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Keep `crossorigin` and `data-alpha-stacked`: the player needs both. A deck
|
|
407
|
+
without the hosted player shows the clip in a box in Safari. A transparent
|
|
408
|
+
clip costs 2 credits a second more than an opaque one.
|
|
375
409
|
- People: real photos only with permission; initials otherwise.
|
|
376
410
|
|
|
377
411
|
**Motion.** The presenter's moves are the deck's motion; keep everything else
|
|
@@ -425,6 +459,12 @@ phone screenshots, the same `fix` bar.
|
|
|
425
459
|
showcase is a slide of stacked cards, one point each, with a `go` to each
|
|
426
460
|
card at the word that starts it: `Write [go: #step-1] the slides, write
|
|
427
461
|
[go: #step-2] what you'd say`. Give each card an id.
|
|
462
|
+
- Face: the live head already moves with the speech (eyes, brows, a nod on a
|
|
463
|
+
falling end); add a few face cues only where they carry the point, each on
|
|
464
|
+
the word before it: `This is the big one [nod] and here [look #m3] is the
|
|
465
|
+
proof.` / `Not a slide tool [shake], a presenter.` / `Then it clicked
|
|
466
|
+
[gesture: lightbulb].` / `Really? [brow up] Yes [smile].` A still or
|
|
467
|
+
captions-only presenter ignores them.
|
|
428
468
|
|
|
429
469
|
| Cue | Effect |
|
|
430
470
|
|---|---|
|
|
@@ -434,7 +474,12 @@ phone screenshots, the same `fix` bar.
|
|
|
434
474
|
| `[pause: 0.5]` | a beat of silence (≤ 5 s) |
|
|
435
475
|
| `[play: video]` / `[pause: video]` | play or pause the slide's video |
|
|
436
476
|
| `[go: .list]` / `[go: slot]` | fly beside `.list` / back to the slot |
|
|
437
|
-
| `[look: h2]` | look at the title |
|
|
477
|
+
| `[look: h2]` / `[look #card]` | look at the title / the card, then back |
|
|
478
|
+
| `[look viewer]` / `[look away]` | look back at the viewer / glance aside |
|
|
479
|
+
| `[nod]` / `[shake]` | a nod or head shake that suits the mood |
|
|
480
|
+
| `[brow up]` / `[brow down]` | raise or lower the brows |
|
|
481
|
+
| `[smile]` / `[think]` | `[mood: happy]` / `[mood: thinking]` |
|
|
482
|
+
| `[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) |
|
|
438
483
|
| `[custom: name]` | only the slide's controller sees it |
|
|
439
484
|
- `voice: dash` narrates with D.A.S.H.'s own robot voice, a synthetic
|
|
440
485
|
character voice (not a clone of anyone) any account may use, for decks that
|
|
@@ -502,7 +547,16 @@ an internal codename). Messages and what to do:
|
|
|
502
547
|
|
|
503
548
|
### 6. Look at it, at both widths
|
|
504
549
|
|
|
505
|
-
|
|
550
|
+
To watch it as it will really play, run `hyperdecks play decks/<slug>`. It plays
|
|
551
|
+
the folder as it is, committed or not, with the real face and voice, on
|
|
552
|
+
127.0.0.1 until Ctrl-C. It writes nothing, uploads nothing, creates no share and
|
|
553
|
+
spends nothing, so it plays the last narration: changed lines need `narrate`
|
|
554
|
+
first. It prints who presents, or why it is captions only. `--no-open` skips the
|
|
555
|
+
browser (an agent reads the URL; `--json` prints it as one line).
|
|
556
|
+
`release --qa` is not a preview: it needs a commit, and it changes what every
|
|
557
|
+
link to this deck plays.
|
|
558
|
+
|
|
559
|
+
For the layout pass, serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
|
|
506
560
|
and screenshot it with Playwright at 1440×900 and 390×844, every slide
|
|
507
561
|
(`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
|
|
508
562
|
clear of the text, the mobile titles in use. Fix and repeat until both read well.
|
|
@@ -515,7 +569,8 @@ the deck's QA link on desktop and phone and list each problem as `fix` or
|
|
|
515
569
|
|
|
516
570
|
Pick image models by job: `gen-image` defaults to GPT Image 2.5; `--model
|
|
517
571
|
nano-banana-2.1` is stronger on typography and photoreal scenes. `gen-video`
|
|
518
|
-
defaults to MiniMax H3 Max (`--model kling-v3` is the fallback)
|
|
572
|
+
defaults to MiniMax H3 Max (`--model kling-v3` is the fallback); `--loop` and
|
|
573
|
+
`--transparent` work with both. Use `chart` for
|
|
519
574
|
real numbers instead of a generated picture of a chart, `icons search` for
|
|
520
575
|
real line icons, and `icons --style` with the deck's palette for an
|
|
521
576
|
illustrated set that matches.
|
|
@@ -23,12 +23,20 @@ makes the hello deck; continue from step 5 below.
|
|
|
23
23
|
|
|
24
24
|
1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides.
|
|
25
25
|
`get_brand` for the person's saved brand: write in its designMd's voice.
|
|
26
|
-
None yet
|
|
27
|
-
|
|
28
|
-
`
|
|
29
|
-
|
|
26
|
+
None yet? Offer to import it from what they have: `import_brand` with
|
|
27
|
+
their website (`url`), a PowerPoint or PDF they attach (`file`) or one of
|
|
28
|
+
their decks (`slug`). It reads the real colours, fonts and logo and returns
|
|
29
|
+
a `previewUrl` (a sample slide in that brand) and a `token`. Always give
|
|
30
|
+
them the preview link, say what it found (and its notes), and ask; only
|
|
31
|
+
after a yes call `set_brand { draft: token }`. Never save without the yes.
|
|
32
|
+
To change the brand in words ("less yellow", "serif titles"),
|
|
33
|
+
`revise_brand { instruction }` gives a draft and preview the same way.
|
|
34
|
+
Any brand change can be undone: `brand_history`, then `undo_brand`.
|
|
35
|
+
(`deck_guide { topic: "design" }` lists the font pairings.)
|
|
30
36
|
2. `new_deck { slug, title, layouts }`: registers your draft and returns the
|
|
31
|
-
full text of every file plus a `previewUrl` (the draft, untracked
|
|
37
|
+
full text of every file plus a `previewUrl` (the draft, untracked; once
|
|
38
|
+
narrated it plays with the face its release would have, so the person can
|
|
39
|
+
watch it before anything ships). It
|
|
32
40
|
starts in the saved brand's colours, fonts and logo (`brand` in the result
|
|
33
41
|
says what it took); `brand: false` gives the template's look.
|
|
34
42
|
3. Images: `generate_image`, `render_chart`, `capture_url`, or
|
|
@@ -40,6 +48,12 @@ makes the hello deck; continue from step 5 below.
|
|
|
40
48
|
you swap each for an `<img>` of its real file, or delete the hero's art.
|
|
41
49
|
Icons: `search_icons { query }` returns real line icons as inline `<svg>`
|
|
42
50
|
in `currentColor`; paste one where a features card has its icon.
|
|
51
|
+
For motion, `generate_video` (poll `check_video_job`): `loop: true` for a
|
|
52
|
+
clip that repeats with no jump, `transparent: true` for a moving cut-out
|
|
53
|
+
over the slide. Paste the finished job's `markup` as is (a transparent
|
|
54
|
+
clip's `<video>` names a second file in `data-alpha-stacked`, which the
|
|
55
|
+
player shows in Safari and on iOS).
|
|
56
|
+
`deck_guide { topic: "design" }` says when each fits.
|
|
43
57
|
4. `write_deck_source` to change a file: `edits: [{ find, replace }]` with
|
|
44
58
|
exact text that occurs once (copy it from `get_deck_source`), or `content`
|
|
45
59
|
to replace the file. Pass `baseRev` (the `rev` you last read) so a
|
|
@@ -55,11 +69,14 @@ makes the hello deck; continue from step 5 below.
|
|
|
55
69
|
poll `check_narration_job` until `done`. The draft's player tag then has its
|
|
56
70
|
`data-manifest`. Its `presenter` says who will present (`face: null` is
|
|
57
71
|
captions only, `reason` says why): tell the person. No voice of their own
|
|
58
|
-
yet? Pass `voice: "dash"`.
|
|
72
|
+
yet? Pass `voice: "dash"`. To direct the face, put a few cues in
|
|
73
|
+
script.md on the words that carry the point (`[nod]`, `[look #card]`,
|
|
74
|
+
`[smile]`, `[gesture: lightbulb]`; `deck_guide script` lists them) and
|
|
75
|
+
narrate again.
|
|
59
76
|
8. `check_deck { slug }` again, then `review_deck { slug, draft: true }` and
|
|
60
|
-
`check_review_job`: it shoots desktop and 390×844 phone frames,
|
|
61
|
-
you catch a slide that overflows a phone (the hosted check cannot
|
|
62
|
-
layout).
|
|
77
|
+
`check_review_job`: it shoots desktop and 390×844 phone frames, face included,
|
|
78
|
+
which is how you catch a slide that overflows a phone (the hosted check cannot
|
|
79
|
+
measure layout).
|
|
63
80
|
9. `release_deck { slug, qa: true }` gives the user a QA link to watch first.
|
|
64
81
|
`release_deck { slug, recipients: [{ recipient, email }] }` makes one
|
|
65
82
|
tracked link each. Release creates live links: confirm the recipients with
|
package/src/avatar.mjs
CHANGED
|
@@ -86,6 +86,6 @@ export async function avatarConnect(opts, { log = console.log, interactive = int
|
|
|
86
86
|
log("Waiting for you to pick a face…");
|
|
87
87
|
const done = await ops.waitForConnect(opts, out.connectId);
|
|
88
88
|
if (done.status !== "added") throw new Error(done.status === "denied" ? "not shared on minimoji.com" : done.status === "expired" ? "the code expired; run avatar connect again" : done.error || `the import ended (${done.status})`);
|
|
89
|
-
log("✓ Presenter: your Minimoji. Your
|
|
89
|
+
log("✓ Presenter: your Minimoji. Your drafts show it now; share links get it from the next release.");
|
|
90
90
|
return { status: "added", avatarId: done.avatarId };
|
|
91
91
|
}
|
package/src/brandCli.mjs
CHANGED
|
@@ -7,20 +7,26 @@
|
|
|
7
7
|
// [--fonts <pairing>] [--display "<family>"] [--body …] [--mono …]
|
|
8
8
|
// [--logo <url|file>] [--no-logo] [--presenter-style s]
|
|
9
9
|
// [--design <file.md|->] [--name "<company>"]
|
|
10
|
+
// [--draft <token>] (a brand import, saved after you saw its preview)
|
|
11
|
+
// brand import <url|file|slug> read a brand from a website, a .pptx / .potx /
|
|
12
|
+
// .pdf or one of your decks into a draft + preview
|
|
13
|
+
// brand revise "<change>" a draft + preview of the brand with that change
|
|
14
|
+
// brand history the last changes (from any surface)
|
|
15
|
+
// brand undo [<version>] undo the last change, or go back to a version
|
|
10
16
|
// brand edit design.md in $VISUAL / $EDITOR, saved on exit
|
|
11
17
|
// brand fonts the curated font pairings
|
|
12
18
|
// brand clear forget the brand (new decks get the template look)
|
|
13
19
|
import { spawnSync } from "node:child_process";
|
|
14
20
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
21
|
import { tmpdir } from "node:os";
|
|
16
|
-
import { extname, join } from "node:path";
|
|
22
|
+
import { basename, extname, join } from "node:path";
|
|
17
23
|
import * as ops from "./ops.mjs";
|
|
18
24
|
import { FONT_PAIRINGS } from "./brand.mjs";
|
|
19
25
|
|
|
20
26
|
const LOGO_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml", ".avif": "image/avif" };
|
|
21
27
|
const COLOR_FLAGS = { ink: "ink", paper: "paper", accent: "accent", "accent-2": "accent2", card: "card" };
|
|
22
28
|
const FONT_FLAGS = ["display", "body", "mono"];
|
|
23
|
-
const SET_USAGE = 'usage: brand set [--accent "#hex"] [--ink …] [--paper …] [--accent-2 …] [--card …] [--fonts <pairing>] [--display "<family>"] [--body …] [--mono …] [--logo <url|file>] [--no-logo] [--presenter-style stage|bubble|glass|corner] [--design <file.md|->] [--name "<company>"]';
|
|
29
|
+
const SET_USAGE = 'usage: brand set [--draft <token>] [--accent "#hex"] [--ink …] [--paper …] [--accent-2 …] [--card …] [--fonts <pairing>] [--display "<family>"] [--body …] [--mono …] [--logo <url|file>] [--no-logo] [--presenter-style stage|bubble|glass|corner] [--design <file.md|->] [--name "<company>"]';
|
|
24
30
|
|
|
25
31
|
export function brandLines(b) {
|
|
26
32
|
if (!b) return ["no brand saved: new decks get the template look", " set one: hyperdecks brand set --accent \"#ff5a1f\" --fonts editorial --logo ./logo.svg"];
|
|
@@ -64,6 +70,7 @@ export function brandArgs(opts, { readFile = readFileSync, stdin = () => readFil
|
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
if (opts["no-logo"]) args.clearLogo = true;
|
|
73
|
+
if (flag(opts, "draft") !== undefined) args.draft = flag(opts, "draft");
|
|
67
74
|
return args;
|
|
68
75
|
}
|
|
69
76
|
|
|
@@ -125,3 +132,80 @@ export async function brandClear(opts = {}, { log = (line) => console.log(line)
|
|
|
125
132
|
log(opts.json ? JSON.stringify(out) : "✓ brand cleared; new decks get the template look");
|
|
126
133
|
return out;
|
|
127
134
|
}
|
|
135
|
+
|
|
136
|
+
// What `brand import` read, for the terminal.
|
|
137
|
+
export function importLines(d) {
|
|
138
|
+
const colors = Object.entries(d.colors ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
139
|
+
const fonts = Object.entries(d.fonts ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
140
|
+
return [
|
|
141
|
+
d.source?.kind === "revise" ? `revised: ${d.source.ref}` : `read from ${d.source?.kind} ${d.source?.ref}`,
|
|
142
|
+
...(d.name ? [`name: ${d.name}`] : []),
|
|
143
|
+
`colours: ${colors || "(none found)"}`,
|
|
144
|
+
`fonts: ${fonts || "(none found)"}`,
|
|
145
|
+
`logo: ${d.logoUrl ?? "(none found)"}`,
|
|
146
|
+
...(d.notes ?? []).map((n) => ` · ${n}`),
|
|
147
|
+
"",
|
|
148
|
+
`preview: ${d.previewUrl}`,
|
|
149
|
+
`Nothing is saved yet. To make it your brand: hyperdecks brand set --draft ${d.token}`,
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// brand import <url|file|slug>: an http(s) URL or a domain is a website, an
|
|
154
|
+
// existing path a file, anything else one of your deck slugs.
|
|
155
|
+
export async function brandImport(from, opts = {}, { log = (line) => console.log(line), readFile = readFileSync, exists = existsSync } = {}) {
|
|
156
|
+
const src = String(from ?? "").trim();
|
|
157
|
+
if (!src) throw new Error("usage: brand import <https://your.site | deck.pptx | brand.pdf | deck-slug> [--json]");
|
|
158
|
+
let args;
|
|
159
|
+
if (/^https?:\/\//i.test(src)) args = { url: src };
|
|
160
|
+
else if (exists(src)) args = { bytes: new Uint8Array(readFile(src)), fileName: basename(src) };
|
|
161
|
+
else if (/\.(pptx|potx|pdf)$/i.test(src)) throw new Error(`${src}: no such file`);
|
|
162
|
+
else if (/^[a-z0-9][a-z0-9-]*$/.test(src)) args = { slug: src };
|
|
163
|
+
else if (/^[\w-]+(\.[\w-]+)+(\/.*)?$/.test(src)) args = { url: `https://${src}` };
|
|
164
|
+
else throw new Error(`${src}: not a URL, a file or a deck slug`);
|
|
165
|
+
const d = await ops.importBrand(opts, args);
|
|
166
|
+
if (opts.json) log(JSON.stringify(d, null, 2));
|
|
167
|
+
else for (const line of importLines(d)) log(line);
|
|
168
|
+
return d;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// brand revise "<change>": the model's take on the brand, as a draft + preview.
|
|
172
|
+
export async function brandRevise(words, opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
173
|
+
const instruction = (Array.isArray(words) ? words.join(" ") : String(words ?? "")).trim();
|
|
174
|
+
if (!instruction) throw new Error('usage: brand revise "<change>", e.g. brand revise "make the accent darker and use a serif for titles"');
|
|
175
|
+
const d = await ops.reviseBrand(opts, { instruction });
|
|
176
|
+
if (opts.json) log(JSON.stringify(d, null, 2));
|
|
177
|
+
else for (const line of importLines(d)) log(line);
|
|
178
|
+
return d;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const ago = (at, now = Date.now()) => {
|
|
182
|
+
const m = Math.round((now - at) / 60000);
|
|
183
|
+
if (m < 1) return "just now";
|
|
184
|
+
if (m < 60) return `${m} min ago`;
|
|
185
|
+
const h = Math.round(m / 60);
|
|
186
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export function historyLines(versions, now = Date.now()) {
|
|
190
|
+
if (!versions.length) return ["no brand changes yet"];
|
|
191
|
+
return [
|
|
192
|
+
...versions.map((v) => `${v.current ? "* " : " "}${v.label.padEnd(48)} ${ago(v.at, now).padEnd(12)} ${v.id}`),
|
|
193
|
+
"",
|
|
194
|
+
"hyperdecks brand undo undo the last change",
|
|
195
|
+
"hyperdecks brand undo <version> go back to one of these",
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function brandHistory(opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
200
|
+
const out = await ops.brandHistory(opts);
|
|
201
|
+
if (opts.json) log(JSON.stringify(out, null, 2));
|
|
202
|
+
else for (const line of historyLines(out.versions)) log(line);
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function brandUndo(version, opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
207
|
+
const b = await ops.undoBrand(opts, { version });
|
|
208
|
+
if (opts.json) log(JSON.stringify(b, null, 2));
|
|
209
|
+
else for (const line of [version ? "✓ brand restored" : "✓ last change undone", ...brandLines(b)]) log(line);
|
|
210
|
+
return b;
|
|
211
|
+
}
|