├── .changeset
├── README.md
└── config.json
├── .github
└── workflows
│ ├── benchmark.yml
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── .husky
├── commit-msg
├── pre-commit
└── pre-push
├── .lintstagedrc
├── .npmrc
├── .prettierrc
├── .vscode
├── settings.json
└── tailwind.json
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── apps
└── web
│ ├── .eslintrc.js
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── app
│ ├── benchmark
│ │ └── page.tsx
│ ├── components
│ │ ├── button.tsx
│ │ ├── dialog.tsx
│ │ ├── input-group.tsx
│ │ ├── navbar.tsx
│ │ ├── number-input.tsx
│ │ ├── select-input.tsx
│ │ ├── spinner.tsx
│ │ └── support.tsx
│ ├── content.ts
│ ├── globals.css
│ ├── hero.tsx
│ ├── layout.tsx
│ ├── method-buttons.tsx
│ ├── page.tsx
│ └── writer
│ │ ├── components
│ │ ├── configuration-form.tsx
│ │ ├── editor.tsx
│ │ ├── filetypes.ts
│ │ ├── generate.tsx
│ │ ├── github-button.tsx
│ │ ├── output.tsx
│ │ └── playground.tsx
│ │ ├── hero.tsx
│ │ └── page.tsx
│ ├── next-env.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── browserconfig.xml
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon.ico
│ ├── logo.svg
│ ├── logotype.svg
│ ├── mstile-144x144.png
│ ├── mstile-150x150.png
│ ├── mstile-310x150.png
│ ├── mstile-310x310.png
│ ├── mstile-70x70.png
│ ├── safari-pinned-tab.svg
│ └── site.webmanifest
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── commitlint.config.js
├── docs
└── flipbook-qr.gif
├── package.json
├── packages
├── e2e
│ ├── README.md
│ ├── package.json
│ ├── playwright.config.ts
│ ├── results
│ │ ├── reader-bench-10k.json
│ │ ├── reader-bench-1k.json
│ │ ├── reader-bench-hundred.json
│ │ ├── writer-bench-10k.json
│ │ ├── writer-bench-1k.json
│ │ └── writer-bench-hundred.json
│ ├── scripts
│ │ └── add-benchmarks-to-markdown.js
│ ├── src
│ │ ├── benchmark.spec.ts
│ │ └── helpers.ts
│ └── tsconfig.json
├── eslint-config
│ ├── README.md
│ ├── library.js
│ ├── next.js
│ ├── package.json
│ └── react-internal.js
├── jest-presets
│ ├── base.js
│ ├── browser
│ │ └── jest-preset.js
│ ├── node
│ │ └── jest-preset.js
│ └── package.json
├── reader
│ ├── .eslintignore
│ ├── .eslintrc.js
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── helpers.test.ts
│ │ ├── helpers.ts
│ │ ├── index.ts
│ │ ├── processors
│ │ │ ├── file-processor.test.ts
│ │ │ ├── file-processor.ts
│ │ │ ├── frame-processor.test.ts
│ │ │ ├── frame-processor.ts
│ │ │ ├── index.ts
│ │ │ ├── webrtc-processor.test.ts
│ │ │ └── webrtc-processor.ts
│ │ ├── reader.test.ts
│ │ └── reader.ts
│ └── tsconfig.json
├── shared
│ ├── .eslintignore
│ ├── .eslintrc.js
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── src
│ │ ├── flipbook.test.ts
│ │ ├── flipbook.ts
│ │ ├── index.ts
│ │ ├── utils.test.ts
│ │ └── utils.ts
│ └── tsconfig.json
├── typescript-config
│ ├── base.json
│ ├── nextjs.json
│ ├── package.json
│ └── react-library.json
└── writer
│ ├── .eslintignore
│ ├── .eslintrc.js
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src
│ ├── index.ts
│ ├── writer.test.ts
│ └── writer.ts
│ └── tsconfig.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "restricted",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": []
11 | }
12 |
--------------------------------------------------------------------------------
/.github/workflows/benchmark.yml:
--------------------------------------------------------------------------------
1 | name: Benchmark
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | test_setup:
10 | name: Test setup
11 | runs-on: ubuntu-latest
12 | outputs:
13 | preview_url: ${{ steps.waitForVercelPreviewDeployment.outputs.url }}
14 | steps:
15 | - name: Wait for Vercel preview deployment to be ready
16 | uses: patrickedqvist/wait-for-vercel-preview@v1.3.1
17 | id: waitForVercelPreviewDeployment
18 | with:
19 | token: ${{ secrets.GITHUB_TOKEN }}
20 | max_timeout: 600
21 | benchmark:
22 | name: Benchmark
23 | needs: test_setup
24 | timeout-minutes: 10
25 | runs-on: ubuntu-latest
26 | container:
27 | image: mcr.microsoft.com/playwright:v1.42.0-jammy
28 | steps:
29 | - uses: actions/checkout@v4
30 |
31 | - uses: pnpm/action-setup@v2
32 | with:
33 | version: 8
34 |
35 | - name: 👷♂️ Use Node.js
36 | uses: actions/setup-node@v3
37 | with:
38 | node-version: '20.x'
39 | cache: 'pnpm'
40 |
41 | - name: 🏗️ Install dependencies
42 | run: pnpm install
43 |
44 | - name: 📊 Run benchmark
45 | working-directory: packages/e2e
46 | run: pnpm benchmark
47 | env:
48 | PLAYWRIGHT_TEST_BASE_URL: ${{ needs.test_setup.outputs.preview_url }}
49 |
50 | - name: 📄 Upload benchmark results
51 | uses: actions/upload-artifact@v4
52 | with:
53 | name: benchmark-results
54 | path: packages/e2e/results
55 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Build, Lint, and Test
2 |
3 | on: [push]
4 |
5 | jobs:
6 | run:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 |
11 | - uses: pnpm/action-setup@v2
12 | with:
13 | version: 8
14 |
15 | - name: Use Node.js
16 | uses: actions/setup-node@v3
17 | with:
18 | node-version: '20.x'
19 | cache: 'pnpm'
20 |
21 | - run: pnpm install
22 | - run: pnpm run build
23 | - run: pnpm run lint
24 | - run: pnpm test
25 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | concurrency: ${{ github.workflow }}-${{ github.ref }}
9 |
10 | jobs:
11 | release:
12 | name: Release
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v4
16 |
17 | - uses: pnpm/action-setup@v2
18 | with:
19 | version: 8
20 |
21 | - name: Use Node.js
22 | uses: actions/setup-node@v3
23 | with:
24 | node-version: '20.x'
25 | cache: 'pnpm'
26 |
27 | - run: pnpm install
28 | - run: pnpm run build
29 |
30 | - name: Create Release Pull Request
31 | uses: changesets/action@v1
32 | with:
33 | commit: 'chore: release'
34 | env:
35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 | .pnp
6 | .pnp.js
7 |
8 | # testing
9 | coverage
10 |
11 | # next.js
12 | .next/
13 | out/
14 | build
15 |
16 | # misc
17 | .DS_Store
18 | *.pem
19 |
20 | # debug
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # local env files
26 | .env
27 | .env.local
28 | .env.development.local
29 | .env.test.local
30 | .env.production.local
31 |
32 | # turbo
33 | .turbo
34 |
35 | # vercel
36 | .vercel
37 |
38 | # playwright
39 | test-results/
40 |
41 | # benchmarks
42 | benchmarks.md
43 |
44 | # builds
45 | dist
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | pnpm commitlint --edit $1
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | pnpm pre:commit
5 |
--------------------------------------------------------------------------------
/.husky/pre-push:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | pnpm format && pnpm lint
5 |
--------------------------------------------------------------------------------
/.lintstagedrc:
--------------------------------------------------------------------------------
1 | {
2 | "*.ts": ["pnpm run format:fix --"],
3 | "*.tsx": ["pnpm run format:fix --"],
4 | "*.json": ["pnpm run format:fix --"]
5 | }
6 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | auto-install-peers = true
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "endOfLine": "lf",
4 | "semi": true,
5 | "trailingComma": "es5"
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.workingDirectories": [
3 | {
4 | "mode": "auto"
5 | }
6 | ],
7 | "css.customData": [".vscode/tailwind.json"],
8 | "files.autoSave": "off"
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/tailwind.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1.1,
3 | "atDirectives": [
4 | {
5 | "name": "@tailwind",
6 | "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
7 | "references": [
8 | {
9 | "name": "Tailwind Documentation",
10 | "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
11 | }
12 | ]
13 | },
14 | {
15 | "name": "@apply",
16 | "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
17 | "references": [
18 | {
19 | "name": "Tailwind Documentation",
20 | "url": "https://tailwindcss.com/docs/functions-and-directives#apply"
21 | }
22 | ]
23 | },
24 | {
25 | "name": "@responsive",
26 | "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
27 | "references": [
28 | {
29 | "name": "Tailwind Documentation",
30 | "url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
31 | }
32 | ]
33 | },
34 | {
35 | "name": "@screen",
36 | "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
37 | "references": [
38 | {
39 | "name": "Tailwind Documentation",
40 | "url": "https://tailwindcss.com/docs/functions-and-directives#screen"
41 | }
42 | ]
43 | },
44 | {
45 | "name": "@variants",
46 | "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
47 | "references": [
48 | {
49 | "name": "Tailwind Documentation",
50 | "url": "https://tailwindcss.com/docs/functions-and-directives#variants"
51 | }
52 | ]
53 | }
54 | ]
55 | }
56 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | We welcome contributions from the community and are happy to have them! The top priorities for contribution at the moment are as follows:
4 |
5 | - **Rust Bindings:** We'd like to have Rust bindings for both the writer and reader libraries so that they may be used in native mobile applications.
6 |
7 | And in general, we're always looking for help with:
8 |
9 | - **Documentation:** We'd like to have more comprehensive documentation for both the writer and reader libraries.
10 | - **Testing:** We'd like to have more comprehensive testing for both the writer and reader libraries.
11 | - **Performance:** We'd like to have more performant implementations of both the writer and reader libraries.
12 | - **Bug Fixes:** If you find a bug, please feel free to open an issue or a pull request.
13 |
14 | ## Getting Started
15 |
16 | Everything is written in Typescript and installed/run via PNPM. If you don't have PNPM installed, you can install it via NPM:
17 |
18 | ```bash
19 | npm install -g pnpm
20 | ```
21 |
22 | Once you have PNPM installed, you can install the dependencies for the entire project by running:
23 |
24 | ```bash
25 | pnpm install
26 | ```
27 |
28 | From there, you'll have access to the following commands:
29 |
30 | - `pnpm dev`: Starts the development server for the web app, which is very handy for visually testing the reader and writer libraries.
31 | - `pnpm build`: Builds all of the packages in the project.
32 | - `pnpm test`: Runs the tests for all of the packages in the project.
33 | - `pnpm test:watch`: Runs the tests for all of the packages in the project in watch mode.
34 | - `pnpm lint`: Lints all of the packages in the project.
35 | - `pnpm benchmark`: Runs the benchmarks for all of the packages in the project.
36 |
37 | ## Making Contributions
38 |
39 | All contributions must be formatted using Prettier and pass linting in order to be committed. All commits must follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard. This is enforced via a pre-commit hook, so you don't have to worry about it.
40 |
41 | ## Rules of Engagement
42 |
43 | - **Be nice:** We're all here to help each other. Let's be kind to one another.
44 | - **Be patient:** We're all busy people. Sometimes it takes a while for someone to get back to you. This is free, open-source software. We're all doing this in our spare time - so have a little patience.
45 | - **Be helpful:** We're all at different stages in our careers. If you see someone struggling, offer them a hand. If you're struggling, ask for help.
46 | - **Take matters into your own hands:** If you see something that needs doing or a bug that needs patching, try to do it yourself.
47 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Patrick Cason
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Flipbook
5 |
6 |
7 | [](https://github.com/cereallarceny/flipbook/actions/workflows/ci.yml)
8 | [](https://npmjs.com/package/@flipbookqr/writer) | **Writer:** [](https://npmjs.com/package/@flipbookqr/writer) [](https://packagephobia.com/result?p=@flipbookqr/writer@latest) | **Reader:** [](https://npmjs.com/package/@flipbookqr/reader) [](https://packagephobia.com/result?p=@flipbookqr/reader@latest)
9 |
10 | ## Getting Started
11 |
12 | Flipbook is a series of libraries that you can use in any web, mobile, or desktop application that enable the writing and reading of QR codes that contain larger payloads than traditional QR codes. This is done by creating a series of QR codes that are stitched together into an animated GIF, called a "Flipbook". This Flipbook can then be scanned by the reader library and subsequently reassembled into the original payload.
13 |
14 | [View a CodeSandbox example](https://codesandbox.io/p/sandbox/n6hrwl)
15 |
16 | 
17 |
18 | **[Download Reader](https://flipbook.codes)**
19 |
20 | ### Why?
21 |
22 | [](https://www.youtube.com/watch?v=D4QD9DaISEs)
23 |
24 | The ubiquity of QR codes in daily life has made them a popular tool for sharing information. But the medium is inherently limited to payloads of small sizes. While larger payloads can be supported (to a point), the resulting QR code becomes too difficult to scan reliably.
25 |
26 | ### Are there any size limitations?
27 |
28 | In theory, no. It would simply be a matter of how long it takes for the writer to encode the payload into a Flipbook, and how long it takes for the reader to decode the Flipbook back into the original payload.
29 |
30 | ### What can a Flipbook contain?
31 |
32 | Anything! Books... movies... music... software... anything that can be represented as a series of bytes can be encoded into a Flipbook.
33 |
34 | ## Libraries
35 |
36 | - Writer (Typescript): [Documentation](./packages/writer) | [NPM Package](https://www.npmjs.com/package/@flipbookqr/writer)
37 | - Reader (Typescript): [Documentation](./packages/reader) | [NPM Package](https://www.npmjs.com/package/@flipbookqr/reader)
38 | - Writer (Rust): *Coming soon...*
39 | - Reader (Rust): *Coming soon...*
40 |
41 | ### Want to write a Flipbook binding?
42 |
43 | If you want to write a Flipbook binding for a language that isn't listed here, feel free to open an issue or a pull request. We'd love to see Flipbook supported in as many languages as possible!
44 |
45 | ## Contributing
46 |
47 | If you'd like to contribute to Flipbook, please read our [contributing guide](./CONTRIBUTING.md) to learn how to get started.
48 |
49 | ### Releasing
50 |
51 | To release a new version of Flipbook, do the following:
52 |
53 | 1. Do your work on your own branch, and open a pull request to `main` when you're ready.
54 | 1. On this PR, make sure you have run `pnpm changeset` to generate a new changeset.
55 | 2. Once the PR is merged, it will create a new PR to version all changes and all changesets. The owner(s) can review this PR and merge it.
56 | 3. Once the second PR is merged, owner(s) can run (locally, from the `main` branch):
57 | 1. `pnpm release`
58 | 2. `git push --follow-tags`
59 | 4. [On Github](https://github.com/cereallarceny/flipbook/releases/new), owner(s) can create a release for each package using the pushed tags
60 |
61 | ## License
62 |
63 | Flipbook is licensed under the [MIT License](./LICENSE). Go nuts!
64 |
--------------------------------------------------------------------------------
/apps/web/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | module.exports = {
3 | root: true,
4 | extends: ["@repo/eslint-config/next.js"],
5 | parser: "@typescript-eslint/parser",
6 | parserOptions: {
7 | project: true,
8 | },
9 | };
--------------------------------------------------------------------------------
/apps/web/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # web
2 |
3 | ## 0.1.1
4 |
5 | ### Patch Changes
6 |
7 | - 26b793d: Adding back types to libraries for import
8 | - Updated dependencies [26b793d]
9 | - @flipbookqr/reader@0.2.1
10 | - @flipbookqr/shared@0.2.1
11 | - @flipbookqr/writer@0.2.1
12 |
13 | ## 0.1.0
14 |
15 | ### Minor Changes
16 |
17 | - 2d0b8b1: Dramatically improving performance, browser support, and bundle size
18 |
19 | ### Patch Changes
20 |
21 | - Updated dependencies [2d0b8b1]
22 | - @flipbookqr/reader@0.2.0
23 | - @flipbookqr/shared@0.2.0
24 | - @flipbookqr/writer@0.2.0
25 |
26 | ## 0.0.5
27 |
28 | ### Patch Changes
29 |
30 | - e7642eb: Redid internal infrastructure of repo
31 | - Updated dependencies [e7642eb]
32 | - @flipbookqr/reader@0.1.5
33 | - @flipbookqr/shared@0.1.5
34 | - @flipbookqr/writer@0.1.5
35 |
36 | ## 0.0.4
37 |
38 | ### Patch Changes
39 |
40 | - 3a25452: Allow for camera selection and potentially fixing mobile issues
41 | - Updated dependencies [3a25452]
42 | - @flipbookqr/reader@0.1.4
43 | - @flipbookqr/writer@0.1.4
44 | - @flipbookqr/shared@0.1.4
45 |
46 | ## 0.0.3
47 |
48 | ### Patch Changes
49 |
50 | - 88468e3: Adding video and fixing readmes
51 | - Updated dependencies [88468e3]
52 | - @flipbookqr/reader@0.1.3
53 | - @flipbookqr/writer@0.1.3
54 |
55 | ## 0.0.2
56 |
57 | ### Patch Changes
58 |
59 | - Updated dependencies [6c93146]
60 | - @flipbookqr/reader@0.1.2
61 | - @flipbookqr/writer@0.1.2
62 |
63 | ## 0.0.1
64 |
65 | ### Patch Changes
66 |
67 | - Updated dependencies [33db75b]
68 | - @flipbookqr/reader@0.1.1
69 | - @flipbookqr/writer@0.1.1
70 |
71 | ## 1.0.1
72 |
73 | ### Patch Changes
74 |
75 | - Updated dependencies [0b18eef]
76 | - @flipbookqr/reader@0.1.0
77 | - @flipbookqr/writer@0.1.0
78 |
--------------------------------------------------------------------------------
/apps/web/README.md:
--------------------------------------------------------------------------------
1 | ## Getting Started
2 |
3 | First, run the development server:
4 |
5 | ```bash
6 | yarn dev
7 | ```
8 |
9 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
10 |
11 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
12 |
13 | To create [API routes](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) add an `api/` directory to the `app/` directory with a `route.ts` file. For individual endpoints, create a subfolder in the `api` directory, like `api/hello/route.ts` would map to [http://localhost:3000/api/hello](http://localhost:3000/api/hello).
14 |
15 | ## Learn More
16 |
17 | To learn more about Next.js, take a look at the following resources:
18 |
19 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
20 | - [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial.
21 |
22 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
23 |
24 | ## Deploy on Vercel
25 |
26 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js.
27 |
28 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
29 |
--------------------------------------------------------------------------------
/apps/web/app/benchmark/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useCallback } from 'react';
4 | import type { FormEventHandler, JSX } from 'react';
5 | import { Writer } from '@flipbookqr/writer';
6 | import { Reader, FileProcessor } from '@flipbookqr/reader';
7 | import Image from 'next/image';
8 |
9 | export default function File(): JSX.Element {
10 | const [decoded, setDecoded] = useState(null);
11 | const [isDecoding, setIsDecoding] = useState(false);
12 | const [text, setText] = useState('');
13 | const [src, setSrc] = useState('');
14 |
15 | const generate = useCallback(async () => {
16 | const writer = new Writer();
17 | const qrs = writer.write(text);
18 |
19 | const blob = writer.toGif(qrs);
20 | const url = URL.createObjectURL(blob);
21 |
22 | setSrc(url);
23 | }, [text]);
24 |
25 | const handleSubmit = async (event: Event): Promise => {
26 | event.preventDefault();
27 | setIsDecoding(true);
28 |
29 | try {
30 | const formData = new FormData(event.target as unknown as HTMLFormElement);
31 | const file = formData.get('inputFile') as File;
32 |
33 | const reader = new Reader({
34 | frameProcessor: new FileProcessor(),
35 | });
36 |
37 | const decodedData = await reader.read(file);
38 |
39 | setDecoded(decodedData);
40 | } catch (error) {
41 | // eslint-disable-next-line no-console -- Intentional
42 | console.error('Error reading QR code:', error);
43 | } finally {
44 | setIsDecoding(false);
45 | }
46 | };
47 |
48 | return (
49 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/apps/web/app/components/button.tsx:
--------------------------------------------------------------------------------
1 | import { twMerge } from 'tailwind-merge';
2 |
3 | type ButtonProps =
4 | React.ComponentPropsWithoutRef & {
5 | as?: T;
6 | color?: 'primary' | 'secondary';
7 | className?: string;
8 | };
9 |
10 | function BaseButton({
11 | as: asComponent,
12 | className = '',
13 | color = 'primary',
14 | ...props
15 | }: ButtonProps): JSX.Element {
16 | // The `as` prop allows us to render a different component
17 | const Component = asComponent || 'button';
18 |
19 | // If the button has an `href` prop and it's external, we want to launch it in a new window
20 | const isLink: boolean =
21 | 'href' in props &&
22 | Boolean(
23 | (props as React.ComponentPropsWithoutRef<'a'>).href?.includes('http')
24 | );
25 |
26 | const colorClasses = {
27 | primary: 'bg-indigo-500 hover:bg-indigo-600 text-white',
28 | secondary: 'bg-gray-200 hover:bg-gray-300 text-black',
29 | };
30 |
31 | return (
32 |
42 | );
43 | }
44 |
45 | export function Button({
46 | as: asComponent,
47 | className = '',
48 | ...props
49 | }: ButtonProps): JSX.Element {
50 | return (
51 |
56 | );
57 | }
58 |
59 | export function IconButton({
60 | as: asComponent,
61 | className = '',
62 | ...props
63 | }: ButtonProps): JSX.Element {
64 | return (
65 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/apps/web/app/components/dialog.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react';
2 | import { Dialog, Transition } from '@headlessui/react';
3 | import { twMerge } from 'tailwind-merge';
4 |
5 | interface DialogBoxProps {
6 | isOpen: boolean;
7 | setIsOpen: (open: boolean) => void;
8 | width?:
9 | | 'sm'
10 | | 'md'
11 | | 'lg'
12 | | 'xl'
13 | | '2xl'
14 | | '3xl'
15 | | '4xl'
16 | | '5xl'
17 | | '6xl'
18 | | '7xl';
19 | children?: React.ReactNode;
20 | }
21 |
22 | export default function DialogBox({
23 | isOpen,
24 | setIsOpen,
25 | width = 'md',
26 | children,
27 | }: DialogBoxProps): JSX.Element {
28 | const widthClass = {
29 | sm: 'max-w-sm',
30 | md: 'max-w-md',
31 | lg: 'max-w-lg',
32 | xl: 'max-w-xl',
33 | '2xl': 'max-w-2xl',
34 | '3xl': 'max-w-3xl',
35 | '4xl': 'max-w-4xl',
36 | '5xl': 'max-w-5xl',
37 | '6xl': 'max-w-6xl',
38 | '7xl': 'max-w-7xl',
39 | }[width];
40 |
41 | return (
42 |
43 |
44 |
53 |
54 |
55 |
56 |
57 |
63 |
72 |
73 | {children}
74 |
75 |
76 |
77 |
78 |
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/apps/web/app/components/input-group.tsx:
--------------------------------------------------------------------------------
1 | interface InputGroupProps {
2 | className?: string;
3 | label: string;
4 | children: React.ReactNode;
5 | }
6 |
7 | export default function InputGroup({
8 | className = '',
9 | label,
10 | children,
11 | }: InputGroupProps): JSX.Element {
12 | return (
13 |
14 |
15 | {label}
16 |
17 |
{children}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/apps/web/app/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Disclosure } from '@headlessui/react';
4 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
5 | import NextLink from 'next/link';
6 | import Image from 'next/image';
7 | import { usePathname, useSearchParams } from 'next/navigation';
8 |
9 | const getLinkClasses = (isActive: boolean): string => {
10 | const defaultClasses =
11 | 'inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium';
12 | const activeClasses = 'border-indigo-500 text-gray-900';
13 | const inactiveClasses =
14 | 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700';
15 |
16 | if (isActive) {
17 | return `${defaultClasses} ${activeClasses}`;
18 | }
19 |
20 | return `${defaultClasses} ${inactiveClasses}`;
21 | };
22 |
23 | const getDisclosureClasses = (isActive: boolean): string => {
24 | const defaultClasses =
25 | 'block border-l-4 py-2 pl-3 pr-4 text-base font-medium';
26 | const activeClasses = 'bg-indigo-50 border-indigo-500 text-indigo-700';
27 | const inactiveClasses =
28 | 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700';
29 |
30 | if (isActive) {
31 | return `${defaultClasses} ${activeClasses}`;
32 | }
33 |
34 | return `${defaultClasses} ${inactiveClasses}`;
35 | };
36 |
37 | const leftLinks = [
38 | { href: '/', label: 'Read' },
39 | { href: '/writer', label: 'Write' },
40 | ];
41 |
42 | const rightLinks = [
43 | { href: 'https://github.com/cereallarceny/flipbook', label: 'GitHub' },
44 | ];
45 |
46 | export default function Navbar(): JSX.Element | null {
47 | const pathname = usePathname();
48 | const search = useSearchParams();
49 |
50 | // If we have ?mode=standalone in the URL, don't render the navbar
51 | const mode = search.get('mode');
52 | if (mode === 'standalone') return null;
53 |
54 | return (
55 |
59 | {({ open }) => (
60 | <>
61 |
62 |
63 |
64 | {/* Mobile menu button */}
65 |
66 |
67 | Open main menu
68 | {open ? (
69 |
70 | ) : (
71 |
72 | )}
73 |
74 |
75 |
76 |
77 |
78 |
84 |
85 |
86 |
87 | {leftLinks.map((link) => (
88 |
93 | {link.label}
94 |
95 | ))}
96 |
97 |
98 |
99 | {rightLinks.map((link) => (
100 |
105 | {link.label}
106 |
107 | ))}
108 |
109 |
110 |
111 |
112 |
113 | {leftLinks.map((link) => (
114 |
120 | {link.label}
121 |
122 | ))}
123 |
124 |
125 | >
126 | )}
127 |
128 | );
129 | }
130 |
--------------------------------------------------------------------------------
/apps/web/app/components/number-input.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, type LegacyRef, type InputHTMLAttributes } from 'react';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | function NumberInputElem(
5 | { className = '', ...props }: InputHTMLAttributes,
6 | ref: LegacyRef
7 | ): JSX.Element {
8 | return (
9 |
18 | );
19 | }
20 |
21 | export default forwardRef(NumberInputElem);
22 |
--------------------------------------------------------------------------------
/apps/web/app/components/select-input.tsx:
--------------------------------------------------------------------------------
1 | import { type LegacyRef, forwardRef, type SelectHTMLAttributes } from 'react';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | function SelectInputElem(
5 | { children, className, ...props }: SelectHTMLAttributes,
6 | ref: LegacyRef
7 | ): JSX.Element {
8 | return (
9 |
17 | {children}
18 |
19 | );
20 | }
21 |
22 | export default forwardRef(SelectInputElem);
23 |
--------------------------------------------------------------------------------
/apps/web/app/components/spinner.tsx:
--------------------------------------------------------------------------------
1 | export default function Spinner(): JSX.Element {
2 | return (
3 |
4 |
11 |
15 |
19 |
20 |
Loading...
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/apps/web/app/components/support.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export default function useNavigatorSupport() {
4 | const [userMediaSupport, setUserMediaSupport] = useState(false);
5 | const [displayMediaSupport, setDisplayMediaSupport] =
6 | useState(false);
7 |
8 | useEffect(() => {
9 | if (navigator.mediaDevices && !!navigator.mediaDevices.getUserMedia) {
10 | setUserMediaSupport(true);
11 | }
12 |
13 | if (navigator.mediaDevices && !!navigator.mediaDevices.getDisplayMedia) {
14 | setDisplayMediaSupport(true);
15 | }
16 | }, []);
17 |
18 | return {
19 | getUserMedia: userMediaSupport,
20 | getDisplayMedia: displayMediaSupport,
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/apps/web/app/content.ts:
--------------------------------------------------------------------------------
1 | import pkg from '@flipbookqr/writer/package.json';
2 |
3 | const titleWithoutName = 'QR Codes of infinite size';
4 | const title = `Flipbook - ${titleWithoutName}`;
5 | const url = 'https://flipbook.codes';
6 |
7 | export const meta = {
8 | titleWithoutName,
9 | title,
10 | description:
11 | 'Flipbook is a superset of QR codes that allows for infinitely sized payloads. Download apps, rich-text, and more without the need for an internet connection.',
12 | url,
13 | logotype: {
14 | url: `${url}/logotype.svg`,
15 | width: 3200,
16 | height: 800,
17 | alt: title,
18 | },
19 | };
20 |
21 | export const homepage = {
22 | title: 'Read a Flipbook QR Code',
23 | description:
24 | '"Flipbooks" are a superset of QR codes in the form of an animated GIF. This allows for digital information of any size to be transferred without the need for an internet connection. Flipbooks can be used to download apps, music, movies, rich-text, and more.',
25 | version: `Beta Release v${pkg.version}`,
26 | camera: 'Scan with Camera',
27 | upload: 'Upload File',
28 | screen: 'Scan on Screen',
29 | };
30 |
31 | export const writer = {
32 | github: 'View on Github',
33 | };
34 |
--------------------------------------------------------------------------------
/apps/web/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | * {
6 | box-sizing: border-box;
7 | padding: 0;
8 | margin: 0;
9 | }
10 |
11 | html,
12 | body {
13 | max-width: 100vw;
14 | overflow-x: hidden;
15 | }
16 |
17 | a {
18 | color: inherit;
19 | text-decoration: none;
20 | }
21 |
--------------------------------------------------------------------------------
/apps/web/app/hero.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { ArrowPathIcon, ClipboardIcon } from '@heroicons/react/24/solid';
5 | import { homepage } from './content';
6 | import { CameraScan, ScreenScan, Upload } from './method-buttons';
7 | import { IconButton } from './components/button';
8 |
9 | export default function Hero(): JSX.Element {
10 | const [results, setResults] = useState('');
11 |
12 | return (
13 |
14 |
15 |
16 |
17 | {homepage.version}
18 |
19 |
20 |
21 |
22 | {homepage.title}
23 |
24 |
25 | {homepage.description}
26 |
27 |
28 | {homepage.camera}
29 | {homepage.upload}
30 | {homepage.screen}
31 |
32 | {results ? (
33 |
34 |
35 |
36 | {results}
37 |
38 |
39 |
40 |
{
43 | await navigator.clipboard.writeText(results);
44 | }}
45 | >
46 |
47 |
48 |
{
51 | setResults('');
52 | }}
53 | >
54 |
55 |
56 |
57 |
58 | ) : null}
59 |
VIDEO
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/apps/web/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { Suspense } from 'react';
3 | import { Inter } from 'next/font/google';
4 | import { Analytics } from '@vercel/analytics/react';
5 | import { meta } from './content';
6 | import Navbar from './components/navbar';
7 | import './globals.css';
8 |
9 | const inter = Inter({ subsets: ['latin'] });
10 |
11 | export const metadata: Metadata = {
12 | metadataBase: new URL(
13 | process.env.NODE_ENV === 'production'
14 | ? 'https://flipbook.codes'
15 | : 'http://localhost:3000'
16 | ),
17 | title: meta.title,
18 | description: meta.description,
19 | icons: [
20 | {
21 | url: '/apple-touch-icon.png',
22 | sizes: '180x180',
23 | type: 'image/png',
24 | rel: 'apple-touch-icon',
25 | },
26 | {
27 | url: '/favicon-32x32.png',
28 | sizes: '32x32',
29 | type: 'image/png',
30 | rel: 'icon',
31 | },
32 | {
33 | url: '/favicon-16x16.png',
34 | sizes: '16x16',
35 | type: 'image/png',
36 | rel: 'icon',
37 | },
38 | {
39 | url: '/safari-pinned-tab.svg',
40 | color: '#444444',
41 | rel: 'mask-icon',
42 | },
43 | ],
44 | manifest: '/site.webmanifest',
45 | openGraph: {
46 | type: 'website',
47 | url: meta.url,
48 | title: meta.title,
49 | description: meta.description,
50 | images: [meta.logotype],
51 | },
52 | twitter: {
53 | card: 'summary_large_image',
54 | site: meta.url,
55 | title: meta.title,
56 | description: meta.description,
57 | images: [meta.logotype],
58 | },
59 | };
60 |
61 | export default function RootLayout({
62 | children,
63 | }: {
64 | children: React.ReactNode;
65 | }): JSX.Element {
66 | return (
67 |
68 |
69 |
70 |
71 |
72 |
73 | {children}
74 |
75 |
76 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/apps/web/app/method-buttons.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Fragment, useCallback, useEffect, useState } from 'react';
4 | import { FileProcessor, Reader, WebRTCProcessor } from '@flipbookqr/reader';
5 | import { Button } from './components/button';
6 | import DialogBox from './components/dialog';
7 | import useNavigatorSupport from './components/support';
8 |
9 | interface MethodButtonProps {
10 | setResults: (results: string) => void;
11 | children: React.ReactNode;
12 | }
13 |
14 | export function CameraScan({
15 | setResults,
16 | children,
17 | }: MethodButtonProps): JSX.Element {
18 | // Get the navigator support
19 | const supports = useNavigatorSupport();
20 |
21 | // Store the reader
22 | const [reader, setReader] = useState();
23 |
24 | // Store the dialog state
25 | const [isOpen, setIsOpen] = useState(false);
26 |
27 | // Store the tracks in state
28 | const [tracks, setTracks] = useState([]);
29 |
30 | // When clicking the button, we want to trigger camera selection
31 | const onGetCameraTracks = useCallback(async () => {
32 | // Create a new reader instance
33 | const readerInstance = new Reader({
34 | frameProcessor: new WebRTCProcessor('camera', {
35 | audio: false,
36 | video: true,
37 | }),
38 | });
39 |
40 | if (readerInstance.opts.frameProcessor instanceof WebRTCProcessor) {
41 | const streamTracks =
42 | await readerInstance.opts.frameProcessor.getStreamTracks();
43 | setTracks(streamTracks);
44 | setReader(readerInstance);
45 | setIsOpen(true);
46 | }
47 | }, []);
48 |
49 | // When clicking the track, we want to set the track in the processor and read
50 | const onTrackClick = useCallback(
51 | async (track: MediaStreamTrack) => {
52 | if (reader && reader.opts.frameProcessor instanceof WebRTCProcessor) {
53 | setIsOpen(false);
54 | reader.opts.frameProcessor.setStreamTrack(track);
55 | setResults(await reader.read());
56 | }
57 | },
58 | [reader, setResults]
59 | );
60 |
61 | // If the browser does not support user media, return an empty fragment
62 | if (!supports.getUserMedia) {
63 | return ;
64 | }
65 |
66 | return (
67 | <>
68 | {children}
69 |
70 |
71 |
Select a source:
72 |
73 | {tracks.map((track) => (
74 | void onTrackClick(track)}
78 | type="button"
79 | >
80 | {track.label}
81 |
82 | ))}
83 |
84 |
85 |
86 | >
87 | );
88 | }
89 |
90 | export function Upload({
91 | setResults,
92 | children,
93 | }: MethodButtonProps): JSX.Element {
94 | // Store the file in state
95 | const [file, setFile] = useState(null);
96 |
97 | // When clicking the button, we want to trigger a file input
98 | const onClick = useCallback(() => {
99 | // Create an input element and trigger a click event
100 | const input = document.createElement('input');
101 | input.type = 'file';
102 | input.accept = 'image/*';
103 | input.click();
104 |
105 | // When the input changes, set the file in state
106 | input.addEventListener('change', () => {
107 | const f = input.files?.[0];
108 | if (f) setFile(f);
109 | });
110 |
111 | // Clean up the input
112 | input.remove();
113 | }, []);
114 |
115 | // When the file changes, we want to read the file and set the results
116 | useEffect(() => {
117 | const readFile = async (f: File | null): Promise => {
118 | if (!f) return;
119 |
120 | const reader = new Reader({
121 | frameProcessor: new FileProcessor(),
122 | });
123 |
124 | setResults(await reader.read(f));
125 | };
126 |
127 | try {
128 | void readFile(file);
129 | } catch (error) {
130 | setResults((error as Error).message);
131 | }
132 | }, [file, setResults]);
133 |
134 | return {children} ;
135 | }
136 |
137 | export function ScreenScan({
138 | setResults,
139 | children,
140 | }: MethodButtonProps): JSX.Element {
141 | // Get the navigator support
142 | const supports = useNavigatorSupport();
143 |
144 | // When clicking the button, we want to trigger a screen capture
145 | const onClick = useCallback(async () => {
146 | const reader = new Reader();
147 | setResults(await reader.read());
148 | }, [setResults]);
149 |
150 | // If the browser does not support display media, return an empty fragment
151 | if (!supports.getDisplayMedia) {
152 | return ;
153 | }
154 |
155 | return {children} ;
156 | }
157 |
--------------------------------------------------------------------------------
/apps/web/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Hero from './hero';
2 |
3 | export default function Page(): JSX.Element {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/apps/web/app/writer/components/configuration-form.tsx:
--------------------------------------------------------------------------------
1 | import type { WriterProps } from '@flipbookqr/writer';
2 | import { type SubmitHandler, useForm } from 'react-hook-form';
3 | import { Button } from '../../components/button';
4 | import InputGroup from '../../components/input-group';
5 | import NumberInput from '../../components/number-input';
6 | import SelectInput from '../../components/select-input';
7 | import { ErrorCorrectionLevel } from '@nuintun/qrcode';
8 | import { useMemo } from 'react';
9 |
10 | interface ConfigurationFormProps {
11 | defaultValues: Partial;
12 | onSubmit: SubmitHandler>;
13 | }
14 |
15 | export default function ConfigurationForm({
16 | defaultValues,
17 | onSubmit,
18 | }: ConfigurationFormProps): JSX.Element {
19 | const { register, handleSubmit } = useForm({ defaultValues });
20 |
21 | const errorLevels = useMemo(() => {
22 | const keys = Object.keys(ErrorCorrectionLevel);
23 |
24 | return keys.reduce((acc, key) => {
25 | if (!isNaN(Number(key))) {
26 | return acc;
27 | }
28 |
29 | return {
30 | ...acc,
31 | [key]: ErrorCorrectionLevel[key as keyof typeof ErrorCorrectionLevel],
32 | };
33 | }, {});
34 | }, []);
35 |
36 | return (
37 | void handleSubmit(onSubmit)(...args)}>
38 | Configuration
39 |
40 |
41 |
42 | {Object.keys(errorLevels).map((key) => (
43 |
47 | {key}
48 |
49 | ))}
50 |
51 |
52 |
53 |
54 | Yes
55 | No
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | Trace
76 | Debug
77 | Info
78 | Warn
79 | Error
80 | Silent
81 |
82 |
83 |
84 | Save Configuration
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/apps/web/app/writer/components/editor.tsx:
--------------------------------------------------------------------------------
1 | import { Editor as MonacoEditor } from '@monaco-editor/react';
2 | import Spinner from '../../components/spinner';
3 |
4 | interface EditorProps {
5 | fileName: string;
6 | onChange: (code: string | undefined) => void;
7 | sampleCode: string;
8 | }
9 |
10 | const EDITOR_THEME = 'flipbook-theme';
11 |
12 | export default function Editor({
13 | fileName,
14 | onChange,
15 | sampleCode,
16 | }: EditorProps): JSX.Element {
17 | return (
18 | }
23 | onChange={(code) => {
24 | onChange(code);
25 | }}
26 | onMount={(_e, m) => {
27 | m.editor.defineTheme(EDITOR_THEME, {
28 | base: 'vs-dark',
29 | colors: {
30 | 'editor.background': '#111827',
31 | },
32 | inherit: true,
33 | rules: [
34 | {
35 | background: '111827',
36 | token: '',
37 | },
38 | ],
39 | });
40 |
41 | m.editor.setTheme(EDITOR_THEME);
42 | }}
43 | options={{
44 | fontSize: 16,
45 | minimap: {
46 | enabled: false,
47 | },
48 | scrollBeyondLastLine: false,
49 | scrollbar: {
50 | horizontal: 'hidden',
51 | vertical: 'visible',
52 | },
53 | wordWrap: 'on',
54 | }}
55 | />
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/apps/web/app/writer/components/filetypes.ts:
--------------------------------------------------------------------------------
1 | export interface FileType {
2 | label: string;
3 | value: string;
4 | }
5 |
6 | // A list of file types for use by the Monaco editor
7 | export const fileTypes: FileType[] = [
8 | {
9 | label: 'Plain Text',
10 | value: 'plaintext',
11 | },
12 | {
13 | label: 'JavaScript',
14 | value: 'javascript',
15 | },
16 | {
17 | label: 'TypeScript',
18 | value: 'typescript',
19 | },
20 | {
21 | label: 'HTML',
22 | value: 'html',
23 | },
24 | {
25 | label: 'CSS',
26 | value: 'css',
27 | },
28 | {
29 | label: 'JSON',
30 | value: 'json',
31 | },
32 | {
33 | label: 'Markdown',
34 | value: 'markdown',
35 | },
36 | {
37 | label: 'Python',
38 | value: 'python',
39 | },
40 | {
41 | label: 'Ruby',
42 | value: 'ruby',
43 | },
44 | {
45 | label: 'C',
46 | value: 'c',
47 | },
48 | {
49 | label: 'C++',
50 | value: 'cpp',
51 | },
52 | {
53 | label: 'C#',
54 | value: 'csharp',
55 | },
56 | {
57 | label: 'Go',
58 | value: 'go',
59 | },
60 | {
61 | label: 'Java',
62 | value: 'java',
63 | },
64 | {
65 | label: 'PHP',
66 | value: 'php',
67 | },
68 | {
69 | label: 'Rust',
70 | value: 'rust',
71 | },
72 | {
73 | label: 'Scala',
74 | value: 'scala',
75 | },
76 | {
77 | label: 'SQL',
78 | value: 'sql',
79 | },
80 | {
81 | label: 'Swift',
82 | value: 'swift',
83 | },
84 | {
85 | label: 'YAML',
86 | value: 'yaml',
87 | },
88 | ];
89 |
--------------------------------------------------------------------------------
/apps/web/app/writer/components/generate.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useCallback, useState } from 'react';
4 | import { type WriterProps } from '@flipbookqr/writer';
5 | import { CogIcon } from '@heroicons/react/24/solid';
6 | import { Button, IconButton } from '../../components/button';
7 | import DialogBox from '../../components/dialog';
8 | import ConfigurationForm from './configuration-form';
9 |
10 | interface GenerateProps {
11 | configuration: Partial;
12 | setConfiguration: (config: Partial) => void;
13 | createQR: () => void;
14 | }
15 |
16 | export default function Generate({
17 | configuration,
18 | setConfiguration,
19 | createQR,
20 | }: GenerateProps): JSX.Element {
21 | // State whether the dialog is open
22 | const [isOpen, setIsOpen] = useState(false);
23 |
24 | // A function to launch the configuration dialog
25 | const launchDialog = useCallback(() => {
26 | setIsOpen(!isOpen);
27 | }, [isOpen]);
28 |
29 | return (
30 | <>
31 |
32 | {
35 | setConfiguration({ ...configuration, ...data });
36 | setIsOpen(false);
37 | }}
38 | />
39 |
40 |
41 |
42 |
43 |
44 |
45 | Generate Flipbook
46 |
47 |
48 | >
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/apps/web/app/writer/components/github-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import GitHubButton from 'react-github-btn';
4 |
5 | export function Star(): JSX.Element {
6 | return (
7 |
15 | Star
16 |
17 | );
18 | }
19 |
20 | export function Sponsor(): JSX.Element {
21 | return (
22 |
29 | Sponsor
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/apps/web/app/writer/components/output.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Reader } from '@flipbookqr/reader';
4 | import { useCallback, useEffect, useRef, useState } from 'react';
5 | import { ArrowPathIcon } from '@heroicons/react/24/solid';
6 | import Link from 'next/link';
7 | import { Writer, type WriterProps } from '@flipbookqr/writer';
8 | import { Button, IconButton } from '../../components/button';
9 | import useNavigatorSupport from '../../components/support';
10 |
11 | interface OutputProps {
12 | code: string;
13 | configuration: Partial;
14 | reset: () => void;
15 | }
16 |
17 | const GIF_NAME = 'flipbook-qr.gif';
18 |
19 | export default function Output({
20 | code,
21 | configuration,
22 | reset,
23 | }: OutputProps): JSX.Element {
24 | // Get the navigator support
25 | const supports = useNavigatorSupport();
26 |
27 | // Store a reference to the canvas
28 | const canvasRef = useRef(null);
29 |
30 | // Store the read output
31 | const [output, setOutput] = useState('');
32 |
33 | // A function to read a QR on the screen
34 | const readQR = useCallback(async () => {
35 | // Set output to "reading"
36 | setOutput('Reading...');
37 |
38 | // Read the QR code
39 | const reader = new Reader({ logLevel: configuration.logLevel });
40 | const readResult = await reader.read();
41 |
42 | // Set the output
43 | setOutput(readResult);
44 | }, [configuration]);
45 |
46 | // A function to download the current QR code as a GIF
47 | const downloadQR = useCallback(async () => {
48 | // Create a new writer
49 | const writer = new Writer(configuration);
50 |
51 | // Write the QR code
52 | const qrs = writer.write(code);
53 |
54 | // Compose the QR code GIF
55 | const blob = writer.toGif(qrs);
56 |
57 | // Create the link
58 | const link = document.createElement('a');
59 | link.download = GIF_NAME;
60 | link.href = URL.createObjectURL(blob);
61 |
62 | // Add the link to the body
63 | document.body.appendChild(link);
64 |
65 | // Click the link
66 | link.click();
67 |
68 | // Remove the link from the body
69 | document.body.removeChild(link);
70 | }, []);
71 |
72 | // A function to reset us back to the editor
73 | const resetOutput = useCallback(() => {
74 | // Reset the output
75 | setOutput('');
76 |
77 | // Reset the QR code
78 | reset();
79 | }, []);
80 |
81 | // When the component mounts, write the QR code
82 | useEffect(() => {
83 | // Create a new writer
84 | const writer = new Writer(configuration);
85 |
86 | // Write the QR code
87 | const qrs = writer.write(code);
88 |
89 | // Compose the QR code onto the canvas
90 | writer.toCanvas(qrs, canvasRef.current!);
91 | }, [code, configuration]);
92 |
93 | return (
94 |
95 |
96 | {output !== '' && (
97 |
98 |
Results:
99 |
100 | {output}
101 |
102 |
103 | )}
104 |
105 | {supports.getDisplayMedia && (
106 |
void readQR()} type="button">
107 | Read QR
108 |
109 | )}
110 |
111 | Download (as GIF)
112 |
113 |
114 | Download Reader
115 |
116 |
122 |
123 |
124 |
125 |
126 | );
127 | }
128 |
--------------------------------------------------------------------------------
/apps/web/app/writer/components/playground.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useMemo, useState } from 'react';
4 | import { Writer, type WriterProps } from '@flipbookqr/writer';
5 | import { fileTypes, type FileType } from './filetypes';
6 | import Editor from './editor';
7 | import Generate from './generate';
8 | import Output from './output';
9 |
10 | const sampleCode = `const fibbonacci = (n: number): number => {
11 | if (n <= 1) return n;
12 | return fibbonacci(n - 1) + fibbonacci(n - 2);
13 | }
14 |
15 | fibbonacci(10);`;
16 |
17 | export default function Playground(): JSX.Element {
18 | // The result of the editor
19 | const [code, setCode] = useState(sampleCode);
20 |
21 | // Store the file type
22 | const [fileName, setFileName] = useState('typescript');
23 |
24 | // Get the current file type
25 | const currentFileType = useMemo(
26 | () => fileTypes.find((fileType) => fileType.value === fileName),
27 | [fileName]
28 | );
29 |
30 | // Whether or no
31 | const [qrExists, setQrExists] = useState(false);
32 |
33 | // Store the configuration
34 | const [configuration, setConfiguration] = useState>({
35 | ...new Writer().opts,
36 | });
37 |
38 | // If the QR code exists, show the output
39 | if (qrExists) {
40 | return (
41 | setQrExists(false)}
45 | />
46 | );
47 | }
48 |
49 | return (
50 |
51 |
52 |
53 |
54 | {
60 | setFileName(e.target.value);
61 | }}
62 | >
63 | {fileTypes.map(({ value, label }) => (
64 |
65 | {label}
66 |
67 | ))}
68 |
69 |
70 |
71 |
72 |
73 |
74 | {
77 | if (value) setCode(value);
78 | }}
79 | sampleCode={sampleCode}
80 | />
81 | setQrExists(true)}
85 | />
86 |
87 |
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/apps/web/app/writer/hero.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | interface HeroProps {
4 | buttons: { id: string; children: JSX.Element }[];
5 | description: string;
6 | title: string;
7 | children: JSX.Element;
8 | }
9 |
10 | export default function Hero({
11 | buttons,
12 | description,
13 | title,
14 | children,
15 | }: HeroProps): JSX.Element {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
29 |
30 | {title}
31 |
32 |
33 | {description}
34 |
35 |
36 | {buttons.map((button) => (
37 |
{button.children}
38 | ))}
39 |
40 |
41 |
42 |
43 |
44 |
48 |
49 |
50 |
54 |
55 |
56 | {children}
57 |
58 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/apps/web/app/writer/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '../components/button';
2 | import { meta, writer } from '../content';
3 | import Hero from './hero';
4 | import Playground from './components/playground';
5 | import { Sponsor, Star } from './components/github-button';
6 |
7 | export default function Page(): JSX.Element {
8 | return (
9 |
10 |
16 | {writer.github}
17 |
18 | ),
19 | },
20 | {
21 | id: 'star',
22 | children: (
23 |
24 |
25 |
26 | ),
27 | },
28 | {
29 | id: 'sponsor',
30 | children: (
31 |
32 |
33 |
34 | ),
35 | },
36 | ]}
37 | description={meta.description}
38 | title={meta.titleWithoutName}
39 | >
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/apps/web/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
6 |
--------------------------------------------------------------------------------
/apps/web/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "0.1.1",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "format:fix": "prettier --write \"**/*.{ts,tsx,md}\"",
11 | "format": "prettier --list-different \"**/*.{ts,tsx,md}\""
12 | },
13 | "dependencies": {
14 | "@flipbookqr/reader": "workspace:*",
15 | "@flipbookqr/shared": "workspace:*",
16 | "@flipbookqr/writer": "workspace:*",
17 | "@headlessui/react": "^1.7.17",
18 | "@heroicons/react": "^2.1.5",
19 | "@monaco-editor/react": "^4.6.0",
20 | "@tailwindcss/forms": "^0.5.9",
21 | "@vercel/analytics": "^1.3.1",
22 | "autoprefixer": "^10.4.20",
23 | "monaco-editor": "0.51.0",
24 | "next": "^14.2.9",
25 | "postcss": "^8.4.45",
26 | "react": "^18.3.1",
27 | "react-dom": "^18.3.1",
28 | "react-github-btn": "^1.4.0",
29 | "react-hook-form": "7.53.0",
30 | "tailwindcss": "^3.4.10",
31 | "tailwind-merge": "^2.5.2"
32 | },
33 | "devDependencies": {
34 | "@repo/eslint-config": "workspace:*",
35 | "@repo/typescript-config": "workspace:*"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/apps/web/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/apps/web/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/apps/web/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/apps/web/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #000000
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/apps/web/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/favicon-16x16.png
--------------------------------------------------------------------------------
/apps/web/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/favicon-32x32.png
--------------------------------------------------------------------------------
/apps/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/favicon.ico
--------------------------------------------------------------------------------
/apps/web/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/apps/web/public/logotype.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/apps/web/public/mstile-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/mstile-144x144.png
--------------------------------------------------------------------------------
/apps/web/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/mstile-150x150.png
--------------------------------------------------------------------------------
/apps/web/public/mstile-310x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/mstile-310x150.png
--------------------------------------------------------------------------------
/apps/web/public/mstile-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/mstile-310x310.png
--------------------------------------------------------------------------------
/apps/web/public/mstile-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/apps/web/public/mstile-70x70.png
--------------------------------------------------------------------------------
/apps/web/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.14, written by Peter Selinger 2001-2017
9 |
10 |
12 |
19 |
26 |
33 |
40 |
47 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/apps/web/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Flipbook Reader",
3 | "short_name": "Flipbook",
4 | "id": "com.flipbook.reader",
5 | "theme_color": "#ffffff",
6 | "background_color": "#ffffff",
7 | "display": "fullscreen",
8 | "icons": [
9 | {
10 | "src": "/android-chrome-192x192.png",
11 | "sizes": "192x192",
12 | "type": "image/png"
13 | },
14 | {
15 | "src": "/android-chrome-512x512.png",
16 | "sizes": "512x512",
17 | "type": "image/png"
18 | }
19 | ],
20 | "scope": "/",
21 | "start_url": "/?mode=standalone"
22 | }
23 |
--------------------------------------------------------------------------------
/apps/web/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | const config: Config = {
4 | content: [
5 | './app/**/*.{js,ts,jsx,tsx,mdx}',
6 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
7 | './components/**/*.{js,ts,jsx,tsx,mdx}',
8 |
9 | // Or if using `src` directory:
10 | './src/**/*.{js,ts,jsx,tsx,mdx}',
11 | ],
12 | theme: {
13 | extend: {},
14 | },
15 | plugins: [require('@tailwindcss/forms')],
16 | };
17 |
18 | export default config;
19 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/nextjs.json",
3 | "compilerOptions": {
4 | "plugins": [
5 | {
6 | "name": "next"
7 | }
8 | ]
9 | },
10 | "include": [
11 | "next-env.d.ts",
12 | "next.config.mjs",
13 | "**/*.ts",
14 | "**/*.tsx",
15 | ".next/types/**/*.ts"
16 | ],
17 | "exclude": ["node_modules"]
18 | }
19 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = { extends: ['@commitlint/config-conventional'] };
2 |
--------------------------------------------------------------------------------
/docs/flipbook-qr.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cereallarceny/flipbook/4e0babf4ef323f38804005e4f9829ecf74bd405f/docs/flipbook-qr.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "flipbookqr",
3 | "license": "MIT",
4 | "author": {
5 | "name": "Patrick Cason",
6 | "email": "me@patrickcason.com",
7 | "url": "https://patrickcason.com"
8 | },
9 | "private": true,
10 | "packageManager": "pnpm@9.0.6",
11 | "engines": {
12 | "node": ">=18"
13 | },
14 | "scripts": {
15 | "build": "turbo run build",
16 | "dev": "turbo run dev",
17 | "lint": "turbo run lint",
18 | "test": "turbo run test",
19 | "benchmark": "turbo run benchmark",
20 | "format": "turbo run format",
21 | "format:fix": "turbo run format:fix",
22 | "pre:commit": "lint-staged",
23 | "prepare": "husky install",
24 | "changeset": "changeset",
25 | "release": "changeset publish"
26 | },
27 | "dependencies": {
28 | "@nuintun/qrcode": "3.0.1",
29 | "omggif": "^1.0.10",
30 | "loglevel": "^1.9.2"
31 | },
32 | "devDependencies": {
33 | "@changesets/cli": "^2.27.8",
34 | "@commitlint/cli": "^18.4.4",
35 | "@commitlint/config-conventional": "^18.4.4",
36 | "@types/jest": "^29.5.12",
37 | "@types/node": "^20.16.5",
38 | "@types/omggif": "^1.0.5",
39 | "@types/react": "^18.3.5",
40 | "@types/react-dom": "^18.3.0",
41 | "@types/w3c-image-capture": "^1.0.10",
42 | "husky": "^8.0.3",
43 | "jest": "^29.7.0",
44 | "lint-staged": "^15.2.10",
45 | "prettier": "^3.3.3",
46 | "tsup": "^8.2.4",
47 | "turbo": "^2.1.1"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/packages/e2e/README.md:
--------------------------------------------------------------------------------
1 | ## Benchmarks
2 |
3 | ### Writer
4 |
5 | #### For 100 char string
6 |
7 | | Task Name | ops/sec | Average Time (ns) | Margin | Samples |
8 | | --- | --- | --- | --- | --- |
9 | | Flipbook (writer) with 100 char string | 20 | 48473837 | ±17.67% | 11 |
10 |
11 | #### For 1,000 char string
12 |
13 | | Task Name | ops/sec | Average Time (ns) | Margin | Samples |
14 | | --- | --- | --- | --- | --- |
15 | | Flipbook (writer) with 1000 char string | 11 | 85619445.89999984 | ±7.02% | 10 |
16 |
17 | #### For 10,000 char string
18 |
19 | | Task Name | ops/sec | Average Time (ns) | Margin | Samples |
20 | | --- | --- | --- | --- | --- |
21 | | Flipbook (writer) with 10000 char string | 2 | 338170971.09999996 | ±3.02% | 10 |
22 |
23 | ### Reader
24 |
25 | #### For 100 char string
26 |
27 | | Task Name | ops/sec | Average Time (ns) | Margin | Samples |
28 | | --- | --- | --- | --- | --- |
29 | | Flipbook (reader) with 100 char string | 15 | 65113337.49999994 | ±14.12% | 10 |
30 |
31 | #### For 1,000 char string
32 |
33 | | Task Name | ops/sec | Average Time (ns) | Margin | Samples |
34 | | --- | --- | --- | --- | --- |
35 | | Flipbook (reader) with 1000 char string | 6 | 145288429.1999999 | ±5.76% | 10 |
36 |
37 | #### For 10,000 char string
38 |
39 | | Task Name | ops/sec | Average Time (ns) | Margin | Samples |
40 | | --- | --- | --- | --- | --- |
41 | | Flipbook (reader) with 10000 char string | 1 | 709327133.4000001 | ±1.58% | 10 |
--------------------------------------------------------------------------------
/packages/e2e/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "e2e",
3 | "license": "MIT",
4 | "version": "0.0.0",
5 | "private": true,
6 | "scripts": {
7 | "benchmark:dev": "playwright test --headed --ui",
8 | "benchmark": "playwright test && pnpm generate:readme",
9 | "generate:readme": "node ./scripts/add-benchmarks-to-markdown.js",
10 | "postinstall": "pnpm playwright install"
11 | },
12 | "devDependencies": {
13 | "@playwright/test": "^1.47.0",
14 | "@repo/typescript-config": "workspace:*",
15 | "tinybench": "^2.9.0"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/e2e/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@playwright/test';
2 |
3 | export default defineConfig({
4 | testMatch: '*src/**/*.spec.ts',
5 | timeout: 10 * 60 * 1000, // 10 minutes
6 | workers: process.env.CI ? 1 : undefined,
7 | use: {
8 | baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'http://localhost:3000',
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/packages/e2e/results/reader-bench-10k.json:
--------------------------------------------------------------------------------
1 | {
2 | "Task Name": "Flipbook (reader) with 10000 char string",
3 | "ops/sec": "1",
4 | "Average Time (ns)": 709327133.4000001,
5 | "Margin": "±1.58%",
6 | "Samples": 10
7 | }
8 |
--------------------------------------------------------------------------------
/packages/e2e/results/reader-bench-1k.json:
--------------------------------------------------------------------------------
1 | {
2 | "Task Name": "Flipbook (reader) with 1000 char string",
3 | "ops/sec": "6",
4 | "Average Time (ns)": 145288429.1999999,
5 | "Margin": "±5.76%",
6 | "Samples": 10
7 | }
8 |
--------------------------------------------------------------------------------
/packages/e2e/results/reader-bench-hundred.json:
--------------------------------------------------------------------------------
1 | {
2 | "Task Name": "Flipbook (reader) with 100 char string",
3 | "ops/sec": "15",
4 | "Average Time (ns)": 65113337.49999994,
5 | "Margin": "±14.12%",
6 | "Samples": 10
7 | }
8 |
--------------------------------------------------------------------------------
/packages/e2e/results/writer-bench-10k.json:
--------------------------------------------------------------------------------
1 | {
2 | "Task Name": "Flipbook (writer) with 10000 char string",
3 | "ops/sec": "2",
4 | "Average Time (ns)": 338170971.09999996,
5 | "Margin": "±3.02%",
6 | "Samples": 10
7 | }
8 |
--------------------------------------------------------------------------------
/packages/e2e/results/writer-bench-1k.json:
--------------------------------------------------------------------------------
1 | {
2 | "Task Name": "Flipbook (writer) with 1000 char string",
3 | "ops/sec": "11",
4 | "Average Time (ns)": 85619445.89999984,
5 | "Margin": "±7.02%",
6 | "Samples": 10
7 | }
8 |
--------------------------------------------------------------------------------
/packages/e2e/results/writer-bench-hundred.json:
--------------------------------------------------------------------------------
1 | {
2 | "Task Name": "Flipbook (writer) with 100 char string",
3 | "ops/sec": "20",
4 | "Average Time (ns)": 48473837,
5 | "Margin": "±17.67%",
6 | "Samples": 11
7 | }
8 |
--------------------------------------------------------------------------------
/packages/e2e/scripts/add-benchmarks-to-markdown.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const fs = require('fs/promises');
3 |
4 | // Generate markdown table from data
5 | function generateMarkdownTable(data) {
6 | // Extract header row from the first data entry
7 | const headerRow = Object.keys(data[0]);
8 |
9 | // Generate table header
10 | const tableHeader = `| ${headerRow.join(' | ')} |\n| ${headerRow
11 | .map(() => '---')
12 | .join(' | ')} |`;
13 |
14 | // Generate table rows
15 | const tableRows = data.map(
16 | (row) => `| ${headerRow.map((header) => row[header]).join(' | ')} |`
17 | );
18 |
19 | // Combine header and rows
20 | return `${tableHeader}\n${tableRows.join('\n')}`;
21 | }
22 |
23 | // IIFE (Immediately Invoked Function Expression)
24 | (async () => {
25 | try {
26 | // structure data object
27 | const data = {
28 | 'writer-100': {},
29 | 'writer-1k': {},
30 | 'writer-10k': {},
31 | 'reader-100': {},
32 | 'reader-1k': {},
33 | 'reader-10k': {},
34 | };
35 |
36 | // output directory
37 | const outDir = path.resolve(__dirname, '..', 'results');
38 |
39 | // docs directory
40 | const docsDir = path.resolve(__dirname, '..');
41 |
42 | // read files
43 | const writerHundred = await fs.readFile(
44 | `${outDir}/writer-bench-hundred.json`,
45 | 'utf-8'
46 | );
47 | const writerThousand = await fs.readFile(
48 | `${outDir}/writer-bench-1k.json`,
49 | 'utf-8'
50 | );
51 | const writerTenThousand = await fs.readFile(
52 | `${outDir}/writer-bench-10k.json`,
53 | 'utf-8'
54 | );
55 | const readerHundred = await fs.readFile(
56 | `${outDir}/reader-bench-hundred.json`,
57 | 'utf-8'
58 | );
59 | const readerThousand = await fs.readFile(
60 | `${outDir}/reader-bench-1k.json`,
61 | 'utf-8'
62 | );
63 | const readerTenThousand = await fs.readFile(
64 | `${outDir}/reader-bench-10k.json`,
65 | 'utf-8'
66 | );
67 |
68 | // store data in data object
69 | data['writer-100'] = JSON.parse(writerHundred);
70 | data['writer-1k'] = JSON.parse(writerThousand);
71 | data['writer-10k'] = JSON.parse(writerTenThousand);
72 | data['reader-100'] = JSON.parse(readerHundred);
73 | data['reader-1k'] = JSON.parse(readerThousand);
74 | data['reader-10k'] = JSON.parse(readerTenThousand);
75 |
76 | // generate markdown tables
77 | const writerHundredTable = generateMarkdownTable([data['writer-100']]);
78 | const writerThousandTable = generateMarkdownTable([data['writer-1k']]);
79 | const writerTenThousandTable = generateMarkdownTable([data['writer-10k']]);
80 | const readerHundredTable = generateMarkdownTable([data['reader-100']]);
81 | const readerThousandTable = generateMarkdownTable([data['reader-1k']]);
82 | const readerTenThousandTable = generateMarkdownTable([data['reader-10k']]);
83 |
84 | // create markdown
85 | const markdown = `## Benchmarks
86 |
87 | ### Writer
88 |
89 | #### For 100 char string
90 |
91 | ${writerHundredTable}
92 |
93 | #### For 1,000 char string
94 |
95 | ${writerThousandTable}
96 |
97 | #### For 10,000 char string
98 |
99 | ${writerTenThousandTable}
100 |
101 | ### Reader
102 |
103 | #### For 100 char string
104 |
105 | ${readerHundredTable}
106 |
107 | #### For 1,000 char string
108 |
109 | ${readerThousandTable}
110 |
111 | #### For 10,000 char string
112 |
113 | ${readerTenThousandTable}`;
114 |
115 | // save markdown
116 | await fs.writeFile(`${docsDir}/README.md`, markdown, 'utf-8');
117 | } catch (err) {
118 | console.log('Error saving benchmark:', err);
119 | }
120 | })();
121 |
--------------------------------------------------------------------------------
/packages/e2e/src/benchmark.spec.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import { Bench } from 'tinybench';
4 | import { test, expect } from '@playwright/test';
5 | import {
6 | generateRandomString,
7 | saveBenchMark,
8 | FLIPBOOK_APP_URL,
9 | assetsDir,
10 | } from './helpers';
11 |
12 | const generateTest = (charLength: number, fileName: string) => {
13 | const testName = `Benchmark with ${charLength} char string`;
14 |
15 | test(testName, async ({ page }) => {
16 | // A place to store the URLs of the QR code images
17 | const urls: string[] = [];
18 |
19 | // A place to store the benchmark expected and actual results
20 | const expectedResults: string[] = [];
21 | const results: string[] = [];
22 |
23 | // Create the benchmark instances
24 | const writerBench = new Bench({
25 | warmupIterations: 0,
26 | iterations: 10,
27 | });
28 | const readerBench = new Bench({
29 | warmupIterations: 0,
30 | iterations: 10,
31 | });
32 |
33 | // If the assets directory does not exist, create it
34 | if (!fs.existsSync(assetsDir)) {
35 | fs.mkdirSync(assetsDir, { recursive: true });
36 | }
37 |
38 | // Navigate to the Flipbook app
39 | await page.goto(FLIPBOOK_APP_URL);
40 |
41 | // Initialize the counter for writer and reader
42 | let writeI = 0;
43 | let readI = 0;
44 |
45 | // Benchmark it
46 | writerBench.add(
47 | `Flipbook (writer) with ${charLength} char string`,
48 | async () => {
49 | // Click the Generate Flipbook button
50 | await page.locator('#generate').click();
51 |
52 | // Wait for the editor to be loaded
53 | await page.waitForSelector('#image');
54 | },
55 | {
56 | beforeEach: async function () {
57 | // Generate charLength string
58 | const text = generateRandomString(charLength);
59 |
60 | // Store the expected result
61 | expectedResults.push(text);
62 |
63 | // Fill the textarea with the generated string
64 | await page.locator('#textarea').fill(text);
65 | },
66 | afterEach: async function () {
67 | // Get the data of the QR code image
68 | const imgSrc = await page.locator('#image').getAttribute('src');
69 | const response = await page.goto(imgSrc || '');
70 | const buffer = (await response?.body()) || '';
71 |
72 | // Store the QR code image at the following path
73 | const qrFilePath = path.resolve(
74 | assetsDir,
75 | `${fileName.split('.json')[0]}-${writeI}.gif`
76 | );
77 |
78 | // Save the image to a file
79 | fs.writeFileSync(qrFilePath, buffer);
80 |
81 | // Store the URL of the QR code image
82 | urls.push(qrFilePath);
83 |
84 | // Navigate back to the benchmark page
85 | await page.goto(FLIPBOOK_APP_URL);
86 |
87 | // Increment the counter
88 | writeI++;
89 | },
90 | }
91 | );
92 |
93 | // Run the benchmark
94 | await writerBench.run();
95 |
96 | // Save results of the benchmark
97 | saveBenchMark(`writer-${fileName}`, writerBench.table());
98 |
99 | // Benchmark it
100 | readerBench.add(
101 | `Flipbook (reader) with ${charLength} char string`,
102 | async () => {
103 | // Click the Decode button
104 | await page.locator('#decode').click();
105 |
106 | // Wait for the decoding to be completed
107 | await page.locator('#decoded').waitFor({ state: 'visible' });
108 | },
109 | {
110 | beforeEach: async function () {
111 | // Upload the QR code image
112 | await page.locator('#upload').setInputFiles(urls[readI] || '');
113 |
114 | // Increment the counter
115 | readI++;
116 | },
117 | afterEach: async function () {
118 | // Get the decoded text
119 | const value = await page.locator('#decoded').innerHTML();
120 |
121 | // Store the decoded text
122 | results.push(value);
123 |
124 | // Navigate back to the benchmark page
125 | await page.goto(FLIPBOOK_APP_URL);
126 | },
127 | }
128 | );
129 |
130 | // Run the benchmark
131 | await readerBench.run();
132 |
133 | // Save results of the benchmark
134 | saveBenchMark(`reader-${fileName}`, readerBench.table());
135 |
136 | // Run the assertions
137 | for (let i = 0; i < results.length; i++) {
138 | expect(results[i]).toBe(expectedResults[i]);
139 | }
140 |
141 | // Destroy the assets dir and everything in it
142 | fs.rmdirSync(assetsDir, { recursive: true });
143 | });
144 | };
145 |
146 | // Generate test for 100 char strings
147 | generateTest(100, 'bench-hundred.json');
148 |
149 | // Generate test for 1,000 char strings
150 | generateTest(1000, 'bench-1k.json');
151 |
152 | // Generate test for 10,000 char strings
153 | generateTest(10000, 'bench-10k.json');
154 |
--------------------------------------------------------------------------------
/packages/e2e/src/helpers.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs/promises';
2 | import path from 'node:path';
3 |
4 | export const FLIPBOOK_APP_URL = '/benchmark';
5 |
6 | export const resultsDir = path.resolve(__dirname, '..', 'results');
7 | export const assetsDir = path.resolve(resultsDir, 'assets');
8 |
9 | export function generateRandomString(length: number) {
10 | let result = '';
11 | const characters =
12 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
13 | const charactersLength = characters.length;
14 | let counter = 0;
15 | while (counter < length) {
16 | result += characters.charAt(Math.floor(Math.random() * charactersLength));
17 | counter += 1;
18 | }
19 | return result;
20 | }
21 |
22 | export const saveBenchMark = async (
23 | fileName: string,
24 | value: (Record | null)[]
25 | ) => {
26 | try {
27 | const jsonData = {
28 | 'Task Name': value[0]?.['Task Name'] || null,
29 | 'ops/sec': value[0]?.['ops/sec'] || null,
30 | 'Average Time (ns)': value[0]?.['Average Time (ns)'] || null,
31 | Margin: value[0]?.Margin || null,
32 | Samples: value[0]?.Samples || null,
33 | };
34 |
35 | console.log(`Saving benchmark to ${resultsDir}/${fileName}`, jsonData);
36 |
37 | const jsonString = JSON.stringify(jsonData, null, 2);
38 |
39 | await fs.mkdir(resultsDir, { recursive: true });
40 | await fs.writeFile(`${resultsDir}/${fileName}`, jsonString, 'utf-8');
41 |
42 | console.log('Benchmark saved');
43 | } catch (err) {
44 | console.log('Error saving benchmark:', err);
45 | }
46 | };
47 |
--------------------------------------------------------------------------------
/packages/e2e/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "compilerOptions": {
4 | "outDir": "dist"
5 | },
6 | "include": ["src"],
7 | "exclude": ["node_modules", "dist"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/eslint-config/README.md:
--------------------------------------------------------------------------------
1 | # `@turbo/eslint-config`
2 |
3 | Collection of internal eslint configurations.
4 |
--------------------------------------------------------------------------------
/packages/eslint-config/library.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require('node:path');
2 |
3 | const project = resolve(process.cwd(), 'tsconfig.json');
4 |
5 | /** @type {import("eslint").Linter.Config} */
6 | module.exports = {
7 | extends: [
8 | 'eslint:recommended',
9 | 'plugin:@typescript-eslint/recommended',
10 | 'prettier',
11 | 'turbo',
12 | ],
13 | plugins: ['jest', 'only-warn'],
14 | globals: {
15 | React: true,
16 | JSX: true,
17 | },
18 | env: {
19 | browser: true,
20 | node: true,
21 | jest: true,
22 | },
23 | settings: {
24 | 'import/resolver': {
25 | typescript: {
26 | project,
27 | },
28 | },
29 | },
30 | ignorePatterns: [
31 | // Ignore dotfiles
32 | '.*.js',
33 | 'node_modules/',
34 | 'dist/',
35 | ],
36 | overrides: [
37 | {
38 | files: ['*.js?(x)', '*.ts?(x)'],
39 | },
40 | ],
41 | };
42 |
--------------------------------------------------------------------------------
/packages/eslint-config/next.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require('node:path');
2 |
3 | const project = resolve(process.cwd(), 'tsconfig.json');
4 |
5 | /** @type {import("eslint").Linter.Config} */
6 | module.exports = {
7 | extends: [
8 | 'eslint:recommended',
9 | 'plugin:@typescript-eslint/recommended',
10 | 'prettier',
11 | require.resolve('@vercel/style-guide/eslint/next'),
12 | 'turbo',
13 | ],
14 | globals: {
15 | React: true,
16 | JSX: true,
17 | },
18 | env: {
19 | node: true,
20 | browser: true,
21 | jest: true,
22 | },
23 | plugins: ['jest', 'only-warn'],
24 | settings: {
25 | 'import/resolver': {
26 | typescript: {
27 | project,
28 | },
29 | },
30 | },
31 | ignorePatterns: [
32 | // Ignore dotfiles
33 | '.*.js',
34 | 'node_modules/',
35 | ],
36 | overrides: [{ files: ['*.js?(x)', '*.ts?(x)'] }],
37 | };
38 |
--------------------------------------------------------------------------------
/packages/eslint-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/eslint-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "files": [
6 | "library.js",
7 | "next.js",
8 | "react-internal.js"
9 | ],
10 | "devDependencies": {
11 | "@next/eslint-plugin-next": "^14.2.9",
12 | "@vercel/style-guide": "^5.2.0",
13 | "eslint": "^8.57.0",
14 | "eslint-config-turbo": "^2.0.0",
15 | "eslint-config-prettier": "^9.1.0",
16 | "eslint-plugin-jest": "28.8.3",
17 | "eslint-plugin-only-warn": "^1.1.0",
18 | "@typescript-eslint/parser": "^7.1.0",
19 | "@typescript-eslint/eslint-plugin": "^7.1.0"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/eslint-config/react-internal.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require('node:path');
2 |
3 | const project = resolve(process.cwd(), 'tsconfig.json');
4 |
5 | /*
6 | * This is a custom ESLint configuration for use with
7 | * internal (bundled by their consumer) libraries
8 | * that utilize React.
9 | */
10 |
11 | /** @type {import("eslint").Linter.Config} */
12 | module.exports = {
13 | extends: [
14 | 'eslint:recommended',
15 | 'plugin:@typescript-eslint/recommended',
16 | 'prettier',
17 | 'turbo',
18 | ],
19 | plugins: ['jest', 'only-warn'],
20 | globals: {
21 | React: true,
22 | JSX: true,
23 | },
24 | env: {
25 | browser: true,
26 | jest: true,
27 | },
28 | settings: {
29 | 'import/resolver': {
30 | typescript: {
31 | project,
32 | },
33 | },
34 | },
35 | ignorePatterns: [
36 | // Ignore dotfiles
37 | '.*.js',
38 | 'node_modules/',
39 | 'dist/',
40 | ],
41 | overrides: [
42 | // Force ESLint to detect .tsx files
43 | { files: ['*.js?(x)', '*.ts?(x)'] },
44 | ],
45 | };
46 |
--------------------------------------------------------------------------------
/packages/jest-presets/base.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: [''],
3 | transform: {
4 | '^.+\\.tsx?$': 'ts-jest',
5 | },
6 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
7 | modulePathIgnorePatterns: [
8 | '/test/__fixtures__',
9 | '/node_modules',
10 | '/dist',
11 | ],
12 | preset: 'ts-jest',
13 | collectCoverage: true,
14 | collectCoverageFrom: [
15 | '**/*.ts',
16 | '!**/node_modules/**',
17 | '!**/*.config.ts',
18 | '!**/src/**/index.ts',
19 | ],
20 | coverageThreshold: {
21 | global: {
22 | branches: 80,
23 | functions: 80,
24 | lines: 80,
25 | statements: 80,
26 | },
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/packages/jest-presets/browser/jest-preset.js:
--------------------------------------------------------------------------------
1 | const config = require('../base.js');
2 |
3 | module.exports = {
4 | ...config,
5 | testEnvironment: 'jsdom',
6 | };
7 |
--------------------------------------------------------------------------------
/packages/jest-presets/node/jest-preset.js:
--------------------------------------------------------------------------------
1 | const config = require('../base.js');
2 |
3 | module.exports = config;
4 |
--------------------------------------------------------------------------------
/packages/jest-presets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/jest-presets",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "files": [
7 | "browser/jest-preset.js",
8 | "node/jest-preset.js"
9 | ],
10 | "devDependencies": {
11 | "jest-environment-jsdom": "^29.7.0",
12 | "jest-canvas-mock": "^2.5.2",
13 | "ts-jest": "^29.2.5"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/reader/.eslintignore:
--------------------------------------------------------------------------------
1 | coverage/
2 | node_modules
3 | *.d.ts
4 | *.config.ts
--------------------------------------------------------------------------------
/packages/reader/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | module.exports = {
3 | root: true,
4 | extends: ['@repo/eslint-config/library.js'],
5 | parser: '@typescript-eslint/parser',
6 | parserOptions: {
7 | project: './tsconfig.json',
8 | tsconfigRootDir: __dirname,
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/packages/reader/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @flipbookqr/reader
2 |
3 | ## 0.2.1
4 |
5 | ### Patch Changes
6 |
7 | - 26b793d: Adding back types to libraries for import
8 | - Updated dependencies [26b793d]
9 | - @flipbookqr/shared@0.2.1
10 |
11 | ## 0.2.0
12 |
13 | ### Minor Changes
14 |
15 | - 2d0b8b1: Dramatically improving performance, browser support, and bundle size
16 |
17 | ### Patch Changes
18 |
19 | - Updated dependencies [2d0b8b1]
20 | - @flipbookqr/shared@0.2.0
21 |
22 | ## 0.1.5
23 |
24 | ### Patch Changes
25 |
26 | - e7642eb: Redid internal infrastructure of repo
27 | - Updated dependencies [e7642eb]
28 | - @flipbookqr/shared@0.1.5
29 |
30 | ## 0.1.4
31 |
32 | ### Patch Changes
33 |
34 | - 3a25452: Allow for camera selection and potentially fixing mobile issues
35 | - Updated dependencies [3a25452]
36 | - @flipbookqr/shared@0.1.4
37 |
38 | ## 0.1.3
39 |
40 | ### Patch Changes
41 |
42 | - 88468e3: Adding video and fixing readmes
43 | - Updated dependencies [88468e3]
44 | - @flipbookqr/shared@0.1.3
45 |
46 | ## 0.1.2
47 |
48 | ### Patch Changes
49 |
50 | - 6c93146: Adding release documentation to test everything out
51 | - Updated dependencies [6c93146]
52 | - @flipbookqr/shared@0.1.2
53 |
54 | ## 0.1.1
55 |
56 | ### Patch Changes
57 |
58 | - 33db75b: Changing publish access
59 | - Updated dependencies [33db75b]
60 | - @flipbookqr/shared@0.1.1
61 |
62 | ## 0.1.0
63 |
64 | ### Minor Changes
65 |
66 | - 0b18eef: Initial release
67 |
68 | ### Patch Changes
69 |
70 | - Updated dependencies [0b18eef]
71 | - @flipbookqr/shared@0.1.0
72 |
--------------------------------------------------------------------------------
/packages/reader/README.md:
--------------------------------------------------------------------------------
1 | # @flipbookqr/reader
2 |
3 | The Flipbook reader is responsible for reading "flipbooks" created by the [Flipbook writer](https://github.com/cereallarceny/flipbook/tree/main/packages/writer). It can be used to decode a series of QR codes into a single payload.
4 |
5 | ## Installation
6 |
7 | NPM:
8 |
9 | ```bash
10 | npm install @flipbookqr/reader
11 | ```
12 |
13 | Yarn:
14 |
15 | ```bash
16 | yarn add @flipbookqr/reader
17 | ```
18 |
19 | PNPM:
20 |
21 | ```bash
22 | pnpm add @flipbookqr/reader
23 | ```
24 |
25 | Bun:
26 |
27 | ```bash
28 | bun add @flipbookqr/reader
29 | ```
30 |
31 | ## Usage
32 |
33 | [View a CodeSandbox example](https://codesandbox.io/p/sandbox/n6hrwl)
34 |
35 | ### Screenshare
36 |
37 | The following will read a QR code via the `getDisplayMedia` (screenshare) API:
38 |
39 | ```ts
40 | import { Reader } from '@flipbookqr/reader';
41 |
42 | // Create a new instance of the Flipbook reader
43 | const reader = new Reader(optionalConfig);
44 |
45 | // Read the Flipbook visible on the screen
46 | const result = await reader.read();
47 | ```
48 |
49 | The `result` is a is the original payload that was encoded into the series of QR codes.
50 |
51 | ### Camera
52 |
53 | The following will read a QR code via the `getUserMedia` (camera) API:
54 |
55 | ```ts
56 | import { Reader, WebRTCProcessor } from '@flipbookqr/reader';
57 |
58 | // Create a new instance of the Flipbook reader
59 | const reader = new Reader({
60 | frameProcessor: new WebRTCProcessor('camera'),
61 | });
62 |
63 | // Get a list of all available camera sources
64 | const sources = await reader.opts.frameProcessor.getStreamTracks();
65 |
66 | // Select a camera source
67 | reader.opts.frameProcessor.setStreamTrack(sources[0]);
68 |
69 | // Note: If you don't do the above two commands, it will default to the first camera source
70 |
71 | // Read the Flipbook visible on the screen
72 | const result = await reader.read();
73 | ```
74 |
75 | The `result` is a is the original payload that was encoded into the series of QR codes.
76 |
77 | ### File upload
78 |
79 | The following will read a QR code from a file:
80 |
81 | ```ts
82 | import { Reader, FileProcessor } from '@flipbookqr/reader';
83 |
84 | const file = new File(); // some file
85 |
86 | const reader = new Reader({
87 | frameProcessor: new FileProcessor(),
88 | });
89 |
90 | const result = await reader.read(file);
91 | ```
92 |
93 | The `result` is a is the original payload that was encoded into the series of QR codes.
94 |
95 | ## Configuration
96 |
97 | The `Writer` class accepts an optional configuration object that can be used to customize the behavior of the writer. The following options are available:
98 |
99 | ```typescript
100 | {
101 | logLevel: 'silent' | 'trace' | 'debug' | 'info' | 'warn' | 'error', // Default: 'silent'
102 | frameProcessor: FrameProcessor, // Default: new WebRTCProcessor()
103 | }
104 | ```
105 |
--------------------------------------------------------------------------------
/packages/reader/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@flipbookqr/reader",
3 | "version": "0.2.1",
4 | "main": "dist/index.js",
5 | "module": "dist/index.mjs",
6 | "types": "dist/index.d.ts",
7 | "exports": {
8 | ".": {
9 | "types": "./dist/index.d.ts",
10 | "require": "./dist/index.js",
11 | "import": "./dist/index.mjs"
12 | },
13 | "./package.json": "./package.json"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "license": "MIT",
19 | "publishConfig": {
20 | "access": "public"
21 | },
22 | "scripts": {
23 | "build": "tsup src/index.ts --format cjs,esm --dts --clean",
24 | "lint": "eslint .",
25 | "format:fix": "prettier --write \"**/*.{ts,tsx,md}\"",
26 | "format": "prettier --list-different \"**/*.{ts,tsx,md}\"",
27 | "test": "jest --coverage"
28 | },
29 | "jest": {
30 | "preset": "@repo/jest-presets/browser",
31 | "setupFiles": [
32 | "jest-canvas-mock"
33 | ]
34 | },
35 | "dependencies": {
36 | "@flipbookqr/shared": "workspace:*"
37 | },
38 | "devDependencies": {
39 | "@repo/eslint-config": "workspace:*",
40 | "@repo/jest-presets": "workspace:*",
41 | "@repo/typescript-config": "workspace:*"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/reader/src/helpers.test.ts:
--------------------------------------------------------------------------------
1 | import * as shared from '@flipbookqr/shared';
2 | import { sortFrames, sliceFrames } from './helpers';
3 |
4 | jest.mock('@flipbookqr/shared', () => {
5 | return {
6 | __esModule: true,
7 | ...jest.requireActual('@flipbookqr/shared'),
8 | };
9 | });
10 |
11 | describe('Helpers', () => {
12 | describe('sortFrames', () => {
13 | beforeEach(() => {
14 | jest.restoreAllMocks();
15 | });
16 |
17 | test('should sort frames with head first', () => {
18 | jest.spyOn(shared, 'isHead').mockReturnValue(true);
19 |
20 | const result = sortFrames('[INDEX:1]', '[HEAD]');
21 | expect(result).toBe(-1);
22 | });
23 |
24 | test('should sort frames by index', () => {
25 | const FRAME_1 = '[INDEX:1]';
26 | const FRAME_2 = '[INDEX:2]';
27 |
28 | jest.spyOn(shared, 'getIndex').mockImplementation((frame: string) => {
29 | if (frame === FRAME_2) return 1;
30 | return 2;
31 | });
32 |
33 | const resultA = sortFrames(FRAME_2, FRAME_1);
34 | expect(resultA).toBe(-1);
35 |
36 | jest.spyOn(shared, 'getIndex').mockImplementation((frame: string) => {
37 | if (frame === FRAME_2) return 2;
38 | return 1;
39 | });
40 |
41 | const resultB = sortFrames(FRAME_2, FRAME_1);
42 | expect(resultB).toBe(1);
43 | });
44 |
45 | test('should handle frames with the same index', () => {
46 | const result = sortFrames('[INDEX:1]', '[INDEX:1]');
47 | expect(result).toBe(0);
48 | });
49 | });
50 |
51 | describe('sliceFrames', () => {
52 | test('should remove everything before the index', () => {
53 | const result = sliceFrames('[INDEX:2]Some content');
54 | expect(result).toBe('Some content');
55 | });
56 |
57 | test('should handle frames without an index', () => {
58 | jest.spyOn(shared, 'getIndex').mockReturnValue(-1);
59 |
60 | const result = sliceFrames('No index here');
61 | expect(result).toBe('No index here');
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/packages/reader/src/helpers.ts:
--------------------------------------------------------------------------------
1 | import { createIndexTag, getIndex, isHead } from '@flipbookqr/shared';
2 |
3 | /**
4 | * Sorts frames based on their index. Ensures the head frame is always first.
5 | *
6 | * @param {string} a - The first frame to compare.
7 | * @param {string} b - The second frame to compare.
8 | * @returns {number} - Returns -1 if `a` should come before `b`, 1 if `a` should come after `b`, and 0 if they are equal.
9 | */
10 | export const sortFrames = (a: string, b: string): number => {
11 | // Make sure the head frame is first
12 | if (isHead(a)) return -1;
13 |
14 | // Otherwise, get the indexes
15 | const aIndex = getIndex(a);
16 | const bIndex = getIndex(b);
17 |
18 | // And sort by index
19 | if (aIndex < bIndex) return -1;
20 | if (aIndex > bIndex) return 1;
21 | return 0;
22 | };
23 |
24 | /**
25 | * Slices a frame string to remove everything before the index tag.
26 | *
27 | * @param {string} frame - The frame string to slice.
28 | * @returns {string} - The sliced frame string.
29 | */
30 | export const sliceFrames = (frame: string): string => {
31 | // Get the index
32 | const idx = getIndex(frame);
33 | const idxTag = createIndexTag(idx);
34 |
35 | // Remove everything before the index
36 | if (idx !== -1) {
37 | return frame.slice(frame.indexOf(idxTag) + idxTag.length + 1);
38 | }
39 |
40 | return frame;
41 | };
42 |
--------------------------------------------------------------------------------
/packages/reader/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './reader';
2 | export * from './processors';
3 |
--------------------------------------------------------------------------------
/packages/reader/src/processors/file-processor.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | import { GifReader } from 'omggif';
4 | import { FileProcessor } from './file-processor';
5 | import {
6 | convertFileToBuffer,
7 | convertUnit8ClampedArrayToFile,
8 | } from '@flipbookqr/shared';
9 |
10 | // Mock dependencies
11 | jest.mock('omggif', () => ({
12 | GifReader: jest.fn(),
13 | }));
14 |
15 | // Mock the entire @flipbookqr/shared module
16 | jest.mock('@flipbookqr/shared', () => ({
17 | ...jest.requireActual('@flipbookqr/shared'),
18 | convertFileToBuffer: jest.fn(),
19 | convertUnit8ClampedArrayToFile: jest.fn(),
20 | }));
21 |
22 | global.URL.createObjectURL = jest.fn(
23 | (blob: Blob) => `blob:${blob.size}#t=${Date.now()}`
24 | );
25 |
26 | describe('FileProcessor', () => {
27 | let mockFile: File;
28 |
29 | beforeEach(() => {
30 | // Create a mock File object
31 | mockFile = new File(['dummy content'], 'dummy.png', {
32 | type: 'image/png',
33 | });
34 |
35 | // Clear all mocks
36 | jest.clearAllMocks();
37 | });
38 |
39 | it('should remove canvas when destroy is called', () => {
40 | const processor = new FileProcessor();
41 | processor['_canvas'] = {
42 | remove: jest.fn(),
43 | } as unknown as HTMLCanvasElement;
44 |
45 | processor['destroy']();
46 |
47 | expect(processor['_canvas'].remove).toHaveBeenCalled();
48 | });
49 |
50 | it('should process a single frame and extract QR code data', async () => {
51 | const processor = new FileProcessor();
52 | const mockFrameData = 'mock-frame-data';
53 |
54 | // Mock getFrameData to return some mock frame data
55 | const setFrameSpy = jest
56 |
57 | .spyOn(processor as any, 'setFrame')
58 | .mockImplementation(() => {});
59 | const getFrameDataSpy = jest
60 |
61 | .spyOn(processor as any, 'getFrameData')
62 | .mockReturnValue({ data: mockFrameData });
63 |
64 | // Mock the Image constructor and simulate onload event
65 | const mockImage = {
66 | onload: jest.fn(),
67 | onerror: jest.fn(),
68 | src: '',
69 | };
70 |
71 | (global as any).Image = jest.fn(() => mockImage); // Mock the Image object
72 |
73 | const processSingleFramePromise = processor['processSingleFrame'](mockFile);
74 |
75 | // Simulate the image loading by calling the onload function
76 | mockImage.onload();
77 |
78 | const result = await processSingleFramePromise;
79 |
80 | expect(setFrameSpy).toHaveBeenCalled();
81 | expect(getFrameDataSpy).toHaveBeenCalled();
82 | expect(result).toBe(mockFrameData);
83 | });
84 |
85 | it('should throw an error if processing a single frame fails', async () => {
86 | const processor = new FileProcessor();
87 |
88 | const createObjectURLSpy = jest
89 | .spyOn(URL, 'createObjectURL')
90 | .mockImplementation(() => {
91 | throw new Error('Failed to process frame');
92 | });
93 |
94 | await expect(processor['processSingleFrame'](mockFile)).rejects.toThrow(
95 | 'Failed to process frame'
96 | );
97 |
98 | expect(createObjectURLSpy).toHaveBeenCalled();
99 | });
100 |
101 | it('should process all frames of a GIF file', async () => {
102 | const processor = new FileProcessor();
103 |
104 | // Mock the helper functions
105 | const mockBuffer = new ArrayBuffer(10);
106 | (convertFileToBuffer as jest.Mock).mockResolvedValue(mockBuffer);
107 |
108 | const mockGifReader = {
109 | numFrames: jest.fn().mockReturnValue(3),
110 | decodeAndBlitFrameRGBA: jest.fn(),
111 | width: 100,
112 | height: 100,
113 | };
114 | (GifReader as jest.Mock).mockImplementation(() => mockGifReader);
115 |
116 | // Mock single frame processing
117 | jest
118 |
119 | .spyOn(processor as any, 'processSingleFrame')
120 | .mockResolvedValue('QRData');
121 |
122 | const result = await processor['processAllFrames']();
123 |
124 | expect(convertFileToBuffer).toHaveBeenCalled();
125 | expect(GifReader).toHaveBeenCalledWith(new Uint8Array(mockBuffer));
126 | expect(mockGifReader.numFrames).toHaveBeenCalled();
127 | expect(mockGifReader.decodeAndBlitFrameRGBA).toHaveBeenCalledTimes(3);
128 | expect(convertUnit8ClampedArrayToFile).toHaveBeenCalledTimes(3);
129 | expect(result).toEqual(['QRData', 'QRData', 'QRData']);
130 | });
131 |
132 | it('should check if a file is a GIF', () => {
133 | const processor = new FileProcessor();
134 |
135 | const gifFile = new File(['dummy content'], 'dummy.gif', {
136 | type: 'image/gif',
137 | });
138 | const nonGifFile = new File(['dummy content'], 'dummy.png', {
139 | type: 'image/png',
140 | });
141 |
142 | expect(processor['isGIF'](gifFile)).toBe(true);
143 | expect(processor['isGIF'](nonGifFile)).toBe(false);
144 | });
145 |
146 | it('should read and process all frames for a GIF file', async () => {
147 | const processor = new FileProcessor();
148 |
149 | jest.spyOn(processor as any, 'isGIF').mockReturnValue(true);
150 | jest
151 |
152 | .spyOn(processor as any, 'processAllFrames')
153 | .mockResolvedValue(['QRData1', 'QRData2']);
154 |
155 | const result = await processor.read(mockFile);
156 |
157 | expect(processor['isGIF']).toHaveBeenCalledWith(mockFile);
158 | expect(processor['processAllFrames']).toHaveBeenCalled();
159 | expect(result).toBe('QRData1QRData2');
160 | });
161 |
162 | it('should read and process a single frame for a non-GIF file', async () => {
163 | const processor = new FileProcessor();
164 |
165 | jest.spyOn(processor as any, 'isGIF').mockReturnValue(false);
166 | jest
167 |
168 | .spyOn(processor as any, 'processSingleFrame')
169 | .mockResolvedValue('QRData');
170 |
171 | const result = await processor.read(mockFile);
172 |
173 | expect(processor['isGIF']).toHaveBeenCalledWith(mockFile);
174 | expect(processor['processSingleFrame']).toHaveBeenCalledWith(mockFile);
175 | expect(result).toBe('QRData');
176 | });
177 | });
178 |
--------------------------------------------------------------------------------
/packages/reader/src/processors/file-processor.ts:
--------------------------------------------------------------------------------
1 | import { GifReader } from 'omggif';
2 | import {
3 | convertFileToBuffer,
4 | convertUnit8ClampedArrayToFile,
5 | } from '@flipbookqr/shared';
6 | import { FrameProcessor } from './frame-processor';
7 | import { sliceFrames, sortFrames } from '../helpers';
8 |
9 | /**
10 | * Class for processing image or GIF files to extract QR code data.
11 | *
12 | * @extends FrameProcessor
13 | */
14 | export class FileProcessor extends FrameProcessor {
15 | protected _file?: File;
16 |
17 | /**
18 | * Constructs a new FileProcessor instance.
19 | */
20 | constructor() {
21 | // Initialize the processor
22 | super();
23 | }
24 |
25 | /**
26 | * Cleans up by removing the canvas element from the DOM.
27 | */
28 | protected destroy(): void {
29 | // Remove the canvas element
30 | this._canvas.remove();
31 | }
32 |
33 | /**
34 | * Processes a single image frame and extracts QR code data.
35 | *
36 | * @returns {Promise} A promise that resolves to the extracted QR code data, or an empty string if no data is found.
37 | */
38 | protected processSingleFrame(file: File): Promise {
39 | return new Promise((resolve, reject) => {
40 | try {
41 | // Create a new image element
42 | const img = new Image();
43 |
44 | // When the image is loaded, extract the QR code data
45 | img.onload = () => {
46 | this.setFrame(img);
47 | const frameData = this.getFrameData();
48 | resolve(frameData?.data || '');
49 | };
50 |
51 | // When an error occurs, reject the promise
52 | img.onerror = reject;
53 |
54 | // Set the image source to the file URL
55 | img.src = URL.createObjectURL(file);
56 | } catch (error) {
57 | reject(new Error('Failed to process frame'));
58 | }
59 | });
60 | }
61 |
62 | /**
63 | * Processes all frames in a GIF file and extracts QR code data from each frame.
64 | *
65 | * @returns {Promise} A promise that resolves to an array of QR code data from each frame.
66 | */
67 | protected async processAllFrames(): Promise {
68 | // Convert the GIF file to an ArrayBuffer
69 | const buffer = await convertFileToBuffer(this._file!);
70 |
71 | // Create a reader
72 | const reader = new GifReader(new Uint8Array(buffer));
73 |
74 | // Convert each frame to a File object
75 | const files: File[] = Array.from({ length: reader.numFrames() }).map(
76 | (_, i) => {
77 | // Decode and store the raw data of the QR code
78 | const data: number[] = [];
79 | reader.decodeAndBlitFrameRGBA(i, data);
80 |
81 | // Convert the data to a Uint8ClampedArray and then to a file
82 | return convertUnit8ClampedArrayToFile(
83 | new Uint8ClampedArray(data),
84 | reader.width,
85 | reader.height,
86 | `${Date.now()}.png`
87 | );
88 | }
89 | );
90 |
91 | // Process each frame
92 | const dataPromises: Promise[] = files.map((file) =>
93 | this.processSingleFrame(file)
94 | );
95 |
96 | // Return the QR code data from each frame
97 | return await Promise.all(dataPromises);
98 | }
99 |
100 | /**
101 | * Determines whether the provided file is a GIF image.
102 | *
103 | * @param {File} file - The file to check.
104 | * @returns {boolean} True if the file is a GIF, false otherwise.
105 | */
106 | protected isGIF(file: File): boolean {
107 | // Check the file extension and MIME type
108 | const extension = file.name.split('.').pop()?.toLowerCase();
109 | return extension === 'gif' || file.type.startsWith('image/gif');
110 | }
111 |
112 | /**
113 | * Reads and processes the file (either single frame or GIF) to extract QR code data.
114 | *
115 | * @param {File} file - The file to process.
116 | * @returns {Promise} A promise that resolves to the processed QR code data as a string.
117 | */
118 | async read(file: File): Promise {
119 | // Store the file
120 | this._file = file;
121 |
122 | // If the file is a GIF, process all frames
123 | if (this.isGIF(this._file)) {
124 | const allFrames = await this.processAllFrames();
125 | return allFrames.sort(sortFrames).map(sliceFrames).join('');
126 | }
127 |
128 | // Otherwise, process a single frame
129 | else {
130 | return await this.processSingleFrame(this._file);
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/packages/reader/src/processors/frame-processor.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | import { getLogger } from '@flipbookqr/shared';
4 | import { Decoder } from '@nuintun/qrcode';
5 | import { FrameProcessor } from './frame-processor';
6 |
7 | // Mock the external dependencies
8 | jest.mock('@flipbookqr/shared', () => ({
9 | getLogger: jest.fn(),
10 | }));
11 |
12 | jest.mock('@nuintun/qrcode', () => ({
13 | Decoder: jest.fn().mockImplementation(() => ({
14 | decode: jest.fn(),
15 | })),
16 | }));
17 |
18 | // Create a concrete implementation of FrameProcessor for testing
19 | class TestFrameProcessor extends FrameProcessor {
20 | async read(): Promise {
21 | return 'test';
22 | }
23 | }
24 |
25 | describe('FrameProcessor', () => {
26 | let frameProcessor: TestFrameProcessor;
27 |
28 | let mockLogger: any;
29 |
30 | let mockDecoder: any;
31 |
32 | beforeEach(() => {
33 | // Set up mocks
34 | mockLogger = { info: jest.fn(), error: jest.fn() };
35 | (getLogger as jest.Mock).mockReturnValue(mockLogger);
36 |
37 | // Initialize the FrameProcessor
38 | frameProcessor = new TestFrameProcessor();
39 |
40 | // Mock the decoder's decode method
41 | mockDecoder = {
42 | decode: jest.fn(),
43 | };
44 |
45 | // Mock the Decoder constructor to return the mockDecoder
46 | (Decoder as jest.Mock).mockImplementation(() => mockDecoder);
47 | });
48 |
49 | afterEach(() => {
50 | jest.clearAllMocks();
51 | });
52 |
53 | describe('constructor', () => {
54 | it('should set up logger and canvas correctly', () => {
55 | expect(getLogger).toHaveBeenCalled();
56 |
57 | expect((frameProcessor as any)._log).toBe(mockLogger);
58 |
59 | expect((frameProcessor as any)._canvas).toBeInstanceOf(HTMLCanvasElement);
60 |
61 | expect((frameProcessor as any)._width).toBe(1920);
62 |
63 | expect((frameProcessor as any)._height).toBe(1080);
64 |
65 | expect((frameProcessor as any)._ctx).not.toBeNull();
66 | });
67 | });
68 |
69 | describe('setFrame', () => {
70 | it('should update canvas dimensions for HTMLImageElement', () => {
71 | const imgElement = new Image();
72 | imgElement.width = 800;
73 | imgElement.height = 600;
74 |
75 | (frameProcessor as any).setFrame(imgElement);
76 |
77 | expect((frameProcessor as any)._width).toBe(800);
78 |
79 | expect((frameProcessor as any)._height).toBe(600);
80 |
81 | expect((frameProcessor as any)._canvas.width).toBe(800);
82 |
83 | expect((frameProcessor as any)._canvas.height).toBe(600);
84 |
85 | expect((frameProcessor as any)._ctx?.drawImage).toHaveBeenCalledWith(
86 | imgElement,
87 | 0,
88 | 0,
89 | 800,
90 | 600
91 | );
92 | });
93 |
94 | it('should update canvas dimensions for HTMLVideoElement', () => {
95 | const videoElement = document.createElement('video');
96 | Object.defineProperty(videoElement, 'videoWidth', { value: 1280 });
97 | Object.defineProperty(videoElement, 'videoHeight', { value: 720 });
98 |
99 | (frameProcessor as any).setFrame(videoElement);
100 |
101 | expect((frameProcessor as any)._width).toBe(1280);
102 |
103 | expect((frameProcessor as any)._height).toBe(720);
104 |
105 | expect((frameProcessor as any)._canvas.width).toBe(1280);
106 |
107 | expect((frameProcessor as any)._canvas.height).toBe(720);
108 |
109 | expect((frameProcessor as any)._ctx?.drawImage).toHaveBeenCalledWith(
110 | videoElement,
111 | 0,
112 | 0,
113 | 1280,
114 | 720
115 | );
116 | });
117 |
118 | it('should throw an error for unsupported frame type', () => {
119 | const invalidSource = document.createElement('div'); // Invalid frame type
120 |
121 | expect(() => {
122 | (frameProcessor as any).setFrame(invalidSource as any);
123 | }).toThrow('Unsupported frame type');
124 | });
125 | });
126 |
127 | describe('getFrameData', () => {
128 | it('should return null if no image data is available', () => {
129 | (frameProcessor as any)._ctx = {
130 | getImageData: jest.fn().mockReturnValue(null),
131 | } as any;
132 |
133 | const result = (frameProcessor as any).getFrameData();
134 | expect(result).toBeNull();
135 | });
136 |
137 | it('should return decoded QR code data if available', () => {
138 | const imageData = {
139 | data: new Uint8ClampedArray([255, 255, 255, 255]),
140 | width: 800,
141 | height: 600,
142 | };
143 |
144 | // Mock the canvas context with getImageData returning imageData
145 |
146 | (frameProcessor as any)._ctx = {
147 | getImageData: jest.fn().mockReturnValue(imageData),
148 | } as any;
149 |
150 | const decodedData = { data: 'QR Code Data' };
151 |
152 | // Mock the decode method to return decodedData
153 | mockDecoder.decode.mockReturnValue(decodedData);
154 |
155 | // Call the method
156 |
157 | const result = (frameProcessor as any).getFrameData();
158 |
159 | // Assert that the result is the decoded data
160 | expect(result).toBe(decodedData);
161 |
162 | // Verify that decode was called with the right parameters
163 | expect(mockDecoder.decode).toHaveBeenCalledWith(
164 | imageData.data,
165 | imageData.width,
166 | imageData.height
167 | );
168 | });
169 | });
170 |
171 | describe('read', () => {
172 | it('should return a string value', async () => {
173 | const result = await frameProcessor.read();
174 | expect(result).toBe('test');
175 | });
176 | });
177 | });
178 |
--------------------------------------------------------------------------------
/packages/reader/src/processors/frame-processor.ts:
--------------------------------------------------------------------------------
1 | import { getLogger } from '@flipbookqr/shared';
2 | import { Decoder } from '@nuintun/qrcode';
3 | import type { Logger } from 'loglevel';
4 |
5 | export abstract class FrameProcessor {
6 | protected _ctx: CanvasRenderingContext2D | null;
7 | protected _canvas: HTMLCanvasElement;
8 | protected _width: number;
9 | protected _height: number;
10 | protected _log: Logger;
11 |
12 | constructor() {
13 | // Set up logger
14 | this._log = getLogger();
15 |
16 | // Create canvas element
17 | const canvas = document.createElement('canvas');
18 | this._width = 1920;
19 | this._height = 1080;
20 | this._canvas = canvas;
21 | this._ctx = canvas.getContext('2d');
22 | }
23 |
24 | /**
25 | * Sets the specified frame on the canvas.
26 | *
27 | * @param {HTMLVideoElement | HTMLImageElement | ImageBitmap} source - The source to draw on the canvas.
28 | * @throws Will throw an error if an unsupported frame type is provided.
29 | */
30 | protected setFrame(
31 | source: HTMLVideoElement | HTMLImageElement | ImageBitmap
32 | ): void {
33 | // Store the source dimensions
34 | let width, height;
35 |
36 | // Get the source dimensions
37 | if (source instanceof HTMLImageElement || source instanceof ImageBitmap) {
38 | width = source.width;
39 | height = source.height;
40 | } else if (source instanceof HTMLVideoElement) {
41 | width = source.videoWidth;
42 | height = source.videoHeight;
43 | } else {
44 | throw new Error('Unsupported frame type');
45 | }
46 |
47 | // Update the canvas dimensions
48 | this._width = width;
49 | this._height = height;
50 | this._canvas.width = width;
51 | this._canvas.height = height;
52 |
53 | // Draw the frame on the canvas
54 | this._ctx?.drawImage(source, 0, 0, this._width, this._height);
55 | }
56 |
57 | /**
58 | * Retrieves QR code data from the current frame.
59 | *
60 | * @returns {QRCode | null} The decoded QR code data, or null if no data was found.
61 | */
62 | protected getFrameData(): ReturnType | null {
63 | // Get the frame data from the canvas
64 | const imageData = this._ctx?.getImageData(
65 | 0,
66 | 0,
67 | this._canvas.width,
68 | this._canvas.height
69 | );
70 |
71 | // If no frame data is available, return null
72 | if (!imageData) {
73 | return null;
74 | }
75 |
76 | // Create a new QR code decoder
77 | const qrcode = new Decoder();
78 |
79 | // Decode the QR code data
80 | const results = qrcode.decode(
81 | imageData.data,
82 | imageData.width,
83 | imageData.height
84 | );
85 |
86 | // Return the decoded data
87 | return results;
88 | }
89 |
90 | abstract read(file?: File): Promise;
91 | }
92 |
--------------------------------------------------------------------------------
/packages/reader/src/processors/index.ts:
--------------------------------------------------------------------------------
1 | export * from './webrtc-processor';
2 | export * from './frame-processor';
3 | export * from './file-processor';
4 |
--------------------------------------------------------------------------------
/packages/reader/src/processors/webrtc-processor.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | import { getHeadLength } from '@flipbookqr/shared';
4 | import { WebRTCProcessor } from './webrtc-processor'; // Adjust the path if needed
5 |
6 | // Mock dependencies
7 | jest.mock('@flipbookqr/shared', () => ({
8 | ...jest.requireActual('@flipbookqr/shared'),
9 | getHeadLength: jest.fn(),
10 | }));
11 |
12 | // Mock MediaStream and related Web API methods
13 | global.MediaStream = jest.fn(() => ({
14 | getVideoTracks: jest.fn().mockReturnValue(['mockTrack']),
15 | })) as any;
16 |
17 | // Mock HTMLVideoElement play method
18 | global.HTMLVideoElement.prototype.play = jest.fn().mockResolvedValue(undefined);
19 |
20 | describe('WebRTCProcessor', () => {
21 | let processor: WebRTCProcessor;
22 | let logSpy: jest.SpyInstance;
23 | let errorSpy: jest.SpyInstance;
24 | let playSpy: jest.SpyInstance;
25 |
26 | beforeEach(() => {
27 | processor = new WebRTCProcessor('display', { video: true, audio: false });
28 |
29 | // Spy on the logger
30 | logSpy = jest
31 | .spyOn(processor['_log'], 'debug')
32 | .mockImplementation(jest.fn());
33 | errorSpy = jest
34 | .spyOn(processor['_log'], 'error')
35 | .mockImplementation(jest.fn());
36 |
37 | // Mock navigator.mediaDevices and its methods
38 | Object.defineProperty(navigator, 'mediaDevices', {
39 | writable: true,
40 | value: {
41 | getDisplayMedia: jest.fn().mockResolvedValue(new MediaStream()),
42 | getUserMedia: jest.fn().mockResolvedValue(new MediaStream()),
43 | },
44 | });
45 |
46 | // Mock document.body.appendChild
47 | document.body.appendChild = jest.fn();
48 |
49 | // Mock the HTMLVideoElement play method
50 | playSpy = jest
51 | .spyOn(HTMLVideoElement.prototype, 'play')
52 | .mockResolvedValue(undefined);
53 | });
54 |
55 | afterEach(() => {
56 | jest.clearAllMocks();
57 | });
58 |
59 | describe('startVideo', () => {
60 | beforeEach(() => {
61 | // Mock getDisplayMedia and getUserMedia
62 | navigator.mediaDevices.getDisplayMedia = jest
63 | .fn()
64 | .mockResolvedValue(new MediaStream());
65 | navigator.mediaDevices.getUserMedia = jest
66 | .fn()
67 | .mockResolvedValue(new MediaStream());
68 | });
69 |
70 | it('should set the first track and create a video element', async () => {
71 | await (processor as any).startVideo();
72 |
73 | expect(processor['_track']).toBeDefined();
74 | expect(processor['_video']).toBeDefined();
75 | expect(document.body.appendChild).toHaveBeenCalled();
76 |
77 | // Check the correct order of log calls
78 | expect(logSpy).toHaveBeenCalledTimes(2);
79 | // expect(logSpy).toHaveBeenNthCalledWith(1, 'Got capture stream', {
80 | // getVideoTracks: MediaStream,
81 | // });
82 | expect(logSpy).toHaveBeenNthCalledWith(
83 | 2,
84 | 'Got video element',
85 | processor['_video']
86 | );
87 | });
88 |
89 | it('should play the video when it is started', async () => {
90 | await (processor as any).startVideo();
91 |
92 | // Check that the play() method is called
93 | expect(playSpy).toHaveBeenCalled();
94 | });
95 | });
96 |
97 | describe('getStreamTracks', () => {
98 | it('should get display media tracks when media type is "display"', async () => {
99 | const mockMediaStream = new MediaStream();
100 | navigator.mediaDevices.getDisplayMedia = jest
101 | .fn()
102 | .mockResolvedValue(mockMediaStream);
103 |
104 | const tracks = await processor.getStreamTracks();
105 |
106 | expect(tracks).toEqual(['mockTrack']);
107 | expect(logSpy).toHaveBeenCalledWith(
108 | 'Got capture stream',
109 | mockMediaStream
110 | );
111 | });
112 |
113 | it('should get user media tracks when media type is "camera"', async () => {
114 | const cameraProcessor = new WebRTCProcessor('camera', { video: true });
115 | const mockMediaStream = new MediaStream();
116 | navigator.mediaDevices.getUserMedia = jest
117 | .fn()
118 | .mockResolvedValue(mockMediaStream);
119 |
120 | const tracks = await cameraProcessor.getStreamTracks();
121 |
122 | expect(tracks).toEqual(['mockTrack']);
123 | });
124 | });
125 |
126 | describe('destroy', () => {
127 | let mockCanvas: HTMLCanvasElement;
128 | let mockVideo: HTMLVideoElement;
129 |
130 | beforeEach(() => {
131 | // Create a mock canvas element
132 | mockCanvas = document.createElement('canvas');
133 | mockCanvas.remove = jest.fn();
134 |
135 | // Create a mock video element
136 | mockVideo = document.createElement('video');
137 | mockVideo.remove = jest.fn();
138 |
139 | // Assign the mock elements to the processor
140 |
141 | (processor as any)._canvas = mockCanvas;
142 |
143 | (processor as any)._video = mockVideo;
144 | });
145 |
146 | it('should remove the canvas and video elements', () => {
147 | // Call destroy
148 | (processor as any).destroy();
149 |
150 | // Verify that the canvas and video elements' remove methods were called
151 | expect(mockCanvas.remove).toHaveBeenCalled();
152 | expect(mockVideo.remove).toHaveBeenCalled();
153 | });
154 |
155 | it('should remove the canvas element even if there is no video element', () => {
156 | // Set the video to undefined
157 | (processor as any)._video = undefined;
158 |
159 | // Call destroy
160 | (processor as any).destroy();
161 |
162 | // Verify that only the canvas element's remove method was called
163 | expect(mockCanvas.remove).toHaveBeenCalled();
164 | });
165 | });
166 |
167 | describe('processSingleFrame', () => {
168 | let mockCanvas: HTMLCanvasElement;
169 | let mockVideo: HTMLVideoElement;
170 |
171 | beforeEach(() => {
172 | // Mock canvas and video elements
173 | mockCanvas = document.createElement('canvas');
174 | mockVideo = document.createElement('video');
175 | processor['_canvas'] = mockCanvas;
176 | processor['_video'] = mockVideo;
177 |
178 | // Mock getFrameData to return a QR code result
179 | jest
180 | .spyOn(processor as any, 'getFrameData')
181 | .mockReturnValue({ data: 'mockQRCode' });
182 |
183 | // Mock setFrame to set the video frame on the canvas
184 | jest.spyOn(processor as any, 'setFrame').mockImplementation(() => {});
185 |
186 | // Mock getHeadLength to return a head length
187 | (getHeadLength as jest.Mock).mockReturnValue(5);
188 | });
189 |
190 | it('should process a single frame and add the QR code data to the set', () => {
191 | // Call processSingleFrame
192 | (processor as any).processSingleFrame();
193 |
194 | // Verify that the QR code data was added to the set
195 | expect(processor['_allFrames'].has('mockQRCode')).toBe(true);
196 | });
197 |
198 | it('should update the number of expected frames if head length is found', () => {
199 | // Call processSingleFrame
200 | (processor as any).processSingleFrame();
201 |
202 | // Verify that the number of expected frames is updated
203 | expect(processor['_numExpectedFrames']).toBe(5);
204 | });
205 |
206 | it('should not add empty QR code data to the set', () => {
207 | // Mock getFrameData to return an empty string
208 | jest
209 | .spyOn(processor as any, 'getFrameData')
210 | .mockReturnValue({ data: '' });
211 |
212 | // Call processSingleFrame
213 | (processor as any).processSingleFrame();
214 |
215 | // Verify that no data was added to the set
216 | expect(processor['_allFrames'].size).toBe(0);
217 | });
218 |
219 | it('should handle errors and log them', () => {
220 | // Mock setFrame to throw an error
221 | jest.spyOn(processor as any, 'setFrame').mockImplementation(() => {
222 | throw new Error('mockError');
223 | });
224 |
225 | // Call processSingleFrame
226 | (processor as any).processSingleFrame();
227 |
228 | // Verify that the error was logged
229 | expect(errorSpy).toHaveBeenCalledWith(
230 | 'Error processing frame:',
231 | new Error('mockError')
232 | );
233 | });
234 | });
235 |
236 | describe('processAllFrames', () => {
237 | let requestAnimationFrameSpy: jest.SpyInstance;
238 | let processSingleFrameSpy: jest.SpyInstance;
239 |
240 | beforeEach(() => {
241 | jest.useFakeTimers(); // Use fake timers to control requestAnimationFrame
242 |
243 | // Mock requestAnimationFrame globally
244 | requestAnimationFrameSpy = jest
245 | .spyOn(window, 'requestAnimationFrame')
246 | .mockImplementation((callback) => {
247 | setTimeout(() => callback(0), 16); // Simulate a frame every 16ms (roughly 60fps)
248 | return 1; // Return a mock animation frame ID
249 | });
250 |
251 | // Spy on processSingleFrame method
252 | processSingleFrameSpy = jest
253 | .spyOn(processor as any, 'processSingleFrame')
254 | .mockImplementation(() => {
255 | // Simulate processing of a frame and adding it to the set
256 | processor['_allFrames'].add(
257 | `mockQRCodeData${processor['_allFrames'].size + 1}`
258 | );
259 | });
260 | });
261 |
262 | afterEach(() => {
263 | jest.clearAllMocks();
264 | jest.useRealTimers(); // Restore timers after each test
265 | });
266 |
267 | it('should resolve with an empty array if no video element is present', async () => {
268 | // Set video to undefined
269 | processor['_video'] = undefined;
270 |
271 | const result = await (processor as any).processAllFrames();
272 |
273 | // Check that an empty array is returned
274 | expect(result).toEqual([]);
275 |
276 | // Check that the "Processing all frames" message is logged first
277 | expect(logSpy).toHaveBeenCalledWith('Processing all frames');
278 |
279 | // Check that the "No video element to process" message is logged next
280 | expect(errorSpy).toHaveBeenCalledWith('No video element to process');
281 | });
282 |
283 | it('should process frames until the expected number of frames is reached', async () => {
284 | processor['_video'] = document.createElement('video'); // Mock video element
285 | processor['_numExpectedFrames'] = 3; // Simulate expecting 3 frames
286 |
287 | const resultPromise = (processor as any).processAllFrames();
288 |
289 | // Simulate the passage of time and frame processing
290 | expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
291 | jest.advanceTimersByTime(16); // Move time forward for frame 1
292 | expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(2);
293 | jest.advanceTimersByTime(16); // Move time forward for frame 2
294 | expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(3);
295 | jest.advanceTimersByTime(16); // Move time forward for frame 3
296 |
297 | // Await the promise resolution
298 | const result = await resultPromise;
299 |
300 | // Check that processSingleFrame was called the correct number of times
301 | expect(processSingleFrameSpy).toHaveBeenCalledTimes(3);
302 |
303 | // Ensure all frames are returned as an array
304 | expect(result).toEqual([
305 | 'mockQRCodeData1',
306 | 'mockQRCodeData2',
307 | 'mockQRCodeData3',
308 | ]);
309 |
310 | // Ensure logging for frame processing was done
311 | expect(logSpy).toHaveBeenCalledWith('All frames processed');
312 | });
313 | });
314 |
315 | describe('read', () => {
316 | let startVideoSpy: jest.SpyInstance;
317 | let processAllFramesSpy: jest.SpyInstance;
318 | let destroySpy: jest.SpyInstance;
319 |
320 | beforeEach(() => {
321 | // Mock MediaStreamTrack globally with a stop method
322 | global.MediaStreamTrack = jest.fn().mockImplementation(() => ({
323 | stop: jest.fn(), // Mock the stop method
324 | })) as any;
325 |
326 | // Spy on the startVideo, processAllFrames, and destroy methods
327 | startVideoSpy = jest
328 | .spyOn(processor as any, 'startVideo')
329 | .mockResolvedValue(undefined);
330 | processAllFramesSpy = jest
331 | .spyOn(processor as any, 'processAllFrames')
332 | .mockResolvedValue(['frame1', 'frame2', 'frame3']);
333 | destroySpy = jest
334 | .spyOn(processor as any, 'destroy')
335 | .mockImplementation(() => {});
336 |
337 | processor['_track'] = new MediaStreamTrack(); // Mock a track
338 | processor['_video'] = document.createElement('video'); // Mock a video element
339 | });
340 |
341 | afterEach(() => {
342 | jest.clearAllMocks();
343 | });
344 |
345 | it('should call startVideo if video or track is not initialized', async () => {
346 | processor['_video'] = undefined; // Simulate no video
347 |
348 | await processor.read();
349 |
350 | expect(startVideoSpy).toHaveBeenCalled();
351 | });
352 |
353 | it('should process all frames and return sorted QR code data', async () => {
354 | const result = await processor.read();
355 |
356 | // Verify that processAllFrames is called
357 | expect(processAllFramesSpy).toHaveBeenCalled();
358 |
359 | // Ensure frames are processed, sorted, sliced, and joined
360 | expect(result).toBe('frame1frame2frame3');
361 | });
362 |
363 | it('should stop the video track after processing', async () => {
364 | await processor.read();
365 |
366 | // Check that the stop method was called
367 | expect(processor['_track']!.stop).toHaveBeenCalled();
368 | expect(destroySpy).toHaveBeenCalled();
369 | });
370 |
371 | it('should reject if an error occurs', async () => {
372 | // Force an error to occur
373 | processAllFramesSpy.mockRejectedValue(new Error('mockError'));
374 |
375 | await expect(processor.read()).rejects.toThrow('mockError');
376 | });
377 |
378 | it('should not call startVideo if video and track are already initialized', async () => {
379 | await processor.read();
380 |
381 | // Ensure startVideo is not called since video and track are already initialized
382 | expect(startVideoSpy).not.toHaveBeenCalled();
383 | });
384 | });
385 | });
386 |
--------------------------------------------------------------------------------
/packages/reader/src/processors/webrtc-processor.ts:
--------------------------------------------------------------------------------
1 | import { getHeadLength } from '@flipbookqr/shared';
2 | import { sliceFrames, sortFrames } from '../helpers';
3 | import { FrameProcessor } from './frame-processor';
4 |
5 | type MediaType = 'display' | 'camera';
6 | type MediaOptions = DisplayMediaStreamOptions | MediaStreamConstraints;
7 |
8 | /**
9 | * A class that processes WebRTC media streams, such as video from a camera or display,
10 | * and extracts QR code data from the frames.
11 | *
12 | * @extends FrameProcessor
13 | */
14 | export class WebRTCProcessor extends FrameProcessor {
15 | protected _video: HTMLVideoElement | undefined;
16 | protected _track: MediaStreamTrack | undefined;
17 | protected _mediaType: MediaType;
18 | protected _mediaOptions: MediaOptions;
19 | protected _allFrames: Set = new Set();
20 | protected _numExpectedFrames: number | undefined;
21 |
22 | /**
23 | * Creates an instance of WebRTCProcessor.
24 | *
25 | * @param {MediaType} [mediaType='display'] - Type of media to process ('display' or 'camera').
26 | * @param {MediaOptions} [options={ video: true, audio: false }] - Media stream options for capturing video/audio.
27 | */
28 | constructor(mediaType?: MediaType, options?: MediaOptions) {
29 | // Initialize the processor
30 | super();
31 |
32 | // Initialize video and track
33 | this._video = undefined;
34 | this._track = undefined;
35 |
36 | // Set media type and options
37 | this._mediaType = mediaType || 'display';
38 | this._mediaOptions = options || { video: true, audio: false };
39 | }
40 |
41 | /**
42 | * Cleans up by removing the canvas and video elements.
43 | */
44 | protected destroy(): void {
45 | // Remove the canvas and video elements
46 | this._canvas.remove();
47 | if (this._video) this._video.remove();
48 | }
49 |
50 | /**
51 | * Processes all frames and decodes QR codes from the video stream.
52 | *
53 | * @returns {Promise} A promise that resolves with an array of QR code data strings.
54 | */
55 | protected processAllFrames(): Promise {
56 | return new Promise((resolve) => {
57 | // Create a Set to store all frames and a variable to store the number of expected frames
58 | this._log.debug('Processing all frames');
59 |
60 | // If no video element is present, log an error and resolve with an empty array
61 | if (!this._video) {
62 | this._log.error('No video element to process');
63 | resolve([]);
64 | return;
65 | }
66 |
67 | // Start processing frames using requestAnimationFrame
68 | const processFrames = (): void => {
69 | // Call the method to process a single frame
70 | this.processSingleFrame();
71 |
72 | // If we still need more frames, wait for the next frame
73 | if (
74 | this._numExpectedFrames === undefined ||
75 | this._allFrames.size !== this._numExpectedFrames
76 | ) {
77 | requestAnimationFrame(processFrames);
78 | }
79 |
80 | // Otherwise, resolve with the array of frames
81 | else {
82 | this._log.debug('All frames processed');
83 | resolve(Array.from(this._allFrames));
84 | }
85 | };
86 |
87 | // Start processing the first frame
88 | requestAnimationFrame(processFrames);
89 | });
90 | }
91 |
92 | /**
93 | * Processes a single frame, decodes it, and updates the QR code data set.
94 | */
95 | protected processSingleFrame(): void {
96 | try {
97 | // Set the current frame onto the canvas
98 | this.setFrame(this._video!);
99 |
100 | // Get the QR code data from the frame
101 | const result = this.getFrameData();
102 | const code = result && 'data' in result ? result.data : '';
103 |
104 | // Add the QR code data to the set if it is not already present
105 | if (code !== '' && !this._allFrames.has(code)) {
106 | this._allFrames.add(code);
107 |
108 | // If the head length is found, update the number of expected frames
109 | const headLength = getHeadLength(code);
110 | if (headLength !== -1) {
111 | this._numExpectedFrames = headLength;
112 | }
113 | }
114 | } catch (error) {
115 | this._log.error('Error processing frame:', error);
116 | }
117 | }
118 |
119 | /**
120 | * Starts the video element and begins capturing the video stream.
121 | * If no track has been set, it selects the first available track.
122 | *
123 | * @returns {Promise} A promise that resolves when the video starts playing.
124 | */
125 | protected async startVideo(): Promise {
126 | // If no track is set, get the tracks from the stream and set the first track
127 | if (!this._track) {
128 | const tracks = await this.getStreamTracks();
129 | this.setStreamTrack(tracks[0]!);
130 | }
131 |
132 | // Create a new media stream with the track
133 | const mediaStream = new MediaStream([this._track!]);
134 |
135 | // Create a new video element and set the media stream
136 | const video = document.createElement('video');
137 | video.srcObject = mediaStream;
138 | video.style.display = 'none'; // Hide the video element
139 | document.body.appendChild(video);
140 |
141 | // Set the video element
142 | this._video = video;
143 |
144 | this._log.debug('Got video element', video);
145 |
146 | // Play the video
147 | await video.play();
148 | }
149 |
150 | /**
151 | * Captures video tracks from the media stream based on the selected media type.
152 | *
153 | * @returns {Promise} A promise that resolves with the video tracks.
154 | */
155 | async getStreamTracks(): Promise {
156 | // Store the capture stream
157 | let captureStream: MediaStream;
158 |
159 | // If the media type is 'display', get the display media stream
160 | if (this._mediaType === 'display') {
161 | captureStream = await navigator.mediaDevices.getDisplayMedia(
162 | this._mediaOptions
163 | );
164 | }
165 |
166 | // Otherwise, get the user media stream
167 | else {
168 | captureStream = await navigator.mediaDevices.getUserMedia(
169 | this._mediaOptions
170 | );
171 | }
172 |
173 | this._log.debug('Got capture stream', captureStream);
174 |
175 | // Return the video tracks from the capture stream
176 | return captureStream.getVideoTracks();
177 | }
178 |
179 | /**
180 | * Sets the media stream track for processing.
181 | *
182 | * @param {MediaStreamTrack} track - The media stream track to set.
183 | */
184 | setStreamTrack(track: MediaStreamTrack): void {
185 | this._track = track;
186 | }
187 |
188 | /**
189 | * Reads and processes the video stream to extract and decode QR codes.
190 | *
191 | * @returns {Promise} A promise that resolves with the decoded QR code data as a string.
192 | */
193 | async read(): Promise {
194 | try {
195 | // If no video or track is present, start the video
196 | if (!this._track || !this._video) {
197 | await this.startVideo();
198 | }
199 |
200 | this._log.debug('Got video element', this._video);
201 |
202 | // Process all frames and return the sorted frames as a single string
203 | const allFrames = await this.processAllFrames();
204 |
205 | // Stop the video track
206 | if (this._track) {
207 | this._track.stop();
208 | }
209 |
210 | this._log.debug('Stopped video track');
211 |
212 | // Destroy the processor
213 | this.destroy();
214 |
215 | // Sort and slice the frames
216 | const result = allFrames.sort(sortFrames).map(sliceFrames).join('');
217 |
218 | this._log.debug('Sorted frames', result);
219 |
220 | // Return the processed QR code data
221 | return result;
222 | } catch (e) {
223 | return Promise.reject(e);
224 | }
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/packages/reader/src/reader.test.ts:
--------------------------------------------------------------------------------
1 | import { Reader } from './reader';
2 | import { WebRTCProcessor, type FrameProcessor } from './processors';
3 | import { getLogger } from '@flipbookqr/shared';
4 |
5 | jest.mock('./processors', () => ({
6 | WebRTCProcessor: jest.fn().mockImplementation(() => ({
7 | read: jest.fn().mockResolvedValue('processed frame'),
8 | })),
9 | }));
10 |
11 | jest.mock('@flipbookqr/shared', () => ({
12 | getLogger: jest.fn().mockReturnValue({
13 | setLevel: jest.fn(),
14 | info: jest.fn(),
15 | }),
16 | }));
17 |
18 | describe('Reader', () => {
19 | it('should instantiate with default options', () => {
20 | const reader = new Reader();
21 |
22 | expect(reader.opts.logLevel).toBe('silent');
23 | expect(WebRTCProcessor).toHaveBeenCalled();
24 | expect(reader.opts.frameProcessor).toBeDefined();
25 | });
26 |
27 | it('should instantiate with provided options', () => {
28 | const customFrameProcessor = {
29 | read: jest.fn().mockResolvedValue('custom frame'),
30 | } as unknown as jest.Mocked;
31 | const reader = new Reader({
32 | logLevel: 'info',
33 | frameProcessor: customFrameProcessor,
34 | });
35 |
36 | expect(reader.opts.logLevel).toBe('info');
37 | expect(reader.opts.frameProcessor).toBe(customFrameProcessor);
38 | expect(getLogger().setLevel).toHaveBeenCalledWith('info');
39 | });
40 |
41 | it('should call frameProcessor.read() in the read method', async () => {
42 | const reader = new Reader();
43 |
44 | // Spy on the frameProcessor's read method
45 | const readSpy = jest
46 | .spyOn(reader.opts.frameProcessor, 'read')
47 | .mockResolvedValue('processed frame');
48 |
49 | const frame = await reader.read();
50 |
51 | // Assert that the read method was called
52 | expect(readSpy).toHaveBeenCalled();
53 | expect(frame).toBe('processed frame');
54 | });
55 |
56 | it('should return the result from custom frameProcessor.read()', async () => {
57 | const customFrameProcessor = {
58 | read: jest.fn().mockResolvedValue('custom frame'),
59 | } as unknown as jest.Mocked;
60 | const reader = new Reader({ frameProcessor: customFrameProcessor });
61 |
62 | const frame = await reader.read();
63 |
64 | expect(customFrameProcessor.read).toHaveBeenCalled();
65 | expect(frame).toBe('custom frame');
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/packages/reader/src/reader.ts:
--------------------------------------------------------------------------------
1 | import type { Logger, LogLevelDesc } from 'loglevel';
2 | import { getLogger } from '@flipbookqr/shared';
3 | import { WebRTCProcessor, type FrameProcessor } from './processors';
4 |
5 | interface ReaderProps {
6 | logLevel: LogLevelDesc;
7 | frameProcessor: FrameProcessor;
8 | }
9 |
10 | /**
11 | * Class representing a Reader.
12 | */
13 | export class Reader {
14 | private log: Logger;
15 | opts: ReaderProps;
16 |
17 | /**
18 | * Creates an instance of Reader.
19 | *
20 | * @param {Partial} [opts={}] - The options for the Reader.
21 | */
22 | constructor(opts: Partial = {}) {
23 | // Set up the default options
24 | const DEFAULT_READER_PROPS: ReaderProps = {
25 | logLevel: 'silent',
26 | frameProcessor: new WebRTCProcessor(),
27 | };
28 |
29 | // Merge the options with the defaults
30 | this.opts = { ...DEFAULT_READER_PROPS, ...opts };
31 |
32 | // Set up the logger
33 | const logger = getLogger();
34 | logger.setLevel(this.opts.logLevel);
35 | this.log = logger;
36 | }
37 |
38 | /**
39 | * Reads a frame using the frame processor.
40 | *
41 | * @param {File} [file] - The file to read.
42 | * @returns {Promise} - A promise that resolves to the read frame.
43 | */
44 | async read(file?: File): Promise {
45 | return this.opts.frameProcessor.read(file);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/packages/reader/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "compilerOptions": {
4 | "outDir": "dist"
5 | },
6 | "include": ["src"],
7 | "exclude": ["node_modules", "dist"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/shared/.eslintignore:
--------------------------------------------------------------------------------
1 | coverage/
2 | node_modules
3 | *.d.ts
4 | *.config.ts
--------------------------------------------------------------------------------
/packages/shared/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | module.exports = {
3 | root: true,
4 | extends: ['@repo/eslint-config/library.js'],
5 | parser: '@typescript-eslint/parser',
6 | parserOptions: {
7 | project: './tsconfig.json',
8 | tsconfigRootDir: __dirname,
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/packages/shared/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @flipbookqr/shared
2 |
3 | ## 0.2.1
4 |
5 | ### Patch Changes
6 |
7 | - 26b793d: Adding back types to libraries for import
8 |
9 | ## 0.2.0
10 |
11 | ### Minor Changes
12 |
13 | - 2d0b8b1: Dramatically improving performance, browser support, and bundle size
14 |
15 | ## 0.1.5
16 |
17 | ### Patch Changes
18 |
19 | - e7642eb: Redid internal infrastructure of repo
20 |
21 | ## 0.1.4
22 |
23 | ### Patch Changes
24 |
25 | - 3a25452: Allow for camera selection and potentially fixing mobile issues
26 |
27 | ## 0.1.3
28 |
29 | ### Patch Changes
30 |
31 | - 88468e3: Adding video and fixing readmes
32 |
33 | ## 0.1.2
34 |
35 | ### Patch Changes
36 |
37 | - 6c93146: Adding release documentation to test everything out
38 |
39 | ## 0.1.1
40 |
41 | ### Patch Changes
42 |
43 | - 33db75b: Changing publish access
44 |
45 | ## 0.1.0
46 |
47 | ### Minor Changes
48 |
49 | - 0b18eef: Initial release
50 |
--------------------------------------------------------------------------------
/packages/shared/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@flipbookqr/shared",
3 | "version": "0.2.1",
4 | "main": "dist/index.js",
5 | "module": "dist/index.mjs",
6 | "types": "dist/index.d.ts",
7 | "exports": {
8 | ".": {
9 | "types": "./dist/index.d.ts",
10 | "require": "./dist/index.js",
11 | "import": "./dist/index.mjs"
12 | },
13 | "./package.json": "./package.json"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "license": "MIT",
19 | "publishConfig": {
20 | "access": "public"
21 | },
22 | "scripts": {
23 | "build": "tsup src/index.ts --format cjs,esm --dts --clean",
24 | "lint": "eslint .",
25 | "format:fix": "prettier --write \"**/*.{ts,tsx,md}\"",
26 | "format": "prettier --list-different \"**/*.{ts,tsx,md}\"",
27 | "test": "jest --coverage"
28 | },
29 | "jest": {
30 | "preset": "@repo/jest-presets/browser",
31 | "setupFiles": [
32 | "jest-canvas-mock"
33 | ]
34 | },
35 | "devDependencies": {
36 | "@repo/eslint-config": "workspace:*",
37 | "@repo/jest-presets": "workspace:*",
38 | "@repo/typescript-config": "workspace:*"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/packages/shared/src/flipbook.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | isHead,
3 | createIndexTag,
4 | createHeadTag,
5 | getIndex,
6 | getHeadLength,
7 | } from './flipbook';
8 |
9 | describe('Shared', () => {
10 | describe('isHead', () => {
11 | it('should return true for the head tag', () => {
12 | const result = isHead('>>>HEAD123');
13 | expect(result).toBe(true);
14 | });
15 |
16 | it('should return false for the non-head tag', () => {
17 | const result = isHead('>>>IDX123');
18 | expect(result).toBe(false);
19 | });
20 | });
21 |
22 | describe('createIndexTag', () => {
23 | it('should create index tag with correct format', () => {
24 | const result = createIndexTag(123);
25 | expect(result).toBe('>>>IDX123');
26 | });
27 | });
28 |
29 | describe('createHeadTag', () => {
30 | it('should create head tag with correct format', () => {
31 | const result = createHeadTag(456);
32 | expect(result).toBe('>>>HEAD456');
33 | });
34 | });
35 |
36 | describe('getIndex', () => {
37 | it('should return index from string with index tag', () => {
38 | const result = getIndex('>>>IDX789 some other text');
39 | expect(result).toBe(789);
40 | });
41 |
42 | it('should return -1 if there is no more space', () => {
43 | const result = getIndex('>>>IDX');
44 | expect(result).toBe(-1);
45 | });
46 |
47 | it('should return NaN for string without index tag', () => {
48 | const result = getIndex('some text without index tag');
49 | expect(result).toBe(NaN);
50 | });
51 |
52 | it('should return NaN as a index from string with head tag', () => {
53 | const result = getIndex('>>>HEAD987 some other text');
54 | expect(result).toBe(NaN);
55 | });
56 | });
57 |
58 | describe('getHeadLength', () => {
59 | it('should return head length from string with head tag', () => {
60 | const result = getHeadLength('>>>HEAD654 some other text');
61 | expect(result).toBe(654);
62 | });
63 |
64 | it('should return -1 for string without head tag', () => {
65 | const result = getHeadLength('some text without head tag');
66 | expect(result).toBe(-1);
67 | });
68 |
69 | it('should return NaN for string with head tag with no length', () => {
70 | const result = getHeadLength('>>>HEAD some other text');
71 | expect(result).toBe(NaN);
72 | });
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/packages/shared/src/flipbook.ts:
--------------------------------------------------------------------------------
1 | import log, { type Logger } from 'loglevel';
2 |
3 | // The base index tag
4 | export const DEFAULT_IDX_TAG = '>>>IDX';
5 |
6 | // The base head tag
7 | export const DEFAULT_HEAD_TAG = '>>>HEAD';
8 |
9 | /**
10 | * Returns a logger instance for the 'flipbook' namespace.
11 | *
12 | * @returns {Logger} The logger instance.
13 | */
14 | export const getLogger = (): Logger => log.getLogger('flipbook');
15 |
16 | // Define the logger locally
17 | const logger = getLogger();
18 |
19 | /**
20 | * Checks if the given tag is a head tag.
21 | *
22 | * @param {string} headTag - The string to check.
23 | * @returns {boolean} True if the tag starts with the default head tag, otherwise false.
24 | */
25 | export const isHead = (headTag: string): boolean =>
26 | headTag.startsWith(DEFAULT_HEAD_TAG);
27 |
28 | /**
29 | * Creates an index tag with the given index.
30 | *
31 | * @param {number} idx - The index to be added to the tag.
32 | * @returns {string} The generated index tag.
33 | */
34 | export const createIndexTag = (idx: number): string =>
35 | `${DEFAULT_IDX_TAG}${idx}`;
36 |
37 | /**
38 | * Creates a head tag with the given number of frames.
39 | *
40 | * @param {number} numFrames - The number of frames to include in the head tag.
41 | * @returns {string} The generated head tag.
42 | */
43 | export const createHeadTag = (numFrames: number): string =>
44 | `${DEFAULT_HEAD_TAG}${numFrames}`;
45 |
46 | /**
47 | * Extracts the index from a string that contains an index tag.
48 | *
49 | * @param {string} str - The string to extract the index from.
50 | * @returns {number} The extracted index, or -1 if no index is found.
51 | */
52 | export const getIndex = (str: string): number => {
53 | let newStr = str;
54 | logger.debug('Getting index', newStr);
55 |
56 | // If it's the head tag, remove everything before the first space
57 | if (isHead(newStr)) {
58 | newStr = str.slice().slice(str.indexOf(' ') + 1);
59 | logger.debug('Removing head tag', newStr);
60 | }
61 |
62 | // Get the index of the next space
63 | const nextSpace = newStr.indexOf(' ');
64 |
65 | // If there is no more space, return -1
66 | if (nextSpace === -1) return -1;
67 |
68 | // Return the index
69 | const result = parseInt(
70 | newStr.slice().slice(DEFAULT_IDX_TAG.length, nextSpace)
71 | );
72 | logger.debug('Returning index', result);
73 | return result;
74 | };
75 |
76 | /**
77 | * Extracts the length from the head tag of the given string.
78 | *
79 | * @param {string} str - The string to extract the head length from.
80 | * @returns {number} The head length, or -1 if no valid head tag is found.
81 | */
82 | export const getHeadLength = (str: string): number => {
83 | const nextSpace = str.indexOf(' ');
84 | logger.debug('Getting head length', str);
85 |
86 | // If there is no space or it's not the head, return -1
87 | if (nextSpace === -1 || !isHead(str)) return -1;
88 |
89 | // Return the length from the head tag
90 | const result = parseInt(str.slice(DEFAULT_HEAD_TAG.length, nextSpace));
91 | logger.debug('Returning head length', result);
92 | return result;
93 | };
94 |
95 | export * from './utils';
96 |
--------------------------------------------------------------------------------
/packages/shared/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './flipbook';
2 | export * from './utils';
3 |
--------------------------------------------------------------------------------
/packages/shared/src/utils.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | convertFileToBuffer,
3 | convertUnit8ClampedArrayToFile,
4 | dataURLtoUint8Array,
5 | dataURLtoBlob,
6 | } from './utils'; // Update with actual file path
7 |
8 | // // Mocking FileReader to simulate file reading
9 | // global.FileReader = class {
10 | // onload: ((this: FileReader, ev: ProgressEvent) => any) | null =
11 | // null;
12 | // onerror: (() => void) | null = null;
13 | // result: ArrayBuffer | null = null;
14 |
15 | // readAsArrayBuffer() {
16 | // if (this.onload) {
17 | // const buffer = new ArrayBuffer(8);
18 | // this.result = buffer;
19 | // this.onload({ target: { result: buffer } } as ProgressEvent);
20 | // }
21 | // }
22 | // };
23 |
24 | // // Mocking Blob and File creation
25 | // global.File = class {
26 | // constructor(
27 | // public parts: BlobPart[],
28 | // public fileName: string,
29 | // public options: FilePropertyBag
30 | // ) {}
31 | // };
32 | // global.Blob = class {
33 | // constructor(
34 | // public parts: BlobPart[],
35 | // public options: BlobPropertyBag = {}
36 | // ) {}
37 | // };
38 |
39 | describe('convertFileToBuffer', () => {
40 | it('should resolve with an ArrayBuffer when the file is read successfully', async () => {
41 | const mockFile = new File(['file content'], 'test.txt');
42 | const result = await convertFileToBuffer(mockFile);
43 |
44 | expect(result).toBeInstanceOf(ArrayBuffer);
45 | });
46 |
47 | it('should reject with an error when file reading fails', async () => {
48 | const mockFile = new File(['file content'], 'test.txt');
49 |
50 | // Mock the FileReader with an implementation that triggers the onerror handler
51 | const mockFileReader = {
52 | readAsArrayBuffer: jest.fn(),
53 | onerror: jest.fn(),
54 | };
55 |
56 | jest
57 | .spyOn(global, 'FileReader')
58 | .mockImplementation(() => mockFileReader as unknown as FileReader);
59 |
60 | // Manually trigger the error after `readAsArrayBuffer` is called
61 | mockFileReader.readAsArrayBuffer.mockImplementation(function () {
62 | if (mockFileReader.onerror) {
63 | const event = new ProgressEvent('error');
64 | mockFileReader.onerror(event); // Simulate the error event
65 | }
66 | });
67 |
68 | await expect(convertFileToBuffer(mockFile)).rejects.toThrow(
69 | 'Failed to read file'
70 | );
71 |
72 | jest.restoreAllMocks(); // Cleanup the mocked FileReader after the test
73 | });
74 | });
75 |
76 | describe('convertUnit8ClampedArrayToFile', () => {
77 | it('should create a File object from Uint8ClampedArray', () => {
78 | const imageData = new Uint8ClampedArray([
79 | 255, 0, 0, 255, 0, 255, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255,
80 | ]); // 2x2 red and green pixels
81 | const width = 2;
82 | const height = 2;
83 | const fileName = 'testImage.png';
84 |
85 | const file = convertUnit8ClampedArrayToFile(
86 | imageData,
87 | width,
88 | height,
89 | fileName
90 | );
91 |
92 | expect(file).toBeInstanceOf(File);
93 | expect(file.name).toBe(fileName);
94 | expect(file.type).toBe('image/png'); // Depending on the Blob type
95 |
96 | // Check if the file size matches the expected Blob size (you can't access 'parts')
97 | const blobSize = new Blob([file]).size;
98 | expect(blobSize).toBeGreaterThan(0);
99 | });
100 |
101 | it('should throw an error if 2D context is not supported', () => {
102 | const imageData = new Uint8ClampedArray([255, 0, 0, 255, 0, 255, 0, 255]);
103 | const width = 2;
104 | const height = 2;
105 | const fileName = 'testImage.png';
106 |
107 | // Simulate no 2D context support
108 | jest.spyOn(document, 'createElement').mockReturnValueOnce({
109 | getContext: () => null,
110 | } as unknown as HTMLCanvasElement);
111 |
112 | expect(() =>
113 | convertUnit8ClampedArrayToFile(imageData, width, height, fileName)
114 | ).toThrow('2D context not supported');
115 | });
116 | });
117 |
118 | describe('dataURLtoUint8Array', () => {
119 | it('should convert a data URL to a Uint8Array', () => {
120 | const dataURL = 'data:image/png;base64,AAECAwQFBgc=';
121 | const uint8Array = dataURLtoUint8Array(dataURL);
122 |
123 | expect(uint8Array).toBeInstanceOf(Uint8Array);
124 | expect(uint8Array.length).toBeGreaterThan(0);
125 | });
126 | });
127 |
128 | describe('dataURLtoBlob', () => {
129 | it('should convert a data URL to a Blob object', () => {
130 | const dataURL = 'data:image/png;base64,AAECAwQFBgc=';
131 | const blob = dataURLtoBlob(dataURL);
132 |
133 | expect(blob).toBeInstanceOf(Blob);
134 | expect(blob.type).toBe('image/png');
135 |
136 | // Verify the size of the Blob (based on the length of the Uint8Array)
137 | const expectedSize = dataURLtoUint8Array(dataURL).length;
138 | expect(blob.size).toBe(expectedSize);
139 | });
140 | });
141 |
--------------------------------------------------------------------------------
/packages/shared/src/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Converts a File object to an ArrayBuffer.
3 | *
4 | * @param {File} file - The file to convert.
5 | * @returns {Promise} A promise that resolves to the file's ArrayBuffer.
6 | */
7 | export function convertFileToBuffer(file: File): Promise {
8 | return new Promise((resolve, reject) => {
9 | // Create a new FileReader instance
10 | const reader = new FileReader();
11 |
12 | // When the file is read, resolve the promise with the ArrayBuffer
13 | reader.onload = (event) => {
14 | const arrayBuffer = event.target?.result;
15 | if (arrayBuffer) {
16 | resolve(arrayBuffer as ArrayBuffer);
17 | }
18 | };
19 |
20 | // When an error occurs, reject the promise
21 | reader.onerror = () => reject(new Error('Failed to read file'));
22 |
23 | // Read the file as an ArrayBuffer
24 | reader.readAsArrayBuffer(file);
25 | });
26 | }
27 |
28 | /**
29 | * Converts an Uint8ClampedArray to a File object.
30 | *
31 | * @param {Uint8ClampedArray} imageData - The image data array.
32 | * @param {number} width - The width of the image.
33 | * @param {number} height - The height of the image.
34 | * @param {string} fileName - The name of the file to create.
35 | * @returns {File} The created File object.
36 | */
37 | export function convertUnit8ClampedArrayToFile(
38 | imageData: Uint8ClampedArray,
39 | width: number,
40 | height: number,
41 | fileName: string
42 | ): File {
43 | // Create a canvas element
44 | const canvas = document.createElement('canvas');
45 | canvas.width = width;
46 | canvas.height = height;
47 |
48 | // Get the 2D context
49 | const ctx = canvas.getContext('2d');
50 | if (!ctx) {
51 | throw new Error('2D context not supported');
52 | }
53 |
54 | // Create an ImageData object and put it on the canvas
55 | const imageDataObj = new ImageData(imageData, width, height);
56 | ctx.putImageData(imageDataObj, 0, 0);
57 |
58 | // Convert the canvas to a data URL
59 | const dataURL = canvas.toDataURL();
60 |
61 | // Convert the data URL to a Blob object
62 | const blob = dataURLtoBlob(dataURL);
63 |
64 | // Create a new File object
65 | return new File([blob], fileName, { type: blob.type });
66 | }
67 |
68 | /**
69 | * Converts a data URL to a Uint8Array.
70 | *
71 | * @param {string} dataURL - The data URL to convert.
72 | * @returns {Uint8Array} The Uint8Array created from the data URL.
73 | */
74 | export function dataURLtoUint8Array(dataURL: string): Uint8Array {
75 | // Split the data URL into parts
76 | const parts = dataURL.split(';base64,');
77 | const raw = window.atob(parts[1]!);
78 | const rawLength = raw.length;
79 | const uInt8Array = new Uint8Array(rawLength);
80 |
81 | // Create a Uint8Array from the raw data
82 | for (let i = 0; i < rawLength; ++i) {
83 | uInt8Array[i] = raw.charCodeAt(i);
84 | }
85 |
86 | return uInt8Array;
87 | }
88 |
89 | /**
90 | * Converts a data URL to a Blob object.
91 | *
92 | * @param {string} dataURL - The data URL to convert.
93 | * @returns {Blob} The Blob object created from the data URL.
94 | */
95 | export function dataURLtoBlob(dataURL: string): Blob {
96 | // Convert the data URL to a Uint8Array
97 | const uInt8Array = dataURLtoUint8Array(dataURL);
98 |
99 | // Get the content type
100 | const parts = dataURL.split(';base64,');
101 | const contentType = parts[0]?.split(':')[1];
102 |
103 | // Return a new Blob object
104 | return new Blob([uInt8Array], { type: contentType });
105 | }
106 |
--------------------------------------------------------------------------------
/packages/shared/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "compilerOptions": {
4 | "outDir": "dist"
5 | },
6 | "include": ["src"],
7 | "exclude": ["node_modules", "dist"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/typescript-config/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "allowSyntheticDefaultImports": true,
6 | "declaration": true,
7 | "declarationMap": true,
8 | "esModuleInterop": true,
9 | "incremental": false,
10 | "isolatedModules": true,
11 | "lib": ["es2022", "DOM", "DOM.Iterable"],
12 | "module": "ESNext",
13 | "moduleDetection": "force",
14 | "moduleResolution": "Node",
15 | "noUncheckedIndexedAccess": true,
16 | "resolveJsonModule": true,
17 | "skipLibCheck": true,
18 | "strict": true,
19 | "target": "ESNext",
20 | "paths": {
21 | "@flipbookqr/*": ["../../packages/*/src"]
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/typescript-config/nextjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Next.js",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "plugins": [{ "name": "next" }],
7 | "module": "ESNext",
8 | "moduleResolution": "Bundler",
9 | "allowJs": true,
10 | "jsx": "preserve",
11 | "noEmit": true
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/typescript-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/typescript-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "publishConfig": {
7 | "access": "public"
8 | },
9 | "devDependencies": {
10 | "typescript": "^5.5.4"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/typescript-config/react-library.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "React Library",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "jsx": "react-jsx"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/writer/.eslintignore:
--------------------------------------------------------------------------------
1 | coverage/
2 | node_modules
3 | *.d.ts
4 | *.config.ts
--------------------------------------------------------------------------------
/packages/writer/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | module.exports = {
3 | root: true,
4 | extends: ['@repo/eslint-config/library.js'],
5 | parser: '@typescript-eslint/parser',
6 | parserOptions: {
7 | project: './tsconfig.json',
8 | tsconfigRootDir: __dirname,
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/packages/writer/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @flipbookqr/writer
2 |
3 | ## 0.2.1
4 |
5 | ### Patch Changes
6 |
7 | - 26b793d: Adding back types to libraries for import
8 | - Updated dependencies [26b793d]
9 | - @flipbookqr/shared@0.2.1
10 |
11 | ## 0.2.0
12 |
13 | ### Minor Changes
14 |
15 | - 2d0b8b1: Dramatically improving performance, browser support, and bundle size
16 |
17 | ### Patch Changes
18 |
19 | - Updated dependencies [2d0b8b1]
20 | - @flipbookqr/shared@0.2.0
21 |
22 | ## 0.1.5
23 |
24 | ### Patch Changes
25 |
26 | - e7642eb: Redid internal infrastructure of repo
27 | - Updated dependencies [e7642eb]
28 | - @flipbookqr/shared@0.1.5
29 |
30 | ## 0.1.4
31 |
32 | ### Patch Changes
33 |
34 | - 3a25452: Allow for camera selection and potentially fixing mobile issues
35 | - Updated dependencies [3a25452]
36 | - @flipbookqr/shared@0.1.4
37 |
38 | ## 0.1.3
39 |
40 | ### Patch Changes
41 |
42 | - 88468e3: Adding video and fixing readmes
43 | - Updated dependencies [88468e3]
44 | - @flipbookqr/shared@0.1.3
45 |
46 | ## 0.1.2
47 |
48 | ### Patch Changes
49 |
50 | - 6c93146: Adding release documentation to test everything out
51 | - Updated dependencies [6c93146]
52 | - @flipbookqr/shared@0.1.2
53 |
54 | ## 0.1.1
55 |
56 | ### Patch Changes
57 |
58 | - 33db75b: Changing publish access
59 | - Updated dependencies [33db75b]
60 | - @flipbookqr/shared@0.1.1
61 |
62 | ## 0.1.0
63 |
64 | ### Minor Changes
65 |
66 | - 0b18eef: Initial release
67 |
68 | ### Patch Changes
69 |
70 | - Updated dependencies [0b18eef]
71 | - @flipbookqr/shared@0.1.0
72 |
--------------------------------------------------------------------------------
/packages/writer/README.md:
--------------------------------------------------------------------------------
1 | # @flipbookqr/writer
2 |
3 | The Flipbook writer is responsible for creating "flipbooks" that are a series of QR codes that are stitched together into an animated GIF. This GIF can then be scanned by the [reader library](https://github.com/cereallarceny/flipbook/tree/main/packages/reader) and subsequently reassembled into the original payload.
4 |
5 | ## Installation
6 |
7 | NPM:
8 |
9 | ```bash
10 | npm install @flipbookqr/writer
11 | ```
12 |
13 | Yarn:
14 |
15 | ```bash
16 | yarn add @flipbookqr/writer
17 | ```
18 |
19 | PNPM:
20 |
21 | ```bash
22 | pnpm add @flipbookqr/writer
23 | ```
24 |
25 | Bun:
26 |
27 | ```bash
28 | bun add @flipbookqr/writer
29 | ```
30 |
31 | ## Usage
32 |
33 | [View a CodeSandbox example](https://codesandbox.io/p/sandbox/n6hrwl)
34 |
35 | ```typescript
36 | import { Writer } from '@flipbookqr/writer';
37 |
38 | // Define the payload to be encoded
39 | const payload = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit...';
40 |
41 | // Create a new instance of the Flipbook writer
42 | const writer = new Writer(optionalConfig);
43 |
44 | // Write the payload to a series of QR codes
45 | const qrs = writer.write(payload);
46 |
47 | // Create a new canvas element
48 | const canvas = document.createElement('canvas');
49 |
50 | // Compose a series of QR codes to a canvas element
51 | writer.toCanvas(qrs, canvas);
52 |
53 | // Or, compose a series of QR codes to a GIF
54 | writer.toGif(qrs);
55 | ```
56 |
57 | ## Configuration
58 |
59 | The `Writer` class accepts an optional configuration object that can be used to customize the behavior of the writer. The following options are available:
60 |
61 | ```typescript
62 | {
63 | logLevel: 'silent' | 'trace' | 'debug' | 'info' | 'warn' | 'error', // Default: 'silent'
64 | errorCorrectionLevel: number, // Level of error correction (see @nuintun/qrcode)
65 | encodingHint: boolean, // Enable encoding hint (see @nuintun/qrcode)
66 | version?: number, // QR code version (see @nuintun/qrcode)
67 | moduleSize: number, // Size of each QR code module, default: 4 (see @nuintun/qrcode)
68 | margin: number, // Margin around each QR code, default: 8 (see @nuintun/qrcode)
69 | delay: number, // Delay between frames in milliseconds, default: 100
70 | splitLength: integer, // Payload chunk size, default: 100
71 | }
72 | ```
73 |
--------------------------------------------------------------------------------
/packages/writer/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@flipbookqr/writer",
3 | "version": "0.2.1",
4 | "main": "dist/index.js",
5 | "module": "dist/index.mjs",
6 | "types": "dist/index.d.ts",
7 | "exports": {
8 | ".": {
9 | "types": "./dist/index.d.ts",
10 | "require": "./dist/index.js",
11 | "import": "./dist/index.mjs"
12 | },
13 | "./package.json": "./package.json"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "license": "MIT",
19 | "publishConfig": {
20 | "access": "public"
21 | },
22 | "scripts": {
23 | "build": "tsup src/index.ts --format cjs,esm --dts --clean",
24 | "lint": "eslint .",
25 | "format:fix": "prettier --write \"**/*.{ts,tsx,md}\"",
26 | "format": "prettier --list-different \"**/*.{ts,tsx,md}\"",
27 | "test": "jest --coverage"
28 | },
29 | "jest": {
30 | "preset": "@repo/jest-presets/browser",
31 | "setupFiles": [
32 | "jest-canvas-mock"
33 | ]
34 | },
35 | "dependencies": {
36 | "@flipbookqr/shared": "workspace:*"
37 | },
38 | "devDependencies": {
39 | "@repo/eslint-config": "workspace:*",
40 | "@repo/jest-presets": "workspace:*",
41 | "@repo/typescript-config": "workspace:*"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/writer/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './writer';
2 |
--------------------------------------------------------------------------------
/packages/writer/src/writer.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | import { ErrorCorrectionLevel } from '@nuintun/qrcode';
4 | import { DEFAULT_HEAD_TAG, DEFAULT_IDX_TAG } from '@flipbookqr/shared';
5 | import { Writer } from './writer'; // Assuming this is the file path
6 | import { LogLevelDesc } from 'loglevel';
7 |
8 | global.URL.createObjectURL = jest.fn(
9 | (blob: Blob) => `blob:${blob.size}#t=${Date.now()}`
10 | );
11 |
12 | jest.mock('@nuintun/qrcode', () => ({
13 | Encoder: jest.fn().mockImplementation(() => ({
14 | setVersion: jest.fn(),
15 | setEncodingHint: jest.fn(),
16 | setErrorCorrectionLevel: jest.fn(),
17 | write: jest.fn(),
18 | make: jest.fn(),
19 | getMatrix: jest.fn().mockReturnValue([
20 | [true, false],
21 | [false, true],
22 | ]),
23 | getVersion: jest.fn().mockReturnValue(5),
24 | getMatrixSize: jest.fn().mockReturnValue(21),
25 | })),
26 | ErrorCorrectionLevel: {
27 | M: 'M',
28 | Q: 'Q',
29 | },
30 | }));
31 |
32 | const sampleString = Array.from({ length: 4 })
33 | .map(() => `abcdefghijklmnopqrstuvqxyz1234567890`)
34 | .join('');
35 |
36 | describe('Writer', () => {
37 | let writer: Writer;
38 |
39 | beforeEach(() => {
40 | writer = new Writer();
41 | });
42 |
43 | describe('constructor', () => {
44 | it('should initialize with default options', () => {
45 | expect(writer.opts.logLevel).toBe('silent');
46 | expect(writer.opts.errorCorrectionLevel).toBe(ErrorCorrectionLevel.M);
47 | expect(writer.opts.encodingHint).toBe(true);
48 | expect(writer.opts.moduleSize).toBe(4);
49 | expect(writer.opts.margin).toBe(8);
50 | expect(writer.opts.splitLength).toBe(100);
51 | });
52 |
53 | it('should merge provided options with default options', () => {
54 | const customOpts = {
55 | logLevel: 'debug' as LogLevelDesc,
56 | splitLength: 50,
57 | errorCorrectionLevel: ErrorCorrectionLevel.Q,
58 | };
59 | writer = new Writer(customOpts);
60 | expect(writer.opts.logLevel).toBe('debug');
61 | expect(writer.opts.splitLength).toBe(50);
62 | expect(writer.opts.errorCorrectionLevel).toBe(ErrorCorrectionLevel.Q);
63 | });
64 | });
65 |
66 | describe('split', () => {
67 | it('should split the input string into segments', () => {
68 | const splitResult = (writer as any).split(sampleString, writer.opts);
69 | expect(splitResult).toHaveLength(2);
70 | expect(splitResult[0]).toContain(DEFAULT_HEAD_TAG);
71 | });
72 |
73 | it('should add index and head tags to the segments', () => {
74 | const splitResult = (writer as any).split(sampleString, {
75 | ...writer.opts,
76 | splitLength: 10,
77 | });
78 | expect(splitResult).toHaveLength(15);
79 | expect(splitResult[0]).toContain(DEFAULT_HEAD_TAG);
80 | expect(splitResult[1]).toContain(DEFAULT_IDX_TAG);
81 | });
82 | });
83 |
84 | describe('createEncoder', () => {
85 | it('should create and configure an Encoder', () => {
86 | const encoder = (writer as any).createEncoder('test content');
87 | expect(encoder.setVersion).not.toHaveBeenCalled();
88 | expect(encoder.setEncodingHint).toHaveBeenCalledWith(true);
89 | expect(encoder.setErrorCorrectionLevel).toHaveBeenCalledWith(
90 | ErrorCorrectionLevel.M
91 | );
92 | expect(encoder.write).toHaveBeenCalledWith('test content');
93 | expect(encoder.make).toHaveBeenCalled();
94 | });
95 |
96 | it('should set the QR code version if provided', () => {
97 | const encoder = (writer as any).createEncoder('test content', 7);
98 | expect(encoder.setVersion).toHaveBeenCalledWith(7);
99 | });
100 | });
101 |
102 | describe('write', () => {
103 | it('should split the code and create QR codes for each segment', () => {
104 | const result = writer.write(sampleString);
105 | expect(result).toHaveLength(2);
106 | expect(result[0]!.code).toContain(DEFAULT_HEAD_TAG);
107 | expect(result[1]!.code).toContain(DEFAULT_IDX_TAG);
108 | });
109 | });
110 |
111 | describe('toCanvas', () => {
112 | let canvas: HTMLCanvasElement;
113 | let ctx: CanvasRenderingContext2D;
114 | const sampleString = 'abcdefghijklmnopqrstuvqxyz1234567890';
115 |
116 | beforeEach(() => {
117 | // Create a mock canvas and its 2D context
118 | canvas = document.createElement('canvas');
119 | ctx = canvas.getContext('2d')!;
120 |
121 | // Mock the canvas context's methods for testing
122 | jest.spyOn(ctx, 'clearRect').mockImplementation(() => {});
123 | jest.spyOn(ctx, 'fillRect').mockImplementation(() => {});
124 | jest.spyOn(ctx, 'fillStyle', 'set').mockImplementation(() => {});
125 | });
126 |
127 | it('should throw an error if write() has not been run', () => {
128 | expect(() => writer.toCanvas([], canvas)).toThrow(
129 | 'Run writer.write() before running writer.toCanvas()'
130 | );
131 | });
132 |
133 | it('should set the canvas size based on the QR code size', () => {
134 | // Write some QR codes first
135 | const qrs = writer.write(sampleString);
136 |
137 | // Call the toCanvas method
138 | writer.toCanvas(qrs, canvas);
139 |
140 | // Expect canvas width and height to be set to the QR code size
141 | expect(canvas.width).toBe((writer as any).size);
142 | expect(canvas.height).toBe((writer as any).size);
143 | });
144 |
145 | it('should clear the canvas before drawing each frame', () => {
146 | // Write some QR codes first
147 | const qrs = writer.write(sampleString);
148 |
149 | // Call the toCanvas method
150 | writer.toCanvas(qrs, canvas);
151 |
152 | // Simulate the first frame being drawn
153 | requestAnimationFrame(() => {
154 | expect(ctx.clearRect).toHaveBeenCalledWith(
155 | 0,
156 | 0,
157 | canvas.width,
158 | canvas.height
159 | );
160 | });
161 | });
162 |
163 | it('should draw each pixel of the QR code with the correct color', () => {
164 | // Write some QR codes first
165 | const qrs = writer.write(sampleString);
166 |
167 | // Call the toCanvas method
168 | writer.toCanvas(qrs, canvas);
169 |
170 | // Simulate drawing a frame
171 | requestAnimationFrame(() => {
172 | const qrImage = qrs[0]!.image;
173 |
174 | // Check that the pixels are drawn correctly
175 | qrImage.forEach((row, y) => {
176 | row.forEach((pixel, x) => {
177 | // Expect fillStyle to be set to 'black' for 0 and 'white' for 1
178 | if (pixel === 0) {
179 | expect(ctx.fillStyle).toBe('black');
180 | } else {
181 | expect(ctx.fillStyle).toBe('white');
182 | }
183 | expect(ctx.fillRect).toHaveBeenCalledWith(x, y, 1, 1);
184 | });
185 | });
186 | });
187 | });
188 |
189 | it('should animate the frames according to the delay option', () => {
190 | jest
191 | .spyOn(global, 'requestAnimationFrame')
192 | .mockImplementation((callback) => {
193 | setTimeout(callback, writer.opts.delay);
194 | return 1; // Mock requestAnimationFrame ID
195 | });
196 |
197 | // Write some QR codes first
198 | const qrs = writer.write(sampleString);
199 |
200 | // Call the toCanvas method
201 | writer.toCanvas(qrs, canvas);
202 |
203 | // Check that the animation is advancing to the next frame after the delay
204 | setTimeout(() => {
205 | expect(ctx.clearRect).toHaveBeenCalledTimes(2); // Animation moved to the second frame
206 | }, writer.opts.delay * 2);
207 | });
208 |
209 | it('should loop back to the first frame after reaching the last frame', () => {
210 | jest
211 | .spyOn(global, 'requestAnimationFrame')
212 | .mockImplementation((callback) => {
213 | setTimeout(callback, writer.opts.delay);
214 | return 1; // Mock requestAnimationFrame ID
215 | });
216 |
217 | // Write some QR codes first
218 | const qrs = writer.write(sampleString);
219 |
220 | // Call the toCanvas method
221 | writer.toCanvas(qrs, canvas);
222 |
223 | // Simulate the animation reaching the last frame
224 | setTimeout(
225 | () => {
226 | // Check that after the last frame, it loops back to the first
227 | expect(ctx.clearRect).toHaveBeenCalledTimes(qrs.length + 1); // Number of frames + 1 loop
228 | },
229 | writer.opts.delay * (qrs.length + 1)
230 | );
231 | });
232 | });
233 |
234 | describe('toGif', () => {
235 | it('should throw an error if size is not set', () => {
236 | expect(() => writer.toGif([])).toThrow(
237 | 'Run writer.write() before running writer.toGif()'
238 | );
239 | });
240 |
241 | it('should create a GIF from multiple QR codes', () => {
242 | const qrs = writer.write(sampleString);
243 | const gif = writer.toGif(qrs);
244 |
245 | expect(gif).toBeInstanceOf(Blob);
246 | });
247 | });
248 | });
249 |
--------------------------------------------------------------------------------
/packages/writer/src/writer.ts:
--------------------------------------------------------------------------------
1 | import { GifWriter } from 'omggif';
2 | import { createHeadTag, createIndexTag, getLogger } from '@flipbookqr/shared';
3 | import { Encoder, ErrorCorrectionLevel } from '@nuintun/qrcode';
4 | import type { Logger, LogLevelDesc } from 'loglevel';
5 |
6 | interface WriterResult {
7 | code: string;
8 | image: number[][];
9 | }
10 |
11 | export interface WriterProps {
12 | logLevel: LogLevelDesc;
13 | errorCorrectionLevel: ErrorCorrectionLevel;
14 | encodingHint: boolean;
15 | version?: number;
16 | moduleSize: number;
17 | margin: number;
18 | delay: number;
19 | splitLength: number;
20 | }
21 |
22 | /**
23 | * The Writer class is responsible for generating QR codes from a given string,
24 | * splitting long strings into multiple QR codes, and optionally composing them into a GIF.
25 | */
26 | export class Writer {
27 | private log: Logger;
28 | opts: WriterProps;
29 | private size?: number;
30 | private numFrames?: number;
31 |
32 | /**
33 | * Creates a new Writer instance with the provided options or defaults.
34 | *
35 | * @param {Partial} [opts={}] - Configuration options for the writer, including logging level, QR options, GIF options, size, and split length.
36 | */
37 | constructor(opts: Partial = {}) {
38 | // Default options
39 | const DEFAULT_WRITER_PROPS: WriterProps = {
40 | logLevel: 'silent',
41 | errorCorrectionLevel: ErrorCorrectionLevel.M,
42 | encodingHint: true,
43 | version: undefined,
44 | moduleSize: 4,
45 | margin: 8,
46 | delay: 100,
47 | splitLength: 100,
48 | };
49 |
50 | // Merge default and custom options
51 | this.opts = { ...DEFAULT_WRITER_PROPS, ...opts };
52 |
53 | // Set up logger
54 | const logger = getLogger();
55 | logger.setLevel(this.opts.logLevel);
56 | this.log = logger;
57 | }
58 |
59 | /**
60 | * Splits a string into multiple segments based on the specified split length.
61 | * Each segment is tagged with an index and the total number of segments.
62 | *
63 | * @param {string} code - The string to be split.
64 | * @param {WriterProps} opts - The options specifying the split length.
65 | * @returns {string[]} An array of strings representing the split and tagged segments.
66 | */
67 | private split(code: string, opts: WriterProps): string[] {
68 | // Destructure split length from options
69 | const { splitLength } = opts;
70 | this.log.debug('Split length', splitLength);
71 |
72 | // Store each segment in an array and get the length of the input string
73 | const codes: string[] = [];
74 | const length = code.length;
75 |
76 | // Split the string into segments based on the split length
77 | let i = 0;
78 | while (i < length) {
79 | codes.push(code.slice(i, i + splitLength));
80 | this.log.debug('Creating slice', i, i + splitLength);
81 | i += splitLength;
82 | }
83 |
84 | // Add index and total count tags to each segment
85 | const indexedCodes = codes.map((v, idx) => `${createIndexTag(idx)} ${v}`);
86 | this.log.debug('Indexed codes', indexedCodes);
87 |
88 | // Add head tag to the first segment
89 | indexedCodes[0] = `${createHeadTag(indexedCodes.length)} ${indexedCodes[0]}`;
90 | this.log.debug('Indexing head', [indexedCodes[0]]);
91 |
92 | // Return the tagged segments
93 | return indexedCodes;
94 | }
95 |
96 | /**
97 | * Creates a new QR code encoder with the specified content and version.
98 | *
99 | * @param {string} content - The content to encode into a QR code.
100 | * @param {number} [version] - The version of the QR code to generate.
101 | * @returns {Encoder} A new QR code encoder instance.
102 | */
103 | private createEncoder(content: string, version?: number): Encoder {
104 | // Create the encoder instance
105 | const encoder = new Encoder();
106 |
107 | // Set the version, encoding hint, and error correction level
108 | if (version) encoder.setVersion(version);
109 | encoder.setEncodingHint(this.opts.encodingHint);
110 | encoder.setErrorCorrectionLevel(this.opts.errorCorrectionLevel);
111 |
112 | // Write the content and generate the QR code
113 | encoder.write(content);
114 | encoder.make();
115 |
116 | // Return the encoder instance
117 | return encoder;
118 | }
119 |
120 | /**
121 | * Converts raw encoder data into a 2d binary array
122 | *
123 | * @
124 | */
125 | private encoderTo2dBinaryArray(encoder: Encoder): number[][] {
126 | const size: number = this.size!;
127 | const { margin, moduleSize } = this.opts;
128 |
129 | function isDark(matrix: boolean[][], row: number, col: number): boolean {
130 | if (matrix[row] && matrix[row][col] !== null) {
131 | return matrix[row][col]!;
132 | } else {
133 | return false;
134 | }
135 | }
136 |
137 | // Get the 2d boolean matrix from the encoder
138 | const matrix = encoder.getMatrix();
139 |
140 | // Store the matrix
141 | const finalMatrix: number[][] = [];
142 |
143 | // For each row
144 | for (let y = 0; y < size; y++) {
145 | // Create an array
146 | const row: number[] = [];
147 |
148 | // For each column
149 | for (let x = 0; x < size; x++) {
150 | // Check if the value is black and within the margins, push a 0
151 | if (
152 | margin <= x &&
153 | x < size - margin &&
154 | margin <= y &&
155 | y < size - margin &&
156 | isDark(
157 | matrix,
158 | ((y - margin) / moduleSize) >> 0,
159 | ((x - margin) / moduleSize) >> 0
160 | )
161 | ) {
162 | row.push(0);
163 | }
164 |
165 | // Otherwise it's white, push a 1
166 | else {
167 | row.push(1);
168 | }
169 | }
170 |
171 | // Push the row on the final matrix
172 | finalMatrix.push(row);
173 | }
174 |
175 | // Return the final matrix
176 | return finalMatrix;
177 | }
178 |
179 | /**
180 | * Generates QR codes from the given string.
181 | * The string is split into multiple parts if it exceeds the split length.
182 | *
183 | * @param {string} code - The input string to encode into QR codes.
184 | * @returns {WriterResult[]} An array of WriterResult objects, each containing a string segment and its corresponding QR code image.
185 | */
186 | write(code: string): WriterResult[] {
187 | this.log.debug('Writing code', code);
188 |
189 | // Split the input string into multiple segments
190 | const codes = this.split(code, this.opts);
191 | this.log.debug('Split codes', codes);
192 |
193 | // Store all the encoders
194 | const encoders: Encoder[] = [];
195 |
196 | // Encode the first segment to determine the highest version
197 | const firstEncoder = this.createEncoder(codes[0]!, this.opts.version);
198 | const highestVersion = firstEncoder.getVersion();
199 | this.opts.version = highestVersion;
200 | encoders.push(firstEncoder);
201 |
202 | // Set the size of the QR code
203 | this.size =
204 | this.opts.moduleSize * firstEncoder.getMatrixSize() +
205 | this.opts.margin * 2;
206 |
207 | // Set the number of frames
208 | this.numFrames = codes.length;
209 |
210 | this.log.debug('Size set', this.size);
211 |
212 | // Encode the remaining segments
213 | for (let i = 1; i < codes.length; i++) {
214 | const encoder = this.createEncoder(codes[i]!, highestVersion);
215 | encoders.push(encoder);
216 | }
217 |
218 | this.log.debug('Encoded all qr codes', encoders);
219 |
220 | // Convert QR codes to data URLs
221 | return codes.map((code, i) => {
222 | // Get the encoder for the current segment
223 | const encoder = encoders[i]!;
224 |
225 | // Convert the encoder to a 2d binary array
226 | const image = this.encoderTo2dBinaryArray(encoder);
227 |
228 | this.log.debug('Converted frame to a 2d binary array', image);
229 |
230 | // Return the segment and its corresponding encoder
231 | return { code, image };
232 | });
233 | }
234 |
235 | /**
236 | * Composes multiple QR code frames into a canvas animation.
237 | *
238 | * @param {WriterResult[]} qrs - An array of QR code images to compose into a canvas animation.
239 | * @param {HTMLCanvasElement} canvas - The canvas element to draw the composed QR code animation.
240 | * @returns {HTMLCanvasElement} A canvas element containing the composed QR code animation.
241 | */
242 | toCanvas(qrs: WriterResult[], canvas: HTMLCanvasElement): HTMLCanvasElement {
243 | // If there's no size or number of frames, throw an error
244 | if (!this.size || !this.numFrames) {
245 | throw new Error('Run writer.write() before running writer.toCanvas()');
246 | }
247 |
248 | // Set the canvas size
249 | canvas.width = this.size;
250 | canvas.height = this.size;
251 |
252 | // Get the canvas context
253 | const ctx = canvas.getContext('2d')!;
254 |
255 | // Get the total number of frames, and store the current frame and last frame time
256 | const totalFrames = qrs.length;
257 | let currentFrame = 0;
258 | let lastFrameTime = 0;
259 |
260 | // Function to draw a specific QR code to the canvas
261 | const drawFrame = (qr: WriterResult) => {
262 | // Clear the canvas before drawing the next frame
263 | ctx.clearRect(0, 0, canvas.width, canvas.height);
264 |
265 | // Draw the QR code frame
266 | for (let y = 0; y < this.size!; y++) {
267 | for (let x = 0; x < this.size!; x++) {
268 | if (qr.image[y]![x] === 0) {
269 | ctx.fillStyle = 'black';
270 | } else {
271 | ctx.fillStyle = 'white';
272 | }
273 |
274 | // Draw each pixel (1x1) for the QR code
275 | ctx.fillRect(x, y, 1, 1);
276 | }
277 | }
278 | };
279 |
280 | // Animation function using requestAnimationFrame
281 | const animate = (timestamp: number) => {
282 | // Check if it's time to advance to the next frame based on the delay
283 | if (timestamp - lastFrameTime >= this.opts.delay) {
284 | // Draw the current QR frame
285 | drawFrame(qrs[currentFrame]!);
286 |
287 | // Move to the next frame
288 | currentFrame = (currentFrame + 1) % totalFrames;
289 |
290 | // Update the last frame timestamp
291 | lastFrameTime = timestamp;
292 | }
293 |
294 | // Continue the animation
295 | requestAnimationFrame(animate);
296 | };
297 |
298 | // Start the animation by calling animate on the first frame
299 | requestAnimationFrame(animate);
300 |
301 | return canvas;
302 | }
303 |
304 | /**
305 | * Composes multiple QR code frames into a GIF animation.
306 | *
307 | * @param {WriterResult[]} qrs - An array of QR code images to compose into a GIF
308 | * @returns {Blob} A Blob object containing the GIF data.
309 | */
310 | toGif(qrs: WriterResult[]): Blob {
311 | // If there's no size or number of frames, throw an error
312 | if (!this.size || !this.numFrames) {
313 | throw new Error('Run writer.write() before running writer.toGif()');
314 | }
315 |
316 | // Create a buffer big enough to contain the image
317 | const buf = new Uint8Array(this.size * this.size * qrs.length * 4);
318 |
319 | // Create the writer instance
320 | const writer = new GifWriter(buf, this.size, this.size, {
321 | palette: [0x000000, 0xffffff],
322 | loop: 0,
323 | background: 1,
324 | });
325 |
326 | this.log.debug('Created new GIF writer', writer);
327 |
328 | // For each qr code, add a frame to the gif
329 | for (let i = 0; i < qrs.length; i++) {
330 | // Get the QR code
331 | const qr = qrs[i]!;
332 |
333 | // Add the frame to the GIF
334 | writer.addFrame(0, 0, this.size, this.size, qr.image.flat(), {
335 | delay: 100 / this.opts.delay,
336 | });
337 | }
338 |
339 | // Create the GIF from the first frame to the last
340 | const qr = buf.slice(0, writer.end());
341 |
342 | this.log.debug('Final QR buffer', qr);
343 |
344 | // Return the GIF URL
345 | return new Blob([qr], { type: 'image/gif' });
346 | }
347 | }
348 |
--------------------------------------------------------------------------------
/packages/writer/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "compilerOptions": {
4 | "outDir": "dist"
5 | },
6 | "include": ["src"],
7 | "exclude": ["node_modules", "dist"]
8 | }
9 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "apps/*"
3 | - "packages/*"
4 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalDependencies": ["**/.env.*local"],
4 | "globalEnv": ["NODE_ENV"],
5 | "tasks": {
6 | "build": {
7 | "dependsOn": ["^build"],
8 | "outputs": ["dist", ".next/**", "!.next/cache/**"]
9 | },
10 | "dev": {
11 | "cache": false,
12 | "persistent": true
13 | },
14 | "lint": {},
15 | "format": {},
16 | "format:fix": {},
17 | "test": {
18 | "dependsOn": ["^build"],
19 | "outputs": ["coverage/**"]
20 | },
21 | "benchmark": {}
22 | }
23 | }
24 |
--------------------------------------------------------------------------------