@hyperframes/engine 0.9.4 → 0.9.6
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/config.d.ts +1 -0
- package/dist/config.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/services/chunkStreamingEncoder.d.ts +16 -0
- package/dist/services/chunkStreamingEncoder.js +30 -0
- package/dist/services/frameCapture.js +8 -4
- package/dist/services/inPageEncodeEligibility.d.ts +19 -0
- package/dist/services/inPageEncodeEligibility.js +15 -0
- package/dist/services/referenceBackground.d.ts +34 -0
- package/dist/services/referenceBackground.js +175 -0
- package/dist/services/screenshotService.d.ts +1 -0
- package/dist/services/screenshotService.js +25 -6
- package/dist/services/streamingEncoder.d.ts +5 -0
- package/dist/services/streamingEncoder.js +18 -8
- package/dist/types.d.ts +4 -0
- package/package.json +3 -3
package/dist/config.d.ts
CHANGED
|
@@ -74,6 +74,7 @@ export interface EngineConfig {
|
|
|
74
74
|
* true. Kill switch: `HF_DE_WORKER_ENCODE=false`.
|
|
75
75
|
*/
|
|
76
76
|
enableDrawElementWorkerEncode: boolean;
|
|
77
|
+
useInPageEncode: boolean;
|
|
77
78
|
/**
|
|
78
79
|
* INTERNAL. Set by resolveConfig when it disabled enablePageSideCompositing
|
|
79
80
|
* solely because drawElement was on. Lets the producer's compile-time gates
|
package/dist/config.js
CHANGED
|
@@ -32,6 +32,7 @@ export const DEFAULT_CONFIG = {
|
|
|
32
32
|
videoLayerPassthrough: false,
|
|
33
33
|
useDrawElement: true,
|
|
34
34
|
enableDrawElementWorkerEncode: true,
|
|
35
|
+
useInPageEncode: false,
|
|
35
36
|
// Auto-detected per host in `resolveConfig`; defaults off for the raw
|
|
36
37
|
// DEFAULT_CONFIG (used directly by tests and worker-sizing fallbacks).
|
|
37
38
|
lowMemoryMode: false,
|
|
@@ -76,6 +77,7 @@ const BOOLEAN_ENGINE_CONFIG_FIELDS = [
|
|
|
76
77
|
"videoLayerPassthrough",
|
|
77
78
|
"useDrawElement",
|
|
78
79
|
"enableDrawElementWorkerEncode",
|
|
80
|
+
"useInPageEncode",
|
|
79
81
|
"lowMemoryMode",
|
|
80
82
|
"enablePageSideCompositing",
|
|
81
83
|
"enableChunkedEncode",
|
|
@@ -517,6 +519,7 @@ export function resolveConfig(overrides) {
|
|
|
517
519
|
videoLayerPassthrough: envBool("HF_VIDEO_LAYER_PASSTHROUGH", DEFAULT_CONFIG.videoLayerPassthrough),
|
|
518
520
|
useDrawElement: envBool("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", DEFAULT_CONFIG.useDrawElement),
|
|
519
521
|
enableDrawElementWorkerEncode: envBool("HF_DE_WORKER_ENCODE", DEFAULT_CONFIG.enableDrawElementWorkerEncode),
|
|
522
|
+
useInPageEncode: envBool("HF_INPAGE_ENCODE", DEFAULT_CONFIG.useInPageEncode),
|
|
520
523
|
lowMemoryMode: resolveLowMemoryMode(),
|
|
521
524
|
enablePageSideCompositing: envBool("HF_PAGE_SIDE_COMPOSITING", DEFAULT_CONFIG.enablePageSideCompositing),
|
|
522
525
|
vp9CpuUsed: envVp9CpuUsed(),
|
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* getFrameAtTime, detectGpuEncoder) return a nullable value instead of throwing.
|
|
30
30
|
*
|
|
31
31
|
*/
|
|
32
|
+
export { REFERENCE_BACKGROUND_FIELD, referenceBackgroundResult, createReferenceBackgroundRecorder, readReferenceBackground, type ReferenceBackground, type ReferenceBackgroundResult, } from "./services/referenceBackground.js";
|
|
32
33
|
export type { HfProtocol, HfMediaElement, HfTransitionMeta, CaptureOptions, CaptureVideoMetadataHint, CaptureResult, CaptureBufferResult, CapturePerfSummary, CaptureWarning, CaptureWarningCode, SubTimelineWaitMemo, SubTimelineWaitOutcome, } from "./types.js";
|
|
33
34
|
export { resolveConfig, resolvePageNavigationTimeout, validateEngineConfigSnapshot, DEFAULT_CONFIG, scaleProtocolTimeoutForComposition, shouldClampToScreenshotForConcreteGpu, applyConcreteGpuScreenshotClamp, explainDrawElementDisabled, resolveExtractCacheDir, defaultExtractCacheDir, EXTRACT_CACHE_DIR_DISABLED_ALIASES, type EngineConfig, type ExtractCacheDirResolution, } from "./config.js";
|
|
34
35
|
export { SDR_RGB_TO_BT709_FILTER, SDR_RGB_TO_TAGGED_BT709_FILTER, } from "./utils/sdrCaptureColor.js";
|
|
@@ -47,6 +48,8 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
|
|
|
47
48
|
export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, getCachedGpuEncoder, ENCODER_PRESETS, getEncoderPreset, PRORES_PROFILES, PRORES_PROFILE_NAMES, isProresProfile, type ProresProfile, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
|
|
48
49
|
export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
|
|
49
50
|
export { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, type StreamingEncoderOptions, type StreamingEncoderResult, type FrameReorderBuffer, } from "./services/streamingEncoder.js";
|
|
51
|
+
export { spawnChunkStreamingEncoder, buildChunkStreamingArgs, BT709_LIMITED, type ChunkColour, type ChunkStreamingOptions, } from "./services/chunkStreamingEncoder.js";
|
|
52
|
+
export { resolveInPageEncode, type InPageEncodeDecision, type InPageEncodeInput, } from "./services/inPageEncodeEligibility.js";
|
|
50
53
|
export { parseVideoElements, hasGradedCanvas, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, videoStartOnMediaTimeline, ffmpegSeekSeconds, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, type VideoElement, type ImageElement, type ExtractedFrames, type DeferredFrameRange, type FrameRange, type ExtractionOptions, type ExtractionResult, type ExtractionPhaseBreakdown, type TimelineExtractionWindow, type VideoExtractionFailure, type VideoExtractionFailureKind, type VideoExtractionFailureGroupDetails, type VideoExtractionFailureRetry, type VideoExtractionFailureStatusClass, type SafeVideoExtractionSourceIdentity, type VideoFrameFormat, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, sdrCanvasColourFilters, buildVideoLayerInput, type VideoLayerInput, } from "./services/videoFrameExtractor.js";
|
|
51
54
|
export { qualifyVideoLayer } from "./services/videoLayerPassthrough.js";
|
|
52
55
|
export { isSelfOrAncestorHidden } from "./services/mediaHidden.js";
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* getFrameAtTime, detectGpuEncoder) return a nullable value instead of throwing.
|
|
30
30
|
*
|
|
31
31
|
*/
|
|
32
|
+
export { REFERENCE_BACKGROUND_FIELD, referenceBackgroundResult, createReferenceBackgroundRecorder, readReferenceBackground, } from "./services/referenceBackground.js";
|
|
32
33
|
// ── Configuration ──────────────────────────────────────────────────────────────
|
|
33
34
|
export { resolveConfig, resolvePageNavigationTimeout, validateEngineConfigSnapshot, DEFAULT_CONFIG, scaleProtocolTimeoutForComposition, shouldClampToScreenshotForConcreteGpu, applyConcreteGpuScreenshotClamp, explainDrawElementDisabled, resolveExtractCacheDir, defaultExtractCacheDir, EXTRACT_CACHE_DIR_DISABLED_ALIASES, } from "./config.js";
|
|
34
35
|
export { SDR_RGB_TO_BT709_FILTER, SDR_RGB_TO_TAGGED_BT709_FILTER, } from "./utils/sdrCaptureColor.js";
|
|
@@ -50,6 +51,8 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
|
|
|
50
51
|
// ── Encoding ───────────────────────────────────────────────────────────────────
|
|
51
52
|
export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, getCachedGpuEncoder, ENCODER_PRESETS, getEncoderPreset, PRORES_PROFILES, PRORES_PROFILE_NAMES, isProresProfile, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
|
|
52
53
|
export { spawnStreamingEncoder, createFrameReorderBuffer, } from "./services/streamingEncoder.js";
|
|
54
|
+
export { spawnChunkStreamingEncoder, buildChunkStreamingArgs, BT709_LIMITED, } from "./services/chunkStreamingEncoder.js";
|
|
55
|
+
export { resolveInPageEncode, } from "./services/inPageEncodeEligibility.js";
|
|
53
56
|
// ── Media processing ───────────────────────────────────────────────────────────
|
|
54
57
|
export { parseVideoElements, hasGradedCanvas, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, videoStartOnMediaTimeline, ffmpegSeekSeconds, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, sdrCanvasColourFilters, buildVideoLayerInput, } from "./services/videoFrameExtractor.js";
|
|
55
58
|
export { qualifyVideoLayer } from "./services/videoLayerPassthrough.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Fps } from "@hyperframes/core";
|
|
2
|
+
import type { EngineConfig } from "../config.js";
|
|
3
|
+
import { type StreamingEncoder } from "./streamingEncoder.js";
|
|
4
|
+
export interface ChunkColour {
|
|
5
|
+
primaries: number;
|
|
6
|
+
transfer: number;
|
|
7
|
+
matrix: number;
|
|
8
|
+
fullRange: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare const BT709_LIMITED: ChunkColour;
|
|
11
|
+
export interface ChunkStreamingOptions {
|
|
12
|
+
fps: Fps;
|
|
13
|
+
colour?: ChunkColour;
|
|
14
|
+
}
|
|
15
|
+
export declare function buildChunkStreamingArgs(options: ChunkStreamingOptions, outputPath: string): string[];
|
|
16
|
+
export declare function spawnChunkStreamingEncoder(outputPath: string, options: ChunkStreamingOptions, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>): StreamingEncoder;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { fpsToFfmpegArg } from "@hyperframes/core";
|
|
2
|
+
import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
|
|
3
|
+
import { spawnFfmpegStream } from "./streamingEncoder.js";
|
|
4
|
+
export const BT709_LIMITED = {
|
|
5
|
+
primaries: 1,
|
|
6
|
+
transfer: 1,
|
|
7
|
+
matrix: 1,
|
|
8
|
+
fullRange: false,
|
|
9
|
+
};
|
|
10
|
+
export function buildChunkStreamingArgs(options, outputPath) {
|
|
11
|
+
const { primaries, transfer, matrix, fullRange } = options.colour ?? BT709_LIMITED;
|
|
12
|
+
const vui = [
|
|
13
|
+
`colour_primaries=${primaries}`,
|
|
14
|
+
`transfer_characteristics=${transfer}`,
|
|
15
|
+
`matrix_coefficients=${matrix}`,
|
|
16
|
+
`video_full_range_flag=${fullRange ? 1 : 0}`,
|
|
17
|
+
].join(":");
|
|
18
|
+
const args = ["-f", "h264", "-framerate", fpsToFfmpegArg(options.fps), "-i", "-"];
|
|
19
|
+
args.push("-c:v", "copy", "-bsf:v", `h264_metadata=${vui}`);
|
|
20
|
+
args.push(...["-colorspace:v", "bt709", "-color_primaries:v", "bt709", "-color_trc:v", "bt709"], ...["-color_range", fullRange ? "pc" : "tv"], ...["-video_track_timescale", "90000", "-avoid_negative_ts", "make_zero"]);
|
|
21
|
+
appendRenderProvenanceArgs(args, outputPath);
|
|
22
|
+
args.push("-y", outputPath);
|
|
23
|
+
return args;
|
|
24
|
+
}
|
|
25
|
+
export function spawnChunkStreamingEncoder(outputPath, options, signal, config) {
|
|
26
|
+
return spawnFfmpegStream(outputPath, buildChunkStreamingArgs(options, outputPath), {
|
|
27
|
+
signal,
|
|
28
|
+
config,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -10,15 +10,15 @@
|
|
|
10
10
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
import { COMPOSITION_SOURCE_URL, MEDIA_RENDER_ID_ATTR, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, AUTHORED_SOURCE_BEGIN, AUTHORED_SOURCE_END, RUNTIME_BOOTSTRAP_ATTR, SCENE_CALLBACK_ERROR_PREFIX, } from "@hyperframes/core";
|
|
13
|
+
import { readReferenceBackground } from "./referenceBackground.js";
|
|
13
14
|
import { holdRenderFrameVideos } from "./renderFrameVideoHold.js";
|
|
14
|
-
import { recordWebglContexts, releaseEndedWebglContexts } from "./webglRelease.js";
|
|
15
15
|
import { asDrawElementCaptureFailure, DrawElementCaptureError } from "./drawElementCaptureError.js";
|
|
16
16
|
import { encodePng } from "../utils/alphaBlit.js";
|
|
17
17
|
import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
|
|
18
18
|
import { describePageMotionBlur, describePageMotionBlurAtEnd, installPageMotionBlur, } from "./pageMotionBlur.js";
|
|
19
19
|
// ── Extracted modules ───────────────────────────────────────────────────────
|
|
20
20
|
import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, captureWithoutWaylandDisplay, resolveHeadlessShellPath, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, usesSoftwareWebGpu, } from "./browserManager.js";
|
|
21
|
-
import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, capturesAlpha, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
|
|
21
|
+
import { beginFrameCapture, forgetBeginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, capturesAlpha, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
|
|
22
22
|
import { classifyGpuRenderer, detectGpuBackend, injectDrawElementCanvas, captureDrawElementFrame, resolveDrawElementCaptureMode, forcePreservedDrawingBuffer, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, DE_CANVAS_NOT_INITIALIZED_CODE, } from "./drawElementService.js";
|
|
23
23
|
import { initThreeDProjection, scanCssEffects } from "./threeDProjection.js";
|
|
24
24
|
import { planRiskFrames, scanCssEffectLiveFrames, riskSessionFits, withFramesAfterCallback, MAX_RISK_FRAME_FRACTION, } from "./riskFrames.js";
|
|
@@ -448,6 +448,7 @@ async function waitForCloseWithTimeout(promise) {
|
|
|
448
448
|
* path (preMode already forces "screenshot" for DPR other than 1).
|
|
449
449
|
*/
|
|
450
450
|
async function initDrawElementOrTransparentBackground(session, page, logInitPhase) {
|
|
451
|
+
await session.options.recordReferenceBackground?.(() => readReferenceBackground(page));
|
|
451
452
|
const supersampling = (session.options.deviceScaleFactor ?? 1) !== 1;
|
|
452
453
|
// forceScreenshot is an explicit routing decision made upstream (render-mode
|
|
453
454
|
// compat hints like raw requestAnimationFrame, alpha formats, low-memory) —
|
|
@@ -1005,7 +1006,6 @@ async function constructCaptureSession(input) {
|
|
|
1005
1006
|
}
|
|
1006
1007
|
});
|
|
1007
1008
|
await installMediaPageHooks(page, options);
|
|
1008
|
-
await page.evaluateOnNewDocument(recordWebglContexts);
|
|
1009
1009
|
// Fast capture: record accelerated canvases (webgl/webgl2/webgpu) and force
|
|
1010
1010
|
// preserveDrawingBuffer before any page script can create a context — their
|
|
1011
1011
|
// paint records freeze at the first frame, so captureDrawElementFrame
|
|
@@ -1726,6 +1726,10 @@ export function recordSubTimelineWarning(session, timeoutMs) {
|
|
|
1726
1726
|
},
|
|
1727
1727
|
},
|
|
1728
1728
|
]);
|
|
1729
|
+
if (scriptFailure) {
|
|
1730
|
+
const failed = session.warnings.filter((w) => w.code === "sub_timeline_script_failure");
|
|
1731
|
+
session.options?.onSubTimelineScriptFailure?.(failed);
|
|
1732
|
+
}
|
|
1729
1733
|
}
|
|
1730
1734
|
/**
|
|
1731
1735
|
* Runtime-mounted map-viewport markers, keyed by the CSS selector each map
|
|
@@ -2347,7 +2351,6 @@ async function prepareFrameForCapture(session, frameIndex, time, seekOptions, he
|
|
|
2347
2351
|
const seekStart = Date.now();
|
|
2348
2352
|
session.captureStep = "seeking the timeline";
|
|
2349
2353
|
const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);
|
|
2350
|
-
await releaseEndedWebglContexts(page, quantizedTime);
|
|
2351
2354
|
session.captureStep = "decoding CSS background images";
|
|
2352
2355
|
await decodeDynamicCssBackgroundImages(page);
|
|
2353
2356
|
const seekMs = Date.now() - seekStart;
|
|
@@ -3296,6 +3299,7 @@ async function captureFrameSurface(session, frameIndex, time, seekOptions, heldV
|
|
|
3296
3299
|
interval: session.beginFrameIntervalMs,
|
|
3297
3300
|
noDisplayUpdates: false,
|
|
3298
3301
|
});
|
|
3302
|
+
forgetBeginFrameCapture(page);
|
|
3299
3303
|
session.beginFrameSeekPresented = true;
|
|
3300
3304
|
}
|
|
3301
3305
|
const result = await beginFrameCapture(page, options, frameTimeTicks, session.beginFrameIntervalMs);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface InPageEncodeInput {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
captureMode: "drawelement" | "screenshot" | "beginframe";
|
|
4
|
+
format: string;
|
|
5
|
+
codec: string;
|
|
6
|
+
hdr: boolean;
|
|
7
|
+
transparent: boolean;
|
|
8
|
+
workers: number;
|
|
9
|
+
hls: boolean;
|
|
10
|
+
videoLayer: boolean;
|
|
11
|
+
encoderSupported: boolean;
|
|
12
|
+
}
|
|
13
|
+
export type InPageEncodeDecision = {
|
|
14
|
+
eligible: true;
|
|
15
|
+
} | {
|
|
16
|
+
eligible: false;
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function resolveInPageEncode(input: InPageEncodeInput): InPageEncodeDecision;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function resolveInPageEncode(input) {
|
|
2
|
+
const blocker = [
|
|
3
|
+
[!input.enabled, "disabled"],
|
|
4
|
+
[input.captureMode !== "drawelement", "capture is not drawElement"],
|
|
5
|
+
[input.format !== "mp4", "output is not mp4"],
|
|
6
|
+
[input.codec !== "h264", "codec is not h264"],
|
|
7
|
+
[input.hdr, "hdr"],
|
|
8
|
+
[input.transparent, "transparent"],
|
|
9
|
+
[input.workers !== 1, "parallel workers"],
|
|
10
|
+
[input.hls, "hls"],
|
|
11
|
+
[input.videoLayer, "video layer passthrough"],
|
|
12
|
+
[!input.encoderSupported, "page encoder unsupported"],
|
|
13
|
+
].find(([blocked]) => blocked);
|
|
14
|
+
return blocker ? { eligible: false, reason: blocker[1] } : { eligible: true };
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Page } from "puppeteer-core";
|
|
2
|
+
export declare const REFERENCE_BACKGROUND_FIELD = "reference_background";
|
|
3
|
+
export type ReferenceBackground = {
|
|
4
|
+
kind: "solid";
|
|
5
|
+
rgb: [number, number, number];
|
|
6
|
+
} | {
|
|
7
|
+
kind: "none";
|
|
8
|
+
} | {
|
|
9
|
+
kind: "unsupported_non_solid";
|
|
10
|
+
} | {
|
|
11
|
+
kind: "unknown";
|
|
12
|
+
};
|
|
13
|
+
export type ReferenceBackgroundResult = {
|
|
14
|
+
[REFERENCE_BACKGROUND_FIELD]: ReferenceBackground;
|
|
15
|
+
};
|
|
16
|
+
export declare function referenceBackgroundResult(value: unknown): ReferenceBackgroundResult;
|
|
17
|
+
/** Shared by a render's existing sessions; concurrent workers reserve the first read, not a chunk frame. */
|
|
18
|
+
export declare function createReferenceBackgroundRecorder(record: (result: ReferenceBackgroundResult) => void): (read: () => Promise<ReferenceBackground>) => Promise<void>;
|
|
19
|
+
export interface BackgroundLayer {
|
|
20
|
+
rgba: readonly number[];
|
|
21
|
+
image: string;
|
|
22
|
+
opacity: string;
|
|
23
|
+
filter: string;
|
|
24
|
+
blend: string;
|
|
25
|
+
varying: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface BackgroundObservation {
|
|
28
|
+
layers: readonly BackgroundLayer[];
|
|
29
|
+
explicitNone: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare function classifyReferenceBackground(observation: BackgroundObservation | null): ReferenceBackground;
|
|
32
|
+
/** Runs in the browser, before transparent capture rewrites the author's root/body styles. */
|
|
33
|
+
export declare function observeReferenceBackground(): Promise<BackgroundObservation | null>;
|
|
34
|
+
export declare function readReferenceBackground(page: Pick<Page, "evaluate">): Promise<ReferenceBackground>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
export const REFERENCE_BACKGROUND_FIELD = "reference_background";
|
|
2
|
+
function isByte(value) {
|
|
3
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255;
|
|
4
|
+
}
|
|
5
|
+
function readRgb(value) {
|
|
6
|
+
if (!Array.isArray(value) || value.length !== 3)
|
|
7
|
+
return null;
|
|
8
|
+
const [r, g, b] = value;
|
|
9
|
+
return isByte(r) && isByte(g) && isByte(b) ? [r, g, b] : null;
|
|
10
|
+
}
|
|
11
|
+
export function referenceBackgroundResult(value) {
|
|
12
|
+
if (value !== null && typeof value === "object" && "kind" in value) {
|
|
13
|
+
if (value.kind === "solid" && "rgb" in value) {
|
|
14
|
+
const rgb = readRgb(value.rgb);
|
|
15
|
+
if (rgb)
|
|
16
|
+
return { [REFERENCE_BACKGROUND_FIELD]: { kind: "solid", rgb } };
|
|
17
|
+
}
|
|
18
|
+
if (value.kind === "none" || value.kind === "unsupported_non_solid") {
|
|
19
|
+
return { [REFERENCE_BACKGROUND_FIELD]: { kind: value.kind } };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { [REFERENCE_BACKGROUND_FIELD]: { kind: "unknown" } };
|
|
23
|
+
}
|
|
24
|
+
/** Shared by a render's existing sessions; concurrent workers reserve the first read, not a chunk frame. */
|
|
25
|
+
export function createReferenceBackgroundRecorder(record) {
|
|
26
|
+
let pending;
|
|
27
|
+
return (read) => {
|
|
28
|
+
pending ??= read()
|
|
29
|
+
.catch(() => ({ kind: "unknown" }))
|
|
30
|
+
.then((value) => record(referenceBackgroundResult(value)));
|
|
31
|
+
return pending;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function layerIsIndeterminate(layer) {
|
|
35
|
+
return (layer.varying || layer.opacity !== "1" || layer.filter !== "none" || layer.blend !== "normal");
|
|
36
|
+
}
|
|
37
|
+
function validLayer(layer) {
|
|
38
|
+
return layer.rgba.length === 4 && layer.rgba.every(isByte) && layer.image.length > 0;
|
|
39
|
+
}
|
|
40
|
+
function opaqueReference(rgb, indeterminate) {
|
|
41
|
+
return indeterminate ? { kind: "unknown" } : { kind: "solid", rgb };
|
|
42
|
+
}
|
|
43
|
+
export function classifyReferenceBackground(observation) {
|
|
44
|
+
if (!observation || observation.layers.length === 0)
|
|
45
|
+
return { kind: "unknown" };
|
|
46
|
+
const indeterminate = observation.layers.some(layerIsIndeterminate);
|
|
47
|
+
let translucent = false;
|
|
48
|
+
for (const layer of observation.layers) {
|
|
49
|
+
if (!validLayer(layer))
|
|
50
|
+
return { kind: "unknown" };
|
|
51
|
+
if (layer.image !== "none")
|
|
52
|
+
return { kind: "unsupported_non_solid" };
|
|
53
|
+
const rgb = layer.rgba[3] === 255 ? readRgb(layer.rgba.slice(0, 3)) : null;
|
|
54
|
+
if (rgb)
|
|
55
|
+
return opaqueReference(rgb, translucent || indeterminate);
|
|
56
|
+
translucent ||= layer.rgba[3] !== 0;
|
|
57
|
+
}
|
|
58
|
+
return !indeterminate && !translucent && observation.explicitNone
|
|
59
|
+
? { kind: "none" }
|
|
60
|
+
: { kind: "unknown" };
|
|
61
|
+
}
|
|
62
|
+
/** Runs in the browser, before transparent capture rewrites the author's root/body styles. */
|
|
63
|
+
export async function observeReferenceBackground() {
|
|
64
|
+
const root = document.querySelector("[data-composition-id]");
|
|
65
|
+
if (!root || !document.body)
|
|
66
|
+
return null;
|
|
67
|
+
// Inspect the initial film state, never the worker's eventual frame range.
|
|
68
|
+
window.__hf?.seek?.(0);
|
|
69
|
+
if ("__hfWaitForSeekCompletion" in window &&
|
|
70
|
+
typeof window.__hfWaitForSeekCompletion === "function") {
|
|
71
|
+
await window.__hfWaitForSeekCompletion();
|
|
72
|
+
}
|
|
73
|
+
const elements = [];
|
|
74
|
+
for (let element = root; element; element = element.parentElement)
|
|
75
|
+
elements.push(element);
|
|
76
|
+
const canvas = document.createElement("canvas");
|
|
77
|
+
canvas.width = canvas.height = 1;
|
|
78
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
79
|
+
if (!context)
|
|
80
|
+
return null;
|
|
81
|
+
const layers = elements.map((element) => {
|
|
82
|
+
const style = getComputedStyle(element);
|
|
83
|
+
context.clearRect(0, 0, 1, 1);
|
|
84
|
+
if (!CSS.supports("color", style.backgroundColor))
|
|
85
|
+
return { rgba: [], image: "", opacity: "", filter: "", blend: "", varying: false };
|
|
86
|
+
// Chromium resolves CSS color syntax and color spaces; this is a swatch, not film pixels.
|
|
87
|
+
context.fillStyle = style.backgroundColor;
|
|
88
|
+
context.fillRect(0, 0, 1, 1);
|
|
89
|
+
return {
|
|
90
|
+
rgba: Array.from(context.getImageData(0, 0, 1, 1).data),
|
|
91
|
+
image: style.backgroundImage,
|
|
92
|
+
opacity: style.opacity,
|
|
93
|
+
filter: style.filter,
|
|
94
|
+
blend: style.mixBlendMode,
|
|
95
|
+
varying: false,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
return { layers, explicitNone: false };
|
|
99
|
+
}
|
|
100
|
+
function hasChangingCssBackground() {
|
|
101
|
+
const root = document.querySelector("[data-composition-id]");
|
|
102
|
+
if (!root)
|
|
103
|
+
return true;
|
|
104
|
+
const properties = [
|
|
105
|
+
"background",
|
|
106
|
+
"backgroundColor",
|
|
107
|
+
"backgroundImage",
|
|
108
|
+
"opacity",
|
|
109
|
+
"filter",
|
|
110
|
+
"mixBlendMode",
|
|
111
|
+
];
|
|
112
|
+
return document.getAnimations().some((animation) => {
|
|
113
|
+
const effect = animation.effect;
|
|
114
|
+
if (!(effect instanceof KeyframeEffect) || !(effect.target instanceof Element))
|
|
115
|
+
return false;
|
|
116
|
+
return (effect.target.contains(root) &&
|
|
117
|
+
effect.getKeyframes().some((frame) => properties.some((key) => key in frame)));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function hasChangingTimelineBackground() {
|
|
121
|
+
const root = document.querySelector("[data-composition-id]");
|
|
122
|
+
const timelines = "__timelines" in window ? window.__timelines : undefined;
|
|
123
|
+
if (!root || !timelines || typeof timelines !== "object")
|
|
124
|
+
return false;
|
|
125
|
+
const children = Object.values(timelines).flatMap((timeline) => typeof timeline?.getChildren === "function" ? timeline.getChildren(true, true, false) : []);
|
|
126
|
+
return children.some((tween) => {
|
|
127
|
+
const vars = { ...tween?.vars, ...tween?.vars?.css, ...tween?.vars?.startAt };
|
|
128
|
+
const targets = typeof tween?.targets === "function" ? tween.targets() : [];
|
|
129
|
+
const changesPaint = Object.keys(vars).some((key) => key.startsWith("background") ||
|
|
130
|
+
["opacity", "filter", "autoAlpha", "mixBlendMode"].includes(key));
|
|
131
|
+
return (changesPaint &&
|
|
132
|
+
targets.some((target) => target instanceof Element && target.contains(root)));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function hasExplicitNoBackground() {
|
|
136
|
+
const root = document.querySelector("[data-composition-id]");
|
|
137
|
+
const elements = [];
|
|
138
|
+
for (let element = root; element; element = element.parentElement)
|
|
139
|
+
elements.push(element);
|
|
140
|
+
const declarations = elements.flatMap((element) => element instanceof HTMLElement || element instanceof SVGElement ? [element.style] : []);
|
|
141
|
+
const sheets = [...document.styleSheets, ...document.adoptedStyleSheets].filter((sheet) => !sheet.disabled);
|
|
142
|
+
for (const rule of sheets.flatMap((sheet) => Array.from(sheet.cssRules))) {
|
|
143
|
+
if (rule instanceof CSSFontFaceRule || rule instanceof CSSKeyframesRule)
|
|
144
|
+
continue;
|
|
145
|
+
// Proving absence needs complete cascade participation. Unhandled grouping/nesting stays unknown.
|
|
146
|
+
if (!(rule instanceof CSSStyleRule) || rule.cssRules.length > 0)
|
|
147
|
+
return false;
|
|
148
|
+
if (elements.some((element) => element.matches(rule.selectorText)))
|
|
149
|
+
declarations.push(rule.style);
|
|
150
|
+
}
|
|
151
|
+
const backgrounds = declarations.filter((style) => style.getPropertyValue("background-color") || style.getPropertyValue("background-image"));
|
|
152
|
+
return (backgrounds.length > 0 &&
|
|
153
|
+
backgrounds.every((style) => style.getPropertyValue("background").trim() === "none"));
|
|
154
|
+
}
|
|
155
|
+
export async function readReferenceBackground(page) {
|
|
156
|
+
try {
|
|
157
|
+
const observation = await page.evaluate(observeReferenceBackground);
|
|
158
|
+
const initial = classifyReferenceBackground(observation);
|
|
159
|
+
if (!observation || initial.kind === "unsupported_non_solid")
|
|
160
|
+
return initial;
|
|
161
|
+
const changes = await Promise.all([
|
|
162
|
+
page.evaluate(hasChangingCssBackground),
|
|
163
|
+
page.evaluate(hasChangingTimelineBackground),
|
|
164
|
+
]);
|
|
165
|
+
if (changes.some(Boolean))
|
|
166
|
+
return { kind: "unknown" };
|
|
167
|
+
if (initial.kind === "solid")
|
|
168
|
+
return initial;
|
|
169
|
+
observation.explicitNone = await page.evaluate(hasExplicitNoBackground);
|
|
170
|
+
return classifyReferenceBackground(observation);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return { kind: "unknown" };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -32,6 +32,7 @@ export interface BeginFrameResult {
|
|
|
32
32
|
* the safe direction (screenshot capture always works).
|
|
33
33
|
*/
|
|
34
34
|
export declare function probeBeginFrameLiveness(page: Page, timeoutMs: number, frameTimeTicks?: number, intervalMs?: number): Promise<boolean>;
|
|
35
|
+
export declare function forgetBeginFrameCapture(page: Page): void;
|
|
35
36
|
export declare function beginFrameCapture(page: Page, options: CaptureOptions, frameTimeTicks: number, interval: number): Promise<BeginFrameResult>;
|
|
36
37
|
/**
|
|
37
38
|
* True if the page's actual rendered content is taller than the requested
|
|
@@ -81,11 +81,14 @@ frameTimeTicks, intervalMs) {
|
|
|
81
81
|
* Requires chrome-headless-shell with --enable-begin-frame-control and
|
|
82
82
|
* --deterministic-mode flags.
|
|
83
83
|
*/
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
84
|
+
// The last buffer beginFrameCapture returned per page. A screenshot BeginFrame always
|
|
85
|
+
// reports damage, so a frame is reused only when a screenshot-less BeginFrame right after
|
|
86
|
+
// the seek reports none. Any BeginFrame sent elsewhere moves the display past this buffer:
|
|
87
|
+
// call forgetBeginFrameCapture first.
|
|
88
88
|
const lastFrameCache = new WeakMap();
|
|
89
|
+
export function forgetBeginFrameCapture(page) {
|
|
90
|
+
lastFrameCache.delete(page);
|
|
91
|
+
}
|
|
89
92
|
const PENDING_FRAME_RETRIES = 5;
|
|
90
93
|
async function sendBeginFrame(client, params) {
|
|
91
94
|
for (let attempt = 0;; attempt++) {
|
|
@@ -115,14 +118,30 @@ export async function beginFrameCapture(page, options, frameTimeTicks, interval)
|
|
|
115
118
|
quality: isPng ? undefined : (options.quality ?? 80),
|
|
116
119
|
optimizeForSpeed: true,
|
|
117
120
|
};
|
|
118
|
-
|
|
121
|
+
// With a cached buffer, a screenshot-less BeginFrame first draws the seek and reports
|
|
122
|
+
// whether it changed anything. The screenshot after it is not displayed: a displayed
|
|
123
|
+
// screenshot frame leaves damage the next probe would report, and a settle frame to
|
|
124
|
+
// absorb it would draw page changes that nothing captures.
|
|
125
|
+
const cached = lastFrameCache.get(page);
|
|
126
|
+
let screenshotTicks = frameTimeTicks;
|
|
127
|
+
if (cached) {
|
|
128
|
+
const probe = await sendBeginFrame(client, { frameTimeTicks, interval });
|
|
129
|
+
if (!probe.hasDamage)
|
|
130
|
+
return { buffer: cached, hasDamage: false };
|
|
131
|
+
screenshotTicks = frameTimeTicks + interval / 2;
|
|
132
|
+
}
|
|
133
|
+
const result = await sendBeginFrame(client, {
|
|
134
|
+
frameTimeTicks: screenshotTicks,
|
|
135
|
+
interval,
|
|
136
|
+
screenshot,
|
|
137
|
+
noDisplayUpdates: cached !== undefined,
|
|
138
|
+
});
|
|
119
139
|
let buffer;
|
|
120
140
|
if (result.screenshotData) {
|
|
121
141
|
buffer = Buffer.from(result.screenshotData, "base64");
|
|
122
142
|
lastFrameCache.set(page, buffer);
|
|
123
143
|
}
|
|
124
144
|
else {
|
|
125
|
-
const cached = lastFrameCache.get(page);
|
|
126
145
|
if (cached) {
|
|
127
146
|
buffer = cached;
|
|
128
147
|
}
|
|
@@ -109,3 +109,8 @@ export declare function buildStreamingArgs(options: StreamingEncoderOptions, out
|
|
|
109
109
|
* Spawn a streaming FFmpeg encoder that accepts frame buffers on stdin.
|
|
110
110
|
*/
|
|
111
111
|
export declare function spawnStreamingEncoder(outputPath: string, options: StreamingEncoderOptions, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>): Promise<StreamingEncoder>;
|
|
112
|
+
export declare function spawnFfmpegStream(outputPath: string, args: string[], { signal, config, checkFrame, }: {
|
|
113
|
+
signal?: AbortSignal;
|
|
114
|
+
config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>;
|
|
115
|
+
checkFrame?: (frame: Buffer, index: number) => void;
|
|
116
|
+
}): StreamingEncoder;
|
|
@@ -302,15 +302,29 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null, nvenc
|
|
|
302
302
|
* Spawn a streaming FFmpeg encoder that accepts frame buffers on stdin.
|
|
303
303
|
*/
|
|
304
304
|
export async function spawnStreamingEncoder(outputPath, options, signal, config) {
|
|
305
|
-
const outputDir = dirname(outputPath);
|
|
306
|
-
if (!existsSync(outputDir))
|
|
307
|
-
mkdirSync(outputDir, { recursive: true });
|
|
308
305
|
let gpuEncoder = null;
|
|
309
306
|
if (options.useGpu) {
|
|
310
307
|
gpuEncoder = await getCachedGpuEncoder();
|
|
311
308
|
}
|
|
312
309
|
const nvencPresets = gpuEncoder === "nvenc" ? await getCachedNvencPresets() : undefined;
|
|
313
310
|
const args = buildStreamingArgs(options, outputPath, gpuEncoder, nvencPresets);
|
|
311
|
+
const checksJpeg = !options.rawInputFormat && (options.imageFormat ?? "jpeg") === "jpeg";
|
|
312
|
+
return spawnFfmpegStream(outputPath, args, {
|
|
313
|
+
signal,
|
|
314
|
+
config,
|
|
315
|
+
checkFrame: checksJpeg
|
|
316
|
+
? (frame, index) => {
|
|
317
|
+
const error = jpegInputError(frame);
|
|
318
|
+
if (error)
|
|
319
|
+
throw new Error(`Invalid JPEG input at frame ${index}: ${error}`);
|
|
320
|
+
}
|
|
321
|
+
: undefined,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
export function spawnFfmpegStream(outputPath, args, { signal, config, checkFrame, }) {
|
|
325
|
+
const outputDir = dirname(outputPath);
|
|
326
|
+
if (!existsSync(outputDir))
|
|
327
|
+
mkdirSync(outputDir, { recursive: true });
|
|
314
328
|
const ffmpeg = spawn(getFfmpegBinary(), args, {
|
|
315
329
|
stdio: ["pipe", "pipe", "pipe"],
|
|
316
330
|
// See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
|
|
@@ -412,11 +426,7 @@ export async function spawnStreamingEncoder(outputPath, options, signal, config)
|
|
|
412
426
|
// so without this copy the pipe would read partially-overwritten data
|
|
413
427
|
// and flicker.
|
|
414
428
|
const copy = Buffer.from(buffer);
|
|
415
|
-
|
|
416
|
-
const error = jpegInputError(copy);
|
|
417
|
-
if (error)
|
|
418
|
-
throw new Error(`Invalid JPEG input at frame ${inputFrameIndex}: ${error}`);
|
|
419
|
-
}
|
|
429
|
+
checkFrame?.(copy, inputFrameIndex);
|
|
420
430
|
const accepted = stdin.write(copy);
|
|
421
431
|
inputFrameIndex++;
|
|
422
432
|
// Reset inactivity timer immediately ONLY on `accepted === true`. `true`
|
package/dist/types.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* The engine's page contract. Any web page that wants to be rendered
|
|
5
5
|
* as video must expose `window.__hf` implementing the HfProtocol interface.
|
|
6
6
|
*/
|
|
7
|
+
import type { ReferenceBackground } from "./services/referenceBackground.js";
|
|
7
8
|
import type { AudioFailureDiagnostic } from "./services/audioMixer.types.js";
|
|
8
9
|
import type { Fps } from "@hyperframes/core";
|
|
9
10
|
import type { CompositionLengthSource } from "@hyperframes/core/runtime/composition-length";
|
|
@@ -129,8 +130,11 @@ export interface HfProtocol {
|
|
|
129
130
|
} | null;
|
|
130
131
|
}
|
|
131
132
|
export interface CaptureOptions {
|
|
133
|
+
/** Shared once-only reader, invoked before capture changes root/body paint. */
|
|
134
|
+
recordReferenceBackground?: (read: () => Promise<ReferenceBackground>) => Promise<void>;
|
|
132
135
|
width: number;
|
|
133
136
|
height: number;
|
|
137
|
+
onSubTimelineScriptFailure?: (warnings: readonly CaptureWarning[]) => void;
|
|
134
138
|
/**
|
|
135
139
|
* Producer-resolved composition duration (seconds) — the data-duration
|
|
136
140
|
* clamp actually rendered, which can differ from the page's raw
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"puppeteer": "~25.11.0",
|
|
60
60
|
"puppeteer-core": "^25.11.0",
|
|
61
61
|
"undici": "7.25.0",
|
|
62
|
-
"@hyperframes/core": "^0.9.
|
|
63
|
-
"@hyperframes/parsers": "^0.9.
|
|
62
|
+
"@hyperframes/core": "^0.9.6",
|
|
63
|
+
"@hyperframes/parsers": "^0.9.6"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@types/node": "^25.0.10",
|