@hyperframes/engine 0.8.168 → 0.8.170
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/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/services/__fixtures__/loopback.d.ts +5 -0
- package/dist/services/__fixtures__/loopback.js +16 -0
- package/dist/services/aacEncode.d.ts +13 -0
- package/dist/services/aacEncode.js +102 -0
- package/dist/services/audioMixer.js +14 -19
- package/dist/services/frameCapture.d.ts +4 -0
- package/dist/services/frameCapture.js +25 -0
- package/package.json +3 -3
package/dist/config.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* env var sprawl with a structured interface. Env vars still work as
|
|
6
6
|
* fallbacks for backward compatibility during migration.
|
|
7
7
|
*/
|
|
8
|
-
import { type DistributedFps } from "@hyperframes/core";
|
|
8
|
+
import { type DistributedFps } from "@hyperframes/core/distributed-fps";
|
|
9
9
|
/**
|
|
10
10
|
* Full engine configuration. All fields are wired through the config
|
|
11
11
|
* object; env vars serve as backward-compatible fallbacks resolved
|
package/dist/config.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* fallbacks for backward compatibility during migration.
|
|
7
7
|
*/
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
|
-
import { DISTRIBUTED_FPS } from "@hyperframes/core";
|
|
9
|
+
import { DISTRIBUTED_FPS } from "@hyperframes/core/distributed-fps";
|
|
10
10
|
import { chromeMajorCeiling } from "./services/chromeHostCeiling.js";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { getSystemTotalMb, isLowMemorySystem, LOW_MEMORY_TOTAL_MB_THRESHOLD, } from "./services/systemMemory.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Server } from "node:http";
|
|
2
|
+
import { type Browser } from "puppeteer-core";
|
|
3
|
+
/** Listens on a free loopback port and returns the server's origin. */
|
|
4
|
+
export declare function listenOnLoopback(server: Server): Promise<string>;
|
|
5
|
+
export declare function launchTestChrome(executablePath: string | undefined): Promise<Browser>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import puppeteer from "puppeteer-core";
|
|
2
|
+
/** Listens on a free loopback port and returns the server's origin. */
|
|
3
|
+
export async function listenOnLoopback(server) {
|
|
4
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
5
|
+
const address = server.address();
|
|
6
|
+
if (!address || typeof address === "string")
|
|
7
|
+
throw new Error("No test server address");
|
|
8
|
+
return `http://127.0.0.1:${address.port}`;
|
|
9
|
+
}
|
|
10
|
+
export function launchTestChrome(executablePath) {
|
|
11
|
+
return puppeteer.launch({
|
|
12
|
+
executablePath,
|
|
13
|
+
headless: true,
|
|
14
|
+
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AAC for the mixed 48 kHz audio. One encoder is single-threaded (about 60x real time),
|
|
3
|
+
* so long audio is encoded as frame-aligned segments in parallel and their frames joined.
|
|
4
|
+
*/
|
|
5
|
+
import { type RunFfmpegResult } from "../utils/runFfmpeg.js";
|
|
6
|
+
export declare const AAC_ARGS: string[];
|
|
7
|
+
export declare function aacSegmentCount(totalDuration: number): number;
|
|
8
|
+
export declare function splitAdtsFrames(bytes: Buffer): Buffer[];
|
|
9
|
+
/** `input` is a 48 kHz WAV of exactly `totalDuration`. */
|
|
10
|
+
export declare function encodeAac(input: string, outputPath: string, totalDuration: number, { signal, timeout }: {
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
timeout: number;
|
|
13
|
+
}): Promise<RunFfmpegResult>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AAC for the mixed 48 kHz audio. One encoder is single-threaded (about 60x real time),
|
|
3
|
+
* so long audio is encoded as frame-aligned segments in parallel and their frames joined.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
8
|
+
const SAMPLE_RATE = 48_000;
|
|
9
|
+
const FRAME_SAMPLES = 1024;
|
|
10
|
+
const MIN_SEGMENT_SECONDS = 60;
|
|
11
|
+
// Fixed rather than the core count, so the bytes (and the planHash) do not depend on the machine.
|
|
12
|
+
const MAX_SEGMENTS = 8;
|
|
13
|
+
// Encoded on each side of a segment and dropped. The encoder's rate control takes about
|
|
14
|
+
// 10 s to settle (its error is higher until then), so a segment starts 10 s before its cut.
|
|
15
|
+
const PRE_ROLL_FRAMES = 469;
|
|
16
|
+
const POST_ROLL_FRAMES = 8;
|
|
17
|
+
export const AAC_ARGS = ["-acodec", "aac", "-b:a", "192k"];
|
|
18
|
+
export function aacSegmentCount(totalDuration) {
|
|
19
|
+
return Math.max(1, Math.min(MAX_SEGMENTS, Math.floor(totalDuration / MIN_SEGMENT_SECONDS)));
|
|
20
|
+
}
|
|
21
|
+
export function splitAdtsFrames(bytes) {
|
|
22
|
+
const frames = [];
|
|
23
|
+
for (let at = 0; at < bytes.length;) {
|
|
24
|
+
const length = ((bytes[at + 3] & 0x03) << 11) | (bytes[at + 4] << 3) | (bytes[at + 5] >> 5);
|
|
25
|
+
if (bytes[at] !== 0xff || (bytes[at + 1] & 0xf0) !== 0xf0 || length < 7) {
|
|
26
|
+
throw new Error(`Not an ADTS frame at byte ${at}`);
|
|
27
|
+
}
|
|
28
|
+
frames.push(bytes.subarray(at, at + length));
|
|
29
|
+
at += length;
|
|
30
|
+
}
|
|
31
|
+
return frames;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Packet g of the stream covers samples [(g-1)*1024, (g+1)*1024); packet 0 is the
|
|
35
|
+
* encoder delay. Segment k encodes from `firstFrame` and keeps its packets [keepFrom, keepTo).
|
|
36
|
+
*/
|
|
37
|
+
function planSegments(totalDuration, count) {
|
|
38
|
+
// Round to whole samples first: 147.52 * 48000 is 7080960.000000001 in floating point.
|
|
39
|
+
const packets = Math.ceil(Math.round(totalDuration * SAMPLE_RATE) / FRAME_SAMPLES) + 1;
|
|
40
|
+
const cuts = Array.from({ length: count + 1 }, (_, k) => Math.round((k * packets) / count));
|
|
41
|
+
return cuts.slice(0, -1).map((cut, k) => {
|
|
42
|
+
const next = cuts[k + 1];
|
|
43
|
+
const firstFrame = Math.max(0, cut - PRE_ROLL_FRAMES);
|
|
44
|
+
const end = k === count - 1 ? "" : `:end_sample=${(next + POST_ROLL_FRAMES) * FRAME_SAMPLES}`;
|
|
45
|
+
const trim = `atrim=start_sample=${firstFrame * FRAME_SAMPLES}${end}`;
|
|
46
|
+
return { trim, keepFrom: cut - firstFrame, keepTo: next - firstFrame };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** `input` is a 48 kHz WAV of exactly `totalDuration`. */
|
|
50
|
+
export async function encodeAac(input, outputPath, totalDuration, { signal, timeout }) {
|
|
51
|
+
const count = aacSegmentCount(totalDuration);
|
|
52
|
+
if (count === 1) {
|
|
53
|
+
const args = ["-i", input, "-t", String(totalDuration), ...AAC_ARGS, "-y", outputPath];
|
|
54
|
+
return runFfmpeg(args, { signal, timeout });
|
|
55
|
+
}
|
|
56
|
+
const dir = mkdtempSync(join(dirname(outputPath), ".aac-segments-"));
|
|
57
|
+
try {
|
|
58
|
+
const segments = planSegments(totalDuration, count).map((segment, k) => ({
|
|
59
|
+
...segment,
|
|
60
|
+
path: join(dir, `${k}.aac`),
|
|
61
|
+
}));
|
|
62
|
+
const results = await Promise.all(segments.map(({ trim, path }) => runFfmpeg(["-i", input, "-af", trim, ...AAC_ARGS, "-f", "adts", "-y", path], {
|
|
63
|
+
signal,
|
|
64
|
+
timeout,
|
|
65
|
+
})));
|
|
66
|
+
const failed = results.find((result) => !result.success);
|
|
67
|
+
if (failed)
|
|
68
|
+
return failed;
|
|
69
|
+
const joined = join(dir, "joined.aac");
|
|
70
|
+
const frames = segments.flatMap(({ path, keepFrom, keepTo }) => {
|
|
71
|
+
const segmentFrames = splitAdtsFrames(readFileSync(path));
|
|
72
|
+
if (segmentFrames.length < keepTo) {
|
|
73
|
+
throw new Error(`AAC segment ${path} has ${segmentFrames.length} frames, needs ${keepTo}`);
|
|
74
|
+
}
|
|
75
|
+
return segmentFrames.slice(keepFrom, keepTo);
|
|
76
|
+
});
|
|
77
|
+
writeFileSync(joined, Buffer.concat(frames));
|
|
78
|
+
// ADTS carries neither the encoder delay nor the last packet's short duration: the offset
|
|
79
|
+
// restores the delay, and setts ends the stream at totalDuration as one encoder would.
|
|
80
|
+
const offset = String(-FRAME_SAMPLES / SAMPLE_RATE);
|
|
81
|
+
const end = `setts=duration='if(gte((PTS+DURATION)*TB,${totalDuration}),round(${totalDuration}/TB-PTS),DURATION)'`;
|
|
82
|
+
const muxed = await runFfmpeg([
|
|
83
|
+
"-itsoffset",
|
|
84
|
+
offset,
|
|
85
|
+
"-f",
|
|
86
|
+
"aac",
|
|
87
|
+
"-i",
|
|
88
|
+
joined,
|
|
89
|
+
"-c",
|
|
90
|
+
"copy",
|
|
91
|
+
"-bsf:a",
|
|
92
|
+
end,
|
|
93
|
+
"-y",
|
|
94
|
+
outputPath,
|
|
95
|
+
], { signal, timeout });
|
|
96
|
+
const slowest = Math.max(...results.map((result) => result.durationMs));
|
|
97
|
+
return { ...muxed, durationMs: slowest + muxed.durationMs };
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -10,6 +10,7 @@ import { clampFadesToDuration, readElementFades } from "@hyperframes/core/audio-
|
|
|
10
10
|
import { downloadToTemp, isHttpUrl, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js";
|
|
11
11
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
12
12
|
import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
|
|
13
|
+
import { AAC_ARGS, aacSegmentCount, encodeAac } from "./aacEncode.js";
|
|
13
14
|
import { audioSubprocessEvidence } from "./audioFailureDiagnostic.js";
|
|
14
15
|
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
|
15
16
|
import { resolveMediaElementSrc, resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
|
|
@@ -646,7 +647,7 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config,
|
|
|
646
647
|
scriptPath,
|
|
647
648
|
"-map",
|
|
648
649
|
"[out]",
|
|
649
|
-
...(floatWav ? ["-acodec", "pcm_f32le", "-ar", "48000"] :
|
|
650
|
+
...(floatWav ? ["-acodec", "pcm_f32le", "-ar", "48000"] : AAC_ARGS),
|
|
650
651
|
"-t",
|
|
651
652
|
String(totalDuration),
|
|
652
653
|
"-y",
|
|
@@ -852,30 +853,24 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
|
|
|
852
853
|
return { success: true, degradedAutomation };
|
|
853
854
|
}
|
|
854
855
|
/**
|
|
855
|
-
* Sum the tracks to a float WAV, run the master chain over the sum, then AAC-encode.
|
|
856
|
+
* Sum the tracks to a float WAV, run the master chain (if any) over the sum, then AAC-encode.
|
|
856
857
|
* Groups are already folded into `tracks`, and the sum stays float until the encoder.
|
|
857
858
|
*/
|
|
858
|
-
async function
|
|
859
|
+
async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, totalDuration, signal, config) {
|
|
859
860
|
const sumPath = join(workDir, "master-sum.wav");
|
|
860
861
|
const sum = await mixAudioTracks(tracks, sumPath, totalDuration, signal, config, true);
|
|
861
862
|
if (!sum.success)
|
|
862
863
|
return { ...sum, outputPath };
|
|
863
|
-
const
|
|
864
|
-
|
|
864
|
+
const encoderInput = masterChain
|
|
865
|
+
? (await applyAudioFxChain(sumPath, masterChain, join(workDir, "master-fx.wav"), {
|
|
866
|
+
trackId: "master",
|
|
867
|
+
signal,
|
|
868
|
+
})).path
|
|
869
|
+
: sumPath;
|
|
870
|
+
const encoded = await encodeAac(encoderInput, outputPath, totalDuration, {
|
|
865
871
|
signal,
|
|
872
|
+
timeout: config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout,
|
|
866
873
|
});
|
|
867
|
-
const encoded = await runFfmpeg([
|
|
868
|
-
"-i",
|
|
869
|
-
fx.path,
|
|
870
|
-
"-t",
|
|
871
|
-
String(totalDuration),
|
|
872
|
-
"-acodec",
|
|
873
|
-
"aac",
|
|
874
|
-
"-b:a",
|
|
875
|
-
"192k",
|
|
876
|
-
"-y",
|
|
877
|
-
outputPath,
|
|
878
|
-
], { signal, timeout: config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout });
|
|
879
874
|
if (!encoded.success) {
|
|
880
875
|
const failure = ffmpegFailure("mix", encoded);
|
|
881
876
|
return {
|
|
@@ -1295,8 +1290,8 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1295
1290
|
let mixResult;
|
|
1296
1291
|
try {
|
|
1297
1292
|
mixResult =
|
|
1298
|
-
masterChain && tracks.length > 0
|
|
1299
|
-
? await
|
|
1293
|
+
(masterChain || aacSegmentCount(totalDuration) > 1) && tracks.length > 0
|
|
1294
|
+
? await mixThroughFloatSum(tracks, masterChain, outputPath, workDir, totalDuration, signal, config)
|
|
1300
1295
|
: await mixAudioTracks(tracks, outputPath, totalDuration, signal, config);
|
|
1301
1296
|
}
|
|
1302
1297
|
finally {
|
|
@@ -427,6 +427,10 @@ export declare function recordSubTimelineWarning(session: CaptureSession, timeou
|
|
|
427
427
|
* PRINFRA-300 (blank hook scene, zero diagnostics, "fixed" by re-render).
|
|
428
428
|
*/
|
|
429
429
|
export declare function buildLiveMapWarning(libraries: readonly string[]): CaptureWarning;
|
|
430
|
+
/** Out-of-band videos a poster now sizes, so dropping their media cannot move layout. */
|
|
431
|
+
export declare function releasableVideoIds(options: CaptureOptions): string[];
|
|
432
|
+
/** FFmpeg supplies these videos' frames, so their own players would only hold decoders and buffers in every worker. */
|
|
433
|
+
export declare function releaseInjectedVideoMedia(page: Page, videoIds: readonly string[]): Promise<void>;
|
|
430
434
|
export declare function applyVideoMetadataHints(page: Page, hints: readonly CaptureVideoMetadataHint[] | undefined): Promise<void>;
|
|
431
435
|
/** Thrown once a VFX chain fails: the frames it paints would render without the effect. */
|
|
432
436
|
export declare class VfxFailureError extends Error {
|
|
@@ -1529,6 +1529,28 @@ async function decodeAllImages(page) {
|
|
|
1529
1529
|
}));
|
|
1530
1530
|
});
|
|
1531
1531
|
}
|
|
1532
|
+
/** Out-of-band videos a poster now sizes, so dropping their media cannot move layout. */
|
|
1533
|
+
export function releasableVideoIds(options) {
|
|
1534
|
+
const hinted = new Set((options.videoMetadataHints ?? []).map((hint) => hint.id));
|
|
1535
|
+
return (options.skipReadinessVideoIds ?? []).filter((id) => hinted.has(id));
|
|
1536
|
+
}
|
|
1537
|
+
/** FFmpeg supplies these videos' frames, so their own players would only hold decoders and buffers in every worker. */
|
|
1538
|
+
export async function releaseInjectedVideoMedia(page, videoIds) {
|
|
1539
|
+
if (videoIds.length === 0)
|
|
1540
|
+
return;
|
|
1541
|
+
await page.evaluate((ids) => {
|
|
1542
|
+
for (const id of ids) {
|
|
1543
|
+
const video = window.__hfMediaEl?.(id) ?? document.getElementById(id);
|
|
1544
|
+
if (!(video instanceof HTMLVideoElement))
|
|
1545
|
+
continue;
|
|
1546
|
+
video.pause();
|
|
1547
|
+
for (const source of video.querySelectorAll("source"))
|
|
1548
|
+
source.remove();
|
|
1549
|
+
video.removeAttribute("src");
|
|
1550
|
+
video.load();
|
|
1551
|
+
}
|
|
1552
|
+
}, [...videoIds]);
|
|
1553
|
+
}
|
|
1532
1554
|
export async function applyVideoMetadataHints(page, hints) {
|
|
1533
1555
|
if (!hints || hints.length === 0)
|
|
1534
1556
|
return;
|
|
@@ -1763,6 +1785,7 @@ export async function initializeSession(session) {
|
|
|
1763
1785
|
assertVfxIntact(session);
|
|
1764
1786
|
recordSubTimelineWarning(session, pageReadyTimeout);
|
|
1765
1787
|
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
|
|
1788
|
+
await releaseInjectedVideoMedia(page, releasableVideoIds(session.options));
|
|
1766
1789
|
logInitPhase("applyVideoMetadataHints complete");
|
|
1767
1790
|
// Run independent readiness checks in parallel — videos, images, fonts,
|
|
1768
1791
|
// and Tailwind don't depend on each other's completion.
|
|
@@ -1893,6 +1916,7 @@ export async function initializeSession(session) {
|
|
|
1893
1916
|
assertVfxIntact(session);
|
|
1894
1917
|
recordSubTimelineWarning(session, pageReadyTimeout);
|
|
1895
1918
|
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
|
|
1919
|
+
await releaseInjectedVideoMedia(page, releasableVideoIds(session.options));
|
|
1896
1920
|
logInitPhase("applyVideoMetadataHints complete");
|
|
1897
1921
|
// Run independent readiness checks in parallel — videos, images, fonts,
|
|
1898
1922
|
// and Tailwind don't depend on each other's completion.
|
|
@@ -2498,6 +2522,7 @@ export async function createStaticVerificationPage(session) {
|
|
|
2498
2522
|
knownUnregisteredIds: session.subTimelineWaitMemo.unregisteredIds,
|
|
2499
2523
|
});
|
|
2500
2524
|
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
|
|
2525
|
+
await releaseInjectedVideoMedia(page, releasableVideoIds(session.options));
|
|
2501
2526
|
const skipVideoIds = session.options.skipReadinessVideoIds ?? [];
|
|
2502
2527
|
await Promise.all([
|
|
2503
2528
|
pollVideosReady(page, skipVideoIds, pageReadyTimeout),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.170",
|
|
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.170",
|
|
54
|
+
"@hyperframes/parsers": "^0.8.170"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|