@shipvo/mcp 0.0.9 → 0.0.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 +10 -4
- package/dist/attach.d.ts +1 -1
- package/dist/attach.js +6 -2
- package/dist/recorder.d.ts +47 -3
- package/dist/recorder.js +296 -29
- package/dist/server.js +1632 -471
- package/dist/wait.d.ts +0 -1
- package/dist/wait.js +0 -1
- package/package.json +6 -3
- package/skills/shipvo-mcp/SKILL.md +32 -0
package/README.md
CHANGED
|
@@ -32,19 +32,25 @@ The result gets a line `Waited: matched | timed out after <ms> ms` and `structur
|
|
|
32
32
|
| `stale_ref` | The ref is not on the current screen, or the screen changed since it was read. | Call `screen` and use a ref from the new tree. |
|
|
33
33
|
| `unknown_app` | The app id is not one this server or key can open. | Call `list_apps` and use an id from it. |
|
|
34
34
|
| `sim_timeout` | The simulator did not answer in time. The action may have run. | Call `screen` to see where the app is. Do not repeat the action blindly. |
|
|
35
|
-
| `needs_confirm` | An owner command that cannot be undone did nothing and returned a plan
|
|
35
|
+
| `needs_confirm` | An owner command that cannot be undone did nothing and returned a plan and a one-time `challenge`. | Show the plan to the owner and ask them to approve it at `challenge.approval_url` in the admin. Then call again with the same args and `approval: <challenge id>`. `confirm: true` does nothing. |
|
|
36
36
|
| `bad_url` | `open_url` got a URL the app cannot take. | Fix the URL and call again. |
|
|
37
37
|
| `no_url_listener` | The app is not listening for URLs. | Use the app's screens instead. |
|
|
38
38
|
| `phone_locked` | The phone is locked, so tap, type, scroll and back were not sent. | Call `unlock`, then try again. |
|
|
39
39
|
| `already_locked` | `lock` was called on a locked phone. | Call `unlock` if you want to use the app. |
|
|
40
40
|
| `not_locked` | `unlock` was called on a phone that is not locked. | Carry on; nothing to do. |
|
|
41
|
+
| `app_not_foreground` | The home screen or app switcher is showing, so tap, type, scroll, back, `open_url` and `open_notification` were not sent. | Call `resume_app`. |
|
|
42
|
+
| `recents_not_open` | `kill_app` was called without the app switcher open. | Call `open_recents` first. |
|
|
41
43
|
| `mock_invalid` | A network mock rule is not valid. | Fix the rule. |
|
|
42
44
|
|
|
43
|
-
Tools: `list_apps`, `open_app`, `screen`, `tap`, `type`, `scroll`, `back`, `screenshot`, `set_device`, `push_notification`, `notifications`, `open_notification`, `device_log`, `export_test`, `end_session`, `owner_describe`, `owner_command
|
|
45
|
+
Tools: `list_apps`, `open_app`, `attach_session`, `screen`, `tap`, `type`, `scroll`, `back`, `screenshot`, `set_device`, `push_notification`, `notifications`, `open_notification`, `device_log`, `export_test`, `end_session`, `owner_describe`, `owner_command`, `approvals_list`, `approval_approve` (both need an owner token, and the MCP agent counts as an agent: it cannot approve an agent's hold).
|
|
44
46
|
|
|
45
47
|
`export_test { session }` is read-only. It turns the taps, typing, scrolls, `back` and `set_device` calls recorded in a session into a `@shipvo/test` script and returns the source. Targets are named by visible text; an ambiguous target becomes a `// TODO` comment, and a password-like field uses `process.env.SHIPVO_TEST_<LABEL>` so the typed value is never in the output. Only sessions opened through this server are recorded; an unknown session returns `no_session`. Steps are dropped when the session ends. `payment.pin` is removed from recorded `set_device` calls. Only labels and roles that look like a password, passcode or PIN are treated as secret; other secret-like fields (card number, token) are exported as typed. `type` with `enter: true` and `scroll` with a `ref` are recorded without the Enter or the ref, so a replay can differ from the session.
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
## Record a demo video
|
|
50
|
+
|
|
51
|
+
`record_start`, `record_marker` and `record_stop` write an MP4 of the phone to `.shipvo/recordings/`. `record_stop` takes `music: true` (a ducked music bed and a `.credits.json`) and `narration` (a path to a wav). It needs ffmpeg and `playwright`. Music works only from a repo checkout, or when `SHIPVO_BGMUSIC` points at `scripts/video/bgmusic.py`; otherwise `record_stop` returns `music_unavailable` (and `music_failed` if the mix fails). A runnable example is in [`docs/record-a-demo-video.md`](../../docs/record-a-demo-video.md).
|
|
52
|
+
|
|
53
|
+
**Owner commands that cannot be undone need the owner's approval.** `publish`, price changes, offers, deletes and API keys first return `{ code: "needs_confirm", plan, challenge }` and change nothing. The owner approves the challenge in the admin (an agent cannot), then `owner_command` is called again with the same args and `approval: "<challenge id>"`. An approval works once, for 10 minutes, for exactly those arguments. `approvals_list` shows pending challenges. With `agentPolicy: "allow"` agents act without asking.
|
|
48
54
|
|
|
49
55
|
Native features work with no phone: the simulated device (see `@shipvo/device`) shows permission dialogs, the camera, the fingerprint prompt, payment prompts, notification banners and the share sheet as real dialogs in the tree; `set_device` decides what the phone has (location, a barcode in view, whether the fingerprint matches, whether a payment is approved, network off).
|
|
50
56
|
|
|
@@ -79,7 +85,7 @@ The HTTP endpoint is stateless. There is no protocol session: each POST is handl
|
|
|
79
85
|
### Protocol versions
|
|
80
86
|
|
|
81
87
|
- **2025-era clients** (`initialize`, then calls, with or without `Mcp-Session-Id`): work. `initialize` is answered, no session id is issued, and any `Mcp-Session-Id` a client sends is ignored.
|
|
82
|
-
- **2026-07-28 clients**: `tools/list` and `tools/call` work with no session and no `initialize`. `server/discover` is answered (supported versions, tools capability, server info). A request counts as 2026-era if it calls `server/discover`, sends `Mcp-Method`, or names a 2026 protocol version in `params._meta`; those must carry `Mcp-Method` (and `Mcp-Name` for `tools/call`, `prompts/get`, `resources/read`) matching the body, or get a 400 JSON-RPC error. Requests with no 2026 marker are not checked.
|
|
88
|
+
- **2026-07-28 clients**: `tools/list` and `tools/call` work with no session and no `initialize`. `server/discover` is answered (supported versions, tools capability, server info). A request counts as 2026-era if it calls `server/discover`, sends `Mcp-Method`, or names a 2026 protocol version in `params._meta`; those must carry `Mcp-Method` (and `Mcp-Name` for `tools/call`, `prompts/get`, `resources/read`) matching the body, or get a 400 JSON-RPC error. Requests with no 2026 marker are not checked. Header mismatches return `-32020`; naming a protocol version we do not support returns `-32022`. 2026-era results carry `resultType: "complete"`, and `tools/list` results also carry `ttlMs` and `cacheScope: "private"` (the list depends on the per-request scope headers). 2025-era responses have none of these. `tools/list` is sorted by tool name on stdio and HTTP.
|
|
83
89
|
- This package uses `@modelcontextprotocol/sdk` 1.x (the TypeScript SDK v2 is not stable on npm yet; 1.32.2 is `latest`). Tested by `test/http-stateless.test.ts`: discover without `initialize`, header/body mismatch, scope per request, 2025-era `initialize` clients. Stdio is unchanged.
|
|
84
90
|
|
|
85
91
|
Requests without a valid key are refused. The hosted version at `sim.shipvo.dev` will issue keys per account with scopes (test, owner, read).
|
package/dist/attach.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type ScreenMessage = {
|
|
|
20
20
|
[key: string]: unknown;
|
|
21
21
|
};
|
|
22
22
|
/** Finds `.shipvo/run.json` in `cwd` or a parent, stopping after the git root. Returns it only if the pid is alive and the app matches. */
|
|
23
|
-
export declare function findLiveRun(app: string, cwd?: string): RunInfo | undefined;
|
|
23
|
+
export declare function findLiveRun(app: string | undefined, cwd?: string): RunInfo | undefined;
|
|
24
24
|
export type Driver = {
|
|
25
25
|
request(type: string, args?: Record<string, unknown>, ms?: number): Promise<ScreenMessage>;
|
|
26
26
|
close(): void;
|
package/dist/attach.js
CHANGED
|
@@ -19,7 +19,7 @@ export function findLiveRun(app, cwd = process.cwd()) {
|
|
|
19
19
|
if (existsSync(file)) {
|
|
20
20
|
try {
|
|
21
21
|
const i = JSON.parse(readFileSync(file, "utf8"));
|
|
22
|
-
if (typeof i.port === "number" && typeof i.token === "string" && typeof i.pid === "number" && typeof i.app === "string" && i.app === app && alive(i.pid))
|
|
22
|
+
if (typeof i.port === "number" && typeof i.token === "string" && typeof i.pid === "number" && typeof i.app === "string" && (app === undefined || i.app === app) && alive(i.pid))
|
|
23
23
|
return i;
|
|
24
24
|
}
|
|
25
25
|
catch { }
|
|
@@ -49,7 +49,11 @@ export function connectDriver(info, ms = 3000) {
|
|
|
49
49
|
return res({ ok: false, code: "attach_closed", error: "the shipvo run phone is gone: call open_app again" });
|
|
50
50
|
const id = next++;
|
|
51
51
|
const t = setTimeout(() => { pending.delete(id); res({ ok: false, code: "sim_timeout", error: `the live phone did not answer "${type}" within ${limit} ms. The action may still have run: call screen to see where it is.` }); }, limit);
|
|
52
|
-
pending.set(id, (m) => {
|
|
52
|
+
pending.set(id, (m) => {
|
|
53
|
+
clearTimeout(t);
|
|
54
|
+
// The person using the phone paused the agent. Say what to do instead of retrying.
|
|
55
|
+
res(m.code === "taken_over" ? { ...m, ok: false, error: "the person took over: they are using the live phone now. Stop acting and ask them to press Hand back, then call screen to see where the app is." } : m);
|
|
56
|
+
});
|
|
53
57
|
ws.send(JSON.stringify({ ...args, id, type }));
|
|
54
58
|
}),
|
|
55
59
|
};
|
package/dist/recorder.d.ts
CHANGED
|
@@ -13,26 +13,48 @@ export type Recording = {
|
|
|
13
13
|
width: number;
|
|
14
14
|
height: number;
|
|
15
15
|
chapters: Chapter[];
|
|
16
|
+
credits?: string;
|
|
17
|
+
notes?: string[];
|
|
18
|
+
lumi?: boolean;
|
|
19
|
+
lumi_lines?: number;
|
|
16
20
|
};
|
|
17
21
|
export declare class RecorderError extends Error {
|
|
18
22
|
code: string;
|
|
19
23
|
constructor(code: string, message: string);
|
|
20
24
|
}
|
|
25
|
+
export declare const MAX_IDLE_S = 0.6;
|
|
26
|
+
export type Background = string;
|
|
27
|
+
export type Point = {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
};
|
|
31
|
+
export type Step = {
|
|
32
|
+
at: number;
|
|
33
|
+
text: string;
|
|
34
|
+
};
|
|
35
|
+
/** Lines Lumi speaks: one per step caption, dropped when the previous line would still be playing (about 0.09 s a character plus a 0.6 s gap). Times are seconds on the final video. */
|
|
36
|
+
export declare function voiceCues(steps: Step[], t0: number, shift: (ms: number) => number, total: number): [number, string, string][];
|
|
21
37
|
export declare class Recorder {
|
|
22
38
|
private browser?;
|
|
23
39
|
private page?;
|
|
24
|
-
private ff?;
|
|
25
40
|
private loop?;
|
|
26
41
|
private t0;
|
|
27
42
|
private running;
|
|
28
43
|
private starting;
|
|
29
44
|
private stopping;
|
|
30
45
|
private ffErr;
|
|
31
|
-
private
|
|
46
|
+
private frames;
|
|
47
|
+
private framesDir;
|
|
32
48
|
private chapters;
|
|
33
49
|
private file;
|
|
50
|
+
private raw;
|
|
34
51
|
private size;
|
|
35
52
|
private fps;
|
|
53
|
+
private showTaps;
|
|
54
|
+
private lumi;
|
|
55
|
+
private lumiVoice;
|
|
56
|
+
private lumiOn;
|
|
57
|
+
private acts;
|
|
36
58
|
get active(): boolean;
|
|
37
59
|
start(o: {
|
|
38
60
|
url: string;
|
|
@@ -40,13 +62,35 @@ export declare class Recorder {
|
|
|
40
62
|
fps?: 30 | 60;
|
|
41
63
|
title?: string;
|
|
42
64
|
dir?: string;
|
|
65
|
+
showTaps?: boolean;
|
|
66
|
+
background?: Background;
|
|
67
|
+
lumi?: boolean;
|
|
68
|
+
lumiVoice?: boolean;
|
|
43
69
|
}): Promise<{
|
|
44
70
|
file: string;
|
|
45
71
|
width: number;
|
|
46
72
|
height: number;
|
|
47
73
|
}>;
|
|
48
74
|
private grab;
|
|
75
|
+
/** True while a recording is running and taps should be drawn: callers use it to skip the extra locate call. */
|
|
76
|
+
get drawing(): boolean;
|
|
77
|
+
/** Soft finger dot and ripple at p (0..1 of the phone). No-op when not recording or show_taps is false. */
|
|
78
|
+
tap(p: Point): Promise<void>;
|
|
79
|
+
/** Finger trail from a to b (0..1 of the phone), for swipes and scrolls. */
|
|
80
|
+
trail(a: Point, b: Point): Promise<void>;
|
|
81
|
+
private draw;
|
|
82
|
+
/** The agent did something now. Gaps between actions longer than MAX_IDLE_S are cut from the video. */
|
|
83
|
+
action(): void;
|
|
84
|
+
/** An action that takes time. Its whole span is kept; call the returned function when it ends. */
|
|
85
|
+
actionSpan(): () => void;
|
|
49
86
|
marker(caption: string): Chapter;
|
|
50
|
-
stop(
|
|
87
|
+
stop(o?: {
|
|
88
|
+
music?: boolean;
|
|
89
|
+
narration?: string;
|
|
90
|
+
}): Promise<Recording>;
|
|
91
|
+
/** Encode the saved frames, each shown from the time it was taken until the next one. Resolves to ffmpeg's exit code (-1 if it did not start). */
|
|
92
|
+
private encode;
|
|
51
93
|
private cleanup;
|
|
52
94
|
}
|
|
95
|
+
/** Speak the cues with Lumi's voice: scripts/video/dialogue.py with the `energetic` preset. SHIPVO_DIALOGUE points at the script. Returns the wav path (next to the video). */
|
|
96
|
+
export declare function lumiVoice(file: string, cues: [number, string, string][], total: number): string;
|
package/dist/recorder.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Records the phone frame page to an MP4. Playwright takes screenshots while a session runs; ffmpeg encodes them.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
-
import { mkdirSync } from "node:fs";
|
|
6
|
-
import { join, resolve } from "node:path";
|
|
2
|
+
// Waits longer than 0.6 s between agent actions are cut at stop. Each frame is saved with the time it was taken and ffmpeg runs once at stop,
|
|
3
|
+
// so the video lasts as long as the recording even when screenshots are slow or the machine is busy.
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
7
8
|
export const SIZES = {
|
|
8
9
|
"9:16": { width: 1080, height: 1920 }, "16:9": { width: 1920, height: 1080 }, "1:1": { width: 1080, height: 1080 },
|
|
9
10
|
};
|
|
@@ -14,21 +15,74 @@ export class RecorderError extends Error {
|
|
|
14
15
|
this.code = code;
|
|
15
16
|
}
|
|
16
17
|
}
|
|
18
|
+
export const MAX_IDLE_S = 0.6;
|
|
19
|
+
// Runs in the recorded page. Draws on top of the phone (the first iframe, else the whole page); points are 0..1 of it.
|
|
20
|
+
// A dot holds for 0.5 s so it shows in the video, then fades. Elements remove themselves.
|
|
21
|
+
const overlayScript = (kind, a, b) => `(() => {
|
|
22
|
+
const f = document.querySelector("iframe"); const r = f ? f.getBoundingClientRect() : { left: 0, top: 0, width: innerWidth, height: innerHeight };
|
|
23
|
+
const at = (p) => ({ x: r.left + p.x * r.width, y: r.top + p.y * r.height });
|
|
24
|
+
const add = (css, ms) => { const e = document.createElement("div"); e.setAttribute("data-shipvo-touch", "");
|
|
25
|
+
e.style.cssText = "position:fixed;z-index:2147483647;pointer-events:none;border-radius:50%;" + css; document.body.appendChild(e); setTimeout(() => e.remove(), ms); return e; };
|
|
26
|
+
if (!document.getElementById("shipvo-touch-css")) { const st = document.createElement("style"); st.id = "shipvo-touch-css";
|
|
27
|
+
st.textContent = "@keyframes shipvo-hold{0%,60%{opacity:1}100%{opacity:0}}@keyframes shipvo-ripple{0%{transform:translate(-50%,-50%) scale(.6);opacity:.9}100%{transform:translate(-50%,-50%) scale(2.4);opacity:0}}"; document.head.appendChild(st); }
|
|
28
|
+
const dot = (p) => add("left:" + p.x + "px;top:" + p.y + "px;width:44px;height:44px;margin:-22px 0 0 -22px;background:rgba(255,255,255,.75);box-shadow:0 0 14px rgba(0,0,0,.35);animation:shipvo-hold 900ms ease-out forwards", 950);
|
|
29
|
+
const a = at(${JSON.stringify(a)});
|
|
30
|
+
${kind === "tap" ? `dot(a); add("left:" + a.x + "px;top:" + a.y + "px;width:60px;height:60px;border:3px solid rgba(255,255,255,.9);animation:shipvo-ripple 800ms ease-out forwards", 850);`
|
|
31
|
+
: `const b = at(${JSON.stringify(b)}); const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy);
|
|
32
|
+
const t = add("left:" + a.x + "px;top:" + a.y + "px;width:" + len + "px;height:18px;margin-top:-9px;border-radius:9px;transform-origin:0 50%;transform:rotate(" + Math.atan2(dy, dx) + "rad);background:linear-gradient(90deg,rgba(255,255,255,0),rgba(255,255,255,.7));animation:shipvo-hold 900ms ease-out forwards", 950);
|
|
33
|
+
dot(a); dot(b);`}
|
|
34
|
+
})()`;
|
|
35
|
+
const BRAND_BG = "linear-gradient(160deg,#0b1f3a,#1d4ed8)";
|
|
36
|
+
// Page background behind the phone. "brand" and "plain" are fixed; anything else must be a CSS colour, checked in the page with CSS.supports.
|
|
37
|
+
// Returns false (and changes nothing) if the value is not a colour, so url(...) and injected rules never reach the style rule.
|
|
38
|
+
const backgroundScript = (b) => `(() => { const v = ${JSON.stringify(b === "brand" ? BRAND_BG : b === "plain" ? "#ffffff" : b)};
|
|
39
|
+
if (${b === "brand" || b === "plain" ? "false" : "true"} && !CSS.supports("color", v)) return false;
|
|
40
|
+
let s = document.getElementById("shipvo-bg-css"); if (!s) { s = document.createElement("style"); s.id = "shipvo-bg-css"; document.head.appendChild(s); }
|
|
41
|
+
s.textContent = "html,body{background:" + v + " !important}"; return true; })()`;
|
|
42
|
+
// Lower third: replaces the previous caption, stays until the next marker.
|
|
43
|
+
const captionScript = (text) => `(() => { let e = document.getElementById("shipvo-caption"); if (!e) { e = document.createElement("div"); e.id = "shipvo-caption";
|
|
44
|
+
e.style.cssText = "position:fixed;z-index:2147483647;pointer-events:none;left:50%;bottom:6%;transform:translateX(-50%);max-width:80%;padding:14px 28px;border-radius:14px;background:rgba(0,0,0,.72);color:#fff;font:600 44px/1.2 system-ui,sans-serif;text-align:center"; document.body.appendChild(e); }
|
|
45
|
+
e.textContent = ${JSON.stringify(text)}; })()`;
|
|
46
|
+
// Lumi in the recorded frame. The observer logs only caption changes, so the same text shown twice in a row is logged once. Hidden with a style rule when off. When on, a small observer in the page logs each caption Lumi shows, with the time, so the steps can be spoken.
|
|
47
|
+
const lumiScript = (on) => `(() => { if (!${on}) { const s = document.createElement("style"); s.id = "shipvo-lumi-off"; s.textContent = '[data-part="lumi"],[data-part="lumi-caption"],.sf-lumi,.sf-lumi-cap{display:none !important}'; document.head.appendChild(s); return false; }
|
|
48
|
+
const has = !!document.querySelector('[data-part="lumi"]'); const log = window.__shipvoSteps = window.__shipvoSteps || [];
|
|
49
|
+
const c = document.querySelector('[data-part="lumi-caption"]');
|
|
50
|
+
if (c) new MutationObserver(() => { const t = (c.textContent || "").trim(); if (t && !c.hidden && t !== (log.at(-1) || {}).text) log.push({ at: Date.now(), text: t }); }).observe(c, { childList: true, characterData: true, subtree: true, attributes: true });
|
|
51
|
+
return has; })()`;
|
|
52
|
+
/** Lines Lumi speaks: one per step caption, dropped when the previous line would still be playing (about 0.09 s a character plus a 0.6 s gap). Times are seconds on the final video. */
|
|
53
|
+
export function voiceCues(steps, t0, shift, total) {
|
|
54
|
+
const out = [];
|
|
55
|
+
let free = 0;
|
|
56
|
+
for (const s of steps) {
|
|
57
|
+
const t = shift(s.at - t0) / 1000, text = s.text.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
58
|
+
if (!text || t < 0 || t > total - 0.5 || t < free)
|
|
59
|
+
continue;
|
|
60
|
+
out.push([Math.round(t * 1000) / 1000, "lumi", text]);
|
|
61
|
+
free = t + 0.6 + text.length * 0.09;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
17
65
|
export class Recorder {
|
|
18
66
|
browser;
|
|
19
67
|
page;
|
|
20
|
-
ff;
|
|
21
68
|
loop;
|
|
22
69
|
t0 = 0;
|
|
23
70
|
running = false;
|
|
24
71
|
starting = false;
|
|
25
72
|
stopping = false;
|
|
26
73
|
ffErr = "";
|
|
27
|
-
|
|
74
|
+
frames = [];
|
|
75
|
+
framesDir = "";
|
|
28
76
|
chapters = [];
|
|
29
77
|
file = "";
|
|
78
|
+
raw = "";
|
|
30
79
|
size = SIZES["9:16"];
|
|
31
80
|
fps = 30;
|
|
81
|
+
showTaps = true;
|
|
82
|
+
lumi = true;
|
|
83
|
+
lumiVoice = false;
|
|
84
|
+
lumiOn = false;
|
|
85
|
+
acts = [];
|
|
32
86
|
get active() { return this.running || this.starting || this.stopping; }
|
|
33
87
|
async start(o) {
|
|
34
88
|
if (this.active)
|
|
@@ -38,11 +92,21 @@ export class Recorder {
|
|
|
38
92
|
this.size = SIZES[o.format ?? "9:16"];
|
|
39
93
|
this.fps = o.fps ?? 30;
|
|
40
94
|
this.chapters = [];
|
|
95
|
+
this.acts = [];
|
|
41
96
|
this.ffErr = "";
|
|
97
|
+
this.frames = [];
|
|
98
|
+
this.showTaps = o.showTaps !== false;
|
|
99
|
+
this.lumi = o.lumi !== false;
|
|
100
|
+
this.lumiVoice = this.lumi && o.lumiVoice === true;
|
|
101
|
+
this.lumiOn = false;
|
|
42
102
|
const dir = resolve(o.dir ?? join(process.cwd(), ".shipvo", "recordings"));
|
|
43
103
|
mkdirSync(dir, { recursive: true });
|
|
44
104
|
const slug = (o.title ?? "recording").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "recording";
|
|
45
105
|
this.file = join(dir, `${slug}-${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)}.mp4`);
|
|
106
|
+
this.raw = this.file.replace(/\.mp4$/, ".raw.mp4");
|
|
107
|
+
this.framesDir = this.file.replace(/\.mp4$/, ".frames");
|
|
108
|
+
rmSync(this.framesDir, { recursive: true, force: true });
|
|
109
|
+
mkdirSync(this.framesDir);
|
|
46
110
|
let pw;
|
|
47
111
|
try {
|
|
48
112
|
const mod = "playwright";
|
|
@@ -64,14 +128,26 @@ export class Recorder {
|
|
|
64
128
|
catch (e) {
|
|
65
129
|
throw new RecorderError("frame_unreachable", `could not open the phone frame page ${o.url}: ${e.message.split("\n")[0]}`);
|
|
66
130
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
131
|
+
if (o.background && this.page.evaluate) {
|
|
132
|
+
let good;
|
|
133
|
+
try {
|
|
134
|
+
good = await this.page.evaluate(backgroundScript(o.background));
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
throw new RecorderError("background_failed", `could not set the background: ${e.message.split("\n")[0]}`);
|
|
138
|
+
}
|
|
139
|
+
if (good !== true)
|
|
140
|
+
throw new RecorderError("invalid_background", `background must be "brand", "plain" or a CSS colour such as "#102030"; got ${JSON.stringify(o.background).slice(0, 60)}`);
|
|
141
|
+
}
|
|
142
|
+
if (this.page.evaluate) {
|
|
143
|
+
try {
|
|
144
|
+
this.lumiOn = (await this.page.evaluate(lumiScript(this.lumi))) === true;
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
}
|
|
72
148
|
await this.grab();
|
|
73
|
-
// The video timeline starts
|
|
74
|
-
this.t0 = Date.now();
|
|
149
|
+
// The video timeline starts at the first frame, not before the first (slow) screenshot.
|
|
150
|
+
this.t0 = this.frames[0] ?? Date.now();
|
|
75
151
|
this.running = true;
|
|
76
152
|
this.starting = false;
|
|
77
153
|
this.loop = (async () => { while (this.running)
|
|
@@ -80,48 +156,176 @@ export class Recorder {
|
|
|
80
156
|
}
|
|
81
157
|
catch (e) {
|
|
82
158
|
await this.cleanup();
|
|
159
|
+
if (this.framesDir)
|
|
160
|
+
rmSync(this.framesDir, { recursive: true, force: true });
|
|
83
161
|
throw e;
|
|
84
162
|
}
|
|
85
163
|
}
|
|
86
164
|
async grab() {
|
|
87
165
|
try {
|
|
88
166
|
const b = await this.page.screenshot({ type: "jpeg", quality: 90 });
|
|
89
|
-
|
|
90
|
-
|
|
167
|
+
const t = Date.now();
|
|
168
|
+
writeFileSync(join(this.framesDir, `${this.frames.length}.jpg`), b);
|
|
169
|
+
this.frames.push(t);
|
|
170
|
+
}
|
|
171
|
+
catch { }
|
|
172
|
+
}
|
|
173
|
+
/** True while a recording is running and taps should be drawn: callers use it to skip the extra locate call. */
|
|
174
|
+
get drawing() { return this.running && this.showTaps; }
|
|
175
|
+
/** Soft finger dot and ripple at p (0..1 of the phone). No-op when not recording or show_taps is false. */
|
|
176
|
+
async tap(p) { await this.draw("tap", p); }
|
|
177
|
+
/** Finger trail from a to b (0..1 of the phone), for swipes and scrolls. */
|
|
178
|
+
async trail(a, b) { await this.draw("trail", a, b); }
|
|
179
|
+
async draw(kind, a, b) {
|
|
180
|
+
if (!this.drawing || !this.page?.evaluate)
|
|
181
|
+
return;
|
|
182
|
+
try {
|
|
183
|
+
await this.page.evaluate(overlayScript(kind, a, b));
|
|
91
184
|
}
|
|
92
185
|
catch { }
|
|
93
186
|
}
|
|
187
|
+
/** The agent did something now. Gaps between actions longer than MAX_IDLE_S are cut from the video. */
|
|
188
|
+
action() { if (this.running) {
|
|
189
|
+
const t = Date.now() - this.t0;
|
|
190
|
+
this.acts.push([t, t]);
|
|
191
|
+
} }
|
|
192
|
+
/** An action that takes time. Its whole span is kept; call the returned function when it ends. */
|
|
193
|
+
actionSpan() { if (!this.running)
|
|
194
|
+
return () => { }; const a = Date.now() - this.t0; return () => { if (this.running)
|
|
195
|
+
this.acts.push([a, Date.now() - this.t0]); }; }
|
|
94
196
|
marker(caption) {
|
|
95
197
|
if (!this.running)
|
|
96
198
|
throw new RecorderError("no_recording", "no recording is running: call record_start first");
|
|
199
|
+
this.action();
|
|
200
|
+
void this.page?.evaluate?.(captionScript(caption))?.catch?.(() => { });
|
|
97
201
|
const last = this.chapters.at(-1)?.t ?? -0.001;
|
|
98
202
|
const c = { t: Math.max(Math.round(Date.now() - this.t0) / 1000, Math.round((last + 0.001) * 1000) / 1000), caption };
|
|
99
203
|
this.chapters.push(c);
|
|
100
204
|
return c;
|
|
101
205
|
}
|
|
102
|
-
async stop() {
|
|
206
|
+
async stop(o = {}) {
|
|
103
207
|
if (!this.running)
|
|
104
208
|
throw new RecorderError("no_recording", "no recording is running: call record_start first");
|
|
105
209
|
this.running = false;
|
|
106
210
|
this.stopping = true;
|
|
107
211
|
try {
|
|
108
|
-
const { file,
|
|
212
|
+
const { file, raw, size, t0, fps } = this;
|
|
213
|
+
let chapters = this.chapters;
|
|
214
|
+
let drop = (_t) => 0;
|
|
109
215
|
await this.loop;
|
|
216
|
+
let steps = [];
|
|
217
|
+
if (this.lumiVoice && this.page?.evaluate) {
|
|
218
|
+
try {
|
|
219
|
+
const r = await this.page.evaluate("window.__shipvoSteps || []");
|
|
220
|
+
if (Array.isArray(r))
|
|
221
|
+
steps = r;
|
|
222
|
+
}
|
|
223
|
+
catch { }
|
|
224
|
+
}
|
|
110
225
|
await this.grab();
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
const err = this.ffErr;
|
|
115
|
-
const out = { file, duration_s, ...size, chapters };
|
|
226
|
+
const total = Date.now() - t0;
|
|
227
|
+
let duration_s = Math.round(total) / 1000;
|
|
228
|
+
const end = t0 + total;
|
|
116
229
|
await this.cleanup();
|
|
230
|
+
let code = await this.encode(end);
|
|
231
|
+
let err = this.ffErr;
|
|
232
|
+
if (code === 0) {
|
|
233
|
+
// Cut every wait longer than MAX_IDLE_S: keep it after each action, drop the rest up to the next one.
|
|
234
|
+
// Keep MAX_IDLE_S after each action ends and drop the rest up to the next one starts. Time inside an action and the tail after the last one are kept.
|
|
235
|
+
const spans = [[0, 0], ...this.acts].sort((x, y) => x[0] - y[0]);
|
|
236
|
+
const cuts = [];
|
|
237
|
+
let busy = 0;
|
|
238
|
+
for (const [a, e] of spans) {
|
|
239
|
+
if (a - busy > MAX_IDLE_S * 1000 + 50)
|
|
240
|
+
cuts.push([busy + MAX_IDLE_S * 1000, a]);
|
|
241
|
+
busy = Math.max(busy, e);
|
|
242
|
+
}
|
|
243
|
+
if (cuts.length) {
|
|
244
|
+
drop = (t) => cuts.reduce((n, [a, b]) => n + Math.max(0, Math.min(t, b) - a), 0);
|
|
245
|
+
const expr = cuts.map(([a, b]) => `between(t,${a / 1000},${b / 1000})`).join("+");
|
|
246
|
+
const r = spawnSync("ffmpeg", ["-y", "-loglevel", "error", "-i", raw, "-vf", `select='not(${expr})',setpts=N/(${fps}*TB)`, "-r", String(fps), "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-movflags", "+faststart", file]);
|
|
247
|
+
if (r.status !== 0) {
|
|
248
|
+
code = r.status ?? -1;
|
|
249
|
+
err = String(r.stderr);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
chapters = chapters.map((c) => ({ ...c, t: Math.round(c.t * 1000 - drop(c.t * 1000)) / 1000 }));
|
|
253
|
+
duration_s = Math.round(total - drop(total)) / 1000;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else if (existsSync(raw))
|
|
257
|
+
renameSync(raw, file);
|
|
258
|
+
rmSync(raw, { force: true });
|
|
259
|
+
}
|
|
260
|
+
const out = { file, duration_s, ...size, chapters, lumi: this.lumiOn };
|
|
117
261
|
if (code !== 0)
|
|
118
262
|
throw new RecorderError("encode_failed", `ffmpeg failed${code === -1 ? " to start (is it installed?)" : ""}: ${err.trim().slice(0, 300)}`);
|
|
263
|
+
let vo;
|
|
264
|
+
let narration = o.narration;
|
|
265
|
+
let voice;
|
|
266
|
+
if (this.lumiVoice && !narration) {
|
|
267
|
+
const cues = voiceCues(steps, t0, (ms) => ms - drop(ms), duration_s);
|
|
268
|
+
if (cues.length) {
|
|
269
|
+
// A failed voice must not throw away the finished recording: keep the silent video (music still applies) and say why.
|
|
270
|
+
try {
|
|
271
|
+
voice = narration = lumiVoice(file, cues, duration_s);
|
|
272
|
+
out.lumi_lines = cues.length;
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
if (!(e instanceof RecorderError) || e.code !== "voice_failed")
|
|
276
|
+
throw e;
|
|
277
|
+
(out.notes ??= []).push(e.message);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else
|
|
281
|
+
(out.notes ??= []).push("lumi_voice was on but Lumi showed no step lines to speak");
|
|
282
|
+
}
|
|
283
|
+
else if (this.lumiVoice)
|
|
284
|
+
(out.notes ??= []).push("lumi_voice was ignored because narration was given");
|
|
285
|
+
try {
|
|
286
|
+
if (narration) {
|
|
287
|
+
const r = addNarration(file, narration, !!o.music);
|
|
288
|
+
vo = r.vo;
|
|
289
|
+
if (r.trimmed)
|
|
290
|
+
(out.notes ??= []).push(`narration was ${r.alen.toFixed(1)} s and the video is ${r.vlen.toFixed(1)} s: the narration was trimmed to the video length`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
if (voice)
|
|
295
|
+
rmSync(voice, { force: true });
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
if (o.music)
|
|
299
|
+
out.credits = addMusic(file, vo);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
if (vo)
|
|
303
|
+
rmSync(vo, { force: true });
|
|
304
|
+
}
|
|
119
305
|
return out;
|
|
120
306
|
}
|
|
121
307
|
finally {
|
|
308
|
+
rmSync(this.framesDir, { recursive: true, force: true });
|
|
122
309
|
this.stopping = false;
|
|
123
310
|
}
|
|
124
311
|
}
|
|
312
|
+
/** Encode the saved frames, each shown from the time it was taken until the next one. Resolves to ffmpeg's exit code (-1 if it did not start). */
|
|
313
|
+
encode(end) {
|
|
314
|
+
const n = this.frames.length;
|
|
315
|
+
if (!n) {
|
|
316
|
+
this.ffErr = "no frames were captured";
|
|
317
|
+
return Promise.resolve(-1);
|
|
318
|
+
}
|
|
319
|
+
// Names are slugged to [a-z0-9-] in start(), so the quoted paths in this list never hold a single quote.
|
|
320
|
+
const lines = this.frames.map((t, i) => `file '${i}.jpg'\nduration ${(Math.max((this.frames[i + 1] ?? end) - t, 1) / 1000).toFixed(3)}`);
|
|
321
|
+
lines.push(`file '${n - 1}.jpg'`);
|
|
322
|
+
const list = join(this.framesDir, "list.txt");
|
|
323
|
+
writeFileSync(list, lines.join("\n") + "\n");
|
|
324
|
+
const ff = spawn("ffmpeg", ["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", list,
|
|
325
|
+
"-vf", `fps=${this.fps},scale=${this.size.width}:${this.size.height},format=yuv420p`, "-c:v", "libx264", "-preset", "veryfast", "-movflags", "+faststart", this.raw], { stdio: ["ignore", "ignore", "pipe"] });
|
|
326
|
+
ff.stderr.on("data", (d) => { this.ffErr += d; });
|
|
327
|
+
return new Promise((r) => { ff.on("error", (e) => { this.ffErr += e.message; r(-1); }); ff.on("close", r); });
|
|
328
|
+
}
|
|
125
329
|
async cleanup() {
|
|
126
330
|
this.running = false;
|
|
127
331
|
this.starting = false;
|
|
@@ -129,15 +333,78 @@ export class Recorder {
|
|
|
129
333
|
await this.loop;
|
|
130
334
|
}
|
|
131
335
|
catch { }
|
|
132
|
-
try {
|
|
133
|
-
this.ff?.stdin?.end();
|
|
134
|
-
this.ff?.kill();
|
|
135
|
-
}
|
|
136
|
-
catch { }
|
|
137
336
|
try {
|
|
138
337
|
await this.browser?.close();
|
|
139
338
|
}
|
|
140
339
|
catch { }
|
|
141
|
-
this.browser = this.page = this.
|
|
340
|
+
this.browser = this.page = this.loop = undefined;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Mix a ducked music bed under the finished MP4 with scripts/video/bgmusic.py and write <name>.credits.json next to it. Returns the credits path.
|
|
344
|
+
* SHIPVO_BGMUSIC points at the script; SHIPVO_MUSIC_FILE uses a local track instead of picking and downloading one. */
|
|
345
|
+
function addMusic(file, narration) {
|
|
346
|
+
const script = process.env.SHIPVO_BGMUSIC ?? join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "scripts", "video", "bgmusic.py");
|
|
347
|
+
if (!existsSync(script))
|
|
348
|
+
throw new RecorderError("music_unavailable", `music needs bgmusic.py, not found at ${script}: set SHIPVO_BGMUSIC`);
|
|
349
|
+
const credits = file.replace(/\.mp4$/, ".credits.json"), work = file.replace(/\.mp4$/, ".music"), tmp = join(work, "mixed.mp4");
|
|
350
|
+
mkdirSync(work, { recursive: true });
|
|
351
|
+
try {
|
|
352
|
+
const track = process.env.SHIPVO_MUSIC_FILE;
|
|
353
|
+
const args = track ? ["mix", "--music", track] : ["auto", "--want", "cinematic,documentary,corporate", "--outdir", join(work, "tracks")];
|
|
354
|
+
const r = spawnSync("python3", [script, ...args, "--video", file, ...(narration ? ["--narration", narration] : []), "--out", tmp, "--work", work, "--credits", credits], { encoding: "utf8" });
|
|
355
|
+
if (r.error || r.status !== 0 || !existsSync(tmp))
|
|
356
|
+
throw new RecorderError("music_failed", `could not mix music${r.error ? " (is python3 installed?)" : ""}: ${String(r.error?.message ?? r.stderr).trim().split("\n").slice(-2).join(" ").slice(0, 300)}. The silent video is at ${file}`);
|
|
357
|
+
renameSync(tmp, file);
|
|
358
|
+
return credits;
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
rmSync(work, { recursive: true, force: true });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** Speak the cues with Lumi's voice: scripts/video/dialogue.py with the `energetic` preset. SHIPVO_DIALOGUE points at the script. Returns the wav path (next to the video). */
|
|
365
|
+
export function lumiVoice(file, cues, total) {
|
|
366
|
+
const script = process.env.SHIPVO_DIALOGUE ?? join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "scripts", "video", "dialogue.py");
|
|
367
|
+
const bad = (why) => new RecorderError("voice_failed", `${why}. The silent video is at ${file}`);
|
|
368
|
+
if (!existsSync(script))
|
|
369
|
+
throw bad(`lumi_voice needs dialogue.py, not found at ${script}: set SHIPVO_DIALOGUE`);
|
|
370
|
+
const work = file.replace(/\.mp4$/, ".voice");
|
|
371
|
+
mkdirSync(work, { recursive: true });
|
|
372
|
+
try {
|
|
373
|
+
const spec = join(work, "dialogue.json");
|
|
374
|
+
writeFileSync(spec, JSON.stringify({ cast: { lumi: { voice: "af_jessica", speed: 1.1, fx: "energetic" } }, cues }));
|
|
375
|
+
const r = spawnSync("python3", ["-I", script, spec, "--total", total.toFixed(3), "--out", work], { encoding: "utf8" });
|
|
376
|
+
const wav = join(work, "vo.wav");
|
|
377
|
+
if (r.error || r.status !== 0 || !existsSync(wav))
|
|
378
|
+
throw bad(`could not make Lumi's voice${r.error ? " (is python3 installed?)" : ""}: ${String(r.error?.message ?? r.stderr).trim().split("\n").slice(-2).join(" ").slice(0, 300)}`);
|
|
379
|
+
const out = file.replace(/\.mp4$/, ".lumi-voice.wav");
|
|
380
|
+
renameSync(wav, out);
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
rmSync(work, { recursive: true, force: true });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const probeDur = (f) => { const r = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", f], { encoding: "utf8" }); const d = parseFloat(r.stdout); return r.status === 0 && d > 0 ? d : NaN; };
|
|
388
|
+
/** Add a narration wav to the silent MP4. The wav is cut to the video length and padded with silence if shorter, so the audio matches the video.
|
|
389
|
+
* With `forMusic` the trimmed wav is left next to the video for the music mixer (the caller deletes it) and the video is not changed. */
|
|
390
|
+
function addNarration(file, wav, forMusic) {
|
|
391
|
+
const bad = (why) => new RecorderError("narration_unreadable", `${why}. The silent video is at ${file}`);
|
|
392
|
+
if (!existsSync(wav))
|
|
393
|
+
throw bad(`narration file not found: ${wav}`);
|
|
394
|
+
const alen = probeDur(wav), vlen = probeDur(file);
|
|
395
|
+
if (!(alen > 0))
|
|
396
|
+
throw bad(`narration file is not readable audio: ${wav}`);
|
|
397
|
+
const trimmed = alen > vlen + 0.05;
|
|
398
|
+
const tmp = file.replace(/\.mp4$/, forMusic ? ".narration.wav" : ".narrated.mp4");
|
|
399
|
+
const args = forMusic ? ["-i", wav, "-t", vlen.toFixed(3), tmp]
|
|
400
|
+
: ["-i", file, "-i", wav, "-map", "0:v", "-map", "1:a", "-af", "apad", "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-t", vlen.toFixed(3), "-movflags", "+faststart", tmp];
|
|
401
|
+
const r = spawnSync("ffmpeg", ["-y", "-loglevel", "error", ...args]);
|
|
402
|
+
if (r.status !== 0 || !existsSync(tmp)) {
|
|
403
|
+
rmSync(tmp, { force: true });
|
|
404
|
+
throw bad(`could not add narration: ${String(r.stderr).trim().split("\n").slice(-1)[0].slice(0, 200)}`);
|
|
142
405
|
}
|
|
406
|
+
if (forMusic)
|
|
407
|
+
return { trimmed, alen, vlen, vo: tmp };
|
|
408
|
+
renameSync(tmp, file);
|
|
409
|
+
return { trimmed, alen, vlen };
|
|
143
410
|
}
|