@hypersocial/decks-cli 0.3.1 → 0.5.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/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +56 -2
- package/skills/hyperdecks/chat.md +23 -2
- package/src/avatar.mjs +91 -0
- package/src/commands.mjs +135 -7
- package/src/files.mjs +2 -0
- package/src/firstDeck.mjs +105 -0
- package/src/init.mjs +58 -102
- package/src/narrator.mjs +29 -0
- package/src/ops.mjs +280 -2
- package/src/presenter.mjs +28 -0
- package/src/release.mjs +18 -5
- package/src/runtime.mjs +36 -2
- package/src/species.mjs +15 -0
package/package.json
CHANGED
|
@@ -35,7 +35,51 @@ word, and wait for them:
|
|
|
35
35
|
- voice: cloning needs them to type their consent in a terminal (`hyperdecks
|
|
36
36
|
voice clone <file> --name <name>`), or to agree to the statement before you
|
|
37
37
|
call `clone_voice`. Never type or confirm consent for them. `--voice dash`
|
|
38
|
-
lets D.A.S.H., a character voice, present instead
|
|
38
|
+
lets D.A.S.H., a character voice, present instead;
|
|
39
|
+
- narrator: who narrates their decks, a face plus a voice. Without one, a deck
|
|
40
|
+
in the user's own voice plays captions only. See *Narrators* below; only the
|
|
41
|
+
person picks one.
|
|
42
|
+
|
|
43
|
+
### Narrators: who narrates a deck (a face plus a voice)
|
|
44
|
+
|
|
45
|
+
A narrator pairs a face (one of the user's avatars) with a voice (one of their
|
|
46
|
+
ready voices, or D.A.S.H.'s). D.A.S.H. is built in. A deck uses its own
|
|
47
|
+
narrator, else the default: `narrate` speaks in the narrator's voice (an
|
|
48
|
+
explicit `--voice` or the script's `voice:` still wins) and `release` puts its
|
|
49
|
+
face on the deck. **Never add a narrator for the person**: `narrator new`
|
|
50
|
+
returns a link; hand it to them, word for word, and wait.
|
|
51
|
+
|
|
52
|
+
| Step | CLI | MCP |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| List them, the default and the voices | `hyperdecks narrator --json` | `list_narrators` |
|
|
55
|
+
| Add one (the person picks face and voice) | `hyperdecks narrator new --json` → `url` | `new_narrator_link` |
|
|
56
|
+
| Make one the default (the person named it) | `hyperdecks narrator default <name>` | `set_default_narrator` |
|
|
57
|
+
| Remove one (confirm first) | `hyperdecks narrator remove <name>` | `remove_narrator` |
|
|
58
|
+
| Change its voice (the person asked) | `hyperdecks narrator voice <name> <voice>` | `set_narrator_voice` |
|
|
59
|
+
| Rename it (the person asked) | `hyperdecks narrator rename <name> <new name>` | `rename_narrator` |
|
|
60
|
+
| Change its face to one of their avatars (the person named it) | `hyperdecks narrator face <name> <avatar>` | `set_narrator_face` |
|
|
61
|
+
| Hear a voice or a narrator | `hyperdecks voice hear <voice>` (`--narrator <name>`) → `url`, `caption` | `hear_voice` |
|
|
62
|
+
| One deck's own narrator | `hyperdecks deck narrator <slug> <name\|none\|default>` | `set_deck_narrator` |
|
|
63
|
+
|
|
64
|
+
### Avatars: who presents a deck in the user's voice
|
|
65
|
+
|
|
66
|
+
The faces narrators wear; these commands still work as before.
|
|
67
|
+
|
|
68
|
+
A deck narrated in the user's own voice is presented by their default avatar
|
|
69
|
+
(a cartoon Minimoji, never a photo) from its next release; with none it plays
|
|
70
|
+
captions only. D.A.S.H. presents only in its own voice. **Never pick or approve
|
|
71
|
+
a face for the person.** Every command that needs a choice returns a link (and,
|
|
72
|
+
for an import, a code): hand it to them, word for word, and wait.
|
|
73
|
+
|
|
74
|
+
| Step | CLI | MCP |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| List them and the default | `hyperdecks avatar --json` | `list_avatars` |
|
|
77
|
+
| Pick a species and an avatar | `hyperdecks avatar new [--species cat] --json` → `pickUrl` | `new_avatar_link` |
|
|
78
|
+
| A cartoon from a photo | `hyperdecks avatar from-photo [me.jpg] --json` → `reviewUrl` (or `selfieUrl`) | `avatar_from_photo` |
|
|
79
|
+
| Import from Minimoji | `hyperdecks avatar connect --json` → `url` + `code` | `connect_avatar` |
|
|
80
|
+
| Make one the default (the person named it) | `hyperdecks avatar default <name>` | `set_default_avatar` |
|
|
81
|
+
| Remove one (confirm first) | `hyperdecks avatar remove <name>` | `remove_avatar` |
|
|
82
|
+
| One deck's own presenter | `hyperdecks deck avatar <slug> <name\|none\|default>` | `set_deck_avatar` |
|
|
39
83
|
|
|
40
84
|
## Target Selection
|
|
41
85
|
|
|
@@ -92,11 +136,21 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
92
136
|
| Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
|
|
93
137
|
| Share-link poster | `hyperdecks poster <slug>` | `generate_deck_poster` |
|
|
94
138
|
| Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
|
|
95
|
-
| One more link | `hyperdecks share new <slug> "<name>"` | `create_share` |
|
|
139
|
+
| One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
|
|
140
|
+
| Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
|
|
141
|
+
| Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
|
|
96
142
|
| Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
|
|
97
143
|
| All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
|
|
98
144
|
| What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
|
|
99
145
|
|
|
146
|
+
Forwarding is on by default: a viewer can forward the deck to someone, which
|
|
147
|
+
makes a child link tracked on its own ("Dana via Peter"; `share list` shows it
|
|
148
|
+
indented under Peter's, `list_shares` gives `parentToken` and `forwardedBy`).
|
|
149
|
+
One hop only: a forwarded link can't be forwarded again, nor can a QA link.
|
|
150
|
+
Disabling the parent link (`share off`) turns off its forwards too, and
|
|
151
|
+
`share forward default` (or `allowForward: null`) drops a link's own setting
|
|
152
|
+
so it follows the deck's.
|
|
153
|
+
|
|
100
154
|
### 1. Ground it in the user's own material
|
|
101
155
|
|
|
102
156
|
Build the deck from what you can already read in this session: files in the
|
|
@@ -7,6 +7,18 @@ reaches a recipient until `release_deck`. Topics: call `deck_guide` with
|
|
|
7
7
|
`format`, `script`, `design`, `layouts`, `presenter`, `transitions` or `check`
|
|
8
8
|
before the step that needs it.
|
|
9
9
|
|
|
10
|
+
## First time here?
|
|
11
|
+
|
|
12
|
+
`get_started` with no answers says where the person stands (ready voices,
|
|
13
|
+
narrators, credits) and the steps. No voice of their own: `clone_voice_link`
|
|
14
|
+
(they record in the browser), or `clone_voice` with 1-3 recordings they attach
|
|
15
|
+
in the chat, only after they agree to the consent statement, word for word.
|
|
16
|
+
`hear_voice` plays a voice or a narrator in the chat. A face from a photo they
|
|
17
|
+
attach: `avatar_from_photo { file }`, then `check_avatar_draft` until it hands
|
|
18
|
+
back the link they approve it on. `rename_narrator` and `set_narrator_face`
|
|
19
|
+
change a narrator when they ask. Then `get_started { name, about, ask, voice }`
|
|
20
|
+
makes the hello deck; continue from step 5 below.
|
|
21
|
+
|
|
10
22
|
## The loop
|
|
11
23
|
|
|
12
24
|
1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides.
|
|
@@ -31,8 +43,17 @@ before the step that needs it.
|
|
|
31
43
|
8. `release_deck { slug, qa: true }` gives the user a QA link to watch first.
|
|
32
44
|
`release_deck { slug, recipients: [{ recipient, email }] }` makes one
|
|
33
45
|
tracked link each. Release creates live links: confirm the recipients with
|
|
34
|
-
the user before you call it.
|
|
35
|
-
|
|
46
|
+
the user before you call it. In Claude and ChatGPT the result shows as a
|
|
47
|
+
deck card: the deck playing from its QA link (the draft before release),
|
|
48
|
+
with each share link and its opens. Never paste a recipient's link to "test"
|
|
49
|
+
it: opening it counts as their open.
|
|
50
|
+
9. Later: `list_shares` and `deck_stats` (both show the card),
|
|
51
|
+
`activity_overview`; `email_embed { token }` for the HTML that pastes into an
|
|
52
|
+
email as the deck's moving preview (it needs a recipient's link, not a QA
|
|
53
|
+
one); `set_share_notify` to mute a link's "opened" alerts;
|
|
54
|
+
`set_recipient_archived` to tidy the activity feed; `credits` for the
|
|
55
|
+
balance. `remove_deck` deletes a deck and breaks every link to it: confirm
|
|
56
|
+
with the user first.
|
|
36
57
|
|
|
37
58
|
## Rules that matter
|
|
38
59
|
|
package/src/avatar.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// hyperdecks avatar [list] | new [--species x] | from-photo [file] [--save-to-minimoji] | connect | default <id> | remove <id>
|
|
2
|
+
//
|
|
3
|
+
// The face that presents decks narrated in your own voice (convex/avatars.ts).
|
|
4
|
+
// Only a person picks or approves a face: in a terminal you pick by number;
|
|
5
|
+
// with --json, or without a terminal (an agent), each command returns the link
|
|
6
|
+
// (and for connect, the code) to hand the person, and never chooses.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { extname } from "node:path";
|
|
9
|
+
import * as ops from "./ops.mjs";
|
|
10
|
+
import { SPECIES as TABLE } from "./species.mjs";
|
|
11
|
+
|
|
12
|
+
export const SPECIES = TABLE.map((s) => s.id);
|
|
13
|
+
const PHOTO_TYPES = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png" };
|
|
14
|
+
|
|
15
|
+
const interactiveOf = (opts) => !opts.json && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
16
|
+
|
|
17
|
+
export async function avatarList(opts, { log = console.log } = {}) {
|
|
18
|
+
const out = await ops.listAvatars(opts);
|
|
19
|
+
if (opts.json) return out;
|
|
20
|
+
for (const a of out.avatars) log(`${a.isDefault ? "*" : " "} ${a.name}\t${a.source}${a.species ? ` (${a.species})` : ""}\t${a.id}`);
|
|
21
|
+
log("\n* the default; decks narrated in your voice use it unless they set their own (deck avatar <slug> <id>)");
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Pick a species and one of its avatars in the terminal; s shuffles, u undoes.
|
|
26
|
+
export async function avatarNew(opts, { ask, log = console.log, interactive = interactiveOf(opts) } = {}) {
|
|
27
|
+
const species = opts.species ? String(opts.species).toLowerCase() : null;
|
|
28
|
+
if (species && !SPECIES.includes(species)) throw new Error(`--species is one of ${SPECIES.join(", ")}`);
|
|
29
|
+
if (!interactive) {
|
|
30
|
+
const link = await ops.newAvatarLink(opts, species ? { species } : {});
|
|
31
|
+
if (!opts.json) log(`Pick an avatar here (only you can choose it):\n ${link.pickUrl}`);
|
|
32
|
+
return link;
|
|
33
|
+
}
|
|
34
|
+
log(`Species: ${SPECIES.join(" ")}`);
|
|
35
|
+
let chosen = species;
|
|
36
|
+
while (!chosen) {
|
|
37
|
+
const a = (await ask("Species [human]: ")).trim().toLowerCase() || "human";
|
|
38
|
+
if (SPECIES.includes(a)) chosen = a;
|
|
39
|
+
else log(` not a species; one of ${SPECIES.join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
const link = await ops.newAvatarLink(opts, { species: chosen });
|
|
42
|
+
log(`Preview them: ${link.pickUrl}`);
|
|
43
|
+
const seeds = [0];
|
|
44
|
+
for (;;) {
|
|
45
|
+
const page = await ops.avatarOptions(opts, chosen, seeds[seeds.length - 1]);
|
|
46
|
+
log(page.options.map((o, i) => ` ${String(i + 1).padStart(2)} ${o.name}`).join("\n"));
|
|
47
|
+
const keys = [page.canShuffle && "s to shuffle", seeds.length > 1 && "u to undo"].filter(Boolean).join(", ");
|
|
48
|
+
const a = (await ask(`Pick 1-${page.options.length}${keys ? `, or ${keys}` : ""}: `)).trim().toLowerCase();
|
|
49
|
+
if (a === "s" && page.canShuffle) { seeds.push(seeds[seeds.length - 1] + 1); continue; }
|
|
50
|
+
if (a === "u" && seeds.length > 1) { seeds.pop(); continue; }
|
|
51
|
+
const n = Number(a);
|
|
52
|
+
const option = Number.isInteger(n) ? page.options[n - 1] : null;
|
|
53
|
+
if (!option) continue;
|
|
54
|
+
await ops.addPickedAvatar(opts, option.avatarKey);
|
|
55
|
+
log(`✓ Added ${option.name} (${chosen}). Default for decks in your voice.`);
|
|
56
|
+
return { status: "added", name: option.name, species: chosen };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A face from a photo file, or (no file) a selfie in the browser. Either way the
|
|
61
|
+
// person approves it on the link before it is used.
|
|
62
|
+
export async function avatarFromPhoto(file, opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
|
|
63
|
+
let photo = {};
|
|
64
|
+
if (file) {
|
|
65
|
+
const contentType = PHOTO_TYPES[extname(file).toLowerCase()];
|
|
66
|
+
if (!contentType) throw new Error("the photo must be a .jpg or .png");
|
|
67
|
+
photo = { bytes: readFileSync(file), contentType };
|
|
68
|
+
}
|
|
69
|
+
const out = await ops.avatarFromPhoto(opts, photo, { onProgress: (m) => !opts.json && log(m) });
|
|
70
|
+
const url = out.reviewUrl ?? out.selfieUrl;
|
|
71
|
+
if (opts.json) return out;
|
|
72
|
+
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
|
+
if (opts["save-to-minimoji"]) log(" (tick \"Also save it to my Minimoji account\" there)");
|
|
74
|
+
if (!interactive) return out;
|
|
75
|
+
log("Waiting for you…");
|
|
76
|
+
const avatar = await ops.waitForLink(opts, out.token);
|
|
77
|
+
log(`✓ ${avatar.name} presents your decks now. Your photo was deleted.`);
|
|
78
|
+
return { status: "added", name: avatar.name };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function avatarConnect(opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
|
|
82
|
+
const out = await ops.connectAvatar(opts);
|
|
83
|
+
if (opts.json) return out;
|
|
84
|
+
log(`On any device, go to ${out.url}\nand enter ${out.code} (expires in ${Math.round(out.expiresIn / 60)} min)`);
|
|
85
|
+
if (!interactive) return out;
|
|
86
|
+
log("Waiting for you to pick a face…");
|
|
87
|
+
const done = await ops.waitForConnect(opts, out.connectId);
|
|
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 decks use it from the next release.");
|
|
90
|
+
return { status: "added", avatarId: done.avatarId };
|
|
91
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -87,7 +87,8 @@ function shareBase(opts) {
|
|
|
87
87
|
|
|
88
88
|
async function shareNew(pos, opts) {
|
|
89
89
|
const [slug, recipient] = pos;
|
|
90
|
-
if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify]");
|
|
90
|
+
if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward]");
|
|
91
|
+
if (opts["allow-forward"] && opts["no-forward"]) throw new Error("pick one of --allow-forward and --no-forward");
|
|
91
92
|
const { token } = await ops.createShare(opts, {
|
|
92
93
|
slug,
|
|
93
94
|
recipient,
|
|
@@ -96,6 +97,7 @@ async function shareNew(pos, opts) {
|
|
|
96
97
|
requireEmail: opts["require-email"],
|
|
97
98
|
qa: opts.qa || undefined,
|
|
98
99
|
notify: opts["no-notify"] ? false : undefined,
|
|
100
|
+
allowForward: opts["allow-forward"] ? true : opts["no-forward"] ? false : undefined,
|
|
99
101
|
expires: opts.expires,
|
|
100
102
|
limit: opts.limit,
|
|
101
103
|
});
|
|
@@ -112,7 +114,11 @@ async function shareList(pos, opts) {
|
|
|
112
114
|
console.log(JSON.stringify(rows, null, 2));
|
|
113
115
|
return;
|
|
114
116
|
}
|
|
115
|
-
|
|
117
|
+
// Rows arrive with each link's forwards right after it; indent those.
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
const who = r.parentToken ? ` ↳ ${r.recipient} via ${r.forwardedBy ?? "?"}` : r.recipient;
|
|
120
|
+
console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
|
|
121
|
+
}
|
|
116
122
|
if (!rows.length) console.log("(no shares)");
|
|
117
123
|
}
|
|
118
124
|
|
|
@@ -126,8 +132,24 @@ async function shareToggle(pos, opts, enabled) {
|
|
|
126
132
|
async function shareRm(pos, opts) {
|
|
127
133
|
const [token] = pos;
|
|
128
134
|
if (!token) throw new Error("usage: share rm <token>");
|
|
129
|
-
await ops.removeShare(opts, token);
|
|
130
|
-
console.log(`OK removed ${token}`);
|
|
135
|
+
const { removedForwards } = await ops.removeShare(opts, token);
|
|
136
|
+
console.log(`OK removed ${token}${removedForwards ? ` and ${removedForwards} forwarded link${removedForwards === 1 ? "" : "s"}` : ""}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const FORWARD = { on: true, off: false, default: null };
|
|
140
|
+
|
|
141
|
+
async function shareForward(pos, opts) {
|
|
142
|
+
const [mode, token] = pos;
|
|
143
|
+
if (!Object.hasOwn(FORWARD, mode ?? "") || !token) throw new Error("usage: share forward on|off|default <token>");
|
|
144
|
+
await ops.setShareForwarding(opts, token, FORWARD[mode]);
|
|
145
|
+
console.log(`OK forwarding ${mode === "default" ? "follows the deck default" : mode} for ${token}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function deckForward(pos, opts) {
|
|
149
|
+
const [mode, slug] = pos;
|
|
150
|
+
if ((mode !== "on" && mode !== "off") || !slug) throw new Error("usage: deck forward on|off <slug>");
|
|
151
|
+
await ops.setDeckForwarding(opts, slug, mode === "on");
|
|
152
|
+
console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
|
|
131
153
|
}
|
|
132
154
|
|
|
133
155
|
function shareUrl(pos, opts) {
|
|
@@ -281,7 +303,9 @@ commands:
|
|
|
281
303
|
login [--token <pat>] [--broker <url>] [--dev]
|
|
282
304
|
logout [--dev]
|
|
283
305
|
whoami [--json] [--dev]
|
|
284
|
-
deck list|stats|open|rm
|
|
306
|
+
deck list|stats|open|rm|forward
|
|
307
|
+
deck forward on|off <slug>
|
|
308
|
+
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
285
309
|
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
|
|
286
310
|
scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
|
|
287
311
|
layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
|
|
@@ -298,8 +322,11 @@ commands:
|
|
|
298
322
|
the skill, one topic at a time (what deck_guide serves)
|
|
299
323
|
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
|
|
300
324
|
on a committed deck: check → narrate → import → share new per --to
|
|
301
|
-
share new|list|on|off|mute|unmute|rm|url
|
|
302
|
-
share new <slug> <recipient> [--qa] [--no-notify]
|
|
325
|
+
share new|list|on|off|forward|mute|unmute|rm|url
|
|
326
|
+
share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
|
|
327
|
+
share forward on|off|default <token>
|
|
328
|
+
let this link's viewer forward it as their own tracked link ("Dana via Peter",
|
|
329
|
+
one hop); default follows the deck. share off on a link also turns off its forwards.
|
|
303
330
|
recipient archive|unarchive
|
|
304
331
|
import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live]
|
|
305
332
|
import-design <project-id> --serve-token <token> --entry <file.html> --slug <slug>
|
|
@@ -341,6 +368,24 @@ commands:
|
|
|
341
368
|
asks you to type "I consent" before anything is uploaded
|
|
342
369
|
voice upload <file...> upload recordings and print their sample ids (for the MCP clone_voice tool)
|
|
343
370
|
voice rm <name|id> delete a voice, also at the provider
|
|
371
|
+
voice hear <voice> | voice hear --narrator <name|id>
|
|
372
|
+
a short sample of the voice: its clip URL and the line it says
|
|
373
|
+
avatar [--json] your avatars (who presents decks in your voice) and the default
|
|
374
|
+
avatar new [--species <s>] pick a species, then an avatar (s shuffles); --json or an agent gets a pick link
|
|
375
|
+
avatar from-photo [file] [--save-to-minimoji]
|
|
376
|
+
draw a cartoon of you from a photo (no file: a selfie link); you approve it on a link first
|
|
377
|
+
avatar connect import your avatar from Minimoji with a code you enter on minimoji.com
|
|
378
|
+
avatar default <name|id|dash> make it the default
|
|
379
|
+
avatar remove <name|id> remove it; decks already shared keep working, captions only
|
|
380
|
+
deck avatar <slug> <name|id|none|default> the deck's own presenter (applies at its next release)
|
|
381
|
+
narrator [--json] who narrates your decks (a face plus a voice) and the default
|
|
382
|
+
narrator new a link where you pick a face and a voice; it becomes the default
|
|
383
|
+
narrator default <name|id|dash> make it the default
|
|
384
|
+
narrator remove <name|id> remove it; decks already shared keep working
|
|
385
|
+
narrator voice <name|id> <voice> change its voice (one of yours, or dash)
|
|
386
|
+
narrator rename <name|id> <new name> rename it
|
|
387
|
+
narrator face <name|id> <avatar> change its face (one of your avatars)
|
|
388
|
+
deck narrator <slug> <name|id|none|default> the deck's own narrator (none: captions only)
|
|
344
389
|
|
|
345
390
|
global:
|
|
346
391
|
--json
|
|
@@ -664,6 +709,87 @@ function commandTable(pos, rest, opts) {
|
|
|
664
709
|
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
665
710
|
console.log(`OK removed voice "${out.name}"${out.removedAtProvider ? " (deleted at the provider; decks already narrated with it keep playing)" : ""}`);
|
|
666
711
|
},
|
|
712
|
+
avatar: async () => {
|
|
713
|
+
const { avatarList } = await import("./avatar.mjs");
|
|
714
|
+
const out = await avatarList(opts);
|
|
715
|
+
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
716
|
+
},
|
|
717
|
+
"avatar new": async () => {
|
|
718
|
+
const { avatarNew } = await import("./avatar.mjs");
|
|
719
|
+
const { askLine } = await import("./init.mjs");
|
|
720
|
+
const out = await avatarNew(opts, { ask: askLine });
|
|
721
|
+
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
722
|
+
},
|
|
723
|
+
"avatar from-photo": async () => {
|
|
724
|
+
const { avatarFromPhoto } = await import("./avatar.mjs");
|
|
725
|
+
const out = await avatarFromPhoto(rest[0], opts);
|
|
726
|
+
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
727
|
+
},
|
|
728
|
+
"avatar connect": async () => {
|
|
729
|
+
const { avatarConnect } = await import("./avatar.mjs");
|
|
730
|
+
const out = await avatarConnect(opts);
|
|
731
|
+
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
732
|
+
},
|
|
733
|
+
"avatar default": async () => {
|
|
734
|
+
if (!rest[0]) throw new Error("usage: avatar default <name|id|dash>");
|
|
735
|
+
const out = await ops.setDefaultAvatar(opts, rest.join(" "));
|
|
736
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} is the default for decks in your voice`);
|
|
737
|
+
},
|
|
738
|
+
"avatar remove": async () => {
|
|
739
|
+
if (!rest[0]) throw new Error("usage: avatar remove <name|id>");
|
|
740
|
+
const out = await ops.removeAvatar(opts, rest.join(" "));
|
|
741
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK removed ${out.name}; decks already shared keep working`);
|
|
742
|
+
},
|
|
743
|
+
"deck avatar": async () => {
|
|
744
|
+
if (rest.length < 2) throw new Error("usage: deck avatar <slug> <name|id|none|default>");
|
|
745
|
+
const out = await ops.setDeckAvatar(opts, rest[0], rest.slice(1).join(" "));
|
|
746
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (applies at its next release)`);
|
|
747
|
+
},
|
|
748
|
+
narrator: async () => {
|
|
749
|
+
const { narratorList } = await import("./narrator.mjs");
|
|
750
|
+
const out = await narratorList(opts);
|
|
751
|
+
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
752
|
+
},
|
|
753
|
+
"narrator new": async () => {
|
|
754
|
+
const { narratorNew } = await import("./narrator.mjs");
|
|
755
|
+
const out = await narratorNew(opts);
|
|
756
|
+
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
757
|
+
},
|
|
758
|
+
"narrator default": async () => {
|
|
759
|
+
if (!rest[0]) throw new Error("usage: narrator default <name|id|dash>");
|
|
760
|
+
const out = await ops.setDefaultNarrator(opts, rest.join(" "));
|
|
761
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} narrates your decks by default`);
|
|
762
|
+
},
|
|
763
|
+
"narrator remove": async () => {
|
|
764
|
+
if (!rest[0]) throw new Error("usage: narrator remove <name|id>");
|
|
765
|
+
const out = await ops.removeNarrator(opts, rest.join(" "));
|
|
766
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK removed ${out.name}; decks already shared keep working`);
|
|
767
|
+
},
|
|
768
|
+
"narrator voice": async () => {
|
|
769
|
+
if (rest.length < 2) throw new Error("usage: narrator voice <name|id> <voice>");
|
|
770
|
+
const out = await ops.setNarratorVoice(opts, rest[0], rest.slice(1).join(" "));
|
|
771
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} speaks in ${out.voice} now`);
|
|
772
|
+
},
|
|
773
|
+
"narrator rename": async () => {
|
|
774
|
+
if (rest.length < 2) throw new Error("usage: narrator rename <name|id> <new name>");
|
|
775
|
+
const out = await ops.renameNarrator(opts, rest[0], rest.slice(1).join(" "));
|
|
776
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.was} is ${out.name} now`);
|
|
777
|
+
},
|
|
778
|
+
"narrator face": async () => {
|
|
779
|
+
if (rest.length < 2) throw new Error("usage: narrator face <name|id> <avatar>");
|
|
780
|
+
const out = await ops.setNarratorFace(opts, rest[0], rest.slice(1).join(" "));
|
|
781
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} wears ${out.face} now`);
|
|
782
|
+
},
|
|
783
|
+
"voice hear": async () => {
|
|
784
|
+
if (!rest[0] && !opts.narrator) throw new Error("usage: voice hear <voice> | voice hear --narrator <name|id>");
|
|
785
|
+
const out = await ops.hearVoice(opts, opts.narrator ? { narrator: opts.narrator } : { voice: rest.join(" ") });
|
|
786
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `"${out.caption}"\n ${out.url}`);
|
|
787
|
+
},
|
|
788
|
+
"deck narrator": async () => {
|
|
789
|
+
if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
|
|
790
|
+
const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
|
|
791
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (applies at its next narrate and release)`);
|
|
792
|
+
},
|
|
667
793
|
"preview setup": async () => {
|
|
668
794
|
const { setupCapture } = await import("./preview-capture.mjs");
|
|
669
795
|
await setupCapture(rest, opts);
|
|
@@ -675,10 +801,12 @@ function commandTable(pos, rest, opts) {
|
|
|
675
801
|
"deck stats": () => deckStats(rest, opts),
|
|
676
802
|
"deck open": () => deckOpen(rest, opts),
|
|
677
803
|
"deck rm": () => deckRemove(rest, opts),
|
|
804
|
+
"deck forward": () => deckForward(rest, opts),
|
|
678
805
|
"share new": () => shareNew(rest, opts),
|
|
679
806
|
"share list": () => shareList(rest, opts),
|
|
680
807
|
"share on": () => shareToggle(rest, opts, true),
|
|
681
808
|
"share off": () => shareToggle(rest, opts, false),
|
|
809
|
+
"share forward": () => shareForward(rest, opts),
|
|
682
810
|
"share mute": async () => { if (!rest[0]) throw new Error("usage: share mute <token>"); await ops.setShareNotify(opts, rest[0], false); console.log(`OK muted ${rest[0]}`); },
|
|
683
811
|
"share unmute": async () => { if (!rest[0]) throw new Error("usage: share unmute <token>"); await ops.setShareNotify(opts, rest[0], true); console.log(`OK notification preference requested for ${rest[0]} (QA links remain muted)`); },
|
|
684
812
|
"share rm": () => shareRm(rest, opts),
|
package/src/files.mjs
CHANGED
|
@@ -19,6 +19,8 @@ const FILES = {
|
|
|
19
19
|
"templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
|
|
20
20
|
"templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
|
|
21
21
|
"templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
|
|
22
|
+
"templates/first-deck/self.json": () => new URL("../templates/first-deck/self.json", import.meta.url),
|
|
23
|
+
"templates/first-deck/character.json": () => new URL("../templates/first-deck/character.json", import.meta.url),
|
|
22
24
|
"skills/hyperdecks/SKILL.md": () => new URL("../skills/hyperdecks/SKILL.md", import.meta.url),
|
|
23
25
|
"skills/hyperdecks/chat.md": () => new URL("../skills/hyperdecks/chat.md", import.meta.url),
|
|
24
26
|
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The first deck `hyperdecks init` (and the hosted get_started tool) makes: a
|
|
2
|
+
// three-slide hello deck (hero, features, closer) filled from three answers.
|
|
3
|
+
// No file or process I/O beyond the packaged templates, so the hosted MCP route
|
|
4
|
+
// can bundle it.
|
|
5
|
+
import { packageText } from "./files.mjs";
|
|
6
|
+
import { slugify } from "./importer.mjs";
|
|
7
|
+
|
|
8
|
+
export const FIRST_DECK_LAYOUTS = "hero,features,closer";
|
|
9
|
+
export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
|
|
10
|
+
export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
|
|
11
|
+
|
|
12
|
+
// One answer as it goes on a slide and into the script: one line, no stage-tag
|
|
13
|
+
// brackets, ending in a full stop so it is its own caption.
|
|
14
|
+
export function cleanAnswer(key, value) {
|
|
15
|
+
const text = String(value ?? "").replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
|
|
16
|
+
if (text.length < 2) throw new Error(`--${key} needs an answer`);
|
|
17
|
+
if (text.length > ANSWER_MAX[key]) throw new Error(`--${key} is ${text.length} characters; keep it to ${ANSWER_MAX[key]} so it fits a caption`);
|
|
18
|
+
return key === "name" ? text.replace(/[.!?]+$/, "") : /[.!?…]$/.test(text) ? text : `${text}.`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const firstDeckSlug = (email) => `hello-${slugify(String(email ?? "").split("@")[0]) || "there"}`.slice(0, 58).replace(/-+$/, "");
|
|
22
|
+
|
|
23
|
+
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
24
|
+
|
|
25
|
+
export function firstDeckCopy(presenter) {
|
|
26
|
+
return JSON.parse(packageText(`templates/first-deck/${presenter === "dash" ? "character" : "self"}.json`));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function slots(text, answers, html) {
|
|
30
|
+
const map = { __NAME__: answers.name, __WHAT__: answers.about, __ASK__: answers.ask };
|
|
31
|
+
return text.replace(/__NAME__|__WHAT__|__ASK__/g, (k) => (html ? escapeHtml(map[k]) : map[k]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Replace exactly one match, or fail loudly: a layout change must not ship a deck
|
|
35
|
+
// that still says "One sentence on what this deck is about."
|
|
36
|
+
function swap(html, pattern, to, what) {
|
|
37
|
+
let hit = 0;
|
|
38
|
+
const out = html.replace(pattern, (...m) => (hit++, typeof to === "function" ? to(...m) : to));
|
|
39
|
+
if (hit !== 1) throw new Error(`first deck: the ${what} in the layout template changed (${hit} matches); update init's first-deck fill`);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function section(html, id, edit) {
|
|
44
|
+
return swap(html, new RegExp(`<section\\b[^>]*\\sid="${id}"[\\s\\S]*?</section>`), (s) => edit(s), `#${id} slide`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// index.html and script.md of the scaffolded hero,features,closer deck, filled
|
|
48
|
+
// with the answers: no template copy is left, and no generated imagery.
|
|
49
|
+
export function fillFirstDeck({ html, title, answers, presenter, voice }) {
|
|
50
|
+
const copy = firstDeckCopy(presenter);
|
|
51
|
+
const h = (t) => slots(t, answers, true);
|
|
52
|
+
const note = (s) => swap(s, /\sdata-note="[^"]*"/, ' data-note="From your init answers."', "data-note");
|
|
53
|
+
let out = section(html, "hero", (s) => {
|
|
54
|
+
s = note(s);
|
|
55
|
+
s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.hero.kicker)}</div>`, "hero kicker");
|
|
56
|
+
s = swap(s, /<h1 class="tt"[^>]*>[\s\S]*?<\/h1>/, `<h1 class="tt" data-title-mobile="${h(copy.hero.title)}">${h(copy.hero.title)}</h1>`, "hero title");
|
|
57
|
+
return swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.hero.lead)}</p>`, "hero lead");
|
|
58
|
+
});
|
|
59
|
+
out = section(out, "features", (s) => {
|
|
60
|
+
s = note(s);
|
|
61
|
+
s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.features.kicker)}</div>`, "features kicker");
|
|
62
|
+
s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.features.title)}">${h(copy.features.title)}</h2>`, "features title");
|
|
63
|
+
copy.features.cards.forEach((card, i) => {
|
|
64
|
+
s = swap(s, new RegExp(`(<li class="card" id="features-f${i + 1}">[\\s\\S]*?</svg>)<b>[^<]*</b><p>[^<]*</p>`), (_, icon) => `${icon}<b>${h(card.name)}</b><p>${h(card.line)}</p>`, `features card ${i + 1}`);
|
|
65
|
+
});
|
|
66
|
+
return s;
|
|
67
|
+
});
|
|
68
|
+
out = section(out, "closer", (s) => {
|
|
69
|
+
s = note(s);
|
|
70
|
+
s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.closer.title)}">${h(copy.closer.title)}</h2>`, "closer title");
|
|
71
|
+
s = swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.closer.lead)}</p>`, "closer lead");
|
|
72
|
+
s = swap(s, /<a class="cta"[^>]*>[\s\S]*?<\/a>/, `<p class="cta" id="closer-cta">${h(copy.closer.cta)}</p>`, "closer call to action");
|
|
73
|
+
return swap(s, /<div class="contact muted">[^<]*<\/div>/, `<div class="contact muted">${h(copy.closer.contact)}</div>`, "closer contact");
|
|
74
|
+
});
|
|
75
|
+
// A character's live head presents its own deck; a person's voice ships with
|
|
76
|
+
// captions only (data-presenter="none"), never on a character's face.
|
|
77
|
+
if (presenter === "dash") {
|
|
78
|
+
out = swap(out, /(<script type="module" src="https:\/\/decks\.hypersocial\.ai\/player\/v1\/player\.js"[^>]*?)\sdata-presenter="none"/, (_, tag) => `${tag} data-presenter="head" data-avatar="${DASH_AVATAR}"`, "player tag");
|
|
79
|
+
}
|
|
80
|
+
const s = (t) => slots(t, answers, false);
|
|
81
|
+
const script = `---
|
|
82
|
+
voice: ${voice}
|
|
83
|
+
maxTotalSeconds: 90
|
|
84
|
+
transition: drift
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
# ${title} — narration script
|
|
88
|
+
|
|
89
|
+
Written by \`hyperdecks init\` from your three answers. Make it yours: rewrite
|
|
90
|
+
these lines and the slides in index.html, then
|
|
91
|
+
|
|
92
|
+
hyperdecks narrate .
|
|
93
|
+
hyperdecks release . --to "<name>"
|
|
94
|
+
|
|
95
|
+
## hero
|
|
96
|
+
${s(copy.hero.script)}
|
|
97
|
+
|
|
98
|
+
## features
|
|
99
|
+
${s(copy.features.script)}
|
|
100
|
+
|
|
101
|
+
## closer
|
|
102
|
+
${s(copy.closer.script)}
|
|
103
|
+
`;
|
|
104
|
+
return { html: out, script };
|
|
105
|
+
}
|
package/src/init.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// QA link and print it. Every step checks real state before it acts, so running
|
|
7
7
|
// init again resumes where it stopped and changes nothing once it is complete.
|
|
8
8
|
// Each step returns { step, status: "done" | "skipped" | "ran" | "needs-human", detail }.
|
|
9
|
-
//
|
|
9
|
+
// In chat (no folder, no git) the hosted MCP's get_started tool walks the same
|
|
10
|
+
// steps on a hosted draft (lib/getStarted.ts); the deck fill is shared (firstDeck.mjs).
|
|
10
11
|
import { execFileSync } from "node:child_process";
|
|
11
12
|
import { createHash, randomBytes } from "node:crypto";
|
|
12
13
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -15,7 +16,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
15
16
|
import { createInterface } from "node:readline/promises";
|
|
16
17
|
import { fileURLToPath } from "node:url";
|
|
17
18
|
import * as ops from "./ops.mjs";
|
|
18
|
-
import {
|
|
19
|
+
import { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug } from "./firstDeck.mjs";
|
|
19
20
|
import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
|
|
20
21
|
import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
|
|
21
22
|
import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
|
|
@@ -23,17 +24,13 @@ import { narrate as runNarrate } from "./narrate.mjs";
|
|
|
23
24
|
import { release as runRelease } from "./release.mjs";
|
|
24
25
|
|
|
25
26
|
export const STATE_FILE = join(CRED_DIR, "init.json");
|
|
26
|
-
export const FIRST_DECK_LAYOUTS = "hero,features,closer";
|
|
27
|
-
export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
|
|
28
27
|
// Same numbers as convex/lib/tokenCost.ts (the published CLI cannot import it).
|
|
29
28
|
export const VOICE_CLONE_COST = 10;
|
|
30
29
|
export const STARTER_GRANT = 30;
|
|
31
|
-
export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
|
|
32
30
|
const SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "hyperdecks");
|
|
33
|
-
// CLI-only, so read next to the package rather than through files.mjs (the hosted route's list).
|
|
34
|
-
const FIRST_DECK_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "first-deck");
|
|
35
31
|
const CHARACTERS = ["dash", "d.a.s.h."];
|
|
36
32
|
const RESUME = "npx hyperdecks init";
|
|
33
|
+
export { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug };
|
|
37
34
|
export const AGENT_VOICE_HELP = "ask the user to run `npx hyperdecks voice clone <file> --name <name>` in a terminal, " +
|
|
38
35
|
"or upload with `voice upload` and call `clone_voice` after they agree to the statement; " +
|
|
39
36
|
"or run init again with `--voice dash` to let D.A.S.H. present";
|
|
@@ -66,100 +63,6 @@ export function writeState(path, all) {
|
|
|
66
63
|
|
|
67
64
|
// ---- answers and the first deck -------------------------------------------
|
|
68
65
|
|
|
69
|
-
// One answer as it goes on a slide and into the script: one line, no stage-tag
|
|
70
|
-
// brackets, ending in a full stop so it is its own caption.
|
|
71
|
-
export function cleanAnswer(key, value) {
|
|
72
|
-
const text = String(value ?? "").replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
|
|
73
|
-
if (text.length < 2) throw new Error(`--${key} needs an answer`);
|
|
74
|
-
if (text.length > ANSWER_MAX[key]) throw new Error(`--${key} is ${text.length} characters; keep it to ${ANSWER_MAX[key]} so it fits a caption`);
|
|
75
|
-
return key === "name" ? text.replace(/[.!?]+$/, "") : /[.!?…]$/.test(text) ? text : `${text}.`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export const firstDeckSlug = (email) => `hello-${slugify(String(email ?? "").split("@")[0]) || "there"}`.slice(0, 58).replace(/-+$/, "");
|
|
79
|
-
|
|
80
|
-
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
81
|
-
|
|
82
|
-
export function firstDeckCopy(presenter) {
|
|
83
|
-
return JSON.parse(readFileSync(join(FIRST_DECK_DIR, `${presenter === "dash" ? "character" : "self"}.json`), "utf8"));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function slots(text, answers, html) {
|
|
87
|
-
const map = { __NAME__: answers.name, __WHAT__: answers.about, __ASK__: answers.ask };
|
|
88
|
-
return text.replace(/__NAME__|__WHAT__|__ASK__/g, (k) => (html ? escapeHtml(map[k]) : map[k]));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Replace exactly one match, or fail loudly: a layout change must not ship a deck
|
|
92
|
-
// that still says "One sentence on what this deck is about."
|
|
93
|
-
function swap(html, pattern, to, what) {
|
|
94
|
-
let hit = 0;
|
|
95
|
-
const out = html.replace(pattern, (...m) => (hit++, typeof to === "function" ? to(...m) : to));
|
|
96
|
-
if (hit !== 1) throw new Error(`first deck: the ${what} in the layout template changed (${hit} matches); update init's first-deck fill`);
|
|
97
|
-
return out;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function section(html, id, edit) {
|
|
101
|
-
return swap(html, new RegExp(`<section\\b[^>]*\\sid="${id}"[\\s\\S]*?</section>`), (s) => edit(s), `#${id} slide`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// index.html and script.md of the scaffolded hero,features,closer deck, filled
|
|
105
|
-
// with the answers: no template copy is left, and no generated imagery.
|
|
106
|
-
export function fillFirstDeck({ html, title, answers, presenter, voice }) {
|
|
107
|
-
const copy = firstDeckCopy(presenter);
|
|
108
|
-
const h = (t) => slots(t, answers, true);
|
|
109
|
-
const note = (s) => swap(s, /\sdata-note="[^"]*"/, ' data-note="From your init answers."', "data-note");
|
|
110
|
-
let out = section(html, "hero", (s) => {
|
|
111
|
-
s = note(s);
|
|
112
|
-
s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.hero.kicker)}</div>`, "hero kicker");
|
|
113
|
-
s = swap(s, /<h1 class="tt"[^>]*>[\s\S]*?<\/h1>/, `<h1 class="tt" data-title-mobile="${h(copy.hero.title)}">${h(copy.hero.title)}</h1>`, "hero title");
|
|
114
|
-
return swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.hero.lead)}</p>`, "hero lead");
|
|
115
|
-
});
|
|
116
|
-
out = section(out, "features", (s) => {
|
|
117
|
-
s = note(s);
|
|
118
|
-
s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.features.kicker)}</div>`, "features kicker");
|
|
119
|
-
s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.features.title)}">${h(copy.features.title)}</h2>`, "features title");
|
|
120
|
-
copy.features.cards.forEach((card, i) => {
|
|
121
|
-
s = swap(s, new RegExp(`(<li class="card" id="features-f${i + 1}">[\\s\\S]*?</svg>)<b>[^<]*</b><p>[^<]*</p>`), (_, icon) => `${icon}<b>${h(card.name)}</b><p>${h(card.line)}</p>`, `features card ${i + 1}`);
|
|
122
|
-
});
|
|
123
|
-
return s;
|
|
124
|
-
});
|
|
125
|
-
out = section(out, "closer", (s) => {
|
|
126
|
-
s = note(s);
|
|
127
|
-
s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.closer.title)}">${h(copy.closer.title)}</h2>`, "closer title");
|
|
128
|
-
s = swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.closer.lead)}</p>`, "closer lead");
|
|
129
|
-
s = swap(s, /<a class="cta"[^>]*>[\s\S]*?<\/a>/, `<p class="cta" id="closer-cta">${h(copy.closer.cta)}</p>`, "closer call to action");
|
|
130
|
-
return swap(s, /<div class="contact muted">[^<]*<\/div>/, `<div class="contact muted">${h(copy.closer.contact)}</div>`, "closer contact");
|
|
131
|
-
});
|
|
132
|
-
// A character's live head presents its own deck; a person's voice ships with
|
|
133
|
-
// captions only (data-presenter="none"), never on a character's face.
|
|
134
|
-
if (presenter === "dash") {
|
|
135
|
-
out = swap(out, /(<script type="module" src="https:\/\/decks\.hypersocial\.ai\/player\/v1\/player\.js"[^>]*?)\sdata-presenter="none"/, (_, tag) => `${tag} data-presenter="head" data-avatar="${DASH_AVATAR}"`, "player tag");
|
|
136
|
-
}
|
|
137
|
-
const s = (t) => slots(t, answers, false);
|
|
138
|
-
const script = `---
|
|
139
|
-
voice: ${voice}
|
|
140
|
-
maxTotalSeconds: 90
|
|
141
|
-
transition: drift
|
|
142
|
-
---
|
|
143
|
-
|
|
144
|
-
# ${title} — narration script
|
|
145
|
-
|
|
146
|
-
Written by \`hyperdecks init\` from your three answers. Make it yours: rewrite
|
|
147
|
-
these lines and the slides in index.html, then
|
|
148
|
-
|
|
149
|
-
hyperdecks narrate .
|
|
150
|
-
hyperdecks release . --to "<name>"
|
|
151
|
-
|
|
152
|
-
## hero
|
|
153
|
-
${s(copy.hero.script)}
|
|
154
|
-
|
|
155
|
-
## features
|
|
156
|
-
${s(copy.features.script)}
|
|
157
|
-
|
|
158
|
-
## closer
|
|
159
|
-
${s(copy.closer.script)}
|
|
160
|
-
`;
|
|
161
|
-
return { html: out, script };
|
|
162
|
-
}
|
|
163
66
|
|
|
164
67
|
// ---- git ------------------------------------------------------------------
|
|
165
68
|
|
|
@@ -279,6 +182,7 @@ export async function init(pos = [], opts = {}, io = {}) {
|
|
|
279
182
|
env = process.env,
|
|
280
183
|
random = () => randomBytes(2).toString("hex"),
|
|
281
184
|
fetch,
|
|
185
|
+
avatars = null, // the avatar.mjs commands (tests pass fakes)
|
|
282
186
|
} = io;
|
|
283
187
|
if (pos.length) throw new Error(`unexpected argument(s): ${pos.join(" ")}; init takes flags only (hyperdecks init --help)`);
|
|
284
188
|
for (const k of ["voice", "name", "about", "ask", "dir"]) if (k in opts && typeof opts[k] !== "string") throw new Error(`--${k} requires a value`);
|
|
@@ -335,7 +239,17 @@ export async function init(pos = [], opts = {}, io = {}) {
|
|
|
335
239
|
const voiceStep = await chooseVoice(base, opts, { interactive, ask, voiceClone, sub, log, state, who });
|
|
336
240
|
record(voiceStep);
|
|
337
241
|
if (voiceStep.status === "needs-human") return finish("incomplete");
|
|
338
|
-
|
|
242
|
+
let { voice, presenter } = voiceStep;
|
|
243
|
+
|
|
244
|
+
// 3b. Who presents a deck in your own voice: the deck stays captions-only in
|
|
245
|
+
// its source, and release puts your default avatar on it.
|
|
246
|
+
if (presenter === "self") {
|
|
247
|
+
const face = await presenterStep(base, opts, { interactive, ask, log, sub, avatars: avatars ?? (await import("./avatar.mjs")), state });
|
|
248
|
+
record(face);
|
|
249
|
+
if (face.presenter === "dash") ({ voice, presenter } = face);
|
|
250
|
+
// Asked once, unless the step could not even look (it asks again next run).
|
|
251
|
+
if (!face.warning) save({ presenterChosen: true });
|
|
252
|
+
}
|
|
339
253
|
|
|
340
254
|
// 4. The first deck.
|
|
341
255
|
const scaffold = await scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save });
|
|
@@ -494,6 +408,48 @@ async function chooseVoice(base, opts, { interactive, ask, voiceClone, sub, log,
|
|
|
494
408
|
}
|
|
495
409
|
}
|
|
496
410
|
|
|
411
|
+
// Who narrates your decks: your own voice needs a face, or the deck plays
|
|
412
|
+
// captions only. Only you pick it: without a terminal (an agent) this points
|
|
413
|
+
// at `hyperdecks narrator new`.
|
|
414
|
+
async function presenterStep(base, opts, { interactive, ask, log, sub, avatars, state }) {
|
|
415
|
+
let mine;
|
|
416
|
+
try {
|
|
417
|
+
mine = await ops.listAvatars(base);
|
|
418
|
+
} catch (e) {
|
|
419
|
+
const why = ops.errorText(e);
|
|
420
|
+
if (/not switched on/.test(why)) return step("presenter", "skipped", "presenter: captions only (avatars are not switched on for this account yet)");
|
|
421
|
+
return step("presenter", "skipped", `presenter: captions only for now (${why}); run init again, or \`hyperdecks avatar new\``, { warning: true });
|
|
422
|
+
}
|
|
423
|
+
const current = mine.avatars.find((a) => a.isDefault && a.source !== "dash");
|
|
424
|
+
if (current) return step("presenter", "done", `presenter: ${current.name}`);
|
|
425
|
+
if (!interactive || state.presenterChosen) {
|
|
426
|
+
return step("presenter", "skipped", "presenter: captions only; give your decks a narrator (a face plus your voice) with `hyperdecks narrator new`");
|
|
427
|
+
}
|
|
428
|
+
const io = { ask, log: sub, interactive: true };
|
|
429
|
+
for (;;) {
|
|
430
|
+
log("\nWho narrates your decks?");
|
|
431
|
+
log(" 1 D.A.S.H. our robot, in its own voice");
|
|
432
|
+
log(" 2 Pick a Minimoji choose a species, in your voice");
|
|
433
|
+
log(" 3 Make one from a selfie a cartoon of you, you approve it");
|
|
434
|
+
log(" 4 Import from Minimoji one you already have");
|
|
435
|
+
log(" 5 Nobody captions only");
|
|
436
|
+
const pick = (await ask("Choose 1-5 [2]: ")).trim() || "2";
|
|
437
|
+
try {
|
|
438
|
+
if (pick === "1") return step("presenter", "done", "D.A.S.H. presents, in the dash character voice", { voice: "dash", presenter: "dash" });
|
|
439
|
+
if (pick === "2") { const out = await avatars.avatarNew(base, io); return step("presenter", "ran", `presenter: ${out.name}`); }
|
|
440
|
+
if (pick === "3") {
|
|
441
|
+
const file = parseDroppedPaths(await ask("Drag a photo (.jpg or .png) here, or press Enter for a selfie link: "))[0];
|
|
442
|
+
const out = await avatars.avatarFromPhoto(file, base, io);
|
|
443
|
+
return step("presenter", "ran", `presenter: ${out.name}`);
|
|
444
|
+
}
|
|
445
|
+
if (pick === "4") { await avatars.avatarConnect(base, io); return step("presenter", "ran", "presenter: your Minimoji"); }
|
|
446
|
+
if (pick === "5") return step("presenter", "skipped", "presenter: captions only; add a narrator later with `hyperdecks narrator new`");
|
|
447
|
+
} catch (e) {
|
|
448
|
+
log(`That did not finish: ${ops.errorText(e)}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
497
453
|
async function deckOwnedBy(base, slug, who) {
|
|
498
454
|
const deck = await (await client(base)).query(api.decks.get, { slug });
|
|
499
455
|
return deck ? { exists: true, mine: deck.ownerId === who.subject } : { exists: false, mine: false };
|
package/src/narrator.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// hyperdecks narrator [list] | new | default <id> | remove <id> | voice <id> <voice>
|
|
2
|
+
//
|
|
3
|
+
// Who narrates your decks: a face plus a voice (convex/narrators.ts). Only a
|
|
4
|
+
// person adds one: `narrator new` hands back a link where they pick the face
|
|
5
|
+
// and the voice; in a terminal it waits for them, with --json (or an agent) it
|
|
6
|
+
// returns the link and never chooses.
|
|
7
|
+
import * as ops from "./ops.mjs";
|
|
8
|
+
|
|
9
|
+
const interactiveOf = (opts) => !opts.json && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
10
|
+
|
|
11
|
+
export async function narratorList(opts, { log = console.log } = {}) {
|
|
12
|
+
const out = await ops.listNarrators(opts);
|
|
13
|
+
if (opts.json) return out;
|
|
14
|
+
for (const n of out.narrators) log(`${n.isDefault ? "*" : " "} ${n.name}\t${n.face ?? "(no face)"} · ${n.voice ? `${n.voice}${n.voiceReady ? "" : " (not ready)"}` : "(voice removed)"}\t${n.id}`);
|
|
15
|
+
if (out.note) log(`\n${out.note}`);
|
|
16
|
+
log("\n* the default; decks use it unless they set their own (deck narrator <slug> <id>)");
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function narratorNew(opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
|
|
21
|
+
const link = await ops.newNarratorLink(opts);
|
|
22
|
+
if (opts.json) return link;
|
|
23
|
+
log(`Pick your narrator's face and voice here (only you can choose them):\n ${link.url}`);
|
|
24
|
+
if (!interactive) return link;
|
|
25
|
+
log("Waiting for you…");
|
|
26
|
+
const narrator = await ops.waitForNarrator(opts, link.token);
|
|
27
|
+
log(`✓ Added ${narrator.name}. \`hyperdecks narrator\` shows which one is the default.`);
|
|
28
|
+
return { status: "added", ...narrator };
|
|
29
|
+
}
|
package/src/ops.mjs
CHANGED
|
@@ -395,6 +395,14 @@ export async function listShares(opts, slug) {
|
|
|
395
395
|
recipientEmail: r.recipientEmail ?? null,
|
|
396
396
|
expiresAt: r.expiresAt ?? null,
|
|
397
397
|
viewLimit: r.viewLimit ?? null,
|
|
398
|
+
// Forwarding: allowForward is the effective setting (link override, else
|
|
399
|
+
// the deck's, else on); a forwarded link carries its parent's token and the
|
|
400
|
+
// viewer who forwarded it. Rows come back with each link's forwards after it.
|
|
401
|
+
allowForward: !!r.allowForward,
|
|
402
|
+
allowForwardOverride: r.allowForwardOverride ?? null,
|
|
403
|
+
parentToken: r.parentToken ?? null,
|
|
404
|
+
forwardedBy: r.forwardedBy ?? null,
|
|
405
|
+
probablyForwarded: !!r.probablyForwarded,
|
|
398
406
|
}));
|
|
399
407
|
}
|
|
400
408
|
|
|
@@ -407,6 +415,7 @@ export async function createShare(opts, fields) {
|
|
|
407
415
|
if (fields.email) args.recipientEmail = fields.email;
|
|
408
416
|
if (fields.note) args.note = fields.note;
|
|
409
417
|
if (fields.requireEmail) args.requireEmail = true;
|
|
418
|
+
if (fields.allowForward !== undefined) args.allowForward = !!fields.allowForward;
|
|
410
419
|
if (fields.expires) {
|
|
411
420
|
const t = Date.parse(fields.expires);
|
|
412
421
|
if (Number.isNaN(t)) throw new Error(`expires must be an ISO date; got ${fields.expires}`);
|
|
@@ -433,8 +442,23 @@ export async function setShareNotify(opts, token, notify) {
|
|
|
433
442
|
|
|
434
443
|
export async function removeShare(opts, token) {
|
|
435
444
|
if (!token) throw new Error("token is required");
|
|
436
|
-
await (await client(opts)).mutation(api.shares.remove, { token });
|
|
437
|
-
return { token, removed: true };
|
|
445
|
+
const out = await (await client(opts)).mutation(api.shares.remove, { token });
|
|
446
|
+
return { token, removed: true, removedForwards: out?.removedForwards ?? 0 };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Per-link forwarding override: true/false, or null to follow the deck default.
|
|
450
|
+
export async function setShareForwarding(opts, token, allowForward) {
|
|
451
|
+
if (!token) throw new Error("token is required");
|
|
452
|
+
const value = allowForward === null || allowForward === undefined ? null : !!allowForward;
|
|
453
|
+
await (await client(opts)).mutation(api.shares.setAllowForward, { token, allowForward: value });
|
|
454
|
+
return { token, allowForward: value };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The deck default for every link without its own override (on unless set off).
|
|
458
|
+
export async function setDeckForwarding(opts, slug, allowForward) {
|
|
459
|
+
if (!slug) throw new Error("slug is required");
|
|
460
|
+
await (await client(opts)).mutation(api.decks.setAllowForward, { slug, allowForward: !!allowForward });
|
|
461
|
+
return { slug, allowForward: !!allowForward };
|
|
438
462
|
}
|
|
439
463
|
|
|
440
464
|
// Register a new deck (draft) you own. Refuses any slug that already exists:
|
|
@@ -830,3 +854,257 @@ export async function importDeckFromDesign(opts = {}, fields = {}, adapter = {})
|
|
|
830
854
|
);
|
|
831
855
|
return { ...result, fetched: files.size, missing };
|
|
832
856
|
}
|
|
857
|
+
|
|
858
|
+
// ---- avatars (convex/avatars.ts) --------------------------------------------
|
|
859
|
+
// The face that presents decks narrated in your own voice. An agent never picks
|
|
860
|
+
// or approves a face: every way that needs a choice hands back a link (and for
|
|
861
|
+
// an import, a code) for the person; only an interactive terminal picks itself.
|
|
862
|
+
|
|
863
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
864
|
+
|
|
865
|
+
// Your avatars, D.A.S.H. first, after following any edits or revokes made on
|
|
866
|
+
// minimoji.com (best effort: a Minimoji blip still lists them).
|
|
867
|
+
export async function listAvatars(opts = {}) {
|
|
868
|
+
const c = await client(opts);
|
|
869
|
+
await c.action(api.avatarsNode.refresh, {}).catch(() => {});
|
|
870
|
+
const out = await c.query(api.avatars.listAvatars, {});
|
|
871
|
+
if (!out.enabled) throw new Error("avatars are not switched on for this account yet");
|
|
872
|
+
return { default: out.defaultId, avatars: out.avatars.map(({ id, name, source, species, isDefault, linked }) => ({ id, name, source, species: species ?? null, isDefault, linked: !!linked })) };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export async function findAvatar(opts, ref) {
|
|
876
|
+
const want = String(ref ?? "").trim().toLowerCase();
|
|
877
|
+
if (!want) throw new Error("name an avatar (its id or name; `dash` for D.A.S.H.)");
|
|
878
|
+
const { avatars } = await listAvatars(opts);
|
|
879
|
+
const hit = avatars.find((a) => a.id === ref || a.name.toLowerCase() === want || (want === "dash" && a.source === "dash"));
|
|
880
|
+
if (!hit) throw new Error(`no avatar "${ref}" (yours: ${avatars.map((a) => a.name).join(", ")})`);
|
|
881
|
+
return hit;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export async function setDefaultAvatar(opts, ref) {
|
|
885
|
+
const a = await findAvatar(opts, ref);
|
|
886
|
+
await (await client(opts)).mutation(api.avatars.setDefault, { avatarId: a.id });
|
|
887
|
+
return { default: a.id, name: a.name };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export async function removeAvatar(opts, ref) {
|
|
891
|
+
const a = await findAvatar(opts, ref);
|
|
892
|
+
if (a.source === "dash") throw new Error("D.A.S.H. is built in and cannot be removed");
|
|
893
|
+
return { ...(await (await client(opts)).mutation(api.avatars.removeAvatar, { avatarId: a.id })), id: a.id };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// A deck's own presenter: an avatar, `none` (captions only) or `default`.
|
|
897
|
+
export async function setDeckAvatar(opts, slug, ref) {
|
|
898
|
+
if (!slug) throw new Error("usage: deck avatar <slug> <avatar|none|default>");
|
|
899
|
+
const value = ref === "none" || ref === "default" ? ref : (await findAvatar(opts, ref)).id;
|
|
900
|
+
if (value === "dash") throw new Error("D.A.S.H. presents decks in its own voice; for an own-voice deck pick one of your avatars, none or default");
|
|
901
|
+
return (await client(opts)).mutation(api.avatars.setDeckAvatar, { slug, avatar: value });
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// The link where the person picks one (optionally of one species).
|
|
905
|
+
export async function newAvatarLink(opts, { species } = {}) {
|
|
906
|
+
const link = await (await client(opts)).mutation(api.avatars.createLink, { kind: "pick", ...(species ? { species: String(species) } : {}) });
|
|
907
|
+
return { status: "waiting", pickUrl: link.url, token: link.token, expiresAt: link.expiresAt };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export async function avatarOptions(opts, species, seed = 0) {
|
|
911
|
+
return (await client(opts)).action(api.avatarsNode.options, { species: String(species), seed });
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export async function addPickedAvatar(opts, avatarKey) {
|
|
915
|
+
return (await client(opts)).mutation(api.avatars.addPicked, { avatarKey });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// A face from a photo. With bytes: upload and draw it, then hand back the link
|
|
919
|
+
// where the person approves it (nothing is used before). Without: the link
|
|
920
|
+
// where the person takes a selfie in their browser.
|
|
921
|
+
export async function avatarFromPhoto(opts, { bytes, contentType } = {}, { onProgress } = {}) {
|
|
922
|
+
const c = await client(opts);
|
|
923
|
+
if (!bytes) {
|
|
924
|
+
const link = await c.mutation(api.avatars.createLink, { kind: "selfie" });
|
|
925
|
+
return { status: "waiting", selfieUrl: link.url, token: link.token, expiresAt: link.expiresAt };
|
|
926
|
+
}
|
|
927
|
+
if (!/^image\/(jpeg|png)$/.test(contentType ?? "")) throw new Error("the photo must be a .jpg or .png");
|
|
928
|
+
const storageId = await runtimeUploadBytes(c, bytes, contentType);
|
|
929
|
+
const { draftId } = await c.mutation(api.avatars.drawSelfie, { storageId });
|
|
930
|
+
onProgress?.("drawing…");
|
|
931
|
+
const draft = await pollUntil(() => c.query(api.avatars.getDraft, { draftId }), (d) => !d || d.status !== "drawing", { timeoutMs: 6 * 60_000 });
|
|
932
|
+
if (!draft) throw new Error("the drawing is gone; run the command again");
|
|
933
|
+
if (draft.status === "failed") throw new Error(draft.error || "the drawing failed; try another photo");
|
|
934
|
+
const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
|
|
935
|
+
return { status: "waiting", reviewUrl: link.url, token: link.token, draftId, expiresAt: link.expiresAt };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Import from Minimoji with a device code: the person opens `url`, enters
|
|
939
|
+
// `code` and picks one there. The server watches for the answer.
|
|
940
|
+
export async function connectAvatar(opts = {}) {
|
|
941
|
+
const out = await (await client(opts)).action(api.avatarsNode.startConnect, { makeDefault: true });
|
|
942
|
+
return { status: "waiting", url: out.url, code: out.userCode, expiresIn: out.expiresIn, connectId: out.connectId };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export async function waitForConnect(opts, connectId, { timeoutMs = 11 * 60_000 } = {}) {
|
|
946
|
+
const c = await client(opts);
|
|
947
|
+
return pollUntil(() => c.query(api.avatars.getConnect, { connectId }), (x) => x.status !== "waiting", { timeoutMs });
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Wait for the person to finish on a /presenter link; returns the avatar added.
|
|
951
|
+
export async function waitForLink(opts, token, { timeoutMs = 60 * 60_000 } = {}) {
|
|
952
|
+
const c = await client(opts);
|
|
953
|
+
const link = await pollUntil(() => c.query(api.avatars.getLink, { token }), (l) => !!l.avatar || l.expired, { timeoutMs });
|
|
954
|
+
if (!link.avatar) throw new Error("the link expired before an avatar was added; run the command again");
|
|
955
|
+
return link.avatar;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export async function presenterFor(opts, slug, voice) {
|
|
959
|
+
return (await client(opts)).query(api.avatars.presenterFor, { slug, ...(voice ? { voice: String(voice) } : {}) });
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ---- narrators (convex/narrators.ts) ------------------------------------------
|
|
963
|
+
// Who narrates your decks: a face plus a voice. D.A.S.H. is built in. As with
|
|
964
|
+
// avatars, an agent never adds one: `narrator new` hands back a link.
|
|
965
|
+
|
|
966
|
+
export async function listNarrators(opts = {}) {
|
|
967
|
+
const out = await (await client(opts)).query(api.narrators.list, {});
|
|
968
|
+
return {
|
|
969
|
+
default: out.defaultId,
|
|
970
|
+
...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
|
|
971
|
+
narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
|
|
972
|
+
voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export async function findNarrator(opts, ref) {
|
|
977
|
+
const want = String(ref ?? "").trim().toLowerCase();
|
|
978
|
+
if (!want) throw new Error("name a narrator (its id or name; `dash` for D.A.S.H.)");
|
|
979
|
+
const { narrators } = await listNarrators(opts);
|
|
980
|
+
const byId = narrators.find((n) => n.id === ref || (want === "dash" && n.builtin));
|
|
981
|
+
if (byId) return byId;
|
|
982
|
+
// Names repeat ("Me" is the default): an ambiguous name never picks one.
|
|
983
|
+
const named = narrators.filter((n) => n.name.toLowerCase() === want);
|
|
984
|
+
if (named.length > 1) throw new Error(`"${ref}" matches ${named.length} narrators (ids: ${named.map((n) => n.id).join(", ")}); use the id`);
|
|
985
|
+
if (!named.length) throw new Error(`no narrator "${ref}" (yours: ${narrators.map((n) => `${n.name} ${n.id}`).join(", ")})`);
|
|
986
|
+
return named[0];
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// The link where the person picks a face and a voice; it becomes their default.
|
|
990
|
+
export async function newNarratorLink(opts = {}) {
|
|
991
|
+
const link = await (await client(opts)).mutation(api.narrators.newLink, {});
|
|
992
|
+
return { status: "waiting", url: link.url, token: link.token, expiresAt: link.expiresAt };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
export async function waitForNarrator(opts, token, { timeoutMs = 60 * 60_000 } = {}) {
|
|
996
|
+
const c = await client(opts);
|
|
997
|
+
const link = await pollUntil(() => c.query(api.avatars.getLink, { token }), (l) => !!l.narrator || l.expired, { timeoutMs });
|
|
998
|
+
if (!link.narrator) throw new Error("the link expired before a narrator was added; run the command again");
|
|
999
|
+
return link.narrator;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export async function setDefaultNarrator(opts, ref) {
|
|
1003
|
+
const n = await findNarrator(opts, ref);
|
|
1004
|
+
await (await client(opts)).mutation(api.narrators.setDefault, { narrator: n.id });
|
|
1005
|
+
return { default: n.id, name: n.name };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export async function removeNarrator(opts, ref) {
|
|
1009
|
+
const n = await findNarrator(opts, ref);
|
|
1010
|
+
if (n.builtin) throw new Error("D.A.S.H. is built in and cannot be removed");
|
|
1011
|
+
return { ...(await (await client(opts)).mutation(api.narrators.remove, { narrator: n.id })), id: n.id };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// voice: one of your ready voices (name or id) or dash.
|
|
1015
|
+
export async function setNarratorVoice(opts, ref, voice) {
|
|
1016
|
+
if (!voice) throw new Error("usage: narrator voice <narrator> <voice>");
|
|
1017
|
+
const n = await findNarrator(opts, ref);
|
|
1018
|
+
await (await client(opts)).mutation(api.narrators.setVoice, { narrator: n.id, voice: String(voice) });
|
|
1019
|
+
return { narrator: n.id, name: n.name, voice: String(voice) };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
export async function renameNarrator(opts, ref, name) {
|
|
1023
|
+
if (!String(name ?? "").trim()) throw new Error("usage: narrator rename <name|id> <new name>");
|
|
1024
|
+
const n = await findNarrator(opts, ref);
|
|
1025
|
+
if (n.builtin) throw new Error("D.A.S.H. is built in; its name stays");
|
|
1026
|
+
await (await client(opts)).mutation(api.narrators.rename, { narrator: n.id, name: String(name).trim() });
|
|
1027
|
+
return { narrator: n.id, was: n.name, name: String(name).trim() };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// face: one of your avatars (name or id from `avatar`); D.A.S.H. keeps its own.
|
|
1031
|
+
export async function setNarratorFace(opts, ref, avatarRef) {
|
|
1032
|
+
if (!avatarRef) throw new Error("usage: narrator face <name|id> <avatar>");
|
|
1033
|
+
const n = await findNarrator(opts, ref);
|
|
1034
|
+
if (n.builtin) throw new Error("D.A.S.H. is built in and keeps its face");
|
|
1035
|
+
const a = await findAvatar(opts, avatarRef);
|
|
1036
|
+
if (a.source === "dash") throw new Error("D.A.S.H.'s face is D.A.S.H.'s; pick one of your avatars");
|
|
1037
|
+
await (await client(opts)).mutation(api.narrators.setFace, { narrator: n.id, avatarId: a.id });
|
|
1038
|
+
return { narrator: n.id, name: n.name, face: a.name };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// A short sample of a voice, or of a narrator's voice: { url, caption } (what
|
|
1042
|
+
// the dashboard's play buttons play). A narrator also brings its face still.
|
|
1043
|
+
export async function hearVoice(opts, { voice, narrator } = {}) {
|
|
1044
|
+
const c = await client(opts);
|
|
1045
|
+
if (!voice && !narrator) throw new Error("name a voice or a narrator (list_voices / list_narrators)");
|
|
1046
|
+
let ref = voice ? String(voice) : null;
|
|
1047
|
+
let who = null;
|
|
1048
|
+
if (narrator) {
|
|
1049
|
+
const n = await findNarrator(opts, narrator);
|
|
1050
|
+
const raw = (await c.query(api.narrators.list, {})).narrators.find((x) => x.id === n.id);
|
|
1051
|
+
if (!raw?.voice) throw new Error(`${n.name} has no voice yet; give it one (narrator voice)`);
|
|
1052
|
+
if (!raw.voice.ready) throw new Error(`${n.name}'s voice ${raw.voice.name} is not ready yet`);
|
|
1053
|
+
ref = raw.voice.ref;
|
|
1054
|
+
who = { narrator: n.id, name: n.name, faceUrl: raw.face?.thumbUrl ?? null, faceName: raw.face?.name ?? null };
|
|
1055
|
+
}
|
|
1056
|
+
const out = await c.action(api.narratorsNode.hear, { voice: ref });
|
|
1057
|
+
return { ...out, voice: ref, ...(who ?? {}) };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// A photo drawing started from chat (avatar_from_photo with a photo): still
|
|
1061
|
+
// drawing, failed, or drawn, when it hands back the link where the person
|
|
1062
|
+
// approves it. Nothing is used before they do.
|
|
1063
|
+
export async function startAvatarDrawing(opts, { bytes, contentType }) {
|
|
1064
|
+
if (!/^image\/(jpeg|png)$/.test(contentType ?? "")) throw new Error("the photo must be a .jpg or .png");
|
|
1065
|
+
const c = await client(opts);
|
|
1066
|
+
const storageId = await runtimeUploadBytes(c, bytes, contentType);
|
|
1067
|
+
const { draftId } = await c.mutation(api.avatars.drawSelfie, { storageId });
|
|
1068
|
+
return { status: "drawing", draftId };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
export async function avatarDraftStatus(opts, draftId) {
|
|
1072
|
+
if (!draftId) throw new Error("draftId is required");
|
|
1073
|
+
const c = await client(opts);
|
|
1074
|
+
const draft = await c.query(api.avatars.getDraft, { draftId: String(draftId) });
|
|
1075
|
+
if (!draft) throw new Error("the drawing is gone; start again with avatar_from_photo");
|
|
1076
|
+
if (draft.status === "drawing") return { status: "drawing", draftId };
|
|
1077
|
+
if (draft.status === "failed") throw new Error(draft.error || "the drawing failed; try another photo");
|
|
1078
|
+
if (draft.status === "added") return { status: "added", draftId, avatarId: draft.avatarId ?? null };
|
|
1079
|
+
if (draft.status !== "drawn") throw new Error(`the drawing is in an unexpected state (${draft.status}); start again with avatar_from_photo`);
|
|
1080
|
+
const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
|
|
1081
|
+
return { status: "waiting", reviewUrl: link.url, token: link.token, draftId, expiresAt: link.expiresAt };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// A deck's own narrator: a narrator, `none` (captions only) or `default`.
|
|
1085
|
+
export async function setDeckNarrator(opts, slug, ref) {
|
|
1086
|
+
if (!slug || !ref) throw new Error("usage: deck narrator <slug> <narrator|none|default>");
|
|
1087
|
+
const value = ref === "none" || ref === "default" ? ref : (await findNarrator(opts, ref)).id;
|
|
1088
|
+
return (await client(opts)).mutation(api.narrators.setDeckNarrator, { slug, narrator: value });
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Polls `read` until `done`; a few failed reads in a row (a network blip) are
|
|
1092
|
+
// retried, since the person may still be finishing in the browser.
|
|
1093
|
+
async function pollUntil(read, done, { intervalMs = 2000, timeoutMs }) {
|
|
1094
|
+
const start = Date.now();
|
|
1095
|
+
let misses = 0;
|
|
1096
|
+
for (;;) {
|
|
1097
|
+
let value;
|
|
1098
|
+
try {
|
|
1099
|
+
value = await read();
|
|
1100
|
+
misses = 0;
|
|
1101
|
+
} catch (e) {
|
|
1102
|
+
if (++misses >= 5) throw e;
|
|
1103
|
+
await sleep(intervalMs);
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
if (done(value)) return value;
|
|
1107
|
+
if (Date.now() - start > timeoutMs) throw new Error("timed out waiting; run the command again");
|
|
1108
|
+
await sleep(intervalMs);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The released deck's presenter (roadmap: avatars-pick-make-from-a-selfie-or-
|
|
2
|
+
// import-your-minimoji). A deck narrated in the owner's own voice is written
|
|
3
|
+
// captions-only (data-presenter="none"); at release, when the owner has an
|
|
4
|
+
// avatar, its player tag gets that face instead. Shared by the hosted release
|
|
5
|
+
// (convex/deckSources.ts) and the CLI's local release (release.mjs), so both
|
|
6
|
+
// rewrite the tag the same way. A tag that already names a presenter (D.A.S.H.
|
|
7
|
+
// on a character-voice deck, or one set by hand) is left alone.
|
|
8
|
+
|
|
9
|
+
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
|
|
10
|
+
|
|
11
|
+
const attr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """);
|
|
12
|
+
|
|
13
|
+
// html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
|
|
14
|
+
// the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
|
|
15
|
+
// or html unchanged when there is no such tag or it already has a presenter.
|
|
16
|
+
export function withAvatar(html, { avatarUrl, imageUrl }) {
|
|
17
|
+
const tag = PLAYER_TAG.exec(html)?.[0];
|
|
18
|
+
if (!tag || /\sdata-avatar=/.test(tag)) return html;
|
|
19
|
+
// Captions only: data-presenter="none", or no presenter at all (the player's
|
|
20
|
+
// default when there is no data-presenter-image either).
|
|
21
|
+
const none = /\sdata-presenter="none"/.test(tag);
|
|
22
|
+
const unset = !/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag);
|
|
23
|
+
if (!none && !unset) return html;
|
|
24
|
+
const image = imageUrl && !/\sdata-presenter-image=/.test(tag) ? ` data-presenter-image="${attr(imageUrl)}"` : "";
|
|
25
|
+
const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
|
|
26
|
+
const next = none ? tag.replace(/\sdata-presenter="none"/, face) : tag.replace(/>$/, `${face}>`);
|
|
27
|
+
return html.replace(tag, () => next);
|
|
28
|
+
}
|
package/src/release.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { readFileSync } from "node:fs";
|
|
|
15
15
|
import { join, posix } from "node:path";
|
|
16
16
|
import { zipSync } from "fflate";
|
|
17
17
|
import * as ops from "./ops.mjs";
|
|
18
|
+
import { withAvatar } from "./presenter.mjs";
|
|
18
19
|
import { target } from "./runtime.mjs";
|
|
19
20
|
import { checkDeck, localRefs, resolveDeckDir } from "./check.mjs";
|
|
20
21
|
import { narrate as runNarrate } from "./narrate.mjs";
|
|
@@ -47,8 +48,11 @@ export function deckCommit(deckDir) {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
// What import uploads: the page alone, or a zip of the page and its local files.
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// `presenter` (from avatars.presenterFor) puts the owner's avatar on the
|
|
52
|
+
// uploaded page's player tag; the committed index.html is never changed.
|
|
53
|
+
export function releaseBundle(deckDir, presenter = null) {
|
|
54
|
+
const raw = readFileSync(join(deckDir, "index.html"));
|
|
55
|
+
const html = presenter ? Buffer.from(withAvatar(raw.toString("utf8"), presenter)) : raw;
|
|
52
56
|
// check has already refused refs that leave the folder or do not decode.
|
|
53
57
|
const refs = [...new Set(localRefs(html.toString("utf8")).map((r) => posix.normalize(decodeURIComponent(r)).replace(/^(\.?\/)+/, "")))];
|
|
54
58
|
if (!refs.length) return { bytes: html, filename: "index.html", files: ["index.html"] };
|
|
@@ -95,13 +99,22 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
95
99
|
throw new Error(`narration changed the deck (${after.split("\n").length} file(s)):\n${after}\n` +
|
|
96
100
|
`run \`hyperdecks check ${pos[0]}\`, look at it, commit (index.html and narration/), then run release again`);
|
|
97
101
|
}
|
|
102
|
+
// Narrated in your own voice: the released page gets your avatar's face.
|
|
103
|
+
let presenter = null;
|
|
104
|
+
try {
|
|
105
|
+
presenter = await ops.presenterFor(opts, slug, opts.voice);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
log(`⚠ could not look up your avatar (${ops.errorText(e)}); releasing captions only`);
|
|
108
|
+
}
|
|
109
|
+
const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
|
|
110
|
+
if (presenter) log("✓ presented by your avatar");
|
|
98
111
|
const imported = await ops.importDeckFromBytes(opts, {
|
|
99
112
|
slug,
|
|
100
113
|
title: opts.title,
|
|
101
114
|
status: opts.status || "live",
|
|
102
|
-
bytes:
|
|
103
|
-
filename:
|
|
104
|
-
htmlFile:
|
|
115
|
+
bytes: released.bytes,
|
|
116
|
+
filename: released.filename,
|
|
117
|
+
htmlFile: released.htmlFile,
|
|
105
118
|
});
|
|
106
119
|
const sha = commit(deckDir);
|
|
107
120
|
log(`✓ imported "${imported.title}" as ${imported.slug} (${bundle.files.length} file(s), commit ${sha})\n deck: ${imported.deckUrl}`);
|
package/src/runtime.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { ConvexHttpClient } from "convex/browser";
|
|
9
9
|
import { makeFunctionReference } from "convex/server";
|
|
10
10
|
|
|
11
|
-
export const VERSION = "0.
|
|
11
|
+
export const VERSION = "0.5.0";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
14
|
export const PROD = {
|
|
@@ -20,9 +20,41 @@ export const PROD = {
|
|
|
20
20
|
|
|
21
21
|
export const api = {
|
|
22
22
|
previews: { get: makeFunctionReference("previews:get") },
|
|
23
|
+
avatars: {
|
|
24
|
+
listAvatars: makeFunctionReference("avatars:listAvatars"),
|
|
25
|
+
setDefault: makeFunctionReference("avatars:setDefault"),
|
|
26
|
+
removeAvatar: makeFunctionReference("avatars:removeAvatar"),
|
|
27
|
+
setDeckAvatar: makeFunctionReference("avatars:setDeckAvatar"),
|
|
28
|
+
addPicked: makeFunctionReference("avatars:addPicked"),
|
|
29
|
+
drawSelfie: makeFunctionReference("avatars:drawSelfie"),
|
|
30
|
+
getDraft: makeFunctionReference("avatars:getDraft"),
|
|
31
|
+
getConnect: makeFunctionReference("avatars:getConnect"),
|
|
32
|
+
createLink: makeFunctionReference("avatars:createLink"),
|
|
33
|
+
getLink: makeFunctionReference("avatars:getLink"),
|
|
34
|
+
presenterFor: makeFunctionReference("avatars:presenterFor"),
|
|
35
|
+
},
|
|
36
|
+
narrators: {
|
|
37
|
+
list: makeFunctionReference("narrators:list"),
|
|
38
|
+
setDefault: makeFunctionReference("narrators:setDefault"),
|
|
39
|
+
remove: makeFunctionReference("narrators:remove"),
|
|
40
|
+
setVoice: makeFunctionReference("narrators:setVoice"),
|
|
41
|
+
setDeckNarrator: makeFunctionReference("narrators:setDeckNarrator"),
|
|
42
|
+
newLink: makeFunctionReference("narrators:newLink"),
|
|
43
|
+
rename: makeFunctionReference("narrators:rename"),
|
|
44
|
+
setFace: makeFunctionReference("narrators:setFace"),
|
|
45
|
+
},
|
|
46
|
+
narratorsNode: {
|
|
47
|
+
hear: makeFunctionReference("narratorsNode:hear"),
|
|
48
|
+
},
|
|
49
|
+
avatarsNode: {
|
|
50
|
+
options: makeFunctionReference("avatarsNode:options"),
|
|
51
|
+
startConnect: makeFunctionReference("avatarsNode:startConnect"),
|
|
52
|
+
refresh: makeFunctionReference("avatarsNode:refresh"),
|
|
53
|
+
},
|
|
23
54
|
assets: {
|
|
24
55
|
generateUploadUrl: makeFunctionReference("assets:generateUploadUrl"),
|
|
25
56
|
saveAsset: makeFunctionReference("assets:saveAsset"),
|
|
57
|
+
list: makeFunctionReference("assets:list"),
|
|
26
58
|
},
|
|
27
59
|
assetsNode: {
|
|
28
60
|
importAssetFromUrl: makeFunctionReference("assetsNode:importAssetFromUrl"),
|
|
@@ -48,6 +80,7 @@ export const api = {
|
|
|
48
80
|
get: makeFunctionReference("decks:get"),
|
|
49
81
|
listForViewer: makeFunctionReference("decks:listForViewer"),
|
|
50
82
|
remove: makeFunctionReference("decks:remove"),
|
|
83
|
+
setAllowForward: makeFunctionReference("decks:setAllowForward"),
|
|
51
84
|
setHtmlStorage: makeFunctionReference("decks:setHtmlStorage"),
|
|
52
85
|
upsert: makeFunctionReference("decks:upsert"),
|
|
53
86
|
whoami: makeFunctionReference("decks:whoami"),
|
|
@@ -57,6 +90,7 @@ export const api = {
|
|
|
57
90
|
deckAnalytics: makeFunctionReference("shares:deckAnalytics"),
|
|
58
91
|
listForDeck: makeFunctionReference("shares:listForDeck"),
|
|
59
92
|
remove: makeFunctionReference("shares:remove"),
|
|
93
|
+
setAllowForward: makeFunctionReference("shares:setAllowForward"),
|
|
60
94
|
setEnabled: makeFunctionReference("shares:setEnabled"),
|
|
61
95
|
setNotify: makeFunctionReference("shares:setNotify"),
|
|
62
96
|
},
|
|
@@ -102,7 +136,7 @@ export const api = {
|
|
|
102
136
|
},
|
|
103
137
|
};
|
|
104
138
|
|
|
105
|
-
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open"]);
|
|
139
|
+
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji"]);
|
|
106
140
|
// release's recipients, in order: each --to starts one, and an --email right
|
|
107
141
|
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
108
142
|
// opts.email stays the last --email for the other commands (share new).
|
package/src/species.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The avatar picker's species (roadmap: avatars-pick-make-from-a-selfie-or-
|
|
2
|
+
// import-your-minimoji), shared by the backend (convex/lib/avatars/species.ts),
|
|
3
|
+
// the CLI (avatar.mjs) and the web picker. `family` is Minimoji's character
|
|
4
|
+
// family in GET /capabilities; humans have none (we deal their documents).
|
|
5
|
+
export const SPECIES = [
|
|
6
|
+
{ id: "human", label: "Human", family: null },
|
|
7
|
+
{ id: "robot", label: "Robot", family: "dash" },
|
|
8
|
+
{ id: "alien", label: "Alien", family: "alien" },
|
|
9
|
+
{ id: "cat", label: "Cat", family: "cat" },
|
|
10
|
+
{ id: "dog", label: "Dog", family: "dog" },
|
|
11
|
+
{ id: "rabbit", label: "Rabbit", family: "rabbit" },
|
|
12
|
+
{ id: "monkey", label: "Monkey", family: "monkey" },
|
|
13
|
+
{ id: "penguin", label: "Penguin", family: "bird" },
|
|
14
|
+
{ id: "ghost", label: "Ghost", family: "ghost" },
|
|
15
|
+
];
|