@hyperframes/engine 0.8.186 → 0.8.188

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,7 @@ 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 { frameSequencePath } from "./services/extractedFrameIndex.js";
60
62
  export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionBlur.js";
61
63
  export type { PageMotionBlurOff } from "./types.js";
62
64
  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,7 @@ 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 { frameSequencePath } from "./services/extractedFrameIndex.js";
66
68
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
67
69
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
68
70
  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
@@ -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";
@@ -315,7 +315,7 @@ function downloadFailure(error, elementId) {
315
315
  detail: boundedDetail(`Download failed for audio element ${elementId}: ${message}`),
316
316
  };
317
317
  }
318
- function ffmpegFailure(stage, result, elementId) {
318
+ function ffmpegFailure(stage, result, timeoutMs, elementId) {
319
319
  const stderr = result.stderr ?? "";
320
320
  let reason = "ffmpeg_failed";
321
321
  let owner = "system";
@@ -352,12 +352,10 @@ function ffmpegFailure(stage, result, elementId) {
352
352
  elementId,
353
353
  subprocess: audioSubprocessEvidence(result),
354
354
  detail: boundedDetail(result.error?.message
355
- ? `${formatFfmpegError(result.exitCode, stderr)}: ${result.error.message}`
356
- : formatFfmpegError(result.exitCode, stderr)),
355
+ ? `${describeFfmpegFailure(result, timeoutMs)}: ${result.error.message}`
356
+ : describeFfmpegFailure(result, timeoutMs)),
357
357
  };
358
358
  }
359
- /** Extra samples per second inside a segment the baker cannot draw straight. */
360
- const CURVED_SEGMENT_SAMPLES_PER_SEC = 30;
361
359
  /**
362
360
  * A volume lane as keyframes for the PCM baker.
363
361
  *
@@ -366,36 +364,38 @@ const CURVED_SEGMENT_SAMPLES_PER_SEC = 30;
366
364
  * sampled, or the bake would quietly straighten it.
367
365
  *
368
366
  * 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.
367
+ * track start from. Values are `gainedVolume` of the track's `data-volume`.
368
+ * Returns null when the track has no volume lane.
370
369
  */
371
- export function volumeLaneKeyframes(automation, trackStart, duration) {
370
+ export function volumeLaneKeyframes(automation, trackStart, duration, gain, gainAhead = false) {
372
371
  const lane = automation.lanes.find((l) => l.target === VOLUME_TARGET);
373
372
  if (!lane || lane.points.length === 0)
374
373
  return null;
374
+ const points = gainedVolumePoints(lane, gain, gainAhead);
375
375
  const out = [];
376
376
  const push = (t, v) => {
377
377
  out.push({ time: trackStart + t, volume: v });
378
378
  };
379
379
  // The envelope holds its first value before the first point, rather than
380
380
  // falling back to `data-volume`.
381
- const first = lane.points[0];
381
+ const first = points[0];
382
382
  if (first.t > 0)
383
383
  push(0, first.v);
384
- for (let i = 0; i < lane.points.length; i += 1) {
385
- const a = lane.points[i];
384
+ for (let i = 0; i < points.length; i += 1) {
385
+ const a = points[i];
386
386
  push(a.t, a.v);
387
- const b = lane.points[i + 1];
388
- if (!b || !a.curve)
387
+ const b = points[i + 1];
388
+ if (!b || !isBentSegment(a))
389
389
  continue;
390
- const steps = Math.max(2, Math.ceil((b.t - a.t) * CURVED_SEGMENT_SAMPLES_PER_SEC));
390
+ const steps = volumeSegmentSampleCount(b.t - a.t);
391
391
  for (let k = 1; k < steps; k += 1) {
392
392
  const t = a.t + ((b.t - a.t) * k) / steps;
393
- push(t, sampleAutomationLane(lane, t));
393
+ push(t, gainedVolume(sampleAutomationLane(lane, t), gain, gainAhead));
394
394
  }
395
395
  }
396
396
  // Hold the last value to the clip's end, so the baker does not ramp away
397
397
  // from it toward whatever it would otherwise assume.
398
- const last = lane.points[lane.points.length - 1];
398
+ const last = points[points.length - 1];
399
399
  if (duration > last.t)
400
400
  push(duration, last.v);
401
401
  return out;
@@ -547,7 +547,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
547
547
  failure,
548
548
  };
549
549
  }
550
- let failure = result.success ? undefined : ffmpegFailure(stage, result);
550
+ let failure = result.success ? undefined : ffmpegFailure(stage, result, ffmpegProcessTimeout);
551
551
  const probedLinkFailedToRead = readsUrl && (failure?.reason === "invalid_media" || failure?.reason === "ffmpeg_failed");
552
552
  if (failure && probedLinkFailedToRead) {
553
553
  failure = { ...failure, reason: "download_failed", owner: "system", retryable: true };
@@ -594,7 +594,9 @@ async function generateSilence(outputPath, duration, signal, config) {
594
594
  failure,
595
595
  };
596
596
  }
597
- const failure = !result.success ? ffmpegFailure("silence", result) : undefined;
597
+ const failure = !result.success
598
+ ? ffmpegFailure("silence", result, ffmpegProcessTimeout)
599
+ : undefined;
598
600
  return {
599
601
  success: result.success,
600
602
  outputPath,
@@ -732,7 +734,7 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config)
732
734
  };
