@hyperframes/producer 0.9.9 → 0.9.11

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.
@@ -1435,6 +1435,27 @@ import {
1435
1435
  prepareCaptureSessionForReuse,
1436
1436
  spawnStreamingEncoder
1437
1437
  } from "@hyperframes/engine";
1438
+
1439
+ // src/services/render/stallClock.ts
1440
+ var SUSPEND_GAP_MS = 15e3;
1441
+ function createStallClock(now = () => Date.now()) {
1442
+ let since = now();
1443
+ let seen = since;
1444
+ return {
1445
+ reset() {
1446
+ since = seen = now();
1447
+ },
1448
+ idle(expectedMs) {
1449
+ const at = now();
1450
+ const gap = at - seen;
1451
+ seen = at;
1452
+ if (gap > expectedMs + SUSPEND_GAP_MS) since += gap;
1453
+ return at - since;
1454
+ }
1455
+ };
1456
+ }
1457
+
1458
+ // src/services/render/stages/captureStreamingStage.ts
1438
1459
  var DEFAULT_CAPTURE_STALL_MS = 6e4;
1439
1460
  function resolveCaptureStallTimeoutMs() {
1440
1461
  const raw = process.env.HF_DE_STALL_MS ?? process.env.HF_DE_PARALLEL_STALL_MS;
@@ -1802,35 +1823,33 @@ function watchStall(phase, rule) {
1802
1823
  const detail = rule.detail ?? (() => void 0);
1803
1824
  let last = rule.progress();
1804
1825
  let budgetMs = rule.firstProgressMs;
1805
- let lastChangeAt = Date.now();
1826
+ const silence = createStallClock();
1827
+ const pollMs = Math.min(STALL_POLL_MS, rule.noProgressMs, rule.firstProgressMs);
1806
1828
  let attempt;
1807
1829
  let timer;
1808
1830
  let inTail = false;
1809
1831
  const restartWindow = (ms) => {
1810
1832
  last = rule.progress();
1811
1833
  budgetMs = ms;
1812
- lastChangeAt = Date.now();
1834
+ silence.reset();
1813
1835
  };
1814
1836
  const tripped = new Promise((_, reject) => {
1815
- timer = setInterval(
1816
- () => {
1817
- const current = rule.progress();
1818
- if (rule.complete?.() && !inTail) {
1819
- inTail = true;
1820
- return restartWindow(rule.tailMs ?? rule.noProgressMs);
1821
- }
1822
- if (current !== last) return restartWindow(rule.noProgressMs);
1823
- if (Date.now() - lastChangeAt < budgetMs) return;
1824
- const error = new RenderStageStalledError(phase, budgetMs, current, detail());
1825
- if (attempt && !attempt.signal.aborted) {
1826
- attempt.abort(error);
1827
- return restartWindow(rule.noProgressMs);
1828
- }
1829
- clearInterval(timer);
1830
- reject(error);
1831
- },
1832
- Math.min(STALL_POLL_MS, rule.noProgressMs, rule.firstProgressMs)
1833
- );
1837
+ timer = setInterval(() => {
1838
+ const current = rule.progress();
1839
+ if (rule.complete?.() && !inTail) {
1840
+ inTail = true;
1841
+ return restartWindow(rule.tailMs ?? rule.noProgressMs);
1842
+ }
1843
+ if (current !== last) return restartWindow(rule.noProgressMs);
1844
+ if (silence.idle(pollMs) < budgetMs) return;
1845
+ const error = new RenderStageStalledError(phase, budgetMs, current, detail());
1846
+ if (attempt && !attempt.signal.aborted) {
1847
+ attempt.abort(error);
1848
+ return restartWindow(rule.noProgressMs);
1849
+ }
1850
+ clearInterval(timer);
1851
+ reject(error);
1852
+ }, pollMs);
1834
1853
  });
1835
1854
  const openAttempt = (parent) => {
1836
1855
  attempt = new AbortController();
package/dist/index.js CHANGED
@@ -3721,6 +3721,27 @@ import {
3721
3721
  prepareCaptureSessionForReuse,
3722
3722
  spawnStreamingEncoder
3723
3723
  } from "@hyperframes/engine";
3724
+
3725
+ // src/services/render/stallClock.ts
3726
+ var SUSPEND_GAP_MS = 15e3;
3727
+ function createStallClock(now = () => Date.now()) {
3728
+ let since = now();
3729
+ let seen = since;
3730
+ return {
3731
+ reset() {
3732
+ since = seen = now();
3733
+ },
3734
+ idle(expectedMs) {
3735
+ const at = now();
3736
+ const gap = at - seen;
3737
+ seen = at;
3738
+ if (gap > expectedMs + SUSPEND_GAP_MS) since += gap;
3739
+ return at - since;
3740
+ }
3741
+ };
3742
+ }
3743
+
3744
+ // src/services/render/stages/captureStreamingStage.ts
3724
3745
  var DEFAULT_CAPTURE_STALL_MS = 6e4;
3725
3746
  var DE_STALL_POLL_MS = 5e3;
3726
3747
  var STALL_ADVICE = "Chrome stopped answering. If another app is keeping the GPU or CPU busy (a 3D render, a game, another export), let it finish or render one job at a time, then retry.";
@@ -3765,21 +3786,27 @@ var SequentialCaptureStallError = class extends Error {
3765
3786
  };
3766
3787
  function raceAgainstStall(promise, deadlineMs, input, signal) {
3767
3788
  return new Promise((resolve13, reject) => {
3768
- const timer = setTimeout(
3769
- () => {
3770
- reject(
3771
- signal?.aborted ? new Error(
3772
- `[Render] Sequential ${captureModeLabel(input.session.captureMode)} capture aborted while a capture call was in flight (frame ${input.frameIndex}/${input.totalFrames}).`
3773
- ) : new SequentialCaptureStallError(
3774
- input.session,
3775
- input.stallTimeoutMs,
3776
- input.frameIndex,
3777
- input.totalFrames
3778
- )
3779
- );
3780
- },
3781
- Math.max(0, deadlineMs)
3789
+ const clock = createStallClock();
3790
+ const trip = () => reject(
3791
+ signal?.aborted ? new Error(
3792
+ `[Render] Sequential ${captureModeLabel(input.session.captureMode)} capture aborted while a capture call was in flight (frame ${input.frameIndex}/${input.totalFrames}).`
3793
+ ) : new SequentialCaptureStallError(
3794
+ input.session,
3795
+ input.stallTimeoutMs,
3796
+ input.frameIndex,
3797
+ input.totalFrames
3798
+ )
3782
3799
  );
3800
+ const deadline = Math.max(0, deadlineMs);
3801
+ let wait = Math.min(deadline, DE_STALL_POLL_MS);
3802
+ let timer;
3803
+ const look = () => {
3804
+ const idle = clock.idle(wait);
3805
+ if (idle >= deadline) return trip();
3806
+ wait = Math.min(deadline - idle, DE_STALL_POLL_MS);
3807
+ timer = setTimeout(look, wait);
3808
+ };
3809
+ timer = setTimeout(look, wait);
3783
3810
  promise.then(
3784
3811
  (value) => {
3785
3812
  clearTimeout(timer);
@@ -3889,26 +3916,26 @@ function createDrainFrameGuard(args) {
3889
3916
  return buf;
3890
3917
  };
3891
3918
  }
3892
- async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEncoder, reorderBuffer, assertNotAborted, onProgress, log, stats, abortSignal, risk) {
3919
+ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEncoder, reorderBuffer, assertNotAborted, onProgress, log, stats, abortSignal, resume, risk) {
3893
3920
  let prev = null;
3894
3921
  const frameTime = (i) => i * job.config.fps.den / job.config.fps.num;
3895
3922
  const guard = createDrainFrameGuard({ log, stats, frameTime });
3896
3923
  const guardFrame = (item, buf) => item.trusted ? Promise.resolve(buf) : guard(session, item.idx, buf);
3897
3924
  const captureRisk = async (idx) => {
3898
3925
  await risk.open();
3899
- lastProgressAt = Date.now();
3926
+ quiet.reset();
3900
3927
  return raceAgainstStall(
3901
3928
  risk.capture(idx, frameTime(idx)),
3902
- stallTimeoutMs - (Date.now() - lastProgressAt),
3929
+ stallTimeoutMs - quiet.idle(0),
3903
3930
  { session, frameIndex: idx, totalFrames, stallTimeoutMs },
3904
3931
  abortSignal
3905
3932
  );
3906
3933
  };
3907
3934
  const stallTimeoutMs = resolveCaptureStallTimeoutMs();
3908
- let lastProgressAt = Date.now();
3935
+ const quiet = createStallClock();
3909
3936
  const captureWithStallGuard = (idx, promise) => raceAgainstStall(
3910
3937
  promise,
3911
- stallTimeoutMs - (Date.now() - lastProgressAt),
3938
+ stallTimeoutMs - quiet.idle(0),
3912
3939
  { session, frameIndex: idx, totalFrames, stallTimeoutMs },
3913
3940
  abortSignal
3914
3941
  );
@@ -3920,7 +3947,8 @@ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEnc
3920
3947
  ensureFrameWritten(await currentEncoder.writeFrame(buf), prev.idx, currentEncoder);
3921
3948
  reorderBuffer.advanceTo(prev.idx + 1);
3922
3949
  job.framesRendered = prev.idx + 1;
3923
- lastProgressAt = Date.now();
3950
+ resume.written = prev.idx + 1;
3951
+ quiet.reset();
3924
3952
  reportFrameProgress(
3925
3953
  job,
3926
3954
  `Streaming frame ${prev.idx + 1}/${totalFrames}`,
@@ -3940,7 +3968,8 @@ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEnc
3940
3968
  ensureFrameWritten(await currentEncoder.writeFrame(buf), item.idx, currentEncoder);
3941
3969
  reorderBuffer.advanceTo(item.idx + 1);
3942
3970
  job.framesRendered = item.idx + 1;
3943
- lastProgressAt = Date.now();
3971
+ resume.written = item.idx + 1;
3972
+ quiet.reset();
3944
3973
  reportFrameProgress(
3945
3974
  job,
3946
3975
  `Streaming frame ${item.idx + 1}/${totalFrames}`,
@@ -3955,7 +3984,7 @@ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEnc
3955
3984
  const boundarySS = process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true";
3956
3985
  const batchable = (f) => !session.staticFrames?.has(f) && !(boundarySS && session.clipBoundaryFrames?.has(f)) && !risk?.frames.has(f);
3957
3986
  let prevBatch = [];
3958
- let i = 0;
3987
+ let i = resume.from;
3959
3988
  while (i < totalFrames) {
3960
3989
  assertNotAborted();
3961
3990
  if (batchable(i)) {
@@ -3988,7 +4017,7 @@ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEnc
3988
4017
  await drainBatch(prevBatch);
3989
4018
  return;
3990
4019
  }
3991
- for (let i = 0; i < totalFrames; i++) {
4020
+ for (let i = resume.from; i < totalFrames; i++) {
3992
4021
  assertNotAborted();
3993
4022
  if (risk?.frames.has(i)) {
3994
4023
  const buffer = await captureRisk(i);
@@ -4006,6 +4035,10 @@ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEnc
4006
4035
  }
4007
4036
  await drainPrev();
4008
4037
  }
4038
+ var STALL_RESUMES_BEFORE_FALLBACK = 3;
4039
+ function resumesAfterStall(error, abortSignal, resume, resumes) {
4040
+ return error instanceof SequentialCaptureStallError && abortSignal?.aborted !== true && resume.written > resume.from && resumes < STALL_RESUMES_BEFORE_FALLBACK;
4041
+ }
4009
4042
  async function runCaptureStreamingStage(input) {
4010
4043
  const {
4011
4044
  fileServer,
@@ -4079,7 +4112,7 @@ async function runCaptureStreamingStage(input) {
4079
4112
  let parallelGuardRan = false;
4080
4113
  let writtenFrames = 0;
4081
4114
  let resumeFrom = 0;
4082
- let resumedAfterStall = false;
4115
+ let resumes = 0;
4083
4116
  let workerResults;
4084
4117
  for (; ; ) {
4085
4118
  const parallelReorder = createFrameReorderBuffer(resumeFrom, totalFrames);
@@ -4114,7 +4147,7 @@ async function runCaptureStreamingStage(input) {
4114
4147
  }
4115
4148
  const stallTimeoutMs = resolveCaptureStallTimeoutMs();
4116
4149
  let lastCapturedFrames = 0;
4117
- let lastProgressAt = Date.now();
4150
+ const quiet = createStallClock();
4118
4151
  const workerPhases = /* @__PURE__ */ new Map();
4119
4152
  const phaseDetail = (phase) => `phase=${phase.phase} frame=${phase.frameIndex ?? "n/a"} step=${phase.readStep?.() ?? "n/a"} browser=${phase.browserExecutable} version=${phase.browserVersion} CanvasDrawElement=${phase.canvasDrawElement} gpu=${phase.gpuBackend}`;
4120
4153
  const phaseSummary = () => [...workerPhases.entries()].sort(([left], [right]) => left - right).map(([workerId, detail]) => `worker=${workerId} ${detail}`).join("; ");
@@ -4125,23 +4158,21 @@ async function runCaptureStreamingStage(input) {
4125
4158
  captureLabel === "BeginFrame",
4126
4159
  cfg
4127
4160
  );
4128
- const stallTimer = setInterval(
4129
- () => {
4130
- if (!watchdogArmed || Date.now() - lastProgressAt <= budget()) return;
4131
- stalled = true;
4132
- const stallErr = new ParallelCaptureStallError({
4133
- variant: "drain",
4134
- captureLabel,
4135
- stallTimeoutMs: budget(),
4136
- lastCapturedFrames: resumeFrom + lastCapturedFrames,
4137
- totalFrames,
4138
- phaseSummary: phaseSummary()
4139
- });
4140
- parallelReorder.abort(stallErr);
4141
- stallController.abort();
4142
- },
4143
- Math.min(budget(), DE_STALL_POLL_MS)
4144
- );
4161
+ const pollMs = Math.min(budget(), DE_STALL_POLL_MS);
4162
+ const stallTimer = setInterval(() => {
4163
+ if (!watchdogArmed || quiet.idle(pollMs) <= budget()) return;
4164
+ stalled = true;
4165
+ const stallErr = new ParallelCaptureStallError({
4166
+ variant: "drain",
4167
+ captureLabel,
4168
+ stallTimeoutMs: budget(),
4169
+ lastCapturedFrames: resumeFrom + lastCapturedFrames,
4170
+ totalFrames,
4171
+ phaseSummary: phaseSummary()
4172
+ });
4173
+ parallelReorder.abort(stallErr);
4174
+ stallController.abort();
4175
+ }, pollMs);
4145
4176
  try {
4146
4177
  workerResults = await executeParallelCapture(
4147
4178
  fileServer.url,
@@ -4156,7 +4187,7 @@ async function runCaptureStreamingStage(input) {
4156
4187
  workerPhases.set(phase.workerId, phaseDetail(phase));
4157
4188
  if (phase.phase === "frame_capture" && !watchdogArmed) {
4158
4189
  watchdogArmed = true;
4159
- lastProgressAt = Date.now();
4190
+ quiet.reset();
4160
4191
  }
4161
4192
  log.info("[Render] Parallel capture worker phase", {
4162
4193
  workerId: phase.workerId,
@@ -4172,7 +4203,7 @@ async function runCaptureStreamingStage(input) {
4172
4203
  }
4173
4204
  if (progress.capturedFrames > lastCapturedFrames) {
4174
4205
  lastCapturedFrames = progress.capturedFrames;
4175
- lastProgressAt = Date.now();
4206
+ quiet.reset();
4176
4207
  watchdogArmed = true;
4177
4208
  }
4178
4209
  const doneFrames = resumeFrom + progress.capturedFrames;
@@ -4203,17 +4234,14 @@ async function runCaptureStreamingStage(input) {
4203
4234
  }
4204
4235
  );
4205
4236
  } catch (err) {
4206
- if (stalled && abortSignal?.aborted !== true && captureLabel === "BeginFrame" && !resumedAfterStall && writtenFrames > resumeFrom) {
4207
- resumedAfterStall = true;
4208
- log.warn(
4209
- "[Render] Parallel BeginFrame capture stalled; resuming from the first unwritten frame",
4210
- {
4211
- resumeFrom: writtenFrames,
4212
- totalFrames,
4213
- workerPhases: phaseSummary()
4214
- }
4215
- );
4237
+ if (stalled && abortSignal?.aborted !== true && writtenFrames > resumeFrom && resumes < STALL_RESUMES_BEFORE_FALLBACK) {
4238
+ log.warn("[Render] Parallel capture stalled; resuming from the first unwritten frame", {
4239
+ resumeFrom: writtenFrames,
4240
+ totalFrames,
4241
+ workerPhases: phaseSummary()
4242
+ });
4216
4243
  resumeFrom = writtenFrames;
4244
+ resumes++;
4217
4245
  continue;
4218
4246
  }
4219
4247
  if (parallelDrainError) throw poolFailure(parallelDrainError, probeSession);
@@ -4249,116 +4277,133 @@ async function runCaptureStreamingStage(input) {
4249
4277
  }
4250
4278
  lastBrowserConsole = withWorkerPageErrors(lastBrowserConsole, workerPageErrors);
4251
4279
  } else {
4252
- const videoInjector = createRenderVideoFrameInjector();
4253
- const session = probeSession ?? await createCaptureSession(
4254
- fileServer.url,
4255
- framesDir,
4256
- buildCaptureOptions(),
4257
- videoInjector,
4258
- captureCfg
4259
- );
4260
- captureBeyondViewport = session.options.captureBeyondViewport;
4261
- if (probeSession) {
4262
- prepareCaptureSessionForReuse(session, framesDir, videoInjector);
4263
- probeSession = null;
4264
- }
4265
- let riskSession = null;
4266
- const openRiskSession = async () => {
4267
- const opened = await createCaptureSession(
4280
+ const resume = { from: 0, written: 0 };
4281
+ let resumes = 0;
4282
+ for (; ; ) {
4283
+ const videoInjector = createRenderVideoFrameInjector();
4284
+ const session = probeSession ?? await createCaptureSession(
4268
4285
  fileServer.url,
4269
4286
  framesDir,
4270
4287
  buildCaptureOptions(),
4271
- createRenderVideoFrameInjector(),
4272
- {
4273
- ...captureCfg,
4274
- useDrawElement: false,
4275
- staticFrameDedup: false,
4276
- enableBrowserPool: false
4277
- }
4288
+ videoInjector,
4289
+ captureCfg
4278
4290
  );
4279
- riskSession = opened;
4280
- await initializeSession(opened);
4281
- return opened;
4282
- };
4283
- try {
4284
- if (!session.isInitialized) {
4285
- await initializeSession(session);
4291
+ captureBeyondViewport = session.options.captureBeyondViewport;
4292
+ if (probeSession) {
4293
+ prepareCaptureSessionForReuse(session, framesDir, videoInjector);
4294
+ probeSession = null;
4286
4295
  }
4287
- await completeDeferredDrawElementInit(session);
4288
- assertNotAborted();
4289
- lastBrowserConsole = session.browserConsoleBuffer;
4290
- if (session.workerEncodeEnabled) {
4291
- deDrainStats = {
4292
- verifyChecked: 0,
4293
- blankSuspects: 0,
4294
- blankDeterministicAccepts: 0,
4295
- blankRecaptures: 0
4296
- };
4297
- const riskFrames = session.deRiskFrames;
4298
- await runWorkerEncodePipelineLoop(
4299
- session,
4300
- totalFrames,
4301
- job,
4302
- currentEncoder,
4303
- reorderBuffer,
4304
- assertNotAborted,
4305
- onProgress,
4306
- log,
4307
- deDrainStats,
4308
- abortSignal,
4309
- riskFrames && riskFrames.size > 0 ? {
4310
- frames: riskFrames,
4311
- // Opened on the first risk frame, in a browser of its own: pages sharing a
4312
- // browser share a compositor, which starves the drawElement page's paint events.
4313
- open: async () => {
4314
- riskSession ??= await openRiskSession();
4315
- },
4316
- capture: async (frameIndex, time) => (await captureFrameToBuffer(riskSession, frameIndex, time)).buffer
4317
- } : void 0
4296
+ let riskSession = null;
4297
+ const openRiskSession = async () => {
4298
+ const opened = await createCaptureSession(
4299
+ fileServer.url,
4300
+ framesDir,
4301
+ buildCaptureOptions(),
4302
+ createRenderVideoFrameInjector(),
4303
+ {
4304
+ ...captureCfg,
4305
+ useDrawElement: false,
4306
+ staticFrameDedup: false,
4307
+ enableBrowserPool: false
4308
+ }
4318
4309
  );
4319
- } else {
4320
- const stallTimeoutMs = resolveCaptureStallTimeoutMs();
4321
- let lastProgressAt = Date.now();
4322
- for (let i = 0; i < totalFrames; i++) {
4323
- assertNotAborted();
4324
- const time = i * job.config.fps.den / job.config.fps.num;
4325
- const budget = stallBudgetMs(
4326
- stallTimeoutMs,
4327
- i,
4328
- session.captureMode === "beginframe",
4329
- cfg
4330
- );
4331
- const { buffer } = await raceAgainstStall(
4332
- captureFrameToBuffer(session, i, time),
4333
- budget - (Date.now() - lastProgressAt),
4334
- { session, frameIndex: i, totalFrames, stallTimeoutMs: budget },
4335
- abortSignal
4336
- );
4337
- await reorderBuffer.waitForFrame(i);
4338
- ensureFrameWritten(await currentEncoder.writeFrame(buffer), i, currentEncoder);
4339
- reorderBuffer.advanceTo(i + 1);
4340
- job.framesRendered = i + 1;
4341
- lastProgressAt = Date.now();
4342
- const frameProgress = (i + 1) / totalFrames;
4343
- const progress = 25 + frameProgress * 55;
4344
- reportFrameProgress(
4310
+ riskSession = opened;
4311
+ await initializeSession(opened);
4312
+ return opened;
4313
+ };
4314
+ let resumed = false;
4315
+ try {
4316
+ if (!session.isInitialized) {
4317
+ await initializeSession(session);
4318
+ }
4319
+ await completeDeferredDrawElementInit(session);
4320
+ assertNotAborted();
4321
+ lastBrowserConsole = session.browserConsoleBuffer;
4322
+ if (session.workerEncodeEnabled) {
4323
+ deDrainStats ??= {
4324
+ verifyChecked: 0,
4325
+ blankSuspects: 0,
4326
+ blankDeterministicAccepts: 0,
4327
+ blankRecaptures: 0
4328
+ };
4329
+ const riskFrames = session.deRiskFrames;
4330
+ await runWorkerEncodePipelineLoop(
4331
+ session,
4332
+ totalFrames,
4345
4333
  job,
4346
- `Streaming frame ${i + 1}/${totalFrames}`,
4347
- Math.round(progress),
4334
+ currentEncoder,
4335
+ reorderBuffer,
4336
+ assertNotAborted,
4348
4337
  onProgress,
4349
- i + 1,
4350
- totalFrames
4338
+ log,
4339
+ deDrainStats,
4340
+ abortSignal,
4341
+ resume,
4342
+ riskFrames && riskFrames.size > 0 ? {
4343
+ frames: riskFrames,
4344
+ // Opened on the first risk frame, in a browser of its own: pages sharing a
4345
+ // browser share a compositor, which starves the drawElement page's paint events.
4346
+ open: async () => {
4347
+ riskSession ??= await openRiskSession();
4348
+ },
4349
+ capture: async (frameIndex, time) => (await captureFrameToBuffer(riskSession, frameIndex, time)).buffer
4350
+ } : void 0
4351
4351
  );
4352
+ } else {
4353
+ const stallTimeoutMs = resolveCaptureStallTimeoutMs();
4354
+ let lastProgressAt = Date.now();
4355
+ for (let i = resume.from; i < totalFrames; i++) {
4356
+ assertNotAborted();
4357
+ const time = i * job.config.fps.den / job.config.fps.num;
4358
+ const budget = stallBudgetMs(
4359
+ stallTimeoutMs,
4360
+ i,
4361
+ session.captureMode === "beginframe",
4362
+ cfg
4363
+ );
4364
+ const { buffer } = await raceAgainstStall(
4365
+ captureFrameToBuffer(session, i, time),
4366
+ budget - (Date.now() - lastProgressAt),
4367
+ { session, frameIndex: i, totalFrames, stallTimeoutMs: budget },
4368
+ abortSignal
4369
+ );
4370
+ await reorderBuffer.waitForFrame(i);
4371
+ ensureFrameWritten(await currentEncoder.writeFrame(buffer), i, currentEncoder);
4372
+ reorderBuffer.advanceTo(i + 1);
4373
+ job.framesRendered = i + 1;
4374
+ resume.written = i + 1;
4375
+ lastProgressAt = Date.now();
4376
+ const frameProgress = (i + 1) / totalFrames;
4377
+ const progress = 25 + frameProgress * 55;
4378
+ reportFrameProgress(
4379
+ job,
4380
+ `Streaming frame ${i + 1}/${totalFrames}`,
4381
+ Math.round(progress),
4382
+ onProgress,
4383
+ i + 1,
4384
+ totalFrames
4385
+ );
4386
+ }
4387
+ }
4388
+ dedupPerfs.push(getCapturePerfSummary(session));
4389
+ } catch (error) {
4390
+ lastBrowserConsole = session.browserConsoleBuffer;
4391
+ if (!resumesAfterStall(error, abortSignal, resume, resumes)) {
4392
+ throw wrapCaptureStageError(error, lastBrowserConsole);
4352
4393
  }
4394
+ log.warn("[Render] Sequential capture stalled; resuming from the first unwritten frame", {
4395
+ resumeFrom: resume.written,
4396
+ totalFrames
4397
+ });
4398
+ resume.from = resume.written;
4399
+ resumes++;
4400
+ resumed = true;
4401
+ } finally {
4402
+ lastBrowserConsole = session.browserConsoleBuffer;
4403
+ await closeCaptureSession(session);
4404
+ if (riskSession) await closeCaptureSession(riskSession);
4353
4405
  }
4354
- dedupPerfs.push(getCapturePerfSummary(session));
4355
- } catch (error) {
4356
- lastBrowserConsole = session.browserConsoleBuffer;
4357
- throw wrapCaptureStageError(error, lastBrowserConsole);
4358
- } finally {
4359
- lastBrowserConsole = session.browserConsoleBuffer;
4360
- await closeCaptureSession(session);
4361
- if (riskSession) await closeCaptureSession(riskSession);
4406
+ if (!resumed) break;
4362
4407
  }
4363
4408
  }
4364
4409
  const encodeFrom = job.progress;
@@ -5569,35 +5614,33 @@ function watchStall(phase, rule) {
5569
5614
  const detail = rule.detail ?? (() => void 0);
5570
5615
  let last = rule.progress();
5571
5616
  let budgetMs = rule.firstProgressMs;
5572
- let lastChangeAt = Date.now();
5617
+ const silence = createStallClock();
5618
+ const pollMs = Math.min(STALL_POLL_MS, rule.noProgressMs, rule.firstProgressMs);
5573
5619
  let attempt;
5574
5620
  let timer;
5575
5621
  let inTail = false;
5576
5622
  const restartWindow = (ms) => {
5577
5623
  last = rule.progress();
5578
5624
  budgetMs = ms;
5579
- lastChangeAt = Date.now();
5625
+ silence.reset();
5580
5626
  };
5581
5627
  const tripped = new Promise((_, reject) => {
5582
- timer = setInterval(
5583
- () => {
5584
- const current = rule.progress();
5585
- if (rule.complete?.() && !inTail) {
5586
- inTail = true;
5587
- return restartWindow(rule.tailMs ?? rule.noProgressMs);
5588
- }
5589
- if (current !== last) return restartWindow(rule.noProgressMs);
5590
- if (Date.now() - lastChangeAt < budgetMs) return;
5591
- const error = new RenderStageStalledError(phase, budgetMs, current, detail());
5592
- if (attempt && !attempt.signal.aborted) {
5593
- attempt.abort(error);
5594
- return restartWindow(rule.noProgressMs);
5595
- }
5596
- clearInterval(timer);
5597
- reject(error);
5598
- },
5599
- Math.min(STALL_POLL_MS, rule.noProgressMs, rule.firstProgressMs)
5600
- );
5628
+ timer = setInterval(() => {
5629
+ const current = rule.progress();
5630
+ if (rule.complete?.() && !inTail) {
5631
+ inTail = true;
5632
+ return restartWindow(rule.tailMs ?? rule.noProgressMs);
5633
+ }
5634
+ if (current !== last) return restartWindow(rule.noProgressMs);
5635
+ if (silence.idle(pollMs) < budgetMs) return;
5636
+ const error = new RenderStageStalledError(phase, budgetMs, current, detail());
5637
+ if (attempt && !attempt.signal.aborted) {
5638
+ attempt.abort(error);
5639
+ return restartWindow(rule.noProgressMs);
5640
+ }
5641
+ clearInterval(timer);
5642
+ reject(error);
5643
+ }, pollMs);
5601
5644
  });
5602
5645
  const openAttempt = (parent) => {
5603
5646
  attempt = new AbortController();
@@ -3734,6 +3734,27 @@ import {
3734
3734
  prepareCaptureSessionForReuse,
3735
3735
  spawnStreamingEncoder
3736
3736
  } from "@hyperframes/engine";
3737
+
3738
+ // src/services/render/stallClock.ts
3739
+ var SUSPEND_GAP_MS = 15e3;
3740
+ function createStallClock(now = () => Date.now()) {
3741
+ let since = now();
3742
+ let seen = since;
3743
+ return {
3744
+ reset() {
3745
+ since = seen = now();
3746
+ },
3747
+ idle(expectedMs) {
3748
+ const at = now();
3749
+ const gap = at - seen;
3750
+ seen = at;
3751
+ if (gap > expectedMs + SUSPEND_GAP_MS) since += gap;
3752
+ return at - since;
3753
+ }
3754
+ };
3755
+ }
3756
+
3757
+ // src/services/render/stages/captureStreamingStage.ts
3737
3758
  var DEFAULT_CAPTURE_STALL_MS = 6e4;
3738
3759
  var DE_STALL_POLL_MS = 5e3;
3739
3760
  var STALL_ADVICE = "Chrome stopped answering. If another app is keeping the GPU or CPU busy (a 3D render, a game, another export), let it finish or render one job at a time, then retry.";
@@ -3778,21 +3799,27 @@ var SequentialCaptureStallError = class extends Error {
3778
3799
  };
3779
3800
  function raceAgainstStall(promise, deadlineMs, input, signal) {
3780
3801
  return new Promise((resolve11, reject) => {
3781
- const timer = setTimeout(
3782
- () => {
3783
- reject(
3784
- signal?.aborted ? new Error(
3785
- `[Render] Sequential ${captureModeLabel(input.session.captureMode)} capture aborted while a capture call was in flight (frame ${input.frameIndex}/${input.totalFrames}).`
3786
- ) : new SequentialCaptureStallError(
3787
- input.session,
3788
- input.stallTimeoutMs,
3789
- input.frameIndex,
3790
- input.totalFrames
3791
- )
3792
- );
3793
- },
3794
- Math.max(0, deadlineMs)
3802
+ const clock = createStallClock();
3803
+ const trip = () => reject(
3804
+ signal?.aborted ? new Error(
3805
+ `[Render] Sequential ${captureModeLabel(input.session.captureMode)} capture aborted while a capture call was in flight (frame ${input.frameIndex}/${input.totalFrames}).`
3806
+ ) : new SequentialCaptureStallError(
3807
+ input.session,
3808
+ input.stallTimeoutMs,
3809
+ input.frameIndex,
3810
+ input.totalFrames
3811
+ )
3795
3812
  );
3813
+ const deadline = Math.max(0, deadlineMs);
3814
+ let wait = Math.min(deadline, DE_STALL_POLL_MS);
3815
+ let timer;
3816
+ const look = () => {
3817
+ const idle = clock.idle(wait);
3818
+ if (idle >= deadline) return trip();
3819
+ wait = Math.min(deadline - idle, DE_STALL_POLL_MS);
3820
+ timer = setTimeout(look, wait);
3821
+ };
3822
+ timer = setTimeout(look, wait);
3796
3823
  promise.then(
3797
3824
  (value) => {
3798
3825
  clearTimeout(timer);
@@ -3902,26 +3929,26 @@ function createDrainFrameGuard(args) {
3902
3929
  return buf;
3903
3930
  };
3904
3931
  }
3905
- async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEncoder, reorderBuffer, assertNotAborted, onProgress, log, stats, abortSignal, risk) {
3932
+ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEncoder, reorderBuffer, assertNotAborted, onProgress, log, stats, abortSignal, resume, risk) {
3906
3933
  let prev = null;
3907
3934
  const frameTime = (i) => i * job.config.fps.den / job.config.fps.num;
3908
3935
  const guard = createDrainFrameGuard({ log, stats, frameTime });
3909
3936
  const guardFrame = (item, buf) => item.trusted ? Promise.resolve(buf) : guard(session, item.idx, buf);
3910
3937
  const captureRisk = async (idx) => {
3911
3938
  await risk.open();
3912
- lastProgressAt = Date.now();
3939
+ quiet.reset();
3913
3940
  return raceAgainstStall(
3914
3941
  risk.capture(idx, frameTime(idx)),
3915
- stallTimeoutMs - (Date.now() - lastProgressAt),
3942
+ stallTimeoutMs - quiet.idle(0),
3916
3943
  { session, frameIndex: idx, totalFrames, stallTimeoutMs },
3917
3944
  abortSignal
3918
3945
  );
3919
3946
  };
3920
3947
  const stallTimeoutMs = resolveCaptureStallTimeoutMs();
3921
- let lastProgressAt = Date.now();
3948
+ const quiet = createStallClock();
3922
3949
  const captureWithStallGuard = (idx, promise) => raceAgainstStall(
3923
3950
  promise,
3924
- stallTimeoutMs - (Date.now() - lastProgressAt),
3951
+ stallTimeoutMs - quiet.idle(0),
3925
3952
  { session, frameIndex: idx, totalFrames, stallTimeoutMs },
3926
3953
  abortSignal
3927
3954
  );
@@ -3933,7 +3960,8 @@ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEnc
3933
3960
  ensureFrameWritten(await currentEncoder.writeFrame(buf), prev.idx, currentEncoder);
3934
3961
  reorderBuffer.advanceTo(prev.idx + 1);
3935
3962
  job.framesRendered = prev.idx + 1;
3936
- lastProgressAt = Date.now();
3963
+ resume.written = prev.idx + 1;
3964
+ quiet.reset();
3937
3965
  reportFrameProgress(
3938
3966
  job,
3939
3967
  `Streaming frame ${prev.idx + 1}/${totalFrames}`,
@@ -3953,7 +3981,8 @@ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEnc
3953
3981
  ensureFrameWritten(await currentEncoder.writeFrame(buf), item.idx, currentEncoder);
3954
3982
  reorderBuffer.advanceTo(item.idx + 1);
3955
3983
  job.framesRendered = item.idx + 1;
3956
- lastProgressAt = Date.now();
3984
+ resume.written = item.idx + 1;
3985
+ quiet.reset();
3957
3986
  reportFrameProgress(
3958
3987
  job,
3959
3988
  `Streaming frame ${item.idx + 1}/${totalFrames}`,
@@ -3968,7 +3997,7 @@ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEnc
3968
3997
  const boundarySS = process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true";
3969
3998
  const batchable = (f) => !session.staticFrames?.has(f) && !(boundarySS && session.clipBoundaryFrames?.has(f)) && !risk?.frames.has(f);
3970
3999
  let prevBatch = [];
3971
- let i = 0;
4000
+ let i = resume.from;
3972
4001
  while (i < totalFrames) {
3973
4002
  assertNotAborted();
3974
4003
  if (batchable(i)) {
@@ -4001,7 +4030,7 @@ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEnc
4001
4030
  await drainBatch(prevBatch);
4002
4031
  return;
4003
4032
  }
4004
- for (let i = 0; i < totalFrames; i++) {
4033
+ for (let i = resume.from; i < totalFrames; i++) {
4005
4034
  assertNotAborted();
4006
4035
  if (risk?.frames.has(i)) {
4007
4036
  const buffer = await captureRisk(i);
@@ -4019,6 +4048,10 @@ async function runWorkerEncodePipelineLoop(session, totalFrames, job, currentEnc
4019
4048
  }
4020
4049
  await drainPrev();
4021
4050
  }
4051
+ var STALL_RESUMES_BEFORE_FALLBACK = 3;
4052
+ function resumesAfterStall(error, abortSignal, resume, resumes) {
4053
+ return error instanceof SequentialCaptureStallError && abortSignal?.aborted !== true && resume.written > resume.from && resumes < STALL_RESUMES_BEFORE_FALLBACK;
4054
+ }
4022
4055
  async function runCaptureStreamingStage(input) {
4023
4056
  const {
4024
4057
  fileServer,
@@ -4092,7 +4125,7 @@ async function runCaptureStreamingStage(input) {
4092
4125
  let parallelGuardRan = false;
4093
4126
  let writtenFrames = 0;
4094
4127
  let resumeFrom = 0;
4095
- let resumedAfterStall = false;
4128
+ let resumes = 0;
4096
4129
  let workerResults;
4097
4130
  for (; ; ) {
4098
4131
  const parallelReorder = createFrameReorderBuffer(resumeFrom, totalFrames);
@@ -4127,7 +4160,7 @@ async function runCaptureStreamingStage(input) {
4127
4160
  }
4128
4161
  const stallTimeoutMs = resolveCaptureStallTimeoutMs();
4129
4162
  let lastCapturedFrames = 0;
4130
- let lastProgressAt = Date.now();
4163
+ const quiet = createStallClock();
4131
4164
  const workerPhases = /* @__PURE__ */ new Map();
4132
4165
  const phaseDetail = (phase) => `phase=${phase.phase} frame=${phase.frameIndex ?? "n/a"} step=${phase.readStep?.() ?? "n/a"} browser=${phase.browserExecutable} version=${phase.browserVersion} CanvasDrawElement=${phase.canvasDrawElement} gpu=${phase.gpuBackend}`;
4133
4166
  const phaseSummary = () => [...workerPhases.entries()].sort(([left], [right]) => left - right).map(([workerId, detail]) => `worker=${workerId} ${detail}`).join("; ");
@@ -4138,23 +4171,21 @@ async function runCaptureStreamingStage(input) {
4138
4171
  captureLabel === "BeginFrame",
4139
4172
  cfg
4140
4173
  );
4141
- const stallTimer = setInterval(
4142
- () => {
4143
- if (!watchdogArmed || Date.now() - lastProgressAt <= budget()) return;
4144
- stalled = true;
4145
- const stallErr = new ParallelCaptureStallError({
4146
- variant: "drain",
4147
- captureLabel,
4148
- stallTimeoutMs: budget(),
4149
- lastCapturedFrames: resumeFrom + lastCapturedFrames,
4150
- totalFrames,
4151
- phaseSummary: phaseSummary()
4152
- });
4153
- parallelReorder.abort(stallErr);
4154
- stallController.abort();
4155
- },
4156
- Math.min(budget(), DE_STALL_POLL_MS)
4157
- );
4174
+ const pollMs = Math.min(budget(), DE_STALL_POLL_MS);
4175
+ const stallTimer = setInterval(() => {
4176
+ if (!watchdogArmed || quiet.idle(pollMs) <= budget()) return;
4177
+ stalled = true;
4178
+ const stallErr = new ParallelCaptureStallError({
4179
+ variant: "drain",
4180
+ captureLabel,
4181
+ stallTimeoutMs: budget(),
4182
+ lastCapturedFrames: resumeFrom + lastCapturedFrames,
4183
+ totalFrames,
4184
+ phaseSummary: phaseSummary()
4185
+ });
4186
+ parallelReorder.abort(stallErr);
4187
+ stallController.abort();
4188
+ }, pollMs);
4158
4189
  try {
4159
4190
  workerResults = await executeParallelCapture(
4160
4191
  fileServer.url,
@@ -4169,7 +4200,7 @@ async function runCaptureStreamingStage(input) {
4169
4200
  workerPhases.set(phase.workerId, phaseDetail(phase));
4170
4201
  if (phase.phase === "frame_capture" && !watchdogArmed) {
4171
4202
  watchdogArmed = true;
4172
- lastProgressAt = Date.now();
4203
+ quiet.reset();
4173
4204
  }
4174
4205
  log.info("[Render] Parallel capture worker phase", {
4175
4206
  workerId: phase.workerId,
@@ -4185,7 +4216,7 @@ async function runCaptureStreamingStage(input) {
4185
4216
  }
4186
4217
  if (progress.capturedFrames > lastCapturedFrames) {
4187
4218
  lastCapturedFrames = progress.capturedFrames;
4188
- lastProgressAt = Date.now();
4219
+ quiet.reset();
4189
4220
  watchdogArmed = true;
4190
4221
  }
4191
4222
  const doneFrames = resumeFrom + progress.capturedFrames;
@@ -4216,17 +4247,14 @@ async function runCaptureStreamingStage(input) {
4216
4247
  }
4217
4248
  );
4218
4249
  } catch (err) {
4219
- if (stalled && abortSignal?.aborted !== true && captureLabel === "BeginFrame" && !resumedAfterStall && writtenFrames > resumeFrom) {
4220
- resumedAfterStall = true;
4221
- log.warn(
4222
- "[Render] Parallel BeginFrame capture stalled; resuming from the first unwritten frame",
4223
- {
4224
- resumeFrom: writtenFrames,
4225
- totalFrames,
4226
- workerPhases: phaseSummary()
4227
- }
4228
- );
4250
+ if (stalled && abortSignal?.aborted !== true && writtenFrames > resumeFrom && resumes < STALL_RESUMES_BEFORE_FALLBACK) {
4251
+ log.warn("[Render] Parallel capture stalled; resuming from the first unwritten frame", {
4252
+ resumeFrom: writtenFrames,
4253
+ totalFrames,
4254
+ workerPhases: phaseSummary()
4255
+ });
4229
4256
  resumeFrom = writtenFrames;
4257
+ resumes++;
4230
4258
  continue;
4231
4259
  }
4232
4260
  if (parallelDrainError) throw poolFailure(parallelDrainError, probeSession);
@@ -4262,116 +4290,133 @@ async function runCaptureStreamingStage(input) {
4262
4290
  }
4263
4291
  lastBrowserConsole = withWorkerPageErrors(lastBrowserConsole, workerPageErrors);
4264
4292
  } else {
4265
- const videoInjector = createRenderVideoFrameInjector();
4266
- const session = probeSession ?? await createCaptureSession(
4267
- fileServer.url,
4268
- framesDir,
4269
- buildCaptureOptions(),
4270
- videoInjector,
4271
- captureCfg
4272
- );
4273
- captureBeyondViewport = session.options.captureBeyondViewport;
4274
- if (probeSession) {
4275
- prepareCaptureSessionForReuse(session, framesDir, videoInjector);
4276
- probeSession = null;
4277
- }
4278
- let riskSession = null;
4279
- const openRiskSession = async () => {
4280
- const opened = await createCaptureSession(
4293
+ const resume = { from: 0, written: 0 };
4294
+ let resumes = 0;
4295
+ for (; ; ) {
4296
+ const videoInjector = createRenderVideoFrameInjector();
4297
+ const session = probeSession ?? await createCaptureSession(
4281
4298
  fileServer.url,
4282
4299
  framesDir,
4283
4300
  buildCaptureOptions(),
4284
- createRenderVideoFrameInjector(),
4285
- {
4286
- ...captureCfg,
4287
- useDrawElement: false,
4288
- staticFrameDedup: false,
4289
- enableBrowserPool: false
4290
- }
4301
+ videoInjector,
4302
+ captureCfg
4291
4303
  );
4292
- riskSession = opened;
4293
- await initializeSession(opened);
4294
- return opened;
4295
- };
4296
- try {
4297
- if (!session.isInitialized) {
4298
- await initializeSession(session);
4304
+ captureBeyondViewport = session.options.captureBeyondViewport;
4305
+ if (probeSession) {
4306
+ prepareCaptureSessionForReuse(session, framesDir, videoInjector);
4307
+ probeSession = null;
4299
4308
  }
4300
- await completeDeferredDrawElementInit(session);
4301
- assertNotAborted();
4302
- lastBrowserConsole = session.browserConsoleBuffer;
4303
- if (session.workerEncodeEnabled) {
4304
- deDrainStats = {
4305
- verifyChecked: 0,
4306
- blankSuspects: 0,
4307
- blankDeterministicAccepts: 0,
4308
- blankRecaptures: 0
4309
- };
4310
- const riskFrames = session.deRiskFrames;
4311
- await runWorkerEncodePipelineLoop(
4312
- session,
4313
- totalFrames,
4314
- job,
4315
- currentEncoder,
4316
- reorderBuffer,
4317
- assertNotAborted,
4318
- onProgress,
4319
- log,
4320
- deDrainStats,
4321
- abortSignal,
4322
- riskFrames && riskFrames.size > 0 ? {
4323
- frames: riskFrames,
4324
- // Opened on the first risk frame, in a browser of its own: pages sharing a
4325
- // browser share a compositor, which starves the drawElement page's paint events.
4326
- open: async () => {
4327
- riskSession ??= await openRiskSession();
4328
- },
4329
- capture: async (frameIndex, time) => (await captureFrameToBuffer(riskSession, frameIndex, time)).buffer
4330
- } : void 0
4309
+ let riskSession = null;
4310
+ const openRiskSession = async () => {
4311
+ const opened = await createCaptureSession(
4312
+ fileServer.url,
4313
+ framesDir,
4314
+ buildCaptureOptions(),
4315
+ createRenderVideoFrameInjector(),
4316
+ {
4317
+ ...captureCfg,
4318
+ useDrawElement: false,
4319
+ staticFrameDedup: false,
4320
+ enableBrowserPool: false
4321
+ }
4331
4322
  );
4332
- } else {
4333
- const stallTimeoutMs = resolveCaptureStallTimeoutMs();
4334
- let lastProgressAt = Date.now();
4335
- for (let i = 0; i < totalFrames; i++) {
4336
- assertNotAborted();
4337
- const time = i * job.config.fps.den / job.config.fps.num;
4338
- const budget = stallBudgetMs(
4339
- stallTimeoutMs,
4340
- i,
4341
- session.captureMode === "beginframe",
4342
- cfg
4343
- );
4344
- const { buffer } = await raceAgainstStall(
4345
- captureFrameToBuffer(session, i, time),
4346
- budget - (Date.now() - lastProgressAt),
4347
- { session, frameIndex: i, totalFrames, stallTimeoutMs: budget },
4348
- abortSignal
4349
- );
4350
- await reorderBuffer.waitForFrame(i);
4351
- ensureFrameWritten(await currentEncoder.writeFrame(buffer), i, currentEncoder);
4352
- reorderBuffer.advanceTo(i + 1);
4353
- job.framesRendered = i + 1;
4354
- lastProgressAt = Date.now();
4355
- const frameProgress = (i + 1) / totalFrames;
4356
- const progress = 25 + frameProgress * 55;
4357
- reportFrameProgress(
4323
+ riskSession = opened;
4324
+ await initializeSession(opened);
4325
+ return opened;
4326
+ };
4327
+ let resumed = false;
4328
+ try {
4329
+ if (!session.isInitialized) {
4330
+ await initializeSession(session);
4331
+ }
4332
+ await completeDeferredDrawElementInit(session);
4333
+ assertNotAborted();
4334
+ lastBrowserConsole = session.browserConsoleBuffer;
4335
+ if (session.workerEncodeEnabled) {
4336
+ deDrainStats ??= {
4337
+ verifyChecked: 0,
4338
+ blankSuspects: 0,
4339
+ blankDeterministicAccepts: 0,
4340
+ blankRecaptures: 0
4341
+ };
4342
+ const riskFrames = session.deRiskFrames;
4343
+ await runWorkerEncodePipelineLoop(
4344
+ session,
4345
+ totalFrames,
4358
4346
  job,
4359
- `Streaming frame ${i + 1}/${totalFrames}`,
4360
- Math.round(progress),
4347
+ currentEncoder,
4348
+ reorderBuffer,
4349
+ assertNotAborted,
4361
4350
  onProgress,
4362
- i + 1,
4363
- totalFrames
4351
+ log,
4352
+ deDrainStats,
4353
+ abortSignal,
4354
+ resume,
4355
+ riskFrames && riskFrames.size > 0 ? {
4356
+ frames: riskFrames,
4357
+ // Opened on the first risk frame, in a browser of its own: pages sharing a
4358
+ // browser share a compositor, which starves the drawElement page's paint events.
4359
+ open: async () => {
4360
+ riskSession ??= await openRiskSession();
4361
+ },
4362
+ capture: async (frameIndex, time) => (await captureFrameToBuffer(riskSession, frameIndex, time)).buffer
4363
+ } : void 0
4364
4364
  );
4365
+ } else {
4366
+ const stallTimeoutMs = resolveCaptureStallTimeoutMs();
4367
+ let lastProgressAt = Date.now();
4368
+ for (let i = resume.from; i < totalFrames; i++) {
4369
+ assertNotAborted();
4370
+ const time = i * job.config.fps.den / job.config.fps.num;
4371
+ const budget = stallBudgetMs(
4372
+ stallTimeoutMs,
4373
+ i,
4374
+ session.captureMode === "beginframe",
4375
+ cfg
4376
+ );
4377
+ const { buffer } = await raceAgainstStall(
4378
+ captureFrameToBuffer(session, i, time),
4379
+ budget - (Date.now() - lastProgressAt),
4380
+ { session, frameIndex: i, totalFrames, stallTimeoutMs: budget },
4381
+ abortSignal
4382
+ );
4383
+ await reorderBuffer.waitForFrame(i);
4384
+ ensureFrameWritten(await currentEncoder.writeFrame(buffer), i, currentEncoder);
4385
+ reorderBuffer.advanceTo(i + 1);
4386
+ job.framesRendered = i + 1;
4387
+ resume.written = i + 1;
4388
+ lastProgressAt = Date.now();
4389
+ const frameProgress = (i + 1) / totalFrames;
4390
+ const progress = 25 + frameProgress * 55;
4391
+ reportFrameProgress(
4392
+ job,
4393
+ `Streaming frame ${i + 1}/${totalFrames}`,
4394
+ Math.round(progress),
4395
+ onProgress,
4396
+ i + 1,
4397
+ totalFrames
4398
+ );
4399
+ }
4400
+ }
4401
+ dedupPerfs.push(getCapturePerfSummary(session));
4402
+ } catch (error) {
4403
+ lastBrowserConsole = session.browserConsoleBuffer;
4404
+ if (!resumesAfterStall(error, abortSignal, resume, resumes)) {
4405
+ throw wrapCaptureStageError(error, lastBrowserConsole);
4365
4406
  }
4407
+ log.warn("[Render] Sequential capture stalled; resuming from the first unwritten frame", {
4408
+ resumeFrom: resume.written,
4409
+ totalFrames
4410
+ });
4411
+ resume.from = resume.written;
4412
+ resumes++;
4413
+ resumed = true;
4414
+ } finally {
4415
+ lastBrowserConsole = session.browserConsoleBuffer;
4416
+ await closeCaptureSession(session);
4417
+ if (riskSession) await closeCaptureSession(riskSession);
4366
4418
  }
4367
- dedupPerfs.push(getCapturePerfSummary(session));
4368
- } catch (error) {
4369
- lastBrowserConsole = session.browserConsoleBuffer;
4370
- throw wrapCaptureStageError(error, lastBrowserConsole);
4371
- } finally {
4372
- lastBrowserConsole = session.browserConsoleBuffer;
4373
- await closeCaptureSession(session);
4374
- if (riskSession) await closeCaptureSession(riskSession);
4419
+ if (!resumed) break;
4375
4420
  }
4376
4421
  }
4377
4422
  const encodeFrom = job.progress;
@@ -5582,35 +5627,33 @@ function watchStall(phase, rule) {
5582
5627
  const detail = rule.detail ?? (() => void 0);
5583
5628
  let last = rule.progress();
5584
5629
  let budgetMs = rule.firstProgressMs;
5585
- let lastChangeAt = Date.now();
5630
+ const silence = createStallClock();
5631
+ const pollMs = Math.min(STALL_POLL_MS, rule.noProgressMs, rule.firstProgressMs);
5586
5632
  let attempt;
5587
5633
  let timer;
5588
5634
  let inTail = false;
5589
5635
  const restartWindow = (ms) => {
5590
5636
  last = rule.progress();
5591
5637
  budgetMs = ms;
5592
- lastChangeAt = Date.now();
5638
+ silence.reset();
5593
5639
  };
5594
5640
  const tripped = new Promise((_, reject) => {
5595
- timer = setInterval(
5596
- () => {
5597
- const current = rule.progress();
5598
- if (rule.complete?.() && !inTail) {
5599
- inTail = true;
5600
- return restartWindow(rule.tailMs ?? rule.noProgressMs);
5601
- }
5602
- if (current !== last) return restartWindow(rule.noProgressMs);
5603
- if (Date.now() - lastChangeAt < budgetMs) return;
5604
- const error = new RenderStageStalledError(phase, budgetMs, current, detail());
5605
- if (attempt && !attempt.signal.aborted) {
5606
- attempt.abort(error);
5607
- return restartWindow(rule.noProgressMs);
5608
- }
5609
- clearInterval(timer);
5610
- reject(error);
5611
- },
5612
- Math.min(STALL_POLL_MS, rule.noProgressMs, rule.firstProgressMs)
5613
- );
5641
+ timer = setInterval(() => {
5642
+ const current = rule.progress();
5643
+ if (rule.complete?.() && !inTail) {
5644
+ inTail = true;
5645
+ return restartWindow(rule.tailMs ?? rule.noProgressMs);
5646
+ }
5647
+ if (current !== last) return restartWindow(rule.noProgressMs);
5648
+ if (silence.idle(pollMs) < budgetMs) return;
5649
+ const error = new RenderStageStalledError(phase, budgetMs, current, detail());
5650
+ if (attempt && !attempt.signal.aborted) {
5651
+ attempt.abort(error);
5652
+ return restartWindow(rule.noProgressMs);
5653
+ }
5654
+ clearInterval(timer);
5655
+ reject(error);
5656
+ }, pollMs);
5614
5657
  });
5615
5658
  const openAttempt = (parent) => {
5616
5659
  attempt = new AbortController();
@@ -0,0 +1,7 @@
1
+ export interface StallClock {
2
+ /** Progress landed: the silence starts again. */
3
+ reset(): void;
4
+ /** Silent ms, not counting a suspended stretch; `expectedMs` is how long after the last look this one was due. */
5
+ idle(expectedMs: number): number;
6
+ }
7
+ export declare function createStallClock(now?: () => number): StallClock;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/producer",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,11 +35,11 @@
35
35
  "postcss": "^8.4.0",
36
36
  "puppeteer": "~25.11.0",
37
37
  "puppeteer-core": "^25.11.0",
38
- "@hyperframes/core": "0.9.9",
39
- "@hyperframes/engine": "0.9.9",
40
- "@hyperframes/lint": "0.9.9",
41
- "@hyperframes/studio-server": "0.9.9",
42
- "@hyperframes/parsers": "0.9.9"
38
+ "@hyperframes/core": "0.9.11",
39
+ "@hyperframes/engine": "0.9.11",
40
+ "@hyperframes/lint": "0.9.11",
41
+ "@hyperframes/parsers": "0.9.11",
42
+ "@hyperframes/studio-server": "0.9.11"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^25.0.10",
@@ -48,7 +48,7 @@
48
48
  "tsx": "^4.21.0",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^4.1.11",
51
- "@hyperframes/aws-lambda": "0.9.9"
51
+ "@hyperframes/aws-lambda": "0.9.11"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=22"