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