@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 +1 -0
- package/dist/index.js +1 -0
- package/dist/services/audioMixer.d.ts +2 -0
- package/dist/services/audioMixer.js +107 -8
- package/dist/services/chunkEncoder.js +2 -0
- package/dist/services/softwareEncoderThreads.d.ts +2 -0
- package/dist/services/softwareEncoderThreads.js +5 -0
- package/dist/services/streamingEncoder.js +2 -0
- package/dist/services/videoFrameExtractor.js +4 -9
- package/package.json +3 -3
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
|
-
|
|
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.
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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
|
|
786
|
-
const ffmpegProcessTimeout = config
|
|
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,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 ||
|
|
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.
|
|
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.
|
|
54
|
-
"@hyperframes/parsers": "^0.8.
|
|
53
|
+
"@hyperframes/core": "^0.8.189",
|
|
54
|
+
"@hyperframes/parsers": "^0.8.189"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|