Skip to content
138 changes: 116 additions & 22 deletions packages/core/src/delay-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,36 @@ if (typeof window !== 'undefined') {
window.remotion_delayRenderHandles = [];
}

type TimeoutSuspensionState = {
active: number;
startedAt: number;
elapsed: number;
handles: Map<number, {suspend: () => () => void; cancel: () => void}>;
};

const timeoutSuspensions = new WeakMap<
DelayRenderScope,
TimeoutSuspensionState
>();

export const getDelayRenderSuspendedTime = (
scope: DelayRenderScope,
): number => {
const state = timeoutSuspensions.get(scope);
return state
? state.elapsed + (state.active > 0 ? Date.now() - state.startedAt : 0)
: 0;
};

export const suspendDelayRenderTimeout = (
scope: DelayRenderScope,
handle: number,
): (() => void) => {
return (
timeoutSuspensions.get(scope)?.handles.get(handle)?.suspend() ?? (() => {})
);
};

const defaultTimeout = 30000;

export type DelayRenderOptions = {
Expand Down Expand Up @@ -89,32 +119,95 @@ export const delayRenderInternal = ({
defaultTimeout) - 2000,
);
const retriesLeft = (options?.retries ?? 0) - (scope.remotion_attempt - 1);
let remaining = timeoutToUse;
let startedAt = Date.now();
let suspensionDepth = 0;
let registered = true;
const state = timeoutSuspensions.get(scope) ?? {
active: 0,
startedAt: 0,
elapsed: 0,
handles: new Map(),
};
timeoutSuspensions.set(scope, state);
const endSuspension = () => {
state.active--;
if (state.active === 0) {
state.elapsed += Date.now() - state.startedAt;
}
};

const cancel = () => {
if (!registered) {
return;
}

registered = false;
if (suspensionDepth > 0) {
endSuspension();
}

state.handles.delete(handle);
};

const onTimeout = () => {
cancel();
const message = [
`A delayRender()`,
label ? `"${label}"` : null,
`was called but not cleared after ${timeoutToUse}ms. See https://remotion.dev/docs/timeout for help.`,
retriesLeft > 0 ? DELAY_RENDER_RETRIES_LEFT + retriesLeft : null,
retriesLeft > 0 ? DELAY_RENDER_RETRY_TOKEN : null,
DELAY_RENDER_CALLSTACK_TOKEN,
called,
]
.filter(truthy)
.join(' ');

// in client-side rendering, don't throw (would be uncaught from setTimeout)
if (environment.isClientSideRendering) {
scope.remotion_cancelledError = getErrorStackWithMessage(
Error(message),
);
} else {
cancelRenderInternal(scope, Error(message));
}
};

scope.remotion_delayRenderTimeouts[handle] = {
label: label ?? null,
startTime: Date.now(),
timeout: setTimeout(() => {
const message = [
`A delayRender()`,
label ? `"${label}"` : null,
`was called but not cleared after ${timeoutToUse}ms. See https://remotion.dev/docs/timeout for help.`,
retriesLeft > 0 ? DELAY_RENDER_RETRIES_LEFT + retriesLeft : null,
retriesLeft > 0 ? DELAY_RENDER_RETRY_TOKEN : null,
DELAY_RENDER_CALLSTACK_TOKEN,
called,
]
.filter(truthy)
.join(' ');

// in client-side rendering, don't throw (would be uncaught from setTimeout)
if (environment.isClientSideRendering) {
scope.remotion_cancelledError = getErrorStackWithMessage(
Error(message),
);
} else {
cancelRenderInternal(scope, Error(message));
}
}, timeoutToUse),
timeout: setTimeout(onTimeout, remaining),
};
state.handles.set(handle, {
cancel,
suspend: () => {
if (suspensionDepth++ === 0) {
clearTimeout(scope.remotion_delayRenderTimeouts[handle].timeout);
remaining = Math.max(0, remaining - (Date.now() - startedAt));
if (state.active++ === 0) {
state.startedAt = Date.now();
}
}

let resumed = false;
return () => {
if (resumed || !registered) {
return;
}

resumed = true;
if (--suspensionDepth === 0) {
endSuspension();
startedAt = Date.now();
scope.remotion_delayRenderTimeouts[handle].timeout = setTimeout(
onTimeout,
remaining,
);
}
};
},
});
}

