@hypersocial/decks-cli 0.1.8 → 0.1.10
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 +50 -10
- package/package.json +4 -2
- package/skills/hyperdecks/SKILL.md +23 -0
- package/src/commands.mjs +75 -8
- package/src/ops.mjs +52 -0
- package/src/preview-capture.mjs +194 -0
- package/src/preview.mjs +117 -0
- package/src/runtime.mjs +9 -2
package/README.md
CHANGED
|
@@ -5,25 +5,25 @@ Command-line client for Hypersocial Decks live analytics and share management.
|
|
|
5
5
|
Most users should install the friendlier package name:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
8
|
+
npx hyperdecks whoami
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
This package targets production by default:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
npx @hypersocial/decks-cli login
|
|
15
|
+
npx @hypersocial/decks-cli whoami
|
|
16
|
+
npx @hypersocial/decks-cli deck list
|
|
17
|
+
npx @hypersocial/decks-cli deck stats netflix
|
|
18
|
+
npx @hypersocial/decks-cli activity --limit 20
|
|
19
|
+
npx @hypersocial/decks-cli import ./claude-design-export.zip --slug acme-pitch
|
|
20
|
+
npx @hypersocial/decks-cli skills install
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
Global install:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
|
|
26
|
+
npm install -g @hypersocial/decks-cli
|
|
27
27
|
decks login
|
|
28
28
|
decks activity
|
|
29
29
|
```
|
|
@@ -55,9 +55,10 @@ deck list [--json]
|
|
|
55
55
|
deck stats <slug> [--json]
|
|
56
56
|
deck open <slug>
|
|
57
57
|
|
|
58
|
-
share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n]
|
|
58
|
+
share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify]
|
|
59
59
|
share list <slug> [--json]
|
|
60
60
|
share on|off <token>
|
|
61
|
+
share mute|unmute <token>
|
|
61
62
|
share rm <token>
|
|
62
63
|
share url <token>
|
|
63
64
|
|
|
@@ -115,3 +116,42 @@ The same import is exposed to agents via the `hypersocial-decks` MCP server as t
|
|
|
115
116
|
```
|
|
116
117
|
|
|
117
118
|
It authenticates with the stored CLI credential — run `decks login --prod` first.
|
|
119
|
+
|
|
120
|
+
## QA and narration (0.1.10)
|
|
121
|
+
|
|
122
|
+
`share new <slug> "QA" --qa` serves the full deck runtime but is completely
|
|
123
|
+
untracked and always muted. `share new <slug> "Telemetry test" --no-notify`
|
|
124
|
+
creates a tracked muted link; use it only on an isolated test deck for telemetry
|
|
125
|
+
QA. `share mute <token>` and `share unmute <token>` are owner-only; QA links
|
|
126
|
+
cannot be unmuted. Statistics distinguish narration started, narration finished,
|
|
127
|
+
narration not started, and unknown legacy playback. Closing a deck does not
|
|
128
|
+
establish completion. See the main repository README for the runtime event contract.
|
|
129
|
+
|
|
130
|
+
Publishing 0.1.10 is a separate coordinated step after backend/frontend rollout;
|
|
131
|
+
this source change prepares packages and does not publish them.
|
|
132
|
+
|
|
133
|
+
Existing published 0.1.9 supports production login, import and ordinary share
|
|
134
|
+
creation; publishing 0.1.10 is not required for those flows.
|
|
135
|
+
|
|
136
|
+
## Animated email previews (prepared 0.1.10; publication pending)
|
|
137
|
+
|
|
138
|
+
This command is implemented in the prepared CLI source. npm publication, deployment of the owner-only `previews:get` Convex query, and upgrading a global CLI are coordinated release steps still pending. Local-file export needs no login or backend deployment. Both `hyperdecks` and `decks` accept the same command; the canonical package is `@hypersocial/decks-cli`.
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
hyperdecks preview setup
|
|
142
|
+
hyperdecks preview create --file ./presentation/index.html --out ./preview-local --json
|
|
143
|
+
hyperdecks share list my-deck --json
|
|
144
|
+
hyperdecks preview create my-deck --share EXISTING_TOKEN --out ./preview-email --upload --json
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Generate locally, select an existing same-deck recipient share, then explicitly upload into a **new** output directory. The command never creates or visits a share. Upload requires an owned deck slug; `--file` with `--upload` or `--share` is rejected. Disabled, expired, exhausted and mismatched shares fail before upload. QA shares are supported intentionally: their click-through remains untracked and muted, so use a regular recipient share for measured outreach.
|
|
148
|
+
|
|
149
|
+
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.
|
|
150
|
+
|
|
151
|
+
`--json` prints one result on stdout; errors/progress use stderr and failures exit nonzero. `manifest.json` records source, status, absolute artifact paths, MIME types, bytes, dimensions, clip durations/fps, share URL, warnings and each completed upload. Files larger than 5 MiB warn: reduce duration/width for email. On upload failure local artifacts survive; inspect the manifest's URLs and `upload` records (an uploaded storage blob can exist even if saving its asset failed). Retry in a fresh directory; never overwrite an image already used in an email.
|
|
152
|
+
|
|
153
|
+
Only successful explicit uploads produce `email.html` and `email.sendable: true`, with the GIF wrapped in the exact recipient link. Use that HTML/GIF in your draft manually. Some email clients show only the first GIF frame: the PNG poster and the visible watch badge provide a static fallback. MP4 is for other channels; email video support varies. Local exports provide files to insert manually and a separate share URL when selected, with no claim of sendable remote-image HTML. Static image downloads do not open the deck, start narration, record engagement, or notify the owner.
|
|
154
|
+
|
|
155
|
+
Playwright and the platform FFmpeg executable are declared dependencies loaded only for preview capture; normal login/import/stats do not need capture prerequisites. `preview setup` installs the matching Chromium with no personal profile. On Linux, install Playwright's system libraries using `npx playwright@1.64.1 install-deps chromium` (may need administrator access), then run setup. FFmpeg comes from `@ffmpeg-installer/ffmpeg`, never a global/Homebrew executable. npm installs must include optional dependencies and allow install scripts; pnpm users must approve their `@ffmpeg-installer/<platform>` chmod script. Reinstall with `npm install -g hyperdecks --include=optional` if it is missing. Unsupported platforms fail with recovery guidance.
|
|
156
|
+
|
|
157
|
+
The local server exposes only supported static files within the explicit HTML file's directory; dotfiles, traversal and outside symlinks are denied. Keep capture inputs in a dedicated asset directory. Relative modules/fonts/video and external `<base>` URLs are retained. A fresh browser context receives no CLI credentials, cookies, profile or environment secrets. Recipient routes, first-party telemetry, non-GET requests, sockets and unrelated local services are blocked by URL and DNS checks that fail closed on lookup errors. Chromium resolves independently, so DNS rebinding is not fully prevented; use OS/network isolation for untrusted content. Video stays muted; the exporter does not click controls or start narration. Capture is bounded and temporary browser/server/frame files are removed on success/failure. Use only trusted deck content; third-party static resources may be fetched while rendering. HTTP redirects are refused before the browser follows them; use direct final asset URLs if a resource warns or fails to load.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypersocial/decks-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Command-line client for Hypersocial Decks live analytics and share management.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/commands.mjs",
|
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
"node": ">=20"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
33
34
|
"convex": "^1.41.0",
|
|
34
|
-
"fflate": "^0.8.2"
|
|
35
|
+
"fflate": "^0.8.2",
|
|
36
|
+
"playwright": "^1.64.1"
|
|
35
37
|
},
|
|
36
38
|
"publishConfig": {
|
|
37
39
|
"access": "public"
|
|
@@ -53,3 +53,26 @@ hyperdecks login
|
|
|
53
53
|
live changes, state the action and target clearly before executing when user
|
|
54
54
|
intent is ambiguous.
|
|
55
55
|
- Avoid printing full share tokens unless the user needs the exact URL/token.
|
|
56
|
+
|
|
57
|
+
## Animated email previews (prepared 0.1.10; publication pending)
|
|
58
|
+
|
|
59
|
+
This command is implemented in the prepared CLI source. npm publication, deployment of the owner-only `previews:get` Convex query, and upgrading a global CLI are coordinated release steps still pending. Local-file export needs no login or backend deployment. Both `hyperdecks` and `decks` accept the same command; the canonical package is `@hypersocial/decks-cli`.
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
hyperdecks preview setup
|
|
63
|
+
hyperdecks preview create --file ./presentation/index.html --out ./preview-local --json
|
|
64
|
+
hyperdecks share list my-deck --json
|
|
65
|
+
hyperdecks preview create my-deck --share EXISTING_TOKEN --out ./preview-email --upload --json
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Generate locally, select an existing same-deck recipient share, then explicitly upload into a **new** output directory. The command never creates or visits a share. Upload requires an owned deck slug; `--file` with `--upload` or `--share` is rejected. Disabled, expired, exhausted and mismatched shares fail before upload. QA shares are supported intentionally: their click-through remains untracked and muted, so use a regular recipient share for measured outreach.
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
`--json` prints one result on stdout; errors/progress use stderr and failures exit nonzero. `manifest.json` records source, status, absolute artifact paths, MIME types, bytes, dimensions, clip durations/fps, share URL, warnings and each completed upload. Files larger than 5 MiB warn: reduce duration/width for email. On upload failure local artifacts survive; inspect the manifest's URLs and `upload` records (an uploaded storage blob can exist even if saving its asset failed). Retry in a fresh directory; never overwrite an image already used in an email.
|
|
73
|
+
|
|
74
|
+
Only successful explicit uploads produce `email.html` and `email.sendable: true`, with the GIF wrapped in the exact recipient link. Use that HTML/GIF in your draft manually. Some email clients show only the first GIF frame: the PNG poster and the visible watch badge provide a static fallback. MP4 is for other channels; email video support varies. Local exports provide files to insert manually and a separate share URL when selected, with no claim of sendable remote-image HTML. Static image downloads do not open the deck, start narration, record engagement, or notify the owner.
|
|
75
|
+
|
|
76
|
+
Playwright and the platform FFmpeg executable are declared dependencies loaded only for preview capture; normal login/import/stats do not need capture prerequisites. `preview setup` installs the matching Chromium with no personal profile. On Linux, install Playwright's system libraries using `npx playwright@1.64.1 install-deps chromium` (may need administrator access), then run setup. FFmpeg comes from `@ffmpeg-installer/ffmpeg`, never a global/Homebrew executable. npm installs must include optional dependencies and allow install scripts; pnpm users must approve their `@ffmpeg-installer/<platform>` chmod script. Reinstall with `npm install -g hyperdecks --include=optional` if it is missing. Unsupported platforms fail with recovery guidance.
|
|
77
|
+
|
|
78
|
+
The local server exposes only supported static files within the explicit HTML file's directory; dotfiles, traversal and outside symlinks are denied. Keep capture inputs in a dedicated asset directory. Relative modules/fonts/video and external `<base>` URLs are retained. A fresh browser context receives no CLI credentials, cookies, profile or environment secrets. Recipient routes, first-party telemetry, non-GET requests, sockets and unrelated local services are blocked by URL and DNS checks that fail closed on lookup errors. Chromium resolves independently, so DNS rebinding is not fully prevented; use OS/network isolation for untrusted content. Video stays muted; the exporter does not click controls or start narration. Capture is bounded and temporary browser/server/frame files are removed on success/failure. Use only trusted deck content; third-party static resources may be fetched while rendering. HTTP redirects are refused before the browser follows them; use direct final asset URLs if a resource warns or fails to load.
|
package/src/commands.mjs
CHANGED
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
} from "./runtime.mjs";
|
|
11
11
|
import * as ops from "./ops.mjs";
|
|
12
12
|
|
|
13
|
+
export const playbackLabel = (p) => p.narrationFinished ? "narration finished"
|
|
14
|
+
: p.narrationStarted ? "narration started"
|
|
15
|
+
: p.telemetryVersion === 2 ? "narration not started" : "playback unknown (legacy)";
|
|
16
|
+
|
|
13
17
|
const fmtMs = (ms) => (ms >= 60000 ? `${(ms / 60000).toFixed(1)}m` : `${Math.round(ms / 1000)}s`);
|
|
14
18
|
|
|
15
19
|
async function whoamiCmd(opts) {
|
|
@@ -57,7 +61,7 @@ async function deckStats(pos, opts) {
|
|
|
57
61
|
console.log(" recent:");
|
|
58
62
|
for (const r of s.recent) {
|
|
59
63
|
const where = [r.city, r.country].filter(Boolean).join(", ");
|
|
60
|
-
console.log(` ${new Date(r.lastSeenAt).toISOString()}\t${r.recipient}\t${fmtMs(r.totalMs)}\tslide ${r.maxSlide}${r
|
|
64
|
+
console.log(` ${new Date(r.lastSeenAt).toISOString()}\t${r.recipient}\t${fmtMs(r.totalMs)}\tslide ${r.maxSlide} · ${playbackLabel(r)}\t${where}`);
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
}
|
|
@@ -83,13 +87,15 @@ function shareBase(opts) {
|
|
|
83
87
|
|
|
84
88
|
async function shareNew(pos, opts) {
|
|
85
89
|
const [slug, recipient] = pos;
|
|
86
|
-
if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n]");
|
|
90
|
+
if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify]");
|
|
87
91
|
const { token } = await ops.createShare(opts, {
|
|
88
92
|
slug,
|
|
89
93
|
recipient,
|
|
90
94
|
email: opts.email,
|
|
91
95
|
note: opts.note,
|
|
92
96
|
requireEmail: opts["require-email"],
|
|
97
|
+
qa: opts.qa || undefined,
|
|
98
|
+
notify: opts["no-notify"] ? false : undefined,
|
|
93
99
|
expires: opts.expires,
|
|
94
100
|
limit: opts.limit,
|
|
95
101
|
});
|
|
@@ -106,7 +112,7 @@ async function shareList(pos, opts) {
|
|
|
106
112
|
console.log(JSON.stringify(rows, null, 2));
|
|
107
113
|
return;
|
|
108
114
|
}
|
|
109
|
-
for (const r of rows) console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${r.recipient}\topens:${r.opens}${r.archived ? "\t(archived)" : ""}`);
|
|
115
|
+
for (const r of rows) console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${r.recipient}\topens:${r.opens}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.archived ? "\t(archived)" : ""}`);
|
|
110
116
|
if (!rows.length) console.log("(no shares)");
|
|
111
117
|
}
|
|
112
118
|
|
|
@@ -156,11 +162,26 @@ async function activity(pos, opts) {
|
|
|
156
162
|
console.log(`${when}\tsent\t${f.recipient}\t${f.deckTitle}`);
|
|
157
163
|
} else {
|
|
158
164
|
const where = [f.city, f.country].filter(Boolean).join(", ");
|
|
159
|
-
console.log(`${when}\topened\t${f.recipient}\t${f.deckTitle}\tslide ${f.maxSlide}\t${fmtMs(f.totalMs)}${f
|
|
165
|
+
console.log(`${when}\topened\t${f.recipient}\t${f.deckTitle}\tslide ${f.maxSlide}\t${fmtMs(f.totalMs)} · ${playbackLabel(f)}\t${where}`);
|
|
160
166
|
}
|
|
161
167
|
}
|
|
162
168
|
}
|
|
163
169
|
|
|
170
|
+
async function credits(pos, opts) {
|
|
171
|
+
const bal = await ops.creditsBalance(opts);
|
|
172
|
+
const ledger = await ops.creditsLedger(opts, opts.limit || 10);
|
|
173
|
+
if (opts.json) {
|
|
174
|
+
console.log(JSON.stringify({ ...bal, ledger }, null, 2));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
console.log(`credits: ${bal.balance}`);
|
|
178
|
+
for (const e of ledger) {
|
|
179
|
+
const when = new Date(e.at).toISOString().slice(0, 16).replace("T", " ");
|
|
180
|
+
console.log(` ${when} ${e.delta > 0 ? "+" : ""}${e.delta}\t${e.kind}\t${e.reason}`);
|
|
181
|
+
}
|
|
182
|
+
if (!ledger.length) console.log(" (no history yet)");
|
|
183
|
+
}
|
|
184
|
+
|
|
164
185
|
async function importDeck(pos, opts) {
|
|
165
186
|
const [src] = pos;
|
|
166
187
|
if (!src) {
|
|
@@ -220,13 +241,21 @@ commands:
|
|
|
220
241
|
logout [--dev]
|
|
221
242
|
whoami [--json] [--dev]
|
|
222
243
|
deck list|stats|open|rm
|
|
223
|
-
share new|list|on|off|rm|url
|
|
244
|
+
share new|list|on|off|mute|unmute|rm|url
|
|
245
|
+
share new <slug> <recipient> [--qa] [--no-notify]
|
|
224
246
|
recipient archive|unarchive
|
|
225
247
|
import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live]
|
|
226
248
|
import-design <project-id> --serve-token <token> --entry <file.html> --slug <slug>
|
|
227
249
|
skill install [--agent codex,claude-code] [--all-agents]
|
|
228
250
|
skills install [--agent codex,claude-code] [--all-agents]
|
|
229
251
|
activity [--deck <slug>] [--limit <n>] [--json]
|
|
252
|
+
gen-image "<prompt>" [--transparent] [--image <url>] [--size <s>] [--quality <q>] [--count <n>] [--slug <deck>] [--download <file>]
|
|
253
|
+
image-job <jobId> [--json]
|
|
254
|
+
credits [--limit <n>] [--json] show your credit balance and recent history
|
|
255
|
+
|
|
256
|
+
preview create <slug> --out <directory> [--share <token>] [--upload] [--json]
|
|
257
|
+
preview create --file <index.html> --out <directory> [--duration 4] [--width 600] [--delay 1000] [--label "Watch"] [--aspect-ratio 16:9]
|
|
258
|
+
preview setup install isolated Chromium (Linux: see README for system dependencies)
|
|
230
259
|
|
|
231
260
|
global:
|
|
232
261
|
--json
|
|
@@ -244,7 +273,7 @@ async function genImage(pos, opts) {
|
|
|
244
273
|
'usage: gen-image "<prompt>" [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--slug <deck>] [--name <name>] [--download <file>]',
|
|
245
274
|
);
|
|
246
275
|
}
|
|
247
|
-
|
|
276
|
+
let out = await ops.generateImage(opts, {
|
|
248
277
|
prompt,
|
|
249
278
|
image: opts.image,
|
|
250
279
|
transparent: opts.transparent,
|
|
@@ -255,6 +284,16 @@ async function genImage(pos, opts) {
|
|
|
255
284
|
slug: opts.slug,
|
|
256
285
|
name: opts.name,
|
|
257
286
|
});
|
|
287
|
+
// Slow requests (high/auto quality, transparent, count>1) come back as a job id;
|
|
288
|
+
// poll it so gen-image stays one blocking command. Progress prints to stderr so
|
|
289
|
+
// it doesn't pollute the URL output (or --json).
|
|
290
|
+
if (out.jobId) {
|
|
291
|
+
process.stderr.write(`queued ${out.count} image(s) — job ${out.jobId}\n`);
|
|
292
|
+
out = await ops.pollImageJob(opts, out.jobId, {
|
|
293
|
+
onProgress: (job) => process.stderr.write(` ${job.progress} (${job.status})…\r`),
|
|
294
|
+
});
|
|
295
|
+
process.stderr.write("\n");
|
|
296
|
+
}
|
|
258
297
|
const images = out.images || [];
|
|
259
298
|
if (opts.download && images.length) {
|
|
260
299
|
for (let i = 0; i < images.length; i++) {
|
|
@@ -267,9 +306,24 @@ async function genImage(pos, opts) {
|
|
|
267
306
|
if (opts.json) {
|
|
268
307
|
console.log(JSON.stringify(out, null, 2));
|
|
269
308
|
} else {
|
|
270
|
-
for (const im of images) console.log(im.url);
|
|
271
|
-
if (opts.download) console.log(
|
|
309
|
+
for (const im of images) if (im.url) console.log(im.url);
|
|
310
|
+
if (opts.download) console.log(`\nSaved ${images.length} file(s) to ${opts.download}`);
|
|
311
|
+
}
|
|
312
|
+
if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function imageJob(pos, opts) {
|
|
316
|
+
const [jobId] = pos;
|
|
317
|
+
if (!jobId) throw new Error("usage: image-job <jobId>");
|
|
318
|
+
const job = await ops.checkImageJob(opts, jobId);
|
|
319
|
+
if (opts.json) {
|
|
320
|
+
console.log(JSON.stringify(job, null, 2));
|
|
321
|
+
return;
|
|
272
322
|
}
|
|
323
|
+
console.log(`status: ${job.status}`);
|
|
324
|
+
console.log(`progress: ${job.progress}`);
|
|
325
|
+
if (job.error) console.log(`error: ${job.error}`);
|
|
326
|
+
for (const im of job.results || []) if (im.url) console.log(im.url);
|
|
273
327
|
}
|
|
274
328
|
|
|
275
329
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
@@ -286,6 +340,15 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
286
340
|
const [group, cmd] = pos;
|
|
287
341
|
const rest = pos.slice(2);
|
|
288
342
|
const handlers = {
|
|
343
|
+
"preview create": async () => {
|
|
344
|
+
const { createPreview } = await import("./preview.mjs");
|
|
345
|
+
const result = await createPreview(rest, opts);
|
|
346
|
+
console.log(opts.json ? JSON.stringify(result) : `Preview saved: ${result.manifestPath}\n${result.email.sendable ? result.email.path : "Insert the GIF or PNG manually; upload with an existing share for linked email HTML."}`);
|
|
347
|
+
},
|
|
348
|
+
"preview setup": async () => {
|
|
349
|
+
const { setupCapture } = await import("./preview-capture.mjs");
|
|
350
|
+
await setupCapture(rest, opts);
|
|
351
|
+
},
|
|
289
352
|
login: () => login(opts),
|
|
290
353
|
logout: () => logout(opts),
|
|
291
354
|
whoami: () => whoamiCmd(opts),
|
|
@@ -297,6 +360,8 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
297
360
|
"share list": () => shareList(rest, opts),
|
|
298
361
|
"share on": () => shareToggle(rest, opts, true),
|
|
299
362
|
"share off": () => shareToggle(rest, opts, false),
|
|
363
|
+
"share mute": async () => { if (!rest[0]) throw new Error("usage: share mute <token>"); await ops.setShareNotify(opts, rest[0], false); console.log(`OK muted ${rest[0]}`); },
|
|
364
|
+
"share unmute": async () => { if (!rest[0]) throw new Error("usage: share unmute <token>"); await ops.setShareNotify(opts, rest[0], true); console.log(`OK notification preference requested for ${rest[0]} (QA links remain muted)`); },
|
|
300
365
|
"share rm": () => shareRm(rest, opts),
|
|
301
366
|
"share url": () => shareUrl(rest, opts),
|
|
302
367
|
"recipient archive": () => recipientArchive(rest, opts, true),
|
|
@@ -307,6 +372,8 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
307
372
|
"skills install": () => installHyperdecksSkill(opts, rest),
|
|
308
373
|
activity: () => activity(pos.slice(1), opts),
|
|
309
374
|
"gen-image": () => genImage(pos.slice(1), opts),
|
|
375
|
+
"image-job": () => imageJob(pos.slice(1), opts),
|
|
376
|
+
credits: () => credits(pos.slice(1), opts),
|
|
310
377
|
};
|
|
311
378
|
|
|
312
379
|
const handler = handlers[`${group} ${cmd}`] || handlers[group];
|
package/src/ops.mjs
CHANGED
|
@@ -51,9 +51,52 @@ export async function generateImage(opts, fields = {}) {
|
|
|
51
51
|
if (fields.quality) args.quality = String(fields.quality);
|
|
52
52
|
if (fields.count != null && fields.count !== "") args.count = Number(fields.count);
|
|
53
53
|
if (fields.format) args.format = String(fields.format);
|
|
54
|
+
// Returns { images } for fast requests, or { jobId, status, count } for slow ones
|
|
55
|
+
// (high/auto quality, transparent, count>1). The MCP tool returns this as-is so it
|
|
56
|
+
// never blocks; the CLI polls with pollImageJob to stay one-command (see commands).
|
|
54
57
|
return (await client(opts)).action(api.imagegen.generateImage, args);
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
// Read one async image job's status/results. Shared by the MCP check_image_job tool
|
|
61
|
+
// and the CLI poller.
|
|
62
|
+
export async function checkImageJob(opts, jobId) {
|
|
63
|
+
if (!jobId) throw new Error("jobId is required");
|
|
64
|
+
return (await client(opts)).query(api.imagegen.checkImageJob, { jobId: String(jobId) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Read the signed-in user's credit balance.
|
|
68
|
+
export async function creditsBalance(opts = {}) {
|
|
69
|
+
return (await client(opts)).query(api.credits.getBalance, {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Read the user's recent credit ledger (most recent first).
|
|
73
|
+
export async function creditsLedger(opts = {}, limit) {
|
|
74
|
+
const args = limit ? { limit: Number(limit) } : {};
|
|
75
|
+
return (await client(opts)).query(api.credits.getLedger, args);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Poll an async image job until it finishes. Returns { images, slug } on success
|
|
79
|
+
// (results are the same shape as the inline { images }); throws on job error or
|
|
80
|
+
// timeout. `onProgress(job)` is called each tick. Used by the CLI so gen-image stays
|
|
81
|
+
// a single blocking command even when it routes to a background job.
|
|
82
|
+
export async function pollImageJob(opts, jobId, { intervalMs = 3000, timeoutMs = 900_000, onProgress } = {}) {
|
|
83
|
+
const c = await client(opts);
|
|
84
|
+
const start = Date.now();
|
|
85
|
+
for (;;) {
|
|
86
|
+
const job = await c.query(api.imagegen.checkImageJob, { jobId: String(jobId) });
|
|
87
|
+
if (job.status === "done") return { images: job.results || [], slug: job.slug, creditsRemaining: job.creditsRemaining };
|
|
88
|
+
if (job.status === "error") {
|
|
89
|
+
// A batch isn't atomic: earlier images may have been generated + saved before
|
|
90
|
+
// the failure. Surface that so the user knows assets/spend exist despite "error".
|
|
91
|
+
const ok = (job.results || []).filter((r) => r.url).length;
|
|
92
|
+
throw new Error(`${job.error || "image job failed"}${ok ? ` (${ok} image(s) completed before the failure)` : ""}`);
|
|
93
|
+
}
|
|
94
|
+
if (Date.now() - start > timeoutMs) throw new Error(`image job ${jobId} timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
95
|
+
if (onProgress) onProgress(job);
|
|
96
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
57
100
|
export async function listShares(opts, slug) {
|
|
58
101
|
if (!slug) throw new Error("slug is required");
|
|
59
102
|
const rows = await (await client(opts)).query(api.shares.listForDeck, { deckSlug: slug });
|
|
@@ -63,6 +106,8 @@ export async function listShares(opts, slug) {
|
|
|
63
106
|
enabled: r.enabled,
|
|
64
107
|
opens: r.opens,
|
|
65
108
|
archived: !!r.archived,
|
|
109
|
+
qa: !!r.qa,
|
|
110
|
+
notify: r.qa ? false : r.notify !== false,
|
|
66
111
|
lastOpenedAt: r.lastOpenedAt ?? null,
|
|
67
112
|
requireEmail: r.requireEmail,
|
|
68
113
|
recipientEmail: r.recipientEmail ?? null,
|
|
@@ -75,6 +120,8 @@ export async function createShare(opts, fields) {
|
|
|
75
120
|
const { slug, recipient } = fields;
|
|
76
121
|
if (!slug || !recipient) throw new Error("slug and recipient are required");
|
|
77
122
|
const args = { deckSlug: slug, recipient };
|
|
123
|
+
if (fields.qa !== undefined) args.qa = !!fields.qa;
|
|
124
|
+
if (fields.notify !== undefined) args.notify = !!fields.notify;
|
|
78
125
|
if (fields.email) args.recipientEmail = fields.email;
|
|
79
126
|
if (fields.note) args.note = fields.note;
|
|
80
127
|
if (fields.requireEmail) args.requireEmail = true;
|
|
@@ -97,6 +144,11 @@ export async function setShareEnabled(opts, token, enabled) {
|
|
|
97
144
|
return { token, enabled: !!enabled };
|
|
98
145
|
}
|
|
99
146
|
|
|
147
|
+
export async function setShareNotify(opts, token, notify) {
|
|
148
|
+
if (!token) throw new Error("token is required");
|
|
149
|
+
await (await client(opts)).mutation(api.shares.setNotify, { token, notify });
|
|
150
|
+
}
|
|
151
|
+
|
|
100
152
|
export async function removeShare(opts, token) {
|
|
101
153
|
if (!token) throw new Error("token is required");
|
|
102
154
|
await (await client(opts)).mutation(api.shares.remove, { token });
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { readFile, realpath, mkdtemp, rm, stat } from 'node:fs/promises';
|
|
5
|
+
import { resolve, dirname, extname, sep, join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const MIME = { '.html':'text/html', '.htm':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.gif':'image/gif', '.webp':'image/webp', '.svg':'image/svg+xml', '.woff':'font/woff', '.woff2':'font/woff2', '.ttf':'font/ttf', '.otf':'font/otf', '.mp4':'video/mp4', '.webm':'video/webm', '.mp3':'audio/mpeg', '.wav':'audio/wav', '.wasm':'application/wasm', '.glb':'model/gltf-binary', '.gltf':'model/gltf+json', '.bin':'application/octet-stream' };
|
|
14
|
+
const SETUP = 'Run `hyperdecks preview setup` (or `decks preview setup`) to install the matching isolated Chromium. On Linux install system libraries with `npx playwright install-deps chromium` using the Playwright version in your installed CLI.';
|
|
15
|
+
export async function setupCapture(pos, opts) {
|
|
16
|
+
if (pos.length || Object.keys(opts).some(k => !['json', 'dev', 'convex', 'prod'].includes(k))) throw new Error('usage: preview setup');
|
|
17
|
+
await run(process.execPath, [join(dirname(require.resolve('playwright/package.json')), 'cli.js'), 'install', 'chromium'], { timeout: 240000, maxBuffer: 4 * 1024 * 1024 });
|
|
18
|
+
console.log(opts.json ? JSON.stringify({ installed: 'chromium' }) : 'Chromium installed. Preview capture is ready.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Capability URL + realpath containment; captured code gets no general file server.
|
|
22
|
+
export async function serveSource({ file, html }) {
|
|
23
|
+
const root = file ? await realpath(dirname(file)) : null;
|
|
24
|
+
const prefix = '/' + randomUUID() + '/';
|
|
25
|
+
const entry = prefix + (file ? encodeURIComponent(resolve(file).split(sep).pop()) : 'index.html');
|
|
26
|
+
const server = createServer(async (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
const path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
|
29
|
+
if (req.method !== 'GET' || !path.startsWith(prefix)) { res.writeHead(404).end(); return; }
|
|
30
|
+
if (!file && path === entry) { res.writeHead(200, { 'Content-Type':'text/html; charset=utf-8', 'Cache-Control':'no-store' }).end(html); return; }
|
|
31
|
+
const rel = path.slice(prefix.length);
|
|
32
|
+
if (!root || rel.split('/').some(s => s.startsWith('.') || s.includes('\\')) || !MIME[extname(rel).toLowerCase()]) { res.writeHead(404).end(); return; }
|
|
33
|
+
const full = await realpath(resolve(root, rel));
|
|
34
|
+
if (!full.startsWith(root + sep) || !(await stat(full)).isFile()) { res.writeHead(404).end(); return; }
|
|
35
|
+
res.writeHead(200, { 'Content-Type': MIME[extname(full).toLowerCase()] + (MIME[extname(full).toLowerCase()].startsWith('text/') ? '; charset=utf-8' : ''), 'Cache-Control':'no-store' });
|
|
36
|
+
res.end(await readFile(full));
|
|
37
|
+
} catch { res.writeHead(404).end(); }
|
|
38
|
+
});
|
|
39
|
+
await new Promise((ok, fail) => { server.once('error', fail); server.listen(0, '127.0.0.1', ok); });
|
|
40
|
+
const url = `http://127.0.0.1:${server.address().port}${entry}`;
|
|
41
|
+
return { url, close: () => new Promise((ok, fail) => { server.close(e => e ? fail(e) : ok()); server.closeAllConnections(); }) };
|
|
42
|
+
}
|
|
43
|
+
export function allowCaptureRequest(url, method, type, entryUrl, firstPartyOrigins = []) {
|
|
44
|
+
let u, path;
|
|
45
|
+
try { u = new URL(url); path = decodeURIComponent(u.pathname); } catch { return false; }
|
|
46
|
+
if (!['http:', 'https:', 'data:', 'blob:'].includes(u.protocol) || method !== 'GET') return false;
|
|
47
|
+
if (/(^|\/)(v|track|telemetry|analytics|beacon)(\/|$)/i.test(path)) return false;
|
|
48
|
+
if (type === 'document') return url === entryUrl;
|
|
49
|
+
if (['websocket', 'eventsource', 'ping', 'other'].includes(type)) return false;
|
|
50
|
+
if (firstPartyOrigins.some(v => new URL(v).origin === u.origin) || /\.convex\.(cloud|site)$/.test(u.hostname)) {
|
|
51
|
+
return /^\/api\/storage\//.test(path);
|
|
52
|
+
}
|
|
53
|
+
// Prevent page code from reaching local services or cloud metadata. Only the
|
|
54
|
+
// capability file server is available on loopback; redirects are checked too.
|
|
55
|
+
if (u.origin !== new URL(entryUrl).origin && /^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[|.*\.localhost$)/i.test(u.hostname.replace(/\.+$/, ''))) return false;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// DNS is checked per request; HTTP redirects are refused, without trusting host spelling.
|
|
60
|
+
// This is defense in depth for trusted capture inputs, not a network sandbox:
|
|
61
|
+
// Chromium resolves independently, so DNS rebinding requires OS/network isolation.
|
|
62
|
+
export function isPublicAddress(address) {
|
|
63
|
+
if (isIP(address) === 4) {
|
|
64
|
+
const [a,b] = address.split('.').map(Number);
|
|
65
|
+
return !(a === 0 || a === 10 || a === 127 || a >= 224 ||
|
|
66
|
+
(a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
|
|
67
|
+
(a === 172 && b >= 16 && b <= 31) || (a === 192 && [0,168].includes(b)) ||
|
|
68
|
+
(a === 198 && [18,19].includes(b)));
|
|
69
|
+
}
|
|
70
|
+
// Global unicast only; mapped IPv4, unique-local, multicast and link-local fail.
|
|
71
|
+
return isIP(address) === 6 && /^[23][0-9a-f]{3}:/i.test(address) && !/^2001:(db8|0):/i.test(address);
|
|
72
|
+
}
|
|
73
|
+
export async function allowCaptureDestination(url, entryUrl, resolver = lookup) {
|
|
74
|
+
try {
|
|
75
|
+
const u = new URL(url);
|
|
76
|
+
if (u.origin === new URL(entryUrl).origin) return true;
|
|
77
|
+
if (['data:', 'blob:'].includes(u.protocol)) return true;
|
|
78
|
+
const host = u.hostname.replace(/\.+$/, '').replace(/^\[|\]$/g, '');
|
|
79
|
+
if (isIP(host)) return isPublicAddress(host);
|
|
80
|
+
// Bound DNS latency; lookup itself is OS-owned and may finish after timeout.
|
|
81
|
+
let timer;
|
|
82
|
+
try {
|
|
83
|
+
const addresses = await Promise.race([
|
|
84
|
+
resolver(host, { all:true, verbatim:true }),
|
|
85
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('DNS timeout')), 3000); }),
|
|
86
|
+
]);
|
|
87
|
+
return addresses.length > 0 && addresses.every(({address}) => isPublicAddress(address));
|
|
88
|
+
} finally { clearTimeout(timer); }
|
|
89
|
+
} catch { return false; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Playwright's continue() follows redirects without routing each hop. Fetch
|
|
93
|
+
// without following redirects and never fulfill a 3xx response into Chromium.
|
|
94
|
+
export async function routeCaptureRequest(route, entryUrl, origins = []) {
|
|
95
|
+
const r = route.request();
|
|
96
|
+
try {
|
|
97
|
+
if (!allowCaptureRequest(r.url(), r.method(), r.resourceType(), entryUrl, origins) || !await allowCaptureDestination(r.url(), entryUrl)) {
|
|
98
|
+
await route.abort(); return false;
|
|
99
|
+
}
|
|
100
|
+
const response = await route.fetch({ maxRedirects:0, timeout:15000 });
|
|
101
|
+
if (response.status() >= 300 && response.status() < 400) {
|
|
102
|
+
await response.dispose(); await route.abort(); return false;
|
|
103
|
+
}
|
|
104
|
+
await route.fulfill({ response });
|
|
105
|
+
await response.dispose();
|
|
106
|
+
return true;
|
|
107
|
+
} catch { await route.abort().catch(() => {}); return false; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function encode(ffmpeg, args) {
|
|
111
|
+
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 } : {}) } }); }
|
|
112
|
+
catch (e) { throw new Error(`FFmpeg encoding failed (${e.killed ? '90s timeout' : 'check portable encoder installation'}); no global FFmpeg is used`); }
|
|
113
|
+
}
|
|
114
|
+
export async function capturePreview(config) {
|
|
115
|
+
const { chromium } = await import('playwright');
|
|
116
|
+
let ffmpeg;
|
|
117
|
+
try { ffmpeg = (await import('@ffmpeg-installer/ffmpeg')).default.path; await run(ffmpeg, ['-version'], { timeout:10000 }); }
|
|
118
|
+
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.'); }
|
|
119
|
+
let browser, server, temp, deadline, timedOut = false;
|
|
120
|
+
const warnings = [];
|
|
121
|
+
try {
|
|
122
|
+
temp = await mkdtemp(join(tmpdir(), 'hyperdecks-preview-'));
|
|
123
|
+
try {
|
|
124
|
+
browser = await chromium.launch({ headless: true, timeout:30000, chromiumSandbox: process.platform === 'linux', args: ['--mute-audio', '--enable-unsafe-swiftshader'], env: { ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: temp, HOME: temp } });
|
|
125
|
+
} catch { throw new Error(`Could not launch isolated Chromium. ${SETUP}`); }
|
|
126
|
+
deadline = setTimeout(() => { timedOut = true; browser.close().catch(() => {}); }, 60000);
|
|
127
|
+
server = await serveSource(config);
|
|
128
|
+
const context = await browser.newContext({ viewport: { width:1280, height:720 }, deviceScaleFactor:1, serviceWorkers:'block', acceptDownloads:false });
|
|
129
|
+
let blocked = 0, failed = 0;
|
|
130
|
+
await context.route('**/*', async route => {
|
|
131
|
+
if (!await routeCaptureRequest(route, server.url, config.firstPartyOrigins)) blocked++;
|
|
132
|
+
});
|
|
133
|
+
await context.routeWebSocket('**/*', ws => ws.close());
|
|
134
|
+
// Defense in depth for unload beacons and popup attempts. No credentials,
|
|
135
|
+
// storage state, permission grants or personal profile enter this context.
|
|
136
|
+
await context.addInitScript(() => {
|
|
137
|
+
navigator.sendBeacon = () => false;
|
|
138
|
+
window.open = () => null;
|
|
139
|
+
const play = HTMLMediaElement.prototype.play;
|
|
140
|
+
HTMLMediaElement.prototype.play = function() { this.muted = true; return this instanceof HTMLAudioElement ? Promise.resolve() : play.call(this); };
|
|
141
|
+
});
|
|
142
|
+
const page = await context.newPage();
|
|
143
|
+
page.setDefaultTimeout(15000);
|
|
144
|
+
page.on('pageerror', () => failed++);
|
|
145
|
+
page.on('requestfailed', request => { if (request.failure()?.errorText !== 'net::ERR_FAILED') failed++; });
|
|
146
|
+
await page.goto(server.url, { waitUntil:'domcontentloaded', timeout:30000 });
|
|
147
|
+
const fontsReady = await page.evaluate(async () => Promise.race([document.fonts.ready.then(() => true), new Promise(ok => setTimeout(() => ok(false), 5000))]));
|
|
148
|
+
if (!fontsReady) warnings.push('Fonts did not settle within 5s; inspect the poster or increase --delay.');
|
|
149
|
+
await page.waitForTimeout(config.delay);
|
|
150
|
+
const detected = await page.evaluate(() => {
|
|
151
|
+
const meta = document.querySelector('meta[name="preview-aspect-ratio"]')?.getAttribute('content');
|
|
152
|
+
if (meta && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(meta)) { const [w,h] = meta.split(':').map(Number); return w/h; }
|
|
153
|
+
const slide = document.querySelector('.slide, [data-slide], canvas');
|
|
154
|
+
const rect = slide?.getBoundingClientRect();
|
|
155
|
+
return rect && rect.width > 100 && rect.height > 100 ? rect.width/rect.height : null;
|
|
156
|
+
});
|
|
157
|
+
const aspect = config.aspect ?? (detected >= 0.5 && detected <= 3 ? detected : 16/9);
|
|
158
|
+
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.');
|
|
159
|
+
const width = config.width % 2 ? config.width + 1 : config.width;
|
|
160
|
+
const height = Math.round(width / aspect / 2) * 2;
|
|
161
|
+
await page.setViewportSize({ width:1280, height:Math.round(1280/aspect) });
|
|
162
|
+
await page.evaluate(label => {
|
|
163
|
+
const badge = document.createElement('div');
|
|
164
|
+
badge.textContent = '▶ ' + label;
|
|
165
|
+
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');
|
|
166
|
+
document.documentElement.appendChild(badge);
|
|
167
|
+
}, config.label);
|
|
168
|
+
const fps = 10, frames = Math.round(config.duration * fps), duration = frames/fps;
|
|
169
|
+
// Screenshots capture the live WebGL compositor and CSS motion without
|
|
170
|
+
// starting a deck narration tour or waiting forever on looped media.
|
|
171
|
+
for (let i = 0; i < frames; i++) {
|
|
172
|
+
const start = Date.now();
|
|
173
|
+
await page.screenshot({ path: join(temp, `frame-${String(i).padStart(4,'0')}.png`), timeout:10000 });
|
|
174
|
+
if (i < frames-1) await page.waitForTimeout(Math.max(0, 1000/fps - (Date.now()-start)));
|
|
175
|
+
}
|
|
176
|
+
await context.close();
|
|
177
|
+
clearTimeout(deadline);
|
|
178
|
+
const input = ['-framerate', String(fps), '-i', join(temp, 'frame-%04d.png')];
|
|
179
|
+
const paths = Object.fromEntries(['png','gif','mp4'].map(t => [t, resolve(config.out, `preview.${t}`)]));
|
|
180
|
+
await encode(ffmpeg, ['-i', join(temp,'frame-0000.png'), '-vf', `scale=${width}:${height}`, '-frames:v','1', paths.png]);
|
|
181
|
+
await encode(ffmpeg, [...input, '-filter_complex', `scale=${width}:${height}:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse`, '-loop','0', paths.gif]);
|
|
182
|
+
await encode(ffmpeg, [...input, '-vf',`scale=${width}:${height}`, '-an','-c:v','libx264','-pix_fmt','yuv420p','-movflags','+faststart',paths.mp4]);
|
|
183
|
+
if (blocked) warnings.push(`${blocked} navigation/telemetry/unsafe request(s) blocked; no recipient page was visited.`);
|
|
184
|
+
if (failed) warnings.push(`${failed} page/resource error(s); inspect the poster and increase --delay if necessary.`);
|
|
185
|
+
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 } : {}) }) })));
|
|
186
|
+
return { files, warnings };
|
|
187
|
+
} catch (e) { throw new Error(`Capture failed: ${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}`); }
|
|
188
|
+
finally {
|
|
189
|
+
clearTimeout(deadline);
|
|
190
|
+
if (browser) await browser.close().catch(() => {});
|
|
191
|
+
if (server) await server.close();
|
|
192
|
+
if (temp) await rm(temp, { recursive:true, force:true });
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/preview.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve, extname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { api, client, target, uploadBytes } from "./runtime.mjs";
|
|
5
|
+
|
|
6
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
7
|
+
export function httpUrl(value) {
|
|
8
|
+
const u = new URL(value);
|
|
9
|
+
if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) throw new Error("Expected an HTTP(S) URL without credentials");
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function bounded(value, fallback, min, max, name, integer = false) {
|
|
13
|
+
const n = value === undefined ? fallback : Number(value);
|
|
14
|
+
if (value === '' || !Number.isFinite(n) || n < min || n > max || (integer && !Number.isInteger(n))) throw new Error(`${name} must be ${integer ? 'an integer ' : ''}between ${min} and ${max}`);
|
|
15
|
+
return n;
|
|
16
|
+
}
|
|
17
|
+
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
|
+
for (const k of Object.keys(opts)) if (!allowed.has(k)) throw new Error(`Unknown preview option --${k}`);
|
|
20
|
+
for (const [k, v] of Object.entries(opts)) if (v === undefined) throw new Error(`--${k} requires a value`);
|
|
21
|
+
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>");
|
|
22
|
+
if (!opts.out || typeof opts.out !== 'string') throw new Error('--out <new-directory> is required');
|
|
23
|
+
if (opts.file && (opts.upload || opts.share)) throw new Error('Local-file capture is export-only; use an owned deck slug for --share/--upload');
|
|
24
|
+
if (opts.upload && !opts.share) throw new Error('--upload requires --share <existing-token>');
|
|
25
|
+
if (pos[0] && !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,199}$/.test(pos[0])) throw new Error('Invalid deck slug');
|
|
26
|
+
if (opts.share !== undefined && !/^[a-zA-Z0-9_-]{1,200}$/.test(opts.share)) throw new Error('Invalid share token');
|
|
27
|
+
if (opts.file && !['.html', '.htm'].includes(extname(opts.file).toLowerCase())) throw new Error('--file must be an HTML file');
|
|
28
|
+
const label = opts.label ?? 'Watch presentation';
|
|
29
|
+
if (typeof label !== 'string' || !label.trim() || label.length > 120 || /[\x00-\x1f]/.test(label)) throw new Error('--label must be 1–120 printable characters');
|
|
30
|
+
let aspect;
|
|
31
|
+
if (opts['aspect-ratio'] !== undefined) {
|
|
32
|
+
const m = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(opts['aspect-ratio']);
|
|
33
|
+
aspect = m ? Number(m[1]) / Number(m[2]) : NaN;
|
|
34
|
+
bounded(aspect, null, 0.5, 3, '--aspect-ratio');
|
|
35
|
+
}
|
|
36
|
+
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 };
|
|
37
|
+
}
|
|
38
|
+
export function validateShare(rows, slug, token, now = Date.now()) {
|
|
39
|
+
const row = rows.find(s => s.token === token && s.deckSlug === slug);
|
|
40
|
+
if (!row || row.enabled !== true || (row.expiresAt != null && row.expiresAt <= now) || (row.viewLimit != null && row.opens >= row.viewLimit)) throw new Error('Share is missing, mismatched, disabled, expired, or at its open limit; select another existing share');
|
|
41
|
+
return row;
|
|
42
|
+
}
|
|
43
|
+
export function emailHtml(image, url, label, width, height) {
|
|
44
|
+
httpUrl(image); httpUrl(url);
|
|
45
|
+
return `<a href="${escapeHtml(url)}" target="_blank"><img src="${escapeHtml(image)}" alt="${escapeHtml(label)}" width="${width}" height="${height}" style="display:block;max-width:100%;height:auto;border:0"></a>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Capture is injected by tests; production loads the browser/encoder only here.
|
|
49
|
+
export async function createPreview(pos, opts, adapters = {}) {
|
|
50
|
+
const config = previewOptions(pos, opts);
|
|
51
|
+
let c, deck, html, shareUrl = null;
|
|
52
|
+
const warnings = [];
|
|
53
|
+
if (config.file) {
|
|
54
|
+
if (!(await stat(config.file)).isFile()) throw new Error('--file must be a regular HTML file');
|
|
55
|
+
} else {
|
|
56
|
+
c = await client(opts);
|
|
57
|
+
deck = await c.query(api.previews.get, { slug: config.slug });
|
|
58
|
+
if (opts.share) {
|
|
59
|
+
if (deck.status === 'archived') throw new Error('Archived deck cannot provide a sendable share preview');
|
|
60
|
+
validateShare(await c.query(api.shares.listForDeck, { deckSlug: config.slug }), config.slug, opts.share);
|
|
61
|
+
shareUrl = httpUrl(`${target(opts).shareBase}/v/${opts.share}`);
|
|
62
|
+
}
|
|
63
|
+
html = deck.html;
|
|
64
|
+
if (!html && deck.htmlUrl) {
|
|
65
|
+
const res = await (adapters.fetch || fetch)(httpUrl(deck.htmlUrl), { signal: AbortSignal.timeout(30000), redirect: 'error' });
|
|
66
|
+
if (!res.ok) throw new Error(`Could not read stored HTML (${res.status})`);
|
|
67
|
+
html = await res.text();
|
|
68
|
+
}
|
|
69
|
+
if (!html) throw new Error('Deck has no HTML to capture');
|
|
70
|
+
}
|
|
71
|
+
// A new directory prevents replacing artifacts referenced by an earlier manifest.
|
|
72
|
+
await mkdir(config.out, { recursive: false });
|
|
73
|
+
const manifestPath = resolve(config.out, 'manifest.json');
|
|
74
|
+
const result = { version: 1, status: 'capturing', source: config.file ? { file: config.file } : { slug: config.slug, title: deck.title }, shareUrl, manifestPath, files: [], email: { sendable: false, path: null, html: null }, warnings };
|
|
75
|
+
const save = () => writeFile(manifestPath, JSON.stringify(result, null, 2) + '\n');
|
|
76
|
+
await save();
|
|
77
|
+
try {
|
|
78
|
+
const capture = adapters.capture || (await import('./preview-capture.mjs')).capturePreview;
|
|
79
|
+
const captured = await capture({ ...config, html, firstPartyOrigins: [targetForCapture(opts), 'https://decks.hypersocial.ai', 'https://dashing-snail-874.convex.cloud'] });
|
|
80
|
+
result.files = captured.files;
|
|
81
|
+
warnings.push(...captured.warnings);
|
|
82
|
+
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
|
+
result.status = 'exported';
|
|
84
|
+
await save();
|
|
85
|
+
if (opts.upload) {
|
|
86
|
+
// Recheck after potentially slow capture and before any upload side effect.
|
|
87
|
+
validateShare(await c.query(api.shares.listForDeck, { deckSlug: config.slug }), config.slug, opts.share);
|
|
88
|
+
const id = randomUUID();
|
|
89
|
+
for (const f of result.files) {
|
|
90
|
+
const name = `preview-${id}-${f.type}`;
|
|
91
|
+
const storageId = await (adapters.uploadBytes || uploadBytes)(c, await readFile(f.path), f.contentType);
|
|
92
|
+
f.upload = { name, storageId, saved: false };
|
|
93
|
+
await save();
|
|
94
|
+
const saved = await c.mutation(api.assets.saveAsset, { name, storageId, contentType: f.contentType, deckSlug: config.slug });
|
|
95
|
+
f.upload.saved = true;
|
|
96
|
+
f.url = httpUrl(saved.url);
|
|
97
|
+
await save();
|
|
98
|
+
}
|
|
99
|
+
const gif = result.files.find(f => f.type === 'gif');
|
|
100
|
+
result.email = { sendable: true, path: resolve(config.out, 'email.html'), html: emailHtml(gif.url, shareUrl, config.label, gif.width, gif.height) };
|
|
101
|
+
await writeFile(result.email.path, result.email.html + '\n', { flag: 'wx' });
|
|
102
|
+
result.status = 'uploaded';
|
|
103
|
+
} else warnings.push('Local export: insert GIF/PNG into an email client manually. No sendable remote-image HTML exists until explicit upload with an existing share.');
|
|
104
|
+
await save();
|
|
105
|
+
return result;
|
|
106
|
+
} catch (e) {
|
|
107
|
+
result.status = 'failed';
|
|
108
|
+
// Do not persist raw HTTP/broker errors which may contain signed URLs or tokens.
|
|
109
|
+
result.error = 'Preview failed; local artifacts and any recorded partial uploads are preserved.';
|
|
110
|
+
await save();
|
|
111
|
+
throw new Error(`Preview failed. Artifacts and partial upload records: ${manifestPath}. ${e.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function targetForCapture(opts) {
|
|
115
|
+
// Offline local-file capture must not require deployment configuration/login.
|
|
116
|
+
return opts.file ? 'https://decks.hypersocial.ai' : target(opts).brokerBase;
|
|
117
|
+
}
|
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.1.
|
|
11
|
+
export const VERSION = "0.1.10";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
14
|
export const PROD = {
|
|
@@ -19,6 +19,7 @@ export const PROD = {
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
export const api = {
|
|
22
|
+
previews: { get: makeFunctionReference("previews:get") },
|
|
22
23
|
assets: {
|
|
23
24
|
generateUploadUrl: makeFunctionReference("assets:generateUploadUrl"),
|
|
24
25
|
saveAsset: makeFunctionReference("assets:saveAsset"),
|
|
@@ -44,13 +45,19 @@ export const api = {
|
|
|
44
45
|
listForDeck: makeFunctionReference("shares:listForDeck"),
|
|
45
46
|
remove: makeFunctionReference("shares:remove"),
|
|
46
47
|
setEnabled: makeFunctionReference("shares:setEnabled"),
|
|
48
|
+
setNotify: makeFunctionReference("shares:setNotify"),
|
|
47
49
|
},
|
|
48
50
|
imagegen: {
|
|
49
51
|
generateImage: makeFunctionReference("imagegen:generateImage"),
|
|
52
|
+
checkImageJob: makeFunctionReference("imagegen:checkImageJob"),
|
|
53
|
+
},
|
|
54
|
+
credits: {
|
|
55
|
+
getBalance: makeFunctionReference("credits:getBalance"),
|
|
56
|
+
getLedger: makeFunctionReference("credits:getLedger"),
|
|
50
57
|
},
|
|
51
58
|
};
|
|
52
59
|
|
|
53
|
-
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "help", "version", "all-agents", "global", "project", "transparent"]);
|
|
60
|
+
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload"]);
|
|
54
61
|
|
|
55
62
|
export const CRED_DIR = join(homedir(), ".config", "hypersocial-decks");
|
|
56
63
|
export const CRED_FILE = join(CRED_DIR, "credentials.json");
|