linkshell-cli 0.8.1 → 0.8.2

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.
@@ -0,0 +1,62 @@
1
+ /** How the picture is sent, best first. A gateway carries messages rather than video: relayed, it starts lower. */
2
+ export declare const LADDER: readonly [{
3
+ readonly fps: 20;
4
+ readonly width: 1600;
5
+ readonly bitrate: "3M";
6
+ readonly ceiling: "4M";
7
+ }, {
8
+ readonly fps: 15;
9
+ readonly width: 1440;
10
+ readonly bitrate: "1500k";
11
+ readonly ceiling: "2M";
12
+ }, {
13
+ readonly fps: 12;
14
+ readonly width: 1280;
15
+ readonly bitrate: "900k";
16
+ readonly ceiling: "1300k";
17
+ }, {
18
+ readonly fps: 10;
19
+ readonly width: 1024;
20
+ readonly bitrate: "500k";
21
+ readonly ceiling: "700k";
22
+ }, {
23
+ readonly fps: 8;
24
+ readonly width: 854;
25
+ readonly bitrate: "260k";
26
+ readonly ceiling: "380k";
27
+ }];
28
+ /** The level a relayed stream starts at, and never goes above. */
29
+ export declare const RELAY_LEVEL = 2;
30
+ export declare class Pacer {
31
+ private seq;
32
+ private shown;
33
+ private readonly sentAt;
34
+ /** The path's own delay: the least a frame has taken to come back as shown. */
35
+ private floor;
36
+ private behindSince?;
37
+ private troubles;
38
+ private calmSince;
39
+ private calmFor;
40
+ private raisedAt?;
41
+ private firstSent?;
42
+ private heard;
43
+ private settledAt;
44
+ private lastSent;
45
+ constructor(now: number);
46
+ /** Whether frames are being dropped for the viewer to catch up. */
47
+ get dropping(): boolean;
48
+ /** How long ago the oldest frame not yet shown was sent. */
49
+ private lag;
50
+ /** The number to send this frame under, or undefined to drop it. */
51
+ next(key: boolean, now: number): number | undefined;
52
+ /** The viewer has shown everything up to `seq`. */
53
+ ack(seq: number, now: number): void;
54
+ /** Whether to move to a lighter picture ("down") or try a better one ("up"); asked after each frame. */
55
+ advice(now: number, canGoUp: boolean, canGoDown: boolean): "up" | "down" | undefined;
56
+ /**
57
+ * The capture was started again (another level). The viewer drops what it had of the old stream
58
+ * without showing it, so none of that will ever be answered for: the new stream starts clean, with
59
+ * its keyframe, and is judged on its own frames once what was on the way has had time to arrive.
60
+ */
61
+ restarted(now: number): void;
62
+ }
@@ -0,0 +1,143 @@
1
+ // Keeping a viewer close to live. The picture goes down pipes that deliver
2
+ // everything, in order (the gateway's relay, a data channel, a socket): when
3
+ // the network can't carry the stream, nothing is lost, it queues, and the
4
+ // phone ends up watching the past. So the viewer says which frame it has shown,
5
+ // and the host, knowing how far behind that is, stops adding to the queue
6
+ // (frames are dropped here, before anything relays them), resumes at a
7
+ // keyframe once the path has caught up, and moves to a lighter picture when
8
+ // that keeps happening.
9
+ /** How the picture is sent, best first. A gateway carries messages rather than video: relayed, it starts lower. */
10
+ export const LADDER = [
11
+ { fps: 20, width: 1600, bitrate: "3M", ceiling: "4M" },
12
+ { fps: 15, width: 1440, bitrate: "1500k", ceiling: "2M" },
13
+ { fps: 12, width: 1280, bitrate: "900k", ceiling: "1300k" },
14
+ { fps: 10, width: 1024, bitrate: "500k", ceiling: "700k" },
15
+ { fps: 8, width: 854, bitrate: "260k", ceiling: "380k" },
16
+ ];
17
+ /** The level a relayed stream starts at, and never goes above. */
18
+ export const RELAY_LEVEL = 2;
19
+ /** Behind by this much more than the path's own delay: stop sending. */
20
+ const BEHIND_MS = 450;
21
+ /** Caught up to within this of the path's own delay: resume (at a keyframe). */
22
+ const CAUGHT_UP_MS = 150;
23
+ /** Stuck behind for this long, or behind twice within TROUBLE_WINDOW_MS: a lighter picture. */
24
+ const STUCK_MS = 2500;
25
+ const TROUBLE_WINDOW_MS = 10_000;
26
+ /** Smooth for this long: try a better picture. Doubles each time the better one didn't hold. */
27
+ const CALM_MS = 30_000;
28
+ const CALM_MAX_MS = 240_000;
29
+ /** A viewer that never says what it has shown is not paced at all. */
30
+ const SILENT_MS = 5000;
31
+ /** After a change of picture, what was already on its way gets this long to arrive before more is concluded. */
32
+ const SETTLE_MS = 4000;
33
+ /** No path's own delay is taken to be longer than this: a stream that began on a choked path would look normal. */
34
+ const FLOOR_MAX_MS = 800;
35
+ /** Behind, and nothing heard for this long: a keyframe goes anyway. Whatever went wrong, the next answer ends it. */
36
+ const PROBE_MS = 8000;
37
+ export class Pacer {
38
+ seq = 0;
39
+ shown = 0;
40
+ sentAt = new Map();
41
+ /** The path's own delay: the least a frame has taken to come back as shown. */
42
+ floor = Infinity;
43
+ behindSince;
44
+ troubles = [];
45
+ calmSince;
46
+ calmFor = CALM_MS;
47
+ raisedAt;
48
+ firstSent;
49
+ heard = false;
50
+ settledAt = 0;
51
+ lastSent = 0;
52
+ constructor(now) {
53
+ this.calmSince = now;
54
+ }
55
+ /** Whether frames are being dropped for the viewer to catch up. */
56
+ get dropping() {
57
+ return this.behindSince !== undefined;
58
+ }
59
+ /** How long ago the oldest frame not yet shown was sent. */
60
+ lag(now) {
61
+ const oldest = this.sentAt.get(this.shown + 1);
62
+ return oldest === undefined ? 0 : now - oldest;
63
+ }
64
+ /** The number to send this frame under, or undefined to drop it. */
65
+ next(key, now) {
66
+ this.firstSent ??= now;
67
+ const paced = this.heard || now - this.firstSent < SILENT_MS;
68
+ if (paced) {
69
+ const own = Number.isFinite(this.floor) ? Math.min(this.floor, FLOOR_MAX_MS) : 0;
70
+ const lag = this.lag(now);
71
+ if (this.behindSince === undefined && lag > own + BEHIND_MS) {
72
+ this.behindSince = now;
73
+ this.troubles.push(now);
74
+ this.calmSince = now;
75
+ }
76
+ if (this.behindSince !== undefined) {
77
+ // Back in at a keyframe, once what was sent has been shown (or nearly).
78
+ const caughtUp = this.sentAt.size === 0 || lag <= own + CAUGHT_UP_MS;
79
+ if (!key || (!caughtUp && now - this.lastSent < PROBE_MS))
80
+ return undefined;
81
+ if (caughtUp) {
82
+ this.behindSince = undefined;
83
+ this.calmSince = now;
84
+ }
85
+ }
86
+ }
87
+ const seq = ++this.seq;
88
+ this.sentAt.set(seq, now);
89
+ this.lastSent = now;
90
+ // A viewer that says nothing must not make this grow for ever.
91
+ if (this.sentAt.size > 600)
92
+ this.sentAt.delete(this.sentAt.keys().next().value);
93
+ return seq;
94
+ }
95
+ /** The viewer has shown everything up to `seq`. */
96
+ ack(seq, now) {
97
+ if (!Number.isInteger(seq) || seq <= this.shown || seq > this.seq)
98
+ return;
99
+ this.heard = true;
100
+ const sent = this.sentAt.get(seq);
101
+ if (sent !== undefined)
102
+ this.floor = Math.min(this.floor, now - sent);
103
+ for (const key of this.sentAt.keys()) {
104
+ if (key > seq)
105
+ break;
106
+ this.sentAt.delete(key);
107
+ }
108
+ this.shown = seq;
109
+ }
110
+ /** Whether to move to a lighter picture ("down") or try a better one ("up"); asked after each frame. */
111
+ advice(now, canGoUp, canGoDown) {
112
+ if (now < this.settledAt)
113
+ return undefined;
114
+ this.troubles = this.troubles.filter((at) => at >= this.settledAt && now - at < TROUBLE_WINDOW_MS);
115
+ const stuck = this.behindSince !== undefined && now - Math.max(this.behindSince, this.settledAt) > STUCK_MS;
116
+ if (canGoDown && (stuck || this.troubles.length >= 2)) {
117
+ // The better picture just tried didn't hold: wait longer before trying it again.
118
+ if (this.raisedAt !== undefined && now - this.raisedAt < TROUBLE_WINDOW_MS * 2)
119
+ this.calmFor = Math.min(this.calmFor * 2, CALM_MAX_MS);
120
+ this.raisedAt = undefined;
121
+ return "down";
122
+ }
123
+ if (canGoUp && this.behindSince === undefined && now - this.calmSince > this.calmFor) {
124
+ this.raisedAt = now;
125
+ return "up";
126
+ }
127
+ return undefined;
128
+ }
129
+ /**
130
+ * The capture was started again (another level). The viewer drops what it had of the old stream
131
+ * without showing it, so none of that will ever be answered for: the new stream starts clean, with
132
+ * its keyframe, and is judged on its own frames once what was on the way has had time to arrive.
133
+ */
134
+ restarted(now) {
135
+ this.sentAt.clear();
136
+ this.shown = this.seq;
137
+ this.behindSince = undefined;
138
+ this.troubles = [];
139
+ this.calmSince = now;
140
+ this.settledAt = now + SETTLE_MS;
141
+ }
142
+ }
143
+ //# sourceMappingURL=screen-pacer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"screen-pacer.js","sourceRoot":"","sources":["../../../../host/src/screen-pacer.ts"],"names":[],"mappings":"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"}
@@ -107,7 +107,18 @@ function sps(unit) {
107
107
  if (unit[i] === 0 && unit[i + 1] === 0 && unit[i + 2] === 1 && (unit[i + 3] & 0x1f) === 7) return unit.subarray(i + 3);
108
108
  }
