@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.
- package/dist/services/audioMixer.js +15 -33
- package/dist/services/browserManager.js +4 -4
- package/dist/services/chunkEncoder.js +1 -1
- 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 -1
- package/dist/services/softwareEncoderThreads.js +4 -2
- package/dist/services/streamingEncoder.js +1 -1
- package/package.json +3 -3
|
@@ -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(
|
|
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
|
-
//
|
|
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 +
|
|
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
|
-
//
|
|
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
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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(() => { });
|
|
@@ -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,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
|
-
|
|
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.
|
|
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.
|
|
54
|
-
"@hyperframes/parsers": "^0.8.
|
|
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",
|