@hyperframes/engine 0.8.205 → 0.8.206

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
@@ -46,7 +46,7 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
46
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";
47
47
  export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
48
48
  export { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, type StreamingEncoderOptions, type StreamingEncoderResult, type FrameReorderBuffer, } from "./services/streamingEncoder.js";
49
- export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, 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, } from "./services/videoFrameExtractor.js";
49
+ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, 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, } from "./services/videoFrameExtractor.js";
50
50
  export { isSelfOrAncestorHidden } from "./services/mediaHidden.js";
51
51
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
52
52
  export { resolveReferencedStart, resolveReferencedDuration, type RefResolverEl, type RefResolverDoc, } from "./services/referenceResolver.js";
@@ -65,7 +65,7 @@ export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionB
65
65
  export type { PageMotionBlurOff } from "./types.js";
66
66
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
67
67
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
68
- export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
68
+ 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";
69
69
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
70
70
  export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, type SafeDownloadUrlIdentity, type UrlDownloadOptions, type UrlDownloadTelemetry, type PublicHttpsTextOptions, } from "./utils/urlDownloader.js";
71
71
  export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, type RunFfmpegOptions, type RunFfmpegResult, } from "./utils/runFfmpeg.js";
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
50
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";
51
51
  export { spawnStreamingEncoder, createFrameReorderBuffer, } from "./services/streamingEncoder.js";
52
52
  // ── Media processing ───────────────────────────────────────────────────────────
53
- export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
53
+ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, sdrCanvasColourFilters, } from "./services/videoFrameExtractor.js";
54
54
  export { isSelfOrAncestorHidden } from "./services/mediaHidden.js";
55
55
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
56
56
  export { resolveReferencedStart, resolveReferencedDuration, } from "./services/referenceResolver.js";
@@ -69,7 +69,7 @@ export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThrea
69
69
  export { frameSequencePath } from "./services/extractedFrameIndex.js";
70
70
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
71
71
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
72
- export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
72
+ export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, runFfprobe, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
73
73
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
74
74
  export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, } from "./utils/urlDownloader.js";
75
75
  export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, } from "./utils/runFfmpeg.js";
@@ -12,8 +12,9 @@ export { BrowserLeasePool } from "./browserLeasePool.js";
12
12
  export type { BrowserLaunchFingerprint, BrowserLease, BrowserPoolState, CaptureMode, } from "./browserLeasePool.js";
13
13
  /** Set while the host cancels renders on Ctrl+C; Puppeteer's own handler would exit before any cleanup ran. */
14
14
  export declare function setHostHandlesSigint(owned: boolean): void;
