@hypersocial/decks-cli 0.7.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 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
- `release` refuses uncommitted changes in the deck folder, runs check and narrate
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -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) from its next release; with none it plays
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.
@@ -125,6 +126,8 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
125
126
  |---|---|---|
126
127
  | Brand (once; every new deck starts from it) | `hyperdecks brand show` / `brand set` / `brand edit` | `get_brand` / `set_brand` |
127
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` |
128
131
  | Scaffold | `hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer]` | `new_deck` (`layouts`) |
129
132
  | Images | `hyperdecks gen-image "<prompt>" --slug <slug>` | `generate_image` |
130
133
  | Video clips | `hyperdecks gen-video "<prompt>" --slug <slug>` | `generate_video` |
@@ -137,6 +140,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
137
140
  | Who narrates (ask the person first; see *Narrators*) | `hyperdecks deck narrator <slug> <name\|none\|default>` | `list_narrators`, then `set_deck_narrator` |
138
141
  | Narrate | `hyperdecks narrate decks/<slug>` | `narrate_deck` |
139
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` |
140
144
  | Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
141
145
  | Share-link poster | `hyperdecks poster <slug>` | `generate_deck_poster` |
142
146
  | Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
@@ -318,6 +322,10 @@ fonts and logo (a PowerPoint's theme exactly) and returns a preview link, a
318
322
  sample slide in that brand, and a token. Always show them the preview and
319
323
  ask; save with `brand set --draft <token>` (`set_brand { draft }`) only after
320
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.
321
329
 
322
330
  **Fonts: pick a pairing.** Fonts load from Google Fonts. Use one of the
323
331
  curated pairings below (display for titles, body for text, mono for kickers
@@ -539,7 +547,16 @@ an internal codename). Messages and what to do:
539
547
 
540
548
  ### 6. Look at it, at both widths
541
549
 
542
- Serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
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>`)
543
560
  and screenshot it with Playwright at 1440×900 and 390×844, every slide
544
561
  (`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
545
562
  clear of the text, the mobile titles in use. Fix and repeat until both read well.
@@ -29,9 +29,14 @@ makes the hello deck; continue from step 5 below.
29
29
  a `previewUrl` (a sample slide in that brand) and a `token`. Always give
30
30
  them the preview link, say what it found (and its notes), and ask; only
31
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`.
32
35
  (`deck_guide { topic: "design" }` lists the font pairings.)
33
36
  2. `new_deck { slug, title, layouts }`: registers your draft and returns the
34
- full text of every file plus a `previewUrl` (the draft, untracked). It
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
35
40
  starts in the saved brand's colours, fonts and logo (`brand` in the result
36
41
  says what it took); `brand: false` gives the template's look.
37
42
  3. Images: `generate_image`, `render_chart`, `capture_url`, or
@@ -69,9 +74,9 @@ makes the hello deck; continue from step 5 below.
69
74
  `[smile]`, `[gesture: lightbulb]`; `deck_guide script` lists them) and
70
75
  narrate again.
71
76
  8. `check_deck { slug }` again, then `review_deck { slug, draft: true }` and
72
- `check_review_job`: it shoots desktop and 390×844 phone frames, which is how
73
- you catch a slide that overflows a phone (the hosted check cannot measure
74
- 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).
75
80
  9. `release_deck { slug, qa: true }` gives the user a QA link to watch first.
76
81
  `release_deck { slug, recipients: [{ recipient, email }] }` makes one
77
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 decks use it from the next release.");
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
@@ -10,6 +10,9 @@
10
10
  // [--draft <token>] (a brand import, saved after you saw its preview)
11
11
  // brand import <url|file|slug> read a brand from a website, a .pptx / .potx /
12
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
13
16
  // brand edit design.md in $VISUAL / $EDITOR, saved on exit
14
17
  // brand fonts the curated font pairings
15
18
  // brand clear forget the brand (new decks get the template look)
@@ -135,7 +138,7 @@ export function importLines(d) {
135
138
  const colors = Object.entries(d.colors ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
136
139
  const fonts = Object.entries(d.fonts ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
137
140
  return [
138
- `read from ${d.source?.kind} ${d.source?.ref}`,
141
+ d.source?.kind === "revise" ? `revised: ${d.source.ref}` : `read from ${d.source?.kind} ${d.source?.ref}`,
139
142
  ...(d.name ? [`name: ${d.name}`] : []),
140
143
  `colours: ${colors || "(none found)"}`,
141
144
  `fonts: ${fonts || "(none found)"}`,
@@ -164,3 +167,45 @@ export async function brandImport(from, opts = {}, { log = (line) => console.log
164
167
  else for (const line of importLines(d)) log(line);
165
168
  return d;
166
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
+ }
@@ -488,9 +488,11 @@ export function extractSite({ html, css = [], url }) {
488
488
  if (card) colors.card = card.hex;
489
489
  }
490
490
  const named = Object.keys(from);
491
- if (named.length) notes.push(`Colours ${named.map((k) => `${k} from ${from[k]}`).join(", ")}; the rest sampled from the site's CSS.`);
492
- else if (Object.keys(colors).length) notes.push("Colours sampled from the site's CSS (it names no colour tokens); check them in the preview.");
493
- else notes.push("No colours found in the site's static CSS (it may draw them with JavaScript); set them with set_brand, or capture_url the page to read them by eye.");
491
+ // For people, plain words; for the logs (details), which token gave what.
492
+ const details = /** @type {string[]} */ ([]);
493
+ if (named.length) details.push(`colours ${named.map((k) => `${k} from ${from[k]}`).join(", ")}; the rest sampled from the site's CSS`);
494
+ if (!named.length && Object.keys(colors).length) notes.push("The site doesn't name its colours, so they were picked from its styles: check them in the sample.");
495
+ else if (!Object.keys(colors).length) notes.push("No colours were found on the site (it may draw them with JavaScript): pick them by hand.");
494
496
 
495
497
  // Fonts: Google Fonts links, @font-face, then font-family by role.
496
498
  const linked = [];
@@ -530,7 +532,7 @@ export function extractSite({ html, css = [], url }) {
530
532
 
531
533
  const candidates = logoCandidates(doc, url);
532
534
  const name = siteName(doc);
533
- return { name, colors, fonts, logoCandidates: candidates, notes, source: { kind: "url", ref: url } };
535
+ return { name, colors, fonts, logoCandidates: candidates, notes, details, source: { kind: "url", ref: url } };
534
536
  }
535
537
 
536
538
  // ---- a Hyperdecks deck -----------------------------------------------------
@@ -559,10 +561,13 @@ export function extractDeck(html, slug) {
559
561
  const alt = logoTag && attr(logoTag.tag, "alt");
560
562
  const name = alt && / logo$/i.test(alt) ? decodeEntities(alt).replace(/ logo$/i, "").trim() : undefined;
561
563
  const notes = /** @type {string[]} */ ([]);
562
- notes.push(`Colours and fonts are deck ${slug}'s own :root tokens.`);
563
- if (!src) notes.push("The deck has no brand logo; add one with set_brand { logoUrl }.");
564
- else if (!/^https:\/\//.test(src)) notes.push(`The deck's logo (${src.slice(0, 80)}) is not a hosted https URL, so it was not copied; add it with set_brand { logoUrl }.`);
565
- return { name, colors, fonts, logoCandidates: src && /^https:\/\//.test(src) ? [{ url: src, via: "deck" }] : [], notes, source: { kind: "deck", ref: slug } };
564
+ const details = [`colours and fonts are deck ${slug}'s :root tokens`];
565
+ if (!src) notes.push("The deck has no logo: add one.");
566
+ else if (!/^https:\/\//.test(src)) {
567
+ notes.push("The deck's logo is not hosted online, so it was not copied: add it.");
568
+ details.push(`deck logo src ${src.slice(0, 80)} is not an https URL`);
569
+ }
570
+ return { name, colors, fonts, logoCandidates: src && /^https:\/\//.test(src) ? [{ url: src, via: "deck" }] : [], notes, details, source: { kind: "deck", ref: slug } };
566
571
  }
567
572
 
568
573
  // ---- PowerPoint ------------------------------------------------------------
@@ -648,8 +653,8 @@ export function extractPptx(bytes, fileName = "presentation.pptx") {
648
653
  if (major) fonts.display = major;
649
654
  if (minor) fonts.body = minor;
650
655
  const notes = /** @type {string[]} */ ([]);
651
- notes.push(`Colours and fonts are the exact theme of ${fileName}${scheme ? ` (colour scheme "${scheme}")` : ""}: ink dk1, paper lt1, accent accent1, accent2 accent2, card lt2; titles the major font, body the minor.`);
652
- if (/^office/i.test(scheme ?? "") && /^(calibri|aptos)/i.test(minor ?? "")) notes.push("This is PowerPoint's default Office theme, not a custom one: the colours may not be the brand's.");
656
+ const details = [`theme of ${fileName}${scheme ? ` (colour scheme "${scheme}")` : ""}: ink dk1, paper lt1, accent accent1, accent2 accent2, card lt2; titles the major font, body the minor`];
657
+ if (/^office/i.test(scheme ?? "") && /^(calibri|aptos)/i.test(minor ?? "")) notes.push("This is PowerPoint's default theme, not a custom one, so the colours may not be your brand's.");
653
658
 
654
659
  // The logo: the first picture on the slide master, else on its first layout.
655
660
  let logo;
@@ -673,14 +678,15 @@ export function extractPptx(bytes, fileName = "presentation.pptx") {
673
678
  // Only a picture under the logo limit is unpacked (its declared size; fflate inflates into exactly that).
674
679
  let declared = 0;
675
680
  const got = type ? unzipSync(bytes, { filter: (f) => f.name === media && (declared = f.originalSize) <= MAX_LOGO_BYTES })[media] : null;
676
- if (!type) notes.push(`The slide master's picture is a .${ext} file, which a deck cannot show; add the logo with set_brand { logoUrl }.`);
677
- else if (declared > MAX_LOGO_BYTES) notes.push(`The slide master's picture is ${Math.ceil(declared / 1024)} KB, over the 2 MB logo limit; add a smaller logo.`);
678
- else if (!got) notes.push("The slide master names a picture the file does not contain.");
679
- else if (got.byteLength > MAX_LOGO_BYTES) notes.push(`The slide master's picture is ${Math.ceil(got.byteLength / 1024)} KB, over the 2 MB logo limit; add a smaller logo.`);
681
+ if (!type) notes.push(`The logo in the file is a .${ext} picture, which a deck cannot show: add it as PNG or SVG.`);
682
+ else if (declared > MAX_LOGO_BYTES) notes.push(`The logo in the file is ${Math.ceil(declared / 1024)} KB, over the 2 MB limit: add a smaller one.`);
683
+ else if (!got) notes.push("No logo was found in the file: add one.");
684
+ else if (got.byteLength > MAX_LOGO_BYTES) notes.push(`The logo in the file is ${Math.ceil(got.byteLength / 1024)} KB, over the 2 MB limit: add a smaller one.`);
680
685
  else logo = { bytes: got, type, via: "slide master" };
681
- } else notes.push("The slide master has no picture, so no logo was found; add one with set_brand { logoUrl }.");
686
+ if (!logo) details.push(`slide master picture ${media}`);
687
+ } else notes.push("No logo was found in the file: add one.");
682
688
  const company = files["docProps/app.xml"] && /<Company>([^<]+)<\/Company>/.exec(xmlText(files["docProps/app.xml"]))?.[1]?.trim();
683
- return { name: company ? decodeEntities(company).slice(0, 60) : undefined, colors, fonts, logo, logoCandidates: [], notes, source: { kind: "pptx", ref: fileName } };
689
+ return { name: company ? decodeEntities(company).slice(0, 60) : undefined, colors, fonts, logo, logoCandidates: [], notes, details, source: { kind: "pptx", ref: fileName } };
684
690
  }
685
691
 
686
692
  // ---- PDF -------------------------------------------------------------------
@@ -849,12 +855,13 @@ export function extractPdf(bytes, fileName = "document.pdf") {
849
855
  if (second) colors.accent2 = second.hex;
850
856
 
851
857
  const notes = /** @type {string[]} */ ([]);
852
- notes.push(families.size ? `Fonts are the ones embedded in ${fileName}.` : `${fileName} embeds no fonts we could read.`);
853
- const skipped = undecoded ? ` (${undecoded} compressed stream${undecoded > 1 ? "s" : ""} could not be decoded)` : "";
854
- if (painted) notes.push(`Colours sampled from what the first pages paint${paper ? "" : "; no page background was drawn, so paper is white"}${skipped}: check them in the preview.`);
855
- else notes.push(`No colours could be read from ${fileName}'s pages${skipped}; set them with set_brand.`);
856
- notes.push("A PDF does not say which picture is the logo; add one with set_brand { logoUrl } or --logo.");
857
- return { colors, fonts, logoCandidates: [], notes, source: { kind: "pdf", ref: fileName } };
858
+ const details = [];
859
+ if (!families.size) notes.push("No fonts could be read from the file: pick them by hand.");
860
+ if (undecoded) details.push(`${undecoded} compressed stream${undecoded > 1 ? "s" : ""} could not be decoded`);
861
+ if (painted) notes.push(`Colours were picked from the first pages${paper ? "" : " (they draw no background, so it is white)"}: check them in the sample.`);
862
+ else notes.push("No colours could be read from the file: pick them by hand.");
863
+ notes.push("A PDF doesn't say which picture is the logo: add one.");
864
+ return { colors, fonts, logoCandidates: [], notes, details, source: { kind: "pdf", ref: fileName } };
858
865
  }
859
866
 
860
867
  // Whether a draft read anything worth saving: a colour (beyond the white page
@@ -914,6 +921,8 @@ const CLASS_PAIRING = { serif: "editorial", geometric: "geometric", friendly: "f
914
921
  // A serif body reads best as the literary pairing's body.
915
922
  const BODY_PAIRING = { serif: "literary" };
916
923
 
924
+ const ROLE_WORDS = { display: "titles", body: "body text", mono: "labels" };
925
+
917
926
  // The draft's fonts as Google Fonts families. A family Google serves is kept
918
927
  // (with Google's spelling); any other maps to the closest pairing's family for
919
928
  // that role, with a note saying so. isGoogleFont(family) -> boolean | Promise.
@@ -949,7 +958,7 @@ export async function resolveFonts(fonts = {}, { isGoogleFont } = {}) {
949
958
  const p = FONT_PAIRINGS.find((x) => x.id === id);
950
959
  pairing ??= p;
951
960
  out[role] = p[role];
952
- notes.push(`${family} is not on Google Fonts, so ${role} uses ${p[role]} (the "${p.id}" pairing), the closest match.`);
961
+ notes.push(`${family} isn't a Google Font, so ${ROLE_WORDS[role]} use${role === "body" ? "s" : ""} ${p[role]}, the closest match.`);
953
962
  }
954
963
  if (!out.mono) {
955
964
  const p =
@@ -959,9 +968,9 @@ export async function resolveFonts(fonts = {}, { isGoogleFont } = {}) {
959
968
  FONT_PAIRINGS.find((x) => x.body === out.body) ??
960
969
  FONT_PAIRINGS.find((x) => x.id === "inter");
961
970
  out.mono = p.mono;
962
- notes.push(`No monospace font found; labels use ${p.mono} (the "${p.id}" pairing).`);
971
+ notes.push(`No label font was found, so labels use ${p.mono}.`);
963
972
  }
964
- for (const f of new Set(unconfirmed)) notes.push(`Could not check that ${f} is on Google Fonts (the check failed), so it is kept as found; if the preview shows a fallback font, pick another.`);
973
+ for (const f of new Set(unconfirmed)) notes.push(`Couldn't check that ${f} is a Google Font, so it is kept: if the sample shows a fallback font, pick another.`);
965
974
  // A role the source left empty takes the other's family.
966
975
  if (!out.display && out.body) out.display = out.body;
967
976
  if (!out.body && out.display) out.body = out.display;
@@ -972,6 +981,7 @@ export async function resolveFonts(fonts = {}, { isGoogleFont } = {}) {
972
981
  // fields: Convex refuses them).
973
982
  export function draftArgs(x) {
974
983
  const args = { source: x.source, colors: x.colors, fonts: x.fonts, notes: x.notes };
984
+ if (x.details?.length) args.details = x.details;
975
985
  if (x.name) args.name = x.name;
976
986
  if (x.logo?.bytes) {
977
987
  args.logoBytes = x.logo.bytes.slice().buffer;
@@ -7,11 +7,15 @@
7
7
  import { applyBrand } from "./brand.mjs";
8
8
  import { assembleLayoutDeck } from "./layouts.mjs";
9
9
 
10
+ const SAMPLE_CSS = ".hd-embedded :is(.hd-gate, .hd-controls, .hd-chapters, .hd-presenter, .hd-slide-note, .hd-note, .hd-flash) { display: none !important; }";
11
+ // The slides a sample shows, each whole in its own 16:9 frame.
12
+ export const SAMPLE_SLIDES = ["hero", "features"];
13
+
10
14
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
11
15
 
12
16
  export function brandPreviewHtml(draft, { saved = false } = {}) {
13
17
  const name = (draft?.name ?? "").trim() || "Your brand";
14
- let html = assembleLayoutDeck({ slug: "brand-preview", title: name, layouts: ["hero", "features"] })["index.html"];
18
+ let html = assembleLayoutDeck({ slug: "brand-preview", title: name, layouts: SAMPLE_SLIDES })["index.html"];
15
19
  // The hero's art slot is a stand-in a real deck fills; the preview has no art.
16
20
  html = html.replace(/\s*<div class="art" role="presentation"><\/div>/, "");
17
21
  html = html.replace(/(<div class="kicker">)brand-preview(<\/div>)/, saved ? "$1Your brand$2" : "$1Brand preview$2");
@@ -19,6 +23,9 @@ export function brandPreviewHtml(draft, { saved = false } = {}) {
19
23
  "One sentence on what this deck is about.",
20
24
  saved ? "Your colours, fonts and logo on a sample slide. Every new deck starts from this." : "Your colours, fonts and logo on a sample slide. Nothing is saved until you say yes.",
21
25
  );
26
+ // Framed on the Brand page (one iframe per slide, #scene=hero / #scene=features),
27
+ // the sample is a picture of the slide: no play gate, controls or presenter.
28
+ html = html.replace("</head>", () => `<style data-brand-sample>${SAMPLE_CSS}</style>\n</head>`);
22
29
  html = html.replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtml(name)} · ${saved ? "brand" : "brand preview"}</title>`);
23
30
  return applyBrand(html, { name: draft?.name ?? undefined, colors: draft?.colors ?? {}, fonts: draft?.fonts ?? {}, logoUrl: draft?.logoUrl ?? undefined });
24
31
  }
@@ -414,9 +414,9 @@ export function timelineErrors(clip, timeline) {
414
414
  captions.forEach((c, i) => {
415
415
  if (!(c.start >= 0 && c.end > c.start && c.end <= duration + 0.1)) errs.push(MESSAGES.timing(clip.id, i, c.start, c.end, duration));
416
416
  });
417
- // Caption text carries the script's spelling; the timed words carry the
418
- // transcript's (which may spell a name differently, as the narrate guard's
419
- // pronunciation map allows), so coverage is checked on the text.
417
+ // Caption text carries the script's spelling; the timed words of an older
418
+ // clip carry the transcript's (which may spell a name differently, as the
419
+ // narrate guard's pronunciation map allows), so coverage is checked on the text.
420
420
  const covered = captions.map((c) => String(c.text ?? "")).join(" ");
421
421
  const timed = captions.every((c) => Array.isArray(c.words) && c.words.length);
422
422
  if (!timed || normalizeWords(covered) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
package/src/commands.mjs CHANGED
@@ -322,6 +322,9 @@ commands:
322
322
  file an image, video or font under the deck; prints its hosted URL
323
323
  guide [workflow|format|script|design|layouts|presenter|transitions|check]
324
324
  the skill, one topic at a time (what deck_guide serves)
325
+ play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
326
+ play the folder as it is, committed or not, with its real face and voice, on 127.0.0.1
327
+ until Ctrl-C; nothing is written, uploaded or shared, and changed lines need narrate first
325
328
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
326
329
  on a committed deck: check → narrate → import → share new per --to
327
330
  share new|list|on|off|forward|mute|unmute|rm|url
@@ -362,6 +365,12 @@ commands:
362
365
  brand import <url|file|slug> [--json]
363
366
  read a brand from your website, a .pptx / .potx / .pdf or one of your decks: shows its
364
367
  colours, fonts and logo and a preview link; save it with brand set --draft <token>
368
+ brand revise "<change>" [--json]
369
+ a draft of your brand with that change ("darker accent, serif titles") and a
370
+ preview link; save it with brand set --draft <token>
371
+ brand history the brand's last changes, from the dashboard, chat or here
372
+ brand undo [<version>]
373
+ undo the last change, or go back to a version from brand history
365
374
  brand edit edit design.md (voice, do and don't) in $EDITOR
366
375
  brand fonts the curated font pairings (display + body + mono)
367
376
  brand clear forget the brand; new decks get the template look
@@ -393,7 +402,7 @@ commands:
393
402
  avatar connect import your avatar from Minimoji with a code you enter on minimoji.com
394
403
  avatar default <name|id|dash> make it the default
395
404
  avatar remove <name|id> remove it; decks already shared keep working, captions only
396
- deck avatar <slug> <name|id|none|default> the deck's own presenter (applies at its next release)
405
+ deck avatar <slug> <name|id|none|default> the deck's own presenter (play and the draft link now; share links at its next release)
397
406
  narrator [--json] who narrates your decks (a face plus a voice) and the default
398
407
  narrator new a link where you pick a face and a voice; it becomes the default
399
408
  narrator default <name|id|dash> make it the default
@@ -693,6 +702,13 @@ function commandTable(pos, rest, opts) {
693
702
  const out = await release(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
694
703
  if (opts.json) console.log(JSON.stringify(out, null, 2));
695
704
  },
705
+ play: async () => {
706
+ const { play } = await import("./play.mjs");
707
+ await play(pos.slice(1), opts, opts.json ? {
708
+ log: (line) => console.error(line),
709
+ started: ({ url, slug, deckDir, presenter, who }) => console.log(JSON.stringify({ url, slug, deckDir, presenter, who })),
710
+ } : {});
711
+ },
696
712
  narrate: async () => {
697
713
  const { narrate } = await import("./narrate.mjs");
698
714
  const result = await narrate(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
@@ -774,7 +790,7 @@ function commandTable(pos, rest, opts) {
774
790
  "deck avatar": async () => {
775
791
  if (rest.length < 2) throw new Error("usage: deck avatar <slug> <name|id|none|default>");
776
792
  const out = await ops.setDeckAvatar(opts, rest[0], rest.slice(1).join(" "));
777
- console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (applies at its next release)`);
793
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (play and the draft link now; share links at its next release)`);
778
794
  },
779
795
  narrator: async () => {
780
796
  const { narratorList } = await import("./narrator.mjs");
@@ -819,7 +835,7 @@ function commandTable(pos, rest, opts) {
819
835
  "deck narrator": async () => {
820
836
  if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
821
837
  const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
822
- console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (applies at its next narrate and release)`);
838
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
823
839
  },
824
840
  "preview setup": async () => {
825
841
  const { setupCapture } = await import("./preview-capture.mjs");
@@ -872,6 +888,9 @@ function commandTable(pos, rest, opts) {
872
888
  "brand fonts": async () => (await import("./brandCli.mjs")).brandFonts(opts),
873
889
  "brand clear": async () => (await import("./brandCli.mjs")).brandClear(opts),
874
890
  "brand import": async () => (await import("./brandCli.mjs")).brandImport(rest[0], opts),
891
+ "brand revise": async () => (await import("./brandCli.mjs")).brandRevise(rest, opts),
892
+ "brand history": async () => (await import("./brandCli.mjs")).brandHistory(opts),
893
+ "brand undo": async () => (await import("./brandCli.mjs")).brandUndo(rest[0], opts),
875
894
  review: () => review(pos.slice(1), opts),
876
895
  "review-job": () => reviewJob(pos.slice(1), opts),
877
896
  poster: () => poster(pos.slice(1), opts),
package/src/ops.mjs CHANGED
@@ -964,6 +964,11 @@ export async function presenterFor(opts, slug, voice) {
964
964
  return (await client(opts)).query(api.avatars.presenterFor, { slug, ...(voice ? { voice: String(voice) } : {}) });
965
965
  }
966
966
 
967
+ // The same answer as presenterFor plus who presents in words: { presenter, who }.
968
+ export async function presenterWithReason(opts, slug, voice) {
969
+ return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}) });
970
+ }
971
+
967
972
  // ---- narrators (convex/narrators.ts) ------------------------------------------
968
973
  // Who narrates your decks: a face plus a voice. D.A.S.H. is built in. As with
969
974
  // avatars, an agent never adds one: `narrator new` hands back a link.
@@ -1155,6 +1160,26 @@ export async function importBrand(opts, { url, slug, bytes, fileName } = {}) {
1155
1160
  return c.action(api.brandNode.createDraft, draftArgs(extractFile(data, fileName)));
1156
1161
  }
1157
1162
 
1163
+ // Revise the brand with a prompt ("darker accent, serif titles"): a draft
1164
+ // like importBrand's, with a previewUrl; nothing is saved until
1165
+ // setBrand({ draft: token }).
1166
+ export async function reviseBrand(opts, { instruction } = {}) {
1167
+ const ask = String(instruction ?? "").trim();
1168
+ if (!ask) throw new Error('say what to change, e.g. "make the accent darker and use a serif for titles"');
1169
+ return (await client(opts)).action(api.brandRevise.revise, { instruction: ask });
1170
+ }
1171
+
1172
+ // The brand's last changes, newest first (the first is the brand now).
1173
+ export async function brandHistory(opts = {}) {
1174
+ return { versions: await (await client(opts)).query(api.brand.history, {}) };
1175
+ }
1176
+
1177
+ // Undo the brand's last change, or go back to one version from brandHistory.
1178
+ export async function undoBrand(opts = {}, { version } = {}) {
1179
+ const c = await client(opts);
1180
+ return version ? c.mutation(api.brand.restore, { version: String(version) }) : c.mutation(api.brand.undo, {});
1181
+ }
1182
+
1158
1183
  export async function clearBrand(opts = {}) {
1159
1184
  await (await client(opts)).mutation(api.brand.clear, {});
1160
1185
  return { cleared: true };
package/src/play.mjs ADDED
@@ -0,0 +1,119 @@
1
+ // hyperdecks play <slug|deck-dir> [--voice v] [--slug s] [--port n] [--no-open] [--json]
2
+ //
3
+ // Plays the working folder as it is, committed or not, the way it will play once
4
+ // released: the owner's face goes on the player tag of the page it serves, in
5
+ // memory only. It writes no file, uploads nothing, creates no share and renders
6
+ // no audio, so it plays the manifest index.html already points at; changed lines
7
+ // need `narrate` first. The server listens on 127.0.0.1 behind a random path until
8
+ // Ctrl-C. The deck's script.md and evidence.md are not served (preview-capture.mjs
9
+ // serves only the file types a page loads).
10
+ import { readFileSync } from "node:fs";
11
+ import { basename, join, relative } from "node:path";
12
+ import { playerScript, sectionIds } from "./checkRules.mjs";
13
+ import { resolveDeckDir } from "./check.mjs";
14
+ import * as ops from "./ops.mjs";
15
+ import { playsCaptionsOnly, presenterLine, withAvatar } from "./presenter.mjs";
16
+ import { serveSource } from "./preview-capture.mjs";
17
+ import { openBrowser } from "./runtime.mjs";
18
+
19
+ const USAGE = "usage: play <slug|deck-dir> [--voice <name|id>] [--slug <slug>] [--port <n>] [--no-open]";
20
+
21
+ function portOf(value) {
22
+ if (value === undefined) return 0;
23
+ const port = Number(value);
24
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`--port must be a port number (got ${value})`);
25
+ return port;
26
+ }
27
+
28
+ // A path `narrate` and `import` take: the argument to play may be a slug.
29
+ function pathFrom(cwd, deckDir) {
30
+ const rel = relative(cwd ?? process.cwd(), deckDir);
31
+ return !rel ? "." : rel.startsWith("..") ? deckDir : rel;
32
+ }
33
+
34
+ // The face to add, and { who } in words. Never stops the play: without an answer
35
+ // the page plays as written, as a release does when this lookup fails.
36
+ async function lookUpPresenter(opts, { slug, path, html, log }) {
37
+ const narrate = `hyperdecks narrate ${path}`;
38
+ const lead = playsCaptionsOnly(html) ? "captions only" : "playing as written";
39
+ if (!playerScript(html)?.manifest) {
40
+ log(`⚠ no narration yet, so this plays silent: run \`${narrate}\` first`);
41
+ return { presenter: null, who: null };
42
+ }
43
+ try {
44
+ const found = await ops.presenterWithReason(opts, slug, opts.voice);
45
+ if (!found.who) log(`⚠ ${lead}: no finished narration on this account for "${slug}": run \`${narrate}\` first`);
46
+ return found;
47
+ } catch (e) {
48
+ const why = ops.errorText(e);
49
+ if (/deck not found/.test(why)) log(`⚠ ${lead}: "${slug}" is not on this account yet, so it has no presenter. Register it (\`hyperdecks import ${join(path, "index.html")} --slug ${slug} --status draft\`), then run \`${narrate}\``);
50
+ else if (/forbidden/.test(why)) log(`⚠ ${lead}: the deck "${slug}" belongs to another account. Log in as its owner, or pass --slug with yours`);
51
+ else log(`⚠ ${lead}: could not ask who presents (${why}). Your avatar is not the problem; try again, and if it keeps failing this CLI may be newer than the backend`);
52
+ return { presenter: null, who: null };
53
+ }
54
+ }
55
+
56
+ // Says what the served page does, not what the lookup answered.
57
+ function sayWhoPresents({ html, page, presenter, who, log }) {
58
+ if (presenter && page === html) log("⚠ the player tag already names a presenter, so it plays as written");
59
+ else if (who?.face && !presenter && playsCaptionsOnly(html)) log(`⚠ captions only: the page names no presenter, and a face for voice "${who.voice}" is not added here`);
60
+ else if (who) log(presenterLine(who));
61
+ }
62
+
63
+ // A warning only. An error with text is the backend's answer (the local script
64
+ // differs from the hosted one, a line that won't compile), so it is shown; no
65
+ // script.md, or a backend that could not say, is not worth a line.
66
+ async function warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log }) {
67
+ try {
68
+ const script = readFileSync(join(deckDir, "script.md"), "utf8");
69
+ const plan = await ops.previewNarration(opts, { slug, script, voice: who.voice, sectionIds: sectionIds(html) });
70
+ const changed = plan.slides.filter((s) => s.render).map((s) => s.id);
71
+ if (changed.length) log(`⚠ script.md changed since the last narration (${changed.join(", ")}); this plays the old audio. Run \`hyperdecks narrate ${path}\` to hear the new lines`);
72
+ } catch (e) {
73
+ if (typeof e?.data === "string") log(`⚠ could not compare script.md with the last narration: ${e.data}`);
74
+ }
75
+ }
76
+
77
+ // Starts serving and returns; the caller closes it. `play` is the command.
78
+ export async function startPlay(pos, opts = {}, { log = (line) => console.log(line), open = openBrowser, cwd } = {}) {
79
+ const [deckDirArg] = pos;
80
+ if (!deckDirArg) throw new Error(USAGE);
81
+ for (const k of ["voice", "slug", "port"]) if (k in opts && typeof opts[k] !== "string") throw new Error(`--${k} requires a value`);
82
+ const port = portOf(opts.port);
83
+ const deckDir = resolveDeckDir(deckDirArg, cwd);
84
+ const path = pathFrom(cwd, deckDir);
85
+ const slug = opts.slug || basename(deckDir);
86
+ const file = join(deckDir, "index.html");
87
+ const html = readFileSync(file, "utf8");
88
+
89
+ const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
90
+ const page = presenter ? withAvatar(html, presenter) : html;
91
+ sayWhoPresents({ html, page, presenter, who, log });
92
+ // --voice names the voice it was narrated in, so there is nothing to compare it to.
93
+ if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });
94
+
95
+ let server;
96
+ try {
97
+ server = await serveSource({ file, html: page, port });
98
+ } catch (e) {
99
+ throw new Error(e?.code === "EADDRINUSE" ? `port ${port} is in use; pick another with --port` : e?.message || String(e));
100
+ }
101
+ log(`▶ playing ${slug}: ${server.url}\n Ctrl-C to stop. Nothing is written, uploaded or shared.`);
102
+ if (!opts["no-open"]) open(server.url);
103
+ return { url: server.url, close: server.close, deckDir, slug, presenter: page === html ? null : presenter, who: who ?? null };
104
+ }
105
+
106
+ export async function play(pos, opts = {}, io = {}) {
107
+ const session = await startPlay(pos, opts, io);
108
+ await new Promise((stopped) => {
109
+ const stop = () => {
110
+ process.off("SIGINT", stop);
111
+ process.off("SIGTERM", stop);
112
+ stopped();
113
+ };
114
+ process.on("SIGINT", stop);
115
+ process.on("SIGTERM", stop);
116
+ io.started?.(session);
117
+ });
118
+ await session.close();
119
+ }
package/src/presenter.mjs CHANGED
@@ -1,26 +1,33 @@
1
1
  // The released deck's presenter (roadmap: avatars-pick-make-from-a-selfie-or-
