@hyperframes/engine 0.8.185 → 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 +3 -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/frameCapture.js +13 -0
- package/dist/services/mediaRenderIdBridge.d.ts +4 -4
- package/dist/services/mediaRenderIdBridge.js +4 -4
- package/dist/services/pageMotionBlur.d.ts +12 -0
- package/dist/services/pageMotionBlur.js +66 -0
- package/dist/services/screenshotService.js +7 -2
- package/dist/services/videoFrameExtractor.js +2 -2
- package/dist/types.d.ts +3 -0
- 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,7 +58,9 @@ 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";
|
|
63
|
+
export type { PageMotionBlurOff } from "./types.js";
|
|
61
64
|
export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
|
|
62
65
|
export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
|
|
63
66
|
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, probeMediaProfile, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.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);
|
|
@@ -13,6 +13,7 @@ import { COMPOSITION_SOURCE_URL, quantizeSeekTime, quantizeTimeToFrame, fpsToNum
|
|
|
13
13
|
import { DrawElementCaptureError } from "./drawElementCaptureError.js";
|
|
14
14
|
import { encodePng } from "../utils/alphaBlit.js";
|
|
15
15
|
import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
|
|
16
|
+
import { describePageMotionBlur, describePageMotionBlurAtEnd, installPageMotionBlur, } from "./pageMotionBlur.js";
|
|
16
17
|
// ── Extracted modules ───────────────────────────────────────────────────────
|
|
17
18
|
import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, captureWithoutWaylandDisplay, resolveHeadlessShellPath, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, usesSoftwareWebGpu, } from "./browserManager.js";
|
|
18
19
|
import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
|
|
@@ -946,6 +947,7 @@ async function constructCaptureSession(input) {
|
|
|
946
947
|
}
|
|
947
948
|
}, variablesJson);
|
|
948
949
|
}
|
|
950
|
+
await installPageMotionBlur(page, options);
|
|
949
951
|
const browserVersion = await browser.version();
|
|
950
952
|
const sessionOptions = resolveCaptureSessionOptions(options, browserVersion);
|
|
951
953
|
const expectedMajor = config?.expectedChromiumMajor;
|
|
@@ -2462,6 +2464,7 @@ export async function createStaticVerificationPage(session) {
|
|
|
2462
2464
|
JSON.parse(json);
|
|
2463
2465
|
}, variablesJson);
|
|
2464
2466
|
}
|
|
2467
|
+
await installPageMotionBlur(page, session.options);
|
|
2465
2468
|
await page.setViewport({
|
|
2466
2469
|
width: session.options.width,
|
|
2467
2470
|
height: session.options.height,
|
|
@@ -3192,6 +3195,11 @@ async function captureFrameCore(session, frameIndex, time) {
|
|
|
3192
3195
|
session.capturePerf.screenshotMs += screenshotMs;
|
|
3193
3196
|
session.capturePerf.totalMs += captureTimeMs;
|
|
3194
3197
|
session.capturePerf.frameMs.push(captureTimeMs);
|
|
3198
|
+
if (session.capturePerf.frames === 1) {
|
|
3199
|
+
const blur = await describePageMotionBlur(session.page).catch(() => null);
|
|
3200
|
+
if (blur)
|
|
3201
|
+
console.log(blur);
|
|
3202
|
+
}
|
|
3195
3203
|
// Retain this freshly-captured buffer so the following static frames can reuse it.
|
|
3196
3204
|
if (session.staticFrames) {
|
|
3197
3205
|
session.lastFrameBuffer = buffer;
|
|
@@ -3527,6 +3535,11 @@ export async function closeCaptureSession(session) {
|
|
|
3527
3535
|
// point up to `intervalMs` earlier. Best effort; the page may be gone.
|
|
3528
3536
|
await session.chromeMemory.sampleOnce();
|
|
3529
3537
|
}
|
|
3538
|
+
if (session.page && !session.pageReleased) {
|
|
3539
|
+
const blur = await describePageMotionBlurAtEnd(session.page);
|
|
3540
|
+
if (blur)
|
|
3541
|
+
console.log(blur);
|
|
3542
|
+
}
|
|
3530
3543
|
// Realized static-dedup telemetry: how much the cache actually helped this
|
|
3531
3544
|
// render (vs the prediction logged at arm time). Both capture paths
|
|
3532
3545
|
// (sequential orchestrator + parallel workers) close their session here, so
|
|
@@ -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`
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tells the page's capture motion blur (`data-motion-blur="on"`) to stand down, before any of
|
|
3
|
+
* its scripts run: when the engine blurs (it would average frames that are already blurred) or
|
|
4
|
+
* when the render must match a Studio preview that could not blur (`pageMotionBlur: "off"`).
|
|
5
|
+
*/
|
|
6
|
+
import type { Page } from "puppeteer-core";
|
|
7
|
+
import type { CaptureOptions } from "../types.js";
|
|
8
|
+
export declare function installPageMotionBlur(page: Pick<Page, "evaluateOnNewDocument">, options: Pick<CaptureOptions, "motionBlur" | "pageMotionBlur" | "deviceScaleFactor">): Promise<void>;
|
|
9
|
+
/** One line saying what the page's blur did for this session; null when the page never asked for blur. */
|
|
10
|
+
export declare function describePageMotionBlur(page: Pick<Page, "evaluate">): Promise<string | null>;
|
|
11
|
+
/** The session's closing line, only when frames fell back to plain DOM: the first-frame line cannot know. */
|
|
12
|
+
export declare function describePageMotionBlurAtEnd(page: Pick<Page, "evaluate">, timeoutMs?: number): Promise<string | null>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tells the page's capture motion blur (`data-motion-blur="on"`) to stand down, before any of
|
|
3
|
+
* its scripts run: when the engine blurs (it would average frames that are already blurred) or
|
|
4
|
+
* when the render must match a Studio preview that could not blur (`pageMotionBlur: "off"`).
|
|
5
|
+
*/
|
|
6
|
+
import { PAGE_DEVICE_SCALE_GLOBAL, PAGE_MOTION_BLUR_OFF_GLOBAL, acceleratedCanvasTrackerSource, } from "@hyperframes/core";
|
|
7
|
+
function pageOffReason(options) {
|
|
8
|
+
if (options.motionBlur)
|
|
9
|
+
return "engine-motion-blur";
|
|
10
|
+
if (options.pageMotionBlur === "off")
|
|
11
|
+
return "preview-unsupported";
|
|
12
|
+
return options.pageMotionBlur ?? null;
|
|
13
|
+
}
|
|
14
|
+
export async function installPageMotionBlur(page, options) {
|
|
15
|
+
await page.evaluateOnNewDocument(acceleratedCanvasTrackerSource());
|
|
16
|
+
const scale = options.deviceScaleFactor ?? 1;
|
|
17
|
+
if (scale > 1) {
|
|
18
|
+
await page.evaluateOnNewDocument((key, v) => {
|
|
19
|
+
window[key] = v;
|
|
20
|
+
}, PAGE_DEVICE_SCALE_GLOBAL, scale);
|
|
21
|
+
}
|
|
22
|
+
const off = pageOffReason(options);
|
|
23
|
+
if (!off)
|
|
24
|
+
return;
|
|
25
|
+
await page.evaluateOnNewDocument((key, v) => {
|
|
26
|
+
window[key] = v;
|
|
27
|
+
}, PAGE_MOTION_BLUR_OFF_GLOBAL, off);
|
|
28
|
+
}
|
|
29
|
+
async function readPageMotionBlur(page) {
|
|
30
|
+
return page.evaluate(() => {
|
|
31
|
+
const api = Reflect.get(window, "__hfMotionBlur");
|
|
32
|
+
const report = api && typeof api === "object" ? Reflect.get(api, "report") : null;
|
|
33
|
+
const got = typeof report === "function" ? report() : null;
|
|
34
|
+
if (!got || typeof got !== "object")
|
|
35
|
+
return null;
|
|
36
|
+
return {
|
|
37
|
+
state: String(Reflect.get(got, "state")),
|
|
38
|
+
code: String(Reflect.get(got, "reasonCode")),
|
|
39
|
+
movers: Number(Reflect.get(got, "movers")),
|
|
40
|
+
failedFrames: Number(Reflect.get(got, "failedFrames")) || 0,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const formatPageMotionBlur = (r) => `[motion-blur] ${r.state} reasonCode=${r.code} movers=${r.movers} failedFrames=${r.failedFrames}`;
|
|
45
|
+
/** One line saying what the page's blur did for this session; null when the page never asked for blur. */
|
|
46
|
+
export async function describePageMotionBlur(page) {
|
|
47
|
+
const r = await readPageMotionBlur(page);
|
|
48
|
+
return r && formatPageMotionBlur(r);
|
|
49
|
+
}
|
|
50
|
+
/** The session's closing line, only when frames fell back to plain DOM: the first-frame line cannot know. */
|
|
51
|
+
export async function describePageMotionBlurAtEnd(page, timeoutMs = 1500) {
|
|
52
|
+
let timer;
|
|
53
|
+
const stuck = new Promise((resolve) => {
|
|
54
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
const r = await Promise.race([readPageMotionBlur(page), stuck]);
|
|
58
|
+
return r && r.failedFrames > 0 ? `${formatPageMotionBlur(r)} (session end)` : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -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/dist/types.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { AudioFailureDiagnostic } from "./services/audioMixer.types.js";
|
|
|
8
8
|
import type { Fps } from "@hyperframes/core";
|
|
9
9
|
import type { ChromeMemoryStats } from "./services/chromeMemorySampler.js";
|
|
10
10
|
import type { MotionBlurOptions } from "./services/motionBlur.js";
|
|
11
|
+
export type PageMotionBlurOff = "off" | "hdr-render" | "layered-composite" | "distributed-render";
|
|
11
12
|
/**
|
|
12
13
|
* Outcome of waiting for a sub-composition's GSAP timelines to register.
|
|
13
14
|
* Threaded string-typed through `CapturePerfSummary` / `RenderPerfSummary` /
|
|
@@ -151,6 +152,8 @@ export interface CaptureOptions {
|
|
|
151
152
|
* screenshot capture mode; see `services/motionBlur.ts`.
|
|
152
153
|
*/
|
|
153
154
|
motionBlur?: MotionBlurOptions;
|
|
155
|
+
/** Why the page's capture motion blur stands down: the preview could not blur, or the route cannot host it. */
|
|
156
|
+
pageMotionBlur?: PageMotionBlurOff;
|
|
154
157
|
deviceScaleFactor?: number;
|
|
155
158
|
/**
|
|
156
159
|
* Opt into Chrome's capture-beyond-viewport screenshot path. Leave undefined
|
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",
|