305 lines
15 KiB
JavaScript
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;
|
|
};
|