@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 +2 -0
- package/dist/index.js +2 -0
- package/dist/services/aacEncode.js +2 -0
- package/dist/services/audioFxRender.d.ts +1 -0
- package/dist/services/audioFxRender.js +7 -0
- package/dist/services/audioMixer.d.ts +3 -2
- package/dist/services/audioMixer.js +44 -34
- package/dist/services/chunkEncoder.js +3 -2
- package/dist/services/extractedFrameIndex.d.ts +1 -0
- package/dist/services/extractedFrameIndex.js +3 -0
- package/dist/services/mediaRenderIdBridge.d.ts +4 -4
- package/dist/services/mediaRenderIdBridge.js +4 -4
- package/dist/services/screenshotService.js +7 -2
- package/dist/services/videoFrameExtractor.js +2 -2
- package/package.json +3 -3
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";
|
|
@@ -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.
|
|
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 {
|
|
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
|
-
? `${
|
|
356
|
-
:
|
|
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.
|
|
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 =
|
|
381
|
+
const first = points[0];
|
|
382
382
|
if (first.t > 0)
|
|
383
383
|
push(0, first.v);
|
|
384
|
-
for (let i = 0; i <
|
|
385
|
-
const a =
|
|
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 =
|
|
388
|
-
if (!b || !a
|
|
387
|
+
const b = points[i + 1];
|
|
388
|
+
if (!b || !isBentSegment(a))
|
|
389
389
|
continue;
|
|
390
|
-
const steps =
|
|
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 =
|
|
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
|
|
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),
|
|
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 (
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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 =
|
|
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.
|
|
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.
|
|
54
|
-
"@hyperframes/parsers": "^0.8.
|
|
53
|
+
"@hyperframes/core": "^0.8.188",
|
|
54
|
+
"@hyperframes/parsers": "^0.8.188"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|