@hypersocial/decks-cli 0.9.7 → 0.9.9
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 +1 -1
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +78 -5
- package/skills/hyperdecks/chat.md +12 -4
- package/src/avatar.mjs +13 -2
- package/src/checkRules.mjs +100 -1
- package/src/commands.mjs +42 -21
- package/src/export-video.mjs +15 -10
- package/src/guide.mjs +1 -1
- package/src/init.mjs +8 -4
- package/src/layouts.mjs +1 -1
- package/src/mediaPreviewMake.mjs +72 -0
- package/src/mediaPreviews.mjs +160 -0
- package/src/ops.mjs +81 -17
- package/src/presenter.mjs +102 -16
- package/src/preview.mjs +2 -2
- package/src/release.mjs +90 -15
- package/src/runtime.mjs +62 -2
- package/src/source.mjs +2 -2
- package/templates/layouts/_deck.html +7 -0
- package/templates/layouts/chart.html +5 -0
- package/templates/layouts/closer.html +5 -0
- package/templates/layouts/compare.html +5 -0
- package/templates/layouts/features.html +6 -0
- package/templates/layouts/hero.html +5 -0
- package/templates/layouts/number.html +5 -0
- package/templates/layouts/phones.html +5 -0
- package/templates/layouts/product.html +5 -0
- package/templates/layouts/quote.html +6 -0
- package/templates/layouts/split.html +5 -0
- package/templates/layouts/team.html +6 -0
- package/templates/layouts/timeline.html +5 -0
package/src/guide.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export function skillSection(skill, topic) {
|
|
|
29
29
|
function layoutsTopic() {
|
|
30
30
|
const rows = LAYOUTS.map((name) => {
|
|
31
31
|
const { meta } = readLayout(name);
|
|
32
|
-
return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
|
|
32
|
+
return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.nopresenter && `Without a presenter: ${meta.nopresenter}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
|
|
33
33
|
});
|
|
34
34
|
return `# Layouts\n\nPass any of these, in order, as new_deck's \`layouts\` (repeat one for more of the same: number, number gives slides number and number-2).\n\n${rows.join("\n\n")}`;
|
|
35
35
|
}
|
package/src/init.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import * as ops from "./ops.mjs";
|
|
19
19
|
import { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug } from "./firstDeck.mjs";
|
|
20
|
-
import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
|
|
20
|
+
import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, linkBase, login as runLogin, openBrowser, target } from "./runtime.mjs";
|
|
21
21
|
import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
|
|
22
22
|
import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
|
|
23
23
|
import { narrate as runNarrate } from "./narrate.mjs";
|
|
@@ -450,9 +450,13 @@ async function presenterStep(base, opts, { interactive, ask, log, sub, avatars,
|
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
// decks:get answers only for decks the caller owns, so whether the slug is
|
|
454
|
+
// taken at all comes from slugTaken.
|
|
453
455
|
async function deckOwnedBy(base, slug, who) {
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
+
const c = await client(base);
|
|
457
|
+
if (!(await c.query(api.decks.slugTaken, { slug }))) return { exists: false, mine: false };
|
|
458
|
+
const deck = await c.query(api.decks.get, { slug });
|
|
459
|
+
return { exists: true, mine: !!deck && deck.ownerId === who.subject };
|
|
456
460
|
}
|
|
457
461
|
|
|
458
462
|
async function scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save }) {
|
|
@@ -544,7 +548,7 @@ async function releaseStep(base, opts, state, { release, sub, save, fetch }) {
|
|
|
544
548
|
// A run killed after release made its link but before init.json recorded it.
|
|
545
549
|
const mine = !state.releasedAt && shares.find((s) => s.qa && s.recipient === "Me" && s.enabled);
|
|
546
550
|
if (mine) {
|
|
547
|
-
const url = `${
|
|
551
|
+
const url = `${await linkBase(base)}/v/${mine.token}`;
|
|
548
552
|
save({ releasedAt: new Date().toISOString(), shareUrl: url, shareToken: mine.token });
|
|
549
553
|
return step("release", "done", `released: ${url}`);
|
|
550
554
|
}
|
package/src/layouts.mjs
CHANGED
|
@@ -20,7 +20,7 @@ export function fillTemplate(file, { slug, title, presenterStyle }) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// One layout file: a leading comment of `key: value` lines (layout, summary,
|
|
23
|
-
// use, slot, presenter, cues, script), its <style> and its <section>.
|
|
23
|
+
// use, slot, presenter, nopresenter, cues, script), its <style> and its <section>.
|
|
24
24
|
export function readLayout(name) {
|
|
25
25
|
const text = packageText(`templates/layouts/${name}.html`);
|
|
26
26
|
const head = /^<!--([\s\S]*?)-->/.exec(text);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The pixels behind mediaPreviews.mjs: ffmpeg (the @ffmpeg-installer build the
|
|
2
|
+
// CLI's video export and Convex's videoStack already use) reads one image or a
|
|
3
|
+
// video's first frame and writes
|
|
4
|
+
// - a ~20 px wide JPEG as a data URL (the blur preview, well under 1 KB), and
|
|
5
|
+
// - for a video, a poster: its first frame as a JPEG at most 1280 px wide.
|
|
6
|
+
// Used by the CLI release (local files) and convex/mediaPreviewsNode.ts (fetched
|
|
7
|
+
// bytes written to a temp file). The size comes from ffmpeg's own stream line.
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { streamSize } from "./mediaPreviews.mjs";
|
|
10
|
+
|
|
11
|
+
export const PREVIEW_WIDTH = 20;
|
|
12
|
+
const TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
function run(ffmpeg, args) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
execFile(ffmpeg, args, { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: TIMEOUT_MS }, (err, stdout, stderr) => {
|
|
17
|
+
const text = stderr.toString("utf8");
|
|
18
|
+
if (err) {
|
|
19
|
+
const why = err.killed ? `ffmpeg timed out after ${TIMEOUT_MS / 1000}s` : text.trim().split("\n").slice(-2).join(" ") || err.message;
|
|
20
|
+
return reject(new Error(why.slice(0, 300)));
|
|
21
|
+
}
|
|
22
|
+
resolve({ stdout, stderr: text });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Only local files by default: a "video" that is really a playlist (HLS) would
|
|
28
|
+
// otherwise make ffmpeg fetch its segment URLs itself, past any SSRF guard.
|
|
29
|
+
// Nested demuxers inherit the whitelist. The CLI widens it for an https input.
|
|
30
|
+
export const LOCAL_ONLY = "file,pipe";
|
|
31
|
+
export const HTTPS_TOO = "file,pipe,https,tls,tcp,crypto";
|
|
32
|
+
// A picture with more pixels than this (a "pixel bomb": a small file that
|
|
33
|
+
// decodes to gigabytes) is refused by the decoder instead of exhausting memory.
|
|
34
|
+
export const MAX_PIXELS = 64 * 1024 * 1024;
|
|
35
|
+
const frameArgs = (input, allow, vf, q, codec = "mjpeg") => ["-hide_banner", "-nostdin", "-protocol_whitelist", allow, "-max_pixels", String(MAX_PIXELS), "-i", input, "-map", "0:v:0", "-frames:v", "1", "-vf", vf, ...(codec === "mjpeg" ? ["-q:v", String(q)] : []), "-f", "image2pipe", "-c:v", codec, "pipe:1"];
|
|
36
|
+
// A picture with an alpha channel (a background-removed cutout) keeps it: a
|
|
37
|
+
// JPEG preview would put a black box behind the cutout while it loads.
|
|
38
|
+
export const hasAlpha = (stderr) => /Stream #[^\n]*?Video:[^\n]*?\b(rgba|bgra|argb|abgr|ya8|ya16\w*|yuva\w*|gbrap\w*|rgba64\w*|pal8)\b/.test(stderr ?? "");
|
|
39
|
+
|
|
40
|
+
// { w, h, blur, poster? } for one file (a path, or a URL ffmpeg can open).
|
|
41
|
+
// `poster` (video only) is the JPEG bytes; the caller stores it and swaps in its URL.
|
|
42
|
+
// A picture or video container by its first bytes: JPEG, PNG, GIF, WebP, ISO
|
|
43
|
+
// BMFF (MP4/MOV/AVIF/HEIC) or EBML (WebM). Anything else (an HLS playlist, an
|
|
44
|
+
// HTML error page) never reaches ffmpeg.
|
|
45
|
+
export function knownMedia(bytes) {
|
|
46
|
+
const b = bytes.subarray(0, 12), ascii = (from, to) => String.fromCharCode(...b.subarray(from, to));
|
|
47
|
+
return (b[0] === 0xff && b[1] === 0xd8) || ascii(1, 4) === "PNG" || ascii(0, 3) === "GIF"
|
|
48
|
+
|| (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") || ascii(4, 8) === "ftyp"
|
|
49
|
+
|| (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function makePreview(ffmpeg, input, { video = false, allow = LOCAL_ONLY } = {}) {
|
|
53
|
+
let small = await run(ffmpeg, frameArgs(input, allow, `scale=${PREVIEW_WIDTH}:-2:flags=area,format=yuvj420p`, 12));
|
|
54
|
+
const size = streamSize(small.stderr);
|
|
55
|
+
if (!size) throw new Error(`no picture in ${input}`);
|
|
56
|
+
let type = "image/jpeg";
|
|
57
|
+
if (!video && hasAlpha(small.stderr)) {
|
|
58
|
+
small = await run(ffmpeg, frameArgs(input, allow, `scale=${PREVIEW_WIDTH}:-2:flags=area,format=rgba`, 0, "png"));
|
|
59
|
+
type = "image/png";
|
|
60
|
+
}
|
|
61
|
+
if (!small.stdout.length) throw new Error(`ffmpeg wrote no preview for ${input}`);
|
|
62
|
+
const out = { ...size, blur: `data:${type};base64,${small.stdout.toString("base64")}` };
|
|
63
|
+
if (!video) return out;
|
|
64
|
+
const frame = await run(ffmpeg, frameArgs(input, allow, "scale='min(1280,iw)':-2,format=yuvj420p", 4));
|
|
65
|
+
if (!frame.stdout.length) throw new Error(`ffmpeg wrote no poster for ${input}`);
|
|
66
|
+
return { ...out, poster: frame.stdout };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The bundled ffmpeg's path, loaded only when release needs it.
|
|
70
|
+
export async function bundledFfmpeg() {
|
|
71
|
+
return (await import("@ffmpeg-installer/ffmpeg")).default.path;
|
|
72
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Blur previews for a deck's media, added at release (CLI release.mjs and the
|
|
2
|
+
// hosted release in convex/deckSources.ts). Pure string work, no Node APIs, so
|
|
3
|
+
// the Convex mutation can run it; the pixels come from mediaPreviewMake.mjs.
|
|
4
|
+
//
|
|
5
|
+
// mediaRefs(html) lists what needs a preview: each <img>, each <video> without
|
|
6
|
+
// a poster (its first frame becomes one) and each inline background image.
|
|
7
|
+
// withMediaPreviews(html, previews) writes them onto the tags:
|
|
8
|
+
// data-hd-blur="data:image/jpeg;base64,…" a ~20 px wide preview (< 1 KB)
|
|
9
|
+
// style="--hd-ar:W/H;…" so the box has its shape before the pixels arrive
|
|
10
|
+
// (player.css: :where([data-hd-blur]) { aspect-ratio: var(--hd-ar) })
|
|
11
|
+
// poster="…" a <video> that had none
|
|
12
|
+
// The player (packages/player/media-load.js) shows the preview, blurred and
|
|
13
|
+
// shimmering, until the real media is ready. Idempotent: a tag that already has
|
|
14
|
+
// data-hd-blur (or opts out with data-hd-no-blur) is left as it is, so a
|
|
15
|
+
// re-release changes nothing.
|
|
16
|
+
|
|
17
|
+
// Media this small is an icon: a blurred icon is noise, not a preview.
|
|
18
|
+
export const MIN_PREVIEW_SIDE = 64;
|
|
19
|
+
// The longest data URL written onto a tag; a bigger preview is dropped.
|
|
20
|
+
export const MAX_BLUR_CHARS = 1600;
|
|
21
|
+
|
|
22
|
+
const SKIP = /^(data:|blob:|javascript:|#)/i;
|
|
23
|
+
const VECTOR = /\.svg(?:[?#]|$)/i;
|
|
24
|
+
|
|
25
|
+
export function tagAttr(tag, name) {
|
|
26
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, "i").exec(tag);
|
|
27
|
+
if (!m) return null;
|
|
28
|
+
return (m[1] ?? m[2] ?? m[3]).replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'");
|
|
29
|
+
}
|
|
30
|
+
const hasAttr = (tag, name) => new RegExp(`\\s${name}(?:\\s*=|[\\s/>])`, "i").test(tag);
|
|
31
|
+
|
|
32
|
+
function usable(src) {
|
|
33
|
+
const s = (src ?? "").trim();
|
|
34
|
+
return s && !SKIP.test(s) && !VECTOR.test(s) ? s : null;
|
|
35
|
+
}
|
|
36
|
+
// A tag the author sized as an icon (both sides given and small) is skipped up front.
|
|
37
|
+
function iconSized(tag) {
|
|
38
|
+
const w = Number(tagAttr(tag, "width")), h = Number(tagAttr(tag, "height"));
|
|
39
|
+
return w > 0 && h > 0 && Math.max(w, h) < MIN_PREVIEW_SIDE;
|
|
40
|
+
}
|
|
41
|
+
const done = (tag) => hasAttr(tag, "data-hd-blur") || hasAttr(tag, "data-hd-no-blur");
|
|
42
|
+
|
|
43
|
+
// Tags are matched quote-aware, so a `>` inside an attribute value (alt="a > b") does not end the tag.
|
|
44
|
+
const ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`;
|
|
45
|
+
const IMG = new RegExp(`<img\\b${ATTRS}>`, "gi");
|
|
46
|
+
const VIDEO_OPEN = new RegExp(`^<video\\b${ATTRS}>`, "i");
|
|
47
|
+
const VIDEO = new RegExp(`<video\\b${ATTRS}>([\\s\\S]*?)<\\/video\\s*>`, "gi");
|
|
48
|
+
const STYLED = new RegExp(`<[a-z][\\w-]*\\b(?:[^>"']|"[^"]*"|'[^']*')*?\\sstyle\\s*=\\s*(?:"[^"]*"|'[^']*')${ATTRS}>`, "gi");
|
|
49
|
+
// Script, style, comment and template text is code, not markup: never read or rewritten.
|
|
50
|
+
const OPAQUE = /<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>|<!--[\s\S]*?-->|<template\b[\s\S]*?<\/template\s*>/gi;
|
|
51
|
+
function markupOnly(html, rewrite) {
|
|
52
|
+
const kept = [];
|
|
53
|
+
const masked = html.replace(OPAQUE, (m) => `\u0000${kept.push(m) - 1}\u0000`);
|
|
54
|
+
return rewrite(masked).replace(/\u0000(\d+)\u0000/g, (_, i) => kept[Number(i)]);
|
|
55
|
+
}
|
|
56
|
+
const BG_URL = /background(?:-image)?\s*:[^;]*?url\(\s*(?:"([^"]+)"|'([^']+)'|([^)\s]+))\s*\)/i;
|
|
57
|
+
|
|
58
|
+
function videoSrc(open, body) {
|
|
59
|
+
return usable(tagAttr(open, "src")) ?? usable([...body.matchAll(/<source\b[^>]*>/gi)].map((m) => tagAttr(m[0], "src")).find(usable));
|
|
60
|
+
}
|
|
61
|
+
function bgSrc(tag) {
|
|
62
|
+
const m = BG_URL.exec(tagAttr(tag, "style") ?? "");
|
|
63
|
+
return m ? usable(m[1] ?? m[2] ?? m[3]) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// What release must make previews for: { images: [src], videos: [src] }, each
|
|
67
|
+
// src once, as written in the page (relative or absolute).
|
|
68
|
+
export function mediaRefs(page) {
|
|
69
|
+
const images = new Set(), videos = new Set();
|
|
70
|
+
const html = page.replace(OPAQUE, "");
|
|
71
|
+
for (const [tag] of html.matchAll(IMG)) {
|
|
72
|
+
const src = usable(tagAttr(tag, "src"));
|
|
73
|
+
if (src && !done(tag) && !iconSized(tag)) images.add(src);
|
|
74
|
+
}
|
|
75
|
+
for (const m of html.matchAll(VIDEO)) {
|
|
76
|
+
const open = VIDEO_OPEN.exec(m[0])[0];
|
|
77
|
+
// A transparent clip (alpha-video.js swaps it for a canvas) has no frame to blur.
|
|
78
|
+
if (done(open) || hasAttr(open, "data-alpha-stacked")) continue;
|
|
79
|
+
const src = videoSrc(open, m[1]);
|
|
80
|
+
if (src) videos.add(src);
|
|
81
|
+
}
|
|
82
|
+
for (const [tag] of html.matchAll(STYLED)) {
|
|
83
|
+
if (/^<(img|video)\b/i.test(tag) || done(tag)) continue;
|
|
84
|
+
const src = bgSrc(tag);
|
|
85
|
+
if (src) images.add(src);
|
|
86
|
+
}
|
|
87
|
+
return { images: [...images], videos: [...videos] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A preview is usable when it is a small data URL and its media is not an icon.
|
|
91
|
+
export function usablePreview(p) {
|
|
92
|
+
return !!p && typeof p.blur === "string" && p.blur.startsWith("data:image/") && p.blur.length <= MAX_BLUR_CHARS && !/["'<>\s]/.test(p.blur)
|
|
93
|
+
&& Number.isFinite(p.w) && Number.isFinite(p.h) && Math.min(p.w, p.h) > 0 && Math.max(p.w, p.h) >= MIN_PREVIEW_SIDE;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const escapeAttr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
97
|
+
|
|
98
|
+
// Add attributes before the tag's closing `>` (or `/>`). The shape goes in as
|
|
99
|
+
// --hd-ar, which player.css applies with zero specificity, so any aspect-ratio
|
|
100
|
+
// the deck sets (in a stylesheet or the tag) still wins.
|
|
101
|
+
function annotate(tag, p, { ratio = true, poster = null } = {}) {
|
|
102
|
+
let out = tag;
|
|
103
|
+
if (ratio) {
|
|
104
|
+
const rule = `--hd-ar:${Math.round(p.w)}/${Math.round(p.h)};`;
|
|
105
|
+
const m = /\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(out);
|
|
106
|
+
if (m) {
|
|
107
|
+
const q = m[1] !== undefined ? '"' : "'";
|
|
108
|
+
out = out.replace(m[0], ` style=${q}${rule}${m[1] ?? m[2]}${q}`);
|
|
109
|
+
} else out = out.replace(/\s*\/?>$/, (end) => ` style="${rule}"${end}`);
|
|
110
|
+
}
|
|
111
|
+
const extra = `${poster ? ` poster="${escapeAttr(poster)}"` : ""} data-hd-blur="${p.blur}"`;
|
|
112
|
+
return out.replace(/\s*(\/?)>$/, (_, slash) => `${extra}${slash ? " /" : ""}>`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// previews: { [src]: { w, h, blur, poster? } } keyed by the src as mediaRefs
|
|
116
|
+
// returned it. Media without a usable preview is left as written. Returns the
|
|
117
|
+
// new page and how many tags it changed.
|
|
118
|
+
export function withMediaPreviews(page, previews) {
|
|
119
|
+
const get = (src) => (src && usablePreview(previews[src]) ? previews[src] : null);
|
|
120
|
+
let count = 0;
|
|
121
|
+
const html = markupOnly(page, (html) => {
|
|
122
|
+
let out = html.replace(IMG, (tag) => {
|
|
123
|
+
const p = done(tag) || iconSized(tag) ? null : get(usable(tagAttr(tag, "src")));
|
|
124
|
+
if (!p) return tag;
|
|
125
|
+
count++;
|
|
126
|
+
// An author who sized both sides chose the shape already.
|
|
127
|
+
return annotate(tag, p, { ratio: !(hasAttr(tag, "width") && hasAttr(tag, "height")) });
|
|
128
|
+
});
|
|
129
|
+
out = out.replace(VIDEO, (whole, body) => {
|
|
130
|
+
const open = VIDEO_OPEN.exec(whole)[0];
|
|
131
|
+
if (done(open) || hasAttr(open, "data-alpha-stacked")) return whole;
|
|
132
|
+
const p = get(videoSrc(open, body));
|
|
133
|
+
if (!p) return whole;
|
|
134
|
+
count++;
|
|
135
|
+
const poster = hasAttr(open, "poster") ? null : p.poster ?? null;
|
|
136
|
+
return annotate(open, p, { ratio: !(hasAttr(open, "width") && hasAttr(open, "height")), poster }) + whole.slice(open.length);
|
|
137
|
+
});
|
|
138
|
+
out = out.replace(STYLED, (tag) => {
|
|
139
|
+
if (/^<(img|video)\b/i.test(tag) || done(tag)) return tag;
|
|
140
|
+
const p = get(bgSrc(tag));
|
|
141
|
+
if (!p) return tag;
|
|
142
|
+
count++;
|
|
143
|
+
// A background's box is the element's own layout, never the image's shape.
|
|
144
|
+
return annotate(tag, p, { ratio: false });
|
|
145
|
+
});
|
|
146
|
+
return out;
|
|
147
|
+
});
|
|
148
|
+
return { html, count };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The size ffmpeg reports for the first video stream ("Stream #0:0: Video: …, 1920x1080").
|
|
152
|
+
export function streamSize(stderr) {
|
|
153
|
+
const m = /Stream #[^\n]*?Video:[^\n]*?\b(\d{2,5})x(\d{2,5})\b/.exec(stderr ?? "");
|
|
154
|
+
if (!m) return null;
|
|
155
|
+
let w = Number(m[1]), h = Number(m[2]);
|
|
156
|
+
// A phone video stored sideways carries a rotation: its shown shape is swapped.
|
|
157
|
+
const rot = /rotate\s*:\s*-?(90|270)\b|rotation of -?(90|270)/.exec(stderr ?? "");
|
|
158
|
+
if (rot) [w, h] = [h, w];
|
|
159
|
+
return { w, h };
|
|
160
|
+
}
|
package/src/ops.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer.mjs";
|
|
2
2
|
import { zipSync } from "fflate";
|
|
3
|
+
import { SPECIES } from "./species.mjs";
|
|
3
4
|
import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
|
|
4
5
|
import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
|
|
5
|
-
import { api, client, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
6
|
+
import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
6
7
|
import {
|
|
7
8
|
bytesFromText,
|
|
8
9
|
isTextAsset,
|
|
@@ -19,8 +20,9 @@ export async function whoami(opts = {}) {
|
|
|
19
20
|
return (await client(opts)).query(api.decks.whoami, {});
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// Your own decks. `all: true` lists every deck, for admins only (support tools).
|
|
24
|
+
export async function listDecks(opts = {}, { all = false } = {}) {
|
|
25
|
+
const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
|
|
24
26
|
return rows.map((d) => ({
|
|
25
27
|
slug: d.slug,
|
|
26
28
|
title: d.title,
|
|
@@ -30,6 +32,10 @@ export async function listDecks(opts = {}) {
|
|
|
30
32
|
// How it opens (null: as its HTML says) and whether viewers may switch.
|
|
31
33
|
view: d.view ?? null,
|
|
32
34
|
viewToggle: d.viewToggle !== false,
|
|
35
|
+
// How the player moves on: auto, slide or step (null: as its HTML says, else auto).
|
|
36
|
+
pace: d.pace ?? null,
|
|
37
|
+
// Whether each link's AI-readable version (/v/<token>/md) serves (on unless set off).
|
|
38
|
+
aiReadable: d.aiReadable !== false,
|
|
33
39
|
// false: the deck runs its own player, so view/viewToggle do not apply
|
|
34
40
|
// (null: not known yet, treated as on).
|
|
35
41
|
usesPlayer: d.usesPlayer ?? null,
|
|
@@ -50,7 +56,11 @@ export async function activityOverview(opts = {}) {
|
|
|
50
56
|
// looked up as a token; anything else is a recipient name.
|
|
51
57
|
export const RECIPIENT_ENGAGEMENT_DESCRIPTION =
|
|
52
58
|
"What one recipient watched, per deck link: visits, narration heard (%), replayed / skipped slides, " +
|
|
53
|
-
"
|
|
59
|
+
"the slides they replayed on purpose and how often (summary.slideReplays), whether they watched it again " +
|
|
60
|
+
"from the start (summary.watchedAgain), a one-line read of both (summary.replayNote), " +
|
|
61
|
+
"where they dropped off, time per slide, each visit, and forwards. Each link's aiReads lists the AI " +
|
|
62
|
+
"assistants (ChatGPT, Claude, ...) that read its text version (/v/<token>/md), separately: those are " +
|
|
63
|
+
"not views. Use it to draft a follow-up: lead " +
|
|
54
64
|
"with what they spent time on, and never tell the recipient they were tracked.";
|
|
55
65
|
export const SHARE_TOKEN = /^[a-f0-9]{16}$/;
|
|
56
66
|
export async function recipientEngagement(opts, who, deck) {
|
|
@@ -264,7 +274,11 @@ export async function ownedDeck(opts, slug) {
|
|
|
264
274
|
const c = await client(opts);
|
|
265
275
|
const [deck, me] = await Promise.all([c.query(api.decks.get, { slug }), c.query(api.decks.whoami, {})]);
|
|
266
276
|
if (!me) throw new Error("not logged in: run `hyperdecks login` first");
|
|
267
|
-
if (!deck)
|
|
277
|
+
if (!deck) {
|
|
278
|
+
// get answers only for your own decks; say so when the slug is someone else's.
|
|
279
|
+
if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`forbidden: ${slug} is not your deck`);
|
|
280
|
+
throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
|
|
281
|
+
}
|
|
268
282
|
if (!me.isAdmin && deck.ownerId !== me.subject) throw new Error(`forbidden: ${slug} is not your deck`);
|
|
269
283
|
return deck;
|
|
270
284
|
}
|
|
@@ -288,7 +302,7 @@ export const VIDEO_RECIPIENT = "Video viewers";
|
|
|
288
302
|
export async function videoShareLink(opts, slug) {
|
|
289
303
|
const live = (await listShares(opts, slug)).find((s) => s.recipient === VIDEO_RECIPIENT && s.enabled && !s.qa && !s.archived && !s.requireEmail && (s.expiresAt == null || s.expiresAt > Date.now()) && (s.viewLimit == null || s.opens < s.viewLimit));
|
|
290
304
|
const token = live?.token ?? (await createShare(opts, { slug, recipient: VIDEO_RECIPIENT })).token;
|
|
291
|
-
return `${
|
|
305
|
+
return `${await linkBase(opts)}/v/${token}`;
|
|
292
306
|
}
|
|
293
307
|
|
|
294
308
|
// `export --video --upload`: file the MP4 with the deck's exports (30 days).
|
|
@@ -428,10 +442,24 @@ export async function checkNarrationJob(opts, jobId) {
|
|
|
428
442
|
return (await client(opts)).query(api.narration.getJob, { jobId: String(jobId) });
|
|
429
443
|
}
|
|
430
444
|
|
|
445
|
+
// Your own voices (what clone, rename and delete act on).
|
|
431
446
|
export async function listVoices(opts = {}) {
|
|
432
447
|
return (await client(opts)).query(api.voices.listVoices, {});
|
|
433
448
|
}
|
|
434
449
|
|
|
450
|
+
// Every voice you can narrate with: yours, then the built-in ones (builtin: true,
|
|
451
|
+
// id stock:<id>), which any account may use. What list_voices shows.
|
|
452
|
+
export async function listAllVoices(opts = {}) {
|
|
453
|
+
const c = await client(opts);
|
|
454
|
+
try {
|
|
455
|
+
return await c.query(api.voices.listVoices, { builtin: true });
|
|
456
|
+
} catch (e) {
|
|
457
|
+
// A backend from before built-in voices refuses the argument: your own voices, then.
|
|
458
|
+
if (!/builtin|extra field|Validator/i.test(errorText(e))) throw e;
|
|
459
|
+
return c.query(api.voices.listVoices, {});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
435
463
|
// ---- voice cloning (convex/voices.ts) ---------------------------------------
|
|
436
464
|
|
|
437
465
|
// The text of a backend error: ConvexError data (production redacts the rest), else the message.
|
|
@@ -603,25 +631,36 @@ export async function setDeckForwarding(opts, slug, allowForward) {
|
|
|
603
631
|
|
|
604
632
|
// How a deck opens in the player and whether viewers may switch: view is
|
|
605
633
|
// "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
|
|
606
|
-
// true/false
|
|
607
|
-
//
|
|
634
|
+
// true/false; pace is "auto" (plays itself), "slide" (waits for Next after each
|
|
635
|
+
// slide), "step" (also stops before each [reveal] cue), or null for the deck's
|
|
636
|
+
// own HTML. aiReadable true/false: whether each link's AI-readable version
|
|
637
|
+
// (/v/<token>/md) serves (on by default; off is a 404 there). Any may be left
|
|
638
|
+
// out; all go in one owner-only mutation, so a failure changes none.
|
|
608
639
|
// Applies on every link's next load (the route injects it), with no re-release.
|
|
609
640
|
export const DECK_VIEWS = ["slides", "scroll"];
|
|
610
|
-
export
|
|
641
|
+
export const DECK_PACES = ["auto", "slide", "step"];
|
|
642
|
+
export async function setDeckView(opts, slug, { view, viewToggle, pace, aiReadable } = {}) {
|
|
611
643
|
if (!slug) throw new Error("slug is required");
|
|
612
|
-
|
|
644
|
+
const player = view !== undefined || viewToggle !== undefined || pace !== undefined;
|
|
645
|
+
if (!player && aiReadable === undefined) throw new Error("give a view (slides, scroll or default), a viewToggle, a pace (auto, slide, step or default) and/or aiReadable");
|
|
613
646
|
if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
|
|
647
|
+
if (pace !== undefined && pace !== null && !DECK_PACES.includes(pace)) throw new Error(`pace must be auto, slide, step or null (got ${JSON.stringify(pace)})`);
|
|
614
648
|
const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
|
|
615
649
|
slug,
|
|
616
650
|
...(view !== undefined ? { view } : {}),
|
|
617
651
|
...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
|
|
652
|
+
...(pace !== undefined ? { pace } : {}),
|
|
653
|
+
...(aiReadable !== undefined ? { aiReadable: !!aiReadable } : {}),
|
|
618
654
|
});
|
|
619
|
-
// Saved either way; a deck on its own player says the setting won't apply
|
|
620
|
-
|
|
655
|
+
// Saved either way; a deck on its own player says the setting won't apply
|
|
656
|
+
// (aiReadable alone always applies: /md doesn't need the player).
|
|
657
|
+
const applies = !player || res?.applies !== false;
|
|
621
658
|
return {
|
|
622
659
|
slug,
|
|
623
660
|
...(view !== undefined ? { view } : {}),
|
|
624
661
|
...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
|
|
662
|
+
...(pace !== undefined ? { pace } : {}),
|
|
663
|
+
...(aiReadable !== undefined ? { aiReadable: !!aiReadable } : {}),
|
|
625
664
|
applies,
|
|
626
665
|
...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
|
|
627
666
|
};
|
|
@@ -631,7 +670,7 @@ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
|
|
|
631
670
|
// upsert would otherwise reset your own live deck to a draft and rename it.
|
|
632
671
|
export async function createDeck(opts, { slug, title }) {
|
|
633
672
|
const c = await client(opts);
|
|
634
|
-
if (await c.query(api.decks.
|
|
673
|
+
if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`deck ${slug} already exists; pick another slug`);
|
|
635
674
|
await c.mutation(api.decks.upsert, { slug, title, status: "draft" });
|
|
636
675
|
return { slug, title, status: "draft" };
|
|
637
676
|
}
|
|
@@ -1087,6 +1126,22 @@ export async function addPickedAvatar(opts, avatarKey) {
|
|
|
1087
1126
|
return (await client(opts)).mutation(api.avatars.addPicked, { avatarKey });
|
|
1088
1127
|
}
|
|
1089
1128
|
|
|
1129
|
+
// A drawing that failed on a pet photo (no human face, but Minimoji saw a cat,
|
|
1130
|
+
// a dog…): the offer to make that species' avatar instead, or null. The
|
|
1131
|
+
// person still picks the face (new_avatar_link with the species).
|
|
1132
|
+
export function petOffer(draft) {
|
|
1133
|
+
const s = SPECIES.find((x) => x.id === draft?.suggestedSpecies && x.id !== "human");
|
|
1134
|
+
if (!s) return null;
|
|
1135
|
+
const word = s.label.toLowerCase();
|
|
1136
|
+
return {
|
|
1137
|
+
status: "failed",
|
|
1138
|
+
error: draft.error ?? null,
|
|
1139
|
+
suggestedSpecies: s.id,
|
|
1140
|
+
offer: `That looks like a ${word}. Make a ${word} avatar?`,
|
|
1141
|
+
next: `if they want one: new_avatar_link with species "${s.id}" (a link to pick from the ${word} faces), or \`hyperdecks avatar new --species ${s.id}\``,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1090
1145
|
// A face from a photo. With bytes: upload and draw it, then hand back the link
|
|
1091
1146
|
// where the person approves it (nothing is used before). Without: the link
|
|
1092
1147
|
// where the person takes a selfie in their browser.
|
|
@@ -1102,7 +1157,11 @@ export async function avatarFromPhoto(opts, { bytes, contentType } = {}, { onPro
|
|
|
1102
1157
|
onProgress?.("drawing…");
|
|
1103
1158
|
const draft = await pollUntil(() => c.query(api.avatars.getDraft, { draftId }), (d) => !d || d.status !== "drawing", { timeoutMs: 6 * 60_000 });
|
|
1104
1159
|
if (!draft) throw new Error("the drawing is gone; run the command again");
|
|
1105
|
-
if (draft.status === "failed")
|
|
1160
|
+
if (draft.status === "failed") {
|
|
1161
|
+
const offer = petOffer(draft);
|
|
1162
|
+
if (offer) return { ...offer, draftId };
|
|
1163
|
+
throw new Error(draft.error || "the drawing failed; try another photo");
|
|
1164
|
+
}
|
|
1106
1165
|
const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
|
|
1107
1166
|
return { status: "waiting", reviewUrl: link.url, token: link.token, draftId, expiresAt: link.expiresAt };
|
|
1108
1167
|
}
|
|
@@ -1146,7 +1205,7 @@ export async function listNarrators(opts = {}) {
|
|
|
1146
1205
|
default: out.defaultId,
|
|
1147
1206
|
...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
|
|
1148
1207
|
narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, spokenName: n.spokenName ?? null, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
|
|
1149
|
-
voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
|
|
1208
|
+
voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, builtin: !!x.builtin, narrators: x.narrators })),
|
|
1150
1209
|
};
|
|
1151
1210
|
}
|
|
1152
1211
|
|
|
@@ -1188,7 +1247,8 @@ export async function removeNarrator(opts, ref) {
|
|
|
1188
1247
|
return { ...(await (await client(opts)).mutation(api.narrators.remove, { narrator: n.id })), id: n.id };
|
|
1189
1248
|
}
|
|
1190
1249
|
|
|
1191
|
-
// voice: one of your ready voices (name or id) or
|
|
1250
|
+
// voice: one of your ready voices (name or id), a built-in voice (name or
|
|
1251
|
+
// stock:<id>, list_voices), or dash.
|
|
1192
1252
|
export async function setNarratorVoice(opts, ref, voice) {
|
|
1193
1253
|
if (!voice) throw new Error("usage: narrator voice <narrator> <voice>");
|
|
1194
1254
|
const n = await findNarrator(opts, ref);
|
|
@@ -1259,7 +1319,11 @@ export async function avatarDraftStatus(opts, draftId) {
|
|
|
1259
1319
|
const draft = await c.query(api.avatars.getDraft, { draftId: String(draftId) });
|
|
1260
1320
|
if (!draft) throw new Error("the drawing is gone; start again with avatar_from_photo");
|
|
1261
1321
|
if (draft.status === "drawing") return { status: "drawing", draftId };
|
|
1262
|
-
if (draft.status === "failed")
|
|
1322
|
+
if (draft.status === "failed") {
|
|
1323
|
+
const offer = petOffer(draft);
|
|
1324
|
+
if (offer) return { ...offer, draftId };
|
|
1325
|
+
throw new Error(draft.error || "the drawing failed; try another photo");
|
|
1326
|
+
}
|
|
1263
1327
|
if (draft.status === "added") return { status: "added", draftId, avatarId: draft.avatarId ?? null };
|
|
1264
1328
|
if (draft.status !== "drawn") throw new Error(`the drawing is in an unexpected state (${draft.status}); start again with avatar_from_photo`);
|
|
1265
1329
|
const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
|
package/src/presenter.mjs
CHANGED
|
@@ -12,32 +12,118 @@
|
|
|
12
12
|
export const DASH_AVATAR = "https://hyperdecks.ai/player/v1/dash-avatar.json";
|
|
13
13
|
export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://hyperdecks.ai/avatars/dash.png" });
|
|
14
14
|
|
|
15
|
-
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/
|
|
15
|
+
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/gi;
|
|
16
16
|
|
|
17
17
|
const attr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """);
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
19
|
+
// The live player tag: the first one outside an HTML comment (a commented-out
|
|
20
|
+
// copy is not what plays), with where it starts. check's playerScript reads it
|
|
21
|
+
// the same way (closed comments only, so a stray "<!--" in a script hides nothing).
|
|
22
|
+
function playerTag(html) {
|
|
23
|
+
const comments = [...html.matchAll(/<!--[\s\S]*?-->/g)].map((m) => [m.index, m.index + m[0].length]);
|
|
24
|
+
for (const m of html.matchAll(PLAYER_TAG)) {
|
|
25
|
+
if (!comments.some(([a, b]) => m.index >= a && m.index < b)) return { tag: m[0], index: m.index };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const value = (tag, name) => {
|
|
31
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`).exec(tag);
|
|
32
|
+
return m ? (m[1] ?? m[2] ?? m[3]) : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The face this tag shows, decided as packages/player/player.js does: data-presenter
|
|
36
|
+
// head|static|none, else static when there is a still, else none. head draws
|
|
37
|
+
// data-avatar (and falls back to the still without one); static draws the
|
|
38
|
+
// still. Null: the deck plays the voice and captions with no face.
|
|
39
|
+
function tagFace(tag) {
|
|
40
|
+
const presenter = value(tag, "data-presenter");
|
|
41
|
+
const avatarUrl = value(tag, "data-avatar") || null;
|
|
42
|
+
const imageUrl = value(tag, "data-presenter-image") || null;
|
|
43
|
+
const mode = ["head", "static", "none"].includes(presenter) ? presenter : imageUrl ? "static" : "none";
|
|
44
|
+
if (mode === "head" && (avatarUrl || imageUrl)) return { avatarUrl, imageUrl };
|
|
45
|
+
if (mode === "static" && imageUrl) return { avatarUrl: null, imageUrl };
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The face the page's player shows ({ avatarUrl, imageUrl }, either may be
|
|
50
|
+
// null), or null when it plays captions only or has no player tag.
|
|
51
|
+
export function presentedFace(html) {
|
|
52
|
+
const found = playerTag(html);
|
|
53
|
+
return found ? tagFace(found.tag) : null;
|
|
54
|
+
}
|
|
22
55
|
|
|
23
|
-
// Whether the page's player tag
|
|
56
|
+
// Whether the page's player tag shows no face, so it has captions only.
|
|
24
57
|
export function playsCaptionsOnly(html) {
|
|
25
|
-
const
|
|
26
|
-
return !!
|
|
58
|
+
const found = playerTag(html);
|
|
59
|
+
return !!found && !tagFace(found.tag);
|
|
27
60
|
}
|
|
28
61
|
|
|
29
62
|
// html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
|
|
30
|
-
// the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
|
|
31
|
-
//
|
|
63
|
+
// the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL), or
|
|
64
|
+
// html unchanged when there is no such tag, the tag already shows a face, or it
|
|
65
|
+
// names a non-empty data-avatar of its own (set by hand: left alone even beside "none").
|
|
66
|
+
// Anything else the player would play captions only (data-presenter none or
|
|
67
|
+
// missing, head with no data-avatar, static with no still) gets the face.
|
|
32
68
|
export function withAvatar(html, { avatarUrl, imageUrl }) {
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const image = imageUrl &&
|
|
69
|
+
const found = playerTag(html);
|
|
70
|
+
if (!found) return html;
|
|
71
|
+
const { tag, index } = found;
|
|
72
|
+
if (tagFace(tag) || value(tag, "data-avatar")) return html;
|
|
73
|
+
const image = imageUrl && value(tag, "data-presenter-image") === null ? ` data-presenter-image="${attr(imageUrl)}"` : "";
|
|
38
74
|
const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
|
|
39
|
-
const
|
|
40
|
-
|
|
75
|
+
const named = /\sdata-presenter\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/;
|
|
76
|
+
const next = named.test(tag) ? tag.replace(named, face) : tag.replace(/\s*\/?>$/, (end) => `${face}${end}`);
|
|
77
|
+
return html.slice(0, index) + next + html.slice(index + tag.length);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// What is wrong with the player tag's presenter as written, for check: null, or
|
|
81
|
+
// { kind, presenter }. "unfilled": it asks for a face (head or static) but names
|
|
82
|
+
// none, so it plays captions only until release fills it in. "kept": it has a
|
|
83
|
+
// data-avatar the player ignores (data-presenter="none"), and release leaves it.
|
|
84
|
+
// A plain "none" or no data-presenter is not an issue: release adds the face.
|
|
85
|
+
export function presenterTagIssue(html) {
|
|
86
|
+
const found = playerTag(html);
|
|
87
|
+
if (!found || tagFace(found.tag)) return null;
|
|
88
|
+
const presenter = value(found.tag, "data-presenter");
|
|
89
|
+
if (value(found.tag, "data-avatar")) return { kind: "kept", presenter };
|
|
90
|
+
if (presenter === "head" || presenter === "static") return { kind: "unfilled", presenter };
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Why a resolved face did not land on the page: withAvatar leaves a tag with
|
|
95
|
+
// its own data-avatar alone, and a page with no live player tag has nowhere to put it.
|
|
96
|
+
function keptOutReason(html) {
|
|
97
|
+
const found = playerTag(html);
|
|
98
|
+
if (!found) return "the page has no live player tag to put the face on";
|
|
99
|
+
const mode = value(found.tag, "data-presenter");
|
|
100
|
+
return `the player tag names its own data-avatar, but data-presenter=${mode === null ? " is missing" : `"${mode}"`} so it plays no face, and release leaves a hand-set data-avatar alone: set data-presenter="head", or remove data-avatar`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What a release says about the face, from the page as written (`html`), the
|
|
104
|
+
// face it puts on (`presenter`, from avatars.presenterFor; null for none), who
|
|
105
|
+
// the backend says presents (`who`: { narrator, voice, face, reason }) and, when
|
|
106
|
+
// the face lookup itself failed, `lookupError`. Returns the page that ships, a
|
|
107
|
+
// line for the terminal and the summary to report: "presented by" only when
|
|
108
|
+
// that page shows a face, and with no resolved face on it, the tag's own.
|
|
109
|
+
// Both releases use it: release.mjs and convex/deckSources.ts.
|
|
110
|
+
export const TAG_FACE = "the face set on the player tag";
|
|
111
|
+
export function releasedPresenter(html, presenter, who, { lookupError } = {}) {
|
|
112
|
+
const page = presenter ? withAvatar(html, presenter) : html;
|
|
113
|
+
const captions = (reason) => ({ page, line: `⚠ releasing captions only: ${reason}`, summary: who ? { ...who, face: null, reason } : null });
|
|
114
|
+
const shown = presentedFace(page);
|
|
115
|
+
if (shown) {
|
|
116
|
+
// Left as written, with no resolved face or another one: whatever the tag names is what shows.
|
|
117
|
+
if (page === html && shown.avatarUrl !== presenter?.avatarUrl) {
|
|
118
|
+
return { page, line: `✓ presented by ${TAG_FACE} (${shown.avatarUrl ? "data-avatar" : "data-presenter-image"})`, summary: who ? { ...who, face: TAG_FACE } : null };
|
|
119
|
+
}
|
|
120
|
+
return { page, line: `✓ presented by ${who?.face ?? "your avatar"}`, summary: who ?? null };
|
|
121
|
+
}
|
|
122
|
+
if (lookupError) return captions(`could not look up your avatar (${lookupError}); release again to try once more`);
|
|
123
|
+
if (presenter) return captions(keptOutReason(page));
|
|
124
|
+
if (who && !who.face) return { page, line: `⚠ releasing captions only: ${who.reason ?? "no face"}`, summary: who };
|
|
125
|
+
if (who?.face) return captions(`no face for "${who.face}" came back to put on the player tag`);
|
|
126
|
+
return { page, line: "", summary: null };
|
|
41
127
|
}
|
|
42
128
|
|
|
43
129
|
// One line for who presents a deck, from narrate's or release's `presenter`
|