Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added packages/example/public/road-loop.mp4
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {Video} from '@remotion/media';
import {CalculateMetadataFunction, Composition, staticFile} from 'remotion';
// https://www.remotion.dev/docs/mediabunny/metadata
import {getMediaMetadata} from '../get-media-metadata';

const src = staticFile('road-loop.mp4');

export const calculateMetadataFn: CalculateMetadataFunction<
Record<string, unknown>
> = async () => {
const {durationInSeconds, dimensions, fps} = await getMediaMetadata(src);

return {
durationInFrames: Math.round(durationInSeconds * fps!) * 4,
fps: fps!,
width: dimensions!.width,
height: dimensions!.height,
};
};

export const Component = () => {
return <Video loop src={src} />;
};

export const NewPerfectlyLoopedVideoComp = () => {
return (
<Composition
component={Component}
id="NewPerfectlyLoopedVideo"
calculateMetadata={calculateMetadataFn}
/>
);
};

// In Root.tsx:
// <NewVideoComp />
2 changes: 2 additions & 0 deletions packages/example/src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ import {NewAudioExample} from './NewAudio/NewAudio';
import {ChangingTrimBeforeValue} from './OffthreadRemoteVideo/ChangingTrimBefore';
import {LoopedOffthreadRemoteVideo} from './OffthreadRemoteVideo/LoopedOffthreadRemoteVideo';
import {MultiChannelAudio} from './OffthreadRemoteVideo/MultiChannelAudio';
import {NewPerfectlyLoopedVideoComp} from './OffthreadRemoteVideo/NewPerfectlyLoopedVideo';
import {NewVideoComp} from './OffthreadRemoteVideo/NewRemoteVideo';
import {OffthreadRemoteSeries} from './OffthreadRemoteVideo/OffthreadRemoteSeries';
import {ParseAndDownloadMedia} from './ParseAndDownloadMedia';
Expand Down Expand Up @@ -781,6 +782,7 @@ export const Index: React.FC = () => {
/>
<OffthreadRemoteVideo />
<NewVideoComp />
<NewPerfectlyLoopedVideoComp />
<OffthreadRemoteSeries />
<LoopedOffthreadRemoteVideo />
<MultiChannelAudio />
Expand Down
120 changes: 120 additions & 0 deletions packages/media/src/audio-iterator-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const audioIteratorManager = ({
let audioBufferIterator: AudioIterator | null = null;
let audioIteratorsCreated = 0;

// secondary iterator for pre-warming loop audio
let loopTransitionIterator: AudioIterator | null = null;
let loopTransitionFirstChunk: WrappedAudioBuffer | null = null;
let loopSwapCount = 0;

const scheduleAudioChunk = ({
buffer,
mediaTimestamp,
Expand Down Expand Up @@ -224,10 +229,97 @@ export const audioIteratorManager = ({
}

if (audioSatisfyResult.type === 'ended') {
// current iterator ended - try prewarmed if available
if (loopTransitionIterator) {
audioBufferIterator?.destroy();
// swap the iterator with pre-warmed one
audioBufferIterator = loopTransitionIterator;
loopTransitionIterator = null;
loopSwapCount++;

if (loopTransitionFirstChunk) {
onAudioChunk({
getIsPlaying,
buffer: loopTransitionFirstChunk,
playbackRate,
scheduleAudioNode,
});
loopTransitionFirstChunk = null;
}

for (let i = 0; i < 2; i++) {
const result = await audioBufferIterator.getNext();

if (nonce.isStale()) {
return;
}

if (!result.value) {
break;
}

onAudioChunk({
getIsPlaying,
buffer: result.value,
playbackRate,
scheduleAudioNode,
});
}

return;
}

// Audio iterator has ended, but we're seeking to a valid time. This happens when looping - restart the iterator from the new position.
await startAudioIterator({
nonce,
playbackRate,
startFromSecond: newTime,
getIsPlaying,
scheduleAudioNode,
});
return;
}

if (audioSatisfyResult.type === 'not-satisfied') {
// Current iterator can't satisfy - try prewarmed if available
if (loopTransitionIterator) {
audioBufferIterator?.destroy();
audioBufferIterator = loopTransitionIterator;
loopTransitionIterator = null;
loopSwapCount++;

if (loopTransitionFirstChunk) {
onAudioChunk({
getIsPlaying,
buffer: loopTransitionFirstChunk,
playbackRate,
scheduleAudioNode,
});
loopTransitionFirstChunk = null;
}

for (let i = 0; i < 2; i++) {
const result = await audioBufferIterator.getNext();

if (nonce.isStale()) {
return;
}

if (!result.value) {
break;
}

onAudioChunk({
getIsPlaying,
buffer: result.value,
playbackRate,
scheduleAudioNode,
});
}

return;
}

await startAudioIterator({
nonce,
playbackRate,
Expand Down Expand Up @@ -331,6 +423,7 @@ export const audioIteratorManager = ({
},
seek,
getAudioIteratorsCreated: () => audioIteratorsCreated,
getLoopSwapCount: () => loopSwapCount,
setMuted: (newMuted: boolean) => {
muted = newMuted;
gainNode.gain.value = muted ? 0 : currentVolume;
Expand All @@ -340,6 +433,33 @@ export const audioIteratorManager = ({
gainNode.gain.value = muted ? 0 : currentVolume;
},
scheduleAudioChunk,
prepareLoopTransition: async ({
startTimeInSeconds,
}: {
startTimeInSeconds: number;
}) => {
// Idempotent - if we already have a prewarmed iterator, skip
if (loopTransitionIterator) {
return;
}

loopTransitionIterator = makeAudioIterator(audioSink, startTimeInSeconds);

try {
// Pre-warm the decoder by fetching the first chunk
// This initializes the AudioDecoder and starts decoding
// We store the chunk to schedule it later without losing audio data
const result = await loopTransitionIterator.getNext();
if (result.value) {
loopTransitionFirstChunk = result.value;
}
} catch (e) {
loopTransitionIterator?.destroy();
loopTransitionIterator = null;
loopTransitionFirstChunk = null;
throw e;
}
},
};
};

Expand Down
82 changes: 76 additions & 6 deletions packages/media/src/media-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ export class MediaPlayer {

private playing = false;
private loop = false;

private fps: number;

private trimBefore: number | undefined;
private trimAfter: number | undefined;

private totalDuration: number | undefined;
private mediaDurationInSeconds: number | undefined;

private debugOverlay = false;

Expand Down Expand Up @@ -192,7 +193,7 @@ export class MediaPlayer {
return {type: 'disposed'};
}

this.totalDuration = durationInSeconds;
this.mediaDurationInSeconds = durationInSeconds;

const audioTrack = audioTracks[this.audioStreamIndex] ?? null;

Expand Down Expand Up @@ -229,7 +230,7 @@ export class MediaPlayer {
loop: this.loop,
trimBefore: this.trimBefore,
trimAfter: this.trimAfter,
mediaDurationInSeconds: this.totalDuration,
mediaDurationInSeconds: this.mediaDurationInSeconds,
fps: this.fps,
ifNoMediaDuration: 'infinity',
src: this.src,
Expand Down Expand Up @@ -309,7 +310,7 @@ export class MediaPlayer {
loop: this.loop,
trimBefore: this.trimBefore,
trimAfter: this.trimAfter,
mediaDurationInSeconds: this.totalDuration ?? null,
mediaDurationInSeconds: this.mediaDurationInSeconds ?? null,
fps: this.fps,
ifNoMediaDuration: 'infinity',
src: this.src,
Expand All @@ -319,6 +320,13 @@ export class MediaPlayer {
throw new Error(`should have asserted that the time is not null`);
}

const shouldPrepareLoopTransition =
this.shouldPrepareLoopTransition(newTime);

if (shouldPrepareLoopTransition) {
this.prepareSeamlessLoop();
}

const nonce = this.nonceManager.createAsyncOperation();
await this.seekPromiseChain;

Expand All @@ -335,6 +343,7 @@ export class MediaPlayer {
}

const currentPlaybackTime = this.getPlaybackTime();

if (currentPlaybackTime === newTime) {
return;
}
Expand Down Expand Up @@ -362,7 +371,7 @@ export class MediaPlayer {
loop: this.loop,
trimBefore: this.trimBefore,
trimAfter: this.trimAfter,
mediaDurationInSeconds: this.totalDuration ?? null,
mediaDurationInSeconds: this.mediaDurationInSeconds ?? null,
fps: this.fps,
ifNoMediaDuration: 'infinity',
src: this.src,
Expand Down Expand Up @@ -428,7 +437,7 @@ export class MediaPlayer {
loop: this.loop,
trimBefore: this.trimBefore,
trimAfter: this.trimAfter,
mediaDurationInSeconds: this.totalDuration ?? null,
mediaDurationInSeconds: this.mediaDurationInSeconds ?? null,
fps: this.fps,
ifNoMediaDuration: 'infinity',
src: this.src,
Expand Down Expand Up @@ -518,6 +527,67 @@ export class MediaPlayer {
this.loop = loop;
}

private getLoopDuration(): number {
if (!this.mediaDurationInSeconds) {
return 0;
}

const loopDurationInSeconds =
Internals.calculateMediaDuration({
trimBefore: this.trimBefore,
trimAfter: this.trimAfter,
mediaDurationInFrames: this.mediaDurationInSeconds * this.fps,
playbackRate: 1,
}) / this.fps;

return loopDurationInSeconds;
}

private shouldPrepareLoopTransition(mediaTime: number): boolean {
if (!this.loop || !this.mediaDurationInSeconds) {
return false;
}

const thresholdInSeconds = 1.0;
const loopStartMediaTime = (this.trimBefore ?? 0) / this.fps;

const loopDuration = this.getLoopDuration();

const positionInLoop = mediaTime - loopStartMediaTime;
const timeUntilEndSec = loopDuration - positionInLoop;

return timeUntilEndSec <= thresholdInSeconds && timeUntilEndSec > 0;
}

private prepareSeamlessLoop(): void {
if (!this.mediaDurationInSeconds) {
return;
}

const startTimeInSeconds = getTimeInSeconds({
unloopedTimeInSeconds: 0,
playbackRate: this.playbackRate,
loop: this.loop,
trimBefore: this.trimBefore,
trimAfter: this.trimAfter,
mediaDurationInSeconds: this.mediaDurationInSeconds,
fps: this.fps,
ifNoMediaDuration: 'infinity',
src: this.src,
});

if (startTimeInSeconds === null) {
return;
}

this.audioIteratorManager?.prepareLoopTransition({
startTimeInSeconds,
});
this.videoIteratorManager?.prepareLoopTransition({
startTime: startTimeInSeconds,
});
}

public async dispose(): Promise<void> {
if (this.initializationPromise) {
try {
Expand Down
Loading
Loading