1. 記事一覧 >
  2. ブログ記事
category logo

【WebRTC】別タブをキャプチャするWebアプリを作った(クロップ・ダウンロード機能付き)

(更新) (公開)

はじめに

WebRTC という技術を使って、別タブやデスクトップ全体、ブラウザ以外のアプリケーションのスクリーンショットを撮影できる Web アプリを作成しました。
拡張機能やネイティブメッセージングは一切使っていません。js(と HTML 5.1)のみで実現しています。

【 WebRTC 】

WebRTC は、ウェブブラウザやモバイルアプリケーションにシンプルな API 経由でリアルタイム通信を提供する自由かつオープンソースのプロジェクトです。

ネイティブメッセージングとは、ブラウザとネイティブアプリケーション(Windows の場合、exe)と連携する機能のことです。詳細は、別記事「Native Messaging の exe を試作してみた(Chrome,Firefox,Edge 拡張機能)」をご覧ください。

IE11 は使用不可です。Chrome、Edge のみ正常動作確認済みです。モバイルには対応していません。

macOS の Safari の場合、デスクトップ全体キャプチャのみ対応します。


前置きを読み飛ばす場合、こちらに飛んでください。

ソースコード(説明コメント有り)


また、GitHub のリポジトリ(https://github.com/itc-lab/react-screenshot-by-using-webrtc)で公開しています。(説明コメント無し)

TypeScript、ESLint、Prettier、MUI(Material UI)、Tailwind を使っていますが、今回の機能実現に必要なものではありません。(依存関係が無駄に多いです。)



WebRTC について

getDisplayMedia()

Web 会議など画面共有の時に使う MediaDevices.getDisplayMedia() / Screen Capture API を使用しました。
これにより、何ができるかと言うと、ユーザーの許可を得た上で、Chrome 別タブや、デスクトップ全体、別のアプリケーションのストリーミング映像(現在の画面)を Web アプリ側で見ることができます。
Web アプリ側からデスクトップ全体を見る動作がこれにより実現します。

getDisplayMedia() は、localhost または、https:// でしか動作しません。

画面共有

getDisplayMedia() は、 React, TypeScript とは無関係で、ネイティブの JavaScript で使えます。

WebRTC 公式リポジトリの JavaScript サンプルが大いに参考になりました。(というか、呼び出し手順は、ほとんどそのままです。)

webrtc/samples(https://github.com/webrtc/samples/tree/gh-pages/src/content/getusermedia/canvas)


なぜ WebRTC か?

スクリーンキャプチャ Web アプリを実装するにあたり、以下の目標がありました。
1.Web 画面をキャプチャして、png としてダウンロードしたい。
2.Web 画面は、認証を通した後の画面でもキャプチャできるものとする。


1に関しては、html2canvas 等、ライブラリを使えば、できます。
ただ、使えるのは、1だけ実現しようとしたときです。
2を実現しようとすると、キャプチャしたい Web サイトを iframe に表示させて認証を通し、キャプチャする方法が考えられますが、
iframe は、別の世界線のため、情報を得られず、真っ暗になります。(同一オリジンの Web ページの場合は、キャプチャ可能と思われますが、今回はそうでないものとします。)

iframeのキャプチャ→真っ暗


サーバーサイドで puppeteer を使ってキャプチャするパターンも考えられますが、これもまた、2が実現できません。(アカウント情報入力をエミュレートすれば、できるかもしれませんが、キャプチャする Web サイトの仕様をあらかじめ知っている上で利用する必要が出てきます。)

iframeのキャプチャ→真っ暗

【 puppeteer 】

puppeteer とは、プログラムから API で Chrome ブラウザ を制御できる Node.js のライブラリです。


動作内容

以下の動作内容になります。


1.準備
キャプチャしたい Web サイトを別タブや別ウィンドウで開いておきます。(サインイン/ログインが必要であれば、サインイン/ログイン済みにします。)

2.CAPTURE ボタンをクリックします。

3.キャプチャしたい画面を選択します。

4.選択した画面へフォーカスが移るため、元の画面に戻ります。

5.キャプチャした画像を動かしたり、ズームイン/ズームアウトしたりして、調整します。また、保存したい範囲を調整します。
右側のボタンの他に、CTRLキーを押しながら画像をドラッグすると、動かせます。また画像上でマウスホイールを動かすことによって、画像をズームイン/ズームアウトできます。

6.DOWNLOAD ボタンをクリックします。

download.png として、PCに保存できます。

3で選択した画面にフォーカスが移りますので、元のタブに戻る必要があります。

https://github.com/WICG/conditional-focus

の記述にある通り、

const controller = new CaptureController();

controller.setFocusBehavior('no-focus-change');

navigator.mediaDevices.getDisplayMedia({ video: true, controller })

でフォーカスが移らないようにできそうですが、2022 年 11 月現在、TypeScript エラーになり、設定できませんでした。



ソースコード

説明コメント付き全文

全機能 App.tsx 1個だけになります。
package.json 含めて全体は、GitHub のリポジトリ(https://github.com/itc-lab/react-screenshot-by-using-webrtc)に公開しました。
以下、App.tsx の説明をコメントに記載しました。

src/App.tsx
import { PointerEvent, WheelEvent, useState } from 'react';
import { Button, IconButton } from '@mui/material';
import Tooltip from '@mui/material/Tooltip';
// MUIのスライダコンポーネント。ズームイン、ズームアウトの調整に使用
import Slider from '@mui/material/Slider';
// MUIのレイアウト用コンポーネント。ボタン等のレイアウトに使用。
import Stack from '@mui/material/Stack';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; // ←アイコン
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; // →アイコン
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; // ↑アイコン
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; // ↓アイコン
import ReactCrop, { PixelCrop, Crop } from 'react-image-crop';
// キャプチャ画像クロップ(くりぬき)に使用
// https://www.npmjs.com/package/react-image-crop
// import ReactCrop, { PixelCrop, Crop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';

function App() {
  // react-image-cropの画像クロップ範囲(範囲が確定する度に値を更新)
  // 枠を動かし終わったらsetCompletedCropが動く
  // export interface PixelCrop extends Crop {
  //   unit: 'px';
  // }
  const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
  // react-image-cropの画像クロップ範囲(範囲が変わる度に値を更新)
  // 枠を動かしている最中に何度もsetCropが動く
  // export interface Crop {
  //   x: number;
  //   y: number;
  //   width: number;
  //   height: number;
  //   unit: 'px' | '%';
  // }
  const [crop, setCrop] = useState<Crop>();
  // ズームイン、ズームアウトの値(%)
  const [scale, setScale] = useState(100);
  // キャプチャ済み画像位置(画像の左上角の座標)(left,top)
  const [offsetX, setOffsetX] = useState(0);
  const [offsetY, setOffsetY] = useState(0);
  // キャプチャ済み画像をドラッグして移動しているかどうかのフラグ(true: 移動している、false: 移動していない)
  // imgMoving=trueの場合、移動中に画像の位置を変更してドラッグ位置に追随する
  const [imgMoving, setImgMoving] = useState(false);
  // キャプチャ済み画像移動の時のドラッグ開始位置を加味した座標
  const [distX, setDistX] = useState(0);
  const [distY, setDistY] = useState(0);
  // マウスホイールの操作(画像表示divタグのonWheel={(e) => handleWheel(e)})
  const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
    setScale(scale - event.deltaY / 25); // 操作量/25 % ズームイン、ズームアウト
    event.stopPropagation(); // イベントの伝搬を止める
  };

  // 画像ドラッグ開始(画像表示divタグのonPointerDown={(e) => handlePointerDown(e)})
  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
    // CTRLキーが押されている時だけ動作
    if (event.ctrlKey) {
      setImgMoving(true); // 画像移動中フラグをtrueに。
      // ドラッグ開始位置を加味した座標をセット
      setDistX(offsetX - event.clientX);
      setDistY(offsetY - event.clientY);
      event.preventDefault(); // イベントに対するデフォルトの動作をキャンセル
      event.stopPropagation(); // イベントの伝搬を止める
    }
  };

  // 画像ドラッグ終了(画像表示divタグのonPointerUp={(e) => handlePointerUp(e)})
  const handlePointerUp = () => {
    setImgMoving(false); // 画像移動中フラグをfalseに。
  };

  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
    if (!event.ctrlKey) {
      // CTRLキーを離した
      setImgMoving(false); // 画像移動中フラグをfalseに。
    } else if (imgMoving) {
      // 画像移動中フラグtrue
      setOffsetX(distX + event.clientX); // 移動先に画像位置を変更(画像がドラッグに追随)
      setOffsetY(distY + event.clientY);
      event.preventDefault(); // イベントに対するデフォルトの動作をキャンセル
      event.stopPropagation(); // イベントの伝搬を止める
    }
  };

  // メディアのmetaデータが読み込まれたら実行(videoのonLoadedMetadata={onVideoLoad})
  // つまり、videoに何か映ったら実行
  const onVideoLoad = () => {
    const video = document.querySelector('video'); // videoタグのElementを取得
    if (video === null) return;
    const canvas = document.querySelector('canvas'); // canvasタグのElementを取得
    if (canvas === null) return;
    canvas.width = video.videoWidth; // canvas の幅を video と同一にする
    canvas.height = video.videoHeight; // canvas の高さを video と同一にする
    // getContext
    // (method) HTMLCanvasElement.getContext(contextId: "2d", options?: CanvasRenderingContext2DSettings | undefined): CanvasRenderingContext2D | null (+4 overloads)
    // ドキュメントの canvas 要素に画像やグラフィックスを描画および操作するための
    // メソッドとプロパティを提供するオブジェクトを返す。
    // コンテキスト オブジェクトには、キャンバスに描画できる色、線幅、フォント、
    // およびその他のグラフィック パラメータに関する情報が含まれている。
    // getContext("2d") オブジェクトは、2D図形を扱う。線、ボックス、円、などを描画するメソッドを持っている。
    const context = canvas.getContext('2d');
    if (context === null) return;
    // drawImage
    // (method) CanvasDrawImage.drawImage(image: CanvasImageSource, dx: number, dy: number): void (+2 overloads)
    // videoの全ての範囲をcanvasに描画
    context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
    const img = document.getElementById('image') as HTMLImageElement; // imgタグの部分 canvasの描画内容を表示する部分
    const canvasdiv = document.getElementById('canvasdiv'); // imgタグを囲むdiv
    if (canvasdiv === null) return;
    const style = window.getComputedStyle(canvasdiv); // divのstyleを把握
    const divwidth = Number(style.width.replace('px', '')); // divのstyleのwidthを数値に変換
    const divheight = Number(style.height.replace('px', '')); // divのstyleのheightを数値に変換
    // 画像のセンターに移動
    // setOffsetでimgタグのleft,topに反映されて、画像のx,y座標値が変わる。
    // divwidth、divheight よりもキャプチャした画像が大きい場合、
    // マイナス値(画像の左上座標が画面外)
    setOffsetX((divwidth - video.videoWidth) / 2);
    setOffsetY((divheight - video.videoHeight) / 2);
    img.style.width = `${video.videoWidth}px`; // imgタグの幅をvideoの幅に合わせる
    img.style.height = `${video.videoHeight}px`; // imgタグの高さをvideoの高さに合わせる
    img.src = canvas.toDataURL(); // canvasの描画内容をデータURIに変換する(引数無しの場合、png)
    img.style.display = 'block'; // imgタグ非表示→表示(videoとcanvasは常に非表示)
    setScale(100); // ズームイン、ズームアウトは、100%(画像の元々の大きさ)とする
    // キャプチャ状態(取り込み中)の映像を操作
    // HTML 5.1でHTMLMediaElementインタフェースにsrcObjectプロパティが追加されている。
    // 古いバージョンのMedia Source仕様では、createObjectURL() を使用してオブジェクトURLを作成し、
    // src をそのURLに設定する必要があった。srcObject に MediaStream を直接設定できる。
    const stream = video.srcObject as MediaStream;
    // const tracks: [MediaStreamTrack]
    // getTracks()でトラック情報を得る。
    // トラック情報:マイクトラック、カメラトラック、スクリーンキャプチャトラック...
    const tracks = stream.getTracks() as [MediaStreamTrack];
    // キャプチャ状態のトラックを全て停止(今回の場合、スクリーンキャプチャトラック1個しかないが。)
    tracks.forEach((track) => {
      track.stop();
    });
    // videoタグのストリームをクリア(video->canvas->imgになったので、不要)
    video.srcObject = null;
    // react-image-cropの画像クロップ範囲初期値をセット
    // (少し内側にずらして、90%の大きさとする。)
    setCrop({
      x: 5,
      y: 5,
      width: 90,
      height: 90,
      unit: '%',
    });
  };

  // マウスホイールまたは、ボタンで画像をズームイン、ズームアウト
  const handleScale = (event: Event, value: number) => {
    // ズームイン、ズームアウトのスケール(%)をセット
    // (imgタグのtransform: `scale(${scale / 100})`に反映)
    setScale(value);
  };

  // getDisplayMedia()成功時処理
  // getDisplayMedia()
  // MediaDevices インターフェイスの getDisplayMedia() メソッドは、
  // ディスプレイまたはその一部(ウィンドウなど)の内容を
  // MediaStream としてキャプチャする許可を選択し、許可するようユーザーに促す
  // 生成されたストリームは、 MediaStream Recording API を使って記録したり、WebRTC セッションとして送信することが可能
  function handleSuccess(stream: MediaProvider | null) {
    // クロップ枠をいったん解除
    setCrop(undefined);
    const video = document.querySelector('video') as HTMLVideoElement; // videoタグのElement取得
    video.srcObject = stream; // srcObject に MediaStream(キャプチャ中の映像)を指定。
    video.play(); // 再生開始(videoタグに映像を映す)→onLoadedMetadata={onVideoLoad}が発動
  }

  const handleError = (error: string) => {
    // eslint-disable-next-line
    console.log('navigator.getDisplayMedia error: ', error);
  };

  const clickCapture = () => {
    // getDisplayMedia()
    // MediaDevices インターフェイスの getDisplayMedia() メソッドは、
    // ディスプレイまたはその一部(ウィンドウなど)の内容を
    // MediaStream としてキャプチャする許可を選択し、許可するようユーザーに促す
    navigator.mediaDevices
      .getDisplayMedia({ video: true })
      .then(handleSuccess) // 成功時処理
      .catch(handleError); // エラー時処理
  };

  const clickDownload = () => {
    // completedCrop=クロップ範囲の指定が無かったら、反応しない。
    if (!completedCrop || completedCrop.width === 0) {
      return;
    }

    const image = document.getElementById('image') as HTMLImageElement; // imgタグの部分
    const canvas = document.querySelector('canvas'); // canvasタグの部分
    if (canvas === null) return;
    // getContext("2d") オブジェクトは、2D図形を扱う。線、ボックス、円、などを描画するメソッドを持っている。
    const ctx = canvas.getContext('2d');
    if (ctx === null) return;

    // naturalWidth
    // img要素で実際に表示されている横幅ではなく、画像の本来の横幅
    const scaleX = image.naturalWidth / image.width;
    const scaleY = image.naturalHeight / image.height;
    // devicePixelRatio slightly increases sharpness on retina devices
    // at the expense of slightly slower render times and needing to
    // size the image back down if you want to download/upload and be
    // true to the images natural size.
    //  const pixelRatio = window.devicePixelRatio;
    // メモリ上における実際のサイズを設定(ピクセル密度の分だけ倍増)。
    // 上のコメントは、公式デモcanvasPreview.tsソースコードのコメント。
    // 注意:このあたりは、公式デモのソースコードから借用したもの。
    // 1でぼやける場合、window.devicePixelRatio。
    // devicePixelRatio は Window インターフェイスのプロパティで、現在のディスプレイ機器における CSS 解像度と物理解像度の比を返す。
    // CSS の 1px が デバイス上では、4px 使っている場合、デバイスピクセル比は、2。
    const pixelRatio = 1;
    // クロップ範囲を調整
    canvas.width = Math.floor(completedCrop.width * scaleX * pixelRatio);
    canvas.height = Math.floor(completedCrop.height * scaleY * pixelRatio);
    // (method) CanvasTransform.scale(x: number, y: number): void
    // キャンバス上の長さを縦方向および横方向に拡縮する変形を適用
    ctx.scale(pixelRatio, pixelRatio);
    // キャンバスに描画された画像のスムージングの品質設定
    ctx.imageSmoothingQuality = 'high';
    // クロップ範囲左上の角の座標
    const cropX = completedCrop.x * scaleX;
    const cropY = completedCrop.y * scaleY;
    // 画像の中心座標
    const centerX = image.naturalWidth / 2;
    const centerY = image.naturalHeight / 2;
    // save()
    // 描画状態を保存
    // 現在の塗りの色・輪郭の色・透明度・線の太さ… などの描画スタイルをsave()メソッドで保存
    ctx.save();
    // 変形を適用
    // 複数の変形を適用する際には、実行する変形の順序とは逆順にソースを記述
    // 6.クロップ開始位置(左上角)分画像を移動
    ctx.translate(-cropX, -cropY);
    // 5.ズームイン、ズームアウト操作用に画像をずらした分戻す
    ctx.translate(centerX, centerY);
    // 4.クロップ操作中、枠内で移動させた分画像を移動
    ctx.translate(offsetX, offsetY);
    // 3.ズームイン、ズームアウト エフェクト
    ctx.scale(scale / 100, scale / 100);
    // 2.画像の中心をcanvasの原点0,0に移動
    ctx.translate(-centerX, -centerY);
    // 1.imageを使ってフルサイズで描画
    ctx.drawImage(
      image,
      0,
      0,
      image.naturalWidth,
      image.naturalHeight,
      0,
      0,
      image.naturalWidth,
      image.naturalHeight
    );
    // save()のときの状態に戻す(移動、エフェクトの効果をクリア)
    ctx.restore();

    // a hrefタグ生成
    const a = document.createElement('a');
    // canvasの描画内容をデータURIに変換して(引数無しの場合、png)、そのURIをhrefへセット
    a.href = canvas.toDataURL();
    // ダウンロード時のファイル名を指定
    a.download = 'download.png';
    // クリックイベントを発生させる
    a.click();
  };

  return (
    <div className="mx-auto mt-4 text-center max-w-screen-sm md:max-w-screen-md lg:max-w-screen-lg">
      {/* muted:音をミュート(消音) */}
      {/* onLoadedMetadata:メディアのmetaデータが読み込まれたら */}
      <video muted onLoadedMetadata={onVideoLoad} style={{ display: 'none' }} />
      <canvas style={{ display: 'none' }} />
      <div
        id="canvasdiv"
        className="rounded outline outline-1 outline-gray-400 w-full h-64 sm:h-72 md:h-80 lg:h-96 mb-2"
        onWheel={(e) => handleWheel(e)}
        onPointerMove={(e) => handlePointerMove(e)}
        onPointerDown={(e) => handlePointerDown(e)}
        onPointerUp={handlePointerUp}
      >
        <Stack sx={{ height: '100%' }} spacing={0} direction="row">
          <ReactCrop
            crop={crop}
            keepSelection
            // クロップを変更するたびに発生するコールバック (つまり、ドラッグ/サイズ変更の際に何度も)。
            // 現在のトリミング状態オブジェクトを渡す。
            // このコールバックを実装してクロップ状態を更新する必要があることに注意。そうしないと何も変わらない。
            onChange={(c) => setCrop(c)}
            // onComplete?: (crop: PixelCrop, percentCrop: PercentCrop) => void
            // サイズ変更、ドラッグなどでクロップ範囲が確定したら(クロップ範囲の操作を止めたら)発動。
            // 現在のトリミング状態オブジェクトを渡す。
            onComplete={(c) => setCompletedCrop(c)}
            style={{
              position: 'relative',
              width: '100%',
              height: '100%',
              maxHeight: '100%',
              overflow: 'hidden',
            }}
          >
            <img
              id="image"
              alt="Crop me"
              style={{
                transform: `scale(${scale / 100})`,
                left: `${offsetX}px`,
                top: `${offsetY}px`,
                display: 'none',
                position: 'relative',
                maxWidth: 'none',
                maxHeight: 'none',
                border: 0,
              }}
            />
          </ReactCrop>
          <Stack>
            <Slider
              orientation="vertical"
              size="small"
              aria-label="Scale"
              valueLabelDisplay="auto"
              value={scale}
              onChange={(event, value) => {
                handleScale(event, value as number);
              }}
              max={200}
              sx={{ height: '100%' }}
              valueLabelFormat={(x) => `${x}%`}
              className="mt-1 mb-4"
            />
            <Tooltip title="Left" placement="right">
              <IconButton
                aria-label="left"
                size="small"
                onClick={() => setOffsetX(offsetX - 10)}
              >
                <ArrowBackIcon fontSize="small" />
              </IconButton>
            </Tooltip>
            <Tooltip title="Right" placement="right">
              <IconButton
                aria-label="right"
                size="small"
                onClick={() => setOffsetX(offsetX + 10)}
              >
                <ArrowForwardIcon fontSize="small" />
              </IconButton>
            </Tooltip>
            <Tooltip title="Up" placement="right">
              <IconButton
                aria-label="up"
                size="small"
                onClick={() => setOffsetY(offsetY - 10)}
              >
                <ArrowUpwardIcon fontSize="small" />
              </IconButton>
            </Tooltip>
            <Tooltip title="Down" placement="right">
              <IconButton
                aria-label="down"
                size="small"
                onClick={() => setOffsetY(offsetY + 10)}
              >
                <ArrowDownwardIcon fontSize="small" />
              </IconButton>
            </Tooltip>
          </Stack>
        </Stack>
      </div>
      <div>
        <Button
          className="w-32"
          sx={{ margin: 1 }}
          variant="contained"
          onClick={clickCapture}
        >
          Capture
        </Button>
      </div>
      <div>
        <Button
          className="w-32"
          sx={{ margin: 1 }}
          variant="contained"
          onClick={clickDownload}
        >
          Download
        </Button>
      </div>
    </div>
  );
}

