@hyperframes/engine 0.8.169 → 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.
@@ -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"] : ["-acodec", "aac", "-b:a", "192k"]),
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 mixThroughMasterBus(tracks, masterChain, outputPath, workDir, totalDuration, signal, config) {
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 fx = await applyAudioFxChain(sumPath, masterChain, join(workDir, "master-fx.wav"), {
864
- trackId: "master",
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 mixThroughMasterBus(tracks, masterChain, outputPath, workDir, totalDuration, signal, config)
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.169",
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.169",
54
- "@hyperframes/parsers": "^0.8.169"
53
+ "@hyperframes/core": "^0.8.170",
54
+ "@hyperframes/parsers": "^0.8.170"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",