@hypersocial/decks-cli 0.9.11 → 0.9.12
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 +2 -0
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +4 -0
- package/skills/hyperdecks/chat.md +13 -0
- package/src/avatar-packs.mjs +89 -0
- package/src/avatar-view.mjs +34 -0
- package/src/commands.mjs +80 -2
- package/src/deckPlayer.mjs +2 -1
- package/src/export-video.mjs +7 -2
- package/src/ops.mjs +106 -0
- package/src/play.mjs +4 -2
- package/src/preview-capture.mjs +27 -8
- package/src/preview-moment.mjs +243 -0
- package/src/preview.mjs +12 -2
- package/src/runtime.mjs +10 -3
package/README.md
CHANGED
|
@@ -259,6 +259,8 @@ Generate locally, select an existing same-deck recipient share, then explicitly
|
|
|
259
259
|
|
|
260
260
|
Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a play/watch badge on GIF, PNG and silent MP4. `--duration` accepts 1–10 seconds, `--width` 240–1200 pixels (odd widths round up for MP4), `--delay` 0–10000 milliseconds (default 1000), and `--label "Watch · 2:41"` supplies an explicit label; no whole-deck narration duration is inferred. Aspect comes from `--aspect-ratio W:H`, `<meta name="preview-aspect-ratio" content="16:9">`, or the first slide/canvas bounds; otherwise 16:9. Output height rounds to an even pixel. Fonts get up to 5 seconds to settle. Inspect readiness warnings/poster for slow modules or media and increase the bounded delay if needed.
|
|
261
261
|
|
|
262
|
+
To show the slide that sells the deck instead of its opening, name it and a moment of its narration: `hyperdecks preview create my-deck --scene chars --start 3.2 --share EXISTING_TOKEN --out ./preview-chars --upload --json`. `--scene` is the slide's id (its `<section class="slide" id>`; an unknown id fails and lists the deck's ids); `--start` is seconds into that slide's narration (default 0; it must fall inside the slide's clip plus its hold). The GIF then starts on exactly that moment: the guest speaking then, with its mouth and caption bubble, and every cue fired by then. It records frame by frame on the player's page clock (as `export --video` does), silently, after the slide's character heads finish loading; `--delay` still adds settle time first. Private character heads use the same in-memory, deck-scoped view grant; nothing is written to disk. `manifest.json` gets a `moment` saying which slide, which guest and which caption the first frame shows. Needs a deck on player/v1.
|
|
263
|
+
|
|
262
264
|
`--json` prints one line on stdout, identical to `<out>/manifest.json`:
|
|
263
265
|
|
|
264
266
|
```json
|
package/package.json
CHANGED
|
@@ -150,6 +150,8 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
150
150
|
| Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
|
|
151
151
|
| Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
|
|
152
152
|
| Slides or scroll, viewer toggle, pace, AI-readable version | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default] [--ai-readable on\|off]` | `set_deck_view` |
|
|
153
|
+
| Give a deck to another account (only when asked; confirm the email) | `hyperdecks deck transfer <slug> --to <email>` (`deck transfers cancel <slug>` takes it back) | `transfer_deck` (`cancel: true`) |
|
|
154
|
+
| Transfers waiting on you, and accepting one (ask who narrates and, for each guest that needsPick, a voice and avatar of theirs) | `hyperdecks deck transfers`, then `deck transfers accept <id> --narrator <name\|dash\|none\|default> [--guest <n>=<voice>/<avatar>]` (or `decline <id>`) | `list_transfers`, then `accept_transfer` (`decline: true`) |
|
|
153
155
|
| Present or check your own deck (owner link) | `hyperdecks deck link <slug>` (`deck open` opens it) | `ownerUrl` from `list_decks` or `check_deck` |
|
|
154
156
|
| Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
|
|
155
157
|
| All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
|
|
@@ -1012,6 +1014,8 @@ Generate locally, select an existing same-deck recipient share, then explicitly
|
|
|
1012
1014
|
|
|
1013
1015
|
Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a play/watch badge on GIF, PNG and silent MP4. `--duration` accepts 1–10 seconds, `--width` 240–1200 pixels (odd widths round up for MP4), `--delay` 0–10000 milliseconds (default 1000), and `--label "Watch · 2:41"` supplies an explicit label; no whole-deck narration duration is inferred. Aspect comes from `--aspect-ratio W:H`, `<meta name="preview-aspect-ratio" content="16:9">`, or the first slide/canvas bounds; otherwise 16:9. Output height rounds to an even pixel. Fonts get up to 5 seconds to settle. Inspect readiness warnings/poster for slow modules or media and increase the bounded delay if needed.
|
|
1014
1016
|
|
|
1017
|
+
To show the slide that sells the deck instead of its opening, name it and a moment of its narration: `hyperdecks preview create my-deck --scene chars --start 3.2 --share EXISTING_TOKEN --out ./preview-chars --upload --json`. `--scene` is the slide's id (its `<section class="slide" id>`; an unknown id fails and lists the deck's ids); `--start` is seconds into that slide's narration (default 0; it must fall inside the slide's clip plus its hold). The GIF then starts on exactly that moment: the guest speaking then, with its mouth and caption bubble, and every cue fired by then. It records frame by frame on the player's page clock (as `export --video` does), silently, after the slide's character heads finish loading; `--delay` still adds settle time first. Private character heads use the same in-memory, deck-scoped view grant; nothing is written to disk. `manifest.json` gets a `moment` saying which slide, which guest and which caption the first frame shows. Needs a deck on player/v1.
|
|
1018
|
+
|
|
1015
1019
|
`--json` prints one line on stdout, identical to `<out>/manifest.json`:
|
|
1016
1020
|
|
|
1017
1021
|
```json
|
|
@@ -128,6 +128,19 @@ makes the hello deck; continue from step 5 below.
|
|
|
128
128
|
worker it answers with the `hyperdecks export <slug> --video` command
|
|
129
129
|
instead; give that to the user to run.
|
|
130
130
|
|
|
131
|
+
## Giving a deck to another account
|
|
132
|
+
|
|
133
|
+
Like moving a GitHub repo. Only when the person asks: confirm the
|
|
134
|
+
recipient's email, then `transfer_deck { slug, to }`. They get an email and
|
|
135
|
+
have 7 days; nothing moves until they accept, and `transfer_deck { slug,
|
|
136
|
+
cancel: true }` takes it back. On the other side, `list_transfers` shows what
|
|
137
|
+
waits. Before `accept_transfer`, ask who narrates it for them when
|
|
138
|
+
`needsNarrator` (`list_narrators`: theirs, dash, none or default), and a
|
|
139
|
+
voice and avatar of theirs for each guest with `needsPick`; the sender's
|
|
140
|
+
voice and face never move. Accepting moves the deck with its share links
|
|
141
|
+
(links already sent keep working), recipients and stats, and starts narrating
|
|
142
|
+
their picks; `release_deck` puts them on the links.
|
|
143
|
+
|
|
131
144
|
## Rules that matter
|
|
132
145
|
|
|
133
146
|
- Every slide is `<section class="slide" id="…" data-note="<source of its facts>">`,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Pack management never changes avatar IDs or the user's default presenter.
|
|
2
|
+
// Publication is a distinct command; there is no public flag on create/import.
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
import { client } from "./runtime.mjs";
|
|
5
|
+
|
|
6
|
+
const ref = (name) => makeFunctionReference(`avatarPacks:${name}`);
|
|
7
|
+
const PACK_USAGE = "usage: avatar pack list | create <name> [--private-only] [--description text] | get <pack-id> | add <pack-id> <avatar-id> | grant <pack-id> --account <id>|--email <email>|--team <id> | grants <pack-id> | revoke <grant-id> | publish <pack-id> | unpublish <pack-id> | invitations | claim | catalog";
|
|
8
|
+
const TEAM_USAGE = "usage: avatar team list | create <name> | members <team-id> | add <team-id> --account <id>|--email <email> | remove <membership-id>";
|
|
9
|
+
const text = (value, label) => {
|
|
10
|
+
if (typeof value !== "string" || !value.trim() || value.startsWith("--")) throw new Error(`${label} requires a value`);
|
|
11
|
+
return value.trim();
|
|
12
|
+
};
|
|
13
|
+
function exactly(pos, count, usage) {
|
|
14
|
+
if (pos.length !== count || pos.some(value => !value)) throw new Error(usage);
|
|
15
|
+
return pos;
|
|
16
|
+
}
|
|
17
|
+
function recipient(opts, allowTeam) {
|
|
18
|
+
const names = allowTeam ? ["account", "email", "team"] : ["account", "email"];
|
|
19
|
+
const selected = names.filter(name => Object.hasOwn(opts, name));
|
|
20
|
+
if (selected.length !== 1 || (!allowTeam && Object.hasOwn(opts, "team"))) throw new Error(`choose exactly one of ${names.map(name => `--${name}`).join(", ")}`);
|
|
21
|
+
const key = selected[0], value = text(opts[key], `--${key}`);
|
|
22
|
+
return { [{ account: "accountId", email: "email", team: "teamId" }[key]]: value };
|
|
23
|
+
}
|
|
24
|
+
function output(result, opts, log) {
|
|
25
|
+
if (opts.json) log(JSON.stringify(result, null, 2));
|
|
26
|
+
else if (Array.isArray(result)) {
|
|
27
|
+
if (!result.length) log("(none)");
|
|
28
|
+
for (const item of result) log(`${item.id ?? item._id}\t${item.name ?? item.granteeId ?? item.memberId ?? item.email ?? item.teamId ?? ""}${item.visibility ? `\t${item.visibility}` : ""}${item.access ? ` (${item.access})` : ""}${item.privateOnly ? " · permanently private-only" : ""}${item.status ? `\t${item.status}` : ""}`);
|
|
29
|
+
} else log(JSON.stringify(result, null, 2));
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function avatarPacksCommand(pos = [], opts = {}, { log = console.log } = {}) {
|
|
34
|
+
const [action = "list", ...args] = pos;
|
|
35
|
+
let method = "query", apiName, payload;
|
|
36
|
+
switch (action) {
|
|
37
|
+
case "list": exactly(args, 0, PACK_USAGE); apiName = "list"; payload = {}; break;
|
|
38
|
+
case "create":
|
|
39
|
+
if (!args.length) throw new Error(PACK_USAGE);
|
|
40
|
+
if (Object.hasOwn(opts, "public") || Object.hasOwn(opts, "publish")) throw new Error("packs are created private; use the separate publish command for an eligible pack");
|
|
41
|
+
apiName = "create"; method = "mutation";
|
|
42
|
+
payload = { name: text(args.join(" "), "pack name"), privateOnly: opts["private-only"] === true };
|
|
43
|
+
if (Object.hasOwn(opts, "description")) payload.description = text(opts.description, "--description");
|
|
44
|
+
break;
|
|
45
|
+
case "get": case "grants":
|
|
46
|
+
exactly(args, 1, PACK_USAGE); apiName = action; payload = { packId: args[0] }; break;
|
|
47
|
+
case "add":
|
|
48
|
+
exactly(args, 2, PACK_USAGE); apiName = "addAvatar"; method = "mutation";
|
|
49
|
+
payload = { packId: args[0], avatarId: args[1] }; break;
|
|
50
|
+
case "grant":
|
|
51
|
+
exactly(args, 1, PACK_USAGE); apiName = "grant"; method = "mutation";
|
|
52
|
+
payload = { packId: args[0], ...recipient(opts, true) }; break;
|
|
53
|
+
case "revoke":
|
|
54
|
+
exactly(args, 1, PACK_USAGE); apiName = "revoke"; method = "mutation"; payload = { grantId: args[0] }; break;
|
|
55
|
+
case "publish": case "unpublish":
|
|
56
|
+
exactly(args, 1, PACK_USAGE); apiName = action; method = "mutation"; payload = { packId: args[0] }; break;
|
|
57
|
+
case "invitations": case "claim":
|
|
58
|
+
exactly(args, 0, PACK_USAGE); apiName = action === "claim" ? "claimInvitations" : "invitations";
|
|
59
|
+
method = action === "claim" ? "mutation" : "query"; payload = {}; break;
|
|
60
|
+
case "catalog":
|
|
61
|
+
exactly(args, 0, PACK_USAGE); apiName = "publicCatalog";
|
|
62
|
+
payload = { paginationOpts: { numItems: 32, cursor: Object.hasOwn(opts, "cursor") ? text(opts.cursor, "--cursor") : null } }; break;
|
|
63
|
+
default: throw new Error(PACK_USAGE);
|
|
64
|
+
}
|
|
65
|
+
const result = await (await client(opts))[method](ref(apiName), payload);
|
|
66
|
+
if (action === "get" && !result) throw new Error("pack not found or unavailable to your account");
|
|
67
|
+
if (!opts.json && action === "create") log(result.privateOnly ? "Created privately. The private-only lock is permanent; this pack cannot be published." : "Created privately. Only an explicit publish command makes this pack public.");
|
|
68
|
+
return output(result, opts, log);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function avatarTeamsCommand(pos = [], opts = {}, { log = console.log } = {}) {
|
|
72
|
+
const [action = "list", ...args] = pos;
|
|
73
|
+
let method = "query", apiName, payload;
|
|
74
|
+
switch (action) {
|
|
75
|
+
case "list": exactly(args, 0, TEAM_USAGE); apiName = "teams"; payload = {}; break;
|
|
76
|
+
case "create":
|
|
77
|
+
if (!args.length) throw new Error(TEAM_USAGE);
|
|
78
|
+
apiName = "createTeam"; method = "mutation"; payload = { name: text(args.join(" "), "team name") }; break;
|
|
79
|
+
case "members":
|
|
80
|
+
exactly(args, 1, TEAM_USAGE); apiName = "teamMembers"; payload = { teamId: args[0] }; break;
|
|
81
|
+
case "add":
|
|
82
|
+
exactly(args, 1, TEAM_USAGE); apiName = "addTeamMember"; method = "mutation";
|
|
83
|
+
payload = { teamId: args[0], ...recipient(opts, false) }; break;
|
|
84
|
+
case "remove":
|
|
85
|
+
exactly(args, 1, TEAM_USAGE); apiName = "removeTeamMember"; method = "mutation"; payload = { membershipId: args[0] }; break;
|
|
86
|
+
default: throw new Error(TEAM_USAGE);
|
|
87
|
+
}
|
|
88
|
+
return output(await (await client(opts))[method](ref(apiName), payload), opts, log);
|
|
89
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Ephemeral HTML transform for play/capture/export. Never write its output to
|
|
2
|
+
// source push, release source, disk caches, or logs: it contains a view grant.
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
const ISSUE_OWNER_VIEW = makeFunctionReference("avatarAccess:issueOwnerView");
|
|
5
|
+
const VIEW_META = /<meta\b[^>]*\bname\s*=\s*(?:"hd-avatar-view"|'hd-avatar-view'|hd-avatar-view(?=\s|\/?>))[^>]*>/gi;
|
|
6
|
+
const PRIVATE_REFS = /\/avatar\/private\/|\bdata-hd-avatar\b/i;
|
|
7
|
+
|
|
8
|
+
export async function withAvatarView(client, slug, html, scope = "draft") {
|
|
9
|
+
if (typeof html !== "string") throw new Error("avatar preview requires HTML text");
|
|
10
|
+
if (scope !== "draft" && scope !== "released") throw new Error("avatar preview scope must be draft or released");
|
|
11
|
+
const hadView = /<meta\b[^>]*\bname\s*=\s*(?:"hd-avatar-view"|'hd-avatar-view'|hd-avatar-view(?=\s|\/?>))/i.test(html);
|
|
12
|
+
const needsView = hadView || PRIVATE_REFS.test(html);
|
|
13
|
+
// Always request a session: guest URLs may live only in the narration
|
|
14
|
+
// manifest. Offline ordinary playback remains usable without a grant;
|
|
15
|
+
// protected endpoints still reject private models without authorization.
|
|
16
|
+
const clean = html.replace(VIEW_META, "");
|
|
17
|
+
if (!needsView && !/\bdata-(?:manifest|hd-speaker)\b/i.test(html)) return clean;
|
|
18
|
+
let grant;
|
|
19
|
+
try {
|
|
20
|
+
if (typeof slug !== "string" || !slug.trim()) throw new Error("a deck slug is required");
|
|
21
|
+
grant = await client.mutation(ISSUE_OWNER_VIEW, { slug, scope });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (needsView) throw new Error("Cannot authorize private avatars for this preview. Sign in as the deck owner, verify pack access, and push the current deck source before retrying.", { cause: error });
|
|
24
|
+
return clean;
|
|
25
|
+
}
|
|
26
|
+
if (!grant) {
|
|
27
|
+
if (needsView) throw new Error("No current private-avatar view grant. Push the deck source and script so their avatar IDs are registered, then retry.");
|
|
28
|
+
return clean;
|
|
29
|
+
}
|
|
30
|
+
if (!/^[a-f0-9]{64}$/.test(grant.token) || !Number.isFinite(grant.expiresAt) || grant.expiresAt <= Date.now()) throw new Error("The server returned an invalid or expired avatar view grant");
|
|
31
|
+
const meta = `<meta name="hd-avatar-view" content="${grant.token}">`;
|
|
32
|
+
// Put the grant before any player script; the HTML string stays in memory.
|
|
33
|
+
return /<head\b[^>]*>/i.test(clean) ? clean.replace(/<head\b[^>]*>/i, match => `${match}\n${meta}`) : `${meta}\n${clean}`;
|
|
34
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -173,6 +173,65 @@ async function deckForward(pos, opts) {
|
|
|
173
173
|
console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
const TRANSFER_USAGE = "usage: deck transfer <slug> --to <email>";
|
|
177
|
+
async function deckTransfer(pos, opts) {
|
|
178
|
+
const [slug] = pos;
|
|
179
|
+
if (!slug || !opts.to) throw new Error(TRANSFER_USAGE);
|
|
180
|
+
const out = await ops.transferDeck(opts, slug, opts.to);
|
|
181
|
+
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
182
|
+
console.log(`OK ${out.existing ? "already offered" : "offered"} ${slug} to ${out.to}, until ${out.expiresAt.slice(0, 10)}`);
|
|
183
|
+
console.log(" Nothing moves until they accept (signed in to Hyperdecks with that email). Cancel: deck transfers cancel " + slug);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const TRANSFERS_USAGE = "usage: deck transfers [accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]... [--no-narrate] | decline <id> | cancel <slug>]";
|
|
187
|
+
// "1=Ana/Ana Face,2=stock:liam" → [{ guest: 1, voice: "Ana", avatar: "Ana Face" }, …]
|
|
188
|
+
export function parseGuestPicks(spec) {
|
|
189
|
+
if (!spec) return [];
|
|
190
|
+
return String(spec).split(",").map((part) => {
|
|
191
|
+
const m = /^\s*(\d+)\s*=\s*([^/]+?)\s*(?:\/\s*(.+?)\s*)?$/.exec(part);
|
|
192
|
+
if (!m) throw new Error(`--guest ${part}: write <n>=<voice> or <n>=<voice>/<avatar>`);
|
|
193
|
+
return { guest: Number(m[1]), voice: m[2], ...(m[3] ? { avatar: m[3] } : {}) };
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function deckTransfers(pos, opts) {
|
|
198
|
+
const [action, arg] = pos;
|
|
199
|
+
if (action === "accept") {
|
|
200
|
+
if (!arg) throw new Error(TRANSFERS_USAGE);
|
|
201
|
+
const out = await ops.acceptTransfer(opts, arg, { narrator: opts.narrator, guests: parseGuestPicks(opts.guest), renarrate: !opts["no-narrate"] });
|
|
202
|
+
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
203
|
+
console.log(`OK ${out.slug} is yours, with its share links and stats; links already sent keep working`);
|
|
204
|
+
if (out.narration?.jobId) console.log(` narrating your picks: job ${out.narration.jobId} (narrate status), then release ${out.slug}`);
|
|
205
|
+
else if (out.narration?.error) console.log(` ⚠ narration did not start: ${out.narration.error}\n ${out.narration.note}`);
|
|
206
|
+
else if (out.narration?.note) console.log(` ${out.narration.note}`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (action === "decline") {
|
|
210
|
+
if (!arg) throw new Error(TRANSFERS_USAGE);
|
|
211
|
+
const out = await ops.declineTransfer(opts, arg);
|
|
212
|
+
return void console.log(opts.json ? JSON.stringify(out) : `OK declined ${out.slug}`);
|
|
213
|
+
}
|
|
214
|
+
if (action === "cancel") {
|
|
215
|
+
if (!arg) throw new Error(TRANSFERS_USAGE);
|
|
216
|
+
const out = await ops.cancelTransfer(opts, arg);
|
|
217
|
+
return void console.log(opts.json ? JSON.stringify(out) : `OK cancelled the transfer of ${out.slug} to ${out.to}`);
|
|
218
|
+
}
|
|
219
|
+
if (action) throw new Error(TRANSFERS_USAGE);
|
|
220
|
+
const out = await ops.listTransfers(opts);
|
|
221
|
+
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
222
|
+
if (out.emailUnverified) console.log("⚠ your account's email is not verified, so transfers sent to it can't be shown or accepted");
|
|
223
|
+
if (!out.incoming.length && !out.outgoing.length) return void console.log("No transfers waiting.");
|
|
224
|
+
for (const t of out.incoming) {
|
|
225
|
+
console.log(`← ${t.slug} "${t.title}" from ${t.from ?? "someone"} (until ${t.expiresAt.slice(0, 10)}) id ${t.id}`);
|
|
226
|
+
if (t.needsNarrator) console.log(" narrated in the sender's narrator or voice: pick yours with --narrator <name|dash|none|default>");
|
|
227
|
+
for (const g of t.guests) {
|
|
228
|
+
console.log(` guest ${g.guest} ${g.name} (${g.selector}): voice ${g.voice}${g.avatar ? `, avatar ${g.avatar}` : ""}${g.needsPick ? " → pick with --guest " + g.guest + "=<voice>/<avatar>" : ""}`);
|
|
229
|
+
}
|
|
230
|
+
console.log(` accept: deck transfers accept ${t.id}${t.needsNarrator ? " --narrator <…>" : ""}${t.guests.filter((g) => g.needsPick).map((g) => ` --guest ${g.guest}=<voice>/<avatar>`).join("")}`);
|
|
231
|
+
}
|
|
232
|
+
for (const t of out.outgoing) console.log(`→ ${t.slug} "${t.title}" to ${t.to} (until ${t.expiresAt.slice(0, 10)}); cancel: deck transfers cancel ${t.slug}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
176
235
|
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]";
|
|
177
236
|
const VIEWS = { slides: "slides", scroll: "scroll", default: null };
|
|
178
237
|
const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
|
|
@@ -363,12 +422,20 @@ commands:
|
|
|
363
422
|
login [--token <pat>] [--broker <url>] [--dev]
|
|
364
423
|
logout [--dev]
|
|
365
424
|
whoami [--json] [--dev]
|
|
366
|
-
deck list|stats|open|link|rm|forward|view
|
|
425
|
+
deck list|stats|open|link|rm|forward|view|transfer|transfers
|
|
367
426
|
deck link <slug> [--json]
|
|
368
427
|
the owner link: present or check your own deck (only you, signed in; untracked;
|
|
369
428
|
Present or H hides your owner tools). Use it instead of a QA link. deck open opens it.
|
|
370
429
|
deck forward on|off <slug>
|
|
371
430
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
431
|
+
deck transfer <slug> --to <email>
|
|
432
|
+
give the deck to another Hyperdecks account (like a GitHub repo transfer). Nothing moves until
|
|
433
|
+
they accept, within 7 days; then its share links, recipients and stats are theirs, not yours.
|
|
434
|
+
deck transfers [accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]... [--no-narrate]
|
|
435
|
+
| decline <id> | cancel <slug>]
|
|
436
|
+
transfers waiting on you and the ones you sent. Accepting picks your narrator and a voice and
|
|
437
|
+
avatar for each guest in the sender's own voice or face, then narrates those lines as you;
|
|
438
|
+
the deck plays as released until you release it.
|
|
372
439
|
deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
|
|
373
440
|
how the deck opens in the player (default: as its HTML says, data-view or slides) and
|
|
374
441
|
whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
|
|
@@ -471,6 +538,9 @@ commands:
|
|
|
471
538
|
|
|
472
539
|
preview create <slug> --out <directory> [--share <token>] [--upload] [--json]
|
|
473
540
|
preview create --file <index.html> --out <directory> [--duration 4] [--width 600] [--delay 1000] [--label "Watch"] [--aspect-ratio 16:9]
|
|
541
|
+
preview create <slug> --out <directory> --scene <slide-id> [--start <seconds>] [--share <token> --upload]
|
|
542
|
+
the email GIF of one slide, from <seconds> into its narration (default 0): the guest
|
|
543
|
+
speaking then, its caption bubble and every cue fired by then; needs a player/v1 deck
|
|
474
544
|
preview setup install isolated Chromium (Linux: see README for system dependencies)
|
|
475
545
|
|
|
476
546
|
narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
|
|
@@ -483,6 +553,9 @@ commands:
|
|
|
483
553
|
voice rm <name|id> delete a voice, also at the provider
|
|
484
554
|
voice hear <voice> | voice hear --narrator <name|id>
|
|
485
555
|
a short sample of the voice: its clip URL and the line it says
|
|
556
|
+
avatar pack [list|create|get|add|grant|grants|revoke|publish|unpublish|invitations|claim|catalog]
|
|
557
|
+
owner-managed packs; create <name> --private-only permanently prevents publication
|
|
558
|
+
avatar team [list|create|members|add|remove] owner-managed account groups for pack grants
|
|
486
559
|
avatar [--json] your avatars (who presents decks in your voice) and the default
|
|
487
560
|
avatar new [--species <s>] pick a species, then an avatar (s shuffles); --json or an agent gets a pick link
|
|
488
561
|
avatar from-photo [file] [--save-to-minimoji]
|
|
@@ -805,7 +878,8 @@ function commandTable(pos, rest, opts) {
|
|
|
805
878
|
"preview create": async () => {
|
|
806
879
|
const { createPreview } = await import("./preview.mjs");
|
|
807
880
|
const result = await createPreview(rest, opts);
|
|
808
|
-
|
|
881
|
+
const shows = result.moment ? `Shows ${result.moment.scene} at ${result.moment.start}s${result.moment.guest ? `: ${result.moment.guest.caption}` : ""}\n` : "";
|
|
882
|
+
console.log(opts.json ? JSON.stringify(result) : `Preview saved: ${result.manifestPath}\n${shows}${result.email.sendable ? result.email.path : "Insert the GIF or PNG manually; upload with an existing share for linked email HTML."}`);
|
|
809
883
|
},
|
|
810
884
|
init: async () => {
|
|
811
885
|
const { init } = await import("./init.mjs");
|
|
@@ -913,6 +987,8 @@ function commandTable(pos, rest, opts) {
|
|
|
913
987
|
const out = await avatarList(opts);
|
|
914
988
|
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
915
989
|
},
|
|
990
|
+
"avatar pack": async () => (await import("./avatar-packs.mjs")).avatarPacksCommand(rest, opts),
|
|
991
|
+
"avatar team": async () => (await import("./avatar-packs.mjs")).avatarTeamsCommand(rest, opts),
|
|
916
992
|
"avatar new": async () => {
|
|
917
993
|
const { avatarNew } = await import("./avatar.mjs");
|
|
918
994
|
const { askLine } = await import("./init.mjs");
|
|
@@ -1019,6 +1095,8 @@ function commandTable(pos, rest, opts) {
|
|
|
1019
1095
|
"deck rm": () => deckRemove(rest, opts),
|
|
1020
1096
|
"deck forward": () => deckForward(rest, opts),
|
|
1021
1097
|
"deck view": () => deckView(rest, opts),
|
|
1098
|
+
"deck transfer": () => deckTransfer(rest, opts),
|
|
1099
|
+
"deck transfers": () => deckTransfers(rest, opts),
|
|
1022
1100
|
"share new": () => shareNew(rest, opts),
|
|
1023
1101
|
"share list": () => shareList(rest, opts),
|
|
1024
1102
|
"share on": () => shareToggle(rest, opts, true),
|
package/src/deckPlayer.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// runs its own runtime (a legacy copy-and-swap deck.js, a plain HTML export)
|
|
6
6
|
// gets none of them.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// A copy of a page our routes served names a pinned build (/player/v1/b/<hash>/player.js).
|
|
9
|
+
export const loadsPlayer = (html) => /\/player\/v1\/(?:b\/[0-9a-f]+\/)?player\.js\b/.test(String(html ?? ""));
|
|
9
10
|
|
|
10
11
|
const SCRIPT_SRC = /<script\b[^>]*\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>/gi;
|
|
11
12
|
const MAX_SCRIPT_NAME = 120;
|
package/src/export-video.mjs
CHANGED
|
@@ -424,7 +424,8 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
424
424
|
// A development checkout plays its own player/v1 (HYPERDECKS_PLAYER_DIR), not the released one.
|
|
425
425
|
if (config.playerDir) {
|
|
426
426
|
await context.route(/\/player\/v1\/[^?#]+/, async (route) => {
|
|
427
|
-
|
|
427
|
+
// A pinned build (/player/v1/b/<hash>/, lib/deckPlayer.ts) is the checkout's files too.
|
|
428
|
+
const name = new URL(route.request().url()).pathname.replace(/^.*\/player\/v1\/(b\/[^/]+\/)?/, '');
|
|
428
429
|
if (name.split('/').some((s) => s === '..' || s.startsWith('.'))) return route.abort();
|
|
429
430
|
const type = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png' }[extname(name)] ?? 'application/octet-stream';
|
|
430
431
|
try { await route.fulfill({ body: await readFile(join(config.playerDir, name)), contentType: type }); }
|
|
@@ -768,7 +769,11 @@ export async function exportVideo(arg, opts = {}, { log = (l) => process.stderr.
|
|
|
768
769
|
} else {
|
|
769
770
|
const deck = await ops.releasedDeck(opts, arg);
|
|
770
771
|
const { serveSource } = await import('./preview-capture.mjs');
|
|
771
|
-
|
|
772
|
+
// The worker imports recordVideo with already-scoped HTML and no CLI auth dependencies.
|
|
773
|
+
const { withAvatarView } = await import("./avatar-view.mjs");
|
|
774
|
+
const { client: avatarClient } = await import("./runtime.mjs");
|
|
775
|
+
const html = await withAvatarView({ mutation: async (ref, args) => (await avatarClient(opts)).mutation(ref, args) }, arg, deck.html, "released");
|
|
776
|
+
const server = await serveSource({ html });
|
|
772
777
|
source = { slug: arg, title: deck.title, url: server.url, close: server.close, presenter: null };
|
|
773
778
|
}
|
|
774
779
|
try {
|
package/src/ops.mjs
CHANGED
|
@@ -1359,6 +1359,112 @@ export async function setDeckNarrator(opts, slug, ref) {
|
|
|
1359
1359
|
return (await client(opts)).mutation(api.narrators.setDeckNarrator, { slug, narrator: value });
|
|
1360
1360
|
}
|
|
1361
1361
|
|
|
1362
|
+
// ---- deck transfers (convex/deckTransfers.ts) ----------------------------------
|
|
1363
|
+
|
|
1364
|
+
// The tool descriptions, shared by the stdio and remote MCP servers.
|
|
1365
|
+
export const TRANSFER_DECK_DESCRIPTION =
|
|
1366
|
+
"Give a deck to another Hyperdecks account, like transferring a GitHub repo: they get an email and accept within 7 days " +
|
|
1367
|
+
"(list_transfers, accept_transfer); until then nothing changes. Accepting moves the deck with its share links (links already sent " +
|
|
1368
|
+
"keep working), recipients and stats; you keep nothing. Only when the person asked for it, and confirm the email first. cancel: true takes the offer back.";
|
|
1369
|
+
export const LIST_TRANSFERS_DESCRIPTION =
|
|
1370
|
+
"Deck transfers waiting on the user (incoming: id, deck, sender, whether they must pick a narrator, and guests, numbered, with needsPick " +
|
|
1371
|
+
"when a guest speaks in the sender's own voice or face) and the ones they sent (outgoing).";
|
|
1372
|
+
export const ACCEPT_TRANSFER_DESCRIPTION =
|
|
1373
|
+
"Accept (or decline: true) a deck transfer from list_transfers. Ask the person first: who narrates it for them (list_narrators: one of " +
|
|
1374
|
+
"theirs, dash, none or default; required when needsNarrator), and a voice (list_voices) and avatar (list_avatars) for each guest with " +
|
|
1375
|
+
"needsPick. The sender's voice and face never move. Then their picks are narrated as them (narration.jobId; poll check_narration_job); " +
|
|
1376
|
+
"the deck keeps playing as released until they release_deck.";
|
|
1377
|
+
|
|
1378
|
+
// Offer a deck to the Hyperdecks account signed in with `to`. Nothing moves
|
|
1379
|
+
// until they accept; the offer lasts 7 days.
|
|
1380
|
+
export async function transferDeck(opts, slug, to) {
|
|
1381
|
+
if (!slug || !to) throw new Error("usage: deck transfer <slug> --to <email>");
|
|
1382
|
+
const out = await (await client(opts)).mutation(api.deckTransfers.create, { slug, to: String(to) });
|
|
1383
|
+
return { ...out, expiresAt: new Date(out.expiresAt).toISOString() };
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
export async function cancelTransfer(opts, slug) {
|
|
1387
|
+
if (!slug) throw new Error("usage: deck transfers cancel <slug>");
|
|
1388
|
+
return (await client(opts)).mutation(api.deckTransfers.cancel, { slug });
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Transfers waiting on you (incoming, numbered guests to pick for) and the
|
|
1392
|
+
// ones you sent (outgoing). A guest needs a pick when it speaks in the
|
|
1393
|
+
// sender's own voice or face (needsPick).
|
|
1394
|
+
export async function listTransfers(opts = {}) {
|
|
1395
|
+
const out = await (await client(opts)).query(api.deckTransfers.list, {});
|
|
1396
|
+
const when = (ms) => new Date(ms).toISOString();
|
|
1397
|
+
return {
|
|
1398
|
+
incoming: out.incoming.map((t) => ({
|
|
1399
|
+
id: t.id,
|
|
1400
|
+
slug: t.slug,
|
|
1401
|
+
title: t.title,
|
|
1402
|
+
from: t.from,
|
|
1403
|
+
expiresAt: when(t.expiresAt),
|
|
1404
|
+
needsNarrator: t.needsNarrator,
|
|
1405
|
+
guests: t.guests.map((g, i) => ({
|
|
1406
|
+
guest: i + 1,
|
|
1407
|
+
name: g.name,
|
|
1408
|
+
selector: g.selector,
|
|
1409
|
+
voice: g.voice ?? "(the sender's own voice)",
|
|
1410
|
+
avatar: g.personalAvatar ? "(the sender's own avatar)" : g.avatar,
|
|
1411
|
+
needsPick: g.personalVoice || g.personalAvatar,
|
|
1412
|
+
})),
|
|
1413
|
+
})),
|
|
1414
|
+
emailUnverified: !!out.emailUnverified,
|
|
1415
|
+
outgoing: out.outgoing.map((t) => ({ id: t.id, slug: t.slug, title: t.title, to: t.to, expiresAt: when(t.expiresAt) })),
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Accept an incoming transfer. narrator: one of yours (name or id), dash, none
|
|
1420
|
+
// or default; required when the deck narrates in the sender's narrator or
|
|
1421
|
+
// voice. guests: [{ guest, voice, avatar? }], guest numbers from
|
|
1422
|
+
// listTransfers, one for each guest that needsPick. Then, unless
|
|
1423
|
+
// renarrate is false, narrates the rewritten draft as you (only the
|
|
1424
|
+
// re-picked lines render; the rest is cached). The live deck plays as
|
|
1425
|
+
// released until you release it.
|
|
1426
|
+
export async function acceptTransfer(opts, id, picks = {}) {
|
|
1427
|
+
const { narrator, guests, renarrate = true } = picks;
|
|
1428
|
+
if (!id) throw new Error("usage: deck transfers accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]");
|
|
1429
|
+
const c = await client(opts);
|
|
1430
|
+
// Guest numbers are positions in list's guests; only then is the list needed.
|
|
1431
|
+
let listed = null;
|
|
1432
|
+
if (guests?.length) {
|
|
1433
|
+
const all = await c.query(api.deckTransfers.list, {});
|
|
1434
|
+
listed = all.incoming.find((t) => t.id === id);
|
|
1435
|
+
if (!listed) throw new Error(`no transfer ${id} is waiting for you${all.emailUnverified ? " (your account's email is not verified)" : ""} (see deck transfers)`);
|
|
1436
|
+
}
|
|
1437
|
+
const speakers = (guests ?? []).map((p) => {
|
|
1438
|
+
const g = listed.guests[Number(p.guest) - 1];
|
|
1439
|
+
if (!g) throw new Error(`no guest ${p.guest} in ${listed.slug} (guests: 1..${listed.guests.length})`);
|
|
1440
|
+
if (!p.voice) throw new Error(`guest ${p.guest}: name a voice`);
|
|
1441
|
+
return { key: g.key, voice: String(p.voice), ...(p.avatar ? { avatar: String(p.avatar) } : {}) };
|
|
1442
|
+
});
|
|
1443
|
+
const narratorArg = narrator === undefined || narrator === null || narrator === "" ? undefined
|
|
1444
|
+
: ["none", "default", "dash"].includes(String(narrator).toLowerCase()) ? String(narrator).toLowerCase()
|
|
1445
|
+
: (await findNarrator(opts, narrator)).id;
|
|
1446
|
+
const out = await c.mutation(api.deckTransfers.accept, { id, ...(narratorArg !== undefined ? { narrator: narratorArg } : {}), speakers });
|
|
1447
|
+
const result = { ...out, narration: null };
|
|
1448
|
+
if (out.renarrate && renarrate) {
|
|
1449
|
+
try {
|
|
1450
|
+
const job = await narrateDeck(opts, { slug: out.slug, fromSource: true });
|
|
1451
|
+
result.narration = { jobId: job.jobId, note: "narrating the re-picked lines; then release the deck to put them on its links" };
|
|
1452
|
+
} catch (e) {
|
|
1453
|
+
result.narration = { error: errorText(e), note: "the deck is yours; narrate it again when this is fixed, then release" };
|
|
1454
|
+
}
|
|
1455
|
+
} else if (out.renarrate) {
|
|
1456
|
+
result.narration = { note: "narrate the deck, then release it, to put your voice and face on its links" };
|
|
1457
|
+
} else if (!out.hosted) {
|
|
1458
|
+
result.narration = { note: "this deck has no hosted script, so nothing was re-narrated" };
|
|
1459
|
+
}
|
|
1460
|
+
return result;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
export async function declineTransfer(opts, id) {
|
|
1464
|
+
if (!id) throw new Error("usage: deck transfers decline <id>");
|
|
1465
|
+
return (await client(opts)).mutation(api.deckTransfers.decline, { id });
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1362
1468
|
// The account's brand (convex/brand.ts): what new_deck / deck new start from.
|
|
1363
1469
|
export async function getBrand(opts = {}) {
|
|
1364
1470
|
return (await client(opts)).query(api.brand.get, {});
|
package/src/play.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withAvatarView } from "./avatar-view.mjs";
|
|
1
2
|
// hyperdecks play <slug|deck-dir> [--voice v] [--slug s] [--port n] [--no-open] [--json]
|
|
2
3
|
//
|
|
3
4
|
// Plays the working folder as it is, committed or not, the way it will play once
|
|
@@ -14,7 +15,7 @@ import { resolveDeckDir } from "./check.mjs";
|
|
|
14
15
|
import * as ops from "./ops.mjs";
|
|
15
16
|
import { playsCaptionsOnly, presenterLine, withNarrator } from "./presenter.mjs";
|
|
16
17
|
import { serveSource } from "./preview-capture.mjs";
|
|
17
|
-
import { openBrowser } from "./runtime.mjs";
|
|
18
|
+
import { openBrowser, client } from "./runtime.mjs";
|
|
18
19
|
|
|
19
20
|
const USAGE = "usage: play <slug|deck-dir> [--voice <name|id>] [--slug <slug>] [--port <n>] [--no-open]";
|
|
20
21
|
|
|
@@ -88,7 +89,8 @@ export async function startPlay(pos, opts = {}, { log = (line) => console.log(li
|
|
|
88
89
|
const html = readFileSync(file, "utf8");
|
|
89
90
|
|
|
90
91
|
const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
|
|
91
|
-
|
|
92
|
+
let page = withNarrator(html, presenter, who);
|
|
93
|
+
page = await withAvatarView({ mutation: async (ref, args) => (await client(opts)).mutation(ref, args) }, slug, page, "draft");
|
|
92
94
|
sayWhoPresents({ html, page, presenter, who, log });
|
|
93
95
|
// --voice names the voice it was narrated in, so there is nothing to compare it to.
|
|
94
96
|
if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });
|
package/src/preview-capture.mjs
CHANGED
|
@@ -255,11 +255,36 @@ async function encode(ffmpeg, args) {
|
|
|
255
255
|
try { await run(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-nostdin', ...args], { timeout: 90000, maxBuffer: 1024 * 1024, env: { PATH: dirname(ffmpeg), ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}) } }); }
|
|
256
256
|
catch (e) { throw new Error(`FFmpeg encoding failed (${e.killed ? '90s timeout' : 'check portable encoder installation'}); no global FFmpeg is used`); }
|
|
257
257
|
}
|
|
258
|
+
// The numbered frames in `dir` (frame-0000.png…) as the preview's PNG poster
|
|
259
|
+
// (the first frame), looping GIF and silent MP4 in `out`.
|
|
260
|
+
async function encodeOutputs(ffmpeg, { dir, out, width, height, fps, duration }) {
|
|
261
|
+
const input = ['-framerate', String(fps), '-i', join(dir, 'frame-%04d.png')];
|
|
262
|
+
const paths = Object.fromEntries(['png','gif','mp4'].map(t => [t, resolve(out, `preview.${t}`)]));
|
|
263
|
+
await encode(ffmpeg, ['-i', join(dir,'frame-0000.png'), '-vf', `scale=${width}:${height}`, '-frames:v','1', paths.png]);
|
|
264
|
+
await encode(ffmpeg, [...input, '-filter_complex', `scale=${width}:${height}:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse`, '-loop','0', paths.gif]);
|
|
265
|
+
await encode(ffmpeg, [...input, '-vf',`scale=${width}:${height}`, '-an','-c:v','libx264','-pix_fmt','yuv420p','-movflags','+faststart',paths.mp4]);
|
|
266
|
+
return Promise.all(['gif','png','mp4'].map(async type => ({ type, path: paths[type], contentType: type === 'mp4' ? 'video/mp4' : `image/${type}`, bytes: (await stat(paths[type])).size, width, height, ...(type === 'png' ? {} : { duration, fps, silent:true, ...(type === 'gif' ? { loop:0 } : {}) }) })));
|
|
267
|
+
}
|
|
268
|
+
// Even output dimensions for `width` at `aspect`, as the encoders need them.
|
|
269
|
+
const outputSize = (width, aspect) => { const w = width % 2 ? width + 1 : width; return { width: w, height: Math.round(w / aspect / 2) * 2 }; };
|
|
270
|
+
|
|
271
|
+
async function captureMoment(config, chromium, ffmpeg) {
|
|
272
|
+
const { recordMoment, withTempDir } = await import('./preview-moment.mjs');
|
|
273
|
+
const fps = 10, frames = Math.round(config.duration * fps);
|
|
274
|
+
return withTempDir(async dir => {
|
|
275
|
+
const recorded = await recordMoment({ ...config, frames, fps, playerDir: config.playerDir ?? process.env.HYPERDECKS_PLAYER_DIR ?? null }, { chromium, serveSource, dir });
|
|
276
|
+
const files = await encodeOutputs(ffmpeg, { dir, out: config.out, ...outputSize(config.width, recorded.aspect), fps, duration: frames / fps });
|
|
277
|
+
return { files, warnings: recorded.warnings, moment: recorded.moment };
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
258
281
|
export async function capturePreview(config) {
|
|
259
282
|
const { chromium } = await import('playwright');
|
|
260
283
|
let ffmpeg;
|
|
261
284
|
try { ffmpeg = (await import('@ffmpeg-installer/ffmpeg')).default.path; await run(ffmpeg, ['-version'], { timeout:10000 }); }
|
|
262
285
|
catch { throw new Error('Portable FFmpeg missing. Reinstall the CLI with optional dependencies and install scripts enabled (`npm install -g hyperdecks --include=optional`); pnpm users must approve @ffmpeg-installer platform build scripts.'); }
|
|
286
|
+
// --scene / --start: one moment of the narration, on the page clock (preview-moment.mjs).
|
|
287
|
+
if (config.scene != null || config.start != null) return captureMoment(config, chromium, ffmpeg);
|
|
263
288
|
let browser, server, temp, deadline, timedOut = false, phase = 'launch';
|
|
264
289
|
const warnings = [];
|
|
265
290
|
try {
|
|
@@ -309,8 +334,7 @@ export async function capturePreview(config) {
|
|
|
309
334
|
else if (busy > 1) warnings.push(`The deck kept its main thread busy for ${busy.toFixed(1)}s; script-driven motion may look choppy.`);
|
|
310
335
|
if (!config.aspect && !detected) warnings.push('Using 16:9 aspect ratio; use --aspect-ratio W:H or preview-aspect-ratio metadata for a different deck shape.');
|
|
311
336
|
const aspect = config.aspect ?? (detected >= 0.5 && detected <= 3 ? detected : 16/9);
|
|
312
|
-
const
|
|
313
|
-
const height = Math.round(width / aspect / 2) * 2;
|
|
337
|
+
const { width, height } = outputSize(config.width, aspect);
|
|
314
338
|
await page.setViewportSize({ width:1280, height:Math.round(1280/aspect) });
|
|
315
339
|
const fps = 10, frames = Math.round(config.duration * fps), duration = frames/fps;
|
|
316
340
|
// Screencast captures the live WebGL compositor and CSS motion without
|
|
@@ -339,14 +363,9 @@ export async function capturePreview(config) {
|
|
|
339
363
|
await context.close();
|
|
340
364
|
clearTimeout(deadline);
|
|
341
365
|
phase = 'encode';
|
|
342
|
-
const
|
|
343
|
-
const paths = Object.fromEntries(['png','gif','mp4'].map(t => [t, resolve(config.out, `preview.${t}`)]));
|
|
344
|
-
await encode(ffmpeg, ['-i', join(temp,'frame-0000.png'), '-vf', `scale=${width}:${height}`, '-frames:v','1', paths.png]);
|
|
345
|
-
await encode(ffmpeg, [...input, '-filter_complex', `scale=${width}:${height}:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse`, '-loop','0', paths.gif]);
|
|
346
|
-
await encode(ffmpeg, [...input, '-vf',`scale=${width}:${height}`, '-an','-c:v','libx264','-pix_fmt','yuv420p','-movflags','+faststart',paths.mp4]);
|
|
366
|
+
const files = await encodeOutputs(ffmpeg, { dir: temp, out: config.out, width, height, fps, duration });
|
|
347
367
|
if (blocked) warnings.push(`${blocked} navigation/telemetry/unsafe request(s) blocked; no recipient page was visited.`);
|
|
348
368
|
if (failed) warnings.push(`${failed} page/resource error(s); inspect the poster and increase --delay if necessary.`);
|
|
349
|
-
const files = await Promise.all(['gif','png','mp4'].map(async type => ({ type, path: paths[type], contentType: type === 'mp4' ? 'video/mp4' : `image/${type}`, bytes: (await stat(paths[type])).size, width, height, ...(type === 'png' ? {} : { duration, fps, silent:true, ...(type === 'gif' ? { loop:0 } : {}) }) })));
|
|
350
369
|
return { files, warnings };
|
|
351
370
|
} catch (e) { throw new Error(`Capture failed during ${phase}: ${timedOut ? '60s capture timeout; the renderer did not respond' : /Timeout/i.test(e.name) ? 'bounded capture timeout; inspect the deck or increase --delay' : e.message}`); }
|
|
352
371
|
finally {
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// `preview create --scene <id> --start <seconds>`: the email GIF of one moment
|
|
2
|
+
// of a narrated deck, not its opening.
|
|
3
|
+
//
|
|
4
|
+
// The ordinary preview (preview-capture.mjs capturePreview) opens the deck as
|
|
5
|
+
// a recipient would and screencasts it in real time; its narration never
|
|
6
|
+
// starts, so it can only show the opening. This one records the way the video
|
|
7
|
+
// export does (export-video.mjs): the player's export mode (?export=1: no
|
|
8
|
+
// chrome, plays without a tap, narration on a silent clock media element) on
|
|
9
|
+
// a page clock the recorder steps (frame-clock.mjs). The deck opens on the
|
|
10
|
+
// chosen slide (window.__hdExportFrom, export-mode.js), the clock is stepped
|
|
11
|
+
// `start` seconds into its narration, so every cue, guest line, caption
|
|
12
|
+
// bubble and mouth shape that would have happened by then has happened, and
|
|
13
|
+
// then each GIF frame is the clock advanced 1/fps and a screenshot taken.
|
|
14
|
+
// Nothing plays aloud; no view is counted (the capture request rules refuse
|
|
15
|
+
// /v/ and telemetry, and export mode sends no engagement events).
|
|
16
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { extname, join } from 'node:path';
|
|
19
|
+
import { installFrameClock } from './frame-clock.mjs';
|
|
20
|
+
import { exportPageUrl, launchRecorder, planTimeline } from './export-video.mjs';
|
|
21
|
+
import { recorderArgs, routeCaptureRequest } from './preview-capture.mjs';
|
|
22
|
+
|
|
23
|
+
// Page-clock milliseconds per step while seeking to `start`: timers fire in
|
|
24
|
+
// order within a step, so a coarser step only means fewer animation frames
|
|
25
|
+
// drawn on the way. The GIF's own frames are 1/fps apart.
|
|
26
|
+
const SEEK_STEP_MS = 100;
|
|
27
|
+
// Real milliseconds the deck may take to become ready (the head loads in it).
|
|
28
|
+
const READY_MS = 60000;
|
|
29
|
+
// Real milliseconds a slide's heads may take to finish loading once on it.
|
|
30
|
+
const HEADS_MS = 15000;
|
|
31
|
+
|
|
32
|
+
// Where to start: the slide (`scene`, the first slide when omitted) and the
|
|
33
|
+
// seconds into its narration, checked against the player's own plan
|
|
34
|
+
// ([{ id, duration, hold }], the manifest's clip length plus its hold).
|
|
35
|
+
export function seekTarget(plan, { scene, start = 0 } = {}) {
|
|
36
|
+
const timeline = planTimeline(plan ?? []);
|
|
37
|
+
if (!timeline.ids.length) throw new Error('the deck has no slides');
|
|
38
|
+
const index = scene == null ? 0 : timeline.ids.indexOf(scene);
|
|
39
|
+
if (index < 0) throw new Error(`Unknown scene "${scene}". This deck's slides: ${timeline.ids.join(', ')}`);
|
|
40
|
+
const id = timeline.ids[index], seconds = timeline.seconds[index], entry = plan[index];
|
|
41
|
+
// A clip whose length the plan doesn't know (a data-clip deck's): its start can't be bounded here.
|
|
42
|
+
const known = !entry.audio || entry.duration > 0;
|
|
43
|
+
if (known && start > 0 && !(start < seconds)) {
|
|
44
|
+
throw new Error(seconds > 0
|
|
45
|
+
? `--start ${start} is past the end of slide ${id}: it runs ${seconds.toFixed(1)}s (${entry.audio ? 'its narration plus its hold' : 'its hold; it has no narration'}), so pick 0 to ${Math.max(0, seconds - 0.1).toFixed(1)}`
|
|
46
|
+
: `--start ${start}: slide ${id} has no narration to start into; use --start 0`);
|
|
47
|
+
}
|
|
48
|
+
return { index, id, start, seconds, narrated: !!entry.audio };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const within = async (promise, ms, fallback) => {
|
|
52
|
+
promise.catch(() => {});
|
|
53
|
+
let timer;
|
|
54
|
+
try { return await Promise.race([promise, new Promise((ok) => { timer = setTimeout(() => ok(fallback), ms); })]); }
|
|
55
|
+
finally { clearTimeout(timer); }
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// What the first frame shows, read from the page: the slide the player is on,
|
|
59
|
+
// the guest speaking and its caption bubble's words (null when none), and the
|
|
60
|
+
// state of each character head on the slide (head-slots.js data-hd-head-state).
|
|
61
|
+
function readMoment() {
|
|
62
|
+
const log = window.__hdExport;
|
|
63
|
+
const slide = log?.slides?.at(-1)?.id ?? null;
|
|
64
|
+
const bubble = document.querySelector('.hd-guest-bubble:not([hidden])');
|
|
65
|
+
const heads = [...(document.getElementById(slide)?.querySelectorAll('[data-hd-head-state]') ?? [])].map((e) => e.dataset.hdHeadState);
|
|
66
|
+
return {
|
|
67
|
+
slide,
|
|
68
|
+
// Every slide the player showed, in order: the scene alone when it opened there.
|
|
69
|
+
visited: (log?.slides ?? []).map((s) => s.id),
|
|
70
|
+
guest: bubble ? { speaker: bubble.dataset.speaker ?? null, caption: bubble.textContent.trim() } : null,
|
|
71
|
+
caption: document.querySelector('.hd-caption')?.textContent.trim() || null,
|
|
72
|
+
heads,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Records `frames` PNGs (frame-0000.png…) into `dir` and returns
|
|
77
|
+
// { aspect, moment, warnings }. config: { html | file, scene, start, frames,
|
|
78
|
+
// fps, delay, label, aspect, firstPartyOrigins, playerDir }.
|
|
79
|
+
export async function recordMoment(config, { chromium, serveSource, dir }) {
|
|
80
|
+
const warnings = [];
|
|
81
|
+
let browser, server, phase = 'launch';
|
|
82
|
+
try {
|
|
83
|
+
browser = await launchRecorder(chromium, {
|
|
84
|
+
headless: true, timeout: 30000, chromiumSandbox: config.sandbox ?? process.platform === 'linux',
|
|
85
|
+
args: ['--autoplay-policy=no-user-gesture-required', '--mute-audio', ...recorderArgs()],
|
|
86
|
+
env: { ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: dir, HOME: dir },
|
|
87
|
+
});
|
|
88
|
+
server = await serveSource(config);
|
|
89
|
+
const pageUrl = exportPageUrl(server.url);
|
|
90
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1, serviceWorkers: 'block', acceptDownloads: false });
|
|
91
|
+
let blocked = 0;
|
|
92
|
+
await context.route('**/*', async (route) => {
|
|
93
|
+
if (!await routeCaptureRequest(route, pageUrl, config.firstPartyOrigins)) blocked++;
|
|
94
|
+
});
|
|
95
|
+
// A development checkout (or a test) plays its own player/v1, as video export does.
|
|
96
|
+
const missingPlayer = [];
|
|
97
|
+
if (config.playerDir) {
|
|
98
|
+
await context.route(/\/player\/v1\/[^?#]+/, async (route) => {
|
|
99
|
+
const name = new URL(route.request().url()).pathname.replace(/^.*\/player\/v1\/(b\/[^/]+\/)?/, '');
|
|
100
|
+
if (name.split('/').some((s) => s === '..' || s.startsWith('.'))) return route.abort();
|
|
101
|
+
const type = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png' }[extname(name)] ?? 'application/octet-stream';
|
|
102
|
+
try { await route.fulfill({ body: await readFile(join(config.playerDir, name)), contentType: type }); }
|
|
103
|
+
catch { missingPlayer.push(name); await route.abort(); }
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
await context.routeWebSocket('**/*', (ws) => ws.close());
|
|
107
|
+
await context.addInitScript({ content: `(${installFrameClock.toString()})(window);` });
|
|
108
|
+
// The player opens on __hdExportFrom, read once its slides are in the page:
|
|
109
|
+
// the scene's index among them, as the player lists them (section.slide in order).
|
|
110
|
+
// An unknown scene opens the first slide; seekTarget then refuses it by name.
|
|
111
|
+
await context.addInitScript((scene) => {
|
|
112
|
+
navigator.sendBeacon = () => false;
|
|
113
|
+
window.open = () => null;
|
|
114
|
+
window.__hdExportWait = new Promise((go) => { window.__hdExportGo = go; });
|
|
115
|
+
if (scene == null) return;
|
|
116
|
+
Object.defineProperty(window, '__hdExportFrom', { configurable: true, get: () => Math.max(0, [...document.querySelectorAll('section.slide')].map((s) => s.id).indexOf(scene)) });
|
|
117
|
+
}, config.scene ?? null);
|
|
118
|
+
const page = await context.newPage();
|
|
119
|
+
page.setDefaultTimeout(30000);
|
|
120
|
+
const pageErrors = [];
|
|
121
|
+
page.on('pageerror', (e) => pageErrors.push(String(e?.message ?? e)));
|
|
122
|
+
// Every call into the page is bounded: a deck that holds its main thread fails, it doesn't hang the CLI.
|
|
123
|
+
const ask = async (fn, arg, what) => {
|
|
124
|
+
const none = Symbol('timeout');
|
|
125
|
+
const answer = await within(page.evaluate(fn, arg), 30000, none);
|
|
126
|
+
if (answer === none) throw new Error(`the page stopped answering ${what} (over 30s)`);
|
|
127
|
+
return answer;
|
|
128
|
+
};
|
|
129
|
+
const step = (ms, what) => ask(async (ms) => { window.__hdFrameClock.step(ms); await window.__hdFrameClock.settle(); return window.__hdExport; }, ms, what);
|
|
130
|
+
phase = 'page load';
|
|
131
|
+
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
132
|
+
phase = 'player ready';
|
|
133
|
+
await page.waitForSelector('body[data-hd-ready="true"]', { timeout: READY_MS }).catch(() => {
|
|
134
|
+
const missing = missingPlayer.length ? ` (missing from the local player: ${missingPlayer.slice(0, 3).join(', ')})` : '';
|
|
135
|
+
throw new Error(`the deck was not ready after ${READY_MS / 1000}s${missing}; --scene and --start need a deck on player/v1 (https://hyperdecks.ai/player/v1/player.js)`);
|
|
136
|
+
});
|
|
137
|
+
const state = await ask(() => (window.__hdExport ? JSON.parse(JSON.stringify(window.__hdExport)) : null), undefined, 'for its export log');
|
|
138
|
+
if (!state) throw new Error('--scene and --start need a deck on player/v1 (https://hyperdecks.ai/player/v1/player.js); this deck has its own runtime');
|
|
139
|
+
if (state.manifestError) throw new Error(`the deck's narration manifest could not load (${state.manifestError.url}: ${state.manifestError.message})`);
|
|
140
|
+
if (!(state.version >= 3 && state.clock === 'page' && state.range === true)) throw new Error('the deck\'s player is too old to open on a slide; reload https://hyperdecks.ai/player/v1/player.js, or update the deck\'s copy');
|
|
141
|
+
phase = 'scene check';
|
|
142
|
+
const target = seekTarget(state.plan, { scene: config.scene, start: config.start ?? 0 });
|
|
143
|
+
if (state.silent && target.start > 0) throw new Error('--start needs a narrated deck: this one has no narration to start into (run `hyperdecks narrate`)');
|
|
144
|
+
const duration = config.frames / config.fps;
|
|
145
|
+
if (!state.silent && target.start + duration > target.seconds + 0.05) warnings.push(`slide ${target.id} ends ${Math.max(0, target.seconds - target.start).toFixed(1)}s after --start; the rest of the ${duration}s preview shows the deck moving on`);
|
|
146
|
+
const face = state.presenter;
|
|
147
|
+
if (face?.wanted === 'head' && face.shown !== 'head') warnings.push(`the deck's live head did not load${face.why ? ` (${face.why})` : ''}; the preview shows ${face.shown === 'static' ? 'its still image' : 'captions only'}`);
|
|
148
|
+
|
|
149
|
+
phase = 'layout probe';
|
|
150
|
+
// The deck's own shape, as the ordinary preview reads it (preview-capture.mjs), unless --aspect-ratio names one.
|
|
151
|
+
let aspect = config.aspect;
|
|
152
|
+
if (!aspect) {
|
|
153
|
+
const detected = await ask(() => {
|
|
154
|
+
const meta = document.querySelector('meta[name="preview-aspect-ratio"]')?.getAttribute('content');
|
|
155
|
+
if (meta && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(meta)) { const [w, h] = meta.split(':').map(Number); return w / h; }
|
|
156
|
+
const rect = document.querySelector('section.slide')?.getBoundingClientRect();
|
|
157
|
+
return rect && rect.width > 100 && rect.height > 100 ? rect.width / rect.height : null;
|
|
158
|
+
}, undefined, 'to the layout probe');
|
|
159
|
+
aspect = detected >= 0.5 && detected <= 3 ? detected : 16 / 9;
|
|
160
|
+
if (aspect !== detected) warnings.push('Using 16:9 aspect ratio; use --aspect-ratio W:H or preview-aspect-ratio metadata for a different deck shape.');
|
|
161
|
+
}
|
|
162
|
+
if (Math.abs(aspect - 16 / 9) > 0.001) {
|
|
163
|
+
await page.setViewportSize({ width: 1280, height: Math.round(1280 / aspect) });
|
|
164
|
+
await page.waitForTimeout(200); // the player refits on resize; the clock still runs in real time
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The slide's heads load before the tour starts: once it runs, the player gives late guest heads
|
|
168
|
+
// a budget on the page clock (player.js holdForGuestHeads), which the seek spends in moments.
|
|
169
|
+
// Polled from here: in-page waits run on timers and rAF, which the frozen clock holds.
|
|
170
|
+
const headsSettled = async () => {
|
|
171
|
+
for (const until = Date.now() + HEADS_MS; Date.now() < until; await new Promise((ok) => setTimeout(ok, 250))) {
|
|
172
|
+
const loading = await ask((id) => [...(document.getElementById(id)?.querySelectorAll('[data-hd-head-state="loading"]') ?? [])].length, target.id, 'while heads loaded');
|
|
173
|
+
if (!loading) return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
};
|
|
177
|
+
phase = 'heads';
|
|
178
|
+
await headsSettled();
|
|
179
|
+
|
|
180
|
+
phase = 'seek';
|
|
181
|
+
// Clip lengths come from the manifest (player.js clipLength), as a recipient's <audio> would roughly end them.
|
|
182
|
+
await ask(async () => {
|
|
183
|
+
window.__hdFrameClock.freeze();
|
|
184
|
+
window.__hdExportGo();
|
|
185
|
+
await window.__hdFrameClock.settle();
|
|
186
|
+
}, undefined, 'as the tour started');
|
|
187
|
+
const failure = (l) => l.errors?.length && `narration failed on ${l.errors[0].id} (${l.errors[0].message})`;
|
|
188
|
+
// The start counts from when the slide's own clip plays (a clip that loads late starts late), or,
|
|
189
|
+
// for a slide with no narration of its own, from when it lands.
|
|
190
|
+
const arrived = (l) => l.silent || (target.narrated ? l.clips.some((c) => c.id === target.id && !c.audio) : l.slides.some((s) => s.id === target.id));
|
|
191
|
+
let log = await ask(() => window.__hdExport, undefined, 'for its export log');
|
|
192
|
+
for (let n = 0; !arrived(log); n++) {
|
|
193
|
+
if (failure(log)) throw new Error(failure(log));
|
|
194
|
+
if (n === 600) throw new Error(`slide ${target.id}'s narration did not start within 60s of the deck's time`);
|
|
195
|
+
log = await step(SEEK_STEP_MS, 'while the tour started');
|
|
196
|
+
}
|
|
197
|
+
for (let n = Math.round((target.start * 1000) / SEEK_STEP_MS); n > 0; n--) log = await step(SEEK_STEP_MS, 'while seeking');
|
|
198
|
+
if (failure(log)) throw new Error(failure(log));
|
|
199
|
+
|
|
200
|
+
phase = 'heads';
|
|
201
|
+
// The clock is frozen, so waiting changes nothing on the slide: it lets heads and images finish loading.
|
|
202
|
+
const headsLoaded = await headsSettled();
|
|
203
|
+
if (!headsLoaded) warnings.push(`a character head on ${target.id} was still loading after ${HEADS_MS / 1000}s; it shows as a still or silhouette`);
|
|
204
|
+
phase = 'delay';
|
|
205
|
+
await page.waitForTimeout(config.delay);
|
|
206
|
+
await step(0, 'after the delay');
|
|
207
|
+
|
|
208
|
+
phase = 'label overlay';
|
|
209
|
+
await ask((label) => {
|
|
210
|
+
const badge = document.createElement('div');
|
|
211
|
+
badge.textContent = '▶ ' + label;
|
|
212
|
+
badge.setAttribute('style', 'all:initial;position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.85);color:white;font:600 25px sans-serif;padding:14px 24px;border-radius:40px;z-index:2147483647;white-space:nowrap;max-width:90%;overflow:hidden;text-overflow:ellipsis;pointer-events:none');
|
|
213
|
+
document.documentElement.appendChild(badge);
|
|
214
|
+
}, config.label, 'to the label overlay');
|
|
215
|
+
const moment = { scene: target.id, start: target.start, ...(await ask(readMoment, undefined, 'to the moment check')) };
|
|
216
|
+
if (moment.slide !== target.id) throw new Error(`the player showed ${moment.slide ?? 'no slide'}, not ${target.id}`);
|
|
217
|
+
const unready = moment.heads.filter((s) => s !== 'ready').length;
|
|
218
|
+
if (headsLoaded && unready) warnings.push(`${unready} character head(s) on ${target.id} are not live (${[...new Set(moment.heads.filter((s) => s !== 'ready'))].join(', ')}); they show as stills or silhouettes`);
|
|
219
|
+
|
|
220
|
+
phase = 'frames';
|
|
221
|
+
const session = await context.newCDPSession(page);
|
|
222
|
+
for (let n = 0; n < config.frames; n++) {
|
|
223
|
+
if (n) log = await step(1000 / config.fps, 'while recording (a frame took over 30s to draw)');
|
|
224
|
+
const shot = await within(session.send('Page.captureScreenshot', { format: 'png' }), 30000, null);
|
|
225
|
+
if (!shot?.data) throw new Error('a frame took over 30s to capture');
|
|
226
|
+
await writeFile(join(dir, `frame-${String(n).padStart(4, '0')}.png`), Buffer.from(shot.data, 'base64'));
|
|
227
|
+
}
|
|
228
|
+
if (failure(log)) warnings.push(`${failure(log)} while recording; the preview may stop moving there`);
|
|
229
|
+
if (blocked) warnings.push(`${blocked} navigation/telemetry/unsafe request(s) blocked; no recipient page was visited.`);
|
|
230
|
+
if (pageErrors.length) warnings.push(`${pageErrors.length} page error(s): ${pageErrors.slice(0, 2).join('; ').slice(0, 300)}`);
|
|
231
|
+
return { aspect, moment, warnings };
|
|
232
|
+
} catch (e) {
|
|
233
|
+
throw new Error(`Capture failed during ${phase}: ${e.message}`);
|
|
234
|
+
} finally {
|
|
235
|
+
if (browser) await browser.close().catch(() => {});
|
|
236
|
+
if (server) await server.close();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function withTempDir(fn) {
|
|
241
|
+
const dir = await mkdtemp(join(tmpdir(), 'hyperdecks-preview-'));
|
|
242
|
+
try { return await fn(dir); } finally { await rm(dir, { recursive: true, force: true }); }
|
|
243
|
+
}
|
package/src/preview.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withAvatarView } from "./avatar-view.mjs";
|
|
1
2
|
import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
|
|
2
3
|
import { resolve, extname } from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -15,7 +16,7 @@ function bounded(value, fallback, min, max, name, integer = false) {
|
|
|
15
16
|
return n;
|
|
16
17
|
}
|
|
17
18
|
export function previewOptions(pos, opts) {
|
|
18
|
-
const allowed = new Set(['file', 'out', 'duration', 'width', 'delay', 'label', 'aspect-ratio', 'share', 'upload', 'json', 'prod', 'dev', 'admin', 'broker', 'convex', 'token', 'client']);
|
|
19
|
+
const allowed = new Set(['file', 'out', 'duration', 'width', 'delay', 'label', 'aspect-ratio', 'share', 'upload', 'scene', 'start', 'json', 'prod', 'dev', 'admin', 'broker', 'convex', 'token', 'client']);
|
|
19
20
|
for (const k of Object.keys(opts)) if (!allowed.has(k)) throw new Error(`Unknown preview option --${k}`);
|
|
20
21
|
for (const [k, v] of Object.entries(opts)) if (v === undefined) throw new Error(`--${k} requires a value`);
|
|
21
22
|
if (pos.length > 1 || (opts.file ? pos.length !== 0 : pos.length !== 1)) throw new Error("usage: preview create <slug> --out <new-directory> OR preview create --file <index.html> --out <new-directory>");
|
|
@@ -33,7 +34,13 @@ export function previewOptions(pos, opts) {
|
|
|
33
34
|
aspect = m ? Number(m[1]) / Number(m[2]) : NaN;
|
|
34
35
|
bounded(aspect, null, 0.5, 3, '--aspect-ratio');
|
|
35
36
|
}
|
|
36
|
-
|
|
37
|
+
// --scene <slide-id> / --start <seconds>: a moment of the narration, not the opening (preview-moment.mjs).
|
|
38
|
+
// The id is checked against the deck's slides, and the start against that slide's length, once it loads.
|
|
39
|
+
if (opts.scene !== undefined && (typeof opts.scene !== 'string' || !opts.scene.trim() || opts.scene.length > 200 || /[\x00-\x1f]/.test(opts.scene))) throw new Error('--scene must be a slide id (the id of its <section class="slide">)');
|
|
40
|
+
const moment = opts.scene !== undefined || opts.start !== undefined
|
|
41
|
+
? { scene: opts.scene ?? null, start: bounded(opts.start, 0, 0, 3600, '--start (seconds into the slide\'s narration)') }
|
|
42
|
+
: {};
|
|
43
|
+
return { slug: pos[0], file: opts.file && resolve(opts.file), out: resolve(opts.out), duration: bounded(opts.duration, 4, 1, 10, '--duration'), width: bounded(opts.width, 600, 240, 1200, '--width', true), delay: bounded(opts.delay, 1000, 0, 10000, '--delay', true), aspect, label, ...moment };
|
|
37
44
|
}
|
|
38
45
|
export function validateShare(rows, slug, token, now = Date.now()) {
|
|
39
46
|
const row = rows.find(s => s.token === token && s.deckSlug === slug);
|
|
@@ -67,6 +74,7 @@ export async function createPreview(pos, opts, adapters = {}) {
|
|
|
67
74
|
html = await res.text();
|
|
68
75
|
}
|
|
69
76
|
if (!html) throw new Error('Deck has no HTML to capture');
|
|
77
|
+
html = await withAvatarView(c, config.slug, html, 'released');
|
|
70
78
|
}
|
|
71
79
|
// A new directory prevents replacing artifacts referenced by an earlier manifest.
|
|
72
80
|
await mkdir(config.out, { recursive: false });
|
|
@@ -78,6 +86,8 @@ export async function createPreview(pos, opts, adapters = {}) {
|
|
|
78
86
|
const capture = adapters.capture || (await import('./preview-capture.mjs')).capturePreview;
|
|
79
87
|
const captured = await capture({ ...config, html, firstPartyOrigins: [targetForCapture(opts), 'https://hyperdecks.ai', 'https://decks.hypersocial.ai', 'https://dashing-snail-874.convex.cloud'] });
|
|
80
88
|
result.files = captured.files;
|
|
89
|
+
// What the first frame shows (--scene/--start): the slide, the speaking guest and its caption.
|
|
90
|
+
if (captured.moment) result.moment = captured.moment;
|
|
81
91
|
warnings.push(...captured.warnings);
|
|
82
92
|
for (const f of result.files) if (f.bytes > 5 * 1024 * 1024) warnings.push(`${f.type} exceeds 5 MiB; shorten --duration or reduce --width for email`);
|
|
83
93
|
result.status = 'exported';
|
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.9.
|
|
11
|
+
export const VERSION = "0.9.12";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
14
|
// The app's home. HYPERDECKS_HOST points the prod target's broker and share
|
|
@@ -180,6 +180,13 @@ export const api = {
|
|
|
180
180
|
getJob: makeFunctionReference("narration:getJob"),
|
|
181
181
|
listVoices: makeFunctionReference("narration:listVoices"),
|
|
182
182
|
},
|
|
183
|
+
deckTransfers: {
|
|
184
|
+
create: makeFunctionReference("deckTransfers:create"),
|
|
185
|
+
cancel: makeFunctionReference("deckTransfers:cancel"),
|
|
186
|
+
list: makeFunctionReference("deckTransfers:list"),
|
|
187
|
+
accept: makeFunctionReference("deckTransfers:accept"),
|
|
188
|
+
decline: makeFunctionReference("deckTransfers:decline"),
|
|
189
|
+
},
|
|
183
190
|
voices: {
|
|
184
191
|
listVoices: makeFunctionReference("voices:listVoices"),
|
|
185
192
|
getVoice: makeFunctionReference("voices:getVoice"),
|
|
@@ -194,7 +201,7 @@ export const api = {
|
|
|
194
201
|
},
|
|
195
202
|
};
|
|
196
203
|
|
|
197
|
-
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", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card", "backup"]);
|
|
204
|
+
const BOOL = new Set(["private-only", "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", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-narrate", "no-open", "save-to-minimoji", "captions-only", "no-brand", "brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card", "backup"]);
|
|
198
205
|
// release's recipients, in order: each --to starts one, and an --email right
|
|
199
206
|
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
200
207
|
// opts.email stays the last --email for the other commands (share new).
|
|
@@ -223,7 +230,7 @@ export function parse(args) {
|
|
|
223
230
|
else {
|
|
224
231
|
const v = args[++i];
|
|
225
232
|
if (k === "to" || (k === "email" && opts.recipients)) addRecipient(opts, k, v);
|
|
226
|
-
if (k === "agent" && opts[k]) opts[k] = `${opts[k]},${v}`;
|
|
233
|
+
if ((k === "agent" || k === "guest") && opts[k]) opts[k] = `${opts[k]},${v}`;
|
|
227
234
|
else opts[k] = v;
|
|
228
235
|
}
|
|
229
236
|
} else {
|