2
2
  // import-your-minimoji). A deck narrated in the owner's own voice is written
3
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.
4
+ // avatar, its player tag gets that face instead. Shared by both releases
5
+ // (convex/deckSources.ts, release.mjs), `hyperdecks play` and the /p draft
6
+ // preview, so all rewrite the tag the same way; only a release stores it. A tag that already names a presenter (D.A.S.H.
7
7
  // on a character-voice deck, or one set by hand) is left alone.
8
8
 
9
9
  const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
10
10
 
11
11
  const attr = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
12
12
 
13
+ // Whether this tag plays captions only: data-presenter="none", or no presenter
14
+ // at all (the player's default when there is no data-presenter-image either).
15
+ const noPresenter = (tag) => /\sdata-presenter="none"/.test(tag) || (!/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag));
16
+
17
+ // Whether the page's player tag names nobody, so without a face it has captions only.
18
+ export function playsCaptionsOnly(html) {
19
+ const tag = PLAYER_TAG.exec(html)?.[0];
20
+ return !!tag && !/\sdata-avatar=/.test(tag) && noPresenter(tag);
21
+ }
22
+
13
23
  // html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
14
24
  // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
15
25
  // or html unchanged when there is no such tag or it already has a presenter.
16
26
  export function withAvatar(html, { avatarUrl, imageUrl }) {
17
27
  const tag = PLAYER_TAG.exec(html)?.[0];
18
28
  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).
