storyshot 0.1.0 → 0.1.3
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 +9 -5
- package/bin/storyshot.mjs +5 -3
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +11 -3
- package/src/auth.mjs +105 -27
- package/src/check.mjs +1 -1
- package/src/config.mjs +15 -1
- package/src/init.mjs +3 -3
- package/src/inspect.mjs +9 -4
- package/src/mcp.mjs +31 -23
- package/src/record.mjs +5 -4
package/README.md
CHANGED
|
@@ -108,11 +108,14 @@ so a change only pays for the lines that changed.
|
|
|
108
108
|
|
|
109
109
|
When the flow is behind a login, the agent says so in its plan. Then:
|
|
110
110
|
|
|
111
|
-
1. A
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
1. A normal Chrome window opens (the `login` tool, or `storyshot login <url>`).
|
|
112
|
+
No script is attached to it while you log in, so "Sign in with Google" and
|
|
113
|
+
2FA work as usual.
|
|
114
|
+
2. You log in there. When you see the app logged in, tell the agent "done"
|
|
115
|
+
(or press Enter in the terminal). You can also just close the window.
|
|
116
|
+
3. The session is saved to `videos/<site>-login.json`. It includes cookies,
|
|
117
|
+
localStorage, IndexedDB and sessionStorage, and the file is added to `.gitignore` because
|
|
118
|
+
it holds your session. The temporary browser profile is deleted.
|
|
116
119
|
4. Every recording starts logged in, so the video never shows a login screen.
|
|
117
120
|
|
|
118
121
|
If the session expires, Storyshot says so ("the saved login has expired, run
|
|
@@ -191,6 +194,7 @@ music: { bpm: 120, volume: 1 } # or { file: track.mp3 }, or fal
|
|
|
191
194
|
sfx: true # click / whoosh / riser sounds
|
|
192
195
|
voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceover, see below
|
|
193
196
|
storageState: auth.json # optional Playwright login state
|
|
197
|
+
ignoreHTTPSErrors: true # internal site with a self-signed HTTPS certificate (plain http:// needs nothing)
|
|
194
198
|
captionPosition: bottom # or top; can also be set per step. A caption that would cover a step's click target or zoom subject moves to the other side automatically
|
|
195
199
|
purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
|
|
196
200
|
highlight: false # spotlight targets before click/type (per step or per action too)
|
package/bin/storyshot.mjs
CHANGED
|
@@ -33,6 +33,8 @@ Options:
|
|
|
33
33
|
--draft (render/compose) use the free local voice instead of a paid TTS provider and write
|
|
34
34
|
<out>.draft.mp4; when it looks right, run \`compose\` without --draft (reuses the recording)
|
|
35
35
|
--viewport 1600x900 (inspect, init)
|
|
36
|
+
--insecure (inspect, init) accept a self-signed HTTPS certificate (internal sites);
|
|
37
|
+
storyboards use \`ignoreHTTPSErrors: true\`
|
|
36
38
|
`;
|
|
37
39
|
|
|
38
40
|
const args = process.argv.slice(2);
|
|
@@ -80,11 +82,11 @@ try {
|
|
|
80
82
|
} else if (cmd === 'login') {
|
|
81
83
|
const { login, authFileFor } = await import('../src/auth.mjs');
|
|
82
84
|
const r = await login(arg, { file: flag('save') ?? authFileFor(arg), viewport: viewport() });
|
|
83
|
-
console.log(`✔ saved the login session to ${r.file} (${r.how}, ${r.cookies} cookies)${r.gitignore ? `\n added it to ${r.gitignore}` : ''}` +
|
|
85
|
+
console.log(`✔ saved the login session to ${r.file} (${r.how}, ${r.cookies} cookies, ${r.siteCookies} for this site${r.sessionStorage ? `, sessionStorage for ${r.sessionStorage} site(s)` : ''})${r.gitignore ? `\n added it to ${r.gitignore}` : ''}` +
|
|
84
86
|
`\n use it: \`storageState: ${r.file.split(/[\\/]/).pop()}\` in the storyboard (path relative to the yaml), or \`--storage ${r.file}\` for inspect/init`);
|
|
85
87
|
} else if (cmd === 'init') {
|
|
86
88
|
const { initStoryboard } = await import('../src/init.mjs');
|
|
87
|
-
const r = await initStoryboard(arg, { purpose: flag('purpose') ?? 'pitch', topic: flag('topic'), file: flag('file'), storageState: flag('storage'), viewport: viewport() });
|
|
89
|
+
const r = await initStoryboard(arg, { purpose: flag('purpose') ?? 'pitch', topic: flag('topic'), file: flag('file'), storageState: flag('storage'), ignoreHTTPSErrors: args.includes('--insecure'), viewport: viewport() });
|
|
88
90
|
console.log(`✔ wrote ${r.file}\n screenshot: ${r.screenshot}\n next: edit it, then \`storyshot check ${r.file}\``);
|
|
89
91
|
} else if (cmd === 'inspect') {
|
|
90
92
|
await (await import('../src/doctor.mjs')).ensureBrowser();
|
|
@@ -94,7 +96,7 @@ try {
|
|
|
94
96
|
const after = flag('after');
|
|
95
97
|
console.log((await record(cfg, { log: () => {}, inspectAfter: after != null ? Number(after) : cfg.steps.length })).text);
|
|
96
98
|
} else {
|
|
97
|
-
console.log((await inspect(arg, { viewport: viewport(), storageState: flag('storage') })).text);
|
|
99
|
+
console.log((await inspect(arg, { viewport: viewport(), storageState: flag('storage'), ignoreHTTPSErrors: args.includes('--insecure') })).text);
|
|
98
100
|
}
|
|
99
101
|
} else if (cmd === 'check') {
|
|
100
102
|
const r = check(arg, { draft: args.includes('--draft') });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storyshot",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Turn a short YAML storyboard into a polished product demo video of your web app — smooth cursor, auto zoom, captions, music.",
|
|
5
5
|
"author": "boheling <boheling@gmail.com>",
|
|
6
6
|
"repository": {
|
|
@@ -82,9 +82,14 @@ everything.
|
|
|
82
82
|
- Tell the user a browser window will open and that they should log in
|
|
83
83
|
there, preferably with a demo account (the video shows whatever is on
|
|
84
84
|
screen).
|
|
85
|
-
- Call `login` with a URL behind the login. It
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
- Call `login` with a URL behind the login. It opens a normal Chrome
|
|
86
|
+
window and returns at once. Tell the user to log in there and, once the
|
|
87
|
+
app shows them logged in, to leave the window open and reply "done".
|
|
88
|
+
Nothing watches the window meanwhile (Google sign-in breaks if a script
|
|
89
|
+
probes it), so wait for their reply.
|
|
90
|
+
- Then call `login` again with the same URL and `done: true`. It saves the
|
|
91
|
+
session to `videos/<site>-login.json`, adds it to `.gitignore` and closes
|
|
92
|
+
the window. (CLI: `storyshot login <url>`, then press Enter.)
|
|
88
93
|
- Pass that file as `storage_state` to `inspect_page` and `init_storyboard`,
|
|
89
94
|
and add `storageState: <site>-login.json` to the storyboard.
|
|
90
95
|
- Never ask for, store or type a password, and never write login steps.
|
|
@@ -163,6 +168,9 @@ Rules of thumb from real renders:
|
|
|
163
168
|
`at (x, y)`.
|
|
164
169
|
- Never put real credentials in a storyboard. For logged-in apps, use
|
|
165
170
|
`storageState` (see step 1).
|
|
171
|
+
- Internal sites: `http://` URLs just work. If an `https://` internal site
|
|
172
|
+
fails with an untrusted certificate, add `ignoreHTTPSErrors: true` (inspect/init:
|
|
173
|
+
`ignore_https_errors`). Never turn it on for public sites.
|
|
166
174
|
|
|
167
175
|
Run `check_storyboard` after every edit, and fix every ✖ before rendering. It
|
|
168
176
|
checks structure, typos and leftover `TODO`s, but not whether targets exist on
|
package/src/auth.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Logged-in apps: detect login walls, and `storyshot login` — the user logs in once in a real
|
|
2
2
|
// browser window and the session (cookies, localStorage, IndexedDB) is saved for recording.
|
|
3
3
|
import { chromium } from 'playwright';
|
|
4
|
-
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
5
|
+
import os from 'node:os';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { VIDEO_DIR, siteOf } from './config.mjs';
|
|
@@ -41,44 +42,121 @@ export function gitIgnored(file) {
|
|
|
41
42
|
function ignore(file) {
|
|
42
43
|
if (gitIgnored(file) === true) return null;
|
|
43
44
|
let root = process.cwd();
|
|
44
|
-
try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: path.dirname(path.resolve(file)) }).toString().trim(); } catch {}
|
|
45
|
+
try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: path.dirname(path.resolve(file)), stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
|
|
45
46
|
const gi = path.join(root, '.gitignore');
|
|
46
47
|
const rel = path.relative(root, path.resolve(file)).split(path.sep).join('/');
|
|
48
|
+
if (rel.startsWith('../') || path.isAbsolute(rel)) return null; // outside the project: nothing to ignore
|
|
47
49
|
const cur = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
|
|
48
50
|
if (cur.split('\n').includes(rel)) return null;
|
|
49
51
|
fs.writeFileSync(gi, `${cur}${cur && !cur.endsWith('\n') ? '\n' : ''}# Storyshot login session (contains your session cookies — never commit)\n${rel}\n`);
|
|
50
52
|
return gi;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
// --- login ------------------------------------------------------------------------------------
|
|
56
|
+
// The user logs in in a plain Chrome window that NOTHING is attached to while they do it: Google
|
|
57
|
+
// (and other SSO providers) refuse automated browsers, polling the page from a script makes
|
|
58
|
+
// Google's sign-in bounce between the app and accounts.google.com, and even an open DevTools port
|
|
59
|
+
// gets "This browser or app may not be secure". So Chrome runs with only a temporary profile; when
|
|
60
|
+
// the user is done we quit it and read the session back from that profile.
|
|
61
|
+
|
|
62
|
+
function chromeExecutable() {
|
|
63
|
+
const home = os.homedir();
|
|
64
|
+
const candidates = process.platform === 'darwin'
|
|
65
|
+
? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', path.join(home, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')]
|
|
66
|
+
: process.platform === 'win32'
|
|
67
|
+
? [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean).map((d) => path.join(d, 'Google', 'Chrome', 'Application', 'chrome.exe'))
|
|
68
|
+
: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
|
69
|
+
return candidates.find((c) => fs.existsSync(c)) ?? chromium.executablePath();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Opens the window and returns at once. Pass the result to finishLogin() when the user is done.
|
|
73
|
+
export function openLoginWindow(url, { viewport = { width: 1600, height: 900 }, headless = false } = {}) {
|
|
74
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'storyshot-login-'));
|
|
75
|
+
// "Continue where you left off" also keeps session cookies when the user closes the window.
|
|
76
|
+
fs.mkdirSync(path.join(dir, 'Default'), { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(dir, 'Default', 'Preferences'), JSON.stringify({ session: { restore_on_startup: 1 } }));
|
|
78
|
+
const exe = chromeExecutable();
|
|
79
|
+
const args = [`--user-data-dir=${dir}`, '--no-first-run', '--no-default-browser-check',
|
|
80
|
+
`--window-size=${viewport.width},${viewport.height}`, ...(headless ? ['--headless=new'] : []), url];
|
|
81
|
+
let proc;
|
|
82
|
+
try { proc = spawn(exe, args, { stdio: 'ignore' }); } catch (e) {
|
|
83
|
+
throw new Error(`could not open a browser window (${e.message}). Log in on a machine with a screen (storyshot login ${url}) and copy the file here.`);
|
|
60
84
|
}
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
const session = { url, dir, exe, proc, exited: false };
|
|
86
|
+
proc.on('exit', () => { session.exited = true; });
|
|
87
|
+
proc.on('error', () => { session.exited = true; });
|
|
88
|
+
return session;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Quit the window (if the user hasn't), read the session back from its profile, save it and delete
|
|
92
|
+
// the temporary profile.
|
|
93
|
+
export async function finishLogin(session, { file = authFileFor(session.url), log = console.log } = {}) {
|
|
94
|
+
const { url, dir, exe, proc } = session;
|
|
95
|
+
const how = session.exited ? 'window closed' : 'logged in';
|
|
96
|
+
let state;
|
|
97
|
+
try {
|
|
98
|
+
if (!session.exited) {
|
|
99
|
+
// SIGTERM makes Chrome shut down cleanly and flush cookies; force it if it hangs
|
|
100
|
+
proc.kill('SIGTERM');
|
|
101
|
+
const t = setTimeout(() => proc.kill('SIGKILL'), 10_000);
|
|
102
|
+
await new Promise((r) => (session.exited ? r() : proc.once('exit', r)));
|
|
103
|
+
clearTimeout(t);
|
|
104
|
+
}
|
|
105
|
+
// --restore-last-session reopens the user's tabs, which brings back their sessionStorage (some
|
|
106
|
+
// apps, e.g. Microsoft MSAL, keep the login token there; storageState() doesn't capture it)
|
|
107
|
+
const context = await chromium.launchPersistentContext(dir, { executablePath: exe, headless: true, ignoreHTTPSErrors: true, args: ['--restore-last-session'] });
|
|
75
108
|
await new Promise((r) => setTimeout(r, 1000));
|
|
109
|
+
const sessionStorage = {};
|
|
110
|
+
for (const p of context.pages()) {
|
|
111
|
+
if (!/^https?:/.test(p.url())) continue;
|
|
112
|
+
await p.waitForLoadState('load', { timeout: 15_000 }).catch(() => {});
|
|
113
|
+
const items = await p.evaluate(() => Object.entries(window.sessionStorage)).catch(() => []);
|
|
114
|
+
if (items.length) Object.assign(sessionStorage[new URL(p.url()).origin] ??= {}, Object.fromEntries(items));
|
|
115
|
+
}
|
|
116
|
+
// load the app once in a fresh tab, so its localStorage / IndexedDB are part of the snapshot
|
|
117
|
+
const page = await context.newPage();
|
|
118
|
+
await page.goto(url, { waitUntil: 'load', timeout: 30_000 }).catch(() => {});
|
|
119
|
+
state = await context.storageState({ indexedDB: true });
|
|
120
|
+
const extra = Object.entries(sessionStorage).map(([origin, items]) => ({ origin, items: Object.entries(items).map(([name, value]) => ({ name, value })) }));
|
|
121
|
+
if (extra.length) state.sessionStorage = extra;
|
|
122
|
+
await context.close().catch(() => {});
|
|
123
|
+
} finally {
|
|
124
|
+
if (!session.exited) proc.kill('SIGKILL');
|
|
125
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
76
126
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (done === 'timeout') log('warning: timed out waiting for the login; saving the session as it is');
|
|
127
|
+
const host = new URL(url).hostname.replace(/^www\./, '');
|
|
128
|
+
const siteCookies = state.cookies.filter((c) => host.endsWith(c.domain.replace(/^\./, '')) || c.domain.replace(/^\./, '').endsWith(host)).length;
|
|
80
129
|
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
81
130
|
fs.writeFileSync(file, JSON.stringify(state, null, 1));
|
|
82
131
|
const gi = ignore(file);
|
|
83
|
-
return { file: path.resolve(file), how
|
|
132
|
+
return { file: path.resolve(file), how, gitignore: gi, cookies: state.cookies.length, siteCookies, sessionStorage: state.sessionStorage?.length ?? 0 };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A browser context with a saved login: storageState plus the sessionStorage that login() saved
|
|
136
|
+
// alongside it (Playwright has no sessionStorage in storageState, so it is restored by a script).
|
|
137
|
+
export async function sessionContext(browser, { storageState, ...options } = {}) {
|
|
138
|
+
let state = storageState;
|
|
139
|
+
if (typeof state === 'string') state = JSON.parse(fs.readFileSync(state, 'utf8'));
|
|
140
|
+
const { sessionStorage, ...rest } = state ?? {};
|
|
141
|
+
const context = await browser.newContext({ ...options, storageState: state ? rest : undefined });
|
|
142
|
+
if (sessionStorage?.length) {
|
|
143
|
+
await context.addInitScript((saved) => {
|
|
144
|
+
const s = saved.find((o) => o.origin === location.origin);
|
|
145
|
+
if (s) for (const { name, value } of s.items) if (window.sessionStorage.getItem(name) === null) window.sessionStorage.setItem(name, value);
|
|
146
|
+
}, sessionStorage);
|
|
147
|
+
}
|
|
148
|
+
return context;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// CLI: open the window, wait for Enter (or the window closing), then save.
|
|
152
|
+
export async function login(url, { file = authFileFor(url), viewport, log = console.log } = {}) {
|
|
153
|
+
const session = openLoginWindow(url, { viewport });
|
|
154
|
+
log(`A Chrome window is open at ${url}. Log in there (2FA and "Sign in with Google" work).`);
|
|
155
|
+
log('When you see the app logged in, come back here and press Enter (or just close the window).');
|
|
156
|
+
await new Promise((resolve) => {
|
|
157
|
+
const done = () => { process.stdin.pause(); resolve(); };
|
|
158
|
+
process.stdin.resume(); process.stdin.once('data', done);
|
|
159
|
+
session.proc.once('exit', done);
|
|
160
|
+
});
|
|
161
|
+
return finishLogin(session, { file, log });
|
|
84
162
|
}
|
package/src/check.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import YAML from 'yaml';
|
|
|
4
4
|
import { loadConfig } from './config.mjs';
|
|
5
5
|
import { gitIgnored } from './auth.mjs';
|
|
6
6
|
|
|
7
|
-
const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'colorScheme',
|
|
7
|
+
const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'colorScheme',
|
|
8
8
|
'scrollOffset', 'captionPosition', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
|
|
9
9
|
const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition'];
|
|
10
10
|
const ZOOM = ['target', 'scale', 'start', 'end', 'ramp', 'offset'];
|
package/src/config.mjs
CHANGED
|
@@ -29,7 +29,20 @@ export const PURPOSES = {
|
|
|
29
29
|
// .mp4, so a promo, a tutorial and a lesson for the same app never collide or all read "demo".
|
|
30
30
|
export const VIDEO_DIR = 'videos';
|
|
31
31
|
export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs' };
|
|
32
|
-
|
|
32
|
+
// pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
|
|
33
|
+
// (http://10.1.2.3:8080 → 10-1-2-3-8080, http://localhost:3000 → localhost-3000)
|
|
34
|
+
export const siteOf = (url) => {
|
|
35
|
+
const { hostname, port } = new URL(url);
|
|
36
|
+
const host = hostname.replace(/^\[|\]$/g, '');
|
|
37
|
+
const ip = /^[\d.]+$/.test(host) || host.includes(':');
|
|
38
|
+
return [ip ? host.replace(/[.:]+/g, '-').replace(/^-|-$/g, '') : host.replace(/^www\./, '').split('.')[0], port].filter(Boolean).join('-');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// A clear message for an internal site with a self-signed / untrusted HTTPS certificate.
|
|
42
|
+
export const certError = (e, url) => (/ERR_CERT|ERR_SSL/.test(e?.message ?? '')
|
|
43
|
+
? new Error(`${url} has an untrusted HTTPS certificate (${e.message.match(/ERR_\w+/)[0]}). If it is your own internal site with a self-signed ` +
|
|
44
|
+
'certificate, add `ignoreHTTPSErrors: true` to the storyboard (inspect/init: --insecure, MCP: ignore_https_errors: true).')
|
|
45
|
+
: null);
|
|
33
46
|
const slug = (s) => String(s).toLowerCase().normalize('NFKD').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
34
47
|
|
|
35
48
|
export function defaultStoryboard(url, purpose = 'pitch', topic) {
|
|
@@ -108,6 +121,7 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
108
121
|
output,
|
|
109
122
|
scale,
|
|
110
123
|
colorScheme: raw.colorScheme ?? 'light',
|
|
124
|
+
ignoreHTTPSErrors: raw.ignoreHTTPSErrors === true,
|
|
111
125
|
storageState: raw.storageState ? path.resolve(dir, raw.storageState) : undefined,
|
|
112
126
|
scrollOffset: raw.scrollOffset ?? 40,
|
|
113
127
|
captionPosition: raw.captionPosition ?? 'bottom',
|
package/src/init.mjs
CHANGED
|
@@ -9,11 +9,11 @@ import { loginAdvice } from './auth.mjs';
|
|
|
9
9
|
const q = (s) => JSON.stringify(String(s));
|
|
10
10
|
const tgt = (t) => (typeof t === 'string' ? q(t) : `{ ${Object.entries(t).map(([k, v]) => `${k}: ${typeof v === 'string' ? q(v) : v}`).join(', ')} }`);
|
|
11
11
|
|
|
12
|
-
export async function initStoryboard(url, { purpose = 'pitch', topic, file, storageState, viewport = { width: 1600, height: 900 } } = {}) {
|
|
12
|
+
export async function initStoryboard(url, { purpose = 'pitch', topic, file, storageState, ignoreHTTPSErrors = false, viewport = { width: 1600, height: 900 } } = {}) {
|
|
13
13
|
if (!PURPOSES[purpose]) throw new Error(`purpose must be one of ${Object.keys(PURPOSES).join(', ')}`);
|
|
14
14
|
file ??= defaultStoryboard(url, purpose, topic);
|
|
15
15
|
if (fs.existsSync(file)) throw new Error(`${file} already exists — edit it, or choose another path`);
|
|
16
|
-
const page = await inspect(url, { viewport, storageState, outDir: path.join(path.dirname(path.resolve(file)), '.storyshot', 'inspect') });
|
|
16
|
+
const page = await inspect(url, { viewport, storageState, ignoreHTTPSErrors, outDir: path.join(path.dirname(path.resolve(file)), '.storyshot', 'inspect') });
|
|
17
17
|
if (page.login?.wall) {
|
|
18
18
|
throw new Error(`${url} asks to log in (${page.login.reason})${storageState ? ' even with the saved session — it has probably expired, log in again' : ''}. ` +
|
|
19
19
|
`${loginAdvice(url)} Then run init again with that session (MCP: storage_state, CLI: --storage).`);
|
|
@@ -38,7 +38,7 @@ name: ${name}
|
|
|
38
38
|
url: ${page.url}
|
|
39
39
|
purpose: ${purpose}
|
|
40
40
|
out: ${name}.mp4
|
|
41
|
-
${storageState ? `storageState: ${path.relative(path.dirname(path.resolve(file)), path.resolve(storageState)).split(path.sep).join('/')} # saved login (never commit it)\n` : ''}${narrated ? 'voice: { provider: kokoro } # free, local, English. For the final: { provider: elevenlabs, voice: <voice id> } + ELEVENLABS_API_KEY' : '# voice: { provider: kokoro } # optional voiceover; add `say:` lines to steps'}
|
|
41
|
+
${storageState ? `storageState: ${path.relative(path.dirname(path.resolve(file)), path.resolve(storageState)).split(path.sep).join('/')} # saved login (never commit it)\n` : ''}${ignoreHTTPSErrors ? 'ignoreHTTPSErrors: true # internal site with a self-signed certificate\n' : ''}${narrated ? 'voice: { provider: kokoro } # free, local, English. For the final: { provider: elevenlabs, voice: <voice id> } + ELEVENLABS_API_KEY' : '# voice: { provider: kokoro } # optional voiceover; add `say:` lines to steps'}
|
|
42
42
|
|
|
43
43
|
setup: # runs before recording; clean the frame here${dismiss ? `\n - dismiss: ${tgt(dismiss.target)}` : '\n # - dismiss: { role: button, name: Accept cookies }'}
|
|
44
44
|
|
package/src/inspect.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { chromium } from 'playwright';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { detectLogin, loginAdvice } from './auth.mjs';
|
|
6
|
+
import { detectLogin, loginAdvice, sessionContext } from './auth.mjs';
|
|
7
|
+
import { certError } from './config.mjs';
|
|
7
8
|
|
|
8
9
|
// Runs in the page. Positions are relative to the top of the main scrolling content,
|
|
9
10
|
// which may be the window or an inner scroll container (common in web apps).
|
|
@@ -161,11 +162,15 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
|
|
|
161
162
|
return { text: lines.join('\n'), height: info.height, title: info.title, url, headings: info.headings, anchors: info.anchors, elements };
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
export async function inspect(url, { viewport = { width: 1600, height: 900 }, storageState, outDir = '.storyshot/inspect', maxElements = 80 } = {}) {
|
|
165
|
+
export async function inspect(url, { viewport = { width: 1600, height: 900 }, storageState, ignoreHTTPSErrors = false, outDir = '.storyshot/inspect', maxElements = 80 } = {}) {
|
|
165
166
|
const browser = await chromium.launch();
|
|
166
|
-
const context = await browser
|
|
167
|
+
const context = await sessionContext(browser, { viewport, storageState, ignoreHTTPSErrors });
|
|
167
168
|
const page = await context.newPage();
|
|
168
|
-
await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 }).catch(() =>
|
|
169
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 }).catch(async (e) => {
|
|
170
|
+
const cert = certError(e, url);
|
|
171
|
+
if (cert) { await browser.close(); throw cert; }
|
|
172
|
+
await page.waitForLoadState('domcontentloaded');
|
|
173
|
+
});
|
|
169
174
|
await page.waitForTimeout(1500); // let late toasts / banners appear so they get listed
|
|
170
175
|
|
|
171
176
|
const desc = await describePage(page, viewport, { maxElements });
|
package/src/mcp.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { record } from './record.mjs';
|
|
|
14
14
|
import { guide } from './guide.mjs';
|
|
15
15
|
import { check, formatCheck } from './check.mjs';
|
|
16
16
|
import { initStoryboard } from './init.mjs';
|
|
17
|
-
import {
|
|
17
|
+
import { openLoginWindow, finishLogin, authFileFor } from './auth.mjs';
|
|
18
18
|
import { ensureBrowser, ffmpegInstallCommand } from './doctor.mjs';
|
|
19
19
|
import { execFileSync } from 'node:child_process';
|
|
20
20
|
|
|
@@ -54,11 +54,12 @@ server.registerTool('init_storyboard', {
|
|
|
54
54
|
purpose: z.enum(['pitch', 'tutorial', 'course', 'docs']).optional().describe('pitch = 15–30s promo (default); tutorial = narrated how-to; course = narrated lesson; docs = silent per-step GIFs'),
|
|
55
55
|
topic: z.string().optional().describe('Short topic for the file name, e.g. "export-pdf" → videos/<site>-tutorial-export-pdf.yaml. Use it for tutorials/lessons, since a site usually gets several'),
|
|
56
56
|
storage_state: z.string().optional().describe('Absolute path to a saved login (from the login tool) for apps behind a login'),
|
|
57
|
+
ignore_https_errors: z.boolean().optional().describe('Accept a self-signed HTTPS certificate (internal sites only); also writes ignoreHTTPSErrors: true into the storyboard'),
|
|
57
58
|
},
|
|
58
|
-
}, async ({ url, path: file, purpose = 'pitch', topic, storage_state }) => {
|
|
59
|
+
}, async ({ url, path: file, purpose = 'pitch', topic, storage_state, ignore_https_errors = false }) => {
|
|
59
60
|
try {
|
|
60
61
|
await ensureBrowser(() => {});
|
|
61
|
-
const r = await initStoryboard(url, { purpose, topic, file: file ? abs(file) : undefined, storageState: storage_state ? abs(storage_state) : undefined });
|
|
62
|
+
const r = await initStoryboard(url, { purpose, topic, file: file ? abs(file) : undefined, storageState: storage_state ? abs(storage_state) : undefined, ignoreHTTPSErrors: ignore_https_errors });
|
|
62
63
|
return { content: [text(`${r.login?.hint ? `ℹ Login: ${r.login.hint}. If the flow needs it, call login first.\n\n` : ''}Wrote ${r.file}:\n\n${r.yaml}`), image(r.screenshot)] };
|
|
63
64
|
} catch (e) { return fail(`init failed: ${e.message}`); }
|
|
64
65
|
});
|
|
@@ -73,10 +74,11 @@ server.registerTool('inspect_page', {
|
|
|
73
74
|
storyboard: z.string().optional().describe('Absolute path to a storyboard YAML (instead of url)'),
|
|
74
75
|
after_step: z.number().int().min(0).optional().describe('With storyboard: run steps 1..N first (0 = only setup). Default: all steps.'),
|
|
75
76
|
storage_state: z.string().optional().describe('With url: absolute path to a saved login (from the login tool)'),
|
|
77
|
+
ignore_https_errors: z.boolean().optional().describe('With url: accept a self-signed HTTPS certificate (internal sites only)'),
|
|
76
78
|
viewport_width: z.number().optional(),
|
|
77
79
|
viewport_height: z.number().optional(),
|
|
78
80
|
},
|
|
79
|
-
}, async ({ url, storyboard, after_step, storage_state, viewport_width = 1600, viewport_height = 900 }) => {
|
|
81
|
+
}, async ({ url, storyboard, after_step, storage_state, ignore_https_errors = false, viewport_width = 1600, viewport_height = 900 }) => {
|
|
80
82
|
try {
|
|
81
83
|
await ensureBrowser(() => {});
|
|
82
84
|
if (storyboard) {
|
|
@@ -85,7 +87,7 @@ server.registerTool('inspect_page', {
|
|
|
85
87
|
return { content: [text(r.text), image(r.screenshot)] };
|
|
86
88
|
}
|
|
87
89
|
if (!url) return fail('give either url or storyboard');
|
|
88
|
-
const r = await inspect(url, { viewport: { width: viewport_width, height: viewport_height }, storageState: storage_state ? abs(storage_state) : undefined });
|
|
90
|
+
const r = await inspect(url, { viewport: { width: viewport_width, height: viewport_height }, storageState: storage_state ? abs(storage_state) : undefined, ignoreHTTPSErrors: ignore_https_errors });
|
|
89
91
|
return { content: [text(r.text), image(r.screenshot)] };
|
|
90
92
|
} catch (e) {
|
|
91
93
|
const extra = [];
|
|
@@ -95,35 +97,41 @@ server.registerTool('inspect_page', {
|
|
|
95
97
|
}
|
|
96
98
|
});
|
|
97
99
|
|
|
98
|
-
// --- login: the user logs in once in a
|
|
100
|
+
// --- login: the user logs in once in a plain Chrome window ---------------------------------------
|
|
101
|
+
// Two calls: the first opens the window and returns; after the user says they are logged in, the
|
|
102
|
+
// second (done: true) saves the session. Nothing watches the window in between, because Google
|
|
103
|
+
// sign-in breaks when a script probes it.
|
|
99
104
|
const logins = new Map();
|
|
100
105
|
server.registerTool('login', {
|
|
101
106
|
title: 'Log in (user)',
|
|
102
|
-
description: 'For apps behind a login.
|
|
103
|
-
'
|
|
104
|
-
|
|
107
|
+
description: 'For apps behind a login. Call 1 (`url`): opens a normal Chrome window at `url` and returns at once. ' +
|
|
108
|
+
'Then tell the user: "A Chrome window opened. Log in there (Sign in with Google and 2FA work). When you see the app logged in, ' +
|
|
109
|
+
'leave the window open and reply \'done\'." Never type passwords yourself, and do NOT make call 2 until the user replies. ' +
|
|
110
|
+
'Call 2 (same `url`, `done: true`): saves the session to a file (added to .gitignore) and closes the window. ' +
|
|
105
111
|
'Then use the file as storage_state in inspect_page/init_storyboard and as `storageState:` in the storyboard.',
|
|
106
112
|
inputSchema: {
|
|
107
113
|
url: z.string().describe('A page of the app that requires login (its login page or a page behind it)'),
|
|
114
|
+
done: z.boolean().optional().describe('true only after the user has said they are logged in'),
|
|
108
115
|
path: z.string().optional().describe('Absolute path for the session file. Default: <cwd>/videos/<site>-login.json'),
|
|
109
116
|
},
|
|
110
|
-
}, async ({ url, path: file }) => {
|
|
117
|
+
}, async ({ url, done, path: file }) => {
|
|
111
118
|
const out = abs(file ?? authFileFor(url));
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
if (!done) {
|
|
120
|
+
const old = logins.get(out);
|
|
121
|
+
if (old && !old.exited) return { content: [text(`The login window for ${url} is already open. Ask the user to log in there and reply when done, then call login with done: true.`)] };
|
|
122
|
+
await ensureBrowser(() => {});
|
|
123
|
+
try { logins.set(out, openLoginWindow(url)); } catch (e) { return fail(e.message); }
|
|
124
|
+
return { content: [text(`A Chrome window is open at ${url}. Tell the user: log in there (Sign in with Google and 2FA work); when the app shows them logged in, leave the window open and reply "done". ` +
|
|
125
|
+
'Wait for their reply, then call login again with the same url and done: true.')] };
|
|
118
126
|
}
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return { content: [text(`✔ Saved the login session to ${r.file} (${r.how}, ${r.cookies} cookies).${r.gitignore ? ` Added it to ${r.gitignore}.` : ''}\n` +
|
|
127
|
+
const session = logins.get(out);
|
|
128
|
+
if (!session) return fail('no login window is open for this url — call login without done first');
|
|
129
|
+
logins.delete(out);
|
|
130
|
+
let r;
|
|
131
|
+
try { r = await finishLogin(session, { file: out }); } catch (e) { return fail(`login failed: ${e.message}`); }
|
|
132
|
+
return { content: [text(`✔ Saved the login session to ${r.file} (${r.how}, ${r.cookies} cookies, ${r.siteCookies} for this site${r.sessionStorage ? `, sessionStorage for ${r.sessionStorage} site(s)` : ''}).${r.gitignore ? ` Added it to ${r.gitignore}.` : ''}\n` +
|
|
125
133
|
`Next: inspect_page with storage_state "${r.file}" to confirm you see the logged-in app, and put \`storageState: ${path.basename(r.file)}\` (path relative to the yaml) in the storyboard.` +
|
|
126
|
-
(r.
|
|
134
|
+
(r.siteCookies || r.sessionStorage ? '' : '\nNote: no cookies were saved for this site — the user may not have finished logging in; inspect will show the login wall again if so.'))] };
|
|
127
135
|
});
|
|
128
136
|
|
|
129
137
|
server.registerTool('check_storyboard', {
|
package/src/record.mjs
CHANGED
|
@@ -4,7 +4,8 @@ import { chromium } from 'playwright';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { describePage } from './inspect.mjs';
|
|
7
|
-
import { detectLogin, loginAdvice } from './auth.mjs';
|
|
7
|
+
import { detectLogin, loginAdvice, sessionContext } from './auth.mjs';
|
|
8
|
+
import { certError } from './config.mjs';
|
|
8
9
|
|
|
9
10
|
// Injected into every page: fake cursor, click ripple, smooth in-page anchor scrolling.
|
|
10
11
|
function pageScript({ scrollOffset }) {
|
|
@@ -154,8 +155,8 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
154
155
|
const { width: VW, height: VH } = cfg.viewport;
|
|
155
156
|
|
|
156
157
|
const browser = await chromium.launch();
|
|
157
|
-
const context = await browser
|
|
158
|
-
viewport: cfg.viewport, deviceScaleFactor: cfg.scale, colorScheme: cfg.colorScheme, storageState: cfg.storageState,
|
|
158
|
+
const context = await sessionContext(browser, {
|
|
159
|
+
viewport: cfg.viewport, deviceScaleFactor: cfg.scale, colorScheme: cfg.colorScheme, storageState: cfg.storageState, ignoreHTTPSErrors: cfg.ignoreHTTPSErrors,
|
|
159
160
|
});
|
|
160
161
|
await context.addInitScript(pageScript, { scrollOffset: cfg.scrollOffset });
|
|
161
162
|
const page = await context.newPage();
|
|
@@ -354,7 +355,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
354
355
|
}
|
|
355
356
|
|
|
356
357
|
log(`opening ${cfg.url}`);
|
|
357
|
-
await page.goto(cfg.url, { waitUntil: 'networkidle' }).catch(() => page.goto(cfg.url));
|
|
358
|
+
await page.goto(cfg.url, { waitUntil: 'networkidle' }).catch(() => page.goto(cfg.url)).catch((e) => { throw certError(e, cfg.url) ?? e; });
|
|
358
359
|
// sent to a login page instead of the start url → no (valid) session
|
|
359
360
|
if (new URL(page.url()).pathname !== new URL(cfg.url).pathname || cfg.storageState) {
|
|
360
361
|
const d = await detectLogin(page);
|