@hyperframes/engine 0.8.146 → 0.8.148

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
@@ -45,7 +45,7 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
45
45
  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";
46
46
  export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
47
47
  export { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, type StreamingEncoderOptions, type StreamingEncoderResult, type FrameReorderBuffer, } from "./services/streamingEncoder.js";
48
- export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, resolveFinalFrameExtractionWindow, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, 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";
48
+ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, resolveFinalFrameExtractionWindow, 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
49
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
50
50
  export { resolveReferencedStart, resolveReferencedDuration, type RefResolverEl, type RefResolverDoc, } from "./services/referenceResolver.js";
51
51
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
package/dist/index.js CHANGED
@@ -49,7 +49,7 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
49
49
  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";
50
50
  export { spawnStreamingEncoder, createFrameReorderBuffer, } from "./services/streamingEncoder.js";
51
51
  // ── Media processing ───────────────────────────────────────────────────────────
52
- export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, resolveFinalFrameExtractionWindow, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
52
+ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, resolveFinalFrameExtractionWindow, 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
53
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
54
54
  export { resolveReferencedStart, resolveReferencedDuration, } from "./services/referenceResolver.js";
55
55
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
@@ -153,14 +153,7 @@ export function writeWav(path, samples, sampleRate, channels = 1, float = false)
153
153
  // in between.
154
154
  writeFileSync(path, buf);
155
155
  }
