@hypersocial/decks-cli 0.6.0 → 0.7.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.
@@ -0,0 +1,24 @@
1
+ // The page a brand import (brandImport.mjs, convex/brandDrafts.ts) is shown on
2
+ // before it is saved: a hero and a features slide from the layout library, in
3
+ // the draft's colours, fonts and logo (applyBrand, the same code new decks
4
+ // use). Served at /p/brand/<token> (app/p/brand/[token]/route.ts), and with
5
+ // { saved: true } for the saved brand on the dashboard's Brand tab
6
+ // (app/dashboard/brand/sample/route.ts).
7
+ import { applyBrand } from "./brand.mjs";
8
+ import { assembleLayoutDeck } from "./layouts.mjs";
9
+
10
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
11
+
12
+ export function brandPreviewHtml(draft, { saved = false } = {}) {
13
+ const name = (draft?.name ?? "").trim() || "Your brand";
14
+ let html = assembleLayoutDeck({ slug: "brand-preview", title: name, layouts: ["hero", "features"] })["index.html"];
15
+ // The hero's art slot is a stand-in a real deck fills; the preview has no art.
16
+ html = html.replace(/\s*<div class="art" role="presentation"><\/div>/, "");
17
+ html = html.replace(/(<div class="kicker">)brand-preview(<\/div>)/, saved ? "$1Your brand$2" : "$1Brand preview$2");
18
+ html = html.replace(
19
+ "One sentence on what this deck is about.",
20
+ 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
+ );
22
+ html = html.replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtml(name)} · ${saved ? "brand" : "brand preview"}</title>`);
23
+ return applyBrand(html, { name: draft?.name ?? undefined, colors: draft?.colors ?? {}, fonts: draft?.fonts ?? {}, logoUrl: draft?.logoUrl ?? undefined });
24
+ }
@@ -41,6 +41,8 @@ export const MESSAGES = {
41
41
  mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
42
42
  slideId: (n) => `slide ${n} has no id`,
43
43
  selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
44
+ gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
45
+ gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
44
46
  presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
45
47
  transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
46
48
  zoomTarget: (id, target, prev) => `slide ${id}: zoom target "${target}" matches nothing on the slide before it (${prev})`,
@@ -217,8 +219,33 @@ export function selectorMatches(body, selector) {
217
219
  });
218
220
  }
219
221
 
220
- // [go: <selector>] and [look: <selector>] in script.md must match something in
221
- // their slide ([go: slot] returns home and always resolves).
222
+ // The Minimoji head's gestures, for [gesture: <id>]: lib/deckScript.ts GESTURES
223
+ // (the published CLI cannot import it; a test keeps the two equal).
224
+ export const GESTURES = [
225
+ "big-double-laugh", "boiling-point", "bright-tilt", "buoyant-pop", "chuckle-bob", "clipped-huff", "comfort-tilt",
226
+ "curious-glance", "deep-snore", "delighted-sway", "dodge-glance", "double-take", "dream-twitch", "final-flop",
227
+ "fold-inward", "freeze-and-shiver", "hard-recoil", "head-shake", "knowing-nod", "lean-out-laugh", "lightbulb",
228
+ "look-away", "low-simmer", "nervous-scan", "nod-on-the-beat", "one-sided-lean", "other-side", "party-bounce",
229
+ "ponder-tilt", "pop-back", "pressure-build", "quiet-sigh", "radiant-lift", "shaky-sniff", "sharp-glare",
230
+ "side-eye-hmm", "sideways-flinch", "sleepy-sway", "slow-droop", "snap-and-fume", "sob-heave", "soft-reset",
231
+ "spark-salute", "sunny-nod", "thinking-it-over", "tiny-aftershock", "tiny-double-take", "tiny-wince", "uneasy-sway",
232
+ ];
233
+
234
+ // [gesture: <id>] in script.md must name one of the head's gestures.
235
+ export function cueGestureErrors(script = "") {
236
+ const errs = [];
237
+ script.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
238
+ for (const [, colon, raw] of line.matchAll(/\[\s*gesture\b\s*(:?)\s*([^\]]*?)\s*\]/gi)) {
239
+ if (!GESTURES.includes(raw.toLowerCase())) errs.push(MESSAGES.gesture(i + 1, raw));
240
+ else if (!colon) errs.push(MESSAGES.gestureColon(i + 1, raw));
241
+ }
242
+ });
243
+ return errs;
244
+ }
245
+
246
+ // [go: <selector>] and [look: <selector>] (or [look <selector>]) in script.md
247
+ // must match something in their slide ([go: slot] returns home, and [look
248
+ // viewer], [look away] and [look back] need no element).
222
249
  export function cueSelectorErrors(html, script = "") {
223
250
  const bodies = new Map(slides(html).map((s) => [s.id, s.body]));
224
251
  const errs = [];
@@ -227,8 +254,9 @@ export function cueSelectorErrors(html, script = "") {
227
254
  const heading = /^##\s+(\S+)/.exec(line);
228
255
  if (heading) { id = heading[1]; return; }
229
256
  if (!id || !bodies.has(id)) return;
230
- for (const [, cue, raw] of line.matchAll(/\[\s*(go|look)\s*:\s*([^\]]*?)\s*\]/gi)) {
231
- if (raw === "slot" || !raw) continue;
257
+ for (const [, colon, spaced, raw] of line.matchAll(/\[\s*(?:(go|look)\s*:\s*|(look)\s+)([^\]]*?)\s*\]/gi)) {
258
+ const cue = colon ?? spaced;
259
+ if (!raw || raw === "slot" || (cue.toLowerCase() === "look" && /^(viewer|away|back)$/i.test(raw))) continue;
232
260
  if (selectorMatches(bodies.get(id), raw) === false) errs.push(MESSAGES.selector(i + 1, cue.toLowerCase(), raw, id));
233
261
  }
234
262
  });
@@ -252,13 +280,13 @@ export function playerScript(html) {
252
280
  return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
253
281
  }
254
282
 
255
- // Relative references: src, href, poster and data-presenter-image (either quote), srcset candidates and
283
+ // Relative references: src, href, poster, data-presenter-image and data-alpha-stacked (either quote), srcset candidates and
256
284
  // CSS url(…) in style attributes and <style> blocks; everything that is not a URL
257
285
  // scheme, protocol-relative or a fragment. Query strings and fragments dropped.
258
286
  export function localRefs(html) {
259
287
  const doc = uncommented(html);
260
288
  const raw = [
261
- ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
289
+ ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
262
290
  ...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
263
291
  ...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
264
292
  ];
@@ -306,7 +334,7 @@ export function placeholderErrors(html, { hosted = false } = {}) {
306
334
  // Everything checkable from the deck's own files. `exists(ref)` answers whether a
307
335
  // local reference resolves inside the deck folder: true, false or "outside".
308
336
  export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false } = {}) {
309
- const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...placeholderErrors(html, { hosted })];
337
+ const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...cueGestureErrors(script), ...placeholderErrors(html, { hosted })];
310
338
  const player = playerScript(html);
311
339
  if (!player) errs.push(MESSAGES.noPlayer());
312
340
  else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
@@ -487,7 +515,7 @@ export function stageSize(html) {
487
515
  export function absoluteRefs(html) {
488
516
  const doc = uncommented(html);
489
517
  const raw = [
490
- ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
518
+ ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
491
519
  ...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
492
520
  ...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
493
521
  ];
package/src/commands.mjs CHANGED
@@ -342,8 +342,10 @@ commands:
342
342
  [--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
343
343
  default model gpt-image-2.5; --seed applies to nano-banana-2.1
344
344
  image-job <jobId> [--json]
345
- gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
345
+ gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]
346
346
  a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
347
+ --loop starts and ends on one frame (no jump at the seam); --transparent cuts the subject
348
+ out as a WebM + a stacked-alpha MP4 for Safari (prints both URLs and the <video> markup)
347
349
  video-job <jobId> [--json]
348
350
  screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]
349
351
  a PNG of a public web page (private and local addresses are refused)
@@ -356,7 +358,10 @@ commands:
356
358
  brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
357
359
  brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
358
360
  [--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
359
- [--presenter-style s] [--design <file.md|->] [--name "<company>"]
361
+ [--presenter-style s] [--design <file.md|->] [--name "<company>"] [--draft <token>]
362
+ brand import <url|file|slug> [--json]
363
+ read a brand from your website, a .pptx / .potx / .pdf or one of your decks: shows its
364
+ colours, fonts and logo and a preview link; save it with brand set --draft <token>
360
365
  brand edit edit design.md (voice, do and don't) in $EDITOR
361
366
  brand fonts the curated font pairings (display + body + mono)
362
367
  brand clear forget the brand; new decks get the template look
@@ -472,7 +477,7 @@ async function imageJob(pos, opts) {
472
477
  async function genVideo(pos, opts) {
473
478
  const prompt = pos.join(" ").trim() || opts.prompt;
474
479
  if (!prompt) {
475
- throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]');
480
+ throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]');
476
481
  }
477
482
  const queued = await ops.generateVideo(opts, {
478
483
  prompt,
@@ -480,12 +485,15 @@ async function genVideo(pos, opts) {
480
485
  seconds: opts.seconds,
481
486
  aspect: opts.aspect,
482
487
  model: opts.model,
488
+ loop: opts.loop,
489
+ transparent: opts.transparent,
483
490
  slug: opts.slug,
484
491
  name: opts.name,
485
492
  });
486
493
  // A clip takes a minute or more; poll so gen-video stays one blocking command.
487
494
  // Progress goes to stderr so stdout stays the URL (or --json).
488
- process.stderr.write(`queued a ${queued.seconds}s ${queued.model} clip — job ${queued.jobId}\n`);
495
+ const kind = [queued.loop && "looping", queued.transparent && "transparent"].filter(Boolean).join(" ");
496
+ process.stderr.write(`queued a ${queued.seconds}s ${kind ? `${kind} ` : ""}${queued.model} clip — job ${queued.jobId}\n`);
489
497
  const job = await ops.pollVideoJob(opts, queued.jobId, {
490
498
  onProgress: (j, ms) => process.stderr.write(` ${j.status} ${Math.round(ms / 1000)}s…\r`),
491
499
  });
@@ -493,13 +501,21 @@ async function genVideo(pos, opts) {
493
501
  const url = job.video?.url;
494
502
  if (!url) throw new Error(`video job ${queued.jobId} finished without a URL; check it with: video-job ${queued.jobId}`);
495
503
  // Print the paid clip's URL before downloading, so a failed download never hides it.
504
+ // A transparent clip prints both files (WebM, then Safari's stacked MP4) and the markup.
496
505
  console.log(opts.json ? JSON.stringify(job, null, 2) : url);
506
+ if (!opts.json && job.video?.stacked?.url) console.log(job.video.stacked.url);
507
+ if (!opts.json && job.transparent && job.markup) process.stderr.write(`markup: ${job.markup}\n`);
497
508
  if (job.creditsRemaining != null) process.stderr.write(`credits left: ${job.creditsRemaining}\n`);
498
509
  if (opts.download) {
499
- const res = await fetch(url);
500
- if (!res.ok) throw new Error(`download of ${url} failed (${res.status})`);
501
- writeFileSync(opts.download, Buffer.from(await res.arrayBuffer()));
502
- process.stderr.write(`saved to ${opts.download}\n`);
510
+ // A transparent clip is two files: the WebM where asked, the stacked MP4 beside it.
511
+ const files = [[url, opts.download]];
512
+ if (job.video?.stacked?.url) files.push([job.video.stacked.url, `${String(opts.download).replace(/\.[^./]+$/, "")}-stacked.mp4`]);
513
+ for (const [from, to] of files) {
514
+ const res = await fetch(from);
515
+ if (!res.ok) throw new Error(`download of ${from} failed (${res.status})`);
516
+ writeFileSync(to, Buffer.from(await res.arrayBuffer()));
517
+ process.stderr.write(`saved to ${to}\n`);
518
+ }
503
519
  }
504
520
  }
505
521
 
@@ -514,6 +530,8 @@ async function videoJob(pos, opts) {
514
530
  console.log(`status: ${job.status}`);
515
531
  if (job.error) console.log(`error: ${job.error}`);
516
532
  if (job.video?.url) console.log(job.video.url);
533
+ if (job.video?.stacked?.url) console.log(job.video.stacked.url);
534
+ if (job.markup) console.log(`markup: ${job.markup}`);
517
535
  }
518
536
 
519
537
  async function screenshot(pos, opts) {
@@ -853,6 +871,7 @@ function commandTable(pos, rest, opts) {
853
871
  "brand edit": async () => (await import("./brandCli.mjs")).brandEdit(opts),
854
872
  "brand fonts": async () => (await import("./brandCli.mjs")).brandFonts(opts),
855
873
  "brand clear": async () => (await import("./brandCli.mjs")).brandClear(opts),
874
+ "brand import": async () => (await import("./brandCli.mjs")).brandImport(rest[0], opts),
856
875
  review: () => review(pos.slice(1), opts),
857
876
  "review-job": () => reviewJob(pos.slice(1), opts),
858
877
  poster: () => poster(pos.slice(1), opts),
package/src/importer.mjs CHANGED
@@ -158,7 +158,7 @@ export function rewriteTextAssetUrls(text, sourcePath, assets, uploadedByName) {
158
158
  export function rewriteHtmlAssetUrls(html, htmlEntry, assets, uploadedByName) {
159
159
  const assetMap = buildAssetUrlMap(assets, uploadedByName, htmlEntry);
160
160
  let out = html.replace(
161
- /\b(src|href|poster|data-presenter-image)\s*=\s*(["'])([^"']+)\2/gi,
161
+ /\b(src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(["'])([^"']+)\2/gi,
162
162
  (match, attr, quote, raw) => {
163
163
  const url = resolveLocalUrl(raw, assetMap);
164
164
  return url ? `${attr}=${quote}${url}${quote}` : match;
package/src/ops.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { zipSync } from "fflate";
2
2
  import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
3
+ import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
3
4
  import { api, client, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
4
5
  import {
5
6
  bytesFromText,
@@ -120,8 +121,9 @@ export async function pollImageJob(opts, jobId, { intervalMs = 3000, timeoutMs =
120
121
 
121
122
  // Generate a short clip via fal (MiniMax H3 Max by default, Kling v3 with
122
123
  // model "kling-v3": text→video, or image→video when an image URL is given as the
123
- // first frame). Always a background job: returns
124
- // { jobId, status, seconds, aspect?, creditsRemaining? } without waiting, so the MCP
124
+ // first frame; loop starts and ends on one frame, transparent mattes the background
125
+ // out). Always a background job: returns
126
+ // { jobId, status, seconds, aspect?, loop, transparent, creditsRemaining? } without waiting, so the MCP
125
127
  // tool never blocks; the CLI polls with pollVideoJob.
126
128
  export async function generateVideo(opts, fields = {}) {
127
129
  if (!fields.prompt) throw new Error("prompt is required");
@@ -131,6 +133,8 @@ export async function generateVideo(opts, fields = {}) {
131
133
  if (fields.seconds != null && fields.seconds !== "") args.seconds = Number(fields.seconds);
132
134
  if (fields.aspect) args.aspect = String(fields.aspect);
133
135
  if (fields.model) args.model = String(fields.model);
136
+ if (fields.loop === true || fields.loop === "true") args.loop = true;
137
+ if (fields.transparent === true || fields.transparent === "true") args.transparent = true;
134
138
  if (fields.slug) args.slug = String(fields.slug);
135
139
  if (fields.name) args.name = String(fields.name);
136
140
  return (await client(opts)).action(api.videogen.generateVideo, args);
@@ -232,11 +236,11 @@ export async function checkVideoJob(opts, jobId) {
232
236
  }
233
237
 
234
238
  // Poll a video job until it finishes. Returns the job (its `video` holds the hosted
235
- // URL); throws on job error or timeout. The server settles every job within 15
236
- // minutes, so the 16-minute default only trips if the job row itself stalls. A few
237
- // failed checks in a row (network, Convex hiccup) are tolerated: the job keeps
239
+ // URL); throws on job error or timeout. A few failed checks in a row (network, Convex hiccup) are tolerated: the job keeps
238
240
  // running server-side either way, so giving up early would only lose the result.
239
- export async function pollVideoJob(opts, jobId, { intervalMs = 5000, timeoutMs = 960_000, maxErrors = 5, onProgress } = {}) {
241
+ // 30 minutes: a transparent clip can take the render's 12 minutes, then up to 14
242
+ // more to matte and store it (convex/videogen.ts gives the matte its own clock).
243
+ export async function pollVideoJob(opts, jobId, { intervalMs = 5000, timeoutMs = 1_800_000, maxErrors = 5, onProgress } = {}) {
240
244
  const c = await client(opts);
241
245
  const start = Date.now();
242
246
  const resume = `check it later with: video-job ${jobId}`;
@@ -747,7 +751,7 @@ function designServeUrl(projectId, token, path) {
747
751
  }
748
752
 
749
753
  // Pull every local asset path a deck file references (HTML or CSS): the
750
- // ext-resource-dependency manifest, src/href/poster/data-presenter-image, srcset, <script>/<link>,
754
+ // ext-resource-dependency manifest, src/href/poster/data-presenter-image/data-alpha-stacked, srcset, <script>/<link>,
751
755
  // and CSS url(). Mirrors the importer's rewrite surface so we fetch the same set
752
756
  // it will later rewrite. External/data/blob refs are skipped.
753
757
  function extractDesignRefs(text) {
@@ -759,7 +763,7 @@ function extractDesignRefs(text) {
759
763
  if (clean) out.add(clean);
760
764
  };
761
765
  for (const m of text.matchAll(/ext-resource-dependency"\s+content="([^"]+)"/gi)) add(m[1]);
762
- for (const m of text.matchAll(/\b(?:src|href|poster|data-presenter-image)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
766
+ for (const m of text.matchAll(/\b(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
763
767
  for (const m of text.matchAll(/\bsrcset\s*=\s*["']([^"']+)["']/gi)) {
764
768
  for (const part of m[1].split(",")) add(part.trim().split(/\s+/)[0]);
765
769
  }
@@ -1097,8 +1101,10 @@ export async function getBrand(opts = {}) {
1097
1101
  // Merge into the saved brand: a field left out keeps its value, "" clears it.
1098
1102
  // fontPairing names one of FONT_PAIRINGS and fills the three fonts (an explicit
1099
1103
  // font still wins). logoUrl is fetched into our storage; logoBytes (with
1100
- // logoType) is a file the CLI read. clearLogo removes the logo.
1101
- export async function setBrand(opts, { name, designMd, colors, fonts, fontPairing, presenterStyle, logoUrl, logoBytes, logoType, clearLogo } = {}) {
1104
+ // logoType) is a file the CLI read. clearLogo removes the logo. draft is a
1105
+ // brand import's token (importBrand): its colours, fonts, name and logo,
1106
+ // saved after the person has seen the preview; other fields still win.
1107
+ export async function setBrand(opts, { name, designMd, colors, fonts, fontPairing, presenterStyle, logoUrl, logoBytes, logoType, clearLogo, draft } = {}) {
1102
1108
  const c = await client(opts);
1103
1109
  let fontSet = fonts;
1104
1110
  if (fontPairing) {
@@ -1115,9 +1121,9 @@ export async function setBrand(opts, { name, designMd, colors, fonts, fontPairin
1115
1121
  };
1116
1122
  known(colors, COLOR_KEYS, "colour");
1117
1123
  known(fontSet, FONT_ROLES, "font role");
1118
- const fields = { name, designMd, colors, fonts: fontSet, presenterStyle };
1124
+ const fields = { name, designMd, colors, fonts: fontSet, presenterStyle, draft: draft ? String(draft).trim() : undefined };
1119
1125
  const defined = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && v !== null));
1120
- if (!Object.keys(defined).length && !logoUrl && !logoBytes && !clearLogo) throw new Error("nothing to set: pass colors, fonts, fontPairing, logoUrl, designMd, name or presenterStyle");
1126
+ if (!Object.keys(defined).length && !logoUrl && !logoBytes && !clearLogo) throw new Error("nothing to set: pass draft, colors, fonts, fontPairing, logoUrl, designMd, name or presenterStyle");
1121
1127
  let out = Object.keys(defined).length ? await c.mutation(api.brand.set, defined) : null;
1122
1128
  try {
1123
1129
  if (clearLogo) out = await c.mutation(api.brand.clear, { logoOnly: true });
@@ -1134,6 +1140,21 @@ export async function setBrand(opts, { name, designMd, colors, fonts, fontPairin
1134
1140
  return out ?? getBrand(opts);
1135
1141
  }
1136
1142
 
1143
+ // Read a brand from a website (url), one of your decks (slug) or a .pptx /
1144
+ // .potx / .pdf (bytes + fileName, read here with brandImport.mjs) into a
1145
+ // draft. Returns { token, colors, fonts, logoUrl, notes, previewUrl, … };
1146
+ // nothing is saved until setBrand({ draft: token }).
1147
+ export async function importBrand(opts, { url, slug, bytes, fileName } = {}) {
1148
+ const given = [url, slug, bytes].filter((x) => x !== undefined && x !== null && x !== "");
1149
+ if (given.length !== 1) throw new Error("import a brand from one of: a website url, a deck slug or a .pptx / .potx / .pdf file");
1150
+ const c = await client(opts);
1151
+ if (url) return c.action(api.brandNode.importFromUrl, { url: String(url) });
1152
+ if (slug) return c.action(api.brandNode.importFromDeck, { slug: String(slug) });
1153
+ const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
1154
+ if (data.byteLength > MAX_IMPORT_FILE_BYTES) throw new Error(`${fileName ?? "the file"} is over ${MAX_IMPORT_FILE_BYTES / 1024 / 1024} MB`);
1155
+ return c.action(api.brandNode.createDraft, draftArgs(extractFile(data, fileName)));
1156
+ }
1157
+
1137
1158
  export async function clearBrand(opts = {}) {
1138
1159
  await (await client(opts)).mutation(api.brand.clear, {});
1139
1160
  return { cleared: true };
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.6.0";
11
+ export const VERSION = "0.7.0";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -51,6 +51,9 @@ export const api = {
51
51
  brandNode: {
52
52
  importLogo: makeFunctionReference("brandNode:importLogo"),
53
53
  uploadLogo: makeFunctionReference("brandNode:uploadLogo"),
54
+ importFromUrl: makeFunctionReference("brandNode:importFromUrl"),
55
+ importFromDeck: makeFunctionReference("brandNode:importFromDeck"),
56
+ createDraft: makeFunctionReference("brandNode:createDraft"),
54
57
  },
55
58
  narratorsNode: {
56
59
  hear: makeFunctionReference("narratorsNode:hear"),
@@ -145,7 +148,7 @@ export const api = {
145
148
  },
146
149
  };
147
150
 
148
- 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", "captions-only", "no-brand", "no-logo"]);
151
+ const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo"]);
149
152
  // release's recipients, in order: each --to starts one, and an --email right
150
153
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
151
154
  // opts.email stays the last --email for the other commands (share new).