@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 +1 -1
- package/dist/index.js +1 -1
- package/dist/services/audioFxRender.js +1 -8
- package/dist/services/audioMixer.d.ts +2 -18
- package/dist/services/audioMixer.js +35 -123
- package/dist/services/audioVolumeEnvelope.d.ts +1 -1
- package/dist/services/audioVolumeEnvelope.js +1 -1
- package/dist/services/frameCapture.d.ts +3 -0
- package/dist/services/frameCapture.js +11 -0
- package/dist/services/videoFrameExtractor.d.ts +1 -0
- package/dist/services/videoFrameExtractor.js +18 -8
- package/dist/utils/ffprobe.d.ts +4 -0
- package/dist/utils/ffprobe.js +18 -0
- package/package.json +3 -3
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
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
"-
|
|
585
|
-
"
|
|
586
|
-
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
* `
|
|
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
|
-
* `
|
|
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
|
|
746
|
-
if (
|
|
747
|
-
metadata.videoStreamDurationSeconds =
|
|
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
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
}
|
package/dist/utils/ffprobe.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/ffprobe.js
CHANGED
|
@@ -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.
|
|
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.
|
|
50
|
-
"@hyperframes/parsers": "^0.8.
|
|
49
|
+
"@hyperframes/core": "^0.8.148",
|
|
50
|
+
"@hyperframes/parsers": "^0.8.148"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^25.0.10",
|