Vue.js + Vite で車検証QR読み取り機能を作成したときの色々なネタ(備忘録)
※Version
"vue": "^3.2.47"
"@zxing/browser": "^0.1.1",
"@vitejs/plugin-basic-ssl": "^1.0.1",
その他は省略
概要
Vue.jsでデバイスのカメラを利用した二次元QRの読み取り機能の実装。
※開発環境はVite
※Vite環境は既に準備できているものとします。
Vite環境でhttps
デバイスのカメラを使って実装を確認するのでまずはHttpsでViteのサーバーを起動します。
なんちゃってHttpsでViteのサーバー起動するプラグインをインストール
npm i @vitejs/plugin-basic-ssl -D
vite.config.tsに上記プラグインの指定とhttps通信の設定を追記
※★★★の箇所
import basicSsl from '@vitejs/plugin-basic-ssl';//★★★
export default defineConfig(({ command, mode }) => {
const root = 'src';
return {
root: root,
//省略
plugins: [
vue(),
checker({
vueTsc: true,
eslint: {
lintCommand: 'eslint "./**/*.{ts,vue}"',
},
}),
basicSsl(),//★★★
],
//省略
server: {
host: '0.0.0.0',
port: 8888,
strictPort: false,
https: true,//★★★
open: true,
},
};
});
Viteのサーバー起動したら警告出ますが無視できる
①「詳細設定」をクリック
②「localhost にアクセスする(安全ではありません)」をクリック
これで「一応」WebRTC系の機能がブラウザで利用できる
WebRTCのデバイス関係のあれこれ
enumerateDevices ※デバイスリストを取得する
navigator.mediaDevices.enumerateDevices();
最近のスマホはレンズが無駄に?多く搭載されています。
デバイス一覧ではすべて別のデバイスで認識されそうですのでビデオ入力デバイスの一覧を取得します。
enumerateDevicesで取得できるデバイスの種類として以下のデバイスが列挙されるようです
- ビデオ入力
- 音声入力
- 音声出力
とりあえずこれらをいい感じに使いやすく関数化、
export const MediaKind = {
videoinput: 'videoinput',
audioinput: 'audioinput',
audiooutput: 'audiooutput',
} as const;
export type MediaKind = typeof MediaKind[keyof typeof MediaKind];
export const GetMediaList = async (mediaKind: MediaKind | undefined = undefined) => {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
throw new Error('enumerateDevices() not supported.');
}
//デバイスの権限取れてない場合の保険、他の手段あればそちらでもOK
await navigator.mediaDevices.getUserMedia({
audio: false,
video: { facingMode: 'environment'}
});
const list = await navigator.mediaDevices.enumerateDevices();
return list
.filter((row) => {
if (mediaKind !== undefined && row.kind !== mediaKind) return false;
return true;
})
.map((row) => {
return {
deviceId: row.deviceId,
groupId: row.groupId,
kind: row.kind,
label: row.label,
};
});
};
//使用例
// const mediaList = await GetMediaList(MediaKind.videoinput );
この関数でのほしいデバイス情報は「deviceId」と「label」です。
この関数を実行するとブラウザがカメラの権限を聞いてきます。
const mediaList = await GetMediaList(MediaKind.videoinput );
getUserMedia ※デバイスのストリームを取得する
navigator.mediaDevices.getUserMedia();
この関数でデバイスのカメラ入力Streamを取得してブラウザのVideoタグに登録します。
ということで、今回の二次元コード読み取りで使う想定でいい感じに使いやすく関数化、
/**
* カメラの起動
*/
export const GetVideoStream = async (
deviceId: string | undefined = undefined
): Promise<{ stream: MediaStream | null; message: string }> => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
deviceId: deviceId,
advanced: [
{ facingMode: 'environment' },
{ aspectRatio: 1 },
{
width: { min: 1080, ideal: 1080, max: 1080 },
height: { min: 1080, ideal: 1080, max: 1080 },
},
],
},
});
return { stream: stream, message: '' };
} catch (error: any) {
if (error instanceof Error) {
if (error.message == 'Permission denied') {
return {
stream: null,
message: 'カメラの使用権限を再設定してください。\n※設定後ブラウザのリフレッシュをお願いします。',
};
} else if (
error.name == 'TypeError' &&
error.message == "undefined is not an object (evaluating 'navigator.mediaDevices.getUserMedia')"
) {
return { stream: null, message: 'ご利用のデバイスとブラウザの組み合わせではご利用になれません' };
} else {
return {
stream: null,
message: error.name + ': ' + error.message,
};
}
}
return { stream: null, message: String(error) };
}
};
getUserMediaの引数について
いくつか捕捉
- video.deviceId・・・・・ enumerateDevices()で取得した「deviceId」
- video.advanced・・・・・ この中に含まれる値が設定できたら設定される(できなかったら無視される)
- video.advanced[X].aspectRatio・・・・・ 縦横比、今回二次元QRなので1を指定
getUserMediaで取得したStreamを止める
Streamの停止方法、カメラの切り替えとかのときに呼び出す
/**
* カメラの停止
*/
export const StopVideoStream = (stream: MediaStream | null): null => {
try {
if (stream) {
for (let i = 0; i < stream.getTracks().length; i++) {
stream.getTracks()[i].stop();
}
}
} catch (e) {
console.error('StopStream', e);
}
return null;
};
VideoタグにStreamをセットしてカメラ映像をVideoタグに表示
stream格納用変数、ビデオタグ格納Refオブジェクトを用意
const state = reactive<State>({
video: {
stream: null,
//省略
},
//省略
});
const elmVideo = ref<HTMLVideoElement | null>(null);
videoタグのsrcObjectにstream格納用プロパティをセット
<video
class="video-origin"
ref="elmVideo"
autoplay="true"
muted="true"
playsinline="true"
:srcObject="state.video.stream"
></video>
stream格納用変数にstreamをセット
事前にdeviceIdリストを取得しているものとします。
const ret = await Device.GetVideoStream(deviceId);
if (ret.stream === null) throw new Error(ret.message);
state.video.stream = ret.stream;
ここまででVideoタグにカメラの映像が映ると思います。
VideoタグからQRを解析
QRを読み込む処理
手順は
- ビデオタグの特定の領域の画像データをCanvasに書き込む
- Canvasから画像を生成
- codeReader.decodeFromImageUrlで画像から二次元コードの読み取り
- ※ コードがなければエラーが発生
ちなみにcodeReaderのデコード方法は他にも以下の関数がありますが、今回は諸事情で画像化を経由しています。
- codeReader.decodeFromVideoElement
- codeReader.decodeFromStream
以下は上記工程をシンプルに実行した場合のイメージ
//npm i @zxing/browser でインストールが必要
import { BrowserQRCodeReader } from '@zxing/browser';
const qrRead = async () => {
//例外処理など省略
const rect = {x:0,y:0,w:600,h:600}
const canv = document.createElement('canvas');
canv.height = rect.h;
canv.width = rect.w;
const ctx = canv.getContext('2d');
ctx.drawImage(elmVideo.value, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w, rect.w);
const dataUrl = canv.toDataURL('image/jpg');
const ret = await decodeFromImageUrl(dataUrl);
};
/**
* DataUrlからQR画像を解析します
* 解析失敗の場合Nullが返却される
*/
const decodeFromImageUrl = async (dataUrl: string) => {
try {
const codeReader = new BrowserQRCodeReader();
const ret = await codeReader.decodeFromImageUrl(dataUrl);
return ret.getText();
} catch {
//画像にQRがセットされていない場合エラーを検知するから「これはスルーでOK」
return null;
}
};
その他、今回利用すると幸せになれそうなコード
Videoタグのサイズ捕捉
読み取り部分のレスポンシブ対応で利用
/**
* ストリームを開始したビデオのサイズを取得する
*/
export const GetVideoSize = (videoElement: HTMLVideoElement) => {
return new Promise<{ w: number; h: number } | null>((resolve) => {
// console.log('IncDevice.GetVideoSize');
const ret = { w: 0, h: 0 };
const intervalId = setInterval(() => {
if (!(videoElement.readyState >= HTMLMediaElement.HAVE_METADATA)) return;
ret.h = videoElement.videoHeight;
ret.w = videoElement.videoWidth;
clearInterval(intervalId);
resolve(ret);
return;
}, 200);
setTimeout(() => {
clearInterval(intervalId);
resolve(null);
}, 2000);
});
};
認識エリアの描画
Videoタグに同サイズのcanvasを重ねて描画します。
※上記Videoタグのサイズ捕捉でVideoタグの解像度(表示サイズ)が取得できているものとします。
videoタグと同じ階層にcanvasタグ設置
<div class="view-rect-scale" :style="styleViewRectScale">
<video
class="video-origin"
ref="elmVideo"
autoplay="true"
muted="true"
playsinline="true"
:srcObject="state.video.stream"
></video>
<canvas
class="canvas-overlay"
ref="elmCanvas"
:height="state.video.size.h"
:width="state.video.size.w"
></canvas>
</div>
スタイルでVideoタグの上にcanvasタグを設置
.view-rect-scale {
//省略
canvas,
video {
position: absolute;
inset: 0 0 0 0;
}
}
//キャンバスタグ捕捉用
const elmCanvas = ref<HTMLCanvasElement | null>(null);
/**
* ガイド線の描画
*/
const drawOverlay = () => {
// console.log('drawOverlay');
let ctx: CanvasRenderingContext2D | null = null;
try {
if (!elmCanvas.value) return;
ctx = elmCanvas.value.getContext('2d');
if (!ctx) throw new Error(`state.element.canvasOverlay.getContext('2d') Error`);
ctx.clearRect(0, 0, state.video.size.w, state.video.size.h);
const color = state.scan.data === null ? 'rgb(255,120,0)' : 'rgb(0,140,0)';
ctx.strokeStyle = color;
ctx.lineWidth = 3;
const w = Math.min(state.video.size.w, state.video.size.h);
const lineW = (w * state.scan.adjuster) / 2;
state.scan.rect.x = state.video.size.w / 2 - lineW / 2;
state.scan.rect.y = state.video.size.h / 2 - lineW / 2;
state.scan.rect.w = lineW;
state.scan.rect.h = lineW;
ctx.strokeRect(state.scan.rect.x, state.scan.rect.y, state.scan.rect.w, state.scan.rect.h);
} catch (error) {
console.error('drawOverlay', error);
} finally {
ctx = null;
}
};
/**
* drawOverlay()実行タイミング
* - ガイド線の描画タイミング
* - これ以外にはカメラStream取得後に実行
*/
watch(
() => [state.scan.adjuster, state.scan.data],
() => {
drawOverlay();
}
);
完成したソース
今回のソースは2つのファイル
<script setup lang="ts">
import { onMounted, ref, watch, reactive, computed } from 'vue';
import { BrowserQRCodeReader } from '@zxing/browser';
import { useElementSize } from '@vueuse/core';
import { Device } from './device';
interface State {
video: {
stream: MediaStream | null;
size: {
h: number;
w: number;
};
};
device: {
id: null | string;
list: Device.DeviceListRow[];
};
scan: {
intervalId: null;
isDecoding: boolean;
fps: number;
data: string | null;
adjuster: number;
rect: {
x: number;
y: number;
h: number;
w: number;
};
};
}
const state = reactive<State>({
video: {
stream: null,
size: {
h: 0,
w: 0,
},
},
device: {
id: null,
list: [],
},
scan: {
intervalId: null,
isDecoding: false,
data: null,
fps: 1,
adjuster: 1,
rect: {
x: 0,
y: 0,
h: 0,
w: 0,
},
},
});
const elmVideo = ref<HTMLVideoElement | null>(null);
const elmCanvas = ref<HTMLCanvasElement | null>(null);
const elmViewContainer = ref(null);
const elmViewContainerSize = useElementSize(elmViewContainer);
//---------------------------------------------
/**
* カメラ一覧取得
*/
const getDeviceList = async () => {
state.device.list = await Device.GetMediaList(Device.MediaKind.videoinput);
};
watch(
() => state.device.list,
(list) => {
//カメラが一つだけだったらそれでいいじゃん
if (list.length === 1) state.device.id = list[0].deviceId;
}
);
watch(
() => state.device.id,
async (value) => {
if (!value) return;
console.log('ID 変更' + value);
resetCamera();
}
);
/**
* カメラ機能リセット
*/
const resetCamera = async () => {
try {
if (state.device.id === null) return;
await startStream(state.device.id);
drawOverlay();
startQrReader();
} catch (error) {
console.error('resetCamera', error);
}
};
//-[stream]----------------------------------------------
const startStream = async (deviceId: string) => {
try {
if (elmVideo.value === null) throw new Error('ビデオタグがありません?');
//既存Streamがある場合、停止させる
if (state.video.stream === null) {
state.video.stream = Device.StopVideoStream(state.video.stream);
}
const ret = await Device.GetVideoStream(deviceId);
if (ret.stream === null) throw new Error(ret.message);
state.video.stream = ret.stream;
const size = await Device.GetVideoSize(elmVideo.value);
if (size === null) throw new Error('ビデオの解像度が何故か取れませんでした?');
state.video.size = size;
} catch (error: any) {
console.error('GetVideoStream : Error : ' + error.message);
}
};
/**
* ビデオの解像度から親要素に収まる拡大率を計算する
*/
const scale = computed(() => {
const width = elmViewContainerSize.width.value;
const rateX = width / state.video.size.w;
const rateY = width / state.video.size.h;
if (rateX > 1 || rateY > 1) {
return 1;
} else {
return Math.max(rateX, rateY);
}
});
// [ キャンバス描画 ] -------------------------------------------------------------
/**
* ガイド線の描画
*/
const drawOverlay = () => {
// console.log('drawOverlay');
let ctx: CanvasRenderingContext2D | null = null;
try {
if (!elmCanvas.value) return;
ctx = elmCanvas.value.getContext('2d');
if (!ctx) throw new Error(`state.element.canvasOverlay.getContext('2d') Error`);
ctx.clearRect(0, 0, state.video.size.w, state.video.size.h);
const color = state.scan.data === null ? 'rgb(255,120,0)' : 'rgb(0,140,0)';
ctx.strokeStyle = color;
ctx.lineWidth = 3;
const w = Math.min(state.video.size.w, state.video.size.h);
const lineW = (w * state.scan.adjuster) / 2;
state.scan.rect.x = state.video.size.w / 2 - lineW / 2;
state.scan.rect.y = state.video.size.h / 2 - lineW / 2;
state.scan.rect.w = lineW;
state.scan.rect.h = lineW;
ctx.strokeRect(state.scan.rect.x, state.scan.rect.y, state.scan.rect.w, state.scan.rect.h);
} catch (error) {
console.error('drawOverlay', error);
} finally {
ctx = null;
}
};
/**
* ガイド線の描画タイミング
*/
watch(
() => [state.scan.adjuster, state.scan.data],
() => {
drawOverlay();
}
);
// [ QR解析関連 ] -------------------------------------------------------------
/**
* qr解析タスクの開始
*/
const startQrReader = () => {
stopQrReader();
//console.log('startQrReader');
state.scan.intervalId = (setInterval as any)(async () => {
try {
if (state.scan.data !== null) return;
if (state.scan.isDecoding === true) {
console.log('QrReader >> isDecoding');
return;
}
state.scan.isDecoding = true;
await qrRead();
} catch (error) {
console.error('QrReader >> isDecoding', error);
} finally {
state.scan.isDecoding = false;
}
}, Math.floor(1000 / state.scan.fps));
};
/**
* QRコードを解析します。
*/
const qrRead = async () => {
console.log('qrRead');
if (!elmVideo.value) return;
let canv: HTMLCanvasElement | null = null;
let ctx: CanvasRenderingContext2D | null = null;
try {
canv = document.createElement('canvas');
if (!canv) return;
const rect = state.scan.rect;
canv.height = rect.h;
canv.width = rect.w;
ctx = canv.getContext('2d');
if (!ctx) return;
ctx.drawImage(elmVideo.value, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w, rect.w);
const dataUrl = canv.toDataURL('image/jpg');
const ret = await decodeFromImageUrl(dataUrl);
console.log('decodeFromImageUrl', ret);
if (ret != null) {
state.scan.data = ret;
} else {
state.scan.data = null;
}
} catch (error) {
console.error('qrRead', error);
} finally {
ctx = null;
if (canv) {
canv.height = 0;
canv.width = 0;
canv.remove();
}
canv = null;
}
};
/**
* DataUrlからQR画像を解析します
* 解析失敗の場合Nullが返却される
*/
const decodeFromImageUrl = async (dataUrl: string) => {
try {
const codeReader = new BrowserQRCodeReader();
const ret = await codeReader.decodeFromImageUrl(dataUrl);
return ret.getText();
} catch {
//画像にQRがセットされていない場合エラーを検知するから「これはスルーでOK」
return null;
}
};
/**
* qr解析タスクの終了
*/
const stopQrReader = () => {
if (state.scan.intervalId !== null) {
console.log('stopQrReader', state.scan.intervalId);
clearInterval(state.scan.intervalId);
}
};
//-[Style]----------------------------------------------
/**
* Video、Canvasタグ格納用の拡大縮小する要素のStyle
*/
const styleViewRectScale = computed(() => {
if (state.video.size.h === 0) {
return { opacity: 0 };
}
return {
transform: `scale(${scale.value})`,
opacity: state.video.stream?.active === false ? '0' : '1',
};
});
/**
* スキャンしたデータを表示する要素のStyle
*/
const styleScanData = computed(() => {
const rect = state.scan.rect;
const scaleRate = 1 / scale.value;
return {
transform: `scale(${scaleRate})`,
top: `${rect.y + rect.h}px`,
left: `${rect.x}px`,
width: `${rect.w / scaleRate}px`,
opacity: !state.scan.data ? '0' : '1',
};
});
/**
* QR解析を一時中止しスキャンしたデータを削除、一定時間後にQR解析再開
*/
const clear = () => {
stopQrReader();
state.scan.data = null;
setTimeout(() => {
startQrReader();
}, 1500);
};
onMounted(() => {
getDeviceList();
});
</script>
<template>
<div class="container-fluid">
<div class="card mt-2 mb-3">
<div class="card-header bg-info">カメラ</div>
<div class="card-body">
<div class="">デバイスリスト</div>
<select class="form-select mb-1" v-model="state.device.id">
<template v-for="(row, index) in state.device.list" :key="index">
<option :value="row.deviceId">{{ row.label }}</option>
</template>
</select>
<div class="view">
<div class="view-rect" ref="elmViewContainer">
<div class="view-rect-scale" :style="styleViewRectScale">
<video
class="video-origin"
ref="elmVideo"
autoplay="true"
muted="true"
playsinline="true"
:srcObject="state.video.stream"
></video>
<canvas
class="canvas-overlay"
ref="elmCanvas"
:height="state.video.size.h"
:width="state.video.size.w"
></canvas>
<div class="scanData" :style="styleScanData">{{ state.scan.data }}</div>
</div>
</div>
<div class="clear-btn-container" :class="{ isShow: state.scan.data !== null }">
<button type="button" class="btn btn-warning clearBtn" @click="clear()">クリア</button>
</div>
</div>
</div>
<div class="card-footer">
<label class="form-label">読み取りサイズ</label>
<input type="range" class="form-range" min="0.2" max="2" step="0.2" v-model="state.scan.adjuster" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.card,
.card-header,
.card-body {
border-color: rgb(24, 7, 112);
}
.scanData {
position: absolute;
height: 2em;
overflow: hidden;
border: solid 1px gray;
border-radius: 10px;
background-color: white;
transform-origin: top left;
padding: 2px 4px;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0;
transition: opacity 300ms;
}
.view {
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
flex-direction: column;
.clear-btn-container {
position: absolute;
inset: 0 auto auto auto;
min-width: 300px;
max-width: 100%;
width: 60%;
margin-bottom: 4px;
opacity: 0;
transition: opacity 300ms;
&.isShow {
opacity: 1;
}
.btn {
margin: 10px 10px 0 10px;
width: calc(100% - 20px);
border: solid 1px rgb(203, 88, 0);
}
}
> .view-rect {
min-width: 300px;
max-width: 100%;
width: 60%;
&::before {
content: '';
display: block;
padding-top: 100%;
}
> .view-rect-scale {
position: absolute;
inset: 0 0 0 0;
transform-origin: top left;
opacity: 0;
canvas,
video {
position: absolute;
inset: 0 0 0 0;
}
}
}
}
</style>
export namespace Device {
export const MediaKind = {
videoinput: 'videoinput',
audioinput: 'audioinput',
audiooutput: 'audiooutput',
} as const;
export type MediaKind = typeof MediaKind[keyof typeof MediaKind];
export interface DeviceListRow {
deviceId: string;
groupId: string;
kind: MediaKind;
label: string;
}
export const GetMediaList = async (mediaKind: MediaKind | undefined = undefined) => {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
throw new Error('enumerateDevices() not supported.');
}
//デバイスの権限取れてない場合の保険、他の手段あればそちらでもOK
await navigator.mediaDevices.getUserMedia({
audio: false,
video: { facingMode: 'environment' },
});
const list = await navigator.mediaDevices.enumerateDevices();
return list
.filter((row) => {
if (mediaKind !== undefined && row.kind !== mediaKind) return false;
return true;
})
.map((row) => {
return {
deviceId: row.deviceId,
groupId: row.groupId,
kind: row.kind,
label: row.label,
};
});
};
/**
* カメラの起動
*/
export const GetVideoStream = async (
deviceId: string | undefined = undefined
): Promise<{ stream: MediaStream | null; message: string }> => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
deviceId: deviceId,
advanced: [
{ facingMode: 'environment' },
{ aspectRatio: 1 },
{
width: { min: 1080, ideal: 1080, max: 1080 },
height: { min: 1080, ideal: 1080, max: 1080 },
},
],
},
});
return { stream: stream, message: '' };
} catch (error: any) {
if (error instanceof Error) {
if (error.message == 'Permission denied') {
return {
stream: null,
message: 'カメラの使用権限を再設定してください。\n※設定後ブラウザのリフレッシュをお願いします。',
};
} else if (
error.name == 'TypeError' &&
error.message == "undefined is not an object (evaluating 'navigator.mediaDevices.getUserMedia')"
) {
return { stream: null, message: 'ご利用のデバイスとブラウザの組み合わせではご利用になれません' };
} else {
return {
stream: null,
message: error.name + ': ' + error.message,
};
}
}
return { stream: null, message: String(error) };
}
};
/**
* カメラの停止
*/
export const StopVideoStream = (stream: MediaStream | null): null => {
try {
if (stream) {
for (let i = 0; i < stream.getTracks().length; i++) {
stream.getTracks()[i].stop();
}
}
} catch (e) {
console.error('StopStream', e);
}
return null;
};
/**
* ストリームを開始したビデオのサイズを取得する
*/
export const GetVideoSize = (videoElement: HTMLVideoElement) => {
return new Promise<{ w: number; h: number } | null>((resolve) => {
// console.log('IncDevice.GetVideoSize');
const ret = { w: 0, h: 0 };
const intervalId = setInterval(() => {
if (!(videoElement.readyState >= HTMLMediaElement.HAVE_METADATA)) return;
ret.h = videoElement.videoHeight;
ret.w = videoElement.videoWidth;
clearInterval(intervalId);
resolve(ret);
return;
}, 200);
setTimeout(() => {
clearInterval(intervalId);
resolve(null);
}, 2000);
});
};
}
GitHubにサンプル上げてます
ついでにGitHubにデモページも・・・