@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 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, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, type SafeDownloadUrlIdentity, type UrlDownloadOptions, type UrlDownloadTelemetry, type PublicHttpsTextOptions, } from "./utils/urlDownloader.js";
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, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, } from "./utils/urlDownloader.js";
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,2 @@
1
+ /** Literal-host policy shared by rendering and capture; DNS resolution remains trusted. */
2
+ export declare function isBlockedNetworkHost(hostname: string): boolean;
@@ -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 { BlockList, isIP } from "node:net";
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.221",
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.221",
59
- "@hyperframes/parsers": "^0.8.221"
62
+ "@hyperframes/core": "^0.8.222",
63
+ "@hyperframes/parsers": "^0.8.222"
60
64
  },
61
65
  "devDependencies": {
62
66
  "@types/node": "^25.0.10",