@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.
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +45 -7
- package/skills/hyperdecks/chat.md +17 -5
- package/src/brandCli.mjs +41 -2
- package/src/brandImport.mjs +981 -0
- package/src/brandPreview.mjs +24 -0
- package/src/checkRules.mjs +36 -8
- package/src/commands.mjs +27 -8
- package/src/importer.mjs +1 -1
- package/src/ops.mjs +33 -12
- package/src/runtime.mjs +5 -2
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|
package/src/checkRules.mjs
CHANGED
|
@@ -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
|
-
//
|
|
221
|
-
//
|
|
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 [,
|
|
231
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
124
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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).
|