From 68520bd9c5df9e6e8e6b0d2060b3924dcff21563 Mon Sep 17 00:00:00 2001 From: Ze-Zheng Wu Date: Mon, 18 Mar 2024 12:57:49 +0800 Subject: [PATCH] feat: add react hooks and components --- README.md | 3 +- biome.json | 6 + copy-files-from-to.json | 4 + index.html | 11 +- main.ts | 18 - main.tsx | 45 + package.json | 45 +- scripts/build-iife.ts | 20 +- scripts/vite-plugin-emscripten-bun.ts | 68 ++ src/bindings/barcodeFormat.ts | 8 +- src/bindings/binarizer.ts | 2 +- src/bindings/characterSet.ts | 2 +- src/bindings/contentType.ts | 2 +- src/bindings/eanAddOnSymbol.ts | 2 +- src/bindings/exposedReaderBindings.ts | 60 +- src/bindings/exposedWriterBindings.ts | 38 +- src/bindings/index.ts | 4 +- src/bindings/position.ts | 1 - src/bindings/readResult.ts | 25 +- src/bindings/readerOptions.ts | 116 ++- src/bindings/textMode.ts | 2 +- src/bindings/writeResult.ts | 6 +- src/bindings/writerOptions.ts | 38 +- src/core.ts | 33 +- src/index.css.ts | 22 + .../components/StreamBarcodeDetector.tsx | 186 ++++ src/react/components/index.ts | 1 + src/react/hooks/index.ts | 3 + src/react/hooks/useHeadlessVideoScanner.ts | 95 ++ src/react/hooks/useUserMediaStream.ts | 97 ++ src/react/hooks/useVideoScanner.ts | 170 ++++ src/react/index.ts | 2 + src/reader/index.ts | 2 +- src/scanner/index.ts | 1 + src/scanner/videoScanner.ts | 510 +++++++++++ src/stream/compatibility.d.ts | 11 + src/stream/index.ts | 1 + src/stream/media-track-shims.d.ts | 61 ++ src/stream/shimGetUserMedia.ts | 50 ++ src/stream/userMediaStream.ts | 834 ++++++++++++++++++ src/stream/webrtc-adapter.d.ts | 30 + src/writer/index.ts | 6 +- tsconfig.base.json | 2 + tsconfig.json | 6 +- tsconfig.node.json | 2 - tsconfig.production.json | 5 + typedoc.json | 6 +- vite.config.ts | 46 + 48 files changed, 2521 insertions(+), 187 deletions(-) delete mode 100644 main.ts create mode 100644 main.tsx create mode 100644 scripts/vite-plugin-emscripten-bun.ts create mode 100644 src/index.css.ts create mode 100644 src/react/components/StreamBarcodeDetector.tsx create mode 100644 src/react/components/index.ts create mode 100644 src/react/hooks/index.ts create mode 100644 src/react/hooks/useHeadlessVideoScanner.ts create mode 100644 src/react/hooks/useUserMediaStream.ts create mode 100644 src/react/hooks/useVideoScanner.ts create mode 100644 src/react/index.ts create mode 100644 src/scanner/index.ts create mode 100644 src/scanner/videoScanner.ts create mode 100644 src/stream/compatibility.d.ts create mode 100644 src/stream/index.ts create mode 100644 src/stream/media-track-shims.d.ts create mode 100644 src/stream/shimGetUserMedia.ts create mode 100644 src/stream/userMediaStream.ts create mode 100644 src/stream/webrtc-adapter.d.ts create mode 100644 tsconfig.production.json diff --git a/README.md b/README.md index edae031e..971f0b88 100644 --- a/README.md +++ b/README.md @@ -218,8 +218,7 @@ The wasm binary won't be fetched or instantiated unless a [read](#readbarcodesfr import { getZXingModule } from "zxing-wasm"; /** - * This function will trigger the download and - * instantiation of the wasm binary immediately + * This function will trigger the download and instantiation of the wasm binary immediately */ const zxingModulePromise1 = getZXingModule(); diff --git a/biome.json b/biome.json index 17349071..2a437cd3 100644 --- a/biome.json +++ b/biome.json @@ -34,6 +34,12 @@ } } }, + { + "include": ["react/components"], + "formatter": { + "lineWidth": 120 + } + }, { "include": ["package.json"], "json": { diff --git a/copy-files-from-to.json b/copy-files-from-to.json index a33a23ac..f83dbdb5 100644 --- a/copy-files-from-to.json +++ b/copy-files-from-to.json @@ -11,6 +11,10 @@ { "from": "./src/full/*.wasm", "to": "./dist/full/" + }, + { + "from": "./src/stream/media-track-shims.d.ts", + "to": "./dist/" } ], "copyFilesSettings": { diff --git a/index.html b/index.html index f0464cdf..23d81393 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,13 @@ - + - ZXing Reader Demo + + + + ZXing WASM React -
- +
+ diff --git a/main.ts b/main.ts deleted file mode 100644 index d4750662..00000000 --- a/main.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - readBarcodesFromImageFile, - writeBarcodeToImageFile, -} from "./src/full/index"; - -// import { readBarcodesFromImageFile } from "./src/reader/index"; - -// import { writeBarcodeToImageFile } from "./src/writer/index"; - -const text = "Hello World!"; -const barcodeImage = (await writeBarcodeToImageFile(text)).image; -if (barcodeImage) { - const readResults = await readBarcodesFromImageFile(barcodeImage, { - formats: ["QRCode"], - }); - console.log(readResults); - console.log(readResults[0].text === text); -} diff --git a/main.tsx b/main.tsx new file mode 100644 index 00000000..920b3fc5 --- /dev/null +++ b/main.tsx @@ -0,0 +1,45 @@ +/// + +import React, { useState } from "react"; +import ReactDOM from "react-dom/client"; +import { StreamBarcodeDetector } from "./src/react/components/StreamBarcodeDetector.js"; + +import type { InitConstraints } from "./src/stream/index.js"; + +const App = () => { + const [initConstraints] = useState({ + video: { + aspectRatio: undefined, + }, + }); + + const [videoConstraints] = useState({ + advanced: [ + { + exposureMode: "continuous", + }, + ], + }); + + return ( + { + console.log(r); + }} + onStreamInspect={(c) => { + console.log(c); + }} + initConstraints={initConstraints} + videoConstraints={videoConstraints} + scanThrottle={0} + negativeDebounce={0} + formats={["QRCode"]} + /> + ); +}; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/package.json b/package.json index 429292f8..86f6ad50 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,35 @@ }, "./reader/zxing_reader.wasm": "./dist/reader/zxing_reader.wasm", "./writer/zxing_writer.wasm": "./dist/writer/zxing_writer.wasm", - "./full/zxing_full.wasm": "./dist/full/zxing_full.wasm" + "./full/zxing_full.wasm": "./dist/full/zxing_full.wasm", + "./scanner": { + "import": "./dist/es/scanner/index.js", + "require": "./dist/cjs/scanner/index.js", + "default": "./dist/es/scanner/index.js" + }, + "./stream": { + "import": "./dist/es/stream/index.js", + "require": "./dist/cjs/stream/index.js", + "default": "./dist/es/stream/index.js" + }, + "./media-track-shims": { + "types": "./dist/media-track-shims.d.ts" + }, + "./react": { + "import": "./dist/es/react/index.js", + "require": "./dist/cjs/react/index.js", + "default": "./dist/es/react/index.js" + }, + "./react/components": { + "import": "./dist/es/react/components/index.js", + "require": "./dist/cjs/react/components/index.js", + "default": "./dist/es/react/components/index.js" + }, + "./react/hooks": { + "import": "./dist/es/react/hooks/index.js", + "require": "./dist/cjs/react/hooks/index.js", + "default": "./dist/es/react/hooks/index.js" + } }, "repository": { "type": "git", @@ -66,7 +94,7 @@ "submodule:update": "git submodule update --remote", "cmake": "emcmake cmake -S src/cpp -B build", "build:wasm": "cmake --build build -j$(($(nproc) - 1))", - "copy:wasm": "copy-files-from-to", + "copy": "copy-files-from-to", "docs:dev": "conc \"npm:docs:preview\" \"typedoc --watch --excludeInternal\"", "docs:build": "typedoc --excludeInternal", "docs:preview": "vite preview --outDir ./docs", @@ -101,11 +129,17 @@ "@changesets/cli": "^2.27.7", "@types/babel__core": "^7.20.5", "@types/node": "^22.4.2", + "@types/react": "^18.2.56", + "@types/react-dom": "^18.2.19", + "@vanilla-extract/css": "^1.14.1", + "@vanilla-extract/vite-plugin": "^4.0.4", "concurrently": "^8.2.2", "copy-files-from-to": "^3.11.0", "lint-staged": "^15.2.9", "prettier": "^3.3.3", "pretty-quick": "^4.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", "rimraf": "^6.0.1", "simple-git-hooks": "^2.11.1", "tsx": "^4.17.0", @@ -115,7 +149,12 @@ "vite-plugin-babel": "^1.2.0" }, "dependencies": { - "@types/emscripten": "^1.39.13" + "@types/emscripten": "^1.39.13", + "just-compare": "^2.3.0", + "sha1-uint8array": "^0.10.7", + "type-fest": "^4.10.2", + "webrtc-adapter": "^8.2.3", + "zustand": "^4.5.1" }, "overrides": { "typedoc": { diff --git a/scripts/build-iife.ts b/scripts/build-iife.ts index ab28abe7..6ce39d0d 100644 --- a/scripts/build-iife.ts +++ b/scripts/build-iife.ts @@ -1,12 +1,15 @@ import { rimraf } from "rimraf"; import { type LibraryOptions, build } from "vite"; import viteConfig from "../vite.config.js"; +import { emscriptenBun } from "./vite-plugin-emscripten-bun.js"; async function buildIife() { await rimraf("dist/iife"); await Promise.all( - Object.entries((viteConfig.build?.lib as LibraryOptions).entry).map( - ([entryAlias, entryPath]) => { + Object.entries((viteConfig.build?.lib as LibraryOptions).entry) + // TODO: pay attention to the order + .slice(0, 3) + .map(([entryAlias, entryPath]) => { return build({ ...viteConfig, build: { @@ -19,14 +22,21 @@ async function buildIife() { formats: ["iife"], name: "ZXingWASM", }, - rollupOptions: undefined, + rollupOptions: { + external: ["react"], + output: { + globals: { + react: "React", + }, + }, + }, outDir: "dist/iife", emptyOutDir: false, }, configFile: false, + plugins: [emscriptenBun()], }); - }, - ), + }), ); } diff --git a/scripts/vite-plugin-emscripten-bun.ts b/scripts/vite-plugin-emscripten-bun.ts new file mode 100644 index 00000000..b0b5b88e --- /dev/null +++ b/scripts/vite-plugin-emscripten-bun.ts @@ -0,0 +1,68 @@ +import type { PluginItem } from "@babel/core"; +import { + binaryExpression, + identifier, + logicalExpression, + stringLiteral, + unaryExpression, + variableDeclaration, + variableDeclarator, +} from "@babel/types"; +import babel from "vite-plugin-babel"; + +function emscriptenBunBabel(): PluginItem { + return { + visitor: { + VariableDeclaration(path) { + if ( + path.node.kind === "var" && + path.node.declarations[0]?.id.type === "Identifier" && + path.node.declarations[0]?.id.name === "ENVIRONMENT_IS_WEB" + ) { + path.insertAfter([ + variableDeclaration("var", [ + variableDeclarator( + identifier("ENVIRONMENT_IS_BUN"), + binaryExpression( + "!==", + unaryExpression("typeof", identifier("Bun")), + stringLiteral("undefined"), + ), + ), + ]), + ]); + } + }, + LogicalExpression(path) { + if ( + path.node.operator === "||" && + path.node.left.type === "Identifier" && + (path.node.left.name === "ENVIRONMENT_IS_WEB" || + path.node.left.name === "ENVIRONMENT_IS_WORKER") && + path.node.right.type === "Identifier" && + (path.node.right.name === "ENVIRONMENT_IS_WORKER" || + path.node.right.name === "ENVIRONMENT_IS_WEB") + ) { + path.replaceWith( + logicalExpression( + "||", + path.node, + identifier("ENVIRONMENT_IS_BUN"), + ), + ); + path.skip(); + } + }, + }, + }; +} + +export function emscriptenBun() { + return babel({ + babelConfig: { + plugins: [emscriptenBunBabel()], + }, + filter: /zxing_(reader|writer|full)\.js$/, + include: /zxing_(reader|writer|full)\.js$/, + }); +} diff --git a/src/bindings/barcodeFormat.ts b/src/bindings/barcodeFormat.ts index 2e0877cf..108e2c1b 100644 --- a/src/bindings/barcodeFormat.ts +++ b/src/bindings/barcodeFormat.ts @@ -29,12 +29,14 @@ export const barcodeFormats = [ export type BarcodeFormat = (typeof barcodeFormats)[number]; /** - * Barcode formats that can be used in {@link ReaderOptions.formats | `ReaderOptions.formats`} to read barcodes. + * Barcode formats that can be used in {@link ReaderOptions.formats | `ReaderOptions.formats`} to + * read barcodes. */ export type ReadInputBarcodeFormat = Exclude; /** - * Barcode formats that can be used in {@link WriterOptions.format | `WriterOptions.format`} to write barcodes. + * Barcode formats that can be used in {@link WriterOptions.format | `WriterOptions.format`} to write + * barcodes. */ export type WriteInputBarcodeFormat = Exclude< BarcodeFormat, @@ -73,7 +75,7 @@ export function formatFromString(format: string): BarcodeFormat { let end = barcodeFormats.length - 1; while (start <= end) { const mid = Math.floor((start + end) / 2); - const midElement = barcodeFormats[mid]; + const midElement = barcodeFormats[mid]!; const normalizedMidElement = normalizeFormatString(midElement); if (normalizedMidElement === normalizedTarget) { return midElement; diff --git a/src/bindings/binarizer.ts b/src/bindings/binarizer.ts index 63f4d320..202bf81f 100644 --- a/src/bindings/binarizer.ts +++ b/src/bindings/binarizer.ts @@ -23,5 +23,5 @@ export function binarizerToZXingEnum( } export function zxingEnumToBinarizer(zxingEnum: ZXingEnum): Binarizer { - return binarizers[zxingEnum.value]; + return binarizers[zxingEnum.value]!; } diff --git a/src/bindings/characterSet.ts b/src/bindings/characterSet.ts index 8b0dae48..877a42b3 100644 --- a/src/bindings/characterSet.ts +++ b/src/bindings/characterSet.ts @@ -53,5 +53,5 @@ export function characterSetToZXingEnum( } export function zxingEnumToCharacterSet(zxingEnum: ZXingEnum): CharacterSet { - return characterSets[zxingEnum.value]; + return characterSets[zxingEnum.value]!; } diff --git a/src/bindings/contentType.ts b/src/bindings/contentType.ts index 0a634f0f..d96a0d3b 100644 --- a/src/bindings/contentType.ts +++ b/src/bindings/contentType.ts @@ -25,5 +25,5 @@ export function contentTypeToZXingEnum( } export function zxingEnumToContentType(zxingEnum: ZXingEnum): ContentType { - return contentTypes[zxingEnum.value]; + return contentTypes[zxingEnum.value]!; } diff --git a/src/bindings/eanAddOnSymbol.ts b/src/bindings/eanAddOnSymbol.ts index 7856ebea..87382d2f 100644 --- a/src/bindings/eanAddOnSymbol.ts +++ b/src/bindings/eanAddOnSymbol.ts @@ -20,5 +20,5 @@ export function eanAddOnSymbolToZXingEnum( export function zxingEnumToEanAddOnSymbol( zxingEnum: ZXingEnum, ): EanAddOnSymbol { - return eanAddOnSymbols[zxingEnum.value]; + return eanAddOnSymbols[zxingEnum.value]!; } diff --git a/src/bindings/exposedReaderBindings.ts b/src/bindings/exposedReaderBindings.ts index d3c03aaf..de3346ad 100644 --- a/src/bindings/exposedReaderBindings.ts +++ b/src/bindings/exposedReaderBindings.ts @@ -1,57 +1,61 @@ -import { type ReaderOptions, defaultReaderOptions as ro } from "./index.js"; +import { + type ReaderOptions, + type ResolvedReaderOptions, + defaultReaderOptions as ro, +} from "./index.js"; -export const defaultReaderOptions: Required = { +export const defaultReaderOptions: ReaderOptions = { ...ro, formats: [...ro.formats], -}; +} satisfies ResolvedReaderOptions; export { barcodeFormats, + binarizers, + characterSets, + contentTypes, + eanAddOnSymbols, + readOutputEccLevels, + textModes, type BarcodeFormat, + type Binarizer, + type CharacterSet, + type ContentType, + type EanAddOnSymbol, + type Point, + type Position, type ReadInputBarcodeFormat, type ReadOutputBarcodeFormat, - binarizers, + type ReadOutputEccLevel, + type ReadResult, + type ReaderOptions, + type TextMode, type ZXingBinarizer, - type Binarizer, - characterSets, type ZXingCharacterSet, - type CharacterSet, - contentTypes, type ZXingContentType, - type ContentType, - type ZXingReaderOptions, - type ReaderOptions, - eanAddOnSymbols, type ZXingEanAddOnSymbol, - type EanAddOnSymbol, - readOutputEccLevels, - type ReadOutputEccLevel, type ZXingEnum, type ZXingPoint, type ZXingPosition, - type Point, - type Position, type ZXingReadResult, - type ReadResult, - textModes, + type ZXingReaderOptions, type ZXingTextMode, - type TextMode, type ZXingVector, } from "./index.js"; export { /** - * @deprecated renamed as `defaultReaderOptions` + * @deprecated Renamed as `ReaderOptions` */ - defaultReaderOptions as defaultDecodeHints, -}; -export { + type ReaderOptions as DecodeHints, /** - * @deprecated renamed as `ZXingReaderOptions` + * @deprecated Renamed as `ZXingReaderOptions` */ type ZXingReaderOptions as ZXingDecodeHints, +} from "./index.js"; +export { /** - * @deprecated renamed as `ReaderOptions` + * @deprecated Renamed as `defaultReaderOptions` */ - type ReaderOptions as DecodeHints, -} from "./index.js"; + defaultReaderOptions as defaultDecodeHints, +}; diff --git a/src/bindings/exposedWriterBindings.ts b/src/bindings/exposedWriterBindings.ts index 3daebdc8..8188afa9 100644 --- a/src/bindings/exposedWriterBindings.ts +++ b/src/bindings/exposedWriterBindings.ts @@ -1,36 +1,42 @@ -import { type WriterOptions, defaultWriterOptions as wo } from "./index.js"; +import { + type ResolvedWriterOptions, + type WriterOptions, + defaultWriterOptions as wo, +} from "./index.js"; -export const defaultWriterOptions: Required = { ...wo }; +export const defaultWriterOptions: WriterOptions = { + ...wo, +} satisfies ResolvedWriterOptions; export { barcodeFormats, - type BarcodeFormat, - type WriteInputBarcodeFormat, characterSets, - type ZXingCharacterSet, - type CharacterSet, writeInputEccLevels, + type BarcodeFormat, + type CharacterSet, + type WriteInputBarcodeFormat, type WriteInputEccLevel, - type ZXingWriterOptions, + type WriteResult, type WriterOptions, + type ZXingCharacterSet, type ZXingEnum, type ZXingWriteResult, - type WriteResult, + type ZXingWriterOptions, } from "./index.js"; export { /** - * @deprecated renamed as `defaultWriterOptions` + * @deprecated Renamed as `WriterOptions` */ - defaultWriterOptions as defaultEncodeHints, -}; -export { + type WriterOptions as EncodeHints, /** - * @deprecated renamed as `ZXingWriterOptions` + * @deprecated Renamed as `ZXingWriterOptions` */ type ZXingWriterOptions as ZXingEncodeHints, +} from "./index.js"; +export { /** - * @deprecated renamed as `WriterOptions` + * @deprecated Renamed as `defaultWriterOptions` */ - type WriterOptions as EncodeHints, -} from "./index.js"; + defaultWriterOptions as defaultEncodeHints, +}; diff --git a/src/bindings/index.ts b/src/bindings/index.ts index 0081c441..64de3f45 100644 --- a/src/bindings/index.ts +++ b/src/bindings/index.ts @@ -2,13 +2,13 @@ export * from "./barcodeFormat.js"; export * from "./binarizer.js"; export * from "./characterSet.js"; export * from "./contentType.js"; -export * from "./readerOptions.js"; export * from "./eanAddOnSymbol.js"; export * from "./eccLevel.js"; -export * from "./writerOptions.js"; export * from "./enum.js"; export * from "./position.js"; export * from "./readResult.js"; +export * from "./readerOptions.js"; export * from "./textMode.js"; export * from "./vector.js"; export * from "./writeResult.js"; +export * from "./writerOptions.js"; diff --git a/src/bindings/position.ts b/src/bindings/position.ts index b7637f65..0188399f 100644 --- a/src/bindings/position.ts +++ b/src/bindings/position.ts @@ -21,7 +21,6 @@ export interface ZXingPosition { * * @property x X coordinate. * @property y Y coordinate. - * * @see {@link Position | `Position`} */ export interface Point extends ZXingPoint {} diff --git a/src/bindings/readResult.ts b/src/bindings/readResult.ts index 28bc3c92..947e567d 100644 --- a/src/bindings/readResult.ts +++ b/src/bindings/readResult.ts @@ -63,9 +63,9 @@ export interface ZXingReadResult { /** * Number of symbols in a structured append sequence. * - * If this is not part of a structured append sequence, the returned value is `-1`. - * If it is a structured append symbol but the total number of symbols is unknown, the - * returned value is `0` (see PDF417 if optional "Segment Count" not given). + * If this is not part of a structured append sequence, the returned value is `-1`. If it is a + * structured append symbol but the total number of symbols is unknown, the returned value is `0` + * (see PDF417 if optional "Segment Count" not given). */ sequenceSize: number; /** @@ -75,9 +75,9 @@ export interface ZXingReadResult { /** * ID to check if a set of symbols belongs to the same structured append sequence. * - * If the symbology does not support this feature, the returned value is empty (see MaxiCode). - * For QR Code, this is the parity integer converted to a string. - * For PDF417 and DataMatrix, this is the `"fileId"`. + * If the symbology does not support this feature, the returned value is empty (see MaxiCode). For + * QR Code, this is the parity integer converted to a string. For PDF417 and DataMatrix, this is + * the `"fileId"`. */ sequenceId: string; /** @@ -104,14 +104,13 @@ export interface ReadResult "format" | "eccLevel" | "contentType" | "position" > { /** - * Format of the barcode, should be one of {@link ReadOutputBarcodeFormat | `ReadOutputBarcodeFormat`}. + * Format of the barcode, should be one of + * {@link ReadOutputBarcodeFormat | `ReadOutputBarcodeFormat`}. * - * Possible values are: - * `"Aztec"`, `"Codabar"`, `"Code128"`, `"Code39"`, `"Code93"`, - * `"DataBar"`, `"DataBarExpanded"`, `"DataMatrix"`, `"DXFilmEdge"`, - * `"EAN-13"`, `"EAN-8"`, `"ITF"`, - * `"MaxiCode"`, `"MicroQRCode"`, `"None"`, - * `"PDF417"`, `"QRCode"`, `"rMQRCode"`, `"UPC-A"`, `"UPC-E"` + * Possible values are: `"Aztec"`, `"Codabar"`, `"Code128"`, `"Code39"`, `"Code93"`, `"DataBar"`, + * `"DataBarExpanded"`, `"DataMatrix"`, `"DXFilmEdge"`, `"EAN-13"`, `"EAN-8"`, `"ITF"`, + * `"MaxiCode"`, `"MicroQRCode"`, `"None"`, `"PDF417"`, `"QRCode"`, `"rMQRCode"`, `"UPC-A"`, + * `"UPC-E"` */ format: ReadOutputBarcodeFormat; /** diff --git a/src/bindings/readerOptions.ts b/src/bindings/readerOptions.ts index 042c25b6..d49cb8b9 100644 --- a/src/bindings/readerOptions.ts +++ b/src/bindings/readerOptions.ts @@ -44,26 +44,27 @@ export interface ZXingReaderOptions { tryDownscale: boolean; binarizer: ZXingEnum; /** - * Set to `true` if the input contains nothing but a single perfectly aligned barcode (usually generated images). + * Set to `true` if the input contains nothing but a single perfectly aligned barcode (usually + * generated images). * * @defaultValue `false` */ isPure: boolean; /** - * Image size ( min(width, height) ) threshold at which to start downscaled scanning - * **WARNING**: this API is experimental and may change / disappear + * Image size ( min(width, height) ) threshold at which to start downscaled scanning **WARNING**: + * this API is experimental and may change / disappear * - * @experimental * @defaultValue `500` + * @experimental * @see {@link tryDownscale | `tryDownscale`} {@link downscaleFactor | `downscaleFactor`} */ downscaleThreshold: number; /** - * Scale factor to use during downscaling, meaningful values are `2`, `3` and `4`. - * **WARNING**: this API is experimental and may change / disappear + * Scale factor to use during downscaling, meaningful values are `2`, `3` and `4`. **WARNING**: + * this API is experimental and may change / disappear * - * @experimental * @defaultValue `3` + * @experimental * @see {@link tryDownscale | `tryDownscale`} {@link downscaleThreshold | `downscaleThreshold`} */ downscaleFactor: number; @@ -74,8 +75,8 @@ export interface ZXingReaderOptions { */ minLineCount: number; /** - * The maximum number of symbols / barcodes to detect / look for in the image. - * The upper limit of this number is 255. + * The maximum number of symbols / barcodes to detect / look for in the image. The upper limit of + * this number is 255. * * @defaultValue `255` */ @@ -132,32 +133,27 @@ export interface ReaderOptions * A set of {@link ReadInputBarcodeFormat | `ReadInputBarcodeFormat`}s that should be searched for. * An empty list `[]` indicates all supported formats. * - * Supported values in this list are: - * `"Aztec"`, `"Codabar"`, `"Code128"`, `"Code39"`, `"Code93"`, - * `"DataBar"`, `"DataBarExpanded"`, `"DataMatrix"`, `"DXFilmEdge"`, - * `"EAN-13"`, `"EAN-8"`, `"ITF"`, `"Linear-Codes"`, `"Matrix-Codes"`, - * `"MaxiCode"`, `"MicroQRCode"`, `"PDF417"`, `"QRCode"`, `"rMQRCode"`, `"UPC-A"`, `"UPC-E"` + * Supported values in this list are: `"Aztec"`, `"Codabar"`, `"Code128"`, `"Code39"`, `"Code93"`, + * `"DataBar"`, `"DataBarExpanded"`, `"DataMatrix"`, `"DXFilmEdge"`, `"EAN-13"`, `"EAN-8"`, + * `"ITF"`, `"Linear-Codes"`, `"Matrix-Codes"`, `"MaxiCode"`, `"MicroQRCode"`, `"PDF417"`, + * `"QRCode"`, `"rMQRCode"`, `"UPC-A"`, `"UPC-E"` * * @defaultValue `[]` */ formats?: ReadInputBarcodeFormat[]; /** - * Algorithm to use for the grayscale to binary transformation. - * The difference is how to get to a threshold value T - * which results in a bit value R = L <= T. + * Algorithm to use for the grayscale to binary transformation. The difference is how to get to a + * threshold value T which results in a bit value R = L <= T. * * - `"LocalAverage"` * * T = average of neighboring pixels for matrix and GlobalHistogram for linear - * * - `"GlobalHistogram"` * * T = valley between the 2 largest peaks in the histogram (per line in linear case) - * * - `"FixedThreshold"` * * T = 127 - * * - `"BoolCast"` * * T = 0, fastest possible @@ -166,16 +162,15 @@ export interface ReaderOptions */ binarizer?: Binarizer; /** - * Specify whether to ignore, read or require EAN-2 / 5 add-on symbols while scanning EAN / UPC codes. + * Specify whether to ignore, read or require EAN-2 / 5 add-on symbols while scanning EAN / UPC + * codes. * * - `"Ignore"` * * Ignore any Add-On symbol during read / scan - * * - `"Read"` * * Read EAN-2 / EAN-5 Add-On symbol if found - * * - `"Require"` * * Require EAN-2 / EAN-5 Add-On symbol to be present @@ -184,41 +179,43 @@ export interface ReaderOptions */ eanAddOnSymbol?: EanAddOnSymbol; /** - * Specifies the `TextMode` that controls the result of {@link ReadResult.text | `ReadResult.text`}. + * Specifies the `TextMode` that controls the result of + * {@link ReadResult.text | `ReadResult.text`}. * * - `"Plain"` * - * {@link ReadResult.bytes | `ReadResult.bytes`} transcoded to unicode based on ECI info or guessed character set - * + * {@link ReadResult.bytes | `ReadResult.bytes`} transcoded to unicode based on ECI info or guessed + * character set * - `"ECI"` * - * Standard content following the ECI protocol with every character set ECI segment transcoded to unicode - * + * Standard content following the ECI protocol with every character set ECI segment transcoded to + * unicode * - `"HRI"` * * Human Readable Interpretation (dependent on the ContentType) - * * - `"Hex"` * * {@link ReadResult.bytes | `ReadResult.bytes`} transcoded to ASCII string of HEX values - * * - `"Escaped"` * - * Escape non-graphical characters in angle brackets (e.g. ASCII `29` will be transcoded to `""`) + * Escape non-graphical characters in angle brackets (e.g. ASCII `29` will be transcoded to + * `""`) * * @defaultValue `"Plain"` */ textMode?: TextMode; /** - * Character set to use (when applicable). - * If this is set to `"Unknown"`, auto-detecting will be used. + * Character set to use (when applicable). If this is set to `"Unknown"`, auto-detecting will be + * used. * * @defaultValue `"Unknown"` */ characterSet?: CharacterSet; } -export const defaultReaderOptions: Required = { +export type ResolvedReaderOptions = Required; + +export const defaultReaderOptions: ResolvedReaderOptions = { formats: [], tryHarder: true, tryRotate: true, @@ -240,12 +237,55 @@ export const defaultReaderOptions: Required = { characterSet: "Unknown", }; +export function resolveReaderOptions( + readerOptions?: ReaderOptions, +): ResolvedReaderOptions { + return { + formats: readerOptions?.formats ?? defaultReaderOptions.formats, + tryHarder: readerOptions?.tryHarder ?? defaultReaderOptions.tryHarder, + tryRotate: readerOptions?.tryRotate ?? defaultReaderOptions.tryRotate, + tryInvert: readerOptions?.tryInvert ?? defaultReaderOptions.tryInvert, + tryDownscale: + readerOptions?.tryDownscale ?? defaultReaderOptions.tryDownscale, + binarizer: readerOptions?.binarizer ?? defaultReaderOptions.binarizer, + isPure: readerOptions?.isPure ?? defaultReaderOptions.isPure, + downscaleFactor: + readerOptions?.downscaleFactor ?? defaultReaderOptions.downscaleFactor, + downscaleThreshold: + readerOptions?.downscaleThreshold ?? + defaultReaderOptions.downscaleThreshold, + minLineCount: + readerOptions?.minLineCount ?? defaultReaderOptions.minLineCount, + maxNumberOfSymbols: + readerOptions?.maxNumberOfSymbols ?? + defaultReaderOptions.maxNumberOfSymbols, + tryCode39ExtendedMode: + readerOptions?.tryCode39ExtendedMode ?? + defaultReaderOptions.tryCode39ExtendedMode, + validateCode39CheckSum: + readerOptions?.validateCode39CheckSum ?? + defaultReaderOptions.validateCode39CheckSum, + validateITFCheckSum: + readerOptions?.validateITFCheckSum ?? + defaultReaderOptions.validateITFCheckSum, + returnCodabarStartEnd: + readerOptions?.returnCodabarStartEnd ?? + defaultReaderOptions.returnCodabarStartEnd, + returnErrors: + readerOptions?.returnErrors ?? defaultReaderOptions.returnErrors, + eanAddOnSymbol: + readerOptions?.eanAddOnSymbol ?? defaultReaderOptions.eanAddOnSymbol, + textMode: readerOptions?.textMode ?? defaultReaderOptions.textMode, + characterSet: + readerOptions?.characterSet ?? defaultReaderOptions.characterSet, + }; +} + export function readerOptionsToZXingReaderOptions( zxingModule: ZXingModule, - readerOptions: Required, + readerOptions: ResolvedReaderOptions, ): ZXingReaderOptions { - return { - ...readerOptions, + return Object.assign(readerOptions, { formats: formatsToString(readerOptions.formats), binarizer: binarizerToZXingEnum(zxingModule, readerOptions.binarizer), eanAddOnSymbol: eanAddOnSymbolToZXingEnum( @@ -257,5 +297,5 @@ export function readerOptionsToZXingReaderOptions( zxingModule, readerOptions.characterSet, ), - }; + }); } diff --git a/src/bindings/textMode.ts b/src/bindings/textMode.ts index 0fb2606b..edfcd3c5 100644 --- a/src/bindings/textMode.ts +++ b/src/bindings/textMode.ts @@ -18,5 +18,5 @@ export function textModeToZXingEnum( } export function zxingEnumToTextMode(zxingEnum: ZXingEnum): TextMode { - return textModes[zxingEnum.value]; + return textModes[zxingEnum.value]!; } diff --git a/src/bindings/writeResult.ts b/src/bindings/writeResult.ts index 3386294e..652a90df 100644 --- a/src/bindings/writeResult.ts +++ b/src/bindings/writeResult.ts @@ -4,8 +4,7 @@ export interface ZXingWriteResult { image: Uint8Array; /** - * Encoding error. - * If there's no error, this will be an empty string `""`. + * Encoding error. If there's no error, this will be an empty string `""`. * * @see {@link WriteResult.error | `WriteResult.error`} */ @@ -16,8 +15,7 @@ export interface ZXingWriteResult { export interface WriteResult extends Omit { /** - * The encoded barcode as an image blob. - * If some error happens, this will be `null`. + * The encoded barcode as an image blob. If some error happens, this will be `null`. * * @see {@link WriteResult.error | `WriteResult.error`} */ diff --git a/src/bindings/writerOptions.ts b/src/bindings/writerOptions.ts index 9a382852..394a4876 100644 --- a/src/bindings/writerOptions.ts +++ b/src/bindings/writerOptions.ts @@ -41,32 +41,29 @@ export interface WriterOptions /** * The format of the barcode to write. * - * Supported values are: - * `"Aztec"`, `"Codabar"`, `"Code128"`, `"Code39"`, `"Code93"`, - * `"DataMatrix"`, `"EAN-13"`, `"EAN-8"`, `"ITF"`, - * `"PDF417"`, `"QRCode"`, `"UPC-A"`, `"UPC-E"` + * Supported values are: `"Aztec"`, `"Codabar"`, `"Code128"`, `"Code39"`, `"Code93"`, + * `"DataMatrix"`, `"EAN-13"`, `"EAN-8"`, `"ITF"`, `"PDF417"`, `"QRCode"`, `"UPC-A"`, `"UPC-E"` * * @defaultValue `"QRCode"` */ format?: WriteInputBarcodeFormat; /** - * Character set to use for encoding the text. - * Used for Aztec, PDF417, and QRCode only. + * Character set to use for encoding the text. Used for Aztec, PDF417, and QRCode only. * * @defaultValue `"UTF8"` */ characterSet?: CharacterSet; /** - * Error correction level of the symbol. - * Used for Aztec, PDF417, and QRCode only. - * `-1` means auto. + * Error correction level of the symbol. Used for Aztec, PDF417, and QRCode only. `-1` means auto. * * @defaultValue `-1` */ eccLevel?: WriteInputEccLevel; } -export const defaultWriterOptions: Required = { +export type ResolvedWriterOptions = Required; + +export const defaultWriterOptions: ResolvedWriterOptions = { width: 200, height: 200, format: "QRCode", @@ -75,15 +72,28 @@ export const defaultWriterOptions: Required = { margin: 10, }; +export function resolveWriterOptions( + writerOptions?: WriterOptions, +): ResolvedWriterOptions { + return { + width: writerOptions?.width ?? defaultWriterOptions.width, + height: writerOptions?.height ?? defaultWriterOptions.height, + format: writerOptions?.format ?? defaultWriterOptions.format, + characterSet: + writerOptions?.characterSet ?? defaultWriterOptions.characterSet, + eccLevel: writerOptions?.eccLevel ?? defaultWriterOptions.eccLevel, + margin: writerOptions?.margin ?? defaultWriterOptions.margin, + }; +} + export function writerOptionsToZXingWriterOptions( zxingModule: ZXingModule, - writerOptions: Required, + writerOptions: ResolvedWriterOptions, ): ZXingWriterOptions { - return { - ...writerOptions, + return Object.assign(writerOptions, { characterSet: characterSetToZXingEnum( zxingModule, writerOptions.characterSet, ), - }; + }); } diff --git a/src/core.ts b/src/core.ts index 5e816101..953d52be 100644 --- a/src/core.ts +++ b/src/core.ts @@ -12,9 +12,9 @@ import { type ZXingVector, type ZXingWriteResult, type ZXingWriterOptions, - defaultReaderOptions, - defaultWriterOptions, readerOptionsToZXingReaderOptions, + resolveReaderOptions, + resolveWriterOptions, writerOptionsToZXingWriterOptions, zxingReadResultToReadResult, zxingWriteResultToWriteResult, @@ -93,7 +93,7 @@ export type ZXingModuleFactory = export type ZXingModuleOverrides = Partial; -const defaultModuleOverrides: ZXingModuleOverrides = import.meta.env.PROD +export const defaultModuleOverrides: ZXingModuleOverrides = import.meta.env.PROD ? { locateFile: (path, prefix) => { const match = path.match(/_(.+?)\.wasm$/); @@ -173,12 +173,9 @@ export async function readBarcodesFromImageFileWithFactory< >( zxingModuleFactory: ZXingModuleFactory, imageFile: Blob, - readerOptions: ReaderOptions = defaultReaderOptions, + readerOptions?: ReaderOptions, ) { - const requiredReaderOptions: Required = { - ...defaultReaderOptions, - ...readerOptions, - }; + const resolvedReaderOptions = resolveReaderOptions(readerOptions); const zxingModule = await getZXingModuleWithFactory(zxingModuleFactory); const { size } = imageFile; const buffer = new Uint8Array(await imageFile.arrayBuffer()); @@ -187,7 +184,7 @@ export async function readBarcodesFromImageFileWithFactory< const zxingReadResultVector = zxingModule.readBarcodesFromImage( bufferPtr, size, - readerOptionsToZXingReaderOptions(zxingModule, requiredReaderOptions), + readerOptionsToZXingReaderOptions(zxingModule, resolvedReaderOptions), ); zxingModule._free(bufferPtr); const readResults: ReadResult[] = []; @@ -204,12 +201,9 @@ export async function readBarcodesFromImageDataWithFactory< >( zxingModuleFactory: ZXingModuleFactory, imageData: ImageData, - readerOptions: ReaderOptions = defaultReaderOptions, + readerOptions?: ReaderOptions, ) { - const requiredReaderOptions: Required = { - ...defaultReaderOptions, - ...readerOptions, - }; + const resolvedReaderOptions = resolveReaderOptions(readerOptions); const zxingModule = await getZXingModuleWithFactory(zxingModuleFactory); const { data: buffer, @@ -223,7 +217,7 @@ export async function readBarcodesFromImageDataWithFactory< bufferPtr, width, height, - readerOptionsToZXingReaderOptions(zxingModule, requiredReaderOptions), + readerOptionsToZXingReaderOptions(zxingModule, resolvedReaderOptions), ); zxingModule._free(bufferPtr); const readResults: ReadResult[] = []; @@ -240,16 +234,13 @@ export async function writeBarcodeToImageFileWithFactory< >( zxingModuleFactory: ZXingModuleFactory, text: string, - writerOptions: WriterOptions = defaultWriterOptions, + writerOptions?: WriterOptions, ) { - const requiredWriterOptions: Required = { - ...defaultWriterOptions, - ...writerOptions, - }; + const resolvedWriterOptions = resolveWriterOptions(writerOptions); const zxingModule = await getZXingModuleWithFactory(zxingModuleFactory); const zxingWriteResult = zxingModule.writeBarcodeToImage( text, - writerOptionsToZXingWriterOptions(zxingModule, requiredWriterOptions), + writerOptionsToZXingWriterOptions(zxingModule, resolvedWriterOptions), ); return zxingWriteResultToWriteResult(zxingWriteResult); } diff --git a/src/index.css.ts b/src/index.css.ts new file mode 100644 index 00000000..837ed0ed --- /dev/null +++ b/src/index.css.ts @@ -0,0 +1,22 @@ +import { style } from "@vanilla-extract/css"; + +export const wrapperClass = style({ + display: "grid", + alignItems: "center", + justifyContent: "center", +}); + +const canvasClass = style({ + gridArea: "1 / 1", + maxWidth: "100%", + maxHeight: "100%", +}); + +export const frameClass = canvasClass; + +export const overlayClass = style([ + canvasClass, + { + pointerEvents: "none", + }, +]); diff --git a/src/react/components/StreamBarcodeDetector.tsx b/src/react/components/StreamBarcodeDetector.tsx new file mode 100644 index 00000000..3f824a36 --- /dev/null +++ b/src/react/components/StreamBarcodeDetector.tsx @@ -0,0 +1,186 @@ +import { type ComponentPropsWithRef, type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef } from "react"; +import { frameClass, overlayClass, wrapperClass } from "../../index.css.js"; +import type { ReaderOptions } from "../../reader/index.js"; +import { + type UseUserMediaStreamOptions, + type UseVideoScannerOptions, + useUserMediaStream, + useVideoScanner, +} from "../hooks/index.js"; + +export interface StreamBarcodeDetectorProps + extends UseUserMediaStreamOptions, + UseVideoScannerOptions, + ReaderOptions, + ComponentPropsWithRef<"div"> { + overlayCanvasProps?: ComponentPropsWithoutRef<"canvas">; + frameCanvasProps?: ComponentPropsWithoutRef<"canvas">; +} + +export const StreamBarcodeDetector = memo( + ({ + /** + * stream options + */ + streaming = true, + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + onStreamStart, + onStreamStop, + onStreamUpdate, + onStreamInspect, + + /** + * scanner options + */ + scanning = true, + wasmLocation, + readerOptions, + scanThrottle, + negativeDebounce, + onScanDetect, + onScanUpdate, + onScanStart, + onScanStop, + onScanClose, + onRepaint, + + /** + * reader options + */ + formats, + tryHarder, + tryRotate, + tryInvert, + tryDownscale, + binarizer, + isPure, + downscaleThreshold, + downscaleFactor, + minLineCount, + maxNumberOfSymbols, + tryCode39ExtendedMode, + validateCode39CheckSum, + validateITFCheckSum, + returnCodabarStartEnd, + returnErrors, + eanAddOnSymbol, + textMode, + characterSet, + + /** + * inner element props + */ + overlayCanvasProps, + frameCanvasProps, + + /** + * element options + */ + children, + ref, + ...wrapperProps + }: StreamBarcodeDetectorProps = {}) => { + const resolvedReaderOptions = useMemo( + () => ({ + formats: formats ?? readerOptions?.formats, + tryHarder: tryHarder ?? readerOptions?.tryHarder, + tryRotate: tryRotate ?? readerOptions?.tryRotate, + tryInvert: tryInvert ?? readerOptions?.tryInvert, + tryDownscale: tryDownscale ?? readerOptions?.tryDownscale, + binarizer: binarizer ?? readerOptions?.binarizer, + isPure: isPure ?? readerOptions?.isPure, + downscaleFactor: downscaleFactor ?? readerOptions?.downscaleFactor, + downscaleThreshold: downscaleThreshold ?? readerOptions?.downscaleThreshold, + minLineCount: minLineCount ?? readerOptions?.minLineCount, + maxNumberOfSymbols: maxNumberOfSymbols ?? readerOptions?.maxNumberOfSymbols, + tryCode39ExtendedMode: tryCode39ExtendedMode ?? readerOptions?.tryCode39ExtendedMode, + validateCode39CheckSum: validateCode39CheckSum ?? readerOptions?.validateCode39CheckSum, + validateITFCheckSum: validateITFCheckSum ?? readerOptions?.validateITFCheckSum, + returnCodabarStartEnd: returnCodabarStartEnd ?? readerOptions?.returnCodabarStartEnd, + returnErrors: returnErrors ?? readerOptions?.returnErrors, + eanAddOnSymbol: eanAddOnSymbol ?? readerOptions?.eanAddOnSymbol, + textMode: textMode ?? readerOptions?.textMode, + characterSet: characterSet ?? readerOptions?.characterSet, + }), + [ + readerOptions, + formats, + tryHarder, + tryRotate, + tryInvert, + tryDownscale, + binarizer, + isPure, + downscaleFactor, + downscaleThreshold, + minLineCount, + maxNumberOfSymbols, + tryCode39ExtendedMode, + validateCode39CheckSum, + validateITFCheckSum, + returnCodabarStartEnd, + returnErrors, + eanAddOnSymbol, + textMode, + characterSet, + ], + ); + + const streamVideoRefCallback = useUserMediaStream({ + streaming, + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + onStreamStart, + onStreamStop, + onStreamUpdate, + onStreamInspect, + }); + + const { + videoRefCallback: scannerVideoRefCallback, + overlayCanvasElementRef, + frameCanvasElementRef, + } = useVideoScanner({ + /** + * scanner options + */ + scanning, + wasmLocation, + readerOptions: resolvedReaderOptions, + scanThrottle, + negativeDebounce, + onScanDetect, + onScanUpdate, + onScanStart, + onScanStop, + onScanClose, + onRepaint, + }); + + const videoElementRef = useRef(document.createElement("video")); + + useEffect(() => { + streamVideoRefCallback(videoElementRef.current); + scannerVideoRefCallback(videoElementRef.current); + }, [streamVideoRefCallback, scannerVideoRefCallback]); + + return ( +
+ + {frameCanvasProps?.children} + + + {overlayCanvasProps?.children} + + {children} +
+ ); + }, +); + +export default StreamBarcodeDetector; diff --git a/src/react/components/index.ts b/src/react/components/index.ts new file mode 100644 index 00000000..4ed7de7a --- /dev/null +++ b/src/react/components/index.ts @@ -0,0 +1 @@ +export * from "./StreamBarcodeDetector.js"; diff --git a/src/react/hooks/index.ts b/src/react/hooks/index.ts new file mode 100644 index 00000000..98c45ecd --- /dev/null +++ b/src/react/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./useUserMediaStream.js"; +export * from "./useHeadlessVideoScanner.js"; +export * from "./useVideoScanner.js"; diff --git a/src/react/hooks/useHeadlessVideoScanner.ts b/src/react/hooks/useHeadlessVideoScanner.ts new file mode 100644 index 00000000..80b20206 --- /dev/null +++ b/src/react/hooks/useHeadlessVideoScanner.ts @@ -0,0 +1,95 @@ +import { type RefCallback, useCallback, useEffect, useRef } from "react"; +import { + type VideoScanner, + type VideoScannerOptions, + createVideoScanner, +} from "../../scanner/index.js"; + +export interface UseHeadlessVideoScannerOptions extends VideoScannerOptions { + /** + * control the activation of scanning + */ + scanning?: boolean; +} + +export function useHeadlessVideoScanner({ + /** + * scanner options + */ + scanning = true, + wasmLocation, + readerOptions, + scanThrottle, + negativeDebounce, + onScanDetect, + onScanUpdate, + onScanStart, + onScanStop, + onScanClose, + onRepaint, +}: UseHeadlessVideoScannerOptions) { + const videoScannerRef = useRef(null); + + useEffect(() => { + if (scanning) { + videoScannerRef.current?.start(); + } else { + videoScannerRef.current?.stop(); + } + }, [scanning]); + + const createVideoScannerRef = useRef((videoElement: HTMLVideoElement) => + createVideoScanner(videoElement, { + wasmLocation, + readerOptions, + scanThrottle, + negativeDebounce, + onScanDetect, + onScanUpdate, + onScanStart, + onScanStop, + onScanClose, + onRepaint, + }), + ); + + const videoRefCallback = useCallback>( + (videoElement) => { + if (videoElement !== null) { + videoScannerRef.current = createVideoScannerRef.current(videoElement); + } else { + videoScannerRef.current?.close(); + videoScannerRef.current = null; + } + }, + [], + ); + + useEffect(() => { + videoScannerRef.current?.setOptions({ + wasmLocation, + readerOptions, + scanThrottle, + negativeDebounce, + onScanDetect, + onScanUpdate, + onScanStart, + onScanStop, + onScanClose, + onRepaint, + }); + }, [ + wasmLocation, + readerOptions, + scanThrottle, + negativeDebounce, + onScanDetect, + onScanUpdate, + onScanStart, + onScanStop, + onScanClose, + onRepaint, + ]); + + return videoRefCallback; +} diff --git a/src/react/hooks/useUserMediaStream.ts b/src/react/hooks/useUserMediaStream.ts new file mode 100644 index 00000000..2b57c4a4 --- /dev/null +++ b/src/react/hooks/useUserMediaStream.ts @@ -0,0 +1,97 @@ +import { + type RefCallback, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { + type UserMediaStream, + type UserMediaStreamOptions, + attachMediaStream, + createUserMediaStream, +} from "../../stream/index.js"; + +export interface UseUserMediaStreamOptions extends UserMediaStreamOptions { + /** + * control the activation of streaming + */ + streaming?: boolean; +} + +export function useUserMediaStream({ + /** + * stream options + */ + streaming = true, + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + onStreamStart, + onStreamStop, + onStreamUpdate, + onStreamInspect, +}: UseUserMediaStreamOptions) { + const userMediaStreamRef = useRef( + createUserMediaStream({ + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + onStreamStart, + onStreamStop, + onStreamUpdate, + onStreamInspect, + }), + ); + + const [stream, setStream] = useState(null); + + useEffect(() => { + (async () => { + if (streaming) { + const stream = await userMediaStreamRef.current.start(); + setStream(stream); + userMediaStreamRef.current.inspect(); + } else { + await userMediaStreamRef.current.stop(); + setStream(null); + } + })(); + }, [streaming]); + + const videoRefCallback = useCallback>( + (videoElement) => { + if (stream !== null && videoElement !== null) { + attachMediaStream(videoElement, stream); + videoElement.play(); + } + }, + [stream], + ); + + useEffect(() => { + userMediaStreamRef.current.setOptions({ + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + onStreamStart, + onStreamStop, + onStreamUpdate, + onStreamInspect, + }); + }, [ + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + onStreamStart, + onStreamStop, + onStreamUpdate, + onStreamInspect, + ]); + + return videoRefCallback; +} diff --git a/src/react/hooks/useVideoScanner.ts b/src/react/hooks/useVideoScanner.ts new file mode 100644 index 00000000..5319c116 --- /dev/null +++ b/src/react/hooks/useVideoScanner.ts @@ -0,0 +1,170 @@ +import { type RefCallback, useCallback, useRef } from "react"; +import type { ReadResult } from "../../reader/index.js"; +import { + type UseHeadlessVideoScannerOptions, + useHeadlessVideoScanner, +} from "./useHeadlessVideoScanner.js"; + +export interface UseVideoScannerOptions + extends UseHeadlessVideoScannerOptions {} + +export function useVideoScanner({ + onScanUpdate, + onRepaint, + ...restUseVideoScannerOptions +}: UseVideoScannerOptions) { + const videoElementRef = useRef(null); + + const scannerVideoRefCallback = useCallback>( + (videoElement) => { + videoElementRef.current = videoElement; + }, + [], + ); + + const overlayCanvasElementRef = useRef(null); + + const handleOverlayUpdate = useCallback( + (() => { + let context: CanvasRenderingContext2D | undefined = undefined; + return (readResults: ReadResult[]) => { + // check canvas ready state + if ( + overlayCanvasElementRef.current === null || + videoElementRef.current === null + ) { + return; + } + // update canvas dimension + const { videoWidth, videoHeight } = videoElementRef.current; + if (context === undefined) { + context = overlayCanvasElementRef.current.getContext("2d")!; + } + if (context.canvas.width !== videoWidth) { + context.canvas.width = videoWidth; + } + if (context.canvas.height !== videoHeight) { + context.canvas.height = videoHeight; + } + // draw overlay + context.clearRect(0, 0, videoWidth, videoHeight); + context.beginPath(); + + context.moveTo(0, 0); + context.lineTo(videoWidth, 0); + context.lineTo(videoWidth, videoHeight); + context.lineTo(0, videoHeight); + context.closePath(); + + for (const readResult of readResults) { + if (readResult.isValid) { + context.moveTo( + readResult.position.topLeft.x, + readResult.position.topLeft.y, + ); + context.lineTo( + readResult.position.bottomLeft.x, + readResult.position.bottomLeft.y, + ); + context.lineTo( + readResult.position.bottomRight.x, + readResult.position.bottomRight.y, + ); + context.lineTo( + readResult.position.topRight.x, + readResult.position.topRight.y, + ); + context.closePath(); + } + } + + context.fillStyle = "rgba(0, 0, 0, 0.8)"; + context.fill(); + // context.clearRect(0, 0, videoWidth, videoHeight); + // for (const readResult of readResults) { + // if (readResult.isValid) { + // context.strokeStyle = "#f00"; + // context.lineWidth = 1; + // context.beginPath(); + // context.moveTo( + // readResult.position.topLeft.x, + // readResult.position.topLeft.y, + // ); + // context.lineTo( + // readResult.position.topRight.x, + // readResult.position.topRight.y, + // ); + // context.lineTo( + // readResult.position.bottomRight.x, + // readResult.position.bottomRight.y, + // ); + // context.lineTo( + // readResult.position.bottomLeft.x, + // readResult.position.bottomLeft.y, + // ); + // context.closePath(); + // context.stroke(); + // } + // } + }; + })(), + [], + ); + + const handleScanUpdate = useCallback( + (readResults: ReadResult[]) => { + handleOverlayUpdate(readResults); + onScanUpdate?.(readResults); + }, + [handleOverlayUpdate, onScanUpdate], + ); + + const frameCanvasElementRef = useRef(null); + + const handleFrameUpdate = useCallback( + (() => { + let context: CanvasRenderingContext2D | undefined = undefined; + return () => { + if ( + frameCanvasElementRef.current === null || + videoElementRef.current === null + ) { + return; + } + const { videoWidth, videoHeight } = videoElementRef.current; + if (context === undefined) { + context = frameCanvasElementRef.current.getContext("2d")!; + } + if (context.canvas.width !== videoWidth) { + context.canvas.width = videoWidth; + } + if (context.canvas.height !== videoHeight) { + context.canvas.height = videoHeight; + } + context.drawImage(videoElementRef.current, 0, 0); + }; + })(), + [], + ); + + const handleRepaint = useCallback(() => { + handleFrameUpdate(); + onRepaint?.(); + }, [handleFrameUpdate, onRepaint]); + + const headlessScannerVideoRefCallback = useHeadlessVideoScanner({ + onScanUpdate: handleScanUpdate, + onRepaint: handleRepaint, + ...restUseVideoScannerOptions, + }); + + const videoRefCallback = useCallback>( + (videoElement) => { + scannerVideoRefCallback(videoElement); + headlessScannerVideoRefCallback(videoElement); + }, + [scannerVideoRefCallback, headlessScannerVideoRefCallback], + ); + + return { videoRefCallback, overlayCanvasElementRef, frameCanvasElementRef }; +} diff --git a/src/react/index.ts b/src/react/index.ts new file mode 100644 index 00000000..c67e1a1a --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,2 @@ +export * from "./components/index.js"; +export * from "./hooks/index.js"; diff --git a/src/reader/index.ts b/src/reader/index.ts index 1ffa08e6..2925907a 100644 --- a/src/reader/index.ts +++ b/src/reader/index.ts @@ -50,6 +50,6 @@ export async function readBarcodesFromImageData( export * from "../bindings/exposedReaderBindings.js"; export { purgeZXingModule, - type ZXingReaderModule, type ZXingModuleOverrides, + type ZXingReaderModule, } from "../core.js"; diff --git a/src/scanner/index.ts b/src/scanner/index.ts new file mode 100644 index 00000000..e7327e1b --- /dev/null +++ b/src/scanner/index.ts @@ -0,0 +1 @@ +export * from "./videoScanner.js"; diff --git a/src/scanner/videoScanner.ts b/src/scanner/videoScanner.ts new file mode 100644 index 00000000..bfa170af --- /dev/null +++ b/src/scanner/videoScanner.ts @@ -0,0 +1,510 @@ +import { createHash } from "sha1-uint8array"; +import type { SetRequired } from "type-fest"; +import { subscribeWithSelector } from "zustand/middleware"; +import { createStore } from "zustand/vanilla"; +import { defaultModuleOverrides } from "../core.js"; +import { + type ReadResult, + type ReaderOptions, + barcodeFormats, + contentTypes, + readBarcodesFromImageData, + setZXingModuleOverrides, +} from "../reader/index.js"; + +/** + * The default minimum interval in milliseconds for scanning operations. + */ +const SCAN_THROTTLE = 40; + +/** + * The default minimum interval in milliseconds to confirm a negative result. + */ +const NEGATIVE_DEBOUNCE = 0; + +/** + * Symbols used as unique keys for internal state properties to avoid overriden. + */ +const scanSymbol = Symbol("scan"); +const closeSymbol = Symbol("close"); + +/** + * Options for configuring the VideoScanner. + */ +export interface VideoScannerOptions { + /** + * Location of the ZXing WebAssembly (WASM) file used for barcode decoding. + */ + wasmLocation?: string; + /** + * Configuration options for the ZXing barcode reader. + */ + readerOptions?: ReaderOptions; + /** + * Minimum interval in milliseconds between scan operations to throttle the scan frequency. + */ + scanThrottle?: number; + /** + * Minimum duration in milliseconds before a negative result is confirmed. + */ + negativeDebounce?: number; + /** + * Callback function that is triggered when a new barcode is detected. + * + * @param readResults - Array of detected barcodes. + */ + onScanDetect?: (readResults: ReadResult[]) => unknown; + /** + * Callback function that is triggered on each scan update, regardless of new detections. + * + * @param readResults - Array of detected barcodes. + */ + onScanUpdate?: (readResults: ReadResult[]) => unknown; + /** + * Callback function that is triggered when the scanning process starts. + */ + onScanStart?: () => unknown; + /** + * Callback function that is triggered when the scanning process stops. + */ + onScanStop?: () => unknown; + /** + * Callback function that is triggered when the scanning process is closed. + */ + onScanClose?: () => unknown; + /** + * Callback function that is triggered when the browser repaints. + */ + onRepaint?: () => unknown; +} + +/** + * Internal state and options for VideoScanner. + */ +interface VideoScannerState extends ResolvedVideoScannerOptions { + /** + * Indicates whether the scanner is currently scanning. + */ + [scanSymbol]: boolean; + /** + * Indicates whether the scanner has been closed. + */ + [closeSymbol]: boolean; +} + +/** + * Resolved options for the VideoScanner with default values provided. + */ +type ResolvedVideoScannerOptions = SetRequired< + VideoScannerOptions, + "scanThrottle" | "negativeDebounce" +>; + +/** + * Resolves and merges the provided VideoScannerOptions with the default values for unspecified options. + * + * @param videoScannerOptions - The user-provided configuration options for the VideoScanner. + * @returns The resolved configuration options with default values for unspecified options. + */ +function resolveVideoScannerOptions( + videoScannerOptions: VideoScannerOptions, +): ResolvedVideoScannerOptions { + return { + wasmLocation: + "wasmLocation" in videoScannerOptions + ? videoScannerOptions.wasmLocation + : undefined, + readerOptions: + "readerOptions" in videoScannerOptions + ? videoScannerOptions.readerOptions + : undefined, + scanThrottle: videoScannerOptions.scanThrottle ?? SCAN_THROTTLE, + negativeDebounce: videoScannerOptions.negativeDebounce ?? NEGATIVE_DEBOUNCE, + onScanDetect: + "onScanDetect" in videoScannerOptions + ? videoScannerOptions.onScanDetect + : undefined, + onScanUpdate: + "onScanUpdate" in videoScannerOptions + ? videoScannerOptions.onScanUpdate + : undefined, + onScanStart: + "onScanStart" in videoScannerOptions + ? videoScannerOptions.onScanStart + : undefined, + onScanStop: + "onScanStop" in videoScannerOptions + ? videoScannerOptions.onScanStop + : undefined, + onScanClose: + "onScanClose" in videoScannerOptions + ? videoScannerOptions.onScanClose + : undefined, + onRepaint: + "onRepaint" in videoScannerOptions + ? videoScannerOptions.onRepaint + : undefined, + }; +} + +/** + * Represents a video scanner that can start, stop, and close the scanning process, and update its configuration. + */ +export interface VideoScanner { + /** + * Starts the scanning process. + */ + start: () => void; + /** + * Stops the scanning process. + */ + stop: () => void; + /** + * Closes the scanner and performs cleanup operations. + */ + close: () => void; + /** + * Update the configuration options of the VideoScanner. + * + * @param videoScannerOptions - New configuration options. + */ + setOptions: (videoScannerOptions: VideoScannerOptions) => void; +} + +/** + * Creates and returns a VideoScanner object. + * + * Initializes a VideoScanner with the specified HTMLVideoElement and configuration options, providing control methods + * for starting, stopping, and closing the video scanner, along with updating its options. + * + * @param videoElement - The HTMLVideoElement used for scanning. + * @param videoScannerOptions - Configuration options for the VideoScanner. + * @returns A VideoScanner object providing control methods for video scanning. + */ +export function createVideoScanner( + videoElement: HTMLVideoElement, + videoScannerOptions: VideoScannerOptions, +): VideoScanner { + const { + wasmLocation, + readerOptions, + scanThrottle, + negativeDebounce, + onScanDetect, + onScanUpdate, + onScanStart, + onScanStop, + onScanClose, + onRepaint, + } = resolveVideoScannerOptions(videoScannerOptions); + + // request animation frame id + let requestAnimationFrameId: number; + + // create a state store + const videoScannerStore = createStore()( + subscribeWithSelector(() => ({ + [scanSymbol]: false, + [closeSymbol]: false, + + wasmLocation, + readerOptions, + scanThrottle, + negativeDebounce, + + onScanDetect, + onScanUpdate, + onScanStart, + onScanStop, + onScanClose, + onRepaint, + })), + ); + + const start = () => { + videoScannerStore.setState({ + [scanSymbol]: true, + }); + }; + + const stop = () => { + videoScannerStore.setState({ + [scanSymbol]: false, + }); + }; + + const close = () => { + videoScannerStore.setState({ + [closeSymbol]: true, + }); + }; + + // subscribe to scan start and stop actions + // so we can call the event handlers + const unsubScan = videoScannerStore.subscribe( + (options) => options[scanSymbol], + (scan) => { + if (scan) { + videoScannerStore.getState().onScanStart?.(); + } else { + videoScannerStore.getState().onScanStop?.(); + } + }, + ); + + // subscribe to the close action + // so we can do some cleanups + const unsubClose = videoScannerStore.subscribe( + (options) => options[closeSymbol], + (close) => { + if (close) { + globalThis.cancelAnimationFrame(requestAnimationFrameId); + stop(); + unsubScan(); + unsubClose(); + unsubWasmLocation(); + videoScannerStore.getState().onScanClose?.(); + } + }, + { + fireImmediately: true, + }, + ); + + // subscribe to wasm location change + const unsubWasmLocation = videoScannerStore.subscribe( + (options) => options.wasmLocation, + (wasmLocation) => { + if (wasmLocation === undefined) { + setZXingModuleOverrides(defaultModuleOverrides); + return; + } + setZXingModuleOverrides({ + locateFile: (path, prefix) => { + if (path.endsWith(".wasm")) { + return wasmLocation; + } + return prefix + path; + }, + }); + }, + { + fireImmediately: true, + }, + ); + + // define the frame request callback function + const frameRequestCallback = (() => { + // keep the context values in the closure + let detecting = false; + let prevTimestamp = 0; + + type SignatureMap = Map< + string, + { timestamp: number; readResult: ReadResult } + >; + let prevSignatureMap: SignatureMap = new Map(); + + // return the actual callback function + return async (currTimestamp: DOMHighResTimeStamp) => { + // get options snapshot + const { + [scanSymbol]: scan, + [closeSymbol]: close, + readerOptions, + scanThrottle, + onScanDetect, + onScanUpdate, + onRepaint, + } = videoScannerStore.getState(); + + // return if closed + if (close) { + return; + } + + // call repaint event handler + onRepaint?.(); + + // skip if the following conditions are met + if (!scan || detecting || currTimestamp - prevTimestamp < scanThrottle) { + if (!scan) { + // clear previous signature map + prevSignatureMap.clear(); + // clear previouse timestamp + prevTimestamp = 0; + } + // trigger next cycle + requestAnimationFrameId = + globalThis.requestAnimationFrame(frameRequestCallback); + return; + } + + // set detecting status + detecting = true; + + // detect + const imageData = getImageDataFromVideoElement(videoElement); + let readResults: ReadResult[] = []; + if (imageData !== null) { + readResults = await readBarcodesFromImageData(imageData, readerOptions); + } + + // populate the signature map and set the flag + let newSymbolDetected = false; + const currSignatureMap: SignatureMap = new Map(); + for (const readResult of readResults) { + const signature = getSignature(readResult); + if (!newSymbolDetected && !prevSignatureMap.has(signature)) { + newSymbolDetected = true; + } + currSignatureMap.set(signature, { + timestamp: currTimestamp, + readResult, + }); + } + for (const [prevSignature, prevResult] of prevSignatureMap) { + if ( + !currSignatureMap.has(prevSignature) && + currTimestamp - prevResult.timestamp < negativeDebounce + ) { + currSignatureMap.set(prevSignature, prevResult); + readResults.push(prevResult.readResult); + } + } + prevSignatureMap = currSignatureMap; + + // call onScanDetect event handler + if (newSymbolDetected) { + onScanDetect?.(readResults); + } + + // call onScanUpdate event handler + onScanUpdate?.(readResults); + + // update prevTimestamp + prevTimestamp = currTimestamp; + + // clear detecting status + detecting = false; + + // trigger next cycle + requestAnimationFrameId = + globalThis.requestAnimationFrame(frameRequestCallback); + }; + })(); + + requestAnimationFrameId = + globalThis.requestAnimationFrame(frameRequestCallback); + + return { + start, + stop, + close, + setOptions: (videoScannerOptions) => + videoScannerStore.setState( + resolveVideoScannerOptions(videoScannerOptions), + ), + }; +} + +/** + * Retrieves image data from the provided HTMLVideoElement for processing. + * + * Ensures the video element is in a ready state and has non-zero dimensions before extracting the image data. + * + * @param videoElement - The HTMLVideoElement to extract image data from. + * @returns ImageData if successful, or null in case of failure or invalid state. + */ +export const getImageDataFromVideoElement = (() => { + let context: + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | undefined = undefined; + return (videoElement: HTMLVideoElement) => { + if (videoElement.readyState === 0 || videoElement.readyState === 1) { + return null; + } + const width = videoElement.videoWidth; + const height = videoElement.videoHeight; + if (width === 0 || height === 0) { + return null; + } + if (context === undefined) { + const canvas = createCanvas(width, height); + context = canvas.getContext("2d", { willReadFrequently: true }) as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D; + } else { + if (context.canvas.width !== width) { + context.canvas.width = width; + } + if (context.canvas.height !== height) { + context.canvas.height = height; + } + } + context.drawImage(videoElement, 0, 0); + try { + const imageData = context.getImageData(0, 0, width, height); + return imageData; + } catch (e) { + return null; + } + }; +})(); + +/** + * Dynamically creates a canvas element suitable for the environment. + * + * Prefers OffscreenCanvas but falls back to HTMLCanvasElement where OffscreenCanvas is not supported. + * + * @param width - The width of the canvas. + * @param height - The height of the canvas. + * @returns A canvas element, either OffscreenCanvas or HTMLCanvasElement based on environment support. + */ +function createCanvas( + width: number, + height: number, +): OffscreenCanvas | HTMLCanvasElement { + try { + const canvas = new OffscreenCanvas(width, height); + if ( + canvas.getContext("2d", { willReadFrequently: true }) instanceof + OffscreenCanvasRenderingContext2D + ) { + return canvas; + } + throw undefined; + } catch { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } +} + +/** + * Generates a unique signature for a given ReadResult. + * + * Creates a signature string for a ReadResult object to uniquely identify a read barcode. + * + * @param readResult - The ReadResult object for which to generate the signature. + * @returns A unique signature string for the given ReadResult. + */ +function getSignature(readResult: ReadResult) { + return createHash() + .update( + new Uint8Array( + (+readResult.isValid << 0) + + (+readResult.isMirrored << 1) + + (+readResult.isInverted << 2) + + (+readResult.hasECI << 3) + + (+readResult.readerInit << 4), + ), + ) + .update(new Uint8Array(barcodeFormats.indexOf(readResult.format) + 1)) + .update(new Uint8Array(contentTypes.indexOf(readResult.contentType) + 1)) + .update(readResult.symbologyIdentifier + readResult.version) + .update(readResult.bytes) + .digest("hex"); +} diff --git a/src/stream/compatibility.d.ts b/src/stream/compatibility.d.ts new file mode 100644 index 00000000..2e6d5684 --- /dev/null +++ b/src/stream/compatibility.d.ts @@ -0,0 +1,11 @@ +declare type CreateObjectURLCompat = ( + obj: Blob | MediaSource | MediaStream, +) => string; + +declare interface HTMLVideoElement { + mozSrcObject?: HTMLVideoElement["srcObject"]; +} + +declare interface MediaStreamTrack { + getCapabilities?: MediaStreamTrack["getCapabilities"]; +} diff --git a/src/stream/index.ts b/src/stream/index.ts new file mode 100644 index 00000000..0729d42f --- /dev/null +++ b/src/stream/index.ts @@ -0,0 +1 @@ +export * from "./userMediaStream.js"; diff --git a/src/stream/media-track-shims.d.ts b/src/stream/media-track-shims.d.ts new file mode 100644 index 00000000..5c0d38b1 --- /dev/null +++ b/src/stream/media-track-shims.d.ts @@ -0,0 +1,61 @@ +// TODO: complete constraints and capabilities + +declare interface MediaTrackSupportedConstraints { + brightness?: boolean; + browserWindow?: boolean; + colorTemperature?: boolean; + contrast?: boolean; + exposureCompensation?: boolean; + exposureMode?: boolean; + exposureTime?: boolean; + focusDistance?: boolean; + focusMode?: boolean; + iso?: boolean; + latency?: boolean; + mediaSource?: boolean; + pan?: boolean; + pointsOfInterest?: boolean; + resizeMode?: boolean; + saturation?: boolean; + scrollWithPage?: boolean; + sharpness?: boolean; + suppressLocalAudioPlayback?: boolean; + tilt?: boolean; + torch?: boolean; + viewPortHeight?: boolean; + viewPortOffsetX?: boolean; + viewPortOffsetY?: boolean; + viewportWidth?: boolean; + whiteBalanceMode?: boolean; + zoom?: boolean; +} + +interface NumberRangeWithStep { + min: number; + max: number; + step: number; +} + +declare interface MediaTrackCapabilities { + brightness?: NumberRangeWithStep; + colorTemperature?: NumberRangeWithStep; + contrast?: NumberRangeWithStep; + exposureMode?: string[]; + exposureTime?: NumberRangeWithStep; + resizeMode?: string[]; + saturation?: NumberRangeWithStep; + sharpness?: NumberRangeWithStep; + whiteBalanceMode?: string[]; +} + +declare interface MediaTrackConstraintSet { + brightness?: ConstrainULong; + colorTemperature?: ConstrainULong; + contrast?: ConstrainULong; + exposureMode?: ConstrainDOMString; + exposureTime?: ConstrainDouble; + resizeMode?: ConstrainDOMString; + saturation?: ConstrainULong; + sharpness?: ConstrainULong; + whiteBalanceMode?: ConstrainDOMString; +} diff --git a/src/stream/shimGetUserMedia.ts b/src/stream/shimGetUserMedia.ts new file mode 100644 index 00000000..2212e910 --- /dev/null +++ b/src/stream/shimGetUserMedia.ts @@ -0,0 +1,50 @@ +import { detectBrowser } from "webrtc-adapter/dist/utils"; + +/** + * Provide a shim for getUserMedia API across different browsers. + * + * This function ensures compatibility of the getUserMedia API with various browsers by applying + * necessary shims provided by the 'webrtc-adapter' package. It detects the browser and applies the + * appropriate shim. The function is designed to be called once to set up the shims and will not + * re-apply the shims on subsequent calls. + * + * @returns A promise that resolves once the appropriate shim (if any) has been applied. It resolves + * to `void` as its purpose is to configure the environment rather than return a value. + */ +export const shimGetUserMedia = (() => { + let called = false; + return async () => { + // Ensure the shim is applied only once + if (called) { + return; + } + + // Detect the current browser + const browserDetails = detectBrowser(window); + + // Apply browser-specific shim for getUserMedia + switch (browserDetails.browser) { + case "chrome": + ( + await import("webrtc-adapter/dist/chrome/getusermedia") + ).shimGetUserMedia(window, browserDetails); + break; + case "firefox": + ( + await import("webrtc-adapter/dist/firefox/getusermedia") + ).shimGetUserMedia(window, browserDetails); + break; + case "safari": + ( + await import("webrtc-adapter/dist/safari/safari_shim") + ).shimGetUserMedia(window); + break; + default: + // Handle other browsers or runtimes in a non-disruptive way + break; + } + + // Mark the shim as applied + called = true; + }; +})(); diff --git a/src/stream/userMediaStream.ts b/src/stream/userMediaStream.ts new file mode 100644 index 00000000..637a7464 --- /dev/null +++ b/src/stream/userMediaStream.ts @@ -0,0 +1,834 @@ +import compare from "just-compare"; +import type { SetRequired } from "type-fest"; +import { subscribeWithSelector } from "zustand/middleware"; +import { shallow } from "zustand/shallow"; +import { createStore } from "zustand/vanilla"; +import { shimGetUserMedia } from "./shimGetUserMedia.js"; + +/** + * The default maximum time in milliseconds to wait for getting the capabilities of a media track. + */ +const GET_CAPABILITIES_TIMEOUT = 500; + +/** + * Media stream constraints for initialization. + * + * This can either be a standard `MediaStreamConstraints` object or a function that returns + * `MediaStreamConstraints`. The function is provided with an argument, + * `MediaTrackSupportedConstraints`, which represents constraints supported by the **user agent**, + * and should return either `MediaStreamConstraints` or a promise that resolves to it. + * + * Note that `MediaTrackSupportedConstraints` doesn't reflect the constraints supported by the + * device. It only provides information about which constraint can be understood by the user agent. + * + * - [`MediaStreamConstraints`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#constraints) + * - [`MediaTrackSupportedConstraints`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSupportedConstraints) + */ +export type InitConstraints = + | MediaStreamConstraints + | (( + supportedConstraints: MediaTrackSupportedConstraints, + ) => MediaStreamConstraints | Promise); + +/** + * Media track constraints specific to video tracks. + * + * Can be a `MediaTrackConstraints` object or a function that returns `MediaTrackConstraints`. The + * function is provided with an argument, `MediaTrackCapabilities`, which reprensents the media + * track capabilities, and should return either `MediaTrackConstraints` or a promise that resolves + * to it. + * + * Unlike `MediaTrackSupportedConstraints`, `MediaTrackCapabilities` provides the accurate + * information of the track capabilities supported by your device. + * + * - [`MediaTrackConstraints`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints) + * - [`MediaTrackCapabilities`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/getCapabilities#return_value) + */ +export type VideoConstraints = + | MediaTrackConstraints + | undefined + | (( + capabilities: MediaTrackCapabilities, + ) => + | MediaTrackConstraints + | undefined + | Promise); + +/** + * Media track constraints specific to audio tracks. + * + * Can be a `MediaTrackConstraints` object or a function that returns `MediaTrackConstraints`. The + * function is provided with an argument, `MediaTrackCapabilities`, which reprensents the media + * track capabilities, and should return either `MediaTrackConstraints` or a promise that resolves + * to it. + * + * Unlike `MediaTrackSupportedConstraints`, `MediaTrackCapabilities` provides the accurate + * information of the track capabilities supported by your device. + * + * - [`MediaTrackConstraints`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints) + * - [`MediaTrackCapabilities`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/getCapabilities#return_value) + */ +export type AudioConstraints = + | MediaTrackConstraints + | undefined + | (( + capabilities: MediaTrackCapabilities, + ) => + | MediaTrackConstraints + | undefined + | Promise); + +/** + * Details the result of a "start" action, including the options used, and the resulting media stream. + */ +interface StartActionResult { + type: "start"; + options: StartMediaStreamOptions; + stream: MediaStream; +} + +/** + * Details the result of an "inspect" action, including options used, the media stream inspected, + * and the capabilities of video and audio tracks. + */ +interface InspectActionResult { + type: "inspect"; + options: InspectMediaStreamOptions; + stream: MediaStream; + videoTracksCapabilities: MediaTrackCapabilities[]; + audioTracksCapabilities: MediaTrackCapabilities[]; +} + +/** + * Details the result of a "constrain" action, including the options used and the constrained media stream. + */ +interface ConstrainActionResult { + type: "constrain"; + options: ConstrainMediaStreamOptions; + stream: MediaStream; +} + +/** + * Represents the result of a "stop" action. + */ +interface StopActionResult { + type: "stop"; +} + +/** + * Union type representing the possible outcomes of actions performed on a media stream. + */ +type ActionResult = + | StartActionResult + | InspectActionResult + | ConstrainActionResult + | StopActionResult; + +/** + * Configuration options for the UserMediaStream object, including constraints for initialization, + * video, and audio tracks, a timeout for capability retrieval, and callbacks for stream lifecycle events. + */ +export interface UserMediaStreamOptions { + /** + * Media stream constraints for initialization. + */ + initConstraints?: InitConstraints; + /** + * Media track constraints specific to video tracks. + */ + videoConstraints?: VideoConstraints; + /** + * Media track constraints specific to audio tracks. + */ + audioConstraints?: AudioConstraints; + /** + * The default maximum time (in milliseconds) to wait for getting the capabilities of a media + * track. + */ + getCapabilitiesTimeout?: number; + /** + * Callback function that is triggered when the stream starts. + * + * @param stream - Media stream + */ + onStreamStart?: (stream: MediaStream) => unknown; + /** + * Callback function that is triggered when the stream stops. + */ + onStreamStop?: () => unknown; + /** + * Callback function that is triggered on each application of constraints. + * + * @param stream - Media stream + */ + onStreamUpdate?: (stream: MediaStream) => unknown; + /** + * Callback function that is triggered when the stream is inspected. + * + * @param streamCapablities - User media stream capabilities + */ + onStreamInspect?: ( + streamCapablities?: UserMediaStreamCapabilities, + ) => unknown; +} + +/** + * Represents the capabilities of video and audio tracks within a user media stream. + */ +export interface UserMediaStreamCapabilities { + videoTracksCapabilities: MediaTrackCapabilities[]; + audioTracksCapabilities: MediaTrackCapabilities[]; +} + +/** + * Interface for controlling and interacting with a user media stream, including starting, + * inspecting, stopping the stream, and updating configuration options. + */ +export interface UserMediaStream { + start: () => Promise; + inspect: () => Promise; + stop: () => Promise; + setOptions: (userMediaStreamOptions: UserMediaStreamOptions) => void; +} + +/** + * Resolved options for the UserMediaStream with default values provided. + */ +type ResolvedUserMediaStreamOptions = SetRequired< + UserMediaStreamOptions, + "initConstraints" | "getCapabilitiesTimeout" +>; + +/** + * Resolves and merges the provided UserMediaStreamOptions with default values for unspecified options. + * + * @param userMediaStreamOptions - The user-provided configuration options for the UserMediaStream. + * @returns The resolved configuration options with default values for unspecified options. + */ +function resolveUserMediaStreamOptions( + userMediaStreamOptions: UserMediaStreamOptions, +): ResolvedUserMediaStreamOptions { + return { + initConstraints: userMediaStreamOptions.initConstraints ?? { + video: true, + audio: false, + }, + videoConstraints: + "videoConstraints" in userMediaStreamOptions + ? userMediaStreamOptions.videoConstraints + : undefined, + audioConstraints: + "audioConstraints" in userMediaStreamOptions + ? userMediaStreamOptions.audioConstraints + : undefined, + getCapabilitiesTimeout: + userMediaStreamOptions.getCapabilitiesTimeout ?? GET_CAPABILITIES_TIMEOUT, + onStreamStart: + "onStreamStart" in userMediaStreamOptions + ? userMediaStreamOptions.onStreamStart + : undefined, + onStreamStop: + "onStreamStop" in userMediaStreamOptions + ? userMediaStreamOptions.onStreamStop + : undefined, + onStreamUpdate: + "onStreamUpdate" in userMediaStreamOptions + ? userMediaStreamOptions.onStreamUpdate + : undefined, + onStreamInspect: + "onStreamInspect" in userMediaStreamOptions + ? userMediaStreamOptions.onStreamInspect + : undefined, + }; +} + +/** + * Creates and returns a UserMediaStream object with the specified configuration options. + * Provides methods to start, inspect, and stop the media stream, as well as to update its options. + * + * @param userMediaStreamOptions - Optional configuration options for the UserMediaStream. + * @returns An object providing control methods for managing a user media stream. + */ +export function createUserMediaStream( + userMediaStreamOptions: UserMediaStreamOptions = {}, +): UserMediaStream { + const { + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + onStreamStart, + onStreamStop, + onStreamUpdate, + onStreamInspect, + } = resolveUserMediaStreamOptions(userMediaStreamOptions); + + // initialize a queue + let queue: Promise = Promise.resolve({ + type: "stop", + }); + + // queueify start + const queuefiedStart = async ( + startMediaStreamOptions: StartMediaStreamOptions, + ) => { + // update the queue + queue = queue.then((prevActionResult) => { + switch (prevActionResult.type) { + // previous action is a start action + case "start": { + // if options are the same, reuse the previous action result + if (compare(prevActionResult.options, startMediaStreamOptions)) { + return prevActionResult; + } + // otherwise stop and then start + return stopMediaStream(prevActionResult.stream).then(() => + startMediaStream(startMediaStreamOptions), + ); + } + // previous action is an inspect action + case "inspect": { + // stop and then start + return stopMediaStream(prevActionResult.stream).then(() => + startMediaStream(startMediaStreamOptions), + ); + } + // previous action is a constrain action + case "constrain": { + // stop and then start + return stopMediaStream(prevActionResult.stream).then(() => + startMediaStream(startMediaStreamOptions), + ); + } + // previous action is a stop action + case "stop": { + // just start + return startMediaStream(startMediaStreamOptions); + } + // unknown action + default: { + throw new TypeError( + `Unknown action result: ${prevActionResult satisfies never}`, + ); + } + } + }); + // execute the queue and get the result + const actionResult = (await queue) as StartActionResult; + // return the stream + return actionResult.stream; + }; + + // queueify inspect + const queuefiedInspect = async ( + inspectMediaStreamOptions: InspectMediaStreamOptions, + ) => { + // update the queue + queue = queue.then((prevActionResult) => { + switch (prevActionResult.type) { + // previous action is a start action + case "start": { + // just inspect + return inspectMediaStream( + prevActionResult.stream, + inspectMediaStreamOptions, + ); + } + // previous action is an inspect action + case "inspect": { + // if options are the same, reuse the previous action result + if (compare(prevActionResult.options, inspectMediaStreamOptions)) { + return prevActionResult; + } + // otherwise just inspect + return inspectMediaStream( + prevActionResult.stream, + inspectMediaStreamOptions, + ); + } + // previous action is a constrain action + case "constrain": { + // just inspect + return inspectMediaStream( + prevActionResult.stream, + inspectMediaStreamOptions, + ); + } + // previous action is a stop action + case "stop": { + // we cannot inspect a stopped stream + // so do nothing and reuse the previous action + return prevActionResult; + } + // unknown action + default: { + throw new TypeError( + `Unknown action result: ${prevActionResult satisfies never}`, + ); + } + } + }); + // execute the queue and get the result + const actionResult = await queue; + if (actionResult.type === "inspect") { + // return the stream if it is not stopped + return { + videoTracksCapabilities: actionResult.videoTracksCapabilities, + audioTracksCapabilities: actionResult.audioTracksCapabilities, + }; + } + }; + + // queueify constrain + const queuefiedConstrain = async ( + constrainMediaStreamOptions: ConstrainMediaStreamOptions, + ) => { + // update the queue + queue = queue.then((prevActionResult) => { + switch (prevActionResult.type) { + // previous action is a start action + case "start": { + // just apply constraints + return constrainMediaStream( + prevActionResult.stream, + constrainMediaStreamOptions, + ); + } + // previous action is an inspect action + case "inspect": { + // just apply constraints + return constrainMediaStream( + prevActionResult.stream, + constrainMediaStreamOptions, + ); + } + // previous action is a constrain action + case "constrain": { + // if options are the same, reuse the previous action result + if (compare(prevActionResult.options, constrainMediaStreamOptions)) { + return prevActionResult; + } + // otherwise just apply constraints + return constrainMediaStream( + prevActionResult.stream, + constrainMediaStreamOptions, + ); + } + // previous action is a stop action + case "stop": { + // we cannot apply constraints to a stopped stream + // so do nothing and reuse the previous action + return prevActionResult; + } + // unknown action + default: { + throw new TypeError( + `Unknown action result: ${prevActionResult satisfies never}`, + ); + } + } + }); + // execute the queue and get the result + const actionResult = await queue; + if (actionResult.type === "constrain") { + // return the stream if it is not stopped + return actionResult.stream; + } + }; + + // queueify stop + const queuefiedStop = async () => { + queue = queue.then((prevActionResult) => { + switch (prevActionResult.type) { + // previous action is a start action + case "start": { + // just stop the stream + return stopMediaStream(prevActionResult.stream); + } + // previous action is an inspect action + case "inspect": { + // just stop the stream + return stopMediaStream(prevActionResult.stream); + } + // previous action is a constrain action + case "constrain": { + // just stop the stream + return stopMediaStream(prevActionResult.stream); + } + // previous action is a stop action + case "stop": { + // reuse the previous action + return prevActionResult; + } + // unknown action + default: { + throw new TypeError( + `Unknown action result: ${prevActionResult satisfies never}`, + ); + } + } + }); + // execute the queue + await queue; + }; + + // create a state store + const userMediaStreamStore = createStore()( + subscribeWithSelector(() => ({ + initConstraints, + videoConstraints, + audioConstraints, + + getCapabilitiesTimeout, + + onStreamStart, + onStreamStop, + onStreamUpdate, + onStreamInspect, + })), + ); + + // invoke constrain when constraints update + userMediaStreamStore.subscribe( + (options) => [options.videoConstraints, options.audioConstraints] as const, + async ([videoConstraints, audioConstraints]) => { + const { getCapabilitiesTimeout, onStreamUpdate } = + userMediaStreamStore.getState(); + const stream = await queuefiedConstrain({ + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + }); + stream && onStreamUpdate?.(stream); + return stream; + }, + { equalityFn: shallow }, + ); + + // the exposed control function to start a stream + const start = async () => { + const { + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + onStreamStart, + } = userMediaStreamStore.getState(); + const stream = await queuefiedStart({ + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + }); + onStreamStart?.(stream); + return stream; + }; + + // the exposed inspect function to inspect a stream + const inspect = async () => { + const { getCapabilitiesTimeout, onStreamInspect } = + userMediaStreamStore.getState(); + const streamCapablities = await queuefiedInspect({ + getCapabilitiesTimeout, + }); + onStreamInspect?.(streamCapablities ? { ...streamCapablities } : undefined); + return streamCapablities; + }; + + // the exposed control function to stop a stream + const stop = async () => { + const { onStreamStop } = userMediaStreamStore.getState(); + await queuefiedStop(); + onStreamStop?.(); + }; + + // return the exposed control functions + return { + start, + inspect, + stop, + setOptions: (userMediaStreamOptions) => + userMediaStreamStore.setState( + resolveUserMediaStreamOptions(userMediaStreamOptions), + ), + }; +} + +interface StartMediaStreamOptions { + initConstraints: InitConstraints; + videoConstraints?: VideoConstraints; + audioConstraints?: AudioConstraints; + getCapabilitiesTimeout: number; +} + +/** + * Start a media stream with specified constraints. + * + * This function does some checks mainly for compatibility. + * + * @param startMediaStreamOptions - The constraints (initial, video, and audio) to apply along with + * meta configuration options. + * @returns A promise that resolves to an StartActionResult for later queueing. + */ +async function startMediaStream({ + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, +}: StartMediaStreamOptions): Promise { + // check if we are in the secure context + if (window.isSecureContext !== true) { + throw new DOMException( + "Cannot use navigator.mediaDevices in insecure contexts. Please use HTTPS or localhost.", + "NotAllowedError", + ); + } + + // check if we can use the getUserMedia API + if (navigator.mediaDevices?.getUserMedia === undefined) { + throw new DOMException( + "The method navigator.mediaDevices.getUserMedia is not defined. Does your runtime support this API?", + "NotSupportedError", + ); + } + + // shim WebRTC APIs in the client runtime + await shimGetUserMedia(); + + // resolve initial constraints + const resolvedInitConstraints = + typeof initConstraints === "function" + ? // callback constraints + await initConstraints(navigator.mediaDevices.getSupportedConstraints()) + : initConstraints; + + // apply initial constraints and get the media stream + const stream = await navigator.mediaDevices.getUserMedia( + resolvedInitConstraints, + ); + + // apply video and audio constraints + await constrainMediaStream(stream, { + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + }); + + return { + type: "start", + options: { + initConstraints, + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + }, + stream: stream, + }; +} + +interface ConstrainMediaStreamOptions { + videoConstraints?: VideoConstraints; + audioConstraints?: AudioConstraints; + getCapabilitiesTimeout: number; +} + +/** + * Apply video and audio constraints to a given media stream. + * + * This function processes both video and audio tracks of the media stream, applying the specified + * constraints to each kind of tracks. + * + * @param stream - The MediaStream to which constraints will be applied. + * @param constrainMediaStreamOptions - The video and audio constraints to apply along with meta + * configuration options. + * @returns A promise that resolves to a ConstrainActionResult for later queueing. + */ +async function constrainMediaStream( + stream: MediaStream, + { + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + }: ConstrainMediaStreamOptions, +): Promise { + // get video tracks + const videoTracks = stream.getVideoTracks(); + + // get audio tracks + const audioTracks = stream.getAudioTracks(); + + // apply media track constraints + await Promise.all([ + // apply video constraints + Promise.all( + videoTracks.map(async (videoTrack) => { + const resolvedVideoConstraints = + typeof videoConstraints === "function" + ? // callback constraints + await videoConstraints( + await getCapabilities(videoTrack, getCapabilitiesTimeout), + ) + : videoConstraints; + await videoTrack.applyConstraints(resolvedVideoConstraints); + }), + ), + // apply audio constraints + Promise.all( + audioTracks.map(async (audioTrack) => { + const resolvedAudioConstraints = + typeof audioConstraints === "function" + ? // callback constraints + await audioConstraints( + await getCapabilities(audioTrack, getCapabilitiesTimeout), + ) + : audioConstraints; + await audioTrack.applyConstraints(resolvedAudioConstraints); + }), + ), + ]); + + return { + type: "constrain", + options: { + videoConstraints, + audioConstraints, + getCapabilitiesTimeout, + }, + stream, + }; +} + +interface InspectMediaStreamOptions { + getCapabilitiesTimeout: number; +} + +async function inspectMediaStream( + stream: MediaStream, + { getCapabilitiesTimeout }: InspectMediaStreamOptions, +): Promise { + // get video tracks + const videoTracks = stream.getVideoTracks(); + + // get audio tracks + const audioTracks = stream.getAudioTracks(); + + const [videoTracksCapabilities, audioTracksCapabilities] = await Promise.all([ + Promise.all( + videoTracks.map(async (videoTrack) => + getCapabilities(videoTrack, getCapabilitiesTimeout), + ), + ), + Promise.all( + audioTracks.map(async (audioTrack) => + getCapabilities(audioTrack, getCapabilitiesTimeout), + ), + ), + ]); + + return { + type: "inspect", + options: { + getCapabilitiesTimeout, + }, + stream, + videoTracksCapabilities, + audioTracksCapabilities, + }; +} + +/** + * Stop a given media stream. + * + * This function stops all tracks of the given media stream. It ensures that the media stream is + * properly terminated. + * + * @param stream - The MediaStream to be stopped. + * @returns A promise that resolves to a StopActionResult for later queueing. + */ +async function stopMediaStream(stream: MediaStream): Promise { + for (const track of stream.getTracks()) { + stream.removeTrack(track); + track.stop(); + } + + return { + type: "stop", + }; +} + +/** + * Attach a given media stream to a HTMLVideoElement. + * + * This function sets the source of the video element to the provided media stream. It supports + * different ways of attaching the stream based on browser compatibility. + * + * @param videoElement - The HTMLVideoElement to which the media stream will be attached. + * @param stream - The MediaStream to be attached to the video element. + */ +export function attachMediaStream( + videoElement: HTMLVideoElement, + stream: MediaStream, +) { + // attach the stream to the video element + if (videoElement.srcObject !== undefined) { + videoElement.srcObject = stream; + } else if (videoElement.mozSrcObject !== undefined) { + videoElement.mozSrcObject = stream; + } else if (window.URL.createObjectURL) { + videoElement.src = (window.URL.createObjectURL as CreateObjectURLCompat)( + stream, + ); + } else if (window.webkitURL) { + videoElement.src = ( + window.webkitURL.createObjectURL as CreateObjectURLCompat + )(stream); + } else { + videoElement.src = stream.id; + } +} + +/** + * Asynchronously retrieve the capabilities of a given media stream track. + * + * This function attempts to fetch the capabilities of a MediaStreamTrack. If called too early, it + * may return an empty object as [the capabilities might not be available + * immediately](https://oberhofer.co/mediastreamtrack-and-its-capabilities/#queryingcapabilities). + * Because capabilities are allowed to be an empty object, a timeout mechanism is implemented to + * ensure the function returns even if capabilities are not obtained within the specified time, + * preventing indefinite waiting. + * + * @param track - The MediaStreamTrack whose capabilities are to be fetched. + * @param timeout - The maximum time (in milliseconds) to wait for fetching capabilities. + * @returns A promise that resolves to the track's capabilities, which may be an empty object. + */ +export async function getCapabilities( + track: MediaStreamTrack, + timeout = GET_CAPABILITIES_TIMEOUT, +): Promise { + return new Promise((resolve) => { + // timeout, return empty capabilities + let timeoutId: number | undefined = setTimeout(() => { + resolve({}); + timeoutId = undefined; + return; + }, timeout); + + // not supported, return empty capabilities + if (!track.getCapabilities) { + clearTimeout(timeoutId); + resolve({}); + timeoutId = undefined; + return; + } + + // poll to check capabilities + let capabilities: MediaTrackCapabilities = {}; + while (Object.keys(capabilities).length === 0 && timeoutId !== undefined) { + capabilities = track.getCapabilities(); + } + clearTimeout(timeoutId); + resolve(capabilities); + timeoutId = undefined; + return; + }); +} diff --git a/src/stream/webrtc-adapter.d.ts b/src/stream/webrtc-adapter.d.ts new file mode 100644 index 00000000..ad4f11e3 --- /dev/null +++ b/src/stream/webrtc-adapter.d.ts @@ -0,0 +1,30 @@ +/** + * This file declares types for the non-public exports from "webrtc-adapter". As we're only + * interested in the getUserMedia shims, we use this approach to avoid importing the whole library. + * + * This type declaration file is only meant to be used internally. + */ + +declare module "webrtc-adapter/dist/chrome/getusermedia" { + function shimGetUserMedia( + window: Window, + browserDetails: import("webrtc-adapter").IAdapter["browserDetails"], + ): void; +} + +declare module "webrtc-adapter/dist/firefox/getusermedia" { + function shimGetUserMedia( + window: Window, + browserDetails: import("webrtc-adapter").IAdapter["browserDetails"], + ): void; +} + +declare module "webrtc-adapter/dist/safari/safari_shim" { + function shimGetUserMedia(window: Window): void; +} + +declare module "webrtc-adapter/dist/utils" { + function detectBrowser( + window: Window, + ): import("webrtc-adapter").IAdapter["browserDetails"]; +} diff --git a/src/writer/index.ts b/src/writer/index.ts index 0d43db84..1b8b1955 100644 --- a/src/writer/index.ts +++ b/src/writer/index.ts @@ -1,6 +1,6 @@ /** - * The writer part API of this package is subject to change a lot. - * Please track the status of [this issue](https://github.com/zxing-cpp/zxing-cpp/issues/332). + * The writer part API of this package is subject to change a lot. Please track the status of [this + * issue](https://github.com/zxing-cpp/zxing-cpp/issues/332). * * @packageDocumentation */ @@ -45,6 +45,6 @@ export async function writeBarcodeToImageFile( export * from "../bindings/exposedWriterBindings.js"; export { purgeZXingModule, - type ZXingWriterModule, type ZXingModuleOverrides, + type ZXingWriterModule, } from "../core.js"; diff --git a/tsconfig.base.json b/tsconfig.base.json index f1a2ef8f..ffaea4a9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,6 +1,8 @@ { "compilerOptions": { "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", "useDefineForClassFields": true, "skipLibCheck": true, "resolveJsonModule": true, diff --git a/tsconfig.json b/tsconfig.json index 07dcdbbb..05b705a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,12 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "module": "NodeNext", + "jsx": "react-jsx", "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["emscripten"], - "moduleResolution": "NodeNext" + "noUncheckedIndexedAccess": true }, - "include": ["./src"], + "include": ["./src", "./main.ts", "./main.tsx"], "references": [ { "path": "./tsconfig.node.json" diff --git a/tsconfig.node.json b/tsconfig.node.json index ac17999f..80324b61 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,9 +2,7 @@ "extends": "./tsconfig.base.json", "compilerOptions": { "composite": true, - "module": "ESNext", "types": ["node"], - "moduleResolution": "Bundler", "resolveJsonModule": true, "allowJs": true }, diff --git a/tsconfig.production.json b/tsconfig.production.json new file mode 100644 index 00000000..a16641f0 --- /dev/null +++ b/tsconfig.production.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src"], + "ignore": ["./src/index.css.ts"] +} diff --git a/typedoc.json b/typedoc.json index dddddcdd..9d9a3f37 100644 --- a/typedoc.json +++ b/typedoc.json @@ -3,7 +3,11 @@ "entryPoints": [ "./src/full/index.ts", "./src/reader/index.ts", - "./src/writer/index.ts" + "./src/writer/index.ts", + "./src/scanner/index.ts", + "./src/stream/index.ts", + "./src/react/hooks/index.ts" ], + "sortEntryPoints": false, "out": "docs" } diff --git a/vite.config.ts b/vite.config.ts index 5a60786d..f69c242c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; import { defineConfig } from "vite"; import babel from "vite-plugin-babel"; import { version } from "./package.json"; @@ -11,15 +12,36 @@ export default defineConfig({ "reader/index": "src/reader/index.ts", "writer/index": "src/writer/index.ts", "full/index": "src/full/index.ts", + "scanner/index": "src/scanner/index.ts", + "stream/index": "src/stream/index.ts", + "react/index": "src/react/index.ts", + "react/hooks/index": "src/react/hooks/index.ts", + "react/components/index": "src/react/components/index.ts", }, formats: ["es"], fileName: (_, entryName) => `${entryName}.js`, }, outDir: "dist/es", rollupOptions: { + external: ["react"], output: { chunkFileNames: "[name]-[hash].js", manualChunks: (id) => { + if (/webrtc-adapter\/dist\/utils/.test(id)) { + return "shim-utils"; + } + if (/webrtc-adapter\/dist\/chrome/.test(id)) { + return "shim-chrome"; + } + if (/webrtc-adapter\/dist\/firefox/.test(id)) { + return "shim-firefox"; + } + if (/webrtc-adapter\/dist\/safari/.test(id)) { + return "shim-safari"; + } + if (/zustand/.test(id)) { + return "zustand"; + } if ( /core\.ts|exposedReaderBindings\.ts|exposedWriterBindings\.ts/.test( id, @@ -27,11 +49,34 @@ export default defineConfig({ ) { return "core"; } + if (/src\/reader\/index\.ts/.test(id)) { + return "reader"; + } + if (/src\/writer\/index\.ts/.test(id)) { + return "writer"; + } + if (/src\/full\/index\.ts/.test(id)) { + return "full"; + } + if (/src\/scanner\/index\.ts/.test(id)) { + return "scanner"; + } + if (/src\/stream\/index\.ts/.test(id)) { + return "stream"; + } + if (/src\/react\/hooks\/index\.ts/.test(id)) { + return "react-hooks"; + } + if (/src\/react\/components\/index\.ts/.test(id)) { + return "react-components"; + } }, + chunkFileNames: "[name]-[hash].js", }, }, }, plugins: [ + vanillaExtractPlugin(), babel({ babelConfig: { plugins: [emscriptenPatch()], @@ -42,5 +87,6 @@ export default defineConfig({ ], define: { NPM_PACKAGE_VERSION: JSON.stringify(version), + "process.env.NODE_ENV": JSON.stringify("production"), }, });