├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── RELEASE.md
├── assets
├── qr.png
└── setup.jpg
├── deploy
├── deploy.sh
├── id_rsa.enc
└── id_rsa.pub
├── docs
├── app.js
├── favicon.png
├── index.html
└── style.css
├── export.js
├── gulpfile.js
├── index.js
├── package.json
└── src
├── camera.js
├── scanner.js
└── zxing.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 |
4 | .idea/
5 | yarn.lock
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | - '6'
3 | install: npm install
4 | script: gulp release
5 | env:
6 | global:
7 | - COMMIT_AUTHOR_NAME: "'Chris Schmich'"
8 | - COMMIT_AUTHOR_EMAIL: 'schmch@gmail.com'
9 | - ENCRYPTION_LABEL: 95f025fe7ce3
10 | deploy:
11 | provider: script
12 | script: bash ./deploy/deploy.sh
13 | skip_cleanup: true
14 | on:
15 | branch: master
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Chris Schmich
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #  Instascan
2 | Real-time webcam-driven HTML5 QR code scanner. [Try the live demo](https://schmich.github.io/instascan/).
3 |
4 | ## Installing
5 |
6 | *Note:* Chrome requires HTTPS when using the WebRTC API. Any pages using this library should be served over HTTPS.
7 |
8 | ### NPM
9 |
10 | `npm install --save instascan`
11 |
12 | ```javascript
13 | const Instascan = require('instascan');
14 | ```
15 |
16 | ### Bower
17 |
18 | Pending. [Drop a note](https://github.com/schmich/instascan/issues/31) if you need Bower support.
19 |
20 | ### Minified
21 |
22 | Copy `instascan.min.js` from the [releases](https://github.com/schmich/instascan/releases) page and load with:
23 |
24 | ```html
25 |
26 | ```
27 |
28 | ## Example
29 |
30 | ```html
31 |
32 |
33 |
34 | Instascan
35 |
36 |
37 |
38 |
39 |
54 |
55 |
56 | ```
57 |
58 | ## API
59 |
60 | ### let scanner = new Instascan.Scanner(opts)
61 |
62 | Create a new scanner with options:
63 |
64 | ```javascript
65 | let opts = {
66 | // Whether to scan continuously for QR codes. If false, use scanner.scan() to manually scan.
67 | // If true, the scanner emits the "scan" event when a QR code is scanned. Default true.
68 | continuous: true,
69 |
70 | // The HTML element to use for the camera's video preview. Must be a element.
71 | // When the camera is active, this element will have the "active" CSS class, otherwise,
72 | // it will have the "inactive" class. By default, an invisible element will be created to
73 | // host the video.
74 | video: document.getElementById('preview'),
75 |
76 | // Whether to horizontally mirror the video preview. This is helpful when trying to
77 | // scan a QR code with a user-facing camera. Default true.
78 | mirror: true,
79 |
80 | // Whether to include the scanned image data as part of the scan result. See the "scan" event
81 | // for image format details. Default false.
82 | captureImage: false,
83 |
84 | // Only applies to continuous mode. Whether to actively scan when the tab is not active.
85 | // When false, this reduces CPU usage when the tab is not active. Default true.
86 | backgroundScan: true,
87 |
88 | // Only applies to continuous mode. The period, in milliseconds, before the same QR code
89 | // will be recognized in succession. Default 5000 (5 seconds).
90 | refractoryPeriod: 5000,
91 |
92 | // Only applies to continuous mode. The period, in rendered frames, between scans. A lower scan period
93 | // increases CPU usage but makes scan response faster. Default 1 (i.e. analyze every frame).
94 | scanPeriod: 1
95 | };
96 | ```
97 |
98 | ### scanner.start(camera)
99 |
100 | - Activate `camera` and start scanning using it as the source. Returns promise.
101 | - This must be called in order to use [`scanner.scan`](#let-result--scannerscan) or receive [`scan`](#scanneraddlistenerscan-callback) events.
102 | - `camera`: Instance of `Instascan.Camera` from [`Instascan.Camera.getCameras`](#instascancameragetcameras).
103 | - `.then(function () { ... })`: called when camera is active and scanning has started.
104 | - `.catch(function (err) { ... })`
105 | - Called when an error occurs trying to initialize the camera for scanning.
106 | - `err`: An `Instascan.MediaError` in the case of a known `getUserMedia` failure ([see error types](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#Errors)).
107 |
108 | ### scanner.stop()
109 |
110 | - Stop scanning and deactivate the camera. Returns promise.
111 | - `.then(function () { ... })`: called when camera and scanning have stopped.
112 |
113 | ### let result = scanner.scan()
114 |
115 | - Scan video immediately for a QR code.
116 | - QR codes recognized with this method are not emitted via the `scan` event.
117 | - If no QR code is detected, `result` is `null`.
118 | - `result.content`: Scanned content decoded from the QR code.
119 | - `result.image`: Undefined if [`scanner.captureImage`](#let-scanner--new-instascanscanneropts) is `false`, otherwise, see the [`scan`](#scanneraddlistenerscan-callback) event for format.
120 |
121 | ### scanner.addListener('scan', callback)
122 |
123 | - Emitted when a QR code is scanned using the camera in continuous mode (see [`scanner.continuous`](#let-scanner--new-instascanscanneropts)).
124 | - `callback`: `function (content, image)`
125 | - `content`: Scanned content decoded from the QR code.
126 | - `image`: `null` if [`scanner.captureImage`](#let-scanner--new-instascanscanneropts) is `false`, otherwise, a base64-encoded [WebP](https://en.wikipedia.org/wiki/WebP)-compressed data URI of the camera frame used to decode the QR code.
127 |
128 | ### scanner.addListener('active', callback)
129 |
130 | - Emitted when the scanner becomes active as the result of [`scanner.start`](#scannerstartcamera) or the tab gaining focus.
131 | - If `opts.video` element was specified, it will have the `active` CSS class.
132 | - `callback`: `function ()`
133 |
134 | ### scanner.addListener('inactive', callback)
135 |
136 | - Emitted when the scanner becomes inactive as the result of [`scanner.stop`](#scannerstop) or the tab losing focus.
137 | - If `opts.video` element was specified, it will have the `inactive` CSS class.
138 | - `callback`: `function ()`
139 |
140 | ### Instascan.Camera.getCameras()
141 |
142 | - Enumerate available video devices. Returns promise.
143 | - `.then(function (cameras) { ... })`
144 | - Called when cameras are available.
145 | - `cameras`: Array of `Instascan.Camera` instances available for use.
146 | - `.catch(function (err) { ... })`
147 | - Called when an error occurs while getting cameras.
148 | - `err`: An `Instascan.MediaError` in the case of a known `getUserMedia` failure ([see error types](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#Errors)).
149 |
150 | ### camera.id
151 |
152 | - Unique camera ID provided by the browser.
153 | - These IDs are stable and can be persisted across instances of your application (e.g. in localStorage).
154 |
155 | ### camera.name
156 |
157 | - Camera name, including manufacturer and model
158 | - e.g. "Microsoft LifeCam HD-3000".
159 |
160 | ## Compatibility
161 |
162 | Instascan works on non-iOS platforms in [any browser that supports the WebRTC/getUserMedia API](http://caniuse.com/#feat=stream), which currently includes Chome, Firefox, Opera, and Edge. IE and Safari are not supported.
163 |
164 | Instascan does not work on iOS since Apple does not yet support WebRTC in WebKit *and* forces other browser vendors (Chrome, Firefox, Opera) to use their implementation of WebKit. [Apple is actively working on WebRTC support in WebKit](https://bugs.webkit.org/show_bug.cgi?id=124288).
165 |
166 | ## Performance
167 |
168 | Many factors affect how quickly and reliably Instascan can detect QR codes.
169 |
170 | If you control creation of the QR code, consider the following:
171 |
172 | - A larger physical code is better. A 2" square code is better than a 1" square code.
173 | - Flat, smooth, matte surfaces are better than curved, rough, glossy surfaces.
174 | - Include a sufficient quiet zone, the white border surrounding QR code. The quiet zone should be at least four times the width of an individual element in your QR code.
175 | - A simpler code is better. You can use [this QR code generator](https://www.the-qrcode-generator.com/) to see how your input affects complexity.
176 | - For the same length, numeric content is simpler than ASCII content, which is simpler than Unicode content.
177 | - Shorter content is simpler. If you're encoding a URL, consider using a shortener such as [goo.gl](https://goo.gl/) or [bit.ly](https://bitly.com/).
178 |
179 | When scanning, consider the following:
180 |
181 | - QR code orientation doesn't matter.
182 | - Higher resolution video is better, but is more CPU intensive.
183 | - Direct, orthogonal scanning is better than scanning at an angle.
184 | - Blurry video greatly reduces scanner performance.
185 | - Auto-focus can cause lags in detection as the camera adjusts focus. Consider disabling it or using a fixed-focus camera with the subject positioned at the focal point.
186 | - Exposure adjustment on cameras can cause lags in detection. Consider disabling it or having a fixed white backdrop.
187 |
188 | ## Example Setup
189 |
190 | - Purpose: To scan QR code stickers on paper cards and plastic bags.
191 | - Camera: [Microsoft LifeCam HD-3000](http://www.newegg.com/Product/Product.aspx?Item=9SIA4RE40S4991), 720p, fixed focus, around $30 USD.
192 | - Small support to ensure camera is focused on subject.
193 | - White paper backdrop to mitigate exposure adjustment.
194 |
195 | 
196 |
197 | ## Credits
198 |
199 | Powered by the [Emscripten JavaScript build](https://github.com/kig/zxing-cpp-emscripten) of the [C++ port](https://github.com/glassechidna/zxing-cpp) of the [ZXing Java library](https://github.com/zxing/zxing).
200 |
201 | ## License
202 |
203 | Copyright © 2016 Chris Schmich
204 | MIT License. See [LICENSE](LICENSE) for details.
205 |
--------------------------------------------------------------------------------
/RELEASE.md:
--------------------------------------------------------------------------------
1 | # Publishing a Release
2 |
3 | - `git pull` to ensure you're synced with origin
4 | - Bump version number in `package.json` to `x.y.z`
5 | - `git commit` final changes
6 | - `git tag -s x.y.z -m 'Release x.y.z.'` to create a release tag
7 | - `git push && git push --tags` to push changes and release tag
8 | - `npm publish` to publish new version
9 | - [Create release notes on GitHub](https://github.com/schmich/instascan/releases)
10 |
--------------------------------------------------------------------------------
/assets/qr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schmich/instascan/b0f9519f2dd2a6661e67066d6ed678e621dd5ce2/assets/qr.png
--------------------------------------------------------------------------------
/assets/setup.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schmich/instascan/b0f9519f2dd2a6661e67066d6ed678e621dd5ce2/assets/setup.jpg
--------------------------------------------------------------------------------
/deploy/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euf -o pipefail
4 |
5 | cd $(dirname $0)
6 |
7 | key_name="encrypted_${ENCRYPTION_LABEL}_key"
8 | iv_name="encrypted_${ENCRYPTION_LABEL}_iv"
9 | key=${!key_name}
10 | iv=${!iv_name}
11 | openssl aes-256-cbc -K "$key" -iv "$iv" -in id_rsa.enc -out id_rsa -d
12 | chmod 400 id_rsa
13 | eval `ssh-agent -s`
14 | ssh-add id_rsa
15 |
16 | url=`git config remote.origin.url`
17 | user=$(basename $(dirname "$url"))
18 | project=$(basename "$url" .git)
19 | repo="git@github.com:$user/$project-builds"
20 | sha=`git rev-parse --verify HEAD`
21 |
22 | git clone "$repo" deploy && cd deploy
23 | cp ../../dist/instascan.min.js . && git add ./instascan.min.js
24 |
25 | if [ -z "$(git diff --cached)" ]; then
26 | echo 'No changes to deploy.'
27 | exit 0
28 | fi
29 |
30 | git config user.name "$COMMIT_AUTHOR_NAME"
31 | git config user.email "$COMMIT_AUTHOR_EMAIL"
32 | git commit -a -m "Automatic build for $user/$project@${sha}."
33 | git push "$repo" master
34 |
--------------------------------------------------------------------------------
/deploy/id_rsa.enc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schmich/instascan/b0f9519f2dd2a6661e67066d6ed678e621dd5ce2/deploy/id_rsa.enc
--------------------------------------------------------------------------------
/deploy/id_rsa.pub:
--------------------------------------------------------------------------------
1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAEAQDBT93Y3h0g+SqCALZUEa15jY/VqYQG+kZ4qowD7uglYOqKehPUkSVqN3li1lnTYE70bDCtRq+G1Dv3/Y479mCv/Tl5oOlqiSE1hDdnT/oqGurTUCKaVjDClUQIrV7mG4YOHkv2FjQQMLx7nRir7PDlHtvH3XCCV2ZhOMLhF+7Eu5c6bBjGgxJCxcfXv5a4KahW60PrdM4ZOAaEmMzYd63ooaeyjIY5uSfGdihK1Jt1VnzYn3jwnFz0uIO8z6MefGkhE9kmypSET8HHp/lUanZVmHh5IKWgMRgBMFSBQubIFSxt5f4YQF6PAwKso/otk9HQT9ev+EmIaBgqKAak+Su6VBi0r9K3FdxMf2ows3+IhJVv9+Alj3szjQ1IbAtDMcfFnsJutJrKmMbHjAXOBUiiy4FMsltSRi6FXE/dsprQz/f8lCVmK+rQOr32tVv5dOq3hUMWu2KZ8wFDb4EHA8enKn8LGVWjSpPmdYrb+9l+OaD0Vq1QE7IRibAkEZDhD5gzN1KmPeaPIfZsfwuv1Lr6Cx7Eexg2Z47zUiod5sGqHE0wdRuW3PSxq0wmuE8ewqWOzM6rJpkvL2dqGESjhKhBw1YgDd9GriPw+1ApZWhx9W+SCBl4ILu+RzPQhnC8Jj2TzsSYU7362GIXdKaNsfG2iKMP2zsO12Z+sIPCfFPCmRe11g8qeD4E9Ed6Uyd4jVPXywXZprHJBKdwTSMot/X3obhMMF51pudGVSTVFyGXSi63fllLgZbcYlngjsJQyiVNXzS9WNxKjhof0YOLS5mXUn1FAdz1FuV4QkxEqhad2wxAQGBH364rSbgXBmJPBPlpT7NZOs1qIgqNSZnNC6SDQix07ts4YAm0DaLleIYwsQRIsHnKPoGKtyk+yfnKARrcq/gDNSFLqoqfgYU6JsCLzno/NPOozmdMcmzHMT6ypsRVg3K4MV0U741LBSD0i8dOveGAgUGeH6yx+eeDNbIO0Jvb2/bXnjb9dB9Q0Gi74nvKnXFLv6lAakLeeJoR+TbfqfyEREj3WC9e1nFD7aU+JkUGnBSwFSRaOKRU12o+6+/bifxhOgWZ2jvUhAHPytyAW86dhf+K8ZYiBhqV1jMtJNLWf4BEElVX3BXYG2YM/vH9RxnhitXqoB8+gsXkIHguKuENHONhIYI5h/nSGVkfvOE77AbordtEB/moJJFUTQVI8A4iWA8hbjjxK+9MTXA8XkeF7yxJ/Uwdghudf4OKeIML0sVw/RbBSnkG30umtZyGlxmNvYLyYN83LMDNxij49V9aULUvw3qekljuuTzA80pg1Is7gu6gQbgb4WgV18L9IlRoftJaYNKTS6Z5/imlsZEQGWsOGe4PemN3L9yx schmch@gmail.com
2 |
--------------------------------------------------------------------------------
/docs/app.js:
--------------------------------------------------------------------------------
1 | var app = new Vue({
2 | el: '#app',
3 | data: {
4 | scanner: null,
5 | activeCameraId: null,
6 | cameras: [],
7 | scans: []
8 | },
9 | mounted: function () {
10 | var self = this;
11 | self.scanner = new Instascan.Scanner({ video: document.getElementById('preview'), scanPeriod: 5 });
12 | self.scanner.addListener('scan', function (content, image) {
13 | self.scans.unshift({ date: +(Date.now()), content: content });
14 | });
15 | Instascan.Camera.getCameras().then(function (cameras) {
16 | self.cameras = cameras;
17 | if (cameras.length > 0) {
18 | self.activeCameraId = cameras[0].id;
19 | self.scanner.start(cameras[0]);
20 | } else {
21 | console.error('No cameras found.');
22 | }
23 | }).catch(function (e) {
24 | console.error(e);
25 | });
26 | },
27 | methods: {
28 | formatName: function (name) {
29 | return name || '(unknown)';
30 | },
31 | selectCamera: function (camera) {
32 | this.activeCameraId = camera.id;
33 | this.scanner.start(camera);
34 | }
35 | }
36 | });
37 |
--------------------------------------------------------------------------------
/docs/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schmich/instascan/b0f9519f2dd2a6661e67066d6ed678e621dd5ce2/docs/favicon.png
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Instascan – Demo
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/docs/style.css:
--------------------------------------------------------------------------------
1 | body, html {
2 | padding: 0;
3 | margin: 0;
4 | font-family: 'Helvetica Neue', 'Calibri', Arial, sans-serif;
5 | height: 100%;
6 | }
7 | #app {
8 | background: #263238;
9 | display: flex;
10 | align-items: stretch;
11 | justify-content: stretch;
12 | height: 100%;
13 | }
14 | .sidebar {
15 | background: #eceff1;
16 | min-width: 250px;
17 | display: flex;
18 | flex-direction: column;
19 | justify-content: flex-start;
20 | overflow: auto;
21 | }
22 | .sidebar h2 {
23 | font-weight: normal;
24 | font-size: 1.0rem;
25 | background: #607d8b;
26 | color: #fff;
27 | padding: 10px;
28 | margin: 0;
29 | }
30 | .sidebar ul {
31 | margin: 0;
32 | padding: 0;
33 | list-style-type: none;
34 | }
35 | .sidebar li {
36 | line-height: 175%;
37 | white-space: nowrap;
38 | overflow: hidden;
39 | text-wrap: none;
40 | text-overflow: ellipsis;
41 | }
42 | .cameras ul {
43 | padding: 15px 20px;
44 | }
45 | .cameras .active {
46 | font-weight: bold;
47 | color: #009900;
48 | }
49 | .cameras a {
50 | color: #555;
51 | text-decoration: none;
52 | cursor: pointer;
53 | }
54 | .cameras a:hover {
55 | text-decoration: underline;
56 | }
57 | .scans li {
58 | padding: 10px 20px;
59 | border-bottom: 1px solid #ccc;
60 | }
61 | .scans-enter-active {
62 | transition: background 3s;
63 | }
64 | .scans-enter {
65 | background: yellow;
66 | }
67 | .empty {
68 | font-style: italic;
69 | }
70 | .preview-container {
71 | flex-direction: column;
72 | align-items: center;
73 | justify-content: center;
74 | display: flex;
75 | width: 100%;
76 | overflow: hidden;
77 | }
78 |
--------------------------------------------------------------------------------
/export.js:
--------------------------------------------------------------------------------
1 | window.Instascan = require('./index');
2 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | var gulp = require('gulp');
2 | var rename = require('gulp-rename');
3 | var browserify = require('browserify');
4 | var source = require('vinyl-source-stream');
5 | var buffer = require('vinyl-buffer');
6 | var uglify = require('gulp-uglify');
7 | var babelify = require('babelify');
8 |
9 | gulp.task('default', ['build', 'watch']);
10 |
11 | gulp.task('watch', function () {
12 | gulp.watch('./src/*.js', ['build']);
13 | gulp.watch('./*.js', ['build']);
14 | });
15 |
16 | function build(file) {
17 | return browserify(file, {
18 | noParse: [require.resolve('./src/zxing')]
19 | })
20 | .transform(babelify, {
21 | ignore: /zxing\.js$/i,
22 | presets: ['es2015'],
23 | plugins: ['syntax-async-functions', 'transform-regenerator']
24 | })
25 | .bundle()
26 | .pipe(source('instascan.js'));
27 | }
28 |
29 | gulp.task('release', function () {
30 | return build('./export.js')
31 | .pipe(buffer())
32 | .pipe(uglify())
33 | .pipe(rename({ suffix: '.min' }))
34 | .pipe(gulp.dest('./dist/'));
35 | });
36 |
37 | gulp.task('build', function () {
38 | return build('./export.js')
39 | .pipe(gulp.dest('./dist/'));
40 | });
41 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | require('babel-polyfill');
2 | require('webrtc-adapter');
3 |
4 | var Instascan = {
5 | Scanner: require('./src/scanner'),
6 | Camera: require('./src/camera')
7 | };
8 |
9 | module.exports = Instascan;
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "instascan",
3 | "version": "1.0.0",
4 | "description": "Webcam-driven QR code scanner.",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "files": [
10 | "src/",
11 | "index.js"
12 | ],
13 | "repository": {
14 | "type": "git",
15 | "url": "git+https://github.com/schmich/instascan.git"
16 | },
17 | "keywords": [
18 | "qr",
19 | "code",
20 | "quick",
21 | "response",
22 | "scan",
23 | "scanner",
24 | "webcam",
25 | "realtime"
26 | ],
27 | "author": "Chris Schmich ",
28 | "license": "MIT",
29 | "bugs": {
30 | "url": "https://github.com/schmich/instascan/issues"
31 | },
32 | "homepage": "https://github.com/schmich/instascan",
33 | "devDependencies": {
34 | "babel-plugin-syntax-async-functions": "^6.8.0",
35 | "babel-plugin-transform-regenerator": "^6.9.0",
36 | "babel-preset-es2015": "^6.9.0",
37 | "babelify": "^7.3.0",
38 | "browserify": "^13.0.1",
39 | "gulp": "^3.9.1",
40 | "gulp-rename": "^1.2.2",
41 | "gulp-uglify": "^1.5.4",
42 | "vinyl-buffer": "^1.0.0",
43 | "vinyl-source-stream": "^1.1.0"
44 | },
45 | "dependencies": {
46 | "babel-polyfill": "^6.9.1",
47 | "fsm-as-promised": "^0.13.0",
48 | "visibilityjs": "^1.2.3",
49 | "webrtc-adapter": "^1.4.0"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/camera.js:
--------------------------------------------------------------------------------
1 | function cameraName(label) {
2 | let clean = label.replace(/\s*\([0-9a-f]+(:[0-9a-f]+)?\)\s*$/, '');
3 | return clean || label || null;
4 | }
5 |
6 | class MediaError extends Error {
7 | constructor(type) {
8 | super(`Cannot access video stream (${type}).`);
9 | this.type = type;
10 | }
11 | }
12 |
13 | class Camera {
14 | constructor(id, name) {
15 | this.id = id;
16 | this.name = name;
17 | this._stream = null;
18 | }
19 |
20 | async start() {
21 | let constraints = {
22 | audio: false,
23 | video: {
24 | mandatory: {
25 | sourceId: this.id,
26 | minWidth: 600,
27 | maxWidth: 800,
28 | minAspectRatio: 1.6
29 | },
30 | optional: []
31 | }
32 | };
33 |
34 | this._stream = await Camera._wrapErrors(async () => {
35 | return await navigator.mediaDevices.getUserMedia(constraints);
36 | });
37 |
38 | return this._stream;
39 | }
40 |
41 | stop() {
42 | if (!this._stream) {
43 | return;
44 | }
45 |
46 | for (let stream of this._stream.getVideoTracks()) {
47 | stream.stop();
48 | }
49 |
50 | this._stream = null;
51 | }
52 |
53 | static async getCameras() {
54 | await this._ensureAccess();
55 |
56 | let devices = await navigator.mediaDevices.enumerateDevices();
57 | return devices
58 | .filter(d => d.kind === 'videoinput')
59 | .map(d => new Camera(d.deviceId, cameraName(d.label)));
60 | }
61 |
62 | static async _ensureAccess() {
63 | return await this._wrapErrors(async () => {
64 | let access = await navigator.mediaDevices.getUserMedia({ video: true });
65 | for (let stream of access.getVideoTracks()) {
66 | stream.stop();
67 | }
68 | });
69 | }
70 |
71 | static async _wrapErrors(fn) {
72 | try {
73 | return await fn();
74 | } catch (e) {
75 | if (e.name) {
76 | throw new MediaError(e.name);
77 | } else {
78 | throw e;
79 | }
80 | }
81 | }
82 | }
83 |
84 | module.exports = Camera;
85 |
--------------------------------------------------------------------------------
/src/scanner.js:
--------------------------------------------------------------------------------
1 | const EventEmitter = require('events');
2 | const ZXing = require('./zxing')();
3 | const Visibility = require('visibilityjs');
4 | const StateMachine = require('fsm-as-promised');
5 |
6 | class ScanProvider {
7 | constructor(emitter, analyzer, captureImage, scanPeriod, refractoryPeriod) {
8 | this.scanPeriod = scanPeriod;
9 | this.captureImage = captureImage;
10 | this.refractoryPeriod = refractoryPeriod;
11 | this._emitter = emitter;
12 | this._frameCount = 0;
13 | this._analyzer = analyzer;
14 | this._lastResult = null;
15 | this._active = false;
16 | }
17 |
18 | start() {
19 | this._active = true;
20 | requestAnimationFrame(() => this._scan());
21 | }
22 |
23 | stop() {
24 | this._active = false;
25 | }
26 |
27 | scan() {
28 | return this._analyze(false);
29 | }
30 |
31 | _analyze(skipDups) {
32 | let analysis = this._analyzer.analyze();
33 | if (!analysis) {
34 | return null;
35 | }
36 |
37 | let { result, canvas } = analysis;
38 | if (!result) {
39 | return null;
40 | }
41 |
42 | if (skipDups && result === this._lastResult) {
43 | return null;
44 | }
45 |
46 | clearTimeout(this.refractoryTimeout);
47 | this.refractoryTimeout = setTimeout(() => {
48 | this._lastResult = null;
49 | }, this.refractoryPeriod);
50 |
51 | let image = this.captureImage ? canvas.toDataURL('image/webp', 0.8) : null;
52 |
53 | this._lastResult = result;
54 |
55 | let payload = { content: result };
56 | if (image) {
57 | payload.image = image;
58 | }
59 |
60 | return payload;
61 | }
62 |
63 | _scan() {
64 | if (!this._active) {
65 | return;
66 | }
67 |
68 | requestAnimationFrame(() => this._scan());
69 |
70 | if (++this._frameCount !== this.scanPeriod) {
71 | return;
72 | } else {
73 | this._frameCount = 0;
74 | }
75 |
76 | let result = this._analyze(true);
77 | if (result) {
78 | setTimeout(() => {
79 | this._emitter.emit('scan', result.content, result.image || null);
80 | }, 0);
81 | }
82 | }
83 | }
84 |
85 | class Analyzer {
86 | constructor(video) {
87 | this.video = video;
88 |
89 | this.imageBuffer = null;
90 | this.sensorLeft = null;
91 | this.sensorTop = null;
92 | this.sensorWidth = null;
93 | this.sensorHeight = null;
94 |
95 | this.canvas = document.createElement('canvas');
96 | this.canvas.style.display = 'none';
97 | this.canvasContext = null;
98 |
99 | this.decodeCallback = ZXing.Runtime.addFunction(function (ptr, len, resultIndex, resultCount) {
100 | let result = new Uint8Array(ZXing.HEAPU8.buffer, ptr, len);
101 | let str = String.fromCharCode.apply(null, result);
102 | if (resultIndex === 0) {
103 | window.zxDecodeResult = '';
104 | }
105 | window.zxDecodeResult += str;
106 | });
107 | }
108 |
109 | analyze() {
110 | if (!this.video.videoWidth) {
111 | return null;
112 | }
113 |
114 | if (!this.imageBuffer) {
115 | let videoWidth = this.video.videoWidth;
116 | let videoHeight = this.video.videoHeight;
117 |
118 | this.sensorWidth = videoWidth;
119 | this.sensorHeight = videoHeight;
120 | this.sensorLeft = Math.floor((videoWidth / 2) - (this.sensorWidth / 2));
121 | this.sensorTop = Math.floor((videoHeight / 2) - (this.sensorHeight / 2));
122 |
123 | this.canvas.width = this.sensorWidth;
124 | this.canvas.height = this.sensorHeight;
125 |
126 | this.canvasContext = this.canvas.getContext('2d');
127 | this.imageBuffer = ZXing._resize(this.sensorWidth, this.sensorHeight);
128 | return null;
129 | }
130 |
131 | this.canvasContext.drawImage(
132 | this.video,
133 | this.sensorLeft,
134 | this.sensorTop,
135 | this.sensorWidth,
136 | this.sensorHeight
137 | );
138 |
139 | let data = this.canvasContext.getImageData(0, 0, this.sensorWidth, this.sensorHeight).data;
140 | for (let i = 0, j = 0; i < data.length; i += 4, j++) {
141 | let [r, g, b] = [data[i], data[i + 1], data[i + 2]];
142 | ZXing.HEAPU8[this.imageBuffer + j] = Math.trunc((r + g + b) / 3);
143 | }
144 |
145 | let err = ZXing._decode_qr(this.decodeCallback);
146 | if (err) {
147 | return null;
148 | }
149 |
150 | let result = window.zxDecodeResult;
151 | if (result != null) {
152 | return { result: result, canvas: this.canvas };
153 | }
154 |
155 | return null;
156 | }
157 | }
158 |
159 | class Scanner extends EventEmitter {
160 | constructor(opts) {
161 | super();
162 |
163 | this.video = this._configureVideo(opts);
164 | this.mirror = (opts.mirror !== false);
165 | this.backgroundScan = (opts.backgroundScan !== false);
166 | this._continuous = (opts.continuous !== false);
167 | this._analyzer = new Analyzer(this.video);
168 | this._camera = null;
169 |
170 | let captureImage = opts.captureImage || false;
171 | let scanPeriod = opts.scanPeriod || 1;
172 | let refractoryPeriod = opts.refractoryPeriod || (5 * 1000);
173 |
174 | this._scanner = new ScanProvider(this, this._analyzer, captureImage, scanPeriod, refractoryPeriod);
175 | this._fsm = this._createStateMachine();
176 |
177 | Visibility.change((e, state) => {
178 | if (state === 'visible') {
179 | setTimeout(() => {
180 | if (this._fsm.can('activate')) {
181 | this._fsm.activate();
182 | }
183 | }, 0);
184 | } else {
185 | if (!this.backgroundScan && this._fsm.can('deactivate')) {
186 | this._fsm.deactivate();
187 | }
188 | }
189 | });
190 |
191 | this.addListener('active', () => {
192 | this.video.classList.remove('inactive');
193 | this.video.classList.add('active');
194 | });
195 |
196 | this.addListener('inactive', () => {
197 | this.video.classList.remove('active');
198 | this.video.classList.add('inactive');
199 | });
200 |
201 | this.emit('inactive');
202 | }
203 |
204 | scan() {
205 | return this._scanner.scan();
206 | }
207 |
208 | async start(camera = null) {
209 | if (this._fsm.can('start')) {
210 | await this._fsm.start(camera);
211 | } else {
212 | await this._fsm.stop();
213 | await this._fsm.start(camera);
214 | }
215 | }
216 |
217 | async stop() {
218 | if (this._fsm.can('stop')) {
219 | await this._fsm.stop();
220 | }
221 | }
222 |
223 | set captureImage(capture) {
224 | this._scanner.captureImage = capture;
225 | }
226 |
227 | get captureImage() {
228 | return this._scanner.captureImage;
229 | }
230 |
231 | set scanPeriod(period) {
232 | this._scanner.scanPeriod = period;
233 | }
234 |
235 | get scanPeriod() {
236 | return this._scanner.scanPeriod;
237 | }
238 |
239 | set refractoryPeriod(period) {
240 | this._scanner.refractoryPeriod = period;
241 | }
242 |
243 | get refractoryPeriod() {
244 | return this._scanner.refractoryPeriod;
245 | }
246 |
247 | set continuous(continuous) {
248 | this._continuous = continuous;
249 |
250 | if (continuous && this._fsm.current === 'active') {
251 | this._scanner.start();
252 | } else {
253 | this._scanner.stop();
254 | }
255 | }
256 |
257 | get continuous() {
258 | return this._continuous;
259 | }
260 |
261 | set mirror(mirror) {
262 | this._mirror = mirror;
263 |
264 | if (mirror) {
265 | this.video.style.MozTransform = 'scaleX(-1)';
266 | this.video.style.webkitTransform = 'scaleX(-1)';
267 | this.video.style.OTransform = 'scaleX(-1)';
268 | this.video.style.msFilter = 'FlipH';
269 | this.video.style.filter = 'FlipH';
270 | this.video.style.transform = 'scaleX(-1)';
271 | } else {
272 | this.video.style.MozTransform = null;
273 | this.video.style.webkitTransform = null;
274 | this.video.style.OTransform = null;
275 | this.video.style.msFilter = null;
276 | this.video.style.filter = null;
277 | this.video.style.transform = null;
278 | }
279 | }
280 |
281 | get mirror() {
282 | return this._mirror;
283 | }
284 |
285 | async _enableScan(camera) {
286 | this._camera = camera || this._camera;
287 | if (!this._camera) {
288 | throw new Error('Camera is not defined.');
289 | }
290 |
291 | let stream = await this._camera.start();
292 | this.video.srcObject = stream;
293 |
294 | if (this._continuous) {
295 | this._scanner.start();
296 | }
297 | }
298 |
299 | _disableScan() {
300 | this.video.src = '';
301 |
302 | if (this._scanner) {
303 | this._scanner.stop();
304 | }
305 |
306 | if (this._camera) {
307 | this._camera.stop();
308 | }
309 | }
310 |
311 | _configureVideo(opts) {
312 | if (opts.video) {
313 | if (opts.video.tagName !== 'VIDEO') {
314 | throw new Error('Video must be a element.');
315 | }
316 | }
317 |
318 | let video = opts.video || document.createElement('video');
319 | video.setAttribute('autoplay', 'autoplay');
320 |
321 | return video;
322 | }
323 |
324 | _createStateMachine() {
325 | return StateMachine.create({
326 | initial: 'stopped',
327 | events: [
328 | {
329 | name: 'start',
330 | from: 'stopped',
331 | to: 'started'
332 | },
333 | {
334 | name: 'stop',
335 | from: ['started', 'active', 'inactive'],
336 | to: 'stopped'
337 | },
338 | {
339 | name: 'activate',
340 | from: ['started', 'inactive'],
341 | to: ['active', 'inactive'],
342 | condition: function (options) {
343 | if (Visibility.state() === 'visible' || this.backgroundScan) {
344 | return 'active';
345 | } else {
346 | return 'inactive';
347 | }
348 | }
349 | },
350 | {
351 | name: 'deactivate',
352 | from: ['started', 'active'],
353 | to: 'inactive'
354 | }
355 | ],
356 | callbacks: {
357 | onenteractive: async (options) => {
358 | await this._enableScan(options.args[0]);
359 | this.emit('active');
360 | },
361 | onleaveactive: () => {
362 | this._disableScan();
363 | this.emit('inactive');
364 | },
365 | onenteredstarted: async (options) => {
366 | await this._fsm.activate(options.args[0]);
367 | }
368 | }
369 | });
370 | }
371 | }
372 |
373 | module.exports = Scanner;
374 |
--------------------------------------------------------------------------------