@hypersocial/decks-cli 0.1.12 → 0.2.1
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 +103 -0
- package/package.json +7 -1
- package/skills/hyperdecks/SKILL.md +153 -1
- package/src/check.mjs +316 -0
- package/src/commands.mjs +134 -12
- package/src/narrate.mjs +95 -0
- package/src/ops.mjs +182 -0
- package/src/preview-capture.mjs +93 -20
- package/src/release.mjs +133 -0
- package/src/runtime.mjs +33 -2
- package/src/scaffold.mjs +43 -0
- package/src/voice.mjs +87 -0
- package/templates/evidence.md +16 -0
- package/templates/narrated-deck.html +125 -0
- package/templates/script.md +42 -0
package/src/release.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// hyperdecks release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--forbid "…"]
|
|
2
|
+
//
|
|
3
|
+
// check → narrate → import → share new, from a committed deck folder:
|
|
4
|
+
// 1. refuses while the deck folder has uncommitted changes, so every release
|
|
5
|
+
// corresponds to a commit;
|
|
6
|
+
// 2. runs check (the same rules as `hyperdecks check`) and stops on any issue;
|
|
7
|
+
// 3. runs narrate, which re-renders only changed slides. If that changes the
|
|
8
|
+
// deck folder (a new manifest URL in index.html), release stops and asks for
|
|
9
|
+
// a commit, then a second release goes through;
|
|
10
|
+
// 4. imports index.html plus the local files it references (nothing else in the
|
|
11
|
+
// folder: script.md, evidence.md and narration/ stay private);
|
|
12
|
+
// 5. creates one share per --to (paired in order with --email) and prints the URLs.
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join, posix } from "node:path";
|
|
16
|
+
import { zipSync } from "fflate";
|
|
17
|
+
import * as ops from "./ops.mjs";
|
|
18
|
+
import { target } from "./runtime.mjs";
|
|
19
|
+
import { checkDeck, localRefs, resolveDeckDir } from "./check.mjs";
|
|
20
|
+
import { narrate as runNarrate } from "./narrate.mjs";
|
|
21
|
+
|
|
22
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
23
|
+
|
|
24
|
+
// `git status --porcelain` for the deck folder; throws outside a repository.
|
|
25
|
+
export function deckChanges(deckDir) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync("git", ["-C", deckDir, "status", "--porcelain", "--untracked-files=all", "--", "."], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trimEnd();
|
|
28
|
+
} catch (e) {
|
|
29
|
+
throw new Error(`release needs the deck in a git repository, so a release corresponds to a commit (${String(e.stderr || e.message).trim()})`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Files git does not track (ignored, or never added) can't be part of a commit.
|
|
34
|
+
export function untrackedFiles(deckDir, files) {
|
|
35
|
+
return files.filter((f) => {
|
|
36
|
+
try {
|
|
37
|
+
execFileSync("git", ["-C", deckDir, "ls-files", "--error-unmatch", "--", f], { stdio: "ignore" });
|
|
38
|
+
return false;
|
|
39
|
+
} catch {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function deckCommit(deckDir) {
|
|
46
|
+
return execFileSync("git", ["-C", deckDir, "rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// What import uploads: the page alone, or a zip of the page and its local files.
|
|
50
|
+
export function releaseBundle(deckDir) {
|
|
51
|
+
const html = readFileSync(join(deckDir, "index.html"));
|
|
52
|
+
// check has already refused refs that leave the folder or do not decode.
|
|
53
|
+
const refs = [...new Set(localRefs(html.toString("utf8")).map((r) => posix.normalize(decodeURIComponent(r)).replace(/^(\.?\/)+/, "")))];
|
|
54
|
+
if (!refs.length) return { bytes: html, filename: "index.html", files: ["index.html"] };
|
|
55
|
+
const files = { "index.html": html };
|
|
56
|
+
for (const ref of refs) files[ref] = readFileSync(join(deckDir, ref));
|
|
57
|
+
return { bytes: Buffer.from(zipSync(files)), filename: "deck.zip", htmlFile: "index.html", files: Object.keys(files) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// From the CLI, parse() collects opts.recipients in order (each --email follows
|
|
61
|
+
// its --to); MCP passes the same shape.
|
|
62
|
+
export function recipients(opts) {
|
|
63
|
+
const list = opts.recipients ?? [];
|
|
64
|
+
if (!Array.isArray(list)) throw new Error("recipients must be a list");
|
|
65
|
+
if (list.some((r) => !String(r?.recipient ?? "").trim())) throw new Error("every recipient needs a name");
|
|
66
|
+
return list.map((r) => ({ recipient: String(r.recipient).trim(), email: r.email ? String(r.email) : undefined }));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function release(pos, opts = {}, io = {}) {
|
|
70
|
+
const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd } = io;
|
|
71
|
+
const deckDir = resolveDeckDir(pos[0], cwd);
|
|
72
|
+
const to = recipients(opts);
|
|
73
|
+
|
|
74
|
+
const dirty = changes(deckDir);
|
|
75
|
+
if (dirty) throw new Error(`refusing to release: ${deckDir} has uncommitted changes\n${dirty}\ncommit them first so the release corresponds to a commit`);
|
|
76
|
+
|
|
77
|
+
const checked = await checkDeck(deckDir, opts, { fetch, cwd });
|
|
78
|
+
const slug = opts.slug || checked.slug;
|
|
79
|
+
if (!SLUG.test(slug)) throw new Error(`"${slug}" is not a deck slug (lowercase letters, digits, dashes): rename the folder or pass --slug`);
|
|
80
|
+
if (checked.errors.length) throw new Error(`check failed for ${slug}:\n${checked.errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
81
|
+
const bundle = releaseBundle(deckDir);
|
|
82
|
+
const loose = untracked(deckDir, bundle.files);
|
|
83
|
+
if (loose.length) throw new Error(`refusing to release: git does not track ${loose.join(", ")} (ignored or never added); commit them so the release corresponds to a commit`);
|
|
84
|
+
log(`✓ check passed: ${checked.slides} slides, ${checked.totalDuration}s`);
|
|
85
|
+
|
|
86
|
+
// Cached slides cost nothing, so this only renders what changed since the commit.
|
|
87
|
+
// Only the options narrate understands: an undefined --slide/--voice key would fail its flag check.
|
|
88
|
+
const narrateOpts = { ...opts, slug };
|
|
89
|
+
for (const k of ["slide", "json", "dry-run", "to", "email", "recipients"]) delete narrateOpts[k];
|
|
90
|
+
if (narrateOpts.voice === undefined) delete narrateOpts.voice;
|
|
91
|
+
const job = await narrate([deckDir], narrateOpts, { log });
|
|
92
|
+
if (!job?.manifestUrl) throw new Error(`narration finished without a manifest (${job?.notice || "no manifest URL"}); fix it and release again`);
|
|
93
|
+
const after = changes(deckDir);
|
|
94
|
+
if (after) {
|
|
95
|
+
throw new Error(`narration changed the deck (${after.split("\n").length} file(s)):\n${after}\n` +
|
|
96
|
+
`run \`hyperdecks check ${pos[0]}\`, look at it, commit (index.html and narration/), then run release again`);
|
|
97
|
+
}
|
|
98
|
+
const imported = await ops.importDeckFromBytes(opts, {
|
|
99
|
+
slug,
|
|
100
|
+
title: opts.title,
|
|
101
|
+
status: opts.status || "live",
|
|
102
|
+
bytes: bundle.bytes,
|
|
103
|
+
filename: bundle.filename,
|
|
104
|
+
htmlFile: bundle.htmlFile,
|
|
105
|
+
});
|
|
106
|
+
const sha = commit(deckDir);
|
|
107
|
+
log(`✓ imported "${imported.title}" as ${imported.slug} (${bundle.files.length} file(s), commit ${sha})\n deck: ${imported.deckUrl}`);
|
|
108
|
+
|
|
109
|
+
const base = target(opts).shareBase;
|
|
110
|
+
const shares = [];
|
|
111
|
+
for (const r of to) {
|
|
112
|
+
let token;
|
|
113
|
+
try {
|
|
114
|
+
({ token } = await ops.createShare(opts, {
|
|
115
|
+
slug: imported.slug,
|
|
116
|
+
recipient: r.recipient,
|
|
117
|
+
email: r.email,
|
|
118
|
+
qa: opts.qa || undefined,
|
|
119
|
+
notify: opts["no-notify"] ? false : undefined,
|
|
120
|
+
}));
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// The deck is already live and earlier links exist: say so, so a retry does not duplicate them.
|
|
123
|
+
const made = shares.map((s) => ` ${s.recipient}: ${s.url}`).join("\n") || " (none)";
|
|
124
|
+
throw new Error(`share for "${r.recipient}" failed: ${ops.errorText(e)}\nthe deck is imported (${imported.deckUrl}); links already created:\n${made}\n` +
|
|
125
|
+
`create the rest with \`hyperdecks share new ${imported.slug} "<name>"\` rather than releasing again`);
|
|
126
|
+
}
|
|
127
|
+
const share = { recipient: r.recipient, email: r.email, qa: !!opts.qa, url: `${base}/v/${token}` };
|
|
128
|
+
shares.push(share);
|
|
129
|
+
log(`✓ share for "${r.recipient}"${share.qa ? " (QA, untracked)" : ""} → ${share.url}`);
|
|
130
|
+
}
|
|
131
|
+
if (!shares.length) log(` no --to given: \`hyperdecks share new ${imported.slug} "Me" --qa\` makes a link for your own viewing`);
|
|
132
|
+
return { slug: imported.slug, deckDir, commit: sha, deckUrl: imported.deckUrl, totalDuration: checked.totalDuration, files: bundle.files, shares };
|
|
133
|
+
}
|
package/src/runtime.mjs
CHANGED
|
@@ -51,13 +51,42 @@ export const api = {
|
|
|
51
51
|
generateImage: makeFunctionReference("imagegen:generateImage"),
|
|
52
52
|
checkImageJob: makeFunctionReference("imagegen:checkImageJob"),
|
|
53
53
|
},
|
|
54
|
+
videogen: {
|
|
55
|
+
generateVideo: makeFunctionReference("videogen:generateVideo"),
|
|
56
|
+
checkVideoJob: makeFunctionReference("videogen:checkVideoJob"),
|
|
57
|
+
},
|
|
58
|
+
narration: {
|
|
59
|
+
preview: makeFunctionReference("narration:preview"),
|
|
60
|
+
narrateDeck: makeFunctionReference("narration:narrateDeck"),
|
|
61
|
+
getJob: makeFunctionReference("narration:getJob"),
|
|
62
|
+
listVoices: makeFunctionReference("narration:listVoices"),
|
|
63
|
+
},
|
|
64
|
+
voices: {
|
|
65
|
+
listVoices: makeFunctionReference("voices:listVoices"),
|
|
66
|
+
getVoice: makeFunctionReference("voices:getVoice"),
|
|
67
|
+
addSample: makeFunctionReference("voices:addSample"),
|
|
68
|
+
cloneVoice: makeFunctionReference("voices:cloneVoice"),
|
|
69
|
+
renameVoice: makeFunctionReference("voices:renameVoice"),
|
|
70
|
+
deleteVoice: makeFunctionReference("voices:deleteVoice"),
|
|
71
|
+
},
|
|
54
72
|
credits: {
|
|
55
73
|
getBalance: makeFunctionReference("credits:getBalance"),
|
|
56
74
|
getLedger: makeFunctionReference("credits:getLedger"),
|
|
57
75
|
},
|
|
58
76
|
};
|
|
59
77
|
|
|
60
|
-
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload"]);
|
|
78
|
+
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated"]);
|
|
79
|
+
// release's recipients, in order: each --to starts one, and an --email right
|
|
80
|
+
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
81
|
+
// opts.email stays the last --email for the other commands (share new).
|
|
82
|
+
function addRecipient(opts, k, v) {
|
|
83
|
+
if (v === undefined || v.startsWith("--")) throw new Error(`--${k} requires a value`);
|
|
84
|
+
const list = (opts.recipients ||= []);
|
|
85
|
+
if (k === "to") return void list.push({ recipient: v });
|
|
86
|
+
const last = list[list.length - 1];
|
|
87
|
+
if (!last || last.email !== undefined) throw new Error("each --email must follow the --to it belongs to");
|
|
88
|
+
last.email = v;
|
|
89
|
+
}
|
|
61
90
|
|
|
62
91
|
export const CRED_DIR = join(homedir(), ".config", "hypersocial-decks");
|
|
63
92
|
export const CRED_FILE = join(CRED_DIR, "credentials.json");
|
|
@@ -74,7 +103,9 @@ export function parse(args) {
|
|
|
74
103
|
if (BOOL.has(k)) opts[k] = true;
|
|
75
104
|
else {
|
|
76
105
|
const v = args[++i];
|
|
77
|
-
|
|
106
|
+
if (k === "to" || (k === "email" && opts.recipients)) addRecipient(opts, k, v);
|
|
107
|
+
if (k === "agent" && opts[k]) opts[k] = `${opts[k]},${v}`;
|
|
108
|
+
else opts[k] = v;
|
|
78
109
|
}
|
|
79
110
|
} else {
|
|
80
111
|
pos.push(a);
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// hyperdecks deck new <slug> "<title>" --narrated [--dir <path>]
|
|
2
|
+
//
|
|
3
|
+
// Writes decks/<slug>/ (or --dir) from the packaged templates: index.html (eight
|
|
4
|
+
// slides for the hosted player/v1), script.md (the narration) and evidence.md
|
|
5
|
+
// (where each fact comes from), then registers the deck as a draft you own so
|
|
6
|
+
// narrate can render into it.
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import * as ops from "./ops.mjs";
|
|
11
|
+
|
|
12
|
+
export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
13
|
+
export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
|
|
14
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
15
|
+
|
|
16
|
+
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
17
|
+
|
|
18
|
+
// Template text with __SLUG__ / __TITLE__ filled; HTML gets an escaped title.
|
|
19
|
+
export function fillTemplate(file, { slug, title }) {
|
|
20
|
+
const text = readFileSync(join(TEMPLATE_DIR, file), "utf8");
|
|
21
|
+
const t = /\.html$/.test(file) ? escapeHtml(title) : title;
|
|
22
|
+
return text.split("__SLUG__").join(slug).split("__TITLE__").join(t);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function deckNew(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
|
|
26
|
+
const [slug, title] = pos;
|
|
27
|
+
if (!slug || !title) throw new Error('usage: deck new <slug> "<title>" --narrated [--dir <path>]');
|
|
28
|
+
if (!opts.narrated) throw new Error("the published CLI scaffolds narrated decks only: add --narrated");
|
|
29
|
+
if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
|
|
30
|
+
const dir = resolve(cwd, typeof opts.dir === "string" ? opts.dir : join("decks", slug));
|
|
31
|
+
if (existsSync(dir)) throw new Error(`${dir} already exists; pick another slug or --dir`);
|
|
32
|
+
const files = Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title })]);
|
|
33
|
+
await ops.createDeck(opts, { slug, title });
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
for (const [out, text] of files) writeFileSync(join(dir, out), text);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
throw new Error(`deck ${slug} is registered but its files could not be written (${e.message}); remove the draft with \`hyperdecks deck rm ${slug}\` and run deck new again`);
|
|
40
|
+
}
|
|
41
|
+
log(`✓ created deck "${slug}" → ${dir}\n files: ${Object.keys(NARRATED_FILES).join(", ")}\n next: hyperdecks narrate ${dir} --dry-run`);
|
|
42
|
+
return { slug, title, dir, files: Object.keys(NARRATED_FILES) };
|
|
43
|
+
}
|
package/src/voice.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// hyperdecks voice clone <file...> --name <name> | voice upload <file...> | voice list | voice rm <name|id>
|
|
2
|
+
//
|
|
3
|
+
// Cloning runs on the backend with the platform ElevenLabs key (convex/voices.ts).
|
|
4
|
+
// This checks the files, asks for consent, uploads the samples, starts the clone
|
|
5
|
+
// and polls it. Nothing is uploaded until the user has typed the consent phrase.
|
|
6
|
+
import { readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { extname, resolve } from "node:path";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import * as ops from "./ops.mjs";
|
|
10
|
+
|
|
11
|
+
// Same text as convex/lib/voice/consent.ts (the published CLI cannot import it).
|
|
12
|
+
export const CONSENT_STATEMENT = "I confirm this is my own voice and I consent to it being cloned for narrating my decks";
|
|
13
|
+
export const CONSENT_PHRASE = "I consent";
|
|
14
|
+
export const MAX_SAMPLES = 3;
|
|
15
|
+
export const MAX_SAMPLE_BYTES = 10 * 1024 * 1024;
|
|
16
|
+
const TYPES = { ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".wav": "audio/wav" };
|
|
17
|
+
|
|
18
|
+
// The checks that need no server: count, extension and size. Length (30 s to 3
|
|
19
|
+
// min in total) and the real format are checked on the server before any
|
|
20
|
+
// provider call.
|
|
21
|
+
export function sampleFiles(paths) {
|
|
22
|
+
if (!paths.length) throw new Error("usage: voice clone <file...> --name <name> (1 to 3 mp3, m4a or wav recordings, 30 s to 3 min in total)");
|
|
23
|
+
if (paths.length > MAX_SAMPLES) throw new Error(`at most ${MAX_SAMPLES} recordings per voice (got ${paths.length})`);
|
|
24
|
+
return paths.map((p) => {
|
|
25
|
+
const path = resolve(p);
|
|
26
|
+
const contentType = TYPES[extname(path).toLowerCase()];
|
|
27
|
+
if (!contentType) throw new Error(`${p}: recordings must be .mp3, .m4a or .wav`);
|
|
28
|
+
let size;
|
|
29
|
+
try {
|
|
30
|
+
size = statSync(path).size;
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(`${p}: no such file`);
|
|
33
|
+
}
|
|
34
|
+
if (size > MAX_SAMPLE_BYTES) throw new Error(`${p}: ${(size / 1024 / 1024).toFixed(1)} MB; each recording must be at most 10 MB`);
|
|
35
|
+
return { path, contentType, size };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function askLine(question) {
|
|
40
|
+
// Consent is the person's to type: piped input is refused, not read.
|
|
41
|
+
if (!process.stdin.isTTY) throw new Error("voice clone needs a terminal to ask for consent; agents use voice upload and the MCP clone_voice tool");
|
|
42
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
43
|
+
try {
|
|
44
|
+
return await rl.question(question);
|
|
45
|
+
} catch {
|
|
46
|
+
return ""; // stdin closed
|
|
47
|
+
} finally {
|
|
48
|
+
rl.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function upload(opts, files, log) {
|
|
53
|
+
const ids = [];
|
|
54
|
+
for (const f of files) {
|
|
55
|
+
const { sampleId } = await ops.uploadVoiceSample(opts, { bytes: readFileSync(f.path), contentType: f.contentType });
|
|
56
|
+
log(` uploaded ${f.path} (${(f.size / 1024 / 1024).toFixed(1)} MB)`);
|
|
57
|
+
ids.push(sampleId);
|
|
58
|
+
}
|
|
59
|
+
return ids;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function voiceClone(pos, opts = {}, { ask = askLine, log = (line) => console.error(line) } = {}) {
|
|
63
|
+
if (typeof opts.name !== "string" || !opts.name.trim()) throw new Error("usage: voice clone <file...> --name <name>");
|
|
64
|
+
const files = sampleFiles(pos);
|
|
65
|
+
const name = opts.name.trim();
|
|
66
|
+
if ((await ops.listVoices(opts)).some((x) => x.name.toLowerCase() === name.toLowerCase())) {
|
|
67
|
+
throw new Error(`you already have a voice called "${name}"; pick another --name or voice rm it first`);
|
|
68
|
+
}
|
|
69
|
+
log(`Cloning a voice named "${name}" from ${files.length} recording(s).\n`);
|
|
70
|
+
log(` "${CONSENT_STATEMENT}"\n`);
|
|
71
|
+
const answer = await ask(`Type "${CONSENT_PHRASE}" to agree and upload: `);
|
|
72
|
+
if (answer.trim() !== CONSENT_PHRASE) throw new Error("consent not given; nothing was uploaded");
|
|
73
|
+
const sampleIds = await upload(opts, files, log);
|
|
74
|
+
const { voiceId, cost } = await ops.cloneVoice(opts, {
|
|
75
|
+
name, sampleIds, consent: { method: "cli-typed-phrase", statement: CONSENT_STATEMENT },
|
|
76
|
+
});
|
|
77
|
+
log(`cloning voice ${voiceId}${cost ? ` (${cost} credits, refunded if it fails)` : ""}…`);
|
|
78
|
+
const voice = await ops.pollVoice(opts, voiceId);
|
|
79
|
+
if (voice.status === "failed") throw new Error(`voice clone failed: ${voice.error}`);
|
|
80
|
+
log(`✓ voice "${voice.name}" is ready: hyperdecks narrate <deck-dir> --voice "${voice.name}"`);
|
|
81
|
+
return voice;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// For agents using the MCP clone_voice tool: upload recordings, print sample ids.
|
|
85
|
+
export async function voiceUpload(pos, opts = {}, { log = (line) => console.error(line) } = {}) {
|
|
86
|
+
return { sampleIds: await upload(opts, sampleFiles(pos), log) };
|
|
87
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# __TITLE__ — evidence
|
|
2
|
+
|
|
3
|
+
Every fact on a slide traces to a source the deck's author can open. One row per
|
|
4
|
+
fact: the slide id, the claim as it appears, where it came from and when. The
|
|
5
|
+
slide's `data-note` names the same source in a few words. This file stays in the
|
|
6
|
+
deck folder; `hyperdecks release` does not upload it.
|
|
7
|
+
|
|
8
|
+
| Slide | Claim | Source | Date |
|
|
9
|
+
|---|---|---|---|
|
|
10
|
+
| problem | | | |
|
|
11
|
+
| why-now | | | |
|
|
12
|
+
| proof | | | |
|
|
13
|
+
|
|
14
|
+
Proposed (not yet agreed): list every slide that proposes rather than reports.
|
|
15
|
+
|
|
16
|
+
- ask
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>__TITLE__</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Space+Mono:wght@400;700&display=swap">
|
|
10
|
+
<style>
|
|
11
|
+
/* A narrated deck: slides only. The hosted player (player/v1) scales the
|
|
12
|
+
stage, draws the chrome and the presenter, and plays the narration. */
|
|
13
|
+
:root { --ink: #0b0b0f; --paper: #fafaf7; --accent: #ffda1b; --font: "Space Grotesk", system-ui, sans-serif; --mono: "Space Mono", ui-monospace, monospace; }
|
|
14
|
+
body { color: var(--ink); font-family: var(--font); --hd-font: var(--font); --hd-mono: var(--mono); }
|
|
15
|
+
.slide { display: flex; flex-direction: column; gap: 28px; padding: 64px 72px 56px; background: var(--paper); }
|
|
16
|
+
.slide.accent { background: var(--accent); }
|
|
17
|
+
.kicker { font: 18px var(--mono); letter-spacing: .16em; text-transform: uppercase; }
|
|
18
|
+
.tt { font-size: 104px; line-height: .95; letter-spacing: -.045em; margin: 0; text-wrap: balance; }
|
|
19
|
+
.lead { font-size: 32px; line-height: 1.3; max-width: 30em; margin: 0; }
|
|
20
|
+
.content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 28px; padding-right: 320px; }
|
|
21
|
+
/* The presenter (face + caption bubble) moves into each slide's slot. */
|
|
22
|
+
.narration-slot { position: absolute; right: 56px; bottom: 72px; }
|
|
23
|
+
.reveal { opacity: 0; transition: opacity .4s; }
|
|
24
|
+
.reveal.is-revealed { opacity: 1; }
|
|
25
|
+
@media (max-width: 650px) {
|
|
26
|
+
.slide { padding: 40px 20px 150px; }
|
|
27
|
+
.tt { font-size: 40px; }
|
|
28
|
+
.lead { font-size: 18px; }
|
|
29
|
+
.content { padding-right: 0; justify-content: flex-start; }
|
|
30
|
+
.narration-slot { right: 12px; left: 12px; bottom: 72px; }
|
|
31
|
+
}
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
|
|
36
|
+
<!-- One <section class="slide" id> per slide, one idea each. The id joins the
|
|
37
|
+
slide to its narration (`## <id>` in script.md), #scene=<id> links and the events.
|
|
38
|
+
data-note: where this slide's facts come from (a file, a thread, a doc).
|
|
39
|
+
`hyperdecks check` fails a slide without one.
|
|
40
|
+
data-intro: the bubble's text before the narration plays.
|
|
41
|
+
data-title-mobile: shorter text below 650px; check requires it on any title
|
|
42
|
+
over 28 characters. Every slide here carries one. -->
|
|
43
|
+
<section class="slide accent" id="hello" data-chapter="Hello" data-intro="Hi! Press play." data-note="Title slide: no facts.">
|
|
44
|
+
<div class="content">
|
|
45
|
+
<div class="kicker">__SLUG__</div>
|
|
46
|
+
<h1 class="tt" data-title-mobile="__TITLE__">__TITLE__</h1>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="narration-slot"></div>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
51
|
+
<section class="slide" id="problem" data-chapter="Problem" data-note="Source: name the document, email or thread the problem comes from.">
|
|
52
|
+
<div class="content">
|
|
53
|
+
<div class="kicker">The problem</div>
|
|
54
|
+
<h2 class="tt" data-title-mobile="The problem.">The problem, in one line.</h2>
|
|
55
|
+
<p class="lead">One sentence on who has it and what it costs them.</p>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="narration-slot"></div>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<section class="slide accent" id="why-now" data-chapter="Why now" data-note="Source: name where the timing evidence comes from.">
|
|
61
|
+
<div class="content">
|
|
62
|
+
<div class="kicker">Why now</div>
|
|
63
|
+
<h2 class="tt" data-title-mobile="Why now.">Why this matters now.</h2>
|
|
64
|
+
<p class="lead">The change that makes this the moment.</p>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="narration-slot"></div>
|
|
67
|
+
</section>
|
|
68
|
+
|
|
69
|
+
<section class="slide" id="idea" data-chapter="The idea" data-note="Source: name the doc or conversation the idea is drawn from.">
|
|
70
|
+
<div class="content">
|
|
71
|
+
<div class="kicker">The idea</div>
|
|
72
|
+
<h2 class="tt" data-title-mobile="The idea.">One idea per slide.</h2>
|
|
73
|
+
<p class="lead reveal" id="detail">Revealed by a [reveal: #detail] cue in script.md.</p>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="narration-slot"></div>
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<section class="slide accent" id="how" data-chapter="How" data-note="Source: name the spec, repo or notes this is based on.">
|
|
79
|
+
<div class="content">
|
|
80
|
+
<div class="kicker">How it works</div>
|
|
81
|
+
<h2 class="tt" data-title-mobile="How it works.">How it works.</h2>
|
|
82
|
+
<p class="lead">Three steps at most, in the audience's words.</p>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="narration-slot"></div>
|
|
85
|
+
</section>
|
|
86
|
+
|
|
87
|
+
<section class="slide" id="proof" data-chapter="Proof" data-note="Source: name the metric's dashboard, report or email, with its date.">
|
|
88
|
+
<div class="content">
|
|
89
|
+
<div class="kicker">Proof</div>
|
|
90
|
+
<h2 class="tt" data-title-mobile="The proof.">The evidence so far.</h2>
|
|
91
|
+
<p class="lead">A number with its source and date, never an estimate shown as fact.</p>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="narration-slot"></div>
|
|
94
|
+
</section>
|
|
95
|
+
|
|
96
|
+
<section class="slide accent" id="ask" data-chapter="The ask" data-note="Proposed: not agreed with anyone yet.">
|
|
97
|
+
<div class="content">
|
|
98
|
+
<div class="kicker">Proposed</div>
|
|
99
|
+
<h2 class="tt" data-title-mobile="The ask.">What we propose next.</h2>
|
|
100
|
+
<p class="lead">Label anything not yet agreed as proposed.</p>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="narration-slot"></div>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<section class="slide" id="bye" data-chapter="Bye" data-note="Closing slide: no facts.">
|
|
106
|
+
<div class="content">
|
|
107
|
+
<h2 class="tt" data-title-mobile="Thanks.">Thanks for watching.</h2>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="narration-slot"></div>
|
|
110
|
+
</section>
|
|
111
|
+
|
|
112
|
+
<!-- `hyperdecks narrate decks/__SLUG__` fills data-manifest with the manifest
|
|
113
|
+
URL it writes. data-presenter: head (live Minimoji, needs
|
|
114
|
+
data-avatar), static (data-presenter-image) or none (captions only). -->
|
|
115
|
+
<script type="module" src="https://decks.hypersocial.ai/player/v1/player.js"
|
|
116
|
+
data-manifest="" data-stage="1600x900" data-presenter="none"></script>
|
|
117
|
+
<!-- Slide controllers for anything the built-in cues don't cover:
|
|
118
|
+
<script type="module">
|
|
119
|
+
window.hyperdecks.register("point", {
|
|
120
|
+
onEnter(ctx) {}, onExit(ctx) {}, prebuild(ctx) {},
|
|
121
|
+
onCue(cue, ctx) { if (cue.action === "custom:confetti") { /* … */ } },
|
|
122
|
+
});
|
|
123
|
+
</script> -->
|
|
124
|
+
</body>
|
|
125
|
+
</html>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
maxTotalSeconds: 180
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# __TITLE__ — narration script
|
|
6
|
+
|
|
7
|
+
One `## <slide-id>` block per slide, in slide order; the id is the slide's
|
|
8
|
+
`<section id>` in index.html. Each sentence is one caption: keep it a short
|
|
9
|
+
phrase (4 to 85 characters, two lines in the bubble). Aim for 150 to 180 s in
|
|
10
|
+
all. Holds: 1.1 s by default, `hold: 2` on interactive slides. Stage tags become
|
|
11
|
+
cues at the word before them: [wave], [mood: excited], [reveal: #detail],
|
|
12
|
+
[custom: confetti]. With no `voice:` above, your first ready voice narrates.
|
|
13
|
+
|
|
14
|
+
hyperdecks narrate decks/__SLUG__ --dry-run
|
|
15
|
+
hyperdecks narrate decks/__SLUG__
|
|
16
|
+
hyperdecks check decks/__SLUG__
|
|
17
|
+
|
|
18
|
+
## hello
|
|
19
|
+
Hi, [wave] this is __TITLE__. Let me walk you through it.
|
|
20
|
+
|
|
21
|
+
## problem
|
|
22
|
+
Here is the problem. It costs people real time.
|
|
23
|
+
|
|
24
|
+
## why-now
|
|
25
|
+
Why now? Something just changed.
|
|
26
|
+
|
|
27
|
+
## idea
|
|
28
|
+
Every slide makes one point. Here is the detail. [reveal: #detail]
|
|
29
|
+
|
|
30
|
+
## how
|
|
31
|
+
It works in three steps. Each one is simple.
|
|
32
|
+
|
|
33
|
+
## proof
|
|
34
|
+
Here is the evidence so far. Every number has a source.
|
|
35
|
+
|
|
36
|
+
## ask
|
|
37
|
+
hold: 2
|
|
38
|
+
|
|
39
|
+
This part is a proposal. Nothing here is agreed yet.
|
|
40
|
+
|
|
41
|
+
## bye
|
|
42
|
+
That's it. Thanks for watching! [wave]
|