109
109
  if (!("VideoDecoder" in window)) say("这个系统版本的浏览器内核不支持视频解码,请升级系统后再试。");
110
- let decoder;
110
+ let decoder, skipping = false, arrived = 0;
111
+ // The host is told which frame is on screen: that is how it knows when this end has fallen behind,
112
+ // and stops sending rather than let the picture drift into the past.
113
+ let lastShown = 0, telling = 0;
114
+ // For trying a slow path out without one: ?lag=2500&lagFor=12 answers 2.5 s late for the first 12 s.
115
+ const lag = Number(query.get("lag")) || 0, lagUntil = performance.now() + (Number(query.get("lagFor")) || 0) * 1000;
116
+ function shownUpTo(seq) {
117
+ if (seq > lastShown) lastShown = seq;
118
+ if (lag && performance.now() < lagUntil) { const late = lastShown; setTimeout(() => send({ t: "ack", n: late }), lag); return; }
119
+ if (!telling) telling = setTimeout(() => { telling = 0; send({ t: "ack", n: lastShown }); }, 40);
120
+ }
121
+ let lighterAt = -Infinity;
111
122
  const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/stream" + location.search);
112
123
  ws.binaryType = "arraybuffer";
113
124
  const send = (message) => { if (ws.readyState === 1) ws.send(JSON.stringify(message)); };
