expo-device-hub 0.10.0 → 0.10.1
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/package.json
CHANGED
|
@@ -257,7 +257,7 @@ Options:
|
|
|
257
257
|
--unsafe-no-auth Allow a non-loopback bind with NO authentication.
|
|
258
258
|
Anyone who can reach the port can control the device.
|
|
259
259
|
-s, --serial <serial> adb device serial (defaults to the only booted device)
|
|
260
|
-
--max-fps <n>
|
|
260
|
+
--max-fps <n> Frame-rate target; RGB888 handoff is uncapped (default: ${SCRCPY_DEFAULTS.maxFps})
|
|
261
261
|
--bit-rate <bps> H.264 bit rate (default: ${SCRCPY_DEFAULTS.bitRate})
|
|
262
262
|
--max-size <px> Cap longest screen edge in pixels; 0 = native. The
|
|
263
263
|
gRPC screenshot source uses host-side software H.264
|
|
@@ -7,6 +7,9 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
7
7
|
import { isEmulatorSerial, parseEmulatorSerial } from "./device-capabilities.js";
|
|
8
8
|
import { execText } from "./exec.js";
|
|
9
9
|
const MAX_GRPC_MESSAGE_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
// Allow several full RGB frames in flight without a 64 KiB flow-control cycle
|
|
11
|
+
// for each chunk. Cooperative reads yield after a bounded amount of work.
|
|
12
|
+
const GRPC_RECEIVE_WINDOW_BYTES = 8 * 1024 * 1024;
|
|
10
13
|
const MAX_PROTO_VARINT_BYTES = 10;
|
|
11
14
|
const CONTROLLER_PREFIX = "/android.emulation.control.EmulatorController/";
|
|
12
15
|
const UNARY_TIMEOUT_MS = 5_000;
|
|
@@ -505,6 +508,81 @@ export class GrpcMessageParser {
|
|
|
505
508
|
this.#onMessage(message);
|
|
506
509
|
}
|
|
507
510
|
}
|
|
511
|
+
/** Yield receive work between bounded batches, independently of FFmpeg. */ export class GrpcReadScheduler {
|
|
512
|
+
#stream;
|
|
513
|
+
#consume;
|
|
514
|
+
#onError;
|
|
515
|
+
#batchBytes;
|
|
516
|
+
#remainingBytes;
|
|
517
|
+
#pending = null;
|
|
518
|
+
#immediate = null;
|
|
519
|
+
#paused = false;
|
|
520
|
+
#closed = false;
|
|
521
|
+
#onFinish = null;
|
|
522
|
+
constructor(options){
|
|
523
|
+
// Match an I/O-sized quantum: a full RGB frame per turn can still starve
|
|
524
|
+
// pipe progress when an unrestricted source stays continuously readable.
|
|
525
|
+
this.#batchBytes = options.batchBytes ?? 64 * 1024;
|
|
526
|
+
if (!Number.isSafeInteger(this.#batchBytes) || this.#batchBytes <= 0) {
|
|
527
|
+
throw new RangeError("batchBytes must be a positive safe integer");
|
|
528
|
+
}
|
|
529
|
+
this.#remainingBytes = this.#batchBytes;
|
|
530
|
+
this.#stream = options.stream;
|
|
531
|
+
this.#consume = options.consume;
|
|
532
|
+
this.#onError = options.onError;
|
|
533
|
+
}
|
|
534
|
+
push(chunk) {
|
|
535
|
+
if (this.#closed || chunk.length === 0) return;
|
|
536
|
+
try {
|
|
537
|
+
// pause() prevents another data event until the retained suffix is read.
|
|
538
|
+
if (this.#pending) throw new Error("gRPC source emitted data while paused");
|
|
539
|
+
const bytes = Math.min(chunk.length, this.#remainingBytes);
|
|
540
|
+
this.#remainingBytes -= bytes;
|
|
541
|
+
this.#consume(chunk.subarray(0, bytes));
|
|
542
|
+
if (this.#closed) return;
|
|
543
|
+
if (bytes < chunk.length) this.#pending = chunk.subarray(bytes);
|
|
544
|
+
if (this.#remainingBytes === 0) {
|
|
545
|
+
this.#paused = true;
|
|
546
|
+
this.#stream.pause();
|
|
547
|
+
}
|
|
548
|
+
if (!this.#immediate) {
|
|
549
|
+
this.#immediate = setImmediate(()=>this.#continue());
|
|
550
|
+
}
|
|
551
|
+
} catch (error) {
|
|
552
|
+
this.close();
|
|
553
|
+
this.#onError(error);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
finish(callback) {
|
|
557
|
+
if (this.#closed) return;
|
|
558
|
+
if (this.#pending) this.#onFinish = callback;
|
|
559
|
+
else callback();
|
|
560
|
+
}
|
|
561
|
+
close() {
|
|
562
|
+
this.#closed = true;
|
|
563
|
+
if (this.#immediate) clearImmediate(this.#immediate);
|
|
564
|
+
this.#immediate = null;
|
|
565
|
+
this.#pending = null;
|
|
566
|
+
this.#onFinish = null;
|
|
567
|
+
}
|
|
568
|
+
#continue() {
|
|
569
|
+
this.#immediate = null;
|
|
570
|
+
if (this.#closed) return;
|
|
571
|
+
this.#remainingBytes = this.#batchBytes;
|
|
572
|
+
const pending = this.#pending;
|
|
573
|
+
this.#pending = null;
|
|
574
|
+
if (pending) this.push(pending);
|
|
575
|
+
if (this.#closed) return;
|
|
576
|
+
if (this.#onFinish && !this.#pending) {
|
|
577
|
+
const finish = this.#onFinish;
|
|
578
|
+
this.#onFinish = null;
|
|
579
|
+
finish();
|
|
580
|
+
} else if (this.#paused && this.#remainingBytes > 0) {
|
|
581
|
+
this.#paused = false;
|
|
582
|
+
this.#stream.resume();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
508
586
|
const SYSTEM_PACING_CLOCK = {
|
|
509
587
|
now: ()=>performance.now(),
|
|
510
588
|
setTimeout: (callback, delayMs)=>setTimeout(callback, delayMs),
|
|
@@ -647,7 +725,16 @@ export class EmulatorGrpcClient {
|
|
|
647
725
|
this.#endpoint = endpoint;
|
|
648
726
|
this.#unaryTimeoutMs = positiveTimeout(options.unaryTimeoutMs ?? UNARY_TIMEOUT_MS, "unaryTimeoutMs");
|
|
649
727
|
this.#streamInactivityTimeoutMs = positiveTimeout(options.streamInactivityTimeoutMs ?? STREAM_INACTIVITY_TIMEOUT_MS, "streamInactivityTimeoutMs");
|
|
650
|
-
this.#session = http2.connect(`http://127.0.0.1:${endpoint.port}
|
|
728
|
+
this.#session = http2.connect(`http://127.0.0.1:${endpoint.port}`, {
|
|
729
|
+
settings: {
|
|
730
|
+
initialWindowSize: GRPC_RECEIVE_WINDOW_BYTES
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
this.#session.once("connect", ()=>{
|
|
734
|
+
if (!this.#closed) {
|
|
735
|
+
this.#session.setLocalWindowSize(GRPC_RECEIVE_WINDOW_BYTES);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
651
738
|
this.#session.on("error", (error)=>{
|
|
652
739
|
if (!this.#closed) {
|
|
653
740
|
for (const listener of this.#errorListeners)listener(error);
|
|
@@ -687,12 +774,14 @@ export class EmulatorGrpcClient {
|
|
|
687
774
|
let inactivityTimer = null;
|
|
688
775
|
let activityGeneration = 0;
|
|
689
776
|
let pacer = null;
|
|
777
|
+
let readScheduler = null;
|
|
690
778
|
const settle = (error)=>{
|
|
691
779
|
if (settled) return;
|
|
692
780
|
settled = true;
|
|
693
781
|
if (timer) clearTimeout(timer);
|
|
694
782
|
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
695
783
|
pacer?.close();
|
|
784
|
+
readScheduler?.close();
|
|
696
785
|
options.signal?.removeEventListener("abort", onAbort);
|
|
697
786
|
if (error) reject(error);
|
|
698
787
|
else resolve(messages);
|
|
@@ -723,6 +812,7 @@ export class EmulatorGrpcClient {
|
|
|
723
812
|
inactivityTimer.unref?.();
|
|
724
813
|
};
|
|
725
814
|
const onMessage = (body, receivedAtMs = Date.now())=>{
|
|
815
|
+
if (settled) return;
|
|
726
816
|
if (options.onMessage) options.onMessage(body, receivedAtMs);
|
|
727
817
|
else messages.push(body);
|
|
728
818
|
resetInactivityTimer();
|
|
@@ -767,6 +857,12 @@ export class EmulatorGrpcClient {
|
|
|
767
857
|
onError: cancelForFrameError,
|
|
768
858
|
signal: options.signal
|
|
769
859
|
});
|
|
860
|
+
} else if (options.onMessage) {
|
|
861
|
+
readScheduler = new GrpcReadScheduler({
|
|
862
|
+
stream,
|
|
863
|
+
consume: (chunk)=>parser.push(chunk),
|
|
864
|
+
onError: cancelForFrameError
|
|
865
|
+
});
|
|
770
866
|
}
|
|
771
867
|
stream.on("response", (values)=>takeStatus(values));
|
|
772
868
|
stream.on("trailers", (values)=>takeStatus(values));
|
|
@@ -774,6 +870,7 @@ export class EmulatorGrpcClient {
|
|
|
774
870
|
if (settled) return;
|
|
775
871
|
try {
|
|
776
872
|
if (pacer) pacer.push(chunk);
|
|
873
|
+
else if (readScheduler) readScheduler.push(chunk);
|
|
777
874
|
else parser.push(chunk);
|
|
778
875
|
} catch (error) {
|
|
779
876
|
cancelForFrameError(error);
|
|
@@ -784,8 +881,10 @@ export class EmulatorGrpcClient {
|
|
|
784
881
|
});
|
|
785
882
|
stream.on("close", ()=>{
|
|
786
883
|
if (settled) return;
|
|
787
|
-
if (grpcStatus === "0")
|
|
788
|
-
|
|
884
|
+
if (grpcStatus === "0") {
|
|
885
|
+
if (readScheduler) readScheduler.finish(()=>settle());
|
|
886
|
+
else settle();
|
|
887
|
+
} else if (grpcStatus !== null) {
|
|
789
888
|
settle(new Error(`${method}: grpc-status ${grpcStatus}${grpcMessage ? ` (${grpcMessage})` : ""}`));
|
|
790
889
|
} else {
|
|
791
890
|
settle(new Error(`${method}: stream closed without grpc status`));
|
|
@@ -22,8 +22,8 @@ const ACCESS_UNIT_CADENCE_WINDOW = 8;
|
|
|
22
22
|
const ACCESS_UNIT_CADENCE_OUTLIER_FLOOR_MS = 250;
|
|
23
23
|
const ACCESS_UNIT_CADENCE_OUTLIER_MULTIPLIER = 4;
|
|
24
24
|
const ACCESS_UNIT_SLOW_CADENCE_SIMILARITY = 1.5;
|
|
25
|
-
const ENCODER_WRITE_RETRY_DELAY_MS = 8;
|
|
26
25
|
const DEFAULT_IDLE_REPEAT_MS = 500;
|
|
26
|
+
const ENCODER_DRAIN_TIMEOUT_MS = 5_000;
|
|
27
27
|
const FIRST_FRAME_TIMEOUT_MS = 10_000;
|
|
28
28
|
const MAX_QUEUED_PACKET_BYTES = 64 * 1024 * 1024;
|
|
29
29
|
const DISPLAY_SIZE_POLL_MS = 2_000;
|
|
@@ -499,12 +499,12 @@ export function isUsablePngFrame(image) {
|
|
|
499
499
|
}
|
|
500
500
|
return {
|
|
501
501
|
encoderInputFormat: "rgb24",
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
// encoder write pacer decides when to submit it. MMAP also drains metadata
|
|
505
|
-
// immediately, then selects which notifications trigger memory reads.
|
|
502
|
+
// Keep receiving RGB independently of encoder readiness so the latest
|
|
503
|
+
// image stays fresh. MMAP selects metadata before shared-memory reads.
|
|
506
504
|
predecodeMaxFps: undefined,
|
|
507
|
-
|
|
505
|
+
// A static source may send only one image. Keep warming the encoder until
|
|
506
|
+
// it emits output; the regular idle timer starts after that startup gate.
|
|
507
|
+
needsEncoderFollowUp: (repeat, encoderHasOutput)=>!repeat || !encoderHasOutput
|
|
508
508
|
};
|
|
509
509
|
}
|
|
510
510
|
const DEFAULT_MMAP_NOTIFICATION_CLOCK = {
|
|
@@ -719,26 +719,6 @@ export function resolveGrpcDisplayGeometry(options) {
|
|
|
719
719
|
}
|
|
720
720
|
};
|
|
721
721
|
}
|
|
722
|
-
export class GrpcFrameWritePacer {
|
|
723
|
-
#frameIntervalMs;
|
|
724
|
-
#nextFreshWriteAt = 0;
|
|
725
|
-
constructor(frameIntervalMs){
|
|
726
|
-
if (!Number.isFinite(frameIntervalMs) || frameIntervalMs <= 0) {
|
|
727
|
-
throw new RangeError("frameIntervalMs must be a positive number");
|
|
728
|
-
}
|
|
729
|
-
this.#frameIntervalMs = frameIntervalMs;
|
|
730
|
-
}
|
|
731
|
-
reset(now) {
|
|
732
|
-
this.#nextFreshWriteAt = now;
|
|
733
|
-
}
|
|
734
|
-
recordWrite(now, repeat, accepted = true) {
|
|
735
|
-
if (repeat || !accepted) return;
|
|
736
|
-
this.#nextFreshWriteAt = Math.max(this.#nextFreshWriteAt + this.#frameIntervalMs, now + this.#frameIntervalMs);
|
|
737
|
-
}
|
|
738
|
-
waitMs(now) {
|
|
739
|
-
return Math.max(0, this.#nextFreshWriteAt - now);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
722
|
export class GrpcAccessUnitBoundaryCadence {
|
|
743
723
|
#minimumIntervalMs;
|
|
744
724
|
#lastFreshImageAt = null;
|
|
@@ -1230,8 +1210,8 @@ function nonNegativeInteger(value, name, maximum) {
|
|
|
1230
1210
|
const keyFrameInterval = nonNegativeNumber(options.keyFrameInterval ?? SCRCPY_DEFAULTS.keyFrameInterval, "keyFrameInterval");
|
|
1231
1211
|
const configuredRepeatFrameMs = nonNegativeNumber(options.repeatFrameMs ?? SCRCPY_DEFAULTS.repeatFrameMs, "repeatFrameMs", 60_000);
|
|
1232
1212
|
const repeatFrameMs = configuredRepeatFrameMs > 0 ? configuredRepeatFrameMs : DEFAULT_IDLE_REPEAT_MS;
|
|
1213
|
+
const encoderDrainTimeoutMs = positiveNumber(dependencies.encoderDrainTimeoutMs ?? ENCODER_DRAIN_TIMEOUT_MS, "encoderDrainTimeoutMs", 60_000);
|
|
1233
1214
|
const frameIntervalMs = 1_000 / maxFps;
|
|
1234
|
-
const frameWritePacer = new GrpcFrameWritePacer(frameIntervalMs);
|
|
1235
1215
|
const accessUnitBoundaryCadence = new GrpcAccessUnitBoundaryCadence(frameIntervalMs);
|
|
1236
1216
|
if (!isEmulatorSerial(serial)) {
|
|
1237
1217
|
throw new Error(`grpc-screenshot requires an Android Emulator serial; received ${serial}`);
|
|
@@ -1267,8 +1247,11 @@ function nonNegativeInteger(value, name, maximum) {
|
|
|
1267
1247
|
let encoderHasOutput = false;
|
|
1268
1248
|
let captureTransport = null;
|
|
1269
1249
|
let latest = null;
|
|
1250
|
+
let lastSubmittedImage = null;
|
|
1251
|
+
let waitingForEncoder = false;
|
|
1252
|
+
let pendingRepeat = false;
|
|
1253
|
+
let encoderDrainTimer = null;
|
|
1270
1254
|
let lastWriteAt = 0;
|
|
1271
|
-
let writeTimer = null;
|
|
1272
1255
|
let flushTimer = null;
|
|
1273
1256
|
let idleTimer = null;
|
|
1274
1257
|
let displaySizePollTimer = null;
|
|
@@ -1320,62 +1303,75 @@ function nonNegativeInteger(value, name, maximum) {
|
|
|
1320
1303
|
if (closed || fatalFailure) return Promise.resolve(null);
|
|
1321
1304
|
return new Promise((resolve)=>waiters.push(resolve));
|
|
1322
1305
|
};
|
|
1323
|
-
const
|
|
1324
|
-
if (writeTimer) clearTimeout(writeTimer);
|
|
1306
|
+
const clearBoundaryTimer = ()=>{
|
|
1325
1307
|
if (flushTimer) clearTimeout(flushTimer);
|
|
1326
|
-
writeTimer = null;
|
|
1327
1308
|
flushTimer = null;
|
|
1328
1309
|
};
|
|
1310
|
+
const clearEncoderDrainTimer = ()=>{
|
|
1311
|
+
if (encoderDrainTimer) clearTimeout(encoderDrainTimer);
|
|
1312
|
+
encoderDrainTimer = null;
|
|
1313
|
+
};
|
|
1314
|
+
const encoderWritable = (encoder)=>{
|
|
1315
|
+
if (closed || lifetime.signal.aborted || fatalFailure || encoderLifecycle?.current !== encoder) return;
|
|
1316
|
+
clearEncoderDrainTimer();
|
|
1317
|
+
waitingForEncoder = false;
|
|
1318
|
+
if (latest !== lastSubmittedImage) {
|
|
1319
|
+
pendingRepeat = false;
|
|
1320
|
+
writeFrame(false);
|
|
1321
|
+
} else if (pendingRepeat) {
|
|
1322
|
+
pendingRepeat = false;
|
|
1323
|
+
writeFrame(true);
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
const watchEncoderDrain = (encoder)=>{
|
|
1327
|
+
if (encoderDrainTimer || closed || lifetime.signal.aborted || fatalFailure) return;
|
|
1328
|
+
encoderDrainTimer = setTimeout(()=>{
|
|
1329
|
+
encoderDrainTimer = null;
|
|
1330
|
+
if (closed || lifetime.signal.aborted || fatalFailure || encoderLifecycle?.current !== encoder || !waitingForEncoder) return;
|
|
1331
|
+
if (encoder.writable) {
|
|
1332
|
+
// Recover a missed notification only when the encoder confirms it can
|
|
1333
|
+
// accept input. A timer must never force more bytes into a blocked pipe.
|
|
1334
|
+
encoderWritable(encoder);
|
|
1335
|
+
} else {
|
|
1336
|
+
emitFatal({
|
|
1337
|
+
message: `ffmpeg input remained backpressured for ${encoderDrainTimeoutMs}ms`,
|
|
1338
|
+
code: "encoder-exit"
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
}, encoderDrainTimeoutMs);
|
|
1342
|
+
encoderDrainTimer.unref?.();
|
|
1343
|
+
};
|
|
1329
1344
|
const nowUs = ()=>BigInt(Math.round(performance.now() * 1_000));
|
|
1330
1345
|
const writeFrame = (repeat)=>{
|
|
1331
1346
|
const encoder = encoderLifecycle?.current;
|
|
1332
1347
|
if (closed || lifetime.signal.aborted || fatalFailure || !encoder || !captureTransport || !latest) {
|
|
1333
1348
|
return;
|
|
1334
1349
|
}
|
|
1350
|
+
if (waitingForEncoder) {
|
|
1351
|
+
if (repeat) pendingRepeat = true;
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (!repeat && latest === lastSubmittedImage) return;
|
|
1335
1355
|
const now = performance.now();
|
|
1336
1356
|
const accepted = encoder.write(latest.image, nowUs());
|
|
1337
|
-
|
|
1357
|
+
waitingForEncoder = !accepted || !encoder.writable;
|
|
1358
|
+
pendingRepeat = !accepted && repeat;
|
|
1359
|
+
if (accepted) lastSubmittedImage = latest;
|
|
1360
|
+
if (waitingForEncoder) watchEncoderDrain(encoder);
|
|
1361
|
+
else clearEncoderDrainTimer();
|
|
1338
1362
|
captureDiagnostics.recordEncoderWrite(repeat, accepted, latest.image.length, now);
|
|
1339
1363
|
if (accepted) lastWriteAt = Date.now();
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
flushTimer = setTimeout(()=>{
|
|
1346
|
-
flushTimer = null;
|
|
1347
|
-
writeFrame(true);
|
|
1348
|
-
}, accepted ? accessUnitBoundaryCadence.boundaryDelayMs() : Math.min(ENCODER_WRITE_RETRY_DELAY_MS, frameIntervalMs));
|
|
1349
|
-
flushTimer.unref?.();
|
|
1350
|
-
}
|
|
1351
|
-
return;
|
|
1352
|
-
}
|
|
1353
|
-
if (accepted && needsFollowUp) {
|
|
1354
|
-
if (flushTimer) clearTimeout(flushTimer);
|
|
1364
|
+
// Keep receiving while ffmpeg is blocked so drain submits the newest
|
|
1365
|
+
// complete image, without building a FIFO of stale frames upstream.
|
|
1366
|
+
if (!accepted) return;
|
|
1367
|
+
clearBoundaryTimer();
|
|
1368
|
+
if (captureTransport.needsEncoderFollowUp(repeat, encoderHasOutput)) {
|
|
1355
1369
|
flushTimer = setTimeout(()=>{
|
|
1356
1370
|
flushTimer = null;
|
|
1357
1371
|
writeFrame(true);
|
|
1358
1372
|
}, accessUnitBoundaryCadence.boundaryDelayMs());
|
|
1359
1373
|
flushTimer.unref?.();
|
|
1360
|
-
} else if (needsFollowUp && !writeTimer) {
|
|
1361
|
-
writeTimer = setTimeout(()=>{
|
|
1362
|
-
writeTimer = null;
|
|
1363
|
-
scheduleWrite();
|
|
1364
|
-
}, Math.min(ENCODER_WRITE_RETRY_DELAY_MS, frameIntervalMs));
|
|
1365
|
-
writeTimer.unref?.();
|
|
1366
|
-
}
|
|
1367
|
-
};
|
|
1368
|
-
const scheduleWrite = ()=>{
|
|
1369
|
-
if (writeTimer || closed || !encoderLifecycle?.current || !latest) return;
|
|
1370
|
-
const waitMs = frameWritePacer.waitMs(performance.now());
|
|
1371
|
-
if (waitMs <= 0) {
|
|
1372
|
-
writeFrame(false);
|
|
1373
|
-
return;
|
|
1374
1374
|
}
|
|
1375
|
-
writeTimer = setTimeout(()=>{
|
|
1376
|
-
writeTimer = null;
|
|
1377
|
-
writeFrame(false);
|
|
1378
|
-
}, waitMs);
|
|
1379
1375
|
};
|
|
1380
1376
|
const currentGeometry = (image = latest)=>{
|
|
1381
1377
|
if (!image) return null;
|
|
@@ -1399,13 +1395,16 @@ function nonNegativeInteger(value, name, maximum) {
|
|
|
1399
1395
|
});
|
|
1400
1396
|
return null;
|
|
1401
1397
|
}
|
|
1402
|
-
|
|
1398
|
+
waitingForEncoder = false;
|
|
1399
|
+
pendingRepeat = false;
|
|
1400
|
+
lastSubmittedImage = null;
|
|
1403
1401
|
encoderHasOutput = false;
|
|
1404
1402
|
if (sessionMeta) {
|
|
1405
1403
|
sessionMeta.width = size.width;
|
|
1406
1404
|
sessionMeta.height = size.height;
|
|
1407
1405
|
}
|
|
1408
1406
|
let stopping = false;
|
|
1407
|
+
let managed = null;
|
|
1409
1408
|
const next = runtime.createEncoder({
|
|
1410
1409
|
encoderName,
|
|
1411
1410
|
width: latest.width,
|
|
@@ -1428,6 +1427,10 @@ function nonNegativeInteger(value, name, maximum) {
|
|
|
1428
1427
|
message,
|
|
1429
1428
|
code: "encoder-exit"
|
|
1430
1429
|
});
|
|
1430
|
+
},
|
|
1431
|
+
onWritable: ()=>{
|
|
1432
|
+
// A runtime adapter may notify synchronously during construction.
|
|
1433
|
+
if (!stopping && managed) encoderWritable(managed);
|
|
1431
1434
|
}
|
|
1432
1435
|
});
|
|
1433
1436
|
if (restart.announceSize) {
|
|
@@ -1438,7 +1441,10 @@ function nonNegativeInteger(value, name, maximum) {
|
|
|
1438
1441
|
clientResized: false
|
|
1439
1442
|
});
|
|
1440
1443
|
}
|
|
1441
|
-
|
|
1444
|
+
managed = {
|
|
1445
|
+
get writable () {
|
|
1446
|
+
return next.writable;
|
|
1447
|
+
},
|
|
1442
1448
|
width: next.width,
|
|
1443
1449
|
height: next.height,
|
|
1444
1450
|
quarterTurn: next.quarterTurn,
|
|
@@ -1449,10 +1455,12 @@ function nonNegativeInteger(value, name, maximum) {
|
|
|
1449
1455
|
return next.close();
|
|
1450
1456
|
}
|
|
1451
1457
|
};
|
|
1458
|
+
return managed;
|
|
1452
1459
|
});
|
|
1453
1460
|
const restartEncoder = (announceSize, clearPending)=>{
|
|
1454
1461
|
if (closed || lifetime.signal.aborted || !latest) return Promise.resolve();
|
|
1455
|
-
|
|
1462
|
+
clearBoundaryTimer();
|
|
1463
|
+
clearEncoderDrainTimer();
|
|
1456
1464
|
return encoderLifecycle.restart({
|
|
1457
1465
|
announceSize,
|
|
1458
1466
|
clearPending
|
|
@@ -1517,7 +1525,7 @@ function nonNegativeInteger(value, name, maximum) {
|
|
|
1517
1525
|
}));
|
|
1518
1526
|
return;
|
|
1519
1527
|
}
|
|
1520
|
-
|
|
1528
|
+
writeFrame(false);
|
|
1521
1529
|
};
|
|
1522
1530
|
const inputState = new GrpcInputState(client);
|
|
1523
1531
|
const touch = async (unitX, unitY, pressure, identifier, signal)=>{
|
|
@@ -1658,7 +1666,8 @@ function nonNegativeInteger(value, name, maximum) {
|
|
|
1658
1666
|
transportToClose?.stop();
|
|
1659
1667
|
options.signal?.removeEventListener("abort", abortFromParent);
|
|
1660
1668
|
lifetime.abort(new Error("gRPC screenshot session closed"));
|
|
1661
|
-
|
|
1669
|
+
clearBoundaryTimer();
|
|
1670
|
+
clearEncoderDrainTimer();
|
|
1662
1671
|
if (idleTimer) clearInterval(idleTimer);
|
|
1663
1672
|
idleTimer = null;
|
|
1664
1673
|
if (displaySizePollTimer) clearTimeout(displaySizePollTimer);
|
|
@@ -624,6 +624,7 @@ export class H264Encoder {
|
|
|
624
624
|
#stderr = new FfmpegStderrTail();
|
|
625
625
|
#resolveProcessDone;
|
|
626
626
|
#closed = false;
|
|
627
|
+
#waitingForDrain = false;
|
|
627
628
|
#failureReported = false;
|
|
628
629
|
#closeTask = null;
|
|
629
630
|
constructor(opts){
|
|
@@ -664,6 +665,10 @@ export class H264Encoder {
|
|
|
664
665
|
this.#proc.stdin.once("error", (error)=>{
|
|
665
666
|
this.#reportFailure(`ffmpeg stdin failed: ${error.message}`);
|
|
666
667
|
});
|
|
668
|
+
this.#proc.stdin.on("drain", ()=>{
|
|
669
|
+
this.#waitingForDrain = false;
|
|
670
|
+
if (!this.#closed) this.#opts.onWritable?.();
|
|
671
|
+
});
|
|
667
672
|
this.#proc.once("error", (error)=>{
|
|
668
673
|
this.#reportFailure(`ffmpeg failed to start: ${error.message}`);
|
|
669
674
|
this.#resolveProcessDone();
|
|
@@ -676,6 +681,9 @@ export class H264Encoder {
|
|
|
676
681
|
this.#resolveProcessDone();
|
|
677
682
|
});
|
|
678
683
|
}
|
|
684
|
+
/** Whether one more complete source image can be accepted now. */ get writable() {
|
|
685
|
+
return !this.#closed && !this.#waitingForDrain && this.#proc.stdin.writable && !this.#proc.stdin.writableNeedDrain;
|
|
686
|
+
}
|
|
679
687
|
/** Feed one complete source image; false means backpressure rejected it. */ write(image, ptsUs) {
|
|
680
688
|
if (!Buffer.isBuffer(image)) {
|
|
681
689
|
throw new TypeError("encoder input must be a Buffer");
|
|
@@ -698,13 +706,13 @@ export class H264Encoder {
|
|
|
698
706
|
if (typeof ptsUs !== "bigint" || ptsUs < 0n) {
|
|
699
707
|
throw new RangeError("ptsUs must be a non-negative bigint");
|
|
700
708
|
}
|
|
701
|
-
if (
|
|
702
|
-
return false;
|
|
703
|
-
}
|
|
709
|
+
if (!this.writable) return false;
|
|
704
710
|
try {
|
|
705
711
|
// A false return from Writable.write means "accepted, wait for drain",
|
|
706
712
|
// not "rejected", so every successful call receives a PTS entry.
|
|
707
|
-
|
|
713
|
+
// Bun's child-process stdin can leave writableNeedDrain false after a
|
|
714
|
+
// backpressured write, so retain the write result until drain fires.
|
|
715
|
+
this.#waitingForDrain = !this.#proc.stdin.write(image);
|
|
708
716
|
this.#parser.enqueuePts(ptsUs);
|
|
709
717
|
return true;
|
|
710
718
|
} catch (error) {
|