@remotion/media 4.0.424 → 4.0.425

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.
Files changed (48) hide show
  1. package/dist/audio/allow-wait.js +15 -0
  2. package/dist/audio/audio-for-preview.js +304 -0
  3. package/dist/audio/audio-for-rendering.js +194 -0
  4. package/dist/audio/audio-preview-iterator.js +176 -0
  5. package/dist/audio/audio.js +20 -0
  6. package/dist/audio/props.js +1 -0
  7. package/dist/audio-extraction/audio-cache.js +66 -0
  8. package/dist/audio-extraction/audio-iterator.js +132 -0
  9. package/dist/audio-extraction/audio-manager.js +113 -0
  10. package/dist/audio-extraction/extract-audio.js +132 -0
  11. package/dist/audio-iterator-manager.js +228 -0
  12. package/dist/browser-can-use-webgl2.js +13 -0
  13. package/dist/caches.js +61 -0
  14. package/dist/calculate-playbacktime.js +4 -0
  15. package/dist/convert-audiodata/apply-volume.js +17 -0
  16. package/dist/convert-audiodata/combine-audiodata.js +23 -0
  17. package/dist/convert-audiodata/convert-audiodata.js +73 -0
  18. package/dist/convert-audiodata/resample-audiodata.js +94 -0
  19. package/dist/debug-overlay/preview-overlay.js +42 -0
  20. package/dist/extract-frame-and-audio.js +101 -0
  21. package/dist/get-sink.js +15 -0
  22. package/dist/get-time-in-seconds.js +40 -0
  23. package/dist/helpers/round-to-4-digits.js +4 -0
  24. package/dist/index.js +12 -0
  25. package/dist/is-type-of-error.js +20 -0
  26. package/dist/looped-frame.js +10 -0
  27. package/dist/media-player.js +431 -0
  28. package/dist/nonce-manager.js +13 -0
  29. package/dist/prewarm-iterator-for-looping.js +56 -0
  30. package/dist/render-timestamp-range.js +9 -0
  31. package/dist/show-in-timeline.js +31 -0
  32. package/dist/use-media-in-timeline.js +103 -0
  33. package/dist/video/props.js +1 -0
  34. package/dist/video/video-for-preview.js +331 -0
  35. package/dist/video/video-for-rendering.js +263 -0
  36. package/dist/video/video-preview-iterator.js +122 -0
  37. package/dist/video/video.js +35 -0
  38. package/dist/video-extraction/add-broadcast-channel-listener.js +125 -0
  39. package/dist/video-extraction/extract-frame-via-broadcast-channel.js +113 -0
  40. package/dist/video-extraction/extract-frame.js +85 -0
  41. package/dist/video-extraction/get-allocation-size.js +6 -0
  42. package/dist/video-extraction/get-frames-since-keyframe.js +108 -0
  43. package/dist/video-extraction/keyframe-bank.js +159 -0
  44. package/dist/video-extraction/keyframe-manager.js +206 -0
  45. package/dist/video-extraction/remember-actual-matroska-timestamps.js +19 -0
  46. package/dist/video-extraction/rotate-frame.js +34 -0
  47. package/dist/video-iterator-manager.js +109 -0
  48. package/package.json +4 -4
