@hyperframes/engine 0.8.147 → 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";
@@ -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.147",
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.147",
50
- "@hyperframes/parsers": "^0.8.147"
49
+ "@hyperframes/core": "^0.8.148",
50
+ "@hyperframes/parsers": "^0.8.148"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^25.0.10",