@hyperframes/producer 0.8.214 → 0.8.217

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.
@@ -3707,6 +3707,10 @@ function resolveCaptureStallTimeoutMs() {
3707
3707
  const parsed = raw ? Number(raw) : Number.NaN;
3708
3708
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CAPTURE_STALL_MS;
3709
3709
  }
3710
+ function stallBudgetMs(stallTimeoutMs, framesDone, beginFrame, cfg) {
3711
+ const deadline = cfg.beginFrameFirstFrameDeadlineMs;
3712
+ return beginFrame && framesDone === 0 && deadline ? Math.min(stallTimeoutMs, deadline) : stallTimeoutMs;
3713
+ }
3710
3714
  function captureModeLabel(mode) {
3711
3715
  if (mode === "drawelement") return "drawElement";
3712
3716
  if (mode === "beginframe") return "BeginFrame";
@@ -4079,14 +4083,20 @@ async function runCaptureStreamingStage(input) {
4079
4083
  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}`;
4080
4084
  const phaseSummary = () => [...workerPhases.entries()].sort(([left], [right]) => left - right).map(([workerId, detail]) => `worker=${workerId} ${detail}`).join("; ");
4081
4085
  let stalled = false;
4086
+ const budget = () => stallBudgetMs(
4087
+ stallTimeoutMs,
4088
+ resumeFrom + lastCapturedFrames,
4089
+ captureLabel === "BeginFrame",
4090
+ cfg
4091
+ );
4082
4092
  const stallTimer = setInterval(
4083
4093
  () => {
4084
- if (!watchdogArmed || Date.now() - lastProgressAt <= stallTimeoutMs) return;
4094
+ if (!watchdogArmed || Date.now() - lastProgressAt <= budget()) return;
4085
4095
  stalled = true;
4086
4096
  const stallErr = new ParallelCaptureStallError({
4087
4097
  variant: "drain",
4088
4098
  captureLabel,
4089
- stallTimeoutMs,
4099
+ stallTimeoutMs: budget(),
4090
4100
  lastCapturedFrames: resumeFrom + lastCapturedFrames,
4091
4101
  totalFrames,
4092
4102
  phaseSummary: phaseSummary()
@@ -4094,7 +4104,7 @@ async function runCaptureStreamingStage(input) {
4094
4104
  parallelReorder.abort(stallErr);
4095
4105
  stallController.abort();
4096
4106
  },
4097
- Math.min(stallTimeoutMs, DE_STALL_POLL_MS)
4107
+ Math.min(budget(), DE_STALL_POLL_MS)
4098
4108
  );
4099
4109
  try {
4100
4110
  workerResults = await executeParallelCapture(
@@ -4175,7 +4185,7 @@ async function runCaptureStreamingStage(input) {
4175
4185
  throw new ParallelCaptureStallError({
4176
4186
  variant: "pool",
4177
4187
  captureLabel,
4178
- stallTimeoutMs,
4188
+ stallTimeoutMs: budget(),
4179
4189
  lastCapturedFrames: resumeFrom + lastCapturedFrames,
4180
4190
  totalFrames,
4181
4191
  phaseSummary: phaseSummary()
@@ -4276,10 +4286,16 @@ async function runCaptureStreamingStage(input) {
4276
4286
  for (let i = 0; i < totalFrames; i++) {
4277
4287
  assertNotAborted();
4278
4288
  const time = i * job.config.fps.den / job.config.fps.num;
4289
+ const budget = stallBudgetMs(
4290
+ stallTimeoutMs,
4291
+ i,
4292
+ session.captureMode === "beginframe",
4293
+ cfg
4294
+ );
4279
4295
  const { buffer } = await raceAgainstStall(
4280
4296
  captureFrameToBuffer(session, i, time),
4281
- stallTimeoutMs - (Date.now() - lastProgressAt),
4282
- { session, frameIndex: i, totalFrames, stallTimeoutMs },
4297
+ budget - (Date.now() - lastProgressAt),
4298
+ { session, frameIndex: i, totalFrames, stallTimeoutMs: budget },
4283
4299
  abortSignal
4284
4300
  );
4285
4301
  await reorderBuffer.waitForFrame(i);
@@ -5497,6 +5513,7 @@ function watchStall(phase, rule) {
5497
5513
  let lastChangeAt = Date.now();
5498
5514
  let attempt;
5499
5515
  let timer;
5516
+ let inTail = false;
5500
5517
  const restartWindow = (ms) => {
5501
5518
  last = rule.progress();
5502
5519
  budgetMs = ms;
@@ -5506,6 +5523,10 @@ function watchStall(phase, rule) {
5506
5523
  timer = setInterval(
5507
5524
  () => {
5508
5525
  const current = rule.progress();
5526
+ if (rule.complete?.() && !inTail) {
5527
+ inTail = true;
5528
+ return restartWindow(rule.tailMs ?? rule.noProgressMs);
5529
+ }
5509
5530
  if (current !== last) return restartWindow(rule.noProgressMs);
5510
5531
  if (Date.now() - lastChangeAt < budgetMs) return;
5511
5532
  const error = new RenderStageStalledError(phase, budgetMs, current);
@@ -5521,6 +5542,7 @@ function watchStall(phase, rule) {
5521
5542
  });
5522
5543
  const openAttempt = (parent) => {
5523
5544
  attempt = new AbortController();
5545
+ inTail = false;
5524
5546
  restartWindow(rule.firstProgressMs);
5525
5547
  return parent ? AbortSignal.any([parent, attempt.signal]) : attempt.signal;
5526
5548
  };
@@ -8512,7 +8534,8 @@ async function runCompileStage(input) {
8512
8534
  }
8513
8535
 
8514
8536
  // src/services/render/stages/probeStage.ts
8515
- import { join as join17 } from "node:path";
8537
+ import { createHash as createHash7 } from "node:crypto";
8538
+ import { basename as basename5, join as join17 } from "node:path";
8516
8539
  import { parseHTML as parseHTML6 } from "linkedom";
8517
8540
  import {
8518
8541
  closeCaptureSession as closeCaptureSession4,
@@ -8520,6 +8543,7 @@ import {
8520
8543
  createCaptureSession as createCaptureSession4,
8521
8544
  deriveBeginFrameProbeTimeTicks,
8522
8545
  getCompositionDuration,
8546
+ getCompositionLengthSource,
8523
8547
  initializeSession as initializeSession4,
8524
8548
  isTransientBrowserError,
8525
8549
  probeBeginFrameLiveness,
@@ -8658,6 +8682,13 @@ function hasRuntimeMediaChanges(html) {
8658
8682
  function probeRequiresBrowser(args) {
8659
8683
  return args.durationSeconds <= 0 || args.unresolvedCompositionCount > 0 || args.hasAutoStart || args.hasScriptedAudio || args.hasVariableMedia || args.hasInsertedMedia;
8660
8684
  }
8685
+ function reportedLengthSource(source) {
8686
+ if (!source) return null;
8687
+ if (source.from !== "media") return { from: source.from };
8688
+ const name = basename5(source.src.split(/[?#]/, 1)[0] ?? "");
8689
+ const srcHash = createHash7("sha256").update(name).digest("hex").slice(0, 12);
8690
+ return { from: "media", tag: source.tag, trimmed: source.trimmed, srcHash };
8691
+ }
8661
8692
  async function runProbeStage(input) {
8662
8693
  const {
8663
8694
  projectDir,
@@ -8679,6 +8710,7 @@ async function runProbeStage(input) {
8679
8710
  const probeCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
8680
8711
  let fileServer = null;
8681
8712
  let probeSession = null;
8713
+ let lengthSource = input.composition.duration > 0 ? { from: "declared" } : null;
8682
8714
  let beginFrameStalled = false;
8683
8715
  let lastBrowserConsole = [];
8684
8716
  const probeStart = Date.now();
@@ -8840,10 +8872,12 @@ async function runProbeStage(input) {
8840
8872
  if (composition.duration <= 0) {
8841
8873
  log.info("Discovering composition duration...");
8842
8874
  const discoveredDuration = await getCompositionDuration(session);
8875
+ lengthSource = reportedLengthSource(await getCompositionLengthSource(session));
8843
8876
  assertNotAborted();
8844
8877
  log.info("Probed composition duration from browser", {
8845
8878
  discoveredDuration,
8846
- staticDuration: compiled.staticDuration
8879
+ staticDuration: compiled.staticDuration,
8880
+ lengthSource
8847
8881
  });
8848
8882
  composition.duration = discoveredDuration;
8849
8883
  } else {
@@ -9132,6 +9166,7 @@ async function runProbeStage(input) {
9132
9166
  probeSession,
9133
9167
  lastBrowserConsole,
9134
9168
  duration,
9169
+ lengthSource,
9135
9170
  totalFrames,
9136
9171
  browserProbeMs,
9137
9172
  beginFrameStalled
@@ -9619,7 +9654,7 @@ function checkedDbfs(value) {
9619
9654
  }
9620
9655
  return value;
9621
9656
  }
9622
- async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
9657
+ async function scanTruePeak(audioPath, trim, timeoutMs, signal, seekSeconds = 0) {
9623
9658
  let firstBlock;
9624
9659
  let laterBlocks = Number.NEGATIVE_INFINITY;
9625
9660
  let partialLine = "";
@@ -9635,7 +9670,21 @@ async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
9635
9670
  }
9636
9671
  };
9637
9672
  const result = await runFfmpeg4(
9638
- ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
9673
+ [
9674
+ ...seekSeconds > 0 ? ["-ss", String(seekSeconds)] : [],
9675
+ ...[
9676
+ "-i",
9677
+ audioPath,
9678
+ "-map",
9679
+ "0:a:0",
9680
+ "-vn",
9681
+ "-af",
9682
+ `${trim}ebur128=peak=true`,
9683
+ "-f",
9684
+ "null",
9685
+ "-"
9686
+ ]
9687
+ ],
9639
9688
  { signal, timeout: timeoutMs, onStderr: readLines }
9640
9689
  );
9641
9690
  if (!result.success) {
@@ -9661,12 +9710,15 @@ async function measureTruePeakDbfs(audioPath, options) {
9661
9710
  const scans = await Promise.all(
9662
9711
  Array.from({ length: count }, (_, k) => {
9663
9712
  const start = k === 0 ? 0 : cut(k) - block;
9664
- const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
9713
+ const seekSeconds = Math.max(0, Math.floor(start / sampleRate) - 1);
9714
+ const skipped = seekSeconds * sampleRate;
9715
+ const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1) - skipped}`;
9665
9716
  return scanTruePeak(
9666
9717
  audioPath,
9667
- `atrim=start_sample=${start}${end},`,
9718
+ `atrim=start_sample=${start - skipped}${end},`,
9668
9719
  timeoutMs,
9669
- segmentSignal
9720
+ segmentSignal,
9721
+ seekSeconds
9670
9722
  );
9671
9723
  })
