@hyperframes/engine 0.9.15 → 0.9.16

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
@@ -72,7 +72,7 @@ export type { PageMotionBlurOff } from "./types.js";
72
72
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
73
73
  export { isBrowserSafePort, listenOnBrowserSafePort, settleOnBrowserSafePort, } from "./utils/browserSafePort.js";
74
74
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
75
- 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";
75
+ export { extractMediaMetadata, displaySize, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, runFfprobe, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
76
76
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
77
77
  export { _setDownloadFetchForTests, assertPublicHttpsUrl, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, type SafeDownloadUrlIdentity, type UrlDownloadOptions, type UrlDownloadTelemetry, type PublicHttpsTextOptions, } from "./utils/urlDownloader.js";
78
78
  export { isBlockedNetworkHost } from "./utils/networkPolicy.js";
package/dist/index.js CHANGED
@@ -76,7 +76,7 @@ export { frameSequencePath } from "./services/extractedFrameIndex.js";
76
76
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
77
77
  export { isBrowserSafePort, listenOnBrowserSafePort, settleOnBrowserSafePort, } from "./utils/browserSafePort.js";
78
78
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
79
- export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, runFfprobe, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
79
+ export { extractMediaMetadata, displaySize, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, runFfprobe, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
80
80
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
81
81
  export { _setDownloadFetchForTests, assertPublicHttpsUrl, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, } from "./utils/urlDownloader.js";
82
82
  export { isBlockedNetworkHost } from "./utils/networkPolicy.js";
@@ -27,6 +27,7 @@ const TRANSIENT_BROWSER_ERROR_PATTERNS = [
27
27
  /ECONNREFUSED/i,
28
28
  /net::ERR_NETWORK_CHANGED/i,
29
29
  /Composition has zero duration[\s\S]*Runtime ready: false/,
30
+ /Composition setup did not finish/,
30
31
  // CDP can refuse a capture call outright with this exact wording; timed-out
31
32
  // variants of the same call hit PROTOCOL_TIMEOUT_PATTERNS, checked first.
32
33
  // Anchored to this literal reason (not just the CDP method) so an unrelated,
@@ -427,7 +427,7 @@ export interface HfDiagnostic {
427
427
  pendingBuildReadyKeys: string[];
428
428
  rejectedBuildReadyKeys: string[];
429
429
  }
430
- export declare function buildZeroDurationDiagnostic(diag: HfDiagnostic): string;
430
+ export declare function buildZeroDurationDiagnostic(diag: HfDiagnostic, timeoutMs?: number): string;
431
431
  export declare function evaluateHfDiagnostic(page: Page): Promise<HfDiagnostic>;
432
432
  export declare function pollHfReady(page: Page, timeoutMs: number, intervalMs?: number): Promise<void>;
433
433
  export declare function waitForSubCompositionTimelines(session: CaptureSession, page: Page, timeoutMs: number, options?: {
@@ -1217,12 +1217,16 @@ export const HF_READY_DIAGNOSTIC_EXPR = `(async function() {
1217
1217
  };
1218
1218
  })()`;
1219
1219
  // fallow-ignore-next-line complexity
1220
- export function buildZeroDurationDiagnostic(diag) {
1220
+ export function buildZeroDurationDiagnostic(diag, timeoutMs) {
1221
1221
  const hints = [];
1222
1222
  if (diag.pendingBuildReadyKeys.length > 0) {
1223
1223
  hints.push(`window.__hf.buildReady never resolved for: ${diag.pendingBuildReadyKeys.join(", ")}. ` +
1224
1224
  "The runtime holds render-ready until every registered buildReady promise settles. " +
1225
- "Find where the composition registers that key and confirm it actually resolves.");
1225
+ "Find where the composition registers that key and confirm it actually resolves. " +
1226
+ "During a render, Date and performance.now stay at 0 and requestAnimationFrame " +
1227
+ "callbacks wait until the first frame, so setup that awaits an animation frame or " +
1228
+ "the clock never finishes. Await the library's own readiness promise instead (for " +
1229
+ "Three.js, renderer.compileAsync).");
1226
1230
  }
1227
1231
  if (diag.rejectedBuildReadyKeys.length > 0) {
1228
1232
  hints.push(`window.__hf.buildReady rejected for: ${diag.rejectedBuildReadyKeys.join(", ")}. ` +
@@ -1231,7 +1235,7 @@ export function buildZeroDurationDiagnostic(diag) {
1231
1235
  if (!diag.hasPlayer) {
1232
1236
  hints.push("window.__player was never set: the HyperFrames runtime did not initialize.");
1233
1237
  }
1234
- if (!diag.hasTimeline) {
1238
+ if (diag.renderReady && !diag.hasTimeline) {
1235
1239
  hints.push("No GSAP timeline registered (window.__timelines is empty). " +
1236
1240
  "CSS/WAAPI/Lottie animations are usually auto-detected (the runtime infers " +
1237
1241
  "duration from the longest running animation), but this composition's duration " +
@@ -1245,7 +1249,11 @@ export function buildZeroDurationDiagnostic(diag) {
1245
1249
  if (diag.hasSeek && diag.duration === 0 && diag.renderReady) {
1246
1250
  hints.push("The runtime finished initializing but reported zero duration. This is permanent.");
1247
1251
  }
1248
- return (`[FrameCapture] Composition has zero duration.\n` +
1252
+ const within = timeoutMs === undefined ? "" : ` within ${timeoutMs}ms`;
1253
+ const headline = diag.renderReady
1254
+ ? "Composition has zero duration."
1255
+ : `Composition setup did not finish${within}.`;
1256
+ return (`[FrameCapture] ${headline}\n` +
1249
1257
  ` Runtime ready: ${diag.renderReady}, __player: ${diag.hasPlayer}, ` +
1250
1258
  `__hf.seek: ${diag.hasSeek}, GSAP timeline: ${diag.hasTimeline}, ` +
1251
1259
  `data-duration: ${diag.declaredDuration > 0 ? diag.declaredDuration + "s" : "not set"}\n` +
@@ -1301,7 +1309,7 @@ export async function pollHfReady(page, timeoutMs, intervalMs = 100) {
1301
1309
  }
1302
1310
  const diag = await evaluateHfDiagnostic(page);
1303
1311
  if (diag.hasSeek && diag.duration === 0) {
1304
- throw new Error(buildZeroDurationDiagnostic(diag));
1312
+ throw new Error(buildZeroDurationDiagnostic(diag, timeoutMs));
1305
1313
  }
1306
1314
  throw new Error(`[FrameCapture] window.__hf not ready after ${timeoutMs}ms. ` +
1307
1315
  `Page must expose window.__hf = { duration, seek }.\n` +
@@ -43,6 +43,7 @@ export interface VideoMetadata {
43
43
  colorRange?: string;
44
44
  pixelFormat?: string;
45
45
  sampleAspectRatio?: number;
46
+ rotation?: number;
46
47
  /** Decoded frame count from the video stream's `nb_frames`. Omitted when the
47
48
  * container does not surface a reliable count (still images, malformed
48
49
  * streams, or muxes that require `-count_packets` to populate). Callers
@@ -90,6 +91,9 @@ interface FFProbeStream {
90
91
  color_primaries?: string;
91
92
  color_space?: string;
92
93
  tags?: Record<string, string>;
94
+ side_data_list?: {
95
+ rotation?: number;
96
+ }[];
93
97
  disposition?: {
94
98
  attached_pic?: number;
95
99
  };
@@ -172,6 +176,10 @@ export declare function readTagCI(tags: Record<string, string | undefined> | und
172
176
  * smallest representable 2dp rate instead.
173
177
  */
174
178
  export declare function parseFrameRate(frameRateStr: string | undefined): number;
179
+ export declare function displaySize(metadata: Pick<VideoMetadata, "width" | "height" | "sampleAspectRatio" | "rotation">): {
180
+ width: number;
181
+ height: number;
182
+ };
175
183
  export declare function parseSampleAspectRatio(value: string | undefined): number | undefined;
176
184
  /**
177
185
  * Whether the first frame decoded with `decoder` (alpha-aware) has an alpha pixel format; undefined on any
@@ -637,6 +637,18 @@ export function parseFrameRate(frameRateStr) {
637
637
  // caller's 30fps default.
638
638
  return rounded > 0 ? rounded : 0.01;
639
639
  }
640
+ function displayRotation(stream) {
641
+ const turn = Number(stream.side_data_list?.find((data) => data.rotation !== undefined)?.rotation ??
642
+ stream.tags?.rotate ??
643
+ 0);
644
+ return Number.isFinite(turn) && turn % 360 !== 0 ? { rotation: turn } : {};
645
+ }
646
+ export function displaySize(metadata) {
647
+ const width = Math.round(metadata.width * (metadata.sampleAspectRatio ?? 1));
648
+ return Math.abs(metadata.rotation ?? 0) % 180 === 90
649
+ ? { width: metadata.height, height: width }
650
+ : { width, height: metadata.height };
651
+ }
640
652
  export function parseSampleAspectRatio(value) {
641
653
  const [num, den] = (value ?? "").split(":").map(Number);
642
654
  if (!num || !den || !Number.isFinite(num) || !Number.isFinite(den))
@@ -777,8 +789,12 @@ export async function extractMediaMetadata(filePath) {
777
789
  const alphaPlaneDecoded = alphaDecoder
778
790
  ? await probeDecodedAlphaPlane(filePath, alphaDecoder)
779
791
  : undefined;
780
- const containerDuration = output?.format.duration ? parseFloat(output.format.duration) : 0;
792
+ let containerDuration = output?.format.duration ? parseFloat(output.format.duration) : 0;
781
793
  const streamDuration = videoStream.duration ? parseFloat(videoStream.duration) : 0;
794
+ // Recorder output (WebM/Matroska written to a pipe) carries no duration header: read it off the packets.
795
+ if (!(containerDuration > 0) && !(streamDuration > 0) && !isPng) {
796
+ containerDuration = (await scanPacketDuration(filePath, fps)) ?? 0;
797
+ }
782
798
  const parsedStreamStart = videoStream.start_time ? parseFloat(videoStream.start_time) : 0;
783
799
  const streamStart = Number.isFinite(parsedStreamStart) ? parsedStreamStart : 0;
784
800
  // `nb_frames` is populated by the container demuxer; a muxer that requires
@@ -825,6 +841,7 @@ export async function extractMediaMetadata(filePath) {
825
841
  colorRange: videoStream.color_range,
826
842
  pixelFormat,
827
843
  ...(sampleAspectRatio === undefined ? {} : { sampleAspectRatio }),
844
+ ...displayRotation(videoStream),
828
845
  frames,
829
846
  };
830
847
  })();
@@ -857,6 +874,30 @@ export function readStoredFinalFrame(filePath, metadata) {
857
874
  : undefined;
858
875
  });
859
876
  }
877
+ const PACKET_TAIL_CHARS = 64 * 1024;
878
+ /** The video stream's length from its packets (last end time), for a file whose headers state none. */
879
+ async function scanPacketDuration(filePath, fps) {
880
+ try {
881
+ const stdout = await runFfprobe(filePath, [
882
+ ...["-select_streams", "v:0", "-show_entries", "packet=pts_time,duration_time"],
883
+ ...["-of", "csv=p=0"],
884
+ ], undefined, { retainTail: true, maxChars: PACKET_TAIL_CHARS });
885
+ const lines = stdout.split("\n");
886
+ // A tail cut at the cap starts partway through a line, and its number would read as a huge time.
887
+ if (stdout.length >= PACKET_TAIL_CHARS)
888
+ lines.shift();
889
+ const frame = fps > 0 ? 1 / fps : 0;
890
+ const ends = lines
891
+ .map((line) => line.trim().split(",").map(Number))
892
+ .filter(([pts]) => Number.isFinite(pts))
893
+ .map(([pts, duration]) => pts + (Number.isFinite(duration) ? duration : frame));
894
+ return ends.length ? Math.max(...ends) : undefined;
895
+ }
896
+ catch {
897
+ // expected: a file ffprobe cannot scan keeps its "no duration" verdict.
898
+ return undefined;
899
+ }
900
+ }
860
901
  /**
861
902
  * `duration_time` of the stream's last packet (by presentation time), read from the same final
862
903
  * second the final-frame probe reads; undefined when ffprobe reports none.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.9.15",
3
+ "version": "0.9.16",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -61,8 +61,8 @@
61
61
  "puppeteer": "~25.11.0",
62
62
  "puppeteer-core": "^25.11.0",
63
63
  "undici": "7.25.0",
64
- "@hyperframes/core": "^0.9.15",
65
- "@hyperframes/parsers": "^0.9.15"
64
+ "@hyperframes/core": "^0.9.16",
65
+ "@hyperframes/parsers": "^0.9.16"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/node": "^25.0.10",