@@ -115,9 +126,15 @@ ws.onopen = () => { if (mode !== "view") askControl(); };
115
126
  ws.onmessage = (event) => {
116
127
  if (typeof event.data === "string") return heard(JSON.parse(event.data));
117
128
  if (!("VideoDecoder" in window)) return;
118
- const bytes = new Uint8Array(event.data);
119
- const key = bytes[0] === 1;
120
- const unit = bytes.subarray(1);
129
+ // A frame: whether it is a keyframe, its number, the picture.
130
+ const head = new DataView(event.data);
131
+ const key = head.getUint8(0) === 1;
132
+ const seq = head.getUint32(1);
133
+ const unit = new Uint8Array(event.data, 5);
134
+ arrived = seq;
135
+ // A decoder that can't keep up skips to the next keyframe; what it skips has still arrived.
136
+ if (decoder && !key && (skipping || decoder.decodeQueueSize > 8)) { skipping = true; return shownUpTo(seq); }
137
+ skipping = false;
121
138
  if (!decoder) {
122
139
  const params = key && sps(unit);
123
140
  if (!params) return;
@@ -129,6 +146,7 @@ ws.onmessage = (event) => {
129
146
  layout();
130
147
  }
131
148
  ctx.drawImage(frame, 0, 0);
149
+ shownUpTo(frame.timestamp);
132
150
  frame.close();
133
151
  say("");
134
152
  },
@@ -136,12 +154,24 @@ ws.onmessage = (event) => {
136
154
  });
