@hyperframes/engine 0.8.186 → 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 CHANGED
@@ -41,6 +41,7 @@ export { augmentPageNavigationTimeoutError, isPageNavigationTimeoutError, type N
41
41
  export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, DrawElementVerificationError, isDrawElementVerificationError, getDrawElementVerificationDetails, type DrawElementVerificationDetails, recaptureDrawElementFrameForVerify, completeDeferredDrawElementInit, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, deriveBeginFrameProbeTimeTicks, type CaptureSession, isTransientBrowserError, isMemoryExhaustionError, type BeforeCaptureHook, type DiscardWarmupInnerCapture, type StaticVerificationOutcome, VfxFailureError, waitForImagesReady, } from "./services/frameCapture.js";
42
42
  export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, type CaptureFailureKind, type CaptureWorkerDiagnostic, } from "./services/captureFailure.js";
43
43
  export { createChromeMemorySampler, type ChromeMemoryStats, } from "./services/chromeMemorySampler.js";
44
+ export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
44
45
  export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, type BeginFrameResult, } from "./services/screenshotService.js";
45
46
  export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
46
47
  export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
@@ -57,6 +58,8 @@ export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndices
57
58
  export { createFileServer, type FileServerOptions, type FileServerHandle, } from "./services/fileServer.js";
58
59
  export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
59
60
  export { frameFileExtension } from "./services/frameCapture.js";
61
+ export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThreads.js";
62
+ export { frameSequencePath } from "./services/extractedFrameIndex.js";
60
63
  export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionBlur.js";
61
64
  export type { PageMotionBlurOff } from "./types.js";
62
65
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ export { createCaptureSession, initializeSession, closeCaptureSession, captureFr
44
44
  export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, } from "./services/captureFailure.js";
45
45
  export { createChromeMemorySampler, } from "./services/chromeMemorySampler.js";
46
46
  // ── Screenshot (BeginFrame) ─────────────────────────────────────────────────────
47
+ export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
47
48
  export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, } from "./services/screenshotService.js";
48
49
  // ── Encoding ───────────────────────────────────────────────────────────────────
49
50
  export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
@@ -63,6 +64,8 @@ export { createFileServer, } from "./services/fileServer.js";
63
64
  // ── Utilities ──────────────────────────────────────────────────────────────────
64
65
  export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
65
66
  export { frameFileExtension } from "./services/frameCapture.js";
67
+ export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThreads.js";
68
+ export { frameSequencePath } from "./services/extractedFrameIndex.js";
66
69
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
67
70
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
68
71
  export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
@@ -57,6 +57,8 @@ export async function encodeAac(input, outputPath, totalDuration, { signal, time
57
57
  path: join(dir, `${k}.aac`),
58
58
  }));
