@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 +1 -0
- package/dist/index.js +1 -0
- package/dist/services/audioMixer.d.ts +2 -0
- package/dist/services/audioMixer.js +122 -41
- package/dist/services/browserManager.js +4 -4
- package/dist/services/chunkEncoder.js +2 -0
- package/dist/services/frameCapture.js +5 -1
- package/dist/services/pageMotionBlur.js +3 -1
- package/dist/services/renderFrameVideoHold.d.ts +2 -0
- package/dist/services/renderFrameVideoHold.js +19 -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;
|
|
@@ -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
|
-
|
|
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.
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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
|
|
786
|
-
const ffmpegProcessTimeout = config
|
|
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(
|
|
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
|
-
//
|
|
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 +
|
|
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
|
-
//
|
|
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
|
|
1071
|
-
//
|
|
1072
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
`${
|
|
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,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,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.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.
|
|
54
|
-
"@hyperframes/parsers": "^0.8.
|
|
53
|
+
"@hyperframes/core": "^0.8.190",
|
|
54
|
+
"@hyperframes/parsers": "^0.8.190"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|