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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-device-hub",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Expo DevTools plugin for managing iOS simulators and Android emulators.",
5
5
  "keywords": [
6
6
  "expo",
@@ -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> Cap source frame rate (default: ${SCRCPY_DEFAULTS.maxFps})
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") settle();
788
- else if (grpcStatus !== null) {
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
- // Drain raw RGB responses independently of encoder FPS. Pausing HTTP/2
503
- // queues old pixels upstream; onImage keeps only the newest frame and the
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
- needsEncoderFollowUp: (repeat)=>!repeat
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 clearWriteTimers = ()=>{
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
- frameWritePacer.recordWrite(now, repeat, accepted);
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
- const needsFollowUp = captureTransport.needsEncoderFollowUp(repeat, encoderHasOutput);
1341
- if (repeat) {
1342
- if (flushTimer) clearTimeout(flushTimer);
1343
- flushTimer = null;
1344
- if (!accepted || needsFollowUp) {
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
- frameWritePacer.reset(performance.now());
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
- return {
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
- clearWriteTimers();
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
- scheduleWrite();
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
- clearWriteTimers();
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 (this.#closed || !this.#proc.stdin.writable || this.#proc.stdin.writableNeedDrain) {
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
- this.#proc.stdin.write(image);
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) {