59
59
  const results = await Promise.all(segments.map(({ trim, path }) => runFfmpeg([
60
+ "-cpuflags",
61
+ "0",
60
62
  "-i",
61
63
  input,
62
64
  "-af",
@@ -107,6 +107,7 @@ export declare function applyAudioFxChain(inputWav: string, chain: HfAudioFxChai
107
107
  signal?: AbortSignal;
108
108
  automation?: HfAutomation;
109
109
  envelope?: AudioFxEnvelope;
110
+ inputGain?: number;
110
111
  streaming?: Partial<AudioFxStreamingLimits>;
111
112
  }): Promise<{
112
113
  path: string;
@@ -258,6 +258,11 @@ function applyEnvelopeToPlanes(planes, sampleRate, gainAt, startFrame = 0) {
258
258
  plane[frame] = (plane[frame] ?? 0) * gain;
259
259
  }
260
260
  }
261
+ /** The clip's `data-volume`, ahead of the chain where an editor puts clip gain. */
262
+ function applyInputGain(planes, gain = 1) {
263
+ if (gain !== 1)
264
+ applyEnvelopeToPlanes(planes, 1, () => gain);
265
+ }
261
266
  const DEFAULT_STREAMING_LIMITS = {
262
267
  aboveBytes: 512 * 1024 * 1024,
263
268
  segmentSeconds: 300,
@@ -324,6 +329,7 @@ async function renderInSegments(openPage, inputWav, header, chain, outputWav, op
324
329
  throw new AudioFxRenderError(`Audio FX cancelled for track ${options.trackId}`);
325
330
  }
326
331
  const planes = deinterleave(readFrames(inFd, header, seg.readFrom, seg.readTo), channels);
332
+ applyInputGain(planes, options.inputGain);
327
333
  // A fresh page per window, so the browser never holds more than one window of audio.
328
334
  const page = await openPage();
329
335
  let outPlanes;
@@ -397,6 +403,7 @@ export async function applyAudioFxChain(inputWav, chain, outputWav, options) {
397
403
  }
398
404
  const input = readWav(inputWav);
399
405
  const planes = deinterleave(input.samples, input.channels);
406
+ applyInputGain(planes, options.inputGain);
400
407
  // An empty track has nothing to process — and an OfflineAudioContext of zero
401
408
  // length throws, which is fatal for the WHOLE render rather than this track:
402
409
  // the error travels past the mixer's per-track failure collector. ffmpeg
@@ -18,11 +18,12 @@ export declare function buildTrackInputFilter(track: Pick<AudioTrack, "start" |
18
18
  * sampled, or the bake would quietly straighten it.
19
19
  *
20
20
  * Times come out in composition seconds, which is what the baker subtracts the
21
- * track start from. Returns null when the track has no volume lane.
21
+ * track start from. Values are `gainedVolume` of the track's `data-volume`.
22
+ * Returns null when the track has no volume lane.
22
23
  */
23
24
  export declare function volumeLaneKeyframes(automation: {
24
25
  lanes: HfAutomationLane[];
25
- }, trackStart: number, duration: number): AudioVolumeKeyframe[] | null;
26
+ }, trackStart: number, duration: number, gain: number, gainAhead?: boolean): AudioVolumeKeyframe[] | null;
26
27
  /**
27
28
  * The top-level root's `data-fx-chain`. It belongs to the composition, not to a clip,
28
29
  * so `processCompositionAudio` takes it beside the tracks: a track the render adds
@@ -30,6 +31,8 @@ export declare function volumeLaneKeyframes(automation: {
30
31
  */
31
32
  export declare function parseMasterFxChain(html: string): string | undefined;
32
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;
33
36
  export declare function processCompositionAudio(elements: AudioElement[], baseDir: string, workDir: string, outputPath: string, totalDuration: number, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">> & {
34
37
  /** Serialised master bus chain, from `parseMasterFxChain`. */
35
38
  masterFxChain?: string;
@@ -6,11 +6,11 @@ import { parseHTML } from "linkedom";
6
6
  import { extractAudioMetadata } from "../utils/ffprobe.js";
7
7
  import { isNotMediaPayload } from "../utils/notMediaPayload.js";
8
8
  import { urlInputArgs } from "../utils/urlInput.js";
9
- import { clampAudioGain } from "@hyperframes/core/audio-gain";
9
+ import { clampAudioGain, clipGain } from "@hyperframes/core/audio-gain";
10
10
  import { clampFadesToDuration, readElementFades } from "@hyperframes/core/audio-fade";
11
11
  import { downloadToTemp, isHttpUrl, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js";
12
12
  import { DEFAULT_CONFIG } from "../config.js";
13
- import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
13
+ import { describeFfmpegFailure, runFfmpeg } from "../utils/runFfmpeg.js";
14
14
  import { encodeAac } from "./aacEncode.js";
15
15
  import { audioSubprocessEvidence } from "./audioFailureDiagnostic.js";
16
16
  import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -18,8 +18,8 @@ import { resolveMediaElementSrc, resolveProjectRelativeSrc } from "./videoFrameE
18
18
  import { resolveReferencedStart } from "./referenceResolver.js";
19
19
  import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
20
20
  import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
21
- import { HF_AUDIO_FX_ATTR, parseAudioFxChain, } from "@hyperframes/core/audio-fx";
22
- import { HF_AUDIO_AUTOMATION_ATTR, parseAutomation, resolveAutomation, sampleAutomationLane, VOLUME_TARGET, } from "@hyperframes/core/audio-automation";
21
+ import { enabledAudioFxNodes, HF_AUDIO_FX_ATTR, parseAudioFxChain, } from "@hyperframes/core/audio-fx";
22
+ import { gainedVolume, gainedVolumePoints, HF_AUDIO_AUTOMATION_ATTR, isBentSegment, volumeSegmentSampleCount, parseAutomation, resolveAutomation, sampleAutomationLane, VOLUME_TARGET, } from "@hyperframes/core/audio-automation";
23
23
  import { chainTailSeconds } from "@hyperframes/core/audio-fx-tail";
24
24
  import { isAudibleVideoElement, MEDIA_RENDER_ID_ATTR, normalizePlaybackRate, normalizeRateSpec, parseStrictFiniteTimingNumber, readElementRateSpec, readMediaStart, sourceTimeAt, timeAtSourceTime, } from "@hyperframes/core";
25
25
  import { readMasterFxChain, resolveAudioGroups } from "@hyperframes/core/audio-groups";
@@ -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
- return redacted.length <= maxLength ? redacted : `${redacted.slice(0, maxLength - 1)}…`;
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);
@@ -315,7 +319,7 @@ function downloadFailure(error, elementId) {
315
319
  detail: boundedDetail(`Download failed for audio element ${elementId}: ${message}`),
316
320
  };
317
321
  }
318
- function ffmpegFailure(stage, result, elementId) {
322
+ function ffmpegFailure(stage, result, timeoutMs, elementId) {
319
323
  const stderr = result.stderr ?? "";
320
324
  let reason = "ffmpeg_failed";
321
325
  let owner = "system";
@@ -351,13 +355,12 @@ function ffmpegFailure(stage, result, elementId) {
351
355
  retryable,
352
356
  elementId,
353
357
  subprocess: audioSubprocessEvidence(result),
354
- detail: boundedDetail(result.error?.message
355
- ? `${formatFfmpegError(result.exitCode, stderr)}: ${result.error.message}`
356
- : formatFfmpegError(result.exitCode, stderr)),
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
- /** Extra samples per second inside a segment the baker cannot draw straight. */
360
- const CURVED_SEGMENT_SAMPLES_PER_SEC = 30;
361
364
  /**
362
365
  * A volume lane as keyframes for the PCM baker.
363
366
  *
@@ -366,36 +369,38 @@ const CURVED_SEGMENT_SAMPLES_PER_SEC = 30;
366
369
  * sampled, or the bake would quietly straighten it.
367
370
  *
368
371
  * Times come out in composition seconds, which is what the baker subtracts the
369
- * track start from. Returns null when the track has no volume lane.
372
+ * track start from. Values are `gainedVolume` of the track's `data-volume`.
373
+ * Returns null when the track has no volume lane.
370
374
  */
371
- export function volumeLaneKeyframes(automation, trackStart, duration) {
375
+ export function volumeLaneKeyframes(automation, trackStart, duration, gain, gainAhead = false) {
372
376
  const lane = automation.lanes.find((l) => l.target === VOLUME_TARGET);
373
377
  if (!lane || lane.points.length === 0)
374
378
  return null;
379
+ const points = gainedVolumePoints(lane, gain, gainAhead);
375
380
  const out = [];
376
381
  const push = (t, v) => {
377
382
  out.push({ time: trackStart + t, volume: v });
378
383
  };
379
384
  // The envelope holds its first value before the first point, rather than
380
385
  // falling back to `data-volume`.
381
- const first = lane.points[0];
386
+ const first = points[0];
382
387
  if (first.t > 0)
383
388
  push(0, first.v);
384
- for (let i = 0; i < lane.points.length; i += 1) {
385
- const a = lane.points[i];
389
+ for (let i = 0; i < points.length; i += 1) {
390
+ const a = points[i];
386
391
  push(a.t, a.v);
387
- const b = lane.points[i + 1];
388
- if (!b || !a.curve)
392
+ const b = points[i + 1];
393
+ if (!b || !isBentSegment(a))
389
394
  continue;
390
- const steps = Math.max(2, Math.ceil((b.t - a.t) * CURVED_SEGMENT_SAMPLES_PER_SEC));
395
+ const steps = volumeSegmentSampleCount(b.t - a.t);
391
396
  for (let k = 1; k < steps; k += 1) {
392
397
  const t = a.t + ((b.t - a.t) * k) / steps;
393
- push(t, sampleAutomationLane(lane, t));
398
+ push(t, gainedVolume(sampleAutomationLane(lane, t), gain, gainAhead));
394
399
  }
395
400
  }
396
401
  // Hold the last value to the clip's end, so the baker does not ramp away
397
402
  // from it toward whatever it would otherwise assume.
398
- const last = lane.points[lane.points.length - 1];
403
+ const last = points[points.length - 1];
399
404
  if (duration > last.t)
400
405
  push(duration, last.v);
401
406
  return out;
@@ -547,7 +552,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
547
552
  failure,
548
553
  };
549
554
  }
550
- let failure = result.success ? undefined : ffmpegFailure(stage, result);
555
+ let failure = result.success ? undefined : ffmpegFailure(stage, result, ffmpegProcessTimeout);
551
556
  const probedLinkFailedToRead = readsUrl && (failure?.reason === "invalid_media" || failure?.reason === "ffmpeg_failed");
552
557
  if (failure && probedLinkFailedToRead) {
553
558
  failure = { ...failure, reason: "download_failed", owner: "system", retryable: true };
@@ -594,7 +599,9 @@ async function generateSilence(outputPath, duration, signal, config) {
594
599
  failure,
595
600
  };
596
601
  }
597
- const failure = !result.success ? ffmpegFailure("silence", result) : undefined;
602
+ const failure = !result.success
603
+ ? ffmpegFailure("silence", result, ffmpegProcessTimeout)
604
+ : undefined;
598
605
  return {
599
606
  success: result.success,
600
607
  outputPath,
@@ -603,8 +610,14 @@ async function generateSilence(outputPath, duration, signal, config) {
603
610
  failure,
604
611
  };
605
612
  }
606
- async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config) {
607
- const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
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);
608
621
  const masterOutputGain = config?.audioGain ?? DEFAULT_CONFIG.audioGain;
609
622
  if (tracks.length === 0) {
610
623
  const result = await generateSilence(outputPath, totalDuration, signal, config);
@@ -732,7 +745,7 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config)
732
745
  };
733
746
  }
734
747
  if (!result.success) {
735
- const failure = ffmpegFailure("mix", result);
748
+ const failure = ffmpegFailure("mix", result, ffmpegProcessTimeout);
736
749
  return {
737
750
  success: false,
738
751
  outputPath,
@@ -780,20 +793,21 @@ function groupNormalizeOptionUnsupported(stderr) {
780
793
  * compensation at all and is preferred; the manual per-own-count fallback
781
794
  * only runs when this ffmpeg build's `amix` rejects the option.
782
795
  */
783
- async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal, config) {
784
- const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
796
+ async function mixGroupMembersFlat(memberTracks, outputPath, totalDuration, signal, config) {
797
+ const ffmpegProcessTimeout = mixProcessTimeout(config, totalDuration);
785
798
  const outputDir = dirname(outputPath);
786
799
  if (!existsSync(outputDir))
787
800
  mkdirSync(outputDir, { recursive: true });
788
- const buildInputFilters = (ignoreKeyframes) => memberTracks.map((track, i) => {
801
+ const buildInputFilters = (ignoreKeyframes, padMembers) => memberTracks.map((track, i) => {
789
802
  const volumeFilter = buildVolumeExpression(track, ignoreKeyframes);
790
- return buildTrackInputFilter(track, i, volumeFilter, totalDuration);
803
+ return buildTrackInputFilter(track, i, volumeFilter, totalDuration, padMembers);
791
804
  });
792
805
  const mixInputs = memberTracks.map((_, i) => `[a${i}]`).join("");
793
806
  const runOnce = async (useNormalize, ignoreKeyframes = false) => {
794
- const inputFilters = buildInputFilters(ignoreKeyframes);
807
+ const inputFilters = buildInputFilters(ignoreKeyframes, !useNormalize);
808
+ const padUnitySumOnceToFilm = `[mixed];[mixed]apad,asetpts=N/SR/TB,atrim=0:${formatFilterNumber(totalDuration)}`;
795
809
  const mixFilter = useNormalize
796
- ? `${mixInputs}amix=inputs=${memberTracks.length}:duration=longest:dropout_transition=0:normalize=0[out]`
810
+ ? `${mixInputs}amix=inputs=${memberTracks.length}:duration=longest:dropout_transition=0:normalize=0${padUnitySumOnceToFilm}[out]`
797
811
  : // Every member is padded and trimmed to totalDuration above, and
798
812
  // dropout_transition=0 keeps all N inputs active for that whole span.
799
813
  // amix's default normalize therefore divides by exactly N throughout;
@@ -868,11 +882,99 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
868
882
  }
869
883
  }
870
884
  if (signal?.aborted || !result.success) {
871
- const failure = ffmpegFailure("mix", result);
885
+ const failure = ffmpegFailure("mix", result, ffmpegProcessTimeout);
872
886
  return { success: false, error: failure.detail, failure };
873
887
  }
874
888
  return { success: true, degradedAutomation };
875
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
+ }
876
978
  /**
877
979
  * Sum the tracks to a float WAV, run the master chain (if any) over the sum, then AAC-encode.
878
980
  * Groups are already folded into `tracks`, and the sum stays float until the encoder.
@@ -893,7 +995,7 @@ async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, tota
893
995
  timeout: config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout,
894
996
  });
895
997
  if (!encoded.success) {
896
- const failure = ffmpegFailure("mix", encoded);
998
+ const failure = ffmpegFailure("mix", encoded, config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout);
897
999
  return {
898
1000
  success: false,
899
1001
  outputPath,
@@ -1060,8 +1162,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1060
1162
  // envelope belongs on their output. A missing or broken chain is fatal
1061
1163
  // for the whole mix rather than a per-track warning — quietly rendering
1062
1164
  // the dry signal ships a mix that sounds plausible and is wrong.
1165
+ const chain = element.fxChain ? parseAudioFxChain(element.fxChain) : undefined;
1063
1166
  const automation = element.automation
1064
- ? resolveAutomation(parseAutomation(element.automation), element.fxChain ? parseAudioFxChain(element.fxChain) : undefined)
1167
+ ? resolveAutomation(parseAutomation(element.automation), chain)
1065
1168
  : null;
1066
1169
  // Computed before the chain runs, not after: the FX pass bakes the
1067
1170
  // envelope into its float output so the duck lands before the ±1 clamp
@@ -1070,9 +1173,13 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1070
1173
  // A volume lane supersedes keyframes probed from the timeline: the two
1071
1174
  // would fight, and the lane is the explicit one. `lint` warns when a
1072
1175
  // track carries both.
1176
+ //
1177
+ // With clip gain ahead of the chain, the lane carries its share of the capped product.
1178
+ const gainBeforeFx = chain !== undefined && enabledAudioFxNodes(chain).length > 0;
1073
1179
  const laneKeyframes = automation
1074
- ? volumeLaneKeyframes(automation, element.start, element.end - element.start)
1180
+ ? volumeLaneKeyframes(automation, element.start, element.end - element.start, element.volume ?? 1, gainBeforeFx)
1075
1181
  : null;
1182
+ const tweened = !laneKeyframes && (element.volumeKeyframes?.length ?? 0) > 0;
1076
1183
  const envelopeKeyframes = laneKeyframes ?? element.volumeKeyframes;
1077
1184
  const envelope = envelopeKeyframes && envelopeKeyframes.length > 0
1078
1185
  ? {
@@ -1083,11 +1190,10 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1083
1190
  : null;
1084
1191
  let bakedEnvelope = false;
1085
1192
  let tailSeconds = 0;
1086
- if (element.fxChain) {
1193
+ if (chain) {
1087
1194
  // The chain is serialised into the attribute, the same way colour
1088
1195
  // grading carries its config, so there is no side-car file to find,
1089
1196
  // resolve or lose.
1090
- const chain = parseAudioFxChain(element.fxChain);
1091
1197
  // The rendered WAV is longer than the input by exactly this much, so
1092
1198
  // the mix has to be told to let it through.
1093
1199
  tailSeconds = chainTailSeconds(chain, automation ?? undefined);
@@ -1096,6 +1202,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1096
1202
  signal: effectiveSignal,
1097
1203
  ...(automation ? { automation } : {}),
1098
1204
  ...(envelope ? { envelope } : {}),
1205
+ ...(gainBeforeFx ? { inputGain: clipGain(element.volume ?? 1, tweened) } : {}),
1099
1206
  });
1100
1207
  audioSrcPath = fxResult.path;
1101
1208
  bakedEnvelope = fxResult.envelopeBaked;
@@ -1116,7 +1223,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1116
1223
  mediaStart: element.mediaStart,
1117
1224
  duration: element.end - element.start,
1118
1225
  // Gain is already in the samples when baked, so mix at unity.
1119
- volume: bakedEnvelope ? 1.0 : (element.volume ?? 1.0),
1226
+ volume: bakedEnvelope || (gainBeforeFx && !tweened) ? 1.0 : (element.volume ?? 1.0),
1120
1227
  volumeKeyframes: bakedEnvelope ? undefined : (envelopeKeyframes ?? undefined),
1121
1228
  ...(element.fadeIn ? { fadeIn: element.fadeIn } : {}),
1122
1229
  ...(element.fadeOut ? { fadeOut: element.fadeOut } : {}),
@@ -1260,7 +1367,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1260
1367
  const automation = meta.automation
1261
1368
  ? resolveAutomation(parseAutomation(meta.automation), meta.fxChain ? parseAudioFxChain(meta.fxChain) : undefined)
1262
1369
  : null;
1263
- const laneKeyframes = automation ? volumeLaneKeyframes(automation, 0, totalDuration) : null;
1370
+ const laneKeyframes = automation
1371
+ ? volumeLaneKeyframes(automation, 0, totalDuration, meta.volume)
1372
+ : null;
1264
1373
  const envelope = laneKeyframes && laneKeyframes.length > 0
1265
1374
  ? { keyframes: laneKeyframes, trackStart: 0, baseVolume: meta.volume }
1266
1375
  : null;
@@ -5,6 +5,7 @@
5
5
  * Encodes captured frames into video using FFmpeg.
6
6
  * Supports CPU (libx264) and GPU encoding.
7
7
  */
8
+ import { frameSequencePath } from "./extractedFrameIndex.js";
8
9
  import { closeSync, copyFileSync, existsSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, statSync, writeFileSync, writeSync, } from "fs";
9
10
  import { join, dirname, extname } from "path";
10
11
  import { DEFAULT_CONFIG } from "../config.js";
@@ -16,6 +17,7 @@ import { describeFfmpegFailure, ffmpegStatsReader, formatFfmpegError, isExternal
16
17
  import { extractAudioMetadata } from "../utils/ffprobe.js";
17
18
  import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
18
19
  import { appendVp9CpuUsedArg } from "./vp9Options.js";
20
+ import { appendSoftwareEncoderThreadsArg } from "./softwareEncoderThreads.js";
19
21
  import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
20
22
  export const ENCODER_PRESETS = {
21
23
  draft: { preset: "ultrafast", quality: 28, codec: "h264" },
@@ -212,6 +214,7 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
212
214
  else {
213
215
  const encoderName = codec === "h264" ? "libx264" : "libx265";
214
216
  args.push("-c:v", encoderName, "-preset", preset);
217
+ appendSoftwareEncoderThreadsArg(args);
215
218
  if (bitrate)
216
219
  args.push("-b:v", bitrate);
217
220
  else
@@ -396,7 +399,7 @@ export async function encodeFramesFromDir(framesDir, framePattern, outputPath, o
396
399
  if (options.useGpu) {
397
400
  gpuEncoder = await getCachedGpuEncoder();
398
401
  }
399
- const inputPath = join(framesDir, framePattern);
402
+ const inputPath = frameSequencePath(framesDir, framePattern);
400
403
  const inputArgs = ["-framerate", fpsToFfmpegArg(options.fps), "-i", inputPath];
401
404
  const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
402
405
  const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
@@ -534,7 +537,7 @@ export async function encodeFramesChunkedConcat(framesDir, framePattern, outputP
534
537
  ? ".mov"
535
538
  : ".mp4";
536
539
  const chunkPath = join(chunkDir, `chunk_${String(i).padStart(4, "0")}${ext}`);
537
- const inputPath = join(framesDir, framePattern);
540
+ const inputPath = frameSequencePath(framesDir, framePattern);
538
541
  const inputArgs = [
539
542
  "-framerate",
540
543
  fpsToFfmpegArg(options.fps),
@@ -1,5 +1,6 @@
1
1
  export type ExtractedFrameFormat = "jpg" | "png";
2
2
  export declare const FRAME_FILENAME_PREFIX = "frame_";
3
+ export declare function frameSequencePath(dir: string, framePattern: string): string;
3
4
  export declare class ExtractedFrameSequenceError extends Error {
4
5
  constructor(message: string);
5
6
  }
@@ -1,6 +1,9 @@
1
1
  import { readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  export const FRAME_FILENAME_PREFIX = "frame_";
4
+ export function frameSequencePath(dir, framePattern) {
5
+ return join(dir.replaceAll("%", "%%"), framePattern);
6
+ }
4
7
  export class ExtractedFrameSequenceError extends Error {
5
8
  constructor(message) {
6
9
  super(message);
@@ -4,14 +4,14 @@
4
4
  * The pipeline addresses media by id, but element ids are only unique within
5
5
  * one composition FILE and the render document inlines many, so
6
6
  * `document.getElementById` silently resolves duplicates to whichever element
7
- * comes first. The producer stamps a document-unique `data-hf-render-id`
7
+ * comes first. The compiler stamps a document-unique `data-hf-render-id`
8
8
  * (core's mediaRenderIds.ts); these helpers are the single place that knows to
9
9
  * prefer it, so every capture stage addresses the same element.
10
10
  *
11
11
  * Installed via `evaluateOnNewDocument` so it exists before any page script.
12
- * Every call site keeps a `getElementById` fallback for documents that were
13
- * never compiled by the producer (snapshot, check, and direct engine callers),
14
- * where the authored id already is the identity.
12
+ * Every call site keeps a `getElementById` fallback for uncompiled documents
13
+ * passed directly to the engine. CLI captures and producer renders both carry
14
+ * document-unique IDs from the compiler.
15
15
  *
16
16
  * `__hfMediaId` mirrors core's `readMediaRenderId`, which is the definition of
17
17
  * this rule; the copy exists only because code serialized into `page.evaluate`
@@ -4,14 +4,14 @@
4
4
  * The pipeline addresses media by id, but element ids are only unique within
5
5
  * one composition FILE and the render document inlines many, so
6
6
  * `document.getElementById` silently resolves duplicates to whichever element
7
- * comes first. The producer stamps a document-unique `data-hf-render-id`
7
+ * comes first. The compiler stamps a document-unique `data-hf-render-id`
8
8
  * (core's mediaRenderIds.ts); these helpers are the single place that knows to
9
9
  * prefer it, so every capture stage addresses the same element.
10
10
  *
11
11
  * Installed via `evaluateOnNewDocument` so it exists before any page script.
12
- * Every call site keeps a `getElementById` fallback for documents that were
13
- * never compiled by the producer (snapshot, check, and direct engine callers),
14
- * where the authored id already is the identity.
12
+ * Every call site keeps a `getElementById` fallback for uncompiled documents
13
+ * passed directly to the engine. CLI captures and producer renders both carry
14
+ * document-unique IDs from the compiler.
15
15
  *
16
16
  * `__hfMediaId` mirrors core's `readMediaRenderId`, which is the definition of
17
17
  * this rule; the copy exists only because code serialized into `page.evaluate`
@@ -556,6 +556,7 @@ async function injectResolvedVideoFramesBatch(page, updates) {
556
556
  async (items, visualProperties, colorGradingSourceHiddenAttr) => {
557
557
  const injectedIds = [];
558
558
  const pendingDecodes = [];
559
+ const imagesToPublish = [];
559
560
  const replacementLayoutProperties = new Set([
560
561
  "width",
561
562
  "height",
@@ -692,6 +693,8 @@ async function injectResolvedVideoFramesBatch(page, updates) {
692
693
  img.style.objectFit = computedStyle.objectFit;
693
694
  img.style.objectPosition = computedStyle.objectPosition;
694
695
  img.style.zIndex = computedStyle.zIndex;
696
+ // Publish after full decode so raster cannot race it with a reduced JPEG decode.
697
+ img.style.setProperty("visibility", "hidden", "important");
695
698
  img.decoding = "sync";
696
699
  if (img.getAttribute("src") !== item.dataUri) {
697
700
  if (item.crossOrigin)
@@ -705,7 +708,7 @@ async function injectResolvedVideoFramesBatch(page, updates) {
705
708
  }));
706
709
  }
707
710
  img.style.opacity = String(computedOpacity);
708
- img.style.visibility = "visible";
711
+ imagesToPublish.push(img);
709
712
  // Hide the native <video> with visibility only — never clobber inline
710
713
  // opacity, so subsequent reads (and queryElementStacking) see the real
711
714
  // GSAP-controlled value.
@@ -716,6 +719,8 @@ async function injectResolvedVideoFramesBatch(page, updates) {
716
719
  if (pendingDecodes.length > 0) {
717
720
  await Promise.all(pendingDecodes);
718
721
  }
722
+ for (const img of imagesToPublish)
723
+ img.style.visibility = "visible";
719
724
  if (injectedIds.length > 0) {
720
725
  const redraw = window
721
726
  .__hf?.colorGrading?.redraw;
@@ -757,7 +762,7 @@ export async function syncVideoFrameVisibility(page, activeVideoIds) {
757
762
  const img = video.nextElementSibling;
758
763
  const hasImg = img && img.classList.contains("__render_frame__");
759
764
  const ancestorHidden = isVisualAncestorHidden(video);
760
- const visible = active.has(video.id) && !ancestorHidden;
765
+ const visible = active.has(window.__hfMediaId?.(video) || video.id) && !ancestorHidden;
761
766
  if (visible) {
762
767
  // Active video: show injected <img>, hide native <video>.
763
768
  // Do NOT clobber inline opacity here — GSAP-controlled opacity must
@@ -0,0 +1,2 @@
1
+ /** 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;
@@ -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
@@ -27,7 +27,7 @@ import { isFfmpegFilterAvailable } from "../utils/psnrFilterAvailability.js";
27
27
  import { DEFAULT_CONFIG } from "../config.js";
28
28
  import { unwrapTemplate } from "../utils/htmlTemplate.js";
29
29
  import { FRAME_FILENAME_PREFIX, gcExtractionCache, gcSweepDue, lookupCacheEntry, partialCacheEntryDir, publishCacheEntry, readContentSha256, readKeyStat, rehydrateCacheEntry, touchCacheEntry, } from "./extractionCache.js";
30
- import { framePathsFromDirectory } from "./extractedFrameIndex.js";
30
+ import { framePathsFromDirectory, frameSequencePath } from "./extractedFrameIndex.js";
31
31
  export { resolveProjectRelativeSrc };
32
32
  /**
33
33
  * The single source of truth for the source-video frame-extraction allow-list.
@@ -571,7 +571,7 @@ outputDirOverride) {
571
571
  }
572
572
  const format = resolveFrameFormat(metadata, options.format);
573
573
  const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${format}`;
574
- const outputPattern = join(videoOutputDir, framePattern);
574
+ const outputPattern = frameSequencePath(videoOutputDir, framePattern);
575
575
  // Forced-SDR extraction tone-maps HDR with zscale, the Studio proxy policy. A macOS ffmpeg
576
576
  // without zscale falls back to VideoToolbox, whose frames stay HDR and are tone-mapped by
577
577
  // Chrome instead (darker), and the render warns once.
@@ -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 || codecMayHaveAlpha(metadata.videoCodec))
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.186",
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.186",
54
- "@hyperframes/parsers": "^0.8.186"
53
+ "@hyperframes/core": "^0.8.189",
54
+ "@hyperframes/parsers": "^0.8.189"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",