156
- /**
157
- * Split an interleaved buffer into one array per channel.
158
- *
159
- * The graph used to fold everything to mono, which collapsed a stereo bed's
160
- * width for the render only — and cost ~3 dB through the very mono-to-stereo
161
- * rematrix that `prepareAudioTrack`'s pan filter exists to avoid. Preview kept
162
- * the track stereo, so the two diverged the moment any effect was enabled.
163
- */
156
+ /** Split an interleaved buffer into one array per channel, keeping stereo width as preview does. */
164
157
  function deinterleave(samples, channels) {
165
158
  if (channels <= 1)
166
159
  return [samples];
@@ -1,26 +1,10 @@
1
- /**
2
- * Audio Mixer Service
3
- *
4
- * Processes and mixes audio tracks using FFmpeg.
5
- */
6
1
  import { type EngineConfig } from "../config.js";
7
2
  import type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
8
3
  import { type HfAutomationLane } from "@hyperframes/core/audio-automation";
9
4
  import type { AudioVolumeKeyframe } from "./audioMixer.types.js";
10
5
  export type { AudioElement, MixResult } from "./audioMixer.types.js";
11
- /**
12
- * Filename every caller must use for the mixed-audio artifact.
13
- *
14
- * The extension is load-bearing, not cosmetic: FFmpeg picks the muxer from it,
15
- * and the mix is AAC-encoded. A raw ADTS `.aac` stream has nowhere to record
16
- * the encoder's priming delay, so those leading samples decode as real silence
17
- * and shift the whole track ~1024 samples (21.33 ms at 48 kHz) late against a
18
- * frame-accurate video track. An MP4-family container carries the delay as an
19
- * edit list, which every decoder then strips, so the mix lands on its authored
20
- * start. Keep the choice here rather than at each call site: the same file is
21
- * muxed into the video, shipped in a distributed plan, and handed to users as
22
- * the PNG-sequence sidecar, and all three have to agree.
23
- */
6
+ /** An MP4-family container records AAC priming as an edit list; a raw .aac plays ~21 ms late.
7
+ * The video mux, the distributed plan and the PNG-sequence sidecar all read this one name. */
24
8
  export declare const MIXED_AUDIO_FILENAME = "audio.m4a";
25
9
  /** afade stages after the volume filter (leading comma), or "" when none. Stream time 0 is the clip start. */
26
10
  export declare function buildFadeFilters(track: Pick<AudioTrack, "start" | "end" | "fadeIn" | "fadeOut">): string;
@@ -1,9 +1,4 @@
1
1
  // fallow-ignore-file complexity code-duplication
2
- /**
3
- * Audio Mixer Service
4
- *
5
- * Processes and mixes audio tracks using FFmpeg.
6
- */
7
2
  import { isSelfOrAncestorHidden, memberGroupKey, isMemberGroupHidden } from "./mediaHidden.js";
8
3
  import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from "fs";
9
4
  import { join, dirname, isAbsolute, relative } from "path";
@@ -26,19 +21,8 @@ import { chainTailSeconds } from "@hyperframes/core/audio-fx-tail";
26
21
  import { isAudibleVideoElement, MEDIA_RENDER_ID_ATTR, normalizePlaybackRate, normalizeRateSpec, parseStrictFiniteTimingNumber, readElementRateSpec, readMediaStart, sourceTimeAt, timeAtSourceTime, } from "@hyperframes/core";
27
22
  import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
28
23
  import { applyAudioFxChain, AudioFxRenderError } from "./audioFxRender.js";
29
- /**
30
- * Filename every caller must use for the mixed-audio artifact.
31
- *
32
- * The extension is load-bearing, not cosmetic: FFmpeg picks the muxer from it,
33
- * and the mix is AAC-encoded. A raw ADTS `.aac` stream has nowhere to record
34
- * the encoder's priming delay, so those leading samples decode as real silence
35
- * and shift the whole track ~1024 samples (21.33 ms at 48 kHz) late against a
36
- * frame-accurate video track. An MP4-family container carries the delay as an
37
- * edit list, which every decoder then strips, so the mix lands on its authored
38
- * start. Keep the choice here rather than at each call site: the same file is
39
- * muxed into the video, shipped in a distributed plan, and handed to users as
40
- * the PNG-sequence sidecar, and all three have to agree.
41
- */
24
+ /** An MP4-family container records AAC priming as an edit list; a raw .aac plays ~21 ms late.
25
+ * The video mux, the distributed plan and the PNG-sequence sidecar all read this one name. */
42
26
  export const MIXED_AUDIO_FILENAME = "audio.m4a";
43
27
  function clampVolume(volume) {
44
28
  return clampAudioGain(volume);
@@ -87,11 +71,11 @@ function buildAtempoFilter(playbackRate) {
87
71
  const RAMP_SLICE_SECONDS = 0.25;
88
72
  const MAX_RAMP_SLICES = 240;
89
73
  /** Bake a rate lane as consecutive source slices, each stretched by its mean `sourceTimeAt` rate, keeping pitch. */
90
- function buildRampFilterComplex(lane, duration, tailFilter) {
74
+ function buildRampFilterComplex(lane, duration, headFilter, tailFilter) {
91
75
  const slices = Math.min(MAX_RAMP_SLICES, Math.max(1, Math.ceil(duration / RAMP_SLICE_SECONDS)));
92
76
  const step = duration / slices;
93
77
  const split = Array.from({ length: slices }, (_, i) => `[s${i}]`).join("");
94
- const parts = [`[0:a]asplit=${slices}${split}`];
78
+ const parts = [`[0:a]${headFilter ? `${headFilter},` : ""}asplit=${slices}${split}`];
95
79
  for (let i = 0; i < slices; i += 1) {
96
80
  const from = sourceTimeAt(lane, i * step);
97
81
  const to = sourceTimeAt(lane, (i + 1) * step);
@@ -112,27 +96,17 @@ function buildRampFilterComplex(lane, duration, tailFilter) {
112
96
  parts.push(`[cat]${tailFilter}[out]`);
113
97
  return parts.join(";");
114
98
  }
115
- function preparedAudioOutputArgs(srcPath, playbackRate, duration = 0) {
116
- return stereoOutputArgs(srcPath).then((channelArgs) => {
117
- const filters = [];
118
- const outputArgs = [];
119
- if (channelArgs[0] === "-af" && channelArgs[1]) {
120
- filters.push(channelArgs[1]);
121
- }
122
- else {
123
- outputArgs.push(...channelArgs);
124
- }
125
- if (typeof playbackRate === "object") {
126
- const graph = buildRampFilterComplex(playbackRate, duration, filters.join(",") || null);
127
- return ["-filter_complex", graph, "-map", "[out]", ...outputArgs];
128
- }
129
- const atempo = buildAtempoFilter(playbackRate);
130
- if (atempo)
131
- filters.push(atempo);
132
- if (filters.length > 0)
133
- outputArgs.push("-af", filters.join(","));
134
- return outputArgs;
135
- });
99
+ function preparedAudioOutputArgs(metadata, playbackRate, duration, headFilter) {
100
+ const channelFilter = metadata?.channels === 1 ? STEREO_CHANNEL_FILTER : null;
101
+ const outputArgs = channelFilter ? [] : ["-ac", "2"];
102
+ if (typeof playbackRate === "object") {
103
+ const graph = buildRampFilterComplex(playbackRate, duration, headFilter, channelFilter);
104
+ return ["-filter_complex", graph, "-map", "[out]", ...outputArgs];
105
+ }
106
+ const filters = [headFilter, channelFilter, buildAtempoFilter(playbackRate)].filter(Boolean);
107
+ if (filters.length > 0)
108
+ outputArgs.push("-af", filters.join(","));
109
+ return outputArgs;
136
110
  }
137
111
  function escapeExpressionCommas(expression) {
138
112
  return expression.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
@@ -165,17 +139,6 @@ const VOLUME_SIMPLIFY_EPSILON = 0.005;
165
139
  // mono source by 3 dB. Explicitly map front-center into both stereo channels;
166
140
  // native stereo sources have FL/FR and pass through unchanged.
167
141
  const STEREO_CHANNEL_FILTER = "pan=stereo|FL=FL+FC|FR=FR+FC";
168
- async function stereoOutputArgs(srcPath) {
169
- try {
170
- const { channels } = await extractAudioMetadata(srcPath);
171
- if (channels === 1)
172
- return ["-af", STEREO_CHANNEL_FILTER];
173
- }
174
- catch {
175
- // Preserve the previous FFmpeg conversion path when metadata probing fails.
176
- }
177
- return ["-ac", "2"];
178
- }
179
142
  /**
180
143
  * Reduce a sorted keyframe list to a perceptually-equivalent piecewise-linear
181
144
  * envelope with a bounded segment count.
@@ -517,74 +480,27 @@ export function parseAudioElements(html) {
517
480
  }
518
481
  return elements;
519
482
  }
520
- async function extractAudioFromVideo(videoPath, outputPath, options, signal, config) {
521
- const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
522
- const outputDir = dirname(outputPath);
523
- if (!existsSync(outputDir))
524
- mkdirSync(outputDir, { recursive: true });
525
- const playbackRate = normalizeRateSpec(options?.playbackRate);
526
- const args = [];
527
- if (options?.startTime !== undefined)
528
- args.push("-ss", String(options.startTime));
529
- if (options?.duration !== undefined) {
530
- args.push("-t", String(sourceTimeAt(playbackRate, options.duration)));
531
- }
532
- args.push("-i", videoPath);
533
- const outputArgs = await preparedAudioOutputArgs(videoPath, playbackRate, options?.duration);
534
- args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
535
- if (playbackRate !== 1 && options?.duration !== undefined) {
536
- args.push("-t", String(options.duration));
537
- }
538
- args.push("-y", outputPath);
539
- const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
540
- if (signal?.aborted) {
541
- const failure = {
542
- stage: "cancelled",
543
- reason: "cancelled",
544
- owner: "user",
545
- retryable: false,
546
- detail: "Audio extract cancelled",
547
- };
548
- return {
549
- success: false,
550
- outputPath,
551
- durationMs: result.durationMs,
552
- error: failure.detail,
553
- failure,
554
- };
555
- }
556
- if (!result.success) {
557
- const failure = ffmpegFailure("extract", result);
558
- return {
559
- success: false,
560
- outputPath,
561
- durationMs: result.durationMs,
562
- error: failure.detail,
563
- failure,
564
- };
565
- }
566
- return { success: true, outputPath, durationMs: result.durationMs };
567
- }
568
- async function prepareAudioTrack(srcPath, outputPath, mediaStart, duration, playbackRate = 1, signal, config) {
483
+ /** Decode `duration` s of audio from `mediaStart` in Chrome's media time. Until every stream has
484
+ * started, an input seek snaps audio to the video's first keyframe, so read by timestamp instead. */
485
+ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config) {
569
486
  const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
570
487
  const outputDir = dirname(outputPath);
571
488
  if (!existsSync(outputDir))
572
489
  mkdirSync(outputDir, { recursive: true });
573
490
  const normalizedPlaybackRate = normalizeRateSpec(playbackRate);
574
- const outputArgs = await preparedAudioOutputArgs(srcPath, normalizedPlaybackRate, duration);
575
- const args = [
576
- "-ss",
577
- String(mediaStart),
578
- "-t",
579
- String(sourceTimeAt(normalizedPlaybackRate, duration)),
580
- "-i",
581
- srcPath,
582
- "-acodec",
583
- "pcm_s16le",
584
- "-ar",
585
- "48000",
586
- ...outputArgs,
587
- ];
491
+ const sourceDuration = sourceTimeAt(normalizedPlaybackRate, duration);
492
+ // A failed probe keeps FFmpeg's default channel conversion and the input seek.
493
+ const metadata = await extractAudioMetadata(srcPath).catch(() => null);
494
+ const inputSeek = mediaStart >= (metadata?.latestStreamLeadSeconds ?? 0);
495
+ const start = formatFilterNumber(mediaStart);
496
+ const timestampTrim = inputSeek
497
+ ? null
498
+ : `atrim=start=${start}:end=${formatFilterNumber(mediaStart + sourceDuration)},asetpts=PTS-${start}/TB,aresample=async=1:first_pts=0`;
499
+ const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, timestampTrim);
500
+ const args = inputSeek
501
+ ? ["-ss", String(mediaStart), "-t", String(sourceDuration), "-i", srcPath]
502
+ : ["-i", srcPath];
503
+ args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
588
504
  if (normalizedPlaybackRate !== 1)
589
505
  args.push("-t", String(duration));
590
506
  args.push("-y", outputPath);
@@ -595,7 +511,7 @@ async function prepareAudioTrack(srcPath, outputPath, mediaStart, duration, play
595
511
  reason: "cancelled",
596
512
  owner: "user",
597
513
  retryable: false,
598
- detail: "Audio prepare cancelled",
514
+ detail: `Audio ${stage} cancelled`,
599
515
  };
600
516
  return {
601
517
  success: false,
@@ -605,7 +521,7 @@ async function prepareAudioTrack(srcPath, outputPath, mediaStart, duration, play
605
521
  failure,
606
522
  };
607
523
  }
608
- const failure = !result.success ? ffmpegFailure("prepare", result) : undefined;
524
+ const failure = !result.success ? ffmpegFailure(stage, result) : undefined;
609
525
  return {
610
526
  success: result.success,
611
527
  outputPath,
@@ -1024,11 +940,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1024
940
  let audioSrcPath = srcPath;
1025
941
  if (element.type === "video") {
1026
942
  const extractedPath = join(workDir, `${element.id}-extracted.wav`);
1027
- const extractResult = await extractAudioFromVideo(srcPath, extractedPath, {
1028
- startTime: element.mediaStart,
1029
- duration: element.end - element.start,
1030
- playbackRate: element.playbackRate,
1031
- }, effectiveSignal, config);
943
+ const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config);
1032
944
  if (!extractResult.success) {
1033
945
  failures.push(extractResult.failure
1034
946
  ? { ...extractResult.failure, elementId: element.id }
@@ -1046,7 +958,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1046
958
  }
1047
959
  else {
1048
960
  const trimmedPath = join(workDir, `${element.id}-trimmed.wav`);
1049
- const prepResult = await prepareAudioTrack(srcPath, trimmedPath, element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config);
961
+ const prepResult = await extractAudioSegment(srcPath, trimmedPath, "prepare", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config);
1050
962
  if (!prepResult.success) {
1051
963
  failures.push(prepResult.failure
1052
964
  ? { ...prepResult.failure, elementId: element.id }
@@ -11,7 +11,7 @@
11
11
  * output (and the golden baselines) only change where a fade is actually applied.
12
12
  *
13
13
  * The prepared tracks are always `pcm_s16le`, 48 kHz, stereo (see
14
- * `prepareAudioTrack` / `extractAudioFromVideo`). Anything else is rejected so
14
+ * `extractAudioSegment`). Anything else is rejected so
15
15
  * the caller can fall back to the expression path rather than corrupting audio.
16
16
  */
17
17
  import type { AudioVolumeKeyframe } from "./audioMixer.types.js";
@@ -11,7 +11,7 @@
11
11
  * output (and the golden baselines) only change where a fade is actually applied.
12
12
  *
13
13
  * The prepared tracks are always `pcm_s16le`, 48 kHz, stereo (see
14
- * `prepareAudioTrack` / `extractAudioFromVideo`). Anything else is rejected so
14
+ * `extractAudioSegment`). Anything else is rejected so
15
15
  * the caller can fall back to the expression path rather than corrupting audio.
16
16
  */
17
17
  import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
@@ -20,6 +20,8 @@ export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSum
20
20
  * `heldVideoTime` (motion-blur samples only) is the output frame's time: a video on screen at
21
21
  * both times shows its frame for `heldVideoTime`, so footage does not smear. */
22
22
  export type BeforeCaptureHook = (page: Page, time: number, heldVideoTime?: number) => Promise<void>;
23
+ /** The per-frame wait a capture is in, worded for the stall error a user reads. */
24
+ type CaptureStep = "seeking the timeline" | "decoding CSS background images" | "injecting video frames" | "waiting for page seek handlers" | "loading color-grading LUTs" | "compositing the page" | "capturing the frame";
23
25
  export interface CaptureSession {
24
26
  browser: Browser;
25
27
  /** Exact ownership token for this browser acquisition. */
@@ -202,6 +204,7 @@ export interface CaptureSession {
202
204
  * retries via screenshot.
203
205
  */
204
206
  deFrameTimeouts?: number;
207
+ captureStep?: CaptureStep;
205
208
  /**
206
209
  * drawElement init passed every gate but stopped before verification +
207
210
  * canvas injection: the session has no video-frame injector yet (probe
@@ -2040,7 +2040,9 @@ async function prepareFrameForCapture(session, frameIndex, time, seekOptions, he
2040
2040
  assertVfxIntact(session);
2041
2041
  const quantizedTime = quantizeSeekTime(time, fpsToNumber(options.fps), seekOptions?.subFrameDivisions);
2042
2042
  const seekStart = Date.now();
2043
+ session.captureStep = "seeking the timeline";
2043
2044
  const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);
2045
+ session.captureStep = "decoding CSS background images";
2044
2046
  await decodeDynamicCssBackgroundImages(page);
2045
2047
  const seekMs = Date.now() - seekStart;
2046
2048
  // Before-capture hook (e.g. video frame injection) — runs before
@@ -2048,9 +2050,12 @@ async function prepareFrameForCapture(session, frameIndex, time, seekOptions, he
2048
2050
  // replacements for <video> elements.
2049
2051
  const beforeCaptureStart = Date.now();
2050
2052
  if (session.onBeforeCapture) {
2053
+ session.captureStep = "injecting video frames";
2051
2054
  await session.onBeforeCapture(page, quantizedTime, heldVideoTime);
2052
2055
  }
2056
+ session.captureStep = "waiting for page seek handlers";
2053
2057
  await waitForPendingSeekCompletion(page);
2058
+ session.captureStep = "loading color-grading LUTs";
2054
2059
  await page.evaluate(async () => {
2055
2060
  const runtime = window.__hf?.colorGrading;
2056
2061
  await runtime?.waitForActiveLuts?.();
@@ -2063,6 +2068,7 @@ async function prepareFrameForCapture(session, frameIndex, time, seekOptions, he
2063
2068
  if (hasPendingComposite &&
2064
2069
  session.captureMode !== "beginframe" &&
2065
2070
  session.captureMode !== "drawelement") {
2071
+ session.captureStep = "compositing the page";
2066
2072
  await page.evaluate(async () => {
2067
2073
  const w = window;
2068
2074
  if (typeof w.__hf_page_composite_prepare === "function") {
@@ -2082,6 +2088,7 @@ async function prepareFrameForCapture(session, frameIndex, time, seekOptions, he
2082
2088
  }
2083
2089
  });
2084
2090
  }
2091
+ session.captureStep = "capturing the frame";
2085
2092
  return { quantizedTime, seekMs, beforeCaptureMs };
2086
2093
  }
2087
2094
  // ── Static-frame dedup (default-on, opt-out HF_STATIC_DEDUP=false) ─────────────
@@ -3095,12 +3102,14 @@ async function captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan)
3095
3102
  const fps = fpsToNumber(session.options.fps);
3096
3103
  const frameTime = quantizeSeekTime(absFrameIndex / fps, fps);
3097
3104
  const eventfulSeekStart = Date.now();
3105
+ session.captureStep = "seeking the timeline";
3098
3106
  await seekPageTimeline(session.page, frameTime, undefined);
3099
3107
  const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
3100
3108
  if (session.onBeforeCapture) {
3101
3109
  // The injector copies a video's own style only when its frame changes: inject at frameTime
3102
3110
  // first so the held frame carries frameTime's style, not the first sample's.
3103
3111
  const injectStart = Date.now();
3112
+ session.captureStep = "injecting video frames";
3104
3113
  await session.onBeforeCapture(session.page, frameTime, frameTime);
3105
3114
  totals.beforeCaptureMs += Date.now() - injectStart;
3106
3115
  }
@@ -3125,6 +3134,7 @@ async function captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan)
3125
3134
  accumulator.add(sample.buffer);
3126
3135
  }
3127
3136
  const restoreSeekStart = Date.now();
3137
+ session.captureStep = "seeking the timeline";
3128
3138
  await seekPageTimeline(session.page, frameTime, { suppressEvents: true });
3129
3139
  totals.seekMs += Date.now() - restoreSeekStart;
3130
3140
  const blended = accumulator.finish();
@@ -3396,6 +3406,7 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
3396
3406
  const startTime = Date.now();
3397
3407
  const fps = fpsToNumber(options.fps);
3398
3408
  const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
3409
+ session.captureStep = "capturing the frame";
3399
3410
  const { encodeResults, failedAt, error } = await produceDrawElementFrameBatch(page, quantized, options.width, options.height, options.quality ?? 80);
3400
3411
  // A later batch failure abandons this prefix. Its asynchronous encodes can
3401
3412
  // still reject while session cleanup shuts the worker down.
@@ -309,6 +309,7 @@ export declare function codecMayHaveAlpha(codec: string | undefined): boolean;
309
309
  export declare function decoderForCodec(codec: string | undefined): string;
310
310
  export declare function resolveFrameFormat(metadata: VideoMetadata, requested?: VideoFrameFormat): CacheFrameFormat;
311
311
  export declare function extractAllVideoFrames(videos: VideoElement[], baseDir: string, options: ExtractionOptions, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "extractCacheDir" | "extractCacheMaxBytes">>, compiledDir?: string): Promise<ExtractionResult>;
312
+ export declare function isVideoHiddenBeforeStreamStart(videoStreamLeadSeconds: number | undefined, mediaTime: number): boolean;
312
313
  export declare function getFrameAtTime(extracted: ExtractedFrames, globalTime: number, videoStart: number, loop?: boolean, mediaStart?: number): string | null;
313
314
  /**
314
315
  * Whether a media source is shorter than its `data-duration` slot by more than
@@ -742,9 +742,9 @@ outputDirOverride) {
742
742
  if (framePaths.size > 0 && duration > 0 && !options.finalFrameOnly && !options.frameRanges) {
743
743
  const expectedAtFps = extractionFrameCountForDuration(duration, normalizedFps, metadata.isVFR);
744
744
  if (expectedAtFps > 0 && framePaths.size <= expectedAtFps * 0.6) {
745
- const correctedDuration = startTime + framePaths.size / fps;
746
- if (correctedDuration < metadata.videoStreamDurationSeconds) {
747
- metadata.videoStreamDurationSeconds = correctedDuration;
745
+ const correctedStreamDuration = startTime + framePaths.size / fps - (metadata.videoStreamLeadSeconds ?? 0);
746
+ if (correctedStreamDuration < metadata.videoStreamDurationSeconds) {
747
+ metadata.videoStreamDurationSeconds = correctedStreamDuration;
748
748
  }
749
749
  }
750
750
  }
@@ -796,7 +796,7 @@ function resolveSegmentDuration(requested, mediaStart, sourceDuration) {
796
796
  export function resolvePlayableVideoDuration(metadata) {
797
797
  return Number.isFinite(metadata.videoStreamDurationSeconds) &&
798
798
  metadata.videoStreamDurationSeconds > 0
799
- ? metadata.videoStreamDurationSeconds
799
+ ? (metadata.videoStreamLeadSeconds ?? 0) + metadata.videoStreamDurationSeconds
800
800
  : metadata.durationSeconds;
801
801
  }
802
802
  function canHoldFinalFramePastEof(video) {
@@ -925,10 +925,12 @@ export async function resolveFinalFrameExtractionWindow(videoPath, video, metada
925
925
  if (!window.ensureFinalFrame)
926
926
  return window;
927
927
  const playableDuration = resolvePlayableVideoDuration(metadata);
928
- const finalFrameTimestamp = await extractFinalVideoFrameTimestamp(videoPath, {
929
- videoStreamDurationSeconds: playableDuration,
930
- videoStreamStartSeconds: metadata.videoStreamStartSeconds,
931
- }, signal);
928
+ const videoStreamLead = metadata.videoStreamLeadSeconds ?? 0;
929
+ const finalFrameTimestamp = videoStreamLead +
930
+ (await extractFinalVideoFrameTimestamp(videoPath, {
931
+ videoStreamDurationSeconds: playableDuration - videoStreamLead,
932
+ videoStreamStartSeconds: metadata.videoStreamStartSeconds,
933
+ }, signal));
932
934
  if (window.mediaStart < finalFrameTimestamp - 1e-9)
933
935
  return window;
934
936
  const sourceRemaining = playableDuration - video.mediaStart;
@@ -1781,6 +1783,11 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1781
1783
  phaseBreakdown: breakdown,
1782
1784
  };
1783
1785
  }
1786
+ const BROWSER_SHOWS_UPCOMING_FIRST_FRAME_WITHIN_SECONDS = 1;
1787
+ export function isVideoHiddenBeforeStreamStart(videoStreamLeadSeconds, mediaTime) {
1788
+ return ((videoStreamLeadSeconds ?? 0) - mediaTime >=
1789
+ BROWSER_SHOWS_UPCOMING_FIRST_FRAME_WITHIN_SECONDS - 1e-6);
1790
+ }
1784
1791
  function getFrameIndexAtTime(extracted, globalTime, videoStart, loop = false, mediaStart = 0, holdLastFrame = false, playbackRate = 1) {
1785
1792
  let localTime = Math.max(0, globalTime - videoStart);
1786
1793
  const normalizedPlaybackRate = normalizeRateSpec(playbackRate);
@@ -1803,6 +1810,9 @@ function getFrameIndexAtTime(extracted, globalTime, videoStart, loop = false, me
1803
1810
  const frameIndex = Math.floor(sourceTime * extracted.fps + 1e-9);
1804
1811
  if (frameIndex < 0 || extracted.totalFrames <= 0)
1805
1812
  return null;
1813
+ if (isVideoHiddenBeforeStreamStart(extracted.metadata.videoStreamLeadSeconds, mediaStart + sourceTime)) {
1814
+ return null;
1815
+ }
1806
1816
  if (frameIndex >= extracted.totalFrames) {
1807
1817
  return loop || holdLastFrame ? extracted.totalFrames - 1 : null;
1808
1818
  }
@@ -14,6 +14,8 @@ export interface VideoMetadata {
14
14
  * timestamps are absolute, so callers crossing those APIs must normalize by
15
15
  * this value. Absent only in legacy/manually-constructed metadata. */
16
16
  videoStreamStartSeconds?: number;
17
+ /** Seconds after the file's media time 0 (its earliest audio/video stream start) at which video begins. */
18
+ videoStreamLeadSeconds?: number;
17
19
  width: number;
18
20
  height: number;
19
21
  fps: number;
@@ -43,6 +45,8 @@ export interface AudioMetadata {
43
45
  channels: number;
44
46
  audioCodec: string;
45
47
  bitrate?: number;
48
+ /** Seconds after the file's media time 0 at which its last audio or video stream starts. */
49
+ latestStreamLeadSeconds?: number;
46
50
  }
47
51
  interface StillImageMetadata {
48
52
  width: number;
@@ -645,6 +645,7 @@ export async function extractMediaMetadata(filePath) {
645
645
  durationSeconds: containerDuration,
646
646
  videoStreamDurationSeconds: effectiveStreamDuration,
647
647
  videoStreamStartSeconds: streamStart,
648
+ videoStreamLeadSeconds: streamLeadSeconds(output, videoStream),
648
649
  width: videoStream.width || stillImage()?.width || 0,
649
650
  height: videoStream.height || stillImage()?.height || 0,
650
651
  fps,
@@ -784,6 +785,7 @@ export async function extractAudioMetadata(filePath, options) {
784
785
  channels: audioStream.channels || 2,
785
786
  audioCodec,
786
787
  bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
788
+ latestStreamLeadSeconds: latestStreamLeadSeconds(output),
787
789
  };
788
790
  })();
789
791
  if (options?.signal)
@@ -791,6 +793,22 @@ export async function extractAudioMetadata(filePath, options) {
791
793
  writeMetadataCache(audioMetadataCache, filePath, identity, probePromise);
792
794
  return probePromise;
793
795
  }
796
+ /** Chrome's media time 0 is the earliest of these; a stream starting later plays that much in. */
797
+ function audioVideoStreamStarts(output) {
798
+ return (output?.streams ?? [])
799
+ .filter((s) => (s.codec_type === "audio" || s.codec_type === "video") && s.disposition?.attached_pic !== 1)
800
+ .map((s) => parseFloat(s.start_time ?? ""))
801
+ .filter(Number.isFinite);
802
+ }
803
+ function streamLeadSeconds(output, stream) {
804
+ const starts = audioVideoStreamStarts(output);
805
+ const start = parseFloat(stream.start_time ?? "");
806
+ return Number.isFinite(start) && starts.length > 0 ? Math.max(0, start - Math.min(...starts)) : 0;
807
+ }
808
+ function latestStreamLeadSeconds(output) {
809
+ const starts = audioVideoStreamStarts(output);
810
+ return starts.length > 0 ? Math.max(...starts) - Math.min(...starts) : 0;
811
+ }
794
812
  function audioStartSeconds(stream, format) {
795
813
  const start = parseFloat(stream.start_time ?? format.start_time ?? "");
796
814
  return Number.isFinite(start) ? start : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.146",
3
+ "version": "0.8.148",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,8 +46,8 @@
46
46
  "linkedom": "^0.18.12",
47
47
  "puppeteer": "^25.8.0",
48
48
  "puppeteer-core": "^25.8.0",
49
- "@hyperframes/core": "^0.8.146",
50
- "@hyperframes/parsers": "^0.8.146"
49
+ "@hyperframes/core": "^0.8.148",
50
+ "@hyperframes/parsers": "^0.8.148"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^25.0.10",