@hyperframes/engine 0.8.189 → 0.8.190-hdr.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.
@@ -11,6 +11,8 @@ import { clampFadesToDuration, readElementFades } from "@hyperframes/core/audio-
11
11
  import { downloadToTemp, isHttpUrl, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js";
12
12
  import { DEFAULT_CONFIG } from "../config.js";
13
13
  import { describeFfmpegFailure, runFfmpeg } from "../utils/runFfmpeg.js";
14
+ import { cpus } from "node:os";
15
+ import { createConcurrencyLimit } from "../utils/concurrencyLimit.js";
14
16
  import { encodeAac } from "./aacEncode.js";
15
17
  import { audioSubprocessEvidence } from "./audioFailureDiagnostic.js";
16
18
  import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -129,6 +131,8 @@ function legacyFilterScriptOptionIsUnsupported(stderr) {
129
131
  * no audio). 32 segments keeps a wide safety margin and is far more resolution
130
132
  * than a piecewise-linear volume envelope needs.
131
133
  */
134
+ // Caps the ffmpeg runs that prepare clips; all of a long mix's clips at once starved the host (200 clips, 200 ffmpeg).
135
+ const inPrepareSlot = createConcurrencyLimit(Math.max(2, Math.floor(cpus().length / 2)));
132
136
  const MAX_VOLUME_SEGMENTS = 32;
133
137
  /**
134
138
  * Volume delta below which a keyframe is collinear enough to drop. Kept tight
@@ -1043,7 +1047,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1043
1047
  else
1044
1048
  signal.addEventListener("abort", () => internalController.abort(), { once: true });
1045
1049
  }
1046
- await Promise.all(elements.map(async (element, index) => {
1050
+ await Promise.all(elements.map((element, index) => inPrepareSlot(async () => {
1047
1051
  if (effectiveSignal.aborted) {
1048
1052
  failures.push({
1049
1053
  stage: "cancelled",
@@ -1089,12 +1093,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1089
1093
  });
1090
1094
  return;
1091
1095
  }
1092
- // STUDIO-5433: an audio src that resolved to a text document (an
1093
- // unresolved nested-composition preview URL, or a 403/404 body served
1094
- // with a 200) never reaches the probe below when the element carries an
1095
- // authored duration or `loop`. It then fails inside ffmpeg as
1096
- // `prepare/ffmpeg_failed` with owner "system" — an authoring bug paged
1097
- // as a platform fault, after every frame has already been captured.
1096
+ // A src that resolved to a text document is an authoring error, not an ffmpeg failure.
1098
1097
  if (await isNotMediaPayload(srcPath)) {
1099
1098
  failures.push({
1100
1099
  stage: "source",
@@ -1118,7 +1117,8 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1118
1117
  }
1119
1118
  const effectiveDuration = timeAtSourceTime(normalizeRateSpec(element.playbackRate), metadata.durationSeconds - element.mediaStart);
1120
1119
  element.end =
1121
- element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
1120
+ element.start +
1121
+ (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
1122
1122
  }
1123
1123
  let audioSrcPath = srcPath;
1124
1124
  if (element.type === "video") {
@@ -1157,24 +1157,14 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1157
1157
  }
1158
1158
  audioSrcPath = trimmedPath;
1159
1159
  }
1160
- // Apply the track's FX chain to the dry, trimmed audio, before volume
1161
- // automation is baked in: effects should see the raw signal, and the
1162
- // envelope belongs on their output. A missing or broken chain is fatal
1163
- // for the whole mix rather than a per-track warning — quietly rendering
1164
- // the dry signal ships a mix that sounds plausible and is wrong.
1160
+ // The FX chain runs on the dry trimmed audio, before the envelope. A broken chain fails the mix.
1165
1161
  const chain = element.fxChain ? parseAudioFxChain(element.fxChain) : undefined;
1166
1162
  const automation = element.automation
1167
1163
  ? resolveAutomation(parseAutomation(element.automation), chain)
1168
1164
  : null;
1169
- // Computed before the chain runs, not after: the FX pass bakes the
1170
- // envelope into its float output so the duck lands before the ±1 clamp
1171
- // in writeWav, instead of after it.
1172
- //
1173
- // A volume lane supersedes keyframes probed from the timeline: the two
1174
- // would fight, and the lane is the explicit one. `lint` warns when a
1175
- // track carries both.
1176
- //
1177
- // With clip gain ahead of the chain, the lane carries its share of the capped product.
1165
+ // Computed before the chain so the FX pass bakes the envelope in ahead of the ±1 clamp.
1166
+ // A volume lane supersedes probed keyframes. With clip gain ahead of the chain, the lane
1167
+ // carries its share of the capped product.
1178
1168
  const gainBeforeFx = chain !== undefined && enabledAudioFxNodes(chain).length > 0;
1179
1169
  const laneKeyframes = automation
1180
1170
  ? volumeLaneKeyframes(automation, element.start, element.end - element.start, element.volume ?? 1, gainBeforeFx)
@@ -1191,11 +1181,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1191
1181
  let bakedEnvelope = false;
1192
1182
  let tailSeconds = 0;
1193
1183
  if (chain) {
1194
- // The chain is serialised into the attribute, the same way colour
1195
- // grading carries its config, so there is no side-car file to find,
1196
- // resolve or lose.
1197
- // The rendered WAV is longer than the input by exactly this much, so
1198
- // the mix has to be told to let it through.
1184
+ // The rendered WAV is longer than the input by the chain's tail; the mix lets it through.
1199
1185
  tailSeconds = chainTailSeconds(chain, automation ?? undefined);
1200
1186
  const fxResult = await applyAudioFxChain(audioSrcPath, chain, join(workDir, `${element.id}-fx.wav`), {
1201
1187
  trackId: element.id,
@@ -1207,11 +1193,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1207
1193
  audioSrcPath = fxResult.path;
1208
1194
  bakedEnvelope = fxResult.envelopeBaked;
1209
1195
  }
1210
- // Primary volume-automation path for a track the FX pass did not bake:
1211
- // multiply the envelope into the PCM samples (sample-accurate, no
1212
- // keyframe ceiling). If the WAV isn't the expected 16-bit PCM, fall
1213
- // back to the ffmpeg expression path by leaving the keyframes on the
1214
- // track for buildVolumeExpression to handle.
1196
+ // Bake the envelope into the PCM samples; a non-16-bit WAV falls back to the ffmpeg expression.
1215
1197
  if (envelope && !bakedEnvelope) {
1216
1198
  bakedEnvelope = applyVolumeEnvelopeToWav(audioSrcPath, envelope.keyframes, envelope.trackStart, envelope.baseVolume);
1217
1199
  }
@@ -1247,7 +1229,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1247
1229
  detail: boundedDetail(`Audio processing failed for element ${element.id}: ${err instanceof Error ? err.message : String(err)}`),
1248
1230
  });
1249
1231
  }
1250
- })).catch((err) => {
1232
+ }, effectiveSignal))).catch((err) => {
1251
1233
  // Promise.all rejected on the first fatal failure without waiting for its
1252
1234
  // siblings; abort them so their ffmpeg stops before workDir disappears.
1253
1235
  internalController.abort();
@@ -244,7 +244,6 @@ const BEGINFRAME_ONLY_FLAGS = new Set([
244
244
  function stripBeginFrameFlags(args) {
245
245
  return args.filter((a) => !BEGINFRAME_ONLY_FLAGS.has(a));
246
246
  }
247
- const BEGINFRAME_SCREENSHOT_PROBE_ATTEMPTS = 10;
248
247
  const BEGINFRAME_PROBE_TIMEOUT_MS = 2000;
249
248
  const BEGINFRAME_PROBE_CLEANUP_TIMEOUT_MS = 250;
250
249
  async function awaitBeforeDeadline(operation, deadline, label) {
@@ -325,7 +324,8 @@ async function probeBeginFrameSupport(browser, timeoutMs = BEGINFRAME_PROBE_TIME
325
324
  let bytes = Buffer.alloc(0);
326
325
  let isPng = false;
327
326
  let attempts = 0;
328
- for (attempts = 1; attempts <= BEGINFRAME_SCREENSHOT_PROBE_ATTEMPTS; attempts += 1) {
327
+ while (!isPng && Date.now() < deadline) {
328
+ attempts += 1;
329
329
  const response = await awaitBeforeDeadline(client.send("HeadlessExperimental.beginFrame", {
330
330
  frameTimeTicks: 1000 + (attempts - 1) * 33,
331
331
  interval: 33,
@@ -339,13 +339,13 @@ async function probeBeginFrameSupport(browser, timeoutMs = BEGINFRAME_PROBE_TIME
339
339
  bytes[1] === 0x50 &&
340
340
  bytes[2] === 0x4e &&
341
341
  bytes[3] === 0x47;
342
- if (isPng)
342
+ if (isPng || Date.now() + 10 >= deadline)
343
343
  break;
344
344
  await awaitBeforeDeadline(new Promise((resolveDelay) => setTimeout(resolveDelay, 10)), deadline, `screenshot retry delay ${attempts}`);
345
345
  }
346
346
  if (!isPng) {
347
347
  throw new Error(`beginFrame screenshot returned ${bytes.length} bytes after ` +
348
- `${BEGINFRAME_SCREENSHOT_PROBE_ATTEMPTS} attempts with signature ` +
348
+ `${attempts} attempts in ${Date.now() - started}ms with signature ` +
349
349
  `${bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"}`);
350
350
  }
351
351
  await awaitBeforeDeadline(client.detach(), deadline, "CDP detach").catch(() => { });
@@ -214,7 +214,7 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
214
214
  else {
215
215
  const encoderName = codec === "h264" ? "libx264" : "libx265";
216
216
  args.push("-c:v", encoderName, "-preset", preset);
217
- appendSoftwareEncoderThreadsArg(args);
217
+ appendSoftwareEncoderThreadsArg(args, encoderName);
218
218
  if (bitrate)
219
219
  args.push("-b:v", bitrate);
220
220
  else
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import { existsSync, mkdirSync, writeFileSync } from "fs";
11
11
  import { join } from "path";
12
- import { COMPOSITION_SOURCE_URL, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
12
+ import { COMPOSITION_SOURCE_URL, MEDIA_RENDER_ID_ATTR, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
13
+ import { holdRenderFrameVideos } from "./renderFrameVideoHold.js";
13
14
  import { DrawElementCaptureError } from "./drawElementCaptureError.js";
14
15
  import { encodePng } from "../utils/alphaBlit.js";
15
16
  import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
@@ -902,6 +903,9 @@ async function constructCaptureSession(input) {
902
903
  // Media elements are addressed by a document-unique render id, not by the
903
904
  // per-file element id. Install the resolvers before any page script runs.
904
905
  await installMediaRenderIdBridge(page);
906
+ if (options.skipReadinessVideoIds?.length) {
907
+ await page.evaluateOnNewDocument(holdRenderFrameVideos, options.skipReadinessVideoIds, MEDIA_RENDER_ID_ATTR);
908
+ }
905
909
  // Fast capture: record accelerated canvases (webgl/webgl2/webgpu) and force
906
910
  // preserveDrawingBuffer before any page script can create a context — their
907
911
  // paint records freeze at the first frame, so captureDrawElementFrame
@@ -33,15 +33,17 @@ async function readPageMotionBlur(page) {
33
33
  const got = typeof report === "function" ? report() : null;
34
34
  if (!got || typeof got !== "object")
35
35
  return null;
36
+ const tier = Reflect.get(got, "tier");
36
37
  return {
37
38
  state: String(Reflect.get(got, "state")),
38
39
  code: String(Reflect.get(got, "reasonCode")),
39
40
  movers: Number(Reflect.get(got, "movers")),
40
41
  failedFrames: Number(Reflect.get(got, "failedFrames")) || 0,
42
+ tier: typeof tier === "string" ? tier : null,
41
43
  };
42
44
  });
43
45
  }
44
- const formatPageMotionBlur = (r) => `[motion-blur] ${r.state} reasonCode=${r.code} movers=${r.movers} failedFrames=${r.failedFrames}`;
46
+ const formatPageMotionBlur = (r) => `[motion-blur] ${r.state} reasonCode=${r.code} movers=${r.movers} failedFrames=${r.failedFrames}${r.tier ? ` tier=${r.tier}` : ""}`;
45
47
  /** One line saying what the page's blur did for this session; null when the page never asked for blur. */
46
48
  export async function describePageMotionBlur(page) {
47
49
  const r = await readPageMotionBlur(page);
@@ -0,0 +1,2 @@
1
+ /** In-page, before any script: holds replaced videos at preload none from parse (Chrome ignores a later none). */
2
+ export declare function holdRenderFrameVideos(ids: readonly string[], renderIdAttr: string): void;
@@ -0,0 +1,19 @@
1
+ /** In-page, before any script: holds replaced videos at preload none from parse (Chrome ignores a later none). */
2
+ export function holdRenderFrameVideos(ids, renderIdAttr) {
3
+ const held = new Set(ids);
4
+ window.__HF_RENDER_FRAME_VIDEO_IDS = ids;
5
+ const hold = (el) => {
6
+ if (el instanceof HTMLVideoElement && held.has(el.getAttribute(renderIdAttr) || el.id))
7
+ el.preload = "none";
8
+ };
9
+ new MutationObserver((records) => {
10
+ for (const record of records)
11
+ for (const node of record.addedNodes) {
12
+ if (node.nodeType !== 1)
13
+ continue;
14
+ hold(node);
15
+ for (const video of node.querySelectorAll("video"))
16
+ hold(video);
17
+ }
18
+ }).observe(document, { childList: true, subtree: true });
19
+ }
@@ -1,2 +1,3 @@
1
+ export declare const X265_MAX_THREADS = 16;
1
2
  /** x264/x265 bitstreams depend on the thread count, so it comes from the CPU quota, never ffmpeg's host-core guess. */
2
- export declare function appendSoftwareEncoderThreadsArg(args: string[], parallelism?: number): void;
3
+ export declare function appendSoftwareEncoderThreadsArg(args: string[], encoder: "libx264" | "libx265", parallelism?: number): void;
@@ -1,5 +1,7 @@
1
1
  import { availableParallelism } from "node:os";
2
+ export const X265_MAX_THREADS = 16;
2
3
  /** x264/x265 bitstreams depend on the thread count, so it comes from the CPU quota, never ffmpeg's host-core guess. */
3
- export function appendSoftwareEncoderThreadsArg(args, parallelism = availableParallelism()) {
4
- args.push("-threads", String(Math.max(1, Math.floor(parallelism))));
4
+ export function appendSoftwareEncoderThreadsArg(args, encoder, parallelism = availableParallelism()) {
5
+ const cap = encoder === "libx265" ? X265_MAX_THREADS : Infinity;
6
+ args.push("-threads", String(Math.min(cap, Math.max(1, Math.floor(parallelism)))));
5
7
  }
@@ -181,7 +181,7 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
181
181
  else {
182
182
  const encoderName = codec === "h264" ? "libx264" : "libx265";
183
183
  args.push("-c:v", encoderName, "-preset", preset);
184
- appendSoftwareEncoderThreadsArg(args);
184
+ appendSoftwareEncoderThreadsArg(args, encoderName);
185
185
  if (bitrate)
186
186
  args.push("-b:v", bitrate);
187
187
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.189",
3
+ "version": "0.8.190-hdr.1",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,8 +50,8 @@
50
50
  "linkedom": "^0.18.12",
51
51
  "puppeteer": "^25.8.0",
52
52
  "puppeteer-core": "^25.8.0",
53
- "@hyperframes/core": "^0.8.189",
54
- "@hyperframes/parsers": "^0.8.189"
53
+ "@hyperframes/core": "^0.8.190-hdr.1",
54
+ "@hyperframes/parsers": "^0.8.190-hdr.1"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",