@remotion/media 4.0.351
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/LICENSE.md +49 -0
- package/README.md +18 -0
- package/dist/audio/audio-for-rendering.d.ts +3 -0
- package/dist/audio/audio-for-rendering.js +99 -0
- package/dist/audio/audio.d.ts +3 -0
- package/dist/audio/audio.js +60 -0
- package/dist/audio/props.d.ts +29 -0
- package/dist/audio/props.js +1 -0
- package/dist/audio-extraction/audio-cache.d.ts +11 -0
- package/dist/audio-extraction/audio-cache.js +58 -0
- package/dist/audio-extraction/audio-iterator.d.ts +24 -0
- package/dist/audio-extraction/audio-iterator.js +106 -0
- package/dist/audio-extraction/audio-manager.d.ts +64 -0
- package/dist/audio-extraction/audio-manager.js +83 -0
- package/dist/audio-extraction/extract-audio.d.ts +10 -0
- package/dist/audio-extraction/extract-audio.js +77 -0
- package/dist/audio-for-rendering.d.ts +3 -0
- package/dist/audio-for-rendering.js +94 -0
- package/dist/audio.d.ts +3 -0
- package/dist/audio.js +60 -0
- package/dist/audiodata-to-array.d.ts +0 -0
- package/dist/audiodata-to-array.js +1 -0
- package/dist/caches.d.ts +86 -0
- package/dist/caches.js +15 -0
- package/dist/convert-audiodata/combine-audiodata.d.ts +2 -0
- package/dist/convert-audiodata/combine-audiodata.js +40 -0
- package/dist/convert-audiodata/convert-audiodata.d.ts +16 -0
- package/dist/convert-audiodata/convert-audiodata.js +53 -0
- package/dist/convert-audiodata/data-types.d.ts +1 -0
- package/dist/convert-audiodata/data-types.js +22 -0
- package/dist/convert-audiodata/is-planar-format.d.ts +1 -0
- package/dist/convert-audiodata/is-planar-format.js +3 -0
- package/dist/convert-audiodata/log-audiodata.d.ts +1 -0
- package/dist/convert-audiodata/log-audiodata.js +8 -0
- package/dist/convert-audiodata/resample-audiodata.d.ts +10 -0
- package/dist/convert-audiodata/resample-audiodata.js +72 -0
- package/dist/convert-audiodata/trim-audiodata.d.ts +0 -0
- package/dist/convert-audiodata/trim-audiodata.js +1 -0
- package/dist/deserialized-audiodata.d.ts +15 -0
- package/dist/deserialized-audiodata.js +26 -0
- package/dist/esm/index.mjs +14487 -0
- package/dist/extract-audio.d.ts +7 -0
- package/dist/extract-audio.js +98 -0
- package/dist/extract-frame-and-audio.d.ts +15 -0
- package/dist/extract-frame-and-audio.js +28 -0
- package/dist/extract-frame-via-broadcast-channel.d.ts +15 -0
- package/dist/extract-frame-via-broadcast-channel.js +104 -0
- package/dist/extract-frame.d.ts +27 -0
- package/dist/extract-frame.js +21 -0
- package/dist/extrct-audio.d.ts +7 -0
- package/dist/extrct-audio.js +94 -0
- package/dist/get-frames-since-keyframe.d.ts +22 -0
- package/dist/get-frames-since-keyframe.js +41 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/keyframe-bank.d.ts +25 -0
- package/dist/keyframe-bank.js +120 -0
- package/dist/keyframe-manager.d.ts +23 -0
- package/dist/keyframe-manager.js +170 -0
- package/dist/log.d.ts +10 -0
- package/dist/log.js +33 -0
- package/dist/new-video-for-rendering.d.ts +3 -0
- package/dist/new-video-for-rendering.js +108 -0
- package/dist/new-video.d.ts +3 -0
- package/dist/new-video.js +37 -0
- package/dist/props.d.ts +29 -0
- package/dist/props.js +1 -0
- package/dist/remember-actual-matroska-timestamps.d.ts +4 -0
- package/dist/remember-actual-matroska-timestamps.js +19 -0
- package/dist/serialize-videoframe.d.ts +0 -0
- package/dist/serialize-videoframe.js +1 -0
- package/dist/video/props.d.ts +28 -0
- package/dist/video/props.js +1 -0
- package/dist/video/video-for-rendering.d.ts +3 -0
- package/dist/video/video-for-rendering.js +115 -0
- package/dist/video/video.d.ts +3 -0
- package/dist/video/video.js +37 -0
- package/dist/video-extraction/extract-frame-via-broadcast-channel.d.ts +16 -0
- package/dist/video-extraction/extract-frame-via-broadcast-channel.js +107 -0
- package/dist/video-extraction/extract-frame.d.ts +9 -0
- package/dist/video-extraction/extract-frame.js +21 -0
- package/dist/video-extraction/get-frames-since-keyframe.d.ts +23 -0
- package/dist/video-extraction/get-frames-since-keyframe.js +42 -0
- package/dist/video-extraction/keyframe-bank.d.ts +25 -0
- package/dist/video-extraction/keyframe-bank.js +121 -0
- package/dist/video-extraction/keyframe-manager.d.ts +23 -0
- package/dist/video-extraction/keyframe-manager.js +171 -0
- package/dist/video-extraction/remember-actual-matroska-timestamps.d.ts +5 -0
- package/dist/video-extraction/remember-actual-matroska-timestamps.js +19 -0
- package/dist/video-for-rendering.d.ts +3 -0
- package/dist/video-for-rendering.js +108 -0
- package/dist/video.d.ts +3 -0
- package/dist/video.js +37 -0
- package/package.json +55 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PcmS16AudioData } from './convert-audiodata/convert-audiodata';
|
|
2
|
+
export declare const extractAudio: ({ src, timeInSeconds, durationInSeconds, volume, }: {
|
|
3
|
+
src: string;
|
|
4
|
+
timeInSeconds: number;
|
|
5
|
+
durationInSeconds: number;
|
|
6
|
+
volume: number;
|
|
7
|
+
}) => Promise<PcmS16AudioData | null>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { combineAudioDataAndClosePrevious } from './convert-audiodata/combine-audiodata';
|
|
2
|
+
import { convertAudioData } from './convert-audiodata/convert-audiodata';
|
|
3
|
+
import { TARGET_NUMBER_OF_CHANNELS, TARGET_SAMPLE_RATE, } from './convert-audiodata/resample-audiodata';
|
|
4
|
+
import { sinkPromises } from './extract-frame';
|
|
5
|
+
import { getSinks } from './video-extraction/get-frames-since-keyframe';
|
|
6
|
+
export const extractAudio = async ({ src, timeInSeconds, durationInSeconds, volume, }) => {
|
|
7
|
+
console.time('extractAudio');
|
|
8
|
+
if (!sinkPromises[src]) {
|
|
9
|
+
sinkPromises[src] = getSinks(src);
|
|
10
|
+
}
|
|
11
|
+
const { audio, actualMatroskaTimestamps, isMatroska } = await sinkPromises[src];
|
|
12
|
+
if (audio === null) {
|
|
13
|
+
console.timeEnd('extractAudio');
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
// https://discord.com/channels/@me/1409810025844838481/1415028953093111870
|
|
17
|
+
// Audio frames might have dependencies on previous and next frames so we need to decode a bit more
|
|
18
|
+
// and then discard it.
|
|
19
|
+
// The worst case seems to be FLAC files with a 65'535 sample window, which would be 1486.0ms at 44.1Khz.
|
|
20
|
+
// So let's set a threshold of 1.5 seconds.
|
|
21
|
+
const extraThreshold = 1.5;
|
|
22
|
+
// Matroska timestamps are not accurate unless we start from the beginning
|
|
23
|
+
// So for matroska, we need to decode all samples :(
|
|
24
|
+
// https://github.com/Vanilagy/mediabunny/issues/105
|
|
25
|
+
const sampleIterator = audio.sampleSink.samples(isMatroska ? 0 : Math.max(0, timeInSeconds - extraThreshold), timeInSeconds + durationInSeconds);
|
|
26
|
+
const samples = [];
|
|
27
|
+
for await (const sample of sampleIterator) {
|
|
28
|
+
const realTimestamp = actualMatroskaTimestamps.getRealTimestamp(sample.timestamp);
|
|
29
|
+
if (realTimestamp !== null && realTimestamp !== sample.timestamp) {
|
|
30
|
+
sample.setTimestamp(realTimestamp);
|
|
31
|
+
}
|
|
32
|
+
actualMatroskaTimestamps.observeTimestamp(sample.timestamp);
|
|
33
|
+
actualMatroskaTimestamps.observeTimestamp(sample.timestamp + sample.duration);
|
|
34
|
+
if (sample.timestamp + sample.duration - 0.0000000001 <= timeInSeconds) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (sample.timestamp >= timeInSeconds + durationInSeconds - 0.0000000001) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
samples.push(sample);
|
|
41
|
+
}
|
|
42
|
+
const audioDataArray = [];
|
|
43
|
+
for (let i = 0; i < samples.length; i++) {
|
|
44
|
+
const sample = samples[i];
|
|
45
|
+
// Less than 1 sample would be included - we did not need it after all!
|
|
46
|
+
if (Math.abs(sample.timestamp - (timeInSeconds + durationInSeconds)) *
|
|
47
|
+
sample.sampleRate <
|
|
48
|
+
1) {
|
|
49
|
+
sample.close();
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Less than 1 sample would be included - we did not need it after all!
|
|
53
|
+
if (sample.timestamp + sample.duration <= timeInSeconds) {
|
|
54
|
+
sample.close();
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const isFirstSample = i === 0;
|
|
58
|
+
const isLastSample = i === samples.length - 1;
|
|
59
|
+
const audioDataRaw = sample.toAudioData();
|
|
60
|
+
// amount of samples to shave from start and end
|
|
61
|
+
let trimStartInSeconds = 0;
|
|
62
|
+
let trimEndInSeconds = 0;
|
|
63
|
+
// TODO: Apply playback rate
|
|
64
|
+
// TODO: Apply tone frequency
|
|
65
|
+
if (isFirstSample) {
|
|
66
|
+
trimStartInSeconds = timeInSeconds - sample.timestamp;
|
|
67
|
+
}
|
|
68
|
+
if (isLastSample) {
|
|
69
|
+
trimEndInSeconds =
|
|
70
|
+
// clamp to 0 in case the audio ends early
|
|
71
|
+
Math.max(0, sample.timestamp +
|
|
72
|
+
sample.duration -
|
|
73
|
+
(timeInSeconds + durationInSeconds));
|
|
74
|
+
}
|
|
75
|
+
const audioData = convertAudioData({
|
|
76
|
+
audioData: audioDataRaw,
|
|
77
|
+
newSampleRate: TARGET_SAMPLE_RATE,
|
|
78
|
+
trimStartInSeconds,
|
|
79
|
+
trimEndInSeconds,
|
|
80
|
+
targetNumberOfChannels: TARGET_NUMBER_OF_CHANNELS,
|
|
81
|
+
volume,
|
|
82
|
+
});
|
|
83
|
+
audioDataRaw.close();
|
|
84
|
+
if (audioData.numberOfFrames === 0) {
|
|
85
|
+
sample.close();
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
audioDataArray.push(audioData);
|
|
89
|
+
sample.close();
|
|
90
|
+
}
|
|
91
|
+
if (audioDataArray.length === 0) {
|
|
92
|
+
console.timeEnd('extractAudio');
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const combined = combineAudioDataAndClosePrevious(audioDataArray);
|
|
96
|
+
console.timeEnd('extractAudio');
|
|
97
|
+
return combined;
|
|
98
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LogLevel } from 'remotion';
|
|
2
|
+
import type { PcmS16AudioData } from './convert-audiodata/convert-audiodata';
|
|
3
|
+
export declare const extractFrameAndAudio: ({ src, timeInSeconds, logLevel, durationInSeconds, includeAudio, includeVideo, volume, loop, }: {
|
|
4
|
+
src: string;
|
|
5
|
+
timeInSeconds: number;
|
|
6
|
+
logLevel: LogLevel;
|
|
7
|
+
durationInSeconds: number;
|
|
8
|
+
includeAudio: boolean;
|
|
9
|
+
includeVideo: boolean;
|
|
10
|
+
volume: number;
|
|
11
|
+
loop: boolean;
|
|
12
|
+
}) => Promise<{
|
|
13
|
+
frame: VideoFrame | null;
|
|
14
|
+
audio: PcmS16AudioData | null;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { extractAudio } from './audio-extraction/extract-audio';
|
|
2
|
+
import { extractFrame } from './video-extraction/extract-frame';
|
|
3
|
+
export const extractFrameAndAudio = async ({ src, timeInSeconds, logLevel, durationInSeconds, includeAudio, includeVideo, volume, loop, }) => {
|
|
4
|
+
const [frame, audio] = await Promise.all([
|
|
5
|
+
includeVideo
|
|
6
|
+
? extractFrame({
|
|
7
|
+
src,
|
|
8
|
+
timeInSeconds,
|
|
9
|
+
logLevel,
|
|
10
|
+
loop,
|
|
11
|
+
})
|
|
12
|
+
: null,
|
|
13
|
+
includeAudio
|
|
14
|
+
? extractAudio({
|
|
15
|
+
src,
|
|
16
|
+
timeInSeconds,
|
|
17
|
+
durationInSeconds,
|
|
18
|
+
volume,
|
|
19
|
+
logLevel,
|
|
20
|
+
loop,
|
|
21
|
+
})
|
|
22
|
+
: null,
|
|
23
|
+
]);
|
|
24
|
+
return {
|
|
25
|
+
frame: frame?.toVideoFrame() ?? null,
|
|
26
|
+
audio,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PcmS16AudioData } from './convert-audiodata/convert-audiodata';
|
|
2
|
+
import type { LogLevel } from './log';
|
|
3
|
+
export declare const extractFrameViaBroadcastChannel: ({ src, timeInSeconds, logLevel, durationInSeconds, includeAudio, includeVideo, isClientSideRendering, volume, }: {
|
|
4
|
+
src: string;
|
|
5
|
+
timeInSeconds: number;
|
|
6
|
+
durationInSeconds: number;
|
|
7
|
+
logLevel: LogLevel;
|
|
8
|
+
includeAudio: boolean;
|
|
9
|
+
includeVideo: boolean;
|
|
10
|
+
isClientSideRendering: boolean;
|
|
11
|
+
volume: number;
|
|
12
|
+
}) => Promise<{
|
|
13
|
+
frame: ImageBitmap | VideoFrame | null;
|
|
14
|
+
audio: PcmS16AudioData | null;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { extractFrameAndAudio } from './extract-frame-and-audio';
|
|
2
|
+
// Doesn't exist in studio
|
|
3
|
+
if (window.remotion_broadcastChannel && window.remotion_isMainTab) {
|
|
4
|
+
window.remotion_broadcastChannel.addEventListener('message', async (event) => {
|
|
5
|
+
const data = event.data;
|
|
6
|
+
if (data.type === 'request') {
|
|
7
|
+
try {
|
|
8
|
+
const { frame, audio } = await extractFrameAndAudio({
|
|
9
|
+
src: data.src,
|
|
10
|
+
timeInSeconds: data.timeInSeconds,
|
|
11
|
+
logLevel: data.logLevel,
|
|
12
|
+
durationInSeconds: data.durationInSeconds,
|
|
13
|
+
includeAudio: data.includeAudio,
|
|
14
|
+
includeVideo: data.includeVideo,
|
|
15
|
+
volume: data.volume,
|
|
16
|
+
});
|
|
17
|
+
const videoFrame = frame;
|
|
18
|
+
const imageBitmap = videoFrame
|
|
19
|
+
? await createImageBitmap(videoFrame)
|
|
20
|
+
: null;
|
|
21
|
+
if (videoFrame) {
|
|
22
|
+
videoFrame.close();
|
|
23
|
+
}
|
|
24
|
+
const response = {
|
|
25
|
+
type: 'response-success',
|
|
26
|
+
id: data.id,
|
|
27
|
+
frame: imageBitmap,
|
|
28
|
+
audio,
|
|
29
|
+
};
|
|
30
|
+
window.remotion_broadcastChannel.postMessage(response);
|
|
31
|
+
videoFrame?.close();
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const response = {
|
|
35
|
+
type: 'response-error',
|
|
36
|
+
id: data.id,
|
|
37
|
+
errorStack: error.stack ?? 'No stack trace',
|
|
38
|
+
};
|
|
39
|
+
window.remotion_broadcastChannel.postMessage(response);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
throw new Error('Invalid message: ' + JSON.stringify(data));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export const extractFrameViaBroadcastChannel = ({ src, timeInSeconds, logLevel, durationInSeconds, includeAudio, includeVideo, isClientSideRendering, volume, }) => {
|
|
48
|
+
if (isClientSideRendering || window.remotion_isMainTab) {
|
|
49
|
+
return extractFrameAndAudio({
|
|
50
|
+
logLevel,
|
|
51
|
+
src,
|
|
52
|
+
timeInSeconds,
|
|
53
|
+
durationInSeconds,
|
|
54
|
+
includeAudio,
|
|
55
|
+
includeVideo,
|
|
56
|
+
volume,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const requestId = crypto.randomUUID();
|
|
60
|
+
const resolvePromise = new Promise((resolve, reject) => {
|
|
61
|
+
const onMessage = (event) => {
|
|
62
|
+
const data = event.data;
|
|
63
|
+
if (!data) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (data.type === 'response-success' && data.id === requestId) {
|
|
67
|
+
resolve({
|
|
68
|
+
frame: data.frame ? data.frame : null,
|
|
69
|
+
audio: data.audio ? data.audio : null,
|
|
70
|
+
});
|
|
71
|
+
window.remotion_broadcastChannel.removeEventListener('message', onMessage);
|
|
72
|
+
}
|
|
73
|
+
else if (data.type === 'response-error' && data.id === requestId) {
|
|
74
|
+
reject(data.errorStack);
|
|
75
|
+
window.remotion_broadcastChannel.removeEventListener('message', onMessage);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
window.remotion_broadcastChannel.addEventListener('message', onMessage);
|
|
79
|
+
});
|
|
80
|
+
const request = {
|
|
81
|
+
type: 'request',
|
|
82
|
+
src,
|
|
83
|
+
timeInSeconds,
|
|
84
|
+
id: requestId,
|
|
85
|
+
logLevel,
|
|
86
|
+
durationInSeconds,
|
|
87
|
+
includeAudio,
|
|
88
|
+
includeVideo,
|
|
89
|
+
volume,
|
|
90
|
+
};
|
|
91
|
+
window.remotion_broadcastChannel.postMessage(request);
|
|
92
|
+
let timeoutId;
|
|
93
|
+
return Promise.race([
|
|
94
|
+
resolvePromise.then((res) => {
|
|
95
|
+
clearTimeout(timeoutId);
|
|
96
|
+
return res;
|
|
97
|
+
}),
|
|
98
|
+
new Promise((_, reject) => {
|
|
99
|
+
timeoutId = setTimeout(() => {
|
|
100
|
+
reject(new Error(`Timeout while extracting frame at time ${timeInSeconds}sec from ${src}`));
|
|
101
|
+
}, Math.max(3000, window.remotion_puppeteerTimeout - 5000));
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { LogLevel } from './log';
|
|
2
|
+
import { type GetSink } from './video-extraction/get-frames-since-keyframe';
|
|
3
|
+
export declare const keyframeManager: {
|
|
4
|
+
requestKeyframeBank: ({ packetSink, timestamp, videoSampleSink, src, logLevel, }: {
|
|
5
|
+
timestamp: number;
|
|
6
|
+
packetSink: import("mediabunny").EncodedPacketSink;
|
|
7
|
+
videoSampleSink: import("mediabunny").VideoSampleSink;
|
|
8
|
+
src: string;
|
|
9
|
+
logLevel: LogLevel;
|
|
10
|
+
}) => Promise<import("./video-extraction/keyframe-bank").KeyframeBank>;
|
|
11
|
+
addKeyframeBank: ({ src, bank, startTimestampInSeconds, }: {
|
|
12
|
+
src: string;
|
|
13
|
+
bank: Promise<import("./video-extraction/keyframe-bank").KeyframeBank>;
|
|
14
|
+
startTimestampInSeconds: number;
|
|
15
|
+
}) => void;
|
|
16
|
+
getCacheStats: () => Promise<{
|
|
17
|
+
count: number;
|
|
18
|
+
totalSize: number;
|
|
19
|
+
}>;
|
|
20
|
+
clearAll: () => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
export declare const sinkPromises: Record<string, Promise<GetSink>>;
|
|
23
|
+
export declare const extractFrame: ({ src, timeInSeconds, logLevel, }: {
|
|
24
|
+
src: string;
|
|
25
|
+
timeInSeconds: number;
|
|
26
|
+
logLevel: LogLevel;
|
|
27
|
+
}) => Promise<import("mediabunny").VideoSample | null>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getSinks, } from './video-extraction/get-frames-since-keyframe';
|
|
2
|
+
import { makeKeyframeManager } from './video-extraction/keyframe-manager';
|
|
3
|
+
export const keyframeManager = makeKeyframeManager();
|
|
4
|
+
export const sinkPromises = {};
|
|
5
|
+
export const extractFrame = async ({ src, timeInSeconds, logLevel, }) => {
|
|
6
|
+
console.time('extractFrame');
|
|
7
|
+
if (!sinkPromises[src]) {
|
|
8
|
+
sinkPromises[src] = getSinks(src);
|
|
9
|
+
}
|
|
10
|
+
const { video } = await sinkPromises[src];
|
|
11
|
+
const keyframeBank = await keyframeManager.requestKeyframeBank({
|
|
12
|
+
packetSink: video.packetSink,
|
|
13
|
+
videoSampleSink: video.sampleSink,
|
|
14
|
+
timestamp: timeInSeconds,
|
|
15
|
+
src,
|
|
16
|
+
logLevel,
|
|
17
|
+
});
|
|
18
|
+
const frame = await keyframeBank.getFrameFromTimestamp(timeInSeconds);
|
|
19
|
+
console.timeEnd('extractFrame');
|
|
20
|
+
return frame;
|
|
21
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PcmS16AudioData } from './convert-audiodata/convert-audiodata';
|
|
2
|
+
export declare const extractAudio: ({ src, timeInSeconds, durationInSeconds, volume, }: {
|
|
3
|
+
src: string;
|
|
4
|
+
timeInSeconds: number;
|
|
5
|
+
durationInSeconds: number;
|
|
6
|
+
volume: number;
|
|
7
|
+
}) => Promise<PcmS16AudioData | null>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { combineAudioDataAndClosePrevious } from './convert-audiodata/combine-audiodata';
|
|
2
|
+
import { convertAudioData } from './convert-audiodata/convert-audiodata';
|
|
3
|
+
import { TARGET_NUMBER_OF_CHANNELS, TARGET_SAMPLE_RATE, } from './convert-audiodata/resample-audiodata';
|
|
4
|
+
import { sinkPromises } from './extract-frame';
|
|
5
|
+
import { getSinks } from './get-frames-since-keyframe';
|
|
6
|
+
export const extractAudio = async ({ src, timeInSeconds, durationInSeconds, volume, }) => {
|
|
7
|
+
if (!sinkPromises[src]) {
|
|
8
|
+
sinkPromises[src] = getSinks(src);
|
|
9
|
+
}
|
|
10
|
+
const { audio, actualMatroskaTimestamps, isMatroska } = await sinkPromises[src];
|
|
11
|
+
if (audio === null) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
// https://discord.com/channels/@me/1409810025844838481/1415028953093111870
|
|
15
|
+
// Audio frames might have dependencies on previous and next frames so we need to decode a bit more
|
|
16
|
+
// and then discard it.
|
|
17
|
+
// The worst case seems to be FLAC files with a 65'535 sample window, which would be 1486.0ms at 44.1Khz.
|
|
18
|
+
// So let's set a threshold of 1.5 seconds.
|
|
19
|
+
const extraThreshold = 1.5;
|
|
20
|
+
// Matroska timestamps are not accurate unless we start from the beginning
|
|
21
|
+
// So for matroska, we need to decode all samples :(
|
|
22
|
+
// https://github.com/Vanilagy/mediabunny/issues/105
|
|
23
|
+
const sampleIterator = audio.sampleSink.samples(isMatroska ? 0 : Math.max(0, timeInSeconds - extraThreshold), timeInSeconds + durationInSeconds);
|
|
24
|
+
const samples = [];
|
|
25
|
+
for await (const sample of sampleIterator) {
|
|
26
|
+
const realTimestamp = actualMatroskaTimestamps.getRealTimestamp(sample.timestamp);
|
|
27
|
+
if (realTimestamp !== null && realTimestamp !== sample.timestamp) {
|
|
28
|
+
sample.setTimestamp(realTimestamp);
|
|
29
|
+
}
|
|
30
|
+
actualMatroskaTimestamps.observeTimestamp(sample.timestamp);
|
|
31
|
+
actualMatroskaTimestamps.observeTimestamp(sample.timestamp + sample.duration);
|
|
32
|
+
if (sample.timestamp + sample.duration - 0.0000000001 <= timeInSeconds) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (sample.timestamp >= timeInSeconds + durationInSeconds - 0.0000000001) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
samples.push(sample);
|
|
39
|
+
}
|
|
40
|
+
const audioDataArray = [];
|
|
41
|
+
for (let i = 0; i < samples.length; i++) {
|
|
42
|
+
const sample = samples[i];
|
|
43
|
+
// Less than 1 sample would be included - we did not need it after all!
|
|
44
|
+
if (Math.abs(sample.timestamp - (timeInSeconds + durationInSeconds)) *
|
|
45
|
+
sample.sampleRate <
|
|
46
|
+
1) {
|
|
47
|
+
sample.close();
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// Less than 1 sample would be included - we did not need it after all!
|
|
51
|
+
if (sample.timestamp + sample.duration <= timeInSeconds) {
|
|
52
|
+
sample.close();
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const isFirstSample = i === 0;
|
|
56
|
+
const isLastSample = i === samples.length - 1;
|
|
57
|
+
const audioDataRaw = sample.toAudioData();
|
|
58
|
+
// amount of samples to shave from start and end
|
|
59
|
+
let trimStartInSeconds = 0;
|
|
60
|
+
let trimEndInSeconds = 0;
|
|
61
|
+
// TODO: Apply playback rate
|
|
62
|
+
// TODO: Apply tone frequency
|
|
63
|
+
if (isFirstSample) {
|
|
64
|
+
trimStartInSeconds = timeInSeconds - sample.timestamp;
|
|
65
|
+
}
|
|
66
|
+
if (isLastSample) {
|
|
67
|
+
trimEndInSeconds =
|
|
68
|
+
// clamp to 0 in case the audio ends early
|
|
69
|
+
Math.max(0, sample.timestamp +
|
|
70
|
+
sample.duration -
|
|
71
|
+
(timeInSeconds + durationInSeconds));
|
|
72
|
+
}
|
|
73
|
+
const audioData = convertAudioData({
|
|
74
|
+
audioData: audioDataRaw,
|
|
75
|
+
newSampleRate: TARGET_SAMPLE_RATE,
|
|
76
|
+
trimStartInSeconds,
|
|
77
|
+
trimEndInSeconds,
|
|
78
|
+
targetNumberOfChannels: TARGET_NUMBER_OF_CHANNELS,
|
|
79
|
+
volume,
|
|
80
|
+
});
|
|
81
|
+
audioDataRaw.close();
|
|
82
|
+
if (audioData.numberOfFrames === 0) {
|
|
83
|
+
sample.close();
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
audioDataArray.push(audioData);
|
|
87
|
+
sample.close();
|
|
88
|
+
}
|
|
89
|
+
if (audioDataArray.length === 0) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const combined = combineAudioDataAndClosePrevious(audioDataArray);
|
|
93
|
+
return combined;
|
|
94
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { EncodedPacket } from 'mediabunny';
|
|
2
|
+
import { AudioSampleSink, EncodedPacketSink, VideoSampleSink } from 'mediabunny';
|
|
3
|
+
export declare const getSinks: (src: string) => Promise<{
|
|
4
|
+
video: {
|
|
5
|
+
sampleSink: VideoSampleSink;
|
|
6
|
+
packetSink: EncodedPacketSink;
|
|
7
|
+
};
|
|
8
|
+
audio: {
|
|
9
|
+
sampleSink: AudioSampleSink;
|
|
10
|
+
} | null;
|
|
11
|
+
actualMatroskaTimestamps: {
|
|
12
|
+
observeTimestamp: (startTime: number) => void;
|
|
13
|
+
getRealTimestamp: (observedTimestamp: number) => number | null;
|
|
14
|
+
};
|
|
15
|
+
isMatroska: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
export type GetSink = Awaited<ReturnType<typeof getSinks>>;
|
|
18
|
+
export declare const getFramesSinceKeyframe: ({ packetSink, videoSampleSink, startPacket, }: {
|
|
19
|
+
packetSink: EncodedPacketSink;
|
|
20
|
+
videoSampleSink: VideoSampleSink;
|
|
21
|
+
startPacket: EncodedPacket;
|
|
22
|
+
}) => Promise<any>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ALL_FORMATS, AudioSampleSink, EncodedPacketSink, Input, MATROSKA, UrlSource, VideoSampleSink, } from 'mediabunny';
|
|
2
|
+
import { makeKeyframeBank } from './keyframe-bank';
|
|
3
|
+
import { rememberActualMatroskaTimestamps } from './remember-actual-matroska-timestamps';
|
|
4
|
+
export const getSinks = async (src) => {
|
|
5
|
+
const input = new Input({
|
|
6
|
+
formats: ALL_FORMATS,
|
|
7
|
+
source: new UrlSource(src),
|
|
8
|
+
});
|
|
9
|
+
const format = await input.getFormat();
|
|
10
|
+
const videoTrack = await input.getPrimaryVideoTrack();
|
|
11
|
+
if (!videoTrack) {
|
|
12
|
+
throw new Error(`No video track found for ${src}`);
|
|
13
|
+
}
|
|
14
|
+
const audioTrack = await input.getPrimaryAudioTrack();
|
|
15
|
+
const isMatroska = format === MATROSKA;
|
|
16
|
+
return {
|
|
17
|
+
video: {
|
|
18
|
+
sampleSink: new VideoSampleSink(videoTrack),
|
|
19
|
+
packetSink: new EncodedPacketSink(videoTrack),
|
|
20
|
+
},
|
|
21
|
+
audio: audioTrack
|
|
22
|
+
? {
|
|
23
|
+
sampleSink: new AudioSampleSink(audioTrack),
|
|
24
|
+
}
|
|
25
|
+
: null,
|
|
26
|
+
actualMatroskaTimestamps: rememberActualMatroskaTimestamps(isMatroska),
|
|
27
|
+
isMatroska,
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
export const getFramesSinceKeyframe = async ({ packetSink, videoSampleSink, startPacket, }) => {
|
|
31
|
+
const nextKeyPacket = await packetSink.getNextKeyPacket(startPacket, {
|
|
32
|
+
verifyKeyPackets: false,
|
|
33
|
+
});
|
|
34
|
+
const sampleIterator = videoSampleSink.samples(startPacket.timestamp, nextKeyPacket ? nextKeyPacket.timestamp : Infinity);
|
|
35
|
+
const keyframeBank = makeKeyframeBank({
|
|
36
|
+
startTimestampInSeconds: startPacket.timestamp,
|
|
37
|
+
endTimestampInSeconds: nextKeyPacket ? nextKeyPacket.timestamp : Infinity,
|
|
38
|
+
sampleIterator,
|
|
39
|
+
});
|
|
40
|
+
return keyframeBank;
|
|
41
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { VideoSample } from 'mediabunny';
|
|
2
|
+
import type { LogLevel } from './log';
|
|
3
|
+
export type KeyframeBank = {
|
|
4
|
+
startTimestampInSeconds: number;
|
|
5
|
+
endTimestampInSeconds: number;
|
|
6
|
+
getFrameFromTimestamp: (timestamp: number) => Promise<VideoSample | null>;
|
|
7
|
+
prepareForDeletion: () => Promise<void>;
|
|
8
|
+
deleteFramesBeforeTimestamp: ({ logLevel, src, timestampInSeconds, }: {
|
|
9
|
+
timestampInSeconds: number;
|
|
10
|
+
logLevel: LogLevel;
|
|
11
|
+
src: string;
|
|
12
|
+
}) => void;
|
|
13
|
+
hasTimestampInSecond: (timestamp: number) => Promise<boolean>;
|
|
14
|
+
addFrame: (frame: VideoSample) => void;
|
|
15
|
+
getOpenFrameCount: () => {
|
|
16
|
+
size: number;
|
|
17
|
+
timestamps: number[];
|
|
18
|
+
};
|
|
19
|
+
getLastUsed: () => number;
|
|
20
|
+
};
|
|
21
|
+
export declare const makeKeyframeBank: ({ startTimestampInSeconds, endTimestampInSeconds, sampleIterator, }: {
|
|
22
|
+
startTimestampInSeconds: number;
|
|
23
|
+
endTimestampInSeconds: number;
|
|
24
|
+
sampleIterator: AsyncGenerator<VideoSample, void, unknown>;
|
|
25
|
+
}) => KeyframeBank;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Log } from './log';
|
|
2
|
+
// Round to only 4 digits, because WebM has a timescale of 1_000, e.g. framer.webm
|
|
3
|
+
const roundTo4Digits = (timestamp) => {
|
|
4
|
+
return Math.round(timestamp * 1000) / 1000;
|
|
5
|
+
};
|
|
6
|
+
export const makeKeyframeBank = ({ startTimestampInSeconds, endTimestampInSeconds, sampleIterator, }) => {
|
|
7
|
+
const frames = {};
|
|
8
|
+
const frameTimestamps = [];
|
|
9
|
+
let lastUsed = Date.now();
|
|
10
|
+
let alloctionSize = 0;
|
|
11
|
+
const hasDecodedEnoughForTimestamp = (timestamp) => {
|
|
12
|
+
const lastFrameTimestamp = frameTimestamps[frameTimestamps.length - 1];
|
|
13
|
+
if (!lastFrameTimestamp) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const lastFrame = frames[lastFrameTimestamp];
|
|
17
|
+
// Don't decode more, will probably have to re-decode everything
|
|
18
|
+
if (!lastFrame) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return (roundTo4Digits(lastFrame.timestamp + lastFrame.duration) >
|
|
22
|
+
roundTo4Digits(timestamp));
|
|
23
|
+
};
|
|
24
|
+
const addFrame = (frame) => {
|
|
25
|
+
frames[frame.timestamp] = frame;
|
|
26
|
+
frameTimestamps.push(frame.timestamp);
|
|
27
|
+
alloctionSize += frame.allocationSize();
|
|
28
|
+
lastUsed = Date.now();
|
|
29
|
+
};
|
|
30
|
+
const ensureEnoughFramesForTimestamp = async (timestamp) => {
|
|
31
|
+
while (!hasDecodedEnoughForTimestamp(timestamp)) {
|
|
32
|
+
const sample = await sampleIterator.next();
|
|
33
|
+
if (sample.value) {
|
|
34
|
+
addFrame(sample.value);
|
|
35
|
+
}
|
|
36
|
+
if (sample.done) {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
lastUsed = Date.now();
|
|
41
|
+
};
|
|
42
|
+
const getFrameFromTimestamp = async (timestampInSeconds) => {
|
|
43
|
+
lastUsed = Date.now();
|
|
44
|
+
if (timestampInSeconds < startTimestampInSeconds) {
|
|
45
|
+
return Promise.reject(new Error(`Timestamp is before start timestamp (requested: ${timestampInSeconds}sec, start: ${startTimestampInSeconds})`));
|
|
46
|
+
}
|
|
47
|
+
if (timestampInSeconds > endTimestampInSeconds) {
|
|
48
|
+
return Promise.reject(new Error(`Timestamp is after end timestamp (requested: ${timestampInSeconds}sec, end: ${endTimestampInSeconds})`));
|
|
49
|
+
}
|
|
50
|
+
await ensureEnoughFramesForTimestamp(timestampInSeconds);
|
|
51
|
+
for (let i = frameTimestamps.length - 1; i >= 0; i--) {
|
|
52
|
+
const sample = frames[frameTimestamps[i]];
|
|
53
|
+
if (!sample) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (roundTo4Digits(sample.timestamp) <= roundTo4Digits(timestampInSeconds)) {
|
|
57
|
+
return sample;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw new Error('No frame found for timestamp ' + timestampInSeconds);
|
|
61
|
+
};
|
|
62
|
+
const hasTimestampInSecond = async (timestamp) => {
|
|
63
|
+
return (await getFrameFromTimestamp(timestamp)) !== null;
|
|
64
|
+
};
|
|
65
|
+
const prepareForDeletion = async () => {
|
|
66
|
+
// Cleanup frames that have been extracted that might not have been retrieved yet
|
|
67
|
+
const { value } = await sampleIterator.return();
|
|
68
|
+
if (value) {
|
|
69
|
+
value.close();
|
|
70
|
+
}
|
|
71
|
+
for (const frameTimestamp of frameTimestamps) {
|
|
72
|
+
if (!frames[frameTimestamp]) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
alloctionSize -= frames[frameTimestamp].allocationSize();
|
|
76
|
+
frames[frameTimestamp].close();
|
|
77
|
+
delete frames[frameTimestamp];
|
|
78
|
+
}
|
|
79
|
+
frameTimestamps.length = 0;
|
|
80
|
+
};
|
|
81
|
+
const deleteFramesBeforeTimestamp = ({ logLevel, src, timestampInSeconds, }) => {
|
|
82
|
+
for (const frameTimestamp of frameTimestamps) {
|
|
83
|
+
const isLast = frameTimestamp === frameTimestamps[frameTimestamps.length - 1];
|
|
84
|
+
// Don't delete the last frame, since it may be the last one in the video!
|
|
85
|
+
if (isLast) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (frameTimestamp < timestampInSeconds) {
|
|
89
|
+
if (!frames[frameTimestamp]) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
alloctionSize -= frames[frameTimestamp].allocationSize();
|
|
93
|
+
frames[frameTimestamp].close();
|
|
94
|
+
delete frames[frameTimestamp];
|
|
95
|
+
Log.verbose(logLevel, `[Video] Deleted frame ${frameTimestamp} for src ${src}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const getOpenFrameCount = () => {
|
|
100
|
+
return {
|
|
101
|
+
size: alloctionSize,
|
|
102
|
+
timestamps: frameTimestamps,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
const getLastUsed = () => {
|
|
106
|
+
return lastUsed;
|
|
107
|
+
};
|
|
108
|
+
const keyframeBank = {
|
|
109
|
+
startTimestampInSeconds,
|
|
110
|
+
endTimestampInSeconds,
|
|
111
|
+
getFrameFromTimestamp,
|
|
112
|
+
prepareForDeletion,
|
|
113
|
+
hasTimestampInSecond,
|
|
114
|
+
addFrame,
|
|
115
|
+
deleteFramesBeforeTimestamp,
|
|
116
|
+
getOpenFrameCount,
|
|
117
|
+
getLastUsed,
|
|
118
|
+
};
|
|
119
|
+
return keyframeBank;
|
|
120
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EncodedPacketSink, VideoSampleSink } from 'mediabunny';
|
|
2
|
+
import { type KeyframeBank } from './keyframe-bank';
|
|
3
|
+
import type { LogLevel } from './log';
|
|
4
|
+
export declare const makeKeyframeManager: () => {
|
|
5
|
+
requestKeyframeBank: ({ packetSink, timestamp, videoSampleSink, src, logLevel, }: {
|
|
6
|
+
timestamp: number;
|
|
7
|
+
packetSink: EncodedPacketSink;
|
|
8
|
+
videoSampleSink: VideoSampleSink;
|
|
9
|
+
src: string;
|
|
10
|
+
logLevel: LogLevel;
|
|
11
|
+
}) => Promise<KeyframeBank>;
|
|
12
|
+
addKeyframeBank: ({ src, bank, startTimestampInSeconds, }: {
|
|
13
|
+
src: string;
|
|
14
|
+
bank: Promise<KeyframeBank>;
|
|
15
|
+
startTimestampInSeconds: number;
|
|
16
|
+
}) => void;
|
|
17
|
+
getCacheStats: () => Promise<{
|
|
18
|
+
count: number;
|
|
19
|
+
totalSize: number;
|
|
20
|
+
}>;
|
|
21
|
+
clearAll: () => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
export type KeyframeManager = Awaited<ReturnType<typeof makeKeyframeManager>>;
|