dsh-mobile-use 1.0.3 → 1.0.4
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/client.js +26 -4
- package/client.rev +1 -1
- package/index.js +2 -2
- package/mirror.js +40 -16
- package/package.json +1 -1
- package/stream.js +111 -7
package/client.js
CHANGED
|
@@ -550,6 +550,20 @@ window.__ModuleLoader__.load({
|
|
|
550
550
|
let timer
|
|
551
551
|
let since = 0
|
|
552
552
|
let misses = 0
|
|
553
|
+
/* 正在显示与正在解码的 Blob URL 各留一个:替换时先释放旧的待解码,
|
|
554
|
+
卸载时两个都释放。旧写法只靠 onload 回收,换帧快时会漏掉整条 URL。 */
|
|
555
|
+
let shownUrl = ''
|
|
556
|
+
let pendingUrl = ''
|
|
557
|
+
const releaseUrls = () => {
|
|
558
|
+
if (pendingUrl !== '') {
|
|
559
|
+
URL.revokeObjectURL(pendingUrl)
|
|
560
|
+
pendingUrl = ''
|
|
561
|
+
}
|
|
562
|
+
if (shownUrl !== '') {
|
|
563
|
+
URL.revokeObjectURL(shownUrl)
|
|
564
|
+
shownUrl = ''
|
|
565
|
+
}
|
|
566
|
+
}
|
|
553
567
|
const visible = () => {
|
|
554
568
|
const node = frameRef.current
|
|
555
569
|
const host = node === null ? null : (node.parentElement ?? node)
|
|
@@ -574,13 +588,21 @@ window.__ModuleLoader__.load({
|
|
|
574
588
|
const img = imgRef.current
|
|
575
589
|
if (img !== null) {
|
|
576
590
|
const url = URL.createObjectURL(blob)
|
|
577
|
-
|
|
591
|
+
if (pendingUrl !== '') URL.revokeObjectURL(pendingUrl)
|
|
592
|
+
pendingUrl = url
|
|
578
593
|
img.onload = () => {
|
|
579
|
-
if (
|
|
580
|
-
|
|
594
|
+
if (pendingUrl !== url) return
|
|
595
|
+
pendingUrl = ''
|
|
596
|
+
if (shownUrl !== '') URL.revokeObjectURL(shownUrl)
|
|
597
|
+
shownUrl = url
|
|
581
598
|
setReady(true)
|
|
582
599
|
paint()
|
|
583
600
|
}
|
|
601
|
+
img.onerror = () => {
|
|
602
|
+
if (pendingUrl !== url) return
|
|
603
|
+
pendingUrl = ''
|
|
604
|
+
URL.revokeObjectURL(url)
|
|
605
|
+
}
|
|
584
606
|
img.src = url
|
|
585
607
|
}
|
|
586
608
|
misses = 0
|
|
@@ -590,7 +612,7 @@ window.__ModuleLoader__.load({
|
|
|
590
612
|
if (live) timer = setTimeout(pull, misses === 0 ? 0 : Math.min(3000, 300 * misses))
|
|
591
613
|
}
|
|
592
614
|
void pull()
|
|
593
|
-
return () => { live = false; clearTimeout(timer) }
|
|
615
|
+
return () => { live = false; clearTimeout(timer); releaseUrls() }
|
|
594
616
|
}, [serial, paint, engine])
|
|
595
617
|
|
|
596
618
|
/* 画面尺寸与是否在直播:标注坐标要换算到设备像素,必须知道这两个数。 */
|
package/client.rev
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
153
|
package/index.js
CHANGED
|
@@ -22,8 +22,8 @@ import { homedir, tmpdir } from 'node:os'
|
|
|
22
22
|
import { dirname, join } from 'node:path'
|
|
23
23
|
import { fileURLToPath } from 'node:url'
|
|
24
24
|
|
|
25
|
-
import { setupMirror, frameSize as jpegSize } from './mirror.js?v=
|
|
26
|
-
import { ocrAvailable, ocrImage, stopOcr, toRects } from './ocr.js?v=
|
|
25
|
+
import { setupMirror, frameSize as jpegSize } from './mirror.js?v=172'
|
|
26
|
+
import { ocrAvailable, ocrImage, stopOcr, toRects } from './ocr.js?v=117'
|
|
27
27
|
|
|
28
28
|
/** Plugin name,与 bundle patch 里的行 id 对应。 */
|
|
29
29
|
export const name = 'adb'
|
package/mirror.js
CHANGED
|
@@ -20,7 +20,7 @@ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
|
20
20
|
import { dirname, join } from 'node:path'
|
|
21
21
|
import { homedir } from 'node:os'
|
|
22
22
|
|
|
23
|
-
import { LiveStream, packFrame } from './stream.js?v=
|
|
23
|
+
import { LiveStream, packFrame } from './stream.js?v=149'
|
|
24
24
|
|
|
25
25
|
/** 客户端与宿主共用的路由前缀。 */
|
|
26
26
|
export const ROUTE = '/dsh-mobile-use'
|
|
@@ -248,6 +248,9 @@ function pngToJpeg(png) {
|
|
|
248
248
|
})
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/** 单个 MJPEG 连接的待写上限:慢客户端不能把宿主进程的堆吃光。 */
|
|
252
|
+
const MAX_LIVE_QUEUE = 2 * 1024 * 1024
|
|
253
|
+
|
|
251
254
|
/**
|
|
252
255
|
* 往一个 MJPEG 连接写一帧。
|
|
253
256
|
* @param {import('node:http').ServerResponse} res - 连接。
|
|
@@ -257,6 +260,12 @@ function pngToJpeg(png) {
|
|
|
257
260
|
*/
|
|
258
261
|
function writeFrame(res, frame, drop) {
|
|
259
262
|
try {
|
|
263
|
+
/* 慢客户端只许拖垮自己:待写超过上限就直接断连,让它重连,别在宿主里攒队列。 */
|
|
264
|
+
if (res.writableLength > MAX_LIVE_QUEUE) {
|
|
265
|
+
drop()
|
|
266
|
+
res.destroy()
|
|
267
|
+
return
|
|
268
|
+
}
|
|
260
269
|
res.write(`--frame\r\ncontent-type: image/jpeg\r\ncontent-length: ${frame.length}\r\n\r\n`)
|
|
261
270
|
res.write(frame)
|
|
262
271
|
res.write('\r\n')
|
|
@@ -282,6 +291,8 @@ export class Mirror {
|
|
|
282
291
|
this.height = 0
|
|
283
292
|
this.watchers = new Set()
|
|
284
293
|
this.lastWatchAt = 0
|
|
294
|
+
/* 并发 start 的单飞句柄:见 start()。 */
|
|
295
|
+
this.startPromise = null
|
|
285
296
|
this.dropped = 0
|
|
286
297
|
this.seq = 0
|
|
287
298
|
this.waiters = []
|
|
@@ -321,20 +332,31 @@ export class Mirror {
|
|
|
321
332
|
|
|
322
333
|
async start() {
|
|
323
334
|
if (this.timer !== null || this.stopping) return
|
|
324
|
-
|
|
325
|
-
this.
|
|
326
|
-
this.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
335
|
+
/* 并发 start(页面重载 + /frame 同时到)只许跑一次:起两套定时器后,停的时候必漏一套。 */
|
|
336
|
+
if (this.startPromise !== null) return this.startPromise
|
|
337
|
+
this.startPromise = (async () => {
|
|
338
|
+
await mkdir(this.dir, { recursive: true })
|
|
339
|
+
this.startedAt = Date.now()
|
|
340
|
+
this.error = ''
|
|
341
|
+
const size = await this.adb(['-s', this.serial, 'shell', 'wm', 'size'], { timeout: 6000 })
|
|
342
|
+
const found = /(\d+)x(\d+)/.exec(String(size.out ?? ''))
|
|
343
|
+
if (found !== null) this.physical = { w: Number(found[1]), h: Number(found[2]) }
|
|
344
|
+
await this.snapshot()
|
|
345
|
+
this.alignScreen()
|
|
346
|
+
/* 等到这里再判一次:await 期间可能已经有人 stop 了。 */
|
|
347
|
+
if (this.stopping) return
|
|
348
|
+
this.timer = setInterval(() => guard('pump', () => this.pump()), 200)
|
|
349
|
+
this.timer.unref?.()
|
|
350
|
+
this.pushTimer = setInterval(() => guard('push', () => this.push()), PUSH_TICK_MS)
|
|
351
|
+
this.pushTimer.unref?.()
|
|
352
|
+
/* 录制跟观看者对齐:起服务时没人看就先不录,等 /frame 或 /live 把人带来再开。 */
|
|
353
|
+
this.ensureRecorder()
|
|
354
|
+
})().catch((error) => {
|
|
355
|
+
this.error = String(error?.message ?? error)
|
|
356
|
+
}).finally(() => {
|
|
357
|
+
this.startPromise = null
|
|
358
|
+
})
|
|
359
|
+
return this.startPromise
|
|
338
360
|
}
|
|
339
361
|
|
|
340
362
|
/**
|
|
@@ -1621,7 +1643,9 @@ export function setupMirror(ctx, tools) {
|
|
|
1621
1643
|
})
|
|
1622
1644
|
res.flushHeaders()
|
|
1623
1645
|
let closed = false
|
|
1624
|
-
|
|
1646
|
+
/* 先占位再订阅:subscribe 可能同步补发初始化段,那时 unsubscribe 还没赋值。 */
|
|
1647
|
+
let unsubscribe = () => {}
|
|
1648
|
+
unsubscribe = stream.subscribe((bytes) => {
|
|
1625
1649
|
if (closed) return
|
|
1626
1650
|
/* 积压超过 2 MB 就断开重连:宁可重连一次,也不要让延迟越滚越大。 */
|
|
1627
1651
|
if (res.writableLength > 2 << 20) {
|
package/package.json
CHANGED
package/stream.js
CHANGED
|
@@ -211,6 +211,12 @@ function containsIdr(chunk) {
|
|
|
211
211
|
|
|
212
212
|
/** 最后一个观众走后,录屏再活这么久:侧栏重载/切页回来时能立刻出画面,不用等 2 秒起流。 */
|
|
213
213
|
const IDLE_GRACE_MS = 20000
|
|
214
|
+
/** 主链路待写队列上限:ffmpeg 不读数据时,队列涨到这里就整条重建,绝不把宿主内存吃光。 */
|
|
215
|
+
const MAX_REMUX_QUEUE = 4 * 1024 * 1024
|
|
216
|
+
/** JPEG 是可选支路:落后超过这个量就丢掉重建,它没有资格拖慢采集。 */
|
|
217
|
+
const MAX_JPEG_QUEUE = 1024 * 1024
|
|
218
|
+
/** 排空比这还慢就认为 ffmpeg 卡死了(设备端编码器会一直吐,不能跟着等)。 */
|
|
219
|
+
const REMUX_STALL_MS = 2000
|
|
214
220
|
/** 本进程的随机标识:设备端 pid 文件名带上它,多开/重启不会互相认领对方的录屏。 */
|
|
215
221
|
const RUN_TAG = Math.random().toString(36).slice(2, 8)
|
|
216
222
|
|
|
@@ -267,6 +273,15 @@ export class LiveStream {
|
|
|
267
273
|
this.jpegOpenAt = 0
|
|
268
274
|
/** 最近一次有人要 JPEG 的时间:JPEG 观看者也该把推流钉住,否则每轮轮询都要重开录屏。 */
|
|
269
275
|
this.jpegWantedAt = 0
|
|
276
|
+
/** 每次启动/停止都换一代:旧进程的 exit 回调据此作废,停止之后不会自己复活。 */
|
|
277
|
+
this.generation = 0
|
|
278
|
+
this.spawning = false
|
|
279
|
+
/** 背压状态:正在因为 ffmpeg 写不动而暂停读录屏流。 */
|
|
280
|
+
this.recorderPaused = false
|
|
281
|
+
/** 诊断计数:队列保护触发了几次、暂停过几次。 */
|
|
282
|
+
this.queueDrops = 0
|
|
283
|
+
this.queueStalls = 0
|
|
284
|
+
this.jpegDrops = 0
|
|
270
285
|
}
|
|
271
286
|
|
|
272
287
|
/**
|
|
@@ -342,6 +357,11 @@ export class LiveStream {
|
|
|
342
357
|
if (!(globalThis.__dshAdbJpeg.get(this.serial) instanceof Set)) globalThis.__dshAdbJpeg.set(this.serial, new Set())
|
|
343
358
|
globalThis.__dshAdbJpeg.get(this.serial).add(child)
|
|
344
359
|
child.on('exit', () => globalThis.__dshAdbJpeg.get(this.serial)?.delete(child))
|
|
360
|
+
/* 同上:管道 EPIPE 只许结束这条可选支路,不能冒泡到宿主。 */
|
|
361
|
+
child.stdin.on('error', (error) => this.guard('jpeg-stdin-error', () => {
|
|
362
|
+
this.error = 'JPEG 管道断开:' + String(error?.message ?? error)
|
|
363
|
+
if (this.jpegChild === child) this.closeJpeg()
|
|
364
|
+
}))
|
|
345
365
|
child.stdout.on('data', (chunk) => this.guard('feed-jpeg', () => this.feedJpeg(chunk)))
|
|
346
366
|
child.stderr.on('data', (buf) => {
|
|
347
367
|
const text = String(buf).trim()
|
|
@@ -536,7 +556,6 @@ export class LiveStream {
|
|
|
536
556
|
try {
|
|
537
557
|
const line = JSON.stringify({ at: Date.now(), serial: this.serial, note, sessions: this.sessions, clients: this.clients.size, waiting: this.waiting.size, err: this.error }) + '\n'
|
|
538
558
|
mkdirSync(join(homedir(), '.dsh', 'adb-mirror'), { recursive: true })
|
|
539
|
-
mkdirSync(join(homedir(), '.dsh', 'adb-mirror'), { recursive: true })
|
|
540
559
|
appendFileSync(join(homedir(), '.dsh', 'adb-mirror', 'stream-debug.log'), line)
|
|
541
560
|
} catch {
|
|
542
561
|
/* 调试失败不影响推流 */
|
|
@@ -555,7 +574,10 @@ export class LiveStream {
|
|
|
555
574
|
const args = ['-s', this.serial, 'exec-out', command]
|
|
556
575
|
const child = spawn(this.adbPath, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
557
576
|
this.recorder = child
|
|
558
|
-
|
|
577
|
+
/* 新进程意味着读端是新的:上一轮为背压按下的暂停状态必须清掉。 */
|
|
578
|
+
this.recorderPaused = false
|
|
579
|
+
const gen = this.generation
|
|
580
|
+
this.debug('spawn gen=' + String(gen) + ' session=' + String(this.sessions))
|
|
559
581
|
let stderr = ''
|
|
560
582
|
child.stderr.setEncoding('utf8')
|
|
561
583
|
child.stderr.on('data', (chunk) => {
|
|
@@ -563,12 +585,15 @@ export class LiveStream {
|
|
|
563
585
|
})
|
|
564
586
|
child.stdout.on('data', (chunk) => this.guard('feed-raw', () => this.feedRaw(chunk)))
|
|
565
587
|
child.on('error', (error) => this.guard('recorder-error', () => {
|
|
588
|
+
if (gen !== this.generation) return
|
|
566
589
|
this.error = String(error?.message ?? error)
|
|
567
590
|
this.recorder = null
|
|
568
591
|
this.scheduleRestart()
|
|
569
592
|
}))
|
|
570
593
|
child.on('exit', (code) => this.guard('recorder-exit', () => {
|
|
571
594
|
if (this.recorder === child) this.recorder = null
|
|
595
|
+
/* stop() 之后的旧进程回调一律作废:否则「关掉页面 180 ms 后又自己开始录」。 */
|
|
596
|
+
if (gen !== this.generation) return
|
|
572
597
|
this.closeRemuxer()
|
|
573
598
|
if (this.stopping) return
|
|
574
599
|
const wasIntentional = this.intentional
|
|
@@ -619,22 +644,87 @@ export class LiveStream {
|
|
|
619
644
|
}
|
|
620
645
|
const jpegChild = this.jpegChild
|
|
621
646
|
if (jpegChild !== null) {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
647
|
+
const jpegIn = jpegChild.stdin
|
|
648
|
+
if (jpegIn === null || jpegIn.writableLength > MAX_JPEG_QUEUE) {
|
|
649
|
+
/* 可选支路落后就整条丢掉重建,它没有资格拖住采集链。 */
|
|
650
|
+
this.jpegDrops += 1
|
|
651
|
+
this.closeJpeg()
|
|
652
|
+
} else {
|
|
653
|
+
try {
|
|
654
|
+
jpegIn.write(chunk)
|
|
655
|
+
} catch {
|
|
656
|
+
this.closeJpeg()
|
|
657
|
+
}
|
|
626
658
|
}
|
|
627
659
|
}
|
|
628
660
|
this.ensureRemuxer()
|
|
629
661
|
const remuxer = this.remuxer
|
|
630
662
|
if (remuxer === null) return
|
|
663
|
+
const mp4In = remuxer.stdin
|
|
664
|
+
if (mp4In === null || mp4In.writableLength > MAX_REMUX_QUEUE) {
|
|
665
|
+
/* 排到上限说明 ffmpeg 已经不读了:整条重建,宁可黑半秒也不憋住内存。 */
|
|
666
|
+
this.queueDrops += 1
|
|
667
|
+
this.error = 'ffmpeg 写入落后,已重建转封装'
|
|
668
|
+
this.closeRemuxer()
|
|
669
|
+
return
|
|
670
|
+
}
|
|
631
671
|
try {
|
|
632
|
-
|
|
672
|
+
if (mp4In.write(chunk) === false) this.pauseForRemuxer(remuxer)
|
|
633
673
|
} catch {
|
|
634
674
|
this.closeRemuxer()
|
|
635
675
|
}
|
|
636
676
|
}
|
|
637
677
|
|
|
678
|
+
/**
|
|
679
|
+
* ffmpeg 报背压:暂停读录屏流,等它排空;等太久就整条重建(含重新要 IDR)。
|
|
680
|
+
* @param {import('node:child_process').ChildProcess} remuxer - 当前转封装进程。
|
|
681
|
+
* @returns {void}
|
|
682
|
+
*/
|
|
683
|
+
pauseForRemuxer(remuxer) {
|
|
684
|
+
if (this.recorderPaused) return
|
|
685
|
+
const recorder = this.recorder
|
|
686
|
+
if (recorder === null) return
|
|
687
|
+
this.recorderPaused = true
|
|
688
|
+
this.queueStalls += 1
|
|
689
|
+
this.debug('remux-backpressure pause')
|
|
690
|
+
try {
|
|
691
|
+
recorder.stdout.pause()
|
|
692
|
+
} catch {
|
|
693
|
+
/* 已经关了 */
|
|
694
|
+
}
|
|
695
|
+
let timer = setTimeout(() => this.guard('remux-stall', () => {
|
|
696
|
+
timer = null
|
|
697
|
+
if (this.remuxer === remuxer) {
|
|
698
|
+
this.error = 'ffmpeg 2 秒未消费数据,已重建'
|
|
699
|
+
this.closeRemuxer()
|
|
700
|
+
/* 重新要一张 IDR:新 ffmpeg 从 SPS 开始才解得对。 */
|
|
701
|
+
this.rekey()
|
|
702
|
+
}
|
|
703
|
+
this.resumeRecorder()
|
|
704
|
+
}), REMUX_STALL_MS)
|
|
705
|
+
timer.unref?.()
|
|
706
|
+
remuxer.stdin.once('drain', () => this.guard('remux-drain', () => {
|
|
707
|
+
if (timer !== null) {
|
|
708
|
+
clearTimeout(timer)
|
|
709
|
+
timer = null
|
|
710
|
+
}
|
|
711
|
+
this.resumeRecorder()
|
|
712
|
+
}))
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** 排空后恢复读录屏流。 @returns {void} */
|
|
716
|
+
resumeRecorder() {
|
|
717
|
+
if (!this.recorderPaused) return
|
|
718
|
+
this.recorderPaused = false
|
|
719
|
+
const recorder = this.recorder
|
|
720
|
+
if (recorder === null) return
|
|
721
|
+
try {
|
|
722
|
+
recorder.stdout.resume()
|
|
723
|
+
} catch {
|
|
724
|
+
/* 已经关了 */
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
638
728
|
/**
|
|
639
729
|
* 起 ffmpeg:裸流重排成分片 MP4。
|
|
640
730
|
* @returns {void}
|
|
@@ -658,6 +748,11 @@ export class LiveStream {
|
|
|
658
748
|
'-f', 'mp4', 'pipe:1',
|
|
659
749
|
], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
660
750
|
this.remuxer = child
|
|
751
|
+
/* child.on('error') 接不到管道自身的 EPIPE:必须单独接管 stdin,否则异步 error 事件会打死宿主。 */
|
|
752
|
+
child.stdin.on('error', (error) => this.guard('remux-stdin-error', () => {
|
|
753
|
+
this.error = 'ffmpeg 管道断开:' + String(error?.message ?? error)
|
|
754
|
+
if (this.remuxer === child) this.closeRemuxer()
|
|
755
|
+
}))
|
|
661
756
|
child.stdout.on('data', (chunk) => this.guard('feed-mp4', () => this.feedMp4(chunk)))
|
|
662
757
|
child.stderr.on('data', (buf) => {
|
|
663
758
|
const text = String(buf).trim()
|
|
@@ -834,6 +929,8 @@ export class LiveStream {
|
|
|
834
929
|
|
|
835
930
|
/** 没人看了就停,别占着设备编码器。 @returns {void} */
|
|
836
931
|
stop() {
|
|
932
|
+
/* 换代:之前所有 in-flight 的退出/重启回调从此作废,停止之后不会自己复活。 */
|
|
933
|
+
this.generation += 1
|
|
837
934
|
if (this.idleTimer !== null) {
|
|
838
935
|
clearTimeout(this.idleTimer)
|
|
839
936
|
this.idleTimer = null
|
|
@@ -885,6 +982,13 @@ export class LiveStream {
|
|
|
885
982
|
jpegAt: this.jpegAt,
|
|
886
983
|
jpegLive: this.jpegChild !== null,
|
|
887
984
|
jpegBytes: this.jpeg === null ? 0 : this.jpeg.length,
|
|
985
|
+
/* 诊断:队列保护触发次数与当前待写字节,涨得快就说明 ffmpeg 跟不上。 */
|
|
986
|
+
queueDrops: this.queueDrops,
|
|
987
|
+
queueStalls: this.queueStalls,
|
|
988
|
+
jpegDrops: this.jpegDrops,
|
|
989
|
+
remuxQueued: this.remuxer === null ? 0 : this.remuxer.stdin?.writableLength ?? 0,
|
|
990
|
+
jpegQueued: this.jpegChild === null ? 0 : this.jpegChild.stdin?.writableLength ?? 0,
|
|
991
|
+
generations: this.generation,
|
|
888
992
|
}
|
|
889
993
|
}
|
|
890
994
|
}
|