15
- export declare const sigintLaunchOptions: () => {
15
+ export declare const ownedLaunchOptions: () => {
16
16
  handleSIGINT: boolean;
17
+ pipe: boolean;
17
18
  };
18
19
  export interface GpuProbeLifecycle {
19
20
  launch(puppeteer: PuppeteerNode, options: LaunchOptions): Promise<Browser>;
@@ -23,7 +23,7 @@ let hostHandlesSigint = false;
23
23
  export function setHostHandlesSigint(owned) {
24
24
  hostHandlesSigint = owned;
25
25
  }
26
- export const sigintLaunchOptions = () => ({ handleSIGINT: !hostHandlesSigint });
26
+ export const ownedLaunchOptions = () => ({ handleSIGINT: !hostHandlesSigint, pipe: true });
27
27
  function isSoftwareWebGlRenderer(rendererInfo) {
28
28
  const renderer = rendererInfo.trim().toLowerCase();
29
29
  return (renderer.includes("swiftshader") ||
@@ -54,7 +54,7 @@ async function probeHardwareWebGlInfo(ppt, options) {
54
54
  let probeBrowser;
55
55
  try {
56
56
  const launchOptions = {
57
- ...sigintLaunchOptions(),
57
+ ...ownedLaunchOptions(),
58
58
  headless: true,
59
59
  args: withHyperframesBrowserMarker(options.args),
60
60
  defaultViewport: { width: 64, height: 64 },
@@ -659,7 +659,7 @@ async function launchBrowser(fingerprint) {
659
659
  let captureMode = fingerprint.requestedCaptureMode;
660
660
  let browser;
661
661
  const launch = (args) => ppt.launch({
662
- ...sigintLaunchOptions(),
662
+ ...ownedLaunchOptions(),
663
663
  headless: true,
664
664
  args: withHyperframesBrowserMarker(args),
665
665
  defaultViewport: null,
@@ -6,7 +6,7 @@
6
6
  * the page to expose window.__hf, then captures frames deterministically
7
7
  * via Chrome's BeginFrame API or Page.captureScreenshot fallback.
8
8
  */
9
- import { type Browser, type Page, type Protocol } from "puppeteer-core";
9
+ import { type Browser, type CDPSession, type Page, type Protocol } from "puppeteer-core";
10
10
  import { type RawAuthoredTiming } from "@hyperframes/core";
11
11
  import { type MotionBlurPlan } from "./motionBlur.js";
12
12
  import { type BrowserLease, type CaptureMode } from "./browserManager.js";
@@ -429,6 +429,7 @@ export interface IsolatedComposition {
429
429
  compositionId: string;
430
430
  elementId: string | null;
431
431
  }
432
+ export declare function authorScriptsMayRegisterTimelines(runtimeAttr: string, authoredBegin: string, authoredEnd: string): Promise<boolean>;
432
433
  export interface SubTimelinePollOptions {
433
434
  intervalMs?: number;
434
435
  getScriptLoadFailures?: () => readonly string[];
@@ -467,6 +468,8 @@ export declare function classifyConsoleScriptError(type: string, text: string):
467
468
  export declare function classifyConsoleScriptFailure(type: string, text: string): string | null;
468
469
  /** `runtime-error:<first line>` when a script served with the composition threw; null if not attributable to one. */
469
470
  export declare function classifyPageError(details: Protocol.Runtime.ExceptionDetails, projectScripts: ReadonlySet<string>): string | null;
471
+ /** ` [thrown at: <url>:<line>:<col>, <lookup>]` for a throw's authored frame, so the author can find the element. */
472
+ export declare function describeThrowSite(client: CDPSession, details: Protocol.Runtime.ExceptionDetails, projectScripts: ReadonlySet<string>): Promise<string>;
470
473
  export declare function initializeSession(session: CaptureSession): Promise<void>;
471
474
  /**
472
475
  * Internal helper: seek timeline and inject video frames.
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { existsSync, mkdirSync, writeFileSync } from "fs";
11
11
  import { join } from "path";
12
- import { COMPOSITION_SOURCE_URL, MEDIA_RENDER_ID_ATTR, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, SCENE_CALLBACK_ERROR_PREFIX, } from "@hyperframes/core";
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
13
  import { holdRenderFrameVideos } from "./renderFrameVideoHold.js";
14
14
  import { DrawElementCaptureError } from "./drawElementCaptureError.js";
15
15
  import { encodePng } from "../utils/alphaBlit.js";
@@ -1361,6 +1361,50 @@ export async function waitForSubCompositionTimelines(session, page, timeoutMs, o
1361
1361
  memo.unregisteredIds = session.pendingTimelineIds ?? [];
1362
1362
  }
1363
1363
  }
1364
+ // Runs in the page. False once every inline, template and same-origin script is read and none
1365
+ // mentions __timelines or imports code; an unreadable or module script, or a page still loading, answers true.
1366
+ export async function authorScriptsMayRegisterTimelines(runtimeAttr, authoredBegin, authoredEnd) {
1367
+ if (document.readyState !== "complete")
1368
+ return true;
1369
+ const memo = globalThis;
1370
+ memo.__hfAuthorScan ??= await (async () => {
1371
+ const scripts = [
1372
+ ...Array.from(document.querySelectorAll("script")),
1373
+ ...Array.from(document.querySelectorAll("template")).flatMap((t) => Array.from(t.content.querySelectorAll("script"))),
1374
+ ].filter((script) => !script.hasAttribute(runtimeAttr));
1375
+ // Code that imports another file may register through it, which is not read here.
1376
+ const mayRegister = (code) => code.includes("__timelines") || /\bimport\s*\(|^\s*import\s*[\w{*"']/m.test(code);
1377
+ for (const script of scripts) {
1378
+ if (script.type === "module")
1379
+ return true;
1380
+ if (!script.src) {
1381
+ const text = script.textContent ?? "";
1382
+ const authored = text.includes(authoredBegin)
1383
+ ? text
1384
+ .split(authoredBegin)
1385
+ .slice(1)
1386
+ .map((region) => region.split(authoredEnd)[0])
1387
+ .join("\n")
1388
+ : text;
1389
+ if (mayRegister(authored))
1390
+ return true;
1391
+ continue;
1392
+ }
1393
+ const url = new URL(script.src, location.href);
1394
+ if (url.origin !== location.origin)
1395
+ return true;
1396
+ try {
1397
+ if (mayRegister(await (await fetch(url)).text()))
1398
+ return true;
1399
+ }
1400
+ catch {
1401
+ return true;
1402
+ }
1403
+ }
1404
+ return false;
1405
+ })();
1406
+ return memo.__hfAuthorScan;
1407
+ }
1364
1408
  export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs = 150, getScriptLoadFailures, scriptFailureGraceMs = 2_000, getPageErrors, scriptErrorGraceMs = 5_000, onPending, shouldStop, knownUnregisteredIds = [], } = {}) {
1365
1409
  // Hosts may opt out of the timeline wait with `data-no-timeline` —
1366
1410
  // compositions driven purely by CSS animations / rAF (the render-compat
@@ -1386,15 +1430,19 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
1386
1430
  // A page error is given its grace from when it first appeared, not from when the poll began.
1387
1431
  let errorSeenAt;
1388
1432
  for (;;) {
1389
- ready = Boolean(await page.evaluate(expression));
1433
+ const failures = getScriptLoadFailures?.() ?? [];
1434
+ const errors = getPageErrors?.() ?? [];
1435
+ const failed = failures.length > 0 ? failures : errors;
1436
+ // A failed script keeps the wait, so the failure is still named; only a quiet page is ready early.
1437
+ ready =
1438
+ Boolean(await page.evaluate(expression)) ||
1439
+ (failed.length === 0 &&
1440
+ !(await page.evaluate(authorScriptsMayRegisterTimelines, RUNTIME_BOOTSTRAP_ATTR, AUTHORED_SOURCE_BEGIN, AUTHORED_SOURCE_END)));
1390
1441
  if (ready || shouldStop?.())
1391
1442
  break;
1392
1443
  const now = Date.now();
1393
1444
  if (now >= deadline)
1394
1445
  break;
1395
- const failures = getScriptLoadFailures?.() ?? [];
1396
- const errors = getPageErrors?.() ?? [];
1397
- const failed = failures.length > 0 ? failures : errors;
1398
1446
  const graceMs = failures.length > 0 ? scriptFailureGraceMs : scriptErrorGraceMs;
1399
1447
  if (failed.length > 0)
1400
1448
  errorSeenAt ??= now;
@@ -1839,10 +1887,41 @@ export function classifyPageError(details, projectScripts) {
1839
1887
  if (frames.length === 0 && details.text.startsWith("Uncaught (in promise)"))
1840
1888
  return null;
1841
1889
  const urls = frames.length > 0 ? frames : [details.url];
1842
- // The render compiler names the composition's inline code.
1843
- // Inline handlers report the document, which a widget can move.
1844
- const owned = (url) => url !== undefined && (projectScripts.has(url) || url.startsWith(COMPOSITION_SOURCE_URL));
1845
- return urls.some(owned) ? `runtime-error:${firstLine}` : null;
1890
+ return urls.some((url) => isOwnedScriptUrl(url, projectScripts))
1891
+ ? `runtime-error:${firstLine}`
1892
+ : null;
1893
+ }
1894
+ // The render compiler names the composition's inline code.
1895
+ // Inline handlers report the document, which a widget can move.
1896
+ function isOwnedScriptUrl(url, projectScripts) {
1897
+ return url !== undefined && (projectScripts.has(url) || url.startsWith(COMPOSITION_SOURCE_URL));
1898
+ }
1899
+ const LOOKUP_CALL = /\b(?:getElementById|querySelectorAll|querySelector|getElementsByClassName|getElementsByTagName)\(\s*(["'`])((?:(?!\1).)*)\1/g;
1900
+ /** ` [thrown at: <url>:<line>:<col>, <lookup>]` for a throw's authored frame, so the author can find the element. */
1901
+ export async function describeThrowSite(client, details, projectScripts) {
1902
+ const frame = (details.stackTrace?.callFrames ?? []).find((f) => isOwnedScriptUrl(f.url, projectScripts));
1903
+ if (!frame)
1904
+ return "";
1905
+ let where = `${frame.url}:${frame.lineNumber + 1}:${frame.columnNumber + 1}`;
1906
+ try {
1907
+ // Source is only fetchable with the debugger on; enabling after the fact keeps script ids.
1908
+ await client.send("Debugger.enable");
1909
+ const { scriptSource } = await client.send("Debugger.getScriptSource", {
1910
+ scriptId: frame.scriptId,
1911
+ });
1912
+ const line = scriptSource.split("\n")[frame.lineNumber] ?? "";
1913
+ const lookups = [...line.matchAll(LOOKUP_CALL)];
1914
+ // Only a lone lookup is certain to be the one that returned null; with several the position alone is given.
1915
+ if (lookups.length === 1)
1916
+ where += `, ${lookups[0]?.[0]})`;
1917
+ }
1918
+ catch {
1919
+ // Source unavailable: line:col alone still locates the throw.
1920
+ }
1921
+ finally {
1922
+ await client.send("Debugger.disable").catch(() => undefined);
1923
+ }
1924
+ return ` [thrown at: ${where}]`;
1846
1925
  }
1847
1926
  export async function initializeSession(session) {
1848
1927
  const { page, serverUrl } = session;
@@ -1913,7 +1992,20 @@ export async function initializeSession(session) {
1913
1992
  // Unlike the pageerror Error, this keeps the script URL of syntax errors and thrown non-errors.
1914
1993
  const runtimeClient = await getCdpSession(page);
1915
1994
  runtimeClient.on("Runtime.exceptionThrown", ({ exceptionDetails }) => {
1916
- recordPageError(session, classifyPageError(exceptionDetails, projectScripts));
1995
+ const error = classifyPageError(exceptionDetails, projectScripts);
1996
+ recordPageError(session, error);
1997
+ if (!error)
1998
+ return;
1999
+ // Recorded first so fail-fast sees it; the throw site is appended once the source is fetched.
2000
+ void describeThrowSite(runtimeClient, exceptionDetails, projectScripts).then((site) => {
2001
+ const i = session.pageErrors.indexOf(error);
2002
+ if (!site || i < 0)
2003
+ return;
2004
+ if (session.pageErrors.includes(`${error}${site}`))
2005
+ session.pageErrors.splice(i, 1);
2006
+ else
2007
+ session.pageErrors[i] = `${error}${site}`;
2008
+ });
1917
2009
  });
1918
2010
  await runtimeClient.send("Runtime.enable");
1919
2011
  const pageNavigationTimeout = session.config?.pageNavigationTimeout ?? DEFAULT_CONFIG.pageNavigationTimeout;
@@ -19,7 +19,7 @@
19
19
  * Performance: ~6 fps at 1080x1920 via base64 transfer.
20
20
  */
21
21
  import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker";
22
- import { resolveFullChromePath, sigintLaunchOptions } from "./browserManager.js";
22
+ import { resolveFullChromePath, ownedLaunchOptions } from "./browserManager.js";
23
23
  // ── PQ (SMPTE 2084) OETF ─────────────────────────────────────────────────────
24
24
  const PQ_M1 = 0.1593017578125;
25
25
  const PQ_M2 = 78.84375;
@@ -181,7 +181,7 @@ export async function launchHdrBrowser(width, height) {
181
181
  throw new Error("[HDR] No Chrome binary found. Install: npx @puppeteer/browsers install chrome@stable");
182
182
  }
183
183
  const browser = await ppt.launch({
184
- ...sigintLaunchOptions(),
184
+ ...ownedLaunchOptions(),
185
185
  headless: false,
186
186
  executablePath: chromePath,
187
187
  args: withHyperframesBrowserMarker(buildHdrChromeArgs(width, height)),
@@ -258,6 +258,7 @@ export interface ImageElement {
258
258
  end: number;
259
259
  }
260
260
  export declare function parseImageElements(html: string): ImageElement[];
261
+ export declare function sdrCanvasColourFilters(metadata: VideoMetadata, toneMappedToSdr?: boolean): string[];
261
262
  export declare function extractVideoFramesRange(...args: Parameters<typeof extractVideoFramesRangeUnredacted>): Promise<ExtractedFrames>;
262
263
  declare function extractVideoFramesRangeUnredacted(videoPath: string, videoId: string, startTime: number, duration: number, options: ExtractionOptions, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
263
264
  /** Frames go straight here, with no per-videoId subdir (the cache layer's keyed entry). */
@@ -14,7 +14,7 @@ import { parseHTML } from "linkedom";
14
14
  import { isPathInside } from "@hyperframes/parsers/asset-paths";
15
15
  import { resolveProjectRelativeSrc } from "@hyperframes/parsers/asset-resolution";
16
16
  import { alphaDecoderFor } from "@hyperframes/parsers/media-alpha";
17
- import { MEDIA_RENDER_ID_ATTR, isAudibleVideoElement, fpsToFfmpegArg, fpsToNumber, MEDIA_DURATION_CLAMP_EPSILON_SECONDS, normalizeRateSpec, readElementRateSpec, shiftRateLane, sourceTimeAt, timeAtSourceTime, exportClipWindow, hasClipStarted, isInClipWindow, parseStrictFiniteTimingNumber, readMediaStart, toFps, firstFrameColourArgs, hdrToSdrToneMapFilter, parseFirstFrameColour, } from "@hyperframes/core";
17
+ import { MEDIA_RENDER_ID_ATTR, isAudibleVideoElement, fpsToFfmpegArg, fpsToNumber, MEDIA_DURATION_CLAMP_EPSILON_SECONDS, normalizeRateSpec, readElementRateSpec, shiftRateLane, sourceTimeAt, timeAtSourceTime, exportClipWindow, hasClipStarted, isInClipWindow, mediaElementTime, parseStrictFiniteTimingNumber, readMediaStart, toFps, firstFrameColourArgs, hdrToSdrToneMapFilter, parseFirstFrameColour, } from "@hyperframes/core";
18
18
  import { resolveReferencedStart } from "./referenceResolver.js";
19
19
  import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
20
20
  import { extractFinalVideoFrameTimestamp, extractMediaMetadata, mediaFileIdentity, readMetadataCache, readStoredFinalFrame, resolvePlayableVideoDuration, writeMetadataCache, } from "../utils/ffprobe.js";
@@ -477,6 +477,12 @@ function chromeGuessForUntaggedMatrix(metadata) {
477
477
  const hd = metadata.height >= 720 && !CHROME_BT601_UNTAGGED_CODECS.has(metadata.videoCodec);
478
478
  return [`setparams=colorspace=${hd ? "bt709" : "smpte170m"}`];
479
479
  }
480
+ export function sdrCanvasColourFilters(metadata, toneMappedToSdr = false) {
481
+ if (!isHdrColorSpaceUtil(metadata.colorSpace)) {
482
+ return [...chromeGuessForUntaggedMatrix(metadata), SDR_CANVAS_PASSTHROUGH_FILTER];
483
+ }
484
+ return toneMappedToSdr ? [SDR_CANVAS_PASSTHROUGH_FILTER] : [];
485
+ }
480
486
  /**
481
487
  * Longest stretch of output one ffmpeg process extracts; longer ranges split into segments
482
488
  * that run side by side. Segments sample the same absolute times as a single pass, so their
@@ -678,7 +684,7 @@ outputDirOverride) {
678
684
  vfFilters.push(hdrToSdrToneMapFilter(metadata.colorSpace ?? {}, frameColour));
679
685
  }
680
686
  if (toneMappedToSdr || (!isHdr && !options.sdrToHdrTransfer)) {
681
- vfFilters.push(...(isHdr ? [] : chromeGuessForUntaggedMatrix(metadata)), SDR_CANVAS_PASSTHROUGH_FILTER);
687
+ vfFilters.push(...sdrCanvasColourFilters(metadata, toneMappedToSdr));
682
688
  if (format === "jpg") {
683
689
  if (options.shownBox)
684
690
  vfFilters.push(halveToCoverScaleFilter(options.shownBox));
@@ -1832,9 +1838,9 @@ function getFrameIndexAtTime(extracted, globalTime, videoStart, loop = false, me
1832
1838
  if (cycle > 0 && sourceTime >= cycle)
1833
1839
  sourceTime %= cycle;
1834
1840
  }
1835
- // Add epsilon before flooring to avoid IEEE 754 boundary errors where
1836
- // e.g. 0.28 * 25 === 6.999999999999999 instead of 7.
1837
- const frameIndex = Math.floor(sourceTime * extracted.fps + 1e-9);
1841
+ // The frame the preview's <video> shows at this source time, so render and preview pick the same one.
1842
+ const shownTime = mediaElementTime(mediaStart + sourceTime) - mediaStart;
1843
+ const frameIndex = Math.floor(shownTime * extracted.fps);
1838
1844
  if (frameIndex < 0 || extracted.totalFrames <= 0)
1839
1845
  return null;
1840
1846
  if (isVideoHiddenBeforeStreamStart(extracted.metadata.videoStreamLeadSeconds, mediaStart + sourceTime)) {
@@ -1,3 +1,8 @@
1
+ /** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
2
+ export declare function runFfprobe(filePath: string, argsWithoutInput: string[], signal?: AbortSignal, stdoutOptions?: {
3
+ retainTail?: boolean;
4
+ maxChars?: number;
5
+ }): Promise<string>;
1
6
  export interface MediaProbeCacheEntry<T = FFProbeOutput> {
2
7
  identity: string | null;
3
8
  promise: Promise<T>;
@@ -49,7 +49,7 @@ function sanitizeFfprobeDiagnostic(stderr, filePath) {
49
49
  return `…${redacted.slice(-(FFPROBE_ERROR_MAX_CHARS - 1))}`;
50
50
  }
51
51
  /** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
52
- async function runFfprobe(filePath, argsWithoutInput, signal, stdoutOptions) {
52
+ export async function runFfprobe(filePath, argsWithoutInput, signal, stdoutOptions) {
53
53
  // `--` stops option parsing so a path like "-intro.mp4" is a filename, but
54
54
  // it does NOT cover a path of exactly "-": ffprobe rewrites that to `fd:`
55
55
  // AFTER option parsing and then reads stdin. Since stdin here is a pipe the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.205",
3
+ "version": "0.8.206",
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/parsers": "^0.8.205",
54
- "@hyperframes/core": "^0.8.205"
53
+ "@hyperframes/core": "^0.8.206",
54
+ "@hyperframes/parsers": "^0.8.206"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",