733
735
  }
734
736
  if (!result.success) {
735
- const failure = ffmpegFailure("mix", result);
737
+ const failure = ffmpegFailure("mix", result, ffmpegProcessTimeout);
736
738
  return {
737
739
  success: false,
738
740
  outputPath,
@@ -785,15 +787,16 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
785
787
  const outputDir = dirname(outputPath);
786
788
  if (!existsSync(outputDir))
787
789
  mkdirSync(outputDir, { recursive: true });
788
- const buildInputFilters = (ignoreKeyframes) => memberTracks.map((track, i) => {
790
+ const buildInputFilters = (ignoreKeyframes, padMembers) => memberTracks.map((track, i) => {
789
791
  const volumeFilter = buildVolumeExpression(track, ignoreKeyframes);
790
- return buildTrackInputFilter(track, i, volumeFilter, totalDuration);
792
+ return buildTrackInputFilter(track, i, volumeFilter, totalDuration, padMembers);
791
793
  });
792
794
  const mixInputs = memberTracks.map((_, i) => `[a${i}]`).join("");
793
795
  const runOnce = async (useNormalize, ignoreKeyframes = false) => {
794
- const inputFilters = buildInputFilters(ignoreKeyframes);
796
+ const inputFilters = buildInputFilters(ignoreKeyframes, !useNormalize);
797
+ const padUnitySumOnceToFilm = `[mixed];[mixed]apad,asetpts=N/SR/TB,atrim=0:${formatFilterNumber(totalDuration)}`;
795
798
  const mixFilter = useNormalize
796
- ? `${mixInputs}amix=inputs=${memberTracks.length}:duration=longest:dropout_transition=0:normalize=0[out]`
799
+ ? `${mixInputs}amix=inputs=${memberTracks.length}:duration=longest:dropout_transition=0:normalize=0${padUnitySumOnceToFilm}[out]`
797
800
  : // Every member is padded and trimmed to totalDuration above, and
798
801
  // dropout_transition=0 keeps all N inputs active for that whole span.
799
802
  // amix's default normalize therefore divides by exactly N throughout;
@@ -868,7 +871,7 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
868
871
  }
869
872
  }
870
873
  if (signal?.aborted || !result.success) {
871
- const failure = ffmpegFailure("mix", result);
874
+ const failure = ffmpegFailure("mix", result, ffmpegProcessTimeout);
872
875
  return { success: false, error: failure.detail, failure };
873
876
  }
874
877
  return { success: true, degradedAutomation };
@@ -893,7 +896,7 @@ async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, tota
893
896
  timeout: config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout,
894
897
  });
895
898
  if (!encoded.success) {
896
- const failure = ffmpegFailure("mix", encoded);
899
+ const failure = ffmpegFailure("mix", encoded, config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout);
897
900
  return {
898
901
  success: false,
899
902
  outputPath,
@@ -1060,8 +1063,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1060
1063
  // envelope belongs on their output. A missing or broken chain is fatal
1061
1064
  // for the whole mix rather than a per-track warning — quietly rendering
1062
1065
  // the dry signal ships a mix that sounds plausible and is wrong.
1066
+ const chain = element.fxChain ? parseAudioFxChain(element.fxChain) : undefined;
1063
1067
  const automation = element.automation
1064
- ? resolveAutomation(parseAutomation(element.automation), element.fxChain ? parseAudioFxChain(element.fxChain) : undefined)
1068
+ ? resolveAutomation(parseAutomation(element.automation), chain)
1065
1069
  : null;
1066
1070
  // Computed before the chain runs, not after: the FX pass bakes the
1067
1071
  // envelope into its float output so the duck lands before the ±1 clamp
@@ -1070,9 +1074,13 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1070
1074
  // A volume lane supersedes keyframes probed from the timeline: the two
1071
1075
  // would fight, and the lane is the explicit one. `lint` warns when a
1072
1076
  // track carries both.
1077
+ //
1078
+ // With clip gain ahead of the chain, the lane carries its share of the capped product.
1079
+ const gainBeforeFx = chain !== undefined && enabledAudioFxNodes(chain).length > 0;
1073
1080
  const laneKeyframes = automation
1074
- ? volumeLaneKeyframes(automation, element.start, element.end - element.start)
1081
+ ? volumeLaneKeyframes(automation, element.start, element.end - element.start, element.volume ?? 1, gainBeforeFx)
1075
1082
  : null;
1083
+ const tweened = !laneKeyframes && (element.volumeKeyframes?.length ?? 0) > 0;
1076
1084
  const envelopeKeyframes = laneKeyframes ?? element.volumeKeyframes;
1077
1085
  const envelope = envelopeKeyframes && envelopeKeyframes.length > 0
1078
1086
  ? {
@@ -1083,11 +1091,10 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1083
1091
  : null;
1084
1092
  let bakedEnvelope = false;
1085
1093
  let tailSeconds = 0;
1086
- if (element.fxChain) {
1094
+ if (chain) {
1087
1095
  // The chain is serialised into the attribute, the same way colour
1088
1096
  // grading carries its config, so there is no side-car file to find,
1089
1097
  // resolve or lose.
1090
- const chain = parseAudioFxChain(element.fxChain);
1091
1098
  // The rendered WAV is longer than the input by exactly this much, so
1092
1099
  // the mix has to be told to let it through.
1093
1100
  tailSeconds = chainTailSeconds(chain, automation ?? undefined);
@@ -1096,6 +1103,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1096
1103
  signal: effectiveSignal,
1097
1104
  ...(automation ? { automation } : {}),
1098
1105
  ...(envelope ? { envelope } : {}),
1106
+ ...(gainBeforeFx ? { inputGain: clipGain(element.volume ?? 1, tweened) } : {}),
1099
1107
  });
1100
1108
  audioSrcPath = fxResult.path;
1101
1109
  bakedEnvelope = fxResult.envelopeBaked;
@@ -1116,7 +1124,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1116
1124
  mediaStart: element.mediaStart,
1117
1125
  duration: element.end - element.start,
1118
1126
  // Gain is already in the samples when baked, so mix at unity.
1119
- volume: bakedEnvelope ? 1.0 : (element.volume ?? 1.0),
1127
+ volume: bakedEnvelope || (gainBeforeFx && !tweened) ? 1.0 : (element.volume ?? 1.0),
1120
1128
  volumeKeyframes: bakedEnvelope ? undefined : (envelopeKeyframes ?? undefined),
1121
1129
  ...(element.fadeIn ? { fadeIn: element.fadeIn } : {}),
1122
1130
  ...(element.fadeOut ? { fadeOut: element.fadeOut } : {}),
@@ -1260,7 +1268,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1260
1268
  const automation = meta.automation
1261
1269
  ? resolveAutomation(parseAutomation(meta.automation), meta.fxChain ? parseAudioFxChain(meta.fxChain) : undefined)
1262
1270
  : null;
1263
- const laneKeyframes = automation ? volumeLaneKeyframes(automation, 0, totalDuration) : null;
1271
+ const laneKeyframes = automation
1272
+ ? volumeLaneKeyframes(automation, 0, totalDuration, meta.volume)
1273
+ : null;
1264
1274
  const envelope = laneKeyframes && laneKeyframes.length > 0
1265
1275
  ? { keyframes: laneKeyframes, trackStart: 0, baseVolume: meta.volume }
1266
1276
  : 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";
@@ -396,7 +397,7 @@ export async function encodeFramesFromDir(framesDir, framePattern, outputPath, o
396
397
  if (options.useGpu) {
397
398
  gpuEncoder = await getCachedGpuEncoder();
398
399
  }
399
- const inputPath = join(framesDir, framePattern);
400
+ const inputPath = frameSequencePath(framesDir, framePattern);
400
401
  const inputArgs = ["-framerate", fpsToFfmpegArg(options.fps), "-i", inputPath];
401
402
  const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
402
403
  const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
@@ -534,7 +535,7 @@ export async function encodeFramesChunkedConcat(framesDir, framePattern, outputP
534
535
  ? ".mov"
535
536
  : ".mp4";
536
537
  const chunkPath = join(chunkDir, `chunk_${String(i).padStart(4, "0")}${ext}`);
537
- const inputPath = join(framesDir, framePattern);
538
+ const inputPath = frameSequencePath(framesDir, framePattern);
538
539
  const inputArgs = [
539
540
  "-framerate",
540
541
  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
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.186",
3
+ "version": "0.8.188",
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.188",
54
+ "@hyperframes/parsers": "^0.8.188"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",