@@ -0,0 +1,85 @@
1
+ import { Internals } from 'remotion';
2
+ import { keyframeManager } from '../caches';
3
+ import { getSink } from '../get-sink';
4
+ import { getTimeInSeconds } from '../get-time-in-seconds';
5
+ const extractFrameInternal = async ({ src, timeInSeconds: unloopedTimeInSeconds, logLevel, loop, trimAfter, trimBefore, playbackRate, fps, maxCacheSize, }) => {
6
+ const sink = await getSink(src, logLevel);
7
+ const [video, mediaDurationInSecondsRaw] = await Promise.all([
8
+ sink.getVideo(),
9
+ loop ? sink.getDuration() : Promise.resolve(null),
10
+ ]);
11
+ const mediaDurationInSeconds = loop
12
+ ? mediaDurationInSecondsRaw
13
+ : null;
14
+ if (video === 'no-video-track') {
15
+ throw new Error(`No video track found for ${src}`);
16
+ }
17
+ if (video === 'cannot-decode') {
18
+ return { type: 'cannot-decode', durationInSeconds: mediaDurationInSeconds };
19
+ }
20
+ if (video === 'unknown-container-format') {
21
+ return { type: 'unknown-container-format' };
22
+ }
23
+ if (video === 'network-error') {
24
+ return { type: 'network-error' };
25
+ }
26
+ const timeInSeconds = getTimeInSeconds({
27
+ loop,
28
+ mediaDurationInSeconds,
29
+ unloopedTimeInSeconds,
30
+ src,
31
+ trimAfter,
32
+ playbackRate,
33
+ trimBefore,
34
+ fps,
35
+ ifNoMediaDuration: 'fail',
36
+ });
37
+ if (timeInSeconds === null) {
38
+ return {
39
+ type: 'success',
40
+ frame: null,
41
+ durationInSeconds: await sink.getDuration(),
42
+ };
43
+ }
44
+ // Must catch https://github.com/Vanilagy/mediabunny/issues/235
45
+ // https://discord.com/channels/@me/1127949286789881897/1455728482150518906
46
+ // Should be able to remove once upgraded to Chrome 145
47
+ try {
48
+ const keyframeBank = await keyframeManager.requestKeyframeBank({
49
+ packetSink: video.packetSink,
50
+ videoSampleSink: video.sampleSink,
51
+ timestamp: timeInSeconds,
52
+ src,
53
+ logLevel,
54
+ maxCacheSize,
55
+ });
56
+ if (keyframeBank === 'has-alpha') {
57
+ return {
58
+ type: 'cannot-decode-alpha',
59
+ durationInSeconds: await sink.getDuration(),
60
+ };
61
+ }
62
+ if (!keyframeBank) {
63
+ return {
64
+ type: 'success',
65
+ frame: null,
66
+ durationInSeconds: await sink.getDuration(),
67
+ };
68
+ }
69
+ const frame = await keyframeBank.getFrameFromTimestamp(timeInSeconds);
70
+ return {
71
+ type: 'success',
72
+ frame,
73
+ durationInSeconds: await sink.getDuration(),
74
+ };
75
+ }
76
+ catch (err) {
77
+ Internals.Log.info({ logLevel, tag: '@remotion/media' }, `Error decoding ${src} at time ${timeInSeconds}: ${err}`, err);
78
+ return { type: 'cannot-decode', durationInSeconds: mediaDurationInSeconds };
79
+ }
80
+ };
81
+ let queue = Promise.resolve(undefined);
82
+ export const extractFrame = (params) => {
83
+ queue = queue.then(() => extractFrameInternal(params));
84
+ return queue;
85
+ };
@@ -0,0 +1,6 @@
1
+ export const getAllocationSize = (sample) => {
2
+ if (sample.format === null) {
3
+ return sample.codedHeight * sample.codedWidth * 4;
4
+ }
5
+ return sample.allocationSize();
6
+ };
@@ -0,0 +1,108 @@
1
+ import { ALL_FORMATS, AudioSampleSink, EncodedPacketSink, Input, MATROSKA, UrlSource, VideoSampleSink, WEBM, } from 'mediabunny';
2
+ import { isNetworkError } from '../is-type-of-error';
3
+ import { makeKeyframeBank } from './keyframe-bank';
4
+ import { rememberActualMatroskaTimestamps } from './remember-actual-matroska-timestamps';
5
+ const getRetryDelay = (() => {
6
+ return null;
7
+ });
8
+ const getFormatOrNullOrNetworkError = async (input) => {
9
+ try {
10
+ return await input.getFormat();
11
+ }
12
+ catch (err) {
13
+ if (isNetworkError(err)) {
14
+ return 'network-error';
15
+ }
16
+ return null;
17
+ }
18
+ };
19
+ export const getSinks = async (src) => {
20
+ const input = new Input({
21
+ formats: ALL_FORMATS,
22
+ source: new UrlSource(src, {
23
+ getRetryDelay,
24
+ }),
25
+ });
26
+ const format = await getFormatOrNullOrNetworkError(input);
27
+ const isMatroska = format === MATROSKA || format === WEBM;
28
+ const getVideoSinks = async () => {
29
+ if (format === 'network-error') {
30
+ return 'network-error';
31
+ }
32
+ if (format === null) {
33
+ return 'unknown-container-format';
34
+ }
35
+ const videoTrack = await input.getPrimaryVideoTrack();
36
+ if (!videoTrack) {
37
+ return 'no-video-track';
38
+ }
39
+ const canDecode = await videoTrack.canDecode();
40
+ if (!canDecode) {
41
+ return 'cannot-decode';
42
+ }
43
+ return {
44
+ sampleSink: new VideoSampleSink(videoTrack),
45
+ packetSink: new EncodedPacketSink(videoTrack),
46
+ };
47
+ };
48
+ let videoSinksPromise = null;
49
+ const getVideoSinksPromise = () => {
50
+ if (videoSinksPromise) {
51
+ return videoSinksPromise;
52
+ }
53
+ videoSinksPromise = getVideoSinks();
54
+ return videoSinksPromise;
55
+ };
56
+ // audioSinksPromise is now a record indexed by audio track index
57
+ const audioSinksPromise = {};
58
+ const getAudioSinks = async (index) => {
59
+ if (format === null) {
60
+ return 'unknown-container-format';
61
+ }
62
+ if (format === 'network-error') {
63
+ return 'network-error';
64
+ }
65
+ const audioTracks = await input.getAudioTracks();
66
+ const audioTrack = audioTracks[index];
67
+ if (!audioTrack) {
68
+ return 'no-audio-track';
69
+ }
70
+ const canDecode = await audioTrack.canDecode();
71
+ if (!canDecode) {
72
+ return 'cannot-decode-audio';
73
+ }
74
+ return {
75
+ sampleSink: new AudioSampleSink(audioTrack),
76
+ };
77
+ };
78
+ const getAudioSinksPromise = (index) => {
79
+ if (audioSinksPromise[index]) {
80
+ return audioSinksPromise[index];
81
+ }
82
+ audioSinksPromise[index] = getAudioSinks(index);
83
+ return audioSinksPromise[index];
84
+ };
85
+ return {
86
+ getVideo: () => getVideoSinksPromise(),
87
+ getAudio: (index) => getAudioSinksPromise(index),
88
+ actualMatroskaTimestamps: rememberActualMatroskaTimestamps(isMatroska),
89
+ isMatroska,
90
+ getDuration: () => {
91
+ return input.computeDuration();
92
+ },
93
+ };
94
+ };
95
+ export const getFramesSinceKeyframe = async ({ packetSink, videoSampleSink, startPacket, logLevel, src, }) => {
96
+ const nextKeyPacket = await packetSink.getNextKeyPacket(startPacket, {
97
+ verifyKeyPackets: true,
98
+ });
99
+ const sampleIterator = videoSampleSink.samples(startPacket.timestamp, nextKeyPacket ? nextKeyPacket.timestamp : Infinity);
100
+ const keyframeBank = makeKeyframeBank({
101
+ startTimestampInSeconds: startPacket.timestamp,
102
+ endTimestampInSeconds: nextKeyPacket ? nextKeyPacket.timestamp : Infinity,
103
+ sampleIterator,
104
+ logLevel,
105
+ src,
106
+ });
107
+ return keyframeBank;
108
+ };
@@ -0,0 +1,159 @@
1
+ import { Internals } from 'remotion';
2
+ import { SAFE_BACK_WINDOW_IN_SECONDS } from '../caches';
3
+ import { roundTo4Digits } from '../helpers/round-to-4-digits';
4
+ import { renderTimestampRange } from '../render-timestamp-range';
5
+ import { getAllocationSize } from './get-allocation-size';
6
+ export const makeKeyframeBank = ({ startTimestampInSeconds, endTimestampInSeconds, sampleIterator, logLevel: parentLogLevel, src, }) => {
7
+ Internals.Log.verbose({ logLevel: parentLogLevel, tag: '@remotion/media' }, `Creating keyframe bank from ${startTimestampInSeconds}sec to ${endTimestampInSeconds}sec`);
8
+ const frames = {};
9
+ const frameTimestamps = [];
10
+ let lastUsed = Date.now();
11
+ let allocationSize = 0;
12
+ const deleteFramesBeforeTimestamp = ({ logLevel, timestampInSeconds, }) => {
13
+ const deletedTimestamps = [];
14
+ for (const frameTimestamp of frameTimestamps.slice()) {
15
+ const isLast = frameTimestamp === frameTimestamps[frameTimestamps.length - 1];
16
+ // Don't delete the last frame, since it may be the last one in the video!
17
+ if (isLast) {
18
+ continue;
19
+ }
20
+ if (frameTimestamp < timestampInSeconds) {
21
+ if (!frames[frameTimestamp]) {
22
+ continue;
23
+ }
24
+ allocationSize -= getAllocationSize(frames[frameTimestamp]);
25
+ frameTimestamps.splice(frameTimestamps.indexOf(frameTimestamp), 1);
26
+ frames[frameTimestamp].close();
27
+ delete frames[frameTimestamp];
28
+ deletedTimestamps.push(frameTimestamp);
29
+ }
30
+ }
31
+ if (deletedTimestamps.length > 0) {
32
+ Internals.Log.verbose({ logLevel, tag: '@remotion/media' }, `Deleted ${deletedTimestamps.length} frame${deletedTimestamps.length === 1 ? '' : 's'} ${renderTimestampRange(deletedTimestamps)} for src ${src} because it is lower than ${timestampInSeconds}. Remaining: ${renderTimestampRange(frameTimestamps)}`);
33
+ }
34
+ };
35
+ const hasDecodedEnoughForTimestamp = (timestamp) => {
36
+ const lastFrameTimestamp = frameTimestamps[frameTimestamps.length - 1];
37
+ if (!lastFrameTimestamp) {
38
+ return false;
39
+ }
40
+ const lastFrame = frames[lastFrameTimestamp];
41
+ // Don't decode more, will probably have to re-decode everything
42
+ if (!lastFrame) {
43
+ return true;
44
+ }
45
+ return (roundTo4Digits(lastFrame.timestamp + lastFrame.duration) >
46
+ roundTo4Digits(timestamp) + 0.001);
47
+ };
48
+ const addFrame = (frame) => {
49
+ if (frames[frame.timestamp]) {
50
+ allocationSize -= getAllocationSize(frames[frame.timestamp]);
51
+ frameTimestamps.splice(frameTimestamps.indexOf(frame.timestamp), 1);
52
+ frames[frame.timestamp].close();
53
+ delete frames[frame.timestamp];
54
+ }
55
+ frames[frame.timestamp] = frame;
56
+ frameTimestamps.push(frame.timestamp);
57
+ allocationSize += getAllocationSize(frame);
58
+ lastUsed = Date.now();
59
+ };
60
+ const ensureEnoughFramesForTimestamp = async (timestampInSeconds) => {
61
+ while (!hasDecodedEnoughForTimestamp(timestampInSeconds)) {
62
+ const sample = await sampleIterator.next();
63
+ if (sample.value) {
64
+ addFrame(sample.value);
65
+ }
66
+ if (sample.done) {
67
+ break;
68
+ }
69
+ deleteFramesBeforeTimestamp({
70
+ logLevel: parentLogLevel,
71
+ timestampInSeconds: timestampInSeconds - SAFE_BACK_WINDOW_IN_SECONDS,
72
+ });
73
+ }
74
+ lastUsed = Date.now();
75
+ };
76
+ const getFrameFromTimestamp = async (timestampInSeconds) => {
77
+ lastUsed = Date.now();
78
+ // If the requested timestamp is before the start of this bank, clamp it to the start.
79
+ // This matches Chrome's behavior: render the first available frame rather than showing black.
80
+ // Videos don't always start at timestamp 0 due to encoding artifacts, container format quirks,
81
+ // and keyframe positioning. Users have no control over this, so we clamp to the first frame.
82
+ // Test case: https://github.com/remotion-dev/remotion/issues/5915
83
+ let adjustedTimestamp = timestampInSeconds;
84
+ if (roundTo4Digits(timestampInSeconds) <
85
+ roundTo4Digits(startTimestampInSeconds)) {
86
+ adjustedTimestamp = startTimestampInSeconds;
87
+ }
88
+ // If we request a timestamp after the end of the video, return the last frame
89
+ // same behavior as <video>
90
+ if (roundTo4Digits(adjustedTimestamp) > roundTo4Digits(endTimestampInSeconds)) {
91
+ adjustedTimestamp = endTimestampInSeconds;
92
+ }
93
+ await ensureEnoughFramesForTimestamp(adjustedTimestamp);
94
+ for (let i = frameTimestamps.length - 1; i >= 0; i--) {
95
+ const sample = frames[frameTimestamps[i]];
96
+ if (!sample) {
97
+ return null;
98
+ }
99
+ if (roundTo4Digits(sample.timestamp) <= roundTo4Digits(adjustedTimestamp) ||
100
+ // Match 0.3333333333 to 0.33355555
101
+ // this does not satisfy the previous condition, since one rounds up and one rounds down
102
+ Math.abs(sample.timestamp - adjustedTimestamp) <= 0.001) {
103
+ return sample;
104
+ }
105
+ }
106
+ return null;
107
+ };
108
+ const hasTimestampInSecond = async (timestamp) => {
109
+ return (await getFrameFromTimestamp(timestamp)) !== null;
110
+ };
111
+ const prepareForDeletion = (logLevel) => {
112
+ Internals.Log.verbose({ logLevel, tag: '@remotion/media' }, `Preparing for deletion of keyframe bank from ${startTimestampInSeconds}sec to ${endTimestampInSeconds}sec`);
113
+ // Cleanup frames that have been extracted that might not have been retrieved yet
114
+ sampleIterator.return().then((result) => {
115
+ if (result.value) {
116
+ result.value.close();
117
+ }
118
+ return null;
119
+ });
120
+ let framesDeleted = 0;
121
+ for (const frameTimestamp of frameTimestamps) {
122
+ if (!frames[frameTimestamp]) {
123
+ continue;
124
+ }
125
+ allocationSize -= getAllocationSize(frames[frameTimestamp]);
126
+ frames[frameTimestamp].close();
127
+ delete frames[frameTimestamp];
128
+ framesDeleted++;
129
+ }
130
+ frameTimestamps.length = 0;
131
+ return { framesDeleted };
132
+ };
133
+ const getOpenFrameCount = () => {
134
+ return {
135
+ size: allocationSize,
136
+ timestamps: frameTimestamps,
137
+ };
138
+ };
139
+ const getLastUsed = () => {
140
+ return lastUsed;
141
+ };
142
+ let queue = Promise.resolve(undefined);
143
+ const keyframeBank = {
144
+ startTimestampInSeconds,
145
+ endTimestampInSeconds,
146
+ getFrameFromTimestamp: (timestamp) => {
147
+ queue = queue.then(() => getFrameFromTimestamp(timestamp));
148
+ return queue;
149
+ },
150
+ prepareForDeletion,
151
+ hasTimestampInSecond,
152
+ addFrame,
153
+ deleteFramesBeforeTimestamp,
154
+ src,
155
+ getOpenFrameCount,
156
+ getLastUsed,
157
+ };
158
+ return keyframeBank;
159
+ };
@@ -0,0 +1,206 @@
1
+ import { Internals } from 'remotion';
2
+ import { canBrowserUseWebGl2 } from '../browser-can-use-webgl2';
3
+ import { getTotalCacheStats, SAFE_BACK_WINDOW_IN_SECONDS } from '../caches';
4
+ import { renderTimestampRange } from '../render-timestamp-range';
5
+ import { getFramesSinceKeyframe } from './get-frames-since-keyframe';
6
+ export const makeKeyframeManager = () => {
7
+ // src => {[startTimestampInSeconds]: KeyframeBank
8
+ const sources = {};
9
+ const addKeyframeBank = ({ src, bank, startTimestampInSeconds, }) => {
10
+ sources[src] = sources[src] ?? {};
11
+ sources[src][startTimestampInSeconds] = bank;
12
+ };
13
+ const logCacheStats = async (logLevel) => {
14
+ let count = 0;
15
+ let totalSize = 0;
16
+ for (const src in sources) {
17
+ for (const bank in sources[src]) {
18
+ const v = await sources[src][bank];
19
+ const { size, timestamps } = v.getOpenFrameCount();
20
+ count += timestamps.length;
21
+ totalSize += size;
22
+ if (size === 0) {
23
+ continue;
24
+ }
25
+ Internals.Log.verbose({ logLevel, tag: '@remotion/media' }, `Open frames for src ${src}: ${renderTimestampRange(timestamps)}`);
26
+ }
27
+ }
28
+ Internals.Log.verbose({ logLevel, tag: '@remotion/media' }, `Video cache stats: ${count} open frames, ${totalSize} bytes`);
29
+ };
30
+ const getCacheStats = async () => {
31
+ let count = 0;
32
+ let totalSize = 0;
33
+ for (const src in sources) {
34
+ for (const bank in sources[src]) {
35
+ const v = await sources[src][bank];
36
+ const { timestamps, size } = v.getOpenFrameCount();
37
+ count += timestamps.length;
38
+ totalSize += size;
39
+ if (size === 0) {
40
+ continue;
41
+ }
42
+ }
43
+ }
44
+ return { count, totalSize };
45
+ };
46
+ const getTheKeyframeBankMostInThePast = async () => {
47
+ let mostInThePast = null;
48
+ let mostInThePastBank = null;
49
+ let numberOfBanks = 0;
50
+ for (const src in sources) {
51
+ for (const b in sources[src]) {
52
+ const bank = await sources[src][b];
53
+ const lastUsed = bank.getLastUsed();
54
+ if (mostInThePast === null || lastUsed < mostInThePast) {
55
+ mostInThePast = lastUsed;
56
+ mostInThePastBank = { src, bank };
57
+ }
58
+ numberOfBanks++;
59
+ }
60
+ }
61
+ if (!mostInThePastBank) {
62
+ throw new Error('No keyframe bank found');
63
+ }
64
+ return { mostInThePastBank, numberOfBanks };
65
+ };
66
+ const deleteOldestKeyframeBank = async (logLevel) => {
67
+ const { mostInThePastBank: { bank: mostInThePastBank, src: mostInThePastSrc }, numberOfBanks, } = await getTheKeyframeBankMostInThePast();
68
+ if (numberOfBanks < 2) {
69
+ return { finish: true };
70
+ }
71
+ if (mostInThePastBank) {
72
+ const { framesDeleted } = mostInThePastBank.prepareForDeletion(logLevel);
73
+ delete sources[mostInThePastSrc][mostInThePastBank.startTimestampInSeconds];
74
+ Internals.Log.verbose({ logLevel, tag: '@remotion/media' }, `Deleted ${framesDeleted} frames for src ${mostInThePastSrc} from ${mostInThePastBank.startTimestampInSeconds}sec to ${mostInThePastBank.endTimestampInSeconds}sec to free up memory.`);
75
+ }
76
+ return { finish: false };
77
+ };
78
+ const ensureToStayUnderMaxCacheSize = async (logLevel, maxCacheSize) => {
79
+ let cacheStats = await getTotalCacheStats();
80
+ while (cacheStats.totalSize > maxCacheSize) {
81
+ const { finish } = await deleteOldestKeyframeBank(logLevel);
82
+ if (finish) {
83
+ break;
84
+ }
85
+ Internals.Log.verbose({ logLevel, tag: '@remotion/media' }, 'Deleted oldest keyframe bank to stay under max cache size', (cacheStats.totalSize / 1024 / 1024).toFixed(1), 'out of', (maxCacheSize / 1024 / 1024).toFixed(1));
86
+ cacheStats = await getTotalCacheStats();
87
+ }
88
+ };
89
+ const clearKeyframeBanksBeforeTime = async ({ timestampInSeconds, src, logLevel, }) => {
90
+ const threshold = timestampInSeconds - SAFE_BACK_WINDOW_IN_SECONDS;
91
+ if (!sources[src]) {
92
+ return;
93
+ }
94
+ const banks = Object.keys(sources[src]);
95
+ for (const startTimeInSeconds of banks) {
96
+ const bank = await sources[src][startTimeInSeconds];
97
+ const { endTimestampInSeconds, startTimestampInSeconds } = bank;
98
+ if (endTimestampInSeconds < threshold) {
99
+ bank.prepareForDeletion(logLevel);
100
+ Internals.Log.verbose({ logLevel, tag: '@remotion/media' }, `[Video] Cleared frames for src ${src} from ${startTimestampInSeconds}sec to ${endTimestampInSeconds}sec`);
101
+ delete sources[src][startTimeInSeconds];
102
+ }
103
+ else {
104
+ bank.deleteFramesBeforeTimestamp({
105
+ timestampInSeconds: threshold,
106
+ logLevel,
107
+ });
108
+ }
109
+ }
110
+ await logCacheStats(logLevel);
111
+ };
112
+ const getKeyframeBankOrRefetch = async ({ packetSink, timestamp, videoSampleSink, src, logLevel, }) => {
113
+ // Try to get the keypacket at the requested timestamp.
114
+ // If it returns null (timestamp is before the first keypacket), fall back to the first packet.
115
+ // This matches mediabunny's internal behavior and handles videos that don't start at timestamp 0.
116
+ const startPacket = (await packetSink.getKeyPacket(timestamp, {
117
+ verifyKeyPackets: true,
118
+ })) ?? (await packetSink.getFirstPacket({ verifyKeyPackets: true }));
119
+ const hasAlpha = startPacket?.sideData.alpha;
120
+ if (hasAlpha && !canBrowserUseWebGl2()) {
121
+ return 'has-alpha';
122
+ }
123
+ if (!startPacket) {
124
+ // e.g. https://discord.com/channels/809501355504959528/809501355504959531/1424400511070765086
125
+ // The video has an offset and the first frame is at time 0.033sec
126
+ // we shall not crash here but handle it gracefully
127
+ return null;
128
+ }
129
+ const startTimestampInSeconds = startPacket.timestamp;
130
+ const existingBank = sources[src]?.[startTimestampInSeconds];
131
+ // Bank does not yet exist, we need to fetch
132
+ if (!existingBank) {
133
+ const newKeyframeBank = getFramesSinceKeyframe({
134
+ packetSink,
135
+ videoSampleSink,
136
+ startPacket,
137
+ logLevel,
138
+ src,
139
+ });
140
+ addKeyframeBank({ src, bank: newKeyframeBank, startTimestampInSeconds });
141
+ return newKeyframeBank;
142
+ }
143
+ // Bank exists and still has the frame we want
144
+ if (await (await existingBank).hasTimestampInSecond(timestamp)) {
145
+ return existingBank;
146
+ }
147
+ Internals.Log.verbose({ logLevel, tag: '@remotion/media' }, `Keyframe bank exists but frame at time ${timestamp} does not exist anymore.`);
148
+ // Bank exists but frames have already been evicted!
149
+ // First delete it entirely
150
+ await (await existingBank).prepareForDeletion(logLevel);
151
+ delete sources[src][startTimestampInSeconds];
152
+ // Then refetch
153
+ const replacementKeybank = getFramesSinceKeyframe({
154
+ packetSink,
155
+ videoSampleSink,
156
+ startPacket,
157
+ logLevel,
158
+ src,
159
+ });
160
+ addKeyframeBank({ src, bank: replacementKeybank, startTimestampInSeconds });
161
+ return replacementKeybank;
162
+ };
163
+ const requestKeyframeBank = async ({ packetSink, timestamp, videoSampleSink, src, logLevel, maxCacheSize, }) => {
164
+ await ensureToStayUnderMaxCacheSize(logLevel, maxCacheSize);
165
+ await clearKeyframeBanksBeforeTime({
166
+ timestampInSeconds: timestamp,
167
+ src,
168
+ logLevel,
169
+ });
170
+ const keyframeBank = await getKeyframeBankOrRefetch({
171
+ packetSink,
172
+ timestamp,
173
+ videoSampleSink,
174
+ src,
175
+ logLevel,
176
+ });
177
+ return keyframeBank;
178
+ };
179
+ const clearAll = async (logLevel) => {
180
+ const srcs = Object.keys(sources);
181
+ for (const src of srcs) {
182
+ const banks = Object.keys(sources[src]);
183
+ for (const startTimeInSeconds of banks) {
184
+ const bank = await sources[src][startTimeInSeconds];
185
+ bank.prepareForDeletion(logLevel);
186
+ delete sources[src][startTimeInSeconds];
187
+ }
188
+ }
189
+ };
190
+ let queue = Promise.resolve(undefined);
191
+ return {
192
+ requestKeyframeBank: ({ packetSink, timestamp, videoSampleSink, src, logLevel, maxCacheSize, }) => {
193
+ queue = queue.then(() => requestKeyframeBank({
194
+ packetSink,
195
+ timestamp,
196
+ videoSampleSink,
197
+ src,
198
+ logLevel,
199
+ maxCacheSize,
200
+ }));
201
+ return queue;
202
+ },
203
+ getCacheStats,
204
+ clearAll,
205
+ };
206
+ };
@@ -0,0 +1,19 @@
1
+ export const rememberActualMatroskaTimestamps = (isMatroska) => {
2
+ const observations = [];
3
+ const observeTimestamp = (startTime) => {
4
+ if (!isMatroska) {
5
+ return;
6
+ }
7
+ observations.push(startTime);
8
+ };
9
+ const getRealTimestamp = (observedTimestamp) => {
10
+ if (!isMatroska) {
11
+ return observedTimestamp;
12
+ }
13
+ return (observations.find((observation) => Math.abs(observedTimestamp - observation) < 0.001) ?? null);
14
+ };
15
+ return {
16
+ observeTimestamp,
17
+ getRealTimestamp,
18
+ };
19
+ };
@@ -0,0 +1,34 @@
1
+ export const rotateFrame = async ({ frame, rotation, }) => {
2
+ if (rotation === 0) {
3
+ const directBitmap = await createImageBitmap(frame);
4
+ frame.close();
5
+ return directBitmap;
6
+ }
7
+ const width = rotation === 90 || rotation === 270
8
+ ? frame.displayHeight
9
+ : frame.displayWidth;
10
+ const height = rotation === 90 || rotation === 270
11
+ ? frame.displayWidth
12
+ : frame.displayHeight;
13
+ const canvas = new OffscreenCanvas(width, height);
14
+ const ctx = canvas.getContext('2d');
15
+ if (!ctx) {
16
+ throw new Error('Could not get 2d context');
17
+ }
18
+ canvas.width = width;
19
+ canvas.height = height;
20
+ if (rotation === 90) {
21
+ ctx.translate(width, 0);
22
+ }
23
+ else if (rotation === 180) {
24
+ ctx.translate(width, height);
25
+ }
26
+ else if (rotation === 270) {
27
+ ctx.translate(0, height);
28
+ }
29
+ ctx.rotate(rotation * (Math.PI / 180));
30
+ ctx.drawImage(frame, 0, 0);
31
+ const bitmap = await createImageBitmap(canvas);
32
+ frame.close();
33
+ return bitmap;
34
+ };