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; };