UNPKG

@uppy/screen-capture

Version:

Uppy plugin that captures video from display or application.

481 lines (480 loc) 18.1 kB
import { jsx as _jsx } from "preact/jsx-runtime"; import { UIPlugin } from '@uppy/core'; import { getFileTypeExtension } from '@uppy/utils'; import packageJson from '../package.json' with { type: 'json' }; import locale from './locale.js'; import RecorderScreen from './RecorderScreen.js'; import ScreenRecIcon from './ScreenRecIcon.js'; // Check if screen capturing is supported. // mediaDevices is supprted on mobile Safari, getDisplayMedia is not function isScreenRecordingSupported() { return window.MediaRecorder && navigator.mediaDevices?.getDisplayMedia; } // Adapted from: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia function getMediaDevices() { return window.MediaRecorder && navigator.mediaDevices; } // Add supported image types const SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']; // https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints const defaultOptions = { // https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints#Properties_of_shared_screen_tracks displayMediaConstraints: { video: { width: 1280, height: 720, frameRate: { ideal: 3, max: 5, }, cursor: 'motion', displaySurface: 'monitor', }, }, // https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints/audio userMediaConstraints: { audio: true, }, preferredVideoMimeType: 'video/webm', preferredImageMimeType: 'image/png', enableScreenshots: true, }; export default class ScreenCapture extends UIPlugin { static VERSION = packageJson.version; mediaDevices; protocol; icon; streamInterrupted; captureActive; capturedMediaFile; videoStream = null; audioStream = null; userDenied = false; recorder = null; outputStream = null; recordingChunks = null; constructor(uppy, opts) { super(uppy, { ...defaultOptions, ...opts }); this.mediaDevices = getMediaDevices(); this.protocol = location.protocol === 'https:' ? 'https' : 'http'; this.id = this.opts.id || 'ScreenCapture'; this.type = 'acquirer'; this.icon = ScreenRecIcon; this.defaultLocale = locale; this.i18nInit(); this.title = this.i18n('pluginNameScreenCapture'); // uppy plugin class related this.install = this.install.bind(this); this.setPluginState = this.setPluginState.bind(this); this.render = this.render.bind(this); // screen capturer related this.start = this.start.bind(this); this.stop = this.stop.bind(this); this.startRecording = this.startRecording.bind(this); this.stopRecording = this.stopRecording.bind(this); this.submit = this.submit.bind(this); this.streamInterrupted = this.streamInactivated.bind(this); this.captureScreenshot = this.captureScreenshot.bind(this); this.discardRecordedMedia = this.discardRecordedMedia.bind(this); // initialize this.captureActive = false; this.capturedMediaFile = null; this.setPluginState({ streamActive: false, audioStreamActive: false, recording: false, recordedVideo: null, screenRecError: null, capturedScreenshotUrl: null, status: 'init', }); } install() { if (!isScreenRecordingSupported()) { this.uppy.log('Screen recorder access is not supported', 'warning'); return null; } this.setPluginState({ streamActive: false, audioStreamActive: false, status: 'init', }); const { target } = this.opts; if (target) { this.mount(target, this); } return undefined; } uninstall() { if (this.videoStream) { this.stop(); } this.unmount(); } start() { if (!this.mediaDevices) { return Promise.reject(new Error('Screen recorder access not supported')); } this.captureActive = true; this.selectAudioStreamSource(); return this.selectVideoStreamSource().then((res) => { // something happened in start -> return if (res === false) { // Close the Dashboard panel if plugin is installed // into Dashboard (could be other parent UI plugin) // @ts-expect-error we can't know Dashboard types here if (this.parent?.hideAllPanels) { // @ts-expect-error we can't know Dashboard types here this.parent.hideAllPanels(); this.captureActive = false; } } }); } selectVideoStreamSource() { // if active stream available, return it if (this.videoStream) { return new Promise((resolve) => resolve(this.videoStream)); } // ask user to select source to record and get mediastream from that return this.mediaDevices .getDisplayMedia(this.opts.displayMediaConstraints) .then((videoStream) => { this.videoStream = videoStream; // add event listener to stop recording if stream is interrupted this.videoStream.addEventListener('inactive', () => { this.streamInactivated(); }); this.setPluginState({ streamActive: true, status: 'ready', screenRecError: null, }); return videoStream; }) .catch((err) => { this.setPluginState({ screenRecError: err, status: 'error', }); this.userDenied = true; setTimeout(() => { this.userDenied = false; }, 1000); return false; }); } selectAudioStreamSource() { // if active stream available, return it if (this.audioStream) { return new Promise((resolve) => resolve(this.audioStream)); } // ask user to select source to record and get mediastream from that return this.mediaDevices .getUserMedia(this.opts.userMediaConstraints) .then((audioStream) => { this.audioStream = audioStream; this.setPluginState({ audioStreamActive: true, }); return audioStream; }) .catch((err) => { if (err.name === 'NotAllowedError') { this.uppy.info(this.i18n('micDisabled'), 'error', 5000); this.uppy.log(this.i18n('micDisabled'), 'warning'); } return false; }); } startRecording() { const options = {}; this.capturedMediaFile = null; this.recordingChunks = []; const { preferredVideoMimeType } = this.opts; this.selectVideoStreamSource() .then((videoStream) => { if (videoStream === false) { throw new Error('No video stream available'); } // Attempt to use the passed preferredVideoMimeType (if any) during recording. // If the browser doesn't support it, we'll fall back to the browser default instead if (preferredVideoMimeType && MediaRecorder.isTypeSupported(preferredVideoMimeType) && getFileTypeExtension(preferredVideoMimeType)) { options.mimeType = preferredVideoMimeType; } // prepare tracks const tracks = [videoStream.getVideoTracks()[0]]; // merge audio if exits if (this.audioStream) { tracks.push(this.audioStream.getAudioTracks()[0]); } // create new stream from video and audio this.outputStream = new MediaStream(tracks); // initialize mediarecorder this.recorder = new MediaRecorder(this.outputStream, options); // push data to buffer when data available this.recorder.addEventListener('dataavailable', (event) => { this.recordingChunks.push(event.data); }); // start recording this.recorder.start(); // set plugin state to recording this.setPluginState({ recording: true, status: 'recording', }); }) .catch((err) => { this.uppy.log(err, 'error'); this.setPluginState({ screenRecError: err, status: 'error' }); }); } streamInactivated() { // get screen recorder state const { recordedVideo, recording } = { ...this.getPluginState() }; if (!recordedVideo && !recording) { // Close the Dashboard panel if plugin is installed // into Dashboard (could be other parent UI plugin) // @ts-expect-error we can't know Dashboard types here if (this.parent?.hideAllPanels) { // @ts-expect-error we can't know Dashboard types here this.parent.hideAllPanels(); } this.setPluginState({ status: 'init' }); } else if (recording) { // stop recorder if it is active this.uppy.log('Capture stream inactive — stop recording'); this.stopRecording(); } this.videoStream = null; this.audioStream = null; this.setPluginState({ streamActive: false, audioStreamActive: false, }); } stopRecording() { const stopped = new Promise((resolve) => { this.recorder.addEventListener('stop', () => { resolve(); }); this.recorder.stop(); }); return stopped .then(() => { // recording stopped this.setPluginState({ recording: false, }); // get video file after recorder stopped return this.getVideo(); }) .then((file) => { // store media file this.capturedMediaFile = file; // create object url for capture result preview this.setPluginState({ recordedVideo: URL.createObjectURL(file.data), status: 'captured', }); }) .then(() => { this.recordingChunks = null; this.recorder = null; }, (error) => { this.recordingChunks = null; this.recorder = null; throw error; }); } discardRecordedMedia() { const { capturedScreenshotUrl, recordedVideo } = this.getPluginState(); if (capturedScreenshotUrl) { URL.revokeObjectURL(capturedScreenshotUrl); } if (recordedVideo) { URL.revokeObjectURL(recordedVideo); } this.capturedMediaFile = null; this.setPluginState({ recordedVideo: null, capturedScreenshotUrl: null, status: this.getPluginState().streamActive ? 'ready' : 'init', }); } submit() { try { // add recorded file to uppy if (this.capturedMediaFile) { this.uppy.addFile(this.capturedMediaFile); } } catch (err) { // Logging the error, exept restrictions, which is handled in Core if (!err.isRestriction) { this.uppy.log(err, 'warning'); } } } stop() { // flush video stream if (this.videoStream) { this.videoStream.getVideoTracks().forEach((track) => { track.stop(); }); this.videoStream.getAudioTracks().forEach((track) => { track.stop(); }); this.videoStream = null; } // flush audio stream if (this.audioStream) { this.audioStream.getAudioTracks().forEach((track) => { track.stop(); }); this.audioStream.getVideoTracks().forEach((track) => { track.stop(); }); this.audioStream = null; } // flush output stream if (this.outputStream) { this.outputStream.getAudioTracks().forEach((track) => { track.stop(); }); this.outputStream.getVideoTracks().forEach((track) => { track.stop(); }); this.outputStream = null; } // Clean up screenshot URL const { capturedScreenshotUrl, recordedVideo } = this.getPluginState(); if (capturedScreenshotUrl) { URL.revokeObjectURL(capturedScreenshotUrl); } if (recordedVideo) { URL.revokeObjectURL(recordedVideo); } // remove preview video this.setPluginState({ recording: false, streamActive: false, audioStreamActive: false, recordedVideo: null, capturedScreenshotUrl: null, status: 'init', }); this.captureActive = false; } getVideo() { const mimeType = this.recordingChunks[0].type; const fileExtension = getFileTypeExtension(mimeType); if (!fileExtension) { return Promise.reject(new Error(`Could not retrieve recording: Unsupported media type "${mimeType}"`)); } const name = `screencap-${Date.now()}.${fileExtension}`; const blob = new Blob(this.recordingChunks, { type: mimeType }); const file = { source: this.id, name, data: new Blob([blob], { type: mimeType }), type: mimeType, }; return Promise.resolve(file); } async captureScreenshot() { if (!this.mediaDevices?.getDisplayMedia) { throw new Error('Screen capture is not supported'); } try { let stream = this.videoStream; // Only request new stream if we don't have one if (!stream) { const newStream = await this.selectVideoStreamSource(); if (!newStream) { throw new Error('Failed to get screen capture stream'); } stream = newStream; } const video = document.createElement('video'); video.srcObject = stream; await new Promise((resolve) => { video.onloadedmetadata = () => { video.play(); resolve(null); }; }); const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Failed to get canvas context'); } ctx.drawImage(video, 0, 0, canvas.width, canvas.height); // Validate and set fallback for preferred image mime type let mimeType = this.opts.preferredImageMimeType; if (!mimeType || !SUPPORTED_IMAGE_TYPES.includes(mimeType)) { this.uppy.log(`Unsupported image type "${mimeType}", falling back to image/png`, 'warning'); mimeType = 'image/png'; } const quality = 1; return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (!blob) { reject(new Error('Failed to create screenshot blob')); return; } const fileExtension = getFileTypeExtension(mimeType) || 'png'; const file = { source: this.id, name: `Screenshot ${new Date().toISOString()}.${fileExtension}`, type: mimeType, data: blob, }; try { this.capturedMediaFile = file; const screenshotUrl = URL.createObjectURL(blob); this.setPluginState({ capturedScreenshotUrl: screenshotUrl, status: 'captured', }); resolve(); } catch (err) { if (this.getPluginState().capturedScreenshotUrl) { this.setPluginState({ capturedScreenshotUrl: null }); } if (!err.isRestriction) { this.uppy.log(err, 'error'); } reject(err); } finally { // Cleanup video.srcObject = null; canvas.remove(); video.remove(); } }, mimeType, quality); }); } catch (err) { this.uppy.log(err, 'error'); throw err; } } render() { // get screen recorder state const recorderState = this.getPluginState(); if (!recorderState.streamActive && !this.captureActive && !this.userDenied) { this.start(); } return (_jsx(RecorderScreen, { ...recorderState, onStartRecording: this.startRecording, onStopRecording: this.stopRecording, enableScreenshots: this.opts.enableScreenshots, onScreenshot: this.captureScreenshot, onStop: this.stop, onSubmit: this.submit, i18n: this.i18n, stream: this.videoStream, onDiscard: this.discardRecordedMedia })); } }