9672
9724
  ).catch((error) => {
@@ -12295,12 +12347,15 @@ function resetCaptureAttemptProgress(job) {
12295
12347
  job.framesRendered = 0;
12296
12348
  }
12297
12349
  var FIRST_FRAME_STALL_MS = 3e5;
12298
- function frameStallRule(job) {
12350
+ var TAIL_STALL_MS = FIRST_FRAME_STALL_MS;
12351
+ function frameStallRule(job, totalFrames) {
12299
12352
  const noProgressMs = resolveCaptureStallTimeoutMs();
12300
12353
  return {
12301
12354
  progress: () => job.framesRendered ?? 0,
12302
12355
  noProgressMs,
12303
- firstProgressMs: Math.max(FIRST_FRAME_STALL_MS, noProgressMs)
12356
+ firstProgressMs: Math.max(FIRST_FRAME_STALL_MS, noProgressMs),
12357
+ complete: () => (job.framesRendered ?? 0) >= totalFrames,
12358
+ tailMs: Math.max(TAIL_STALL_MS, noProgressMs)
12304
12359
  };
12305
12360
  }
12306
12361
  function isRecoverableParallelCaptureError(error) {
@@ -13317,6 +13372,12 @@ async function executeRenderPipeline(input) {
13317
13372
  }
13318
13373
  observability.checkpoint("browser_probe", "duration resolved", {
13319
13374
  durationSeconds: probeResult.duration,
13375
+ lengthFrom: probeResult.lengthSource?.from ?? null,
13376
+ ...probeResult.lengthSource?.from === "media" ? {
13377
+ lengthTag: probeResult.lengthSource.tag,
13378
+ lengthTrimmed: probeResult.lengthSource.trimmed,
13379
+ lengthSrcHash: probeResult.lengthSource.srcHash
13380
+ } : {},
13320
13381
  totalFrames,
13321
13382
  compositionHash,
13322
13383
  beginFrameStalled: probeResult.beginFrameStalled
@@ -13502,6 +13563,14 @@ async function executeRenderPipeline(input) {
13502
13563
  // sample frame indices from this so they land inside the drained range.
13503
13564
  compositionDurationSeconds: job.duration,
13504
13565
  deFrameFallback: true,
13566
+ // A line per scanned chunk, so a long effect scan reads as preparing, not as a stall.
13567
+ onEffectScanProgress: (scanned, total) => updateJobStatus(
13568
+ job,
13569
+ "rendering",
13570
+ `Checking effects, ${scanned} of ${total} frames`,
13571
+ 25,
13572
+ onProgress
13573
+ ),
13505
13574
  requiresWebGpu: compositionRequiresWebGpu2(compiled.html),
13506
13575
  // Live route for Chrome memory (spec §5 Phase −1). The aggregate route
13507
13576
  // through CapturePerfSummary only exists on success; a mid-capture
@@ -14186,7 +14255,7 @@ async function executeRenderPipeline(input) {
14186
14255
  onProgress
14187
14256
  });
14188
14257
  },
14189
- { stall: frameStallRule(job) }
14258
+ { stall: frameStallRule(job, totalFrames) }
14190
14259
  );
14191
14260
  lastBrowserConsole = hdrRes.lastBrowserConsole;
14192
14261
  layeredCaptureWarnings.push(...hdrRes.warnings);
@@ -14631,7 +14700,7 @@ async function executeRenderPipeline(input) {
14631
14700
  interleaveFrames: overlap !== null,
14632
14701
  openAttempt
14633
14702
  }),
14634
- { stall: frameStallRule(job) }
14703
+ { stall: frameStallRule(job, totalFrames) }
14635
14704
  );
14636
14705
  let captureRes;
14637
14706
  try {
@@ -15524,9 +15593,7 @@ function renderConfigFromRequest(request, runtime = {}) {
15524
15593
  };
15525
15594
  }
15526
15595
 
15527
- // src/server.ts
15528
- var DEFAULT_SERVER_FPS = { num: 30, den: 1 };
15529
- var SSE_KEEPALIVE_MS = 3e4;
15596
+ // src/services/safeRenderErrorCode.ts
15530
15597
  var SAFE_RENDER_ERROR_CODES = /* @__PURE__ */ new Set([
15531
15598
  "ASSET_MEDIA_TYPE_MISMATCH",
15532
15599
  "HEIF_IMAGE_CONVERSION_FAILED",
@@ -15544,6 +15611,10 @@ function extractSafeRenderErrorCode(error) {
15544
15611
  const code = error.code;
15545
15612
  return typeof code === "string" && SAFE_RENDER_ERROR_CODES.has(code) ? code : void 0;
15546
15613
  }
15614
+
15615
+ // src/server.ts
15616
+ var DEFAULT_SERVER_FPS = { num: 30, den: 1 };
15617
+ var SSE_KEEPALIVE_MS = 3e4;
15547
15618
  function extractSafeRenderErrorMetadata(error) {
15548
15619
  const errorCode = extractSafeRenderErrorCode(error);
15549
15620
  if (!errorCode || typeof error !== "object" || error === null) return void 0;
package/dist/server.d.ts CHANGED
@@ -16,6 +16,8 @@ import { Hono, type Context } from "hono";
16
16
  import { type RenderConfig } from "./services/renderOrchestrator.js";
17
17
  import { type ProducerLogger } from "./logger.js";
18
18
  import { type CanvasResolution } from "@hyperframes/core";
19
+ import { extractSafeRenderErrorCode } from "./services/safeRenderErrorCode.js";
20
+ export { extractSafeRenderErrorCode };
19
21
  export interface HandlerOptions {
20
22
  /** Custom logger. Defaults to console-based defaultLogger. */
21
23
  logger?: ProducerLogger;
@@ -80,11 +82,6 @@ interface PreparedRenderInput {
80
82
  input: RenderInput;
81
83
  cleanupProjectDir?: string;
82
84
  }
83
- /**
84
- * Preserve only bounded producer error codes across JSON/SSE. Never derive a
85
- * code from the message: it may contain local paths or signed source URLs.
86
- */
87
- export declare function extractSafeRenderErrorCode(error: unknown): string | undefined;
88
85
  export interface SafeRenderErrorMetadata {
89
86
  errorCode: string;
90
87
  errorOwner?: "system" | "user";
@@ -126,4 +123,3 @@ export declare function createProducerApp(options?: HandlerOptions): Hono;
126
123
  * Start the producer HTTP server with graceful shutdown.
127
124
  */
128
125
  export declare function startServer(options?: ServerOptions): import("@hono/node-server").ServerType;
129
- export {};
@@ -333,6 +333,10 @@ export interface StageStallRule {
333
333
  noProgressMs: number;
334
334
  /** Allowed silence before the current attempt's first change. */
335
335
  firstProgressMs: number;
336
+ /** True once every unit of work has landed: nothing more can change `progress()`, only the tail work runs. */
337
+ complete?: () => boolean;
338
+ /** Allowed silence once `complete()` holds; defaults to `noProgressMs`. */
339
+ tailMs?: number;
336
340
  }
337
341
  /**
338
342
  * Classified like the per-call protocol timeout it replaces, so the capture retry ladder retries it.
@@ -26,7 +26,7 @@
26
26
  * regex, the same first-10/first-5 slicing, and the same `console.warn`
27
27
  * prefix.
28
28
  */
29
- import { type CaptureSession, type EngineConfig, type SubTimelineWaitMemo } from "@hyperframes/engine";
29
+ import { type CaptureSession, type CompositionLengthSource, type EngineConfig, type SubTimelineWaitMemo } from "@hyperframes/engine";
30
30
  import type { CompiledComposition } from "../../htmlCompiler.js";
31
31
  import { type FileServerHandle } from "../../fileServer.js";
32
32
  import type { ProducerLogger } from "../../../logger.js";
@@ -67,6 +67,8 @@ export interface ProbeStageResult {
67
67
  lastBrowserConsole: string[];
68
68
  /** Composition duration (post-probe). Guaranteed > 0 — the stage throws on <= 0. */
69
69
  duration: number;
70
+ /** Which input set `duration`; null when the runtime did not say. */
71
+ lengthSource: ReportedLengthSource | null;
70
72
  totalFrames: number;
71
73
  /** Wall-clock ms for the entire probe phase (near-zero when `needsBrowser` was false). */
72
74
  browserProbeMs: number;
@@ -113,6 +115,16 @@ export declare function probeRequiresBrowser(args: {
113
115
  hasVariableMedia: boolean;
114
116
  hasInsertedMedia: boolean;
115
117
  }): boolean;
118
+ export type ReportedLengthSource = {
119
+ from: Exclude<CompositionLengthSource["from"], "media">;
120
+ } | {
121
+ from: "media";
122
+ tag: string;
123
+ trimmed: boolean;
124
+ srcHash: string;
125
+ };
126
+ /** The length source with a media clip's src reduced to a hash of its file name, so no path leaves the render. */
127
+ export declare function reportedLengthSource(source: CompositionLengthSource | null): ReportedLengthSource | null;
116
128
  export declare function runProbeStage(input: ProbeStageInput): Promise<ProbeStageResult>;
117
129
  /**
118
130
  * Preview/render parity for `muted` media: the runtime keeps muted elements
@@ -567,10 +567,11 @@ export declare function captureAttemptMadeProgress(attemptTargetFrameCount: numb
567
567
  export declare function resetCaptureAttemptProgress(job: {
568
568
  framesRendered?: number;
569
569
  }): void;
570
- /** The no-progress rule for capture stages that have no watchdog of their own: disk, HDR, chunk. */
570
+ /** The no-progress rule for capture stages that have no watchdog of their own: disk, HDR, chunk. Once
571
+ * `totalFrames` have landed the frame count can no longer change, so the stage is judged on the tail budget. */
571
572
  export declare function frameStallRule(job: {
572
573
  framesRendered?: number;
573
- }): StageStallRule;
574
+ }, totalFrames: number): StageStallRule;
574
575
  export declare function isRecoverableParallelCaptureError(error: unknown): boolean;
575
576
  /**
576
577
  * Turn a cryptic memory-exhaustion failure (V8 `Set maximum size exceeded`,
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Preserve only bounded producer error codes across JSON/SSE. Never derive a
3
+ * code from the message: it may contain local paths or signed source URLs.
4
+ */
5
+ export declare function extractSafeRenderErrorCode(error: unknown): string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/producer",
3
- "version": "0.8.214",
3
+ "version": "0.8.217",
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/lint": "0.8.214",
39
- "@hyperframes/core": "0.8.214",
40
- "@hyperframes/engine": "0.8.214",
41
- "@hyperframes/parsers": "0.8.214",
42
- "@hyperframes/studio-server": "0.8.214"
38
+ "@hyperframes/engine": "0.8.217",
39
+ "@hyperframes/core": "0.8.217",
40
+ "@hyperframes/lint": "0.8.217",
41
+ "@hyperframes/parsers": "0.8.217",
42
+ "@hyperframes/studio-server": "0.8.217"
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.8.214"
51
+ "@hyperframes/aws-lambda": "0.8.217"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=22"