@hyperframes/engine 0.8.188 → 0.8.189

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;
@@ -273,7 +273,11 @@ function boundedDetail(message, maxLength = 2_000) {
273
273
  .replace(/\bfile:\/\/[^\s"'<>]+/gi, "<redacted-path>")
274
274
  .replace(/\b[A-Za-z]:[\\/].+?(?=:\s[A-Z]|\s(?:ENOENT|EACCES|EPERM)\b|\r?$)/gm, "<redacted-path>")
275
275
  .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)}…`;
276
+ // Keep both ends: the head names the failure, the tail holds ffmpeg's last words.
277
+ const head = Math.floor(maxLength / 4);
278
+ return redacted.length <= maxLength
279
+ ? redacted
280
+ : `${redacted.slice(0, head)}…${redacted.slice(-(maxLength - head - 1))}`;
277
281
  }
278
282
  function probeFailure(message, elementId) {
279
283
  const unavailable = /(?:not found|ENOENT|spawn)/i.test(message);
@@ -351,9 +355,10 @@ function ffmpegFailure(stage, result, timeoutMs, elementId) {
351
355
  retryable,
352
356
  elementId,
353
357
  subprocess: audioSubprocessEvidence(result),
354
- detail: boundedDetail(result.error?.message
355
- ? `${describeFfmpegFailure(result, timeoutMs)}: ${result.error.message}`
356
- : describeFfmpegFailure(result, timeoutMs)),
358
+ detail: boundedDetail(`(ran ${result.durationMs} ms, limit ${timeoutMs} ms) ` +
359
+ (result.error?.message
360
+ ? `${describeFfmpegFailure(result, timeoutMs)}: ${result.error.message}`
361
+ : describeFfmpegFailure(result, timeoutMs))),
357
362
  };
358
363
  }
359
364
  /**
@@ -605,8 +610,14 @@ async function generateSilence(outputPath, duration, signal, config) {
605
610
  failure,
606
611
  };
607
612
  }
608
- async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config) {
609
- const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
613
+ /** A mix graph's work grows with the film length (about 11 ms per film second at 96 inputs); so does its deadline. */
614
+ const MIX_TIMEOUT_MS_PER_FILM_SECOND = 100;
615
+ function mixProcessTimeout(config, totalDuration) {
616
+ const configured = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
617
+ return Math.max(configured, Math.ceil(totalDuration * MIX_TIMEOUT_MS_PER_FILM_SECOND));
618
+ }
619
+ async function mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, config) {
620
+ const ffmpegProcessTimeout = mixProcessTimeout(config, totalDuration);
610
621
  const masterOutputGain = config?.audioGain ?? DEFAULT_CONFIG.audioGain;
611
622
  if (tracks.length === 0) {
612
623
  const result = await generateSilence(outputPath, totalDuration, signal, config);
@@ -782,8 +793,8 @@ function groupNormalizeOptionUnsupported(stderr) {
782
793
  * compensation at all and is preferred; the manual per-own-count fallback
783
794
  * only runs when this ffmpeg build's `amix` rejects the option.
784
795
  */
785
- async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal, config) {
786
- const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
796
+ async function mixGroupMembersFlat(memberTracks, outputPath, totalDuration, signal, config) {
797
+ const ffmpegProcessTimeout = mixProcessTimeout(config, totalDuration);
787
798
  const outputDir = dirname(outputPath);
788
799
  if (!existsSync(outputDir))
789
800
  mkdirSync(outputDir, { recursive: true });
@@ -876,6 +887,94 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
876
887
  }
877
888
  return { success: true, degradedAutomation };
878
889
  }
890
+ /** Tracks per ffmpeg graph; a larger mix is summed in stages, so there is no cap on the total. */
891
+ export const MAX_MIX_INPUTS = 96;
892
+ /** Unity-sum `tracks` (the group gain law) slice by slice into one float WAV, with few full-length files at once. */
893
+ async function sumInSlices(tracks, scratchDir, totalDuration, signal, config) {
894
+ const wavTrack = (id, srcPath) => ({
895
+ id,
896
+ srcPath,
897
+ start: 0,
898
+ end: totalDuration,
899
+ mediaStart: 0,
900
+ duration: totalDuration,
901
+ volume: 1,
902
+ });
903
+ let running = null;
904
+ let degradedAutomation = false;
905
+ for (let at = 0; at < tracks.length; at += MAX_MIX_INPUTS) {
906
+ const id = `sum-${at / MAX_MIX_INPUTS}`;
907
+ const slicePath = join(scratchDir, `${id}-slice.wav`);
908
+ const slice = await mixGroupMembersFlat(tracks.slice(at, at + MAX_MIX_INPUTS), slicePath, totalDuration, signal, config);
909
+ if (!slice.success)
910
+ return slice;
911
+ degradedAutomation ||= slice.degradedAutomation === true;
912
+ if (!running) {
913
+ running = wavTrack(id, slicePath);
914
+ continue;
915
+ }
916
+ const foldedPath = join(scratchDir, `${id}.wav`);
917
+ const folded = await mixGroupMembersFlat([running, wavTrack(`${id}-slice`, slicePath)], foldedPath, totalDuration, signal, config);
918
+ rmSync(running.srcPath, { force: true });
919
+ rmSync(slicePath, { force: true });
920
+ if (!folded.success)
921
+ return folded;
922
+ running = wavTrack(id, foldedPath);
923
+ }
924
+ if (!running)
925
+ throw new Error("[audioMixer] sumInSlices needs at least one track");
926
+ return { success: true, track: running, degradedAutomation };
927
+ }
928
+ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal, config) {
929
+ if (memberTracks.length <= MAX_MIX_INPUTS) {
930
+ return mixGroupMembersFlat(memberTracks, outputPath, totalDuration, signal, config);
931
+ }
932
+ mkdirSync(dirname(outputPath), { recursive: true });
933
+ const scratchDir = mkdtempSync(join(dirname(outputPath), ".mix-batches-"));
934
+ try {
935
+ const summed = await sumInSlices(memberTracks, scratchDir, totalDuration, signal, config);
936
+ if (!summed.success)
937
+ return summed;
938
+ renameSync(summed.track.srcPath, outputPath);
939
+ return { success: true, degradedAutomation: summed.degradedAutomation };
940
+ }
941
+ finally {
942
+ rmSync(scratchDir, { recursive: true, force: true });
943
+ }
944
+ }
945
+ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config) {
946
+ if (tracks.length <= MAX_MIX_INPUTS) {
947
+ return mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, config);
948
+ }
949
+ mkdirSync(dirname(outputPath), { recursive: true });
950
+ const scratchDir = mkdtempSync(join(dirname(outputPath), ".mix-batches-"));
951
+ try {
952
+ const summed = await sumInSlices(tracks, scratchDir, totalDuration, signal, config);
953
+ if (!summed.success) {
954
+ return {
955
+ success: false,
956
+ outputPath,
957
+ durationMs: 0,
958
+ tracksProcessed: 0,
959
+ error: summed.error,
960
+ failures: [summed.failure],
961
+ };
962
+ }
963
+ const mixed = await mixAudioTracksFlat([summed.track], outputPath, totalDuration, signal, config);
964
+ return mixed.success
965
+ ? {
966
+ ...mixed,
967
+ tracksProcessed: tracks.length,
968
+ error: summed.degradedAutomation
969
+ ? "Volume automation exceeded this ffmpeg build's expression limits; rendered at base volume"
970
+ : mixed.error,
971
+ }
972
+ : mixed;
973
+ }
974
+ finally {
975
+ rmSync(scratchDir, { recursive: true, force: true });
976
+ }
977
+ }
879
978
  /**
880
979
  * Sum the tracks to a float WAV, run the master chain (if any) over the sum, then AAC-encode.
881
980
  * Groups are already folded into `tracks`, and the sum stays float until the encoder.
@@ -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
@@ -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.189",
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.189",
54
+ "@hyperframes/parsers": "^0.8.189"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",