@hyperframes/engine 0.8.221 → 0.8.222
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 -1
- package/dist/index.js +2 -1
- package/dist/services/streamingEncoder.d.ts +2 -0
- package/dist/services/streamingEncoder.js +4 -1
- package/dist/services/videoFrameExtractor.d.ts +4 -0
- package/dist/services/videoFrameExtractor.js +35 -1
- package/dist/utils/networkPolicy.d.ts +2 -0
- package/dist/utils/networkPolicy.js +53 -0
- package/dist/utils/urlDownloader.d.ts +0 -2
- package/dist/utils/urlDownloader.js +1 -52
- package/package.json +8 -4
package/dist/index.d.ts
CHANGED
|
@@ -69,7 +69,8 @@ export { isBrowserSafePort, listenOnBrowserSafePort, settleOnBrowserSafePort, }
|
|
|
69
69
|
export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
|
|
70
70
|
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, runFfprobe, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
|
|
71
71
|
export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
|
|
72
|
-
export { _setDownloadFetchForTests, assertPublicHttpsUrl,
|
|
72
|
+
export { _setDownloadFetchForTests, assertPublicHttpsUrl, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, type SafeDownloadUrlIdentity, type UrlDownloadOptions, type UrlDownloadTelemetry, type PublicHttpsTextOptions, } from "./utils/urlDownloader.js";
|
|
73
|
+
export { isBlockedNetworkHost } from "./utils/networkPolicy.js";
|
|
73
74
|
export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, type RunFfmpegOptions, type RunFfmpegResult, } from "./utils/runFfmpeg.js";
|
|
74
75
|
export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
75
76
|
export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChildProcessOutcome, type ManagedProcessTerminationReason, } from "./utils/managedChildProcess.js";
|
package/dist/index.js
CHANGED
|
@@ -73,7 +73,8 @@ export { isBrowserSafePort, listenOnBrowserSafePort, settleOnBrowserSafePort, }
|
|
|
73
73
|
export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
|
|
74
74
|
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, runFfprobe, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
|
|
75
75
|
export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
|
|
76
|
-
export { _setDownloadFetchForTests, assertPublicHttpsUrl,
|
|
76
|
+
export { _setDownloadFetchForTests, assertPublicHttpsUrl, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, } from "./utils/urlDownloader.js";
|
|
77
|
+
export { isBlockedNetworkHost } from "./utils/networkPolicy.js";
|
|
77
78
|
export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, } from "./utils/runFfmpeg.js";
|
|
78
79
|
export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
79
80
|
export { ManagedChildProcess, } from "./utils/managedChildProcess.js";
|
|
@@ -68,6 +68,8 @@ export interface StreamingEncoderResult {
|
|
|
68
68
|
error?: string;
|
|
69
69
|
/** Stable machine-readable cause for failures safe to retry on a fresh host. */
|
|
70
70
|
failureReason?: "external_interruption";
|
|
71
|
+
/** Total time `writeFrame` waited for FFmpeg to drain its stdin pipe. */
|
|
72
|
+
drainWaitMs?: number;
|
|
71
73
|
}
|
|
72
74
|
export interface StreamingEncoder {
|
|
73
75
|
/**
|
|
@@ -376,6 +376,7 @@ export async function spawnStreamingEncoder(outputPath, options, signal, config)
|
|
|
376
376
|
}
|
|
377
377
|
};
|
|
378
378
|
let inputFrameIndex = 0;
|
|
379
|
+
let drainWaitMs = 0;
|
|
379
380
|
const encoder = {
|
|
380
381
|
writeFrame: async (buffer) => {
|
|
381
382
|
const stdin = ffmpeg.stdin;
|
|
@@ -417,7 +418,9 @@ export async function spawnStreamingEncoder(outputPath, options, signal, config)
|
|
|
417
418
|
managed.markActivity();
|
|
418
419
|
return true;
|
|
419
420
|
}
|
|
421
|
+
const drainStart = Date.now();
|
|
420
422
|
const drainResult = await waitForDrainOrExit(stdin);
|
|
423
|
+
drainWaitMs += Date.now() - drainStart;
|
|
421
424
|
if (drainResult !== "drain" || exitStatus !== "running") {
|
|
422
425
|
return false;
|
|
423
426
|
}
|
|
@@ -476,7 +479,7 @@ export async function spawnStreamingEncoder(outputPath, options, signal, config)
|
|
|
476
479
|
};
|
|
477
480
|
}
|
|
478
481
|
const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
|
|
479
|
-
return { success: true, durationMs, fileSize };
|
|
482
|
+
return { success: true, durationMs, fileSize, drainWaitMs };
|
|
480
483
|
},
|
|
481
484
|
getExitStatus: () => exitStatus,
|
|
482
485
|
getExitError: () => {
|
|
@@ -111,6 +111,10 @@ export interface ExtractionOptions {
|
|
|
111
111
|
collectProbeFailures?: boolean;
|
|
112
112
|
/** Extract only these runs of output frames, named as a full extraction names them. CFR only. */
|
|
113
113
|
frameRanges?: readonly FrameRange[];
|
|
114
|
+
onProgress?: (progress: {
|
|
115
|
+
planned?: number;
|
|
116
|
+
done?: number;
|
|
117
|
+
}) => void;
|
|
114
118
|
/** A short decode of a remote source, only to count its frames: it waits on the network, so it runs wider. */
|
|
115
119
|
tailProbe?: boolean;
|
|
116
120
|
/**
|
|
@@ -22,7 +22,7 @@ import { inputAlphaOpaqueWarning, probeInputAlphaPlane } from "../utils/alphaPla
|
|
|
22
22
|
import { redactUrlQueriesInError, urlInputArgs } from "../utils/urlInput.js";
|
|
23
23
|
import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
|
|
24
24
|
import { downloadToTemp, isHttpUrl, safeDownloadUrlIdentity, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js";
|
|
25
|
-
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
25
|
+
import { ffmpegStatsReader, runFfmpeg } from "../utils/runFfmpeg.js";
|
|
26
26
|
import { isFfmpegFilterAvailable, isFfmpegHwaccelAvailable, } from "../utils/psnrFilterAvailability.js";
|
|
27
27
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
28
28
|
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
|
@@ -537,6 +537,18 @@ const EXTRACTION_SLOTS = Math.max(1, Math.floor(cpus().length / 2));
|
|
|
537
537
|
const inExtractionSlot = createConcurrencyLimit(EXTRACTION_SLOTS);
|
|
538
538
|
const TAIL_PROBE_SLOTS = Math.max(1, Number(process.env.HF_TAIL_PROBE_SLOTS) || 16);
|
|
539
539
|
const inTailProbeSlot = createConcurrencyLimit(TAIL_PROBE_SLOTS);
|
|
540
|
+
function framesWrittenReader(onProgress) {
|
|
541
|
+
if (!onProgress)
|
|
542
|
+
return undefined;
|
|
543
|
+
let seen = 0;
|
|
544
|
+
const read = ffmpegStatsReader(({ frames }) => {
|
|
545
|
+
if (frames === undefined || frames <= seen)
|
|
546
|
+
return;
|
|
547
|
+
onProgress({ done: frames - seen });
|
|
548
|
+
seen = frames;
|
|
549
|
+
});
|
|
550
|
+
return (chunk) => read(chunk);
|
|
551
|
+
}
|
|
540
552
|
/** Split runs so no single ffmpeg process extracts more than `segmentFrames` frames. */
|
|
541
553
|
function splitFrameRanges(ranges, segmentFrames) {
|
|
542
554
|
const segments = [];
|
|
@@ -583,6 +595,7 @@ async function runSegmentedExtraction(job) {
|
|
|
583
595
|
], {
|
|
584
596
|
...job.runOptions,
|
|
585
597
|
signal,
|
|
598
|
+
onStderr: framesWrittenReader(job.onProgress),
|
|
586
599
|
timeout: stallTimeoutMs(job.runOptions.timeout, await leadInSecondsUnlessAborted(videoPath, seek, signal), sourceFps),
|
|
587
600
|
}), signal);
|
|
588
601
|
results[index] = result;
|
|
@@ -763,13 +776,20 @@ outputDirOverride) {
|
|
|
763
776
|
gate: options.tailProbe ? inTailProbeSlot : inExtractionSlot,
|
|
764
777
|
segments: splitFrameRanges(options.frameRanges ?? [{ firstFrame: 0, frames: totalFrames }], segmentFrames),
|
|
765
778
|
runOptions,
|
|
779
|
+
onProgress: options.onProgress,
|
|
766
780
|
})
|
|
767
781
|
: inExtractionSlot(async () => runFfmpeg([...hwaccel, ...args, ...filterArgsFor(startTime), ...encodeArgs], {
|
|
768
782
|
...runOptions,
|
|
783
|
+
onStderr: framesWrittenReader(options.onProgress),
|
|
769
784
|
timeout: stallTimeoutMs(ffmpegProcessTimeout, options.finalFrameOnly
|
|
770
785
|
? startTime
|
|
771
786
|
: await leadInSecondsUnlessAborted(videoPath, startTime, signal), metadata.fps),
|
|
772
787
|
}), signal);
|
|
788
|
+
options.onProgress?.({
|
|
789
|
+
planned: options.frameRanges
|
|
790
|
+
? options.frameRanges.reduce((sum, range) => sum + range.frames, 0)
|
|
791
|
+
: totalFrames,
|
|
792
|
+
});
|
|
773
793
|
let processResult = await extract(decodeWithVideoToolbox ? ["-hwaccel", "videotoolbox"] : []);
|
|
774
794
|
const ffmpegExitedFailing = processResult.terminationReason === "exit" &&
|
|
775
795
|
!processResult.success &&
|
|
@@ -885,6 +905,19 @@ const FINAL_FRAME_LOGICAL_DURATION_SECONDS = 1e-6;
|
|
|
885
905
|
export function isVideoPastTimelineEnd(video, timelineEnd) {
|
|
886
906
|
return video.start >= timelineEnd;
|
|
887
907
|
}
|
|
908
|
+
function alignClipStartToFirstOutputFrame(video, fps) {
|
|
909
|
+
if (video.loop || !Number.isFinite(video.start) || video.start < 0 || !(fps > 0))
|
|
910
|
+
return;
|
|
911
|
+
const position = exportClipWindow(video.start, Infinity, fps).start * fps;
|
|
912
|
+
const firstFrame = Math.abs(position - Math.round(position)) < 1e-6 ? Math.round(position) : Math.ceil(position);
|
|
913
|
+
const shift = firstFrame / fps - video.start;
|
|
914
|
+
if (shift === 0)
|
|
915
|
+
return;
|
|
916
|
+
const playbackRate = normalizeRateSpec(video.playbackRate);
|
|
917
|
+
video.mediaStart += sourceTimeAt(playbackRate, shift);
|
|
918
|
+
video.playbackRate = shiftRateLane(playbackRate, shift);
|
|
919
|
+
video.start += shift;
|
|
920
|
+
}
|
|
888
921
|
/** Move a clip to its extraction window; a rate lane is shifted so it keeps integrating from the new origin. */
|
|
889
922
|
export function rebaseVideoToWindow(video, window) {
|
|
890
923
|
if (window.preserveTimelinePhase)
|
|
@@ -1732,6 +1765,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
1732
1765
|
}
|
|
1733
1766
|
try {
|
|
1734
1767
|
const metadata = videoMetadata[index] ?? (await extractMediaMetadata(videoPath));
|
|
1768
|
+
alignClipStartToFirstOutputFrame(video, fps);
|
|
1735
1769
|
const initialWindow = resolveVideoExtractionWindow(video, metadata, options.timelineEnd);
|
|
1736
1770
|
const window = await resolveFinalFrameExtractionWindow(videoPath, video, metadata, initialWindow, signal);
|
|
1737
1771
|
const videoDuration = window.durationSeconds;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { BlockList, isIP } from "node:net";
|
|
2
|
+
// The one list of special-use networks: asset downloads and the capture proxy both read it, so a site one can reach the other can too.
|
|
3
|
+
const NON_PUBLIC_IPV4_ADDRESSES = new BlockList();
|
|
4
|
+
for (const [network, prefix] of [
|
|
5
|
+
["0.0.0.0", 8],
|
|
6
|
+
["10.0.0.0", 8],
|
|
7
|
+
["100.64.0.0", 10],
|
|
8
|
+
["127.0.0.0", 8],
|
|
9
|
+
["169.254.0.0", 16],
|
|
10
|
+
["172.16.0.0", 12],
|
|
11
|
+
["192.0.0.0", 24],
|
|
12
|
+
["192.0.2.0", 24],
|
|
13
|
+
["192.88.99.0", 24],
|
|
14
|
+
["192.168.0.0", 16],
|
|
15
|
+
["198.18.0.0", 15],
|
|
16
|
+
["198.51.100.0", 24],
|
|
17
|
+
["203.0.113.0", 24],
|
|
18
|
+
["224.0.0.0", 4],
|
|
19
|
+
["240.0.0.0", 4],
|
|
20
|
+
]) {
|
|
21
|
+
NON_PUBLIC_IPV4_ADDRESSES.addSubnet(network, prefix, "ipv4");
|
|
22
|
+
}
|
|
23
|
+
const NON_PUBLIC_IPV6_ADDRESSES = new BlockList();
|
|
24
|
+
for (const [network, prefix] of [
|
|
25
|
+
["::", 128],
|
|
26
|
+
["::1", 128],
|
|
27
|
+
["::ffff:0:0", 96],
|
|
28
|
+
["fc00::", 7],
|
|
29
|
+
["fe80::", 10],
|
|
30
|
+
["fec0::", 10],
|
|
31
|
+
["ff00::", 8],
|
|
32
|
+
["2001:db8::", 32],
|
|
33
|
+
]) {
|
|
34
|
+
NON_PUBLIC_IPV6_ADDRESSES.addSubnet(network, prefix, "ipv6");
|
|
35
|
+
}
|
|
36
|
+
/** Literal-host policy shared by rendering and capture; DNS resolution remains trusted. */
|
|
37
|
+
export function isBlockedNetworkHost(hostname) {
|
|
38
|
+
const h = hostname
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/\.$/, "")
|
|
41
|
+
.replace(/^\[|\]$/g, "");
|
|
42
|
+
if (h === "localhost" ||
|
|
43
|
+
h.endsWith(".localhost") ||
|
|
44
|
+
h.endsWith(".local") ||
|
|
45
|
+
h.endsWith(".internal"))
|
|
46
|
+
return true;
|
|
47
|
+
const addressType = isIP(h);
|
|
48
|
+
if (addressType === 0)
|
|
49
|
+
return false;
|
|
50
|
+
return addressType === 4
|
|
51
|
+
? NON_PUBLIC_IPV4_ADDRESSES.check(h, "ipv4")
|
|
52
|
+
: NON_PUBLIC_IPV6_ADDRESSES.check(h, "ipv6");
|
|
53
|
+
}
|
|
@@ -46,8 +46,6 @@ export declare class UrlDownloadError extends Error {
|
|
|
46
46
|
/** A bounded in-call refetch can be safe even when upstream retry is not. */
|
|
47
47
|
locallyRetryable?: boolean);
|
|
48
48
|
}
|
|
49
|
-
/** Literal-host policy shared by rendering and capture; DNS resolution remains trusted. */
|
|
50
|
-
export declare function isBlockedNetworkHost(hostname: string): boolean;
|
|
51
49
|
/**
|
|
52
50
|
* Validate that a URL is safe to fetch on behalf of customer-supplied
|
|
53
51
|
* compositions. Throws if the URL is non-HTTPS or targets a private/reserved
|
|
@@ -2,7 +2,7 @@ import { closeSync, createReadStream, createWriteStream, existsSync, fsyncSync,
|
|
|
2
2
|
import { createHash, randomUUID } from "crypto";
|
|
3
3
|
import dns from "node:dns";
|
|
4
4
|
import { fetchGoesThroughEnvProxy } from "@hyperframes/core/env-proxy";
|
|
5
|
-
import {
|
|
5
|
+
import { isBlockedNetworkHost } from "./networkPolicy.js";
|
|
6
6
|
import { dirname, extname, join } from "path";
|
|
7
7
|
import { Readable, Transform } from "stream";
|
|
8
8
|
import { pipeline } from "stream/promises";
|
|
@@ -111,57 +111,6 @@ function isRetryableNetworkCause(error) {
|
|
|
111
111
|
code.startsWith("UND_ERR_") ||
|
|
112
112
|
/fetch failed|network|socket|connection reset|terminated/i.test(message));
|
|
113
113
|
}
|
|
114
|
-
const NON_PUBLIC_IPV4_ADDRESSES = new BlockList();
|
|
115
|
-
for (const [network, prefix] of [
|
|
116
|
-
["0.0.0.0", 8],
|
|
117
|
-
["10.0.0.0", 8],
|
|
118
|
-
["100.64.0.0", 10],
|
|
119
|
-
["127.0.0.0", 8],
|
|
120
|
-
["169.254.0.0", 16],
|
|
121
|
-
["172.16.0.0", 12],
|
|
122
|
-
["192.0.0.0", 24],
|
|
123
|
-
["192.0.2.0", 24],
|
|
124
|
-
["192.88.99.0", 24],
|
|
125
|
-
["192.168.0.0", 16],
|
|
126
|
-
["198.18.0.0", 15],
|
|
127
|
-
["198.51.100.0", 24],
|
|
128
|
-
["203.0.113.0", 24],
|
|
129
|
-
["224.0.0.0", 4],
|
|
130
|
-
["240.0.0.0", 4],
|
|
131
|
-
]) {
|
|
132
|
-
NON_PUBLIC_IPV4_ADDRESSES.addSubnet(network, prefix, "ipv4");
|
|
133
|
-
}
|
|
134
|
-
const NON_PUBLIC_IPV6_ADDRESSES = new BlockList();
|
|
135
|
-
for (const [network, prefix] of [
|
|
136
|
-
["::", 128],
|
|
137
|
-
["::1", 128],
|
|
138
|
-
["::ffff:0:0", 96],
|
|
139
|
-
["fc00::", 7],
|
|
140
|
-
["fe80::", 10],
|
|
141
|
-
["fec0::", 10],
|
|
142
|
-
["ff00::", 8],
|
|
143
|
-
["2001:db8::", 32],
|
|
144
|
-
]) {
|
|
145
|
-
NON_PUBLIC_IPV6_ADDRESSES.addSubnet(network, prefix, "ipv6");
|
|
146
|
-
}
|
|
147
|
-
/** Literal-host policy shared by rendering and capture; DNS resolution remains trusted. */
|
|
148
|
-
export function isBlockedNetworkHost(hostname) {
|
|
149
|
-
const h = hostname
|
|
150
|
-
.toLowerCase()
|
|
151
|
-
.replace(/\.$/, "")
|
|
152
|
-
.replace(/^\[|\]$/g, "");
|
|
153
|
-
if (h === "localhost" ||
|
|
154
|
-
h.endsWith(".localhost") ||
|
|
155
|
-
h.endsWith(".local") ||
|
|
156
|
-
h.endsWith(".internal"))
|
|
157
|
-
return true;
|
|
158
|
-
const addressType = isIP(h);
|
|
159
|
-
if (addressType === 0)
|
|
160
|
-
return false;
|
|
161
|
-
return addressType === 4
|
|
162
|
-
? NON_PUBLIC_IPV4_ADDRESSES.check(h, "ipv4")
|
|
163
|
-
: NON_PUBLIC_IPV6_ADDRESSES.check(h, "ipv6");
|
|
164
|
-
}
|
|
165
114
|
/**
|
|
166
115
|
* Validate that a URL is safe to fetch on behalf of customer-supplied
|
|
167
116
|
* compositions. Throws if the URL is non-HTTPS or targets a private/reserved
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.222",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,7 +43,11 @@
|
|
|
43
43
|
"import": "./dist/utils/shaderTransitions.js",
|
|
44
44
|
"types": "./dist/utils/shaderTransitions.d.ts"
|
|
45
45
|
},
|
|
46
|
-
"./package.json": "./package.json"
|
|
46
|
+
"./package.json": "./package.json",
|
|
47
|
+
"./network-policy": {
|
|
48
|
+
"import": "./dist/utils/networkPolicy.js",
|
|
49
|
+
"types": "./dist/utils/networkPolicy.d.ts"
|
|
50
|
+
}
|
|
47
51
|
},
|
|
48
52
|
"publishConfig": {
|
|
49
53
|
"access": "public"
|
|
@@ -55,8 +59,8 @@
|
|
|
55
59
|
"puppeteer": "~25.11.0",
|
|
56
60
|
"puppeteer-core": "^25.11.0",
|
|
57
61
|
"undici": "7.25.0",
|
|
58
|
-
"@hyperframes/core": "^0.8.
|
|
59
|
-
"@hyperframes/parsers": "^0.8.
|
|
62
|
+
"@hyperframes/core": "^0.8.222",
|
|
63
|
+
"@hyperframes/parsers": "^0.8.222"
|
|
60
64
|
},
|
|
61
65
|
"devDependencies": {
|
|
62
66
|
"@types/node": "^25.0.10",
|