├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── launch.json ├── images ├── close.png ├── icon.ico └── rec.png ├── package.json ├── readme.md ├── src ├── config.ts ├── index.ts ├── renderer │ ├── Root │ │ ├── CutVideoRect │ │ │ └── CutVideoRect.tsx │ │ ├── DragPoints │ │ │ └── DragPoints.tsx │ │ └── Root.tsx │ ├── Styled │ │ └── DrawRect.tsx │ ├── index.html │ └── index.tsx └── utils │ ├── GIFEncoderEvent.ts │ ├── Logging.ts │ ├── SendBlobEvent.ts │ ├── ShortCutKeyEvent.ts │ ├── WorkerEvent.ts │ ├── b64.ts │ ├── parseGetParam.ts │ └── types.ts ├── tsconfig.json ├── typings ├── GIFEncoder │ └── index.d.ts ├── LZWEncoder │ └── index.d.ts ├── MediaRecorder │ └── index.d.ts ├── NeuQuant │ └── index.d.ts └── whammy │ └── index.d.ts ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack.config.js 2 | .eslintrc.js 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | }, 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended", 11 | "prettier/@typescript-eslint", 12 | ], 13 | globals: { 14 | Atomics: "readonly", 15 | SharedArrayBuffer: "readonly", 16 | }, 17 | parser: "@typescript-eslint/parser", 18 | parserOptions: { 19 | ecmaFeatures: { 20 | jsx: true, 21 | }, 22 | ecmaVersion: 2018, 23 | sourceType: "module", 24 | }, 25 | plugins: ["react", "@typescript-eslint"], 26 | rules: { 27 | "@typescript-eslint/no-unused-vars": "off", 28 | "@typescript-eslint/explicit-function-return-type": "off", 29 | "@typescript-eslint/no-use-before-define": "off", 30 | "@typescript-eslint/explicit-module-boundary-types": "off", 31 | "prettier/prettier": [ 32 | "error", 33 | { 34 | singleQuote: false, 35 | semi: true, 36 | tabWidth: 2, 37 | endOfLine: "lf", 38 | trailingComma: "es5", 39 | }, 40 | ], 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn-error.log 4 | TAKE-win32-x64 5 | config.json 6 | *.zip 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | package.json 3 | package-lock.json 4 | yarn.lock 5 | public 6 | static 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": false, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // IntelliSense を使用して利用可能な属性を学べます。 3 | // 既存の属性の説明をホバーして表示します。 4 | // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Main Process", 9 | "type": "node", 10 | "request": "launch", 11 | "cwd": "${workspaceRoot}", 12 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 13 | "windows": { 14 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" 15 | }, 16 | "program": "${workspaceRoot}/dist/index.js" 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "type": "chrome", 21 | "request": "launch", 22 | "env": {"NODE_ENV": "development"}, 23 | "cwd": "${workspaceRoot}", 24 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 25 | "windows": { 26 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" 27 | }, 28 | "runtimeArgs": [ 29 | "${workspaceRoot}/dist/main.js", 30 | "--remote-debugging-port=9222" 31 | ], 32 | "webRoot" : "dist/" 33 | }, 34 | { 35 | "name": "ReleaseMode - Debug Renderer Process", 36 | "type": "chrome", 37 | "request": "launch", 38 | "env": {"NODE_ENV": "production"}, 39 | "cwd": "${workspaceRoot}", 40 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 41 | "windows": { 42 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" 43 | }, 44 | "runtimeArgs": [ 45 | "${workspaceRoot}/dist/main.js", 46 | "--remote-debugging-port=9222" 47 | ], 48 | "webRoot" : "dist/" 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /images/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hapo31/TAKE/58a001bb3d9bbb3bd689a5c5b842c9fa09bc5104/images/close.png -------------------------------------------------------------------------------- /images/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hapo31/TAKE/58a001bb3d9bbb3bd689a5c5b842c9fa09bc5104/images/icon.ico -------------------------------------------------------------------------------- /images/rec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hapo31/TAKE/58a001bb3d9bbb3bd689a5c5b842c9fa09bc5104/images/rec.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "take", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "electron ./dist", 7 | "format": "eslint --fix \"./src/**/*.{ts,tsx}\" ", 8 | "watch": "webpack --watch --config ./webpack.config.js --mode development", 9 | "build:win": "cross-env NODE_ENV=production webpack --config ./webpack.config.js --mode production && copy package.json dist\\ && electron-packager ./dist TAKE --platform=win32 --arch=x64 --icon=./images/icon.ico --electron-version=10.1.2 --overwrite && copy readme.md TAKE-win32-x64\\readme.txt" 10 | }, 11 | "devDependencies": { 12 | "@types/fluent-ffmpeg": "^2.1.9", 13 | "@types/react": "~16.9.41", 14 | "@types/react-dom": "~16.9.8", 15 | "@types/styled-components": "~5.1.0", 16 | "@typescript-eslint/eslint-plugin": "~3.4.0", 17 | "@typescript-eslint/parser": "~3.4.0", 18 | "copy-webpack-plugin": "~6.0.2", 19 | "cross-env": "~7.0.2", 20 | "electron": "~10.2.0", 21 | "electron-packager": "~15.1.0", 22 | "eslint": "~7.3.1", 23 | "eslint-config-prettier": "^6.11.0", 24 | "eslint-plugin-prettier": "^3.1.3", 25 | "eslint-plugin-react": "^7.19.0", 26 | "prettier": "~2.0.5", 27 | "ts-loader": "~7.0.5", 28 | "typescript": "~3.9.5", 29 | "webpack": "~4.43.0", 30 | "webpack-cli": "~3.3.12" 31 | }, 32 | "dependencies": { 33 | "fluent-ffmpeg": "^2.1.2", 34 | "react": "^16.8.6", 35 | "react-dom": "^16.8.6", 36 | "react-redux": "^7.0.3", 37 | "redux": "^4.0.1", 38 | "styled-components": "~5.1.1", 39 | "tempfile": "^3.0.0" 40 | }, 41 | "license": "MIT", 42 | "author": "happou31", 43 | "description": "Simple Desktop capture" 44 | } 45 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## 1. 使い方 2 | 3 | TAKE はシンプルなスクリーンショット撮影アプリケーションです。 4 | TAKE.exe を実行すると、タスクトレイに常駐します。 5 | タスクトレイのアイコンを右クリックして Record を選ぶか、 6 | Win+Shift+A を同時に押すと、 7 | 画面全体が選択できるようになるので、好きなところを選んで撮影してください。 8 | Escape キーで撮影を終了します。 9 | 出てきたダイアログで保存する場所とファイル名を指定してください。 10 | また、拡張子を含めたファイル名を指定することで、指定した形式で保存します。(FFmpeg が使える場合のみ) 11 | 12 | ## 2. 設定ファイル 13 | 14 | 初回起動時に config.json が生成されます。 15 | 中身を環境に合わせていい感じに設定すると幸せになれます。 16 | 17 | "useFFmpeg" 18 | 動画保存時に ffmpeg を使って動画を変換するかどうかを設定します。 19 | true か false を設定できます。 20 | 21 | "ffmpegPath" 22 | "useFFmpeg" が true のときに有効になります。 23 | ffmpeg があるパスを指定してあげてください。 24 | false と書くと、 ffmpeg へ PATH が通っているものとして扱います。(よく分からなければ直接パスを書くこと) 25 | 26 | "defaultFormat" 27 | デフォルトのファイルフォーマットを指定します。 28 | 撮影した動画を保存するときに拡張子が設定されていない場合にこの設定が適用されます。 29 | FFmpeg が使えるようになっている場合は .mp4 や .gif にすると便利です。 30 | 31 | ## 3. Licence 32 | 33 | MIT 34 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | export type ApplicationConfig = { 4 | useFFmpeg?: boolean; 5 | ffmpegPath?: string | false; 6 | defaultFormat: string; 7 | }; 8 | 9 | export async function loadOrDefaultConfig( 10 | path: string, 11 | defaultConfig: ApplicationConfig 12 | ) { 13 | return new Promise<[ApplicationConfig, boolean]>((res, rej) => { 14 | fs.readFile(path, (err, data) => { 15 | if (err) { 16 | createDefaultConfigJson(path, defaultConfig) 17 | .then((data: ApplicationConfig) => { 18 | res([data, true]); 19 | return; 20 | }) 21 | .catch(err => { 22 | rej(err); 23 | return; 24 | }); 25 | 26 | return; 27 | } else { 28 | try { 29 | const config: ApplicationConfig = JSON.parse(data.toString()); 30 | res([config, false]); 31 | } catch (e) { 32 | rej(e); 33 | } 34 | } 35 | }); 36 | }); 37 | } 38 | 39 | export async function createDefaultConfigJson( 40 | path: string, 41 | defaultConfig: ApplicationConfig 42 | ) { 43 | return new Promise((res, rej) => { 44 | fs.writeFile(path, JSON.stringify(defaultConfig, null, " "), err => { 45 | if (err) { 46 | rej(err.message); 47 | return; 48 | } 49 | res(defaultConfig); 50 | }); 51 | }); 52 | } 53 | 54 | export const AVAILABLE_EXT = [ 55 | "mp4", 56 | "avi", 57 | "mov", 58 | "wmv", 59 | "flv", 60 | "mkv", 61 | "mpg", 62 | "webm", 63 | ]; 64 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserWindow, 3 | app, 4 | App, 5 | Menu, 6 | screen, 7 | ipcMain, 8 | dialog, 9 | globalShortcut, 10 | Tray, 11 | } from "electron"; 12 | import fs, { promises as fsPromises } from "fs"; 13 | import path from "path"; 14 | import ffmpeg from "fluent-ffmpeg"; 15 | import tempfile from "tempfile"; 16 | import SendBlobEvent from "./utils/SendBlobEvent"; 17 | import { 18 | loadOrDefaultConfig, 19 | ApplicationConfig, 20 | createDefaultConfigJson, 21 | } from "./config"; 22 | import { exec } from "child_process"; 23 | import Logging from "./utils/Logging"; 24 | 25 | const isDev = process.env.NODE_ENV !== "production"; 26 | 27 | console.log({ mode: process.env.NODE_ENV }); 28 | 29 | if (!isDev) { 30 | process.on("uncaughtException", () => { 31 | process.exit(); 32 | }); 33 | } 34 | 35 | class MyApp { 36 | private windowToDisplayIdMap: Map = new Map(); 37 | private windows: BrowserWindow[] | null = null; 38 | private app: App; 39 | private mainURL = `file://${__dirname}/index.html`; 40 | 41 | private config: ApplicationConfig; 42 | 43 | private isShowingDialog = false; 44 | 45 | private readonly defaultConfig: ApplicationConfig = { 46 | useFFmpeg: false, 47 | ffmpegPath: false, 48 | defaultFormat: "webm", 49 | }; 50 | 51 | private tray: Tray | null = null; 52 | 53 | private isRecording = false; 54 | 55 | constructor(app: App) { 56 | this.app = app; 57 | this.app.on("ready", this.onReady); 58 | this.app.on("activate", this.onActivated); 59 | // 「何もしないハンドラ」を渡しておかないとデフォルトのハンドラが実行されてアプリが終了しちゃう 60 | this.app.on("window-all-closed", this.onWindowAllClosed); 61 | 62 | this.config = this.defaultConfig; 63 | 64 | process.on("uncaughtException", (error) => { 65 | Logging.error(error); 66 | dialog.showErrorBox("error", error.message); 67 | }); 68 | } 69 | 70 | private onReady = () => { 71 | this.init().catch((err) => { 72 | Logging.error(err); 73 | dialog.showErrorBox("error", err.message); 74 | }); 75 | }; 76 | 77 | private onActivated = () => { 78 | if (this.windows !== null) { 79 | this.createWindows().catch((err) => { 80 | dialog.showErrorBox("error", err.message); 81 | }); 82 | } 83 | }; 84 | 85 | private onWindowAllClosed = () => { 86 | return; 87 | }; 88 | 89 | private async init() { 90 | const gotTheLock = app.requestSingleInstanceLock(); 91 | 92 | if (!gotTheLock) { 93 | this.applicationExit(); 94 | return; 95 | } 96 | 97 | if (!fs.existsSync("config.json")) { 98 | createDefaultConfigJson("./config.json", this.defaultConfig); 99 | } 100 | 101 | this.tray = new Tray(`${__dirname}/images/icon.ico`); 102 | 103 | this.tray.setToolTip("TAKE - take a screenshot."); 104 | 105 | const menu = Menu.buildFromTemplate([ 106 | { 107 | icon: `${__dirname}/images/rec.png`, 108 | label: "Record", 109 | click: (_) => { 110 | this.createWindows(); 111 | }, 112 | }, 113 | { 114 | label: 'Open "config.json"', 115 | click: (_) => { 116 | switch (process.platform) { 117 | case "win32": 118 | exec("start ./config.json"); 119 | break; 120 | case "darwin": 121 | exec("open ./config.json"); 122 | break; 123 | default: 124 | exec("vi ./config.json"); 125 | } 126 | }, 127 | }, 128 | { 129 | label: "Close", 130 | click: (_) => { 131 | this.applicationExit(); 132 | }, 133 | }, 134 | ]); 135 | this.tray.setContextMenu(menu); 136 | 137 | ipcMain.on("send-blob", async (_: Electron.Event, data: SendBlobEvent) => { 138 | this.windows?.forEach((window) => { 139 | if (window && !window.isDestroyed()) { 140 | window.close(); 141 | } 142 | }); 143 | 144 | this.isShowingDialog = true; 145 | const fixedWidth = 146 | data.width % 16 === 0 147 | ? data.width 148 | : data.width + (16 - (data.width % 16)); 149 | const fixedHeight = 150 | data.height % 16 === 0 151 | ? data.height 152 | : data.height + (16 - (data.height % 16)); 153 | const buffer = Buffer.from(data.arrayBuffer); 154 | if (this.windows && isDev === false) { 155 | this.windows.forEach((window) => { 156 | if (window && !window.isDestroyed()) { 157 | window.setAlwaysOnTop(true); 158 | } 159 | }); 160 | } 161 | /* eslint-disable @typescript-eslint/no-explicit-any */ 162 | 163 | const pathStr = ( 164 | await dialog.showSaveDialog(null as any, { 165 | title: "save", 166 | defaultPath: ".", 167 | }) 168 | ).filePath; 169 | 170 | /* eslint-enable @typescript-eslint/no-explicit-any */ 171 | if (pathStr) { 172 | if (this.config.useFFmpeg) { 173 | const tmpfilename = tempfile(".mp4"); 174 | 175 | await fsPromises.writeFile(tmpfilename, buffer); 176 | 177 | const command = ffmpeg(tmpfilename); 178 | const extRaw = path.extname(pathStr).slice(1); 179 | const isContainsExt = extRaw.length !== 0; 180 | const ext = extRaw || this.config.defaultFormat; 181 | 182 | const fileName = !isContainsExt ? `${pathStr}.${ext}` : pathStr; 183 | 184 | // false とかがセットされてたら PATH が通っているものとして扱う的なことにしたい 185 | if (this.config.ffmpegPath) { 186 | command.setFfmpegPath(this.config.ffmpegPath); 187 | } 188 | 189 | if (ext === "mp4") { 190 | command 191 | .size(`${fixedWidth}x${fixedHeight}`) 192 | .videoCodec("libx264") 193 | .addOption("-pix_fmt", "yuv420p"); 194 | } 195 | 196 | command 197 | .output(fileName) 198 | .on("end", () => { 199 | fs.unlink(tmpfilename, (err) => { 200 | if (err) { 201 | throw err; 202 | } 203 | }); 204 | }) 205 | .on("error", (err) => { 206 | throw err; 207 | }) 208 | .run(); 209 | } else { 210 | const extRaw = path.extname(pathStr).slice(1); 211 | const isContainsExt = extRaw.length !== 0; 212 | const fileName = !isContainsExt ? `${pathStr}.webm` : pathStr; 213 | await fsPromises.writeFile(fileName, buffer); 214 | } 215 | } 216 | this.allWindowClose(); 217 | }); 218 | 219 | ipcMain.on("window-hide", (e: Electron.Event) => { 220 | if (this.windows && isDev === false) { 221 | this.windows.forEach((window) => { 222 | if (window) { 223 | window.setOpacity(0.0); 224 | window.setAlwaysOnTop(false); 225 | window.blur(); 226 | } 227 | }); 228 | } 229 | }); 230 | 231 | ipcMain.on("start-recording", () => { 232 | this.isRecording = true; 233 | }); 234 | 235 | globalShortcut.register("Super+Shift+A", () => { 236 | this.createWindows(); 237 | }); 238 | } 239 | 240 | private async createWindows() { 241 | if (this.windows != null) { 242 | return; 243 | } 244 | 245 | const [config, isCreatedConfigFile] = await loadOrDefaultConfig( 246 | "./config.json", 247 | this.defaultConfig 248 | ); 249 | 250 | if (!this.configCheck(config)) { 251 | this.allWindowClose(); 252 | } 253 | 254 | if (isCreatedConfigFile) { 255 | // TODO: 初回作成時はなんか言うといいかも 256 | } 257 | 258 | this.config = config; 259 | 260 | const windowCommonOptions = { 261 | title: "TAKE", 262 | acceptFirstMouse: true, 263 | frame: false, 264 | alwaysOnTop: true, 265 | resizable: false, 266 | movable: false, 267 | skipTaskbar: true, 268 | transparent: true, 269 | opacity: 0.3, 270 | webPreferences: { 271 | nodeIntegration: true, 272 | }, 273 | }; 274 | 275 | const displays = screen.getAllDisplays(); 276 | 277 | const windows = displays.map((display) => { 278 | const bw = new BrowserWindow({ 279 | ...display.bounds, 280 | ...windowCommonOptions, 281 | }); 282 | // 各ウインドウから id を取得出来るように、 BrowserWindow と display の id を紐付ける 283 | this.windowToDisplayIdMap.set(bw.id, display.id); 284 | return bw; 285 | }); 286 | 287 | windows.forEach((window) => { 288 | if (window) { 289 | window.loadURL( 290 | `${this.mainURL}?id=${this.windowToDisplayIdMap.get(window.id)}` 291 | ); 292 | } 293 | }); 294 | 295 | windows.forEach((window) => { 296 | if (window) { 297 | // 1個でもウインドウが手動で閉じられたらそのセッションは終了 298 | window.on("closed", () => { 299 | if (!this.isRecording) { 300 | this.allWindowClose(); 301 | } 302 | }); 303 | } 304 | }); 305 | 306 | globalShortcut.register("Escape", () => { 307 | if (this.windows && !this.isShowingDialog) { 308 | if (this.isRecording) { 309 | this.windows.forEach((window) => { 310 | if (window && !window.isDestroyed()) { 311 | window.webContents.send("shortcut-key", { 312 | name: "RecordingStop", 313 | }); 314 | } 315 | }); 316 | } else { 317 | this.windows.forEach((window) => { 318 | if (window && !window.isDestroyed()) { 319 | window.close(); 320 | } 321 | }); 322 | this.allWindowClose(); 323 | } 324 | } 325 | }); 326 | 327 | this.windows = windows; 328 | } 329 | 330 | private applicationExit() { 331 | this.allWindowClose(); 332 | this.app.quit(); 333 | } 334 | 335 | private allWindowClose() { 336 | if (this.windows) { 337 | this.windows.forEach((window) => { 338 | if (window && !window.isDestroyed()) { 339 | window.close(); 340 | } 341 | }); 342 | if (globalShortcut.isRegistered("Escape")) { 343 | globalShortcut.unregister("Escape"); 344 | } 345 | this.windows = null; 346 | this.isShowingDialog = false; 347 | } 348 | } 349 | 350 | private configCheck(config: ApplicationConfig) { 351 | switch (config.defaultFormat) { 352 | case "mp4": 353 | case "webm": 354 | case "gif": 355 | return true; 356 | default: 357 | dialog.showErrorBox( 358 | "TAKE", 359 | `Unknown "defaultFormat": "${config.defaultFormat}"\nAvailable formats:\n"mp4"\n"gif"\n"webm"` 360 | ); 361 | return false; 362 | } 363 | } 364 | } 365 | 366 | const myApp: MyApp = new MyApp(app); 367 | -------------------------------------------------------------------------------- /src/renderer/Root/CutVideoRect/CutVideoRect.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import SendBlobEvent from "../../../utils/SendBlobEvent"; 3 | 4 | type Props = { 5 | left: number; 6 | top: number; 7 | right: number; 8 | bottom: number; 9 | saving: boolean; 10 | onStart: () => void; 11 | onSave: (ev: SendBlobEvent) => void; 12 | frameRate: number; 13 | srcStream: MediaStream | null; 14 | }; 15 | 16 | export default (props: Props) => { 17 | const [timer, setTimer] = useState(0); 18 | const [recorder, setRecorder] = useState(null); 19 | 20 | const videoRef = useRef(null); 21 | const canvasRef = useRef(null); 22 | 23 | const width = props.right - props.left; 24 | const height = props.bottom - props.top; 25 | 26 | useEffect(() => { 27 | if (recorder === null && canvasRef && canvasRef.current) { 28 | const recorder = new MediaRecorder(canvasRef.current.captureStream(), { 29 | mimeType: "video/webm;codecs=H264", 30 | audioBitsPerSecond: 0, 31 | videoBitsPerSecond: 2500 * 1024, 32 | }); 33 | recorder.addEventListener("dataavailable", async (e) => { 34 | if (canvasRef.current && recorder) { 35 | props.onSave({ 36 | width: canvasRef.current.width, 37 | height: canvasRef.current.height, 38 | arrayBuffer: await e.data.arrayBuffer(), 39 | }); 40 | } 41 | }); 42 | recorder.start(); 43 | setRecorder(recorder); 44 | } 45 | if (props.srcStream && videoRef && videoRef.current) { 46 | const video = videoRef.current; 47 | video.srcObject = props.srcStream; 48 | if (timer !== 0) { 49 | clearInterval(timer); 50 | } 51 | setTimer( 52 | window.setInterval(() => { 53 | if (canvasRef && canvasRef.current) { 54 | const canvas = canvasRef.current; 55 | const ctx = canvas.getContext("2d"); 56 | if (ctx) { 57 | ctx.drawImage( 58 | video, 59 | props.left, 60 | props.top, 61 | width, 62 | height, 63 | 0, 64 | 0, 65 | width, 66 | height 67 | ); 68 | } 69 | } 70 | }, 1000 / props.frameRate) 71 | ); 72 | video.onloadedmetadata = () => video.play(); 73 | props.onStart(); 74 | } 75 | 76 | if (props.saving && recorder) { 77 | if (recorder.state === "recording") { 78 | recorder.stop(); 79 | } 80 | if (timer !== 0) { 81 | clearInterval(timer); 82 | setTimer(0); 83 | } 84 | } 85 | return () => { 86 | if (timer !== 0) { 87 | clearInterval(timer); 88 | } 89 | if (recorder && recorder.state === "recording") { 90 | recorder.stop(); 91 | } 92 | setRecorder(null); 93 | }; 94 | }, [props.srcStream, props.saving]); 95 | 96 | return ( 97 | <> 98 | {props.srcStream !== null ? ( 99 | <> 100 |