scope.remotion_renderReady = false;
Expand Down Expand Up @@ -176,6 +269,7 @@ export const continueRenderInternal = ({
const timeoutEntry = scope.remotion_delayRenderTimeouts[handle];
if (handleExists && environment.isRendering && timeoutEntry) {
const {label, startTime, timeout} = timeoutEntry;
timeoutSuspensions.get(scope)?.handles.get(handle)?.cancel();
clearTimeout(timeout);
const message = [
label ? `"${label}"` : 'A handle',
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ import {createElementSourceProxy} from './create-element-source-proxy.js';
import * as CSSUtils from './default-css.js';
import {OBJECTFIT_CONTAIN_CLASS_NAME} from './default-css.js';
import {DefaultPremountContext} from './DefaultPremountContext.js';
import {
getDelayRenderSuspendedTime,
suspendDelayRenderTimeout,
} from './delay-render.js';
import {
EditorPropsContext,
EditorPropsProvider,
Expand Down Expand Up @@ -353,6 +357,8 @@ const compositionSelectorRef = createRef<{
// Mark them as Internals so use don't assume this is public
// API and are less likely to use it
Comment thread
alec-watts marked this conversation as resolved.
export const Internals = {
getDelayRenderSuspendedTime,
suspendDelayRenderTimeout,
OptimisticSequenceDeletion,
usePendingSequenceDeletions,
createElementSourceProxy,
Expand Down
2 changes: 1 addition & 1 deletion packages/example/e2e/captions-inspector.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ test.describe('captions inspector', () => {
);

const captionsSequence = page
.getByText('<AnimatedCaptions>', {exact: true})
.getByRole('group', {name: 'AnimatedCaptions', exact: true})
.first();
const pageBreakAfterFirstCaption = page.getByRole('button', {
name: 'Add page break after caption 1',
Expand Down
9 changes: 9 additions & 0 deletions packages/media/src/audio/audio-for-rendering.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export const AudioForRendering: React.FC<AudioProps> = ({

const maxCacheSize = useMaxMediaCacheSize(logLevel);
const mediaCache = useRenderMediaCache(logLevel);
const delayRenderScope = useContext(Internals.DelayRenderContextType);

const audioEnabled = Internals.useAudioEnabled();
const {isMutedForPlayback, shouldUseAudio} = Internals.useMediaAudioState({
Expand All @@ -117,6 +118,11 @@ export const AudioForRendering: React.FC<AudioProps> = ({
timeoutInMilliseconds: delayRenderTimeoutInMilliseconds ?? undefined,
});

const stopTracking =
environment.isClientSideRendering && delayRenderScope
? mediaCache.trackRenderHandle(delayRenderScope, newHandle)
: () => {};

extractFrameViaBroadcastChannel({
sampleRate,
src,
Expand Down Expand Up @@ -261,6 +267,7 @@ export const AudioForRendering: React.FC<AudioProps> = ({
});
}

stopTracking();
continueRender(newHandle);
})
.catch((error) => {
Expand All @@ -273,6 +280,7 @@ export const AudioForRendering: React.FC<AudioProps> = ({

return () => {
cancelled = true;
stopTracking();
continueRender(newHandle);
unregisterRenderAsset(id);
};
Expand Down Expand Up @@ -310,6 +318,7 @@ export const AudioForRendering: React.FC<AudioProps> = ({
credentials,
initialRequestInit,
mediaCache,
delayRenderScope,
]);

if (replaceWithHtml5Audio) {
Expand Down
43 changes: 41 additions & 2 deletions packages/media/src/caches.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import {Internals, type LogLevel} from 'remotion';
import {Internals, type DelayRenderScope, type LogLevel} from 'remotion';
import {makeAudioManager} from './audio-extraction/audio-manager';
import {makeSinkManager} from './get-sink';
import {getMaxVideoCacheSize} from './max-cache-size';
Expand All @@ -10,7 +10,26 @@ import {makeKeyframeManager} from './video-extraction/keyframe-manager';
export const getSafeWindowOfMonotonicity = (fps: number) => (0.2 * 30) / fps;

export const makeMediaCache = () => {
const sinkManager = makeSinkManager();
let offlineWaits = 0;
const renderHandles = new Set<{
scope: DelayRenderScope;
handle: number;
resume: (() => void) | null;
}>();
const sinkManager = makeSinkManager((waiting) => {
offlineWaits += waiting ? 1 : -1;
for (const entry of renderHandles) {
if (offlineWaits > 0 && entry.resume === null) {
entry.resume = Internals.suspendDelayRenderTimeout(
entry.scope,
entry.handle,
);
} else if (offlineWaits === 0 && entry.resume !== null) {
entry.resume();
entry.resume = null;
}
}
});
const managerInstances: {
keyframe: ReturnType<typeof makeKeyframeManager> | null;
audio: ReturnType<typeof makeAudioManager> | null;
Expand Down Expand Up @@ -50,6 +69,21 @@ export const makeMediaCache = () => {

return {
sinkManager,
trackRenderHandle: (scope: DelayRenderScope, handle: number) => {
const entry = {
scope,
handle,
resume:
offlineWaits > 0
? Internals.suspendDelayRenderTimeout(scope, handle)
: null,
};
renderHandles.add(entry);
return () => {
entry.resume?.();
renderHandles.delete(entry);
};
},
keyframeManager: keyframeManagerInstance,
audioManager: audioManagerInstance,
getTotalCacheStats: getCacheStats,
Expand All @@ -62,6 +96,11 @@ export const makeMediaCache = () => {
}

disposed = true;
for (const entry of renderHandles) {
entry.resume?.();
}

renderHandles.clear();
try {
keyframeManagerInstance.dispose(logLevel);
} finally {
Expand Down
11 changes: 10 additions & 1 deletion packages/media/src/get-shared-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {ALL_FORMATS, Input, UrlSource} from 'mediabunny';
import {Internals, type LogLevel} from 'remotion';
import {getDurationOrCompute} from './get-duration-or-compute';
import {getMaxSourceCacheSize} from './max-cache-size';
import {makeOfflineMediaFetch} from './offline-media-fetch';
import {
getMediaRequestInitFingerprint,
normalizeMediaRequestInit,
Expand Down Expand Up @@ -65,8 +66,10 @@ export const acquireSharedInput = ({
const lease = Internals.globalMediaResourceManager.acquire<Input>({
key: cacheKey,
create: () => {
const recovery = makeOfflineMediaFetch(null);
const input = new Input({
source: new UrlSource(src, {
fetchFn: recovery.fetchFn,
handleUnhandledError: (error) => {
Internals.Log.warn(
{logLevel, tag: '@remotion/media'},
Expand All @@ -82,7 +85,13 @@ export const acquireSharedInput = ({
formats: ALL_FORMATS,
});

return {resource: input, dispose: () => input.dispose()};
return {
resource: input,
dispose: () => {
recovery.dispose();
input.dispose();
},
};
},
});

Expand Down
5 changes: 4 additions & 1 deletion packages/media/src/get-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ export const getSinkCacheKey = ({
getMediaRequestInitFingerprint(requestInit),
]);

export const makeSinkManager = () => {
export const makeSinkManager = (
onOfflineWait: ((waiting: boolean) => void) | null = null,
) => {
const sinkPromises: Record<string, Promise<GetSink>> = {};
const inputDisposers: Record<string, () => void> = {};
let disposed = false;
Expand Down Expand Up @@ -61,6 +63,7 @@ export const makeSinkManager = () => {
logLevel,
credentials,
normalizedRequestInit,
onOfflineWait,
);
promise = sinks.promise;
sinkPromises[cacheKey] = promise;
Expand Down
Loading
Loading