export default App;

CAPTURE ボタン

clickCapturenavigator.mediaDevices.getDisplayMedia({ video: true }) を呼び出して、取り込む画面の選択画面が出てきます。
選択された画面は、handleSuccess<video /> タグの再生メディアとして再生されます。
<video /> タグの onLoadedMetadata イベントが発生して、onVideoLoad が呼び出されて、以下のような変換を行っています。

onVideoLoad の変換】
注意:<video /><canvas /> は常に非表示です。
<video />

context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);

<canvas /> に描画

img.src = canvas.toDataURL();

<img /> に描画

<video /> 停止


onVideoLoadの変換


DOWNLOAD ボタン

clickDownloadconst ctx = canvas.getContext('2d'); で 2D 描画のコンテキストを得ています。
それから、概ね以下の操作をして、ダウンロードしています。


Step1

取り込んだ画像をフルサイズで描画します。

ダウンロードcanvas処理Step1


Step2

canvas の原点が画像の真ん中になるように画像を移動します。

ダウンロードcanvas処理Step2


Step3

原点から画像を拡大縮小(scale)します。(以下の図は縮小した例です。)

ダウンロードcanvas処理Step3


Step4

拡大縮小するために真ん中に持ってきた画像を元の位置に戻します。

ダウンロードcanvas処理Step4


Step5

クロップするときに画像を動かした分(offsetX, offsetY)画像を動かします。(最初から真ん中を表示するために動かしているため、枠内で画像を動かさない=offset 0ではありません。)
また、クロップ枠の起点(左上角)分(-cropX, -cropY)画像を動かします。

ダウンロードcanvas処理Step5

この後、canvasデータURI と変換して、a.href で参照させれば、ダウンロードリンクみたいな存在になり、ダウンロードできます。


ヨシ!

loading...