@hyperframes/engine 0.8.188 → 0.8.190

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/dist/index.d.ts CHANGED
@@ -58,6 +58,7 @@ export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndices
58
58
  export { createFileServer, type FileServerOptions, type FileServerHandle, } from "./services/fileServer.js";
59
59
  export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
60
60
  export { frameFileExtension } from "./services/frameCapture.js";
61
+ export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThreads.js";
61
62
  export { frameSequencePath } from "./services/extractedFrameIndex.js";
62
63
  export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionBlur.js";
63
64
  export type { PageMotionBlurOff } from "./types.js";
package/dist/index.js CHANGED
@@ -64,6 +64,7 @@ export { createFileServer, } from "./services/fileServer.js";
64
64
  // ── Utilities ──────────────────────────────────────────────────────────────────
65
65
  export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
66
66
  export { frameFileExtension } from "./services/frameCapture.js";
67
+ export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThreads.js";
67
68
  export { frameSequencePath } from "./services/extractedFrameIndex.js";
68
69
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
69
70
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
@@ -31,6 +31,8 @@ export declare function volumeLaneKeyframes(automation: {
31
31
  */
32
32
  export declare function parseMasterFxChain(html: string): string | undefined;
33
33
  export declare function parseAudioElements(html: string): AudioElement[];
34
+ /** Tracks per ffmpeg graph; a larger mix is summed in stages, so there is no cap on the total. */
35
+ export declare const MAX_MIX_INPUTS = 96;
34
36
  export declare function processCompositionAudio(elements: AudioElement[], baseDir: string, workDir: string, outputPath: string, totalDuration: number, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">> & {
35
37
  /** Serialised master bus chain, from `parseMasterFxChain`. */
36
38
  masterFxChain?: string;
@@ -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
@@ -273,7 +277,11 @@ function boundedDetail(message, maxLength = 2_000) {
273
277
  .replace(/\bfile:\/\/[^\s"'<>]+/gi, "<redacted-path>")
274
278
  .replace(/\b[A-Za-z]:[\\/].+?(?=:\s[A-Z]|\s(?:ENOENT|EACCES|EPERM)\b|\r?$)/gm, "<redacted-path>")
275
279
  .replace(/(^|[\s"'(])\/.+?(?=:\s[A-Z]|\s(?:ENOENT|EACCES|EPERM)\b|\r?$)/gm, "$1<redacted-path>");
276
- return redacted.length <= maxLength ? redacted : `${redacted.slice(0, maxLength - 1)}…`;
280
+ // Keep both ends: the head names the failure, the tail holds ffmpeg's last words.
281
+ const head = Math.floor(maxLength / 4);
282
+ return redacted.length <= maxLength
283
+ ? redacted
284
+ : `${redacted.slice(0, head)}…${redacted.slice(-(maxLength - head - 1))}`;
277
285
  }
278
286
  function probeFailure(message, elementId) {
279
287
  const unavailable = /(?:not found|ENOENT|spawn)/i.test(message);
@@ -351,9 +359,10 @@ function ffmpegFailure(stage, result, timeoutMs, elementId) {
351
359
  retryable,
352
360
  elementId,
353
361
  subprocess: audioSubprocessEvidence(result),
354
- detail: boundedDetail(result.error?.message
355
- ? `${describeFfmpegFailure(result, timeoutMs)}: ${result.error.message}`
356
- : describeFfmpegFailure(result, timeoutMs)),
362
+ detail: boundedDetail(`(ran ${result.durationMs} ms, limit ${timeoutMs} ms) ` +
363
+ (result.error?.message
364
+ ? `${describeFfmpegFailure(result, timeoutMs)}: ${result.error.message}`
365
+ : describeFfmpegFailure(result, timeoutMs))),
357
366
  };
358
367
  }
359
368
  /**
@@ -605,8 +614,14 @@ async function generateSilence(outputPath, duration, signal, config) {
605
614
  failure,
606
615
  };
607
616
  }
608
- async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config) {
609
- const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
617
+ /** A mix graph's work grows with the film length (about 11 ms per film second at 96 inputs); so does its deadline. */
618
+ const MIX_TIMEOUT_MS_PER_FILM_SECOND = 100;
619
+ function mixProcessTimeout(config, totalDuration) {
620
+ const configured = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
621
+ return Math.max(configured, Math.ceil(totalDuration * MIX_TIMEOUT_MS_PER_FILM_SECOND));
622
+ }
623
+ async function mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, config) {
624
+ const ffmpegProcessTimeout = mixProcessTimeout(config, totalDuration);
610
625
  const masterOutputGain = config?.audioGain ?? DEFAULT_CONFIG.audioGain;
611
626
  if (tracks.length === 0) {
612
627
  const result = await generateSilence(outputPath, totalDuration, signal, config);
@@ -782,8 +797,8 @@ function groupNormalizeOptionUnsupported(stderr) {
782
797
  * compensation at all and is preferred; the manual per-own-count fallback
783
798
  * only runs when this ffmpeg build's `amix` rejects the option.
784
799
  */
785
- async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal, config) {
786
- const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
800
+ async function mixGroupMembersFlat(memberTracks, outputPath, totalDuration, signal, config) {
801
+ const ffmpegProcessTimeout = mixProcessTimeout(config, totalDuration);
787
802
  const outputDir = dirname(outputPath);
788
803
  if (!existsSync(outputDir))
789
804
  mkdirSync(outputDir, { recursive: true });
@@ -876,6 +891,94 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
876
891
  }
877
892
  return { success: true, degradedAutomation };
878
893
  }
894
+ /** Tracks per ffmpeg graph; a larger mix is summed in stages, so there is no cap on the total. */
895
+ export const MAX_MIX_INPUTS = 96;
896
+ /** Unity-sum `tracks` (the group gain law) slice by slice into one float WAV, with few full-length files at once. */
897
+ async function sumInSlices(tracks, scratchDir, totalDuration, signal, config) {
898
+ const wavTrack = (id, srcPath) => ({
899
+ id,
900
+ srcPath,
901
+ start: 0,
902
+ end: totalDuration,
903
+ mediaStart: 0,
904
+ duration: totalDuration,
905
+ volume: 1,
906
+ });
907
+ let running = null;
908
+ let degradedAutomation = false;
909
+ for (let at = 0; at < tracks.length; at += MAX_MIX_INPUTS) {
910
+ const id = `sum-${at / MAX_MIX_INPUTS}`;
911
+ const slicePath = join(scratchDir, `${id}-slice.wav`);
912
+ const slice = await mixGroupMembersFlat(tracks.slice(at, at + MAX_MIX_INPUTS), slicePath, totalDuration, signal, config);
913
+ if (!slice.success)
914
+ return slice;
915
+ degradedAutomation ||= slice.degradedAutomation === true;
916
+ if (!running) {
917
+ running = wavTrack(id, slicePath);
918
+ continue;
919
+ }
920
+ const foldedPath = join(scratchDir, `${id}.wav`);
921
+ const folded = await mixGroupMembersFlat([running, wavTrack(`${id}-slice`, slicePath)], foldedPath, totalDuration, signal, config);
922
+ rmSync(running.srcPath, { force: true });
923
+ rmSync(slicePath, { force: true });
924
+ if (!folded.success)
925
+ return folded;
926
+ running = wavTrack(id, foldedPath);
927
+ }
928
+ if (!running)
929
+ throw new Error("[audioMixer] sumInSlices needs at least one track");
930
+ return { success: true, track: running, degradedAutomation };
931
+ }
932
+ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal, config) {
933
+ if (memberTracks.length <= MAX_MIX_INPUTS) {
934
+ return mixGroupMembersFlat(memberTracks, outputPath, totalDuration, signal, config);
935
+ }
936
+ mkdirSync(dirname(outputPath), { recursive: true });
937
+ const scratchDir = mkdtempSync(join(dirname(outputPath), ".mix-batches-"));
938
+ try {
939
+ const summed = await sumInSlices(memberTracks, scratchDir, totalDuration, signal, config);
940
+ if (!summed.success)
941
+ return summed;
942
+ renameSync(summed.track.srcPath, outputPath);
943
+ return { success: true, degradedAutomation: summed.degradedAutomation };
944
+ }
945
+ finally {
946
+ rmSync(scratchDir, { recursive: true, force: true });
947
+ }
948
+ }
949
+ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config) {
950
+ if (tracks.length <= MAX_MIX_INPUTS) {
951
+ return mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, config);
952
+ }
953
+ mkdirSync(dirname(outputPath), { recursive: true });
954
+ const scratchDir = mkdtempSync(join(dirname(outputPath), ".mix-batches-"));
955
+ try {
956
+ const summed = await sumInSlices(tracks, scratchDir, totalDuration, signal, config);
957
+ if (!summed.success) {
958
+ return {
959
+ success: false,
960
+ outputPath,
961
+ durationMs: 0,
962
+ tracksProcessed: 0,
963
+ error: summed.error,
964
+ failures: [summed.failure],
965
+ };
966
+ }
967
+ const mixed = await mixAudioTracksFlat([summed.track], outputPath, totalDuration, signal, config);
968
+ return mixed.success
969
+ ? {
970
+ ...mixed,
971
+ tracksProcessed: tracks.length,
972
+ error: summed.degradedAutomation
973
+ ? "Volume automation exceeded this ffmpeg build's expression limits; rendered at base volume"
974
+ : mixed.error,
975
+ }
976
+ : mixed;
977
+ }
978
+ finally {
979
+ rmSync(scratchDir, { recursive: true, force: true });
980
+ }
981
+ }
879
982
  /**
880
983
  * Sum the tracks to a float WAV, run the master chain (if any) over the sum, then AAC-encode.
881
984
  * Groups are already folded into `tracks`, and the sum stays float until the encoder.
@@ -944,7 +1047,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
944
1047
  else
945
1048
  signal.addEventListener("abort", () => internalController.abort(), { once: true });
946
1049
  }
947
- await Promise.all(elements.map(async (element, index) => {
1050
+ await Promise.all(elements.map((element, index) => inPrepareSlot(async () => {
948
1051
  if (effectiveSignal.aborted) {
949
1052
  failures.push({
950
1053
  stage: "cancelled",
@@ -990,12 +1093,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
990
1093
  });
991
1094
  return;
992
1095
  }
993
- // STUDIO-5433: an audio src that resolved to a text document (an
994
- // unresolved nested-composition preview URL, or a 403/404 body served
995
- // with a 200) never reaches the probe below when the element carries an
996
- // authored duration or `loop`. It then fails inside ffmpeg as
997
- // `prepare/ffmpeg_failed` with owner "system" — an authoring bug paged
998
- // 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.
999
1097
  if (await isNotMediaPayload(srcPath)) {
1000
1098
  failures.push({
1001
1099
  stage: "source",
@@ -1019,7 +1117,8 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1019
1117
  }
1020
1118
  const effectiveDuration = timeAtSourceTime(normalizeRateSpec(element.playbackRate), metadata.durationSeconds - element.mediaStart);
1021
1119
  element.end =
1022
- element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
1120
+ element.start +
1121
+ (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
1023
1122
  }
1024
1123
  let audioSrcPath = srcPath;
1025
1124
  if (element.type === "video") {
@@ -1058,24 +1157,14 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1058
1157
  }
1059
1158
  audioSrcPath = trimmedPath;
1060
1159
  }
1061
- // Apply the track's FX chain to the dry, trimmed audio, before volume
1062
- // automation is baked in: effects should see the raw signal, and the
1063
- // envelope belongs on their output. A missing or broken chain is fatal
1064
- // for the whole mix rather than a per-track warning — quietly rendering
1065
- // 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.
1066
1161
  const chain = element.fxChain ? parseAudioFxChain(element.fxChain) : undefined;
1067
1162
  const automation = element.automation
1068
1163
  ? resolveAutomation(parseAutomation(element.automation), chain)
1069
1164
  : null;
1070
- // Computed before the chain runs, not after: the FX pass bakes the
1071
- // envelope into its float output so the duck lands before the ±1 clamp
1072
- // in writeWav, instead of after it.
1073
- //
1074
- // A volume lane supersedes keyframes probed from the timeline: the two
1075
- // would fight, and the lane is the explicit one. `lint` warns when a
1076
- // track carries both.
1077
- //
1078
- // 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.
1079
1168
  const gainBeforeFx = chain !== undefined && enabledAudioFxNodes(chain).length > 0;
1080
1169
  const laneKeyframes = automation
1081
1170
  ? volumeLaneKeyframes(automation, element.start, element.end - element.start, element.volume ?? 1, gainBeforeFx)
@@ -1092,11 +1181,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1092
1181
  let bakedEnvelope = false;
1093
1182
  let tailSeconds = 0;
1094
1183
  if (chain) {
1095
- // The chain is serialised into the attribute, the same way colour
1096
- // grading carries its config, so there is no side-car file to find,
1097
- // resolve or lose.
1098
- // The rendered WAV is longer than the input by exactly this much, so
1099
- // 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.
1100
1185
  tailSeconds = chainTailSeconds(chain, automation ?? undefined);
1101
1186
  const fxResult = await applyAudioFxChain(audioSrcPath, chain, join(workDir, `${element.id}-fx.wav`), {
1102
1187
  trackId: element.id,
@@ -1108,11 +1193,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1108
1193
  audioSrcPath = fxResult.path;
1109
1194
  bakedEnvelope = fxResult.envelopeBaked;
1110
1195
  }
1111
- // Primary volume-automation path for a track the FX pass did not bake:
1112
- // multiply the envelope into the PCM samples (sample-accurate, no
1113
- // keyframe ceiling). If the WAV isn't the expected 16-bit PCM, fall
1114
- // back to the ffmpeg expression path by leaving the keyframes on the
1115
- // track for buildVolumeExpression to handle.
1196
+ // Bake the envelope into the PCM samples; a non-16-bit WAV falls back to the ffmpeg expression.
1116
1197
  if (envelope && !bakedEnvelope) {
1117
1198
  bakedEnvelope = applyVolumeEnvelopeToWav(audioSrcPath, envelope.keyframes, envelope.trackStart, envelope.baseVolume);
1118
1199
  }
@@ -1148,7 +1229,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1148
1229
  detail: boundedDetail(`Audio processing failed for element ${element.id}: ${err instanceof Error ? err.message : String(err)}`),
1149
1230
  });
1150
1231
  }
1151
- })).catch((err) => {
1232
+ }, effectiveSignal))).catch((err) => {
1152
1233
  // Promise.all rejected on the first fatal failure without waiting for its
1153
1234
  // siblings; abort them so their ffmpeg stops before workDir disappears.
1154
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(() => { });
@@ -17,6 +17,7 @@ import { describeFfmpegFailure, ffmpegStatsReader, formatFfmpegError, isExternal
17
17
  import { extractAudioMetadata } from "../utils/ffprobe.js";
18
18
  import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
19
19
  import { appendVp9CpuUsedArg } from "./vp9Options.js";
20
+ import { appendSoftwareEncoderThreadsArg } from "./softwareEncoderThreads.js";
20
21
  import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
21
22
  export const ENCODER_PRESETS = {
22
23
  draft: { preset: "ultrafast", quality: 28, codec: "h264" },
@@ -213,6 +214,7 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
213
214
  else {
214
215
  const encoderName = codec === "h264" ? "libx264" : "libx265";
215
216
  args.push("-c:v", encoderName, "-preset", preset);
217
+ appendSoftwareEncoderThreadsArg(args);
216
218
  if (bitrate)
217
219
  args.push("-b:v", bitrate);
218
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
+ }
@@ -0,0 +1,2 @@
1
+ /** 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;
@@ -0,0 +1,5 @@
1
+ import { availableParallelism } from "node:os";
2
+ /** 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))));
5
+ }
@@ -28,6 +28,7 @@ import { SDR_CAPTURE_TO_BT709_FILTER, SDR_RGB_TO_TAGGED_BT709_FILTER, } from "..
28
28
  import { DEFAULT_CONFIG } from "../config.js";
29
29
  import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
30
30
  import { appendVp9CpuUsedArg } from "./vp9Options.js";
31
+ import { appendSoftwareEncoderThreadsArg } from "./softwareEncoderThreads.js";
31
32
  import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
32
33
  import { appendLockedGopArgs, lockedGopCodecParams, resolveLockedGopSize } from "./chunkEncoder.js";
33
34
  export function createFrameReorderBuffer(startFrame, endFrame) {
@@ -180,6 +181,7 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
180
181
  else {
181
182
  const encoderName = codec === "h264" ? "libx264" : "libx265";
182
183
  args.push("-c:v", encoderName, "-preset", preset);
184
+ appendSoftwareEncoderThreadsArg(args);
183
185
  if (bitrate)
184
186
  args.push("-b:v", bitrate);
185
187
  else
@@ -946,15 +946,10 @@ export function resolveVideoExtractionWindow(video, metadata, timelineEnd) {
946
946
  export function resolveVideoExtractionDuration(video, metadata, timelineEnd) {
947
947
  return resolveVideoExtractionWindow(video, metadata, timelineEnd).durationSeconds;
948
948
  }
949
- /**
950
- * Codecs whose bitstream is allowed to carry an alpha channel. Default the
951
- * extraction path to PNG output for these regardless of `metadata.hasAlpha`
952
- * so a missed sidecar tag doesn't silently strip transparency. Opaque content
953
- * encoded in one of these codecs pays a small file-size cost on the cached
954
- * frames but stays correct on the rare case where alpha IS present and the
955
- * tag was missed.
956
- */
949
+ /** Codecs whose bitstream can carry alpha: decoded with the alpha-aware decoder whether or not a tag says so. */
957
950
  const ALPHA_CAPABLE_CODECS = new Set(["vp9", "vp8", "prores"]);
951
+ /** Their alpha rides a side channel ffprobe can miss, so frames stay PNG; ProRes alpha shows in its pixel format. */
952
+ const SIDECAR_ALPHA_CODECS = new Set(["vp9", "vp8"]);
958
953
  export function codecMayHaveAlpha(codec) {
959
954
  return ALPHA_CAPABLE_CODECS.has((codec ?? "").toLowerCase());
960
955
  }
@@ -962,7 +957,7 @@ export function decoderForCodec(codec) {
962
957
  return alphaDecoderFor(codec) ?? (codec ?? "").toLowerCase();
963
958
  }
964
959
  export function resolveFrameFormat(metadata, requested) {
965
- if (metadata.hasAlpha || codecMayHaveAlpha(metadata.videoCodec))
960
+ if (metadata.hasAlpha || SIDECAR_ALPHA_CODECS.has((metadata.videoCodec ?? "").toLowerCase()))
966
961
  return "png";
967
962
  if (requested === "png" || requested === "jpg")
968
963
  return requested;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.188",
3
+ "version": "0.8.190",
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.188",
54
- "@hyperframes/parsers": "^0.8.188"
53
+ "@hyperframes/core": "^0.8.190",
54
+ "@hyperframes/parsers": "^0.8.190"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",