index.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. const Plugin = require('../Plugin')
  2. const Translator = require('../../core/Translator')
  3. const {
  4. getFileTypeExtension,
  5. canvasToBlob
  6. } = require('../../core/Utils')
  7. const supportsMediaRecorder = require('./supportsMediaRecorder')
  8. const WebcamIcon = require('./WebcamIcon')
  9. const CameraScreen = require('./CameraScreen')
  10. const PermissionsScreen = require('./PermissionsScreen')
  11. // Setup getUserMedia, with polyfill for older browsers
  12. // Adapted from: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
  13. function getMediaDevices () {
  14. if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
  15. return navigator.mediaDevices
  16. }
  17. const getUserMedia = navigator.mozGetUserMedia || navigator.webkitGetUserMedia
  18. if (!getUserMedia) {
  19. return null
  20. }
  21. return {
  22. getUserMedia (opts) {
  23. return new Promise((resolve, reject) => {
  24. getUserMedia.call(navigator, opts, resolve, reject)
  25. })
  26. }
  27. }
  28. }
  29. /**
  30. * Webcam
  31. */
  32. module.exports = class Webcam extends Plugin {
  33. constructor (core, opts) {
  34. super(core, opts)
  35. this.mediaDevices = getMediaDevices()
  36. this.supportsUserMedia = !!this.mediaDevices
  37. this.protocol = location.protocol.match(/https/i) ? 'https' : 'http'
  38. this.id = this.opts.id || 'Webcam'
  39. this.title = 'Webcam'
  40. this.type = 'acquirer'
  41. this.icon = WebcamIcon
  42. this.focus = this.focus.bind(this)
  43. const defaultLocale = {
  44. strings: {
  45. smile: 'Smile!'
  46. }
  47. }
  48. // set default options
  49. const defaultOptions = {
  50. onBeforeSnapshot: () => Promise.resolve(),
  51. countdown: false,
  52. locale: defaultLocale,
  53. modes: [
  54. 'video-audio',
  55. 'video-only',
  56. 'audio-only',
  57. 'picture'
  58. ]
  59. }
  60. // merge default options with the ones set by user
  61. this.opts = Object.assign({}, defaultOptions, opts)
  62. this.locale = Object.assign({}, defaultLocale, this.opts.locale)
  63. this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings)
  64. // i18n
  65. this.translator = new Translator({locale: this.locale})
  66. this.i18n = this.translator.translate.bind(this.translator)
  67. this.install = this.install.bind(this)
  68. this.setPluginState = this.setPluginState.bind(this)
  69. this.render = this.render.bind(this)
  70. // Camera controls
  71. this.start = this.start.bind(this)
  72. this.stop = this.stop.bind(this)
  73. this.takeSnapshot = this.takeSnapshot.bind(this)
  74. this.startRecording = this.startRecording.bind(this)
  75. this.stopRecording = this.stopRecording.bind(this)
  76. this.oneTwoThreeSmile = this.oneTwoThreeSmile.bind(this)
  77. this.webcamActive = false
  78. if (this.opts.countdown) {
  79. this.opts.onBeforeSnapshot = this.oneTwoThreeSmile
  80. }
  81. }
  82. isSupported () {
  83. return !!this.mediaDevices
  84. }
  85. getConstraints () {
  86. const acceptsAudio = this.opts.modes.indexOf('video-audio') !== -1 ||
  87. this.opts.modes.indexOf('audio-only') !== -1
  88. const acceptsVideo = this.opts.modes.indexOf('video-audio') !== -1 ||
  89. this.opts.modes.indexOf('video-only') !== -1 ||
  90. this.opts.modes.indexOf('picture') !== -1
  91. return {
  92. audio: acceptsAudio,
  93. video: acceptsVideo
  94. }
  95. }
  96. start () {
  97. if (!this.isSupported()) {
  98. return Promise.reject(new Error('Webcam access not supported'))
  99. }
  100. this.webcamActive = true
  101. const constraints = this.getConstraints()
  102. // ask user for access to their camera
  103. return this.mediaDevices.getUserMedia(constraints)
  104. .then((stream) => {
  105. this.stream = stream
  106. this.streamSrc = URL.createObjectURL(this.stream)
  107. this.setPluginState({
  108. cameraReady: true
  109. })
  110. })
  111. .catch((err) => {
  112. this.setPluginState({
  113. cameraError: err
  114. })
  115. })
  116. }
  117. startRecording () {
  118. // TODO We can check here if any of the mime types listed in the
  119. // mimeToExtensions map in Utils.js are supported, and prefer to use one of
  120. // those.
  121. // Right now we let the browser pick a type that it deems appropriate.
  122. this.recorder = new MediaRecorder(this.stream)
  123. this.recordingChunks = []
  124. this.recorder.addEventListener('dataavailable', (event) => {
  125. this.recordingChunks.push(event.data)
  126. })
  127. this.recorder.start()
  128. this.setPluginState({
  129. isRecording: true
  130. })
  131. }
  132. stopRecording () {
  133. const stopped = new Promise((resolve, reject) => {
  134. this.recorder.addEventListener('stop', () => {
  135. resolve()
  136. })
  137. this.recorder.stop()
  138. })
  139. return stopped.then(() => {
  140. this.setPluginState({
  141. isRecording: false
  142. })
  143. return this.getVideo()
  144. }).then((file) => {
  145. return this.core.addFile(file)
  146. }).then(() => {
  147. this.recordingChunks = null
  148. this.recorder = null
  149. }, (error) => {
  150. this.recordingChunks = null
  151. this.recorder = null
  152. throw error
  153. })
  154. }
  155. stop () {
  156. this.stream.getAudioTracks().forEach((track) => {
  157. track.stop()
  158. })
  159. this.stream.getVideoTracks().forEach((track) => {
  160. track.stop()
  161. })
  162. this.webcamActive = false
  163. this.stream = null
  164. this.streamSrc = null
  165. }
  166. getVideoElement () {
  167. return this.target.querySelector('.UppyWebcam-video')
  168. }
  169. oneTwoThreeSmile () {
  170. return new Promise((resolve, reject) => {
  171. let count = this.opts.countdown
  172. let countDown = setInterval(() => {
  173. if (!this.webcamActive) {
  174. clearInterval(countDown)
  175. this.captureInProgress = false
  176. return reject(new Error('Webcam is not active'))
  177. }
  178. if (count > 0) {
  179. this.core.info(`${count}...`, 'warning', 800)
  180. count--
  181. } else {
  182. clearInterval(countDown)
  183. this.core.info(this.i18n('smile'), 'success', 1500)
  184. setTimeout(() => resolve(), 1500)
  185. }
  186. }, 1000)
  187. })
  188. }
  189. takeSnapshot () {
  190. if (this.captureInProgress) return
  191. this.captureInProgress = true
  192. this.opts.onBeforeSnapshot().catch((err) => {
  193. const message = typeof err === 'object' ? err.message : err
  194. this.core.info(message, 'error', 5000)
  195. return Promise.reject(new Error(`onBeforeSnapshot: ${message}`))
  196. }).then(() => {
  197. return this.getImage()
  198. }).then((tagFile) => {
  199. this.captureInProgress = false
  200. this.core.addFile(tagFile)
  201. }, (error) => {
  202. this.captureInProgress = false
  203. throw error
  204. })
  205. }
  206. getImage () {
  207. const video = this.getVideoElement()
  208. if (!video) {
  209. return Promise.reject(new Error('No video element found, likely due to the Webcam tab being closed.'))
  210. }
  211. const name = `webcam-${Date.now()}.jpg`
  212. const mimeType = 'image/jpeg'
  213. const canvas = document.createElement('canvas')
  214. canvas.width = video.videoWidth
  215. canvas.height = video.videoHeight
  216. canvas.getContext('2d').drawImage(video, 0, 0)
  217. return canvasToBlob(canvas, mimeType).then((blob) => {
  218. return {
  219. source: this.id,
  220. name: name,
  221. data: new File([blob], name, { type: mimeType }),
  222. type: mimeType
  223. }
  224. })
  225. }
  226. getVideo () {
  227. const mimeType = this.recordingChunks[0].type
  228. const fileExtension = getFileTypeExtension(mimeType)
  229. if (!fileExtension) {
  230. return Promise.reject(new Error(`Could not retrieve recording: Unsupported media type "${mimeType}"`))
  231. }
  232. const name = `webcam-${Date.now()}.${fileExtension}`
  233. const blob = new Blob(this.recordingChunks, { type: mimeType })
  234. const file = {
  235. source: this.id,
  236. name: name,
  237. data: new File([blob], name, { type: mimeType }),
  238. type: mimeType
  239. }
  240. return Promise.resolve(file)
  241. }
  242. focus () {
  243. if (this.opts.countdown) return
  244. setTimeout(() => {
  245. this.core.info(this.i18n('smile'), 'success', 1500)
  246. }, 1000)
  247. }
  248. render (state) {
  249. if (!this.webcamActive) {
  250. this.start()
  251. }
  252. const webcamState = this.getPluginState()
  253. if (!webcamState.cameraReady) {
  254. return PermissionsScreen(webcamState)
  255. }
  256. return CameraScreen(Object.assign({}, webcamState, {
  257. onSnapshot: this.takeSnapshot,
  258. onStartRecording: this.startRecording,
  259. onStopRecording: this.stopRecording,
  260. onFocus: this.focus,
  261. onStop: this.stop,
  262. modes: this.opts.modes,
  263. supportsRecording: supportsMediaRecorder(),
  264. recording: webcamState.isRecording,
  265. src: this.streamSrc
  266. }))
  267. }
  268. install () {
  269. this.setPluginState({
  270. cameraReady: false
  271. })
  272. const target = this.opts.target
  273. if (target) {
  274. this.mount(target, this)
  275. }
  276. }
  277. uninstall () {
  278. if (this.stream) {
  279. this.stop()
  280. }
  281. this.unmount()
  282. }
  283. }