Files

305 lines
15 KiB
JavaScript

import { AudioSampleSource, BufferTarget, Output, StreamTarget, VideoSampleSource, } from 'mediabunny';
import { Internals } from 'remotion';
import { addAudioSample, addVideoSampleAndCloseFrame } from './add-sample';
import { handleArtifacts } from './artifact';
import { onlyInlineAudio } from './audio';
import { canUseWebFsWriter } from './can-use-webfs-target';
import { createScaffold } from './create-scaffold';
import { getRealFrameRange } from './frame-range';
import { getDefaultAudioEncodingConfig } from './get-audio-encoding-config';
import { makeInternalState } from './internal-state';
import { codecToMediabunnyCodec, containerToMediabunnyContainer, getDefaultVideoCodecForContainer, getMimeType, getQualityForWebRendererQuality, } from './mediabunny-mappings';
import { onlyOneRenderAtATimeQueue } from './render-operations-queue';
import { sendUsageEvent } from './send-telemetry-event';
import { createFrame } from './take-screenshot';
import { createThrottledProgressCallback } from './throttle-progress';
import { validateVideoFrame } from './validate-video-frame';
import { waitForReady } from './wait-for-ready';
import { cleanupStaleOpfsFiles, createWebFsTarget } from './web-fs-target';
// TODO: More containers
// TODO: Audio
// TODO: Metadata
// TODO: Validating inputs
// TODO: Apply defaultCodec
const internalRenderMediaOnWeb = async ({ composition, inputProps, delayRenderTimeoutInMilliseconds, logLevel, mediaCacheSizeInBytes, schema, videoCodec: codec, container, signal, onProgress, hardwareAcceleration, keyframeIntervalInSeconds, videoBitrate, frameRange, transparent, onArtifact, onFrame, outputTarget: userDesiredOutputTarget, licenseKey, muted, }) => {
var _a, _b, _c, _d, _e, _f, _g;
const outputTarget = userDesiredOutputTarget === null
? (await canUseWebFsWriter())
? 'web-fs'
: 'arraybuffer'
: userDesiredOutputTarget;
if (outputTarget === 'web-fs') {
await cleanupStaleOpfsFiles();
}
const cleanupFns = [];
const format = containerToMediabunnyContainer(container);
if (codec &&
!format.getSupportedCodecs().includes(codecToMediabunnyCodec(codec))) {
return Promise.reject(new Error(`Codec ${codec} is not supported for container ${container}`));
}
const resolved = await Internals.resolveVideoConfig({
calculateMetadata: (_a = composition.calculateMetadata) !== null && _a !== void 0 ? _a : null,
signal: signal !== null && signal !== void 0 ? signal : new AbortController().signal,
defaultProps: (_b = composition.defaultProps) !== null && _b !== void 0 ? _b : {},
inputProps: inputProps !== null && inputProps !== void 0 ? inputProps : {},
compositionId: composition.id,
compositionDurationInFrames: (_c = composition.durationInFrames) !== null && _c !== void 0 ? _c : null,
compositionFps: (_d = composition.fps) !== null && _d !== void 0 ? _d : null,
compositionHeight: (_e = composition.height) !== null && _e !== void 0 ? _e : null,
compositionWidth: (_f = composition.width) !== null && _f !== void 0 ? _f : null,
});
const realFrameRange = getRealFrameRange(resolved.durationInFrames, frameRange);
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
return Promise.reject(new Error('renderMediaOnWeb() was cancelled'));
}
const { delayRenderScope, div, cleanupScaffold, timeUpdater, collectAssets } = await createScaffold({
width: resolved.width,
height: resolved.height,
fps: resolved.fps,
durationInFrames: resolved.durationInFrames,
Component: composition.component,
resolvedProps: resolved.props,
id: resolved.id,
delayRenderTimeoutInMilliseconds,
logLevel,
mediaCacheSizeInBytes,
schema: schema !== null && schema !== void 0 ? schema : null,
audioEnabled: !muted,
videoEnabled: true,
initialFrame: 0,
defaultCodec: resolved.defaultCodec,
defaultOutName: resolved.defaultOutName,
});
const internalState = makeInternalState();
const artifactsHandler = handleArtifacts();
cleanupFns.push(() => {
cleanupScaffold();
});
const webFsTarget = outputTarget === 'web-fs' ? await createWebFsTarget() : null;
const target = webFsTarget
? new StreamTarget(webFsTarget.stream)
: new BufferTarget();
const output = new Output({
format,
target,
});
try {
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
throw new Error('renderMediaOnWeb() was cancelled');
}
await waitForReady({
timeoutInMilliseconds: delayRenderTimeoutInMilliseconds,
scope: delayRenderScope,
signal,
apiName: 'renderMediaOnWeb',
internalState,
});
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
throw new Error('renderMediaOnWeb() was cancelled');
}
cleanupFns.push(() => {
if (output.state === 'finalized' || output.state === 'canceled') {
return;
}
output.cancel();
});
const videoSampleSource = new VideoSampleSource({
codec: codecToMediabunnyCodec(codec),
bitrate: typeof videoBitrate === 'number'
? videoBitrate
: getQualityForWebRendererQuality(videoBitrate),
sizeChangeBehavior: 'deny',
hardwareAcceleration,
latencyMode: 'quality',
keyFrameInterval: keyframeIntervalInSeconds,
alpha: transparent ? 'keep' : 'discard',
});
cleanupFns.push(() => {
videoSampleSource.close();
});
output.addVideoTrack(videoSampleSource);
// TODO: Should be able to customize
let audioSampleSource = null;
if (!muted) {
const defaultAudioEncodingConfig = await getDefaultAudioEncodingConfig();
if (!defaultAudioEncodingConfig) {
return Promise.reject(new Error('No default audio encoding config found'));
}
audioSampleSource = new AudioSampleSource(defaultAudioEncodingConfig);
cleanupFns.push(() => {
audioSampleSource === null || audioSampleSource === void 0 ? void 0 : audioSampleSource.close();
});
output.addAudioTrack(audioSampleSource);
}
await output.start();
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
throw new Error('renderMediaOnWeb() was cancelled');
}
const progress = {
renderedFrames: 0,
encodedFrames: 0,
};
const throttledOnProgress = createThrottledProgressCallback(onProgress);
for (let frame = realFrameRange[0]; frame <= realFrameRange[1]; frame++) {
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
throw new Error('renderMediaOnWeb() was cancelled');
}
(_g = timeUpdater.current) === null || _g === void 0 ? void 0 : _g.update(frame);
await waitForReady({
timeoutInMilliseconds: delayRenderTimeoutInMilliseconds,
scope: delayRenderScope,
signal,
apiName: 'renderMediaOnWeb',
internalState,
});
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
throw new Error('renderMediaOnWeb() was cancelled');
}
const createFrameStart = performance.now();
const imageData = await createFrame({
div,
width: resolved.width,
height: resolved.height,
logLevel,
internalState,
});
internalState.addCreateFrameTime(performance.now() - createFrameStart);
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
throw new Error('renderMediaOnWeb() was cancelled');
}
const assets = collectAssets.current.collectAssets();
if (onArtifact) {
await artifactsHandler.handle({
imageData,
frame,
assets,
onArtifact,
});
}
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
throw new Error('renderMediaOnWeb() was cancelled');
}
const audio = muted
? null
: onlyInlineAudio({ assets, fps: resolved.fps, frame });
const timestamp = Math.round(((frame - realFrameRange[0]) / resolved.fps) * 1000000);
const videoFrame = new VideoFrame(imageData, {
timestamp,
});
progress.renderedFrames++;
throttledOnProgress === null || throttledOnProgress === void 0 ? void 0 : throttledOnProgress({ ...progress });
// Process frame through onFrame callback if provided
let frameToEncode = videoFrame;
if (onFrame) {
const returnedFrame = await onFrame(videoFrame);
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
throw new Error('renderMediaOnWeb() was cancelled');
}
frameToEncode = validateVideoFrame({
originalFrame: videoFrame,
returnedFrame,
expectedWidth: resolved.width,
expectedHeight: resolved.height,
expectedTimestamp: timestamp,
});
}
const addSampleStart = performance.now();
await Promise.all([
addVideoSampleAndCloseFrame(frameToEncode, videoSampleSource),
audio && audioSampleSource
? addAudioSample(audio, audioSampleSource)
: Promise.resolve(),
]);
internalState.addAddSampleTime(performance.now() - addSampleStart);
progress.encodedFrames++;
throttledOnProgress === null || throttledOnProgress === void 0 ? void 0 : throttledOnProgress({ ...progress });
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
throw new Error('renderMediaOnWeb() was cancelled');
}
}
// Call progress one final time to ensure final state is reported
onProgress === null || onProgress === void 0 ? void 0 : onProgress({ ...progress });
videoSampleSource.close();
audioSampleSource === null || audioSampleSource === void 0 ? void 0 : audioSampleSource.close();
await output.finalize();
Internals.Log.verbose({ logLevel, tag: 'web-renderer' }, `Render timings: waitForReady=${internalState.getWaitForReadyTime().toFixed(2)}ms, createFrame=${internalState.getCreateFrameTime().toFixed(2)}ms, addSample=${internalState.getAddSampleTime().toFixed(2)}ms`);
const mimeType = getMimeType(container);
if (webFsTarget) {
sendUsageEvent({
licenseKey: licenseKey !== null && licenseKey !== void 0 ? licenseKey : null,
succeeded: true,
apiName: 'renderMediaOnWeb',
});
await webFsTarget.close();
return {
getBlob: () => {
return webFsTarget.getBlob();
},
internalState,
};
}
if (!(target instanceof BufferTarget)) {
throw new Error('Expected target to be a BufferTarget');
}
sendUsageEvent({
licenseKey: licenseKey !== null && licenseKey !== void 0 ? licenseKey : null,
succeeded: true,
apiName: 'renderMediaOnWeb',
});
return {
getBlob: () => {
if (!target.buffer) {
throw new Error('The resulting buffer is empty');
}
return Promise.resolve(new Blob([target.buffer], { type: mimeType }));
},
internalState,
};
}
catch (err) {
sendUsageEvent({
succeeded: false,
licenseKey: licenseKey !== null && licenseKey !== void 0 ? licenseKey : null,
apiName: 'renderMediaOnWeb',
}).catch((err2) => {
Internals.Log.error({ logLevel: 'error', tag: 'web-renderer' }, 'Failed to send usage event', err2);
});
throw err;
}
finally {
cleanupFns.forEach((fn) => fn());
}
};
export const renderMediaOnWeb = (options) => {
var _a, _b;
const container = (_a = options.container) !== null && _a !== void 0 ? _a : 'mp4';
const codec = (_b = options.videoCodec) !== null && _b !== void 0 ? _b : getDefaultVideoCodecForContainer(container);
onlyOneRenderAtATimeQueue.ref = onlyOneRenderAtATimeQueue.ref
.catch(() => Promise.resolve())
.then(() => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s;
return internalRenderMediaOnWeb({
...options,
delayRenderTimeoutInMilliseconds: (_a = options.delayRenderTimeoutInMilliseconds) !== null && _a !== void 0 ? _a : 30000,
logLevel: (_c = (_b = options.logLevel) !== null && _b !== void 0 ? _b : window.remotion_logLevel) !== null && _c !== void 0 ? _c : 'info',
schema: (_d = options.schema) !== null && _d !== void 0 ? _d : undefined,
mediaCacheSizeInBytes: (_e = options.mediaCacheSizeInBytes) !== null && _e !== void 0 ? _e : null,
videoCodec: codec,
container,
signal: (_f = options.signal) !== null && _f !== void 0 ? _f : null,
onProgress: (_g = options.onProgress) !== null && _g !== void 0 ? _g : null,
hardwareAcceleration: (_h = options.hardwareAcceleration) !== null && _h !== void 0 ? _h : 'no-preference',
keyframeIntervalInSeconds: (_j = options.keyframeIntervalInSeconds) !== null && _j !== void 0 ? _j : 5,
videoBitrate: (_k = options.videoBitrate) !== null && _k !== void 0 ? _k : 'medium',
frameRange: (_l = options.frameRange) !== null && _l !== void 0 ? _l : null,
transparent: (_m = options.transparent) !== null && _m !== void 0 ? _m : false,
onArtifact: (_o = options.onArtifact) !== null && _o !== void 0 ? _o : null,
onFrame: (_p = options.onFrame) !== null && _p !== void 0 ? _p : null,
outputTarget: (_q = options.outputTarget) !== null && _q !== void 0 ? _q : null,
licenseKey: (_r = options.licenseKey) !== null && _r !== void 0 ? _r : undefined,
muted: (_s = options.muted) !== null && _s !== void 0 ? _s : false,
});
});
return onlyOneRenderAtATimeQueue.ref;
};