137
155
  decoder.configure({ codec: "avc1." + hex(params[1]) + hex(params[2]) + hex(params[3]), optimizeForLatency: true });
138
156
  }
139
- decoder.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: performance.now() * 1000, data: unit }));
157
+ decoder.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: seq, data: unit }));
140
158
  };
141
159
  ws.onclose = () => { if (note.classList.contains("gone") || note.textContent.startsWith("正在")) say("屏幕连接已断开"); };
142
160
 
143
161
  function heard(message) {
144
162
  if (message.error) return say(message.error);
163
+ if (message.restart) {
164
+ // The host starts the stream again at another quality: a new decoder for it.
165
+ try { if (decoder) decoder.close(); } catch {}
166
+ decoder = undefined;
167
+ skipping = false;
168
+ // What the old decoder still held will never be shown: it has arrived, and that is said.
169
+ shownUpTo(arrived);
170
+ if (message.lighter && performance.now() - lighterAt > 30000) {
171
+ lighterAt = performance.now();
172
+ hint("网络较慢:已降低画质,优先保证跟得上", 3000);
173
+ }
174
+ }
145
175
  if (message.control) {
146
176
  const was = controlling();
147
177
  Object.assign(control, { known: true, reason: "" }, message.control);
@@ -1 +1 @@
1
- {"version":3,"file":"screen-viewer.js","sourceRoot":"","sources":["../../../../host/src/screen-viewer.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,2EAA2E;AAC3E,6EAA6E;AAC7E,mEAAmE;AACnE,EAAE;AACF,4DAA4D;AAC5D,yEAAyE;AACzE,2CAA2C;AAC3C,6EAA6E;AAC7E,+EAA+E;AAC/E,2CAA2C;AAC3C,6FAA6F;AAE7F,MAAM,IAAI,GAAG,CAAC,IAAY,EAAE,EAAE,CAC5B,uJAAuJ,IAAI,QAAQ,CAAC;AAEtK,MAAM,KAAK,GAAG;IACZ,IAAI,EAAE,IAAI,CAAC,qHAAqH,CAAC;IACjI,QAAQ,EAAE,IAAI,CAAC,6DAA6D,CAAC;IAC7E,KAAK,EAAE,IAAI,CAAC,oGAAoG,CAAC;IACjH,QAAQ,EAAE,IAAI,CAAC,iJAAiJ,CAAC;IACjK,GAAG,EAAE,IAAI,CAAC,0EAA0E,CAAC;IACrF,MAAM,EAAE,IAAI,CAAC,qHAAqH,CAAC;IACnI,MAAM,EAAE,IAAI,CAAC,oDAAoD,CAAC;IAClE,MAAM,EAAE,IAAI,CAAC,oDAAoD,CAAC;IAClE,IAAI,EAAE,IAAI,CAAC,4BAA4B,CAAC;CACzC,CAAC;AAEF,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCvB,CAAC;AAEF,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmqBxB,CAAC;AAEF,MAAM,UAAU,UAAU;IACxB,OAAO;;;;SAIA,KAAK;;;;;;;;;;;;kEAYoD,KAAK,CAAC,QAAQ;+DACjB,KAAK,CAAC,GAAG;gEACR,KAAK,CAAC,MAAM;;;;;;;;;;;;;;;;;;6CAkB/B,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;UACxD,MAAM;eACD,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"screen-viewer.js","sourceRoot":"","sources":["../../../../host/src/screen-viewer.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,2EAA2E;AAC3E,6EAA6E;AAC7E,mEAAmE;AACnE,EAAE;AACF,4DAA4D;AAC5D,yEAAyE;AACzE,2CAA2C;AAC3C,6EAA6E;AAC7E,+EAA+E;AAC/E,2CAA2C;AAC3C,6FAA6F;AAE7F,MAAM,IAAI,GAAG,CAAC,IAAY,EAAE,EAAE,CAC5B,uJAAuJ,IAAI,QAAQ,CAAC;AAEtK,MAAM,KAAK,GAAG;IACZ,IAAI,EAAE,IAAI,CAAC,qHAAqH,CAAC;IACjI,QAAQ,EAAE,IAAI,CAAC,6DAA6D,CAAC;IAC7E,KAAK,EAAE,IAAI,CAAC,oGAAoG,CAAC;IACjH,QAAQ,EAAE,IAAI,CAAC,iJAAiJ,CAAC;IACjK,GAAG,EAAE,IAAI,CAAC,0EAA0E,CAAC;IACrF,MAAM,EAAE,IAAI,CAAC,qHAAqH,CAAC;IACnI,MAAM,EAAE,IAAI,CAAC,oDAAoD,CAAC;IAClE,MAAM,EAAE,IAAI,CAAC,oDAAoD,CAAC;IAClE,IAAI,EAAE,IAAI,CAAC,4BAA4B,CAAC;CACzC,CAAC;AAEF,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCvB,CAAC;AAEF,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAisBxB,CAAC;AAEF,MAAM,UAAU,UAAU;IACxB,OAAO;;;;SAIA,KAAK;;;;;;;;;;;;kEAYoD,KAAK,CAAC,QAAQ;+DACjB,KAAK,CAAC,GAAG;gEACR,KAAK,CAAC,MAAM;;;;;;;;;;;;;;;;;;6CAkB/B,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;UACxD,MAAM;eACD,CAAC;AAChB,CAAC"}
@@ -3,25 +3,6 @@ export interface Display {
3
3
  index: number;
4
4
  name: string;
5
5
  }
6
- /**
7
- * How the picture is sent. `full` when it goes straight to the device; `low`
8
- * when it is relayed by a gateway, which should carry messages rather than video.
9
- */
10
- declare const PROFILES: {
11
- readonly full: {
12
- readonly fps: 20;
13
- readonly width: 1600;
14
- readonly bitrate: "3M";
15
- readonly ceiling: "4M";
16
- };
17
- readonly low: {
18
- readonly fps: 12;
19
- readonly width: 1280;
20
- readonly bitrate: "900k";
21
- readonly ceiling: "1300k";
22
- };
23
- };
24
- export type ScreenProfile = keyof typeof PROFILES;
25
6
  export interface ScreenAccess {
26
7
  supported: boolean;
27
8
  ffmpeg: boolean;
@@ -30,7 +11,8 @@ export interface ScreenAccess {
30
11
  app?: string;
31
12
  problem?: string;
32
13
  }
33
- export declare function captureArgs(display: number, quality?: ScreenProfile): string[];
14
+ /** The capture for one rung of the ladder (see `screen-pacer.ts`): 0 is the best picture. */
15
+ export declare function captureArgs(display: number, level?: number): string[];
34
16
  /** Splits an Annex-B stream at access-unit delimiters (NAL type 9) into whole frames. */
35
17
  export declare class AccessUnitSplitter {
36
18
  private buffer;
@@ -57,8 +39,13 @@ export declare class ScreenShare {
57
39
  private token;
58
40
  private displays;
59
41
  private readonly captures;
60
- /** Captures the app runs for this host: each one's way to end it. */
61
- private readonly stops;
42
+ /**
43
+ * The capture that has the screen. The system gives it to one at a time, and
44
+ * one started while another still holds it never gets a frame, even after
45
+ * the other has gone: the next starts once this one has.
46
+ */
47
+ private holder?;
48
+ private turn;
62
49
  private readonly controls;
63
50
  constructor(log: (message: string) => void);
64
51
  start(): Promise<{
@@ -74,9 +61,10 @@ export declare class ScreenShare {
74
61
  * `ask`, the system asks for the first permission missing.
75
62
  */
76
63
  access(ask: boolean): Promise<ScreenAccess>;
77
- /** Starts a capture where it may record; resolves to the way to end it. */
64
+ /** Starts a capture where it may record. Undefined when it may not: the viewer has been told why. */
78
65
  private capture;
66
+ /** Takes the screen for a new capture: whatever holds it is ended, and has left, before the new one starts. */
67
+ private take;
79
68
  private stream;
80
69
  stop(): void;
81
70
  }
82
- export {};
@@ -5,6 +5,7 @@ import { promisify } from "node:util";
5
5
  import { WebSocketServer } from "ws";
6
6
  import { RpcError } from "@linkshell/wire";
7
7
  import { closeInputApp, hostAccess, inputApp, InputControl } from "./input.js";
8
+ import { LADDER, Pacer, RELAY_LEVEL } from "./screen-pacer.js";
8
9
  import { viewerPage } from "./screen-viewer.js";
9
10
  // Viewing the computer's screen from the phone. The host serves a small
10
11
  // viewer page and an H.264 stream on a loopback port; the phone opens it
@@ -18,14 +19,6 @@ import { viewerPage } from "./screen-viewer.js";
18
19
  // carries one, LinkShell.app, and the capture runs as its child: the
19
20
  // permission is LinkShell's, whichever terminal started the host.
20
21
  const run = promisify(execFile);
21
- /**
22
- * How the picture is sent. `full` when it goes straight to the device; `low`
23
- * when it is relayed by a gateway, which should carry messages rather than video.
24
- */
25
- const PROFILES = {
26
- full: { fps: 20, width: 1600, bitrate: "3M", ceiling: "4M" },
27
- low: { fps: 12, width: 1280, bitrate: "900k", ceiling: "1300k" },
28
- };
29
22
  async function hasFfmpeg() {
30
23
  return run("ffmpeg", ["-hide_banner", "-version"], { timeout: 5000 }).then(() => true, () => false);
31
24
  }
@@ -46,8 +39,9 @@ async function listDisplays() {
46
39
  }
47
40
  return displays;
48
41
  }
49
- export function captureArgs(display, quality = "full") {
50
- const profile = PROFILES[quality];
42
+ /** The capture for one rung of the ladder (see `screen-pacer.ts`): 0 is the best picture. */
43
+ export function captureArgs(display, level = 0) {
44
+ const profile = LADDER[Math.min(Math.max(level, 0), LADDER.length - 1)];
51
45
  const input = process.platform === "darwin"
52
46
  ? ["-f", "avfoundation", "-capture_cursor", "1", "-framerate", String(profile.fps), "-i", `${display}:none`]
53
47
  : ["-f", "x11grab", "-framerate", String(profile.fps), "-i", process.env.DISPLAY ?? ":0"];
@@ -67,8 +61,9 @@ export function captureArgs(display, quality = "full") {
67
61
  // ProMotion display): the frames are dropped here, before the encoder and the network pay for them.
68
62
  `fps=${profile.fps},scale='min(${profile.width},iw)':-2,format=yuv420p`,
69
63
  ...encode,
64
+ // A keyframe a second: a viewer that fell behind is back on the live picture within one.
70
65
  "-g",
71
- String(profile.fps * 2),
66
+ String(profile.fps),
72
67
  "-bf",
73
68
  "0",
74
69
  // An access-unit delimiter before every frame, so the stream splits into frames cleanly.
@@ -172,8 +167,13 @@ export class ScreenShare {
172
167
  token = "";
173
168
  displays = [];
174
169
  captures = new Set();
175
- /** Captures the app runs for this host: each one's way to end it. */
176
- stops = new Set();
170
+ /**
171
+ * The capture that has the screen. The system gives it to one at a time, and
172
+ * one started while another still holds it never gets a frame, even after
173
+ * the other has gone: the next starts once this one has.
174
+ */
175
+ holder;
176
+ turn = Promise.resolve();
177
177
  controls = new Set();
178
178
  constructor(log) {
179
179
  this.log = log;
@@ -210,8 +210,9 @@ export class ScreenShare {
210
210
  socket.destroy();
211
211
  return;
212
212
  }
213
- const quality = url.searchParams.get("q") === "low" ? "low" : "full";
214
- sockets.handleUpgrade(request, socket, head, (ws) => this.stream(ws, Number(url.searchParams.get("display") ?? NaN), quality));
213
+ // "low": the viewer is reached through a gateway, which should carry messages rather than video.
214
+ const relayed = url.searchParams.get("q") === "low";
215
+ sockets.handleUpgrade(request, socket, head, (ws) => this.stream(ws, Number(url.searchParams.get("display") ?? NaN), relayed));
215
216
  });
216
217
  this.server = server;
217
218
  return new Promise((resolve, reject) => {
@@ -247,10 +248,20 @@ export class ScreenShare {
247
248
  return { supported, ffmpeg, recording: null, control: null, problem: error.message };
248
249
  }
249
250
  }
250
- /** Starts a capture where it may record; resolves to the way to end it. */
251
- async capture(index, quality, on) {
252
- const args = captureArgs(index, quality);
251
+ /** Starts a capture where it may record. Undefined when it may not: the viewer has been told why. */
252
+ async capture(index, level, on) {
253
+ const args = captureArgs(index, level);
254
+ let ended = false;
255
+ let leave = () => { };
256
+ const gone = new Promise((resolve) => (leave = resolve));
257
+ const data = (chunk) => {
258
+ if (!ended)
259
+ on.data(chunk);
260
+ };
253
261
  const finished = (code, errors) => {
262
+ leave();
263
+ if (ended)
264
+ return;
254
265
  if (code)
255
266
  this.log(`[screen] capture exited ${code}: ${errors.trim()}`);
256
267
  on.exit(!code ? undefined : /permission|not authorized|denied/i.test(errors) ? notAllowed("") : "屏幕捕获失败");
@@ -262,13 +273,16 @@ export class ScreenShare {
262
273
  // Someone may be at the computer: the system's question, and its settings, come up there.
263
274
  void app.ask("recording").catch(() => { });
264
275
  on.exit(notAllowed(status.app));
265
- return () => { };
276
+ return undefined;
266
277
  }
267
- const stop = await app.capture(await ffmpegPath(), args, { data: on.data, exit: finished });
268
- this.stops.add(stop);
269
- return () => {
270
- this.stops.delete(stop);
271
- stop();
278
+ const stop = await app.capture(await ffmpegPath(), args, { data, exit: finished });
279
+ return {
280
+ on,
281
+ gone,
282
+ end: () => {
283
+ ended = true;
284
+ stop();
285
+ },
272
286
  };
273
287
  }
274
288
  if (process.platform === "darwin") {
@@ -277,22 +291,50 @@ export class ScreenShare {
277
291
  if (status && !status.recording) {
278
292
  void hostAccess(this.log, "recording").catch(() => { });
279
293
  on.exit(notAllowed(status.app));
280
- return () => { };
294
+ return undefined;
281
295
  }
282
296
  }
283
297
  const capture = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
284
298
  this.captures.add(capture);
285
299
  let errors = "";
286
- capture.stdout.on("data", on.data);
300
+ capture.stdout.on("data", data);
287
301
  capture.stderr.on("data", (chunk) => (errors = (errors + chunk.toString()).slice(-2000)));
288
302
  capture.on("error", (error) => finished(-1, error.message));
289
303
  capture.on("exit", (code) => {
290
304
  this.captures.delete(capture);
291
305
  finished(code ?? 0, errors);
292
306
  });
293
- return () => endCapture(capture);
307
+ return {
308
+ on,
309
+ gone,
310
+ end: () => {
311
+ ended = true;
312
+ endCapture(capture);
313
+ },
314
+ };
315
+ }
316
+ /** Takes the screen for a new capture: whatever holds it is ended, and has left, before the new one starts. */
317
+ take(index, level, on, wanted) {
318
+ const mine = this.turn.then(async () => {
319
+ const previous = this.holder;
320
+ if (previous) {
321
+ this.holder = undefined;
322
+ previous.end();
323
+ // Another device's picture ends here; a viewer changing its own just carries on.
324
+ if (previous.on !== on)
325
+ previous.on.exit("屏幕画面被另一台设备接手了");
326
+ await Promise.race([previous.gone, new Promise((resolve) => setTimeout(resolve, 3000))]);
327
+ }
328
+ if (!wanted())
329
+ return undefined;
330
+ const running = await this.capture(index, level, on);
331
+ this.holder = running;
332
+ return running;
333
+ });
334
+ this.turn = mine.catch(() => { });
335
+ return mine;
294
336
  }
295
- stream(ws, display, quality) {
337
+ stream(ws, display, relayed) {
296
338
  const shown = this.displays.find((entry) => entry.index === display) ?? this.displays[0];
297
339
  const tell = (message) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(message));
298
340
  // The viewer's hands: started when it first asks to control, for the display it is watching.
@@ -302,63 +344,95 @@ export class ScreenShare {
302
344
  posted: (event) => this.log(`[screen] dry run: ${JSON.stringify(event)}`),
303
345
  }, this.log);
304
346
  this.controls.add(control);
305
- ws.on("message", (data, binary) => {
306
- if (binary)
307
- return;
308
- let message;
309
- try {
310
- message = JSON.parse(String(data));
311
- }
312
- catch {
313
- return;
314
- }
315
- if (message?.t === "control")
316
- void control.start(shown?.screen ?? 0);
317
- else
318
- control.send(message);
319
- });
320
- const splitter = new AccessUnitSplitter();
347
+ // The picture: as good as the path carries without falling behind (see `screen-pacer.ts`).
348
+ const best = relayed ? RELAY_LEVEL : 0;
349
+ const pacer = new Pacer(Date.now());
350
+ let level = best;
351
+ let splitter = new AccessUnitSplitter();
321
352
  let closed = false;
322
- let stop;
323
- void this.capture(shown?.index ?? 0, quality, {
353
+ let changing = false;
354
+ let running;
355
+ const events = {
324
356
  data: (chunk) => splitter.push(chunk, (unit, key) => {
325
357
  if (ws.readyState !== ws.OPEN)
326
358
  return;
327
- // Behind (a slow link): skip frames until the next keyframe rather than queue up delay.
328
- if (!key && ws.bufferedAmount > 2_000_000)
329
- return;
330
- ws.send(Buffer.concat([Buffer.from([key ? 1 : 0]), unit]));
359
+ const now = Date.now();
360
+ const seq = pacer.next(key, now);
361
+ if (seq !== undefined) {
362
+ const head = Buffer.allocUnsafe(5);
363
+ head[0] = key ? 1 : 0;
364
+ head.writeUInt32BE(seq >>> 0, 1);
365
+ ws.send(Buffer.concat([head, unit]));
366
+ }
367
+ const advice = changing ? undefined : pacer.advice(now, level > best, level < LADDER.length - 1);
368
+ if (advice)
369
+ void change(level + (advice === "down" ? 1 : -1));
331
370
  }),
332
371
  exit: (message) => {
333
372
  if (message)
334
373
  tell({ error: message });
335
374
  ws.close();
336
375
  },
337
- }).then((end) => {
376
+ };
377
+ const begin = async () => {
378
+ const started = await this.take(shown?.index ?? 0, level, events, () => !closed);
338
379
  if (closed)
339
- end();
380
+ started?.end();
340
381
  else
341
- stop = end;
342
- }, (error) => {
382
+ running = started;
383
+ };
384
+ const failed = (error) => {
343
385
  this.log(`[screen] the capture did not start: ${error.message}`);
344
386
  tell({ error: "屏幕捕获失败" });
345
387
  ws.close();
388
+ };
389
+ const change = async (next) => {
390
+ changing = true;
391
+ this.log(`[screen] picture ${next > level ? "lighter" : "better"}: ${LADDER[next].width} wide, ${LADDER[next].fps} a second, ${LADDER[next].bitrate}`);
392
+ // Nothing more of the old stream goes out; the page starts its decoder afresh for the new one.
393
+ running?.end();
394
+ tell({ restart: true, level: next, lighter: next > level });
395
+ level = next;
396
+ splitter = new AccessUnitSplitter();
397
+ await begin().catch(failed);
398
+ pacer.restarted(Date.now());
399
+ changing = false;
400
+ };
401
+ void begin().catch(failed);
402
+ ws.on("message", (data, binary) => {
403
+ if (binary)
404
+ return;
405
+ let message;
406
+ try {
407
+ message = JSON.parse(String(data));
408
+ }
409
+ catch {
410
+ return;
411
+ }
412
+ const kind = message?.t;
413
+ if (kind === "ack")
414
+ pacer.ack(Number(message.n), Date.now());
415
+ else if (kind === "control")
416
+ void control.start(shown?.screen ?? 0);
417
+ else
418
+ control.send(message);
346
419
  });
347
420
  ws.on("close", () => {
348
421
  closed = true;
349
- stop?.();
422
+ if (running && this.holder === running)
423
+ this.holder = undefined;
424
+ running?.end();
350
425
  control.stop();
351
426
  this.controls.delete(control);
352
427
  });
353
428
  }
354
429
  stop() {
355
430
  // The host is going: there is no later to insist in.
431
+ this.holder?.end();
432
+ this.holder = undefined;
356
433
  for (const capture of this.captures)
357
434
  endCapture(capture, true);
358
435
  this.captures.clear();
359
- for (const stop of this.stops)
360
- stop();
361
- this.stops.clear();
362
436
  for (const control of this.controls)
363
437
  control.stop();
364
438
  this.controls.clear();