@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 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. | Show the plan to the owner, then call again with `confirm: true`. |
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
- **Owner commands that cannot be undone need `confirm: true`.** `owner_command` takes an optional `confirm`. `publish`, price changes, offers, deletes and API keys first return `{ code: "needs_confirm", plan }` and do nothing; call again with the same `args` and `confirm: true` to run. `agentPolicy` (`ask` default, `deny`, `allow`) and `agentDeny` in `shipvo.json` (read from `PN_APP_DIR` or the current folder) can only tighten the control plane's rules. Details: `docs/agents.md`.
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. Not done yet: `ttlMs`/`cacheScope` on list results. `tools/list` order is fixed (registration order).
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) => { clearTimeout(t); res(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
  };
@@ -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 done?;
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(): Promise<Recording>;
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
- // Frames carry wall-clock timestamps, so the video lasts as long as the recording even when screenshots are slow.
3
- // The recording clock (duration_s, chapter times) and the video both start at the first frame.
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
- done;
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
- this.ff = spawn("ffmpeg", ["-y", "-loglevel", "error", "-use_wallclock_as_timestamps", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-i", "-",
68
- "-vf", `fps=${this.fps},scale=${this.size.width}:${this.size.height},format=yuv420p`, "-c:v", "libx264", "-preset", "veryfast", "-movflags", "+faststart", this.file], { stdio: ["pipe", "ignore", "pipe"] });
69
- this.ff.stderr.on("data", (d) => { this.ffErr += d; });
70
- this.ff.stdin.on("error", () => { });
71
- this.done = new Promise((r) => { this.ff.on("error", (e) => { this.ffErr += e.message; r(-1); }); this.ff.on("close", r); });
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 when ffmpeg gets the first frame, not before the first (slow) screenshot.
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
- if (this.ff?.stdin?.writable)
90
- this.ff.stdin.write(b);
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, size, chapters, ff, done, t0 } = this;
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 duration_s = Math.round((Date.now() - t0)) / 1000;
112
- ff.stdin.end();
113
- const code = await done;
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.ff = this.loop = undefined;
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
  }