29
+ if (!noPresenter(tag)) return html;
21
30
  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
31
  const image = imageUrl && !/\sdata-presenter-image=/.test(tag) ? ` data-presenter-image="${attr(imageUrl)}"` : "";
25
32
  const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
26
33
  const next = none ? tag.replace(/\sdata-presenter="none"/, face) : tag.replace(/>$/, `${face}>`);
@@ -19,7 +19,9 @@ export async function setupCapture(pos, opts) {
19
19
  }
20
20
 
21
21
  // Capability URL + realpath containment; captured code gets no general file server.
22
- export async function serveSource({ file, html }) {
22
+ // `html` alone serves that page; with `file` it replaces the page and the file's
23
+ // folder still serves the rest (play's in-memory copy with the face on it).
24
+ export async function serveSource({ file, html, port = 0 }) {
23
25
  const root = file ? await realpath(dirname(file)) : null;
24
26
  const prefix = '/' + randomUUID() + '/';
25
27
  const entry = prefix + (file ? encodeURIComponent(resolve(file).split(sep).pop()) : 'index.html');
@@ -27,7 +29,7 @@ export async function serveSource({ file, html }) {
27
29
  try {
28
30
  const path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
29
31
  if (req.method !== 'GET' || !path.startsWith(prefix)) { res.writeHead(404).end(); return; }
30
- if (!file && path === entry) { res.writeHead(200, { 'Content-Type':'text/html; charset=utf-8', 'Cache-Control':'no-store' }).end(html); return; }
32
+ if (html != null && path === entry) { res.writeHead(200, { 'Content-Type':'text/html; charset=utf-8', 'Cache-Control':'no-store' }).end(html); return; }
31
33
  const rel = path.slice(prefix.length);
32
34
  if (!root || rel.split('/').some(s => s.startsWith('.') || s.includes('\\')) || !MIME[extname(rel).toLowerCase()]) { res.writeHead(404).end(); return; }
33
35
  const full = await realpath(resolve(root, rel));
@@ -36,7 +38,7 @@ export async function serveSource({ file, html }) {
36
38
  res.end(await readFile(full));
37
39
  } catch { res.writeHead(404).end(); }
38
40
  });
39
- await new Promise((ok, fail) => { server.once('error', fail); server.listen(0, '127.0.0.1', ok); });
41
+ await new Promise((ok, fail) => { server.once('error', fail); server.listen(port, '127.0.0.1', ok); });
40
42
  const url = `http://127.0.0.1:${server.address().port}${entry}`;
41
43
  return { url, close: () => new Promise((ok, fail) => { server.close(e => e ? fail(e) : ok()); server.closeAllConnections(); }) };
42
44
  }
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.7.0";
11
+ export const VERSION = "0.8.0";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -32,6 +32,7 @@ export const api = {
32
32
  createLink: makeFunctionReference("avatars:createLink"),
33
33
  getLink: makeFunctionReference("avatars:getLink"),
34
34
  presenterFor: makeFunctionReference("avatars:presenterFor"),
35
+ presenterWithReason: makeFunctionReference("avatars:presenterWithReason"),
35
36
  },
36
37
  narrators: {
37
38
  list: makeFunctionReference("narrators:list"),
@@ -47,7 +48,11 @@ export const api = {
47
48
  get: makeFunctionReference("brand:get"),
48
49
  set: makeFunctionReference("brand:set"),
49
50
  clear: makeFunctionReference("brand:clear"),
51
+ history: makeFunctionReference("brand:history"),
52
+ undo: makeFunctionReference("brand:undo"),
53
+ restore: makeFunctionReference("brand:restore"),
50
54
  },
55
+ brandRevise: { revise: makeFunctionReference("brandRevise:revise") },
51
56
  brandNode: {
52
57
  importLogo: makeFunctionReference("brandNode:importLogo"),
53
58
  uploadLogo: makeFunctionReference("brandNode:uploadLogo"),